Implement database-driven feature gating with contextual upgrade prompts: - Add Feature model with 30 features across 8 categories - Create FeatureService with caching for tier-based feature checking - Add @require_feature decorator and RequireFeature dependency for backend enforcement - Create vendor features API (6 endpoints) and admin features API - Add Alpine.js feature store and upgrade prompts store for frontend - Create Jinja macros: feature_gate, feature_locked, limit_warning, usage_bar - Add usage API for tracking orders/products/team limits with upgrade info - Fix Stripe webhook to create VendorAddOn records on addon purchase - Integrate upgrade prompts into vendor dashboard with tier badge and usage bars 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
358 lines
11 KiB
Python
358 lines
11 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, HTTPException, 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.feature import Feature
|
|
from models.database.subscription import SubscriptionTier
|
|
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]
|
|
|
|
|
|
# ============================================================================
|
|
# 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=[
|
|
FeatureResponse(
|
|
id=f.id,
|
|
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,
|
|
minimum_tier_id=f.minimum_tier_id,
|
|
minimum_tier_code=f.minimum_tier.code if f.minimum_tier else None,
|
|
minimum_tier_name=f.minimum_tier.name if f.minimum_tier else None,
|
|
is_active=f.is_active,
|
|
is_visible=f.is_visible,
|
|
display_order=f.display_order,
|
|
)
|
|
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 = (
|
|
db.query(SubscriptionTier)
|
|
.filter(SubscriptionTier.is_active == True) # noqa: E712
|
|
.order_by(SubscriptionTier.display_order)
|
|
.all()
|
|
)
|
|
|
|
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."""
|
|
feature = feature_service.get_feature_by_code(db, feature_code)
|
|
|
|
if not feature:
|
|
raise HTTPException(status_code=404, detail=f"Feature '{feature_code}' not found")
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
@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."""
|
|
feature = db.query(Feature).filter(Feature.code == feature_code).first()
|
|
|
|
if not feature:
|
|
raise HTTPException(status_code=404, detail=f"Feature '{feature_code}' not found")
|
|
|
|
# Update fields if provided
|
|
if request.name is not None:
|
|
feature.name = request.name
|
|
if request.description is not None:
|
|
feature.description = request.description
|
|
if request.category is not None:
|
|
feature.category = request.category
|
|
if request.ui_location is not None:
|
|
feature.ui_location = request.ui_location
|
|
if request.ui_icon is not None:
|
|
feature.ui_icon = request.ui_icon
|
|
if request.ui_route is not None:
|
|
feature.ui_route = request.ui_route
|
|
if request.ui_badge_text is not None:
|
|
feature.ui_badge_text = request.ui_badge_text
|
|
if request.is_active is not None:
|
|
feature.is_active = request.is_active
|
|
if request.is_visible is not None:
|
|
feature.is_visible = request.is_visible
|
|
if request.display_order is not None:
|
|
feature.display_order = request.display_order
|
|
|
|
# Update minimum tier if provided
|
|
if request.minimum_tier_code is not None:
|
|
if request.minimum_tier_code == "":
|
|
feature.minimum_tier_id = None
|
|
else:
|
|
tier = (
|
|
db.query(SubscriptionTier)
|
|
.filter(SubscriptionTier.code == request.minimum_tier_code)
|
|
.first()
|
|
)
|
|
if not tier:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Tier '{request.minimum_tier_code}' not found",
|
|
)
|
|
feature.minimum_tier_id = tier.id
|
|
|
|
db.commit()
|
|
db.refresh(feature)
|
|
|
|
logger.info(f"Updated feature {feature_code} by admin {current_user.id}")
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
@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."""
|
|
try:
|
|
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 []),
|
|
)
|
|
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@router.get("/tiers/{tier_code}/features")
|
|
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."""
|
|
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
|
|
|
|
if not tier:
|
|
raise HTTPException(status_code=404, detail=f"Tier '{tier_code}' not found")
|
|
|
|
# Get full feature details for the tier's features
|
|
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_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),
|
|
}
|