feat: add password change

Password page with the AD policy listed first, ordered service (throttle limit below the lockout threshold, verification, local hints, directory change, ended sessions, audit and confirmation mail in one transaction), plain-language errors, German text and tests.

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-18 10:08:17 +02:00
co-authored by Claude Sonnet 5.5
parent a1a766adef
commit 49c7fd4a0b
22 changed files with 1346 additions and 17 deletions
+61 -15
View File
@@ -1,5 +1,6 @@
"""Every user-visible string has German text (AD-15); the informal catalogue only differs."""
import ast
import re
from pathlib import Path
@@ -12,29 +13,65 @@ 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*(?:"([^"]+)"|'([^']+)')""")
SINGLE = {"_", "gettext", "gettext_lazy"}
PLURAL = {"ngettext", "ngettext_lazy"}
def msgids_in_code() -> set[str]:
found = set()
def _call_name(node: ast.Call) -> str:
func = node.func
return func.id if isinstance(func, ast.Name) else getattr(func, "attr", "")
def _strings(node: ast.Call, count: int) -> tuple[str, ...] | None:
args = node.args[:count]
if len(args) == count and all(
isinstance(a, ast.Constant) and isinstance(a.value, str) for a in args
):
return tuple(a.value for a in args)
return None
def messages_in_code() -> set[tuple[str, str | None]]:
"""(msgid, msgid_plural) for every translatable string in templates and Python code."""
found: set[tuple[str, str | None]] = set()
templates = [*(PORTAL / "templates").rglob("*.html"), *(PORTAL / "templates").rglob("*.txt")]
for template in templates:
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))
found.update((a or b, None) for a, b in TRANS_TAG.findall(text))
found.update(
(VARIABLE.sub(r"%(\1)s", body).strip(), None) 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")))
for node in ast.walk(ast.parse(source.read_text(encoding="utf-8"))):
if not isinstance(node, ast.Call):
continue
name = _call_name(node)
if name in SINGLE and (strings := _strings(node, 1)):
found.add((strings[0], None))
elif name in PLURAL and (strings := _strings(node, 2)):
found.add((strings[0], strings[1]))
return found
def msgids_in_code() -> set[str]:
return {msgid for msgid, _ in messages_in_code()}
def catalogue(name: str) -> polib.POFile:
return polib.pofile(str(LOCALE / name / "LC_MESSAGES" / "django.po"))
def texts(entry: polib.POEntry) -> list[str]:
"""Every translated text of an entry: one, or one per plural form."""
return list(entry.msgstr_plural.values()) if entry.msgid_plural else [entry.msgstr]
def test_the_scan_finds_the_strings():
messages = messages_in_code()
assert {"Sign out", "Home", "Hello, %(name)s", "Audit log"} <= msgids_in_code()
assert ("At least %(n)s character", "At least %(n)s characters") in messages
def test_every_string_in_the_code_has_german_text():
@@ -42,33 +79,42 @@ def test_every_string_in_the_code_has_german_text():
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 all(t.strip() for t in texts(entry)), f"empty German text for {entry.msgid!r}"
assert "fuzzy" not in entry.flags, f"fuzzy entry {entry.msgid!r}"
def test_plural_strings_are_plural_entries_with_both_forms():
german = {e.msgid: e for e in catalogue("de")}
for msgid, plural in messages_in_code():
if plural:
entry = german[msgid]
assert entry.msgid_plural == plural and len(entry.msgstr_plural) == 2, msgid
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")}
german = {e.msgid: texts(e) 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"
assert all(t.strip() for t in texts(entry)) and "fuzzy" not in entry.flags
assert texts(entry) != german[entry.msgid], f"{entry.msgid!r} does not differ"
@pytest.mark.parametrize("name", ["de", "de_DU"])
def test_placeholders_survive_translation(name):
pattern = re.compile(r"%\(\w+\)s")
for entry in catalogue(name):
assert set(re.findall(r"%\(\w+\)s", entry.msgid)) == set(
re.findall(r"%\(\w+\)s", entry.msgstr)
), entry.msgid
wanted = set(pattern.findall(entry.msgid))
for text in texts(entry):
assert set(pattern.findall(text)) == wanted, 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"))
formal = " ".join(t for e in catalogue("de") for t in texts(e))
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"))
informal = " ".join(t for e in catalogue("de_DU") for t in texts(e))
assert not re.search(r"\b(Sie|Ihnen|Ihr|Ihre)\b", informal)