Files

232 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
# ── Connection pool (default bind) ──────────────────────────────────────
# These apply to the DEFAULT engine built from SQLALCHEMY_DATABASE_URI —
# the one used in single-tenant mode, on tenant-exempt paths, and as the
# fallback bind. PER-TENANT engines are built in
# app/tenancy/engine_cache.py and carry their own (deliberately smaller)
# TENANT_ENGINE_* pool settings; changing these does not affect those.
#
# Without them the pool runs on library defaults, which is where the
# intermittent OperationalError 2006 ("MySQL server has gone away") comes
# from: MySQL closes a connection after wait_timeout (8h by default) and
# SQLAlchemy hands the dead socket to the next request.
#
# pool_pre_ping — cheap liveness check before a connection is handed out;
# a dead one is discarded and replaced transparently.
# pool_recycle — retire connections after 30 min, well under any sane
# wait_timeout, so they are never the stale ones.
# pool_size / — Gunicorn runs sync workers (cpu*2+1), and each worker
# max_overflow holds its OWN pool. Library defaults (5 + 10) mean a
# 9-worker box can open 135 connections against a
# max_connections of 151 — before the per-tenant pools
# are counted at all. A sync worker serves one request at
# a time, so it needs one connection in steady state; the
# small overflow is headroom for background
# email/notification threads.
#
# Tune with scripts/db_health.py, which cross-checks these (and the
# per-tenant pools) against the server's live max_connections and
# wait_timeout.
SQLALCHEMY_ENGINE_OPTIONS = {
'pool_pre_ping': True,
'pool_recycle': int(os.environ.get('DB_POOL_RECYCLE', '1800')),
'pool_size': int(os.environ.get('DB_POOL_SIZE', '5')),
'max_overflow': int(os.environ.get('DB_MAX_OVERFLOW', '5')),
'pool_timeout': 30,
}
# ── 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,
}