Files
orion/app/modules/billing/routes/api/vendor_features.py
Samir Boulahtit 401db56258 refactor: migrate remaining routes to modules and enforce auto-discovery
MIGRATION:
- Delete app/api/v1/vendor/analytics.py (duplicate - analytics module already auto-discovered)
- Move usage routes from app/api/v1/vendor/usage.py to billing module
- Move onboarding routes from app/api/v1/vendor/onboarding.py to marketplace module
- Move features routes to billing module (admin + vendor)
- Move inventory routes to inventory module (admin + vendor)
- Move marketplace/letzshop routes to marketplace module
- Move orders routes to orders module
- Delete legacy letzshop service files (moved to marketplace module)

DOCUMENTATION:
- Add docs/development/migration/module-autodiscovery-migration.md with full migration history
- Update docs/architecture/module-system.md with Entity Auto-Discovery Reference section
- Add detailed sections for each entity type: routes, services, models, schemas, tasks,
  exceptions, templates, static files, locales, configuration

ARCHITECTURE VALIDATION:
- Add MOD-016: Routes must be in modules, not app/api/v1/
- Add MOD-017: Services must be in modules, not app/services/
- Add MOD-018: Tasks must be in modules, not app/tasks/
- Add MOD-019: Schemas must be in modules, not models/schema/
- Update scripts/validate_architecture.py with _validate_legacy_locations method
- Update .architecture-rules/module.yaml with legacy location rules

These rules enforce that all entities must be in self-contained modules.
Legacy locations now trigger ERROR severity violations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 14:25:59 +01:00

354 lines
10 KiB
Python

