Files
JQC_multi_tenant/config.py
T

119 lines
6.3 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()
]
# Per-tenant SQLAlchemy engine pool tuning (see MULTI_TENANT_PLAN.md §4).
TENANT_ENGINE_POOL_SIZE = int(os.environ.get('TENANT_ENGINE_POOL_SIZE', 5))
TENANT_ENGINE_MAX_OVERFLOW = int(os.environ.get('TENANT_ENGINE_MAX_OVERFLOW', 5))
TENANT_ENGINE_POOL_RECYCLE = int(os.environ.get('TENANT_ENGINE_POOL_RECYCLE', 1800))
# ── 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'}
# ── 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,
}