diff --git a/events/services/attendance.py b/events/services/attendance.py index 0dddc00..7dcecb5 100644 --- a/events/services/attendance.py +++ b/events/services/attendance.py @@ -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)), ) diff --git a/events/tests.py b/events/tests.py index f1bb4e8..de5a813 100644 --- a/events/tests.py +++ b/events/tests.py @@ -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" diff --git a/mobile/templates/mobile/coach/roster_member.html b/mobile/templates/mobile/coach/roster_member.html index bc907ed..3876364 100644 --- a/mobile/templates/mobile/coach/roster_member.html +++ b/mobile/templates/mobile/coach/roster_member.html @@ -62,6 +62,10 @@
{{ attendance_counts.no_reply }}
{% trans "No reply" %}
+
+
{{ attendance_counts.no_shows }}
+
{% trans "No-shows" %}
+
diff --git a/mobile/tests.py b/mobile/tests.py index 80172f2..0b9bd45 100644 --- a/mobile/tests.py +++ b/mobile/tests.py @@ -10,6 +10,7 @@ from icalendar import Calendar as ICalCalendar from club.models import Club, ClubMembership, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor 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 news.models import News from notifications.models import Notification @@ -2766,6 +2767,18 @@ class CoachRosterMemberViewTests(TestCase): 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): self.client.force_login(self.user)