feat: add page shell, preferences and translations

Base layout with top bar, role-based menu, profile menu and phone layout; theme, language and German form of address kept in cookies; German formal, German informal and English text; Fluent avatar bundled in the image by a Node build stage; placeholder pages for the Publisher and Admin groups.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
This commit is contained in:
2026-10-17 21:05:40 +02:00
co-authored by Claude Sonnet 5.5
parent 3c69c2a156
commit 0dc079a8ac
34 changed files with 2132 additions and 24 deletions
+73
View File
@@ -0,0 +1,73 @@
"""Every user-visible string has German text (AD-15); the informal catalogue only differs."""
import re
from pathlib import Path
import polib
import pytest
PORTAL = Path(__file__).resolve().parent.parent
LOCALE = PORTAL / "locale"
TRANS_TAG = re.compile(r"""\{%\s*trans\s+(?:"([^"]+)"|'([^']+)')""")
BLOCKTRANS = re.compile(r"\{%\s*blocktrans[^%]*%\}(.*?)\{%\s*endblocktrans\s*%\}", re.S)
VARIABLE = re.compile(r"\{\{\s*(\w+)\s*\}\}")
PY_CALL = re.compile(r"""(?<![\w.])(?:_|gettext|gettext_lazy)\(\s*(?:"([^"]+)"|'([^']+)')""")
def msgids_in_code() -> set[str]:
found = set()
for template in (PORTAL / "templates").rglob("*.html"):
text = template.read_text(encoding="utf-8")
found.update(a or b for a, b in TRANS_TAG.findall(text))
found.update(VARIABLE.sub(r"%(\1)s", body).strip() for body in BLOCKTRANS.findall(text))
for source in PORTAL.rglob("*.py"):
if "tests" in source.parts or "migrations" in source.parts:
continue
found.update(a or b for a, b in PY_CALL.findall(source.read_text(encoding="utf-8")))
return found
def catalogue(name: str) -> polib.POFile:
return polib.pofile(str(LOCALE / name / "LC_MESSAGES" / "django.po"))
def test_the_scan_finds_the_strings():
assert {"Sign out", "Home", "Hello, %(name)s", "Audit log"} <= msgids_in_code()
def test_every_string_in_the_code_has_german_text():
german = {e.msgid: e for e in catalogue("de")}
missing = sorted(msgids_in_code() - german.keys())
assert not missing, f"no German entry for: {missing}"
for entry in german.values():
assert entry.msgstr.strip(), f"empty German text for {entry.msgid!r}"
assert "fuzzy" not in entry.flags, f"fuzzy entry {entry.msgid!r}"
def test_the_german_catalogue_has_no_stale_entries():
stale = sorted({e.msgid for e in catalogue("de")} - msgids_in_code())
assert not stale, f"entries no longer used: {stale}"
def test_the_informal_catalogue_only_holds_differences_to_known_strings():
german = {e.msgid: e.msgstr for e in catalogue("de")}
for entry in catalogue("de_DU"):
assert entry.msgid in german, f"unknown string {entry.msgid!r}"
assert entry.msgstr.strip() and "fuzzy" not in entry.flags
assert entry.msgstr != german[entry.msgid], f"{entry.msgid!r} does not differ"
@pytest.mark.parametrize("name", ["de", "de_DU"])
def test_placeholders_survive_translation(name):
for entry in catalogue(name):
assert set(re.findall(r"%\(\w+\)s", entry.msgid)) == set(
re.findall(r"%\(\w+\)s", entry.msgstr)
), entry.msgid
def test_the_formal_text_does_not_use_du_and_the_informal_does_not_use_sie():
formal = " ".join(e.msgstr for e in catalogue("de"))
assert not re.search(r"\b(du|dir|dich|dein|deine)\b", formal, re.I)
informal = " ".join(e.msgstr for e in catalogue("de_DU"))
assert not re.search(r"\b(Sie|Ihnen|Ihr|Ihre)\b", informal)