Checkpoint: management app redesign, onboarding/signup workflow, and events calendar backend

Large uncommitted body of work accumulated across sessions on this branch --
committing as a checkpoint so it's tracked and future worktree-isolated agents
see the real codebase instead of a stale ancestor commit. Covers the
management app's dedicated Tailwind theme and templates, the club onboarding
requirement/signup workflow (club/services/onboarding.py, requirement/status
models, sign-up dashboard), fee/status auto-activation decoupling, referee
management, and the new events calendar grid service layer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-19 23:34:43 +02:00
parent bff685966d
commit adf1120358
157 changed files with 20342 additions and 4008 deletions

View File

@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ("celery_app",)

22
rosterchief/celery.py Normal file
View File

@@ -0,0 +1,22 @@
"""Celery application for RosterChief's scheduled platform jobs.
Replaces the host crontab described in DEPLOYMENT.md's old "Scheduled jobs" section: the
same Redis instance django-redis already uses for caching (``DJANGO_REDIS_URL``) doubles as
the broker and result backend, so there is nothing new to deploy except the `worker` and
`beat` processes themselves (see compose.yaml) -- no RabbitMQ, no separate broker to run,
monitor or back up.
``autodiscover_tasks()`` with no arguments relies on Celery's Django integration (active
because DJANGO_SETTINGS_MODULE is set below): it walks INSTALLED_APPS and imports each
app's ``tasks.py`` if present -- see billing/tasks.py, club/tasks.py, events/tasks.py.
"""
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rosterchief.settings")
app = Celery("rosterchief")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()

View File

