From 9a4da9b1365c1dcaad5ddc4af55590253ba6dd0d Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Mon, 3 Aug 2026 23:08:47 +0200 Subject: [PATCH] Add a team attendance KPI panel and no-show check-in tracking Team pages now show, for the selected season: overall attendance rate, best/worst attenders, players who missed the last 2 practices, and no-shows (an affirmative RSVP checked in as absent). No-shows need a real distinction the RSVP status alone can't make, so Attendance gains a separate showed_up tri-state field, usable today via Django admin -- a full check-in screen is future work for the coaches app. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy --- ARCHITECTURE.md | 8 ++ events/admin.py | 4 +- .../migrations/0010_attendance_showed_up.py | 18 +++ events/models.py | 7 ++ events/services/__init__.py | 15 ++- events/services/attendance.py | 85 ++++++++++++- events/tests.py | 116 ++++++++++++++++++ .../templates/management/team_detail.html | 93 ++++++++++++++ management/tests.py | 50 +++++++- management/views.py | 17 +++ 10 files changed, 408 insertions(+), 5 deletions(-) create mode 100644 events/migrations/0010_attendance_showed_up.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d94c8e4..c76be18 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -421,6 +421,14 @@ Attendance(UUIDModel) # through model Event <-> Member club-wide (`team=None`), so `season` stays a first-class FK. Keep it consistent in a service/clean(). +**As built, `Attendance` also carries `showed_up`** (nullable bool, default `None`) — +deliberately separate from `status`: `status` is the RSVP, `showed_up` is whether they +actually turned up, set by a check-in. `None` means "never checked in" (true for every +row today — there's no check-in UI yet, only Django admin); a "no-show" is +`status in (present, selected)` and `showed_up is False`, and is *never* inferred from +a missing check-in. See `events/services/attendance.py::record_check_in` and +`management/views.py::TeamDetailView`'s attendance panel. + ### 5.4 `news`, `pages`, `home` (public site / editorial) **`news` is built** (as of the coach_manager-authoring / editor-release-flow work) — diff --git a/events/admin.py b/events/admin.py index 824cbcc..a63394f 100644 --- a/events/admin.py +++ b/events/admin.py @@ -58,7 +58,7 @@ class EventAdmin(admin.ModelAdmin): @admin.register(Attendance) class AttendanceAdmin(admin.ModelAdmin): - list_display = ["event", "member", "status"] - list_filter = ["status", "event__kind"] + list_display = ["event", "member", "status", "showed_up"] + list_filter = ["status", "showed_up", "event__kind"] search_fields = ["event__title", "member__first_name", "member__last_name"] raw_id_fields = ["event", "member"] diff --git a/events/migrations/0010_attendance_showed_up.py b/events/migrations/0010_attendance_showed_up.py new file mode 100644 index 0000000..8ca25f2 --- /dev/null +++ b/events/migrations/0010_attendance_showed_up.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-08-03 20:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0009_attendance_created_attendance_modified_event_created_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='attendance', + name='showed_up', + field=models.BooleanField(blank=True, default=None, help_text='Recorded by a check-in, separate from the RSVP status above. Blank means no check-in has been recorded yet.', null=True, verbose_name='showed up'), + ), + ] diff --git a/events/models.py b/events/models.py index 607e248..19c242b 100644 --- a/events/models.py +++ b/events/models.py @@ -124,6 +124,13 @@ class Attendance(UUIDModel): event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="attendances", verbose_name=_("event")) member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="attendances", verbose_name=_("member")) status = models.CharField(_("status"), max_length=20, choices=AttendanceStatus.choices, default=AttendanceStatus.NO_RESPONSE) + showed_up = models.BooleanField( + _("showed up"), + null=True, + blank=True, + default=None, + help_text=_("Recorded by a check-in, separate from the RSVP status above. Blank means no check-in has been recorded yet."), + ) note = models.TextField(_("note"), blank=True) class Meta: diff --git a/events/services/__init__.py b/events/services/__init__.py index 8a07cde..38c1135 100644 --- a/events/services/__init__.py +++ b/events/services/__init__.py @@ -1,4 +1,12 @@ -from .attendance import effective_members, sync_event_attendances +from .attendance import ( + effective_members, + player_attendance_rankings, + players_who_missed_recent_practices, + record_check_in, + sync_event_attendances, + team_attendance_rate, + team_no_shows, +) from .recurrence import ( apply_template, cancel_occurrence, @@ -17,6 +25,11 @@ __all__ = [ "generate_occurrences", "horizon", "occurrence_datetimes", + "player_attendance_rankings", + "players_who_missed_recent_practices", "propagate_series", + "record_check_in", "sync_event_attendances", + "team_attendance_rate", + "team_no_shows", ] diff --git a/events/services/attendance.py b/events/services/attendance.py index 004b461..03383a8 100644 --- a/events/services/attendance.py +++ b/events/services/attendance.py @@ -6,10 +6,11 @@ The audience of an event is the union of the current rosters of its ``teams`` for events that are still in the future — history is never rewritten. """ +from django.db.models import Count, Q from django.utils import timezone from club.models import Season -from events.models import Attendance +from events.models import Attendance, Event from members.models import Member from teams.models import TeamMembership @@ -57,3 +58,85 @@ def sync_event_attendances(event): to_remove = existing_ids - desired_ids if to_remove: event.attendances.filter(member_id__in=to_remove).delete() + + +def record_check_in(attendance, *, showed_up): + """Record whether ``attendance``'s member actually showed up, separate from + their RSVP status -- the hook a future check-in UI (the coaches app) writes + through. Nothing in this codebase calls this yet; it exists so a no-show can + be recorded the moment something does.""" + attendance.showed_up = showed_up + attendance.save(update_fields=["showed_up"]) + + +def team_attendance_rate(team, season): + """Turnout for ``team`` in ``season``: present / (present + absent) among + past events -- same definition as + controlpanel.services.statistics.attendance_rates, just scoped to one team + instead of the whole club.""" + counts = Attendance.objects.filter(event__teams=team, 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)), + ) + answered = counts["present"] + counts["absent"] + return round(100 * counts["present"] / answered) if answered else None + + +def player_attendance_rankings(team, season, *, minimum_responses=3): + """Each player's turnout this season, best first: + ``[{"member": Member, "rate": int, "responses": int}, ...]``. Excludes + anyone with fewer than ``minimum_responses`` present/absent replies -- one + absence out of one invite would otherwise read as "0%, worst on the team." + """ + rows = ( + Attendance.objects.filter( + event__teams=team, + event__season=season, + event__start__lt=timezone.now(), + status__in=[Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.ABSENT], + ) + .values("member") + .annotate(present=Count("id", filter=Q(status=Attendance.AttendanceStatus.PRESENT)), responses=Count("id")) + .filter(responses__gte=minimum_responses) + ) + + members_by_id = Member.objects.in_bulk([row["member"] for row in rows]) + rankings = [{"member": members_by_id[row["member"]], "rate": round(100 * row["present"] / row["responses"]), "responses": row["responses"]} for row in rows] + rankings.sort(key=lambda entry: entry["rate"], reverse=True) + return rankings + + +def players_who_missed_recent_practices(team, season, *, count=2): + """Members absent or silent (not excused) on *every one* of the team's + ``count`` most recent past training-kind events this season. Empty if the + team has fewer than ``count`` past practices logged yet -- not enough + history to call anyone out.""" + practices = list(Event.objects.filter(teams=team, season=season, kind=Event.EventKind.TRAINING, start__lt=timezone.now()).order_by("-start")[:count]) + if len(practices) < count: + return Member.objects.none() + + flagged = (Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.NO_RESPONSE) + missed_by_member = None + for practice in practices: + missed_here = set(Attendance.objects.filter(event=practice, status__in=flagged).values_list("member_id", flat=True)) + missed_by_member = missed_here if missed_by_member is None else missed_by_member & missed_here + + return Member.objects.filter(pk__in=missed_by_member).order_by("last_name", "first_name") + + +def team_no_shows(team, season): + """Attendance rows where the member RSVPed present/selected but was + checked in as showed_up=False -- most recent first. Each entry carries + both the member and the event: a no-show is about a specific missed + occasion, not a season-long rate. Never inferred from a missing check-in -- + with nothing checking anyone in yet, that would flag every "present" RSVP.""" + return ( + Attendance.objects.filter( + event__teams=team, + event__season=season, + status__in=[Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.SELECTED], + showed_up=False, + ) + .select_related("member", "event") + .order_by("-event__start") + ) diff --git a/events/tests.py b/events/tests.py index fcd4c4e..449e111 100644 --- a/events/tests.py +++ b/events/tests.py @@ -18,7 +18,12 @@ from .services import ( effective_members, generate_occurrences, occurrence_datetimes, + player_attendance_rankings, + players_who_missed_recent_practices, propagate_series, + record_check_in, + team_attendance_rate, + team_no_shows, ) @@ -401,3 +406,114 @@ class EventClubScopeTests(EventsTestBase): series = EventSeries.objects.create(club=self.club, title="Weekly", rrule="FREQ=WEEKLY", dtstart=self.future) with self.assertRaises(ValidationError): series.teams.add(self.other_team) + + +class TeamAttendanceStatsTests(EventsTestBase): + """events.services.attendance's team+season-scoped stats -- the queries + behind management.views.TeamDetailView's attendance panel.""" + + def make_past_training(self, days_ago, **kwargs): + # Past-start events are never auto-synced (see test_past_event_is_not_synced + # above), so attendance rows have to be created by hand here. + kwargs.setdefault("kind", Event.EventKind.TRAINING) + event = self.make_event(start=timezone.now() - timedelta(days=days_ago), **kwargs) + event.teams.add(self.team) + return event + + def set_status(self, event, member, status): + attendance, _created = Attendance.objects.update_or_create(event=event, member=member, defaults={"status": status}) + return attendance + + def test_record_check_in_sets_showed_up(self): + event = self.make_past_training(1) + attendance = self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT) + + record_check_in(attendance, showed_up=False) + + attendance.refresh_from_db() + self.assertFalse(attendance.showed_up) + + def test_team_attendance_rate_excludes_excused_and_no_response(self): + event = self.make_past_training(1) + self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT) + self.set_status(event, self.bob, Attendance.AttendanceStatus.ABSENT) + + self.assertEqual(team_attendance_rate(self.team, self.season), 50) + + def test_team_attendance_rate_is_none_with_no_past_events(self): + self.assertIsNone(team_attendance_rate(self.team, self.season)) + + def test_rankings_exclude_players_below_the_response_minimum(self): + event = self.make_past_training(1) + self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT) + self.set_status(event, self.bob, Attendance.AttendanceStatus.ABSENT) + + rankings = player_attendance_rankings(self.team, self.season, minimum_responses=2) + + self.assertEqual(rankings, []) + + def test_rankings_rank_best_first(self): + e1 = self.make_past_training(10) + e2 = self.make_past_training(3) + self.set_status(e1, self.alice, Attendance.AttendanceStatus.PRESENT) + self.set_status(e2, self.alice, Attendance.AttendanceStatus.PRESENT) + self.set_status(e1, self.bob, Attendance.AttendanceStatus.PRESENT) + self.set_status(e2, self.bob, Attendance.AttendanceStatus.ABSENT) + + rankings = player_attendance_rankings(self.team, self.season, minimum_responses=2) + + self.assertEqual([entry["member"] for entry in rankings], [self.alice, self.bob]) + self.assertEqual(rankings[0]["rate"], 100) + self.assertEqual(rankings[1]["rate"], 50) + + def test_missed_recent_practices_needs_full_history(self): + self.make_past_training(3) # only one practice logged so far + + self.assertFalse(players_who_missed_recent_practices(self.team, self.season, count=2).exists()) + + def test_missed_recent_practices_flags_absence_on_both(self): + e1 = self.make_past_training(10) + e2 = self.make_past_training(3) + self.set_status(e1, self.alice, Attendance.AttendanceStatus.ABSENT) + self.set_status(e2, self.alice, Attendance.AttendanceStatus.NO_RESPONSE) + self.set_status(e1, self.bob, Attendance.AttendanceStatus.PRESENT) + self.set_status(e2, self.bob, Attendance.AttendanceStatus.ABSENT) + + missed = players_who_missed_recent_practices(self.team, self.season, count=2) + + self.assertEqual(list(missed), [self.alice]) + + def test_missed_recent_practices_excludes_an_excused_absence(self): + e1 = self.make_past_training(10) + e2 = self.make_past_training(3) + self.set_status(e1, self.alice, Attendance.AttendanceStatus.EXCUSED) + self.set_status(e2, self.alice, Attendance.AttendanceStatus.ABSENT) + + missed = players_who_missed_recent_practices(self.team, self.season, count=2) + + self.assertNotIn(self.alice, missed) + + def test_no_shows_requires_an_explicit_check_in(self): + event = self.make_past_training(1) + self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT) + + # Nobody has been checked in at all -- must not read as a no-show. + self.assertEqual(list(team_no_shows(self.team, self.season)), []) + + def test_no_shows_flags_a_present_rsvp_checked_in_as_absent(self): + event = self.make_past_training(1) + attendance = self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT) + record_check_in(attendance, showed_up=False) + + no_shows = team_no_shows(self.team, self.season) + + self.assertEqual(len(no_shows), 1) + self.assertEqual(no_shows[0].member, self.alice) + self.assertEqual(no_shows[0].event, event) + + def test_a_confirmed_check_in_is_not_a_no_show(self): + event = self.make_past_training(1) + attendance = self.set_status(event, self.alice, Attendance.AttendanceStatus.PRESENT) + record_check_in(attendance, showed_up=True) + + self.assertEqual(list(team_no_shows(self.team, self.season)), []) diff --git a/management/templates/management/team_detail.html b/management/templates/management/team_detail.html index 1a3406f..6a540b7 100644 --- a/management/templates/management/team_detail.html +++ b/management/templates/management/team_detail.html @@ -28,6 +28,99 @@ {% trans "This club has no seasons yet, so there's no roster or staff to show." %} {% else %} +
+
+
+
{% lucide "user-check" size=16 %} {% trans "Attendance rate" %}
+
+ {% if attendance_rate is None %} + {% trans "N/A" %} + {% else %} + {{ attendance_rate }}% + {% endif %} +
+
+ {% if attendance_rate is None %} + {% trans "No past events this season" %} + {% else %} + + {% endif %} +
+
+
+ +
+
+

{% lucide "trending-up" size=18 %} {% trans "Top attenders" %}

+
    + {% for entry in top_attenders %} +
  • + {{ entry.member }} + {{ entry.rate }}% +
  • + {% empty %} +
  • {% trans "Not enough data yet." %}
  • + {% endfor %} +
+
+
+ +
+
+

{% lucide "trending-down" size=18 %} {% trans "Needs attention" %}

+
    + {% for entry in bottom_attenders %} +
  • + {{ entry.member }} + {{ entry.rate }}% +
  • + {% empty %} +
  • {% trans "Not enough data yet." %}
  • + {% endfor %} +
+
+
+
+ +
+
+
+

{% lucide "alert-triangle" size=18 %} {% trans "Missed the last 2 practices" %}

+
    + {% for member in missed_practices %} +
  • {{ member }}
  • + {% empty %} +
  • {% trans "No one -- or not enough practice history yet." %}
  • + {% endfor %} +
+
+
+ +
+
+

{% lucide "user-x" size=18 %} {% trans "No-shows" %}

+

{% trans "Said they'd attend, but were checked in as absent." %}

+
+ + + {% for entry in no_shows %} + + + + + + {% empty %} + + + + {% endfor %} + +
{{ entry.member }}{{ entry.event.title }}{{ entry.event.start|date:"j M" }}
{% trans "None recorded." %}
+
+
+
+
+
diff --git a/management/tests.py b/management/tests.py index c68cbec..17eb234 100644 --- a/management/tests.py +++ b/management/tests.py @@ -13,7 +13,7 @@ from django.urls import NoReverseMatch, reverse from django.utils import timezone from club.models import Club, ClubMembership, ClubRole, FeePayment, Season -from events.models import Event +from events.models import Attendance, Event from management.bulk_import import TEMPLATE_COLUMNS from management.pdf import PDFExportError, render_pdf from members.models import Family, FamilyMembership, Member @@ -2161,3 +2161,51 @@ class NewsManagementTests(ManagementTestBase): response = self.club_get("news_list") self.assertNotContains(response, reverse("management:news_update", args=[item.pk])) + + +class TeamAttendancePanelTests(ManagementTestBase): + """The attendance KPI panel on the team page -- see + management.views.TeamDetailView and events.services.attendance.""" + + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + self.position = Position.objects.create(club=self.club, name="Forward", short_name="FW", staff_position=False) + self.player = Member.objects.create(first_name="Peter", last_name="Player") + ClubMembership.objects.create(club=self.club, member=self.player, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.position) + + def make_past_training(self, days_ago=1): + event = Event.objects.create(club=self.club, title="Practice", kind=Event.EventKind.TRAINING, season=self.season, start=timezone.now() - datetime.timedelta(days=days_ago)) + event.teams.add(self.team) + return event + + def test_attendance_panel_shows_the_rate_and_rankings(self): + event = self.make_past_training() + Attendance.objects.create(event=event, member=self.player, status=Attendance.AttendanceStatus.PRESENT) + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertContains(response, "Attendance rate") + self.assertContains(response, "Peter Player") + + def test_a_present_rsvp_without_a_check_in_is_never_a_no_show(self): + event = self.make_past_training() + Attendance.objects.create(event=event, member=self.player, status=Attendance.AttendanceStatus.PRESENT) + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertContains(response, "None recorded.") + + def test_a_checked_in_no_show_appears_in_the_panel(self): + event = self.make_past_training() + attendance = Attendance.objects.create(event=event, member=self.player, status=Attendance.AttendanceStatus.PRESENT, showed_up=False) + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertContains(response, "Peter Player") + self.assertContains(response, attendance.event.title) + self.assertNotContains(response, "None recorded.") diff --git a/management/views.py b/management/views.py index 824e847..9f80eef 100644 --- a/management/views.py +++ b/management/views.py @@ -17,6 +17,7 @@ from controlpanel.messages import notify from controlpanel.mixins import RedirectOnInvalidMixin from controlpanel.services.statistics import club_attention, club_charts, club_statistics from events.models import Event, EventSeries, Location, Opponent +from events.services.attendance import player_attendance_rankings, players_who_missed_recent_practices, team_attendance_rate, team_no_shows from formbuilder.models import Form as FormBuilderForm from formbuilder.models import Submission from members.models import Family, FamilyMembership, Member @@ -757,6 +758,10 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView): roster = TeamMembership.objects.none() staff = StaffAssignment.objects.none() + attendance_rate = None + top_attenders, bottom_attenders = [], [] + missed_practices = Member.objects.none() + no_shows = [] if season is not None: roster = list(TeamMembership.objects.filter(team=team, season=season).select_related("member", "position").order_by("position__ordering", "member__last_name")) staff = list(StaffAssignment.objects.filter(team=team, season=season).select_related("member", "position").order_by("position__ordering", "member__last_name")) @@ -766,6 +771,13 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView): for assignment in staff: assignment.edit_form = StaffAssignmentForm(instance=assignment, club=club, team=team, season=season) + attendance_rate = team_attendance_rate(team, season) + rankings = player_attendance_rankings(team, season) + top_attenders = rankings[:5] + bottom_attenders = list(reversed(rankings))[:5] + missed_practices = players_who_missed_recent_practices(team, season) + no_shows = team_no_shows(team, season)[:10] + return super().get_context_data( seasons=Season.objects.filter(club=club).order_by("-start_date"), selected_season=season, @@ -774,6 +786,11 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView): can_manage=can_manage, roster_form=TeamMembershipForm(club=club, team=team, season=season) if can_manage and season else None, staff_form=StaffAssignmentForm(club=club, team=team, season=season) if can_manage and season else None, + attendance_rate=attendance_rate, + top_attenders=top_attenders, + bottom_attenders=bottom_attenders, + missed_practices=missed_practices, + no_shows=no_shows, **kwargs, )