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