507 lines
20 KiB
Python
507 lines
20 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
Feature Store routes.
|
|
Own Instance Pattern: Each activation creates a new FeatureInstance
|
|
in the user's explicit mandate. Supports Orphan Control.
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException, Depends, Request
|
|
from typing import List, Dict, Any, Optional
|
|
from fastapi import status
|
|
import logging
|
|
from pydantic import BaseModel, Field
|
|
|
|
from modules.auth import limiter, getRequestContext, RequestContext
|
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
|
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
|
|
from modules.datamodels.datamodelRbac import AccessRuleContext, Role
|
|
from modules.datamodels.datamodelUam import Mandate
|
|
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
|
from modules.security.rbacCatalog import getCatalogService
|
|
from modules.security.rbac import RbacClass
|
|
from modules.security.rootAccess import getRootDbAppConnector
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(
|
|
prefix="/api/store",
|
|
tags=["Store"],
|
|
responses={404: {"description": "Not found"}}
|
|
)
|
|
|
|
|
|
class StoreActivateRequest(BaseModel):
|
|
"""Request model for activating a store feature."""
|
|
featureCode: str = Field(..., description="Feature code to activate")
|
|
mandateId: str = Field(..., description="Target mandate ID — always explicit, never optional")
|
|
|
|
|
|
class StoreDeactivateRequest(BaseModel):
|
|
"""Request model for deactivating a store feature."""
|
|
featureCode: str = Field(..., description="Feature code to deactivate")
|
|
mandateId: str = Field(..., description="Mandate ID")
|
|
instanceId: str = Field(..., description="FeatureInstance ID to deactivate")
|
|
|
|
|
|
class StoreFeatureResponse(BaseModel):
|
|
"""Response model for a store feature."""
|
|
featureCode: str
|
|
label: Dict[str, str]
|
|
icon: str
|
|
description: Dict[str, str] = {}
|
|
instances: List[Dict[str, Any]] = []
|
|
canActivate: bool
|
|
|
|
|
|
def _getStoreFeatures(catalogService) -> List[Dict[str, Any]]:
|
|
"""Get all features available in the store."""
|
|
resourceObjects = catalogService.getResourceObjects()
|
|
storeFeatures = []
|
|
for obj in resourceObjects:
|
|
meta = obj.get("meta", {})
|
|
if meta.get("category") == "store":
|
|
featureCode = meta.get("featureCode")
|
|
if featureCode:
|
|
featureDef = catalogService.getFeatureDefinition(featureCode)
|
|
if featureDef:
|
|
storeFeatures.append(featureDef)
|
|
return storeFeatures
|
|
|
|
|
|
def _isUserAdminInMandate(db, userId: str, mandateId: str) -> bool:
|
|
"""Check if user has admin role in a mandate."""
|
|
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "mandateId": mandateId, "enabled": True})
|
|
if not userMandates:
|
|
return False
|
|
umId = userMandates[0].get("id")
|
|
umRoles = db.getRecordset(UserMandateRole, recordFilter={"userMandateId": umId})
|
|
for umRole in umRoles:
|
|
roleId = umRole.get("roleId")
|
|
roles = db.getRecordset(Role, recordFilter={"id": roleId})
|
|
for role in roles:
|
|
if "admin" in (role.get("roleLabel") or "").lower():
|
|
return True
|
|
return False
|
|
|
|
|
|
def _getUserAdminMandateIds(db, userId: str) -> List[str]:
|
|
"""Get all mandate IDs where user is admin."""
|
|
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
|
|
adminMandateIds = []
|
|
for um in userMandates:
|
|
mandateId = um.get("mandateId")
|
|
mandate = db.getRecordset(Mandate, recordFilter={"id": mandateId})
|
|
if mandate and mandate[0].get("isSystem"):
|
|
continue
|
|
if _isUserAdminInMandate(db, userId, mandateId):
|
|
adminMandateIds.append(mandateId)
|
|
return adminMandateIds
|
|
|
|
|
|
def _getUserInstancesForFeature(db, userId: str, featureCode: str, mandateIds: List[str]) -> List[Dict[str, Any]]:
|
|
"""Get user's active instances for a feature across their mandates."""
|
|
instances = []
|
|
for mandateId in mandateIds:
|
|
mandateInstances = db.getRecordset(
|
|
FeatureInstance,
|
|
recordFilter={"mandateId": mandateId, "featureCode": featureCode}
|
|
)
|
|
for inst in mandateInstances:
|
|
instanceId = inst.get("id")
|
|
accesses = db.getRecordset(
|
|
FeatureAccess,
|
|
recordFilter={"userId": userId, "featureInstanceId": instanceId}
|
|
)
|
|
if accesses:
|
|
mandate = db.getRecordset(Mandate, recordFilter={"id": mandateId})
|
|
mandateName = mandate[0].get("label") or mandate[0].get("name") if mandate else mandateId
|
|
instances.append({
|
|
"instanceId": instanceId,
|
|
"mandateId": mandateId,
|
|
"mandateName": mandateName,
|
|
"label": inst.get("label", ""),
|
|
"isActive": True,
|
|
})
|
|
return instances
|
|
|
|
|
|
@router.get("/mandates", response_model=List[Dict[str, Any]])
|
|
@limiter.limit("60/minute")
|
|
def listUserMandates(
|
|
request: Request,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
List mandates where the user can activate features (admin mandates).
|
|
Returns empty list if user has no admin mandates — the frontend handles
|
|
this via OnboardingAssistant/OnboardingWizard to create a mandate.
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
db = rootInterface.db
|
|
userId = str(context.user.id)
|
|
adminMandateIds = _getUserAdminMandateIds(db, userId)
|
|
|
|
result = []
|
|
for mid in adminMandateIds:
|
|
records = db.getRecordset(Mandate, recordFilter={"id": mid})
|
|
if records:
|
|
m = records[0]
|
|
result.append({
|
|
"id": mid,
|
|
"name": m.get("name", ""),
|
|
"label": m.get("label") or m.get("name", ""),
|
|
})
|
|
return result
|
|
except Exception as e:
|
|
logger.error(f"Error listing user mandates: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/subscription-info", response_model=Dict[str, Any])
|
|
@limiter.limit("60/minute")
|
|
def getSubscriptionInfo(
|
|
request: Request,
|
|
mandateId: str = None,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""Get subscription info for a mandate (plan, limits)."""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
db = rootInterface.db
|
|
userId = str(context.user.id)
|
|
|
|
if not mandateId:
|
|
adminMandateIds = _getUserAdminMandateIds(db, userId)
|
|
if adminMandateIds:
|
|
mandateId = adminMandateIds[0]
|
|
|
|
if not mandateId:
|
|
return {
|
|
"plan": None,
|
|
"maxDataVolumeMB": None,
|
|
"maxFeatureInstances": None,
|
|
"budgetAiCHF": None,
|
|
}
|
|
|
|
from modules.datamodels.datamodelSubscription import BUILTIN_PLANS
|
|
from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
|
|
|
|
subInterface = _getSubRoot()
|
|
allSubs = subInterface.listForMandate(mandateId)
|
|
if not allSubs:
|
|
return {
|
|
"plan": None,
|
|
"maxDataVolumeMB": None,
|
|
"maxFeatureInstances": None,
|
|
"budgetAiCHF": None,
|
|
}
|
|
|
|
sub = allSubs[0]
|
|
plan = BUILTIN_PLANS.get(sub.get("planKey"))
|
|
currentInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
|
|
|
|
return {
|
|
"plan": sub.get("planKey"),
|
|
"status": sub.get("status"),
|
|
"maxDataVolumeMB": plan.maxDataVolumeMB if plan else None,
|
|
"maxFeatureInstances": plan.maxFeatureInstances if plan else None,
|
|
"budgetAiCHF": plan.budgetAiCHF if plan else None,
|
|
"currentFeatureInstances": len(currentInstances),
|
|
"trialEndsAt": sub.get("trialEndsAt"),
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Error getting subscription info: {e}")
|
|
return {
|
|
"plan": None,
|
|
"maxDataVolumeMB": None,
|
|
"maxFeatureInstances": None,
|
|
"budgetAiCHF": None,
|
|
}
|
|
|
|
|
|
@router.get("/features", response_model=List[StoreFeatureResponse])
|
|
@limiter.limit("60/minute")
|
|
def listStoreFeatures(
|
|
request: Request,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[StoreFeatureResponse]:
|
|
"""List all store features with activation status per mandate."""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
db = rootInterface.db
|
|
catalogService = getCatalogService()
|
|
userId = str(context.user.id)
|
|
|
|
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
|
|
userMandateIds = []
|
|
for um in userMandates:
|
|
mid = um.get("mandateId")
|
|
mRecord = db.getRecordset(Mandate, recordFilter={"id": mid})
|
|
if mRecord and not mRecord[0].get("isSystem"):
|
|
userMandateIds.append(mid)
|
|
|
|
storeFeatures = _getStoreFeatures(catalogService)
|
|
result = []
|
|
|
|
for featureDef in storeFeatures:
|
|
featureCode = featureDef["code"]
|
|
instances = _getUserInstancesForFeature(db, userId, featureCode, userMandateIds)
|
|
result.append(StoreFeatureResponse(
|
|
featureCode=featureCode,
|
|
label=featureDef.get("label", {}),
|
|
icon=featureDef.get("icon", "mdi-puzzle"),
|
|
instances=instances,
|
|
canActivate=True,
|
|
))
|
|
|
|
return result
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error listing store features: {e}")
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
|
|
|
|
|
@router.post("/activate", response_model=Dict[str, Any])
|
|
@limiter.limit("10/minute")
|
|
def activateStoreFeature(
|
|
request: Request,
|
|
data: StoreActivateRequest,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Activate a store feature. Billing-gated: a feature instance is ONLY created
|
|
if the Stripe subscription quantity update succeeds (proration confirmed).
|
|
On any billing failure the provisioned instance is rolled back.
|
|
"""
|
|
featureCode = data.featureCode
|
|
userId = str(context.user.id)
|
|
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
db = rootInterface.db
|
|
catalogService = getCatalogService()
|
|
|
|
featureDef = catalogService.getFeatureDefinition(featureCode)
|
|
if not featureDef:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature '{featureCode}' not found")
|
|
|
|
mandateId = data.mandateId
|
|
|
|
if not _isUserAdminInMandate(db, userId, mandateId):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not admin in target mandate")
|
|
|
|
# ── 1. Resolve subscription & plan ──────────────────────────────
|
|
from modules.datamodels.datamodelSubscription import MandateSubscription, BUILTIN_PLANS, SubscriptionStatusEnum
|
|
from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
|
|
|
|
subInterface = _getSubRoot()
|
|
operative = subInterface.getOperativeForMandate(mandateId)
|
|
if not operative:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
|
detail="Kein aktives Abonnement. Bitte zuerst ein Abo abschliessen.",
|
|
)
|
|
|
|
planKey = operative.get("planKey", "")
|
|
plan = BUILTIN_PLANS.get(planKey)
|
|
isBillable = plan is not None and (plan.pricePerFeatureInstanceCHF or 0) > 0
|
|
|
|
if isBillable:
|
|
if not operative.get("stripeSubscriptionId") or not operative.get("stripeItemIdInstances"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
|
detail="Stripe-Abonnement ist nicht vollständig eingerichtet — Aktivierung nicht möglich.",
|
|
)
|
|
|
|
# ── 2. Capacity check ───────────────────────────────────────────
|
|
if plan and plan.maxFeatureInstances is not None:
|
|
currentInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
|
|
if len(currentInstances) >= plan.maxFeatureInstances:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
|
detail=f"Feature-Instanz-Limit erreicht ({plan.maxFeatureInstances}). Bitte Plan upgraden.",
|
|
)
|
|
|
|
# ── 3. Provision instance ───────────────────────────────────────
|
|
featureInterface = getFeatureInterface(db)
|
|
featureLabel = featureDef.get("label", {}).get("en", featureCode)
|
|
instance = featureInterface.createFeatureInstance(
|
|
featureCode=featureCode,
|
|
mandateId=mandateId,
|
|
label=featureLabel,
|
|
enabled=True,
|
|
copyTemplateRoles=True,
|
|
)
|
|
|
|
if not instance:
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create feature instance")
|
|
|
|
instanceId = instance.get("id") if isinstance(instance, dict) else instance.id
|
|
|
|
instanceRoles = db.getRecordset(Role, recordFilter={"featureInstanceId": instanceId})
|
|
adminRoleId = None
|
|
for ir in instanceRoles:
|
|
roleLabel = (ir.get("roleLabel") or "").lower()
|
|
if roleLabel.endswith("-admin"):
|
|
adminRoleId = ir.get("id")
|
|
break
|
|
|
|
if not adminRoleId:
|
|
_rollbackInstance(db, instanceId)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Keine Feature-Admin-Rolle für {featureCode} gefunden — Rollback.",
|
|
)
|
|
|
|
rootInterface.createFeatureAccess(userId, instanceId, roleIds=[adminRoleId])
|
|
|
|
# ── 4. Billing gate: Stripe quantity sync (MUST succeed) ────────
|
|
if isBillable:
|
|
try:
|
|
rootInterface._syncSubscriptionQuantity(mandateId, raiseOnError=True)
|
|
except Exception as e:
|
|
logger.error("Stripe billing for feature activation failed — rolling back instance %s: %s", instanceId, e)
|
|
_rollbackInstance(db, instanceId, userId=userId)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
|
detail=f"Stripe-Abrechnung fehlgeschlagen: {e}. Feature wurde NICHT aktiviert.",
|
|
)
|
|
else:
|
|
try:
|
|
rootInterface._syncSubscriptionQuantity(mandateId)
|
|
except Exception as e:
|
|
logger.warning("Non-critical Stripe sync failed for free feature: %s", e)
|
|
|
|
# ── 5. Confirmed — notify ──────────────────────────────────────
|
|
_notifyFeatureActivation(mandateId, featureLabel, featureCode, sub=operative, plan=plan)
|
|
logger.info("User %s activated '%s' in mandate %s (instance=%s, billed=%s)", userId, featureCode, mandateId, instanceId, isBillable)
|
|
|
|
return {
|
|
"featureCode": featureCode,
|
|
"mandateId": mandateId,
|
|
"instanceId": instanceId,
|
|
"activated": True,
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error activating store feature '{featureCode}': {e}")
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
|
|
|
|
|
@router.post("/deactivate", response_model=Dict[str, Any])
|
|
@limiter.limit("10/minute")
|
|
def deactivateStoreFeature(
|
|
request: Request,
|
|
data: StoreDeactivateRequest,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Deactivate a store feature. Removes user's FeatureAccess.
|
|
Orphan Control: if last user deactivates, FeatureInstance is deleted.
|
|
"""
|
|
userId = str(context.user.id)
|
|
instanceId = data.instanceId
|
|
mandateId = data.mandateId
|
|
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
db = rootInterface.db
|
|
|
|
# Verify instance exists in mandate
|
|
instances = db.getRecordset(FeatureInstance, recordFilter={"id": instanceId, "mandateId": mandateId})
|
|
if not instances:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature instance not found in mandate")
|
|
|
|
# Find user's FeatureAccess
|
|
accesses = db.getRecordset(FeatureAccess, recordFilter={"userId": userId, "featureInstanceId": instanceId})
|
|
if not accesses:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No active access found")
|
|
|
|
featureAccessId = accesses[0].get("id")
|
|
db.recordDelete(FeatureAccess, featureAccessId)
|
|
|
|
# Orphan Control: check if any FeatureAccess remains
|
|
remainingAccesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instanceId})
|
|
instanceDeleted = False
|
|
if not remainingAccesses:
|
|
db.recordDelete(FeatureInstance, instanceId)
|
|
instanceDeleted = True
|
|
logger.info(f"Orphan Control: deleted instance {instanceId} (no remaining accesses)")
|
|
|
|
try:
|
|
rootInterface._syncSubscriptionQuantity(mandateId, raiseOnError=True)
|
|
except Exception as e:
|
|
logger.error("Stripe quantity sync after deactivation failed for mandate %s: %s", mandateId, e)
|
|
|
|
logger.info(f"User {userId} deactivated instance {instanceId} in mandate {mandateId} (deleted={instanceDeleted})")
|
|
|
|
return {
|
|
"featureCode": data.featureCode,
|
|
"mandateId": mandateId,
|
|
"instanceId": instanceId,
|
|
"deactivated": True,
|
|
"instanceDeleted": instanceDeleted,
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error deactivating store feature: {e}")
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
|
|
|
|
|
# ============================================================================
|
|
# Internal helpers
|
|
# ============================================================================
|
|
|
|
def _rollbackInstance(db, instanceId: str, userId: str = None) -> None:
|
|
"""Delete a freshly provisioned FeatureInstance (and its access) on billing failure."""
|
|
try:
|
|
if userId:
|
|
accesses = db.getRecordset(FeatureAccess, recordFilter={"userId": userId, "featureInstanceId": instanceId})
|
|
for a in accesses:
|
|
db.recordDelete(FeatureAccess, a.get("id"))
|
|
db.recordDelete(FeatureInstance, instanceId)
|
|
logger.info("Rolled back feature instance %s (billing gate)", instanceId)
|
|
except Exception as e:
|
|
logger.error("Rollback of instance %s failed: %s", instanceId, e)
|
|
|
|
|
|
def _notifyFeatureActivation(
|
|
mandateId: str,
|
|
featureLabel: str,
|
|
featureCode: str,
|
|
sub: dict = None,
|
|
plan = None,
|
|
) -> None:
|
|
"""Send email notification to mandate admins about a newly activated feature."""
|
|
try:
|
|
from modules.shared.notifyMandateAdmins import notifyMandateAdmins
|
|
|
|
priceLine = ""
|
|
if plan and plan.pricePerFeatureInstanceCHF:
|
|
priceLine = f"Kosten: CHF {plan.pricePerFeatureInstanceCHF:.2f} / {plan.billingPeriod.value} (anteilig via Stripe-Proration)."
|
|
|
|
bodyParagraphs = [
|
|
f"Die Feature-Instanz «{featureLabel}» ({featureCode}) wurde soeben für Ihren Mandanten aktiviert.",
|
|
]
|
|
if priceLine:
|
|
bodyParagraphs.append(priceLine)
|
|
bodyParagraphs.append("Die Stripe-Abrechnung wird automatisch angepasst.")
|
|
|
|
notifyMandateAdmins(
|
|
mandateId=mandateId,
|
|
subject=f"Feature aktiviert: {featureLabel}",
|
|
headline="Neue Feature-Instanz aktiviert",
|
|
bodyParagraphs=bodyParagraphs,
|
|
)
|
|
except Exception as e:
|
|
logger.warning("_notifyFeatureActivation failed for mandate %s: %s", mandateId, e)
|