196 lines
11 KiB
Python
196 lines
11 KiB
Python
import os
|
||
from datetime import timedelta
|
||
from dotenv import load_dotenv
|
||
|
||
# Load .env from the project root (only takes effect locally; no-op in production
|
||
# if variables are already set in the environment)
|
||
load_dotenv()
|
||
|
||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||
|
||
|
||
def _require_env(key: str) -> str:
|
||
"""Return the value of a required environment variable, raising if absent."""
|
||
value = os.environ.get(key)
|
||
if not value:
|
||
raise RuntimeError(
|
||
f"Required environment variable '{key}' is not set. "
|
||
f"Add it to your .env file (development) or server environment (production)."
|
||
)
|
||
return value
|
||
|
||
|
||
class Config:
|
||
# ── Security ────────────────────────────────────────────────────────────
|
||
# SECRET_KEY must be set externally — no insecure fallback.
|
||
SECRET_KEY = _require_env('SECRET_KEY')
|
||
|
||
# ── Database ────────────────────────────────────────────────────────────
|
||
# DATABASE_URL must be set externally — no hardcoded credentials.
|
||
SQLALCHEMY_DATABASE_URI = _require_env('DATABASE_URL')
|
||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||
SQLALCHEMY_ECHO = False
|
||
|
||
# ── Multi-tenancy (MT-1) ─────────────────────────────────────────────────
|
||
# Master switch. When False (default) the app behaves EXACTLY as the
|
||
# single-tenant deployment: no Host resolution, every query uses
|
||
# SQLALCHEMY_DATABASE_URI. Flip to True only after tenants are registered
|
||
# in the control plane (see MULTI_TENANT_PLAN.md). The control plane env
|
||
# vars (CONTROL_DATABASE_URL, CONTROL_FERNET_KEY) are only required when
|
||
# this is True.
|
||
MULTI_TENANT_ENABLED = os.environ.get(
|
||
'MULTI_TENANT_ENABLED', 'false'
|
||
).strip().lower() in ('1', 'true', 'yes', 'on')
|
||
# Path prefixes that bypass the tenant gate even when enabled (e.g. health
|
||
# checks). '/static/' is always exempt. Comma-separated in the environment.
|
||
MULTI_TENANT_EXEMPT_PATHS = [
|
||
p.strip() for p in os.environ.get('MULTI_TENANT_EXEMPT_PATHS', '').split(',')
|
||
if p.strip()
|
||
]
|
||
# Apex domain for the public marketing/landing page. When MULTI_TENANT_ENABLED
|
||
# is True, requests to this host (and its www. variant) are served the landing
|
||
# page instead of being resolved to a tenant. Mirrors TENANT_BASE_DOMAIN used
|
||
# by the provisioner to build <slug>.<base> subdomains.
|
||
TENANT_BASE_DOMAIN = os.environ.get('TENANT_BASE_DOMAIN', 'jqc.app')
|
||
# Per-tenant SQLAlchemy engine pool tuning (see MULTI_TENANT_PLAN.md §4).
|
||
# MT-21: defaults lowered from 5/5 to 2/3. Backend connections scale as
|
||
# workers × cached-tenants × (pool_size + max_overflow); at the old
|
||
# defaults 8 workers × 32 tenants would reserve 2560 connections against a
|
||
# MySQL max_connections that defaults to 151.
|
||
TENANT_ENGINE_POOL_SIZE = int(os.environ.get('TENANT_ENGINE_POOL_SIZE', 2))
|
||
TENANT_ENGINE_MAX_OVERFLOW = int(os.environ.get('TENANT_ENGINE_MAX_OVERFLOW', 3))
|
||
TENANT_ENGINE_POOL_RECYCLE = int(os.environ.get('TENANT_ENGINE_POOL_RECYCLE', 1800))
|
||
# MT-21: hard cap on cached per-tenant engines per worker process. Beyond
|
||
# this the least-recently-used engine is disposed and rebuilt on demand.
|
||
# 0 disables the bound (previous behaviour — not recommended).
|
||
TENANT_ENGINE_CACHE_MAX = int(os.environ.get('TENANT_ENGINE_CACHE_MAX', 32))
|
||
|
||
# ── File uploads ────────────────────────────────────────────────────────
|
||
UPLOAD_FOLDER = os.path.join(basedir, 'app/static/uploads')
|
||
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50 MB
|
||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
|
||
|
||
# ── Storage backend (R2 migration, MT-2) ────────────────────────────────
|
||
# 'local' (default) = files under app/static/uploads, served via url_for('static').
|
||
# 's3' = Cloudflare R2 / S3-compatible. Flip via env only, per tenant, in MT-8.
|
||
# Object keys are tenant-prefixed inside the backend; DB paths never change.
|
||
STORAGE_BACKEND = os.environ.get('STORAGE_BACKEND', 'local')
|
||
|
||
# Cloudflare R2 — only used when STORAGE_BACKEND=s3 (inert until MT-8).
|
||
R2_ENDPOINT_URL = os.environ.get('R2_ENDPOINT_URL')
|
||
R2_ACCESS_KEY_ID = os.environ.get('R2_ACCESS_KEY_ID')
|
||
R2_SECRET_ACCESS_KEY = os.environ.get('R2_SECRET_ACCESS_KEY')
|
||
R2_BUCKET = os.environ.get('R2_BUCKET')
|
||
R2_PRESIGN_TTL = int(os.environ.get('R2_PRESIGN_TTL', '86400')) # seconds (24h)
|
||
# When true, media_url HEADs the object and falls back to the legacy
|
||
# unprefixed key, then a local static URL, if it's missing (belt-and-braces
|
||
# during an early/partial cutover). Off by default — cutover is gated on
|
||
# full verification, so objects exist.
|
||
R2_MEDIA_FALLBACK = os.environ.get('R2_MEDIA_FALLBACK', 'false').lower() == 'true'
|
||
|
||
# ── Photo capture-time / geo overlay (MT-12) ────────────────────────────
|
||
# When true (default), POST /api/v1/photos/upload burns a timestamp + GPS
|
||
# bar into the image before storing it. Set false to store raw uploads.
|
||
# Global, not per-tenant: the overlay is evidence provenance, which every
|
||
# tenant wants and none should be able to switch off from the UI. Promote to
|
||
# a TenantSettings column only if a tenant ever has a real reason to opt out.
|
||
PHOTO_STAMP_ENABLED = os.environ.get('PHOTO_STAMP_ENABLED', 'true').lower() == 'true'
|
||
|
||
# ── Web portal design (MT-16) ───────────────────────────────────────────
|
||
# Fallback design for users who have never chosen one. A stored
|
||
# users.ui_theme ALWAYS wins, so this only affects accounts that have not
|
||
# touched the switcher.
|
||
#
|
||
# Defaults to 'classic' — deliberately NOT ST's 'modern'. ST is
|
||
# single-tenant and could decide for its own users; MT serves tenants who
|
||
# never saw the A/B test, and flipping every user of every tenant to a new
|
||
# UI on a migration is not a change to make on their behalf. New tenants can
|
||
# be provisioned with DEFAULT_UI_THEME=modern, and any user can opt in from
|
||
# the account menu at any time.
|
||
DEFAULT_UI_THEME = os.environ.get('DEFAULT_UI_THEME', 'classic').strip().lower()
|
||
|
||
# ── Photo retention (MT-13) ─────────────────────────────────────────────
|
||
# Days after an issue is RESOLVED before its photo FILES are deleted by
|
||
# POST /notifications/purge-old-photos. Unset (None) = the purge is a no-op.
|
||
# Deliberately opt-in, not a default: this is a data-minimization policy the
|
||
# operator chooses (GDPR Art. 5(1)(e) storage limitation), and some tenants
|
||
# have longer contractual/audit retention of their own. Applies per tenant —
|
||
# the cron is invoked once per tenant Host, like check-sla.
|
||
PHOTO_RETENTION_DAYS = (
|
||
int(os.environ['PHOTO_RETENTION_DAYS'])
|
||
if os.environ.get('PHOTO_RETENTION_DAYS') else None
|
||
)
|
||
|
||
# ── Issue comment visibility (TEMPORARY — Aug 2026) ──────────────────────
|
||
# True = every comment on an issue is visible to everyone, customers
|
||
# included; the per-comment is_customer_visible flag is ignored
|
||
# when READING.
|
||
# False = phase22 behaviour — customers see only comments explicitly shared
|
||
# with them.
|
||
#
|
||
# The flag is still WRITTEN on every comment, so flipping this back to
|
||
# 'false' restores the old behaviour exactly, with no data to repair.
|
||
# Set COMMENTS_VISIBLE_TO_ALL=false in the environment to revert.
|
||
COMMENTS_VISIBLE_TO_ALL = os.environ.get(
|
||
'COMMENTS_VISIBLE_TO_ALL', 'true').lower() == 'true'
|
||
|
||
# ── Session / cookies ───────────────────────────────────────────────────
|
||
PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
|
||
# Secure by default — subclasses must explicitly opt out for local dev.
|
||
SESSION_COOKIE_SECURE = True
|
||
SESSION_COOKIE_HTTPONLY = True
|
||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||
|
||
# ── Mail ────────────────────────────────────────────────────────────────
|
||
# ── Application base URL (used in email links) ─────────────────────────
|
||
APP_BASE_URL = os.environ.get('APP_BASE_URL', '')
|
||
MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER', 'noreply@janitorialqc.local')
|
||
|
||
# ── Digest email secret token (used to authenticate cron trigger) ────────
|
||
DIGEST_SECRET = os.environ.get('DIGEST_SECRET')
|
||
|
||
# ── Google Maps (used for GPS map on inspection view) ────────────────────
|
||
GOOGLE_MAPS_API_KEY = os.environ.get('GOOGLE_MAPS_API_KEY', '')
|
||
|
||
# ── Stripe / Billing (MT-8) ──────────────────────────────────────────────
|
||
# BILLING_ENABLED=false by default — inert until explicitly flipped.
|
||
# Flip to true only after STRIPE_* keys are set and plans have stripe_price_id.
|
||
BILLING_ENABLED = os.environ.get('BILLING_ENABLED', 'false').strip().lower() in ('1', 'true', 'yes', 'on')
|
||
STRIPE_SECRET_KEY = os.environ.get('STRIPE_SECRET_KEY')
|
||
STRIPE_PUBLISHABLE_KEY = os.environ.get('STRIPE_PUBLISHABLE_KEY')
|
||
STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET')
|
||
STRIPE_PRICE_STARTER = os.environ.get('STRIPE_PRICE_STARTER')
|
||
STRIPE_PRICE_PRO = os.environ.get('STRIPE_PRICE_PRO')
|
||
STRIPE_PRICE_ENTERPRISE = os.environ.get('STRIPE_PRICE_ENTERPRISE')
|
||
|
||
MAIL_SERVER = os.environ.get('MAIL_SERVER')
|
||
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
|
||
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
|
||
|
||
# ── SSL vs STARTTLS selection ────────────────────────────────────────────
|
||
# Port 465 = implicit SSL → MAIL_USE_SSL=True, MAIL_USE_TLS=False
|
||
# Port 587 = STARTTLS → MAIL_USE_SSL=False, MAIL_USE_TLS=True
|
||
# The two flags are mutually exclusive; setting both True breaks Flask-Mail.
|
||
_mail_port = int(os.environ.get('MAIL_PORT') or 587)
|
||
MAIL_PORT = _mail_port
|
||
MAIL_USE_SSL = _mail_port == 465
|
||
MAIL_USE_TLS = not MAIL_USE_SSL # STARTTLS only when NOT using implicit SSL
|
||
|
||
|
||
class DevelopmentConfig(Config):
|
||
DEBUG = True
|
||
SQLALCHEMY_ECHO = True
|
||
# Allow HTTP cookies during local development (HTTP, not HTTPS)
|
||
SESSION_COOKIE_SECURE = False
|
||
|
||
|
||
class ProductionConfig(Config):
|
||
DEBUG = False
|
||
# Inherits SESSION_COOKIE_SECURE = True from Config — no override needed.
|
||
|
||
|
||
config = {
|
||
'development': DevelopmentConfig,
|
||
'production': ProductionConfig,
|
||
'default': DevelopmentConfig,
|
||
} |