Files
ping-mee-self-service-platform/portal/profile/views.py
T
Ebbe BaßandClaude Sonnet 5.5 3fd8f27127 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
2026-10-19 09:03:24 +02:00

162 lines
5.9 KiB
Python

"""The password page (FR-5..FR-7). No business logic here: see services.py (AD-17)."""
from __future__ import annotations
import logging
from django.shortcuts import redirect, render
from django.utils.translation import gettext as _
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 DETAIL_FIELDS, DetailsForm, PasswordChangeForm
logger = logging.getLogger(__name__)
DONE_FLAG = "password_changed"
# Rule keys from services.local_hints, in plain words.
HINT_MESSAGES = {
"too_short": lambda: _("The password is too short."),
"too_few_groups": lambda: _(
"The password needs characters from more groups: upper case, lower case, digits, special."
),
}
def _page(request, form, *, policy=None, status=200, throttled=False):
rules = services.describe_policy(policy) if policy is not None else []
partly_known = policy is not None and policy.fine_grained and not policy.fine_grained_readable
context = {
"form": form,
"rules": rules,
"partly_known": partly_known,
"throttled": throttled,
}
return render(request, "profile/password.html", context, status=status)
def _unavailable(request):
return render(request, "accounts/unavailable.html", status=503)
@login_required
@sensitive_post_parameters("current_password", "new_password", "confirm_password")
@require_http_methods(["GET", "POST"])
def password(request):
user = request.portal_user.user
if request.method == "GET":
try:
policy = get_directory().password_policy(user.sam)
except DirectoryUnavailable:
return _unavailable(request)
return _page(request, PasswordChangeForm(), policy=policy)
form = PasswordChangeForm(request.POST)
if not form.is_valid():
return _page(request, form, policy=_policy_or_none(user), status=400)
try:
result = services.change_password(
user,
form.cleaned_data["current_password"],
form.cleaned_data["new_password"],
ip=request.client_ip,
session_key=request.session.session_key,
)
except Throttled:
return _page(request, PasswordChangeForm(), status=429, throttled=True)
except services.WrongCurrentPassword as exc:
form.add_error("current_password", _("The current password is not correct."))
return _page(request, form, policy=exc.policy, status=400)
except services.PasswordNotAccepted as exc:
messages = [HINT_MESSAGES[key]() for key in exc.reasons] or [
_(
"The new password does not meet the rules above, "
"or it was used or changed too recently."
)
]
for message in messages:
form.add_error("new_password", message)
return _page(request, form, policy=exc.policy, status=400)
except DirectoryUnavailable:
return _unavailable(request)
except services.OutcomeUnknown:
return render(request, "profile/password_unknown.html", status=500)
request.session[DONE_FLAG] = {"mail": result.mail_queued}
return redirect("password-done")
def _policy_or_none(user):
try:
return get_directory().password_policy(user.sam)
except DirectoryUnavailable:
return None
@login_required
def password_done(request):
flag = request.session.pop(DONE_FLAG, None)
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")