refactor: migrate templates and static files to self-contained modules

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>
This commit is contained in:
2026-02-01 14:34:16 +01:00
parent 843703258f
commit 4e28d91a78
542 changed files with 11603 additions and 9037 deletions

View File

@@ -27,6 +27,21 @@ from app.modules.billing.services.billing_service import (
NoActiveSubscriptionError,
SubscriptionNotCancelledError,
)
from app.modules.billing.services.feature_service import (
FeatureService,
feature_service,
FeatureInfo,
FeatureUpgradeInfo,
FeatureCode,
)
from app.modules.billing.services.capacity_forecast_service import (
CapacityForecastService,
capacity_forecast_service,
)
from app.modules.billing.services.platform_pricing_service import (
PlatformPricingService,
platform_pricing_service,
)
__all__ = [
"SubscriptionService",
@@ -43,4 +58,13 @@ __all__ = [
"StripePriceNotConfiguredError",
"NoActiveSubscriptionError",
"SubscriptionNotCancelledError",
"FeatureService",
"feature_service",
"FeatureInfo",
"FeatureUpgradeInfo",
"FeatureCode",
"CapacityForecastService",
"capacity_forecast_service",
"PlatformPricingService",
"platform_pricing_service",
]

View File

@@ -162,7 +162,7 @@ class BillingService:
Raises:
VendorNotFoundException from app.exceptions
"""
from app.exceptions import VendorNotFoundException
from app.modules.tenancy.exceptions import VendorNotFoundException
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:

View File

@@ -0,0 +1,321 @@
# app/modules/billing/services/capacity_forecast_service.py
"""
Capacity forecasting service for growth trends and scaling recommendations.
Provides:
- Historical capacity trend analysis
- Growth rate calculations
- Days-until-threshold projections
- Scaling recommendations based on growth patterns
"""
import logging
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.modules.catalog.models import Product
from app.modules.billing.models import (
CapacitySnapshot,
SubscriptionStatus,
VendorSubscription,
)
from models.database.vendor import Vendor, VendorUser
logger = logging.getLogger(__name__)
# Scaling thresholds based on capacity-planning.md
INFRASTRUCTURE_SCALING = [
{"name": "Starter", "max_vendors": 50, "max_products": 10_000, "cost_monthly": 30},
{"name": "Small", "max_vendors": 100, "max_products": 30_000, "cost_monthly": 80},
{"name": "Medium", "max_vendors": 300, "max_products": 100_000, "cost_monthly": 150},
{"name": "Large", "max_vendors": 500, "max_products": 250_000, "cost_monthly": 350},
{"name": "Scale", "max_vendors": 1000, "max_products": 500_000, "cost_monthly": 700},
{"name": "Enterprise", "max_vendors": None, "max_products": None, "cost_monthly": 1500},
]
class CapacityForecastService:
"""Service for capacity forecasting and trend analysis."""
def capture_daily_snapshot(self, db: Session) -> CapacitySnapshot:
"""
Capture a daily snapshot of platform capacity metrics.
Should be called by a daily background job.
"""
from app.modules.core.services.image_service import image_service
from app.modules.monitoring.services.platform_health_service import platform_health_service
now = datetime.now(UTC)
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
# Check if snapshot already exists for today
existing = (
db.query(CapacitySnapshot)
.filter(CapacitySnapshot.snapshot_date == today)
.first()
)
if existing:
logger.info(f"Snapshot already exists for {today}")
return existing
# Gather metrics
total_vendors = db.query(func.count(Vendor.id)).scalar() or 0
active_vendors = (
db.query(func.count(Vendor.id))
.filter(Vendor.is_active == True) # noqa: E712
.scalar()
or 0
)
# Subscription metrics
total_subs = db.query(func.count(VendorSubscription.id)).scalar() or 0
active_subs = (
db.query(func.count(VendorSubscription.id))
.filter(VendorSubscription.status.in_(["active", "trial"]))
.scalar()
or 0
)
trial_vendors = (
db.query(func.count(VendorSubscription.id))
.filter(VendorSubscription.status == SubscriptionStatus.TRIAL.value)
.scalar()
or 0
)
# Resource metrics
total_products = db.query(func.count(Product.id)).scalar() or 0
total_team = (
db.query(func.count(VendorUser.id))
.filter(VendorUser.is_active == True) # noqa: E712
.scalar()
or 0
)
# Orders this month
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
total_orders = sum(
s.orders_this_period
for s in db.query(VendorSubscription).all()
)
# Storage metrics
try:
image_stats = image_service.get_storage_stats()
storage_gb = image_stats.get("total_size_gb", 0)
except Exception:
storage_gb = 0
try:
db_size = platform_health_service._get_database_size(db)
except Exception:
db_size = 0
# Theoretical capacity from subscriptions
capacity = platform_health_service.get_subscription_capacity(db)
theoretical_products = capacity["products"].get("theoretical_limit", 0)
theoretical_orders = capacity["orders_monthly"].get("theoretical_limit", 0)
theoretical_team = capacity["team_members"].get("theoretical_limit", 0)
# Tier distribution
tier_distribution = capacity.get("tier_distribution", {})
# Create snapshot
snapshot = CapacitySnapshot(
snapshot_date=today,
total_vendors=total_vendors,
active_vendors=active_vendors,
trial_vendors=trial_vendors,
total_subscriptions=total_subs,
active_subscriptions=active_subs,
total_products=total_products,
total_orders_month=total_orders,
total_team_members=total_team,
storage_used_gb=Decimal(str(storage_gb)),
db_size_mb=Decimal(str(db_size)),
theoretical_products_limit=theoretical_products,
theoretical_orders_limit=theoretical_orders,
theoretical_team_limit=theoretical_team,
tier_distribution=tier_distribution,
)
db.add(snapshot)
db.flush()
db.refresh(snapshot)
logger.info(f"Captured capacity snapshot for {today}")
return snapshot
def get_growth_trends(self, db: Session, days: int = 30) -> dict:
"""
Calculate growth trends over the specified period.
Returns growth rates and projections for key metrics.
"""
now = datetime.now(UTC)
start_date = now - timedelta(days=days)
# Get snapshots for the period
snapshots = (
db.query(CapacitySnapshot)
.filter(CapacitySnapshot.snapshot_date >= start_date)
.order_by(CapacitySnapshot.snapshot_date)
.all()
)
if len(snapshots) < 2:
return {
"period_days": days,
"snapshots_available": len(snapshots),
"trends": {},
"message": "Insufficient data for trend analysis",
}
first = snapshots[0]
last = snapshots[-1]
period_days = (last.snapshot_date - first.snapshot_date).days or 1
def calc_growth(metric: str) -> dict:
start_val = getattr(first, metric) or 0
end_val = getattr(last, metric) or 0
change = end_val - start_val
if start_val > 0:
growth_rate = (change / start_val) * 100
daily_rate = growth_rate / period_days
monthly_rate = daily_rate * 30
else:
growth_rate = 0 if end_val == 0 else 100
daily_rate = 0
monthly_rate = 0
return {
"start_value": start_val,
"current_value": end_val,
"change": change,
"growth_rate_percent": round(growth_rate, 2),
"daily_growth_rate": round(daily_rate, 3),
"monthly_projection": round(end_val * (1 + monthly_rate / 100), 0),
}
trends = {
"vendors": calc_growth("active_vendors"),
"products": calc_growth("total_products"),
"orders": calc_growth("total_orders_month"),
"team_members": calc_growth("total_team_members"),
"storage_gb": {
"start_value": float(first.storage_used_gb or 0),
"current_value": float(last.storage_used_gb or 0),
"change": float((last.storage_used_gb or 0) - (first.storage_used_gb or 0)),
},
}
return {
"period_days": period_days,
"snapshots_available": len(snapshots),
"start_date": first.snapshot_date.isoformat(),
"end_date": last.snapshot_date.isoformat(),
"trends": trends,
}
def get_scaling_recommendations(self, db: Session) -> list[dict]:
"""
Generate scaling recommendations based on current capacity and growth.
Returns prioritized list of recommendations.
"""
from app.modules.monitoring.services.platform_health_service import platform_health_service
recommendations = []
# Get current capacity
capacity = platform_health_service.get_subscription_capacity(db)
health = platform_health_service.get_full_health_report(db)
trends = self.get_growth_trends(db, days=30)
# Check product capacity
products = capacity["products"]
if products.get("utilization_percent") and products["utilization_percent"] > 80:
recommendations.append({
"category": "capacity",
"severity": "warning",
"title": "Product capacity approaching limit",
"description": f"Currently at {products['utilization_percent']:.0f}% of theoretical product capacity",
"action": "Consider upgrading vendor tiers or adding capacity",
})
# Check infrastructure tier
current_tier = health.get("infrastructure_tier", {})
next_trigger = health.get("next_tier_trigger")
if next_trigger:
recommendations.append({
"category": "infrastructure",
"severity": "info",
"title": f"Current tier: {current_tier.get('name', 'Unknown')}",
"description": f"Next upgrade trigger: {next_trigger}",
"action": "Monitor growth and plan for infrastructure scaling",
})
# Check growth rate
if trends.get("trends"):
vendor_growth = trends["trends"].get("vendors", {})
if vendor_growth.get("monthly_projection", 0) > 0:
monthly_rate = vendor_growth.get("growth_rate_percent", 0)
if monthly_rate > 20:
recommendations.append({
"category": "growth",
"severity": "info",
"title": "High vendor growth rate",
"description": f"Vendor base growing at {monthly_rate:.1f}% over last 30 days",
"action": "Ensure infrastructure can scale to meet demand",
})
# Check storage
storage_percent = health.get("image_storage", {}).get("total_size_gb", 0)
if storage_percent > 800: # 80% of 1TB
recommendations.append({
"category": "storage",
"severity": "warning",
"title": "Storage usage high",
"description": f"Image storage at {storage_percent:.1f} GB",
"action": "Plan for storage expansion or implement cleanup policies",
})
# Sort by severity
severity_order = {"critical": 0, "warning": 1, "info": 2}
recommendations.sort(key=lambda r: severity_order.get(r["severity"], 3))
return recommendations
def get_days_until_threshold(
self, db: Session, metric: str, threshold: int
) -> int | None:
"""
Calculate days until a metric reaches a threshold based on current growth.
Returns None if insufficient data or no growth.
"""
trends = self.get_growth_trends(db, days=30)
if not trends.get("trends") or metric not in trends["trends"]:
return None
metric_data = trends["trends"][metric]
current = metric_data.get("current_value", 0)
daily_rate = metric_data.get("daily_growth_rate", 0)
if daily_rate <= 0 or current >= threshold:
return None
remaining = threshold - current
days = remaining / (current * daily_rate / 100) if current > 0 else None
return int(days) if days else None
# Singleton instance
capacity_forecast_service = CapacityForecastService()

View File

@@ -0,0 +1,590 @@
# 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",
]

View File

@@ -0,0 +1,94 @@
# app/modules/billing/services/platform_pricing_service.py
"""
Platform pricing service.
Handles database operations for subscription tiers and add-on products.
"""
from sqlalchemy.orm import Session
from app.modules.billing.models import (
AddOnProduct,
SubscriptionTier,
TIER_LIMITS,
TierCode,
)
class PlatformPricingService:
"""Service for handling pricing data operations."""
def get_public_tiers(self, db: Session) -> list[SubscriptionTier]:
"""
Get all public subscription tiers from the database.
Returns:
List of active, public subscription tiers ordered by display_order
"""
return (
db.query(SubscriptionTier)
.filter(
SubscriptionTier.is_active == True,
SubscriptionTier.is_public == True,
)
.order_by(SubscriptionTier.display_order)
.all()
)
def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier | None:
"""
Get a specific tier by code from the database.
Args:
db: Database session
tier_code: The tier code to look up
Returns:
SubscriptionTier if found, None otherwise
"""
return (
db.query(SubscriptionTier)
.filter(
SubscriptionTier.code == tier_code,
SubscriptionTier.is_active == True,
)
.first()
)
def get_tier_from_hardcoded(self, tier_code: str) -> dict | None:
"""
Get tier limits from hardcoded TIER_LIMITS.
Args:
tier_code: The tier code to look up
Returns:
Dict with tier limits if valid code, None otherwise
"""
try:
tier_enum = TierCode(tier_code)
limits = TIER_LIMITS[tier_enum]
return {
"tier_enum": tier_enum,
"limits": limits,
}
except ValueError:
return None
def get_active_addons(self, db: Session) -> list[AddOnProduct]:
"""
Get all active add-on products from the database.
Returns:
List of active add-on products ordered by category and display_order
"""
return (
db.query(AddOnProduct)
.filter(AddOnProduct.is_active == True)
.order_by(AddOnProduct.category, AddOnProduct.display_order)
.all()
)
# Singleton instance
platform_pricing_service = PlatformPricingService()