""" 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