learning-website-django1-6 Exercise 2: Breadcrumbs, Previous/Next and Folder Listings =========================================================================================== Add the third layer (where you are on this site) and the pages that make the breadcrumb links go somewhere: a listing for every folder. The page view now serves either a page or a folder, and a folder listing needs two new templates and the helpers already in menus.py (breadcrumbs, neighbours, listing). Save the view and templates: Save as apps/content/views.py: from django.http import Http404 from django.shortcuts import render from apps.navigation.labels import humanize from apps.navigation.menus import breadcrumbs, listing, neighbours from .models import Page def home(request, site): pages = Page.objects.light().filter(site=site) return render(request, "theme/home.html", { "page_count": pages.count(), "course_count": pages.exclude(course=None).values("course").distinct().count(), "breadcrumbs": [], }) def page(request, site, page_path): """An address is either a page (/hungary/x/chapter/ is the file hungary/x/chapter.html) or a folder (/hungary/x/ lists what is inside it). Anything else is a 404.""" bare = page_path.strip("/") found = Page.objects.filter(path=bare + ".html", site=site).first() # a page of ANOTHER site is a 404 too if found is not None: before, after = neighbours(found) folder = found.path.rsplit("/", 1)[0] if "/" in found.path else "" return render(request, "theme/page.html", { "page": found, # a page with its own

shows it; every other page gets one made from its title "show_h1": "{{ page.title }}

{% endif %}
{{ page.html }}
{% if previous_page or next_page %} {% endif %} {% endblock %} Save as apps/theme/templates/theme/listing.html: {% extends "theme/base.html" %} {% block title %}{{ title }}{% endblock %} {% block content %}

{{ title }}

{% if content.folders %}

Folders

{% endif %} {% if content.pages %}

