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

@@ -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 club-wide (`team=None`), so `season` stays a first-class FK. Keep it consistent in a
service/clean(). 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) ### 5.4 `news`, `pages`, `home` (public site / editorial)
**`news` is built** (as of the coach_manager-authoring / editor-release-flow work) — **`news` is built** (as of the coach_manager-authoring / editor-release-flow work) —

View File

@@ -58,7 +58,7 @@ class EventAdmin(admin.ModelAdmin):
@admin.register(Attendance) @admin.register(Attendance)
class AttendanceAdmin(admin.ModelAdmin): class AttendanceAdmin(admin.ModelAdmin):
list_display = ["event", "member", "status"] list_display = ["event", "member", "status", "showed_up"]
list_filter = ["status", "event__kind"] list_filter = ["status", "showed_up", "event__kind"]
search_fields = ["event__title", "member__first_name", "member__last_name"] search_fields = ["event__title", "member__first_name", "member__last_name"]
raw_id_fields = ["event", "member"] 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")) 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")) 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) 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) note = models.TextField(_("note"), blank=True)
class Meta: 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 ( from .recurrence import (
apply_template, apply_template,
cancel_occurrence, cancel_occurrence,
@@ -17,6 +25,11 @@ __all__ = [
"generate_occurrences", "generate_occurrences",
"horizon", "horizon",
"occurrence_datetimes", "occurrence_datetimes",
"player_attendance_rankings",
"players_who_missed_recent_practices",
"propagate_series", "propagate_series",
"record_check_in",
"sync_event_attendances", "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. 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 django.utils import timezone
from club.models import Season from club.models import Season
from events.models import Attendance from events.models import Attendance, Event
from members.models import Member from members.models import Member
from teams.models import TeamMembership from teams.models import TeamMembership
@@ -57,3 +58,85 @@ def sync_event_attendances(event):
to_remove = existing_ids - desired_ids to_remove = existing_ids - desired_ids
if to_remove: if to_remove:
event.attendances.filter(member_id__in=to_remove).delete() 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, effective_members,
generate_occurrences, generate_occurrences,
occurrence_datetimes, occurrence_datetimes,
player_attendance_rankings,
players_who_missed_recent_practices,
propagate_series, 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) series = EventSeries.objects.create(club=self.club, title="Weekly", rrule="FREQ=WEEKLY", dtstart=self.future)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
series.teams.add(self.other_team) 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)), [])

View File

@@ -28,6 +28,99 @@
<span>{% trans "This club has no seasons yet, so there's no roster or staff to show." %}</span> <span>{% trans "This club has no seasons yet, so there's no roster or staff to show." %}</span>
</div> </div>
{% else %} {% 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 bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
<div class="flex items-center justify-between"> <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 django.utils import timezone
from club.models import Club, ClubMembership, ClubRole, FeePayment, Season 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.bulk_import import TEMPLATE_COLUMNS
from management.pdf import PDFExportError, render_pdf from management.pdf import PDFExportError, render_pdf
from members.models import Family, FamilyMembership, Member from members.models import Family, FamilyMembership, Member
@@ -2161,3 +2161,51 @@ class NewsManagementTests(ManagementTestBase):
response = self.club_get("news_list") response = self.club_get("news_list")
self.assertNotContains(response, reverse("management:news_update", args=[item.pk])) 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.mixins import RedirectOnInvalidMixin
from controlpanel.services.statistics import club_attention, club_charts, club_statistics from controlpanel.services.statistics import club_attention, club_charts, club_statistics
from events.models import Event, EventSeries, Location, Opponent 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 Form as FormBuilderForm
from formbuilder.models import Submission from formbuilder.models import Submission
from members.models import Family, FamilyMembership, Member from members.models import Family, FamilyMembership, Member
@@ -757,6 +758,10 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView):
roster = TeamMembership.objects.none() roster = TeamMembership.objects.none()
staff = StaffAssignment.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: 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")) 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")) 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: for assignment in staff:
assignment.edit_form = StaffAssignmentForm(instance=assignment, club=club, team=team, season=season) 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( return super().get_context_data(
seasons=Season.objects.filter(club=club).order_by("-start_date"), seasons=Season.objects.filter(club=club).order_by("-start_date"),
selected_season=season, selected_season=season,
@@ -774,6 +786,11 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView):
can_manage=can_manage, can_manage=can_manage,
roster_form=TeamMembershipForm(club=club, team=team, season=season) if can_manage and season else None, 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, 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, **kwargs,
) )