Files
college_craft/site/client_site/settings/base.py
T
westfarn b62357c8be Add campaign UTM links and piha.li shortener as a core helper.
Keep mint/UTM in always-on core so email_sms and directmail stay optional, and brand utm_source from SITE_NAME (or UTM_SOURCE) instead of a hardcoded client name.
Closes westfarn/web_django_template#3
2026-09-01 15:37:49 -05:00

397 lines
14 KiB
Python

"""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)
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")
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"),
]
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",
"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" <noreply@…>`` (RFC 5322).
Docker Compose ``.env`` cannot parse split quotes like
``"Name" <addr>`` — use a bare address (this helper adds SITE_NAME)
or one pair of quotes around the whole value:
``DEFAULT_FROM_EMAIL="Your Company <addr@domain>"``.
"""
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")
# --- 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",
},
}