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:
@@ -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())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "controlpanel/base.html" %}
|
||||
{% load lucide %}
|
||||
{% load static lucide %}
|
||||
|
||||
{% block heading %}{{ club.name }}{% endblock heading %}
|
||||
|
||||
@@ -33,6 +33,120 @@
|
||||
<span>This club is archived: its subdomain no longer resolves. Nothing has been deleted — restore it to bring it back.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if attention.no_season %}
|
||||
<div class="alert alert-warning mb-6">
|
||||
{% lucide "calendar-x" size=20 %}
|
||||
<span>
|
||||
No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert.
|
||||
</span>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "banknote" size=16 %} Outstanding</div>
|
||||
<div class="text-3xl font-bold tabular-nums">€{{ attention.outstanding|floatformat:2 }}</div>
|
||||
<div class="text-xs opacity-60">{{ attention.unpaid_members }} member{{ attention.unpaid_members|pluralize }} unpaid this season</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow {% if attention.teams_without_manager %}border-l-4 border-error{% endif %}">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "user-x" size=16 %} No coach</div>
|
||||
<div class="text-3xl font-bold tabular-nums">{{ attention.teams_without_manager }}</div>
|
||||
<div class="text-xs opacity-60">Teams nobody can pick a squad for</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow {% if attention.unrostered %}border-l-4 border-warning{% endif %}">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "user-minus" size=16 %} Unrostered</div>
|
||||
<div class="text-3xl font-bold tabular-nums">{{ attention.unrostered }}</div>
|
||||
<div class="text-xs opacity-60">Active members on no team</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow {% if attention.pending_approvals %}border-l-4 border-warning{% endif %}">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "clock" size=16 %} Pending</div>
|
||||
<div class="text-3xl font-bold tabular-nums">{{ attention.pending_approvals }}</div>
|
||||
<div class="text-xs opacity-60">Memberships awaiting approval</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 grid gap-4 lg:grid-cols-3">
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "repeat" size=18 %} Renewal</h2>
|
||||
{% if attention.renewal_rate is None %}
|
||||
{# No prior season to compare against: a first-season club has not failed to renew anyone. #}
|
||||
<p class="text-sm opacity-60">No previous season to compare against yet.</p>
|
||||
{% else %}
|
||||
<div class="text-4xl font-bold tabular-nums">{{ attention.renewal_rate }}%</div>
|
||||
<p class="text-sm opacity-70">of last season's active members signed up again</p>
|
||||
<progress class="progress progress-primary w-full" value="{{ attention.renewal_rate }}" max="100"></progress>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "user-check" size=18 %} Attendance</h2>
|
||||
{% if attention.attendance.turnout is None %}
|
||||
<p class="text-sm opacity-60">No past events with responses this season.</p>
|
||||
{% else %}
|
||||
<div class="text-4xl font-bold tabular-nums">{{ attention.attendance.turnout }}%</div>
|
||||
<p class="text-sm opacity-70">turnout of those who answered</p>
|
||||
<p class="mt-2 text-sm">
|
||||
{# The leading indicator: it measures whether members use the app at all. #}
|
||||
<span class="font-semibold {% if attention.attendance.no_response > 30 %}text-warning{% endif %}">{{ attention.attendance.no_response }}%</span>
|
||||
<span class="opacity-70">never responded</span>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "hourglass" size=18 %} Unpaid, by age</h2>
|
||||
<table class="table table-sm">
|
||||
<tbody>
|
||||
{% for bucket in attention.aging %}
|
||||
<tr>
|
||||
<td class="{% if bucket.overdue and bucket.total %}font-semibold text-error{% endif %}">{{ bucket.label }}</td>
|
||||
<td class="text-right tabular-nums">€{{ bucket.total|floatformat:2 }}</td>
|
||||
<td class="text-right opacity-60">{{ bucket.count }} order{{ bucket.count|pluralize }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</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 "user-plus" size=18 %} Signups per month</h2>
|
||||
<div class="h-56">
|
||||
<canvas id="signups-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "wallet" size=18 %} Fee status this season</h2>
|
||||
<div class="h-56">
|
||||
<canvas id="fees-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 grid gap-4 md:grid-cols-2">
|
||||
{% for group in groups %}
|
||||
<div class="card bg-base-100 shadow">
|
||||
@@ -127,3 +241,66 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock panel %}
|
||||
|
||||
{% block extra_body %}
|
||||
{{ charts|json_script:"chart-data" }}
|
||||
<script src="{% static 'js/chart.js' %}"></script>
|
||||
<script>
|
||||
(() => {
|
||||
const data = JSON.parse(document.getElementById("chart-data").textContent);
|
||||
const css = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||
|
||||
const render = () => {
|
||||
const ink = css("--color-base-content", "#333");
|
||||
const grid = "color-mix(in oklab, " + ink + " 15%, transparent)";
|
||||
|
||||
const signups = new Chart(document.getElementById("signups-chart"), {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: data.signups.map((point) => point.month),
|
||||
datasets: [{ label: "Signups", data: data.signups.map((point) => point.value), backgroundColor: css("--color-primary", "#4f46e5") }],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: ink }, grid: { color: grid } },
|
||||
y: { beginAtZero: true, ticks: { color: ink, precision: 0 }, grid: { color: grid } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Colour carries the meaning here — unpaid must read as a problem, waived must
|
||||
// not — so the slices are pinned to the semantic theme colours, in order.
|
||||
const fees = new Chart(document.getElementById("fees-chart"), {
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: data.fees.map((slice) => slice.label),
|
||||
datasets: [
|
||||
{
|
||||
data: data.fees.map((slice) => slice.value),
|
||||
backgroundColor: [css("--color-success", "#16a34a"), css("--color-warning", "#f59e0b"), css("--color-error", "#dc2626"), css("--color-neutral", "#6b7280")],
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: "right", labels: { color: ink } } },
|
||||
},
|
||||
});
|
||||
|
||||
return [signups, fees];
|
||||
};
|
||||
|
||||
let charts = render();
|
||||
|
||||
// "auto" removes data-theme entirely, so watch the attribute rather than a click.
|
||||
new MutationObserver(() => {
|
||||
charts.forEach((chart) => chart.destroy());
|
||||
charts = render();
|
||||
}).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
|
||||
})();
|
||||
</script>
|
||||
{% endblock extra_body %}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "banknote" size=16 %} Outstanding</div>
|
||||
<div class="text-3xl font-bold tabular-nums">€{{ attention.outstanding }}</div>
|
||||
<div class="text-3xl font-bold tabular-nums">€{{ attention.outstanding|floatformat:2 }}</div>
|
||||
<div class="text-xs opacity-60">Unpaid across every club</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,8 +50,6 @@
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "user-plus" size=18 %} Signups per month</h2>
|
||||
{# 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. #}
|
||||
<div class="h-56">
|
||||
<canvas id="signups-chart"></canvas>
|
||||
</div>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user