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)