Build M2 Event detail ("answer for several") for the mobile app

Full event-detail screen: hero header, Face-off/Meet/Where facts (the
design mock's Kit/dressing-room details have no backing Event field, so
they're simply omitted), a per-managed-person RSVP card so a parent with
several kids on the same event can answer for each independently, and a
club-visible squad-response aggregate (counts only, never who answered
what).

Extends the existing quick-RSVP POST (built for M1's Home hero) to also
accept "maybe" for M2's three-way buttons, and adds an optional
next=event_detail redirect target so answering here doesn't bounce back
to Home -- M1's and M3's existing forms are unaffected.

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 13:44:53 +02:00
parent 29acd22b7f
commit 13f369e10c
5 changed files with 333 additions and 8 deletions

View File

@@ -0,0 +1,126 @@
{% extends "mobile/base.html" %}
{% load i18n %}
{% comment %}
M2 -- design_handoff_rosterchief_platform/README.md's M2 section, "answer
for several". See EventDetailView's own docstring (mobile/views.py) for
the judgment calls: no event photos, no Kit/dressing-room fields (the
Event model has none), squad response is counts-only (never who answered
what).
{% endcomment %}
{% block content %}
<div class="-mx-4 -mt-4 flex h-[220px] flex-col justify-end bg-ink p-4 text-white" style="background: linear-gradient(180deg, rgba(11,18,32,.35) 0%, rgba(11,18,32,.55) 60%, rgba(11,18,32,.92) 100%), var(--color-navy)">
<a class="mb-auto flex h-11 w-11 items-center justify-center rounded-full bg-white/15" href="{% url "mobile:home" %}" aria-label="{% trans "Back" %}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></svg>
</a>
<span class="mb-2 inline-block w-fit rounded bg-club px-2 py-1 font-display text-xs font-extrabold tracking-wide text-white uppercase">
{% if event.kind == "game" %}{% if event.is_home_game %}{% trans "Home game" %}{% else %}{% trans "Away game" %}{% endif %}{% else %}{{ event.get_kind_display }}{% endif %}
</span>
<h1 class="font-display text-4xl leading-[.98] font-extrabold uppercase">{{ event.title }}</h1>
</div>
<div class="m-card flex flex-col gap-2.5 p-4">
<div class="flex gap-3.5">
<span class="w-20 shrink-0 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Face-off" %}</span>
<span class="text-[15px] font-semibold text-ink">{{ event.start|date:"D d M \a\t H:i" }}</span>
</div>
{% if event.gathering %}
<div class="flex gap-3.5">
<span class="w-20 shrink-0 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Meet" %}</span>
<span class="text-[15px] font-semibold text-ink">{{ event.gathering|date:"H:i" }}</span>
</div>
{% endif %}
{% if event.location %}
<div class="flex gap-3.5">
<span class="w-20 shrink-0 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Where" %}</span>
<span class="text-[15px] font-semibold text-ink">
{{ event.location.name }}<br>
<span class="text-[13px] font-normal text-muted">{{ event.location.address }}, {{ event.location.zip_code }} {{ event.location.city }}</span>
</span>
</div>
{% endif %}
</div>
{% if your_answers %}
<div class="m-card p-4">
<span class="font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Your answers" %}</span>
<div class="mt-3 flex flex-col gap-3">
{% for answer in your_answers %}
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
<div>
<div class="mb-2 flex items-center gap-2.5">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-steel font-display text-sm font-extrabold text-white">{{ answer.member.first_name|slice:":1" }}{{ answer.member.last_name|slice:":1" }}</span>
<div class="min-w-0 flex-1">
<div class="text-[15px] font-semibold text-ink">{{ answer.member.get_full_name }}</div>
{% if answer.membership %}
<div class="text-xs text-muted">
{{ answer.membership.team.short_name }}
{% if answer.membership.jersey_number %}&middot; #{{ answer.membership.jersey_number }}{% endif %}
{% if answer.membership.position %}&middot; {{ answer.membership.position.name }}{% endif %}
</div>
{% endif %}
</div>
{% if answer.attendance.status == "no_response" %}
<span class="pill pill-warn shrink-0">{% trans "No reply" %}</span>
{% endif %}
</div>
<div class="flex gap-1.5">
{% trans "In" as label_in %}
{% trans "Maybe" as label_maybe %}
{% trans "Out" as label_out %}
{% with status=answer.attendance.status %}
<form class="flex-1" method="post" action="{% url "mobile:event_detail" event.pk %}">
{% csrf_token %}
<input type="hidden" name="member_id" value="{{ answer.member.pk }}">
<input type="hidden" name="status" value="present">
<input type="hidden" name="next" value="event_detail">
<button type="submit" class="btn h-11 w-full text-[15px] {% if status == "present" %}btn-positive{% else %}bg-paper border border-stroke text-muted{% endif %}">{{ label_in }}</button>
</form>
<form class="flex-1" method="post" action="{% url "mobile:event_detail" event.pk %}">
{% csrf_token %}
<input type="hidden" name="member_id" value="{{ answer.member.pk }}">
<input type="hidden" name="status" value="maybe">
<input type="hidden" name="next" value="event_detail">
<button type="submit" class="btn h-11 w-full text-[15px] {% if status == "maybe" %}bg-steel text-white{% else %}bg-paper border border-stroke text-muted{% endif %}">{{ label_maybe }}</button>
</form>
<form class="flex-1" method="post" action="{% url "mobile:event_detail" event.pk %}">
{% csrf_token %}
<input type="hidden" name="member_id" value="{{ answer.member.pk }}">
<input type="hidden" name="status" value="absent">
<input type="hidden" name="next" value="event_detail">
<button type="submit" class="btn h-11 w-full text-[15px] {% if status == "absent" %}bg-club text-white{% else %}bg-paper border border-stroke text-muted{% endif %}">{{ label_out }}</button>
</form>
{% endwith %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if squad_summary %}
<div class="m-card p-4">
<div class="flex items-center justify-between">
<span class="font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Squad response" %}</span>
<span class="text-[13px] text-muted">{% blocktrans with responded=squad_summary.responded total=squad_summary.total %}{{ responded }} of {{ total }}{% endblocktrans %}</span>
</div>
<div class="mt-2.5 flex h-2 overflow-hidden rounded-full bg-rule">
<div class="bg-ok" style="width: {{ squad_summary.in_pct }}%"></div>
<div class="bg-club" style="width: {{ squad_summary.out_pct }}%"></div>
<div class="bg-line" style="width: {{ squad_summary.no_reply_pct }}%"></div>
</div>
<div class="mt-2 flex gap-3.5 text-[13px] text-muted">
<span><strong class="text-ink">{{ squad_summary.in_count }}</strong> {% trans "in" %}</span>
<span><strong class="text-ink">{{ squad_summary.out_count }}</strong> {% trans "out" %}</span>
<span><strong class="text-ink">{{ squad_summary.no_reply_count }}</strong> {% trans "no reply" %}</span>
</div>
</div>
{% endif %}
{% if not your_answers and not squad_summary %}
<div class="m-card p-6 text-center">
<p class="text-sm text-muted">{% trans "No one you manage is invited to this event." %}</p>
</div>
{% endif %}
{% endblock content %}

