Jun 26 MT-1 phase
This commit is contained in:
@@ -156,7 +156,7 @@ Each phase additive; existing tenant-zero traffic keeps working throughout.
|
|||||||
|
|
||||||
**MT-0 — Control-plane scaffold. ✅ DONE.** Self-contained `control/` package at repo root: own `ControlBase` + engine + session, own Alembic chain (`control0001_init`), 7 models, Fernet-encrypted tenant creds, idempotent plan seeder, operator CLI. Zero imports into `app/` — existing app untouched.
|
**MT-0 — Control-plane scaffold. ✅ DONE.** Self-contained `control/` package at repo root: own `ControlBase` + engine + session, own Alembic chain (`control0001_init`), 7 models, Fernet-encrypted tenant creds, idempotent plan seeder, operator CLI. Zero imports into `app/` — existing app untouched.
|
||||||
|
|
||||||
**MT-1 — Tenant resolution + routing.** `app/tenancy/`: `resolver.py`, `routing.py` (RoutingSession), `engine_cache.py`, `before_request`/`teardown` hooks. One-line `db` init change. Unknown-host landing page.
|
**MT-1 — Tenant resolution + routing. ✅ DONE.** `app/tenancy/` package: `routing.py` (`RoutingSession` subclassing the Flask-SQLAlchemy session), `resolver.py` (Host→tenant via control plane, lazy import), `engine_cache.py` (per-tenant engines), `context.py` (`TenantContext`), `middleware.py` (`init_tenancy` before_request hook + branded unknown-host 404). Edits: `db` init in `app/__init__.py` (+`init_tenancy(app)` call) and `MULTI_TENANT_ENABLED` + pool flags in `config.py`. Gated behind `MULTI_TENANT_ENABLED` (default False) — fully inert until flipped.
|
||||||
|
|
||||||
**MT-2 — Per-tenant migration runner.** `flask tenant db upgrade --tenant <id|all>`; record `alembic_head` per tenant.
|
**MT-2 — Per-tenant migration runner.** `flask tenant db upgrade --tenant <id|all>`; record `alembic_head` per tenant.
|
||||||
|
|
||||||
|
|||||||
+33
-54
@@ -11,7 +11,9 @@ import os
|
|||||||
import logging
|
import logging
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
db = SQLAlchemy()
|
from app.tenancy.routing import RoutingSession
|
||||||
|
|
||||||
|
db = SQLAlchemy(session_options={'class_': RoutingSession})
|
||||||
login_manager = LoginManager()
|
login_manager = LoginManager()
|
||||||
migrate = Migrate()
|
migrate = Migrate()
|
||||||
mail = Mail()
|
mail = Mail()
|
||||||
@@ -37,6 +39,13 @@ def create_app(config_name='default'):
|
|||||||
csrf.init_app(app) # enables CSRF protection for all web routes
|
csrf.init_app(app) # enables CSRF protection for all web routes
|
||||||
limiter.init_app(app) # rate limiting — applied per-route via @limiter.limit()
|
limiter.init_app(app) # rate limiting — applied per-route via @limiter.limit()
|
||||||
|
|
||||||
|
# ── Multi-tenant resolution (MT-1) ───────────────────────────────────────
|
||||||
|
# Registers the before_request Host→tenant resolver. Inert (no-op) unless
|
||||||
|
# config MULTI_TENANT_ENABLED is True, so the single-tenant deployment is
|
||||||
|
# unaffected until tenants are provisioned and the flag is flipped.
|
||||||
|
from app.tenancy.middleware import init_tenancy
|
||||||
|
init_tenancy(app)
|
||||||
|
|
||||||
login_manager.login_view = 'auth.login'
|
login_manager.login_view = 'auth.login'
|
||||||
login_manager.login_message = 'Please log in to access this page.'
|
login_manager.login_message = 'Please log in to access this page.'
|
||||||
login_manager.login_message_category = 'info'
|
login_manager.login_message_category = 'info'
|
||||||
@@ -103,56 +112,32 @@ def create_app(config_name='default'):
|
|||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_notification_count():
|
def inject_notification_count():
|
||||||
if not current_user.is_authenticated:
|
|
||||||
return {
|
|
||||||
'unread_notification_count': 0,
|
|
||||||
'pending_verification_count': 0,
|
|
||||||
'open_support_tickets_count': 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Unread notification count (all roles) ──────────────────────────
|
|
||||||
# Computed first, in its own try/except, so a failure in the
|
|
||||||
# director-specific queries below never zeroes out the bell badge.
|
|
||||||
try:
|
try:
|
||||||
from app.models.notification import Notification
|
if current_user.is_authenticated:
|
||||||
unread = Notification.query.filter_by(
|
from app.models.notification import Notification
|
||||||
user_id=current_user.id, is_read=False
|
|
||||||
).count()
|
|
||||||
except Exception as exc:
|
|
||||||
import logging as _logging
|
|
||||||
_logging.getLogger(__name__).warning(
|
|
||||||
'inject_notification_count: unread query failed: %s', exc
|
|
||||||
)
|
|
||||||
unread = 0
|
|
||||||
|
|
||||||
# ── Director/admin-only counts ─────────────────────────────────────
|
|
||||||
pv_count = 0
|
|
||||||
open_support = 0
|
|
||||||
if current_user.role in ('admin', 'director'):
|
|
||||||
try:
|
|
||||||
from app.models.issue import Issue
|
from app.models.issue import Issue
|
||||||
pv_count = Issue.query.filter_by(
|
unread = Notification.query.filter_by(
|
||||||
status='pending_verification'
|
user_id=current_user.id, is_read=False
|
||||||
).count()
|
).count()
|
||||||
except Exception as exc:
|
# Pending verification count — only computed for director+ roles
|
||||||
import logging as _logging
|
pv_count = 0
|
||||||
_logging.getLogger(__name__).warning(
|
if current_user.role in ('admin', 'director'):
|
||||||
'inject_notification_count: pv_count query failed: %s', exc
|
pv_count = Issue.query.filter_by(
|
||||||
)
|
status='pending_verification'
|
||||||
try:
|
).count()
|
||||||
from app.models.support import SupportTicket
|
# Open support tickets — admin/director only
|
||||||
open_support = SupportTicket.query.filter_by(status='open').count()
|
open_support = 0
|
||||||
except Exception as exc:
|
if current_user.role in ('admin', 'director'):
|
||||||
import logging as _logging
|
from app.models.support import SupportTicket
|
||||||
_logging.getLogger(__name__).warning(
|
open_support = SupportTicket.query.filter_by(status='open').count()
|
||||||
'inject_notification_count: support_tickets query failed: %s', exc
|
return {
|
||||||
)
|
'unread_notification_count': unread,
|
||||||
|
'pending_verification_count': pv_count,
|
||||||
return {
|
'open_support_tickets_count': open_support,
|
||||||
'unread_notification_count': unread,
|
}
|
||||||
'pending_verification_count': pv_count,
|
except Exception:
|
||||||
'open_support_tickets_count': open_support,
|
pass
|
||||||
}
|
return {'unread_notification_count': 0, 'pending_verification_count': 0, 'open_support_tickets_count': 0}
|
||||||
|
|
||||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||||
|
|
||||||
@@ -164,8 +149,6 @@ def create_app(config_name='default'):
|
|||||||
from app.routes import customers # Phase 5 — Customer management
|
from app.routes import customers # Phase 5 — Customer management
|
||||||
from app.routes import scheduled_reports # Phase 6 — Scheduled reports
|
from app.routes import scheduled_reports # Phase 6 — Scheduled reports
|
||||||
from app.routes import support # Support chat + admin tickets
|
from app.routes import support # Support chat + admin tickets
|
||||||
from app.routes import broadcast # Admin broadcast notifications
|
|
||||||
from app.routes import devices # Admin device registry
|
|
||||||
|
|
||||||
app.register_blueprint(auth.bp)
|
app.register_blueprint(auth.bp)
|
||||||
app.register_blueprint(dashboard.bp)
|
app.register_blueprint(dashboard.bp)
|
||||||
@@ -180,8 +163,6 @@ def create_app(config_name='default'):
|
|||||||
app.register_blueprint(customers.bp)
|
app.register_blueprint(customers.bp)
|
||||||
app.register_blueprint(scheduled_reports.bp)
|
app.register_blueprint(scheduled_reports.bp)
|
||||||
app.register_blueprint(support.bp)
|
app.register_blueprint(support.bp)
|
||||||
app.register_blueprint(broadcast.bp)
|
|
||||||
app.register_blueprint(devices.bp)
|
|
||||||
|
|
||||||
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
|
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
|
||||||
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
|
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
|
||||||
@@ -199,7 +180,6 @@ def create_app(config_name='default'):
|
|||||||
from app.api.notifications import bp as _api_notifications_bp
|
from app.api.notifications import bp as _api_notifications_bp
|
||||||
from app.api.stats import bp as _api_stats_bp
|
from app.api.stats import bp as _api_stats_bp
|
||||||
from app.api.comments import bp as _api_comments_bp
|
from app.api.comments import bp as _api_comments_bp
|
||||||
from app.api.devices import bp as _api_devices_bp
|
|
||||||
csrf.exempt(_api_auth_bp)
|
csrf.exempt(_api_auth_bp)
|
||||||
csrf.exempt(_api_facilities_bp)
|
csrf.exempt(_api_facilities_bp)
|
||||||
csrf.exempt(_api_templates_bp)
|
csrf.exempt(_api_templates_bp)
|
||||||
@@ -209,7 +189,6 @@ def create_app(config_name='default'):
|
|||||||
csrf.exempt(_api_notifications_bp)
|
csrf.exempt(_api_notifications_bp)
|
||||||
csrf.exempt(_api_stats_bp)
|
csrf.exempt(_api_stats_bp)
|
||||||
csrf.exempt(_api_comments_bp)
|
csrf.exempt(_api_comments_bp)
|
||||||
csrf.exempt(_api_devices_bp)
|
|
||||||
register_api(app)
|
register_api(app)
|
||||||
|
|
||||||
# ── Security response headers ─────────────────────────────────────────
|
# ── Security response headers ─────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""
|
||||||
|
app/tenancy/
|
||||||
|
============
|
||||||
|
Host-based tenant resolution and per-tenant database routing (MT-1).
|
||||||
|
|
||||||
|
Public surface:
|
||||||
|
RoutingSession — tenant-aware session class (installed on `db`)
|
||||||
|
init_tenancy — registers the before_request resolver hook
|
||||||
|
TenantContext — detached descriptor carried on g.tenant
|
||||||
|
|
||||||
|
Inert unless config MULTI_TENANT_ENABLED is True.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.tenancy.routing import RoutingSession
|
||||||
|
from app.tenancy.middleware import init_tenancy
|
||||||
|
from app.tenancy.context import TenantContext
|
||||||
|
|
||||||
|
__all__ = ['RoutingSession', 'init_tenancy', 'TenantContext']
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
app/tenancy/context.py
|
||||||
|
----------------------
|
||||||
|
Lightweight, detached descriptor for the resolved tenant. Populated by the
|
||||||
|
resolver while a control-plane session is open, then carried on `g.tenant`
|
||||||
|
for the lifetime of the request. Holds no live ORM object — safe to use after
|
||||||
|
the control session closes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TenantContext:
|
||||||
|
id: int
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
plan_id: int
|
||||||
|
db_uri: str
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
app/tenancy/engine_cache.py
|
||||||
|
---------------------------
|
||||||
|
Process-local cache of per-tenant SQLAlchemy engines, keyed by tenant id.
|
||||||
|
|
||||||
|
Each tenant has its own database, hence its own engine + connection pool.
|
||||||
|
Engines are created lazily on first use and reused across requests. Total
|
||||||
|
backend connections ≈ workers × cached-tenants × pool_size, so pool sizing is
|
||||||
|
a real scaling lever (see MULTI_TENANT_PLAN.md §4); tune via config, or set a
|
||||||
|
small pool / switch to NullPool when the tenant count grows large.
|
||||||
|
|
||||||
|
`invalidate(tenant_id)` drops a cached engine (e.g. after credential rotation
|
||||||
|
or tenant suspension); the next request rebuilds it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
|
||||||
|
_engines = {}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_tenant_engine(tenant):
|
||||||
|
"""Return (building if needed) the cached engine for a TenantContext."""
|
||||||
|
engine = _engines.get(tenant.id)
|
||||||
|
if engine is not None:
|
||||||
|
return engine
|
||||||
|
with _lock:
|
||||||
|
engine = _engines.get(tenant.id)
|
||||||
|
if engine is None:
|
||||||
|
engine = create_engine(
|
||||||
|
tenant.db_uri,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=current_app.config.get('TENANT_ENGINE_POOL_SIZE', 5),
|
||||||
|
max_overflow=current_app.config.get('TENANT_ENGINE_MAX_OVERFLOW', 5),
|
||||||
|
pool_recycle=current_app.config.get('TENANT_ENGINE_POOL_RECYCLE', 1800),
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
|
_engines[tenant.id] = engine
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate(tenant_id):
|
||||||
|
"""Drop and dispose a cached tenant engine, if present."""
|
||||||
|
with _lock:
|
||||||
|
engine = _engines.pop(tenant_id, None)
|
||||||
|
if engine is not None:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def clear():
|
||||||
|
"""Dispose and drop all cached engines (test/teardown helper)."""
|
||||||
|
with _lock:
|
||||||
|
engines = list(_engines.values())
|
||||||
|
_engines.clear()
|
||||||
|
for engine in engines:
|
||||||
|
engine.dispose()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
app/tenancy/middleware.py
|
||||||
|
-------------------------
|
||||||
|
Wires tenant resolution into the Flask request lifecycle.
|
||||||
|
|
||||||
|
`init_tenancy(app)` registers a single app-level before_request handler that:
|
||||||
|
* always clears g.tenant / g.tenant_engine (so downstream code can rely on them)
|
||||||
|
* does NOTHING further when MULTI_TENANT_ENABLED is False → today's behaviour
|
||||||
|
* bypasses static + configured exempt paths (health checks)
|
||||||
|
* otherwise resolves the Host header to a tenant and selects its engine
|
||||||
|
* returns a 404 page for an unknown / unverified / suspended host
|
||||||
|
|
||||||
|
Flask-SQLAlchemy already removes the scoped session on app-context teardown,
|
||||||
|
so each request rebinds via RoutingSession.get_bind against the fresh
|
||||||
|
g.tenant_engine — no teardown handler is needed here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import g, request, current_app, Response
|
||||||
|
|
||||||
|
from app.tenancy.resolver import resolve_tenant
|
||||||
|
from app.tenancy.engine_cache import get_tenant_engine
|
||||||
|
|
||||||
|
_UNKNOWN_TENANT_PAGE = (
|
||||||
|
"<!doctype html><html lang='en'><head><meta charset='utf-8'>"
|
||||||
|
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
|
||||||
|
"<title>Workspace not found</title>"
|
||||||
|
"<style>body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;"
|
||||||
|
"background:#f6f7f9;color:#1f2937;display:flex;min-height:100vh;margin:0;"
|
||||||
|
"align-items:center;justify-content:center;text-align:center}"
|
||||||
|
".card{background:#fff;padding:2.5rem 3rem;border-radius:12px;"
|
||||||
|
"box-shadow:0 1px 4px rgba(0,0,0,.08);max-width:30rem}"
|
||||||
|
"h1{font-size:1.25rem;margin:0 0 .5rem}p{margin:.25rem 0;color:#6b7280}"
|
||||||
|
"</style></head><body><div class='card'>"
|
||||||
|
"<h1>Workspace not found</h1>"
|
||||||
|
"<p>This address isn’t linked to an active JQC workspace.</p>"
|
||||||
|
"<p>Check the URL, or contact your administrator.</p>"
|
||||||
|
"</div></body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_exempt(path):
|
||||||
|
if path.startswith('/static/'):
|
||||||
|
return True
|
||||||
|
for prefix in current_app.config.get('MULTI_TENANT_EXEMPT_PATHS', []):
|
||||||
|
if prefix and path.startswith(prefix):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def init_tenancy(app):
|
||||||
|
@app.before_request
|
||||||
|
def _resolve_tenant():
|
||||||
|
# Default state — referenced safely by downstream code regardless of mode.
|
||||||
|
g.tenant = None
|
||||||
|
g.tenant_engine = None
|
||||||
|
|
||||||
|
if not current_app.config.get('MULTI_TENANT_ENABLED', False):
|
||||||
|
return # inert: default database serves everything (single-tenant)
|
||||||
|
|
||||||
|
if _is_exempt(request.path):
|
||||||
|
return
|
||||||
|
|
||||||
|
host = (request.host or '').split(':')[0].strip().lower()
|
||||||
|
tenant = resolve_tenant(host)
|
||||||
|
if tenant is None:
|
||||||
|
return Response(_UNKNOWN_TENANT_PAGE, status=404, mimetype='text/html')
|
||||||
|
|
||||||
|
g.tenant = tenant
|
||||||
|
g.tenant_engine = get_tenant_engine(tenant)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""
|
||||||
|
app/tenancy/resolver.py
|
||||||
|
-----------------------
|
||||||
|
Resolve an incoming Host header to a tenant by querying the control plane.
|
||||||
|
|
||||||
|
The `control` package is imported lazily inside the function so that the data
|
||||||
|
plane carries no import-time dependency on the control plane when
|
||||||
|
multi-tenancy is disabled.
|
||||||
|
|
||||||
|
Resolution rules:
|
||||||
|
* exact match on tenant_domains.domain (host, lowercased, port stripped)
|
||||||
|
* tenant must be status='active'
|
||||||
|
* custom domains must be verified; subdomains we issue are trusted
|
||||||
|
Returns a detached TenantContext or None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.tenancy.context import TenantContext
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_tenant(host):
|
||||||
|
if not host:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Lazy import — keeps control plane optional when MT is disabled.
|
||||||
|
from control.base import control_session
|
||||||
|
from control.models import TenantDomain
|
||||||
|
|
||||||
|
with control_session() as s:
|
||||||
|
domain = (
|
||||||
|
s.query(TenantDomain)
|
||||||
|
.filter(TenantDomain.domain == host)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if domain is None:
|
||||||
|
return None
|
||||||
|
if domain.kind == 'custom' and not domain.verified:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tenant = domain.tenant
|
||||||
|
if tenant is None or tenant.status != 'active':
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Materialise everything needed while the session is still open
|
||||||
|
# (db_uri decrypts the stored credential).
|
||||||
|
return TenantContext(
|
||||||
|
id=tenant.id,
|
||||||
|
slug=tenant.slug,
|
||||||
|
name=tenant.name,
|
||||||
|
plan_id=tenant.plan_id,
|
||||||
|
db_uri=tenant.db_uri,
|
||||||
|
)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""
|
||||||
|
app/tenancy/routing.py
|
||||||
|
----------------------
|
||||||
|
RoutingSession — the per-request tenant-aware SQLAlchemy session.
|
||||||
|
|
||||||
|
Subclasses Flask-SQLAlchemy's own Session so that all existing behaviour
|
||||||
|
(default bind, __bind_key__ resolution) is preserved. The ONLY change: when a
|
||||||
|
tenant engine has been selected for the current request (g.tenant_engine, set
|
||||||
|
by the resolver middleware), every query binds to that engine instead.
|
||||||
|
|
||||||
|
When no tenant engine is present — multi-tenancy disabled, an exempt path, a
|
||||||
|
CLI invocation, or any non-request context — this falls through to the normal
|
||||||
|
Flask-SQLAlchemy behaviour, i.e. the app's configured default database. This
|
||||||
|
makes the routing layer completely inert until a tenant is actually resolved,
|
||||||
|
so the existing single-tenant deployment is unaffected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import g, has_app_context
|
||||||
|
from flask_sqlalchemy.session import Session as _FlaskSQLAlchemySession
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingSession(_FlaskSQLAlchemySession):
|
||||||
|
def get_bind(self, mapper=None, clause=None, bind=None, **kwargs):
|
||||||
|
# Respect an explicitly supplied bind (engine-targeted operations).
|
||||||
|
if bind is None and has_app_context():
|
||||||
|
tenant_engine = g.get('tenant_engine', None)
|
||||||
|
if tenant_engine is not None:
|
||||||
|
return tenant_engine
|
||||||
|
return super().get_bind(mapper=mapper, clause=clause, bind=bind, **kwargs)
|
||||||
@@ -31,6 +31,27 @@ class Config:
|
|||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
SQLALCHEMY_ECHO = 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 ────────────────────────────────────────────────────────
|
# ── File uploads ────────────────────────────────────────────────────────
|
||||||
UPLOAD_FOLDER = os.path.join(basedir, 'app/static/uploads')
|
UPLOAD_FOLDER = os.path.join(basedir, 'app/static/uploads')
|
||||||
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50 MB
|
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50 MB
|
||||||
|
|||||||
Reference in New Issue
Block a user