diff --git a/app/templates/_quota_warning.html b/app/templates/_quota_warning.html new file mode 100644 index 0000000..7a939ff --- /dev/null +++ b/app/templates/_quota_warning.html @@ -0,0 +1,18 @@ +{# _quota_warning.html + Include at the top of any template whose route uses @quota_soft_check. + Renders nothing when g.quota_warning is absent or limit not exceeded. +#} +{% if g.quota_warning and g.quota_warning.exceeded %} + +{% endif %} diff --git a/app/tenancy/__init__.py b/app/tenancy/__init__.py index 9f7a143..b782888 100644 --- a/app/tenancy/__init__.py +++ b/app/tenancy/__init__.py @@ -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', +] diff --git a/app/tenancy/context.py b/app/tenancy/context.py index 90cbc5a..b155b8c 100644 --- a/app/tenancy/context.py +++ b/app/tenancy/context.py @@ -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 diff --git a/app/tenancy/gates.py b/app/tenancy/gates.py new file mode 100644 index 0000000..329a6b0 --- /dev/null +++ b/app/tenancy/gates.py @@ -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('//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 %} +
+ You have reached your {{ g.quota_warning.axis }} limit + ({{ g.quota_warning.current }}/{{ g.quota_warning.limit }}). + Upgrade your plan for more. +
+ {% 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 diff --git a/app/tenancy/quota.py b/app/tenancy/quota.py new file mode 100644 index 0000000..aa8640a --- /dev/null +++ b/app/tenancy/quota.py @@ -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'') + + +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) diff --git a/app/tenancy/resolver.py b/app/tenancy/resolver.py index f4e846b..62b16ed 100644 --- a/app/tenancy/resolver.py +++ b/app/tenancy/resolver.py @@ -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, )