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:
co-authored by
Claude Sonnet 5.5
parent
ae4b30ec22
commit
3fd8f27127
+149
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user