Files
Kyle DammannandClaude Opus 5.5 3b72436119 Add OPENAI_BASE_URL and end-to-end AI tests through the SDK
- Optional OPENAI_BASE_URL for OpenAI-compatible endpoints or proxies
- Tests run the real openai SDK against a local stub server: request
  shape (model, auth, strict json_schema), text cleanup, photo import
  and an authentication error mapped to the UI message

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:06:35 +01:00

213 lines
8.0 KiB
Python

"""Django settings. All deployment-specific values come from environment variables (see .env.example)."""
import ipaddress
import os
from pathlib import Path
from urllib.parse import urlsplit
from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent
def env(name, default=""):
return os.environ.get(name, default).strip()
def env_bool(name, default=False):
value = env(name)
if not value:
return default
return value.lower() in {"1", "true", "yes", "on"}
def env_int(name, default):
value = env(name)
return int(value) if value else default
def env_list(name, default=""):
return [item.strip() for item in env(name, default).split(",") if item.strip()]
DEBUG = env_bool("DEBUG")
SECRET_KEY = env("SECRET_KEY")
if not SECRET_KEY:
raise ImproperlyConfigured("SECRET_KEY ist nicht gesetzt (siehe .env.example).")
APP_BASE_URL = env("APP_BASE_URL", "http://localhost:8000").rstrip("/")
_base = urlsplit(APP_BASE_URL)
if _base.scheme not in {"http", "https"} or not _base.hostname:
raise ImproperlyConfigured("APP_BASE_URL muss eine absolute http(s)-URL sein.")
# 127.0.0.1/localhost are needed for the container healthcheck.
ALLOWED_HOSTS = [_base.hostname, "localhost", "127.0.0.1", *env_list("ALLOWED_HOSTS")]
CSRF_TRUSTED_ORIGINS = [f"{_base.scheme}://{_base.netloc}"]
TRUSTED_PROXIES = [
ipaddress.ip_network(net, strict=False)
for net in env_list("TRUSTED_PROXIES", "127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16")
]
CLIENT_IP_HEADER = env("CLIENT_IP_HEADER")
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"core",
"accounts",
"recipes",
]
MIDDLEWARE = [
# Must run first: sanitises X-Forwarded-* before anything reads REMOTE_ADDR or is_secure().
"core.middleware.ProxyMiddleware",
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"core.middleware.SecurityHeadersMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
# Every view requires login unless decorated with @login_not_required.
"django.contrib.auth.middleware.LoginRequiredMiddleware",
"accounts.middleware.ForcePasswordChangeMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"
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",
"recipes.context_processors.ai_status",
],
},
},
]
if env("POSTGRES_HOST"):
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"HOST": env("POSTGRES_HOST"),
"PORT": env("POSTGRES_PORT", "5432"),
"NAME": env("POSTGRES_DB", "kochbuch"),
"USER": env("POSTGRES_USER", "kochbuch"),
"PASSWORD": env("POSTGRES_PASSWORD"),
"CONN_MAX_AGE": 60,
"CONN_HEALTH_CHECKS": True,
}
}
else:
# Local development and tests without Docker.
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"}}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
AUTH_USER_MODEL = "accounts.User"
LOGIN_URL = "accounts:login"
LOGIN_REDIRECT_URL = "recipes:list"
LOGOUT_REDIRECT_URL = "accounts:login"
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
]
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", "OPTIONS": {"min_length": 10}},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LOGIN_MAX_FAILURES_PER_EMAIL = env_int("LOGIN_MAX_FAILURES_PER_EMAIL", 5)
LOGIN_MAX_FAILURES_PER_IP = env_int("LOGIN_MAX_FAILURES_PER_IP", 30)
LOGIN_LOCKOUT_MINUTES = env_int("LOGIN_LOCKOUT_MINUTES", 15)
IMAGE_MAX_UPLOAD_MB = env_int("IMAGE_MAX_UPLOAD_MB", 15)
IMAGE_MAX_PER_RECIPE = env_int("IMAGE_MAX_PER_RECIPE", 10)
COMMENT_RATE_LIMIT_PER_HOUR = env_int("COMMENT_RATE_LIMIT_PER_HOUR", 30)
# Request body limit for non-file fields; files are limited per image above.
DATA_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024
# --- AI (OpenAI) -------------------------------------------------------------------
OPENAI_API_KEY = env("OPENAI_API_KEY")
OPENAI_MODEL = env("OPENAI_MODEL", "gpt-5-mini")
# For reasoning models (gpt-5*, o*): minimal|low|medium|high. Leave empty for other models.
OPENAI_REASONING_EFFORT = env("OPENAI_REASONING_EFFORT", "low")
OPENAI_TIMEOUT = env_int("OPENAI_TIMEOUT", 90)
# Optional: OpenAI-compatible endpoint or proxy (default: api.openai.com)
OPENAI_BASE_URL = env("OPENAI_BASE_URL")
# 0 = unlimited (default). Counted per call, including failed ones.
AI_RATE_LIMIT_PER_USER_PER_DAY = env_int("AI_RATE_LIMIT_PER_USER_PER_DAY", 0)
AI_RATE_LIMIT_GLOBAL_PER_DAY = env_int("AI_RATE_LIMIT_GLOBAL_PER_DAY", 0)
INVITATION_VALID_DAYS = 7
PASSWORD_RESET_VALID_HOURS = 24
# --- Cookies & security headers ---------------------------------------------
COOKIE_SECURE = env_bool("COOKIE_SECURE", default=True)
SESSION_COOKIE_SECURE = COOKIE_SECURE
CSRF_COOKIE_SECURE = COOKIE_SECURE
# The __Host- prefix pins the cookie to this exact host; browsers only accept it over HTTPS.
SESSION_COOKIE_NAME = "__Host-kb_session" if COOKIE_SECURE else "kb_session"
CSRF_COOKIE_NAME = "__Host-kb_csrf" if COOKIE_SECURE else "kb_csrf"
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
CSRF_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_AGE = 60 * 60 * 24 * 30
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_HSTS_SECONDS = env_int("HSTS_SECONDS", 31536000)
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "same-origin"
SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"
X_FRAME_OPTIONS = "DENY"
# The reverse proxy redirects to HTTPS; HSTS for other subdomains/preload is the domain owner's call.
SILENCED_SYSTEM_CHECKS = ["security.W005", "security.W008", "security.W021"]
CONTENT_SECURITY_POLICY = (
"default-src 'self'; img-src 'self' data: blob:; style-src 'self'; script-src 'self'; "
"font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; "
"form-action 'self'; frame-ancestors 'none'"
)
PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=(), payment=(), usb=(), screen-wake-lock=(self)"
# --- I18N ----------------------------------------------------------------------
LANGUAGE_CODE = "de"
TIME_ZONE = env("TIME_ZONE", "Europe/Berlin")
USE_I18N = True
USE_TZ = True
# --- Static & media --------------------------------------------------------------
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
# Uploaded files are never served directly; they go through permission-checked views.
MEDIA_ROOT = Path(env("MEDIA_ROOT", str(BASE_DIR / "data" / "media")))
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"root": {"handlers": ["console"], "level": "INFO"},
"loggers": {"django.request": {"level": "WARNING"}},
}