Add Coach mode C3: game line-up, the last of the six coach screens

New models (events/models.py): Lineup (one per event), LineupUnit (Line 1,
Defence pair 1, ... -- coach-entered labels, no fixed sport structure since
neither Club nor Team carries one), LineupSlot (one position, optionally
filled by a member). events/services/lineup.py's publish_lineup is the
reason Attendance.AttendanceStatus.SELECTED/NOT_SELECTED existed at all --
publishing flips every slotted member to SELECTED and every other available
(non-out, non-silent) roster member to NOT_SELECTED, then notifies only the
selected players.

Placement is a native <select> per slot, batch-saved with one "Save
line-up" submit, not the design mock's drag-and-drop -- this codebase has
no established htmx interaction pattern yet (htmx.js is loaded but nothing
uses it) to build a live per-tap version on, and a reliable plain form beats
a first, unproven real-time interaction for an already-large screen.
place_member handles "swap" semantics for it: placing a member vacates any
other slot of theirs in the same lineup, bumping whoever was already in the
target slot back to the available pool.

Wires the missing pieces from C1's own docstring: the "needs you" line-up-
not-published row and the tonight card's "Line-up" button, both deferred
in that stage specifically because this model didn't exist yet.

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 22:47:42 +02:00
parent ccd4e0aa13
commit 53b3c56594
11 changed files with 698 additions and 11 deletions

58
events/services/lineup.py Normal file
View File

@@ -0,0 +1,58 @@
"""Placing players into a Lineup's slots and publishing it -- coach mode's C3
screen (mobile/coach_views.py's CoachLineupView/CoachLineupPlaceView). Mirrors
events.services.attendance's shape: small, focused functions the view writes
through rather than touching the models directly.
"""
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from events.models import Attendance, LineupSlot
from members.models import Member
from notifications.services import notify_members
#: Attendance statuses that mean "not actually available" -- these members
#: stay exactly as they are on publish, never flipped to NOT_SELECTED, and
#: aren't offered as placeable in the available-players pool.
UNAVAILABLE_STATUSES = [Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.EXCUSED, Attendance.AttendanceStatus.NO_RESPONSE]
def place_member(lineup, slot, member):
"""Tap-to-place: ``member`` moves into ``slot``, vacating any other slot
of theirs in the same ``lineup`` first (a member is only ever in one slot
at a time). Whoever was already in ``slot``, if anyone, is simply bumped
back to the available pool -- the tap equivalent of the mock's "slots
accept one player and swap on drop", without true drag-and-drop's two-way
swap (see LineupSlot's own docstring for why tap-to-place instead of
drag-and-drop at all)."""
LineupSlot.objects.filter(unit__lineup=lineup, member=member).exclude(pk=slot.pk).update(member=None)
slot.member = member
slot.save(update_fields=["member"])
def clear_slot(slot):
slot.member = None
slot.save(update_fields=["member"])
def publish_lineup(lineup):
"""Marks the lineup published and writes it into the game record via
Attendance -- the reason AttendanceStatus.SELECTED/NOT_SELECTED exist,
previously unused anywhere. Every slotted member becomes SELECTED; every
other member with an Attendance row for this event who was actually
available (not out/silent, see UNAVAILABLE_STATUSES) becomes
NOT_SELECTED. Notifies only the selected players."""
lineup.published_at = timezone.now()
lineup.save(update_fields=["published_at"])
selected_member_ids = set(LineupSlot.objects.filter(unit__lineup=lineup, member__isnull=False).values_list("member_id", flat=True))
Attendance.objects.filter(event=lineup.event, member_id__in=selected_member_ids).update(status=Attendance.AttendanceStatus.SELECTED)
Attendance.objects.filter(event=lineup.event).exclude(member_id__in=selected_member_ids).exclude(status__in=UNAVAILABLE_STATUSES).update(status=Attendance.AttendanceStatus.NOT_SELECTED)
selected_members = Member.objects.filter(pk__in=selected_member_ids)
if selected_members:
body = _("You're in the line-up for %(event)s.") % {"event": lineup.event.title}
notify_members(selected_members, club=lineup.event.club, title=_("Line-up published"), body=body, source=lineup.event)
return lineup