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

39
mobile/forms.py Normal file
View File

@@ -0,0 +1,39 @@
from django import forms
from members.models import Member
#: Shared by every text-ish field below -- mobile has no equivalent of
#: management/controlpanel's templatetags/field.html (which builds widget
#: classes at render time for their daisyUI-shaped inputs), so this app's one
#: form so far just bakes its own classes straight into the widget.
_INPUT_CLASSES = "h-11 w-full rounded-lg border border-stroke bg-paper px-3 text-[15px] text-ink placeholder:text-dim focus:border-ink focus:outline-none"
class MemberProfileForm(forms.ModelForm):
"""M6 -- "Edit personal info" (design_handoff_rosterchief_platform/README.md).
Covers exactly the fields ``members.models.Member`` actually has. The
design mock also shows a "National register no.", an "Address", an
"Allergies / notes" field and two "Consent" toggles -- none of those have
a backing field on ``Member`` (see EditProfileView's own docstring), so
they're simply not part of this form rather than being invented here.
Same field list and date widget as ``management.forms.MemberForm`` (the
staff-side equivalent editing the same model) -- diverging widget
conventions across the platform for identical fields would be its own bug.
"""
class Meta:
model = Member
fields = ["first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone"]
# Same date widget as management.forms.MemberForm. phone/emergency_phone
# deliberately keep django-phonenumber-field's own RegionalPhoneNumberWidget
# (national-format display, region-aware parsing) rather than being
# swapped for a plain TextInput here -- __init__ below only adds a CSS
# class to whatever widget each field already has, never replaces it.
widgets = {"date_of_birth": forms.DateInput(attrs={"type": "date"})}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs["class"] = _INPUT_CLASSES

View File

@@ -0,0 +1,90 @@
{% extends "mobile/base.html" %}
{% load i18n %}
{% comment %}
M6 -- design_handoff_rosterchief_platform/README.md's M6 section, "Edit
personal info". See EditProfileView's own docstring (mobile/views.py) for
the judgment calls: the design mock's "National register no.", "Address",
"Allergies / notes" and the two "Consent" toggles have no backing field on
Member and are omitted entirely; the guardian ("Contact 1") and open
onboarding-requirements ("Medical form... missing") rows are real data,
shown read-only.
{% endcomment %}
{% block content %}
<form method="post" class="flex flex-col gap-4">
{% csrf_token %}
<div class="-mx-4 -mt-4 flex items-center gap-1 border-b border-line bg-white px-4 py-3">
<a class="-ml-2.5 flex h-11 w-11 shrink-0 items-center justify-center" href="{% url "mobile:me" %}" aria-label="{% trans "Back" %}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#0b1220" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></svg>
</a>
<span class="min-w-0 flex-1 truncate font-display text-xl leading-none font-extrabold text-ink uppercase">{{ member.get_full_name }}</span>
<button type="submit" class="btn btn-primary h-9 px-3.5 text-sm">{% trans "Save" %}</button>
</div>
{% if open_requirement_names %}
<div class="rounded-box border border-warn-border bg-warn-bg p-3.5 text-sm text-warn-deep">
{% blocktrans count counter=open_requirement_names|length with names=open_requirement_summary %}{{ counter }} requirement still open: {{ names }}{% plural %}{{ counter }} requirements still open: {{ names }}{% endblocktrans %}
</div>
{% endif %}
<div>
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Identity" %}</div>
<div class="m-card overflow-hidden">
<div class="p-3.5">
<label class="mb-1 block text-xs text-muted" for="{{ form.first_name.id_for_label }}">{{ form.first_name.label }}</label>
{{ form.first_name }}
{% for error in form.first_name.errors %}<p class="mt-1 text-xs text-club">{{ error }}</p>{% endfor %}
</div>
<div class="h-px bg-rule"></div>
<div class="p-3.5">
<label class="mb-1 block text-xs text-muted" for="{{ form.last_name.id_for_label }}">{{ form.last_name.label }}</label>
{{ form.last_name }}
{% for error in form.last_name.errors %}<p class="mt-1 text-xs text-club">{{ error }}</p>{% endfor %}
</div>
<div class="h-px bg-rule"></div>
<div class="p-3.5">
<label class="mb-1 block text-xs text-muted" for="{{ form.date_of_birth.id_for_label }}">{{ form.date_of_birth.label }}</label>
{{ form.date_of_birth }}
{% for error in form.date_of_birth.errors %}<p class="mt-1 text-xs text-club">{{ error }}</p>{% endfor %}
</div>
</div>
</div>
<div>
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Contact" %}</div>
<div class="m-card overflow-hidden">
<div class="p-3.5">
<label class="mb-1 block text-xs text-muted" for="{{ form.email.id_for_label }}">{{ form.email.label }}</label>
{{ form.email }}
{% for error in form.email.errors %}<p class="mt-1 text-xs text-club">{{ error }}</p>{% endfor %}
</div>
<div class="h-px bg-rule"></div>
<div class="p-3.5">
<label class="mb-1 block text-xs text-muted" for="{{ form.phone.id_for_label }}">{{ form.phone.label }}</label>
{{ form.phone }}
{% for error in form.phone.errors %}<p class="mt-1 text-xs text-club">{{ error }}</p>{% endfor %}
</div>
</div>
</div>
<div>
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Emergency" %}</div>
<div class="m-card overflow-hidden">
<div class="p-3.5">
<label class="mb-1 block text-xs text-muted" for="{{ form.emergency_phone.id_for_label }}">{{ form.emergency_phone.label }}</label>
{{ form.emergency_phone }}
{% for error in form.emergency_phone.errors %}<p class="mt-1 text-xs text-club">{{ error }}</p>{% endfor %}
</div>
{% for row in guardian_rows %}
<div class="h-px bg-rule"></div>
<div class="p-3.5">
<div class="text-xs text-muted">{% trans "Emergency contact" %}</div>
<div class="text-[15px] font-medium text-ink">{{ row.member.get_full_name }} &middot; {{ row.role }}</div>
</div>
{% endfor %}
</div>
</div>
</form>
{% endblock content %}

