New `notifications` app: Notification (club-scoped, keyed to the member it's about, generic `source` via a ContentType/object_id pair so future activities can reuse this without a new model each time) plus notify_members(), which resolves each member's own email (if they hold a login) and every parent/guardian's, always -- a child with their own account doesn't opt their parents out -- and emails the club-branded template to whichever addresses that resolves to. Delivery is email-only for now (no in-app feed exists yet); the row is created either way, ready for one later. news.tasks.notify_news_published resolves the audience (a team-scoped item's current rosters, or every active member if it's club-wide) and calls notify_members with the item's title/plain-text body. NewsPublishForm gained a "Notify linked members" checkbox (opt-in, default off); when checked, NewsPublishView schedules the task with Celery's `eta` set to the item's own published_at -- a scheduled item's notification arrives when it actually goes live, and an immediate publish (eta in the past) just runs right away, no separate branch needed. Registered the new notification email on the Club identity page's Email tab alongside the others. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
425 lines
19 KiB
Python
425 lines
19 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 celery.schedules import crontab
|
|
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="")
|
|
# The loopback is always allowed, and it must be: the container's own healthcheck and the
|
|
# deploy probe hit /healthz over 127.0.0.1/localhost, before any proxy has supplied a real
|
|
# Host header. Without this they get a 400 and the container is marked unhealthy forever.
|
|
# It widens nothing — gunicorn binds to the loopback only; the proxy owns the public domains.
|
|
ALLOWED_HOSTS += [host for host in ("localhost", "127.0.0.1") if host not in ALLOWED_HOSTS]
|
|
|
|
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",
|
|
"django_countries",
|
|
"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",
|
|
"news.apps.NewsConfig",
|
|
"notifications.apps.NotificationsConfig",
|
|
"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",
|
|
# Club-facing UI for team managers, coaches and admins -- not controlpanel (platform
|
|
# staff managing every club) and not the future parent/player app.
|
|
"management.apps.ManagementConfig",
|
|
# Public, read-only JSON API for a club's own external website -- see api/urls.py.
|
|
"api.apps.ApiConfig",
|
|
]
|
|
|
|
# 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",
|
|
# After maintenance: a club under maintenance closes its public API too, same as
|
|
# everything else on its subdomain.
|
|
"api.middleware.PublicApiCorsMiddleware",
|
|
"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",
|
|
"controlpanel.context_processors.job_health",
|
|
"management.context_processors.is_admin",
|
|
"management.context_processors.billing_notice",
|
|
"management.context_processors.management_position",
|
|
"management.context_processors.management_link",
|
|
"management.context_processors.active_nav_section",
|
|
"management.context_processors.news_permissions",
|
|
"management.context_processors.feature_sections",
|
|
"management.context_processors.sidebar_counters",
|
|
],
|
|
},
|
|
},
|
|
]
|
|
|
|
WSGI_APPLICATION = "rosterchief.wsgi.application"
|
|
|
|
|
|
# Tests
|
|
#
|
|
# A custom runner, not extra settings: it swaps in a fast password hasher, the cached
|
|
# template loader and a quiet django.request logger while the suite runs. Those belong
|
|
# nowhere near a deployed process, and a runner is only ever instantiated by
|
|
# `manage.py test` -- see rosterchief/test_runner.py for the full reasoning.
|
|
TEST_RUNNER = "rosterchief.test_runner.RosterChiefTestRunner"
|
|
|
|
|
|
# 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"}}
|
|
|
|
|
|
# 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/
|
|
|
|
STATIC_URL = "static/"
|
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
|
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.
|
|
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)
|
|
|
|
|
|
# Logging
|
|
#
|
|
# Django's default LOGGING gates its console handler behind `require_debug_true`, so with
|
|
# DEBUG=False (every real deployment) an unhandled exception becomes a 500 response and leaves
|
|
# no trace anywhere — gunicorn's error log only sees WSGI-level crashes, not exceptions Django
|
|
# already caught and turned into a response. Without this, `docker compose logs web` is silent
|
|
# during exactly the incidents it most needs to explain.
|
|
LOGGING = {
|
|
"version": 1,
|
|
"disable_existing_loggers": False,
|
|
"handlers": {
|
|
"console": {"class": "logging.StreamHandler"},
|
|
},
|
|
"loggers": {
|
|
"django.request": {"handlers": ["console"], "level": "ERROR", "propagate": False},
|
|
},
|
|
}
|
|
|
|
|
|
# Email
|
|
#
|
|
# Provider-agnostic on purpose: everything is a plain SMTP setting read from the environment,
|
|
# so any provider that speaks SMTP works without a code change. The console backend is the
|
|
# DEFAULT rather than the dev-only branch -- a deployment that forgets to configure mail
|
|
# should print billing reminders to the log, not raise ConnectionRefused against localhost:25
|
|
# on a box with no MTA, which is what Django's own default does.
|
|
#
|
|
# Resend (resend.com) works either way: point DJANGO_EMAIL_BACKEND at Django's own SMTP
|
|
# backend with Resend's SMTP relay credentials, or set it to rosterchief.mail.ResendEmailBackend
|
|
# to send through Resend's HTTP API instead (see that module) -- set RESEND_API_KEY either way.
|
|
# Every Django-sent email (allauth's password reset included, since it goes through
|
|
# django.core.mail like everything else) follows whichever backend is configured here.
|
|
EMAIL_BACKEND = config("DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend")
|
|
EMAIL_HOST = config("DJANGO_EMAIL_HOST", default="")
|
|
EMAIL_PORT = config("DJANGO_EMAIL_PORT", default=587, cast=int)
|
|
EMAIL_HOST_USER = config("DJANGO_EMAIL_HOST_USER", default="")
|
|
EMAIL_HOST_PASSWORD = config("DJANGO_EMAIL_HOST_PASSWORD", default="")
|
|
EMAIL_USE_TLS = config("DJANGO_EMAIL_USE_TLS", default=True, cast=bool)
|
|
EMAIL_USE_SSL = config("DJANGO_EMAIL_USE_SSL", default=False, cast=bool)
|
|
EMAIL_TIMEOUT = config("DJANGO_EMAIL_TIMEOUT", default=10, cast=int)
|
|
|
|
#: Only read by rosterchief.mail.ResendEmailBackend -- irrelevant for the SMTP backend
|
|
#: (which would use EMAIL_HOST_PASSWORD, e.g. Resend's own SMTP relay, instead).
|
|
RESEND_API_KEY = config("RESEND_API_KEY", default="")
|
|
|
|
DEFAULT_FROM_EMAIL = config("DJANGO_DEFAULT_FROM_EMAIL", default="RosterChief <noreply@rosterchief.app>")
|
|
SERVER_EMAIL = config("DJANGO_SERVER_EMAIL", default=DEFAULT_FROM_EMAIL)
|
|
|
|
#: Where a club admin is told to direct a billing question. Shown in reminder emails.
|
|
BILLING_CONTACT_EMAIL = config("ROSTERCHIEF_BILLING_CONTACT_EMAIL", default=DEFAULT_FROM_EMAIL)
|
|
|
|
|
|
# Phone numbers (django-phonenumber-field)
|
|
|
|
PHONENUMBER_DEFAULT_REGION = "BE"
|
|
PHONENUMBER_DB_FORMAT = "E164"
|