learning-website-django1-7 Exercise 2: Group the Courses and Build the Front Page ============================================================================ Give the languages site a front page of its own: every language in a card with its own colour, the courses grouped the way a learner thinks (the rule recorded in language_rules.md on 2026-10-06): Reading and writing alphabets, hiragana, katakana Survival Basic Conversation courses 1 to 3 Everyday Basic Conversation courses 4 to 6 Culture courses about the country, not the language Other courses anything else with numbered chapters Save as apps/site_languages/grouping.py: """Group the language courses the way a learner thinks about them (Learning Website: rule L8, 2026-10-06): Reading and writing alphabets and kana Survival Basic Conversation courses 1-3 Everyday Basic Conversation courses 4-6 Culture courses about the country, not the language Other courses anything else with numbered chapters""" from collections import defaultdict from .config import LANGUAGES WRITING = ("alphabet", "hiragana", "katakana", "kanji") GROUP_ORDER = ["Reading and writing", "Survival", "Everyday", "Culture", "Other courses"] def language_key(folder): """hungary/hungarian-basic-3 -> hungary; culture/japan/japanese-music -> japan""" parts = folder.split("/") return parts[1] if parts[0] == "culture" and len(parts) > 1 else parts[0] def classify(course): """('Survival', sort key) for a Course; the group title is completed with the language by the caller.""" folder = course.folder name = folder.rsplit("/", 1)[-1] if folder.startswith("culture/"): return "Culture", (course.name,) if any(word in name for word in WRITING): order = next(i for i, word in enumerate(WRITING) if word in name) return "Reading and writing", (order, course.course_no or 0) if "-basic-" in name: number = course.course_no or 0 return ("Survival" if number <= 3 else "Everyday"), (number,) return "Other courses", (course.name,) def build_sections(courses): """courses: Course objects with a .chapters attribute (the number of chapters). Returns one section per language, in the order of config.LANGUAGES, with its groups in a fixed order.""" by_language = defaultdict(lambda: defaultdict(list)) for course in courses: group, key = classify(course) by_language[language_key(course.folder)][group].append((key, course)) sections = [] for language in LANGUAGES: groups = [] grouped = by_language.get(language["key"], {}) for title in GROUP_ORDER: if title not in grouped: continue label = f"{title} {language['name']}" if title in ("Survival", "Everyday") else title courses_here = [c for _, c in sorted(grouped[title], key=lambda pair: pair[0])] groups.append({"title": label, "courses": courses_here}) sections.append({**language, "groups": groups}) return sections Save as apps/site_languages/views.py: from django.db.models import Count from django.shortcuts import render from apps.content.models import Course, Page from .grouping import build_sections def home(request, site): """The languages site's front page: every language, its courses grouped the way a learner thinks.""" courses = list(Course.objects.filter(site=site).annotate(chapters=Count("pages"))) sections = build_sections(courses) for section in sections: folder = section["lessons_folder"] section["lessons"] = Page.objects.filter(site=site, path__startswith=folder + "/").count() if folder else 0 return render(request, "site_languages/home.html", { "sections": sections, "breadcrumbs": [], "active_folder": None, }) Save as apps/site_languages/templates/site_languages/home.html: {% extends "theme/base.html" %} {% block title %}{{ site_title }}{% endblock %} {% block content %}

Languages

Courses in French, German, Hungarian and Japanese, grouped the way you would learn them: first the writing system, then the survival courses, then everyday conversation.

{% for lang in sections %}

{{ lang.name }}

{% for group in lang.groups %}

{{ group.title }}

    {% for course in group.courses %}
  • {{ course.name }} {{ course.chapters }} chapters
  • {% endfor %}
{% empty %}

No numbered courses yet.

{% endfor %} {% if lang.lessons %}

Lessons and reference {{ lang.lessons }} pages

