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
166 lines
5.6 KiB
Python
166 lines
5.6 KiB
Python
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
|
|
|
|
|
|
def _password_field():
|
|
return forms.CharField(
|
|
max_length=MAX_PASSWORD_LENGTH,
|
|
strip=False, # spaces can be part of a password
|
|
widget=forms.PasswordInput(render_value=False),
|
|
error_messages={
|
|
"required": _("Please fill in this field."),
|
|
"max_length": _("This password is too long."),
|
|
},
|
|
)
|
|
|
|
|
|
class PasswordChangeForm(forms.Form):
|
|
current_password = _password_field()
|
|
new_password = _password_field()
|
|
confirm_password = _password_field()
|
|
|
|
def clean(self):
|
|
data = super().clean()
|
|
current, new, confirm = (
|
|
data.get("current_password"),
|
|
data.get("new_password"),
|
|
data.get("confirm_password"),
|
|
)
|
|
if new and confirm and new != confirm:
|
|
self.add_error("confirm_password", _("The two new passwords are not the same."))
|
|
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)
|