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:
@@ -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) —
|
||||
|
||||
@@ -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"]
|
||||
|
||||
18
events/migrations/0010_attendance_showed_up.py
Normal file
18
events/migrations/0010_attendance_showed_up.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
116
events/tests.py
116
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)), [])
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user