nexus-5/config/settings.py
2026-01-26 11:09:40 -05:00

385 lines
14 KiB
Python

import os
from pathlib import Path
import dotenv
SITE_NAME = "Nexus v5"
DISPATCH_TEAM_PROFILE_ID = os.getenv('DISPATCH_TEAM_PROFILE_ID')
# --- Security: Oathkeeper Verification ---
OATHKEEPER_SECRET = os.getenv('OATHKEEPER_SECRET')
# --- AI Chat: Anthropic Claude API ---
ANTHROPIC_API_KEY = os.getenv('ANTHROPIC_API_KEY', '')
ANTHROPIC_MODEL = os.getenv('ANTHROPIC_MODEL', 'claude-sonnet-4-20250514')
# --- Initial Setup ---
dotenv.load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.getenv('SECRET_KEY')
DEBUG = os.getenv('DEBUG', 'False').lower() in ('true', '1', 't')
ALLOWED_HOSTS = ['*']
# --- Unified Redis/Valkey Configuration ---
REDIS_HOST = os.getenv('REDIS_HOST')
REDIS_PORT = os.getenv('REDIS_PORT')
REDIS_USERNAME = os.getenv('REDIS_USERNAME', '')
REDIS_PASSWORD = os.getenv('REDIS_PASSWORD')
REDIS_CLUSTER_MODE = os.getenv('REDIS_CLUSTER_MODE', 'False').lower() in ('true', '1', 't')
# ACL auth format: username:password@ (username required for Valkey ACL)
REDIS_AUTH = f"{REDIS_USERNAME}:{REDIS_PASSWORD}@" if REDIS_PASSWORD else ""
# Sentinel configuration (for HA failover)
# Format: "host1:port1,host2:port2,host3:port3"
REDIS_SENTINEL_HOSTS = os.getenv('REDIS_SENTINEL_HOSTS', '')
REDIS_SENTINEL_MASTER = os.getenv('REDIS_SENTINEL_MASTER', 'valkey-ha')
REDIS_SENTINEL_PASSWORD = os.getenv('REDIS_SENTINEL_PASSWORD', '') # Sentinel auth
REDIS_SENTINEL_MODE = bool(REDIS_SENTINEL_HOSTS)
# Parse sentinel hosts into list of tuples [(host, port), ...]
REDIS_SENTINELS = []
if REDIS_SENTINEL_MODE:
REDIS_SENTINELS = [
(h.split(':')[0], int(h.split(':')[1]))
for h in REDIS_SENTINEL_HOSTS.split(',')
]
# --- Django Applications & Middleware ---
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'corsheaders',
'daphne',
'django.contrib.staticfiles',
'django.contrib.postgres',
'core.apps.CoreConfig',
'channels',
'strawberry_django',
'rest_framework',
'storages',
]
MIDDLEWARE = [
'core.middleware.ConditionalCorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'core.middleware.OryHeaderAuthenticationMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
CORS_ALLOWED_ORIGINS = [
"http://localhost:5173",
"https://app.example.com",
]
CORS_ALLOWED_ORIGIN_REGEXES = [
# Regex to allow any origin on the 192.168.100.x subnet
r"^https?://192\.168\.100\.\d{1,3}(:\d+)?$",
]
# CORS credentials support for cookie-based auth
CORS_ALLOW_CREDENTIALS = True
# Allow common headers for GraphQL
CORS_ALLOW_HEADERS = [
'accept',
'accept-encoding',
'authorization',
'content-type',
'dnt',
'origin',
'user-agent',
'x-csrftoken',
'x-requested-with',
]
CSRF_TRUSTED_ORIGINS = [
"https://api.example.com",
"https://app.example.com",
"https://local.example.com:5173"
]
# --- Channels & ASGI ---
ASGI_APPLICATION = 'config.asgi.application'
if REDIS_SENTINEL_MODE:
# Sentinel mode: use master discovery for HA failover
_sentinel_host_config = {
"sentinels": REDIS_SENTINELS,
"master_name": REDIS_SENTINEL_MASTER,
"password": REDIS_PASSWORD,
"username": REDIS_USERNAME,
"db": 0,
}
if REDIS_SENTINEL_PASSWORD:
_sentinel_host_config["sentinel_kwargs"] = {"password": REDIS_SENTINEL_PASSWORD}
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_valkey.core.ValkeyChannelLayer',
'CONFIG': {
"hosts": [_sentinel_host_config],
"prefix": "nexus:channels",
},
},
}
elif REDIS_CLUSTER_MODE:
# Use sharded pubsub for cluster mode
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_valkey.pubsub.ValkeyPubSubChannelLayer',
'CONFIG': {
"hosts": [f"valkey://{REDIS_AUTH}{REDIS_HOST}:{REDIS_PORT}/0"],
"prefix": "nexus:channels",
},
},
}
else:
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_valkey.core.ValkeyChannelLayer',
'CONFIG': {
"hosts": [f"valkey://{REDIS_AUTH}{REDIS_HOST}:{REDIS_PORT}/0"],
"prefix": "nexus:channels",
},
},
}
# --- Framework Settings ---
STRAWBERRY_DJANGO = {
'FIELD_DESCRIPTION_FROM_HELP_TEXT': True,
'TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING': True,
'MUTATIONS_DEFAULT_HANDLE_ERRORS': True,
}
# --- Security Settings ---
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# --- Core Django Settings ---
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'APP_DIRS': True,
'DIRS': [BASE_DIR / 'templates'],
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
# --- Databases & Caches ---
DATABASES = {
'default': {
'ENGINE': 'config.db_backend', # Custom backend for Vault credential reloading
'NAME': os.getenv('DB_NAME'),
'HOST': os.getenv('DB_HOST'),
'PORT': os.getenv('DB_PORT'),
'USER': os.environ.get('DB_USER'), # Fallback for local dev
'PASSWORD': os.environ.get('DB_PASSWORD'), # Fallback for local dev
'CONN_MAX_AGE': 600, # Keep connections for 10 minutes
'CONN_HEALTH_CHECKS': True, # Verify connections before reuse
},
'admin': {
'ENGINE': 'config.db_backend', # Custom backend for Vault credential reloading
'NAME': os.getenv('DB_NAME'),
'HOST': os.getenv('DB_HOST'),
'PORT': os.getenv('DB_PORT'),
'USER': os.environ.get('DB_ADMIN_USER'), # Fallback for local dev
'PASSWORD': os.environ.get('DB_ADMIN_PASSWORD'), # Fallback for local dev
'CONN_MAX_AGE': 600, # Keep connections for 10 minutes
'CONN_HEALTH_CHECKS': True, # Verify connections before reuse
}
}
if REDIS_SENTINEL_MODE:
# Sentinel mode: use django-valkey with SentinelClient for HA failover
_valkey_connection_kwargs = {"password": REDIS_PASSWORD}
if REDIS_USERNAME:
_valkey_connection_kwargs["username"] = REDIS_USERNAME
CACHES = {
"default": {
"BACKEND": "django_valkey.cache.ValkeyCache",
"LOCATION": f"valkey://{REDIS_SENTINEL_MASTER}/0",
"KEY_PREFIX": "nexus:cache",
"OPTIONS": {
"CLIENT_CLASS": "django_valkey.client.SentinelClient",
"SENTINELS": REDIS_SENTINELS,
"CONNECTION_POOL_CLASS": "valkey.sentinel.SentinelConnectionPool",
"CONNECTION_POOL_CLASS_KWARGS": _valkey_connection_kwargs,
"SENTINEL_KWARGS": {"password": REDIS_SENTINEL_PASSWORD} if REDIS_SENTINEL_PASSWORD else {},
},
}
}
elif REDIS_CLUSTER_MODE:
CACHES = {
"default": {
"BACKEND": "django_valkey.cache.ValkeyCache",
"LOCATION": f"valkey://{REDIS_AUTH}{REDIS_HOST}:{REDIS_PORT}/0",
"KEY_PREFIX": "nexus:cache",
"OPTIONS": {
"CLIENT_CLASS": "django_valkey.client.DefaultClient",
"VALKEY_CLIENT_CLASS": "valkey.cluster.ValkeyCluster",
"VALKEY_CLIENT_KWARGS": {
"skip_full_coverage_check": True,
},
},
}
}
else:
CACHES = {
"default": {
"BACKEND": "django_valkey.cache.ValkeyCache",
"LOCATION": f"valkey://{REDIS_AUTH}{REDIS_HOST}:{REDIS_PORT}/0",
"KEY_PREFIX": "nexus:cache",
"OPTIONS": {
"CLIENT_CLASS": "django_valkey.client.DefaultClient",
},
}
}
# --- Celery Configuration ---
# All Redis usage on /0 with key prefixes for namespace isolation
if REDIS_SENTINEL_MODE:
# Sentinel mode: use master discovery for HA failover
# Format: sentinel://user:pass@host1:port/db;sentinel://user:pass@host2:port/db;...
# Each sentinel URL must include full credentials (for master connection after discovery)
if REDIS_USERNAME and REDIS_PASSWORD:
sentinel_urls = ';'.join([
f"sentinel://{REDIS_USERNAME}:{REDIS_PASSWORD}@{h}:{p}/0"
for h, p in REDIS_SENTINELS
])
elif REDIS_PASSWORD:
sentinel_urls = ';'.join([
f"sentinel://:{REDIS_PASSWORD}@{h}:{p}/0"
for h, p in REDIS_SENTINELS
])
else:
sentinel_urls = ';'.join([
f"sentinel://{h}:{p}/0"
for h, p in REDIS_SENTINELS
])
CELERY_BROKER_URL = sentinel_urls
# Use custom backend class that fixes Celery's missing 'username' param for ACL auth
CELERY_RESULT_BACKEND = f"config.celery.FixedSentinelBackend+{sentinel_urls}"
CELERY_BROKER_TRANSPORT_OPTIONS = {
'master_name': REDIS_SENTINEL_MASTER,
'global_keyprefix': 'nexus:celery:',
}
CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS = {
'master_name': REDIS_SENTINEL_MASTER,
'global_keyprefix': 'nexus:celery:',
}
# Sentinel authentication (if Sentinel itself requires auth, separate from master)
if REDIS_SENTINEL_PASSWORD:
CELERY_BROKER_TRANSPORT_OPTIONS['sentinel_kwargs'] = {'password': REDIS_SENTINEL_PASSWORD}
CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS['sentinel_kwargs'] = {'password': REDIS_SENTINEL_PASSWORD}
elif REDIS_CLUSTER_MODE:
# Celery 5.3+ supports cluster mode natively
CELERY_BROKER_URL = f"redis://{REDIS_AUTH}{REDIS_HOST}:{REDIS_PORT}/0"
CELERY_RESULT_BACKEND = f"redis://{REDIS_AUTH}{REDIS_HOST}:{REDIS_PORT}/0"
CELERY_BROKER_TRANSPORT_OPTIONS = {
'global_keyprefix': 'nexus:celery:',
'fanout_prefix': True,
'fanout_patterns': True,
}
CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS = {'global_keyprefix': 'nexus:celery:'}
CELERY_BROKER_USE_SSL = False
CELERY_REDIS_BACKEND_USE_CLUSTER = True
else:
CELERY_BROKER_URL = f"redis://{REDIS_AUTH}{REDIS_HOST}:{REDIS_PORT}/0"
CELERY_RESULT_BACKEND = f"redis://{REDIS_AUTH}{REDIS_HOST}:{REDIS_PORT}/0"
CELERY_BROKER_TRANSPORT_OPTIONS = {'global_keyprefix': 'nexus:celery:'}
CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS = {'global_keyprefix': 'nexus:celery:'}
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = 'America/New_York'
CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 minutes
CELERY_TASK_SOFT_TIME_LIMIT = 25 * 60 # 25 minutes
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
# Celery Beat Schedule (periodic tasks)
from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = {
'cleanup-old-events': {
'task': 'core.tasks.event_cleanup.cleanup_old_events',
'schedule': crontab(hour=2, minute=0), # Run daily at 2 AM Eastern
},
'monitoring-incomplete-work-reminder': {
'task': 'core.tasks.monitoring.run_monitoring_command',
'schedule': crontab(hour=8, minute=0), # 8 AM Eastern
'args': ['incomplete_work_reminder'],
},
'monitoring-nightly-assignments': {
'task': 'core.tasks.monitoring.run_monitoring_command',
'schedule': crontab(hour=18, minute=0), # 6 PM Eastern
'args': ['nightly_assignments'],
},
}
# --- Emailer Microservice Configuration ---
# Emailer is a Rust-based REST API for sending emails via Gmail API
EMAILER_BASE_URL = os.getenv('EMAILER_BASE_URL', 'https://email.example.com')
EMAILER_API_KEY = os.getenv('EMAILER_API_KEY', '')
EMAILER_DEFAULT_SENDER = os.getenv('EMAILER_DEFAULT_SENDER', 'noreply@example.com')
# --- Security & Static Files ---
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'},
]
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
# --- Media Files & File Upload ---
MEDIA_URL = '/api/media/'
# S3 Storage Configuration (Garage S3-compatible cluster)
# boto3/django-storages use AWS_* naming convention but connect to Garage
AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY')
AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME', 'nexus-media')
AWS_S3_ENDPOINT_URL = os.getenv('AWS_S3_ENDPOINT_URL', 'http://10.10.10.39:3900')
AWS_S3_REGION_NAME = 'garage' # Garage ignores this but boto3 requires it
AWS_DEFAULT_ACL = None # Use bucket default
AWS_QUERYSTRING_AUTH = False # Nginx handles auth, not pre-signed URLs
AWS_S3_FILE_OVERWRITE = False # Preserve unique filenames
# Legacy MEDIA_ROOT for local dev fallback (not used in production with S3)
MEDIA_ROOT = BASE_DIR / 'media'
# Django 4.2+ STORAGES configuration (replaces deprecated DEFAULT_FILE_STORAGE)
# Uses custom GarageS3Storage that returns nginx-proxied URLs instead of direct S3 URLs
STORAGES = {
"default": {
"BACKEND": "config.storage.GarageS3Storage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
# Increased limits for video uploads (250 MB max)
DATA_UPLOAD_MAX_MEMORY_SIZE = 250 * 1024 * 1024 # 250 MB
FILE_UPLOAD_MAX_MEMORY_SIZE = 250 * 1024 * 1024 # 250 MB
# --- Internationalization ---
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'