{% endif %}
{% endfor %} {% endblock %} The stylesheet additions at the end of apps/theme/static/theme/base.css: /* the languages front page (Chapter 7). --lang-accent and --lang-text are set per language on the section. */ .lead { color: var(--muted); max-width: 42rem; } .lang-section { margin: 1.75rem 0; padding: .25rem 1.25rem 1rem; background: var(--surface); border: 1px solid var(--border); border-left: 5px solid var(--lang-accent); border-radius: 0 var(--radius) var(--radius) 0; } .lang-section__name { color: var(--lang-text); font-size: 1.5rem; margin: .8rem 0 .2rem; } .lang-group { font-size: .78rem; text-transform: uppercase; letter-spacing: .09em; color: var(--muted); margin: 1.1rem 0 .3rem; } .lang-courses { list-style: none; padding: 0; margin: 0; } .lang-courses li { padding: .25rem 0; } .lang-courses__count { color: var(--muted); font-size: .8rem; margin-left: .5rem; } .lang-empty, .lang-lessons { color: var(--muted); margin: .6rem 0 0; } A site can name a front page of its own. In config/urlconfs/__init__.py: SITE_HOMES = {"languages": "apps.site_languages.views.home"} def build(site): home = import_string(SITE_HOMES[site]) if site in SITE_HOMES else views.home ... re_path(r"^$", home, {"site": site}, name="home"), Every other site keeps the generic front page. Colours ------- Each language card sets two custom properties, --lang-accent (the border) and --lang-text (the heading). They are the same for four languages, but Japanese has a separate text colour: its red (#d64550) is only 3.86 against the dark surface, below the 4.5 that WCAG AA asks for normal text, so headings use a lighter red of the same hue (#e8707a, 5.63). The test suite checks every language's text colour and ALSO checks that the original Japanese red really is below 4.5, so the variant cannot be "tidied away" later. Save as tests/test_languages.py: from types import SimpleNamespace from unittest import mock from django.core.cache import cache from django.test import Client, SimpleTestCase, TestCase from apps.content import importer from apps.content.fragments import SITE_TRANSFORMS, prepare_fragment, register_transform from apps.content.models import Course, Page from apps.site_languages.config import LANGUAGES from apps.site_languages.grouping import build_sections, classify, language_key from apps.site_languages.markup import add_language_attributes from tests.test_importer import ContentTree from tests.test_theme import contrast SURFACE = "#1a1d27" # the language family's surface colour def course(folder, name, number=1): return SimpleNamespace(folder=folder, name=name, course_no=number, chapters=12) class LanguageColourTests(SimpleTestCase): def test_every_languages_text_colour_is_readable_on_the_surface(self): for language in LANGUAGES: self.assertGreaterEqual(contrast(language["text"], SURFACE), 4.5, language["name"]) def test_the_original_japanese_red_is_not_readable_enough_which_is_why_it_has_a_text_variant(self): japanese = next(l for l in LANGUAGES if l["key"] == "japan") self.assertLess(contrast(japanese["accent"], SURFACE), 4.5) self.assertNotEqual(japanese["accent"], japanese["text"]) class LanguageMarkupTests(SimpleTestCase): def test_a_line_in_the_taught_language_gets_its_lang_code(self): self.assertEqual(add_language_attributes('Szia'), 'Szia') def test_japanese_uses_the_html_code_ja_not_jp(self): self.assertIn('lang="ja"', add_language_attributes('')) def test_german_and_french_too(self): self.assertIn('lang="de"', add_language_attributes('
Hallo
')) self.assertIn('lang="fr"', add_language_attributes('

Bonjour

')) def test_the_lesson_wrapper_class_is_not_a_language_line(self): wrapper = '

text

' self.assertEqual(add_language_attributes(wrapper), wrapper) def test_a_class_among_others_is_found(self): self.assertIn('lang="hu"', add_language_attributes('x')) def test_the_english_line_and_other_elements_are_untouched(self): raw = 'HelloA:' self.assertEqual(add_language_attributes(raw), raw) def test_an_existing_lang_attribute_is_kept(self): raw = 'x' self.assertEqual(add_language_attributes(raw), raw) def test_running_it_twice_changes_nothing(self): once = add_language_attributes('a') self.assertEqual(add_language_attributes(once), once) def test_attributes_spread_over_several_lines_are_handled(self): raw = 'a' self.assertIn('lang="hu"', add_language_attributes(raw)) def test_text_that_only_mentions_a_class_is_not_a_tag(self): raw = "

Use class="hu" for lines

" self.assertEqual(add_language_attributes(raw), raw) class SiteTransformTests(SimpleTestCase): def test_the_languages_app_registered_its_transform(self): self.assertIn(add_language_attributes, SITE_TRANSFORMS["languages"]) def test_registering_twice_does_not_apply_it_twice(self): register_transform("languages", add_language_attributes) self.assertEqual(SITE_TRANSFORMS["languages"].count(add_language_attributes), 1) def test_the_transform_applies_to_the_languages_site_only(self): raw = '\nx' self.assertIn('lang="hu"', prepare_fragment(raw, "hungary/x/a.html", "languages")) self.assertNotIn("lang=", prepare_fragment(raw, "linux/x/a.html", "systems")) class GroupingTests(SimpleTestCase): def test_courses_fall_into_the_groups_a_learner_thinks_in(self): cases = { ("hungary/hungarian-alphabet-1", 1): "Reading and writing", ("japan/hiragana-2", 2): "Reading and writing", ("japan/katakana-1", 1): "Reading and writing", ("germany/german-basic-3", 3): "Survival", ("germany/german-basic-4", 4): "Everyday", ("culture/japan/japanese-music", 1): "Culture", ("france/french-something", 1): "Other courses", } for (folder, number), expected in cases.items(): self.assertEqual(classify(course(folder, "x", number))[0], expected, folder) def test_the_language_comes_from_the_folder_even_for_culture_courses(self): self.assertEqual(language_key("hungary/hungarian-basic-1"), "hungary") self.assertEqual(language_key("culture/japan/japanese-music"), "japan") def test_groups_are_named_after_the_language_and_ordered(self): courses = [course("germany/german-basic-4", "German Basic Conversation 4", 4), course("germany/german-basic-1", "German Basic Conversation 1", 1), course("germany/german-basic-2", "German Basic Conversation 2", 2)] german = next(s for s in build_sections(courses) if s["key"] == "germany") self.assertEqual([g["title"] for g in german["groups"]], ["Survival German", "Everyday German"]) self.assertEqual([c.course_no for c in german["groups"][0]["courses"]], [1, 2]) def test_writing_courses_are_ordered_alphabet_then_hiragana_then_katakana(self): courses = [course("japan/katakana-1", "Katakana 1"), course("japan/hiragana-3", "Hiragana 3", 3), course("japan/hiragana-1", "Hiragana 1")] japanese = next(s for s in build_sections(courses) if s["key"] == "japan") self.assertEqual([c.name for c in japanese["groups"][0]["courses"]], ["Hiragana 1", "Hiragana 3", "Katakana 1"]) def test_every_language_has_a_section_even_with_no_courses(self): self.assertEqual([s["name"] for s in build_sections([])], ["French", "German", "Hungarian", "Japanese"]) class LanguagesHomeTests(TestCase): def setUp(self): cache.clear() for folder, name, number in (("hungary/hungarian-basic-3", "Hungarian Basic Conversation 3", 3), ("germany/german-basic-4", "German Basic Conversation 4", 4)): c = Course.objects.create(site="languages", folder=folder, name=name, course_no=number) for n in (1, 2): Page.objects.create(path=f"{folder}/x_{n}.html", site="languages", kind="course_chapter", title=f"T{n}", course=c, chapter_no=n, fragment="

x

") Page.objects.create(path="hungary/hungarian-lessons/l1.html", site="languages", kind="lesson", title="L", fragment="x") self.html = Client().get("/", HTTP_HOST="languages.localhost").content.decode() def test_the_front_page_lists_each_language_with_its_group_titles(self): for text in ("Hungarian", "Survival Hungarian", "Everyday German", "Hungarian Basic Conversation 3", "2 chapters"): self.assertIn(text, self.html) def test_languages_are_in_alphabetical_order(self): order = [self.html.index(f">{n}") for n in ("French", "German", "Hungarian", "Japanese")] self.assertEqual(order, sorted(order)) def test_each_section_carries_its_own_colours(self): self.assertIn("--lang-accent: #5aa469; --lang-text: #5aa469", self.html) self.assertIn("--lang-accent: #d64550; --lang-text: #e8707a", self.html) def test_the_lessons_link_appears_only_where_there_are_lessons(self): self.assertIn('href="/hungary/hungarian-lessons/"', self.html) self.assertNotIn('href="/france/french-lessons/"', self.html) def test_other_sites_keep_the_generic_front_page(self): other = Client().get("/", HTTP_HOST="systems.localhost").content.decode() self.assertNotIn("lang-section", other) class PipelineVersionTests(TestCase): def test_changing_the_pipeline_re_imports_unchanged_files_once(self): tree = ContentTree(self) tree.write("hungary/x/page.html", 'Szia') first = importer.import_tree(tree.root) again = importer.import_tree(tree.root) with mock.patch.object(importer, "PIPELINE_VERSION", "next"): changed = importer.import_tree(tree.root) settled = importer.import_tree(tree.root) self.assertEqual((first.created, again.unchanged, changed.updated, settled.unchanged), (1, 1, 1, 1)) def test_an_imported_languages_page_has_its_lang_attributes(self): tree = ContentTree(self) tree.write("hungary/x/page.html", 'Szia Hi') importer.import_tree(tree.root) stored = Page.objects.get().fragment self.assertIn('Szia', stored) self.assertIn('Hi', stored) Run everything (Django 6.1.2): python manage.py test tests Found 152 test(s). System check identified no issues (0 silenced). Creating test database for alias 'default'... ........................................................................................................................................................ ---------------------------------------------------------------------- Ran 152 tests in 0.660s OK Destroying test database for alias 'default'... WHY THIS WORKS AS AN ANSWER --------------------------- The grouping lives in one small function with tests, so the rule it implements (the two volumes of three) is written down once, in code. The front page is a template over data, so a new language is one entry in config.py.