Files
orion/app/modules/billing/routes/api/admin_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

314 lines
8.8 KiB
Python

# app/modules/billing/routes/api/admin_features.py
"""
Admin feature management endpoints.
Provides endpoints for:
- Listing all features with their tier assignments
- Updating tier feature assignments
- Managing feature metadata
- Viewing feature usage statistics
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_admin_api, require_module_access
from app.core.database import get_db
from app.services.feature_service import feature_service
from models.schema.auth import UserContext
admin_features_router = APIRouter(
prefix="/features",
dependencies=[Depends(require_module_access("billing"))],
)
logger = logging.getLogger(__name__)
# ============================================================================
# Response Schemas
# ============================================================================
class FeatureResponse(BaseModel):
"""Feature information for admin."""
id: int
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
minimum_tier_id: int | None = None
minimum_tier_code: str | None = None
minimum_tier_name: str | None = None
is_active: bool
is_visible: bool
display_order: int
class FeatureListResponse(BaseModel):
"""List of features."""
features: list[FeatureResponse]
total: int
class TierFeaturesResponse(BaseModel):
"""Tier with its features."""
id: int
code: str
name: str
description: str | None = None
features: list[str]
feature_count: int
class TierListWithFeaturesResponse(BaseModel):
"""All tiers with their features."""
tiers: list[TierFeaturesResponse]
class UpdateTierFeaturesRequest(BaseModel):
"""Request to update tier features."""
feature_codes: list[str]
class UpdateFeatureRequest(BaseModel):
"""Request to update feature metadata."""
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
class CategoryListResponse(BaseModel):
"""List of feature categories."""
categories: list[str]
class TierFeatureDetailResponse(BaseModel):
"""Tier features with full details."""
tier_code: str
tier_name: str
features: list[dict]
feature_count: int
# ============================================================================
# Helper Functions
# ============================================================================
def _feature_to_response(feature) -> FeatureResponse:
"""Convert Feature model to response."""
return FeatureResponse(
id=feature.id,
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,
minimum_tier_id=feature.minimum_tier_id,
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,
is_active=feature.is_active,
is_visible=feature.is_visible,
display_order=feature.display_order,
)
# ============================================================================
# Endpoints
# ============================================================================
@admin_features_router.get("", response_model=FeatureListResponse)
def list_features(
category: str | None = Query(None, description="Filter by category"),
active_only: bool = Query(False, description="Only active features"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""List all features with their tier assignments."""
features = feature_service.get_all_features(
db, category=category, active_only=active_only
)
return FeatureListResponse(
features=[_feature_to_response(f) for f in features],
total=len(features),
)
@admin_features_router.get("/categories", response_model=CategoryListResponse)
def list_categories(
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""List all feature categories."""
categories = feature_service.get_categories(db)
return CategoryListResponse(categories=categories)
@admin_features_router.get("/tiers", response_model=TierListWithFeaturesResponse)
def list_tiers_with_features(
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""List all tiers with their feature assignments."""
tiers = feature_service.get_all_tiers_with_features(db)
return TierListWithFeaturesResponse(
tiers=[
TierFeaturesResponse(
id=t.id,
code=t.code,
name=t.name,
description=t.description,
features=t.features or [],
feature_count=len(t.features or []),
)
for t in tiers
]
)
@admin_features_router.get("/{feature_code}", response_model=FeatureResponse)
def get_feature(
feature_code: str,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Get a single feature by code.
Raises 404 if feature not found.
"""
feature = feature_service.get_feature_by_code(db, feature_code)
if not feature:
from app.exceptions import FeatureNotFoundError
raise FeatureNotFoundError(feature_code)
return _feature_to_response(feature)
@admin_features_router.put("/{feature_code}", response_model=FeatureResponse)
def update_feature(
feature_code: str,
request: UpdateFeatureRequest,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Update feature metadata.
Raises 404 if feature not found, 400 if tier code is invalid.
"""
feature = feature_service.update_feature(
db,
feature_code,
name=request.name,
description=request.description,
category=request.category,
ui_location=request.ui_location,
ui_icon=request.ui_icon,
ui_route=request.ui_route,
ui_badge_text=request.ui_badge_text,
minimum_tier_code=request.minimum_tier_code,
is_active=request.is_active,
is_visible=request.is_visible,
display_order=request.display_order,
)
db.commit()
db.refresh(feature)
logger.info(f"Updated feature {feature_code} by admin {current_user.id}")
return _feature_to_response(feature)
@admin_features_router.put("/tiers/{tier_code}/features", response_model=TierFeaturesResponse)
def update_tier_features(
tier_code: str,
request: UpdateTierFeaturesRequest,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Update features assigned to a tier.
Raises 404 if tier not found, 422 if any feature codes are invalid.
"""
tier = feature_service.update_tier_features(db, tier_code, request.feature_codes)
db.commit()
logger.info(
f"Updated tier {tier_code} features to {len(request.feature_codes)} features "
f"by admin {current_user.id}"
)
return TierFeaturesResponse(
id=tier.id,
code=tier.code,
name=tier.name,
description=tier.description,
features=tier.features or [],
feature_count=len(tier.features or []),
)
@admin_features_router.get("/tiers/{tier_code}/features", response_model=TierFeatureDetailResponse)
def get_tier_features(
tier_code: str,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Get features assigned to a specific tier with full details.
Raises 404 if tier not found.
"""
tier, features = feature_service.get_tier_features_with_details(db, tier_code)
return TierFeatureDetailResponse(
tier_code=tier.code,
tier_name=tier.name,
features=[
{
"code": f.code,
"name": f.name,
"category": f.category,
"description": f.description,
}
for f in features
],
feature_count=len(features),
)