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>
293 lines
12 KiB
Python
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"
|