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>
This commit is contained in:
353
app/modules/billing/routes/api/vendor_features.py
Normal file
353
app/modules/billing/routes/api/vendor_features.py
Normal file
@@ -0,0 +1,353 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user