Simplify the line-up to yes/no picks, and stop truncating notifications

Coach mode's line-up screen no longer has lines/slots -- just a yes/no
toggle per available roster player, grouped by their roster position, both
in coach mode and on the published member-side view. Replaces LineupUnit/
LineupSlot with a single LineupSelection model.

Notifications now show their full body text (no more truncatechars) and the
unread colour bar spans the full row height via self-stretch, matching the
calendar row's own marker, so it still reads correctly once a body wraps to
several lines.

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:29:21 +02:00
parent 2b6a4d21bb
commit 135580d83e
13 changed files with 300 additions and 285 deletions

View File

@@ -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, Lineup, LineupSlot, LineupUnit, Location, Opponent
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent
@admin.register(Opponent)
@@ -118,18 +118,11 @@ class LineupAdmin(admin.ModelAdmin):
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(LineupSelection)
class LineupSelectionAdmin(admin.ModelAdmin):
list_display = ["lineup", "member"]
search_fields = ["lineup__event__title", "member__first_name", "member__last_name"]
raw_id_fields = ["lineup", "member"]
@admin.register(EventReferee)

View File

@@ -0,0 +1,44 @@
# Generated by Django 6.0.6 on 2026-08-22 14:23
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0024_lineup_lineupunit_lineupslot'),
('members', '0006_parentclaim_submitted_by_user'),
]
operations = [
migrations.RemoveField(
model_name='lineupunit',
name='lineup',
),
migrations.CreateModel(
name='LineupSelection',
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)),
('lineup', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='selections', to='events.lineup', verbose_name='line-up')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lineup_selections', to='members.member', verbose_name='member')),
],
options={
'verbose_name': 'line-up selection',
'verbose_name_plural': 'line-up selections',
},
),
migrations.DeleteModel(
name='LineupSlot',
),
migrations.DeleteModel(
name='LineupUnit',
),
migrations.AddConstraint(
model_name='lineupselection',
constraint=models.UniqueConstraint(fields=('lineup', 'member'), name='unique_selection_per_lineup_per_member'),
),
]

View File

@@ -200,12 +200,13 @@ class Attendance(UUIDModel):
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."""
CoachLineupView): a plain yes/no pick per roster player (``selections``),
not lines/slots -- a coach doesn't need to think in terms of who's on
which line, just who's in. Publishing (events.services.lineup.
publish_lineup) flips every selected 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"))
@@ -223,45 +224,25 @@ class Lineup(UUIDModel):
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."""
class LineupSelection(UUIDModel):
"""One roster player the coach has marked "in" for a Lineup -- presence
of the row *is* the yes; there's no separate flag and no "no" row.
Grouped by the member's own roster position ("category") when displayed,
both in coach mode and on the published member-side view -- see
events.services.lineup for the publish-time read of this set."""
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)
lineup = models.ForeignKey(Lineup, on_delete=models.CASCADE, related_name="selections", verbose_name=_("line-up"))
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="lineup_selections", verbose_name=_("member"))
class Meta:
verbose_name = _("line-up unit")
verbose_name_plural = _("line-up units")
ordering = ["ordering"]
verbose_name = _("line-up selection")
verbose_name_plural = _("line-up selections")
constraints = [
models.UniqueConstraint(fields=["lineup", "member"], name="unique_selection_per_lineup_per_member"),
]
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'}"
return f"{self.lineup} - {self.member}"
class EventReferee(UUIDModel):

View File

@@ -1,53 +1,46 @@
"""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.
"""Picking players for a Lineup (plain yes/no, no lines/slots) and publishing
it -- coach mode's C3 screen (mobile/coach_views.py's CoachLineupView).
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 club.services.access import event_season
from events.models import Attendance, LineupSlot
from events.models import Attendance, LineupSelection
from members.models import Member
from notifications.services import notify_members
from teams.models import StaffAssignment
from teams.models import StaffAssignment, TeamMembership
#: 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.
#: aren't offered as pickable for the line-up.
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 toggle_selection(lineup, member) -> bool:
"""Flips ``member``'s yes/no pick for ``lineup`` -- deletes their row if
they were in, creates one if they weren't. Returns the new state (True =
now selected)."""
deleted, _details = LineupSelection.objects.filter(lineup=lineup, member=member).delete()
if deleted:
return False
LineupSelection.objects.create(lineup=lineup, member=member)
return True
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
previously unused anywhere. Every selected 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))
selected_member_ids = set(LineupSelection.objects.filter(lineup=lineup).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)
@@ -60,6 +53,36 @@ def publish_lineup(lineup):
return lineup
def selected_members_by_position(lineup):
"""Every LineupSelection for ``lineup``, grouped by the member's roster
position for the lineup's own team/season ("category") -- the read side
behind the published, member-facing Line-up card (mobile/views.py's
EventDetailView). A selected member with no TeamMembership on this team/
season (a guest call-up) lands in a catch-all "No position set" bucket
rather than being dropped -- mirrors CoachLineupView's own grouping."""
season = event_season(lineup.event)
member_ids = list(LineupSelection.objects.filter(lineup=lineup).values_list("member_id", flat=True))
memberships_by_member = {}
if season is not None:
memberships_by_member = {tm.member_id: tm for tm in TeamMembership.objects.filter(team=lineup.team, season=season, member_id__in=member_ids).select_related("position")}
members_by_id = {member.pk: member for member in Member.objects.filter(pk__in=member_ids)}
buckets = {}
for member_id in member_ids:
member = members_by_id.get(member_id)
if member is None:
continue
position = memberships_by_member[member_id].position if member_id in memberships_by_member else None
key = position.pk if position else None
bucket = buckets.setdefault(key, {"label": position.name if position else _("No position set"), "ordering": position.ordering if position else 9999, "members": []})
bucket["members"].append(member)
categories = sorted(buckets.values(), key=lambda bucket: (bucket["ordering"], bucket["label"]))
for bucket in categories:
bucket["members"].sort(key=lambda member: (member.last_name, member.first_name))
return categories
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

