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
@@ -247,3 +247,70 @@ msgstr "Wenn Sie das waren, müssen Sie nichts tun. Wenn nicht, wenden Sie sich
|
||||
|
||||
msgid "This e-mail never contains your password."
|
||||
msgstr "Diese E-Mail enthält niemals Ihr Passwort."
|
||||
|
||||
msgid "Profile"
|
||||
msgstr "Profil"
|
||||
|
||||
msgid "First name"
|
||||
msgstr "Vorname"
|
||||
|
||||
msgid "Last name"
|
||||
msgstr "Nachname"
|
||||
|
||||
msgid "Display name"
|
||||
msgstr "Anzeigename"
|
||||
|
||||
msgid "Telephone"
|
||||
msgstr "Telefon"
|
||||
|
||||
msgid "Mobile"
|
||||
msgstr "Mobil"
|
||||
|
||||
#, python-format
|
||||
msgid "%(field)s is required."
|
||||
msgstr "%(field)s ist erforderlich."
|
||||
|
||||
#, python-format
|
||||
msgid "%(field)s must not contain line breaks or control characters."
|
||||
msgstr "%(field)s darf keine Zeilenumbrüche oder Steuerzeichen enthalten."
|
||||
|
||||
#, python-format
|
||||
msgid "%(field)s must not be longer than %(max)s characters."
|
||||
msgstr "%(field)s darf nicht länger als %(max)s Zeichen sein."
|
||||
|
||||
#, python-format
|
||||
msgid "%(field)s must be a number that starts with + and the country code, or with 0."
|
||||
msgstr "%(field)s muss eine Nummer sein, die mit + und der Landesvorwahl oder mit 0 beginnt."
|
||||
|
||||
msgid "These details are stored in your account. Changes apply to every service that uses it."
|
||||
msgstr "Diese Angaben sind in Ihrem Konto gespeichert. Änderungen gelten für jeden Dienst, der es verwendet."
|
||||
|
||||
msgid "Your changes were saved."
|
||||
msgstr "Ihre Änderungen wurden gespeichert."
|
||||
|
||||
msgid "Nothing to change."
|
||||
msgstr "Es gibt nichts zu ändern."
|
||||
|
||||
msgid "Your changes were not saved."
|
||||
msgstr "Ihre Änderungen wurden nicht gespeichert."
|
||||
|
||||
msgid "User name"
|
||||
msgstr "Benutzername"
|
||||
|
||||
msgid "E-mail address"
|
||||
msgstr "E-Mail-Adresse"
|
||||
|
||||
msgid "For example +49 171 123456. A number that starts with 0 is read as German."
|
||||
msgstr "Zum Beispiel +49 171 123456. Eine Nummer, die mit 0 beginnt, gilt als deutsche Nummer."
|
||||
|
||||
msgid "Save changes"
|
||||
msgstr "Änderungen speichern"
|
||||
|
||||
msgid "A value was not accepted. Please check your entries."
|
||||
msgstr "Ein Wert wurde nicht angenommen. Bitte prüfen Sie Ihre Eingaben."
|
||||
|
||||
msgid "Your details may have been changed, but the portal could not finish. Please open your profile and check them. If they are wrong, contact the administrator."
|
||||
msgstr "Ihre Angaben wurden möglicherweise geändert, aber das Portal konnte nicht abschließen. Bitte öffnen Sie Ihr Profil und prüfen Sie sie. Wenn sie falsch sind, wenden Sie sich an die Administration."
|
||||
|
||||
msgid "Back to the profile"
|
||||
msgstr "Zurück zum Profil"
|
||||
|
||||
@@ -68,3 +68,18 @@ msgstr "Eine Bestätigung wurde an deine E-Mail-Adresse gesendet."
|
||||
|
||||
msgid "Your password may have been changed, but the portal could not finish. Try to sign in with the new password. If that does not work, contact the administrator."
|
||||
msgstr "Dein Passwort wurde möglicherweise geändert, aber das Portal konnte den Vorgang nicht abschließen. Versuche, dich mit dem neuen Passwort anzumelden. Wenn das nicht klappt, wende dich an die Administration."
|
||||
|
||||
msgid "These details are stored in your account. Changes apply to every service that uses it."
|
||||
msgstr "Diese Angaben sind in deinem Konto gespeichert. Änderungen gelten für jeden Dienst, der es verwendet."
|
||||
|
||||
msgid "Your changes were saved."
|
||||
msgstr "Deine Änderungen wurden gespeichert."
|
||||
|
||||
msgid "Your changes were not saved."
|
||||
msgstr "Deine Änderungen wurden nicht gespeichert."
|
||||
|
||||
msgid "A value was not accepted. Please check your entries."
|
||||
msgstr "Ein Wert wurde nicht angenommen. Bitte prüfe deine Eingaben."
|
||||
|
||||
msgid "Your details may have been changed, but the portal could not finish. Please open your profile and check them. If they are wrong, contact the administrator."
|
||||
msgstr "Deine Angaben wurden möglicherweise geändert, aber das Portal konnte nicht abschließen. Bitte öffne dein Profil und prüfe sie. Wenn sie falsch sind, wende dich an die Administration."
|
||||
|
||||
@@ -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)
|
||||
|
||||
+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)
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("profile", views.profile, name="profile"),
|
||||
path("password", views.password, name="password"),
|
||||
path("password/done", views.password_done, name="password-done"),
|
||||
]
|
||||
|
||||
+53
-1
@@ -10,12 +10,13 @@ from django.views.decorators.debug import sensitive_post_parameters
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from portal.accounts.decorators import login_required
|
||||
from portal.accounts.identity import remember
|
||||
from portal.accounts.throttle import Throttled
|
||||
from portal.adapters.directory import DirectoryUnavailable
|
||||
from portal.adapters.registry import get_directory
|
||||
|
||||
from . import services
|
||||
from .forms import PasswordChangeForm
|
||||
from .forms import DETAIL_FIELDS, DetailsForm, PasswordChangeForm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -107,3 +108,54 @@ def password_done(request):
|
||||
if flag is None: # opened directly: nothing to report
|
||||
return redirect("password")
|
||||
return render(request, "profile/password_done.html", {"mail": flag["mail"]})
|
||||
|
||||
|
||||
# -- profile details (FR-9) -------------------------------------------------------------------
|
||||
|
||||
NOTICE_FLAG = "profile_notice" # "saved" or "unchanged", shown once after the redirect
|
||||
|
||||
|
||||
def _details_page(request, form, details, *, status=200, notice=None):
|
||||
context = {
|
||||
"form": form,
|
||||
"details": details,
|
||||
"notice": notice,
|
||||
"fields": [
|
||||
{"field": form[name], "label": label, "max_length": max_length}
|
||||
for name, (label, max_length, _required) in DETAIL_FIELDS.items()
|
||||
],
|
||||
}
|
||||
return render(request, "profile/details.html", context, status=status)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def profile(request):
|
||||
user = request.portal_user.user # the session user only: nothing is read from the request
|
||||
if request.method == "GET":
|
||||
try:
|
||||
details = services.load_details(user)
|
||||
except DirectoryUnavailable:
|
||||
return _unavailable(request)
|
||||
notice = request.session.pop(NOTICE_FLAG, None)
|
||||
return _details_page(request, DetailsForm(initial=details.values), details, notice=notice)
|
||||
|
||||
form = DetailsForm(request.POST)
|
||||
form.is_valid() # trims; the checks need the directory value and run in the service
|
||||
try:
|
||||
result = services.update_details(user, form.cleaned_data, ip=request.client_ip)
|
||||
except services.DetailsInvalid as exc:
|
||||
form.apply_errors(exc.errors)
|
||||
return _details_page(request, form, exc.details, status=400)
|
||||
except services.DetailsNotAccepted as exc:
|
||||
form.add_error(None, _("A value was not accepted. Please check your entries."))
|
||||
return _details_page(request, form, exc.details, status=400)
|
||||
except DirectoryUnavailable:
|
||||
return _unavailable(request)
|
||||
except services.OutcomeUnknown:
|
||||
return render(request, "profile/details_unknown.html", status=500)
|
||||
|
||||
if result.changed:
|
||||
remember(request, result.account) # this session shows the new name at once
|
||||
request.session[NOTICE_FLAG] = "saved" if result.changed else "unchanged"
|
||||
return redirect("profile")
|
||||
|
||||
@@ -22,7 +22,15 @@ class Group:
|
||||
|
||||
|
||||
MENU = (
|
||||
Group(None, None, (Entry(_("Home"), "home"), Entry(_("Change password"), "password"))),
|
||||
Group(
|
||||
None,
|
||||
None,
|
||||
(
|
||||
Entry(_("Home"), "home"),
|
||||
Entry(_("Profile"), "profile"),
|
||||
Entry(_("Change password"), "password"),
|
||||
),
|
||||
),
|
||||
Group(
|
||||
_("Publishing"),
|
||||
"publisher",
|
||||
|
||||
@@ -578,3 +578,17 @@ main button.primary:disabled {
|
||||
animation-duration: 3s;
|
||||
}
|
||||
}
|
||||
|
||||
/* Read-only label/value rows on the profile page; they stack on narrow screens. */
|
||||
.details {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.5rem 1.5rem;
|
||||
margin-block: 0 1rem;
|
||||
}
|
||||
.details dt { font-weight: 600; }
|
||||
.details dd { margin: 0; overflow-wrap: anywhere; }
|
||||
@media (max-width: 720px) {
|
||||
.details { grid-template-columns: 1fr; gap: 0; }
|
||||
.details dd { margin-bottom: 0.75rem; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{% load i18n %}
|
||||
<div class="field{% if field.errors %} has-error{% endif %}">
|
||||
<label for="{{ field.id_for_label }}">{{ label }}</label>
|
||||
<input type="{{ input_type|default:'text' }}" name="{{ field.html_name }}" id="{{ field.id_for_label }}" value="{{ field.value|default_if_none:'' }}" autocomplete="{{ autocomplete }}" maxlength="{{ max_length }}"{% if required %} required{% endif %}{% if field.errors %} aria-invalid="true"{% endif %} aria-describedby="{{ field.id_for_label }}-hint{% if field.errors %} {{ field.id_for_label }}-error{% endif %}">
|
||||
{% if hint %}<p class="hint" id="{{ field.id_for_label }}-hint">{{ hint }}</p>{% else %}<span id="{{ field.id_for_label }}-hint"></span>{% endif %}
|
||||
{% if field.errors %}<div id="{{ field.id_for_label }}-error">{% for error in field.errors %}<p class="error">{{ error }}</p>{% endfor %}</div>{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Profile" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Profile" %}</h1>
|
||||
<p class="lead">{% trans "These details are stored in your account. Changes apply to every service that uses it." %}</p>
|
||||
{% if notice == "saved" %}
|
||||
<div class="notice success" role="status">
|
||||
<p>{% trans "Your changes were saved." %}</p>
|
||||
</div>
|
||||
{% elif notice == "unchanged" %}
|
||||
<div class="notice" role="status">
|
||||
<p>{% trans "Nothing to change." %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.errors %}
|
||||
<div class="error-summary" role="alert" tabindex="-1" data-focus-on-load>
|
||||
<p><strong>{% trans "Your changes were not saved." %}</strong></p>
|
||||
<ul>
|
||||
{% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
|
||||
{% for field in form %}{% for error in field.errors %}
|
||||
<li><a href="#{{ field.id_for_label }}">{{ error }}</a></li>
|
||||
{% endfor %}{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if details %}
|
||||
<dl class="card details">
|
||||
<dt>{% trans "User name" %}</dt>
|
||||
<dd>{{ details.username }}</dd>
|
||||
<dt>{% trans "E-mail address" %}</dt>
|
||||
<dd>{% if details.mail %}{{ details.mail }}{% else %}–{% endif %}</dd>
|
||||
</dl>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'profile' %}" class="card" data-busy-on-submit novalidate>
|
||||
{% csrf_token %}
|
||||
{% trans "For example +49 171 123456. A number that starts with 0 is read as German." as phone_hint %}
|
||||
{% for item in fields %}
|
||||
{% if item.field.name == "first_name" %}
|
||||
{% include "profile/_text_field.html" with field=item.field label=item.label max_length=item.max_length autocomplete="given-name" required=True %}
|
||||
{% elif item.field.name == "last_name" %}
|
||||
{% include "profile/_text_field.html" with field=item.field label=item.label max_length=item.max_length autocomplete="family-name" required=True %}
|
||||
{% elif item.field.name == "display_name" %}
|
||||
{% include "profile/_text_field.html" with field=item.field label=item.label max_length=item.max_length autocomplete="name" required=True %}
|
||||
{% else %}
|
||||
{% include "profile/_text_field.html" with field=item.field label=item.label max_length=item.max_length autocomplete="tel" input_type="tel" hint=phone_hint %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<p class="actions">
|
||||
<button type="submit" class="primary"><span class="spinner" aria-hidden="true"></span>{% trans "Save changes" %}</button>
|
||||
<a class="button" href="{% url 'home' %}">{% trans "Cancel" %}</a>
|
||||
</p>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Result unclear" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Result unclear" %}</h1>
|
||||
<div class="notice" role="alert">
|
||||
<p>{% trans "Your details may have been changed, but the portal could not finish. Please open your profile and check them. If they are wrong, contact the administrator." %}</p>
|
||||
</div>
|
||||
<p><a class="button" href="{% url 'profile' %}">{% trans "Back to the profile" %}</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Profile edit: the page, the phone format, the diff against AD, audit and errors (FR-9)."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from django.test import Client
|
||||
|
||||
from portal.adapters.directory import DirectoryUnavailable, Forbidden, ValidationFailed
|
||||
from portal.audit import services as audit_services
|
||||
from portal.audit.models import AuditEntry
|
||||
from portal.profile.forms import InvalidNumber, normalize_phone
|
||||
from portal.tests.helpers import sign_in
|
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
VALID = {
|
||||
"first_name": "Alice",
|
||||
"last_name": "Anders",
|
||||
"display_name": "Alice Anders",
|
||||
"telephone": "+49301234567",
|
||||
"mobile": "intern 23",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def people(adapters):
|
||||
d = adapters.directory
|
||||
d.add_user(
|
||||
"alice",
|
||||
given_name="Alice",
|
||||
surname="Anders",
|
||||
display_name="Alice Anders",
|
||||
mail="alice@example.test",
|
||||
telephone="+49301234567",
|
||||
mobile="intern 23", # an old value that is not a number
|
||||
)
|
||||
d.add_user("bob", given_name="Bob", surname="Brown", display_name="Bob Brown")
|
||||
return d
|
||||
|
||||
|
||||
def signed_in(adapters, name="alice"):
|
||||
client, _ = sign_in(adapters, name)
|
||||
return client
|
||||
|
||||
|
||||
def save(client, **changes):
|
||||
return client.post("/profile", {**VALID, **changes})
|
||||
|
||||
|
||||
def profile_entries():
|
||||
return AuditEntry.objects.filter(action__startswith="profile.")
|
||||
|
||||
|
||||
def audit_actions():
|
||||
return list(profile_entries().values_list("action", flat=True))
|
||||
|
||||
|
||||
def stored(adapters, sam="alice"):
|
||||
return adapters.directory.find_user(sam)
|
||||
|
||||
|
||||
class Spy:
|
||||
def __init__(self, directory):
|
||||
self.writes = []
|
||||
original = directory.update_attributes
|
||||
|
||||
def wrapper(sam, changes):
|
||||
self.writes.append((sam, dict(changes)))
|
||||
return original(sam, changes)
|
||||
|
||||
directory.update_attributes = wrapper
|
||||
|
||||
|
||||
# -- the page ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_page_shows_the_ad_values_and_plain_text_for_username_and_mail(people, adapters):
|
||||
html = signed_in(adapters).get("/profile").content.decode()
|
||||
for value in ("Alice", "Anders", "Alice Anders", "+49301234567", "intern 23"):
|
||||
assert f'value="{value}"' in html
|
||||
assert "alice@example.test" in html
|
||||
assert 'name="username"' not in html and 'name="mail"' not in html
|
||||
assert re.search(r'<a href="/profile" class="current" aria-current="page">', html)
|
||||
|
||||
|
||||
def test_the_page_speaks_german_in_both_registers(people, adapters):
|
||||
client = signed_in(adapters)
|
||||
client.cookies["pm_lang"] = "de"
|
||||
assert "Diese Angaben sind in Ihrem Konto" in client.get("/profile").content.decode()
|
||||
client.cookies["pm_form"] = "du"
|
||||
assert "in deinem Konto" in client.get("/profile").content.decode()
|
||||
|
||||
|
||||
def test_the_page_is_unavailable_when_the_directory_is_down(people, adapters):
|
||||
client = signed_in(adapters)
|
||||
adapters.directory.unavailable = True
|
||||
response = client.get("/profile")
|
||||
assert response.status_code == 503 and b'name="first_name"' not in response.content
|
||||
|
||||
|
||||
def test_access_rules(people, adapters):
|
||||
assert Client().get("/profile").status_code == 302
|
||||
assert Client().post("/profile", VALID).status_code == 302
|
||||
assert signed_in(adapters).put("/profile").status_code == 405
|
||||
client = signed_in(adapters)
|
||||
strict = Client(enforce_csrf_checks=True)
|
||||
strict.cookies = client.cookies
|
||||
assert strict.post("/profile", {**VALID, "first_name": "Eve"}).status_code == 403
|
||||
assert stored(adapters).given_name == "Alice"
|
||||
|
||||
|
||||
# -- saving ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_only_the_changed_fields_are_written_and_audited_without_values(people, adapters):
|
||||
spy = Spy(adapters.directory)
|
||||
client = signed_in(adapters)
|
||||
response = save(client, first_name="Alicia", mobile="0171 / 123456")
|
||||
assert response.status_code == 302 and response["Location"] == "/profile"
|
||||
assert spy.writes == [("alice", {"givenName": "Alicia", "mobile": "+49171123456"})]
|
||||
account = stored(adapters)
|
||||
assert (account.given_name, account.mobile) == ("Alicia", "+49171123456")
|
||||
assert (account.surname, account.telephone, account.mail) == (
|
||||
"Anders",
|
||||
"+49301234567",
|
||||
"alice@example.test",
|
||||
)
|
||||
entry = AuditEntry.objects.get(action="profile.updated")
|
||||
assert entry.actor == "alice" and entry.ip == "127.0.0.1"
|
||||
assert entry.detail == {"fields": ["first_name", "mobile"]}
|
||||
assert profile_entries().count() == 1
|
||||
|
||||
|
||||
def test_the_saved_notice_shows_once_with_the_stored_number_form(people, adapters):
|
||||
client = signed_in(adapters)
|
||||
response = save(client, telephone="0049 (0) 30 1234568")
|
||||
page = client.get(response["Location"]).content.decode()
|
||||
assert "Your changes were saved." in page and 'value="+49301234568"' in page
|
||||
assert "Your changes were saved." not in client.get("/profile").content.decode()
|
||||
|
||||
|
||||
def test_nothing_changed_makes_no_write_and_no_audit(people, adapters):
|
||||
spy = Spy(adapters.directory)
|
||||
client = signed_in(adapters)
|
||||
response = save(client)
|
||||
assert response.status_code == 302 and spy.writes == [] and profile_entries().count() == 0
|
||||
assert "Nothing to change." in client.get("/profile").content.decode()
|
||||
|
||||
|
||||
def test_a_number_equal_once_normalized_is_not_a_change(people, adapters):
|
||||
spy = Spy(adapters.directory)
|
||||
save(signed_in(adapters), telephone="+49 30 1234567")
|
||||
assert spy.writes == [] and profile_entries().count() == 0
|
||||
|
||||
|
||||
def test_an_empty_number_clears_the_attribute(people, adapters):
|
||||
spy = Spy(adapters.directory)
|
||||
save(signed_in(adapters), telephone="")
|
||||
assert spy.writes == [("alice", {"telephoneNumber": ""})]
|
||||
assert stored(adapters).telephone == ""
|
||||
|
||||
|
||||
def test_an_old_value_that_is_not_a_number_never_blocks_another_field(people, adapters):
|
||||
save(signed_in(adapters), last_name="Ander")
|
||||
account = stored(adapters)
|
||||
assert account.surname == "Ander" and account.mobile == "intern 23"
|
||||
|
||||
|
||||
def test_old_values_that_break_the_rules_are_left_alone_but_changed_ones_are_checked(
|
||||
people, adapters
|
||||
):
|
||||
long_name = "x" * 100
|
||||
adapters.directory.add_user("carol", given_name=long_name, surname="", display_name="Carol")
|
||||
client = signed_in(adapters, "carol")
|
||||
spy = Spy(adapters.directory)
|
||||
old = {
|
||||
"first_name": long_name,
|
||||
"last_name": "",
|
||||
"display_name": "Carol",
|
||||
"telephone": "",
|
||||
"mobile": "",
|
||||
}
|
||||
assert client.post("/profile", {**old, "mobile": "5"}).status_code == 400
|
||||
assert client.post("/profile", {**old, "display_name": "Caro"}).status_code == 302
|
||||
assert spy.writes == [("carol", {"displayName": "Caro"})]
|
||||
|
||||
|
||||
def test_the_session_name_follows_the_saved_display_name(people, adapters):
|
||||
client = signed_in(adapters)
|
||||
assert "Alice Anders" in client.get("/").content.decode()
|
||||
save(client, display_name="Ally")
|
||||
assert "Ally" in client.get("/profile").content.decode()
|
||||
assert "Alice Anders" not in client.get("/").content.decode()
|
||||
|
||||
|
||||
def test_the_save_compares_with_a_fresh_read_not_with_the_page(people, adapters):
|
||||
spy = Spy(adapters.directory)
|
||||
client = signed_in(adapters)
|
||||
client.get("/profile")
|
||||
adapters.directory.update_attributes("alice", {"sn": "Elsewhere"})
|
||||
save(client, display_name="Ally", last_name="Elsewhere") # same as AD now: not written
|
||||
assert spy.writes[1:] == [("alice", {"displayName": "Ally"})]
|
||||
|
||||
|
||||
# -- phone numbers ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "result"),
|
||||
[
|
||||
("0171 / 123456", "+49171123456"),
|
||||
("0171123456", "+49171123456"),
|
||||
("+49 171 123456", "+49171123456"),
|
||||
("+49 (0) 171-123456", "+49171123456"),
|
||||
("0049 171 123456", "+49171123456"),
|
||||
("0049 (0) 171 123456", "+49171123456"),
|
||||
("+43 660 1234567", "+436601234567"),
|
||||
("0043 660 1234567", "+436601234567"),
|
||||
("(0171) 123.456", "+49171123456"),
|
||||
("+49171123456", "+49171123456"),
|
||||
],
|
||||
)
|
||||
def test_phone_inputs_and_their_stored_form(text, result):
|
||||
assert normalize_phone(text) == result
|
||||
assert normalize_phone(result) == result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"171123456",
|
||||
"abc",
|
||||
"0171 abc",
|
||||
"+49 171 12+3456",
|
||||
"+49 1711 23456 7890 1234",
|
||||
"0123",
|
||||
"+49 12",
|
||||
"++49171123456",
|
||||
"intern 23",
|
||||
"",
|
||||
"*)(uid=*",
|
||||
],
|
||||
)
|
||||
def test_phone_inputs_that_are_refused(text):
|
||||
with pytest.raises(InvalidNumber):
|
||||
normalize_phone(text)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["telephone", "mobile"])
|
||||
@pytest.mark.parametrize("text", ["171123456", "0171 abc", "+49 12", "+49 171 12+3456"])
|
||||
def test_a_bad_number_is_refused_under_its_field(people, adapters, field, text):
|
||||
spy = Spy(adapters.directory)
|
||||
response = save(signed_in(adapters), **{field: text})
|
||||
html = response.content.decode()
|
||||
assert response.status_code == 400 and spy.writes == []
|
||||
assert "must be a number that starts with + and the country code, or with 0" in html
|
||||
assert 'class="error"' in html and profile_entries().count() == 0
|
||||
|
||||
|
||||
# -- invalid values ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["first_name", "last_name", "display_name"])
|
||||
def test_a_required_name_cannot_be_empty(people, adapters, field):
|
||||
spy = Spy(adapters.directory)
|
||||
response = save(signed_in(adapters), **{field: " "})
|
||||
assert response.status_code == 400 and spy.writes == [] and profile_entries().count() == 0
|
||||
assert "is required." in response.content.decode()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("first_name", "x" * 65),
|
||||
("last_name", "x" * 65),
|
||||
("display_name", "x" * 257),
|
||||
("first_name", "A\nB"),
|
||||
("last_name", "A\tB"),
|
||||
("display_name", "A\x00B"),
|
||||
],
|
||||
)
|
||||
def test_too_long_or_control_characters_are_refused_and_other_input_stays(
|
||||
people, adapters, field, value
|
||||
):
|
||||
spy = Spy(adapters.directory)
|
||||
response = save(signed_in(adapters), mobile="0171 5", **{field: value})
|
||||
html = response.content.decode()
|
||||
assert response.status_code == 400 and spy.writes == [] and profile_entries().count() == 0
|
||||
assert "Your changes were not saved." in html
|
||||
assert 'value="0171 5"' in html # what else was typed stays in the form
|
||||
assert f'<a href="#id_{field}">' in html
|
||||
|
||||
|
||||
def test_the_limits_are_ads_limits(people, adapters):
|
||||
save(signed_in(adapters), first_name="x" * 64, display_name="y" * 256)
|
||||
account = stored(adapters)
|
||||
assert len(account.given_name) == 64 and len(account.display_name) == 256
|
||||
|
||||
|
||||
def test_values_are_trimmed(people, adapters):
|
||||
save(signed_in(adapters), first_name=" Alicia ")
|
||||
assert stored(adapters).given_name == "Alicia"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["*)(uid=*", "\\00", "CN=x,DC=y", "a)(|(cn=*"])
|
||||
def test_injection_strings_are_plain_text_and_touch_nothing_else(people, adapters, text):
|
||||
spy = Spy(adapters.directory)
|
||||
before_bob = stored(adapters, "bob")
|
||||
assert save(signed_in(adapters), display_name=text).status_code == 302
|
||||
assert spy.writes == [("alice", {"displayName": text})]
|
||||
assert stored(adapters).display_name == text
|
||||
assert stored(adapters, "bob") == before_bob
|
||||
|
||||
|
||||
# -- who is edited -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_posted_username_is_ignored_and_another_user_is_untouched(people, adapters):
|
||||
before_bob = stored(adapters, "bob")
|
||||
response = save(signed_in(adapters), first_name="Alicia", username="bob", sam="bob", dn="x")
|
||||
assert response.status_code == 302
|
||||
assert stored(adapters).given_name == "Alicia" and stored(adapters, "bob") == before_bob
|
||||
assert AuditEntry.objects.get(action="profile.updated").actor == "alice"
|
||||
|
||||
|
||||
# -- failures ------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ad_refusing_a_value_changes_nothing_and_is_audited_as_failure(people, adapters):
|
||||
def refuse(sam, changes):
|
||||
raise ValidationFailed("constraint")
|
||||
|
||||
adapters.directory.update_attributes = refuse
|
||||
response = save(signed_in(adapters), first_name="Alicia")
|
||||
html = response.content.decode()
|
||||
assert response.status_code == 400
|
||||
assert "A value was not accepted. Please check your entries." in html
|
||||
assert "constraint" not in html and 'value="Alicia"' in html
|
||||
assert stored(adapters).given_name == "Alice"
|
||||
assert audit_actions() == ["profile.update_failed"]
|
||||
assert profile_entries().get().detail == {"reason": "refused"}
|
||||
|
||||
|
||||
def test_directory_down_on_save_is_503_and_audited(people, adapters):
|
||||
client = signed_in(adapters)
|
||||
adapters.directory.unavailable = True
|
||||
response = save(client, first_name="Alicia")
|
||||
assert response.status_code == 503
|
||||
assert "This is not possible right now. Please try again later." in response.content.decode()
|
||||
adapters.directory.unavailable = False
|
||||
assert stored(adapters).given_name == "Alice"
|
||||
assert audit_actions() == ["profile.update_failed"]
|
||||
assert profile_entries().get().detail == {"reason": "unavailable"}
|
||||
|
||||
|
||||
def test_directory_failing_during_the_write_rolls_back_and_audits(people, adapters):
|
||||
def down(sam, changes):
|
||||
raise DirectoryUnavailable("gone")
|
||||
|
||||
adapters.directory.update_attributes = down
|
||||
assert save(signed_in(adapters), first_name="Alicia").status_code == 503
|
||||
assert audit_actions() == ["profile.update_failed"]
|
||||
|
||||
|
||||
def test_a_failure_after_the_ad_call_is_logged_and_the_user_told_it_is_unclear(
|
||||
people, adapters, monkeypatch, caplog
|
||||
):
|
||||
real_record = audit_services.record
|
||||
|
||||
def broken(*args, **kwargs):
|
||||
raise RuntimeError("database gone: Alicia")
|
||||
|
||||
client = signed_in(adapters)
|
||||
monkeypatch.setattr(audit_services, "record", broken)
|
||||
with caplog.at_level(logging.ERROR):
|
||||
response = save(client, first_name="Alicia", mobile="0171 5555555")
|
||||
monkeypatch.setattr(audit_services, "record", real_record)
|
||||
assert response.status_code == 500 and "may have been changed" in response.content.decode()
|
||||
assert stored(adapters).given_name == "Alicia" # AD did change
|
||||
assert audit_actions() == ["profile.update_failed"] # no "updated" entry survived
|
||||
log = next(r.getMessage() for r in caplog.records if r.levelno == logging.ERROR)
|
||||
assert "user=alice" in log and "fields=first_name,mobile" in log and "step=audit" in log
|
||||
assert "Alicia" not in log and "5555555" not in log
|
||||
|
||||
|
||||
def test_a_missing_write_right_is_the_not_possible_page_and_a_failure_entry(
|
||||
people, adapters, caplog
|
||||
):
|
||||
def forbidden(sam, changes):
|
||||
raise Forbidden("insufficient access: Alicia")
|
||||
|
||||
client = signed_in(adapters)
|
||||
adapters.directory.update_attributes = forbidden
|
||||
with caplog.at_level(logging.ERROR):
|
||||
response = save(client, first_name="Alicia")
|
||||
assert response.status_code == 503
|
||||
assert "This is not possible right now." in response.content.decode()
|
||||
assert audit_actions() == ["profile.update_failed"]
|
||||
assert profile_entries().get().detail == {"reason": "forbidden"}
|
||||
assert not any("Alicia" in r.getMessage() for r in caplog.records)
|
||||
|
||||
|
||||
def test_an_invalid_save_reads_the_directory_once(people, adapters):
|
||||
client = signed_in(adapters)
|
||||
reads = []
|
||||
original = adapters.directory.find_user
|
||||
adapters.directory.find_user = lambda sam: reads.append(sam) or original(sam)
|
||||
response = save(client, first_name=" ")
|
||||
assert response.status_code == 400 and len(reads) == 1
|
||||
assert "alice@example.test" in response.content.decode()
|
||||
Reference in New Issue
Block a user