bottleneckcrm/backend/crm/settings.py
vickytechkey 2e84de4e85
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 2m5s
feat: add beta deployment workflow and Django settings configuration
2026-11-01 13:07:47 +05:30

766 lines
34 KiB
Python

import logging
import os
import warnings
from datetime import timedelta
from urllib.parse import urlparse
from corsheaders.defaults import default_headers
from dotenv import load_dotenv
from common.public_tokens import RedactAccessLog
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
load_dotenv()
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get(
"SECRET_KEY", "django-insecure-dev-key-please-change-in-production"
)
IS_DEV_ENV = os.environ.get("ENV_TYPE", "dev") == "dev"
if not SECRET_KEY or SECRET_KEY.startswith("django-insecure"):
if not IS_DEV_ENV:
raise ValueError(
"SECRET_KEY must be set to a secure value in non-dev environments"
)
# This key also signs every JWT (see SIMPLE_JWT["SIGNING_KEY"] below). RFC 7518
# section 3.2 requires an HMAC key at least as long as the hash it produces, so
# HS256 needs 32 bytes. PyJWT only warns about a shorter one, on every call, and
# a warning nobody reads is not a control.
#
# Raising here means a deploy fails rather than quietly signing weak tokens.
# Lengthening the key invalidates every access and refresh token already issued,
# so everyone is signed out once when it changes.
JWT_MIN_SIGNING_KEY_BYTES = 32
if len(SECRET_KEY.encode()) < JWT_MIN_SIGNING_KEY_BYTES:
message = (
f"SECRET_KEY is {len(SECRET_KEY.encode())} bytes. It signs every JWT, "
f"and HS256 needs at least {JWT_MIN_SIGNING_KEY_BYTES}. Generate one with "
'`python -c "import secrets; print(secrets.token_urlsafe(48))"`.'
)
if not IS_DEV_ENV:
raise ValueError(message)
warnings.warn(message, RuntimeWarning, stacklevel=2)
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.environ.get("DEBUG", "False").lower() == "true"
# Security: Restrict allowed hosts - set ALLOWED_HOSTS env var in production
_default_hosts = "localhost,127.0.0.1,betaapicsa.tradhox.com,apicsa.tradhox.com,.tradhox.com"
ALLOWED_HOSTS = [
h.strip()
for h in os.environ.get("ALLOWED_HOSTS", _default_hosts).split(",")
if h.strip()
]
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.admin",
"django.contrib.contenttypes",
"django.contrib.messages",
"django.contrib.sessions",
"django.contrib.staticfiles",
"rest_framework",
"rest_framework_simplejwt",
# Required for refresh-token rotation: simplejwt only defines
# RefreshToken.blacklist()/check_blacklist() when this app is installed, so
# without it BLACKLIST_AFTER_ROTATION below is silently a no-op.
"rest_framework_simplejwt.token_blacklist",
"corsheaders",
"django_ses",
"drf_spectacular",
"common",
"accounts",
"cases",
"contacts",
"leads",
"opportunity",
"tasks",
"invoices",
"orders",
"business_hours",
"macros",
"webforms",
"webhooks",
"automation",
"sellers",
"customers",
# "teams", # Merged into common app
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", # CSRF protection
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"corsheaders.middleware.CorsMiddleware",
"crum.CurrentRequestUserMiddleware",
"common.middleware.get_company.GetProfileAndOrg",
"common.middleware.rls_context.RequireOrgContext", # RLS: Enforce org context + set PostgreSQL session variable
# After these, so it queues the request's webhook events while crum still
# holds the request and the RLS context is still set. See webhooks/emit.py.
"webhooks.middleware.WebhookEventsMiddleware",
# Last, so its flush runs before the webhook flush and a rule's own
# changes reach webhooks. See automation/capture.py.
"automation.middleware.AutomationMiddleware",
]
ROOT_URLCONF = "crm.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
# No project-level template directory. Every template lives in its
# owning app's templates/ dir and is found by APP_DIRS, so it ships
# inside the wheel as package data. A BASE_DIR entry here would resolve
# to site-packages/templates on a pip install, where nothing is
# installed, and the magic-link login emails would fail to render.
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"common.context_processors.common.app_name",
# "django_settings_export.settings_export",
],
},
},
]
WSGI_APPLICATION = "crm.wsgi.application"
# Database
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
# Connection pooling (psycopg 3 + psycopg_pool, wired by Django's postgresql
# backend). Off by default so an existing deployment does not silently change
# its connection behaviour on upgrade; set DB_POOL_ENABLED=true to turn it on.
#
# Two things make this more than a performance knob here:
#
# 1. Every ASGI request gets its own thread-sensitive executor and therefore
# its own connection, with no ceiling. That is how two separate incidents
# exhausted PostgreSQL. `max_size` is the ceiling that was missing.
# 2. RLS context lives in a SESSION-scoped GUC, so a reused connection carries
# the previous tenant's org id unless something clears it. `reset` below is
# that something, and pooling MUST NOT be enabled without it. See
# common/rls/pool.py for the full argument.
#
# Sizing: the pool is per PROCESS, not per host. Real peak connections are
# roughly DB_POOL_MAX_SIZE x (uvicorn workers + celery prefork children), and
# docker-compose.yml runs `celery -A crm worker` with no --concurrency, so each
# worker forks one child per CPU. Multiply before comparing to PostgreSQL's
# max_connections (default 100).
DB_POOL_ENABLED = os.environ.get("DB_POOL_ENABLED", "False").lower() == "true"
DB_POOL_MIN_SIZE = int(os.environ.get("DB_POOL_MIN_SIZE", "2"))
DB_POOL_MAX_SIZE = int(os.environ.get("DB_POOL_MAX_SIZE", "10"))
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("CRM_DB_NAME", os.environ.get("DB_NAME", os.environ.get("DBNAME", "crmprofile_beta"))),
"USER": os.environ.get("CRM_DB_USER", os.environ.get("DB_USER", os.environ.get("DBUSER", "vignesh"))),
"PASSWORD": os.environ.get("CRM_DB_PASSWORD", os.environ.get("DB_PASSWORD", os.environ.get("DBPASSWORD", "vignesh_secret_pass_2026"))),
"HOST": os.environ.get("CRM_DB_HOST", os.environ.get("DB_HOST", os.environ.get("DBHOST", "16.113.106.152"))),
"PORT": int(os.environ.get("CRM_DB_PORT", os.environ.get("DB_PORT", os.environ.get("DBPORT", "5432")))),
# Django raises ImproperlyConfigured if this is non-zero alongside a
# pool ("Pooling doesn't support persistent connections"). Keep it 0.
"CONN_MAX_AGE": 0,
"CONN_HEALTH_CHECKS": DB_POOL_ENABLED,
}
}
DATABASES["seller_db"] = {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("SELLER_DB_NAME", "sellerprofile_beta"),
"USER": os.environ.get("SELLER_DB_USER", os.environ.get("CRM_DB_USER", os.environ.get("DB_USER", "vignesh"))),
"PASSWORD": os.environ.get("SELLER_DB_PASSWORD", os.environ.get("CRM_DB_PASSWORD", os.environ.get("DB_PASSWORD", "vignesh_secret_pass_2026"))),
"HOST": os.environ.get("SELLER_DB_HOST", os.environ.get("CRM_DB_HOST", os.environ.get("DB_HOST", "16.113.106.152"))),
"PORT": int(os.environ.get("SELLER_DB_PORT", os.environ.get("CRM_DB_PORT", os.environ.get("DB_PORT", "5432")))),
"CONN_MAX_AGE": 0,
}
DATABASES["customer_db"] = {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("CUSTOMER_DB_NAME", "customerprofile_beta"),
"USER": os.environ.get("CUSTOMER_DB_USER", os.environ.get("CRM_DB_USER", os.environ.get("DB_USER", "vignesh"))),
"PASSWORD": os.environ.get("CUSTOMER_DB_PASSWORD", os.environ.get("CRM_DB_PASSWORD", os.environ.get("DB_PASSWORD", "vignesh_secret_pass_2026"))),
"HOST": os.environ.get("CUSTOMER_DB_HOST", os.environ.get("CRM_DB_HOST", os.environ.get("DB_HOST", "16.113.106.152"))),
"PORT": int(os.environ.get("CUSTOMER_DB_PORT", os.environ.get("CRM_DB_PORT", os.environ.get("DB_PORT", "5432")))),
"CONN_MAX_AGE": 0,
}
DATABASES["product_db"] = {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("PRODUCT_DB_NAME", "productprofile_beta"),
"USER": os.environ.get("PRODUCT_DB_USER", os.environ.get("CRM_DB_USER", os.environ.get("DB_USER", "vignesh"))),
"PASSWORD": os.environ.get("PRODUCT_DB_PASSWORD", os.environ.get("CRM_DB_PASSWORD", os.environ.get("DB_PASSWORD", "vignesh_secret_pass_2026"))),
"HOST": os.environ.get("PRODUCT_DB_HOST", os.environ.get("CRM_DB_HOST", os.environ.get("DB_HOST", "16.113.106.152"))),
"PORT": int(os.environ.get("PRODUCT_DB_PORT", os.environ.get("CRM_DB_PORT", os.environ.get("DB_PORT", "5432")))),
"CONN_MAX_AGE": 0,
}
DATABASE_ROUTERS = ["crm.db_router.CRMDatabaseRouter"]
if DB_POOL_ENABLED:
# Imported lazily and locally: common/rls/pool.py deliberately imports no
# Django, but keeping the import inside the branch means a non-pooled
# deployment never touches it at all.
from common.rls.pool import reset_rls_context
DATABASES["default"]["OPTIONS"] = {
"pool": {
"min_size": DB_POOL_MIN_SIZE,
"max_size": DB_POOL_MAX_SIZE,
# Load-bearing for tenant isolation, not a tidy-up. Removing this
# key turns pooling into a cross-tenant data leak.
"reset": reset_rls_context,
}
}
# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
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/1.10/topics/i18n/
# The fallback day, not the app's day. Each org carries its own `timezone` and
# `GetProfileAndOrg` activates it per request, so this is what gets used only
# where there is no org yet: the auth endpoints, and management commands.
#
# It was "Asia/Kolkata", which made one deployment's home timezone the day
# boundary for every tenant on it. Orgs that existed before `Org.timezone` keep
# that day, assigned to them by `common/migrations/0037_org_timezone.py`, so no
# live tenant's boundaries move.
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Outbound mail is configured through MAILERS. Django 6.1 deprecates the flat
# EMAIL_BACKEND / EMAIL_HOST / ... settings it replaces, and setting any of them
# here warns on every startup, so none of those names may be assigned in this
# module. The environment variables keep their old names so an existing .env
# carries over unchanged.
#
# Connection OPTIONS are built only for the SMTP backend. A mailer configured
# through MAILERS rejects options its backend does not take (InvalidMailer), so
# handing host or port to the console or SES backend would stop every send. The
# SMTP variables are therefore ignored unless EMAIL_BACKEND selects SMTP.
# EMAIL_HOST falls back to "localhost", the default of the deprecated setting:
# a MAILERS SMTP mailer has no default host and fails every send without one.
# EMAIL_PORT and EMAIL_TIMEOUT are left out when empty rather than handed 0, so
# Django picks the port itself: 465 with EMAIL_USE_SSL, 587 with EMAIL_USE_TLS,
# otherwise 25.
_email_backend = os.environ.get(
"EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend"
)
MAILERS = {"default": {"BACKEND": _email_backend}}
if _email_backend == "django.core.mail.backends.smtp.EmailBackend":
MAILERS["default"]["OPTIONS"] = {
"host": os.environ.get("EMAIL_HOST") or "localhost",
"username": os.environ.get("EMAIL_HOST_USER"),
"password": os.environ.get("EMAIL_HOST_PASSWORD"),
"use_tls": os.environ.get("EMAIL_USE_TLS", "False").lower() == "true",
"use_ssl": os.environ.get("EMAIL_USE_SSL", "False").lower() == "true",
}
if os.environ.get("EMAIL_PORT"):
MAILERS["default"]["OPTIONS"]["port"] = int(os.environ["EMAIL_PORT"])
if os.environ.get("EMAIL_TIMEOUT"):
MAILERS["default"]["OPTIONS"]["timeout"] = int(os.environ["EMAIL_TIMEOUT"])
AUTH_USER_MODEL = "common.User"
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
STATIC_URL = "/static/"
# Only when it exists. A source checkout keeps backend/static/ as a place to
# drop files; an installed copy resolves BASE_DIR to site-packages, where there
# is no such directory, and an unconditional entry raises staticfiles.W004 on
# every startup check. A permanent warning is where the next real one hides.
STATICFILES_DIRS = [
path for path in [os.path.join(BASE_DIR, "static")] if os.path.isdir(path)
]
ENV_TYPE = os.environ.get("ENV_TYPE", "dev")
if ENV_TYPE == "dev":
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
MEDIA_URL = "/media/"
elif ENV_TYPE == "prod":
# A star import is the point here: server_settings.py is an operator-supplied
# override file, so its names are not knowable from this side. The old
# suppression said F401 (unused import), which is not the code a star import
# raises, so it suppressed nothing.
from .server_settings import * # noqa: F403
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "noreply@localhost")
ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL", "admin@localhost")
# AWS SES settings (loaded when MAILERS["default"]["BACKEND"] is django_ses.SESBackend)
if "django_ses" in MAILERS["default"]["BACKEND"]:
AWS_SES_REGION_NAME = os.environ.get("AWS_SES_REGION_NAME", "ap-south-1")
AWS_SES_REGION_ENDPOINT = os.environ.get(
"AWS_SES_REGION_ENDPOINT", f"email.{AWS_SES_REGION_NAME}.amazonaws.com"
)
# Uses AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY from env if set;
# otherwise falls back to IAM role credentials.
# celery Tasks
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND = os.environ.get(
"CELERY_RESULT_BACKEND", "redis://localhost:6379/0"
)
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"filters": {
"require_debug_false": {
"()": "django.utils.log.RequireDebugFalse",
},
"require_debug_true": {
"()": "django.utils.log.RequireDebugTrue",
},
# `Not Found: <path>` and `Too Many Requests: <path>` would write a
# calendar feed, survey, invoice or estimate token into the log.
"redact_public_tokens": {
"()": "common.public_tokens.RedactPublicTokens",
},
},
"formatters": {
"django.server": {
"()": "django.utils.log.ServerFormatter",
"format": "[%(server_time)s] %(message)s",
},
"security": {
"format": "%(asctime)s | %(levelname)s | %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"handlers": {
"console": {
"level": "INFO",
"filters": ["require_debug_true"],
"class": "logging.StreamHandler",
},
"console_debug_false": {
"level": "ERROR",
"filters": ["require_debug_false"],
"class": "logging.StreamHandler",
},
"django.server": {
"level": "INFO",
"class": "logging.StreamHandler",
"formatter": "django.server",
},
"mail_admins": {
"level": "ERROR",
"filters": ["require_debug_false"],
"class": "django.utils.log.AdminEmailHandler",
},
"logfile": {
"class": "logging.FileHandler",
"filename": "server.log",
},
"security_audit": {
"class": "logging.FileHandler",
"filename": "security_audit.log",
"formatter": "security",
},
},
"loggers": {
"django": {
"handlers": [
"console",
"console_debug_false",
"logfile",
],
"level": "INFO",
},
# No handlers of its own: records propagate to "django" above, already
# redacted, because a logger's filter runs before propagation.
"django.request": {
"filters": ["redact_public_tokens"],
},
"django.server": {
"handlers": ["django.server"],
"filters": ["redact_public_tokens"],
"level": "INFO",
"propagate": False,
},
"security.audit": {
"handlers": ["security_audit", "console"],
"level": "INFO",
"propagate": False,
},
},
}
# The app server's access log names every path, public-link tokens included.
# uvicorn and gunicorn set up their access loggers before Django loads, and
# naming one in LOGGING above would strip the server's own handler from it, so
# the filter goes onto the existing logger instead. Django's dictConfig leaves
# it there (disable_existing_loggers is False).
for _access_logger in ("uvicorn.access", "gunicorn.access"):
logging.getLogger(_access_logger).addFilter(RedactAccessLog())
APPLICATION_NAME = "bottlecrm"
SETTINGS_EXPORT = ["APPLICATION_NAME"]
REST_FRAMEWORK = {
"EXCEPTION_HANDLER": "rest_framework.views.exception_handler",
"DEFAULT_AUTHENTICATION_CLASSES": (
"common.pat_auth.PATAuthentication",
"rest_framework_simplejwt.authentication.JWTAuthentication",
"common.external_auth.APIKeyAuthentication",
# "rest_framework.authentication.SessionAuthentication",
# "rest_framework.authentication.BasicAuthentication",
),
# How many reverse proxies in front of Django append to X-Forwarded-For.
# `common.request_meta.client_ip` (every recorded IP and every per-IP
# throttle) believes only that many entries from the right. Unset means
# none: REMOTE_ADDR is used. Leave it unset when the app server already
# resolves the client from trusted proxies (uvicorn --proxy-headers
# --forwarded-allow-ips, as hosted production runs); counting the proxy
# again here would make every SvelteKit-relayed request the relay itself.
# Set it only when REMOTE_ADDR is the proxy (e.g. gunicorn behind nginx).
"NUM_PROXIES": int(os.environ["NUM_PROXIES"])
if os.environ.get("NUM_PROXIES")
else None,
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
"PAGE_SIZE": 10,
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
# Anonymous endpoints only (issue #634 started it). There is deliberately
# no DEFAULT_THROTTLE_CLASSES entry: setting one would rate-limit every
# authenticated endpoint in the app, which nobody asked for and which would
# surface as intermittent 429s in the CRM UI. Each view that wants these
# rates names its throttle classes explicitly.
"DEFAULT_THROTTLE_RATES": {
"webform_submit_ip": os.environ.get("WEBFORM_THROTTLE_IP", "10/hour"),
"webform_submit_global": os.environ.get("WEBFORM_THROTTLE_GLOBAL", "200/day"),
# Public help center pages, per visitor. Generous because a reader
# clicks through several articles and a crawler walks the sitemap.
"help_center_ip": os.environ.get("HELP_CENTER_THROTTLE_IP", "600/hour"),
# Public help center pages, per help center across all visitors. The
# backstop for a scraper spread across many addresses.
"help_center_global": os.environ.get(
"HELP_CENTER_THROTTLE_GLOBAL", "10000/hour"
),
# Task calendar feed (G14), per address. Generous because Google,
# Microsoft and Apple poll from shared fetchers carrying many users.
"calendar_feed_ip": os.environ.get("CALENDAR_FEED_THROTTLE_IP", "1000/hour"),
# Task calendar feed, per feed. A calendar app polls every few minutes
# at most; this is the ceiling for one URL however many ask for it.
"calendar_feed_token": os.environ.get(
"CALENDAR_FEED_THROTTLE_TOKEN", "60/hour"
),
# Magic-link and portal sign-in requests, per address
# (`common.throttles`). One person needs a handful; the rest is room
# for an office behind one NAT, and for a deployment without
# RELAY_SECRET, where every web sign-in shares the SvelteKit server's
# bucket.
"magic_link_ip": os.environ.get("MAGIC_LINK_THROTTLE_IP", "30/hour"),
"portal_login_ip": os.environ.get("PORTAL_LOGIN_THROTTLE_IP", "30/hour"),
# Magic-link requests across every caller. The backstop against a
# sender spread over many addresses using us to mail arbitrary inboxes.
# Reaching it pauses sign-in email for everyone, so it sits far above
# real sign-in traffic.
"magic_link_global": os.environ.get("MAGIC_LINK_THROTTLE_GLOBAL", "300/hour"),
# Portal sign-in requests per org, across every caller.
"portal_login_org": os.environ.get("PORTAL_LOGIN_THROTTLE_ORG", "100/hour"),
# Contact and lead spreadsheet import (preview and commit), per user.
"csv_import": os.getenv("IMPORT_THROTTLE", "30/minute"),
},
}
# Cache backend.
#
# This exists so DRF throttling is enforced across the deployment rather than
# per worker. Without a CACHES block Django falls back to a per-process
# LocMemCache, so with N gunicorn workers the effective rate limit is roughly N
# times the configured one and it resets on every restart. That makes the spam
# controls on the public web form endpoint decorative rather than real.
#
# Redis is already a dependency (it is the Celery broker) and Django ships the
# backend, so this adds no package. Local development falls back to LocMemCache,
# where per-process limits are fine because there is one process.
CACHE_URL = os.environ.get("CACHE_URL", "")
if CACHE_URL:
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": CACHE_URL,
}
}
else:
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "crm-default",
}
}
# Shared secret between this API and the SvelteKit servers that relay anonymous
# visitors to it: the web app's sign-in pages, help center and estimate portal, and the
# marketing site's contact form. A request carrying it in
# X-BottleCRM-Relay-Secret is believed about the visitor it names in
# X-BottleCRM-Client-IP (`common.request_meta.client_ip`), so each visitor gets
# their own per-IP throttle bucket and recorded address instead of the relay's.
# Unset turns this off. The same value goes in RELAY_SECRET on both SvelteKit
# services. Anyone holding it can choose the address they are recorded and
# throttled as, so a short one is refused rather than accepted.
RELAY_SECRET = os.environ.get("RELAY_SECRET", "")
if RELAY_SECRET and len(RELAY_SECRET) < 32:
raise ValueError(
f"RELAY_SECRET is {len(RELAY_SECRET)} characters; it must be at least 32. "
"Generate one with "
'`python -c "import secrets; print(secrets.token_urlsafe(48))"`, '
"or leave it unset to turn relayed client addresses off."
)
# AWS accounts whose SNS topics may pin an inbound mailbox that has no Topic ARN
# yet (`cases.inbound_views.InboundMailboxWebhookView`). SNS lets any AWS
# account subscribe any HTTPS endpoint to its own topic, so a mailbox pins a
# confirmation's topic only when it belongs to one of these; with none listed,
# an admin has to enter each mailbox's Topic ARN. Comma-separated 12-digit ids.
# A malformed id is refused rather than dropped, since dropping it would leave
# an operator believing their account is allowed while every mailbox stays
# unpinned.
INBOUND_SNS_ACCOUNT_IDS = frozenset(
part.strip()
for part in os.environ.get("INBOUND_SNS_ACCOUNT_IDS", "").split(",")
if part.strip()
)
_bad_account_ids = sorted(
i
for i in INBOUND_SNS_ACCOUNT_IDS
if not (len(i) == 12 and i.isascii() and i.isdigit())
)
if _bad_account_ids:
raise ValueError(
f"INBOUND_SNS_ACCOUNT_IDS contains {', '.join(map(repr, _bad_account_ids))}, "
"which is not a 12-digit AWS account id. List account ids separated by "
"commas, for example 123456789012,210987654321, or leave it unset."
)
SPECTACULAR_SETTINGS = {
"TITLE": "BottleCRM API",
"DESCRIPTION": "CRM for AI and People",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
"COMPONENT_SPLIT_REQUEST": True,
"PREPROCESSING_HOOKS": ["common.custom_openapi.preprocessing_filter_spec"],
"ENUM_NAME_OVERRIDES": {
# Role enums
"ProfileRoleEnum": "common.utils.ROLES",
"BoardMemberRoleEnum": "tasks.models.BoardMember.ROLE_CHOICES",
# Priority enums
"TaskPriorityEnum": "tasks.models.Task.PRIORITY_CHOICES",
"CasePriorityEnum": "common.utils.PRIORITY_CHOICE",
"BoardTaskPriorityEnum": "tasks.models.BoardTask.PRIORITY_CHOICES",
# Status enums
"TaskStatusEnum": "tasks.models.Task.STATUS_CHOICES",
"CaseStatusEnum": "common.utils.STATUS_CHOICE",
"SolutionStatusEnum": "cases.models.Solution.STATUS_CHOICES",
"DocumentStatusEnum": "common.models.Document.DOCUMENT_STATUS_CHOICE",
"InvoiceStatusEnum": "invoices.models.Invoice.INVOICE_STATUS",
"ContactFormStatusEnum": "common.models.ContactFormSubmission.STATUS_CHOICES",
"LeadStatusEnum": "common.utils.LEAD_STATUS",
},
}
# JWT_SETTINGS = {
# 'bearerFormat': ('Bearer', 'jwt', 'Jwt')
# }
SWAGGER_SETTINGS = {
"DEFAULT_INFO": "crm.urls.info",
"SECURITY_DEFINITIONS": {
"Bearer": {
"type": "apiKey",
"name": "Authorization",
"in": "header",
"description": "Enter 'Bearer <token>'",
},
},
}
CORS_ALLOW_HEADERS = default_headers + ("org",)
# Security: CORS configuration via environment variables
CORS_ORIGIN_ALLOW_ALL = os.environ.get("CORS_ALLOW_ALL", "False").lower() == "true"
CORS_ALLOW_ALL_ORIGINS = CORS_ORIGIN_ALLOW_ALL
CORS_ALLOWED_ORIGIN_REGEXES = [
r"^https://.*\.tradhox\.com$",
r"^http://.*\.tradhox\.com$",
]
CORS_ALLOWED_ORIGINS = [
origin.strip()
for origin in os.environ.get(
"CORS_ALLOWED_ORIGINS",
"http://localhost:5181,http://127.0.0.1:5181,https://betaapicsa.tradhox.com,https://apicsa.tradhox.com",
).split(",")
if origin.strip()
]
# Security: CSRF trusted origins via environment variable
_default_csrf = "https://betaapicsa.tradhox.com,https://apicsa.tradhox.com,https://*.tradhox.com,http://localhost:5181"
_csrf_origins = os.environ.get("CSRF_TRUSTED_ORIGINS", _default_csrf)
CSRF_TRUSTED_ORIGINS = [o.strip() for o in _csrf_origins.split(",") if o.strip()]
# Security: HSTS with 1 year duration (recommended minimum)
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_CONTENT_TYPE_NOSNIFF = True
# `SecurityMiddleware` emits Strict-Transport-Security only when
# `request.is_secure()` is true. Behind a TLS-terminating proxy (nginx, an ALB)
# the request reaches Django over plain HTTP, so `is_secure()` is false and the
# three HSTS settings above were inert in exactly the deployment they were
# written for. This is what lets Django read the proxy's verdict instead.
#
# It is opt-in rather than on by default because the header is
# attacker-controllable on any path that does not pass through the proxy: a
# request sent straight to gunicorn carrying `X-Forwarded-Proto: https` makes
# `request.is_secure()` lie. Set TRUST_PROXY_SSL_HEADER=True only when the proxy
# is the sole ingress AND it overwrites the header rather than appending to a
# client-supplied one. The http-to-https redirect belongs on that same proxy,
# which is why SECURE_SSL_REDIRECT is deliberately left unset here.
if os.environ.get("TRUST_PROXY_SSL_HEADER", "False").lower() == "true":
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
SIMPLE_JWT = {
# Security: Reduced token lifetimes
"ACCESS_TOKEN_LIFETIME": timedelta(hours=1),
"REFRESH_TOKEN_LIFETIME": timedelta(days=14),
# Security: Enable token rotation to invalidate old refresh tokens
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
"UPDATE_LAST_LOGIN": False,
"ALGORITHM": "HS256",
"SIGNING_KEY": SECRET_KEY,
"VERIFYING_KEY": None,
"AUDIENCE": None,
"ISSUER": None,
"AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
"USER_ID_FIELD": "id",
"USER_ID_CLAIM": "user_id",
}
# it is needed in custome middlewere to get the user from the token
JWT_ALGO = "HS256"
DOMAIN_NAME = os.environ.get("DOMAIN_NAME", "http://localhost:8000")
# This API's public origin, the base of the URLs a person copies out of the app
# and uses somewhere else: the task calendar feed and a web form's embed
# snippet (`common.links.api_url`). Left at the dev default in production, a
# calendar subscribes to a port on the member's own machine and a customer's
# site embeds one, and nothing on the server notices. Checked the same way as
# FRONTEND_URL below.
if not IS_DEV_ENV:
_api = urlparse(DOMAIN_NAME)
if _api.scheme not in ("http", "https") or not _api.netloc:
raise ValueError(
f"DOMAIN_NAME is {DOMAIN_NAME!r}, which is not an absolute URL. "
"Calendar feed URLs and web form embed snippets are built from it. "
"Set it to this API's public base URL, for example "
"https://api.example.com."
)
if _api.hostname in ("localhost", "127.0.0.1", "0.0.0.0", "::1"):
raise ValueError(
f"DOMAIN_NAME is {DOMAIN_NAME!r}, which points at this machine. "
"Calendar feed URLs and the web form embed snippets customers paste "
"onto their own sites are built from it. Set it to this API's "
"public base URL, for example https://api.example.com."
)
# The organization API key (`Token: <org.api_key>` header) is one non-expiring
# key per tenant that resolves to an arbitrary active ADMIN. Even now that it is
# read-only and barred from credential endpoints (see common/scopes.py), it
# still reads every record in the org and cannot be revoked per-integration.
# Personal access tokens replace it and are scoped, per-user and revocable.
#
# Left on by default so an upgrade breaks nothing. Set DJANGO_ORG_API_KEY_AUTH
# to "false" once every integration has moved to a personal access token.
ORG_API_KEY_AUTH_ENABLED = os.environ.get(
"DJANGO_ORG_API_KEY_AUTH", "true"
).strip().lower() not in ("false", "0", "no", "off")
FRONTEND_URL = os.environ.get("FRONTEND_URL", "http://localhost:5181")
# Every link this system puts in an email is built from this one value, via
# `common.links.frontend_url`: the magic-link sign-in URL, the customer's
# invoice and estimate portal links, the CSAT survey, and the internal
# "assigned to you" notifications. Left at the dev default in production it
# mails customers a link to a port on their own machine, and mails a sign-in
# token to one, and neither failure shows up anywhere on the server.
#
# Checked here rather than at send time because there is no good answer at send
# time: the email has already been composed and the alternative is dropping it
# silently. A deploy that fails to start is the loud version of the same news.
if not IS_DEV_ENV:
_frontend = urlparse(FRONTEND_URL)
if _frontend.scheme not in ("http", "https") or not _frontend.netloc:
raise ValueError(
f"FRONTEND_URL is {FRONTEND_URL!r}, which is not an absolute URL. "
"Every emailed link is built from it, and a value without a scheme "
"and host produces a relative path, which is not a link at all "
"inside an email. Set it to the web app's public base URL, for "
"example https://app.example.com."
)
if _frontend.hostname in ("localhost", "127.0.0.1", "0.0.0.0", "::1"):
raise ValueError(
f"FRONTEND_URL is {FRONTEND_URL!r}, which points at this machine. "
"It is emailed to customers (invoice and estimate portal links, "
"the CSAT survey) and carries the magic-link sign-in URL. Set it to "
"the web app's public base URL, for example https://app.example.com."
)
# Google OAuth Configuration
#
# There is no GOOGLE_REDIRECT_URI here. The redirect URI used in the token
# exchange is the one the frontend sends in the request body on each call
# (`common/views/auth_views.py`), so a setting of that name was read into
# Django and never referenced again. Same for SWAGGER_ROOT_URL, which named
# nothing: the schema is served from whatever host serves the app.
GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "")
GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "")