Some checks failed
Move all auth schemas (UserContext, UserLogin, LoginResponse, etc.) from legacy models/schema/auth.py to app/modules/tenancy/schemas/auth.py per MOD-019. Update 84 import sites across 14 modules. Legacy file now re-exports for backwards compatibility. Add missing tenancy service methods for cross-module consumers: - merchant_service.get_merchant_by_owner_id() - merchant_service.get_merchant_count_for_owner() - admin_service.get_user_by_id() (public, was private-only) - platform_service.get_active_store_count() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
238 lines
7.7 KiB
Python
238 lines
7.7 KiB
Python
# app/modules/billing/routes/api/admin_features.py
|
|
"""
|
|
Admin feature management endpoints (provider-based system).
|
|
|
|
Provides endpoints for:
|
|
- Browsing the discovered feature catalog from module providers
|
|
- Managing per-tier feature limits (TierFeatureLimit)
|
|
- Managing per-merchant feature overrides (MerchantFeatureOverride)
|
|
|
|
All routes require module access control for the 'billing' module.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, Path
|
|
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.modules.billing.exceptions import InvalidFeatureCodesError
|
|
from app.modules.billing.schemas import (
|
|
FeatureCatalogResponse,
|
|
FeatureDeclarationResponse,
|
|
MerchantFeatureOverrideEntry,
|
|
MerchantFeatureOverrideResponse,
|
|
TierFeatureLimitEntry,
|
|
)
|
|
from app.modules.billing.services.feature_aggregator import feature_aggregator
|
|
from app.modules.billing.services.feature_service import feature_service
|
|
from app.modules.enums import FrontendType
|
|
from app.modules.tenancy.schemas.auth import UserContext
|
|
|
|
admin_features_router = APIRouter(
|
|
prefix="/features",
|
|
dependencies=[Depends(require_module_access("billing", FrontendType.ADMIN))],
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _declaration_to_response(decl) -> FeatureDeclarationResponse:
|
|
"""Convert a FeatureDeclaration dataclass to its Pydantic response schema."""
|
|
return FeatureDeclarationResponse(
|
|
code=decl.code,
|
|
name_key=decl.name_key,
|
|
description_key=decl.description_key,
|
|
category=decl.category,
|
|
feature_type=decl.feature_type.value,
|
|
scope=decl.scope.value,
|
|
default_limit=decl.default_limit,
|
|
unit_key=decl.unit_key,
|
|
is_per_period=decl.is_per_period,
|
|
ui_icon=decl.ui_icon,
|
|
display_order=decl.display_order,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Feature Catalog Endpoints
|
|
# ============================================================================
|
|
|
|
|
|
@admin_features_router.get("/catalog", response_model=FeatureCatalogResponse)
|
|
def get_feature_catalog(
|
|
current_user: UserContext = Depends(get_current_admin_api),
|
|
):
|
|
"""
|
|
Return all discovered features from module providers, grouped by category.
|
|
|
|
Features are declared by modules via FeatureProviderProtocol and
|
|
aggregated at startup. This endpoint does not require a database query.
|
|
"""
|
|
by_category = feature_aggregator.get_declarations_by_category()
|
|
|
|
features: dict[str, list[FeatureDeclarationResponse]] = {}
|
|
total_count = 0
|
|
for category, declarations in by_category.items():
|
|
features[category] = [_declaration_to_response(d) for d in declarations]
|
|
total_count += len(declarations)
|
|
|
|
return FeatureCatalogResponse(features=features, total_count=total_count)
|
|
|
|
|
|
# ============================================================================
|
|
# Tier Feature Limit Endpoints
|
|
# ============================================================================
|
|
|
|
|
|
@admin_features_router.get(
|
|
"/tiers/{tier_id}/limits",
|
|
response_model=list[TierFeatureLimitEntry],
|
|
)
|
|
def get_tier_feature_limits(
|
|
tier_id: int = Path(..., description="Tier ID"),
|
|
current_user: UserContext = Depends(get_current_admin_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get the feature limits configured for a specific tier.
|
|
|
|
Returns all TierFeatureLimit rows associated with the tier,
|
|
each containing a feature_code and its optional limit_value.
|
|
"""
|
|
rows = feature_service.get_tier_feature_limits(db, tier_id)
|
|
|
|
return [
|
|
TierFeatureLimitEntry(
|
|
feature_code=row.feature_code,
|
|
limit_value=row.limit_value,
|
|
enabled=True,
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
|
|
@admin_features_router.put(
|
|
"/tiers/{tier_id}/limits",
|
|
response_model=list[TierFeatureLimitEntry],
|
|
)
|
|
def upsert_tier_feature_limits(
|
|
entries: list[TierFeatureLimitEntry],
|
|
tier_id: int = Path(..., description="Tier ID"),
|
|
current_user: UserContext = Depends(get_current_admin_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Replace the feature limits for a tier.
|
|
|
|
Deletes all existing TierFeatureLimit rows for this tier and
|
|
inserts the provided entries. Only entries with enabled=True
|
|
are persisted (disabled entries are simply omitted).
|
|
"""
|
|
# Validate feature codes against the catalog
|
|
submitted_codes = {e.feature_code for e in entries}
|
|
invalid_codes = feature_aggregator.validate_feature_codes(submitted_codes)
|
|
if invalid_codes:
|
|
raise InvalidFeatureCodesError(invalid_codes)
|
|
|
|
new_rows = feature_service.upsert_tier_feature_limits(
|
|
db, tier_id, [e.model_dump() for e in entries]
|
|
)
|
|
|
|
db.commit()
|
|
|
|
logger.info(
|
|
"Admin %s replaced tier %d feature limits (%d entries)",
|
|
current_user.id,
|
|
tier_id,
|
|
len(new_rows),
|
|
)
|
|
|
|
return [
|
|
TierFeatureLimitEntry(
|
|
feature_code=row.feature_code,
|
|
limit_value=row.limit_value,
|
|
enabled=True,
|
|
)
|
|
for row in new_rows
|
|
]
|
|
|
|
|
|
# ============================================================================
|
|
# Merchant Feature Override Endpoints
|
|
# ============================================================================
|
|
|
|
|
|
@admin_features_router.get(
|
|
"/merchants/{merchant_id}/overrides",
|
|
response_model=list[MerchantFeatureOverrideResponse],
|
|
)
|
|
def get_merchant_feature_overrides(
|
|
merchant_id: int = Path(..., description="Merchant ID"),
|
|
current_user: UserContext = Depends(get_current_admin_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get all feature overrides for a specific merchant.
|
|
|
|
Returns MerchantFeatureOverride rows that allow per-merchant
|
|
exceptions to the default tier limits (e.g. granting extra products).
|
|
"""
|
|
rows = feature_service.get_merchant_overrides(db, merchant_id)
|
|
|
|
return [MerchantFeatureOverrideResponse.model_validate(row) for row in rows]
|
|
|
|
|
|
@admin_features_router.put(
|
|
"/merchants/{merchant_id}/overrides",
|
|
response_model=list[MerchantFeatureOverrideResponse],
|
|
)
|
|
def upsert_merchant_feature_overrides(
|
|
entries: list[MerchantFeatureOverrideEntry],
|
|
merchant_id: int = Path(..., description="Merchant ID"),
|
|
current_user: UserContext = Depends(get_current_admin_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Set feature overrides for a merchant.
|
|
|
|
Upserts MerchantFeatureOverride rows: if an override already exists
|
|
for the (merchant_id, platform_id, feature_code) triple, it is updated;
|
|
otherwise a new row is created.
|
|
|
|
The platform_id is derived from the admin's current platform context.
|
|
"""
|
|
from app.exceptions import ValidationException
|
|
|
|
platform_id = current_user.token_platform_id
|
|
if not platform_id:
|
|
raise ValidationException(
|
|
message="Platform context required. Select a platform first.",
|
|
)
|
|
|
|
# Validate feature codes against the catalog
|
|
submitted_codes = {e.feature_code for e in entries}
|
|
invalid_codes = feature_aggregator.validate_feature_codes(submitted_codes)
|
|
if invalid_codes:
|
|
raise InvalidFeatureCodesError(invalid_codes)
|
|
|
|
results = feature_service.upsert_merchant_overrides(
|
|
db, merchant_id, platform_id, [e.model_dump() for e in entries]
|
|
)
|
|
|
|
db.commit()
|
|
|
|
# Refresh to populate server-generated fields (id, timestamps)
|
|
for row in results:
|
|
db.refresh(row)
|
|
|
|
logger.info(
|
|
"Admin %s upserted %d feature overrides for merchant %d on platform %d",
|
|
current_user.id,
|
|
len(results),
|
|
merchant_id,
|
|
platform_id,
|
|
)
|
|
|
|
return [MerchantFeatureOverrideResponse.model_validate(row) for row in results]
|