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:
28
features/migrations/0004_jobtoggle.py
Normal file
28
features/migrations/0004_jobtoggle.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user