For a club on a plan whose dues are settled, the Dues cell now reads "until <date> · paid" — the end of the current paid period, which is the day the grace period would start if nothing renews. It is exactly the "when does this lapse?" question the paid badge alone could not answer. Driven by a new paid_until annotation: the furthest-out PAID period end, null when the club owes or was never billed (so a fully-paid free tier shows just "paid", and an owing club shows the amount, unchanged). It rides the SAME single query — the assertNumQueries(1) test still holds — and the date is whitespace-nowrap so it does not wrap in the narrow cell. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
454 lines
21 KiB
Python
454 lines
21 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 collections import defaultdict
|
|
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, DecimalField, Exists, F, IntegerField, OuterRef, Q, Subquery, Sum, Value
|
|
from django.db.models.functions import Coalesce, TruncMonth
|
|
from django.utils import timezone
|
|
from waffle import get_waffle_flag_model
|
|
|
|
from authentication.middleware import ELEVATED_ROLES
|
|
from billing.models import Due, DuePayment, Subscription
|
|
from billing.services.dues import dues_in_grace, dues_overdue, subscriptions_due_for_renewal
|
|
from club.models import Club, ClubMembership, ClubRole, Season
|
|
from events.models import Attendance, 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 _subquery(queryset, expression, output_field):
|
|
"""One aggregate, in its own subquery.
|
|
|
|
Deliberately not a pile of annotate(Count(...), Sum(...)) on one queryset: aggregates
|
|
that span *different* joins multiply each other's rows, so a club's outstanding total
|
|
would come back doubled for every membership it happens to have. Subqueries each stand
|
|
alone, so nothing can inflate anything else.
|
|
"""
|
|
return Coalesce(Subquery(queryset.filter(club=OuterRef("pk")).values("club").annotate(value=expression).values("value"), output_field=output_field), Value(0), output_field=output_field)
|
|
|
|
|
|
def clubs_with_health(queryset=None, today=None, now=None):
|
|
"""Clubs annotated with the health of each — for the dashboard table, in one query."""
|
|
today = today or timezone.localdate()
|
|
now = now or timezone.now()
|
|
clubs = Club.objects.active() if queryset is None else queryset
|
|
|
|
in_season = Q(season__start_date__lte=today, season__end_date__gte=today)
|
|
managed_this_season = Q(
|
|
staff_assignments__season__start_date__lte=today,
|
|
staff_assignments__season__end_date__gte=today,
|
|
staff_assignments__position__management_position=True,
|
|
)
|
|
|
|
return (
|
|
clubs.annotate(
|
|
has_season=Exists(Season.objects.filter(club=OuterRef("pk"), start_date__lte=today, end_date__gte=today)),
|
|
active_members=_subquery(ClubMembership.objects.filter(in_season, status=ClubMembership.StatusChoices.ACTIVE), Count("pk"), IntegerField()),
|
|
unpaid_members=_subquery(ClubMembership.objects.filter(in_season, fee_status=ClubMembership.FeeStatus.UNPAID), Count("pk"), IntegerField()),
|
|
outstanding=_subquery(Order.objects.filter(status__in=OWED_STATUSES), Sum("total"), DecimalField(max_digits=10, decimal_places=2)),
|
|
upcoming_events=_subquery(Event.objects.filter(start__gte=now, start__lte=now + timedelta(days=DORMANT_DAYS)), Count("pk"), IntegerField()),
|
|
team_count=_subquery(Team.objects.all(), Count("pk"), IntegerField()),
|
|
teams_managed=_subquery(Team.objects.filter(managed_this_season), Count("pk", distinct=True), IntegerField()),
|
|
admin_count=_subquery(ClubRole.objects.filter(role=ClubRole.Roles.ADMIN), Count("pk"), IntegerField()),
|
|
tier_name=Subquery(Subscription.objects.filter(club=OuterRef("pk")).values("tier__name")[:1]),
|
|
dues_owed=_subquery(Due.objects.filter(status__in=Due.OWING), Sum(F("amount") - F("amount_paid")), DecimalField(max_digits=10, decimal_places=2)),
|
|
dues_grace_until=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("grace_until").values("grace_until")[:1]),
|
|
dues_period_end=Subquery(Due.objects.filter(club=OuterRef("pk"), status__in=Due.OWING).order_by("period_end").values("period_end")[:1]),
|
|
# How far a fully-paid club is covered: the furthest-out PAID period end — the day
|
|
# grace would start if nothing is renewed. Null when the club owes, or was never billed.
|
|
paid_until=Subquery(Due.objects.filter(club=OuterRef("pk"), status=Due.Status.PAID).order_by("-period_end").values("period_end")[:1]),
|
|
)
|
|
.annotate(teams_without_coach=F("team_count") - F("teams_managed"))
|
|
.order_by("name")
|
|
)
|
|
|
|
|
|
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": "trophy"},
|
|
{"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,
|
|
# Platform billing: what the clubs owe US. Distinct from `outstanding`, which is
|
|
# what members owe their clubs — that money is never ours.
|
|
"dues_owed": _dues_owed(),
|
|
"dues_in_grace": dues_in_grace().count(),
|
|
"dues_overdue": dues_overdue().count(),
|
|
"clubs_unbilled": Club.objects.active().filter(subscription__isnull=True).count(),
|
|
# Normally ~0: the renewal job keeps it there. A number that sits here means cron is
|
|
# dead, and a club is about to use the platform for free — silently, because nothing is
|
|
# owed, so no other number on this page would go red.
|
|
"renewals_pending": len(subscriptions_due_for_renewal()),
|
|
}
|
|
|
|
|
|
def _dues_owed():
|
|
"""What clubs owe the platform right now."""
|
|
owed = Due.objects.filter(status__in=Due.OWING).aggregate(total=Sum(F("amount") - F("amount_paid")))["total"]
|
|
|
|
return owed or ZERO
|
|
|
|
|
|
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": signup_split(),
|
|
# Two different pots of money: `dues` is platform income (clubs paying us), while
|
|
# `club_revenue` is members paying their clubs — never ours, and labelling it
|
|
# "revenue" on our dashboard would be a lie.
|
|
"dues": _monthly(DuePayment.objects.all(), "paid_at", Sum("amount")),
|
|
"club_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 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 new_members(club, season):
|
|
"""Members whose first-ever season at this club is ``season``.
|
|
|
|
Keyed on "has no membership in an earlier season", not on "signed up recently" — a
|
|
member who lapsed for a year and came back is a renewal, not a new member, and
|
|
counting them as new would flatter every recovery into growth.
|
|
"""
|
|
if season is None:
|
|
return Member.objects.none()
|
|
|
|
seen_before = ClubMembership.objects.filter(club=club, season__start_date__lt=season.start_date).values("member")
|
|
|
|
return Member.objects.filter(member_of__club=club, member_of__season=season).exclude(pk__in=seen_before).distinct()
|
|
|
|
|
|
def signup_split(club=None, months=MONTHS_OF_HISTORY):
|
|
"""Signups per month, split into first-timers and returners. ``club=None`` is platform-wide.
|
|
|
|
"First" is keyed on (club, member), never on the member alone — the same person can be
|
|
new at one club while renewing at another, and collapsing that would mark their second
|
|
club's very first signup as a renewal.
|
|
|
|
Each member's earliest season is resolved once up front rather than per row: the same
|
|
question asked inside a loop is one query per membership.
|
|
"""
|
|
start = (timezone.now() - timedelta(days=30 * months)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
|
|
memberships = ClubMembership.objects.all() if club is None else ClubMembership.objects.filter(club=club)
|
|
|
|
first_season = {}
|
|
for club_id, member_id, season_start in memberships.values_list("club_id", "member_id", "season__start_date"):
|
|
key = (club_id, member_id)
|
|
if key not in first_season or season_start < first_season[key]:
|
|
first_season[key] = season_start
|
|
|
|
counts = defaultdict(lambda: {"new": 0, "returning": 0})
|
|
for club_id, member_id, season_start, signed_up_at in memberships.filter(signed_up_at__isnull=False, signed_up_at__gte=start).values_list("club_id", "member_id", "season__start_date", "signed_up_at"):
|
|
kind = "new" if season_start == first_season[(club_id, member_id)] else "returning"
|
|
counts[signed_up_at.strftime("%Y-%m")][kind] += 1
|
|
|
|
series, cursor = [], start
|
|
while cursor <= timezone.now():
|
|
month = counts[cursor.strftime("%Y-%m")]
|
|
series.append({"month": cursor.strftime("%b %Y"), "new": month["new"], "returning": month["returning"]})
|
|
cursor = (cursor + timedelta(days=32)).replace(day=1)
|
|
|
|
return series
|
|
|
|
|
|
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(),
|
|
"new_members": new_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": signup_split(club),
|
|
# 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())
|
|
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()),
|
|
],
|
|
},
|
|
]
|