Files
orion/app/modules/billing/dependencies/feature_gate.py
Samir Boulahtit f20266167d
Some checks failed
CI / ruff (push) Failing after 7s
CI / pytest (push) Failing after 1s
CI / architecture (push) Failing after 9s
CI / dependency-scanning (push) Successful in 27s
CI / audit (push) Successful in 8s
CI / docs (push) Has been skipped
fix(lint): auto-fix ruff violations and tune lint rules
- Auto-fixed 4,496 lint issues (import sorting, modern syntax, etc.)
- Added ignore rules for patterns intentional in this codebase:
  E402 (late imports), E712 (SQLAlchemy filters), B904 (raise from),
  SIM108/SIM105/SIM117 (readability preferences)
- Added per-file ignores for tests and scripts
- Excluded broken scripts/rename_terminology.py (has curly quotes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:10:42 +01:00

218 lines
6.5 KiB
Python

# app/modules/billing/dependencies/feature_gate.py
"""
Feature gating decorator and dependencies for tier-based access control.
Resolves store → merchant → subscription → tier → TierFeatureLimit.
Provides:
- @require_feature decorator for endpoints
- RequireFeature dependency for flexible usage
- RequireWithinLimit dependency for quantitative checks
- FeatureNotAvailableError exception with upgrade info
Usage:
# As decorator (simple)
@router.get("/analytics")
@require_feature("analytics_dashboard")
def get_analytics(...):
...
# As dependency (more control)
@router.get("/analytics")
def get_analytics(
_: None = Depends(RequireFeature("analytics_dashboard")),
...
):
...
# Quantitative limit check
@router.post("/products")
def create_product(
_: None = Depends(RequireWithinLimit("products_limit")),
...
):
...
"""
import asyncio
import functools
import logging
from collections.abc import Callable
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.deps import get_current_store_api
from app.core.database import get_db
from app.modules.billing.services.feature_service import feature_service
from app.modules.tenancy.models import User
logger = logging.getLogger(__name__)
class FeatureNotAvailableError(HTTPException):
"""
Exception raised when a feature is not available for the merchant's tier.
Includes upgrade information for the frontend to display.
"""
def __init__(
self,
feature_code: str,
feature_name: str | None = None,
required_tier_code: str | None = None,
required_tier_name: str | None = None,
required_tier_price_cents: int | None = None,
):
detail = {
"error": "feature_not_available",
"message": "This feature requires an upgrade to access.",
"feature_code": feature_code,
"feature_name": feature_name,
"upgrade": {
"tier_code": required_tier_code,
"tier_name": required_tier_name,
"price_monthly_cents": required_tier_price_cents,
}
if required_tier_code
else None,
}
super().__init__(status_code=403, detail=detail)
class RequireFeature:
"""
Dependency class that checks if store's merchant has access to a feature.
Resolves store → merchant → subscription → tier → TierFeatureLimit.
Args:
*feature_codes: One or more feature codes. Access granted if ANY is available.
"""
def __init__(self, *feature_codes: str):
if not feature_codes:
raise ValueError("At least one feature code is required")
self.feature_codes = feature_codes
def __call__(
self,
current_user: User = Depends(get_current_store_api),
db: Session = Depends(get_db),
) -> None:
"""Check if store's merchant has access to any of the required features."""
store_id = current_user.token_store_id
for feature_code in self.feature_codes:
if feature_service.has_feature_for_store(db, store_id, feature_code):
return
# None of the features are available
feature_code = self.feature_codes[0]
raise FeatureNotAvailableError(feature_code=feature_code)
class RequireWithinLimit:
"""
Dependency that checks a quantitative resource limit.
Resolves store → merchant → subscription → tier → TierFeatureLimit,
then checks current usage against the limit.
Args:
feature_code: The quantitative feature to check (e.g., "products_limit")
"""
def __init__(self, feature_code: str):
self.feature_code = feature_code
def __call__(
self,
current_user: User = Depends(get_current_store_api),
db: Session = Depends(get_db),
) -> None:
"""Check if the resource limit allows adding more items."""
store_id = current_user.token_store_id
allowed, message = feature_service.check_resource_limit(
db, self.feature_code, store_id=store_id
)
if not allowed:
raise HTTPException(
status_code=403,
detail={
"error": "limit_exceeded",
"message": message,
"feature_code": self.feature_code,
},
)
def require_feature(*feature_codes: str) -> Callable:
"""
Decorator to require one or more features for an endpoint.
The decorated endpoint will return 403 if the store's merchant
doesn't have access to ANY of the specified features.
Args:
*feature_codes: One or more feature codes. Access granted if ANY is available.
"""
if not feature_codes:
raise ValueError("At least one feature code is required")
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
db = kwargs.get("db")
current_user = kwargs.get("current_user")
if not db or not current_user:
raise HTTPException(
status_code=500,
detail="Feature check failed: missing db or current_user dependency",
)
store_id = current_user.token_store_id
for feature_code in feature_codes:
if feature_service.has_feature_for_store(db, store_id, feature_code):
return await func(*args, **kwargs)
raise FeatureNotAvailableError(feature_code=feature_codes[0])
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
db = kwargs.get("db")
current_user = kwargs.get("current_user")
if not db or not current_user:
raise HTTPException(
status_code=500,
detail="Feature check failed: missing db or current_user dependency",
)
store_id = current_user.token_store_id
for feature_code in feature_codes:
if feature_service.has_feature_for_store(db, store_id, feature_code):
return func(*args, **kwargs)
raise FeatureNotAvailableError(feature_code=feature_codes[0])
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
return decorator
__all__ = [
"require_feature",
"RequireFeature",
"RequireWithinLimit",
"FeatureNotAvailableError",
]