Files
RosterChief/controlpanel/services/statistics.py
Bernard Siebens 016206a79e Build the platform metrics dashboard
The dashboard leads with the numbers that are supposed to be zero, because a
dashboard of healthy counts is one nobody opens:

- Clubs with no season covering today. Seasons scope memberships, rosters and
  events, so such a club cannot take a signup or schedule a match -- and it fails
  silently, nothing errors, it is just inert.
- Dormant clubs: nothing on the calendar for 30 days. Churn signal.
- Admins pending MFA. RequireMFAMiddleware redirects them to enrolment, so they
  are locked out of their own club until they act: a support queue, not a stat.
- Outstanding money across every club.

Then the shape of the business: an onboarding funnel (clubs → with members → with
a team → with events, which separates working clubs from shells), feature-flag
adoption per club, and two charts -- signups and revenue per month.

Charts use chart.js, self-hosted rather than pulled from a CDN, for the same
reason as the fonts: no third-party in the render path. Two things the browser
taught me: the canvas needs a height-bounded wrapper (with maintainAspectRatio
off it sizes to its parent, and a parent with no height grew it to 3489px), and
chart.js cannot read daisyUI's CSS variables, so the charts re-render on a
data-theme change or keep the light palette in dark mode.

The month series is zero-filled: a chart that skips empty months draws a smooth
line straight over a month in which nothing happened.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:59:28 +02:00

201 lines
8.1 KiB
Python

