diff --git a/controlpanel/services/statistics.py b/controlpanel/services/statistics.py index 1b9a553..a0e4570 100644 --- a/controlpanel/services/statistics.py +++ b/controlpanel/services/statistics.py @@ -17,7 +17,7 @@ from waffle import get_waffle_flag_model from authentication.middleware import ELEVATED_ROLES 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 Cart, Order from teams.models import StaffAssignment, Team, TeamMembership @@ -150,6 +150,138 @@ def _money(queryset): return queryset.aggregate(total=Sum("total"))["total"] or ZERO +def previous_season(club, season): + """The season immediately before ``season``. Seasons are ordered by name (which is + derived from the years), so go by the date instead — a club may skip a year.""" + if season is None: + return None + + return Season.objects.filter(club=club, end_date__lt=season.start_date).order_by("-end_date").first() + + +def renewal_rate(club, season): + """Share of last season's active members who signed up again. + + The single best health signal a club has, and it is exactly computable here because + memberships are season-scoped. Returns None when there is no season to compare + against — a first-season club has not failed to renew anyone, and rendering that as + 0% would libel it. + """ + previous = previous_season(club, season) + if previous is None: + return None + + was_active = ClubMembership.objects.filter(club=club, season=previous, status=ClubMembership.StatusChoices.ACTIVE) + total = was_active.count() + if not total: + return None + + returned = ClubMembership.objects.filter(club=club, season=season, member__in=was_active.values("member")).count() + + return round(100 * returned / total) + + +def teams_without_a_manager(club, season): + """Teams with nobody in a management position this season. + + A defect in the club's own setup, not a statistic: without a coach or manager the + access service grants nobody authority over that team, so nobody can pick the squad. + """ + if season is None: + return Team.objects.none() + + return Team.objects.filter(club=club).exclude(staff_assignments__season=season, staff_assignments__position__management_position=True) + + +def unrostered_members(club, season): + """Active members who are on no team this season — people who paid and play nowhere.""" + if season is None: + return Member.objects.none() + + rostered = TeamMembership.objects.filter(team__club=club, season=season).values("member") + + return Member.objects.filter(member_of__club=club, member_of__season=season, member_of__status=ClubMembership.StatusChoices.ACTIVE).exclude(pk__in=rostered).distinct() + + +def fee_aging(club): + """Unpaid orders bucketed by age. "€2,400 overdue past 60 days" drives a phone call; + "€2,400 outstanding" does not.""" + now = timezone.now() + owed = Order.objects.filter(club=club, status__in=OWED_STATUSES) + + buckets = [] + for label, older_than, newer_than in (("0-30 days", 0, 30), ("30-60 days", 30, 60), ("60+ days", 60, None)): + rows = owed.filter(created__lte=now - timedelta(days=older_than)) + if newer_than is not None: + rows = rows.filter(created__gt=now - timedelta(days=newer_than)) + buckets.append({"label": label, "total": _money(rows), "count": rows.count(), "overdue": newer_than is None}) + + return buckets + + +def attendance_rates(club, season): + """Turnout, and how many never answered. + + The no-response share is the leading indicator: it measures whether members are using + the app at all, which every other number here depends on. + """ + if season is None: + return {"turnout": None, "no_response": None, "responses": 0} + + counts = Attendance.objects.filter(event__club=club, 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)), + silent=Count("id", filter=Q(status=Attendance.AttendanceStatus.NO_RESPONSE)), + total=Count("id"), + ) + + answered = counts["present"] + counts["absent"] + + return { + "turnout": round(100 * counts["present"] / answered) if answered else None, + "no_response": round(100 * counts["silent"] / counts["total"]) if counts["total"] else None, + "responses": counts["total"], + } + + +def club_attention(club): + """A club's own numbers that are supposed to be zero.""" + season = Season.covering(club, timezone.localdate()) + memberships = ClubMembership.objects.filter(club=club) + + return { + "season": season, + "no_season": season is None, + "outstanding": _money(Order.objects.filter(club=club, status__in=OWED_STATUSES)), + "aging": fee_aging(club), + "unpaid_members": memberships.filter(season=season, fee_status=ClubMembership.FeeStatus.UNPAID).count() if season else 0, + "pending_approvals": memberships.filter(status=ClubMembership.StatusChoices.PENDING).count(), + "teams_without_manager": teams_without_a_manager(club, season).count(), + "unrostered": unrostered_members(club, season).count(), + "renewal_rate": renewal_rate(club, season), + "attendance": attendance_rates(club, season), + } + + +def club_charts(club): + season = Season.covering(club, timezone.localdate()) + memberships = ClubMembership.objects.filter(club=club, season=season) if season else ClubMembership.objects.none() + + return { + "signups": _monthly(ClubMembership.objects.filter(club=club, signed_up_at__isnull=False), "signed_up_at", Count("id")), + # Fee status this season, in the order a treasurer cares about. + "fees": [ + {"label": label, "value": memberships.filter(fee_status=status).count()} + for status, label in ( + (ClubMembership.FeeStatus.PAID, "Paid"), + (ClubMembership.FeeStatus.PARTIALLY_PAID, "Partial"), + (ClubMembership.FeeStatus.UNPAID, "Unpaid"), + (ClubMembership.FeeStatus.WAIVED, "Waived"), + ) + ], + } + + def club_statistics(club): """Stat groups for one club. Add new groups here as the domain grows.""" season = Season.covering(club, timezone.localdate()) diff --git a/controlpanel/templates/controlpanel/club_detail.html b/controlpanel/templates/controlpanel/club_detail.html index cca65d6..49d5504 100644 --- a/controlpanel/templates/controlpanel/club_detail.html +++ b/controlpanel/templates/controlpanel/club_detail.html @@ -1,5 +1,5 @@ {% extends "controlpanel/base.html" %} -{% load lucide %} +{% load static lucide %} {% block heading %}{{ club.name }}{% endblock heading %} @@ -33,6 +33,120 @@ This club is archived: its subdomain no longer resolves. Nothing has been deleted — restore it to bring it back. {% endif %} + {% if attention.no_season %} +
+ {% lucide "calendar-x" size=20 %} + + No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert. + +
+ {% endif %} + + {% comment %} + The club's own numbers that should be zero. Teams without a manager is a defect in + the club's setup, not a statistic: with nobody in a management position the access + service grants no authority over that team, so nobody can pick the squad. + {% endcomment %} +
+
+
+
{% lucide "banknote" size=16 %} Outstanding
+
€{{ attention.outstanding|floatformat:2 }}
+
{{ attention.unpaid_members }} member{{ attention.unpaid_members|pluralize }} unpaid this season
+
+
+
+
+
{% lucide "user-x" size=16 %} No coach
+
{{ attention.teams_without_manager }}
+
Teams nobody can pick a squad for
+
+
+
+
+
{% lucide "user-minus" size=16 %} Unrostered
+
{{ attention.unrostered }}
+
Active members on no team
+
+
+
+
+
{% lucide "clock" size=16 %} Pending
+
{{ attention.pending_approvals }}
+
Memberships awaiting approval
+
+
+
+ +
+
+
+

{% lucide "repeat" size=18 %} Renewal

+ {% if attention.renewal_rate is None %} + {# No prior season to compare against: a first-season club has not failed to renew anyone. #} +

No previous season to compare against yet.

+ {% else %} +
{{ attention.renewal_rate }}%
+

of last season's active members signed up again

+ + {% endif %} +
+
+ +
+
+

{% lucide "user-check" size=18 %} Attendance

+ {% if attention.attendance.turnout is None %} +

No past events with responses this season.

+ {% else %} +
{{ attention.attendance.turnout }}%
+

turnout of those who answered

+

+ {# The leading indicator: it measures whether members use the app at all. #} + {{ attention.attendance.no_response }}% + never responded +

+ {% endif %} +
+
+ +
+
+

{% lucide "hourglass" size=18 %} Unpaid, by age

+ + + {% for bucket in attention.aging %} + + + + + + {% endfor %} + +
{{ bucket.label }}€{{ bucket.total|floatformat:2 }}{{ bucket.count }} order{{ bucket.count|pluralize }}
+
+
+
+ +
+
+
+

{% lucide "user-plus" size=18 %} Signups per month

+
+ +
+
+
+
+
+

{% lucide "wallet" size=18 %} Fee status this season

+
+ +
+
+
+
+
{% for group in groups %}
@@ -127,3 +241,66 @@
{% endblock panel %} + +{% block extra_body %} + {{ charts|json_script:"chart-data" }} + + +{% endblock extra_body %} diff --git a/controlpanel/templates/controlpanel/dashboard.html b/controlpanel/templates/controlpanel/dashboard.html index 8fff2a9..1485b2b 100644 --- a/controlpanel/templates/controlpanel/dashboard.html +++ b/controlpanel/templates/controlpanel/dashboard.html @@ -40,7 +40,7 @@
{% lucide "banknote" size=16 %} Outstanding
-
€{{ attention.outstanding }}
+
€{{ attention.outstanding|floatformat:2 }}
Unpaid across every club
@@ -50,8 +50,6 @@

{% lucide "user-plus" size=18 %} Signups per month

- {# The wrapper's height is what bounds the canvas: with maintainAspectRatio off, - Chart.js sizes to its parent, and a parent with no height grows without end. #}
@@ -199,31 +197,31 @@ // Locale-aware, so 1234.5 reads as "€ 1.234,50" rather than "€1,234.5". Two of // them: the axis is rounded to keep the labels short, but the tooltip keeps the // cents — rounding a euro amount someone is reading off a chart is a lie. - const axisEuros = new Intl.NumberFormat("nl-BE", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }); - const exactEuros = new Intl.NumberFormat("nl-BE", { style: "currency", currency: "EUR", minimumFractionDigits: 2 }); + const axisEuros = new Intl.NumberFormat("nl-BE", {style: "currency", currency: "EUR", maximumFractionDigits: 0}); + const exactEuros = new Intl.NumberFormat("nl-BE", {style: "currency", currency: "EUR", minimumFractionDigits: 2}); const build = (id, label, series, colour, type, money) => new Chart(document.getElementById(id), { type, data: { labels: series.map((point) => point.month), - datasets: [{ label, data: series.map((point) => point.value), borderColor: colour, backgroundColor: colour, tension: 0.3 }], + datasets: [{label, data: series.map((point) => point.value), borderColor: colour, backgroundColor: colour, tension: 0.3}], }, options: { responsive: true, maintainAspectRatio: false, plugins: { - legend: { display: false }, + legend: {display: false}, // The tooltip carries the unit too: an axis in euros and a bare // number on hover reads as two different quantities. - tooltip: money ? { callbacks: { label: (item) => exactEuros.format(item.parsed.y) } } : {}, + tooltip: money ? {callbacks: {label: (item) => exactEuros.format(item.parsed.y)}} : {}, }, scales: { - x: { ticks: { color: ink }, grid: { color: grid } }, + x: {ticks: {color: ink}, grid: {color: grid}}, y: { beginAtZero: true, - grid: { color: grid }, - ticks: { color: ink, precision: 0, callback: money ? (value) => axisEuros.format(value) : undefined }, + grid: {color: grid}, + ticks: {color: ink, precision: 0, callback: money ? (value) => axisEuros.format(value) : undefined}, }, }, }, @@ -242,7 +240,7 @@ new MutationObserver(() => { charts.forEach((chart) => chart.destroy()); charts = render(); - }).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); + }).observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]}); })(); {% endblock extra_body %} diff --git a/controlpanel/tests.py b/controlpanel/tests.py index e56e340..ad415bf 100644 --- a/controlpanel/tests.py +++ b/controlpanel/tests.py @@ -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") diff --git a/controlpanel/views.py b/controlpanel/views.py index 43cae05..93d87b1 100644 --- a/controlpanel/views.py +++ b/controlpanel/views.py @@ -17,7 +17,7 @@ from .services.platform_admins import ( revoke_platform_access, set_platform_access, ) -from .services.statistics import club_statistics, clubs_with_totals, flag_adoption, onboarding_funnel, platform_attention, platform_charts, platform_totals +from .services.statistics import club_attention, club_charts, club_statistics, clubs_with_totals, flag_adoption, onboarding_funnel, platform_attention, platform_charts, platform_totals Flag = get_waffle_flag_model() Switch = get_waffle_switch_model() @@ -95,6 +95,8 @@ class ClubDetailView(PlatformStaffRequiredMixin, DetailView): return super().get_context_data( nav="clubs", groups=club_statistics(self.object), + attention=club_attention(self.object), + charts=club_charts(self.object), admins=ClubRole.objects.filter(club=self.object, role=ClubRole.Roles.ADMIN).select_related("member", "member__user"), flags=flags_for_club(self.object), **kwargs, diff --git a/static/css/app.css b/static/css/app.css index ee01d86..080d6b1 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -25,6 +25,8 @@ --text-2xl--line-height: calc(2 / 1.5); --text-3xl: 1.875rem; --text-3xl--line-height: calc(2.25 / 1.875); + --text-4xl: 2.25rem; + --text-4xl--line-height: calc(2.5 / 2.25); --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; @@ -1545,6 +1547,23 @@ } } } + .indicator { + @layer daisyui.l1.l2.l3 { + position: relative; + display: inline-flex; + width: max-content; + :where(.indicator-item) { + z-index: 1; + position: absolute; + white-space: nowrap; + top: var(--indicator-t, 0); + bottom: var(--indicator-b, auto); + left: var(--indicator-s, auto); + right: var(--indicator-e, 0); + translate: var(--indicator-x, 50%) var(--indicator-y, -50%); + } + } + } .table { @layer daisyui.l1.l2.l3 { font-size: 0.875rem; @@ -3553,6 +3572,10 @@ font-size: var(--text-3xl); line-height: var(--tw-leading, var(--text-3xl--line-height)); } + .text-4xl { + font-size: var(--text-4xl); + line-height: var(--tw-leading, var(--text-4xl--line-height)); + } .text-base { font-size: var(--text-base); line-height: var(--tw-leading, var(--text-base--line-height)); @@ -3664,6 +3687,9 @@ .text-sky-500 { color: var(--color-sky-500); } + .text-warning { + color: var(--color-warning); + } .tabular-nums { --tw-numeric-spacing: tabular-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); @@ -3841,6 +3867,11 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } } + .lg\:grid-cols-3 { + @media (width >= 64rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } .lg\:grid-cols-4 { @media (width >= 64rem) { grid-template-columns: repeat(4, minmax(0, 1fr));