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

@@ -28,6 +28,99 @@
<span>{% trans "This club has no seasons yet, so there's no roster or staff to show." %}</span>
</div>
{% else %}
<div class="mb-4 grid gap-4 lg:grid-cols-3">
<div class="card bg-base-100 shadow border-l-4 {% if attendance_rate is None %}border-info{% elif attendance_rate < 30 %}border-error{% elif attendance_rate < 65 %}border-warning{% else %}border-success{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "user-check" size=16 %} {% trans "Attendance rate" %}</div>
<div class="text-4xl font-bold tabular-nums font-mono">
{% if attendance_rate is None %}
{% trans "N/A" %}
{% else %}
{{ attendance_rate }}%
{% endif %}
</div>
<div class="text-xs opacity-60">
{% if attendance_rate is None %}
{% trans "No past events this season" %}
{% else %}
<progress class="progress w-full {% if attendance_rate < 30 %}progress-error{% elif attendance_rate < 65 %}progress-warning{% else %}progress-success{% endif %}" value="{{ attendance_rate }}" max="100"></progress>
{% endif %}
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "trending-up" size=18 %} {% trans "Top attenders" %}</h2>
<ul class="divide-y divide-base-200">
{% for entry in top_attenders %}
<li class="flex items-center justify-between py-2">
<span class="text-sm">{{ entry.member }}</span>
<span class="font-semibold tabular-nums font-mono">{{ entry.rate }}%</span>
</li>
{% empty %}
<li class="py-2 text-center text-sm opacity-60">{% trans "Not enough data yet." %}</li>
{% endfor %}
</ul>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "trending-down" size=18 %} {% trans "Needs attention" %}</h2>
<ul class="divide-y divide-base-200">
{% for entry in bottom_attenders %}
<li class="flex items-center justify-between py-2">
<span class="text-sm">{{ entry.member }}</span>
<span class="font-semibold tabular-nums font-mono">{{ entry.rate }}%</span>
</li>
{% empty %}
<li class="py-2 text-center text-sm opacity-60">{% trans "Not enough data yet." %}</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<div class="mb-6 grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "alert-triangle" size=18 %} {% trans "Missed the last 2 practices" %}</h2>
<ul class="divide-y divide-base-200">
{% for member in missed_practices %}
<li class="py-2 text-sm">{{ member }}</li>
{% empty %}
<li class="py-2 text-center text-sm opacity-60">{% trans "No one -- or not enough practice history yet." %}</li>
{% endfor %}
</ul>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "user-x" size=18 %} {% trans "No-shows" %}</h2>
<p class="text-sm opacity-70">{% trans "Said they'd attend, but were checked in as absent." %}</p>
<div class="overflow-x-auto">
<table class="table">
<tbody>
{% for entry in no_shows %}
<tr>
<td>{{ entry.member }}</td>
<td class="opacity-70">{{ entry.event.title }}</td>
<td class="whitespace-nowrap text-right opacity-60">{{ entry.event.start|date:"j M" }}</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center opacity-60">{% trans "None recorded." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">

View File

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

View File

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