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
+18
View File
@@ -1,5 +1,7 @@
from pathlib import Path
from types import SimpleNamespace
import polib
import pytest
from portal.adapters import registry
@@ -36,3 +38,19 @@ def adapters():
registry.install(fakes.directory, fakes.idp)
yield fakes
registry.install()
@pytest.fixture(scope="session", autouse=True)
def compiled_messages():
"""The image compiles the catalogues with gettext; tests compile their own copy."""
for po_file in (Path(__file__).resolve().parent.parent / "locale").glob("*/LC_MESSAGES/*.po"):
polib.pofile(str(po_file)).save_as_mofile(str(po_file.with_suffix(".mo")))
@pytest.fixture(autouse=True)
def plain_static_files(settings):
"""No collectstatic in tests, so use storage without a manifest."""
settings.STORAGES = {
**settings.STORAGES,
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
}
+88
View File
@@ -0,0 +1,88 @@
"""Per-device preferences: cookies in, validated values out (AD-15)."""
import pytest
from django.test import Client, RequestFactory
from portal.shell import preferences
from portal.shell.preferences import Preferences
def request_with(cookies=None, accept_language=""):
request = RequestFactory().get("/", HTTP_ACCEPT_LANGUAGE=accept_language)
request.COOKIES.update(cookies or {})
return request
def test_without_cookies_the_defaults_apply():
prefs = preferences.from_request(request_with())
assert prefs == Preferences(theme="system", language="en", form="sie")
@pytest.mark.parametrize(
("header", "expected"),
[("de-DE,de;q=0.9,en;q=0.5", "de"), ("en-GB", "en"), ("fr-FR", "en"), ("", "en")],
)
def test_the_language_defaults_from_the_browser(header, expected):
assert preferences.from_request(request_with(accept_language=header)).language == expected
def test_valid_cookies_are_used_and_beat_the_browser():
cookies = {"pm_theme": "dark", "pm_lang": "en", "pm_form": "du"}
prefs = preferences.from_request(request_with(cookies, accept_language="de"))
assert prefs == Preferences(theme="dark", language="en", form="du")
@pytest.mark.parametrize("value", ["", "blue", "<script>", "DARK", "x" * 500])
def test_forged_cookie_values_fall_back_to_the_defaults(value):
cookies = {"pm_theme": value, "pm_lang": value, "pm_form": value}
prefs = preferences.from_request(request_with(cookies))
assert prefs == Preferences(theme="system", language="en", form="sie")
def test_the_informal_form_only_changes_german():
assert Preferences(language="de", form="du").language_code == "de-du"
assert Preferences(language="de", form="sie").language_code == "de"
assert Preferences(language="en", form="du").language_code == "en"
def test_only_valid_choices_are_taken_from_a_submission():
assert preferences.chosen({"theme": "dark", "language": "xx", "form": "du"}) == {
"pm_theme": "dark",
"pm_form": "du",
}
assert preferences.chosen({}) == {}
# -- the preferences address ------------------------------------------------------------------
def test_choosing_sets_the_cookies_and_returns_to_the_page():
response = Client().post(
"/preferences", {"theme": "dark", "language": "de", "form": "du", "next": "/x?y=1"}
)
assert response.status_code == 302 and response["Location"] == "/x?y=1"
for name, value in (("pm_theme", "dark"), ("pm_lang", "de"), ("pm_form", "du")):
cookie = response.cookies[name]
assert cookie.value == value
assert cookie["samesite"] == "Lax" and cookie["secure"] and not cookie["httponly"]
assert cookie["max-age"] == preferences.COOKIE_MAX_AGE
def test_an_invalid_value_sets_no_cookie():
response = Client().post("/preferences", {"theme": "neon", "language": "fr"})
assert not response.cookies
@pytest.mark.parametrize("target", ["https://evil.example.test/", "//evil.example.test", "x", ""])
def test_the_return_address_cannot_leave_the_site(target):
response = Client().post("/preferences", {"theme": "dark", "next": target})
assert response["Location"] == "/"
def test_choosing_needs_post():
assert Client().get("/preferences").status_code == 405
def test_choosing_needs_a_csrf_token():
strict = Client(enforce_csrf_checks=True)
assert strict.post("/preferences", {"theme": "dark"}).status_code == 403
+225
View File
@@ -0,0 +1,225 @@
"""The page shell: layout, role-based menu, preferences applied to pages (AD-15, AD-16)."""
import re
from pathlib import Path
import pytest
from django.test import Client
from django.utils import translation
from portal.tests.test_signin import sign_in
pytestmark = pytest.mark.django_db(transaction=True)
STATIC = Path(__file__).resolve().parent.parent / "static" / "portal"
@pytest.fixture
def people(adapters):
d = adapters.directory
d.add_user("alice", display_name="Alice Anders")
d.add_user("pub", display_name="Pat Publisher", is_publisher=True)
d.add_user("boss", display_name="Bo Boss", is_admin=True, is_publisher=True)
d.add_user("admin", display_name="Ann Admin", is_admin=True)
return d
def page(adapters, username="alice", path="/", **cookies):
client, _ = sign_in(adapters, username)
for name, value in cookies.items():
client.cookies[name] = value
return client.get(path), client
def menu_of(response):
html = response.content.decode()
nav = re.search(r'<nav id="menu".*?</nav>', html, re.S)
return nav.group(0) if nav else ""
# -- the layout ----------------------------------------------------------------------------------
def test_a_signed_in_page_has_the_shell_and_the_users_name(people, adapters):
response, _ = page(adapters)
html = response.content.decode()
assert response.status_code == 200
assert 'class="top-bar"' in html and '<nav id="menu"' in html and '<main id="content"' in html
assert html.count("Alice Anders") >= 2 # heading and profile menu
assert ">AA<" in html # initials until the avatar component is defined
def test_the_skip_link_is_the_first_thing_in_the_page(people, adapters):
html = page(adapters)[0].content.decode()
body = html[html.index("<body>") :]
assert body.index('class="skip-link"') < body.index("<header")
assert 'href="#content"' in html
def test_the_page_loads_its_scripts_and_styles_from_its_own_origin(people, adapters):
html = page(adapters)[0].content.decode()
sources = re.findall(r'(?:src|href)="([^"]+)"', html)
assert all(not s.startswith(("http:", "https:", "//")) for s in sources)
assert "/static/vendor/fluent.js" in sources and "/static/portal/shell.js" in sources
def test_nothing_is_inline_so_the_strict_policy_holds(people, adapters):
for username in ("alice", "boss"):
html = page(adapters, username)[0].content.decode()
assert not re.search(r"<script(?![^>]*\bsrc=)", html)
assert not re.search(r"<style\b", html)
assert not re.search(r"\sstyle=", html)
assert not re.search(r"\son[a-z]+=", html)
def test_the_policy_allows_scripts_only_from_the_site(people, adapters):
csp = page(adapters)[0]["Content-Security-Policy"]
assert "script-src 'self'" in csp and "unsafe-inline" not in csp and "unsafe-eval" not in csp
def test_the_current_page_is_marked_in_the_menu(people, adapters):
nav = menu_of(page(adapters)[0])
assert re.search(r'<a href="/" class="current" aria-current="page">', nav)
def test_signed_out_pages_use_the_layout_without_menu_or_avatar():
html = Client().get("/auth/signed-out").content.decode()
assert 'class="top-bar"' in html and "ping-mee Portal" in html
assert '<nav id="menu"' not in html and 'class="profile"' not in html
def test_the_wide_and_phone_layouts_are_in_the_stylesheet():
css = (STATIC / "shell.css").read_text(encoding="utf-8")
assert "@media (width <= 720px)" in css
assert "--touch: 44px" in css and "--nav-width: 220px" in css
narrow = css[css.index("@media (width <= 720px)") :]
assert ".js #menu" in narrow and "display: none" in narrow
assert "flex-direction: column" in narrow
# -- the menu by role -----------------------------------------------------------------------------
def test_a_normal_user_sees_only_the_home_entry(people, adapters):
nav = menu_of(page(adapters, "alice")[0])
assert "Home" in nav and "Publishing" not in nav and "Administration" not in nav
def test_a_publisher_sees_publishing_but_not_administration(people, adapters):
nav = menu_of(page(adapters, "pub")[0])
assert "Publishing" in nav and "Manage announcements" in nav
assert "Administration" not in nav
def test_an_admin_sees_administration_but_not_publishing(people, adapters):
nav = menu_of(page(adapters, "admin")[0])
assert "Administration" in nav and "Audit log" in nav
assert "Publishing" not in nav
def test_someone_who_is_both_sees_both_groups(people, adapters):
nav = menu_of(page(adapters, "boss")[0])
assert "Publishing" in nav and "Administration" in nav
PAGES = {
"/publish/announcements": "publisher",
"/administration/appearance": "admin",
"/administration/settings": "admin",
"/administration/audit": "admin",
"/administration/status": "admin",
}
@pytest.mark.parametrize("path", PAGES)
def test_hidden_pages_refuse_a_direct_request(people, adapters, path):
allowed = {"publisher": {"pub", "boss"}, "admin": {"admin", "boss"}}[PAGES[path]]
for username in ("alice", "pub", "admin", "boss"):
response, _ = page(adapters, username, path)
assert response.status_code == (200 if username in allowed else 403), username
def test_a_refused_page_says_so_in_the_shell(people, adapters):
response, _ = page(adapters, "alice", "/administration/audit")
html = response.content.decode()
assert response.status_code == 403 and "do not have permission" in html
assert 'class="top-bar"' in html and "Audit log" not in menu_of(response)
def test_pages_for_roles_need_a_sign_in():
response = Client().get("/administration/audit")
assert response.status_code == 302 and response["Location"].startswith("/auth/login")
# -- preferences applied to the page ----------------------------------------------------
def test_the_theme_is_an_attribute_on_the_root_element(people, adapters):
assert 'data-theme="system"' in page(adapters)[0].content.decode()
assert 'data-theme="dark"' in page(adapters, pm_theme="dark")[0].content.decode()
assert 'data-theme="light"' in page(adapters, pm_theme="light")[0].content.decode()
def test_an_unknown_theme_cookie_is_not_echoed(people, adapters):
html = page(adapters, pm_theme='"><script>x</script>')[0].content.decode()
assert "<script>x" not in html and 'data-theme="system"' in html
def test_the_browser_language_is_used_when_nothing_is_chosen(people, adapters):
client, _ = sign_in(adapters, "alice")
html = client.get("/", HTTP_ACCEPT_LANGUAGE="de-DE,de;q=0.9").content.decode()
assert '<html lang="de"' in html and "Guten Tag, Alice Anders" in html
def test_german_formal_is_the_default_german(people, adapters):
html = page(adapters, pm_lang="de")[0].content.decode()
assert "Guten Tag, Alice Anders" in html and "Sie sind angemeldet" in html
assert "Startseite" in menu_of(page(adapters, pm_lang="de")[0])
def test_german_informal_uses_du(people, adapters):
html = page(adapters, pm_lang="de", pm_form="du")[0].content.decode()
assert "Hallo, Alice Anders" in html and "Du bist angemeldet" in html
def test_the_form_of_address_does_not_change_english(people, adapters):
html = page(adapters, pm_lang="en", pm_form="du")[0].content.decode()
assert "Hello, Alice Anders" in html and "Hallo" not in html
def test_the_form_control_is_shown_only_in_german(people, adapters):
assert 'value="du"' in page(adapters, pm_lang="de")[0].content.decode()
assert 'value="du"' not in page(adapters, pm_lang="en")[0].content.decode()
def test_the_chosen_options_are_marked_pressed(people, adapters):
html = page(adapters, pm_theme="dark")[0].content.decode()
assert re.search(r'value="dark"[^>]*aria-pressed="true"', html)
assert re.search(r'value="system"[^>]*aria-pressed="false"', html)
def test_the_profile_menu_holds_sign_out_and_posts_back_to_the_page(people, adapters):
response, _ = page(adapters, path="/")
html = response.content.decode()
assert 'action="/auth/logout"' in html and 'action="/preferences"' in html
assert 'name="next" value="/"' in html
def test_responses_are_marked_as_varying_with_the_preferences(people, adapters):
response, _ = page(adapters, pm_lang="de")
assert response["Content-Language"] == "de"
assert {"Cookie", "Accept-Language"} <= {v.strip() for v in response["Vary"].split(",")}
def test_the_language_does_not_leak_into_the_next_request(people, adapters):
client, _ = sign_in(adapters, "alice")
client.cookies["pm_lang"] = "de"
assert "Startseite" in menu_of(client.get("/"))
del client.cookies["pm_lang"]
assert "Home" in menu_of(client.get("/"))
def test_a_german_string_with_no_informal_entry_falls_back_to_the_formal_one():
with translation.override("de-du"):
assert translation.gettext("Home") == "Startseite"
assert translation.gettext("Sign out") == "Abmelden"
+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)