"""
Django settings for coalition project.
Generated by 'django-admin startproject' using Django 5.2.1.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
"""
import json
import os
import sys
from pathlib import Path
from urllib.parse import quote
import dj_database_url
import requests
# Build paths inside the project like this: BASE_DIR / 'subdir'.
[docs]
BASE_DIR = Path(__file__).resolve().parent.parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
[docs]
SECRET_KEY = os.getenv(
"SECRET_KEY",
"django-insecure-=lvqp2vsu5)=!t*_qzm3%h%7btagcgw1#cj^sut9f@95^vbclv",
)
# SECURITY WARNING: don't run with debug turned on in production!
[docs]
DEBUG = os.getenv("DEBUG", "True").lower() in ("true", "1", "t")
# Parse ALLOWED_HOSTS from environment variable
# Support both JSON array format and comma-separated string format
[docs]
allowed_hosts_env = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1")
try:
# Try to parse as JSON array first
[docs]
allowed_hosts_list = json.loads(allowed_hosts_env)
except (json.JSONDecodeError, ValueError):
# Fall back to comma-separated string
allowed_hosts_list = [host.strip() for host in allowed_hosts_env.split(",")]
# Use set to eliminate duplicates
[docs]
allowed_hosts_set = set(allowed_hosts_list)
# Add testserver for Django tests
if "test" in sys.argv or "testserver" not in allowed_hosts_set:
allowed_hosts_set.add("testserver")
# Add internal service hostnames for Docker/ECS communication
# These are used by containers to communicate with each other
if os.getenv("ENVIRONMENT", "local") in ("local", "docker", "development"):
[docs]
internal_hosts = ["api", "nginx", "ssr"]
for host in internal_hosts:
allowed_hosts_set.add(host)
# For Elastic Container Service (ECS) deployments, get the internal IP
# address from the EC2 instance's metadata and add it to ALLOWED_HOSTS.
# This prevents health checks from failing due to disallowed host.
# See: https://stackoverflow.com/a/58595305/1143466
if metadata_uri := os.getenv("ECS_CONTAINER_METADATA_URI"):
container_ip_address = container_metadata["Networks"][0]["IPv4Addresses"][0]
allowed_hosts_set.add(container_ip_address)
# Convert set to list once at the end
[docs]
ALLOWED_HOSTS = list(allowed_hosts_set)
# CSRF Protection Configuration
# Define trusted origins for CSRF token validation
# This should include all domains that will make requests to the Django API
CSRF_TRUSTED_ORIGINS = []
# Parse from environment variable (comma-separated URLs with protocols)
[docs]
csrf_origins_env = os.getenv("CSRF_TRUSTED_ORIGINS", "")
if csrf_origins_env:
[docs]
CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in csrf_origins_env.split(",")]
# Add default origins for development
if DEBUG:
[docs]
default_origins = [
"http://localhost:3000", # Next.js frontend
"http://127.0.0.1:3000",
"http://localhost:8000", # Django development server
"http://127.0.0.1:8000",
]
for origin in default_origins:
if origin not in CSRF_TRUSTED_ORIGINS:
CSRF_TRUSTED_ORIGINS.append(origin)
# Additional CSRF security settings
[docs]
CSRF_COOKIE_SECURE = not DEBUG # Only send CSRF cookie over HTTPS in production
[docs]
CSRF_COOKIE_HTTPONLY = False # Allow JavaScript access to CSRF token
[docs]
CSRF_COOKIE_SAMESITE = "Lax" # Reasonable default for most applications
[docs]
CSRF_USE_SESSIONS = False # Use cookie-based CSRF tokens (more flexible)
# Security settings for AWS ALB
# Tell Django to trust the X-Forwarded-Proto header from the load balancer
# Use the X-Forwarded-Host header from the load balancer
[docs]
USE_X_FORWARDED_HOST = True
[docs]
USE_X_FORWARDED_PORT = True
[docs]
ORGANIZATION_NAME = os.getenv("ORGANIZATION_NAME", "Coalition Builder")
[docs]
TAGLINE = os.getenv("ORG_TAGLINE", "Building strong advocacy partnerships")
# Application definition
[docs]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_extensions",
"django_ratelimit",
"lockdown",
"storages",
"tinymce",
"coalition.content.apps.ContentConfig",
"coalition.campaigns.apps.CampaignsConfig",
"coalition.legislators.apps.LegislatorsConfig",
"coalition.regions.apps.RegionsConfig",
"coalition.stakeholders",
"coalition.endorsements",
"coalition.legal.apps.LegalConfig",
]
# Configure database table names to maintain backward compatibility
[docs]
DEFAULT_APP_CONFIG = None
[docs]
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose",
"stream": "ext://sys.stdout",
},
},
"root": {
"handlers": ["console"],
"level": "WARNING",
},
"loggers": {
"django.request": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
"django.security": {
"handlers": ["console"],
"level": "WARNING",
"propagate": False,
},
"coalition": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
},
}
[docs]
MIDDLEWARE = [
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.middleware.security.SecurityMiddleware",
"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",
"lockdown.middleware.LockdownMiddleware",
# ETagMiddleware placed after security middleware to process final response
# but before any response compression that might change content
"coalition.core.middleware.etag.ETagMiddleware",
]
[docs]
ROOT_URLCONF = "coalition.core.urls"
[docs]
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "templates")], # Add this line
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
[docs]
WSGI_APPLICATION = "coalition.core.wsgi.application"
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
# Use SQLite as a fallback if DATABASE_URL is not set
if os.getenv("DATABASE_URL"):
# Parse DATABASE_URL and ensure PostGIS is used for PostgreSQL
[docs]
db_config = dj_database_url.config(default=quote(os.getenv("DATABASE_URL", "")))
# If using PostgreSQL, make sure to use the PostGIS backend
if db_config.get("ENGINE") == "django.db.backends.postgresql":
db_config["ENGINE"] = "django.contrib.gis.db.backends.postgis"
# For tests, use admin user to create test databases with PostGIS extension
if "test" in sys.argv:
# Use admin credentials for test database creation
db_config.update(
{
"USER": "coalition_admin",
"PASSWORD": "admin_password",
},
)
DATABASES = {
"default": db_config,
}
else:
# Use SpatiaLite for GeoDjango support
DATABASES = {
"default": {
"ENGINE": "django.contrib.gis.db.backends.spatialite",
"NAME": BASE_DIR / "db.sqlite3",
},
}
# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
[docs]
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": (
"django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
),
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
[docs]
LANGUAGE_CODE = "en-us"
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
# Static files URL - use CloudFront CDN when available
[docs]
CLOUDFRONT_DOMAIN = os.getenv("CLOUDFRONT_DOMAIN")
[docs]
STATIC_URL = f"https://{CLOUDFRONT_DOMAIN}/static/" if CLOUDFRONT_DOMAIN else "/static/"
[docs]
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
# Static files directories - where Django will look for static files during development
[docs]
STATICFILES_DIRS = [
os.path.join(BASE_DIR, "static"),
]
# Add frontend build directory for local development
# Check if we're running in Docker (has mounted frontend build) or locally
[docs]
frontend_build_static = os.path.join(BASE_DIR.parent, "frontend", "build", "static")
[docs]
docker_frontend_build_static = "/app/frontend/build/static"
if os.path.exists(docker_frontend_build_static):
# Running in Docker container with mounted frontend build
STATICFILES_DIRS.append(docker_frontend_build_static)
elif os.path.exists(frontend_build_static):
# Running locally with frontend build in parent directory
STATICFILES_DIRS.append(frontend_build_static)
# Additional static file finder configuration
[docs]
STATICFILES_FINDERS = [
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
]
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
[docs]
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Email configuration
# https://docs.djangoproject.com/en/5.2/topics/email/
if DEBUG:
# Development: Log emails to console
[docs]
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
else:
# Production: Use SMTP
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = os.getenv("EMAIL_HOST", "smtp.gmail.com")
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "True").lower() in ("true", "1", "t")
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD")
# Default sender for system emails
[docs]
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", CONTACT_EMAIL)
[docs]
SERVER_EMAIL = DEFAULT_FROM_EMAIL
# Site configuration for email links
[docs]
SITE_URL = os.getenv("SITE_URL", "http://localhost:3000") # Frontend URL
[docs]
API_URL = os.getenv("API_URL", "http://localhost:8000") # Backend URL
# Admin notification emails for endorsement system
[docs]
ADMIN_NOTIFICATION_EMAILS = os.getenv("ADMIN_NOTIFICATION_EMAILS", "")
# Endorsement moderation settings
# Default to manual review for better content control in production
# Set AUTO_APPROVE_VERIFIED_ENDORSEMENTS=true in environment to enable auto-approval
[docs]
AUTO_APPROVE_VERIFIED_ENDORSEMENTS = os.getenv(
"AUTO_APPROVE_VERIFIED_ENDORSEMENTS",
"false",
).lower() in ("true", "1", "t")
# Akismet spam detection
[docs]
AKISMET_SECRET_API_KEY = os.getenv("AKISMET_SECRET_API_KEY")
# Geocoding configuration
# Tiger geocoder confidence threshold (lower rating = better accuracy)
# Default: 20 (reasonable confidence for most use cases)
# Range: 0-100, where 0 is exact match and 100 is no match
# Recommended values:
# - Urban areas: 10-15 (stricter matching)
# - Suburban areas: 15-25 (balanced)
# - Rural areas: 20-30 (more lenient)
[docs]
TIGER_GEOCODING_CONFIDENCE_THRESHOLD = int(
os.getenv("TIGER_GEOCODING_CONFIDENCE_THRESHOLD", "20"),
)
# Cache configuration
# Always use Redis cache for consistency across all environments
# This ensures django-ratelimit works properly in all scenarios
[docs]
CACHE_URL = os.getenv("CACHE_URL", "redis://redis:6379/1")
# Use locmem cache during tests and disable ratelimit checks
if "test" in sys.argv:
[docs]
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "test-cache",
},
}
# Disable django-ratelimit system checks during tests
SILENCED_SYSTEM_CHECKS = ["django_ratelimit.E003", "django_ratelimit.W001"]
else:
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": CACHE_URL,
},
}
# TinyMCE Configuration
[docs]
TINYMCE_DEFAULT_CONFIG = {
"theme": "silver",
"height": 300,
"menubar": False,
"plugins": [
"advlist",
"autolink",
"lists",
"link",
"image",
"charmap",
"preview",
"anchor",
"searchreplace",
"visualblocks",
"code",
"fullscreen",
"insertdatetime",
"media",
"table",
"help",
"wordcount",
"codesample",
],
"toolbar": (
"undo redo | blocks | bold italic underline strikethrough | "
"alignleft aligncenter alignright alignjustify | "
"bullist numlist outdent indent | link image media | "
"removeformat | code | help"
),
"block_formats": (
"Paragraph=p; Heading 1=h1; Heading 2=h2; Heading 3=h3; "
"Heading 4=h4; Heading 5=h5; Heading 6=h6; "
"Preformatted=pre; Blockquote=blockquote"
),
"image_advtab": True,
"image_caption": True,
"relative_urls": False,
"remove_script_host": True,
"convert_urls": True,
"cleanup_on_startup": True,
"custom_undo_redo_levels": 20,
"entity_encoding": "raw",
"content_style": """
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
font-size: 14px;
line-height: 1.6;
}
""",
# Allow SVG and all its child elements (except use for security)
# Also allow target attribute on links for opening in new window
"extended_valid_elements": (
"svg[*],path[*],g[*],circle[*],rect[*],line[*],polyline[*],polygon[*],"
"ellipse[*],text[*],tspan[*],defs[*],symbol[*],clipPath[*],"
"mask[*],pattern[*],linearGradient[*],radialGradient[*],stop[*],"
"animate[*],animateTransform[*],"
"a[href|target|rel|title],"
"style[type],"
"*[style]"
),
# Don't strip empty SVG elements
"valid_children": "+body[style],+body[svg],+p[svg],+div[svg]",
# Enable target option in link dialog
"link_target_list": [
{"title": "None", "value": ""},
{"title": "Same window", "value": "_self"},
{"title": "New window", "value": "_blank"},
],
# Allow all attributes on links
"link_assume_external_targets": True,
}
# TinyMCE minimal configuration for simple fields
[docs]
TINYMCE_MINIMAL_CONFIG = {
"theme": "silver",
"height": 200,
"menubar": False,
"plugins": ["link", "lists", "autolink"],
"toolbar": "bold italic | bullist numlist | link | removeformat",
"statusbar": False,
# Allow basic SVG elements even in minimal config
"extended_valid_elements": (
"svg[*],path[*],circle[*],rect[*],a[href|target|rel|title],"
"style[type],*[style]"
),
"valid_children": "+body[svg],+p[svg],+div[svg]",
# Enable target option in link dialog
"link_target_list": [
{"title": "None", "value": ""},
{"title": "Same window", "value": "_self"},
{"title": "New window", "value": "_blank"},
],
}
# Site Password Protection (django-lockdown)
[docs]
LOCKDOWN_ENABLED = os.getenv("SITE_PASSWORD_ENABLED", "false").lower() in (
"true",
"1",
"yes",
)
[docs]
LOCKDOWN_PASSWORDS = [os.getenv("SITE_PASSWORD", "changeme")]
[docs]
LOCKDOWN_URL_EXCEPTIONS = [
r"^/health/$", # Django health check
r"^/health$", # Next.js health check
r"^/admin/", # Django admin (has its own auth)
r"^/api/", # API endpoints
]
# Session-based lockdown (user stays logged in)
[docs]
LOCKDOWN_SESSION_LOCKDOWN = True
# File Storage Configuration (django-storages with S3)
[docs]
STORAGES = {
"default": {
"BACKEND": "coalition.core.storage.MediaStorage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
# AWS S3 Configuration
[docs]
AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME", "")
[docs]
AWS_S3_REGION_NAME = os.getenv("AWS_REGION", "us-east-1")
# Explicitly tell django-storages to use the default credential chain
# This is important for ECS task roles
[docs]
AWS_S3_ACCESS_KEY_ID = None
[docs]
AWS_S3_SECRET_ACCESS_KEY = None
[docs]
AWS_S3_SESSION_TOKEN = None
# Force boto3 to use the container credentials provider
# This helps when running in ECS/Fargate
[docs]
AWS_S3_SIGNATURE_VERSION = "s3v4"
# Use CloudFront domain for generating URLs when available
if CLOUDFRONT_DOMAIN:
[docs]
AWS_S3_CUSTOM_DOMAIN = CLOUDFRONT_DOMAIN
else:
AWS_S3_CUSTOM_DOMAIN = f"{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com"
# S3 File Settings
[docs]
AWS_S3_OBJECT_PARAMETERS = {
"CacheControl": "max-age=86400", # Cache for 1 day
}
[docs]
AWS_S3_FILE_OVERWRITE = False # Don't overwrite files with same name
[docs]
AWS_DEFAULT_ACL = "public-read" # Make uploaded files publicly readable
[docs]
AWS_S3_VERIFY_SSL = True
# Media files configuration
# Use CloudFront CDN when available for better performance and security
if CLOUDFRONT_DOMAIN:
else:
MEDIA_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/media/"