Add maintenance mode: lock the platform down from the control panel

Closes every club subdomain with a 503 in that club's own colours, stands the
scheduled jobs down, and keeps open exactly what is needed to end it again.

The exemptions ARE the feature:

- /accounts/ stays open on the base domain. Close it too and you cannot sign in to
  turn maintenance off -- a lock-down with no key, fixable only from a shell.
- /healthz answers on every host. Close it and the load balancer decides the node
  is dead, stops routing to it, and takes the control panel down with everything
  else.
- migrate and collectstatic are NOT blocked. Maintenance is usually declared in
  order to run them; a blanket guard on BaseCommand would mean turning the mode off
  to do the work you turned it on for. Only the domain jobs (archive_overdue_clubs,
  extend_event_series, import_members_csv) refuse, and they exit non-zero so cron
  mails you -- a scheduled job that silently skips itself is how a month of billing
  goes missing.

The state is cached with a 10-second TTL, not for ever. Write-through makes the
flip instant for the shared Redis of a real deployment, and the TTL is the belt to
that braces: on a per-process cache -- a dev box with no Redis, or a misconfigured
deploy -- a lock-down that reached only one gunicorn worker would be worse than
useless. Live-verified: a club subdomain, its login page and the base domain all
503 while the control panel and the sign-in screens stay up.

Also adds the two deployment pieces asked for: compose.behind-proxy.yaml for a
dev/test box that already runs Caddy on :80 (app on the loopback, host Caddy proxies
to it -- and the host's Caddy still needs the DNS plugin, because the wildcard is
still a wildcard), and deploy/backup.sh + restore-check.sh with a cron schedule. The
backup writes to a .part file and only lands it once gzip -t says it is readable: a
truncated dump that looks like a backup is the failure you find on the day you need
it. The weekly restore rehearsal is the only line in that cron that proves the rest
work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 10:11:15 +02:00
parent c0a44093d9
commit d30b163122
22 changed files with 796 additions and 12 deletions

View File

@@ -103,3 +103,15 @@ class OpenPeriodForm(forms.Form):
"""Renew, or reactivate an archived club."""
start = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}), label=_("Period starts"), help_text=_("Left blank, it continues from the end of the last period — so a lapsed year is still owed."))
class MaintenanceForm(forms.Form):
"""Closing the platform is a deliberate act, so it takes a sentence explaining itself —
that message is the only thing a club will see."""
message = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 2}),
label=_("Message"),
help_text=_("Shown to every club while the platform is closed. Left blank, they get a generic notice."),
)

View File

@@ -6,6 +6,16 @@
{% endblock title %}
{% block main %}
{% if maintenance_on %}
<div class="alert alert-error mb-6">
{% lucide "wrench" size=20 %}
<span>
<strong>The platform is closed for maintenance.</strong>
Clubs see a maintenance page and the scheduled jobs are standing down.
</span>
<a class="btn btn-sm" href="{% url 'controlpanel:features' %}">Reopen</a>
</div>
{% endif %}
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-col gap-2">
<h1 class="text-3xl font-bold">

View File

@@ -1,5 +1,5 @@
{% extends "controlpanel/base.html" %}
{% load lucide %}
{% load lucide ui %}
{% block heading %}Features{% endblock heading %}
@@ -8,6 +8,48 @@
{% endblock actions %}
{% block panel %}
{% comment %}
The lock-down. Clubs get a maintenance page, the scheduled jobs stand down, and the
control panel and the auth screens stay open — otherwise you could not sign in to
turn it back off.
{% endcomment %}
<div class="card mb-6 bg-base-100 shadow {% if maintenance.is_active %}border-l-4 border-error{% endif %}">
<div class="card-body">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="card-title text-base">{% lucide "wrench" size=18 %} Maintenance mode</h2>
{% if maintenance.is_active %}
<p class="text-sm">
<span class="badge badge-error gap-1">{% lucide "lock" size=12 %} Platform closed</span>
since {{ maintenance.started_at|date:"j M Y, H:i" }}{% if maintenance.started_by %} by {{ maintenance.started_by.email }}{% endif %}.
</p>
{% if maintenance.message %}<p class="mt-1 text-sm opacity-70">“{{ maintenance.message }}”</p>{% endif %}
{% else %}
<p class="text-sm opacity-70">
Closes every club subdomain and stands the scheduled jobs down. The control panel and the sign-in screens stay open.
</p>
{% endif %}
</div>
</div>
<form class="mt-2" method="post" action="{% url 'controlpanel:maintenance' %}">
{% csrf_token %}
{% if not maintenance.is_active %}
<div class="form-control my-2 w-full max-w-xl">
<label class="label" for="{{ maintenance_form.message.id_for_label }}">
<span class="label-text">{{ maintenance_form.message.label }}</span>
</label>
{{ maintenance_form.message|daisy }}
<span class="label-text-alt mt-1 block text-base-content/70">{{ maintenance_form.message.help_text }}</span>
</div>
<button class="btn btn-error gap-2" type="submit">{% lucide "lock" size=16 %} Close the platform</button>
{% else %}
<button class="btn btn-success gap-2" type="submit">{% lucide "lock-open" size=16 %} Reopen the platform</button>
{% endif %}
</form>
</div>
</div>
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "flag" size=18 %} Flags</h2>

