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:
@@ -0,0 +1,3 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
|
||||
22
rosterchief/celery.py
Normal file
22
rosterchief/celery.py
Normal 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()
|
||||
@@ -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
39
rosterchief/storage.py
Normal 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))
|
||||
Reference in New Issue
Block a user