- Add comprehensive unit tests for FeatureService (24 tests) - Add comprehensive unit tests for UsageService (11 tests) - Fix API-002/API-003 architecture violations in feature/usage APIs - Move database queries from API layer to service layer - Create UsageService for usage and limits management - Create custom exceptions (FeatureNotFoundError, TierNotFoundError) - Fix ValidationException usage in content_pages.py - Refactor vendor features API to use proper response models - All 35 new tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
309 lines
8.4 KiB
Python
309 lines
8.4 KiB
Python
# app/api/v1/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
|
|
"""
|
|
|
|
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
|
|
from app.core.database import get_db
|
|
from app.services.feature_service import feature_service
|
|
from models.database.user import User
|
|
|
|
router = APIRouter(prefix="/features")
|
|
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
|
|
# ============================================================================
|
|
|
|
|
|
@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: User = 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),
|
|
)
|
|
|
|
|
|
@router.get("/categories", response_model=CategoryListResponse)
|
|
def list_categories(
|
|
current_user: User = Depends(get_current_admin_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""List all feature categories."""
|
|
categories = feature_service.get_categories(db)
|
|
return CategoryListResponse(categories=categories)
|
|
|
|
|
|
@router.get("/tiers", response_model=TierListWithFeaturesResponse)
|
|
def list_tiers_with_features(
|
|
current_user: User = 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
|
|
]
|
|
)
|
|
|
|
|
|
@router.get("/{feature_code}", response_model=FeatureResponse)
|
|
def get_feature(
|
|
feature_code: str,
|
|
current_user: User = 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)
|
|
|
|
|
|
@router.put("/{feature_code}", response_model=FeatureResponse)
|
|
def update_feature(
|
|
feature_code: str,
|
|
request: UpdateFeatureRequest,
|
|
current_user: User = 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)
|
|
|
|
|
|
@router.put("/tiers/{tier_code}/features", response_model=TierFeaturesResponse)
|
|
def update_tier_features(
|
|
tier_code: str,
|
|
request: UpdateTierFeaturesRequest,
|
|
current_user: User = 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 []),
|
|
)
|
|
|
|
|
|
@router.get("/tiers/{tier_code}/features", response_model=TierFeatureDetailResponse)
|
|
def get_tier_features(
|
|
tier_code: str,
|
|
current_user: User = 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),
|
|
)
|