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:
@@ -2,7 +2,7 @@ from django import forms
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent
|
||||
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSlot, LineupUnit, Location, Opponent
|
||||
|
||||
|
||||
@admin.register(Opponent)
|
||||
@@ -110,6 +110,28 @@ class AttendanceAdmin(admin.ModelAdmin):
|
||||
raw_id_fields = ["event", "member"]
|
||||
|
||||
|
||||
@admin.register(Lineup)
|
||||
class LineupAdmin(admin.ModelAdmin):
|
||||
list_display = ["event", "team", "published_at", "created_by"]
|
||||
list_filter = ["team"]
|
||||
search_fields = ["event__title", "team__name"]
|
||||
raw_id_fields = ["event", "team", "created_by"]
|
||||
|
||||
|
||||
@admin.register(LineupUnit)
|
||||
class LineupUnitAdmin(admin.ModelAdmin):
|
||||
list_display = ["lineup", "label", "ordering"]
|
||||
search_fields = ["lineup__event__title", "label"]
|
||||
raw_id_fields = ["lineup"]
|
||||
|
||||
|
||||
@admin.register(LineupSlot)
|
||||
class LineupSlotAdmin(admin.ModelAdmin):
|
||||
list_display = ["unit", "ordering", "member"]
|
||||
search_fields = ["unit__label", "member__first_name", "member__last_name"]
|
||||
raw_id_fields = ["unit", "member"]
|
||||
|
||||
|
||||
@admin.register(EventReferee)
|
||||
class EventRefereeAdmin(admin.ModelAdmin):
|
||||
list_display = ["event", "display_name", "fee", "km", "total_payable", "assigned_by"]
|
||||
|
||||
65
events/migrations/0024_lineup_lineupunit_lineupslot.py
Normal file
65
events/migrations/0024_lineup_lineupunit_lineupslot.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-21 20:37
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0023_event_deadline_reminder_sent_at'),
|
||||
('members', '0006_parentclaim_submitted_by_user'),
|
||||
('teams', '0012_alter_refereelevel_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Lineup',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('published_at', models.DateTimeField(blank=True, null=True, verbose_name='published at')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_lineups', to='members.member', verbose_name='created by')),
|
||||
('event', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='lineup', to='events.event', verbose_name='event')),
|
||||
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lineups', to='teams.team', verbose_name='team')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'line-up',
|
||||
'verbose_name_plural': 'line-ups',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LineupUnit',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('label', models.CharField(max_length=100, verbose_name='label')),
|
||||
('ordering', models.PositiveSmallIntegerField(default=0, verbose_name='ordering')),
|
||||
('lineup', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='units', to='events.lineup', verbose_name='line-up')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'line-up unit',
|
||||
'verbose_name_plural': 'line-up units',
|
||||
'ordering': ['ordering'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LineupSlot',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('ordering', models.PositiveSmallIntegerField(default=0, verbose_name='ordering')),
|
||||
('member', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='lineup_slots', to='members.member', verbose_name='member')),
|
||||
('unit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slots', to='events.lineupunit', verbose_name='unit')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'line-up slot',
|
||||
'verbose_name_plural': 'line-up slots',
|
||||
'ordering': ['ordering'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -198,6 +198,72 @@ class Attendance(UUIDModel):
|
||||
return f"{self.event} - {self.member}"
|
||||
|
||||
|
||||
class Lineup(UUIDModel):
|
||||
"""A game's line-up -- coach mode's C3 screen (mobile/coach_views.py's
|
||||
CoachLineupView). One per event; ``units`` (Line 1, Defence pair 1, ...)
|
||||
hold ordered ``slots``, each optionally filled by a member. Publishing
|
||||
(events.services.lineup.publish_lineup) flips every slotted member's
|
||||
Attendance.status to SELECTED and every other available roster member's
|
||||
to NOT_SELECTED -- the reason those two statuses exist on Attendance in
|
||||
the first place, previously unused."""
|
||||
|
||||
event = models.OneToOneField(Event, on_delete=models.CASCADE, related_name="lineup", verbose_name=_("event"))
|
||||
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="lineups", verbose_name=_("team"))
|
||||
published_at = models.DateTimeField(_("published at"), null=True, blank=True)
|
||||
created_by = models.ForeignKey(Member, on_delete=models.SET_NULL, null=True, blank=True, related_name="created_lineups", verbose_name=_("created by"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("line-up")
|
||||
verbose_name_plural = _("line-ups")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.team} - {self.event}"
|
||||
|
||||
def clean(self):
|
||||
validate_club_scope(self, self.event.club_id if self.event_id else None, same_club_fields=("team",))
|
||||
|
||||
|
||||
class LineupUnit(UUIDModel):
|
||||
"""One group of slots within a Lineup -- "Line 1", "Defence pair 1", ...
|
||||
Coach-entered labels, not a fixed sport structure: neither Club nor Team
|
||||
carries a sport field to derive one from."""
|
||||
|
||||
lineup = models.ForeignKey(Lineup, on_delete=models.CASCADE, related_name="units", verbose_name=_("line-up"))
|
||||
label = models.CharField(_("label"), max_length=100)
|
||||
ordering = models.PositiveSmallIntegerField(_("ordering"), default=0)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("line-up unit")
|
||||
verbose_name_plural = _("line-up units")
|
||||
ordering = ["ordering"]
|
||||
|
||||
def __str__(self):
|
||||
return self.label
|
||||
|
||||
|
||||
class LineupSlot(UUIDModel):
|
||||
"""One position within a LineupUnit, optionally filled by a member.
|
||||
Placement/swapping is tap-to-place (mobile/coach_views.py's
|
||||
CoachLineupPlaceView), not drag-and-drop -- see that view's own
|
||||
docstring for why. A member is only ever in one slot per lineup at a
|
||||
time; enforcing that (clearing any prior slot of theirs on placement) is
|
||||
the placement service's job, not a DB constraint -- "which unit a member
|
||||
is in" spans this table's own FK, awkward to express as a single
|
||||
UniqueConstraint."""
|
||||
|
||||
unit = models.ForeignKey(LineupUnit, on_delete=models.CASCADE, related_name="slots", verbose_name=_("unit"))
|
||||
ordering = models.PositiveSmallIntegerField(_("ordering"), default=0)
|
||||
member = models.ForeignKey(Member, on_delete=models.SET_NULL, null=True, blank=True, related_name="lineup_slots", verbose_name=_("member"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("line-up slot")
|
||||
verbose_name_plural = _("line-up slots")
|
||||
ordering = ["ordering"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.unit} - {self.member or 'empty'}"
|
||||
|
||||
|
||||
class EventReferee(UUIDModel):
|
||||
"""One referee assigned to one (home) game -- either a club member
|
||||
(``member`` set) or an external referee logged by name only
|
||||
|
||||
58
events/services/lineup.py
Normal file
58
events/services/lineup.py
Normal 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
|
||||
@@ -19,7 +19,7 @@ from notifications.models import Notification
|
||||
from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership
|
||||
|
||||
from .admin import EventAdminForm
|
||||
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent
|
||||
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSlot, LineupUnit, Location, Opponent
|
||||
from .services import (
|
||||
cancel_occurrence,
|
||||
detach_occurrence,
|
||||
@@ -34,6 +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.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
|
||||
@@ -371,6 +372,101 @@ class AttendanceSyncTests(EventsTestBase):
|
||||
self.assertEqual(event.attendances.count(), 0)
|
||||
|
||||
|
||||
class LineupServiceTests(EventsTestBase):
|
||||
"""events.services.lineup -- place_member/clear_slot/publish_lineup, the
|
||||
write path behind coach mode's C3 screen (mobile/coach_views.py)."""
|
||||
|
||||
def make_game_with_lineup(self, **event_kwargs):
|
||||
event_kwargs.setdefault("kind", Event.EventKind.GAME)
|
||||
event = self.make_event(**event_kwargs)
|
||||
event.teams.add(self.team)
|
||||
lineup = Lineup.objects.create(event=event, team=self.team)
|
||||
unit = LineupUnit.objects.create(lineup=lineup, label="Line 1")
|
||||
slot = LineupSlot.objects.create(unit=unit)
|
||||
return event, lineup, unit, slot
|
||||
|
||||
def test_place_member_fills_the_slot(self):
|
||||
_event, lineup, _unit, slot = self.make_game_with_lineup()
|
||||
|
||||
place_member(lineup, slot, self.alice)
|
||||
|
||||
slot.refresh_from_db()
|
||||
self.assertEqual(slot.member, self.alice)
|
||||
|
||||
def test_place_member_vacates_the_members_other_slot_in_the_same_lineup(self):
|
||||
_event, lineup, unit, slot = self.make_game_with_lineup()
|
||||
other_slot = LineupSlot.objects.create(unit=unit, ordering=1, member=self.alice)
|
||||
|
||||
place_member(lineup, slot, self.alice)
|
||||
|
||||
other_slot.refresh_from_db()
|
||||
self.assertIsNone(other_slot.member)
|
||||
slot.refresh_from_db()
|
||||
self.assertEqual(slot.member, self.alice)
|
||||
|
||||
def test_place_member_bumps_whoever_was_already_in_the_slot(self):
|
||||
_event, lineup, _unit, slot = self.make_game_with_lineup()
|
||||
slot.member = self.bob
|
||||
slot.save()
|
||||
|
||||
place_member(lineup, slot, self.alice)
|
||||
|
||||
slot.refresh_from_db()
|
||||
self.assertEqual(slot.member, self.alice)
|
||||
# Bumped, not swapped -- Bob doesn't land in any other slot.
|
||||
self.assertFalse(LineupSlot.objects.filter(unit__lineup=lineup, member=self.bob).exists())
|
||||
|
||||
def test_clear_slot_empties_it(self):
|
||||
_event, _lineup, _unit, slot = self.make_game_with_lineup()
|
||||
slot.member = self.alice
|
||||
slot.save()
|
||||
|
||||
clear_slot(slot)
|
||||
|
||||
slot.refresh_from_db()
|
||||
self.assertIsNone(slot.member)
|
||||
|
||||
def test_publish_sets_published_at(self):
|
||||
_event, lineup, _unit, _slot = self.make_game_with_lineup()
|
||||
|
||||
publish_lineup(lineup)
|
||||
|
||||
lineup.refresh_from_db()
|
||||
self.assertIsNotNone(lineup.published_at)
|
||||
|
||||
def test_publish_selects_slotted_members_and_not_selects_the_rest(self):
|
||||
event, lineup, _unit, slot = self.make_game_with_lineup()
|
||||
slot.member = self.alice
|
||||
slot.save()
|
||||
Attendance.objects.update_or_create(event=event, member=self.alice, defaults={"status": Attendance.AttendanceStatus.PRESENT})
|
||||
Attendance.objects.update_or_create(event=event, member=self.bob, defaults={"status": Attendance.AttendanceStatus.PRESENT})
|
||||
|
||||
publish_lineup(lineup)
|
||||
|
||||
self.assertEqual(Attendance.objects.get(event=event, member=self.alice).status, Attendance.AttendanceStatus.SELECTED)
|
||||
self.assertEqual(Attendance.objects.get(event=event, member=self.bob).status, Attendance.AttendanceStatus.NOT_SELECTED)
|
||||
|
||||
def test_publish_leaves_unavailable_members_untouched(self):
|
||||
event, lineup, _unit, _slot = self.make_game_with_lineup()
|
||||
Attendance.objects.update_or_create(event=event, member=self.bob, defaults={"status": Attendance.AttendanceStatus.ABSENT})
|
||||
|
||||
publish_lineup(lineup)
|
||||
|
||||
self.assertEqual(Attendance.objects.get(event=event, member=self.bob).status, Attendance.AttendanceStatus.ABSENT)
|
||||
|
||||
def test_publish_notifies_only_selected_members(self):
|
||||
event, lineup, _unit, slot = self.make_game_with_lineup()
|
||||
slot.member = self.alice
|
||||
slot.save()
|
||||
Attendance.objects.update_or_create(event=event, member=self.alice, defaults={"status": Attendance.AttendanceStatus.PRESENT})
|
||||
Attendance.objects.update_or_create(event=event, member=self.bob, defaults={"status": Attendance.AttendanceStatus.PRESENT})
|
||||
|
||||
publish_lineup(lineup)
|
||||
|
||||
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})
|
||||
|
||||
|
||||
class RosterChangeSyncTests(EventsTestBase):
|
||||
def test_adding_roster_member_syncs_future_events(self):
|
||||
event = self.make_event()
|
||||
|
||||
Reference in New Issue
Block a user