Line-up: show each player's turnout rate, notify on changes after publish

A "yes" to this game doesn't say how reliably a player actually shows up --
each row on the line-up screen now shows this season's turnout rate
(events.services.attendance.player_attendance_rankings, one query for the
whole team), color-coded, omitted for anyone with too little history to
mean anything.

Also: editing a published line-up and hitting "Save" never notified anyone
(it only ever wrote LineupSelection, never touched Attendance.status) --
"Publish"/"Publish changes" now stays reachable after the first publish,
and publish_lineup diffs against who was SELECTED before this run so only
players whose status actually changed get notified, not everyone currently
selected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 17:59:18 +02:00
parent 240b6bd345
commit 8c415ed46a
5 changed files with 149 additions and 11 deletions

View File

@@ -36,12 +36,21 @@ def publish_lineup(lineup):
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.
NOT_SELECTED.
Also doubles as "publish changes" for an already-published lineup a
coach keeps editing (CoachLineupView's own "Save line-up" only writes
LineupSelection -- Attendance.status, and any notification, only ever
updates here). Notifies by comparing against who was SELECTED *before*
this run, not by re-notifying everyone currently selected -- a player
unaffected by the edit shouldn't get a second "you're in" ping, and one
dropped since the last publish should hear about that specifically.
Clears scheduled_publish_at regardless of whether this run came from a
coach tapping Publish directly or from the scheduled sweep (events.tasks.
publish_scheduled_lineups) catching a due one -- once published, there's
nothing left pending either way."""
previously_selected_ids = set(Attendance.objects.filter(event=lineup.event, status=Attendance.AttendanceStatus.SELECTED).values_list("member_id", flat=True))
lineup.published_at = timezone.now()
lineup.scheduled_publish_at = None
lineup.save(update_fields=["published_at", "scheduled_publish_at"])
@@ -51,10 +60,15 @@ def publish_lineup(lineup):
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:
newly_selected = Member.objects.filter(pk__in=selected_member_ids - previously_selected_ids)
if newly_selected:
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)
notify_members(newly_selected, club=lineup.event.club, title=_("Line-up published"), body=body, source=lineup.event)
newly_dropped = Member.objects.filter(pk__in=previously_selected_ids - selected_member_ids)
if newly_dropped:
body = _("The line-up for %(event)s has changed -- you're not in it this time.") % {"event": lineup.event.title}
notify_members(newly_dropped, club=lineup.event.club, title=_("Line-up updated"), body=body, source=lineup.event)
return lineup

View File

@@ -573,6 +573,43 @@ 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_republish_does_not_renotify_a_player_still_selected(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})
publish_lineup(lineup)
Notification.objects.filter(member=self.alice).delete()
publish_lineup(lineup)
self.assertFalse(Notification.objects.filter(member=self.alice).exists())
def test_republish_notifies_a_newly_added_player(self):
event, lineup = self.make_game_with_lineup()
Attendance.objects.update_or_create(event=event, member=self.alice, defaults={"status": Attendance.AttendanceStatus.PRESENT})
publish_lineup(lineup)
Notification.objects.filter(member=self.alice).delete()
LineupSelection.objects.create(lineup=lineup, member=self.alice)
publish_lineup(lineup)
notification = Notification.objects.get(member=self.alice)
self.assertIn("in the line-up", notification.body)
def test_republish_notifies_a_dropped_player_with_a_different_message(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})
publish_lineup(lineup)
Notification.objects.filter(member=self.alice).delete()
LineupSelection.objects.filter(lineup=lineup, member=self.alice).delete()
publish_lineup(lineup)
notification = Notification.objects.get(member=self.alice)
self.assertIn("not in it this time", notification.body)
self.assertEqual(Attendance.objects.get(event=event, member=self.alice).status, Attendance.AttendanceStatus.NOT_SELECTED)
def test_notify_dropout_notifies_the_teams_managers(self):
event, _lineup = self.make_game_with_lineup()
manager = Member.objects.create(first_name="Cara", last_name="Coach")