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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
This commit is contained in:
2026-08-03 23:08:47 +02:00
parent ab1d34cd0a
commit 9a4da9b136
10 changed files with 408 additions and 5 deletions

View File

@@ -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"]

View File

@@ -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'),
),
]

View File

@@ -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:

View File

@@ -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",
]

View File

@@ -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")
)

View File

@@ -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)), [])