Jun 27 MT-5

This commit is contained in:
2026-06-27 12:56:11 -04:00
parent 668cb645e2
commit 7505e82ab4
6 changed files with 344 additions and 10 deletions
+16 -10
View File
@@ -1,18 +1,24 @@
"""
app/tenancy/
============
Host-based tenant resolution and per-tenant database routing (MT-1).
app/tenancy/__init__.py
-----------------------
Public exports for the tenancy package.
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.
MT-1: RoutingSession, init_tenancy, TenantContext
MT-5: feature_required, quota_soft_check, check_quota, check_feature
"""
from app.tenancy.routing import RoutingSession
from app.tenancy.middleware import init_tenancy
from app.tenancy.context import TenantContext
from app.tenancy.gates import feature_required, quota_soft_check
from app.tenancy.quota import check_quota, check_feature
__all__ = ['RoutingSession', 'init_tenancy', 'TenantContext']
__all__ = [
'RoutingSession',
'init_tenancy',
'TenantContext',
'feature_required',
'quota_soft_check',
'check_quota',
'check_feature',
]
+19
View File
@@ -5,9 +5,13 @@ 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.
MT-5: plan feature flags and quota limits are included so quota.py and
gates.py can read them from g.tenant without a second control-DB round-trip.
"""
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
@@ -17,3 +21,18 @@ class TenantContext:
name: str
plan_id: int
db_uri: str
# MT-5: plan fields (None = unlimited / not loaded)
plan_code: Optional[str] = None
# Quota limits (None = unlimited)
max_users: Optional[int] = None
max_facilities: Optional[int] = None
max_inspections_month: Optional[int] = None
max_issues_month: Optional[int] = None
# Feature gates
allow_mobile_api: bool = True
allow_scheduled_reports: bool = True
allow_branding: bool = True
allow_custom_domain: bool = True
+119
View File
@@ -0,0 +1,119 @@
"""
app/tenancy/gates.py
---------------------
MT-5 enforcement decorators.
@feature_required('mobile_api')
Blocks the route (403 web / JSON) when the tenant's plan doesn't include
the named feature. Inert when MULTI_TENANT_ENABLED=False.
@quota_soft_check('inspections')
Soft quota enforcement per MULTI_TENANT_PLAN.md §6:
- Never blocks the request.
- When over limit, sets g.quota_warning = QuotaStatus so the route/
template can show an upgrade prompt.
- Logs the event for future billing metrics.
Usage
-----
Web route:
@bp.route('/new', methods=['GET','POST'])
@login_required
@feature_required('scheduled_reports') # hard block if not on plan
def create(): ...
@bp.route('/<int:id>/submit', methods=['POST'])
@login_required
@quota_soft_check('inspections') # soft warn, never reject
def submit(id): ...
API route:
@bp.route('', methods=['POST'])
@jwt_required
@feature_required('mobile_api')
@quota_soft_check('inspections')
def create_inspection(): ...
Template usage (quota warning banner):
{% if g.quota_warning and g.quota_warning.exceeded %}
<div class="alert alert-warning">
You have reached your {{ g.quota_warning.axis }} limit
({{ g.quota_warning.current }}/{{ g.quota_warning.limit }}).
<a href="#">Upgrade your plan</a> for more.
</div>
{% endif %}
"""
import logging
from functools import wraps
from flask import g, request, jsonify
from app.tenancy.quota import check_feature, check_quota
logger = logging.getLogger(__name__)
def _is_api_request():
"""True when the request is to an /api/ path or expects JSON."""
return (request.path.startswith('/api/')
or request.accept_mimetypes.best == 'application/json')
def feature_required(feature_key: str):
"""
Hard-block decorator. Returns 403 if the tenant plan doesn't include
feature_key. Transparent when MT is disabled.
"""
def decorator(f):
@wraps(f)
def decorated(*args, **kwargs):
if not check_feature(feature_key):
tenant = getattr(g, 'tenant', None)
slug = tenant.slug if tenant else '?'
logger.warning(
'GATE | feature_blocked | feature=%s tenant=%s path=%s',
feature_key, slug, request.path,
)
if _is_api_request():
return jsonify({
'ok': False,
'error': f'Feature "{feature_key}" is not available on your current plan.',
'upgrade_required': True,
}), 403
# Web: flash + redirect back
from flask import flash, redirect, url_for
flash(
f'This feature is not available on your current plan. '
f'Please upgrade to access it.',
'warning',
)
return redirect(url_for('dashboard.index'))
return f(*args, **kwargs)
return decorated
return decorator
def quota_soft_check(axis: str):
"""
Soft quota decorator — never blocks the request.
Sets g.quota_warning = QuotaStatus when the current count >= plan limit.
The route or template reads g.quota_warning to show an upgrade prompt.
"""
def decorator(f):
@wraps(f)
def decorated(*args, **kwargs):
g.quota_warning = None
status = check_quota(axis)
if status is not None and status.exceeded:
g.quota_warning = status
tenant = getattr(g, 'tenant', None)
slug = tenant.slug if tenant else '?'
logger.info(
'QUOTA | soft_exceeded | axis=%s tenant=%s current=%s limit=%s path=%s',
axis, slug, status.current, status.limit, request.path,
)
return f(*args, **kwargs)
return decorated
return decorator
+156
View File
@@ -0,0 +1,156 @@
"""
app/tenancy/quota.py
---------------------
Live quota helpers for MT-5.
Counts current-month rows directly in the tenant DB — no counter table, always
accurate. Plan limits are read from g.tenant (loaded once by the resolver) so
no second control-DB round-trip is needed.
Quota axes:
inspections → Inspection.inspection_date in current month, status='completed'
issues → Issue.created_at in current month
users → User.active == True (total, not monthly)
facilities → Facility.active == True (total, not monthly)
Returns None when MULTI_TENANT_ENABLED is False or g.tenant is absent —
all quota checks pass, single-tenant behaviour unchanged.
"""
import logging
from datetime import datetime
from flask import g, current_app
logger = logging.getLogger(__name__)
def _mt_enabled():
return current_app.config.get('MULTI_TENANT_ENABLED', False)
def _month_window():
now = datetime.now()
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if now.month == 12:
end = now.replace(year=now.year + 1, month=1, day=1,
hour=0, minute=0, second=0, microsecond=0)
else:
end = now.replace(month=now.month + 1, day=1,
hour=0, minute=0, second=0, microsecond=0)
return start, end
def count_inspections_this_month():
from app.models.inspection import Inspection
start, end = _month_window()
return (Inspection.query
.filter(Inspection.status == 'completed',
Inspection.inspection_date >= start,
Inspection.inspection_date < end)
.count())
def count_issues_this_month():
from app.models.issue import Issue
start, end = _month_window()
return (Issue.query
.filter(Issue.created_at >= start,
Issue.created_at < end)
.count())
def count_active_users():
from app.models.user import User
return User.query.filter_by(active=True).count()
def count_active_facilities():
from app.models.facility import Facility
return Facility.query.filter_by(active=True).count()
# ── Public interface ──────────────────────────────────────────────────────────
class QuotaStatus:
__slots__ = ('axis', 'limit', 'current', 'exceeded')
def __init__(self, axis, limit, current):
self.axis = axis
self.limit = limit
self.current = current
self.exceeded = (limit is not None and current >= limit)
def __repr__(self):
return (f'<QuotaStatus {self.axis} {self.current}/{self.limit} '
f'exceeded={self.exceeded}>')
def check_quota(axis: str):
"""
Check one quota axis. Returns QuotaStatus or None (MT disabled / no tenant).
Reads plan limits from g.tenant — no extra DB query.
"""
if not _mt_enabled():
return None
tenant = getattr(g, 'tenant', None)
if tenant is None:
return None
_limit_attrs = {
'inspections': 'max_inspections_month',
'issues': 'max_issues_month',
'users': 'max_users',
'facilities': 'max_facilities',
}
_counter_fns = {
'inspections': count_inspections_this_month,
'issues': count_issues_this_month,
'users': count_active_users,
'facilities': count_active_facilities,
}
if axis not in _limit_attrs:
logger.warning('quota.check_quota: unknown axis %r', axis)
return None
limit = getattr(tenant, _limit_attrs[axis], None)
if limit is None:
# Unlimited — still return a status so caller can display current usage
try:
current = _counter_fns[axis]()
except Exception as exc:
logger.error('quota.check_quota count failed axis=%s err=%s', axis, exc)
return None
return QuotaStatus(axis=axis, limit=None, current=current)
try:
current = _counter_fns[axis]()
except Exception as exc:
logger.error('quota.check_quota count failed axis=%s err=%s', axis, exc)
return None
return QuotaStatus(axis=axis, limit=limit, current=current)
def check_feature(feature_key: str) -> bool:
"""
Return True if the current tenant's plan allows feature_key.
Reads from g.tenant — no extra DB query.
Returns True when MT is disabled.
"""
if not _mt_enabled():
return True
tenant = getattr(g, 'tenant', None)
if tenant is None:
return True
_gate_attrs = {
'mobile_api': 'allow_mobile_api',
'scheduled_reports': 'allow_scheduled_reports',
'branding': 'allow_branding',
'custom_domain': 'allow_custom_domain',
}
attr = _gate_attrs.get(feature_key)
if attr is None:
return True # unknown feature → fail open
return getattr(tenant, attr, True)
+16
View File
@@ -11,6 +11,10 @@ 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
MT-5: plan fields are loaded in the same session and stored on TenantContext
so quota.py / gates.py can read them without a second control-DB query.
Returns a detached TenantContext or None.
"""
@@ -40,6 +44,8 @@ def resolve_tenant(host):
if tenant is None or tenant.status != 'active':
return None
plan = tenant.plan # relationship already loaded via joined session
# Materialise everything needed while the session is still open
# (db_uri decrypts the stored credential).
return TenantContext(
@@ -48,4 +54,14 @@ def resolve_tenant(host):
name=tenant.name,
plan_id=tenant.plan_id,
db_uri=tenant.db_uri,
# MT-5: plan fields
plan_code=plan.code if plan else None,
max_users=plan.max_users if plan else None,
max_facilities=plan.max_facilities if plan else None,
max_inspections_month=plan.max_inspections_month if plan else None,
max_issues_month=plan.max_issues_month if plan else None,
allow_mobile_api=plan.allow_mobile_api if plan else True,
allow_scheduled_reports=plan.allow_scheduled_reports if plan else True,
allow_branding=plan.allow_branding if plan else True,
allow_custom_domain=plan.allow_custom_domain if plan else True,
)