learning-website-django1-6 Exercise 1: The Global Bar, the Site Menu and Readable Labels ===================================================================================== Build the first two layers of navigation (Learning Website: Framework & Architecture 5), and make sure the labels read well. Django 6.1.2. Every layer is DERIVED, so none can drift: the global bar from the site map, the site menu from the pages that really exist in the database, the labels from the folder names. Save as apps/navigation/labels.py: """Readable labels for folder names, so a menu says "Data Science and ML", not "Data Science And Ml". The live site's helper capitalises every word, which gave "Data Science And Ml" and "Cloud And Devops" (found in Learning Website: Framework & Architecture 5). This one lower-cases small joining words and knows the acronyms and a few product names.""" import re SMALL_WORDS = {"a", "an", "and", "of", "for", "the", "in", "on", "to", "with", "vs"} ACRONYMS = { "ai", "css", "html", "js", "php", "sql", "api", "seo", "tls", "https", "ssh", "vpn", "rdp", "vnc", "grub", "awk", "sed", "iis", "obs", "vfx", "pdf", "k8s", "llm", "nlp", "gpu", "cpu", "os", "ui", "ux", "cli", "sdk", "ide", "json", "xml", "yaml", "rest", "grpc", "cicd", "iot", "orm", "jwt", "oauth", "cdn", "ml", "dns", "aws", "gcp", "usa", "uk", } OVERRIDES = { "devops": "DevOps", "ios": "iOS", "macos": "macOS", "nextjs": "Next.js", "nodejs": "Node.js", "javascript": "JavaScript", "typescript": "TypeScript", "graphql": "GraphQL", "mysql": "MySQL", "postgresql": "PostgreSQL", "sqlite": "SQLite", "mongodb": "MongoDB", "wordpress": "WordPress", "web3": "Web3", "youtube": "YouTube", "linkedin": "LinkedIn", "github": "GitHub", } def humanize(segment): """'data-science-and-ml' -> 'Data Science and ML'""" stem = re.sub(r"\.[a-z0-9]+$", "", segment, flags=re.I) words = [w for w in re.split(r"[-_]+", stem) if w] out = [] for i, word in enumerate(words): low = word.lower() if low in OVERRIDES: out.append(OVERRIDES[low]) elif low in ACRONYMS: out.append(low.upper()) elif i > 0 and low in SMALL_WORDS: out.append(low) else: out.append(low[:1].upper() + low[1:]) return " ".join(out) Save as apps/navigation/menus.py: """The three layers of navigation (Learning Website: Framework & Architecture 5): the global bar, the site menu and the page's own breadcrumb and previous/next links. All of them are DERIVED, so none can drift.""" from django.conf import settings from django.core.cache import cache from apps.content.models import Course, Page from config.sites_config import SIDEBAR_ROUTES, SITE_STYLE, SITES from .labels import humanize CACHE_SECONDS = 3600 def site_url(site, path="/"): """Absolute address of a path on a site, from one template in the settings.""" return settings.SITE_URL_TEMPLATE.format(site=site) + path def global_bar(current): """Layer 1: every site, the same on every page, with the current one marked.""" return [{"name": name, "title": info["title"], "url": site_url(name), "current": name == current} for name, info in SITES.items()] def _menu_cache_key(site): return f"nav:menu:{site}" def invalidate_menus(): """Call after an import: the menus are rebuilt from the database on the next request.""" cache.delete_many([_menu_cache_key(site) for site in SITES]) def site_menu(site): """Layer 2: this site's subject folders that really have pages, in the site map's order.""" def build(): present = {path.split("/")[0] for path in Page.objects.filter(site=site).values_list("path", flat=True)} items = [{"label": humanize(folder), "href": f"/{folder}/", "folder": folder} for folder in SITES[site]["folders"] if folder in present] # a site only gets a Sidebar entry if some sidebar// is routed to it AND has pages if "sidebar" in present and site in SIDEBAR_ROUTES.values(): items.append({"label": "Sidebar", "href": "/sidebar/", "folder": "sidebar"}) return items return cache.get_or_set(_menu_cache_key(site), build, CACHE_SECONDS) def breadcrumbs(site, folder_path, last_label=None): """Layer 3: Site > folder > folder > ... > this page. folder_path is the page's folder, "" for the site home. A course folder is labelled with the course's real name; any other folder with its readable name.""" parts = [p for p in folder_path.split("/") if p] prefixes = ["/".join(parts[:i + 1]) for i in range(len(parts))] names = dict(Course.objects.filter(folder__in=prefixes).values_list("folder", "name")) trail = [{"label": SITES[site]["title"], "href": "/"}] for prefix in prefixes: trail.append({"label": names.get(prefix) or humanize(prefix.rsplit("/", 1)[-1]), "href": f"/{prefix}/"}) if last_label: trail.append({"label": last_label, "href": None}) elif trail[-1]["href"] != "/": trail[-1]["href"] = None # the folder you are on is not a link return trail def neighbours(page): """The previous and next chapter of the same course, by chapter NUMBER (so 10 follows 9, not 1).""" if page.course_id is None or page.chapter_no is None: return None, None chapters = Page.objects.light().filter(course_id=page.course_id) before = chapters.filter(chapter_no__lt=page.chapter_no).order_by("-chapter_no").first() after = chapters.filter(chapter_no__gt=page.chapter_no).order_by("chapter_no").first() return before, after def listing(site, folder_path): """What is directly inside a folder: sub-folders and pages. Returns None if the folder has no pages.""" prefix = folder_path.strip("/") + "/" pages = list(Page.objects.light().filter(site=site, path__startswith=prefix).order_by("chapter_no", "path")) if not pages: return None folders, here = {}, [] for p in pages: rest = p.path[len(prefix):] if "/" in rest: name = rest.split("/", 1)[0] folders[name] = folders.get(name, 0) + 1 else: here.append(p) return { "folders": [{"label": humanize(n), "href": f"/{prefix}{n}/", "count": c} for n, c in sorted(folders.items())], "pages": here, "course": Course.objects.filter(folder=prefix.rstrip("/")).first(), } Save as apps/navigation/links.py: """Links between sites, built from the one site map (Learning Website: Framework & Architecture 5).""" from urllib.parse import urlsplit, urlunsplit from config.sites_config import site_for_path def resolve_link(current_site, href, url_template): """Turn an old-style root-relative link into the right link from current_site. - external links, anchors and relative paths are returned unchanged - a link into the current site stays root-relative (the trailing slash, query and fragment are kept) - a link into another site becomes an absolute URL on that site - a root-relative path that belongs to no site raises NoSiteError: a broken link should fail, not be guessed""" parts = urlsplit(href) if parts.scheme or parts.netloc or not href.startswith("/") or parts.path.lower().endswith(".txt"): return href if parts.path == "/": return href target = site_for_path(parts.path) if target == current_site: return href base = urlsplit(url_template.format(site=target)) return urlunsplit((base.scheme, base.netloc, parts.path, parts.query, parts.fragment)) Save as apps/theme/context_processors.py: from apps.navigation.menus import global_bar, site_menu from config.sites_config import SITE_STYLE def site(request): """Make the current site's name, title, colours and navigation available to every template.""" name = getattr(request, "site", None) if name is None: # error pages for hosts that are not a site return {"site_name": None, "site_title": "Learning Notes", "site_family": "technical", "site_accent": "#44b78b", "global_bar": [], "site_menu": []} style = SITE_STYLE[name] return {"site_name": name, "site_title": request.site_info["title"], "site_family": style["family"], "site_accent": style["accent"], "global_bar": global_bar(name), "site_menu": site_menu(name)} Add the address template to the settings (the global bar uses it): config/settings/dev.py: SITE_URL_TEMPLATE = "http://{site}.localhost:" + os.environ.get("LW_DEV_PORT", "8000") config/settings/prod.py: SITE_URL_TEMPLATE = "https://{site}.osztromok.com" Tell the importer to clear the cached menus when it finishes (so a new import shows up in the menus at once). In apps/content/importer.py: from apps.navigation.menus import invalidate_menus ... invalidate_menus() # just before "return result" Save the base template that shows the layers: Save as apps/theme/templates/theme/base.html: {% load static %} {% block title %}{{ site_title }}{% endblock %} | Philip's Learning Notes {# load order matters: tokens, then base, then components. A page fragment's own