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:
@@ -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)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user