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

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