View File

@@ -6,7 +6,7 @@ from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone, translation
from club.models import Club, ClubMembership, DuesInvoice, Season
from club.models import Club, ClubMembership, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season
from events.models import Attendance, Event
from members.models import Family, FamilyMembership, Member
from news.models import News
@@ -850,3 +850,132 @@ class MeViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "No one to show yet")
self.assertNotContains(response, "Coach mode")
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class EditProfileViewTests(TestCase):
"""M6 -- design_handoff_rosterchief_platform/README.md's M6 section,
"Edit personal info". See EditProfileView's own docstring for the
judgment calls: no schema fields for national register no./address/
allergies/consent (all omitted), guardians and open onboarding
requirements are shown read-only, and an unmanaged member 404s rather
than mirroring EventDetailView.post's 400."""
@classmethod
def setUpTestData(cls):
cls.club = make_club()
today = timezone.localdate()
cls.season = Season.objects.create(club=cls.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
cls.user = User.objects.create_user(email="parent@example.com", password="pw-secret-123")
cls.member = Member.objects.create(first_name="Lars", last_name="Bakker", email="lars@example.com", user=cls.user)
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season)
cls.family = Family.objects.create(name="Bakker")
FamilyMembership.objects.create(family=cls.family, member=cls.member, role=FamilyMembership.FamilyRole.PARENT)
cls.child = Member.objects.create(first_name="Noor", last_name="Bakker")
FamilyMembership.objects.create(family=cls.family, member=cls.child, role=FamilyMembership.FamilyRole.CHILD)
cls.child_membership = ClubMembership.objects.create(club=cls.club, member=cls.child, season=cls.season)
def _get(self, member):
return self.client.get(reverse("mobile:edit_profile", kwargs={"member_id": member.pk}), HTTP_HOST="ajax-united.rosterchief.app")
def _post(self, member, data):
return self.client.post(reverse("mobile:edit_profile", kwargs={"member_id": member.pk}), data=data, HTTP_HOST="ajax-united.rosterchief.app")
def test_requires_login(self):
response = self._get(self.member)
self.assertEqual(response.status_code, 302)
def test_get_renders_the_form_prefilled_with_the_persons_current_data(self):
self.client.force_login(self.user)
response = self._get(self.member)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Lars")
self.assertContains(response, "Bakker")
self.assertContains(response, "lars@example.com")
def test_post_with_valid_data_updates_the_member_and_redirects_to_me(self):
self.client.force_login(self.user)
response = self._post(
self.member,
{"first_name": "Larsen", "last_name": "Bakker", "email": "larsen@example.com", "phone": "", "emergency_phone": ""},
)
self.assertRedirects(response, reverse("mobile:me"), fetch_redirect_response=False)
self.member.refresh_from_db()
self.assertEqual(self.member.first_name, "Larsen")
self.assertEqual(self.member.email, "larsen@example.com")
def test_post_with_invalid_data_rerenders_with_errors_and_does_not_save(self):
self.client.force_login(self.user)
response = self._post(
self.member,
{"first_name": "", "last_name": "Bakker", "email": "lars@example.com", "phone": "", "emergency_phone": ""},
)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context["form"].errors)
self.member.refresh_from_db()
self.assertEqual(self.member.first_name, "Lars")
def test_cannot_edit_a_member_who_isnt_managed(self):
stranger = Member.objects.create(first_name="Someone", last_name="Else")
self.client.force_login(self.user)
get_response = self._get(stranger)
post_response = self._post(stranger, {"first_name": "Hacked", "last_name": "Else", "email": "", "phone": "", "emergency_phone": ""})
self.assertEqual(get_response.status_code, 404)
self.assertEqual(post_response.status_code, 404)
stranger.refresh_from_db()
self.assertEqual(stranger.first_name, "Someone")
def test_a_managed_child_can_be_edited_too(self):
self.client.force_login(self.user)
response = self._post(
self.child,
{"first_name": "Noor", "last_name": "Bakker", "email": "noor@example.com", "phone": "", "emergency_phone": ""},
)
self.assertRedirects(response, reverse("mobile:me"), fetch_redirect_response=False)
self.child.refresh_from_db()
self.assertEqual(self.child.email, "noor@example.com")
def test_guardian_shows_as_a_readonly_emergency_contact_line(self):
self.client.force_login(self.user)
response = self._get(self.child)
self.assertContains(response, "Lars Bakker")
self.assertContains(response, "parent")
def test_no_emergency_contact_line_when_the_person_has_no_guardians(self):
self.client.force_login(self.user)
response = self._get(self.member)
self.assertNotContains(response, "Emergency contact")
def test_open_onboarding_requirement_shows_as_a_banner(self):
OnboardingRequirement.objects.create(club=self.club, name="Medical form")
self.client.force_login(self.user)
response = self._get(self.child)
self.assertContains(response, "Medical form")
self.assertContains(response, "still open")
def test_resolved_requirement_does_not_show_in_the_banner(self):
requirement = OnboardingRequirement.objects.create(club=self.club, name="Medical form")
MemberRequirementStatus.objects.create(membership=self.child_membership, requirement=requirement, is_complete=True)
self.client.force_login(self.user)
response = self._get(self.child)
self.assertNotContains(response, "still open")

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

View File

@@ -3519,6 +3519,12 @@
.mb-auto {
margin-bottom: auto;
}
.-ml-2 {
margin-left: calc(var(--spacing) * -2);
}
.-ml-2\.5 {
margin-left: calc(var(--spacing) * -2.5);
}
.ml-1 {
margin-left: var(--spacing);
}
@@ -5479,6 +5485,12 @@
}
}
}
.focus\:outline-none {
&:focus {
--tw-outline-style: none;
outline-style: none;
}
}
.sm\:col-span-2 {
@media (width >= 40rem) {
grid-column: span 2 / span 2;

File diff suppressed because one or more lines are too long