Files
orion/app/services/feature_service.py
Samir Boulahtit 7d1a421826 feat: add comprehensive tier-based feature management system
Implement database-driven feature gating with contextual upgrade prompts:

- Add Feature model with 30 features across 8 categories
- Create FeatureService with caching for tier-based feature checking
- Add @require_feature decorator and RequireFeature dependency for backend enforcement
- Create vendor features API (6 endpoints) and admin features API
- Add Alpine.js feature store and upgrade prompts store for frontend
- Create Jinja macros: feature_gate, feature_locked, limit_warning, usage_bar
- Add usage API for tracking orders/products/team limits with upgrade info
- Fix Stripe webhook to create VendorAddOn records on addon purchase
- Integrate upgrade prompts into vendor dashboard with tier badge and usage bars

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 18:28:40 +01:00

452 lines
14 KiB
Python

# app/services/feature_service.py
"""
Feature service for tier-based access control.
Provides:
- Feature availability checking with caching
- Vendor feature listing for API/UI
- Feature metadata for upgrade prompts
- Cache invalidation on subscription changes
Usage:
from app.services.feature_service import feature_service
# Check if vendor has feature
if feature_service.has_feature(db, vendor_id, FeatureCode.ANALYTICS_DASHBOARD):
...
# Get all features available to vendor
features = feature_service.get_vendor_features(db, vendor_id)
# Get feature info for upgrade prompt
info = feature_service.get_feature_upgrade_info(db, "analytics_dashboard")
"""
import logging
import time
from dataclasses import dataclass
from functools import lru_cache
from sqlalchemy.orm import Session, joinedload
from models.database.feature import Feature, FeatureCode
from models.database.subscription import SubscriptionTier, VendorSubscription
logger = logging.getLogger(__name__)
@dataclass
class FeatureInfo:
"""Feature information for API responses."""
code: str
name: str
description: str | None
category: str
ui_location: str | None
ui_icon: str | None
ui_route: str | None
ui_badge_text: str | None
is_available: bool
minimum_tier_code: str | None
minimum_tier_name: str | None
@dataclass
class FeatureUpgradeInfo:
"""Information for upgrade prompts."""
feature_code: str
feature_name: str
feature_description: str | None
required_tier_code: str
required_tier_name: str
required_tier_price_monthly_cents: int
class FeatureCache:
"""
In-memory cache for vendor features.
Caches vendor_id -> set of feature codes with TTL.
Invalidated when subscription changes.
"""
def __init__(self, ttl_seconds: int = 300):
self._cache: dict[int, tuple[set[str], float]] = {}
self._ttl = ttl_seconds
def get(self, vendor_id: int) -> set[str] | None:
"""Get cached features for vendor, or None if not cached/expired."""
if vendor_id not in self._cache:
return None
features, timestamp = self._cache[vendor_id]
if time.time() - timestamp > self._ttl:
del self._cache[vendor_id]
return None
return features
def set(self, vendor_id: int, features: set[str]) -> None:
"""Cache features for vendor."""
self._cache[vendor_id] = (features, time.time())
def invalidate(self, vendor_id: int) -> None:
"""Invalidate cache for vendor."""
self._cache.pop(vendor_id, None)
def invalidate_all(self) -> None:
"""Invalidate entire cache."""
self._cache.clear()
class FeatureService:
"""
Service for feature-based access control.
Provides methods to check feature availability and get feature metadata.
Uses in-memory caching with TTL for performance.
"""
def __init__(self):
self._cache = FeatureCache(ttl_seconds=300) # 5 minute cache
self._feature_registry_cache: dict[str, Feature] | None = None
self._feature_registry_timestamp: float = 0
# =========================================================================
# Feature Availability
# =========================================================================
def has_feature(self, db: Session, vendor_id: int, feature_code: str) -> bool:
"""
Check if vendor has access to a specific feature.
Args:
db: Database session
vendor_id: Vendor ID
feature_code: Feature code (use FeatureCode constants)
Returns:
True if vendor has access to the feature
"""
vendor_features = self._get_vendor_feature_codes(db, vendor_id)
return feature_code in vendor_features
def get_vendor_feature_codes(self, db: Session, vendor_id: int) -> set[str]:
"""
Get set of feature codes available to vendor.
Args:
db: Database session
vendor_id: Vendor ID
Returns:
Set of feature codes the vendor has access to
"""
return self._get_vendor_feature_codes(db, vendor_id)
def _get_vendor_feature_codes(self, db: Session, vendor_id: int) -> set[str]:
"""Internal method with caching."""
# Check cache first
cached = self._cache.get(vendor_id)
if cached is not None:
return cached
# Get subscription with tier relationship
subscription = (
db.query(VendorSubscription)
.options(joinedload(VendorSubscription.tier_obj))
.filter(VendorSubscription.vendor_id == vendor_id)
.first()
)
if not subscription:
logger.warning(f"No subscription found for vendor {vendor_id}")
return set()
# Get features from tier
tier = subscription.tier_obj
if tier and tier.features:
features = set(tier.features)
else:
# Fallback: query tier by code
tier = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.code == subscription.tier)
.first()
)
features = set(tier.features) if tier and tier.features else set()
# Cache and return
self._cache.set(vendor_id, features)
return features
# =========================================================================
# Feature Listing
# =========================================================================
def get_vendor_features(
self,
db: Session,
vendor_id: int,
category: str | None = None,
include_unavailable: bool = True,
) -> list[FeatureInfo]:
"""
Get all features with availability status for vendor.
Args:
db: Database session
vendor_id: Vendor ID
category: Optional category filter
include_unavailable: Include features not available to vendor
Returns:
List of FeatureInfo with is_available flag
"""
vendor_features = self._get_vendor_feature_codes(db, vendor_id)
# Query all active features
query = db.query(Feature).filter(Feature.is_active == True) # noqa: E712
if category:
query = query.filter(Feature.category == category)
if not include_unavailable:
# Only return features the vendor has
query = query.filter(Feature.code.in_(vendor_features))
features = (
query.options(joinedload(Feature.minimum_tier))
.order_by(Feature.category, Feature.display_order)
.all()
)
result = []
for feature in features:
result.append(
FeatureInfo(
code=feature.code,
name=feature.name,
description=feature.description,
category=feature.category,
ui_location=feature.ui_location,
ui_icon=feature.ui_icon,
ui_route=feature.ui_route,
ui_badge_text=feature.ui_badge_text,
is_available=feature.code in vendor_features,
minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None,
minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None,
)
)
return result
def get_available_feature_codes(self, db: Session, vendor_id: int) -> list[str]:
"""
Get list of feature codes available to vendor (for frontend).
Simple list for x-feature directive checks.
"""
return list(self._get_vendor_feature_codes(db, vendor_id))
# =========================================================================
# Feature Metadata
# =========================================================================
def get_feature_by_code(self, db: Session, feature_code: str) -> Feature | None:
"""Get feature by code."""
return (
db.query(Feature)
.options(joinedload(Feature.minimum_tier))
.filter(Feature.code == feature_code)
.first()
)
def get_feature_upgrade_info(
self, db: Session, feature_code: str
) -> FeatureUpgradeInfo | None:
"""
Get upgrade information for a feature.
Used for upgrade prompts when a feature is not available.
"""
feature = self.get_feature_by_code(db, feature_code)
if not feature or not feature.minimum_tier:
return None
tier = feature.minimum_tier
return FeatureUpgradeInfo(
feature_code=feature.code,
feature_name=feature.name,
feature_description=feature.description,
required_tier_code=tier.code,
required_tier_name=tier.name,
required_tier_price_monthly_cents=tier.price_monthly_cents,
)
def get_all_features(
self,
db: Session,
category: str | None = None,
active_only: bool = True,
) -> list[Feature]:
"""Get all features (for admin)."""
query = db.query(Feature).options(joinedload(Feature.minimum_tier))
if active_only:
query = query.filter(Feature.is_active == True) # noqa: E712
if category:
query = query.filter(Feature.category == category)
return query.order_by(Feature.category, Feature.display_order).all()
def get_features_by_tier(self, db: Session, tier_code: str) -> list[str]:
"""Get feature codes for a specific tier."""
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
if not tier or not tier.features:
return []
return tier.features
# =========================================================================
# Feature Categories
# =========================================================================
def get_categories(self, db: Session) -> list[str]:
"""Get all unique feature categories."""
result = (
db.query(Feature.category)
.filter(Feature.is_active == True) # noqa: E712
.distinct()
.order_by(Feature.category)
.all()
)
return [row[0] for row in result]
def get_features_grouped_by_category(
self, db: Session, vendor_id: int
) -> dict[str, list[FeatureInfo]]:
"""Get features grouped by category with availability."""
features = self.get_vendor_features(db, vendor_id, include_unavailable=True)
grouped: dict[str, list[FeatureInfo]] = {}
for feature in features:
if feature.category not in grouped:
grouped[feature.category] = []
grouped[feature.category].append(feature)
return grouped
# =========================================================================
# Cache Management
# =========================================================================
def invalidate_vendor_cache(self, vendor_id: int) -> None:
"""
Invalidate cache for a specific vendor.
Call this when:
- Vendor's subscription tier changes
- Tier features are updated (for all vendors on that tier)
"""
self._cache.invalidate(vendor_id)
logger.debug(f"Invalidated feature cache for vendor {vendor_id}")
def invalidate_all_cache(self) -> None:
"""
Invalidate entire cache.
Call this when tier features are modified in admin.
"""
self._cache.invalidate_all()
logger.debug("Invalidated all feature caches")
# =========================================================================
# Admin Operations
# =========================================================================
def update_tier_features(
self, db: Session, tier_code: str, feature_codes: list[str]
) -> SubscriptionTier:
"""
Update features for a tier (admin operation).
Args:
db: Database session
tier_code: Tier code
feature_codes: List of feature codes to assign
Returns:
Updated tier
"""
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
if not tier:
raise ValueError(f"Tier '{tier_code}' not found")
# Validate feature codes exist
valid_codes = {
f.code for f in db.query(Feature.code).filter(Feature.is_active == True).all() # noqa: E712
}
invalid = set(feature_codes) - valid_codes
if invalid:
raise ValueError(f"Invalid feature codes: {invalid}")
tier.features = feature_codes
# Invalidate all caches since tier features changed
self.invalidate_all_cache()
logger.info(f"Updated features for tier {tier_code}: {len(feature_codes)} features")
return tier
def update_feature_minimum_tier(
self, db: Session, feature_code: str, tier_code: str | None
) -> Feature:
"""
Update minimum tier for a feature (for upgrade prompts).
Args:
db: Database session
feature_code: Feature code
tier_code: Tier code or None
"""
feature = db.query(Feature).filter(Feature.code == feature_code).first()
if not feature:
raise ValueError(f"Feature '{feature_code}' not found")
if tier_code:
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
if not tier:
raise ValueError(f"Tier '{tier_code}' not found")
feature.minimum_tier_id = tier.id
else:
feature.minimum_tier_id = None
logger.info(f"Updated minimum tier for feature {feature_code}: {tier_code}")
return feature
# Singleton instance
feature_service = FeatureService()
# ============================================================================
# Convenience Exports
# ============================================================================
# Re-export FeatureCode for easy imports
__all__ = [
"feature_service",
"FeatureService",
"FeatureInfo",
"FeatureUpgradeInfo",
"FeatureCode",
]