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
+130
View File
@@ -1,4 +1,8 @@
import re
import unicodedata
from django import forms
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
MAX_PASSWORD_LENGTH = 256 # Active Directory's limit
@@ -33,3 +37,129 @@ class PasswordChangeForm(forms.Form):
elif new and current and new == current:
self.add_error("new_password", _("The new password must be different."))
return data
# -- profile details --------------------------------------------------------------------------
# Form field name -> (label, maximum length (AD's limit), required)
DETAIL_FIELDS = {
"first_name": (_("First name"), 64, True),
"last_name": (_("Last name"), 64, True),
"display_name": (_("Display name"), 256, True),
"telephone": (_("Telephone"), 64, False),
"mobile": (_("Mobile"), 64, False),
}
PHONE_FIELDS = ("telephone", "mobile")
PHONE_MIN_DIGITS = 7
PHONE_MAX_DIGITS = 15
DEFAULT_COUNTRY_CODE = "+49" # a leading 0 is read as Germany
_PHONE_SEPARATORS = re.compile(r"[ \-/.()]")
_PHONE_SHAPE = re.compile(r"\+?[0-9]+")
class InvalidNumber(ValueError):
"""The text cannot be read as a telephone number."""
def normalize_phone(text: str) -> str:
"""The one stored form of a telephone number: `+` and digits only.
Spaces, `-`, `/`, `.` and brackets are removed and a `(0)` after a country code is dropped.
`00` becomes `+`, a single leading `0` becomes `+49`. Anything else raises InvalidNumber.
The result is its own normal form, so normalizing twice changes nothing.
"""
text = text.strip()
if text.startswith(("+", "00")):
text = text.replace("(0)", "")
text = _PHONE_SEPARATORS.sub("", text)
if not _PHONE_SHAPE.fullmatch(text):
raise InvalidNumber(text)
if text.startswith("00"):
text = "+" + text[2:]
elif text.startswith("0"):
text = DEFAULT_COUNTRY_CODE + text[1:]
elif not text.startswith("+"):
raise InvalidNumber(text)
if not PHONE_MIN_DIGITS <= len(text) - 1 <= PHONE_MAX_DIGITS:
raise InvalidNumber(text)
return text
def _has_control_character(text: str) -> bool:
return any(unicodedata.category(ch) in ("Cc", "Zl", "Zp") for ch in text)
def _check_one(name: str, value: str) -> str:
"""The cleaned value of a changed field, or a ValueError carrying the message."""
label, max_length, required = DETAIL_FIELDS[name]
if not value:
if required:
raise ValueError(gettext("%(field)s is required.") % {"field": label})
return ""
if _has_control_character(value):
raise ValueError(
gettext("%(field)s must not contain line breaks or control characters.")
% {"field": label}
)
if len(value) > max_length:
raise ValueError(
gettext("%(field)s must not be longer than %(max)s characters.")
% {"field": label, "max": max_length}
)
if name in PHONE_FIELDS:
try:
return normalize_phone(value)
except InvalidNumber:
raise ValueError(
gettext(
"%(field)s must be a number that starts with + and the country code, or with 0."
)
% {"field": label}
) from None
return value
def check_details(
values: dict[str, str], current: dict[str, str]
) -> tuple[dict[str, str], dict[str, str]]:
"""Check submitted values against what the directory holds now.
Returns (cleaned, errors). A field whose trimmed text equals the directory value is neither
checked nor changed, so one old value in the directory never blocks saving another field.
A number written in another way but equal once normalized is unchanged too.
"""
cleaned: dict[str, str] = {}
errors: dict[str, str] = {}
for name in DETAIL_FIELDS:
value = (values.get(name) or "").strip()
existing = current.get(name) or ""
if value == existing.strip():
cleaned[name] = existing
continue
try:
result = _check_one(name, value)
except ValueError as exc:
errors[name] = str(exc)
continue
cleaned[name] = existing if result == existing.strip() else result
return cleaned, errors
class _TextField(forms.CharField):
"""Trims only: the checks need the directory value and live in `check_details`."""
def run_validators(self, value):
pass
class DetailsForm(forms.Form):
first_name = _TextField(required=False, strip=True)
last_name = _TextField(required=False, strip=True)
display_name = _TextField(required=False, strip=True)
telephone = _TextField(required=False, strip=True)
mobile = _TextField(required=False, strip=True)
def apply_errors(self, errors: dict[str, str]) -> None:
for name, message in errors.items():
self.add_error(name, message)