# 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 app.exceptions.feature import ( FeatureNotFoundError, InvalidFeatureCodesError, TierNotFoundError, ) 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 get_all_tiers_with_features(self, db: Session) -> list[SubscriptionTier]: """Get all active tiers with their features for admin.""" return ( db.query(SubscriptionTier) .filter(SubscriptionTier.is_active == True) # noqa: E712 .order_by(SubscriptionTier.display_order) .all() ) def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier: """ Get tier by code, raising exception if not found. Raises: TierNotFoundError: If tier not found """ tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first() if not tier: raise TierNotFoundError(tier_code) return tier def get_tier_features_with_details( self, db: Session, tier_code: str ) -> tuple[SubscriptionTier, list[Feature]]: """ Get tier with full feature details. Returns: Tuple of (tier, list of Feature objects) Raises: TierNotFoundError: If tier not found """ tier = self.get_tier_by_code(db, tier_code) feature_codes = tier.features or [] features = ( db.query(Feature) .filter(Feature.code.in_(feature_codes)) .order_by(Feature.category, Feature.display_order) .all() ) return tier, features 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 Raises: TierNotFoundError: If tier not found InvalidFeatureCodesError: If any feature codes are invalid """ tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first() if not tier: raise TierNotFoundError(tier_code) # Validate feature codes exist # noqa: SVC-005 - Features are platform-level, not vendor-scoped 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 InvalidFeatureCodesError(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( self, db: Session, feature_code: str, name: str | None = None, description: str | None = None, category: str | None = None, ui_location: str | None = None, ui_icon: str | None = None, ui_route: str | None = None, ui_badge_text: str | None = None, minimum_tier_code: str | None = None, is_active: bool | None = None, is_visible: bool | None = None, display_order: int | None = None, ) -> Feature: """ Update feature metadata. Args: db: Database session feature_code: Feature code to update ... other optional fields to update Returns: Updated feature Raises: FeatureNotFoundError: If feature not found TierNotFoundError: If minimum_tier_code provided but not found """ feature = ( db.query(Feature) .options(joinedload(Feature.minimum_tier)) .filter(Feature.code == feature_code) .first() ) if not feature: raise FeatureNotFoundError(feature_code) # Update fields if provided if name is not None: feature.name = name if description is not None: feature.description = description if category is not None: feature.category = category if ui_location is not None: feature.ui_location = ui_location if ui_icon is not None: feature.ui_icon = ui_icon if ui_route is not None: feature.ui_route = ui_route if ui_badge_text is not None: feature.ui_badge_text = ui_badge_text if is_active is not None: feature.is_active = is_active if is_visible is not None: feature.is_visible = is_visible if display_order is not None: feature.display_order = display_order # Update minimum tier if provided if minimum_tier_code is not None: if minimum_tier_code == "": feature.minimum_tier_id = None else: tier = ( db.query(SubscriptionTier) .filter(SubscriptionTier.code == minimum_tier_code) .first() ) if not tier: raise TierNotFoundError(minimum_tier_code) feature.minimum_tier_id = tier.id logger.info(f"Updated feature {feature_code}") return feature 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 Raises: FeatureNotFoundError: If feature not found TierNotFoundError: If tier_code provided but not found """ feature = db.query(Feature).filter(Feature.code == feature_code).first() if not feature: raise FeatureNotFoundError(feature_code) if tier_code: tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first() if not tier: raise TierNotFoundError(tier_code) 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", ]