Coach lineup polish, referee level/position delete, feature-flag nav gating, per-job pause

- Coach line-up screen: more breathing room above the sheet, brighter event
  subtitle, and the "Schedule" button now matches the date input's height.
- Positions and referee levels can now be deleted from Settings (blocked with
  a friendly message if still in use on a roster/referee profile/inheritance
  chain).
- The Evaluations nav placeholder is now gated on the formbuilder flag, same
  as Forms itself, since the design reuses formbuilder underneath.
- Control panel: each scheduled platform job can now be paused/resumed
  individually (features.models.JobToggle), independent of the platform-wide
  Maintenance lock.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 11:17:46 +02:00
parent e621086f37
commit 1365dcf18e
23 changed files with 422 additions and 3029 deletions

View File

@@ -0,0 +1,28 @@
# Generated by Django 6.0.6 on 2026-08-23 09:09
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('features', '0003_jobrun'),
]
operations = [
migrations.CreateModel(
name='JobToggle',
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)),
('name', models.CharField(help_text='Dotted Celery task name, matching a features.jobs.JOB_REGISTRY key.', max_length=255, unique=True, verbose_name='job name')),
('enabled', models.BooleanField(default=True, verbose_name='enabled')),
],
options={
'verbose_name': 'job toggle',
'verbose_name_plural': 'job toggles',
},
),
]

View File

@@ -153,6 +153,56 @@ class Maintenance(UUIDModel):
return maintenance
class JobToggle(UUIDModel):
"""Per-job on/off switch for the scheduled platform jobs in features/jobs.py's
JOB_REGISTRY, so the control panel can pause one specific job (a runaway task, one
under investigation, ...) without touching Celery Beat's schedule or reaching for
the platform-wide Maintenance lock, which stands down every job at once.
One row per job that's ever been toggled off; a job with no row here is enabled --
same "absence means default" shape as Maintenance's own singleton, but keyed instead
of a single row, since there's one of these per registry entry rather than one for
the whole platform. Cached the same write-through way for the same reason: a flip
made in the control panel has to reach every Celery worker, not just the process
that made it.
"""
CACHE_KEY = "job_toggle:%s"
CACHE_SECONDS = 10
name = models.CharField(_("job name"), max_length=255, unique=True, help_text=_("Dotted Celery task name, matching a features.jobs.JOB_REGISTRY key."))
enabled = models.BooleanField(_("enabled"), default=True)
class Meta:
verbose_name = _("job toggle")
verbose_name_plural = _("job toggles")
def __str__(self):
return f"{self.name} ({'on' if self.enabled else 'off'})"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
get_cache().set(self.CACHE_KEY % self.name, self.enabled, self.CACHE_SECONDS)
@classmethod
def is_enabled(cls, name: str) -> bool:
cache_key = cls.CACHE_KEY % name
cached = get_cache().get(cache_key)
if cached is not None:
return cached
toggle = cls.objects.filter(name=name).first()
enabled = toggle is None or toggle.enabled
get_cache().set(cache_key, enabled, cls.CACHE_SECONDS)
return enabled
@classmethod
def set_enabled(cls, name: str, enabled: bool) -> JobToggle:
toggle, _created = cls.objects.update_or_create(name=name, defaults={"enabled": enabled})
return toggle
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.

View File

@@ -13,7 +13,7 @@ from waffle import flag_is_active, get_waffle_flag_model
from club.models import Club
from .jobs import JOB_REGISTRY
from .models import JobRun, Maintenance
from .models import JobRun, JobToggle, Maintenance
Flag = get_waffle_flag_model()
User = get_user_model()
@@ -322,3 +322,34 @@ class JobRunTests(TestCase):
self.untracked_task.apply()
self.assertFalse(JobRun.objects.filter(name="features.tests.untracked").exists())
class JobToggleTests(TestCase):
"""Per-job pause/resume switch -- see features.models.JobToggle and its use in
events/billing/club's scheduled tasks (each checks JobToggle.is_enabled(name)
right alongside the existing Maintenance.is_on() gate)."""
def setUp(self):
# Same reasoning as MaintenanceModeTests -- the toggle's cache isn't
# rolled back with the test transaction.
cache.clear()
self.addCleanup(cache.clear)
def test_a_job_with_no_row_is_enabled_by_default(self):
self.assertTrue(JobToggle.is_enabled("events.tasks.extend_event_series"))
def test_disabling_a_job(self):
JobToggle.set_enabled("events.tasks.extend_event_series", False)
self.assertFalse(JobToggle.is_enabled("events.tasks.extend_event_series"))
def test_re_enabling_a_job(self):
JobToggle.set_enabled("events.tasks.extend_event_series", False)
JobToggle.set_enabled("events.tasks.extend_event_series", True)
self.assertTrue(JobToggle.is_enabled("events.tasks.extend_event_series"))
def test_toggling_one_job_does_not_affect_another(self):
JobToggle.set_enabled("events.tasks.extend_event_series", False)
self.assertTrue(JobToggle.is_enabled("events.tasks.send_deadline_reminders"))