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'