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
+4
View File
@@ -16,3 +16,7 @@ context-resources
staticfiles
*.sqlite3*
skills-lock.json
**/node_modules
portal/frontend/dist
portal/static/vendor
**/*.mo
+8
View File
@@ -35,3 +35,11 @@ staticfiles/
.env
.env.*
!.env.example
# Front-end build
node_modules/
portal/frontend/dist/
portal/static/vendor/
# Compiled message catalogues (built in the image; tests compile their own)
*.mo
+12 -3
View File
@@ -1,24 +1,33 @@
# syntax=docker/dockerfile:1
# Builds the Fluent web components bundle; Node.js is not part of the runtime image.
FROM node:24-slim AS frontend
WORKDIR /frontend
COPY portal/frontend/package.json portal/frontend/package-lock.json ./
RUN npm ci --ignore-scripts --no-audit --no-fund
COPY portal/frontend/entry.js portal/frontend/build.mjs ./
RUN npm run build
FROM python:3.13-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:0.13.1 /uv /usr/local/bin/uv
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libldap2-dev libsasl2-dev \
&& apt-get install -y --no-install-recommends build-essential libldap2-dev libsasl2-dev gettext \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock .python-version README.md ./
RUN uv sync --frozen --no-dev --no-install-project
COPY manage.py ./
COPY portal ./portal
# Build-time only values so collectstatic can load settings; not used at runtime.
COPY --from=frontend /frontend/dist/fluent.js ./portal/static/vendor/fluent.js
# Build-time only values so compilemessages and collectstatic can load settings; not used at runtime.
RUN SECRET_KEY=build-time-only-value-not-used-at-runtime-000000 \
ALLOWED_HOSTS=localhost DATA_DIR=/tmp \
LDAP_SERVER=build.invalid LDAP_USER_OU_DN=build LDAP_BIND_DN=build LDAP_BIND_PASSWORD=build \
LDAP_CA_FILE=/app/README.md LDAP_PUBLISHER_GROUP_DN=build LDAP_ADMIN_GROUP_DN=build \
OIDC_ISSUER=https://build.invalid/ OIDC_CLIENT_ID=build OIDC_CLIENT_SECRET=build \
PUBLIC_BASE_URL=https://build.invalid \
/app/.venv/bin/python manage.py collectstatic --noinput
sh -c '/app/.venv/bin/python manage.py compilemessages && /app/.venv/bin/python manage.py collectstatic --noinput'
# Not part of the runtime image: `docker compose --profile test run --rm live-test`
FROM builder AS test
+8
View File
@@ -53,6 +53,14 @@ uv run ruff check .
`pytest` sets its own test environment (see `pyproject.toml`).
### Page shell, front end and translations
The Fluent web components are bundled by a Node build stage in the `Dockerfile` (`portal/frontend`, pinned by `package-lock.json`) and served from the portal's own origin; the runtime image has no Node.js. To rebuild the bundle locally: `cd portal/frontend && npm ci && npm run build`, then copy `dist/fluent.js` to `portal/static/vendor/`. Pages work without it (the avatar falls back to initials).
Text is German (formal and informal) and English. The English text is the message id. Add the German text to `portal/locale/de/LC_MESSAGES/django.po`, and an informal variant to `portal/locale/de_DU/...` only where it differs. `test_translations.py` fails if a string has no German text. The Docker build compiles the catalogues with `gettext`; the tests compile their own copy.
Theme, language and form of address are stored per device in the cookies `pm_theme`, `pm_lang` and `pm_form`; nothing is stored on the server.
### Directory adapter against the test OU
`uv sync` builds `python-ldap`, which needs the OpenLDAP and SASL development libraries (the Docker image installs them). The automated tests use an in-memory fake and a stand-in for the `ldap` module, so they run anywhere.
@@ -0,0 +1,104 @@
---
title: 'Page shell, preferences and translations'
type: 'feature'
ticket: '6'
created: '2026-10-17'
status: 'built'
baseline_revision: '3c69c2a'
route: 'full'
route_source: 'auto'
risk: 'medium'
review: 'quick'
review_source: 'pinned'
context:
- '{project-root}/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md'
- '{project-root}/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/DESIGN.md'
- '{project-root}/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/EXPERIENCE.md'
---
<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">
## Intent
**Problem:** After sign-in the user sees an unstyled English page. Every later epic needs one shared layout, the three per-device preferences and German/English text before it can add a page.
**Approach:** One base layout (top bar, left menu with role-based groups, profile menu, skip link) in plain server-rendered HTML styled with the design tokens, with the Fluent web components used for the interactive controls and bundled into the image. Theme, language and German form of address are stored in cookies and read by the server to render the page. All existing text moves to a German formal, German informal and English catalogue.
## Boundaries & Constraints
**Always:** AD-15 and AD-16 apply. No inline script, inline style or inline event handler (CSP stays `script-src 'self'`); no network request for fonts, icons or scripts at runtime. The theme is an attribute on `<html>`; `system` is resolved by CSS (`prefers-color-scheme`) only. Preferences are never sent to the server except as cookies. Every user-visible string goes through translation. The menu is declared in one place with the role each entry needs; a hidden entry is also refused by its view (decorators from 1.5). Touch targets are at least 44 px; the break between wide and narrow layout is 720 px. The page works for navigation and sign-out without JavaScript.
**Never:** No dashboard content (account card, announcements): that is the account epic. No Admin colour or background settings: the accent colour is a CSS variable with the default `#0F6CBD`, set by a later entry. No server-stored preferences. No Node.js or npm in the runtime image. No CDN.
## I/O & Edge-Case Matrix
| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|----------|--------------|---------------------------|----------------|
| Shell after sign-in | Signed-in user | Top bar with portal name and avatar (initials), left menu, content; their display name is shown in the profile menu | n/a |
| Menu by role | User / Publisher / Admin | User: Startseite. Publisher adds group "Veröffentlichen". Admin adds group "Administration". A Publisher who is not Admin sees no Administration group | A direct address to a hidden page shows "Dazu fehlt Ihnen die Berechtigung." (403) |
| First visit | No preference cookies | Theme System, language from `Accept-Language` (de or en, else en), German form formal | n/a |
| Choose preference | Profile menu | Cookie set, page re-renders with the new value; with JavaScript it applies at once without reloading | An unknown value is ignored, the cookie is not set |
| Informal form | Language English, form "du" | Form has no effect on English and the control is hidden | n/a |
| Informal German | Language German, form "du" | Strings use "du" forms; a string with no informal entry falls back to the formal one | n/a |
| Unsafe return address | `next` outside the site | Ignored; the user goes to `/` | n/a |
| Phone width | 360 px | Menu behind a menu button, one column, no horizontal scroll | n/a |
| Signed-out and error pages | Not signed in | Same layout without menu and avatar; preferences still readable from the cookies | n/a |
</frozen-after-approval>
## Code Map
- `portal/templates/base.html` -- new shared layout; `accounts/base.html` becomes a thin extension of it. All 1.5 pages move onto it.
- `portal/shell/` -- new app: `menu.py` (declaration of entries: label, URL name, required role, group), `preferences.py` (read and validate the three cookies, cookie names and lifetime), `middleware.py` (activates the language, sets `request.preferences`), `context.py` (context processor: menu filtered by role, preferences), `views.py` (`POST /preferences`), `urls.py`.
- `portal/frontend/` -- new: `package.json`, `package-lock.json`, an entry file that imports only the Fluent components used, and a build script.
- `portal/static/portal/` -- `shell.css` (tokens from DESIGN.md, light and dark, phone layout), `shell.js` (menu button, profile menu, apply preference at once; no inline code).
- `portal/locale/{de,de_DU,en}/LC_MESSAGES/django.po` -- catalogues; `de_DU` holds only the informal differences and falls back to `de`.
- `Dockerfile` -- a `frontend` build stage on `node:*-slim` runs `npm ci` and the bundle; the result is copied into `portal/static/vendor/` before `collectstatic`. Message catalogues are compiled in the builder stage.
- `portal/config/settings.py` -- add the app, middleware and context processor, `LANGUAGES` with the informal variant.
- `portal/config/middleware.py` -- CSP gains `style-src 'self'` and `img-src 'self' data:` only if the Fluent bundle needs them (decided at build; no `unsafe-inline`).
- Placeholder pages for the Publisher and Admin groups (one view each, decorated with `publisher_required` / `admin_required`, text "Diese Seite folgt.") so role visibility and the direct-address refusal can be checked now; later epics replace them.
- `portal/tests/` -- `test_preferences.py`, `test_shell.py`, `test_translations.py`.
## Tasks & Acceptance
**Execution:**
- [ ] `portal/frontend/` and Dockerfile stage -- build the Fluent bundle into the image, pinned by lockfile -- AD-16
- [ ] `portal/shell/preferences.py`, `middleware.py` -- cookies in, validated preferences out, language activated per request -- AD-15
- [ ] `portal/templates/base.html`, `shell.css`, `shell.js` -- layout, themes, profile menu, phone menu, skip link
- [ ] `portal/shell/menu.py`, `context.py` -- role-filtered menu and active-item marker
- [ ] `portal/locale/` -- German formal, German informal and English text for every existing string; compile step in the Docker build
- [ ] Move the 1.5 pages onto the shell; placeholder Publisher and Admin pages
- [ ] Tests for every matrix row
**Acceptance Criteria:**
- Given a signed-in user, when the home page loads, then the top bar, menu and their display name are in the page and no script, style or event-handler attribute is inline.
- Given a User, a Publisher and an Admin, when the menu renders, then it shows Startseite, plus "Veröffentlichen" only for Publishers and "Administration" only for Admins, and a direct request to a hidden page returns 403.
- Given cookies theme=dark, lang=de, form=du, when a page renders, then `<html>` carries the dark theme and the page text uses the informal form; with lang=en it is English and ignores the form.
- Given no cookies and `Accept-Language: de-DE`, when a page renders, then it is German formal and the theme is System.
- Given an invalid preference value, when it is posted, then no cookie changes.
- Given every `{% trans %}` and `gettext` string in the code, when the catalogue test runs, then each has a German entry and no entry is fuzzy or empty.
- Given a viewport of 360 px, when the page loads, then the menu is behind a button and there is no horizontal scrolling (checked by hand at the done step; the CSS rule is checked by test).
## Design Notes
Preferences are cookies named `pm_theme`, `pm_lang`, `pm_form`, `SameSite=Lax`, `Secure`, one year, not `HttpOnly` so `shell.js` can apply a change without a reload; they hold no personal data. The informal German register is a language code `de-du` whose catalogue holds only differing strings; Django's gettext lookup falls back to `de`, which falls back to `msgid`. If that fallback does not behave as expected in the build, the plan changes to a template filter, not to duplicated catalogues.
`msgfmt` and `xgettext` (GNU gettext) are not available on the Windows development machine, so strings are added to the catalogues by hand and a test (using `polib`) keeps them complete; the compile step runs in the Docker builder stage, which installs `gettext`. Message extraction with `makemessages` can be run in a container later when the string count grows.
Fluent web components render inside shadow DOM; if a component needs inline styles that the CSP refuses, the control falls back to plain HTML and CSS rather than loosening the policy.
## Verification
**Commands:**
- `uv run pytest` -- expected: all tests pass, including the new ones
- `uv run ruff check portal manage.py docker` -- expected: no findings
- `uv run python manage.py makemigrations --check --dry-run` -- expected: no changes detected
- `docker compose up --build` on the Linux host -- expected: image builds with the front-end stage; pages load with no CSP violations in the browser console
**Manual:** sign in as `ptest-user1`, `ptest-publisher`, `ptest-admin`; check the menu groups, change the three preferences and reload, narrow the window to phone width, and open `/` in a private window to see the defaults.
## Implementation Notes
- Built inline; `pytest` 247 passed, 6 skipped (live LDAP), `ruff check` clean, `makemigrations --check` no changes.
- Judgement calls: the Fluent bundle contains only the avatar (the one control the shell needs that works with a fallback); buttons and menus are native HTML so the shell works without JavaScript. The profile menu is a `<details>` element. Theme applies at once; language and form reload the page because the server renders the text. `LocaleMiddleware` is replaced by `PreferencesMiddleware`. The `de_DU` catalogue fell back to `de` as hoped (tested). No `locale/en` catalogue: the message ids are English. Placeholder pages live at `/publish/announcements` and `/administration/{appearance,settings,audit,status}`.
- Not verified: the Docker build (Node stage, `compilemessages`) and the visual result in a browser, including the Fluent avatar tokens and the phone layout, since neither runs on the Windows machine.
+3 -1
View File
@@ -33,6 +33,7 @@ INSTALLED_APPS = [
"django.contrib.staticfiles",
"portal.audit",
"portal.accounts",
"portal.shell",
]
MIDDLEWARE = [
@@ -44,7 +45,7 @@ MIDDLEWARE = [
"django.contrib.sessions.middleware.SessionMiddleware",
"portal.accounts.middleware.AbsoluteSessionLimitMiddleware",
"portal.accounts.middleware.CurrentUserMiddleware",
"django.middleware.locale.LocaleMiddleware",
"portal.shell.middleware.PreferencesMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
@@ -62,6 +63,7 @@ TEMPLATES = [
"context_processors": [
"django.template.context_processors.request",
"django.template.context_processors.i18n",
"portal.shell.context.shell",
],
},
}
+1
View File
@@ -5,4 +5,5 @@ from .health import healthz
urlpatterns = [
path("healthz", healthz, name="healthz"),
path("", include("portal.accounts.urls")),
path("", include("portal.shell.urls")),
]
+12
View File
@@ -0,0 +1,12 @@
// Bundles entry.js into one minified file the page loads from its own origin (AD-16).
import { build } from "esbuild";
await build({
entryPoints: ["entry.js"],
bundle: true,
minify: true,
format: "iife",
target: ["es2022"],
legalComments: "none",
outfile: "dist/fluent.js",
});
+2
View File
@@ -0,0 +1,2 @@
// Only the components the shell uses; each import registers its custom element.
import "@fluentui/web-components/avatar/define.js";
+557
View File
@@ -0,0 +1,557 @@
{
"name": "portal-frontend",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "portal-frontend",
"version": "0.1.0",
"dependencies": {
"@fluentui/web-components": "3.1.3"
},
"devDependencies": {
"esbuild": "0.28.2"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@fluentui/tokens": {
"version": "1.0.0-alpha.24",
"resolved": "https://registry.npmjs.org/@fluentui/tokens/-/tokens-1.0.0-alpha.24.tgz",
"integrity": "sha512-YrjGQfnxBR+9o8wXyTflON3Ohsv7OrZWYLVRU+7RaiFO1gcu36iDm3WjudlVmYuWRLDO0Gmxotm6RQNhbZuEQQ==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.1"
}
},
"node_modules/@fluentui/web-components": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@fluentui/web-components/-/web-components-3.1.3.tgz",
"integrity": "sha512-1jl4CHoFFSfevkr8l7F+dKNJzyMWBZqwQfB/khNZtwyY0rv87799mpDWSVu0QeiZAFEbqCtnAOnXWsxJ/kek+Q==",
"license": "MIT",
"dependencies": {
"@fluentui/tokens": "^1.0.0-alpha.24",
"tslib": "^2.1.0"
},
"engines": {
"node": "^22.0.0 || ^24.0.0"
},
"peerDependencies": {
"@microsoft/fast-element": "^3.0.0",
"@microsoft/focusgroup-polyfill": "^1.5.0"
}
},
"node_modules/@microsoft/fast-element": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@microsoft/fast-element/-/fast-element-3.1.2.tgz",
"integrity": "sha512-LmyiGq1zjszwgcqSkiOsZph+9NUZJqnmBhuqPDmqndWfyo0bMXKPTevQX9q3nEKCuIIFDMsGdPNGcQ1xoK7sGw==",
"license": "MIT",
"peer": true
},
"node_modules/@microsoft/focusgroup-polyfill": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@microsoft/focusgroup-polyfill/-/focusgroup-polyfill-1.6.0.tgz",
"integrity": "sha512-SlJO8FKrdFdSEXtNTNPTlCeqyqbpyLf6suYHTK7G8WYXZf8/BWK0ucERiPgrUIpNewfQjV82BahSOW/p8YNJ1A==",
"license": "MIT",
"peer": true
},
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "portal-frontend",
"version": "0.1.0",
"private": true,
"description": "Builds the Fluent web components bundle that is copied into the image.",
"type": "module",
"scripts": {
"build": "node build.mjs"
},
"dependencies": {
"@fluentui/web-components": "3.1.3"
},
"devDependencies": {
"esbuild": "0.28.2"
}
}
+109
View File
@@ -0,0 +1,109 @@
# German, formal address (Sie)
msgid ""
msgstr ""
"Project-Id-Version: self-service-portal\n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "No permission"
msgstr "Keine Berechtigung"
msgid "You do not have permission to open this page."
msgstr "Dazu fehlt Ihnen die Berechtigung."
msgid "Back to the portal"
msgstr "Zurück zum Portal"
msgid "Home"
msgstr "Startseite"
#, python-format
msgid "Hello, %(name)s"
msgstr "Guten Tag, %(name)s"
msgid "You are signed in. More pages will follow here."
msgstr "Sie sind angemeldet. Weitere Seiten folgen hier."
msgid "Sign-in not possible"
msgstr "Anmeldung nicht möglich"
msgid "You cannot sign in to the portal with this account. If you think this is a mistake, contact your administrator."
msgstr "Mit diesem Konto können Sie sich nicht am Portal anmelden. Wenn Sie glauben, dass das ein Fehler ist, wenden Sie sich an die Administration."
msgid "Try again"
msgstr "Erneut versuchen"
msgid "Signed out"
msgstr "Abgemeldet"
msgid "You have been signed out"
msgstr "Sie wurden abgemeldet."
msgid "Sign in again"
msgstr "Erneut anmelden"
msgid "Not available"
msgstr "Nicht verfügbar"
msgid "This is not possible right now. Please try again later."
msgstr "Das ist gerade nicht möglich. Bitte versuchen Sie es später erneut."
msgid "Skip to content"
msgstr "Zum Inhalt springen"
msgid "Menu"
msgstr "Menü"
msgid "Profile menu"
msgstr "Profilmenü"
msgid "Main menu"
msgstr "Hauptmenü"
msgid "Theme"
msgstr "Design"
msgid "Language"
msgstr "Sprache"
msgid "Form of address"
msgstr "Anrede"
msgid "Sign out"
msgstr "Abmelden"
msgid "This page is not available yet."
msgstr "Diese Seite folgt."
msgid "Light"
msgstr "Hell"
msgid "Dark"
msgstr "Dunkel"
msgid "System"
msgstr "System"
msgid "Publishing"
msgstr "Veröffentlichen"
msgid "Manage announcements"
msgstr "Ankündigungen verwalten"
msgid "Administration"
msgstr "Administration"
msgid "Appearance"
msgstr "Darstellung"
msgid "Settings"
msgstr "Einstellungen"
msgid "Audit log"
msgstr "Protokoll"
msgid "Status"
msgstr "Status"
+28
View File
@@ -0,0 +1,28 @@
# German, informal address (du); only the differences, the rest falls back to de
msgid ""
msgstr ""
"Project-Id-Version: self-service-portal\n"
"Language: de_DU\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "You do not have permission to open this page."
msgstr "Dazu fehlt dir die Berechtigung."
#, python-format
msgid "Hello, %(name)s"
msgstr "Hallo, %(name)s"
msgid "You are signed in. More pages will follow here."
msgstr "Du bist angemeldet. Weitere Seiten folgen hier."
msgid "You cannot sign in to the portal with this account. If you think this is a mistake, contact your administrator."
msgstr "Mit diesem Konto kannst du dich nicht am Portal anmelden. Wenn du glaubst, dass das ein Fehler ist, wende dich an die Administration."
msgid "You have been signed out"
msgstr "Du wurdest abgemeldet."
msgid "This is not possible right now. Please try again later."
msgstr "Das ist gerade nicht möglich. Bitte versuche es später erneut."
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ShellConfig(AppConfig):
name = "portal.shell"
label = "shell"
+37
View File
@@ -0,0 +1,37 @@
from django.utils.translation import gettext_lazy as _
from . import menu, preferences
THEME_OPTIONS = (
("light", _("Light")),
("dark", _("Dark")),
("system", _("System")),
)
LANGUAGE_OPTIONS = (("de", "Deutsch"), ("en", "English"))
FORM_OPTIONS = (("sie", "Sie"), ("du", "du"))
def initials(name: str) -> str:
words = [w for w in name.split() if w]
if not words:
return "?"
letters = words[0][0] + (words[-1][0] if len(words) > 1 else "")
return letters.upper()
def shell(request):
"""Menu, preferences and the values the base layout needs."""
prefs = getattr(request, "preferences", preferences.Preferences())
portal_user = getattr(request, "portal_user", None)
context = {
"preferences": prefs,
"html_lang": prefs.language,
"theme_options": THEME_OPTIONS,
"language_options": LANGUAGE_OPTIONS,
"form_options": FORM_OPTIONS,
"portal_user": portal_user,
}
if portal_user is not None:
context["menu_groups"] = menu.for_user(portal_user, request.path)
context["initials"] = initials(portal_user.display_name)
return context
+61
View File
@@ -0,0 +1,61 @@
"""The left menu, declared once. An entry is shown only to users who may open its page."""
from __future__ import annotations
from dataclasses import dataclass
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@dataclass(frozen=True)
class Entry:
label: object # lazy translated string
url_name: str
@dataclass(frozen=True)
class Group:
title: object | None
role: str | None # None: everyone; otherwise "publisher" or "admin"
entries: tuple[Entry, ...]
MENU = (
Group(None, None, (Entry(_("Home"), "home"),)),
Group(
_("Publishing"),
"publisher",
(Entry(_("Manage announcements"), "publish-announcements"),),
),
Group(
_("Administration"),
"admin",
(
Entry(_("Appearance"), "admin-appearance"),
Entry(_("Settings"), "admin-settings"),
Entry(_("Audit log"), "admin-audit"),
Entry(_("Status"), "admin-status"),
),
),
)
def _allowed(group: Group, portal_user) -> bool:
if group.role is None:
return True
return bool(getattr(portal_user, f"is_{group.role}", False))
def for_user(portal_user, current_path: str) -> list[dict]:
"""The groups this user sees, each entry with its address and whether it is current."""
groups = []
for group in MENU:
if not _allowed(group, portal_user):
continue
entries = []
for entry in group.entries:
url = reverse(entry.url_name)
entries.append({"label": entry.label, "url": url, "current": url == current_path})
groups.append({"title": group.title, "entries": entries})
return groups
+24
View File
@@ -0,0 +1,24 @@
from django.utils import translation
from django.utils.cache import patch_vary_headers
from . import preferences
class PreferencesMiddleware:
"""Read the preference cookies and activate the language for this request (AD-15)."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
prefs = preferences.from_request(request)
request.preferences = prefs
request.LANGUAGE_CODE = prefs.language_code
translation.activate(prefs.language_code)
try:
response = self.get_response(request)
finally:
translation.deactivate()
response.headers.setdefault("Content-Language", prefs.language)
patch_vary_headers(response, ("Cookie", "Accept-Language"))
return response
+82
View File
@@ -0,0 +1,82 @@
"""Per-device preferences: theme, language and German form of address (AD-15).
They live in cookies and are never stored on the server. Values are checked against fixed
lists, so a forged cookie can only fall back to the default.
"""
from __future__ import annotations
from dataclasses import dataclass
from django.http import HttpRequest, HttpResponse
from django.utils import translation
THEME_COOKIE = "pm_theme"
LANGUAGE_COOKIE = "pm_lang"
FORM_COOKIE = "pm_form"
COOKIE_MAX_AGE = 365 * 24 * 60 * 60
THEMES = ("light", "dark", "system")
LANGUAGES = ("de", "en")
FORMS = ("sie", "du")
DEFAULT_THEME = "system"
DEFAULT_FORM = "sie"
INFORMAL_GERMAN = "de-du" # catalogue with only the informal differences; falls back to "de"
_CHOICES = {
THEME_COOKIE: ("theme", THEMES),
LANGUAGE_COOKIE: ("language", LANGUAGES),
FORM_COOKIE: ("form", FORMS),
}
@dataclass(frozen=True)
class Preferences:
theme: str = DEFAULT_THEME
language: str = "en"
form: str = DEFAULT_FORM
@property
def language_code(self) -> str:
"""The code the translations are activated with."""
if self.language == "de" and self.form == "du":
return INFORMAL_GERMAN
return self.language
def from_request(request: HttpRequest) -> Preferences:
theme = request.COOKIES.get(THEME_COOKIE)
language = request.COOKIES.get(LANGUAGE_COOKIE)
form = request.COOKIES.get(FORM_COOKIE)
if language not in LANGUAGES:
# Default from Accept-Language: German or English, English otherwise.
language = translation.get_language_from_request(request, check_path=False)
language = language if language in LANGUAGES else "en"
return Preferences(
theme=theme if theme in THEMES else DEFAULT_THEME,
language=language,
form=form if form in FORMS else DEFAULT_FORM,
)
def chosen(values) -> dict[str, str]:
"""The cookie name and value for every valid preference in `values` (a POST or a dict)."""
result = {}
for cookie, (field, allowed) in _CHOICES.items():
value = values.get(field)
if value in allowed:
result[cookie] = value
return result
def set_cookies(response: HttpResponse, cookies: dict[str, str], *, secure: bool) -> None:
for name, value in cookies.items():
response.set_cookie(
name,
value,
max_age=COOKIE_MAX_AGE,
secure=secure,
httponly=False, # shell.js applies a change without a reload
samesite="Lax",
)
+12
View File
@@ -0,0 +1,12 @@
from django.urls import path
from . import views
urlpatterns = [
path("preferences", views.set_preferences, name="preferences"),
path("publish/announcements", views.publish_announcements, name="publish-announcements"),
path("administration/appearance", views.admin_appearance, name="admin-appearance"),
path("administration/settings", views.admin_settings, name="admin-settings"),
path("administration/audit", views.admin_audit, name="admin-audit"),
path("administration/status", views.admin_status, name="admin-status"),
]
+37
View File
@@ -0,0 +1,37 @@
from django.conf import settings
from django.http import HttpResponseRedirect
from django.shortcuts import render
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_POST
from portal.accounts.decorators import admin_required, publisher_required
from . import preferences
@require_POST
def set_preferences(request):
"""Store the chosen preferences in cookies (the no-JavaScript path of the profile menu)."""
target = request.POST.get("next", "")
if not (target.startswith("/") and url_has_allowed_host_and_scheme(target, None)):
target = "/"
response = HttpResponseRedirect(target)
preferences.set_cookies(
response, preferences.chosen(request.POST), secure=not settings.DEBUG
)
return response
def _placeholder(title):
def view(request):
return render(request, "shell/placeholder.html", {"title": title})
return view
publish_announcements = publisher_required(_placeholder(_("Manage announcements")))
admin_appearance = admin_required(_placeholder(_("Appearance")))
admin_settings = admin_required(_placeholder(_("Settings")))
admin_audit = admin_required(_placeholder(_("Audit log")))
admin_status = admin_required(_placeholder(_("Status")))
+432
View File
@@ -0,0 +1,432 @@
/* Page shell: design tokens from DESIGN.md, light and dark, wide and phone layout (AD-15). */
:root {
--brand: #0f6cbd;
--on-brand: #fff;
--top-bar: var(--brand);
--on-top-bar: var(--on-brand);
--avatar-bg: #fff;
--avatar-fg: var(--brand);
--bg: #fafafa;
--surface: #fff;
--nav-surface: #f5f5f5;
--text: #242424;
--text-secondary: #616161;
--line: #e0e0e0;
--control-line: #8a8a8a;
--font: "Segoe UI Variable", "Segoe UI", system-ui, sans-serif;
--radius-sm: 4px;
--radius-lg: 8px;
--touch: 44px;
--nav-width: 220px;
--bar-height: 48px;
color-scheme: light;
}
:root[data-theme="dark"] {
--brand: #479ef5;
--on-brand: #0a0a0a;
--top-bar: #0f2a43;
--on-top-bar: #fff;
--avatar-bg: #479ef5;
--avatar-fg: #0a0a0a;
--bg: #1f1f1f;
--surface: #292929;
--nav-surface: #242424;
--text: #fff;
--text-secondary: #c8c8c8;
--line: #3d3d3d;
--control-line: #9a9a9a;
color-scheme: dark;
}
@media (prefers-color-scheme: dark) {
:root[data-theme="system"] {
--brand: #479ef5;
--on-brand: #0a0a0a;
--top-bar: #0f2a43;
--on-top-bar: #fff;
--avatar-bg: #479ef5;
--avatar-fg: #0a0a0a;
--bg: #1f1f1f;
--surface: #292929;
--nav-surface: #242424;
--text: #fff;
--text-secondary: #c8c8c8;
--line: #3d3d3d;
--control-line: #9a9a9a;
color-scheme: dark;
}
}
/* The tokens the Fluent avatar reads, mapped to the portal's own. */
:root {
--colorBrandBackgroundStatic: var(--avatar-bg);
--colorNeutralForegroundStaticInverted: var(--avatar-fg);
--colorNeutralBackground1: var(--surface);
--colorNeutralBackground6: var(--nav-surface);
--colorNeutralForeground3: var(--text-secondary);
--fontFamilyBase: var(--font);
--fontWeightRegular: 400;
--fontWeightSemibold: 600;
--fontSizeBase100: 10px;
--fontSizeBase200: 12px;
--fontSizeBase300: 14px;
--fontSizeBase400: 16px;
--fontSizeBase500: 20px;
--fontSizeBase600: 24px;
--borderRadiusSmall: 2px;
--borderRadiusMedium: 4px;
--borderRadiusLarge: 6px;
--borderRadiusXLarge: 8px;
--borderRadiusCircular: 9999px;
--strokeWidthThin: 1px;
--strokeWidthThick: 2px;
--strokeWidthThicker: 3px;
--strokeWidthThickest: 4px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font: 400 14px/1.45 var(--font);
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
}
a {
color: var(--brand);
}
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.skip-link {
position: absolute;
left: 8px;
top: -100px;
z-index: 20;
padding: 10px 14px;
background: var(--surface);
color: var(--text);
border: 2px solid var(--brand);
border-radius: var(--radius-sm);
}
.skip-link:focus {
top: 8px;
}
/* Top bar */
.top-bar {
position: relative;
z-index: 10;
display: flex;
align-items: center;
gap: 12px;
height: var(--bar-height);
padding: 0 16px;
background: var(--top-bar);
color: var(--on-top-bar);
}
.portal-name {
flex: 1;
color: inherit;
font-weight: 600;
text-decoration: none;
}
.menu-button {
display: none;
width: var(--touch);
height: var(--touch);
margin-left: -10px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: inherit;
cursor: pointer;
place-items: center;
}
.burger,
.burger::before,
.burger::after {
display: block;
width: 18px;
height: 2px;
background: currentcolor;
content: "";
}
.burger {
position: relative;
}
.burger::before {
position: absolute;
top: -6px;
}
.burger::after {
position: absolute;
top: 6px;
}
/* Profile menu */
.profile {
position: relative;
}
.profile summary {
display: grid;
place-items: center;
width: var(--touch);
height: var(--touch);
margin-right: -6px;
list-style: none;
cursor: pointer;
border-radius: 50%;
}
.profile summary::-webkit-details-marker {
display: none;
}
/* The plain initials show until the Fluent avatar is defined. */
.profile fluent-avatar:not(:defined) {
display: none;
}
.profile fluent-avatar:defined + .avatar-fallback {
display: none;
}
.avatar-fallback {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--avatar-bg);
color: var(--avatar-fg);
font-size: 12px;
font-weight: 700;
}
.profile-menu {
position: absolute;
right: 0;
top: calc(100% + 2px);
width: 260px;
max-width: calc(100vw - 24px);
padding: 14px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
box-shadow: 0 6px 20px rgb(0 0 0 / 25%);
}
.profile-name {
margin: 0;
font-weight: 600;
overflow-wrap: anywhere;
}
.profile-user {
margin: 0 0 8px;
font-size: 13px;
color: var(--text-secondary);
overflow-wrap: anywhere;
}
.prefs fieldset {
margin: 0;
padding: 8px 0 0;
border: 0;
border-top: 1px solid var(--line);
}
.prefs legend {
padding: 0;
font-size: 13px;
color: var(--text-secondary);
}
.choices {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 6px 0 8px;
}
.profile-menu button {
min-height: var(--touch);
padding: 0 14px;
border: 1px solid var(--control-line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
font: inherit;
cursor: pointer;
}
.profile-menu button[aria-pressed="true"] {
background: var(--brand);
border-color: var(--brand);
color: var(--on-brand);
font-weight: 600;
}
.sign-out {
margin-top: 4px;
padding-top: 10px;
border-top: 1px solid var(--line);
}
/* Layout */
.layout {
display: flex;
flex: 1;
min-height: 0;
}
#menu {
width: var(--nav-width);
flex: none;
padding: 12px 8px;
background: var(--nav-surface);
border-right: 1px solid var(--line);
}
.menu-heading {
margin: 14px 12px 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);
}
#menu a {
display: flex;
align-items: center;
min-height: 38px;
padding: 0 12px;
margin-bottom: 2px;
border-radius: var(--radius-sm);
color: var(--text);
text-decoration: none;
}
#menu a:hover {
background: var(--surface);
}
#menu a.current {
background: var(--surface);
font-weight: 600;
box-shadow: inset 3px 0 0 var(--brand);
}
main {
flex: 1;
min-width: 0;
padding: 24px 28px;
}
main:focus {
outline: none;
}
h1 {
margin: 0 0 4px;
font-size: 24px;
font-weight: 600;
line-height: 1.25;
}
.lead {
max-width: 640px;
margin: 0 0 18px;
color: var(--text-secondary);
}
main p,
main li {
max-width: 640px;
}
main button {
min-height: var(--touch);
padding: 0 16px;
border: 1px solid var(--control-line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
font: inherit;
cursor: pointer;
}
/* Phone: content only, the menu opens from the menu button. */
@media (width <= 720px) {
main {
padding: 16px;
}
h1 {
font-size: 20px;
}
.layout {
flex-direction: column;
}
#menu {
width: auto;
border-right: 0;
border-bottom: 1px solid var(--line);
}
#menu a {
min-height: var(--touch);
}
/* Without JavaScript the menu stays visible above the content. */
.js .menu-button {
display: grid;
}
.js #menu {
display: none;
}
.js.menu-open #menu {
display: block;
}
}
+73
View File
@@ -0,0 +1,73 @@
// Page shell behaviour: menu button, profile menu and preferences. Everything also works
// without this script (links, a plain form), so it only makes the page quicker.
(function () {
"use strict";
var root = document.documentElement;
root.classList.add("js");
var YEAR = 365 * 24 * 60 * 60;
var COOKIES = { theme: "pm_theme", language: "pm_lang", form: "pm_form" };
var ALLOWED = {
theme: ["light", "dark", "system"],
language: ["de", "en"],
form: ["sie", "du"],
};
function writeCookie(field, value) {
var secure = location.protocol === "https:" ? "; Secure" : "";
document.cookie =
COOKIES[field] + "=" + value + "; Max-Age=" + YEAR + "; Path=/; SameSite=Lax" + secure;
}
var profile = document.querySelector(".profile");
var menuButton = document.querySelector(".menu-button");
document.addEventListener("click", function (event) {
var button = event.target.closest("button[data-pref]");
if (button) {
var field = button.getAttribute("data-pref");
var value = button.value;
if (ALLOWED[field] && ALLOWED[field].indexOf(value) !== -1) {
event.preventDefault();
writeCookie(field, value);
if (field === "theme") {
root.setAttribute("data-theme", value);
button
.closest(".choices")
.querySelectorAll("button")
.forEach(function (other) {
other.setAttribute("aria-pressed", other === button ? "true" : "false");
});
} else {
location.reload(); // language and form change the text, which the server renders
}
}
return;
}
if (profile && profile.open && !profile.contains(event.target)) {
profile.open = false;
}
});
if (menuButton) {
menuButton.addEventListener("click", function () {
var open = root.classList.toggle("menu-open");
menuButton.setAttribute("aria-expanded", open ? "true" : "false");
});
}
document.addEventListener("keydown", function (event) {
if (event.key !== "Escape") {
return;
}
if (profile && profile.open) {
profile.open = false;
profile.querySelector("summary").focus();
} else if (menuButton && root.classList.contains("menu-open")) {
root.classList.remove("menu-open");
menuButton.setAttribute("aria-expanded", "false");
menuButton.focus();
}
});
})();
+1 -13
View File
@@ -1,13 +1 @@
{% load i18n %}<!doctype html>
<html lang="{{ LANGUAGE_CODE }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{% trans "Self-service portal" %}{% endblock %}</title>
</head>
<body>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
{% extends "base.html" %}
+3 -7
View File
@@ -1,11 +1,7 @@
{% extends "accounts/base.html" %}
{% load i18n %}
{% block title %}{% trans "Welcome" %}{% endblock %}
{% block title %}{% trans "Home" %}{% endblock %}
{% block content %}
<h1>{% blocktrans with name=portal_user.display_name %}Signed in as {{ name }}{% endblocktrans %}</h1>
<p>{{ portal_user.user.sam }}</p>
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button type="submit">{% trans "Sign out" %}</button>
</form>
<h1>{% blocktrans with name=portal_user.display_name %}Hello, {{ name }}{% endblocktrans %}</h1>
<p class="lead">{% trans "You are signed in. More pages will follow here." %}</p>
{% endblock %}
+69
View File
@@ -0,0 +1,69 @@
{% load i18n static %}<!doctype html>
<html lang="{{ html_lang }}" data-theme="{{ preferences.theme }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{% block title %}ping-mee Portal{% endblock %}</title>
<link rel="stylesheet" href="{% static 'portal/shell.css' %}">
<script src="{% static 'vendor/fluent.js' %}" defer></script>
<script src="{% static 'portal/shell.js' %}" defer></script>
</head>
<body>
<a class="skip-link" href="#content">{% trans "Skip to content" %}</a>
<header class="top-bar">
{% if portal_user %}
<button type="button" class="menu-button" aria-controls="menu" aria-expanded="false">
<span class="burger" aria-hidden="true"></span>
<span class="visually-hidden">{% trans "Menu" %}</span>
</button>
{% endif %}
<a class="portal-name" href="{% url 'home' %}">ping-mee Portal</a>
{% if portal_user %}
<details class="profile">
<summary aria-label="{% trans 'Profile menu' %}">
<fluent-avatar name="{{ portal_user.display_name }}" color="brand" size="32"></fluent-avatar>
<span class="avatar-fallback" aria-hidden="true">{{ initials }}</span>
</summary>
<div class="profile-menu">
<p class="profile-name">{{ portal_user.display_name }}</p>
<p class="profile-user">{{ portal_user.user.sam }}</p>
<form method="post" action="{% url 'preferences' %}" class="prefs">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
{% trans "Theme" as legend %}
{% include "shell/preference_group.html" with legend=legend field="theme" options=theme_options current=preferences.theme %}
{% trans "Language" as legend %}
{% include "shell/preference_group.html" with legend=legend field="language" options=language_options current=preferences.language %}
{% if preferences.language == "de" %}
{% trans "Form of address" as legend %}
{% include "shell/preference_group.html" with legend=legend field="form" options=form_options current=preferences.form %}
{% endif %}
</form>
<form method="post" action="{% url 'logout' %}" class="sign-out">
{% csrf_token %}
<button type="submit">{% trans "Sign out" %}</button>
</form>
</div>
</details>
{% endif %}
</header>
<div class="layout">
{% if portal_user %}
<nav id="menu" aria-label="{% trans 'Main menu' %}">
{% for group in menu_groups %}
<div class="menu-group">
{% if group.title %}<p class="menu-heading">{{ group.title }}</p>{% endif %}
{% for entry in group.entries %}
<a href="{{ entry.url }}"{% if entry.current %} class="current" aria-current="page"{% endif %}>{{ entry.label }}</a>
{% endfor %}
</div>
{% endfor %}
</nav>
{% endif %}
<main id="content" tabindex="-1">
{% block content %}{% endblock %}
</main>
</div>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<h1>{{ title }}</h1>
<p class="lead">{% trans "This page is not available yet." %}</p>
{% endblock %}
@@ -0,0 +1,8 @@
<fieldset>
<legend>{{ legend }}</legend>
<div class="choices">
{% for value, label in options %}
<button type="submit" name="{{ field }}" value="{{ value }}" data-pref="{{ field }}" aria-pressed="{% if value == current %}true{% else %}false{% endif %}">{{ label }}</button>
{% endfor %}
</div>
</fieldset>
+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)
+1
View File
@@ -16,6 +16,7 @@ dependencies = [
[dependency-groups]
dev = [
"polib>=1.2.0",
"pytest>=8",
"pytest-django>=4.9",
"pytest-env>=1.1",
Generated
+11
View File
@@ -234,6 +234,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
]
[[package]]
name = "polib"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/10/9a/79b1067d27e38ddf84fe7da6ec516f1743f31f752c6122193e7bce38bdbf/polib-1.2.0.tar.gz", hash = "sha256:f3ef94aefed6e183e342a8a269ae1fc4742ba193186ad76f175938621dbfc26b", size = 161658, upload-time = "2023-02-23T17:53:56.873Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/99/45bb1f9926efe370c6dbe324741c749658e44cb060124f28dad201202274/polib-1.2.0-py2.py3-none-any.whl", hash = "sha256:1c77ee1b81feb31df9bca258cbc58db1bbb32d10214b173882452c73af06d62d", size = 20634, upload-time = "2023-02-23T17:53:59.919Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.4"
@@ -389,6 +398,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "polib" },
{ name = "pytest" },
{ name = "pytest-django" },
{ name = "pytest-env" },
@@ -408,6 +418,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "polib", specifier = ">=1.2.0" },
{ name = "pytest", specifier = ">=8" },
{ name = "pytest-django", specifier = ">=4.9" },
{ name = "pytest-env", specifier = ">=1.1" },