View File

@@ -10,6 +10,7 @@ from club.models import Club, ClubMembership, DuesInvoice, Season
from events.models import Attendance, Event
from members.models import Family, FamilyMembership, Member
from news.models import News
from teams.models import Position, Team, TeamMembership
from .models import PushSubscription
from .services.icons import render_fallback_icon
@@ -306,13 +307,28 @@ class EventDetailRsvpTests(TestCase):
self.attendance.refresh_from_db()
self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.NO_RESPONSE)
def test_posting_maybe_updates_attendance(self):
self.client.force_login(self.user)
self._post(self.event, {"status": "maybe"})
self.attendance.refresh_from_db()
self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.MAYBE)
def test_rejects_an_unknown_status_value(self):
self.client.force_login(self.user)
response = self._post(self.event, {"status": "maybe"})
response = self._post(self.event, {"status": "excused"})
self.assertEqual(response.status_code, 400)
def test_next_event_detail_redirects_back_to_the_event_instead_of_home(self):
self.client.force_login(self.user)
response = self._post(self.event, {"status": "present", "next": "event_detail"})
self.assertRedirects(response, reverse("mobile:event_detail", kwargs={"pk": self.event.pk}), fetch_redirect_response=False)
def test_cannot_rsvp_for_an_event_from_another_club(self):
other_club = Club.objects.create(name="Other Club", slug="other-club", secondary_color="#e4002b")
other_event = Event.objects.create(club=other_club, title="Not ours", start=timezone.now() + datetime.timedelta(days=3))
@@ -416,3 +432,103 @@ class CalendarViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "No one to show yet")
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class EventDetailScreenTests(TestCase):
"""M2 -- design_handoff_rosterchief_platform/README.md's M2 section,
"answer for several" (the GET side; POST is covered by
EventDetailRsvpTests above)."""
@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="parent@example.com", user=cls.user)
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season)
cls.team = Team.objects.create(club=cls.club, name="U16", short_name="U16")
cls.position = Position.objects.create(club=cls.club, name="Forward", short_name="F")
cls.event = Event.objects.create(club=cls.club, title="Home game", start=timezone.now() + datetime.timedelta(days=7))
cls.event.teams.add(cls.team)
def _get(self, event=None):
event = event or self.event
return self.client.get(reverse("mobile:event_detail", kwargs={"pk": event.pk}), HTTP_HOST="ajax-united.rosterchief.app")
def test_renders_event_details(self):
self.client.force_login(self.user)
response = self._get()
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Home game")
def test_your_answers_only_lists_managed_people_invited_to_this_event(self):
# TeamMembership -> Event.teams (both already set up in setUpTestData) auto-creates
# a NO_RESPONSE Attendance row via events/signals.py -- no need to create one by hand.
TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, position=self.position, jersey_number=17)
family = Family.objects.create(name="Bakker")
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
not_invited_child = Member.objects.create(first_name="Noor", last_name="Bakker")
FamilyMembership.objects.create(family=family, member=not_invited_child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=self.club, member=not_invited_child, season=self.season)
# Noor is a managed person but has no Attendance row for this event -- not invited.
self.client.force_login(self.user)
response = self._get()
answered_members = {answer["member"] for answer in response.context["your_answers"]}
self.assertEqual(answered_members, {self.member})
self.assertContains(response, "#17")
self.assertContains(response, "No reply")
def test_your_answers_is_empty_when_nobody_managed_is_invited(self):
self.client.force_login(self.user)
response = self._get()
self.assertEqual(list(response.context["your_answers"]), [])
self.assertContains(response, "No one you manage is invited")
def test_squad_response_counts_are_correct(self):
in_member = Member.objects.create(first_name="A", last_name="In")
out_member = Member.objects.create(first_name="B", last_name="Out")
no_reply_member = Member.objects.create(first_name="C", last_name="Silent")
Attendance.objects.create(event=self.event, member=in_member, status=Attendance.AttendanceStatus.PRESENT)
Attendance.objects.create(event=self.event, member=out_member, status=Attendance.AttendanceStatus.ABSENT)
Attendance.objects.create(event=self.event, member=no_reply_member, status=Attendance.AttendanceStatus.NO_RESPONSE)
self.client.force_login(self.user)
response = self._get()
summary = response.context["squad_summary"]
self.assertEqual(summary["in_count"], 1)
self.assertEqual(summary["out_count"], 1)
self.assertEqual(summary["no_reply_count"], 1)
self.assertEqual(summary["total"], 3)
def test_squad_response_is_absent_for_an_event_with_no_teams(self):
club_wide_event = Event.objects.create(club=self.club, title="Club BBQ", start=timezone.now() + datetime.timedelta(days=3), club_wide=True)
self.client.force_login(self.user)
response = self._get(club_wide_event)
self.assertIsNone(response.context["squad_summary"])
def test_404_for_an_event_from_another_club(self):
other_club = Club.objects.create(name="Other Club", slug="other-club", secondary_color="#e4002b")
other_event = Event.objects.create(club=other_club, title="Not ours", start=timezone.now() + datetime.timedelta(days=3))
self.client.force_login(self.user)
response = self._get(other_event)
self.assertEqual(response.status_code, 404)
def test_requires_login(self):
response = self._get()
self.assertEqual(response.status_code, 302)

