Templates Migration: - Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.) - Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.) - Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms) - Migrate public templates to modules (billing, marketplace, cms) - Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/) - Migrate letzshop partials to marketplace module Static Files Migration: - Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file) - Migrate vendor JS to modules: tenancy (4 files), core (2 files) - Migrate shared JS: vendor-selector.js to core, media-picker.js to cms - Migrate storefront JS: storefront-layout.js to core - Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/) - Update all template references to use module_static paths Naming Consistency: - Rename static/platform/ to static/public/ - Rename app/templates/platform/ to app/templates/public/ - Update all extends and static references Documentation: - Update module-system.md with shared templates documentation - Update frontend-structure.md with new module JS organization Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
591 lines
19 KiB
Python
591 lines
19 KiB
Python
# app/modules/billing/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.modules.billing.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.modules.billing.exceptions import (
|
|
FeatureNotFoundError,
|
|
InvalidFeatureCodesError,
|
|
TierNotFoundError,
|
|
)
|
|
from app.modules.billing.models import Feature, FeatureCode
|
|
from app.modules.billing.models 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",
|
|
]
|