Build M6 Edit personal info for the mobile app

The last of the seven Member-mode screens. A real editable form for the
fields Member actually has (first/last name, date of birth, email,
phone, emergency phone) -- the design mock's national-register-number,
address, allergies/notes and consent-toggle rows have no backing field
and are omitted rather than added as new schema for a screen-building
pass. Two mock rows do have real data and are shown read-only instead:
every guardian via Member.guardians ("Emergency contact"), and any open
onboarding requirement for the person's current-season membership (via
club.services.onboarding.checklist_for) as a banner -- no upload/complete
action, that stays staff-only elsewhere in the platform.

Authorization: the target Member (from the URL) must be one of the
signed-in account's managed_people, checked on both GET and POST -- an
unmanaged id 404s, same as another club's Event/News already does
elsewhere in this app.

This completes M1-M7 (Home, Event detail, Calendar, News article, Me &
my people, Edit personal info, Notifications). Coach mode (C1-C6) is a
separate, later phase per the design doc and is intentionally not
started here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-21 14:11:56 +02:00
parent 19e1acb93a
commit 2cdba6a571
6 changed files with 364 additions and 5 deletions

View File

@@ -14,7 +14,7 @@ import json
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, Q
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, JsonResponse
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from django.urls import reverse
@@ -27,14 +27,17 @@ from django.views.generic import TemplateView
from club.models import ClubMembership
from club.services.access import current_season, has_management_access, teams_managed_by
from club.services.fees import remaining_balance
from club.services.onboarding import checklist_for
from controlpanel.messages import notify
from events.models import Attendance, Event
from events.services.calendar import week_bounds
from members.models import Member
from members.models import FamilyMembership, Member
from members.views import ClubScopedPublicMixin
from news.models import News
from notifications.models import Notification
from teams.models import TeamMembership
from .forms import MemberProfileForm
from .mixins import PersonScopeMixin
from .models import PushSubscription
from .services.icons import render_fallback_icon
@@ -484,10 +487,96 @@ class MeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
)
class EditProfileView(_PlaceholderScreen):
class EditProfileView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
"""M6 -- design_handoff_rosterchief_platform/README.md's M6 section,
"Edit personal info". The design mock also shows a "National register
no.", an "Address", an "Allergies / notes" field and two "Consent"
toggles (photos on club channels / share contact with team parents) --
none of those exist on ``members.models.Member``, so (per this build's
"no schema changes for a screen-building pass" rule) they're simply not
part of this screen; MemberProfileForm (mobile/forms.py) only covers the
fields the model actually has.
Two more mock rows *do* have real backing data, both rendered read-only
(never editable here -- family links and staff document review each live
elsewhere in the platform, not on a member's own edit-info screen):
- "Contact 1" becomes every one of ``Member.guardians`` (there can be
more than one, unlike the mock's single row), each with its real
FamilyMembership role (parent/guardian/other) rather than the mock's
invented "mother".
- The "Medical form missing" banner becomes a real, club-defined
open-requirements list from club.services.onboarding.checklist_for,
scoped to this person's *current-season* ClubMembership -- shown only
when at least one active requirement is neither complete nor
bypassed (an "informational" open requirement with no current-season
membership at all just means the banner never renders).
Authorization: the target Member (``member_id`` URL kwarg) must be one of
``self.managed_people`` -- anyone else 404s, on both GET and POST. A 404
(not EventDetailView.post's 400) is the deliberate choice here: that 400
is for a malformed *value* inside an otherwise-valid POST to a resource
the requester can already see (the event); this is a different resource
per person, named directly in the URL, so an unmanaged member should read
as "no such page" exactly like Event/News already do for another club's
objects elsewhere in this file, not as a submission-shaped error.
"""
template_name = "mobile/edit_profile.html"
screen_title = _("Edit info")
active_tab = "me"
def _target_member(self):
member_id = str(self.kwargs["member_id"])
member = next((person for person in self.managed_people if str(person.pk) == member_id), None)
if member is None:
raise Http404("You can't edit that profile.")
return member
def get(self, request, *args, **kwargs):
member = self._target_member()
form = MemberProfileForm(instance=member)
return self.render_to_response(self.get_context_data(member=member, form=form))
def post(self, request, *args, **kwargs):
member = self._target_member()
form = MemberProfileForm(request.POST, instance=member)
if form.is_valid():
form.save()
title = _("Saved")
body = _("%(name)s's info was updated.") % {"name": member.get_full_name()}
notify(request, f"s|{title}|{body}")
return HttpResponseRedirect(reverse("mobile:me"))
return self.render_to_response(self.get_context_data(member=member, form=form))
def get_context_data(self, **kwargs):
member = kwargs["member"]
guardian_rows = [
{"member": family_membership.member, "role": family_membership.get_role_display()}
for family_membership in FamilyMembership.objects.filter(
role__in=[FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN],
family__memberships__member=member,
family__memberships__role=FamilyMembership.FamilyRole.CHILD,
)
.select_related("member")
.distinct()
]
open_requirement_names = []
season = current_season(self.request.club)
if season is not None:
membership = ClubMembership.objects.filter(club=self.request.club, member=member, season=season).first()
if membership is not None:
open_requirement_names = [requirement.name for requirement, status in checklist_for(membership) if status is None or not (status.is_complete or status.is_bypassed)]
return super().get_context_data(
screen_title=member.get_full_name(),
guardian_rows=guardian_rows,
open_requirement_names=open_requirement_names,
open_requirement_summary=", ".join(open_requirement_names),
**kwargs,
)
class NotificationsView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
"""M7 -- design_handoff_rosterchief_platform/README.md's M7 section