385 lines
14 KiB
Python
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' |