{% if content.course %}Chapters{% else %}Pages{% endif %}

    {% for p in content.pages %}
  1. {{ p.title }}
  2. {% endfor %}
{% endif %} {% endblock %} THE SIDEBAR WAS NEVER ROUTED. Writing the listing view exposed a gap from Chapter 2: the URL configuration of each site was built from the folders in the site map, so /sidebar/... was a 404 on every site. The sidebar belongs to whichever site its SUBJECT is routed to (sidebar/linux to systems, sidebar/football to humanities), and /sidebar/ itself is that site's own index. The pattern is changed in config/urlconfs/__init__.py: """One URL configuration per site, created from the site map. Django accepts any importable module path in request.urlconf, so each module is built here and registered in sys.modules under config.urlconfs.. A site's configuration only contains routes for ITS folders, so /linux/ is a 404 on the languages site.""" import re import sys import types from django.urls import re_path from apps.content import views from config.sites_config import SIDEBAR_ROUTES, SITES def build(site): folders = "|".join(re.escape(f) for f in SITES[site]["folders"]) # sidebar//... belongs to the site that its subject is routed to; /sidebar/ itself is that site's index subjects = [s for s, owner in SIDEBAR_ROUTES.items() if owner == site] alternatives = [folders] + [re.escape(f"sidebar/{s}") for s in subjects] alternatives = "|".join(a for a in alternatives if a) index = r"|sidebar/?" if subjects else "" module = types.ModuleType(f"config.urlconfs.{site}") module.urlpatterns = [ re_path(r"^$", views.home, {"site": site}, name="home"), re_path(rf"^(?P(?:(?:{alternatives})(?:/.*)?{index}))$", views.page, {"site": site}, name="page"), ] sys.modules[module.__name__] = module return module for _site in SITES: build(_site) The sidebar index is a separate alternative (sidebar/?), not just "sidebar" in the folder list, so /sidebar/football/x/ cannot match on the systems site. Save as tests/test_navigation.py: from django.core.cache import cache from django.test import Client, SimpleTestCase, TestCase, override_settings from apps.content.models import Course, Page from apps.navigation.labels import humanize from apps.navigation.links import resolve_link from apps.navigation.menus import breadcrumbs, global_bar, invalidate_menus, listing, neighbours, site_menu from config.sites_config import NoSiteError, SITES TEMPLATE = "https://{site}.osztromok.com" HU = "hungary/hungarian-basic-3/hungarian_basic_conversation_3_{n}.html" def make_course(): return Course.objects.create(site="languages", folder="hungary/hungarian-basic-3", name="Hungarian Basic Conversation 3", course_no=3) def make_chapters(course, numbers=(1, 2, 3, 10)): return [Page.objects.create(path=HU.format(n=n), site="languages", kind="course_chapter", title=f"Chapter {n}", course=course, chapter_no=n, fragment=f"

chapter {n}

") for n in numbers] def make_page(path, site, title="A page", **kw): return Page.objects.create(path=path, site=site, kind="lesson", title=title, fragment="

x

", **kw) class LabelTests(SimpleTestCase): def test_small_words_are_lower_case_and_acronyms_upper_case(self): self.assertEqual(humanize("data-science-and-ml"), "Data Science and ML") self.assertEqual(humanize("cloud-and-devops"), "Cloud and DevOps") def test_product_names_use_their_own_capitals(self): self.assertEqual(humanize("javascript"), "JavaScript") self.assertEqual(humanize("ios-development"), "iOS Development") self.assertEqual(humanize("maths-for-programmers"), "Maths for Programmers") def test_the_first_word_is_always_capitalised(self): self.assertEqual(humanize("the-history-of-football"), "The History of Football") def test_an_extension_is_dropped(self): self.assertEqual(humanize("vim_lesson.html"), "Vim Lesson") class GlobalBarTests(SimpleTestCase): @override_settings(SITE_URL_TEMPLATE=TEMPLATE) def test_every_site_appears_once_and_exactly_one_is_current(self): bar = global_bar("systems") self.assertEqual([s["name"] for s in bar], list(SITES)) self.assertEqual([s["name"] for s in bar if s["current"]], ["systems"]) self.assertEqual(bar[0]["url"], "https://languages.osztromok.com/") @override_settings(SITE_URL_TEMPLATE="http://{site}.localhost:8000") def test_development_addresses_use_localhost_and_the_port(self): self.assertEqual(global_bar("ai")[0]["url"], "http://languages.localhost:8000/") class SiteMenuTests(TestCase): def setUp(self): cache.clear() def test_only_folders_that_have_pages_appear_in_the_site_maps_order(self): make_page("japan/x/a.html", "languages") make_page("france/y/b.html", "languages") self.assertEqual([i["label"] for i in site_menu("languages")], ["France", "Japan"]) def test_the_menu_for_one_site_never_shows_another_sites_folders(self): make_page("linux/x/a.html", "systems") self.assertEqual(site_menu("languages"), []) def test_a_sidebar_entry_only_appears_where_sidebar_pages_exist_for_that_site(self): make_page("sidebar/linux/cheat.html", "systems") self.assertEqual(site_menu("systems")[-1]["label"], "Sidebar") self.assertEqual(site_menu("languages"), []) def test_the_menu_is_cached_and_an_import_clears_it(self): make_page("japan/x/a.html", "languages") site_menu("languages") with self.assertNumQueries(0): site_menu("languages") make_page("france/y/b.html", "languages") self.assertEqual(len(site_menu("languages")), 1) # still the cached copy invalidate_menus() self.assertEqual(len(site_menu("languages")), 2) class BreadcrumbTests(TestCase): def test_a_course_folder_is_labelled_with_the_courses_real_name(self): make_course() trail = breadcrumbs("languages", "hungary/hungarian-basic-3", last_label="Chapter 1") self.assertEqual([c["label"] for c in trail], ["Languages", "Hungary", "Hungarian Basic Conversation 3", "Chapter 1"]) self.assertEqual([c["href"] for c in trail], ["/", "/hungary/", "/hungary/hungarian-basic-3/", None]) def test_the_folder_you_are_on_is_not_a_link(self): trail = breadcrumbs("languages", "hungary") self.assertEqual([c["href"] for c in trail], ["/", None]) def test_the_site_home_is_just_the_site(self): self.assertEqual(breadcrumbs("languages", ""), [{"label": "Languages", "href": "/"}]) class NeighbourTests(TestCase): def setUp(self): self.pages = make_chapters(make_course()) def test_neighbours_follow_the_chapter_number_not_the_file_name(self): by_no = {p.chapter_no: p for p in self.pages} self.assertEqual([n.chapter_no if n else None for n in neighbours(by_no[3])], [2, 10]) self.assertEqual([n.chapter_no if n else None for n in neighbours(by_no[10])], [3, None]) def test_the_first_chapter_has_no_previous(self): self.assertEqual(neighbours(self.pages[0])[0], None) def test_a_page_with_no_course_or_number_has_no_neighbours(self): p = make_page("hungary/hungarian-lessons/x.html", "languages") self.assertEqual(neighbours(p), (None, None)) class ListingTests(TestCase): def test_a_folder_lists_its_subfolders_and_its_own_pages(self): make_page("hungary/a/one.html", "languages") make_page("hungary/a/two.html", "languages") make_page("hungary/b/three.html", "languages") make_page("hungary/top.html", "languages", title="Top") out = listing("languages", "hungary") self.assertEqual([(f["label"], f["count"]) for f in out["folders"]], [("A", 2), ("B", 1)]) self.assertEqual([p.title for p in out["pages"]], ["Top"]) def test_a_course_folder_lists_its_chapters_in_numeric_order(self): make_chapters(make_course(), numbers=(10, 2, 1)) out = listing("languages", "hungary/hungarian-basic-3") self.assertEqual([p.chapter_no for p in out["pages"]], [1, 2, 10]) self.assertEqual(out["course"].name, "Hungarian Basic Conversation 3") def test_an_empty_or_unknown_folder_is_none(self): self.assertIsNone(listing("languages", "hungary/nothing")) def test_a_site_only_lists_its_own_pages(self): make_page("linux/x/a.html", "systems") self.assertIsNone(listing("languages", "linux")) class NavigationPagesTests(TestCase): def setUp(self): cache.clear() make_chapters(make_course()) self.client = Client(HTTP_HOST="languages.localhost") def test_a_chapter_shows_the_three_layers_and_its_neighbours(self): html = self.client.get("/hungary/hungarian-basic-3/hungarian_basic_conversation_3_2/").content.decode() self.assertIn('aria-label="Sites"', html) # layer 1 self.assertIn('aria-label="Languages subjects"', html) # layer 2 self.assertIn('aria-label="Breadcrumb"', html) # layer 3 self.assertIn('rel="prev"', html) self.assertIn('rel="next"', html) self.assertIn("Hungarian Basic Conversation 3", html) def test_exactly_one_site_is_marked_current_in_the_global_bar(self): html = self.client.get("/").content.decode() bar = html.split('aria-label="Sites"')[1].split("")[0] self.assertEqual(bar.count('aria-current="true"'), 1) def test_the_active_subject_is_marked_in_the_site_menu(self): html = self.client.get("/hungary/hungarian-basic-3/hungarian_basic_conversation_3_1/").content.decode() menu = html.split('aria-label="Languages subjects"')[1].split("")[0] self.assertIn('href="/hungary/" aria-current="true"', menu) def test_a_folder_address_shows_a_listing(self): response = self.client.get("/hungary/hungarian-basic-3/") self.assertEqual(response.status_code, 200) html = response.content.decode() self.assertIn("

Hungarian Basic Conversation 3

", html) self.assertLess(html.index("Chapter 3"), html.index("Chapter 10")) def test_a_folder_without_pages_is_a_404(self): self.assertEqual(self.client.get("/hungary/nothing/").status_code, 404) def test_a_folder_of_another_site_is_a_404_on_this_site(self): make_page("linux/x/a.html", "systems") self.assertEqual(self.client.get("/linux/x/").status_code, 404) self.assertEqual(Client(HTTP_HOST="systems.localhost").get("/linux/x/").status_code, 200) def test_the_sidebar_is_routed_to_the_site_its_subject_belongs_to(self): make_page("sidebar/linux/cheat.html", "systems", title="Cheat") systems = Client(HTTP_HOST="systems.localhost") self.assertEqual(systems.get("/sidebar/").status_code, 200) self.assertEqual(systems.get("/sidebar/linux/cheat/").status_code, 200) self.assertEqual(self.client.get("/sidebar/linux/cheat/").status_code, 404) # languages has no sidebar self.assertEqual(self.client.get("/sidebar/").status_code, 404) def test_one_sites_sidebar_subject_does_not_leak_into_another(self): make_page("sidebar/football/x.html", "humanities") self.assertEqual(Client(HTTP_HOST="systems.localhost").get("/sidebar/football/x/").status_code, 404) self.assertEqual(Client(HTTP_HOST="humanities.localhost").get("/sidebar/football/x/").status_code, 200) class ResolveLinkTests(SimpleTestCase): def go(self, site, href): return resolve_link(site, href, TEMPLATE) def test_a_link_into_the_same_site_stays_root_relative(self): self.assertEqual(self.go("languages", "/japan/hiragana-1/"), "/japan/hiragana-1/") def test_a_link_into_another_site_becomes_absolute_and_keeps_its_anchor(self): self.assertEqual(self.go("languages", "/linux/shell/vim/#modes"), "https://systems.osztromok.com/linux/shell/vim/#modes") def test_sidebar_links_follow_their_subject(self): self.assertEqual(self.go("languages", "/sidebar/football/"), "https://humanities.osztromok.com/sidebar/football/") def test_external_anchor_relative_and_solution_links_are_left_alone(self): for href in ("https://example.org/x/", "#top", "../x.html", "/linux/a/solutions/x.txt"): self.assertEqual(self.go("languages", href), href) def test_a_path_in_no_site_fails_loudly(self): with self.assertRaises(NoSiteError): self.go("languages", "/no-such-area/page/") Run the whole project (Django 6.1.2): python manage.py test tests Found 125 test(s). System check identified no issues (0 silenced). Creating test database for alias 'default'... ............................................................................................................................. ---------------------------------------------------------------------- Ran 125 tests in 0.602s OK Destroying test database for alias 'default'... What the tests protect ---------------------- - The breadcrumb labels a course folder with the course's real name (Hungarian Basic Conversation 3), other folders with their readable name, and the folder you are on is not a link. - Previous and next follow the chapter NUMBER: chapter 3 is followed by 10, not by a file that sorts after it. The first chapter has no previous link, the last no next, and a page with no course or number has neither. - A folder lists its sub-folders (with page counts) and its own pages; a course folder lists its chapters in numeric order with the course name as the heading; an unknown folder is a 404; another site's folder is a 404 on this site. - The three layers are on a chapter page, with aria-labels, exactly one current site in the global bar and the active subject marked in the site menu. - The sidebar routes to the right site only, and one site's sidebar subject cannot be reached on another site. WHY THIS WORKS AS AN ANSWER --------------------------- A breadcrumb that links to a folder is a promise that the folder page exists. Building the listing at the same time kept that promise, and testing the listing found the unrouted sidebar before a visitor did.