"""Shared Django settings for all environments.""" import json import os import sys from pathlib import Path from urllib.parse import urlparse BASE_DIR = Path(__file__).resolve().parent.parent.parent def env(key: str, default: str | None = None) -> str | None: return os.environ.get(key, default) def env_bool(key: str, default: bool = False) -> bool: value = os.environ.get(key) if value is None: return default return value.lower() in {"1", "true", "yes", "on"} def env_list(key: str, default: str = "") -> list[str]: value = os.environ.get(key, default) if not value: return [] value = value.strip() if value.startswith("["): try: parsed = json.loads(value) except ValueError: parsed = None if isinstance(parsed, list): return [str(item).strip() for item in parsed if str(item).strip()] return [item.strip() for item in value.split(",") if item.strip()] def database_config() -> dict: database_url = env("DATABASE_URL") if database_url: parsed = urlparse(database_url) return { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": parsed.path.lstrip("/"), "USER": parsed.username or "", "PASSWORD": parsed.password or "", "HOST": parsed.hostname or "", "PORT": str(parsed.port or 5432), } } if env("DB_HOST"): return { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": env("DB_NAME", "client_site"), "USER": env("DB_USER", "client_site"), "PASSWORD": env("DB_PASSWORD", ""), "HOST": env("DB_HOST"), "PORT": env("DB_PORT", "5432"), } } return { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3", } } def build_csrf_trusted_origins( allowed_hosts: list[str], explicit: list[str] | None = None ) -> list[str]: """Build CSRF_TRUSTED_ORIGINS for Django 4+ Origin checks on HTTPS POSTs.""" if explicit: return explicit local_hosts = {"localhost", "127.0.0.1", "0.0.0.0"} origins: list[str] = [] for host in allowed_hosts: if not host or host == "*" or host.startswith("."): continue hostname = host.split(":")[0] scheme = "http" if hostname in local_hosts else "https" origins.append(f"{scheme}://{host}") return origins SECRET_KEY = env( "DJANGO_SECRET_KEY", "django-insecure-dev-only-change-me-before-production", ) DEBUG = env_bool("DJANGO_DEBUG", False) allowed_hosts = env_list("DJANGO_ALLOWED_HOSTS", "*") ALLOWED_HOSTS = allowed_hosts if allowed_hosts else ["*"] CSRF_TRUSTED_ORIGINS = build_csrf_trusted_origins( ALLOWED_HOSTS, env_list("DJANGO_CSRF_TRUSTED_ORIGINS"), ) from django.core.exceptions import ImproperlyConfigured # Catalog feature flags. Secret env is the source of truth. # Dev/test default ON so the template is a working seed; prod/beta secrets # must set each flag explicitly (see scripts/validate-env.sh). _feature_default = os.environ.get("DJANGO_ENV", "dev").lower() not in {"prod", "beta"} FEATURE_EMAIL_SMS = env_bool("FEATURE_EMAIL_SMS", _feature_default) FEATURE_DIRECT_MAIL = env_bool("FEATURE_DIRECT_MAIL", _feature_default) FEATURE_BLOG = env_bool("FEATURE_BLOG", _feature_default) FEATURE_PAYMENTS = env_bool("FEATURE_PAYMENTS", _feature_default) FEATURE_SOCIAL = env_bool("FEATURE_SOCIAL", _feature_default) FEATURE_SOCIAL_AI = env_bool("FEATURE_SOCIAL_AI", _feature_default) FEATURE_SHOP = env_bool("FEATURE_SHOP", _feature_default) FEATURE_POS_SYNC = env_bool("FEATURE_POS_SYNC", _feature_default) FEATURE_EVENTS = env_bool("FEATURE_EVENTS", _feature_default) FEATURE_SHIPPING = env_bool("FEATURE_SHIPPING", _feature_default) if FEATURE_PAYMENTS and not FEATURE_EMAIL_SMS: raise ImproperlyConfigured("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS") if FEATURE_SOCIAL_AI and not FEATURE_SOCIAL: raise ImproperlyConfigured("FEATURE_SOCIAL_AI requires FEATURE_SOCIAL") if FEATURE_SHOP and not FEATURE_EMAIL_SMS: raise ImproperlyConfigured("FEATURE_SHOP requires FEATURE_EMAIL_SMS") if FEATURE_SHOP and not FEATURE_PAYMENTS: raise ImproperlyConfigured("FEATURE_SHOP requires FEATURE_PAYMENTS") if FEATURE_POS_SYNC and not FEATURE_SHOP: raise ImproperlyConfigured("FEATURE_POS_SYNC requires FEATURE_SHOP") if FEATURE_EVENTS and not FEATURE_EMAIL_SMS: raise ImproperlyConfigured("FEATURE_EVENTS requires FEATURE_EMAIL_SMS") if FEATURE_EVENTS and not FEATURE_PAYMENTS: raise ImproperlyConfigured("FEATURE_EVENTS requires FEATURE_PAYMENTS") if FEATURE_SHIPPING and not FEATURE_SHOP: raise ImproperlyConfigured("FEATURE_SHIPPING requires FEATURE_SHOP") CORE_APPS = [ "core.apps.CoreConfig", "public.apps.PublicConfig", "accounts.apps.AccountsConfig", "dashboard.apps.DashboardConfig", "leads.apps.LeadsConfig", "contacts.apps.ContactsConfig", "analytics.apps.AnalyticsConfig", ] OPTIONAL_APPS = [ (FEATURE_EMAIL_SMS, "email_sms.apps.EmailSmsConfig"), (FEATURE_DIRECT_MAIL, "directmail.apps.DirectmailConfig"), (FEATURE_BLOG, "blog.apps.BlogConfig"), (FEATURE_PAYMENTS, "payments.apps.PaymentsConfig"), (FEATURE_SOCIAL, "social.apps.SocialConfig"), (FEATURE_SOCIAL_AI, "social_ai.apps.SocialAiConfig"), (FEATURE_SHOP, "shop.apps.ShopConfig"), (FEATURE_POS_SYNC, "pos_sync.apps.PosSyncConfig"), (FEATURE_EVENTS, "events.apps.EventsConfig"), (FEATURE_SHIPPING, "shipping.apps.ShippingConfig"), ] DJANGO_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "whitenoise.runserver_nostatic", "django.contrib.staticfiles", "phonenumber_field", "django_recaptcha", "dj_queue", ] INSTALLED_APPS = ( CORE_APPS + [app for enabled, app in OPTIONAL_APPS if enabled] + DJANGO_APPS ) MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "accounts.middleware.PortalStaffMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "analytics.middleware.UTMTrackingMiddleware", "analytics.middleware.PublicPageViewMiddleware", "public.middleware.UnderConstructionMiddleware", ] ROOT_URLCONF = "client_site.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [BASE_DIR / "client_site" / "templates"], "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", "public.context_processors.site_branding", "public.context_processors.tianji_tracking", "core.context_processors.feature_registry", ], }, }, ] WSGI_APPLICATION = "client_site.wsgi.application" DATABASES = database_config() 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", }, ] LANGUAGE_CODE = "en-us" TIME_ZONE = "America/Chicago" USE_I18N = True USE_TZ = True STATIC_URL = "static/" STATIC_ROOT = BASE_DIR / "staticfiles" STATICFILES_DIRS = [ BASE_DIR / "client_site" / "static", ] # Uploaded blobs live in the DB (core.StoredFile). Default storage is # in-memory only so nothing is written to disk accidentally. STORAGES = { "default": { "BACKEND": "django.core.files.storage.memory.InMemoryStorage", }, "staticfiles": { "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", }, } DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" LOGIN_URL = "accounts:login" LOGIN_REDIRECT_URL = "dashboard:home" LOGOUT_REDIRECT_URL = "accounts:login" # --- Feature gates --- # When true, public visitors only see the under-construction page. # Prod launch holding page: SITE_UNDER_CONSTRUCTION=true # Beta (full app): SITE_UNDER_CONSTRUCTION=false SITE_UNDER_CONSTRUCTION = env_bool("SITE_UNDER_CONSTRUCTION", False) # --- Branding / public site --- SITE_NAME = env("SITE_NAME", "Your Company") SITE_TAGLINE = env("SITE_TAGLINE", "A tagline for the public site") PUBLIC_SITE_URL = env("PUBLIC_SITE_URL", "") CONTACT_PHONE = env("CONTACT_PHONE", "") CONTACT_EMAIL = env("CONTACT_EMAIL", "hello@example.com") CONTACT_ADDRESS = env("CONTACT_ADDRESS", "") CONTACT_SERVICE_AREA = env("CONTACT_SERVICE_AREA", "") CREDIT_NAME = env("CREDIT_NAME", "AI ML Operations, LLC") CREDIT_URL = env("CREDIT_URL", "https://aimloperations.com") def format_from_email(address: str | None, display_name: str | None = None) -> str: """ Ensure From header includes a display name for inbox UIs. Bare ``noreply@…`` shows as "noreply". Prefer ``"Your Company" `` (RFC 5322). Docker Compose ``.env`` cannot parse split quotes like ``"Name" `` — use a bare address (this helper adds SITE_NAME) or one pair of quotes around the whole value: ``DEFAULT_FROM_EMAIL="Your Company "``. """ raw = (address or "").strip() if not raw: return "" # Strip a single layer of wrapping quotes from env/compose. if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in {'"', "'"}: raw = raw[1:-1].strip() if "<" in raw and ">" in raw: return raw name = (display_name or SITE_NAME or "").strip() if not name: return raw safe = name.replace("\\", "\\\\").replace('"', '\\"') return f'"{safe}" <{raw}>' # Inbox "From" — not the same as EMAIL_HOST_USER (SMTP login). DEFAULT_FROM_EMAIL = format_from_email( env("DEFAULT_FROM_EMAIL", "noreply@example.com") ) # --- reCAPTCHA v3 --- RECAPTCHA_PUBLIC_KEY = env("RECAPTCHA_PUBLIC_KEY", "") RECAPTCHA_PRIVATE_KEY = env("RECAPTCHA_PRIVATE_KEY", "") # --- Email (SMTP2GO) --- EMAIL_HOST = env("EMAIL_HOST", "mail.smtp2go.com") EMAIL_HOST_USER = env("EMAIL_HOST_USER", "") EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", "") EMAIL_PORT = int(env("EMAIL_PORT", "2525") or "2525") EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", True) EMAIL_BACKEND = env( "EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend", ) # --- SMTP2GO SMS --- SMTP2GO_SMS_API_KEY = env("SMTP2GO_SMS_API_KEY", "") SMTP2GO_SMS_API_URL = env( "SMTP2GO_SMS_API_URL", "https://api.smtp2go.com/v3/sms/send", ) # Optional shared secret: ?token=… or Authorization: Bearer … SMTP2GO_WEBHOOK_SECRET = env("SMTP2GO_WEBHOOK_SECRET", "") # --- Postcards (PCM Integrations default) --- POSTCARD_PROVIDER = env("POSTCARD_PROVIDER", "pcm") # Login credentials for POST /auth/login → short-lived Bearer token. PCM_API_KEY = env("PCM_API_KEY", "") PCM_API_SECRET = env("PCM_API_SECRET", "") # Optional child-app reference for PCM multi-account (childRefNbr). PCM_CHILD_REF_NBR = env("PCM_CHILD_REF_NBR", "") # PCM webhook auth: each subscription has its own signature secret (copy from PCM UI). # Prefer PCM_WEBHOOK_SECRETS=sec1,sec2,… ; PCM_WEBHOOK_SECRET still accepted (single). PCM_WEBHOOK_SECRETS = env("PCM_WEBHOOK_SECRETS", "") PCM_WEBHOOK_SECRET = env("PCM_WEBHOOK_SECRET", "") # JSON object: company, firstName, lastName, address, address2, city, state, zipCode PCM_RETURN_ADDRESS = env("PCM_RETURN_ADDRESS", "") PCM_RETURN_LINE1 = env("PCM_RETURN_LINE1", "") PCM_RETURN_LINE2 = env("PCM_RETURN_LINE2", "") PCM_RETURN_CITY = env("PCM_RETURN_CITY", "") PCM_RETURN_STATE = env("PCM_RETURN_STATE", "") PCM_RETURN_ZIP = env("PCM_RETURN_ZIP", "") # Legacy / alternate providers (POSTCARD_PROVIDER=lob|click2mail|postgrid) LOB_API_KEY = env("LOB_API_KEY", "") CLICK2MAIL_API_KEY = env("CLICK2MAIL_API_KEY", "") POSTGRID_API_KEY = env("POSTGRID_API_KEY", "") # --- Social (native APIs) --- META_APP_ID = env("META_APP_ID", "") META_APP_SECRET = env("META_APP_SECRET", "") # LinkedIn Client ID/Secret live in SocialAppCredentials (portal UI), not env. SOCIAL_TOKEN_ENCRYPTION_KEY = env("SOCIAL_TOKEN_ENCRYPTION_KEY", "") # --- Ollama (social post drafting) --- # Host-network reachable from app hosts; default matches internal LAN Ollama. OLLAMA_BASE_URL = env("OLLAMA_BASE_URL", "http://10.0.0.128:11434") OLLAMA_MODEL = env("OLLAMA_MODEL", "llama3.2") OLLAMA_TIMEOUT_SECONDS = int(env("OLLAMA_TIMEOUT_SECONDS", "120") or "120") # --- Stripe (FEATURE_PAYMENTS) --- STRIPE_SECRET_KEY = env("STRIPE_SECRET_KEY", "") STRIPE_PUBLISHABLE_KEY = env("STRIPE_PUBLISHABLE_KEY", "") STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", "") STRIPE_CURRENCY = env("STRIPE_CURRENCY", "usd") # --- Shop / POS / shipping (FEATURE_SHOP and add-ons) --- # 0 = unlimited made-to-order print queue. SHOP_PRINT_QUEUE_LIMIT_MINUTES = int( env("SHOP_PRINT_QUEUE_LIMIT_MINUTES", "0") or "0" ) POS_WEBHOOK_SECRET = env("POS_WEBHOOK_SECRET", "") POS_API_BASE_URL = env("POS_API_BASE_URL", "") POS_API_TOKEN = env("POS_API_TOKEN", "") EASYPOST_API_KEY = env("EASYPOST_API_KEY", "") EASYPOST_WEBHOOK_SECRET = env("EASYPOST_WEBHOOK_SECRET", "") SHIP_FROM_LINE1 = env("SHIP_FROM_LINE1", "") SHIP_FROM_CITY = env("SHIP_FROM_CITY", "") SHIP_FROM_STATE = env("SHIP_FROM_STATE", "") SHIP_FROM_ZIP = env("SHIP_FROM_ZIP", "") # --- Nominatim (address suggest; server-side proxy only) --- # Self-hosted on ai-server-4080. Nominatim has no native API keys — gate with # LAN UFW + this Django proxy. Optional NOMINATIM_API_KEY is forwarded as # X-API-Key if you later put auth in front of Nominatim. NOMINATIM_BASE_URL = env("NOMINATIM_BASE_URL", "http://10.0.0.128:8089") NOMINATIM_TIMEOUT_SECONDS = int(env("NOMINATIM_TIMEOUT_SECONDS", "8") or "8") NOMINATIM_COUNTRY_CODES = env("NOMINATIM_COUNTRY_CODES", "us") NOMINATIM_USER_AGENT = env( "NOMINATIM_USER_AGENT", "client_site/1.0 (address-suggest; contact admin)", ) NOMINATIM_API_KEY = env("NOMINATIM_API_KEY", "") # --- URL shortener (piha.li) --- # Call the API host, never the public short domain. Empty = skip minting # (composer falls back to the long UTM URL). Token format: name:secret. # Required in beta/prod when FEATURE_EMAIL_SMS or FEATURE_DIRECT_MAIL is on. SHORTENER_BASE_URL = env("SHORTENER_BASE_URL", "") SHORTENER_API_TOKEN = env("SHORTENER_API_TOKEN", "") SHORTENER_TIMEOUT_SECONDS = int(env("SHORTENER_TIMEOUT_SECONDS", "10") or "10") # Optional override for utm_source. Empty → slug of SITE_NAME. UTM_SOURCE = env("UTM_SOURCE", "") # manage.py test must not mint against a live API (env often has prod tokens). if "test" in sys.argv: SHORTENER_BASE_URL = "" SHORTENER_API_TOKEN = "" # --- Tianji analytics (pageviews + events; notice banner, not opt-in) --- TIANJI_ENABLED = env_bool("TIANJI_ENABLED", not DEBUG) TIANJI_TRACKER_URL = env( "TIANJI_TRACKER_URL", "https://tianji.aimloperations.com/tracker.js", ) TIANJI_WEBSITE_ID = env("TIANJI_WEBSITE_ID", "cmshzhxdf6gee10qzkzrfn9iw") # --- Django Tasks --- # Dev: ImmediateBackend (settings/dev.py). Beta/prod: dj-queue (settings/beta.py|prod.py). TASKS = { "default": { "BACKEND": "django.tasks.backends.immediate.ImmediateBackend", }, }