From 62c36f47b9dfcad87177662d511e89bd0798f982 Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Sat, 22 Aug 2026 16:04:03 +0200 Subject: [PATCH] Show the published line-up on the member event view instead of RSVP buttons Once a game's line-up is published, "Your answers" switches to a read-only status pill and a new Line-up card shows every unit/slot; a SELECTED member can still report they can no longer make it, which flips them to Absent and notifies the team's managers immediately (the closed-deadline guard doesn't apply here, since a published line-up is usually well past it). Also shows the full date for the "Meet" time, not just the hour. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9 --- events/services/lineup.py | 16 +++ events/tests.py | 26 +++- mobile/templates/mobile/event_detail.html | 48 +++++++- mobile/tests.py | 144 ++++++++++++++++++++++ mobile/views.py | 44 +++++-- 5 files changed, 263 insertions(+), 15 deletions(-) diff --git a/events/services/lineup.py b/events/services/lineup.py index 39433c7..605d24a 100644 --- a/events/services/lineup.py +++ b/events/services/lineup.py @@ -7,9 +7,11 @@ through rather than touching the models directly. from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from club.services.access import event_season from events.models import Attendance, LineupSlot from members.models import Member from notifications.services import notify_members +from teams.models import StaffAssignment #: Attendance statuses that mean "not actually available" -- these members #: stay exactly as they are on publish, never flipped to NOT_SELECTED, and @@ -56,3 +58,17 @@ def publish_lineup(lineup): notify_members(selected_members, club=lineup.event.club, title=_("Line-up published"), body=body, source=lineup.event) return lineup + + +def notify_dropout(event, member, note): + """A player who was SELECTED in a published line-up reporting, after the + fact, that they can no longer make it (mobile.views.EventDetailView.post's + "dropout" status) -- unlike an ordinary pre-deadline Out, the roster is + already locked in, so this goes straight to whoever can still act on it: + every current-season manager of the event's own teams (management + position, not every staffer -- a physio can't swap a line-up slot).""" + manager_ids = StaffAssignment.objects.filter(team__in=event.teams.all(), position__management_position=True, season=event_season(event)).values_list("member_id", flat=True).distinct() + managers = Member.objects.filter(pk__in=manager_ids) + if managers: + body = _("%(member)s can no longer make it to %(event)s: ā€œ%(note)sā€") % {"member": member.get_full_name(), "event": event.title, "note": note} + notify_members(managers, club=event.club, title=_("Line-up dropout"), body=body, source=event) diff --git a/events/tests.py b/events/tests.py index 4884177..6ec97fa 100644 --- a/events/tests.py +++ b/events/tests.py @@ -16,7 +16,7 @@ from club.services.onboarding import mark_bypassed, mark_complete from features.models import Maintenance from members.models import Group, GroupMembership, Member from notifications.models import Notification -from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership +from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership from .admin import EventAdminForm from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSlot, LineupUnit, Location, Opponent @@ -34,7 +34,7 @@ from .services import ( team_no_shows, ) from .services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid -from .services.lineup import clear_slot, place_member, publish_lineup +from .services.lineup import clear_slot, notify_dropout, place_member, publish_lineup from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent from .services.referees import RefereeAssignmentError, add_external_referee, assign_referee, conflicting_events, eligible_referees, needs_referee_management, remove_referee, set_referee_fee from .tasks import send_deadline_reminders @@ -466,6 +466,28 @@ class LineupServiceTests(EventsTestBase): notified_member_ids = set(Notification.objects.filter(member__in=[self.alice, self.bob]).values_list("member_id", flat=True)) self.assertEqual(notified_member_ids, {self.alice.pk}) + def test_notify_dropout_notifies_the_teams_managers(self): + event, _lineup, _unit, _slot = self.make_game_with_lineup() + manager = Member.objects.create(first_name="Cara", last_name="Coach") + management_position = Position.objects.create(club=self.club, name="Head coach", short_name="HC", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=self.team, member=manager, season=self.season, position=management_position) + + notify_dropout(event, self.alice, "Twisted an ankle") + + notification = Notification.objects.get(member=manager) + self.assertIn(self.alice.get_full_name(), notification.body) + self.assertIn("Twisted an ankle", notification.body) + + def test_notify_dropout_does_not_notify_non_management_staff(self): + event, _lineup, _unit, _slot = self.make_game_with_lineup() + physio = Member.objects.create(first_name="Pat", last_name="Physio") + physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True, management_position=False) + StaffAssignment.objects.create(team=self.team, member=physio, season=self.season, position=physio_position) + + notify_dropout(event, self.alice, "Twisted an ankle") + + self.assertFalse(Notification.objects.filter(member=physio).exists()) + class RosterChangeSyncTests(EventsTestBase): def test_adding_roster_member_syncs_future_events(self): diff --git a/mobile/templates/mobile/event_detail.html b/mobile/templates/mobile/event_detail.html index 7deecf7..a4b63e5 100644 --- a/mobile/templates/mobile/event_detail.html +++ b/mobile/templates/mobile/event_detail.html @@ -44,7 +44,7 @@ {% if event.gathering %}
{% trans "Meet" %} - {{ event.gathering|date:"H:i" }} + {{ event.gathering|date:"D d M \a\t H:i" }}
{% endif %} {% if event.location %} @@ -58,6 +58,27 @@ {% endif %} + {% if lineup %} +
+
+ {% trans "Line-up" %} + {% trans "Published" %} +
+
+ {% for unit in lineup.units.all %} +
+
{{ unit.label }}
+
+ {% for slot in unit.slots.all %} + {% if slot.member %}{{ slot.member.get_full_name }}{% endif %} + {% endfor %} +
+
+ {% endfor %} +
+
+ {% endif %} + {% if your_answers %}
{% trans "Your answers" %} @@ -81,8 +102,31 @@ {% trans "No reply" %} {% endif %}
- {% if rsvp_closed %} + {% if rsvp_closed or lineup %} {{ answer.attendance.get_status_display }} + {% if lineup and answer.attendance.status == "selected" %} + {% comment %} + Only a SELECTED player sees this -- once they report a + dropout, EventDetailView.post flips them to ABSENT, so a + reload naturally drops this button and falls through to + the shared note paragraph below instead. + {% endcomment %} +
+ +
+ {% csrf_token %} + + + + + +
+ + +
+
+
+ {% endif %} {% else %} {% trans "In" as label_in %} {% trans "Maybe" as label_maybe %} diff --git a/mobile/tests.py b/mobile/tests.py index 15cf068..56da9ed 100644 --- a/mobile/tests.py +++ b/mobile/tests.py @@ -708,6 +708,92 @@ class EventDetailRsvpTests(TestCase): self.assertRedirects(response, reverse("mobile:home"), fetch_redirect_response=False) +@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) +class EventDetailDropoutTests(TestCase): + """The "Can't make it after all" action a SELECTED member sees on a + published line-up (event_detail.html) -- posts status=dropout, which + EventDetailView.post resolves to an ordinary ABSENT plus a manager + notification, since the closed-deadline guard doesn't apply here.""" + + @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") + # Deadline in the past -- a published line-up is typically well after it, + # and the dropout path specifically has to work despite this. + cls.event = Event.objects.create(club=cls.club, title="Away game", start=timezone.now() + datetime.timedelta(days=1), deadline=timezone.now() - datetime.timedelta(hours=1)) + cls.event.teams.add(cls.team) + cls.manager = Member.objects.create(first_name="Cara", last_name="Coach") + management_position = Position.objects.create(club=cls.club, name="Head coach", short_name="HC", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=cls.team, member=cls.manager, season=cls.season, position=management_position) + + def _post(self, data): + return self.client.post(reverse("mobile:event_detail", kwargs={"pk": self.event.pk}), data=data, HTTP_HOST="ajax-united.rosterchief.app") + + def test_dropout_flips_a_selected_member_to_absent_with_the_given_reason(self): + Attendance.objects.create(event=self.event, member=self.member, status=Attendance.AttendanceStatus.SELECTED) + self.client.force_login(self.user) + + response = self._post({"status": "dropout", "note": "Twisted an ankle"}) + + self.assertEqual(response.status_code, 302) + attendance = Attendance.objects.get(event=self.event, member=self.member) + self.assertEqual(attendance.status, Attendance.AttendanceStatus.ABSENT) + self.assertEqual(attendance.note, "Twisted an ankle") + + def test_dropout_bypasses_the_closed_deadline_guard(self): + # setUpTestData's event already has a deadline in the past -- an + # ordinary Out would 400 here (see EventDetailRsvpTests), dropout must not. + Attendance.objects.create(event=self.event, member=self.member, status=Attendance.AttendanceStatus.SELECTED) + self.client.force_login(self.user) + + response = self._post({"status": "dropout", "note": "Twisted an ankle"}) + + self.assertEqual(response.status_code, 302) + + def test_dropout_notifies_the_teams_managers(self): + Attendance.objects.create(event=self.event, member=self.member, status=Attendance.AttendanceStatus.SELECTED) + self.client.force_login(self.user) + + self._post({"status": "dropout", "note": "Twisted an ankle"}) + + notification = Notification.objects.get(member=self.manager) + self.assertIn(self.member.get_full_name(), notification.body) + self.assertIn("Twisted an ankle", notification.body) + + def test_dropout_without_a_reason_is_rejected(self): + Attendance.objects.create(event=self.event, member=self.member, status=Attendance.AttendanceStatus.SELECTED) + self.client.force_login(self.user) + + response = self._post({"status": "dropout"}) + + self.assertEqual(response.status_code, 400) + attendance = Attendance.objects.get(event=self.event, member=self.member) + self.assertEqual(attendance.status, Attendance.AttendanceStatus.SELECTED) + + def test_dropout_is_rejected_when_the_member_was_not_selected(self): + Attendance.objects.create(event=self.event, member=self.member, status=Attendance.AttendanceStatus.PRESENT) + self.client.force_login(self.user) + + response = self._post({"status": "dropout", "note": "Twisted an ankle"}) + + self.assertEqual(response.status_code, 400) + attendance = Attendance.objects.get(event=self.event, member=self.member) + self.assertEqual(attendance.status, Attendance.AttendanceStatus.PRESENT) + + def test_dropout_is_rejected_when_there_is_no_attendance_row_at_all(self): + self.client.force_login(self.user) + + response = self._post({"status": "dropout", "note": "Twisted an ankle"}) + + self.assertEqual(response.status_code, 400) + + @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) class CalendarViewTests(TestCase): """M3 -- design_handoff_rosterchief_platform/README.md's M3 section: a @@ -995,6 +1081,64 @@ class EventDetailScreenTests(TestCase): self.assertEqual(list(response.context["your_answers"]), []) self.assertContains(response, "No one you manage is invited") + def test_no_lineup_card_when_none_is_published(self): + self.client.force_login(self.user) + + response = self._get() + + self.assertIsNone(response.context["lineup"]) + self.assertNotContains(response, "Line-up") + + def test_unpublished_lineup_is_not_shown(self): + lineup = Lineup.objects.create(event=self.event, team=self.team) + unit = LineupUnit.objects.create(lineup=lineup, label="Line 1") + LineupSlot.objects.create(unit=unit, member=self.member) + self.client.force_login(self.user) + + response = self._get() + + self.assertIsNone(response.context["lineup"]) + + def test_published_lineup_shows_its_units_and_slotted_members(self): + lineup = Lineup.objects.create(event=self.event, team=self.team, published_at=timezone.now()) + unit = LineupUnit.objects.create(lineup=lineup, label="Line 1") + LineupSlot.objects.create(unit=unit, member=self.member) + self.client.force_login(self.user) + + response = self._get() + + self.assertEqual(response.context["lineup"], lineup) + self.assertContains(response, "Line 1") + self.assertContains(response, self.member.get_full_name()) + + def test_rsvp_buttons_are_replaced_by_a_readonly_pill_once_the_lineup_is_published(self): + Lineup.objects.create(event=self.event, team=self.team, published_at=timezone.now()) + Attendance.objects.create(event=self.event, member=self.member, status=Attendance.AttendanceStatus.SELECTED) + self.client.force_login(self.user) + + response = self._get() + + self.assertNotContains(response, 'name="status" value="present"') + + def test_selected_member_sees_the_cant_make_it_button(self): + Lineup.objects.create(event=self.event, team=self.team, published_at=timezone.now()) + Attendance.objects.create(event=self.event, member=self.member, status=Attendance.AttendanceStatus.SELECTED) + self.client.force_login(self.user) + + response = self._get() + + self.assertContains(response, "Can't make it after all") + self.assertContains(response, 'name="status" value="dropout"') + + def test_not_selected_member_does_not_see_the_cant_make_it_button(self): + Lineup.objects.create(event=self.event, team=self.team, published_at=timezone.now()) + Attendance.objects.create(event=self.event, member=self.member, status=Attendance.AttendanceStatus.NOT_SELECTED) + self.client.force_login(self.user) + + response = self._get() + + self.assertNotContains(response, 'name="status" value="dropout"') + 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") diff --git a/mobile/views.py b/mobile/views.py index 4823c5d..3046603 100644 --- a/mobile/views.py +++ b/mobile/views.py @@ -27,8 +27,9 @@ from club.services.fees import open_dues_rows from club.services.onboarding import checklist_for from club.services.sponsors import active_sponsors from controlpanel.messages import notify -from events.models import Attendance, Event +from events.models import Attendance, Event, Lineup from events.services.calendar import week_bounds +from events.services.lineup import notify_dropout from members.models import FamilyMembership, Member from members.views import ClubScopedPublicMixin from news.models import News @@ -391,6 +392,10 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView): 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) rsvp_closed = event.deadline is not None and event.deadline < timezone.now() + # A published line-up supersedes ordinary RSVP -- the roster's locked + # in, so "Your answers" below switches to read-only and the line-up + # itself gets its own card. + lineup = Lineup.objects.filter(event=event, published_at__isnull=False).prefetch_related("units__slots__member").first() your_answers = [] if self.managed_people: @@ -430,11 +435,19 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView): "no_reply_pct": round(100 * counts["no_reply_count"] / total), } - return super().get_context_data(screen_title=event.title, event=event, rsvp_closed=rsvp_closed, your_answers=your_answers, squad_summary=squad_summary, **kwargs) + return super().get_context_data(screen_title=event.title, event=event, rsvp_closed=rsvp_closed, lineup=lineup, 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, Attendance.AttendanceStatus.MAYBE): + # "dropout" isn't an AttendanceStatus -- it's this same form posting + # from the "Can't make it after all" button a SELECTED member sees + # once the line-up's published (event_detail.html). It resolves to + # ABSENT below, same as an ordinary Out, but skips the closed-deadline + # guard (the line-up is published well after most deadlines) and + # additionally pings the event's managers, since by this point only + # they can still act on it (swap the slot, warn the opponent, ...). + is_dropout = status == "dropout" + if not is_dropout and status not in (Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.MAYBE): return HttpResponseBadRequest(_("Unknown RSVP status.")) # Every current caller (Home's hero, M2's per-person rows) always sends an @@ -446,11 +459,16 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView): return HttpResponseBadRequest(_("You can't RSVP for that person.")) event = get_object_or_404(Event, pk=kwargs["pk"], club=request.club) + existing = Attendance.objects.filter(event=event, member=member).first() + + if is_dropout: + if existing is None or existing.status != Attendance.AttendanceStatus.SELECTED: + return HttpResponseBadRequest(_("You're not in the published line-up for this event.")) # Mirrors the read-only treatment Home's hero and this same screen's own # "Your answers" card already show once the deadline has passed (see # get_context_data's rsvp_closed) -- enforced here too, since a disabled # button in the UI is only a hint, not a guarantee against a direct POST. - if event.deadline is not None and event.deadline < timezone.now(): + elif event.deadline is not None and event.deadline < timezone.now(): return HttpResponseBadRequest(_("Replies are closed for this event.")) # A reason is only ever meaningful attached to Out/Maybe -- clearing it @@ -461,14 +479,14 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView): # (event_detail's "Your answers") and Coach mode's bench attendance # (mobile/templates/mobile/coach/attendance.html) ever read it. note = "" - if status == Attendance.AttendanceStatus.ABSENT: + if is_dropout or status == Attendance.AttendanceStatus.ABSENT: note = request.POST.get("note", "").strip() # Rejects blank and punctuation-only "answers" (a bare ".", "-", - # "??") -- mandatory for Out specifically, unlike Maybe below. - # Backend-scoped, not the pretty inline-error UX this codebase - # gives ModelForm submissions elsewhere -- matches this view's - # own existing style (see "Unknown RSVP status"/"Replies are - # closed" above, both plain 400s a normal user should never + # "??") -- mandatory for Out/dropout specifically, unlike Maybe + # below. Backend-scoped, not the pretty inline-error UX this + # codebase gives ModelForm submissions elsewhere -- matches this + # view's own existing style (see "Unknown RSVP status"/"Replies + # are closed" above, both plain 400s a normal user should never # actually see, since the template only ever offers Out through # the reason form to begin with). if not any(char.isalnum() for char in note): @@ -477,7 +495,11 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView): # Optional here -- Maybe doesn't owe anyone an explanation the way # a firm no does, but the same field carries it if given one. note = request.POST.get("note", "").strip() - Attendance.objects.update_or_create(event=event, member=member, defaults={"status": status, "note": note}) + + final_status = Attendance.AttendanceStatus.ABSENT if is_dropout else status + Attendance.objects.update_or_create(event=event, member=member, defaults={"status": final_status, "note": note}) + if is_dropout: + notify_dropout(event, member, note) if request.POST.get("next") == "event_detail": return HttpResponseRedirect(reverse("mobile:event_detail", kwargs={"pk": event.pk}))