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

@@ -4300,6 +4300,29 @@ class CoachLineupViewTests(TestCase):
self.assertEqual([row["member"] for row in categories[0]["rows"]], [self.player])
self.assertFalse(categories[0]["rows"][0]["selected"])
def test_a_player_with_too_little_history_shows_no_turnout_rate(self):
# self.player has exactly one Attendance row (for the upcoming game
# itself, not even a past one) -- well under player_attendance_rankings'
# own minimum_responses floor, so no rate should be attached.
self.client.force_login(self.user)
response = self.client.get(reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), HTTP_HOST="ajax-united.rosterchief.app")
self.assertIsNone(response.context["categories"][0]["rows"][0]["attendance_rate"])
self.assertNotContains(response, "turnout")
def test_a_player_with_enough_history_shows_a_turnout_rate(self):
for index, status in enumerate([Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.ABSENT]):
past_practice = Event.objects.create(club=self.club, title=f"Practice {index}", kind=Event.EventKind.TRAINING, season=self.season, start=timezone.now() - datetime.timedelta(days=index + 1))
past_practice.teams.add(self.team)
Attendance.objects.update_or_create(event=past_practice, member=self.player, defaults={"status": status})
self.client.force_login(self.user)
response = self.client.get(reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.context["categories"][0]["rows"][0]["attendance_rate"], 67)
self.assertContains(response, "67% turnout")
def test_save_selects_the_submitted_players(self):
self.client.force_login(self.user)
lineup = Lineup.objects.create(event=self.event, team=self.team)
@@ -4346,6 +4369,33 @@ class CoachLineupViewTests(TestCase):
lineup.refresh_from_db()
self.assertIsNotNone(lineup.published_at)
def test_publish_button_still_offered_after_the_lineup_is_published(self):
# A coach can keep editing a published lineup (test_save_selects_the_
# submitted_players above), and Save alone never notifies anyone -- so
# "Publish changes" has to stay reachable to actually push an edit out.
self.client.force_login(self.user)
Lineup.objects.create(event=self.event, team=self.team, published_at=timezone.now())
response = self.client.get(reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(response, "Publish changes")
def test_republishing_only_notifies_players_whose_status_changed(self):
self.client.force_login(self.user)
lineup = Lineup.objects.create(event=self.event, team=self.team)
LineupSelection.objects.create(lineup=lineup, member=self.player)
self.client.post(reverse("mobile:coach_lineup_publish", kwargs={"event_id": self.event.pk}), HTTP_HOST="ajax-united.rosterchief.app")
Notification.objects.filter(member=self.player).delete()
# Editing selections (Save) alone must not notify -- only a fresh Publish does.
self.client.post(reverse("mobile:coach_lineup", kwargs={"event_id": self.event.pk}), {}, HTTP_HOST="ajax-united.rosterchief.app")
self.assertFalse(Notification.objects.filter(member=self.player).exists())
self.client.post(reverse("mobile:coach_lineup_publish", kwargs={"event_id": self.event.pk}), HTTP_HOST="ajax-united.rosterchief.app")
notification = Notification.objects.get(member=self.player)
self.assertIn("not in it this time", notification.body)
def test_schedule_sets_scheduled_publish_at_without_publishing(self):
self.client.force_login(self.user)
lineup = Lineup.objects.create(event=self.event, team=self.team)