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())
|
||||
|
||||
Reference in New Issue
Block a user