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>
This commit is contained in:
@@ -5,11 +5,17 @@ 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
|
||||
@@ -21,6 +27,10 @@ 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)."""
|
||||
@@ -42,6 +52,100 @@ def platform_totals():
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user