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 authentication.middleware import ELEVATED_ROLES
|
||||||
from club.models import Club, ClubMembership, ClubRole, Season
|
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 members.models import Member
|
||||||
from shop.models import Cart, Order
|
from shop.models import Cart, Order
|
||||||
from teams.models import StaffAssignment, Team, TeamMembership
|
from teams.models import StaffAssignment, Team, TeamMembership
|
||||||
@@ -150,6 +150,138 @@ def _money(queryset):
|
|||||||
return queryset.aggregate(total=Sum("total"))["total"] or ZERO
|
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):
|
def club_statistics(club):
|
||||||
"""Stat groups for one club. Add new groups here as the domain grows."""
|
"""Stat groups for one club. Add new groups here as the domain grows."""
|
||||||
season = Season.covering(club, timezone.localdate())
|
season = Season.covering(club, timezone.localdate())
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "controlpanel/base.html" %}
|
{% extends "controlpanel/base.html" %}
|
||||||
{% load lucide %}
|
{% load static lucide %}
|
||||||
|
|
||||||
{% block heading %}{{ club.name }}{% endblock heading %}
|
{% 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>
|
<span>This club is archived: its subdomain no longer resolves. Nothing has been deleted — restore it to bring it back.</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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">
|
<div class="mb-6 grid gap-4 md:grid-cols-2">
|
||||||
{% for group in groups %}
|
{% for group in groups %}
|
||||||
<div class="card bg-base-100 shadow">
|
<div class="card bg-base-100 shadow">
|
||||||
@@ -127,3 +241,66 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock panel %}
|
{% 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 bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}">
|
||||||
<div class="card-body p-4">
|
<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="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 class="text-xs opacity-60">Unpaid across every club</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,8 +50,6 @@
|
|||||||
<div class="card bg-base-100 shadow">
|
<div class="card bg-base-100 shadow">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-base">{% lucide "user-plus" size=18 %} Signups per month</h2>
|
<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">
|
<div class="h-56">
|
||||||
<canvas id="signups-chart"></canvas>
|
<canvas id="signups-chart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,31 +197,31 @@
|
|||||||
// Locale-aware, so 1234.5 reads as "€ 1.234,50" rather than "€1,234.5". Two of
|
// 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
|
// 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.
|
// 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 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 exactEuros = new Intl.NumberFormat("nl-BE", {style: "currency", currency: "EUR", minimumFractionDigits: 2});
|
||||||
|
|
||||||
const build = (id, label, series, colour, type, money) =>
|
const build = (id, label, series, colour, type, money) =>
|
||||||
new Chart(document.getElementById(id), {
|
new Chart(document.getElementById(id), {
|
||||||
type,
|
type,
|
||||||
data: {
|
data: {
|
||||||
labels: series.map((point) => point.month),
|
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: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: false },
|
legend: {display: false},
|
||||||
// The tooltip carries the unit too: an axis in euros and a bare
|
// The tooltip carries the unit too: an axis in euros and a bare
|
||||||
// number on hover reads as two different quantities.
|
// 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: {
|
scales: {
|
||||||
x: { ticks: { color: ink }, grid: { color: grid } },
|
x: {ticks: {color: ink}, grid: {color: grid}},
|
||||||
y: {
|
y: {
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
grid: { color: grid },
|
grid: {color: grid},
|
||||||
ticks: { color: ink, precision: 0, callback: money ? (value) => axisEuros.format(value) : undefined },
|
ticks: {color: ink, precision: 0, callback: money ? (value) => axisEuros.format(value) : undefined},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -242,7 +240,7 @@
|
|||||||
new MutationObserver(() => {
|
new MutationObserver(() => {
|
||||||
charts.forEach((chart) => chart.destroy());
|
charts.forEach((chart) => chart.destroy());
|
||||||
charts = render();
|
charts = render();
|
||||||
}).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
|
}).observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock extra_body %}
|
{% endblock extra_body %}
|
||||||
|
|||||||
@@ -13,14 +13,31 @@ from django.utils import timezone
|
|||||||
from waffle import get_waffle_flag_model, get_waffle_switch_model
|
from waffle import get_waffle_flag_model, get_waffle_switch_model
|
||||||
|
|
||||||
from club.models import Club, ClubMembership, ClubRole, Season
|
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 members.models import Member
|
||||||
from shop.models import Order
|
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.admins import grant_club_admin
|
||||||
from .services.platform_admins import PlatformAdminError, is_last_superuser, set_platform_access
|
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
|
from .templatetags.ui import as_alert, daisy, excluded, field_icon
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -716,3 +733,136 @@ class DashboardMetricsTests(ControlPanelTestBase):
|
|||||||
self.assertContains(response, 'id="revenue-chart"')
|
self.assertContains(response, 'id="revenue-chart"')
|
||||||
self.assertContains(response, "js/chart.js")
|
self.assertContains(response, "js/chart.js")
|
||||||
self.assertIn("signups", response.context["charts"])
|
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,
|
revoke_platform_access,
|
||||||
set_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()
|
Flag = get_waffle_flag_model()
|
||||||
Switch = get_waffle_switch_model()
|
Switch = get_waffle_switch_model()
|
||||||
@@ -95,6 +95,8 @@ class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
|
|||||||
return super().get_context_data(
|
return super().get_context_data(
|
||||||
nav="clubs",
|
nav="clubs",
|
||||||
groups=club_statistics(self.object),
|
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"),
|
admins=ClubRole.objects.filter(club=self.object, role=ClubRole.Roles.ADMIN).select_related("member", "member__user"),
|
||||||
flags=flags_for_club(self.object),
|
flags=flags_for_club(self.object),
|
||||||
**kwargs,
|
**kwargs,
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
--text-2xl--line-height: calc(2 / 1.5);
|
--text-2xl--line-height: calc(2 / 1.5);
|
||||||
--text-3xl: 1.875rem;
|
--text-3xl: 1.875rem;
|
||||||
--text-3xl--line-height: calc(2.25 / 1.875);
|
--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-medium: 500;
|
||||||
--font-weight-semibold: 600;
|
--font-weight-semibold: 600;
|
||||||
--font-weight-bold: 700;
|
--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 {
|
.table {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@layer daisyui.l1.l2.l3 {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -3553,6 +3572,10 @@
|
|||||||
font-size: var(--text-3xl);
|
font-size: var(--text-3xl);
|
||||||
line-height: var(--tw-leading, var(--text-3xl--line-height));
|
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 {
|
.text-base {
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
line-height: var(--tw-leading, var(--text-base--line-height));
|
line-height: var(--tw-leading, var(--text-base--line-height));
|
||||||
@@ -3664,6 +3687,9 @@
|
|||||||
.text-sky-500 {
|
.text-sky-500 {
|
||||||
color: var(--color-sky-500);
|
color: var(--color-sky-500);
|
||||||
}
|
}
|
||||||
|
.text-warning {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
.tabular-nums {
|
.tabular-nums {
|
||||||
--tw-numeric-spacing: 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,);
|
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));
|
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 {
|
.lg\:grid-cols-4 {
|
||||||
@media (width >= 64rem) {
|
@media (width >= 64rem) {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
|||||||
Reference in New Issue
Block a user