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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user