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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
321
app/modules/billing/services/capacity_forecast_service.py
Normal file
321
app/modules/billing/services/capacity_forecast_service.py
Normal 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()
|
||||
590
app/modules/billing/services/feature_service.py
Normal file
590
app/modules/billing/services/feature_service.py
Normal 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",
|
||||
]
|
||||
94
app/modules/billing/services/platform_pricing_service.py
Normal file
94
app/modules/billing/services/platform_pricing_service.py
Normal 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()
|
||||
Reference in New Issue
Block a user