View File

@@ -13,7 +13,7 @@ import datetime
import json
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Q
from django.db.models import Count, Q
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
@@ -284,17 +284,78 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
return super().get_context_data(scope_all=scope_all, this_week=this_week, next_week=next_week, **kwargs)
class EventDetailView(_PlaceholderScreen):
"""GET is still the M2 placeholder (a later screen owns the full detail
page); POST is M1's quick In/Out RSVP action, reused by any future screen
that posts the same {status, member_id} shape at this URL."""
class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
"""M2 -- design_handoff_rosterchief_platform/README.md's M2 section:
"answer for several". A hero header (no event photos in this codebase --
a solid dark block instead, like M1's hero card), an event-detail card
(Face-off/Meet/Where -- the design mock's extra "Kit" row and dressing-room
detail have no backing Event field, so they're simply not shown), a
per-managed-person RSVP card scoped to ``self.managed_people`` who
actually have an ``Attendance`` row for this event (i.e. are invited --
not every managed person necessarily is), and a club/team-visible
squad-response aggregate (counts only, never who-answered-what) shown
only when the event has an actual team roster to aggregate.
POST is still M1's quick In/Out RSVP action (now also accepting "maybe"
for M2's three-way buttons), reused by any screen that posts the same
{status, member_id} shape at this URL. An optional ``next=event_detail``
field redirects back here instead of Home -- M2's own forms send it so a
parent answering for several people in a row sees each update land
without bouncing away; M1's/M3's existing forms don't send it, so they
keep redirecting to Home unchanged.
"""
template_name = "mobile/event_detail.html"
screen_title = _("Event")
active_tab = "calendar"
def get_context_data(self, **kwargs):
event = get_object_or_404(Event.objects.select_related("location", "opponent"), pk=self.kwargs["pk"], club=self.request.club)
season = event.season or current_season(self.request.club)
your_answers = []
if self.managed_people:
managed_ids = [person.pk for person in self.managed_people]
attendances_by_member = {attendance.member_id: attendance for attendance in Attendance.objects.filter(event=event, member_id__in=managed_ids).select_related("member")}
memberships_by_member = {}
if season is not None:
memberships_by_member = {
membership.member_id: membership
for membership in TeamMembership.objects.filter(member_id__in=managed_ids, team__in=event.teams.all(), season=season).select_related("team", "position")
}
for person in self.managed_people:
attendance = attendances_by_member.get(person.pk)
if attendance is None:
continue
your_answers.append({"member": person, "attendance": attendance, "membership": memberships_by_member.get(person.pk)})
squad_summary = None
if event.teams.exists():
counts = Attendance.objects.filter(event=event).aggregate(
in_count=Count("id", filter=Q(status__in=[Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.SELECTED])),
out_count=Count("id", filter=Q(status__in=[Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.NOT_SELECTED])),
no_reply_count=Count("id", filter=Q(status__in=[Attendance.AttendanceStatus.MAYBE, Attendance.AttendanceStatus.NO_RESPONSE])),
)
total = counts["in_count"] + counts["out_count"] + counts["no_reply_count"]
if total:
squad_summary = {
"total": total,
"responded": counts["in_count"] + counts["out_count"],
"in_count": counts["in_count"],
"out_count": counts["out_count"],
"no_reply_count": counts["no_reply_count"],
"in_pct": round(100 * counts["in_count"] / total),
"out_pct": round(100 * counts["out_count"] / total),
"no_reply_pct": round(100 * counts["no_reply_count"] / total),
}
return super().get_context_data(screen_title=event.title, event=event, your_answers=your_answers, squad_summary=squad_summary, **kwargs)
def post(self, request, *args, **kwargs):
status = request.POST.get("status")
if status not in (Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.ABSENT):
if status not in (Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.MAYBE):
return HttpResponseBadRequest(_("Unknown RSVP status."))
member_id = request.POST.get("member_id") or (str(self.scope_person.pk) if self.scope_person else None)
@@ -304,6 +365,9 @@ class EventDetailView(_PlaceholderScreen):
event = get_object_or_404(Event, pk=kwargs["pk"], club=request.club)
Attendance.objects.update_or_create(event=event, member=member, defaults={"status": status})
if request.POST.get("next") == "event_detail":
return HttpResponseRedirect(reverse("mobile:event_detail", kwargs={"pk": event.pk}))
return HttpResponseRedirect(reverse("mobile:home"))