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

@@ -11,7 +11,7 @@ from datetime import timedelta
from django.utils import timezone
from features.jobs import JOB_REGISTRY
from features.models import JobRun
from features.models import JobRun, JobToggle
#: 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.
@@ -32,6 +32,7 @@ def job_overview():
"label": meta["label"],
"description": meta["description"],
"schedule": meta["schedule"],
"enabled": JobToggle.is_enabled(name),
"runs": (runs := list(JobRun.objects.filter(name=name)[:RECENT_RUNS])),
"latest": runs[0] if runs else None,
}

View File

@@ -12,28 +12,32 @@
{% block panel %}
<div class="rounded border border-info-border bg-info-bg px-4 py-3 text-sm text-info-text">
{% lucide "info" size=16 class="inline -mt-0.5 mr-1" %}
These run on Celery Beat's own schedule (see <span class="font-mono">rosterchief/settings.py</span>) — this page is monitoring only, there is no "run now" here. A run that never appears at its scheduled time is the signal something's wrong with <span class="font-mono">worker</span>/<span class="font-mono">beat</span>, not with this page.
These run on Celery Beat's own schedule (see <span class="font-mono">rosterchief/settings.py</span>) — there is no "run now" here, but each job can be paused individually below. A run that never appears at its scheduled time (and isn't paused) is the signal something's wrong with <span class="font-mono">worker</span>/<span class="font-mono">beat</span>, not with this page.
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
{% for job in jobs %}
<div class="card flex flex-col">
<div class="card flex flex-col {% if not job.enabled %}opacity-70{% endif %}">
<div class="flex items-start justify-between gap-3 border-b border-line px-4 py-3">
<div>
<div class="font-display text-sm font-extrabold tracking-[.08em] text-ink uppercase">{{ job.label }}</div>
<div class="mt-0.5 font-mono text-[11px] text-muted">{{ job.name }}</div>
</div>
{% if job.latest %}
{% if job.latest.status == "success" %}
<span class="badge badge-success shrink-0">ok</span>
{% elif job.latest.status == "failure" %}
<span class="badge badge-error shrink-0">error</span>
<div class="flex shrink-0 flex-col items-end gap-1">
{% if not job.enabled %}
<span class="badge badge-warning shrink-0">paused</span>
{% elif job.latest %}
{% if job.latest.status == "success" %}
<span class="badge badge-success shrink-0">ok</span>
{% elif job.latest.status == "failure" %}
<span class="badge badge-error shrink-0">error</span>
{% else %}
<span class="badge badge-info shrink-0">running</span>
{% endif %}
{% else %}
<span class="badge badge-info shrink-0">running</span>
<span class="badge shrink-0">never run</span>
{% endif %}
{% else %}
<span class="badge shrink-0">never run</span>
{% endif %}
</div>
</div>
<div class="flex flex-1 flex-col gap-2 px-4 py-3 text-sm">
{# flex-1 here so the paragraph absorbs whatever space a shorter description leaves, keeping the schedule pill pinned to the bottom of the content block across every card in the row instead of drifting with description length. #}
@@ -55,7 +59,7 @@
</div>
{% if job.runs|length > 1 %}
<div class="mt-auto flex flex-col gap-1.5 border-t border-rule px-4 py-3">
<div class="flex flex-col gap-1.5 border-t border-rule px-4 py-3">
<div class="font-mono text-[10px] tracking-[.06em] text-dim uppercase">Recent runs</div>
{% for run in job.runs|slice:":5" %}
<div class="flex items-center gap-2 font-mono text-[11px]">
@@ -71,6 +75,17 @@
{% endfor %}
</div>
{% endif %}
<div class="mt-auto border-t border-rule px-4 py-3">
<form method="post" action="{% url 'controlpanel:job_toggle' job.name %}">
{% csrf_token %}
{% if job.enabled %}
<button type="submit" class="btn btn-outline btn-sm btn-error w-full gap-2">{% lucide "pause" size=14 %} Pause job</button>
{% else %}
<button type="submit" class="btn btn-outline btn-sm btn-success w-full gap-2">{% lucide "play" size=14 %} Resume job</button>
{% endif %}
</form>
</div>
</div>
{% endfor %}
</div>

View File

@@ -22,7 +22,7 @@ from billing.services import BillingError
from billing.services.dues import record_payment, start_trial, subscribe, waive
from club.models import Club, ClubMembership, ClubRole, Season
from events.models import Attendance, Competition, Event, Location
from features.models import Maintenance
from features.models import JobToggle, Maintenance
from members.models import Member
from shop.models import Order
from teams.models import Position, StaffAssignment, Team, TeamMembership
@@ -331,6 +331,61 @@ class ClubHomeLocationTests(ControlPanelTestBase):
self.assertFalse(Location.objects.filter(club=self.club, is_home=True).exists())
class JobToggleViewTests(ControlPanelTestBase):
"""Pausing/resuming one scheduled platform job -- see controlpanel.views.
JobToggleView and features.models.JobToggle."""
JOB_NAME = "events.tasks.extend_event_series"
def setUp(self):
super().setUp()
# JobToggle's cache isn't rolled back with the test transaction.
cache.clear()
self.addCleanup(cache.clear)
def toggle(self, name=JOB_NAME):
return self.client.post(reverse("controlpanel:job_toggle", args=[name]))
def test_a_job_starts_enabled(self):
response = self.client.get(reverse("controlpanel:jobs"))
self.assertContains(response, "Pause job")
def test_pausing_a_job(self):
response = self.toggle()
self.assertRedirects(response, reverse("controlpanel:jobs"))
self.assertFalse(JobToggle.is_enabled(self.JOB_NAME))
def test_resuming_a_paused_job(self):
self.toggle()
self.toggle()
self.assertTrue(JobToggle.is_enabled(self.JOB_NAME))
def test_the_jobs_page_reflects_the_paused_state(self):
self.toggle()
response = self.client.get(reverse("controlpanel:jobs"))
self.assertContains(response, "paused")
self.assertContains(response, "Resume job")
def test_an_unknown_job_name_is_a_404(self):
response = self.toggle(name="not.a.real.job")
self.assertEqual(response.status_code, 404)
def test_a_non_staff_user_cannot_toggle_a_job(self):
self.client.force_login(User.objects.create_user(email="plain-jobs@example.com", password="pw-secret-123"))
response = self.toggle()
self.assertEqual(response.status_code, 403)
self.assertTrue(JobToggle.is_enabled(self.JOB_NAME))
class StatisticsTests(TestCase):
@classmethod
def setUpTestData(cls):

View File

@@ -43,6 +43,7 @@ urlpatterns = [
path("admins/add/", views.PlatformAdminAddView.as_view(), name="admin_add"),
path("admins/<uuid:pk>/update/", views.PlatformAdminUpdateView.as_view(), name="admin_update"),
path("admins/<uuid:pk>/revoke/", views.PlatformAdminRevokeView.as_view(), name="admin_revoke"),
# Jobs (Celery Beat's scheduled platform jobs -- monitoring only, see controlpanel/services/jobs.py)
# Jobs (Celery Beat's scheduled platform jobs -- see controlpanel/services/jobs.py)
path("jobs/", views.JobsView.as_view(), name="jobs"),
path("jobs/<str:name>/toggle/", views.JobToggleView.as_view(), name="job_toggle"),
]

View File

@@ -2,7 +2,7 @@ from contextlib import contextmanager
from django.contrib.auth import get_user_model
from django.db.models import Count
from django.http import HttpResponse
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
@@ -19,7 +19,8 @@ from billing.services.invoices import invoice_pdf, issue_invoice
from billing.services.plans import delete_plan, plan_deletion_impact
from club.models import Club, ClubRole
from events.models import Competition, Location
from features.models import Maintenance
from features.jobs import JOB_REGISTRY
from features.models import JobToggle, Maintenance
from .forms import ClubAdminForm, ClubForm, CompetitionForm, DuePaymentForm, FlagForm, HomeLocationForm, MaintenanceForm, OpenPeriodForm, PlanForm, PlanPriceForm, PlatformAdminForm, SubscriptionForm, TrialForm
from .messages import notify
@@ -76,9 +77,10 @@ class DashboardView(PlatformStaffRequiredMixin, TemplateView):
class JobsView(PlatformStaffRequiredMixin, TemplateView):
"""Status and recent history of the scheduled platform jobs -- see features/jobs.py for
the registry and features/models.JobRun for what a Celery task run writes. Monitoring
only, deliberately: these run on Celery Beat's own schedule (rosterchief/settings.py),
not on demand from here."""
the registry and features/models.JobRun for what a Celery task run writes. These still
run on Celery Beat's own schedule (rosterchief/settings.py) -- there is no "run now"
here -- but each one can be paused/resumed individually via JobToggleView below, without
reaching for the platform-wide Maintenance lock."""
template_name = "controlpanel/jobs.html"
@@ -86,6 +88,27 @@ class JobsView(PlatformStaffRequiredMixin, TemplateView):
return super().get_context_data(nav="jobs", jobs=job_overview(), **kwargs)
class JobToggleView(PlatformStaffRequiredMixin, View):
"""Pause or resume one scheduled job -- see features.models.JobToggle. `name` is the
job's dotted Celery task name (a features.jobs.JOB_REGISTRY key), not a database id: a
toggle row only exists once a job has actually been flipped off at least once."""
def post(self, request, name):
if name not in JOB_REGISTRY:
raise Http404
enabled = not JobToggle.is_enabled(name)
JobToggle.set_enabled(name, enabled)
label = JOB_REGISTRY[name]["label"]
if enabled:
notify(request, f"s|Job resumed|“{label}” will run on its schedule again.")
else:
notify(request, f"w|Job paused|“{label}” will stand down until resumed.")
return redirect("controlpanel:jobs")
class ClubListView(PlatformStaffRequiredMixin, ListView):
template_name = "controlpanel/club_list.html"
context_object_name = "clubs"