View File

@@ -20,6 +20,7 @@ from billing.services import BillingError
from billing.services.dues import record_payment, subscribe
from club.models import Club, ClubMembership, ClubRole, Season
from events.models import Attendance, Event
from features.models import Maintenance
from members.models import Member
from shop.models import Order
from teams.models import Position, StaffAssignment, Team, TeamMembership
@@ -1315,3 +1316,38 @@ class BillingFormRenderTests(ControlPanelTestBase):
response = self.client.post(reverse("controlpanel:due_waive", args=[due.pk]), follow=True)
self.assertContains(response, "remove them before waiving")
class MaintenancePanelTests(ControlPanelTestBase):
def setUp(self):
super().setUp()
cache.clear()
self.addCleanup(cache.clear)
def test_closing_the_platform_records_who_did_it(self):
self.client.post(reverse("controlpanel:maintenance"), {"message": "Database upgrade."})
maintenance = Maintenance.current()
self.assertTrue(maintenance.is_active)
self.assertEqual(maintenance.message, "Database upgrade.")
self.assertEqual(maintenance.started_by, self.staff)
def test_posting_again_reopens_the_platform(self):
Maintenance.start(message="x", user=self.staff)
self.client.post(reverse("controlpanel:maintenance"), {})
self.assertFalse(Maintenance.is_on())
def test_every_panel_page_warns_while_the_platform_is_closed(self):
# Not a state to leave on by accident.
Maintenance.start(user=self.staff)
for url in (reverse("controlpanel:dashboard"), reverse("controlpanel:club_list"), reverse("controlpanel:features")):
self.assertContains(self.client.get(url), "closed for maintenance", msg_prefix=url)
def test_the_features_page_offers_the_switch(self):
response = self.client.get(reverse("controlpanel:features"))
self.assertContains(response, "Maintenance mode")
self.assertContains(response, "Close the platform")

View File

@@ -18,6 +18,7 @@ urlpatterns = [
path("clubs/<uuid:pk>/features/<int:flag_pk>/toggle/", views.ClubFeatureToggleView.as_view(), name="club_feature_toggle"),
# Features
path("features/", views.FeatureListView.as_view(), name="features"),
path("features/maintenance/", views.MaintenanceView.as_view(), name="maintenance"),
path("features/flags/new/", views.FlagCreateView.as_view(), name="flag_create"),
path("features/flags/<int:pk>/edit/", views.FlagUpdateView.as_view(), name="flag_update"),
path("features/switches/<int:pk>/toggle/", views.SwitchToggleView.as_view(), name="switch_toggle"),

View File

@@ -13,8 +13,9 @@ from billing.services import BillingError
from billing.services.dues import next_period_start, open_period, reactivate, record_payment, subscribe, waive
from billing.services.invoices import invoice_pdf, issue_invoice
from club.models import Club, ClubRole
from features.models import Maintenance
from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, OpenPeriodForm, PlatformAdminForm, SubscriptionForm, TierForm, TierPriceForm
from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, MaintenanceForm, OpenPeriodForm, PlatformAdminForm, SubscriptionForm, TierForm, TierPriceForm
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin
from .services.admins import grant_club_admin, revoke_club_admin
from .services.platform_admins import (
@@ -197,10 +198,28 @@ class FeatureListView(PlatformStaffRequiredMixin, TemplateView):
nav="features",
flags=Flag.objects.prefetch_related("clubs").order_by("name"),
switches=Switch.objects.order_by("name"),
maintenance=Maintenance.current(),
maintenance_form=MaintenanceForm(),
**kwargs,
)
class MaintenanceView(PlatformStaffRequiredMixin, View):
"""Close the platform, or open it again."""
def post(self, request):
if Maintenance.is_on():
Maintenance.stop()
messages.success(request, "Maintenance ended. The clubs are back.")
else:
form = MaintenanceForm(request.POST)
message = form.cleaned_data["message"] if form.is_valid() else ""
Maintenance.start(message=message, user=request.user)
messages.warning(request, "Platform closed. Every club subdomain now serves a maintenance page, and the scheduled jobs stand down.")
return redirect("controlpanel:features")
class FlagCreateView(PlatformStaffRequiredMixin, CreateView):
model = Flag
form_class = FlagForm