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:
39
mobile/forms.py
Normal file
39
mobile/forms.py
Normal 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
|
||||
90
mobile/templates/mobile/edit_profile.html
Normal file
90
mobile/templates/mobile/edit_profile.html
Normal 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 }} · {{ row.role }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
131
mobile/tests.py
131
mobile/tests.py
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user