Files
RosterChief/rosterchief/settings.py
Bernard Siebens c42963c447 Do not let DEBUG=True take down a container that has no dev deps
The image installs with --no-dev, so django_browser_reload is absent. Settings and
urls both assumed DEBUG implied it was installed, so DJANGO_DEBUG=True in a
deployed container did not merely turn on debugging: the app refused to start, with
a ModuleNotFoundError that says nothing about the actual mistake.

Both now guard on the module being importable. Reproduced the failure locally by
hiding the package with DEBUG on, and confirmed the urlconf loads afterwards.

DEPLOYMENT.md says the obvious thing out loud: a test server is still a deployment
-- real TLS, real domain, real passkeys -- so DEBUG stays off there. The crash is
fixed; the reason to keep it off was never the crash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:14:53 +02:00

293 lines
12 KiB
Python

"""
Django settings for rosterchief project.
Generated by 'django-admin startproject' using Django 6.0.6.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.0/ref/settings/
"""
from importlib.util import find_spec
from pathlib import Path
from decouple import Csv, config
from dj_database_url import parse as db_url
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = config("DJANGO_SECRET_KEY")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = config("DJANGO_DEBUG", default=False, cast=bool)
ALLOWED_HOSTS = config("DJANGO_ALLOWED_HOSTS", cast=Csv(), default="")
INTERNAL_IPS = config("DJANGO_INTERNAL_IPS", cast=Csv(), default="127.0.0.1")
CSRF_TRUSTED_ORIGINS = config("DJANGO_CSRF_TRUSTED_ORIGINS", cast=Csv(), default="")
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# Required by allauth's security-key list template ({% load humanize %}); without it
# that page raises TemplateSyntaxError.
"django.contrib.humanize",
"phonenumber_field",
"lucide",
# Auth: allauth deliberately WITHOUT django.contrib.sites — it is optional in
# allauth 65+, and ARCHITECTURE.md §2.4 rejects the Sites framework (Club is
# the tenant root, not Site).
"allauth",
"allauth.account",
"allauth.mfa",
"club.apps.ClubConfig",
"authentication.apps.AuthenticationConfig",
"members.apps.MembersConfig",
"teams.apps.TeamsConfig",
"events.apps.EventsConfig",
"formbuilder.apps.FormbuilderConfig",
"shop.apps.ShopConfig",
# Platform billing: RosterChief charging the clubs. Not tenant data — see billing/models.py.
"billing.apps.BillingConfig",
"waffle",
"features.apps.FeaturesConfig",
"controlpanel.apps.ControlpanelConfig",
]
# Feature flags (django-waffle). The Flag model is swappable, like AUTH_USER_MODEL:
# ours adds a `clubs` M2M so a feature can be turned on per tenant. Because the
# tenant middleware sets request.club, `flag_is_active(request, "x")` just works.
WAFFLE_FLAG_MODEL = "features.Flag"
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
# Directly after SecurityMiddleware, per WhiteNoise's contract. It serves the collected
# static files from the app itself, so no shared volume or CDN is needed to add a second
# app server.
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"allauth.account.middleware.AccountMiddleware",
"authentication.middleware.RequireMFAMiddleware",
"club.tenancy.ClubTenantMiddleware",
# After tenancy: it decides club-vs-platform from request.club, which was just resolved.
"features.middleware.MaintenanceMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
# Reload the browser when templates, static files or Python change. Dev only: it injects a
# script tag into every HTML response and serves an open event stream, neither of which
# belongs in production.
#
# Guarded on the module being *importable*, not just on DEBUG: it is a dev dependency, and the
# production image installs with --no-dev. Without the guard, DEBUG=True in a deployed
# container does not merely turn on debugging — it stops the app from starting at all, with a
# ModuleNotFoundError that says nothing about the actual mistake.
BROWSER_RELOAD_AVAILABLE = find_spec("django_browser_reload") is not None
if DEBUG and BROWSER_RELOAD_AVAILABLE:
INSTALLED_APPS += ["django_browser_reload"]
MIDDLEWARE += ["django_browser_reload.middleware.BrowserReloadMiddleware"]
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend",
]
# Multi-tenancy: base domain whose subdomains resolve to a club, e.g.
# "ajax-united.rosterchief.app" -> the club with slug "ajax-united". Leave
# unset to fall back to generic "slug.example.com" (3+ label) resolution.
ROSTERCHIEF_BASE_DOMAIN = config("ROSTERCHIEF_BASE_DOMAIN", default="")
ROOT_URLCONF = "rosterchief.urls"
AUTH_USER_MODEL = "authentication.User"
# Authentication (django-allauth)
LOGIN_URL = "account_login"
LOGIN_REDIRECT_URL = "/"
# The User model logs in by email and has no username field.
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
ACCOUNT_LOGIN_METHODS = {"email"}
ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
ACCOUNT_EMAIL_VERIFICATION = "none"
# Two-factor authentication (allauth.mfa)
MFA_SUPPORTED_TYPES = ["totp", "webauthn", "recovery_codes"]
# Passkeys are a first factor: sign in with Touch ID / a security key alone.
MFA_PASSKEY_LOGIN_ENABLED = True
MFA_PASSKEY_SIGNUP_ENABLED = False
# WebAuthn needs a secure context. Browsers treat *.localhost as secure, but the
# dev server is plain HTTP, so allow the insecure origin while DEBUG.
MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = DEBUG
# A passkey is bound to a Relying Party ID (a domain). Our adapter pins it to
# ROSTERCHIEF_BASE_DOMAIN so that ONE passkey works across every club subdomain
# — allauth's default (the request host) would bind it to a single club.
MFA_ADAPTER = "authentication.adapters.RosterChiefMFAAdapter"
MFA_WEBAUTHN_RP_NAME = config("ROSTERCHIEF_RP_NAME", default="RosterChief")
# Where RequireMFAMiddleware sends privileged users who haven't enrolled yet.
MFA_ENROLMENT_URL_NAME = "mfa_index"
# Sessions are shared across club subdomains: log in once and you're authenticated
# on every club (matching the one-passkey-everywhere model). Tenancy still scopes
# what you can *see* — that is the access service's job, not the cookie's.
# Browsers reject a Domain attribute on localhost, so it stays host-only in dev.
SHARED_COOKIE_DOMAIN = f".{ROSTERCHIEF_BASE_DOMAIN}" if ROSTERCHIEF_BASE_DOMAIN and ROSTERCHIEF_BASE_DOMAIN != "localhost" else None
SESSION_COOKIE_DOMAIN = config("DJANGO_SESSION_COOKIE_DOMAIN", default=SHARED_COOKIE_DOMAIN)
CSRF_COOKIE_DOMAIN = config("DJANGO_CSRF_COOKIE_DOMAIN", default=SHARED_COOKIE_DOMAIN)
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"club.context_processors.branding",
"features.context_processors.maintenance",
],
},
},
]
WSGI_APPLICATION = "rosterchief.wsgi.application"
# Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
DATABASES = {
"default": config("DJANGO_DATABASE_URL", default="sqlite:///db.sqlite3", cast=db_url),
}
# Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/6.0/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = config("DJANGO_TIME_ZONE", default="Europe/Brussels", cast=str)
USE_I18N = True
USE_TZ = True
# Cache
#
# Redis in production, and not merely for speed: waffle caches each flag's targeting in the
# Django cache, and LocMemCache is private to one process. Under several gunicorn workers a
# toggle flipped in the control panel flushes ONE worker's cache while the others keep
# serving the stale flag — a feature that "sometimes doesn't turn on". A shared cache is the
# fix, so Redis is required from the first multi-worker deploy, not from the second server.
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"}}
# Static files and uploads
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "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.
AWS_STORAGE_BUCKET_NAME = config("AWS_STORAGE_BUCKET_NAME", default="")
AWS_S3_ENDPOINT_URL = config("AWS_S3_ENDPOINT_URL", default="") # set for Hetzner/Scaleway/Backblaze
AWS_S3_REGION_NAME = config("AWS_S3_REGION_NAME", default="")
AWS_ACCESS_KEY_ID = config("AWS_ACCESS_KEY_ID", default="")
AWS_SECRET_ACCESS_KEY = config("AWS_SECRET_ACCESS_KEY", default="")
AWS_S3_FILE_OVERWRITE = False
AWS_QUERYSTRING_AUTH = False # logos are public; signed URLs would break browser caching
STORAGES = {
"default": {"BACKEND": "storages.backends.s3.S3Storage"} if AWS_STORAGE_BUCKET_NAME else {"BACKEND": "django.core.files.storage.FileSystemStorage"},
# Manifest storage only in production: it demands a collectstatic manifest, and every
# {% static %} in a test would blow up without one.
"staticfiles": {"BACKEND": config("DJANGO_STATICFILES_BACKEND", default="django.contrib.staticfiles.storage.StaticFilesStorage")},
}
# HTTPS, behind a reverse proxy
#
# SECURE_PROXY_SSL_HEADER is not optional here: Caddy terminates TLS, so without it Django
# believes every request is plain HTTP. request.is_secure() goes false, allauth and WebAuthn
# disagree with the browser about the origin, and SECURE_SSL_REDIRECT becomes a loop.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Off by default and switched on by the production env, deliberately: defaulting these to
# `not DEBUG` would redirect every test request to https and break the suite anywhere DEBUG
# is unset. `manage.py check --deploy` is what catches a deploy that forgot them.
SECURE_SSL_REDIRECT = config("DJANGO_SECURE_SSL_REDIRECT", default=False, cast=bool)
SESSION_COOKIE_SECURE = config("DJANGO_SESSION_COOKIE_SECURE", default=False, cast=bool)
CSRF_COOKIE_SECURE = config("DJANGO_CSRF_COOKIE_SECURE", default=False, cast=bool)
SECURE_HSTS_SECONDS = config("DJANGO_SECURE_HSTS_SECONDS", default=0, cast=int)
# Every club is a subdomain, so HSTS must cover them all or it protects only the bare domain.
SECURE_HSTS_INCLUDE_SUBDOMAINS = config("DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS", default=True, cast=bool)
SECURE_HSTS_PRELOAD = config("DJANGO_SECURE_HSTS_PRELOAD", default=False, cast=bool)
# Phone numbers (django-phonenumber-field)
PHONENUMBER_DEFAULT_REGION = "BE"
PHONENUMBER_DB_FORMAT = "E164"