Add a No-shows count to the player detail sheet

To answer the question this was built to address: "Absent" only ever reads
the RSVP (status=absent) -- someone who said they were coming but never
turned up still counts as present there, since present/absent don't touch
showed_up at all. member_attendance_counts now also returns no_shows, same
present/selected + showed_up=False definition team_no_shows already uses,
so that case shows up as its own number instead of being invisible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 14:06:23 +02:00
parent 3501dbce94
commit 4af598500b
4 changed files with 56 additions and 1 deletions

View File

@@ -197,11 +197,20 @@ def member_attendance_sparkline(member, season, *, limit=12):
def member_attendance_counts(member, season):
"""Present/Absent/No-reply totals for the season, alongside the
sparkline -- counts every past attendance row, not just the (possibly
truncated) ones the sparkline itself displays."""
truncated) ones the sparkline itself displays.
``present``/``absent`` read the RSVP (``status``) only -- someone who
said they were coming but never actually turned up still counts as
"present" here, since nothing above touches ``showed_up`` (a separate
axis, only ever set by a coach's bench check-in -- events.services.
attendance.record_check_in). ``no_shows`` is that other axis, same
present/selected + showed_up=False definition as team_no_shows below,
just counted per member instead of listed per team."""
return Attendance.objects.filter(member=member, event__season=season, event__start__lt=timezone.now()).aggregate(
present=Count("id", filter=Q(status=Attendance.AttendanceStatus.PRESENT)),
absent=Count("id", filter=Q(status=Attendance.AttendanceStatus.ABSENT)),
no_reply=Count("id", filter=Q(status=Attendance.AttendanceStatus.NO_RESPONSE)),
no_shows=Count("id", filter=Q(status__in=[Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.SELECTED], showed_up=False)),
)

View File

@@ -34,6 +34,7 @@ from .services import (
team_attendance_rate,
team_no_shows,
)
from .services.attendance import member_attendance_counts
from .services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid
from .services.lineup import cancel_scheduled_publish, notify_dropout, publish_lineup, schedule_lineup_publish, 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
@@ -1065,6 +1066,34 @@ class TeamAttendanceStatsTests(EventsTestBase):
self.assertEqual(list(team_no_shows(self.team, self.season)), [])
def test_member_attendance_counts_no_shows_matches_team_no_shows_definition(self):
event = self.make_past_training(1)
attendance = self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT)
record_check_in(attendance, showed_up=False)
counts = member_attendance_counts(self.alice, self.season)
self.assertEqual(counts["no_shows"], 1)
# Still counted as present -- present/absent read the RSVP only, not showed_up.
self.assertEqual(counts["present"], 1)
def test_member_attendance_counts_no_shows_ignores_an_unchecked_present_rsvp(self):
event = self.make_past_training(1)
self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT)
counts = member_attendance_counts(self.alice, self.season)
self.assertEqual(counts["no_shows"], 0)
def test_member_attendance_counts_no_shows_ignores_a_confirmed_check_in(self):
event = self.make_past_training(1)
attendance = self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT)
record_check_in(attendance, showed_up=True)
counts = member_attendance_counts(self.alice, self.season)
self.assertEqual(counts["no_shows"], 0)
RBIHF_TEAM_ID = "4460"
RBIHF_TEAM_NAME = "Sportoase Antwerp Phantoms"