120 lines
4.0 KiB
Python
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
|