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): def member_attendance_counts(member, season):
"""Present/Absent/No-reply totals for the season, alongside the """Present/Absent/No-reply totals for the season, alongside the
sparkline -- counts every past attendance row, not just the (possibly 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( return Attendance.objects.filter(member=member, event__season=season, event__start__lt=timezone.now()).aggregate(
present=Count("id", filter=Q(status=Attendance.AttendanceStatus.PRESENT)), present=Count("id", filter=Q(status=Attendance.AttendanceStatus.PRESENT)),
absent=Count("id", filter=Q(status=Attendance.AttendanceStatus.ABSENT)), absent=Count("id", filter=Q(status=Attendance.AttendanceStatus.ABSENT)),
no_reply=Count("id", filter=Q(status=Attendance.AttendanceStatus.NO_RESPONSE)), 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_attendance_rate,
team_no_shows, 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.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.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 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)), []) 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_ID = "4460"
RBIHF_TEAM_NAME = "Sportoase Antwerp Phantoms" RBIHF_TEAM_NAME = "Sportoase Antwerp Phantoms"

View File

@@ -62,6 +62,10 @@
<div class="font-display text-2xl leading-none font-extrabold text-dim tabular-nums">{{ attendance_counts.no_reply }}</div> <div class="font-display text-2xl leading-none font-extrabold text-dim tabular-nums">{{ attendance_counts.no_reply }}</div>
<div class="mt-1 font-display text-[10px] font-extrabold tracking-wide text-muted uppercase">{% trans "No reply" %}</div> <div class="mt-1 font-display text-[10px] font-extrabold tracking-wide text-muted uppercase">{% trans "No reply" %}</div>
</div> </div>
<div class="flex-1">
<div class="font-display text-2xl leading-none font-extrabold text-warn-text tabular-nums">{{ attendance_counts.no_shows }}</div>
<div class="mt-1 font-display text-[10px] font-extrabold tracking-wide text-muted uppercase">{% trans "No-shows" %}</div>
</div>
</div> </div>
</div> </div>

View File

@@ -10,6 +10,7 @@ from icalendar import Calendar as ICalCalendar
from club.models import Club, ClubMembership, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor from club.models import Club, ClubMembership, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
from events.models import Attendance, Event, EventReferee, Lineup, LineupSelection, Location, RefereeSignup from events.models import Attendance, Event, EventReferee, Lineup, LineupSelection, Location, RefereeSignup
from events.services.attendance import record_check_in
from members.models import Family, FamilyMembership, Member from members.models import Family, FamilyMembership, Member
from news.models import News from news.models import News
from notifications.models import Notification from notifications.models import Notification
@@ -2766,6 +2767,18 @@ class CoachRosterMemberViewTests(TestCase):
self.assertEqual(response.context["attendance_counts"]["present"], 1) self.assertEqual(response.context["attendance_counts"]["present"], 1)
def test_shows_no_shows(self):
past_event = Event.objects.create(club=self.club, title="Past practice", kind=Event.EventKind.TRAINING, start=timezone.now() - datetime.timedelta(days=2), season=self.season)
past_event.teams.add(self.team)
attendance, _created = Attendance.objects.update_or_create(event=past_event, member=self.player, defaults={"status": Attendance.AttendanceStatus.PRESENT})
record_check_in(attendance, showed_up=False)
self.client.force_login(self.user)
response = self._get()
self.assertEqual(response.context["attendance_counts"]["no_shows"], 1)
self.assertContains(response, "No-shows")
def test_managing_staff_sees_the_edit_form_and_remove_button(self): def test_managing_staff_sees_the_edit_form_and_remove_button(self):
self.client.force_login(self.user) self.client.force_login(self.user)