Jun 27 MT-5
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user