Add secret management guide, client-side performance monitoring, and search accessibility enhancements

- Introduced a comprehensive Secret Management Guide detailing best practices, secret classification, development setup, production management, rotation procedures, and emergency protocols.
- Implemented a client-side performance monitoring script to track various metrics including page load performance, paint metrics, layout shifts, and memory usage.
- Enhanced search accessibility with keyboard navigation support for search results, ensuring compliance with WCAG standards and improving user experience.
This commit is contained in:
pacnpal
2025-12-23 16:41:42 -05:00
parent ae31e889d7
commit edcd8f2076
155 changed files with 22046 additions and 4645 deletions

View File

@@ -6,18 +6,25 @@ This module sets up Celery for background task processing including:
- Cache warming
- Analytics processing
- Email notifications
Celery uses the same Django settings module as the main application,
which can be configured via DJANGO_SETTINGS_MODULE environment variable.
"""
import os
from celery import Celery
from decouple import config
# Set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.local")
# Use the same Django settings module as the main application
# Default to production (matching WSGI/ASGI), can be overridden via environment
# Honor existing DJANGO_SETTINGS_MODULE if already set
if "DJANGO_SETTINGS_MODULE" not in os.environ:
os.environ["DJANGO_SETTINGS_MODULE"] = "config.django.production"
app = Celery("thrillwiki")
# Get Redis URL from environment variable with fallback
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/1")
REDIS_URL = config("REDIS_URL", default="redis://localhost:6379/1")
# Celery Configuration - set directly without loading from Django settings first
app.conf.update(

View File

@@ -1,36 +1,25 @@
"""
Base Django settings for thrillwiki project.
Common settings shared across all environments.
This file contains only core Django settings that are common across all
environments. Environment-specific settings are in local.py, production.py,
and test.py. Modular configuration is imported from config/settings/.
Structure:
- Core settings (SECRET_KEY, DEBUG, ALLOWED_HOSTS)
- Application definition (INSTALLED_APPS, MIDDLEWARE)
- URL and template configuration
- Internationalization
- Imports from modular settings (database, cache, security, etc.)
"""
from datetime import timedelta
import sys
from pathlib import Path
from decouple import config
# Initialize environment variables with better defaults
DEBUG = config("DEBUG", default=True)
SECRET_KEY = config("SECRET_KEY")
ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=lambda v: [s.strip() for s in v.split(',') if s.strip()])
DATABASE_URL = config("DATABASE_URL")
CACHE_URL = config("CACHE_URL", default="locmem://")
EMAIL_URL = config("EMAIL_URL", default="console://")
REDIS_URL = config("REDIS_URL", default="redis://127.0.0.1:6379/1")
CORS_ALLOWED_ORIGINS = config("CORS_ALLOWED_ORIGINS", default="", cast=lambda v: [s.strip() for s in v.split(',') if s.strip()])
API_RATE_LIMIT_PER_MINUTE = config("API_RATE_LIMIT_PER_MINUTE", default=60)
API_RATE_LIMIT_PER_HOUR = config("API_RATE_LIMIT_PER_HOUR", default=1000)
CACHE_MIDDLEWARE_SECONDS = config("CACHE_MIDDLEWARE_SECONDS", default=300)
CACHE_MIDDLEWARE_KEY_PREFIX = config(
"CACHE_MIDDLEWARE_KEY_PREFIX", default="thrillwiki"
)
GDAL_LIBRARY_PATH = config(
"GDAL_LIBRARY_PATH", default="/opt/homebrew/lib/libgdal.dylib"
)
GEOS_LIBRARY_PATH = config(
"GEOS_LIBRARY_PATH", default="/opt/homebrew/lib/libgeos_c.dylib"
)
# =============================================================================
# Path Configuration
# =============================================================================
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent.parent
@@ -40,17 +29,34 @@ apps_dir = BASE_DIR / "apps"
if apps_dir.exists() and str(apps_dir) not in sys.path:
sys.path.insert(0, str(apps_dir))
# =============================================================================
# Core Settings
# =============================================================================
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = config("SECRET_KEY")
# Allowed hosts (already configured above)
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = config("DEBUG", default=True, cast=bool)
# CSRF trusted origins
CSRF_TRUSTED_ORIGINS = config(
"CSRF_TRUSTED_ORIGINS", default="", cast=lambda v: [s.strip() for s in v.split(',') if s.strip()]
# Allowed hosts (comma-separated in .env)
ALLOWED_HOSTS = config(
"ALLOWED_HOSTS",
default="localhost,127.0.0.1",
cast=lambda v: [s.strip() for s in v.split(",") if s.strip()]
)
# Application definition
# CSRF trusted origins (comma-separated in .env)
CSRF_TRUSTED_ORIGINS = config(
"CSRF_TRUSTED_ORIGINS",
default="",
cast=lambda v: [s.strip() for s in v.split(",") if s.strip()]
)
# =============================================================================
# Application Definition
# =============================================================================
DJANGO_APPS = [
"django.contrib.admin",
"django.contrib.auth",
@@ -111,32 +117,46 @@ LOCAL_APPS = [
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
# =============================================================================
# Middleware Configuration
# =============================================================================
MIDDLEWARE = [
"django.middleware.cache.UpdateCacheMiddleware",
"django.middleware.cache.UpdateCacheMiddleware", # Must be first for cache middleware
"django.middleware.gzip.GZipMiddleware", # Response compression
"corsheaders.middleware.CorsMiddleware", # CORS middleware for API
"django.middleware.security.SecurityMiddleware",
"apps.core.middleware.security_headers.SecurityHeadersMiddleware", # Custom security headers (CSP, Permissions-Policy)
"apps.core.middleware.rate_limiting.AuthRateLimitMiddleware", # Rate limiting for auth endpoints
"apps.core.middleware.security_headers.SecurityHeadersMiddleware", # Custom security headers
"apps.core.middleware.rate_limiting.AuthRateLimitMiddleware", # Rate limiting
"whitenoise.middleware.WhiteNoiseMiddleware",
"apps.core.middleware.performance_middleware.PerformanceMiddleware", # Performance monitoring
"apps.core.middleware.performance_middleware.QueryCountMiddleware", # Database query monitoring
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"apps.core.middleware.analytics.PgHistoryContextMiddleware", # Add history context tracking
"apps.core.middleware.analytics.PgHistoryContextMiddleware", # History context tracking
"allauth.account.middleware.AccountMiddleware",
"django.middleware.cache.FetchFromCacheMiddleware",
"django_htmx.middleware.HtmxMiddleware",
"django.middleware.cache.FetchFromCacheMiddleware", # Must be last for cache middleware
]
ROOT_URLCONF = "thrillwiki.urls"
# =============================================================================
# URL Configuration
# =============================================================================
# Add a toggle to enable/disable Django template support via env var
# Use a distinct environment variable name so it doesn't collide with Django's TEMPLATES setting
ROOT_URLCONF = "thrillwiki.urls"
WSGI_APPLICATION = "thrillwiki.wsgi.application"
# =============================================================================
# Template Configuration
# =============================================================================
# Toggle to enable/disable Django template support via env var
TEMPLATES_ENABLED = config("TEMPLATES_ENABLED", default=True, cast=bool)
# Conditional TEMPLATES configuration
if TEMPLATES_ENABLED:
TEMPLATES = [
{
@@ -158,11 +178,11 @@ if TEMPLATES_ENABLED:
}
]
else:
# When templates are disabled, we still need APP_DIRS=True for DRF Spectacular to work
# When templates are disabled, still need APP_DIRS=True for DRF Spectacular
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"APP_DIRS": True, # Changed from False to True to support DRF Spectacular templates
"APP_DIRS": True,
"DIRS": [BASE_DIR / "templates/" / "404"],
"OPTIONS": {
"context_processors": [
@@ -179,462 +199,82 @@ else:
}
]
WSGI_APPLICATION = "thrillwiki.wsgi.application"
# =============================================================================
# Custom User Model
# =============================================================================
# Cloudflare Images Settings - Updated for django-cloudflareimages-toolkit
CLOUDFLARE_IMAGES = {
'ACCOUNT_ID': config("CLOUDFLARE_IMAGES_ACCOUNT_ID"),
'API_TOKEN': config("CLOUDFLARE_IMAGES_API_TOKEN"),
'ACCOUNT_HASH': config("CLOUDFLARE_IMAGES_ACCOUNT_HASH"),
# Optional settings
'DEFAULT_VARIANT': 'public',
'UPLOAD_TIMEOUT': 300,
'WEBHOOK_SECRET': config("CLOUDFLARE_IMAGES_WEBHOOK_SECRET", default=""),
'CLEANUP_EXPIRED_HOURS': 24,
'MAX_FILE_SIZE': 10 * 1024 * 1024, # 10MB
'ALLOWED_FORMATS': ['jpeg', 'png', 'gif', 'webp'],
'REQUIRE_SIGNED_URLS': False,
'DEFAULT_METADATA': {},
}
# Storage configuration
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
"OPTIONS": {
"location": str(BASE_DIR.parent / "shared" / "media"),
},
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
"OPTIONS": {
"location": str(BASE_DIR / "staticfiles"),
},
},
}
# Password validation
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",
},
]
AUTH_USER_MODEL = "accounts.User"
# =============================================================================
# Internationalization
# =============================================================================
LANGUAGE_CODE = "en-us"
TIME_ZONE = "America/New_York"
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = BASE_DIR / "staticfiles"
# =============================================================================
# Default Primary Key
# =============================================================================
# Media files - point to shared/media directory
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR.parent / "shared" / "media"
# Default primary key field type
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Authentication settings
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend",
]
# =============================================================================
# Test Runner
# =============================================================================
# django-allauth settings
SITE_ID = 1
# CORRECTED: Django allauth still expects the old format with asterisks for required fields
# The deprecation warnings are from dj_rest_auth, not our configuration
ACCOUNT_SIGNUP_FIELDS = ["email*", "username*", "password1*", "password2*"]
ACCOUNT_LOGIN_METHODS = {"email", "username"}
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_CHANGE = True
ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_RESEND = True
ACCOUNT_REAUTHENTICATION_REQUIRED = True
ACCOUNT_EMAIL_NOTIFICATIONS = True
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False
LOGIN_REDIRECT_URL = "/"
ACCOUNT_LOGOUT_REDIRECT_URL = "/"
# Custom adapters
ACCOUNT_ADAPTER = "apps.accounts.adapters.CustomAccountAdapter"
SOCIALACCOUNT_ADAPTER = "apps.accounts.adapters.CustomSocialAccountAdapter"
# Social account settings
SOCIALACCOUNT_PROVIDERS = {
"google": {
"SCOPE": [
"profile",
"email",
],
"AUTH_PARAMS": {"access_type": "online"},
},
"discord": {
"SCOPE": ["identify", "email"],
"OAUTH_PKCE_ENABLED": True,
},
}
# Additional social account settings
SOCIALACCOUNT_LOGIN_ON_GET = True
SOCIALACCOUNT_AUTO_SIGNUP = False
SOCIALACCOUNT_STORE_TOKENS = True
# Custom User Model
AUTH_USER_MODEL = "accounts.User"
# Autocomplete configuration
AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = False
# Tailwind configuration
TAILWIND_CLI_CONFIG_FILE = "tailwind.config.js"
TAILWIND_CLI_SRC_CSS = "static/css/src/input.css"
TAILWIND_CLI_DIST_CSS = "css/tailwind.css"
# Test runner
TEST_RUNNER = "django.test.runner.DiscoverRunner"
# Road Trip Service Settings
ROADTRIP_CACHE_TIMEOUT = 3600 * 24 # 24 hours for geocoding
ROADTRIP_ROUTE_CACHE_TIMEOUT = 3600 * 6 # 6 hours for routes
ROADTRIP_MAX_REQUESTS_PER_SECOND = 1 # Respect OSM rate limits
ROADTRIP_USER_AGENT = config("ROADTRIP_USER_AGENT")
ROADTRIP_REQUEST_TIMEOUT = 10 # seconds
ROADTRIP_MAX_RETRIES = 3
ROADTRIP_BACKOFF_FACTOR = 2
# Frontend URL Configuration
FRONTEND_DOMAIN = config("FRONTEND_DOMAIN", default="https://thrillwiki.com")
# ForwardEmail Configuration
FORWARD_EMAIL_BASE_URL = config(
"FORWARD_EMAIL_BASE_URL", default="https://api.forwardemail.net")
FORWARD_EMAIL_API_KEY = config("FORWARD_EMAIL_API_KEY", default="")
FORWARD_EMAIL_DOMAIN = config("FORWARD_EMAIL_DOMAIN", default="")
# Django REST Framework Settings
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication", # Kept for backward compatibility
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
"MAX_PAGE_SIZE": 100,
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.AcceptHeaderVersioning",
"DEFAULT_VERSION": "v1",
"ALLOWED_VERSIONS": ["v1"],
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
"rest_framework.renderers.BrowsableAPIRenderer",
],
"DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser",
"rest_framework.parsers.FormParser",
"rest_framework.parsers.MultiPartParser",
],
"EXCEPTION_HANDLER": "apps.core.api.exceptions.custom_exception_handler",
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.SearchFilter",
"rest_framework.filters.OrderingFilter",
],
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": "60/minute",
"user": "1000/hour",
},
"TEST_REQUEST_DEFAULT_FORMAT": "json",
"NON_FIELD_ERRORS_KEY": "non_field_errors",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
# CORS Settings for API
# https://github.com/adamchainz/django-cors-headers
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = config(
"CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool
) # type: ignore[arg-type]
# Allowed HTTP headers for CORS requests
CORS_ALLOW_HEADERS = [
"accept",
"accept-encoding",
"authorization",
"content-type",
"dnt",
"origin",
"user-agent",
"x-csrftoken",
"x-requested-with",
"x-api-version",
]
# HTTP methods allowed for CORS requests
CORS_ALLOW_METHODS = [
"DELETE",
"GET",
"OPTIONS",
"PATCH",
"POST",
"PUT",
]
# Expose rate limit headers to browsers
CORS_EXPOSE_HEADERS = [
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
"X-RateLimit-Reset",
"X-API-Version",
]
API_RATE_LIMIT_PER_MINUTE = config(
"API_RATE_LIMIT_PER_MINUTE", default=60, cast=int
) # type: ignore[arg-type]
API_RATE_LIMIT_PER_HOUR = config(
"API_RATE_LIMIT_PER_HOUR", default=1000, cast=int
) # type: ignore[arg-type]
SPECTACULAR_SETTINGS = {
"TITLE": "ThrillWiki API",
"DESCRIPTION": """Comprehensive theme park and ride information API.
## API Conventions
### Response Format
All successful responses include a `success: true` field with data nested under `data`.
All error responses include an `error` object with `code` and `message` fields.
### Pagination
List endpoints support pagination with `page` and `page_size` parameters.
Default page size is 20, maximum is 100.
### Filtering
Range filters use `{field}_min` and `{field}_max` naming convention.
Search uses the `search` parameter.
Ordering uses the `ordering` parameter (prefix with `-` for descending).
### Field Naming
All field names use snake_case convention (e.g., `image_url`, `created_at`).
""",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
"COMPONENT_SPLIT_REQUEST": True,
"TAGS": [
{"name": "Parks", "description": "Theme park operations"},
{"name": "Rides", "description": "Ride information and management"},
{"name": "Park Media", "description": "Park photos and media management"},
{"name": "Ride Media", "description": "Ride photos and media management"},
{"name": "Authentication", "description": "User authentication and session management"},
{"name": "Social Authentication", "description": "Social provider login and account linking"},
{"name": "User Profile", "description": "User profile management"},
{"name": "User Settings", "description": "User preferences and settings"},
{"name": "User Notifications", "description": "User notification management"},
{"name": "User Content", "description": "User-generated content (top lists, reviews)"},
{"name": "User Management", "description": "Admin user management operations"},
{"name": "Self-Service Account Management", "description": "User account deletion and management"},
{"name": "Core", "description": "Core utility endpoints (search, suggestions)"},
{
"name": "Statistics",
"description": "Statistical endpoints providing aggregated data and insights",
},
],
"SCHEMA_PATH_PREFIX": "/api/",
"DEFAULT_GENERATOR_CLASS": "drf_spectacular.generators.SchemaGenerator",
"DEFAULT_AUTO_SCHEMA": "drf_spectacular.openapi.AutoSchema",
"PREPROCESSING_HOOKS": [
"api.v1.schema.custom_preprocessing_hook",
],
# "POSTPROCESSING_HOOKS": [
# "api.v1.schema.custom_postprocessing_hook",
# ],
"SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"],
"SWAGGER_UI_SETTINGS": {
"deepLinking": True,
"persistAuthorization": True,
"displayOperationId": False,
"displayRequestDuration": True,
},
"REDOC_UI_SETTINGS": {
"hideDownloadButton": False,
"hideHostname": False,
"hideLoading": False,
"hideSchemaPattern": True,
"scrollYOffset": 0,
"theme": {"colors": {"primary": {"main": "#1976d2"}}},
},
}
# Health Check Configuration
HEALTH_CHECK = {
"DISK_USAGE_MAX": 90, # Fail if disk usage is over 90%
"MEMORY_MIN": 100, # Fail if less than 100MB available memory
}
# Custom health check backends
HEALTH_CHECK_BACKENDS = [
"health_check.db",
"health_check.cache",
"health_check.storage",
"core.health_checks.custom_checks.CacheHealthCheck",
"core.health_checks.custom_checks.DatabasePerformanceCheck",
"core.health_checks.custom_checks.ApplicationHealthCheck",
"core.health_checks.custom_checks.ExternalServiceHealthCheck",
"core.health_checks.custom_checks.DiskSpaceHealthCheck",
]
# Enhanced Cache Configuration
DJANGO_REDIS_CACHE_BACKEND = "django_redis.cache.RedisCache"
DJANGO_REDIS_CLIENT_CLASS = "django_redis.client.DefaultClient"
CACHES = {
"default": {
"BACKEND": DJANGO_REDIS_CACHE_BACKEND,
# pyright: ignore[reportArgumentType]
# type: ignore
"LOCATION": config("REDIS_URL", default="redis://127.0.0.1:6379/1"),
"OPTIONS": {
"CLIENT_CLASS": DJANGO_REDIS_CLIENT_CLASS,
"PARSER_CLASS": "redis.connection.HiredisParser",
"CONNECTION_POOL_CLASS": "redis.BlockingConnectionPool",
"CONNECTION_POOL_CLASS_KWARGS": {
"max_connections": 50,
"timeout": 20,
},
"COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor",
"IGNORE_EXCEPTIONS": True,
},
"KEY_PREFIX": "thrillwiki",
"VERSION": 1,
},
"sessions": {
"BACKEND": DJANGO_REDIS_CACHE_BACKEND,
"LOCATION": config("REDIS_URL", default="redis://127.0.0.1:6379/2"),
"OPTIONS": {
"CLIENT_CLASS": DJANGO_REDIS_CLIENT_CLASS,
},
},
"api": {
"BACKEND": DJANGO_REDIS_CACHE_BACKEND,
"LOCATION": config("REDIS_URL", default="redis://127.0.0.1:6379/3"),
"OPTIONS": {
"CLIENT_CLASS": DJANGO_REDIS_CLIENT_CLASS,
},
},
}
# Use Redis for sessions
# =============================================================================
# Session Security Settings
# Import Modular Settings
# =============================================================================
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "sessions"
SESSION_COOKIE_AGE = 3600 # 1 hour (reduced from 24 hours for security)
SESSION_SAVE_EVERY_REQUEST = True # Update session on each request (sliding expiry)
SESSION_COOKIE_HTTPONLY = True # Prevent JavaScript access to session cookie
SESSION_EXPIRE_AT_BROWSER_CLOSE = False # Session persists until cookie expires
# Import settings from modular configuration files.
# These imports add/override settings defined above.
# Cache middleware settings
CACHE_MIDDLEWARE_SECONDS = 300 # 5 minutes
CACHE_MIDDLEWARE_KEY_PREFIX = "thrillwiki"
# Database configuration (DATABASES, GDAL_LIBRARY_PATH, GEOS_LIBRARY_PATH)
from config.settings.database import * # noqa: F401,F403,E402
# Cache configuration (CACHES, SESSION_*, CACHE_MIDDLEWARE_*)
from config.settings.cache import * # noqa: F401,F403,E402
# Security configuration (SECURE_*, CSRF_*, SESSION_COOKIE_*, AUTH_PASSWORD_VALIDATORS)
from config.settings.security import * # noqa: F401,F403,E402
# Email configuration (EMAIL_*, FORWARD_EMAIL_*)
from config.settings.email import * # noqa: F401,F403,E402
# Logging configuration (LOGGING)
from config.settings.logging import * # noqa: F401,F403,E402
# REST Framework configuration (REST_FRAMEWORK, CORS_*, SIMPLE_JWT, REST_AUTH, SPECTACULAR_SETTINGS)
from config.settings.rest_framework import * # noqa: F401,F403,E402
# Third-party configuration (ACCOUNT_*, SOCIALACCOUNT_*, CLOUDFLARE_IMAGES, etc.)
from config.settings.third_party import * # noqa: F401,F403,E402
# Storage configuration (STATIC_*, MEDIA_*, STORAGES, WHITENOISE_*, FILE_UPLOAD_*)
from config.settings.storage import * # noqa: F401,F403,E402
# =============================================================================
# JWT Settings
# Post-Import Overrides
# =============================================================================
# Security considerations:
# - Short access token lifetime reduces window of vulnerability
# - Refresh token rotation prevents token reuse after refresh
# - Token blacklisting allows revocation of compromised tokens
# - JTI claim enables unique token identification for logging
# Settings that need to reference values from imported modules
SIMPLE_JWT = {
# Token lifetimes
# Security: Shorter access tokens (15 min) provide better security
# but may require more frequent refreshes
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=15), # 15 minutes (reduced from 60)
"REFRESH_TOKEN_LIFETIME": timedelta(days=7), # 7 days
# Token rotation and blacklisting
# Security: Rotate refresh tokens on each use and blacklist old ones
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
# Update last login on token refresh
"UPDATE_LAST_LOGIN": True,
# Cryptographic settings
"ALGORITHM": "HS256",
"SIGNING_KEY": SECRET_KEY,
"VERIFYING_KEY": None,
# Token validation
"AUDIENCE": None,
"ISSUER": "thrillwiki", # Added issuer for token validation
"JWK_URL": None,
"LEEWAY": 0, # No leeway for token expiration
# Authentication header
"AUTH_HEADER_TYPES": ("Bearer",),
"AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
# User identification
"USER_ID_FIELD": "id",
"USER_ID_CLAIM": "user_id",
"USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule",
# Token classes
"AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",),
"TOKEN_TYPE_CLAIM": "token_type",
"TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser",
# JTI claim for unique token identification
# Security: Enables token tracking and revocation
"JTI_CLAIM": "jti",
# Sliding token settings (if using sliding tokens)
"SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp",
"SLIDING_TOKEN_LIFETIME": timedelta(minutes=15),
"SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1),
}
# Update SimpleJWT to use the SECRET_KEY
SIMPLE_JWT["SIGNING_KEY"] = SECRET_KEY # noqa: F405
# =============================================================================
# dj-rest-auth Settings
# Startup Validation
# =============================================================================
REST_AUTH = {
"USE_JWT": True,
"JWT_AUTH_COOKIE": "thrillwiki-auth",
"JWT_AUTH_REFRESH_COOKIE": "thrillwiki-refresh",
# Security: Only send cookies over HTTPS in production
"JWT_AUTH_SECURE": not DEBUG,
# Security: Prevent JavaScript access to cookies
"JWT_AUTH_HTTPONLY": True,
# Security: SameSite cookie attribute (Lax is compatible with OAuth flows)
"JWT_AUTH_SAMESITE": "Lax",
"JWT_AUTH_RETURN_EXPIRATION": True,
"JWT_TOKEN_CLAIMS_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer",
}
# Run configuration validation after all settings are loaded.
# These validations catch configuration errors early during Django startup.
from config.settings.secrets import run_startup_validation as validate_secrets # noqa: E402
from config.settings.validation import run_startup_validation as validate_config # noqa: E402
# Run secret validation (fails fast in production, warns in development)
validate_secrets()
# Run configuration validation (fails fast in production, warns in development)
validate_config()

View File

@@ -1,15 +1,21 @@
"""
Local development settings for thrillwiki project.
This module extends base.py with development-specific configurations:
- Debug mode enabled
- Local memory cache (no Redis required)
- Console email backend option
- Development middleware (nplusone, debug toolbar)
- Enhanced logging for debugging
"""
from ..settings import database
import logging
from .base import *
from .base import * # noqa: F401,F403
# Import database configuration
DATABASES = database.DATABASES
# =============================================================================
# Development Settings
# =============================================================================
# Development-specific settings
DEBUG = True
# For local development, allow all hosts
@@ -22,10 +28,18 @@ CSRF_TRUSTED_ORIGINS = [
"https://beta.thrillwiki.com",
]
# =============================================================================
# GeoDjango Library Paths (macOS with Homebrew)
# =============================================================================
GDAL_LIBRARY_PATH = "/opt/homebrew/lib/libgdal.dylib"
GEOS_LIBRARY_PATH = "/opt/homebrew/lib/libgeos_c.dylib"
# Local cache configuration
# =============================================================================
# Local Cache Configuration
# =============================================================================
# Use local memory cache for development (no Redis required)
LOC_MEM_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
CACHES = {
@@ -38,7 +52,7 @@ CACHES = {
"sessions": {
"BACKEND": LOC_MEM_CACHE_BACKEND,
"LOCATION": "sessions-cache",
"TIMEOUT": 86400, # 24 hours (same as SESSION_COOKIE_AGE)
"TIMEOUT": 86400, # 24 hours
"OPTIONS": {"MAX_ENTRIES": 5000},
},
"api": {
@@ -53,16 +67,29 @@ CACHES = {
CACHE_MIDDLEWARE_SECONDS = 1 # Very short cache for development
CACHE_MIDDLEWARE_KEY_PREFIX = "thrillwiki_dev"
# Development email backend - Use ForwardEmail for actual email sending
# EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" # Console for debugging
EMAIL_BACKEND = "django_forwardemail.backends.ForwardEmailBackend" # Actual email sending
# =============================================================================
# Email Backend
# =============================================================================
# Use ForwardEmail for actual sending, or console for debugging
# Console backend for debugging (uncomment to use):
# EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# ForwardEmail backend for actual email sending:
EMAIL_BACKEND = "django_forwardemail.backends.ForwardEmailBackend"
# =============================================================================
# Security Settings (Relaxed for Development)
# =============================================================================
# Security settings for development
SECURE_SSL_REDIRECT = False
SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False
# Development monitoring tools
# =============================================================================
# Development Apps
# =============================================================================
DEVELOPMENT_APPS = [
# "silk", # Disabled for performance
"nplusone.ext.django",
@@ -70,36 +97,47 @@ DEVELOPMENT_APPS = [
"widget_tweaks",
]
# Add development apps if available
# Add development apps if not already present
for app in DEVELOPMENT_APPS:
if app not in INSTALLED_APPS:
INSTALLED_APPS.append(app)
if app not in INSTALLED_APPS: # noqa: F405
INSTALLED_APPS.append(app) # noqa: F405
# =============================================================================
# Development Middleware
# =============================================================================
# Development middleware
DEVELOPMENT_MIDDLEWARE = [
# "silk.middleware.SilkyMiddleware", # Disabled for performance
"nplusone.ext.django.NPlusOneMiddleware",
"core.middleware.performance_middleware.PerformanceMiddleware",
"core.middleware.performance_middleware.QueryCountMiddleware",
"core.middleware.nextjs.APIResponseMiddleware", # Add this
"core.middleware.request_logging.RequestLoggingMiddleware", # Request logging
# Note: PerformanceMiddleware and QueryCountMiddleware are already in base.py MIDDLEWARE
"apps.core.middleware.nextjs.APIResponseMiddleware",
"apps.core.middleware.request_logging.RequestLoggingMiddleware",
]
# Add development middleware
# Add development middleware if not already present
for middleware in DEVELOPMENT_MIDDLEWARE:
if middleware not in MIDDLEWARE:
MIDDLEWARE.insert(1, middleware) # Insert after security middleware
if middleware not in MIDDLEWARE: # noqa: F405
MIDDLEWARE.insert(1, middleware) # noqa: F405
# =============================================================================
# Debug Toolbar Configuration
# =============================================================================
# Debug toolbar configuration
INTERNAL_IPS = ["127.0.0.1", "::1"]
# Silk configuration disabled for performance
# =============================================================================
# NPlusOne Configuration
# =============================================================================
# Detects N+1 query issues during development
# NPlusOne configuration
NPLUSONE_LOGGER = logging.getLogger("nplusone")
NPLUSONE_LOG_LEVEL = logging.WARN
# Enhanced development logging
# =============================================================================
# Development Logging Configuration
# =============================================================================
# Extended logging for debugging with reduced noise
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
@@ -123,14 +161,14 @@ LOGGING = {
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"filename": BASE_DIR / "logs" / "thrillwiki.log",
"filename": BASE_DIR / "logs" / "thrillwiki.log", # noqa: F405
"maxBytes": 1024 * 1024 * 10, # 10MB
"backupCount": 5,
"formatter": "json",
},
"performance": {
"class": "logging.handlers.RotatingFileHandler",
"filename": BASE_DIR / "logs" / "performance.log",
"filename": BASE_DIR / "logs" / "performance.log", # noqa: F405
"maxBytes": 1024 * 1024 * 10, # 10MB
"backupCount": 5,
"formatter": "json",
@@ -143,22 +181,22 @@ LOGGING = {
"loggers": {
"django": {
"handlers": ["file"],
"level": "WARNING", # Reduced from INFO
"level": "WARNING",
"propagate": False,
},
"django.db.backends": {
"handlers": ["console"],
"level": "WARNING", # Reduced from DEBUG
"level": "WARNING",
"propagate": False,
},
"thrillwiki": {
"handlers": ["console", "file"],
"level": "INFO", # Reduced from DEBUG
"level": "INFO",
"propagate": False,
},
"performance": {
"handlers": ["performance"],
"level": "WARNING", # Reduced from INFO
"level": "WARNING",
"propagate": False,
},
"query_optimization": {
@@ -168,7 +206,7 @@ LOGGING = {
},
"nplusone": {
"handlers": ["console"],
"level": "ERROR", # Reduced from WARNING
"level": "ERROR",
"propagate": False,
},
"request_logging": {

View File

@@ -1,28 +1,34 @@
"""
Production settings for thrillwiki project.
This module extends base.py with production-specific configurations:
- Debug mode disabled
- Strict security settings (HSTS, secure cookies, SSL redirect)
- Redis caching (required in production)
- Structured JSON logging
- Production-optimized static file serving
"""
# Import the module and use its members, e.g., base.BASE_DIR, base***REMOVED***
from . import base
from decouple import config
from .base import * # noqa: F401,F403
# Import the module and use its members, e.g., database.DATABASES
# =============================================================================
# Production Core Settings
# =============================================================================
# Import the module and use its members, e.g., email.EMAIL_HOST
# Import the module and use its members, e.g., security.SECURE_HSTS_SECONDS
# Import the module and use its members, e.g., email.EMAIL_HOST
# Import the module and use its members, e.g., security.SECURE_HSTS_SECONDS
# Production settings
DEBUG = False
# Allowed hosts must be explicitly set in production
ALLOWED_HOSTS = base.config("ALLOWED_HOSTS")
ALLOWED_HOSTS = config(
"ALLOWED_HOSTS",
cast=lambda v: [s.strip() for s in v.split(",") if s.strip()]
)
# CSRF trusted origins for production
CSRF_TRUSTED_ORIGINS = base.config("CSRF_TRUSTED_ORIGINS")
CSRF_TRUSTED_ORIGINS = config(
"CSRF_TRUSTED_ORIGINS",
cast=lambda v: [s.strip() for s in v.split(",") if s.strip()]
)
# =============================================================================
# Security Settings for Production
@@ -50,7 +56,86 @@ SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"
# Production logging
# Proxy SSL header (for reverse proxies like nginx)
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# =============================================================================
# Production Cache Configuration (Redis Required)
# =============================================================================
redis_url = config("REDIS_URL", default=None)
if redis_url:
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": redis_url,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"PARSER_CLASS": "redis.connection.HiredisParser",
"CONNECTION_POOL_CLASS": "redis.BlockingConnectionPool",
"CONNECTION_POOL_CLASS_KWARGS": {
"max_connections": config(
"REDIS_MAX_CONNECTIONS", default=100, cast=int
),
"timeout": 20,
"socket_keepalive": True,
"retry_on_timeout": True,
},
"COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor",
"IGNORE_EXCEPTIONS": False, # Fail loudly in production
},
"KEY_PREFIX": "thrillwiki",
},
"sessions": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": config("REDIS_SESSIONS_URL", default=redis_url),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"PARSER_CLASS": "redis.connection.HiredisParser",
},
"KEY_PREFIX": "sessions",
},
"api": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": config("REDIS_API_URL", default=redis_url),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"PARSER_CLASS": "redis.connection.HiredisParser",
"COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor",
},
"KEY_PREFIX": "api",
},
}
# Use Redis for sessions in production
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "sessions"
# =============================================================================
# Production Static Files Configuration
# =============================================================================
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# Update STORAGES for Django 4.2+
STORAGES["staticfiles"]["BACKEND"] = ( # noqa: F405
"whitenoise.storage.CompressedManifestStaticFilesStorage"
)
# =============================================================================
# Production REST Framework Settings
# =============================================================================
# Only JSON renderer in production (no browsable API)
REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = [ # noqa: F405
"rest_framework.renderers.JSONRenderer",
]
# =============================================================================
# Production Logging Configuration
# =============================================================================
# Structured JSON logging for log aggregation services
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
@@ -59,69 +144,121 @@ LOGGING = {
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
"style": "{",
},
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"format": (
"%(levelname)s %(asctime)s %(module)s %(process)d "
"%(thread)d %(message)s %(pathname)s %(lineno)d"
),
},
"simple": {
"format": "{levelname} {message}",
"style": "{",
},
},
"filters": {
"require_debug_false": {
"()": "django.utils.log.RequireDebugFalse",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "json", # JSON for container environments
},
"file": {
"level": "INFO",
"class": "logging.handlers.RotatingFileHandler",
"filename": base.BASE_DIR / "logs" / "django.log",
"filename": BASE_DIR / "logs" / "django.log", # noqa: F405
"maxBytes": 1024 * 1024 * 15, # 15MB
"backupCount": 10,
"formatter": "verbose",
"formatter": "json",
},
"error_file": {
"level": "ERROR",
"class": "logging.handlers.RotatingFileHandler",
"filename": base.BASE_DIR / "logs" / "django_error.log",
"filename": BASE_DIR / "logs" / "django_error.log", # noqa: F405
"maxBytes": 1024 * 1024 * 15, # 15MB
"backupCount": 10,
"formatter": "verbose",
"formatter": "json",
},
"security_file": {
"level": "INFO",
"class": "logging.handlers.RotatingFileHandler",
"filename": BASE_DIR / "logs" / "security.log", # noqa: F405
"maxBytes": 1024 * 1024 * 10, # 10MB
"backupCount": 10,
"formatter": "json",
},
"mail_admins": {
"level": "ERROR",
"filters": ["require_debug_false"],
"class": "django.utils.log.AdminEmailHandler",
"include_html": True,
},
},
"root": {
"handlers": ["file"],
"handlers": ["console", "file"],
"level": "INFO",
},
"loggers": {
"django": {
"handlers": ["file", "error_file"],
"handlers": ["console", "file", "error_file"],
"level": "INFO",
"propagate": False,
},
"django.request": {
"handlers": ["console", "error_file", "mail_admins"],
"level": "ERROR",
"propagate": False,
},
"django.security": {
"handlers": ["console", "security_file"],
"level": "WARNING",
"propagate": False,
},
"thrillwiki": {
"handlers": ["file", "error_file"],
"handlers": ["console", "file", "error_file"],
"level": "INFO",
"propagate": False,
},
"security": {
"handlers": ["console", "security_file"],
"level": "INFO",
"propagate": False,
},
"celery": {
"handlers": ["console", "file"],
"level": "INFO",
"propagate": False,
},
},
}
# Static files collection for production
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# =============================================================================
# Sentry Integration (Optional)
# =============================================================================
# Configure Sentry for error tracking in production
# Cache settings for production (Redis recommended)
redis_url = base.config("REDIS_URL", default=None)
if redis_url:
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": redis_url,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
}
}
SENTRY_DSN = config("SENTRY_DSN", default="")
# Use Redis for sessions in production
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
if SENTRY_DSN:
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.redis import RedisIntegration
REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
],
}
sentry_sdk.init(
dsn=SENTRY_DSN,
integrations=[
DjangoIntegration(),
CeleryIntegration(),
RedisIntegration(),
],
environment=config("SENTRY_ENVIRONMENT", default="production"),
traces_sample_rate=config(
"SENTRY_TRACES_SAMPLE_RATE", default=0.1, cast=float
),
send_default_pii=False, # Don't send PII to Sentry
attach_stacktrace=True,
)

View File

@@ -1,65 +1,113 @@
"""
Test settings for thrillwiki project.
This module extends base.py with test-specific configurations:
- Debug disabled for realistic testing
- PostGIS database for GeoDjango support
- In-memory cache for isolation
- Simplified password hashing for speed
- Disabled logging to reduce noise
"""
from .base import * # noqa: F403,F405
import os
from .base import * # noqa: F401,F403
# =============================================================================
# Test Core Settings
# =============================================================================
# Test-specific settings
DEBUG = False
# Use in-memory database for faster tests
# =============================================================================
# Test Database Configuration
# =============================================================================
# Use PostGIS for GeoDjango support - required for spatial queries in tests
DATABASES = {
"default": {
"ENGINE": "django.contrib.gis.db.backends.spatialite",
"NAME": ":memory:",
"ENGINE": "django.contrib.gis.db.backends.postgis",
"NAME": os.environ.get("TEST_DB_NAME", "test_thrillwiki"),
"USER": os.environ.get("TEST_DB_USER", "postgres"),
"PASSWORD": os.environ.get("TEST_DB_PASSWORD", "postgres"),
"HOST": os.environ.get("TEST_DB_HOST", "localhost"),
"PORT": os.environ.get("TEST_DB_PORT", "5432"),
"TEST": {
"NAME": os.environ.get("TEST_DB_NAME", "test_thrillwiki"),
},
}
}
# Use in-memory cache for tests
# =============================================================================
# Test Cache Configuration
# =============================================================================
# Use in-memory cache for test isolation
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "test-cache",
}
},
"sessions": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "test-sessions",
},
"api": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "test-api",
},
}
# Disable migrations for faster tests
# =============================================================================
# Email Configuration
# =============================================================================
# Use in-memory email backend for test assertions
class DisableMigrations:
def __contains__(self, item):
return True
def __getitem__(self, item):
return None
MIGRATION_MODULES = DisableMigrations()
# Email backend for tests
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
# Password hashers for faster tests
# =============================================================================
# Password Hashing
# =============================================================================
# Use fast MD5 hashing for tests (never use in production!)
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
]
# Disable logging during tests
# =============================================================================
# Logging Configuration
# =============================================================================
# Disable logging during tests to reduce noise
LOGGING_CONFIG = None
# Media files for tests
MEDIA_ROOT = BASE_DIR / "test_media"
# =============================================================================
# Static and Media Files
# =============================================================================
# Use test-specific directories
# Static files for tests
STATIC_ROOT = BASE_DIR / "test_static"
MEDIA_ROOT = BASE_DIR / "test_media" # noqa: F405
STATIC_ROOT = BASE_DIR / "test_static" # noqa: F405
# =============================================================================
# Security Settings
# =============================================================================
# Disable security features that interfere with testing
# Disable Turnstile for tests
TURNSTILE_SITE_KEY = "test-key"
TURNSTILE_SECRET_KEY = "test-secret"
# Test-specific middleware (remove caching middleware)
MIDDLEWARE = [m for m in MIDDLEWARE if "cache" not in m.lower()]
# =============================================================================
# Middleware Configuration
# =============================================================================
# Remove caching middleware for test isolation
MIDDLEWARE = [m for m in MIDDLEWARE if "cache" not in m.lower()] # noqa: F405
# =============================================================================
# Celery Configuration
# =============================================================================
# Run tasks synchronously during tests
# Celery settings for tests (if Celery is used)
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True

View File

@@ -1 +1,25 @@
# Settings modules package
"""
ThrillWiki Modular Settings Package
This package contains modular configuration files for the ThrillWiki project.
Each module focuses on a specific aspect of the application configuration.
Modules:
- database.py - Database connections and GeoDjango settings
- cache.py - Redis caching and session configuration
- security.py - Security headers, CSRF, and authentication
- email.py - Email backends and configuration
- logging.py - Logging formatters, handlers, and loggers
- rest_framework.py - DRF, JWT, CORS, and API documentation
- third_party.py - Allauth, Celery, Cloudflare, health checks
- storage.py - Static files, media, and WhiteNoise
Usage:
These modules are imported by the environment-specific settings files
in config/django/ (base.py, local.py, production.py, test.py).
Why python-decouple?
All modules use python-decouple for environment variable management
because it's already used in base.py, provides a simpler API than
django-environ, and is sufficient for our configuration needs.
"""

View File

@@ -0,0 +1,146 @@
"""
Cache configuration for thrillwiki project.
This module configures Redis-based caching with connection pooling,
session caching, and API response caching.
Why python-decouple?
- Already used in base.py for consistency
- Simpler API than django-environ
- Sufficient for our configuration needs
- Better separation of config from code
Cache Backends:
- default: General purpose caching (queries, templates, etc.)
- sessions: User session storage (separate for security)
- api: API response caching (high concurrency)
"""
from decouple import config
# =============================================================================
# Redis Configuration
# =============================================================================
REDIS_URL = config("REDIS_URL", default="redis://127.0.0.1:6379/1")
# Redis cache backend classes
DJANGO_REDIS_CACHE_BACKEND = "django_redis.cache.RedisCache"
DJANGO_REDIS_CLIENT_CLASS = "django_redis.client.DefaultClient"
# =============================================================================
# Cache Configuration
# =============================================================================
# Multiple cache backends for different purposes
CACHES = {
# Default cache for general purpose caching
# Used for: database queries, computed values, template fragments
"default": {
"BACKEND": DJANGO_REDIS_CACHE_BACKEND,
"LOCATION": config("REDIS_URL", default="redis://127.0.0.1:6379/1"),
"OPTIONS": {
"CLIENT_CLASS": DJANGO_REDIS_CLIENT_CLASS,
# Use hiredis for faster C-based parsing
"PARSER_CLASS": "redis.connection.HiredisParser",
# Connection pooling for better performance
"CONNECTION_POOL_CLASS": "redis.BlockingConnectionPool",
"CONNECTION_POOL_CLASS_KWARGS": {
"max_connections": config(
"REDIS_MAX_CONNECTIONS", default=100, cast=int
),
"timeout": config("REDIS_CONNECTION_TIMEOUT", default=20, cast=int),
"socket_keepalive": True,
"socket_keepalive_options": {
1: 1, # TCP_KEEPIDLE: Start keepalive after 1s idle
2: 1, # TCP_KEEPINTVL: Send probes every 1s
3: 3, # TCP_KEEPCNT: Close after 3 failed probes
},
"retry_on_timeout": True,
"health_check_interval": 30,
},
# Compress cached data to save memory
"COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor",
# Graceful degradation if Redis is unavailable
"IGNORE_EXCEPTIONS": config(
"REDIS_IGNORE_EXCEPTIONS", default=True, cast=bool
),
},
"KEY_PREFIX": config("CACHE_KEY_PREFIX", default="thrillwiki"),
"VERSION": 1,
},
# Session cache - separate for security isolation
# Uses a different Redis database (db 2)
"sessions": {
"BACKEND": DJANGO_REDIS_CACHE_BACKEND,
"LOCATION": config("REDIS_SESSIONS_URL", default="redis://127.0.0.1:6379/2"),
"OPTIONS": {
"CLIENT_CLASS": DJANGO_REDIS_CLIENT_CLASS,
"PARSER_CLASS": "redis.connection.HiredisParser",
"CONNECTION_POOL_CLASS": "redis.BlockingConnectionPool",
"CONNECTION_POOL_CLASS_KWARGS": {
"max_connections": config(
"REDIS_SESSIONS_MAX_CONNECTIONS", default=50, cast=int
),
"timeout": 10,
"socket_keepalive": True,
},
},
"KEY_PREFIX": "sessions",
},
# API cache - high concurrency for API responses
# Uses a different Redis database (db 3)
"api": {
"BACKEND": DJANGO_REDIS_CACHE_BACKEND,
"LOCATION": config("REDIS_API_URL", default="redis://127.0.0.1:6379/3"),
"OPTIONS": {
"CLIENT_CLASS": DJANGO_REDIS_CLIENT_CLASS,
"PARSER_CLASS": "redis.connection.HiredisParser",
"CONNECTION_POOL_CLASS": "redis.BlockingConnectionPool",
"CONNECTION_POOL_CLASS_KWARGS": {
"max_connections": config(
"REDIS_API_MAX_CONNECTIONS", default=100, cast=int
),
"timeout": 15,
"socket_keepalive": True,
"retry_on_timeout": True,
},
# Compress API responses to save bandwidth
"COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor",
},
"KEY_PREFIX": "api",
},
}
# =============================================================================
# Session Configuration
# =============================================================================
# Use Redis for session storage for better performance and scalability
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "sessions"
# Session timeout in seconds (1 hour)
SESSION_COOKIE_AGE = config("SESSION_COOKIE_AGE", default=3600, cast=int)
# Update session on each request (sliding expiry)
SESSION_SAVE_EVERY_REQUEST = config(
"SESSION_SAVE_EVERY_REQUEST", default=True, cast=bool
)
# Session persists until cookie expires (not browser close)
SESSION_EXPIRE_AT_BROWSER_CLOSE = config(
"SESSION_EXPIRE_AT_BROWSER_CLOSE", default=False, cast=bool
)
# =============================================================================
# Cache Middleware Settings
# =============================================================================
# For Django's cache middleware (UpdateCacheMiddleware/FetchFromCacheMiddleware)
CACHE_MIDDLEWARE_SECONDS = config("CACHE_MIDDLEWARE_SECONDS", default=300, cast=int)
CACHE_MIDDLEWARE_KEY_PREFIX = config(
"CACHE_MIDDLEWARE_KEY_PREFIX", default="thrillwiki"
)

View File

@@ -1,37 +1,109 @@
"""
Database configuration for thrillwiki project.
This module configures database connections, connection pooling, and
GeoDjango settings using python-decouple for consistent environment
variable management.
Why python-decouple?
- Already used in base.py for consistency
- Simpler API than django-environ
- Sufficient for our configuration needs
- Better separation of config from code
Database URL Format:
- PostgreSQL: postgres://user:password@host:port/dbname
- PostGIS: postgis://user:password@host:port/dbname
- SQLite: sqlite:///path/to/db.sqlite3
- SpatiaLite: spatialite:///path/to/db.sqlite3
"""
import environ
from decouple import config
import dj_database_url
env = environ.Env(
DATABASE_URL=(
str,
"postgis://thrillwiki_user:thrillwiki@localhost:5432/thrillwiki_test_db",
),
GDAL_LIBRARY_PATH=(str, "/opt/homebrew/lib/libgdal.dylib"),
GEOS_LIBRARY_PATH=(str, "/opt/homebrew/lib/libgeos_c.dylib"),
CACHE_URL=(str, "locmemcache://"),
CACHE_MIDDLEWARE_SECONDS=(int, 300),
CACHE_MIDDLEWARE_KEY_PREFIX=(str, "thrillwiki"),
# =============================================================================
# Database Configuration
# =============================================================================
# Parse DATABASE_URL environment variable into Django database settings
DATABASE_URL = config(
"DATABASE_URL",
default="postgis://thrillwiki_user:thrillwiki@localhost:5432/thrillwiki_test_db"
)
# Database configuration
db_config = env.db("DATABASE_URL")
# Parse the database URL
db_config = dj_database_url.parse(DATABASE_URL)
# Force PostGIS backend for spatial data support
db_config["ENGINE"] = "django.contrib.gis.db.backends.postgis"
# This ensures GeoDjango features work correctly
if "postgis" in DATABASE_URL or "postgresql" in DATABASE_URL:
db_config["ENGINE"] = "django.contrib.gis.db.backends.postgis"
DATABASES = {
"default": db_config,
}
# GeoDjango Settings - Environment specific with fallbacks
GDAL_LIBRARY_PATH = env("GDAL_LIBRARY_PATH")
GEOS_LIBRARY_PATH = env("GEOS_LIBRARY_PATH")
# =============================================================================
# Database Connection Pooling Configuration
# =============================================================================
# Connection pooling improves performance by reusing database connections
# Cache settings
CACHES = {"default": env.cache("CACHE_URL")}
# CONN_MAX_AGE: How long to keep connections open (in seconds)
# 0 = Close after each request (default Django behavior)
# None = Unlimited reuse (not recommended)
# 600 = 10 minutes (good balance for most applications)
CONN_MAX_AGE = config("DATABASE_CONN_MAX_AGE", default=600, cast=int)
CACHE_MIDDLEWARE_SECONDS = env.int("CACHE_MIDDLEWARE_SECONDS")
CACHE_MIDDLEWARE_KEY_PREFIX = env("CACHE_MIDDLEWARE_KEY_PREFIX")
# Apply CONN_MAX_AGE to the default database
DATABASES["default"]["CONN_MAX_AGE"] = CONN_MAX_AGE
# =============================================================================
# Database Connection Options (PostgreSQL-specific)
# =============================================================================
# These settings are passed to psycopg2 when creating new connections
DATABASE_OPTIONS = {
# Connection timeout in seconds
"connect_timeout": config("DATABASE_CONNECT_TIMEOUT", default=10, cast=int),
# Query timeout in milliseconds (30 seconds default)
# This prevents runaway queries from blocking the database
"options": f"-c statement_timeout={config('DATABASE_STATEMENT_TIMEOUT', default=30000, cast=int)}",
}
# Apply options to PostgreSQL databases
if "postgis" in DATABASE_URL or "postgresql" in DATABASE_URL:
DATABASES["default"].setdefault("OPTIONS", {})
DATABASES["default"]["OPTIONS"].update(DATABASE_OPTIONS)
# =============================================================================
# GeoDjango Settings
# =============================================================================
# Library paths for GDAL and GEOS (required for GeoDjango)
# These vary by operating system and installation method
# macOS with Homebrew (default)
# Linux: /usr/lib/x86_64-linux-gnu/libgdal.so
# Docker: Usually handled by the image
GDAL_LIBRARY_PATH = config(
"GDAL_LIBRARY_PATH",
default="/opt/homebrew/lib/libgdal.dylib"
)
GEOS_LIBRARY_PATH = config(
"GEOS_LIBRARY_PATH",
default="/opt/homebrew/lib/libgeos_c.dylib"
)
# =============================================================================
# Read Replica Configuration (Optional)
# =============================================================================
# Configure read replicas for read-heavy workloads
# Set DATABASE_READ_REPLICA_URL to enable
DATABASE_READ_REPLICA_URL = config("DATABASE_READ_REPLICA_URL", default="")
if DATABASE_READ_REPLICA_URL:
replica_config = dj_database_url.parse(DATABASE_READ_REPLICA_URL)
if "postgis" in DATABASE_READ_REPLICA_URL or "postgresql" in DATABASE_READ_REPLICA_URL:
replica_config["ENGINE"] = "django.contrib.gis.db.backends.postgis"
replica_config["CONN_MAX_AGE"] = CONN_MAX_AGE
DATABASES["replica"] = replica_config

View File

@@ -1,24 +1,74 @@
"""
Email configuration for thrillwiki project.
This module configures email backends and settings using python-decouple
for consistent environment variable management across the project.
Why python-decouple?
- Already used in base.py for consistency
- Simpler API than django-environ
- Sufficient for our configuration needs
- Better separation of config from code
"""
import environ
from decouple import config
env = environ.Env()
# =============================================================================
# Email Backend Configuration
# =============================================================================
# Choose the appropriate email backend based on your environment:
# - Console: django.core.mail.backends.console.EmailBackend (development)
# - ForwardEmail: django_forwardemail.backends.ForwardEmailBackend (production)
# - SMTP: django.core.mail.backends.smtp.EmailBackend (custom SMTP)
# Email settings
EMAIL_BACKEND = env(
"EMAIL_BACKEND", default="email_service.backends.ForwardEmailBackend"
EMAIL_BACKEND = config(
"EMAIL_BACKEND",
default="django_forwardemail.backends.ForwardEmailBackend"
)
FORWARD_EMAIL_BASE_URL = env(
"FORWARD_EMAIL_BASE_URL", default="https://api.forwardemail.net"
# =============================================================================
# ForwardEmail Configuration
# =============================================================================
# ForwardEmail is a privacy-focused email service that supports custom domains
# https://forwardemail.net/
FORWARD_EMAIL_BASE_URL = config(
"FORWARD_EMAIL_BASE_URL",
default="https://api.forwardemail.net"
)
SERVER_EMAIL = env("SERVER_EMAIL", default="django_webmaster@thrillwiki.com")
FORWARD_EMAIL_API_KEY = config("FORWARD_EMAIL_API_KEY", default="")
FORWARD_EMAIL_DOMAIN = config("FORWARD_EMAIL_DOMAIN", default="")
# Email URLs can be configured using EMAIL_URL environment variable
# Example: EMAIL_URL=smtp://user:pass@localhost:587
EMAIL_URL = env("EMAIL_URL", default=None)
# Server email address for sending system emails
SERVER_EMAIL = config("SERVER_EMAIL", default="django_webmaster@thrillwiki.com")
if EMAIL_URL:
email_config = env.email(EMAIL_URL)
vars().update(email_config)
# =============================================================================
# SMTP Configuration
# =============================================================================
# These settings are used when EMAIL_BACKEND is set to SMTP backend
# Configure via individual environment variables or EMAIL_URL
EMAIL_HOST = config("EMAIL_HOST", default="localhost")
EMAIL_PORT = config("EMAIL_PORT", default=587, cast=int)
EMAIL_USE_TLS = config("EMAIL_USE_TLS", default=True, cast=bool)
EMAIL_USE_SSL = config("EMAIL_USE_SSL", default=False, cast=bool)
EMAIL_HOST_USER = config("EMAIL_HOST_USER", default="")
EMAIL_HOST_PASSWORD = config("EMAIL_HOST_PASSWORD", default="")
# =============================================================================
# Email Timeout and Retry Settings
# =============================================================================
# Timeout for email operations in seconds
EMAIL_TIMEOUT = config("EMAIL_TIMEOUT", default=30, cast=int)
# Default from email address
DEFAULT_FROM_EMAIL = config(
"DEFAULT_FROM_EMAIL",
default="ThrillWiki <noreply@thrillwiki.com>"
)
# =============================================================================
# Email Subject Prefix
# =============================================================================
# Prefix added to the subject of emails sent by Django admin
EMAIL_SUBJECT_PREFIX = config("EMAIL_SUBJECT_PREFIX", default="[ThrillWiki] ")

View File

@@ -0,0 +1,201 @@
"""
Logging configuration for thrillwiki project.
This module provides a base logging configuration that can be extended
by environment-specific settings. It supports both console and file
logging with optional JSON formatting for production.
Why python-decouple?
- Already used in base.py for consistency
- Simpler API than django-environ
- Sufficient for our configuration needs
- Better separation of config from code
Log Levels (in order of severity):
- DEBUG: Detailed diagnostic information
- INFO: Confirmation that things are working as expected
- WARNING: Indication of potential problems
- ERROR: Serious problems that prevented function execution
- CRITICAL: Critical errors that may cause application failure
"""
from pathlib import Path
from decouple import config
# =============================================================================
# Log File Configuration
# =============================================================================
# Base directory for log files - defaults to logs/ in the backend directory
LOG_DIR = Path(config("LOG_DIR", default="logs"))
# Ensure log directory exists (will be created if not)
LOG_DIR.mkdir(parents=True, exist_ok=True)
# =============================================================================
# Log Formatters
# =============================================================================
LOGGING_FORMATTERS = {
# Verbose format for development - human readable with full context
"verbose": {
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
"style": "{",
},
# JSON format for production - machine parseable for log aggregation
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"format": (
"%(levelname)s %(asctime)s %(module)s %(process)d "
"%(thread)d %(message)s"
),
},
# Simple format for console output
"simple": {
"format": "{levelname} {message}",
"style": "{",
},
# Request logging format
"request": {
"format": "{levelname} {asctime} [{request_id}] {message}",
"style": "{",
},
}
# =============================================================================
# Log Handlers
# =============================================================================
LOGGING_HANDLERS = {
# Console handler - for development and container environments
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose",
"level": config("CONSOLE_LOG_LEVEL", default="INFO"),
},
# Main application log file
"file": {
"class": "logging.handlers.RotatingFileHandler",
"filename": str(LOG_DIR / "thrillwiki.log"),
"maxBytes": 1024 * 1024 * 10, # 10MB
"backupCount": 5,
"formatter": config("FILE_LOG_FORMATTER", default="json"),
"level": config("FILE_LOG_LEVEL", default="INFO"),
},
# Error-only log file for quick error identification
"error_file": {
"class": "logging.handlers.RotatingFileHandler",
"filename": str(LOG_DIR / "errors.log"),
"maxBytes": 1024 * 1024 * 15, # 15MB
"backupCount": 10,
"formatter": "json",
"level": "ERROR",
},
# Performance log file for slow queries and performance issues
"performance": {
"class": "logging.handlers.RotatingFileHandler",
"filename": str(LOG_DIR / "performance.log"),
"maxBytes": 1024 * 1024 * 10, # 10MB
"backupCount": 5,
"formatter": "json",
"level": "INFO",
},
# Security event log file
"security": {
"class": "logging.handlers.RotatingFileHandler",
"filename": str(LOG_DIR / "security.log"),
"maxBytes": 1024 * 1024 * 10, # 10MB
"backupCount": 10,
"formatter": "json",
"level": "INFO",
},
}
# =============================================================================
# Logger Configuration
# =============================================================================
LOGGING_LOGGERS = {
# Django framework logging
"django": {
"handlers": ["console", "file"],
"level": config("DJANGO_LOG_LEVEL", default="WARNING"),
"propagate": False,
},
# Django database queries - useful for debugging N+1 issues
"django.db.backends": {
"handlers": ["console"],
"level": config("DB_LOG_LEVEL", default="WARNING"),
"propagate": False,
},
# Django request handling
"django.request": {
"handlers": ["console", "error_file"],
"level": "ERROR",
"propagate": False,
},
# Django security events
"django.security": {
"handlers": ["console", "security"],
"level": "WARNING",
"propagate": False,
},
# Application logging
"thrillwiki": {
"handlers": ["console", "file"],
"level": config("APP_LOG_LEVEL", default="INFO"),
"propagate": False,
},
# Performance monitoring
"performance": {
"handlers": ["performance"],
"level": config("PERFORMANCE_LOG_LEVEL", default="INFO"),
"propagate": False,
},
# Query optimization warnings
"query_optimization": {
"handlers": ["console", "file"],
"level": config("QUERY_LOG_LEVEL", default="WARNING"),
"propagate": False,
},
# N+1 query detection
"nplusone": {
"handlers": ["console"],
"level": config("NPLUSONE_LOG_LEVEL", default="WARNING"),
"propagate": False,
},
# Request logging
"request_logging": {
"handlers": ["console"],
"level": config("REQUEST_LOG_LEVEL", default="INFO"),
"propagate": False,
},
# Security events
"security": {
"handlers": ["console", "security"],
"level": "INFO",
"propagate": False,
},
# Celery task logging
"celery": {
"handlers": ["console", "file"],
"level": config("CELERY_LOG_LEVEL", default="INFO"),
"propagate": False,
},
}
# =============================================================================
# Complete Logging Configuration
# =============================================================================
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": LOGGING_FORMATTERS,
"handlers": LOGGING_HANDLERS,
"root": {
"level": config("ROOT_LOG_LEVEL", default="INFO"),
"handlers": ["console"],
},
"loggers": LOGGING_LOGGERS,
}

View File

@@ -0,0 +1,284 @@
"""
Django REST Framework configuration for thrillwiki project.
This module configures DRF, SimpleJWT, dj-rest-auth, CORS, and
drf-spectacular (OpenAPI documentation).
Why python-decouple?
- Already used in base.py for consistency
- Simpler API than django-environ
- Sufficient for our configuration needs
- Better separation of config from code
"""
from datetime import timedelta
from decouple import config
# =============================================================================
# Django REST Framework Settings
# =============================================================================
REST_FRAMEWORK = {
# Authentication classes (order matters - first match wins)
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication", # Backward compatibility
],
# Default permissions - require authentication
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
# Pagination settings
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": config("API_PAGE_SIZE", default=20, cast=int),
"MAX_PAGE_SIZE": config("API_MAX_PAGE_SIZE", default=100, cast=int),
# API versioning via Accept header
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.AcceptHeaderVersioning",
"DEFAULT_VERSION": "v1",
"ALLOWED_VERSIONS": ["v1"],
# Response rendering
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
"rest_framework.renderers.BrowsableAPIRenderer",
],
# Request parsing
"DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser",
"rest_framework.parsers.FormParser",
"rest_framework.parsers.MultiPartParser",
],
# Custom exception handling
"EXCEPTION_HANDLER": "apps.core.api.exceptions.custom_exception_handler",
# Filter backends
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.SearchFilter",
"rest_framework.filters.OrderingFilter",
],
# Rate limiting
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": f"{config('API_RATE_LIMIT_ANON_PER_MINUTE', default=60, cast=int)}/minute",
"user": f"{config('API_RATE_LIMIT_USER_PER_HOUR', default=1000, cast=int)}/hour",
},
# Test settings
"TEST_REQUEST_DEFAULT_FORMAT": "json",
"NON_FIELD_ERRORS_KEY": "non_field_errors",
# OpenAPI schema
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
# =============================================================================
# CORS Settings
# =============================================================================
# Cross-Origin Resource Sharing configuration for API access
# Allow credentials (cookies, authorization headers)
CORS_ALLOW_CREDENTIALS = True
# Allow all origins (not recommended for production)
CORS_ALLOW_ALL_ORIGINS = config(
"CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool
)
# Specific allowed origins (comma-separated)
CORS_ALLOWED_ORIGINS = config(
"CORS_ALLOWED_ORIGINS",
default="",
cast=lambda v: [s.strip() for s in v.split(",") if s.strip()]
)
# Allowed HTTP headers for CORS requests
CORS_ALLOW_HEADERS = [
"accept",
"accept-encoding",
"authorization",
"content-type",
"dnt",
"origin",
"user-agent",
"x-csrftoken",
"x-requested-with",
"x-api-version",
]
# HTTP methods allowed for CORS requests
CORS_ALLOW_METHODS = [
"DELETE",
"GET",
"OPTIONS",
"PATCH",
"POST",
"PUT",
]
# Headers exposed to browsers (for rate limiting)
CORS_EXPOSE_HEADERS = [
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
"X-RateLimit-Reset",
"X-API-Version",
]
# =============================================================================
# API Rate Limiting
# =============================================================================
API_RATE_LIMIT_PER_MINUTE = config(
"API_RATE_LIMIT_PER_MINUTE", default=60, cast=int
)
API_RATE_LIMIT_PER_HOUR = config(
"API_RATE_LIMIT_PER_HOUR", default=1000, cast=int
)
# =============================================================================
# SimpleJWT Settings
# =============================================================================
# JWT token configuration for authentication
# Import SECRET_KEY for signing tokens
# This will be set by base.py before this module is imported
def get_secret_key():
"""Get SECRET_KEY lazily to avoid circular imports."""
return config("SECRET_KEY")
SIMPLE_JWT = {
# Token lifetimes
# Short access tokens (15 min) provide better security
"ACCESS_TOKEN_LIFETIME": timedelta(
minutes=config("JWT_ACCESS_TOKEN_LIFETIME_MINUTES", default=15, cast=int)
),
"REFRESH_TOKEN_LIFETIME": timedelta(
days=config("JWT_REFRESH_TOKEN_LIFETIME_DAYS", default=7, cast=int)
),
# Token rotation and blacklisting
# Rotate refresh tokens on each use and blacklist old ones
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
# Update last login on token refresh
"UPDATE_LAST_LOGIN": True,
# Cryptographic settings
"ALGORITHM": "HS256",
"SIGNING_KEY": None, # Will use Django's SECRET_KEY
"VERIFYING_KEY": None,
# Token validation
"AUDIENCE": None,
"ISSUER": config("JWT_ISSUER", default="thrillwiki"),
"JWK_URL": None,
"LEEWAY": 0, # No leeway for token expiration
# Authentication header
"AUTH_HEADER_TYPES": ("Bearer",),
"AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
# User identification
"USER_ID_FIELD": "id",
"USER_ID_CLAIM": "user_id",
"USER_AUTHENTICATION_RULE": (
"rest_framework_simplejwt.authentication.default_user_authentication_rule"
),
# Token classes
"AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",),
"TOKEN_TYPE_CLAIM": "token_type",
"TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser",
# JTI claim for unique token identification (enables revocation)
"JTI_CLAIM": "jti",
# Sliding token settings
"SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp",
"SLIDING_TOKEN_LIFETIME": timedelta(minutes=15),
"SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1),
}
# =============================================================================
# dj-rest-auth Settings
# =============================================================================
# REST authentication endpoints configuration
# Determine if we're in debug mode for secure cookie setting
_debug = config("DEBUG", default=True, cast=bool)
REST_AUTH = {
"USE_JWT": True,
"JWT_AUTH_COOKIE": "thrillwiki-auth",
"JWT_AUTH_REFRESH_COOKIE": "thrillwiki-refresh",
# Only send cookies over HTTPS in production
"JWT_AUTH_SECURE": not _debug,
# Prevent JavaScript access to cookies
"JWT_AUTH_HTTPONLY": True,
# SameSite cookie attribute (Lax is compatible with OAuth flows)
"JWT_AUTH_SAMESITE": "Lax",
"JWT_AUTH_RETURN_EXPIRATION": True,
"JWT_TOKEN_CLAIMS_SERIALIZER": (
"rest_framework_simplejwt.serializers.TokenObtainPairSerializer"
),
}
# =============================================================================
# drf-spectacular Settings (OpenAPI Documentation)
# =============================================================================
SPECTACULAR_SETTINGS = {
"TITLE": "ThrillWiki API",
"DESCRIPTION": """Comprehensive theme park and ride information API.
## API Conventions
### Response Format
All successful responses include a `success: true` field with data nested under `data`.
All error responses include an `error` object with `code` and `message` fields.
### Pagination
List endpoints support pagination with `page` and `page_size` parameters.
Default page size is 20, maximum is 100.
### Filtering
Range filters use `{field}_min` and `{field}_max` naming convention.
Search uses the `search` parameter.
Ordering uses the `ordering` parameter (prefix with `-` for descending).
### Field Naming
All field names use snake_case convention (e.g., `image_url`, `created_at`).
""",
"VERSION": config("API_VERSION", default="1.0.0"),
"SERVE_INCLUDE_SCHEMA": False,
"COMPONENT_SPLIT_REQUEST": True,
"TAGS": [
{"name": "Parks", "description": "Theme park operations"},
{"name": "Rides", "description": "Ride information and management"},
{"name": "Park Media", "description": "Park photos and media management"},
{"name": "Ride Media", "description": "Ride photos and media management"},
{"name": "Authentication", "description": "User authentication and session management"},
{"name": "Social Authentication", "description": "Social provider login and account linking"},
{"name": "User Profile", "description": "User profile management"},
{"name": "User Settings", "description": "User preferences and settings"},
{"name": "User Notifications", "description": "User notification management"},
{"name": "User Content", "description": "User-generated content (top lists, reviews)"},
{"name": "User Management", "description": "Admin user management operations"},
{"name": "Self-Service Account Management", "description": "User account deletion and management"},
{"name": "Core", "description": "Core utility endpoints (search, suggestions)"},
{"name": "Statistics", "description": "Statistical endpoints providing aggregated data and insights"},
],
"SCHEMA_PATH_PREFIX": "/api/",
"DEFAULT_GENERATOR_CLASS": "drf_spectacular.generators.SchemaGenerator",
"DEFAULT_AUTO_SCHEMA": "drf_spectacular.openapi.AutoSchema",
"PREPROCESSING_HOOKS": [
"api.v1.schema.custom_preprocessing_hook",
],
"SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"],
"SWAGGER_UI_SETTINGS": {
"deepLinking": True,
"persistAuthorization": True,
"displayOperationId": False,
"displayRequestDuration": True,
},
"REDOC_UI_SETTINGS": {
"hideDownloadButton": False,
"hideHostname": False,
"hideLoading": False,
"hideSchemaPattern": True,
"scrollYOffset": 0,
"theme": {"colors": {"primary": {"main": "#1976d2"}}},
},
}

View File

@@ -0,0 +1,391 @@
"""
Secret management configuration for thrillwiki project.
This module provides patterns for secure secret handling including:
- Secret validation
- Secret rotation support
- Integration points for secret management services
- Secure fallback to environment variables
For production, consider integrating with:
- AWS Secrets Manager
- HashiCorp Vault
- Google Secret Manager
- Azure Key Vault
Why python-decouple?
- Already used across the project for consistency
- Provides secure environment variable handling
- Supports .env files and environment variables
"""
import logging
import warnings
from datetime import datetime, timedelta
from typing import Optional
from decouple import config, UndefinedValueError
logger = logging.getLogger("security")
# =============================================================================
# Secret Configuration
# =============================================================================
# Enable secret rotation checking (set to True in production)
SECRET_ROTATION_ENABLED = config(
"SECRET_ROTATION_ENABLED", default=False, cast=bool
)
# Secret version for tracking rotations
SECRET_KEY_VERSION = config("SECRET_KEY_VERSION", default="1")
# Secret expiry warning threshold (days before expiry to start warning)
SECRET_EXPIRY_WARNING_DAYS = config(
"SECRET_EXPIRY_WARNING_DAYS", default=30, cast=int
)
# =============================================================================
# Required Secrets Registry
# =============================================================================
# List of required secrets with validation rules
REQUIRED_SECRETS = {
"SECRET_KEY": {
"min_length": 50,
"description": "Django secret key for cryptographic signing",
"rotation_period_days": 90,
},
"DATABASE_URL": {
"min_length": 10,
"description": "Database connection URL",
"contains_password": True,
},
}
# Optional secrets that should be validated if present
OPTIONAL_SECRETS = {
"SENTRY_DSN": {
"min_length": 10,
"description": "Sentry error tracking DSN",
},
"CLOUDFLARE_IMAGES_API_TOKEN": {
"min_length": 20,
"description": "Cloudflare Images API token",
},
"FORWARD_EMAIL_API_KEY": {
"min_length": 10,
"description": "ForwardEmail API key",
},
"TURNSTILE_SECRET_KEY": {
"min_length": 10,
"description": "Cloudflare Turnstile secret key",
},
}
# =============================================================================
# Secret Validation Functions
# =============================================================================
def validate_secret_strength(name: str, value: str, min_length: int = 10) -> bool:
"""
Validate that a secret meets minimum strength requirements.
Args:
name: Name of the secret (for logging)
value: The secret value to validate
min_length: Minimum required length
Returns:
bool: True if valid, False otherwise
"""
if not value:
logger.error(f"Secret '{name}' is empty or not set")
return False
if len(value) < min_length:
logger.error(
f"Secret '{name}' is too short ({len(value)} chars, "
f"minimum {min_length})"
)
return False
# Check for placeholder values
placeholder_patterns = [
"your-secret-key",
"change-me",
"placeholder",
"example",
"xxx",
"todo",
]
value_lower = value.lower()
for pattern in placeholder_patterns:
if pattern in value_lower:
logger.warning(
f"Secret '{name}' appears to contain a placeholder value"
)
return False
return True
def validate_secret_key(secret_key: str) -> bool:
"""
Validate Django SECRET_KEY meets security requirements.
Requirements:
- At least 50 characters
- Contains mixed case letters
- Contains numbers
- Contains special characters
Args:
secret_key: The SECRET_KEY value
Returns:
bool: True if valid, False otherwise
"""
if len(secret_key) < 50:
logger.error(
f"SECRET_KEY is too short ({len(secret_key)} chars, minimum 50)"
)
return False
has_upper = any(c.isupper() for c in secret_key)
has_lower = any(c.islower() for c in secret_key)
has_digit = any(c.isdigit() for c in secret_key)
has_special = any(not c.isalnum() for c in secret_key)
if not all([has_upper, has_lower, has_digit, has_special]):
logger.warning(
"SECRET_KEY should contain uppercase, lowercase, digits, "
"and special characters"
)
# Don't fail, just warn - some generated keys may not have all
return True
def get_secret(
name: str,
default: Optional[str] = None,
required: bool = True,
min_length: int = 0,
) -> Optional[str]:
"""
Safely retrieve a secret with validation.
Args:
name: Environment variable name
default: Default value if not set
required: Whether the secret is required
min_length: Minimum required length
Returns:
The secret value or None if not found and not required
Raises:
ValueError: If required secret is missing or invalid
"""
try:
value = config(name, default=default)
except UndefinedValueError:
if required:
raise ValueError(f"Required secret '{name}' is not set")
return default
if value and min_length > 0:
if not validate_secret_strength(name, value, min_length):
if required:
raise ValueError(f"Secret '{name}' does not meet requirements")
return default
return value
def validate_required_secrets(raise_on_error: bool = False) -> list[str]:
"""
Validate all required secrets are set and meet requirements.
Args:
raise_on_error: If True, raise ValueError on first error
Returns:
List of error messages (empty if all valid)
"""
errors = []
for name, rules in REQUIRED_SECRETS.items():
try:
value = config(name)
min_length = rules.get("min_length", 0)
if not validate_secret_strength(name, value, min_length):
msg = f"Secret '{name}' validation failed"
errors.append(msg)
if raise_on_error:
raise ValueError(msg)
except UndefinedValueError:
msg = f"Required secret '{name}' is not set: {rules['description']}"
errors.append(msg)
if raise_on_error:
raise ValueError(msg)
return errors
def check_secret_expiry() -> list[str]:
"""
Check if any secrets are approaching expiry.
This is a placeholder for integration with secret management services
that track secret expiry dates.
Returns:
List of warning messages for secrets approaching expiry
"""
warnings_list = []
# Placeholder: In production, integrate with your secret manager
# to check actual expiry dates
# Example check based on version
if SECRET_ROTATION_ENABLED:
try:
version = int(SECRET_KEY_VERSION)
# If version is very old, suggest rotation
if version < 2:
warnings_list.append(
"SECRET_KEY version is old. Consider rotating secrets."
)
except ValueError:
pass
return warnings_list
# =============================================================================
# Secret Provider Integration Points
# =============================================================================
class SecretProvider:
"""
Base class for secret provider integrations.
Subclass this to integrate with secret management services:
- AWS Secrets Manager
- HashiCorp Vault
- Google Secret Manager
- Azure Key Vault
"""
def get_secret(self, name: str) -> Optional[str]:
"""Retrieve a secret by name."""
raise NotImplementedError
def set_secret(self, name: str, value: str) -> bool:
"""Set a secret value."""
raise NotImplementedError
def rotate_secret(self, name: str) -> str:
"""Rotate a secret and return the new value."""
raise NotImplementedError
def list_secrets(self) -> list[str]:
"""List all available secrets."""
raise NotImplementedError
class EnvironmentSecretProvider(SecretProvider):
"""
Default secret provider using environment variables.
This is the fallback provider for development and simple deployments.
"""
def get_secret(self, name: str) -> Optional[str]:
"""Retrieve a secret from environment variables."""
try:
return config(name)
except UndefinedValueError:
return None
def set_secret(self, name: str, value: str) -> bool:
"""Environment variables are read-only at runtime."""
logger.warning(
f"Cannot set secret '{name}' in environment provider. "
"Update your .env file or environment variables."
)
return False
def rotate_secret(self, name: str) -> str:
"""Cannot rotate secrets in environment provider."""
raise NotImplementedError(
"Secret rotation is not supported for environment variables. "
"Use a proper secret management service in production."
)
def list_secrets(self) -> list[str]:
"""List all known secret names."""
return list(REQUIRED_SECRETS.keys()) + list(OPTIONAL_SECRETS.keys())
# Default provider instance
_secret_provider: SecretProvider = EnvironmentSecretProvider()
def get_secret_provider() -> SecretProvider:
"""Get the current secret provider instance."""
return _secret_provider
def set_secret_provider(provider: SecretProvider) -> None:
"""Set a custom secret provider."""
global _secret_provider
_secret_provider = provider
# =============================================================================
# Startup Validation
# =============================================================================
def run_startup_validation() -> None:
"""
Run secret validation on application startup.
This function should be called during Django initialization
to catch configuration errors early.
"""
debug_mode = config("DEBUG", default=True, cast=bool)
# Validate required secrets
errors = validate_required_secrets(raise_on_error=not debug_mode)
if errors:
for error in errors:
if debug_mode:
warnings.warn(f"Secret validation warning: {error}")
else:
logger.error(f"Secret validation error: {error}")
# Check for expiring secrets
if SECRET_ROTATION_ENABLED:
expiry_warnings = check_secret_expiry()
for warning in expiry_warnings:
logger.warning(f"Secret expiry: {warning}")
# Validate SECRET_KEY specifically
try:
secret_key = config("SECRET_KEY")
if not validate_secret_key(secret_key):
if not debug_mode:
raise ValueError("SECRET_KEY does not meet security requirements")
except UndefinedValueError:
if not debug_mode:
raise ValueError("SECRET_KEY is required in production")

View File

@@ -3,16 +3,27 @@ Security configuration for thrillwiki project.
This module configures security headers and settings to protect against common
web vulnerabilities including XSS, clickjacking, MIME sniffing, and more.
Uses python-decouple for consistent environment variable management.
Why python-decouple?
- Already used in base.py for consistency
- Simpler API than django-environ
- Sufficient for our configuration needs
- Better separation of config from code
"""
import environ
from decouple import config
env = environ.Env()
# =============================================================================
# Cloudflare Turnstile Configuration
# =============================================================================
# Turnstile is Cloudflare's CAPTCHA alternative for bot protection
# Get keys from: https://dash.cloudflare.com/?to=/:account/turnstile
# Cloudflare Turnstile settings
TURNSTILE_SITE_KEY = env("TURNSTILE_SITE_KEY", default="")
TURNSTILE_SECRET_KEY = env("TURNSTILE_SECRET_KEY", default="")
TURNSTILE_VERIFY_URL = env(
TURNSTILE_SITE_KEY = config("TURNSTILE_SITE_KEY", default="")
TURNSTILE_SECRET_KEY = config("TURNSTILE_SECRET_KEY", default="")
TURNSTILE_VERIFY_URL = config(
"TURNSTILE_VERIFY_URL",
default="https://challenges.cloudflare.com/turnstile/v0/siteverify",
)
@@ -24,27 +35,31 @@ TURNSTILE_VERIFY_URL = env(
# X-XSS-Protection: Enables browser's built-in XSS filter
# Note: Modern browsers are deprecating this in favor of CSP, but it's still
# useful for older browsers
SECURE_BROWSER_XSS_FILTER = env.bool("SECURE_BROWSER_XSS_FILTER", default=True)
SECURE_BROWSER_XSS_FILTER = config(
"SECURE_BROWSER_XSS_FILTER", default=True, cast=bool
)
# X-Content-Type-Options: Prevents MIME type sniffing attacks
# When True, adds "X-Content-Type-Options: nosniff" header
SECURE_CONTENT_TYPE_NOSNIFF = env.bool("SECURE_CONTENT_TYPE_NOSNIFF", default=True)
SECURE_CONTENT_TYPE_NOSNIFF = config(
"SECURE_CONTENT_TYPE_NOSNIFF", default=True, cast=bool
)
# X-Frame-Options: Protects against clickjacking attacks
# DENY = Never allow framing (most secure)
# SAMEORIGIN = Only allow framing from same origin
X_FRAME_OPTIONS = env("X_FRAME_OPTIONS", default="DENY")
X_FRAME_OPTIONS = config("X_FRAME_OPTIONS", default="DENY")
# Referrer-Policy: Controls how much referrer information is sent
# strict-origin-when-cross-origin = Send full URL for same-origin,
# only origin for cross-origin, nothing for downgrade
SECURE_REFERRER_POLICY = env(
SECURE_REFERRER_POLICY = config(
"SECURE_REFERRER_POLICY", default="strict-origin-when-cross-origin"
)
# Cross-Origin-Opener-Policy: Prevents cross-origin attacks via window references
# same-origin = Document can only be accessed by windows from same origin
SECURE_CROSS_ORIGIN_OPENER_POLICY = env(
SECURE_CROSS_ORIGIN_OPENER_POLICY = config(
"SECURE_CROSS_ORIGIN_OPENER_POLICY", default="same-origin"
)
@@ -53,79 +68,104 @@ SECURE_CROSS_ORIGIN_OPENER_POLICY = env(
# =============================================================================
# Include subdomains in HSTS policy
SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool(
"SECURE_HSTS_INCLUDE_SUBDOMAINS", default=True
SECURE_HSTS_INCLUDE_SUBDOMAINS = config(
"SECURE_HSTS_INCLUDE_SUBDOMAINS", default=True, cast=bool
)
# HSTS max-age in seconds (31536000 = 1 year, recommended minimum)
SECURE_HSTS_SECONDS = env.int("SECURE_HSTS_SECONDS", default=31536000)
SECURE_HSTS_SECONDS = config("SECURE_HSTS_SECONDS", default=31536000, cast=int)
# HSTS preload: Allow inclusion in browser preload lists
# Only enable after confirming HTTPS works properly for all subdomains
SECURE_HSTS_PRELOAD = env.bool("SECURE_HSTS_PRELOAD", default=False)
SECURE_HSTS_PRELOAD = config("SECURE_HSTS_PRELOAD", default=False, cast=bool)
# URLs exempt from SSL redirect (e.g., health checks)
SECURE_REDIRECT_EXEMPT = env.list("SECURE_REDIRECT_EXEMPT", default=[])
# Format: comma-separated list of URL patterns
SECURE_REDIRECT_EXEMPT = config(
"SECURE_REDIRECT_EXEMPT",
default="",
cast=lambda v: [s.strip() for s in v.split(",") if s.strip()]
)
# Redirect all HTTP requests to HTTPS
SECURE_SSL_REDIRECT = env.bool("SECURE_SSL_REDIRECT", default=False)
SECURE_SSL_REDIRECT = config("SECURE_SSL_REDIRECT", default=False, cast=bool)
# Header used by proxy to indicate HTTPS (e.g., ('HTTP_X_FORWARDED_PROTO', 'https'))
SECURE_PROXY_SSL_HEADER = env.tuple("SECURE_PROXY_SSL_HEADER", default=None)
# Header used by proxy to indicate HTTPS
# Common values: ('HTTP_X_FORWARDED_PROTO', 'https')
_proxy_ssl_header = config("SECURE_PROXY_SSL_HEADER", default="")
SECURE_PROXY_SSL_HEADER = (
tuple(_proxy_ssl_header.split(",")) if _proxy_ssl_header else None
)
# =============================================================================
# Session Cookie Security
# =============================================================================
# Only send session cookie over HTTPS
SESSION_COOKIE_SECURE = env.bool("SESSION_COOKIE_SECURE", default=False)
SESSION_COOKIE_SECURE = config("SESSION_COOKIE_SECURE", default=False, cast=bool)
# Prevent JavaScript access to session cookie (mitigates XSS)
SESSION_COOKIE_HTTPONLY = env.bool("SESSION_COOKIE_HTTPONLY", default=True)
SESSION_COOKIE_HTTPONLY = config("SESSION_COOKIE_HTTPONLY", default=True, cast=bool)
# SameSite attribute: Protects against CSRF attacks
# Strict = Cookie only sent for same-site requests (most secure)
# Lax = Cookie sent for same-site and top-level navigations (default)
SESSION_COOKIE_SAMESITE = env("SESSION_COOKIE_SAMESITE", default="Lax")
SESSION_COOKIE_SAMESITE = config("SESSION_COOKIE_SAMESITE", default="Lax")
# =============================================================================
# CSRF Cookie Security
# =============================================================================
# Only send CSRF cookie over HTTPS
CSRF_COOKIE_SECURE = env.bool("CSRF_COOKIE_SECURE", default=False)
CSRF_COOKIE_SECURE = config("CSRF_COOKIE_SECURE", default=False, cast=bool)
# Prevent JavaScript access to CSRF cookie
# Note: Set to False if you need to read the token via JavaScript for AJAX
CSRF_COOKIE_HTTPONLY = env.bool("CSRF_COOKIE_HTTPONLY", default=True)
CSRF_COOKIE_HTTPONLY = config("CSRF_COOKIE_HTTPONLY", default=True, cast=bool)
# SameSite attribute for CSRF cookie
CSRF_COOKIE_SAMESITE = env("CSRF_COOKIE_SAMESITE", default="Lax")
CSRF_COOKIE_SAMESITE = config("CSRF_COOKIE_SAMESITE", default="Lax")
# =============================================================================
# File Upload Security
# Authentication Backends
# =============================================================================
# Order matters: Django tries each backend in order until one succeeds
# Maximum size (in bytes) of file to upload into memory (2.5MB)
FILE_UPLOAD_MAX_MEMORY_SIZE = env.int(
"FILE_UPLOAD_MAX_MEMORY_SIZE", default=2621440
)
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend",
]
# Maximum size (in bytes) of request data (10MB)
DATA_UPLOAD_MAX_MEMORY_SIZE = env.int(
"DATA_UPLOAD_MAX_MEMORY_SIZE", default=10485760
)
# =============================================================================
# Password Validators
# =============================================================================
# Django's built-in password validators for security
# File upload permissions (0o644 = rw-r--r--)
FILE_UPLOAD_PERMISSIONS = 0o644
# Directory permissions for uploaded files (0o755 = rwxr-xr-x)
FILE_UPLOAD_DIRECTORY_PERMISSIONS = 0o755
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": (
"django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
),
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
"OPTIONS": {
"min_length": config("PASSWORD_MIN_LENGTH", default=8, cast=int),
},
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# =============================================================================
# Permissions Policy (Feature Policy successor)
# Controls which browser features can be used
# =============================================================================
# Controls which browser features can be used
PERMISSIONS_POLICY = {
"accelerometer": [],
"ambient-light-sensor": [],

View File

@@ -0,0 +1,124 @@
"""
Storage configuration for thrillwiki project.
This module configures static files, media files, and storage backends
including WhiteNoise for static file serving.
Why python-decouple?
- Already used in base.py for consistency
- Simpler API than django-environ
- Sufficient for our configuration needs
- Better separation of config from code
"""
from pathlib import Path
from decouple import config
# =============================================================================
# Base Directory
# =============================================================================
# This will be set by the importing module, but we define a fallback
BASE_DIR = Path(__file__).resolve().parent.parent.parent
# =============================================================================
# Static Files Configuration
# =============================================================================
# https://docs.djangoproject.com/en/5.0/howto/static-files/
STATIC_URL = config("STATIC_URL", default="static/")
STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = BASE_DIR / "staticfiles"
# =============================================================================
# WhiteNoise Configuration
# =============================================================================
# https://whitenoise.readthedocs.io/
# WhiteNoise serves static files efficiently without a separate web server
# Compression quality for Brotli/Gzip (1-100, higher = better but slower)
WHITENOISE_COMPRESSION_QUALITY = config(
"WHITENOISE_COMPRESSION_QUALITY", default=90, cast=int
)
# Cache max-age for static files (1 year for immutable content)
WHITENOISE_MAX_AGE = config(
"WHITENOISE_MAX_AGE", default=31536000, cast=int
)
# Don't fail on missing manifest entries (graceful degradation)
WHITENOISE_MANIFEST_STRICT = config(
"WHITENOISE_MANIFEST_STRICT", default=False, cast=bool
)
# Additional MIME types
WHITENOISE_MIMETYPES = {
".webp": "image/webp",
".woff2": "font/woff2",
}
# Skip compressing already compressed formats
WHITENOISE_SKIP_COMPRESS_EXTENSIONS = [
"jpg", "jpeg", "png", "gif", "webp", # Images
"zip", "gz", "tgz", "bz2", "tbz", "xz", "br", # Archives
"swf", "flv", # Flash
"woff", "woff2", # Fonts
"mp3", "mp4", "ogg", "webm", # Media
]
# =============================================================================
# Media Files Configuration
# =============================================================================
# User-uploaded content
MEDIA_URL = config("MEDIA_URL", default="/media/")
MEDIA_ROOT = BASE_DIR.parent / "shared" / "media"
# =============================================================================
# Storage Backends Configuration
# =============================================================================
# Django 4.2+ storage configuration
STORAGES = {
# Default storage for user uploads (FileField, ImageField)
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
"OPTIONS": {
"location": str(MEDIA_ROOT),
},
},
# Static files storage
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
"OPTIONS": {
"location": str(STATIC_ROOT),
},
},
}
# =============================================================================
# File Upload Security Settings
# =============================================================================
# These settings help prevent denial-of-service attacks via file uploads
# Maximum size (in bytes) of file to upload into memory (2.5MB)
# Files larger than this are written to disk
FILE_UPLOAD_MAX_MEMORY_SIZE = config(
"FILE_UPLOAD_MAX_MEMORY_SIZE", default=2621440, cast=int
)
# Maximum size (in bytes) of request data (10MB)
# This limits the total size of POST request body
DATA_UPLOAD_MAX_MEMORY_SIZE = config(
"DATA_UPLOAD_MAX_MEMORY_SIZE", default=10485760, cast=int
)
# Maximum number of GET/POST parameters (1000)
DATA_UPLOAD_MAX_NUMBER_FIELDS = config(
"DATA_UPLOAD_MAX_NUMBER_FIELDS", default=1000, cast=int
)
# File upload permissions (0o644 = rw-r--r--)
FILE_UPLOAD_PERMISSIONS = 0o644
# Directory permissions for uploaded files (0o755 = rwxr-xr-x)
FILE_UPLOAD_DIRECTORY_PERMISSIONS = 0o755

View File

@@ -0,0 +1,184 @@
"""
Third-party application configuration for thrillwiki project.
This module configures third-party Django applications including:
- django-allauth (authentication)
- Celery (task queue)
- Health checks
- Tailwind CSS
- Cloudflare Images
- Road Trip service
Why python-decouple?
- Already used in base.py for consistency
- Simpler API than django-environ
- Sufficient for our configuration needs
- Better separation of config from code
"""
from decouple import config
# =============================================================================
# Django Allauth Configuration
# =============================================================================
# https://django-allauth.readthedocs.io/
SITE_ID = 1
# Signup fields configuration
# The asterisks indicate required fields
ACCOUNT_SIGNUP_FIELDS = ["email*", "username*", "password1*", "password2*"]
# Login methods - allow both email and username
ACCOUNT_LOGIN_METHODS = {"email", "username"}
# Email verification settings
ACCOUNT_EMAIL_VERIFICATION = config(
"ACCOUNT_EMAIL_VERIFICATION", default="mandatory"
)
ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_CHANGE = True
ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_RESEND = True
# Security settings
ACCOUNT_REAUTHENTICATION_REQUIRED = True
ACCOUNT_EMAIL_NOTIFICATIONS = True
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False
# Redirect URLs
LOGIN_REDIRECT_URL = config("LOGIN_REDIRECT_URL", default="/")
ACCOUNT_LOGOUT_REDIRECT_URL = config("ACCOUNT_LOGOUT_REDIRECT_URL", default="/")
# Custom adapters for extending allauth behavior
ACCOUNT_ADAPTER = "apps.accounts.adapters.CustomAccountAdapter"
SOCIALACCOUNT_ADAPTER = "apps.accounts.adapters.CustomSocialAccountAdapter"
# Social account provider settings
SOCIALACCOUNT_PROVIDERS = {
"google": {
"SCOPE": [
"profile",
"email",
],
"AUTH_PARAMS": {"access_type": "online"},
},
"discord": {
"SCOPE": ["identify", "email"],
"OAUTH_PKCE_ENABLED": True,
},
}
# Additional social account settings
SOCIALACCOUNT_LOGIN_ON_GET = True
SOCIALACCOUNT_AUTO_SIGNUP = False
SOCIALACCOUNT_STORE_TOKENS = True
# =============================================================================
# Celery Configuration
# =============================================================================
# Celery task queue settings (actual Celery config is in config/celery.py)
CELERY_BROKER_URL = config("REDIS_URL", default="redis://localhost:6379/1")
CELERY_RESULT_BACKEND = config("REDIS_URL", default="redis://localhost:6379/1")
# Task settings for test environments
CELERY_TASK_ALWAYS_EAGER = config(
"CELERY_TASK_ALWAYS_EAGER", default=False, cast=bool
)
CELERY_TASK_EAGER_PROPAGATES = config(
"CELERY_TASK_EAGER_PROPAGATES", default=False, cast=bool
)
# =============================================================================
# Health Check Configuration
# =============================================================================
# https://django-health-check.readthedocs.io/
HEALTH_CHECK = {
"DISK_USAGE_MAX": config("HEALTH_CHECK_DISK_USAGE_MAX", default=90, cast=int),
"MEMORY_MIN": config("HEALTH_CHECK_MEMORY_MIN", default=100, cast=int),
}
# Custom health check backends
HEALTH_CHECK_BACKENDS = [
"health_check.db",
"health_check.cache",
"health_check.storage",
"core.health_checks.custom_checks.CacheHealthCheck",
"core.health_checks.custom_checks.DatabasePerformanceCheck",
"core.health_checks.custom_checks.ApplicationHealthCheck",
"core.health_checks.custom_checks.ExternalServiceHealthCheck",
"core.health_checks.custom_checks.DiskSpaceHealthCheck",
]
# =============================================================================
# Tailwind CSS Configuration
# =============================================================================
# https://django-tailwind.readthedocs.io/
TAILWIND_CLI_CONFIG_FILE = "tailwind.config.js"
TAILWIND_CLI_SRC_CSS = "static/css/src/input.css"
TAILWIND_CLI_DIST_CSS = "css/tailwind.css"
# =============================================================================
# Cloudflare Images Configuration
# =============================================================================
# https://developers.cloudflare.com/images/
CLOUDFLARE_IMAGES = {
"ACCOUNT_ID": config("CLOUDFLARE_IMAGES_ACCOUNT_ID", default=""),
"API_TOKEN": config("CLOUDFLARE_IMAGES_API_TOKEN", default=""),
"ACCOUNT_HASH": config("CLOUDFLARE_IMAGES_ACCOUNT_HASH", default=""),
# Optional settings
"DEFAULT_VARIANT": config("CLOUDFLARE_IMAGES_DEFAULT_VARIANT", default="public"),
"UPLOAD_TIMEOUT": config("CLOUDFLARE_IMAGES_UPLOAD_TIMEOUT", default=300, cast=int),
"WEBHOOK_SECRET": config("CLOUDFLARE_IMAGES_WEBHOOK_SECRET", default=""),
"CLEANUP_EXPIRED_HOURS": config(
"CLOUDFLARE_IMAGES_CLEANUP_HOURS", default=24, cast=int
),
"MAX_FILE_SIZE": config(
"CLOUDFLARE_IMAGES_MAX_FILE_SIZE", default=10 * 1024 * 1024, cast=int
),
"ALLOWED_FORMATS": ["jpeg", "png", "gif", "webp"],
"REQUIRE_SIGNED_URLS": config(
"CLOUDFLARE_IMAGES_REQUIRE_SIGNED_URLS", default=False, cast=bool
),
"DEFAULT_METADATA": {},
}
# =============================================================================
# Road Trip Service Configuration
# =============================================================================
# Settings for the road trip planning service using OpenStreetMap
ROADTRIP_CACHE_TIMEOUT = config(
"ROADTRIP_CACHE_TIMEOUT", default=3600 * 24, cast=int
) # 24 hours for geocoding
ROADTRIP_ROUTE_CACHE_TIMEOUT = config(
"ROADTRIP_ROUTE_CACHE_TIMEOUT", default=3600 * 6, cast=int
) # 6 hours for routes
ROADTRIP_MAX_REQUESTS_PER_SECOND = config(
"ROADTRIP_MAX_REQUESTS_PER_SECOND", default=1, cast=int
) # Respect OSM rate limits
ROADTRIP_USER_AGENT = config(
"ROADTRIP_USER_AGENT", default="ThrillWiki/1.0 (https://thrillwiki.com)"
)
ROADTRIP_REQUEST_TIMEOUT = config(
"ROADTRIP_REQUEST_TIMEOUT", default=10, cast=int
) # seconds
ROADTRIP_MAX_RETRIES = config("ROADTRIP_MAX_RETRIES", default=3, cast=int)
ROADTRIP_BACKOFF_FACTOR = config("ROADTRIP_BACKOFF_FACTOR", default=2, cast=int)
# =============================================================================
# Autocomplete Configuration
# =============================================================================
# django-autocomplete-light settings
AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = config(
"AUTOCOMPLETE_BLOCK_UNAUTHENTICATED", default=False, cast=bool
)
# =============================================================================
# Frontend Configuration
# =============================================================================
FRONTEND_DOMAIN = config("FRONTEND_DOMAIN", default="https://thrillwiki.com")

View File

@@ -0,0 +1,430 @@
"""
Environment variable validation for thrillwiki project.
This module validates environment variables on Django startup to catch
configuration errors early. It checks:
- Required variables are set
- Values have correct types
- Values are within valid ranges
- URLs are properly formatted
- Cross-variable dependencies are satisfied
Why python-decouple?
- Already used across the project for consistency
- Provides type casting and default values
- Supports .env files and environment variables
"""
import logging
import re
import warnings
from typing import Any, Callable, Optional
from urllib.parse import urlparse
from decouple import config, UndefinedValueError
logger = logging.getLogger("thrillwiki")
# =============================================================================
# Validation Rules
# =============================================================================
# Required environment variables with their validation rules
REQUIRED_VARIABLES = {
"SECRET_KEY": {
"type": str,
"min_length": 50,
"description": "Django secret key for cryptographic signing",
},
"DATABASE_URL": {
"type": str,
"validator": "url",
"description": "Database connection URL",
},
}
# Optional variables that should be validated if present
OPTIONAL_VARIABLES = {
"DEBUG": {
"type": bool,
"default": True,
"description": "Debug mode flag",
},
"ALLOWED_HOSTS": {
"type": str,
"description": "Comma-separated list of allowed hosts",
},
"REDIS_URL": {
"type": str,
"validator": "url",
"description": "Redis connection URL",
},
"EMAIL_PORT": {
"type": int,
"min_value": 1,
"max_value": 65535,
"description": "SMTP server port",
},
"CACHE_MIDDLEWARE_SECONDS": {
"type": int,
"min_value": 0,
"max_value": 86400,
"description": "Cache timeout in seconds",
},
"API_RATE_LIMIT_PER_MINUTE": {
"type": int,
"min_value": 1,
"max_value": 10000,
"description": "API rate limit per minute",
},
"API_RATE_LIMIT_PER_HOUR": {
"type": int,
"min_value": 1,
"max_value": 100000,
"description": "API rate limit per hour",
},
"SECURE_HSTS_SECONDS": {
"type": int,
"min_value": 0,
"max_value": 31536000 * 2, # Max 2 years
"description": "HSTS max-age in seconds",
},
"SESSION_COOKIE_AGE": {
"type": int,
"min_value": 60,
"max_value": 86400 * 365, # Max 1 year
"description": "Session cookie age in seconds",
},
"JWT_ACCESS_TOKEN_LIFETIME_MINUTES": {
"type": int,
"min_value": 1,
"max_value": 1440, # Max 24 hours
"description": "JWT access token lifetime in minutes",
},
"JWT_REFRESH_TOKEN_LIFETIME_DAYS": {
"type": int,
"min_value": 1,
"max_value": 365,
"description": "JWT refresh token lifetime in days",
},
"SENTRY_TRACES_SAMPLE_RATE": {
"type": float,
"min_value": 0.0,
"max_value": 1.0,
"description": "Sentry trace sampling rate",
},
}
# Cross-variable validation rules
CROSS_VARIABLE_RULES = [
{
"name": "production_security",
"condition": lambda: config("DEBUG", default=True, cast=bool) is False,
"requirements": [
("SECRET_KEY", lambda v: len(v) >= 50, "must be at least 50 characters"),
("ALLOWED_HOSTS", lambda v: v and v.strip(), "must be set in production"),
],
"description": "Production security requirements",
},
{
"name": "ssl_configuration",
"condition": lambda: config("SECURE_SSL_REDIRECT", default=False, cast=bool),
"requirements": [
("SESSION_COOKIE_SECURE", lambda v: v, "should be True with SSL redirect"),
("CSRF_COOKIE_SECURE", lambda v: v, "should be True with SSL redirect"),
],
"description": "SSL configuration consistency",
},
]
# =============================================================================
# Validation Functions
# =============================================================================
def validate_url(value: str) -> bool:
"""Validate that a value is a valid URL."""
try:
result = urlparse(value)
return all([result.scheme, result.netloc])
except Exception:
return False
def validate_email(value: str) -> bool:
"""Validate that a value is a valid email address."""
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
return bool(re.match(email_pattern, value))
def validate_type(value: Any, expected_type: type) -> bool:
"""Validate that a value is of the expected type."""
if expected_type == bool:
# Special handling for boolean strings
return isinstance(value, bool) or str(value).lower() in (
"true", "false", "1", "0", "yes", "no"
)
return isinstance(value, expected_type)
def validate_range(
value: Any,
min_value: Optional[Any] = None,
max_value: Optional[Any] = None
) -> bool:
"""Validate that a value is within a specified range."""
if min_value is not None and value < min_value:
return False
if max_value is not None and value > max_value:
return False
return True
def validate_length(value: str, min_length: int = 0, max_length: int = None) -> bool:
"""Validate that a string value meets length requirements."""
if len(value) < min_length:
return False
if max_length is not None and len(value) > max_length:
return False
return True
VALIDATORS = {
"url": validate_url,
"email": validate_email,
}
# =============================================================================
# Main Validation Functions
# =============================================================================
def validate_variable(name: str, rules: dict) -> list[str]:
"""
Validate a single environment variable against its rules.
Args:
name: Environment variable name
rules: Validation rules dictionary
Returns:
List of error messages (empty if valid)
"""
errors = []
try:
# Get the value with appropriate type casting
var_type = rules.get("type", str)
default = rules.get("default", None)
if var_type == bool:
value = config(name, default=default, cast=bool)
elif var_type == int:
value = config(name, default=default, cast=int)
elif var_type == float:
value = config(name, default=default, cast=float)
else:
value = config(name, default=default)
except UndefinedValueError:
errors.append(f"{name}: Required variable is not set")
return errors
except ValueError as e:
errors.append(f"{name}: Invalid value - {e}")
return errors
# Type validation
if not validate_type(value, rules.get("type", str)):
errors.append(
f"{name}: Expected type {rules['type'].__name__}, "
f"got {type(value).__name__}"
)
# Length validation (for strings)
if isinstance(value, str):
min_length = rules.get("min_length", 0)
max_length = rules.get("max_length")
if not validate_length(value, min_length, max_length):
errors.append(
f"{name}: Length must be between {min_length} and "
f"{max_length or 'unlimited'}"
)
# Range validation (for numbers)
if isinstance(value, (int, float)):
min_value = rules.get("min_value")
max_value = rules.get("max_value")
if not validate_range(value, min_value, max_value):
errors.append(
f"{name}: Value must be between {min_value} and {max_value}"
)
# Custom validator
validator_name = rules.get("validator")
if validator_name and validator_name in VALIDATORS:
if not VALIDATORS[validator_name](value):
errors.append(f"{name}: Failed {validator_name} validation")
return errors
def validate_cross_rules() -> list[str]:
"""
Validate cross-variable dependencies.
Returns:
List of error/warning messages
"""
errors = []
for rule in CROSS_VARIABLE_RULES:
try:
# Check if the condition applies
if not rule["condition"]():
continue
# Check each requirement
for var_name, check_fn, message in rule["requirements"]:
try:
value = config(var_name, default=None)
if value is not None and not check_fn(value):
errors.append(
f"{rule['name']}: {var_name} {message}"
)
except Exception:
errors.append(
f"{rule['name']}: Could not validate {var_name}"
)
except Exception as e:
errors.append(f"Cross-validation error for {rule['name']}: {e}")
return errors
def validate_all_settings(raise_on_error: bool = False) -> dict:
"""
Validate all environment variables.
Args:
raise_on_error: If True, raise ValueError on first error
Returns:
Dictionary with 'errors' and 'warnings' lists
"""
result = {
"errors": [],
"warnings": [],
"valid": True,
}
# Validate required variables
for name, rules in REQUIRED_VARIABLES.items():
errors = validate_variable(name, rules)
result["errors"].extend(errors)
# Validate optional variables (if set)
for name, rules in OPTIONAL_VARIABLES.items():
try:
# Only validate if the variable is set
config(name)
errors = validate_variable(name, rules)
result["warnings"].extend(errors) # Warnings for optional vars
except UndefinedValueError:
pass # Optional variable not set, that's fine
# Validate cross-variable rules
cross_errors = validate_cross_rules()
result["warnings"].extend(cross_errors)
# Set validity
result["valid"] = len(result["errors"]) == 0
# Handle errors
if result["errors"]:
for error in result["errors"]:
logger.error(f"Configuration error: {error}")
if raise_on_error:
raise ValueError(
f"Configuration validation failed: {result['errors']}"
)
# Log warnings
for warning in result["warnings"]:
logger.warning(f"Configuration warning: {warning}")
return result
def run_startup_validation() -> None:
"""
Run configuration validation on application startup.
This function should be called during Django initialization
to catch configuration errors early.
"""
debug_mode = config("DEBUG", default=True, cast=bool)
result = validate_all_settings(raise_on_error=not debug_mode)
if result["valid"]:
logger.info("Configuration validation passed")
else:
if debug_mode:
for error in result["errors"]:
warnings.warn(f"Configuration error: {error}")
else:
raise ValueError(
"Configuration validation failed. Check logs for details."
)
# =============================================================================
# Django Management Command Support
# =============================================================================
def get_validation_report() -> str:
"""
Generate a detailed validation report.
Returns:
Formatted string report
"""
result = validate_all_settings(raise_on_error=False)
lines = ["=" * 60]
lines.append("Configuration Validation Report")
lines.append("=" * 60)
lines.append("")
if result["valid"]:
lines.append("Status: PASSED")
else:
lines.append("Status: FAILED")
lines.append("")
lines.append(f"Errors: {len(result['errors'])}")
lines.append(f"Warnings: {len(result['warnings'])}")
lines.append("")
if result["errors"]:
lines.append("-" * 40)
lines.append("Errors:")
for error in result["errors"]:
lines.append(f" - {error}")
lines.append("")
if result["warnings"]:
lines.append("-" * 40)
lines.append("Warnings:")
for warning in result["warnings"]:
lines.append(f" - {warning}")
lines.append("")
lines.append("=" * 60)
return "\n".join(lines)