"""Platform and per-club statistics.
``club_statistics`` returns a list of stat *groups*, so growing the model later
means adding an entry here and nothing else. ``clubs_with_totals`` annotates in
a single query — the club list must not fan out into N+1.
"""
from datetime import timedelta
from decimal import Decimal
from allauth.mfa.models import Authenticator
from django.contrib.auth import get_user_model
from django.db.models import Count, Q, Sum
from django.db.models.functions import TruncMonth
from django.utils import timezone
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 members.models import Member
from shop.models import Cart, Order
from teams.models import StaffAssignment, Team, TeamMembership
ZERO = Decimal("0.00")
PAID_STATUSES = (Order.OrderStatus.PAID, Order.OrderStatus.DELIVERED)
OWED_STATUSES = (Order.OrderStatus.PENDING, Order.OrderStatus.PARTIALLY_PAID)
#: A club with nothing scheduled inside this window has stopped using the product.
DORMANT_DAYS = 30
MONTHS_OF_HISTORY = 12
def clubs_with_totals(queryset=None):
"""Clubs annotated with headline counts (one query, no N+1)."""
clubs = Club.objects.all() if queryset is None else queryset
return clubs.annotate(
member_count=Count("clubmemberships__member", distinct=True),
team_count=Count("teams", distinct=True),
event_count=Count("events", distinct=True),
admin_count=Count("clubroles", filter=Q(clubroles__role=ClubRole.Roles.ADMIN), distinct=True),
)
def platform_totals():
return {
"clubs": Club.objects.active().count(),
"archived_clubs": Club.objects.archived().count(),
"members": Member.objects.count(),
"admins": ClubRole.objects.filter(role=ClubRole.Roles.ADMIN).count(),
}
def clubs_without_a_season(today=None):
"""Clubs with no season covering today.
Not cosmetic: seasons scope memberships, rosters and events, so a club without
one cannot take a signup or schedule a match. It fails silently — nothing errors,
the club is simply inert — which is exactly why it belongs on a dashboard.
"""
today = today or timezone.localdate()
return Club.objects.active().exclude(seasons__start_date__lte=today, seasons__end_date__gte=today)
def dormant_clubs(days=DORMANT_DAYS):
"""Active clubs with nothing on the calendar in the next ``days``. Churn signal."""
now = timezone.now()
return Club.objects.active().exclude(events__start__gte=now, events__start__lte=now + timedelta(days=days))
def admins_pending_mfa():
"""Privileged users who have not enrolled a second factor.
They are locked out until they do (RequireMFAMiddleware redirects them to the
enrolment page), so this is a support queue rather than a statistic. The rule is
the middleware's own: platform staff, plus anyone holding an elevated ClubRole.
"""
User = get_user_model()
elevated = User.objects.filter(Q(is_staff=True) | Q(is_superuser=True) | Q(member__roles__role__in=ELEVATED_ROLES))
return elevated.exclude(pk__in=Authenticator.objects.values("user")).distinct()
def onboarding_funnel():
"""How far each active club got: created → has members → has a team → has events.
Separates working clubs from empty shells someone created and walked away from,
and shows which step people stall on.
"""
clubs = clubs_with_totals(Club.objects.active())
total = len(clubs)
return [
{"label": "Clubs", "count": total, "icon": "building-2"},
{"label": "With members", "count": sum(1 for club in clubs if club.member_count), "icon": "users"},
{"label": "With a team", "count": sum(1 for club in clubs if club.team_count), "icon": "shield"},
{"label": "With events", "count": sum(1 for club in clubs if club.event_count), "icon": "calendar-days"},
]
def flag_adoption():
"""Clubs per feature flag. `everyone` overrides club targeting, so a flag set that
way is on (or off) everywhere and its club count says nothing — hence `overridden`."""
Flag = get_waffle_flag_model()
return [{"name": flag.name, "clubs": flag.clubs.count(), "everyone": flag.everyone, "overridden": flag.everyone is not None} for flag in Flag.objects.annotate(club_total=Count("clubs")).order_by("name")]
def platform_attention():
"""The numbers that are supposed to be zero. A dashboard of healthy counts is a
dashboard nobody opens."""
members = Member.objects.count()
return {
"clubs_without_season": clubs_without_a_season().count(),
"dormant_clubs": dormant_clubs().count(),
"admins_pending_mfa": admins_pending_mfa().count(),
"outstanding": _money(Order.objects.filter(status__in=OWED_STATUSES)),
"members_without_login": Member.objects.filter(user__isnull=True).count(),
"members": members,
}
def _monthly(queryset, field, value, months=MONTHS_OF_HISTORY):
"""A dense month-by-month series — zero-filled, because a chart that silently skips
empty months draws a smooth line over a month where nothing happened."""
start = (timezone.now() - timedelta(days=30 * months)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
rows = queryset.filter(**{f"{field}__gte": start}).annotate(month=TruncMonth(field)).values("month").annotate(value=value).order_by("month")
found = {row["month"].strftime("%Y-%m"): row["value"] or 0 for row in rows if row["month"]}
series, cursor = [], start
while cursor <= timezone.now():
key = cursor.strftime("%Y-%m")
series.append({"month": cursor.strftime("%b %Y"), "value": float(found.get(key, 0))})
cursor = (cursor + timedelta(days=32)).replace(day=1)
return series
def platform_charts():
return {
"signups": _monthly(ClubMembership.objects.filter(signed_up_at__isnull=False), "signed_up_at", Count("id")),
"revenue": _monthly(Order.objects.filter(status__in=PAID_STATUSES), "created", Sum("total")),
}
def _money(queryset):
return queryset.aggregate(total=Sum("total"))["total"] or ZERO
def club_statistics(club):
"""Stat groups for one club. Add new groups here as the domain grows."""
season = Season.covering(club, timezone.localdate())
now = timezone.now()
memberships = ClubMembership.objects.filter(club=club)
events = Event.objects.filter(club=club)
orders = Order.objects.filter(club=club)
return [
{
"title": "Members",
"icon": "users",
"stats": [
("Members", memberships.values("member").distinct().count()),
("Active this season", memberships.filter(season=season, status=ClubMembership.StatusChoices.ACTIVE).count() if season else 0),
("Pending", memberships.filter(status=ClubMembership.StatusChoices.PENDING).count()),
("Lapsed", memberships.filter(status=ClubMembership.StatusChoices.LAPSED).count()),
],
},
{
"title": "Teams & staff",
"icon": "shield",
"stats": [
("Teams", Team.objects.filter(club=club).count()),
("Players this season", TeamMembership.objects.filter(team__club=club, season=season).count() if season else 0),
("Staff this season", StaffAssignment.objects.filter(team__club=club, season=season).count() if season else 0),
],
},
{
"title": "Events",
"icon": "calendar-days",
"stats": [
("Upcoming", events.filter(start__gte=now).count()),
("This season", events.filter(season=season).count() if season else 0),
],
},
{
"title": "Shop",
"icon": "shopping-cart",
"stats": [
("Orders", orders.count()),
("Revenue", _money(orders.filter(status__in=PAID_STATUSES))),
("Outstanding", _money(orders.filter(status__in=OWED_STATUSES))),
("Open carts", Cart.objects.filter(club=club, status=Cart.CartStatus.OPEN).count()),
],
},
]