Checkpoint: management app redesign, onboarding/signup workflow, and events calendar backend
Large uncommitted body of work accumulated across sessions on this branch -- committing as a checkpoint so it's tracked and future worktree-isolated agents see the real codebase instead of a stale ancestor commit. Covers the management app's dedicated Tailwind theme and templates, the club onboarding requirement/signup workflow (club/services/onboarding.py, requirement/status models, sign-up dashboard), fee/status auto-activation decoupling, referee management, and the new events calendar grid service layer. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
51
controlpanel/services/jobs.py
Normal file
51
controlpanel/services/jobs.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Read side of the scheduled-job history for the control panel's Jobs tab and the
|
||||
Platform dashboard's job log / failed-jobs tile.
|
||||
|
||||
``features.jobs.JOB_REGISTRY`` is what a job *is* (label, description, schedule);
|
||||
``features.models.JobRun`` is what actually happened, written by the Celery signal handlers
|
||||
in features/signals.py. This module just joins the two for a template.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from features.jobs import JOB_REGISTRY
|
||||
from features.models import JobRun
|
||||
|
||||
#: Runs shown per job on the Jobs tab -- enough to see a pattern (a job that fails every
|
||||
#: third day, say) without the page turning into a full audit log.
|
||||
RECENT_RUNS = 10
|
||||
|
||||
#: What counts as "recent" for the dashboard's failed-jobs KPI tile.
|
||||
FAILURE_WINDOW_HOURS = 24
|
||||
|
||||
#: Rows in the Platform dashboard's job log card.
|
||||
JOB_LOG_ROWS = 8
|
||||
|
||||
|
||||
def job_overview():
|
||||
"""One entry per registered job, its most recent runs, and a shortcut to the latest."""
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"label": meta["label"],
|
||||
"description": meta["description"],
|
||||
"schedule": meta["schedule"],
|
||||
"runs": (runs := list(JobRun.objects.filter(name=name)[:RECENT_RUNS])),
|
||||
"latest": runs[0] if runs else None,
|
||||
}
|
||||
for name, meta in JOB_REGISTRY.items()
|
||||
]
|
||||
|
||||
|
||||
def recent_job_failures(hours=FAILURE_WINDOW_HOURS):
|
||||
"""Failures in the last `hours` -- the platform-health "failed jobs" signal. A number
|
||||
that sits here is exactly what a dead beat schedule or a broken task looks like."""
|
||||
since = timezone.now() - timedelta(hours=hours)
|
||||
return JobRun.objects.filter(status=JobRun.Status.FAILURE, started_at__gte=since)
|
||||
|
||||
|
||||
def recent_job_runs(limit=JOB_LOG_ROWS):
|
||||
"""Every job's runs, most recent first, for the dashboard's Job log card."""
|
||||
return JobRun.objects.all()[:limit]
|
||||
@@ -104,6 +104,42 @@ def clubs_with_health(queryset=None, today=None, now=None):
|
||||
)
|
||||
|
||||
|
||||
#: Risk tiers for the dashboard's "Club health" table, high risk first. Derived from signals
|
||||
#: `clubs_with_health` already annotates -- no separate query, and nothing here is invented:
|
||||
#: a club with no season covering today cannot take a signup, and dues past their grace date
|
||||
#: are exactly what the archive job is about to act on.
|
||||
RISK_HIGH, RISK_WATCH, RISK_OK = "high", "watch", "ok"
|
||||
|
||||
|
||||
def club_risk(club, today):
|
||||
"""The risk tier, plus a human reason naming exactly which signal tripped it -- so the
|
||||
dashboard can show *why*, not just a colour. Checked in the same order as the tier
|
||||
logic below: the first matching condition is the one reported."""
|
||||
if not club.has_season:
|
||||
return RISK_HIGH, _("No season covers today")
|
||||
if club.dues_grace_until is not None and club.dues_grace_until < today:
|
||||
return RISK_HIGH, _("Dues overdue past grace")
|
||||
if not club.upcoming_events:
|
||||
return RISK_WATCH, _("No events in the next 30 days")
|
||||
if club.dues_owed:
|
||||
return RISK_WATCH, _("Dues outstanding")
|
||||
return RISK_OK, _("Nothing needs attention")
|
||||
|
||||
|
||||
def clubs_by_risk(queryset=None, today=None):
|
||||
"""`clubs_with_health`, ordered highest risk first -- the dashboard's Club health table
|
||||
is "sorted by risk" per the design, and risk is exactly the thing that table is for."""
|
||||
today = today or timezone.localdate()
|
||||
order = {RISK_HIGH: 0, RISK_WATCH: 1, RISK_OK: 2}
|
||||
|
||||
clubs = list(clubs_with_health(queryset, today=today))
|
||||
for club in clubs:
|
||||
club.risk, club.risk_reason = club_risk(club, today)
|
||||
clubs.sort(key=lambda club: order[club.risk])
|
||||
|
||||
return clubs
|
||||
|
||||
|
||||
def platform_totals():
|
||||
return {
|
||||
"clubs": Club.objects.active().count(),
|
||||
|
||||
Reference in New Issue
Block a user