Build the club metrics

The club page now leads with its own numbers that should be zero, then the health
signals underneath.

- Teams with no coach. Not a statistic but a defect in the club's setup: with
  nobody in a management position the access service grants no authority over that
  team, so nobody can pick the squad. A physio does not count -- the query keys on
  Position.management_position, and on this season only.
- Unrostered members: active, paid, and on no team.
- Unpaid money bucketed by age. "€250 overdue past 60 days" drives a phone call;
  "€250 outstanding" does not.
- Renewal rate -- last season's actives who signed up again. Exactly computable
  because memberships are season-scoped.
- Turnout, plus the share who never responded. Silence is not an absence, so it is
  excluded from turnout and reported separately: no-response is the leading
  indicator, since it measures whether members use the app at all.

Two of these return None rather than a number, deliberately: a club in its first
season has not failed to renew anyone, and a season with no past events has no
turnout. Rendering either as 0% would libel the club, so the page says why instead.

Money is pinned to two decimals -- SQLite's Sum() drops trailing zeros, so an
aggregate rendered "€250" next to a "€0.00" constant on the same card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 01:08:37 +02:00
parent 21d3947d08
commit f2b78bb1dd
6 changed files with 508 additions and 18 deletions

View File

@@ -13,14 +13,31 @@ from django.utils import timezone
from waffle import get_waffle_flag_model, get_waffle_switch_model
from club.models import Club, ClubMembership, ClubRole, Season
from events.models import Event
from events.models import Attendance, Event
from members.models import Member
from shop.models import Order
from teams.models import Position, Team, TeamMembership
from teams.models import Position, StaffAssignment, Team, TeamMembership
from .services.admins import grant_club_admin
from .services.platform_admins import PlatformAdminError, is_last_superuser, set_platform_access
from .services.statistics import admins_pending_mfa, club_statistics, clubs_with_totals, clubs_without_a_season, dormant_clubs, flag_adoption, onboarding_funnel, platform_attention, platform_charts, platform_totals
from .services.statistics import (
admins_pending_mfa,
attendance_rates,
club_attention,
club_statistics,
clubs_with_totals,
clubs_without_a_season,
dormant_clubs,
fee_aging,
flag_adoption,
onboarding_funnel,
platform_attention,
platform_charts,
platform_totals,
renewal_rate,
teams_without_a_manager,
unrostered_members,
)
from .templatetags.ui import as_alert, daisy, excluded, field_icon
User = get_user_model()
@@ -716,3 +733,136 @@ class DashboardMetricsTests(ControlPanelTestBase):
self.assertContains(response, 'id="revenue-chart"')
self.assertContains(response, "js/chart.js")
self.assertIn("signups", response.context["charts"])
class ClubAttentionTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United")
self.today = timezone.localdate()
self.season = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=30), end_date=self.today + datetime.timedelta(days=300))
self.member = Member.objects.create(first_name="Ada", last_name="Lovelace")
def membership(self, member=None, season=None, **kwargs):
return ClubMembership.objects.create(club=self.club, season=season or self.season, member=member or self.member, **kwargs)
def test_a_team_with_no_manager_is_flagged(self):
team = Team.objects.create(club=self.club, name="U15")
self.assertIn(team, teams_without_a_manager(self.club, self.season))
coach = Position.objects.create(club=self.club, name="Coach", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=team, member=self.member, season=self.season, position=coach)
self.assertNotIn(team, teams_without_a_manager(self.club, self.season))
def test_a_non_management_staffer_does_not_count_as_a_coach(self):
# Somebody has to be able to pick the squad; a physio cannot.
team = Team.objects.create(club=self.club, name="U15")
physio = Position.objects.create(club=self.club, name="Physio", staff_position=True, management_position=False)
StaffAssignment.objects.create(team=team, member=self.member, season=self.season, position=physio)
self.assertIn(team, teams_without_a_manager(self.club, self.season))
def test_a_coach_from_a_previous_season_does_not_count(self):
old = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=400), end_date=self.today - datetime.timedelta(days=40))
team = Team.objects.create(club=self.club, name="U15")
coach = Position.objects.create(club=self.club, name="Coach", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=team, member=self.member, season=old, position=coach)
self.assertIn(team, teams_without_a_manager(self.club, self.season))
def test_an_active_member_on_no_team_is_unrostered(self):
self.membership(status=ClubMembership.StatusChoices.ACTIVE)
self.assertIn(self.member, unrostered_members(self.club, self.season))
team = Team.objects.create(club=self.club, name="U15")
position = Position.objects.create(club=self.club, name="Forward")
TeamMembership.objects.create(team=team, member=self.member, season=self.season, position=position, jersey_number=9)
self.assertNotIn(self.member, unrostered_members(self.club, self.season))
def test_a_pending_member_is_not_counted_as_unrostered(self):
# They have not been let in yet, so having no team is expected.
self.membership(status=ClubMembership.StatusChoices.PENDING)
self.assertNotIn(self.member, unrostered_members(self.club, self.season))
def test_renewal_compares_against_the_previous_season(self):
previous = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=400), end_date=self.today - datetime.timedelta(days=40))
stayed = self.member
left = Member.objects.create(first_name="Bob", last_name="Bobson")
self.membership(member=stayed, season=previous, status=ClubMembership.StatusChoices.ACTIVE)
self.membership(member=left, season=previous, status=ClubMembership.StatusChoices.ACTIVE)
self.membership(member=stayed, season=self.season)
self.assertEqual(renewal_rate(self.club, self.season), 50)
def test_a_previous_season_with_nobody_active_yields_no_rate(self):
# There is a season to compare against but nobody to renew — dividing by that
# would blow up, and calling it 0% would be a lie.
previous = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=400), end_date=self.today - datetime.timedelta(days=40))
self.membership(season=previous, status=ClubMembership.StatusChoices.LAPSED)
self.assertIsNone(renewal_rate(self.club, self.season))
def test_a_first_season_club_has_no_renewal_rate(self):
# It has not failed to renew anyone; rendering that as 0% would libel it.
self.membership(status=ClubMembership.StatusChoices.ACTIVE)
self.assertIsNone(renewal_rate(self.club, self.season))
def test_unpaid_orders_are_bucketed_by_age(self):
old = Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("100.00"), status=Order.OrderStatus.PENDING)
Order.objects.filter(pk=old.pk).update(created=timezone.now() - datetime.timedelta(days=90))
Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("40.00"), status=Order.OrderStatus.PENDING)
Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("999.00"), status=Order.OrderStatus.PAID)
buckets = {bucket["label"]: bucket["total"] for bucket in fee_aging(self.club)}
self.assertEqual(buckets["0-30 days"], Decimal("40.00"))
self.assertEqual(buckets["60+ days"], Decimal("100.00")) # paid orders are not owed
def test_attendance_is_turnout_of_those_who_answered(self):
event = Event.objects.create(club=self.club, season=self.season, title="Match", start=timezone.now() - datetime.timedelta(days=1))
bob = Member.objects.create(first_name="Bob", last_name="Bobson")
carol = Member.objects.create(first_name="Carol", last_name="Carolson")
Attendance.objects.create(event=event, member=self.member, status=Attendance.AttendanceStatus.PRESENT)
Attendance.objects.create(event=event, member=bob, status=Attendance.AttendanceStatus.ABSENT)
Attendance.objects.create(event=event, member=carol, status=Attendance.AttendanceStatus.NO_RESPONSE)
rates = attendance_rates(self.club, self.season)
self.assertEqual(rates["turnout"], 50) # 1 present of 2 who answered — silence is not an absence
self.assertEqual(rates["no_response"], 33) # 1 of 3 never answered
def test_a_future_event_does_not_drag_turnout_down(self):
event = Event.objects.create(club=self.club, season=self.season, title="Next week", start=timezone.now() + datetime.timedelta(days=7))
Attendance.objects.create(event=event, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
self.assertIsNone(attendance_rates(self.club, self.season)["turnout"])
def test_a_club_with_no_season_reports_no_rates(self):
Season.objects.all().delete()
attention = club_attention(self.club)
self.assertTrue(attention["no_season"])
self.assertEqual(attention["teams_without_manager"], 0)
self.assertIsNone(attention["renewal_rate"])
class ClubDetailMetricsTests(ControlPanelTestBase):
def test_the_club_page_renders_its_metrics_and_charts(self):
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertContains(response, "No coach")
self.assertContains(response, "Unrostered")
self.assertContains(response, "Unpaid, by age")
self.assertContains(response, 'id="fees-chart"')
self.assertIn("fees", response.context["charts"])
def test_a_club_without_a_season_is_told_it_is_inert(self):
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertContains(response, "cannot take a signup")