# app/modules/billing/routes/api/vendor_features.py
"""
Vendor features API endpoints.
Provides feature availability information for the frontend to:
- Show/hide UI elements based on tier
- Display upgrade prompts for unavailable features
- Load feature metadata for dynamic rendering
Endpoints:
- GET /features/available - List of feature codes (for quick checks)
- GET /features - Full feature list with availability and metadata
- GET /features/{code} - Single feature details with upgrade info
- GET /features/categories - List feature categories
All routes require module access control for the 'billing' module.
"""
import logging
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, require_module_access
from app.core.database import get_db
from app.exceptions import FeatureNotFoundError
from app.services.feature_service import feature_service
from models.schema.auth import UserContext
vendor_features_router = APIRouter(
prefix="/features",
dependencies=[Depends(require_module_access("billing"))],
)
logger = logging.getLogger(__name__)
# ============================================================================
# Response Schemas
# ============================================================================
class FeatureCodeListResponse(BaseModel):
"""Simple list of available feature codes for quick checks."""
features: list[str]
tier_code: str
tier_name: str
class FeatureResponse(BaseModel):
"""Full feature information."""
code: str
name: str
description: str | None = None
category: str
ui_location: str | None = None
ui_icon: str | None = None
ui_route: str | None = None
ui_badge_text: str | None = None
is_available: bool
minimum_tier_code: str | None = None
minimum_tier_name: str | None = None
class FeatureListResponse(BaseModel):
"""List of features with metadata."""
features: list[FeatureResponse]
available_count: int
total_count: int
tier_code: str
tier_name: str
class FeatureDetailResponse(BaseModel):
"""Single feature detail with upgrade info."""
code: str
name: str
description: str | None = None
category: str
ui_location: str | None = None
ui_icon: str | None = None
ui_route: str | None = None
is_available: bool
# Upgrade info (only if not available)
upgrade_tier_code: str | None = None
upgrade_tier_name: str | None = None
upgrade_tier_price_monthly_cents: int | None = None
class CategoryListResponse(BaseModel):
"""List of feature categories."""
categories: list[str]
class FeatureGroupedResponse(BaseModel):
"""Features grouped by category."""
categories: dict[str, list[FeatureResponse]]
available_count: int
total_count: int
class FeatureCheckResponse(BaseModel):
"""Quick feature availability check response."""
has_feature: bool
feature_code: str
# ============================================================================
# Endpoints
# ============================================================================
@vendor_features_router.get("/available", response_model=FeatureCodeListResponse)
def get_available_features(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get list of feature codes available to vendor.
This is a lightweight endpoint for quick feature checks.
Use this to populate a frontend feature store on app init.
Returns:
List of feature codes the vendor has access to
"""
vendor_id = current_user.token_vendor_id
# Get subscription for tier info
from app.services.subscription_service import subscription_service
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
tier = subscription.tier_obj
# Get available features
feature_codes = feature_service.get_available_feature_codes(db, vendor_id)
return FeatureCodeListResponse(
features=feature_codes,
tier_code=subscription.tier,
tier_name=tier.name if tier else subscription.tier.title(),
)
@vendor_features_router.get("", response_model=FeatureListResponse)
def get_features(
category: str | None = Query(None, description="Filter by category"),
include_unavailable: bool = Query(True, description="Include features not available to vendor"),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get all features with availability status and metadata.
This is a comprehensive endpoint for building feature-gated UIs.
Each feature includes:
- Availability status
- UI metadata (icon, route, location)
- Minimum tier required
Args:
category: Filter to specific category (orders, inventory, etc.)
include_unavailable: Whether to include locked features
Returns:
List of features with metadata and availability
"""
vendor_id = current_user.token_vendor_id
# Get subscription for tier info
from app.services.subscription_service import subscription_service
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
tier = subscription.tier_obj
# Get features
features = feature_service.get_vendor_features(
db,
vendor_id,
category=category,
include_unavailable=include_unavailable,
)
available_count = sum(1 for f in features if f.is_available)
return FeatureListResponse(
features=[
FeatureResponse(
code=f.code,
name=f.name,
description=f.description,
category=f.category,
ui_location=f.ui_location,
ui_icon=f.ui_icon,
ui_route=f.ui_route,
ui_badge_text=f.ui_badge_text,
is_available=f.is_available,
minimum_tier_code=f.minimum_tier_code,
minimum_tier_name=f.minimum_tier_name,
)
for f in features
],
available_count=available_count,
total_count=len(features),
tier_code=subscription.tier,
tier_name=tier.name if tier else subscription.tier.title(),
)
@vendor_features_router.get("/categories", response_model=CategoryListResponse)
def get_feature_categories(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get list of feature categories.
Returns:
List of category names
"""
categories = feature_service.get_categories(db)
return CategoryListResponse(categories=categories)
@vendor_features_router.get("/grouped", response_model=FeatureGroupedResponse)
def get_features_grouped(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get features grouped by category.
Useful for rendering feature comparison tables or settings pages.
"""
vendor_id = current_user.token_vendor_id
grouped = feature_service.get_features_grouped_by_category(db, vendor_id)
# Convert to response format
categories_response = {}
total = 0
available = 0
for category, features in grouped.items():
categories_response[category] = [
FeatureResponse(
code=f.code,
name=f.name,
description=f.description,
category=f.category,
ui_location=f.ui_location,
ui_icon=f.ui_icon,
ui_route=f.ui_route,
ui_badge_text=f.ui_badge_text,
is_available=f.is_available,
minimum_tier_code=f.minimum_tier_code,
minimum_tier_name=f.minimum_tier_name,
)
for f in features
]
total += len(features)
available += sum(1 for f in features if f.is_available)
return FeatureGroupedResponse(
categories=categories_response,
available_count=available,
total_count=total,
)
@vendor_features_router.get("/{feature_code}", response_model=FeatureDetailResponse)
def get_feature_detail(
feature_code: str,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get detailed information about a specific feature.
Includes upgrade information if the feature is not available.
Use this for upgrade prompts and feature explanation modals.
Args:
feature_code: The feature code
Returns:
Feature details with upgrade info if locked
"""
vendor_id = current_user.token_vendor_id
# Get feature
feature = feature_service.get_feature_by_code(db, feature_code)
if not feature:
raise FeatureNotFoundError(feature_code)
# Check availability
is_available = feature_service.has_feature(db, vendor_id, feature_code)
# Get upgrade info if not available
upgrade_tier_code = None
upgrade_tier_name = None
upgrade_tier_price = None
if not is_available:
upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code)
if upgrade_info:
upgrade_tier_code = upgrade_info.required_tier_code
upgrade_tier_name = upgrade_info.required_tier_name
upgrade_tier_price = upgrade_info.required_tier_price_monthly_cents
return FeatureDetailResponse(
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,
is_available=is_available,
upgrade_tier_code=upgrade_tier_code,
upgrade_tier_name=upgrade_tier_name,
upgrade_tier_price_monthly_cents=upgrade_tier_price,
)
@vendor_features_router.get("/check/{feature_code}", response_model=FeatureCheckResponse)
def check_feature(
feature_code: str,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Quick check if vendor has access to a feature.
Returns simple boolean response for inline checks.
Args:
feature_code: The feature code
Returns:
has_feature and feature_code
"""
vendor_id = current_user.token_vendor_id
has_feature = feature_service.has_feature(db, vendor_id, feature_code)
return FeatureCheckResponse(has_feature=has_feature, feature_code=feature_code)