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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-22 16:04:03 +02:00
parent a9bf98a3c9
commit 62c36f47b9
5 changed files with 263 additions and 15 deletions

View File

@@ -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)

View File

@@ -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):

View File

@@ -44,7 +44,7 @@
{% if event.gathering %}
<div class="flex items-center 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>
<span class="text-[15px] font-semibold text-ink">{{ event.gathering|date:"D d M \a\t H:i" }}</span>
</div>
{% endif %}
{% if event.location %}
@@ -58,6 +58,27 @@
{% endif %}
</div>
{% if lineup %}
<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 "Line-up" %}</span>
<span class="pill pill-info">{% trans "Published" %}</span>
</div>
<div class="mt-3 flex flex-col gap-3">
{% for unit in lineup.units.all %}
<div>
<div class="mb-1.5 font-display text-xs font-extrabold tracking-wide text-club uppercase">{{ unit.label }}</div>
<div class="flex flex-wrap gap-1.5">
{% for slot in unit.slots.all %}
{% if slot.member %}<span class="pill pill-neutral">{{ slot.member.get_full_name }}</span>{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% 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>
@@ -81,8 +102,31 @@
<span class="pill pill-warn shrink-0">{% trans "No reply" %}</span>
{% endif %}
</div>
{% if rsvp_closed %}
{% if rsvp_closed or lineup %}
<span class="pill pill-neutral">{{ answer.attendance.get_status_display }}</span>
{% 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 %}
<div class="mt-2" x-data="{ asking: false, reason: '' }">
<button type="button" class="btn btn-secondary h-9 w-full text-xs" x-show="!asking" @click="asking = true">{% trans "Can't make it after all" %}</button>
<form x-show="asking" x-cloak method="post" action="{% url "mobile:event_detail" event.pk %}" hx-boost="false" class="rounded-lg border border-stroke bg-paper p-3">
{% csrf_token %}
<input type="hidden" name="member_id" value="{{ answer.member.pk }}">
<input type="hidden" name="status" value="dropout">
<input type="hidden" name="next" value="event_detail">
<label class="mb-1 block text-xs font-semibold text-muted">{% trans "Let the coach know why -- they'll be notified right away" %}</label>
<textarea class="h-16 w-full rounded-lg border border-stroke bg-white p-2 text-sm text-ink" name="note" x-model="reason" placeholder="{% trans "e.g. sick, injured, running late..." %}"></textarea>
<div class="mt-2 grid grid-cols-2 gap-2">
<button type="button" class="btn btn-secondary h-9 text-xs" @click="asking = false">{% trans "Cancel" %}</button>
<button type="submit" class="btn btn-dark h-9 text-xs">{% trans "Send" %}</button>
</div>
</form>
</div>
{% endif %}
{% else %}
{% trans "In" as label_in %}
{% trans "Maybe" as label_maybe %}

View File

@@ -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")

View File

@@ -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}))