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:
38
features/jobs.py
Normal file
38
features/jobs.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Registry of the platform jobs Celery Beat runs on a schedule (see
|
||||
rosterchief/settings.CELERY_BEAT_SCHEDULE).
|
||||
|
||||
Keyed on each task's dotted Celery name -- the same string a JobRun row carries in `name`
|
||||
-- so features/signals.py can tell a tracked platform job apart from any other Celery task
|
||||
that might get added later without a job to show for it, and so the control panel's Jobs
|
||||
tab can label a JobRun without importing the task function itself.
|
||||
"""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
JOB_REGISTRY = {
|
||||
"events.tasks.extend_event_series": {
|
||||
"label": _("Extend event series"),
|
||||
"description": _("Materialises recurring event occurrences up to the rolling horizon, so the calendar never runs dry."),
|
||||
"schedule": _("Daily at 03:00"),
|
||||
},
|
||||
"billing.tasks.renew_subscriptions": {
|
||||
"label": _("Renew subscriptions"),
|
||||
"description": _("Opens the next billing period for clubs whose current one is running out."),
|
||||
"schedule": _("Daily at 04:00"),
|
||||
},
|
||||
"billing.tasks.send_billing_reminders": {
|
||||
"label": _("Send billing reminders"),
|
||||
"description": _("Emails club admins about outstanding platform fees, once per escalation level."),
|
||||
"schedule": _("Daily at 05:00"),
|
||||
},
|
||||
"billing.tasks.archive_overdue_clubs": {
|
||||
"label": _("Archive overdue clubs"),
|
||||
"description": _("Archives clubs unpaid past their grace period."),
|
||||
"schedule": _("Daily at 06:00"),
|
||||
},
|
||||
"club.tasks.generate_seasons": {
|
||||
"label": _("Generate seasons"),
|
||||
"description": _("Generates the next two years of season rows for every active club, so signups and rosters never hit a missing season."),
|
||||
"schedule": _("Monthly, 1st at 05:00"),
|
||||
},
|
||||
}
|
||||
34
features/migrations/0003_jobrun.py
Normal file
34
features/migrations/0003_jobrun.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-16 14:01
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('features', '0002_maintenance'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='JobRun',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('task_id', models.CharField(max_length=255, unique=True, verbose_name='task id')),
|
||||
('name', models.CharField(help_text='Dotted Celery task name, e.g. billing.tasks.renew_subscriptions.', max_length=255, verbose_name='task name')),
|
||||
('status', models.CharField(choices=[('started', 'Started'), ('success', 'Success'), ('failure', 'Failed')], default='started', max_length=10, verbose_name='status')),
|
||||
('started_at', models.DateTimeField(verbose_name='started at')),
|
||||
('finished_at', models.DateTimeField(blank=True, null=True, verbose_name='finished at')),
|
||||
('detail', models.TextField(blank=True, help_text='What the task returned, on success.', verbose_name='detail')),
|
||||
('error', models.TextField(blank=True, help_text='What the task raised, on failure.', verbose_name='error')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'job run',
|
||||
'verbose_name_plural': 'job runs',
|
||||
'ordering': ['-started_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -151,3 +151,38 @@ class Maintenance(UUIDModel):
|
||||
maintenance.save()
|
||||
|
||||
return maintenance
|
||||
|
||||
|
||||
class JobRun(UUIDModel):
|
||||
"""One execution of a scheduled platform job -- see features/jobs.py for the registry
|
||||
of what each job is, and rosterchief/settings.CELERY_BEAT_SCHEDULE for when it runs.
|
||||
|
||||
Written entirely by the Celery signal handlers in features/signals.py: individual tasks
|
||||
(billing/tasks.py, club/tasks.py, events/tasks.py) don't touch this model, so a task
|
||||
that raises still gets a row -- the signal fires regardless of how the task ended.
|
||||
"""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
STARTED = "started", _("Started")
|
||||
SUCCESS = "success", _("Success")
|
||||
FAILURE = "failure", _("Failed")
|
||||
|
||||
task_id = models.CharField(_("task id"), max_length=255, unique=True)
|
||||
name = models.CharField(_("task name"), max_length=255, help_text=_("Dotted Celery task name, e.g. billing.tasks.renew_subscriptions."))
|
||||
status = models.CharField(_("status"), max_length=10, choices=Status.choices, default=Status.STARTED)
|
||||
started_at = models.DateTimeField(_("started at"))
|
||||
finished_at = models.DateTimeField(_("finished at"), null=True, blank=True)
|
||||
detail = models.TextField(_("detail"), blank=True, help_text=_("What the task returned, on success."))
|
||||
error = models.TextField(_("error"), blank=True, help_text=_("What the task raised, on failure."))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("job run")
|
||||
verbose_name_plural = _("job runs")
|
||||
ordering = ["-started_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} · {self.started_at:%Y-%m-%d %H:%M}"
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
return None if self.finished_at is None else self.finished_at - self.started_at
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
"""Keep waffle's flag cache honest when club targeting changes.
|
||||
"""Keep waffle's flag cache honest when club targeting changes, and keep a JobRun history
|
||||
of the scheduled platform jobs (see features/jobs.py).
|
||||
|
||||
waffle caches a flag's M2M ids and only flushes on ``save()``. Editing an M2M
|
||||
does not call ``save()``, so adding or removing a club would otherwise leave a
|
||||
stale cached set and the flag would keep answering with the old value.
|
||||
"""
|
||||
|
||||
from celery.signals import task_failure, task_postrun, task_prerun
|
||||
from django.db.models.signals import m2m_changed
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Flag
|
||||
from .jobs import JOB_REGISTRY
|
||||
from .models import Flag, JobRun
|
||||
|
||||
FLUSH_ACTIONS = {"post_add", "post_remove", "post_clear"}
|
||||
|
||||
@@ -24,3 +28,41 @@ def flush_flag_club_cache(sender, instance, action, reverse, pk_set, **kwargs):
|
||||
# Reverse edit (club.flags.add(flag)): flush each flag touched.
|
||||
for flag in Flag.objects.filter(pk__in=pk_set or []):
|
||||
flag.flush()
|
||||
|
||||
|
||||
@task_prerun.connect
|
||||
def record_job_start(sender=None, task_id=None, task=None, **kwargs):
|
||||
"""One JobRun row per task execution, for the jobs in JOB_REGISTRY only -- an
|
||||
unregistered Celery task (should one ever be added without a job to show for it)
|
||||
is not the control panel Jobs tab's business."""
|
||||
if task is None or task.name not in JOB_REGISTRY:
|
||||
return
|
||||
|
||||
JobRun.objects.create(task_id=task_id, name=task.name, status=JobRun.Status.STARTED, started_at=timezone.now())
|
||||
|
||||
|
||||
@task_postrun.connect
|
||||
def record_job_finish(sender=None, task_id=None, task=None, retval=None, state=None, **kwargs):
|
||||
"""Closes the row record_job_start opened. Fires whether the task succeeded or raised --
|
||||
on success, ``retval`` is whatever the task returned (see billing/tasks.py, club/tasks.py,
|
||||
events/tasks.py: each returns a short human summary for this) and becomes JobRun.detail.
|
||||
On failure ``retval`` is not reliably the exception, so record_job_failure below (driven
|
||||
by the dedicated task_failure signal instead) fills in JobRun.error."""
|
||||
if task is None or task.name not in JOB_REGISTRY:
|
||||
return
|
||||
|
||||
succeeded = state == "SUCCESS"
|
||||
JobRun.objects.filter(task_id=task_id).update(
|
||||
status=JobRun.Status.SUCCESS if succeeded else JobRun.Status.FAILURE,
|
||||
finished_at=timezone.now(),
|
||||
detail=str(retval)[:4000] if succeeded else "",
|
||||
)
|
||||
|
||||
|
||||
@task_failure.connect
|
||||
def record_job_failure(sender=None, task_id=None, exception=None, **kwargs):
|
||||
task_name = getattr(sender, "name", None)
|
||||
if task_name not in JOB_REGISTRY:
|
||||
return
|
||||
|
||||
JobRun.objects.filter(task_id=task_id).update(error=str(exception)[:4000])
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from io import StringIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from allauth.mfa.models import Authenticator
|
||||
from celery import shared_task
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
from django.core.management import call_command
|
||||
@@ -10,7 +12,8 @@ from waffle import flag_is_active, get_waffle_flag_model
|
||||
|
||||
from club.models import Club
|
||||
|
||||
from .models import Maintenance
|
||||
from .jobs import JOB_REGISTRY
|
||||
from .models import JobRun, Maintenance
|
||||
|
||||
Flag = get_waffle_flag_model()
|
||||
User = get_user_model()
|
||||
@@ -259,3 +262,63 @@ class MaintenanceCommandTests(TestCase):
|
||||
Maintenance.start()
|
||||
|
||||
self.run_command("migrate", "--check") # raises SystemExit only if migrations are pending
|
||||
|
||||
|
||||
@shared_task(name="features.tests.succeed")
|
||||
def _succeed_task():
|
||||
return "did the thing"
|
||||
|
||||
|
||||
@shared_task(name="features.tests.fail")
|
||||
def _fail_task():
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
@shared_task(name="features.tests.untracked")
|
||||
def _untracked_task():
|
||||
return "quiet"
|
||||
|
||||
|
||||
class JobRunTests(TestCase):
|
||||
"""The Celery signal wiring in features/signals.py, exercised against throwaway tasks
|
||||
(module-level, like any real task -- Celery's registry gets confused if the same task
|
||||
name is redefined per-test) rather than the real billing/club/events ones: what matters
|
||||
here is that a JobRun row appears for anything in JOB_REGISTRY and only for that, not the
|
||||
domain logic of any one scheduled job (each of those has its own tests alongside its
|
||||
management command)."""
|
||||
|
||||
def setUp(self):
|
||||
self.succeed_task, self.fail_task, self.untracked_task = _succeed_task, _fail_task, _untracked_task
|
||||
patcher = patch.dict(JOB_REGISTRY, {"features.tests.succeed": {}, "features.tests.fail": {}})
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_a_successful_run_is_recorded(self):
|
||||
# .apply() runs the task synchronously, in-process, regardless of
|
||||
# CELERY_TASK_ALWAYS_EAGER -- exactly what the task_prerun/task_postrun signal
|
||||
# handlers in features/signals.py are wired to react to either way.
|
||||
self.succeed_task.apply()
|
||||
|
||||
run = JobRun.objects.get(name="features.tests.succeed")
|
||||
self.assertEqual(run.status, JobRun.Status.SUCCESS)
|
||||
self.assertEqual(run.detail, "did the thing")
|
||||
self.assertEqual(run.error, "")
|
||||
self.assertIsNotNone(run.started_at)
|
||||
self.assertIsNotNone(run.finished_at)
|
||||
|
||||
def test_a_failed_run_is_recorded(self):
|
||||
# A real worker never raises a task's exception back into whoever called .delay() --
|
||||
# it's async, the caller is long gone by the time the task runs -- so eager mode
|
||||
# doesn't either (CELERY_TASK_EAGER_PROPAGATES is left at its default False; see
|
||||
# rosterchief/settings.py). The result carries FAILURE instead, same as production.
|
||||
result = self.fail_task.apply()
|
||||
|
||||
self.assertEqual(result.state, "FAILURE")
|
||||
run = JobRun.objects.get(name="features.tests.fail")
|
||||
self.assertEqual(run.status, JobRun.Status.FAILURE)
|
||||
self.assertIn("boom", run.error)
|
||||
|
||||
def test_a_task_outside_the_registry_is_not_tracked(self):
|
||||
self.untracked_task.apply()
|
||||
|
||||
self.assertFalse(JobRun.objects.filter(name="features.tests.untracked").exists())
|
||||
|
||||
Reference in New Issue
Block a user