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:
@@ -16,3 +16,7 @@ context-resources
|
||||
staticfiles
|
||||
*.sqlite3*
|
||||
skills-lock.json
|
||||
**/node_modules
|
||||
portal/frontend/dist
|
||||
portal/static/vendor
|
||||
**/*.mo
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+104
@@ -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.
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ from .health import healthz
|
||||
urlpatterns = [
|
||||
path("healthz", healthz, name="healthz"),
|
||||
path("", include("portal.accounts.urls")),
|
||||
path("", include("portal.shell.urls")),
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
// Only the components the shell uses; each import registers its custom element.
|
||||
import "@fluentui/web-components/avatar/define.js";
|
||||
Generated
+557
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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."
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ShellConfig(AppConfig):
|
||||
name = "portal.shell"
|
||||
label = "shell"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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")))
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 +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" %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -16,6 +16,7 @@ dependencies = [
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"polib>=1.2.0",
|
||||
"pytest>=8",
|
||||
"pytest-django>=4.9",
|
||||
"pytest-env>=1.1",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user