View File

@@ -19,7 +19,7 @@ from notifications.models import Notification
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
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent
from .services import (
cancel_occurrence,
detach_occurrence,
@@ -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, notify_dropout, place_member, publish_lineup
from .services.lineup import notify_dropout, publish_lineup, selected_members_by_position, toggle_selection
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
@@ -373,71 +373,46 @@ class AttendanceSyncTests(EventsTestBase):
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)."""
"""events.services.lineup -- toggle_selection/publish_lineup/
selected_members_by_position, the write+read path behind coach mode's C3
screen (mobile/coach_views.py) and the published member-side view
(mobile/views.py's EventDetailView)."""
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
return event, lineup
def test_place_member_fills_the_slot(self):
_event, lineup, _unit, slot = self.make_game_with_lineup()
def test_toggle_selection_selects_an_unselected_member(self):
_event, lineup = self.make_game_with_lineup()
place_member(lineup, slot, self.alice)
now_selected = toggle_selection(lineup, self.alice)
slot.refresh_from_db()
self.assertEqual(slot.member, self.alice)
self.assertTrue(now_selected)
self.assertTrue(LineupSelection.objects.filter(lineup=lineup, member=self.alice).exists())
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)
def test_toggle_selection_deselects_a_selected_member(self):
_event, lineup = self.make_game_with_lineup()
LineupSelection.objects.create(lineup=lineup, member=self.alice)
place_member(lineup, slot, self.alice)
now_selected = toggle_selection(lineup, 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)
self.assertFalse(now_selected)
self.assertFalse(LineupSelection.objects.filter(lineup=lineup, member=self.alice).exists())
def test_publish_sets_published_at(self):
_event, lineup, _unit, _slot = self.make_game_with_lineup()
_event, lineup = 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()
def test_publish_selects_picked_members_and_not_selects_the_rest(self):
event, lineup = self.make_game_with_lineup()
LineupSelection.objects.create(lineup=lineup, member=self.alice)
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})
@@ -447,17 +422,42 @@ class LineupServiceTests(EventsTestBase):
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()
event, lineup = 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_selected_members_by_position_groups_by_the_teams_roster_position(self):
_event, lineup = self.make_game_with_lineup()
winger = Position.objects.create(club=self.club, name="Winger", short_name="W", ordering=1)
defense = Position.objects.create(club=self.club, name="Defense", short_name="D", ordering=2)
TeamMembership.objects.filter(team=self.team, member=self.alice).update(position=winger)
TeamMembership.objects.filter(team=self.team, member=self.bob).update(position=defense)
LineupSelection.objects.create(lineup=lineup, member=self.alice)
LineupSelection.objects.create(lineup=lineup, member=self.bob)
categories = selected_members_by_position(lineup)
self.assertEqual([c["label"] for c in categories], ["Winger", "Defense"])
self.assertEqual(categories[0]["members"], [self.alice])
self.assertEqual(categories[1]["members"], [self.bob])
def test_selected_members_by_position_falls_back_to_no_position_bucket(self):
_event, lineup = self.make_game_with_lineup()
guest = Member.objects.create(first_name="Gia", last_name="Guest")
LineupSelection.objects.create(lineup=lineup, member=guest)
categories = selected_members_by_position(lineup)
self.assertEqual(len(categories), 1)
self.assertEqual(categories[0]["label"], "No position set")
self.assertEqual(categories[0]["members"], [guest])
def test_publish_notifies_only_selected_members(self):
event, lineup, _unit, slot = self.make_game_with_lineup()
slot.member = self.alice
slot.save()
event, lineup = self.make_game_with_lineup()
LineupSelection.objects.create(lineup=lineup, member=self.alice)
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})
@@ -467,7 +467,7 @@ class LineupServiceTests(EventsTestBase):
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()
event, _lineup = 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)
@@ -479,7 +479,7 @@ class LineupServiceTests(EventsTestBase):
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()
event, _lineup = 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)