generated from westfarn/web_django_template
382 lines
13 KiB
Python
382 lines
13 KiB
Python
"""Shared Django settings for all environments."""
|
|
|
|
import json
|
|
import os
|
|
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", "")
|
|
|
|
# --- 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",
|
|
},
|
|
}
|