Files
2026-06-27 12:56:11 -04:00

120 lines
4.0 KiB
Python

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