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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user