feat: add profile edit

Profile page at /profile with first name, last name, display name, telephone and mobile read from AD and shown with the username and e-mail. Only changed fields are written through the directory adapter in one transaction with the audit entry (field names only); numbers are accepted in any common notation and stored as +country code and digits; names are required. Plain-language errors per field, German text and tests; review findings logged in the plan.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FocPJ75XPma7TcuYEMy8zm
This commit is contained in:
Ebbe Baß
2026-10-19 09:03:24 +02:00
co-authored by Claude Sonnet 5.5
parent ae4b30ec22
commit 3fd8f27127
14 changed files with 1055 additions and 5 deletions
+149 -3
View File
@@ -1,4 +1,4 @@
"""Password change for the signed-in user (FR-5, FR-6, FR-7; AD-3, AD-4, AD-8).
"""Password change and profile details of the signed-in user (FR-5..FR-9; AD-3, AD-4, AD-8).
The order is fixed: throttle check, policy read, verification of the current password, local
hints, then the directory change, the ended sessions, the audit entry and the queued mail in
@@ -10,7 +10,7 @@ from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from dataclasses import dataclass, replace
from datetime import timedelta
from zoneinfo import ZoneInfo
@@ -22,11 +22,20 @@ from django.utils.translation import ngettext
from portal.accounts import throttle
from portal.accounts.sessions import SessionUser, end_sessions
from portal.adapters.directory import DirectoryUnavailable, PasswordPolicy, PasswordRejected
from portal.adapters.directory import (
DirectoryUnavailable,
DirectoryUser,
Forbidden,
PasswordPolicy,
PasswordRejected,
ValidationFailed,
)
from portal.adapters.registry import get_directory
from portal.audit import services as audit
from portal.mailer import services as mailer
from .forms import DETAIL_FIELDS, check_details
logger = logging.getLogger(__name__)
ENDPOINT = "password-change"
@@ -309,3 +318,140 @@ def _queue_mail(account) -> bool:
{"name": name or account.sam, "changed_at": now, "zone_name": settings.DISPLAY_TIME_ZONE},
)
return True
# -- profile details (FR-9) -------------------------------------------------------------------
# Form field -> (directory attribute, DirectoryUser field). Only these five are ever written.
DETAIL_ATTRIBUTES = {
"first_name": ("givenName", "given_name"),
"last_name": ("sn", "surname"),
"display_name": ("displayName", "display_name"),
"telephone": ("telephoneNumber", "telephone"),
"mobile": ("mobile", "mobile"),
}
class DetailsInvalid(Exception):
"""Values were refused before anything was written; `errors` maps a field to its message."""
def __init__(self, errors: dict[str, str], details: Details) -> None:
self.errors = errors
self.details = details # what the one directory read held, for the page
super().__init__("The details are not valid")
class DetailsNotAccepted(Exception):
"""The directory refused a value."""
def __init__(self, details: Details) -> None:
self.details = details
super().__init__("The directory refused a value")
@dataclass(frozen=True)
class Details:
username: str
mail: str
values: dict[str, str]
@dataclass(frozen=True)
class DetailsUpdated:
changed: tuple[str, ...] # form field names, in page order
account: DirectoryUser # the user as the directory now holds them
def _values_of(account: DirectoryUser) -> dict[str, str]:
return {name: getattr(account, attr) or "" for name, (_attr, attr) in DETAIL_ATTRIBUTES.items()}
def _read(user: SessionUser) -> DirectoryUser:
account = get_directory().find_user(user.sam)
if account is None:
raise DirectoryUnavailable("The account could not be read")
return account
def load_details(user: SessionUser) -> Details:
"""What the directory holds about the signed-in user. Raises DirectoryUnavailable."""
account = _read(user)
return Details(username=account.sam, mail=account.mail or "", values=_values_of(account))
def _record_details_failure(user: SessionUser, ip: str, reason: str, **detail) -> None:
audit.record_failure(
user.key, "profile.update_failed", ip=ip, detail={"reason": reason, **detail}
)
def update_details(user: SessionUser, values: dict[str, str], *, ip: str) -> DetailsUpdated:
"""Save the changed details of the signed-in user.
Reads the directory afresh, checks the values that differ and writes only those, with the
audit entry in the same transaction (AD-4). Raises DetailsInvalid, DetailsNotAccepted,
DirectoryUnavailable or OutcomeUnknown. Must be called outside an open transaction.
"""
directory = get_directory()
try:
account = _read(user)
except DirectoryUnavailable:
_record_details_failure(user, ip, "unavailable")
raise
current = _values_of(account)
read = Details(username=account.sam, mail=account.mail or "", values=current)
cleaned, errors = check_details(values, current)
if errors:
raise DetailsInvalid(errors, read)
changed = tuple(name for name in DETAIL_FIELDS if cleaned[name] != current[name])
if not changed:
return DetailsUpdated((), account)
changes = {DETAIL_ATTRIBUTES[name][0]: cleaned[name] for name in changed}
updated = replace(account, **{DETAIL_ATTRIBUTES[name][1]: cleaned[name] for name in changed})
changed_in_directory = False
step = "directory"
try:
with transaction.atomic():
directory.update_attributes(user.sam, changes)
changed_in_directory = True
step = "audit"
audit.record(user.key, "profile.updated", ip=ip, detail={"fields": list(changed)})
step = "commit"
except ValidationFailed as exc:
_record_details_failure(user, ip, "refused")
raise DetailsNotAccepted(read) from exc
except DirectoryUnavailable:
_record_details_failure(user, ip, "unavailable")
raise
except Forbidden as exc: # the service account lacks the right: the user sees "not possible"
logger.error(
"The directory refused a profile write: user=%s ip=%s fields=%s",
user.key,
ip,
",".join(changed),
)
_record_details_failure(user, ip, "forbidden")
raise DirectoryUnavailable("The directory refused the write") from exc
except Exception as exc:
if changed_in_directory:
# AD-4: the directory changed but the portal's part did not commit.
logger.error(
"Profile details changed in the directory but the portal did not record them: "
"user=%s ip=%s time=%s fields=%s step=%s error=%s",
user.key,
ip,
timezone.now().isoformat(),
",".join(changed),
step,
type(exc).__name__,
)
try:
_record_details_failure(user, ip, "unclear", step=step, fields=list(changed))
except Exception:
logger.error("The failure entry for %s could not be written", user.key)
raise OutcomeUnknown("The details were changed but not recorded") from exc
_record_details_failure(user, ip, "error")
raise
return DetailsUpdated(changed, updated)