@@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/6.0/ref/settings/
from importlib.util import find_spec
from pathlib import Path
from celery.schedules import crontab
from decouple import Csv, config
from dj_database_url import parse as db_url
@@ -193,6 +194,7 @@ TEMPLATES = [
"django.contrib.messages.context_processors.messages",
"club.context_processors.branding",
"features.context_processors.maintenance",
"controlpanel.context_processors.job_health",
"management.context_processors.is_admin",
"management.context_processors.billing_notice",
"management.context_processors.management_position",
@@ -270,6 +272,45 @@ REDIS_URL = config("DJANGO_REDIS_URL", default="")
CACHES = {"default": {"BACKEND": "django_redis.cache.RedisCache", "LOCATION": REDIS_URL, "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}} if REDIS_URL else {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
# Task queue (Celery)
#
# The scheduled platform jobs (see billing/tasks.py, club/tasks.py, events/tasks.py) used to
# be host crontab entries calling `manage.py <command>` -- see DEPLOYMENT.md. They now run as
# Celery tasks on a beat schedule below, tracked in features.models.JobRun and visible on the
# control panel's Jobs tab, which a bare crontab line mailing stderr on failure never gave us.
#
# Same Redis as CACHES above -- one already-deployed instance, not a second broker to run.
# Without DJANGO_REDIS_URL there is nothing to connect to, so tasks run eagerly (inline, in
# the calling process) instead of being queued -- the same "just works with nothing
# configured" fallback CACHES uses, so `manage.py shell` on a laptop with no Redis can still
# exercise a task directly.
#
# CELERY_TASK_EAGER_PROPAGATES is deliberately left at its default (False): a real worker
# never raises a task's exception back into the caller of .delay() either (it's async --
# the caller has moved on long before the task runs), it catches it, marks the result
# FAILURE and fires task_failure so error-tracking (features/signals.py -> JobRun) can react.
# Propagating in eager mode only would make local/test behaviour diverge from production
# *and* skip that signal, silently losing JobRun.error on every eager failure.
CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL or None
CELERY_TASK_ALWAYS_EAGER = not REDIS_URL
CELERY_TASK_TRACK_STARTED = True
CELERY_TIMEZONE = TIME_ZONE
#: Mirrors the old crontab times exactly (see DEPLOYMENT.md), with one fix: renew_subscriptions
#: was never actually wired to cron there, despite its own docstring saying it's meant to run
#: on a schedule -- controlpanel.services.statistics.platform_attention()'s `renewals_pending`
#: figure exists specifically to catch that class of gap. Placed before the reminder/archive
#: jobs so a club that renews today isn't chased or archived for a period that just closed.
CELERY_BEAT_SCHEDULE = {
"extend-event-series": {"task": "events.tasks.extend_event_series", "schedule": crontab(hour=3, minute=0)},
"renew-subscriptions": {"task": "billing.tasks.renew_subscriptions", "schedule": crontab(hour=4, minute=0)},
"send-billing-reminders": {"task": "billing.tasks.send_billing_reminders", "schedule": crontab(hour=5, minute=0)},
"archive-overdue-clubs": {"task": "billing.tasks.archive_overdue_clubs", "schedule": crontab(hour=6, minute=0)},
"generate-seasons": {"task": "club.tasks.generate_seasons", "schedule": crontab(hour=5, minute=0, day_of_month=1)},
}
# Static files and uploads
# https://docs.djangoproject.com/en/6.0/howto/static-files/
@@ -280,6 +321,11 @@ STATICFILES_DIRS = [BASE_DIR / "static"]
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
# Separate from MEDIA_ROOT on purpose: this directory is never served directly (no
# Caddy passthrough, no /media/* route) and every read goes through an authenticated
# Django view -- see rosterchief/storage.py.
PRIVATE_MEDIA_ROOT = BASE_DIR / "private_media"
# Uploads (club logos) go to S3-compatible storage as soon as a bucket is configured. On one
# server the local disk works; on two, a logo uploaded to node A 404s on node B — so this is
# the switch that decides whether "add a server" is an afternoon or a migration.

39
rosterchief/storage.py Normal file
View File

@@ -0,0 +1,39 @@
"""Private file storage -- for uploads that must never be reachable by a guessable
URL, unlike everything in MEDIA_ROOT (club logos, sponsor logos, team photos, news
photos), which is deliberately public and served straight off disk by Caddy in
production (see deploy/caddy/Caddyfile's `/media/*` block -- it reads from the same
shared volume as Django's own MEDIA_ROOT, so anything placed there is public
regardless of what a Django view's own permission check says).
`PRIVATE_MEDIA_ROOT` is a completely separate directory, on a separate Docker volume
(see compose.yaml) that only the `web` container mounts -- Caddy never sees it. The
only way to read a file stored here is through an authenticated Django view that
streams it explicitly (see management.views.MemberRequirementDocumentView, the one
current use: MemberRequirementStatus.document, e.g. an uploaded medical certificate).
Deliberately local disk only, not S3, regardless of AWS_STORAGE_BUCKET_NAME -- unlike
the default storage's public/private split (which follows whether a bucket is
configured), this one is a fixed choice: local disk today, revisit if/when
multi-server deployment needs it (see DEPLOYMENT.md's own local-disk caveat for
MEDIA_ROOT -- the same one applies here until then).
"""
from django.conf import settings
from django.core.files.storage import FileSystemStorage
class PrivateStorage(FileSystemStorage):
"""FileSystemStorage with .url() permanently disabled.
Passing base_url=None to the parent class does NOT do this -- FileSystemStorage
treats None as "unset" and falls back to settings.MEDIA_URL, so it would happily
hand back a `/media/...` link for a file that was never written under MEDIA_ROOT
in the first place (wrong and broken, but not obviously so -- it looks like a
normal URL until something tries to fetch it). Overriding .url() to always raise
is the only way to make "this storage has no URL" fail loudly instead."""
def url(self, name):
raise ValueError("PrivateStorage has no URL -- read a file through an authenticated view instead, e.g. management.views.MemberRequirementDocumentView.")
private_storage = PrivateStorage(location=str(settings.PRIVATE_MEDIA_ROOT))