From 695c652a56b683bf7f04c385a2e937d273ec7f25 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 13:31:25 +0200
Subject: [PATCH] mandate admin fixes
---
modules/interfaces/interfaceDbApp.py | 83 +++++++++++++++++++++---
modules/interfaces/interfaceDbBilling.py | 2 +-
modules/routes/routeAdminFeatures.py | 2 +
modules/routes/routeDataMandates.py | 40 +++++++-----
modules/routes/routeInvitations.py | 9 ++-
modules/routes/routeStore.py | 62 +++++++++++++++---
modules/routes/routeSystem.py | 2 +
7 files changed, 162 insertions(+), 38 deletions(-)
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index 2ac768fd..27ec5fcf 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -1074,19 +1074,66 @@ class AppObjects:
return False
def _deleteUserReferencedData(self, userId: str) -> None:
- """Deletes all data associated with a user."""
+ """Deletes all data associated with a user (full cascade)."""
try:
- # Delete user auth events
+ from modules.datamodels.datamodelRbac import FeatureAccessRole, UserMandateRole
+ from modules.datamodels.datamodelNotification import UserNotification
+ from modules.datamodels.datamodelInvitation import Invitation
+
+ # 1. FeatureAccess + FeatureAccessRole
+ accesses = self.db.getRecordset(FeatureAccess, recordFilter={"userId": userId})
+ for acc in accesses:
+ accId = acc.get("id")
+ if not accId:
+ continue
+ roles = self.db.getRecordset(FeatureAccessRole, recordFilter={"featureAccessId": accId})
+ for role in roles:
+ self.db.recordDelete(FeatureAccessRole, role.get("id"))
+ self.db.recordDelete(FeatureAccess, accId)
+ if accesses:
+ logger.info(f"User cascade: deleted {len(accesses)} FeatureAccess records for user {userId}")
+
+ # 2. UserMandate + UserMandateRole
+ memberships = self.db.getRecordset(UserMandate, recordFilter={"userId": userId})
+ for um in memberships:
+ umId = um.get("id")
+ if not umId:
+ continue
+ umRoles = self.db.getRecordset(UserMandateRole, recordFilter={"userMandateId": umId})
+ for umr in umRoles:
+ self.db.recordDelete(UserMandateRole, umr.get("id"))
+ self.db.recordDelete(UserMandate, umId)
+ if memberships:
+ logger.info(f"User cascade: deleted {len(memberships)} UserMandate records for user {userId}")
+
+ # 3. UserNotifications
+ notifications = self.db.getRecordset(UserNotification, recordFilter={"userId": userId})
+ for notif in notifications:
+ self.db.recordDelete(UserNotification, notif.get("id"))
+ if notifications:
+ logger.info(f"User cascade: deleted {len(notifications)} notifications for user {userId}")
+
+ # 4. Invitations (by email)
+ user = self.getUser(userId)
+ userEmail = getattr(user, "email", None) if user else None
+ if userEmail:
+ invitations = self.db.getRecordset(Invitation, recordFilter={"email": userEmail})
+ for inv in invitations:
+ self.db.recordDelete(Invitation, inv.get("id"))
+ if invitations:
+ logger.info(f"User cascade: deleted {len(invitations)} invitations for {userEmail}")
+
+ # 5. AuthEvents
events = self.db.getRecordset(AuthEvent, recordFilter={"userId": userId})
for event in events:
self.db.recordDelete(AuthEvent, event["id"])
- # Delete user tokens
+ # 6. Tokens
tokens = self.db.getRecordset(Token, recordFilter={"userId": userId})
for token in tokens:
self.db.recordDelete(Token, token["id"])
- # Delete user connections
+ # 7. UserConnections
connections = self.db.getRecordset(
UserConnection, recordFilter={"userId": userId}
)
@@ -1448,14 +1495,23 @@ class AppObjects:
self.createUserMandate(userId, mandateId, roleIds=[adminRoleId], skipCapacityCheck=True)
+ from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
+ from datetime import datetime, timezone, timedelta
+
+ now = datetime.now(timezone.utc)
+ targetStatus = SubscriptionStatusEnum.TRIALING if plan.trialDays else SubscriptionStatusEnum.ACTIVE
subscription = MandateSubscription(
mandateId=mandateId,
planKey=planKey,
- status=SubscriptionStatusEnum.PENDING,
+ status=targetStatus,
+ startedAt=now.isoformat(),
+ currentPeriodStart=now.isoformat(),
)
if plan.trialDays:
- pass # trialEndsAt set on ACTIVE/TRIALING transition
- from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
+ trialEnd = now + timedelta(days=plan.trialDays)
+ subscription.trialEndsAt = trialEnd.isoformat()
+ subscription.currentPeriodEnd = trialEnd.isoformat()
+
subInterface = _getSubRoot()
subInterface.createSubscription(subscription)
@@ -1584,8 +1640,9 @@ class AppObjects:
if not mandate:
raise ValueError(f"Mandate {mandateId} not found")
- # Strip immutable/protected fields from update data
- _protectedFields = {"id", "isSystem"}
+ _protectedFields = {"id"}
+ if not getattr(self.currentUser, "isSysAdmin", False):
+ _protectedFields.add("isSystem")
_sanitizedData = {k: v for k, v in updateData.items() if k not in _protectedFields}
# Update mandate data using model
@@ -1761,6 +1818,14 @@ class AppObjects:
if billingAccounts or billingSettings:
logger.info(f"Cascade: deleted billing data for mandate {mandateId}")
+ # 3c. Delete Invitations for this mandate
+ from modules.datamodels.datamodelInvitation import Invitation
+ invitations = self.db.getRecordset(Invitation, recordFilter={"mandateId": mandateId})
+ for inv in invitations:
+ self.db.recordDelete(Invitation, inv.get("id"))
+ if invitations:
+ logger.info(f"Cascade: deleted {len(invitations)} Invitations for mandate {mandateId}")
+
# 4. Delete mandate-level Roles
from modules.datamodels.datamodelRbac import Role, AccessRule
roles = self.db.getRecordset(Role, recordFilter={"mandateId": mandateId})
diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py
index d8c052c9..1ea1786a 100644
--- a/modules/interfaces/interfaceDbBilling.py
+++ b/modules/interfaces/interfaceDbBilling.py
@@ -1145,7 +1145,7 @@ class BillingObjects:
continue
mandate = rootInterface.getMandate(mandateId)
- if not mandate:
+ if not mandate or not getattr(mandate, "enabled", True):
continue
mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "")
diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py
index e69df7b9..9d05daf6 100644
--- a/modules/routes/routeAdminFeatures.py
+++ b/modules/routes/routeAdminFeatures.py
@@ -159,6 +159,8 @@ def get_my_feature_instances(
mandateId = str(instance.mandateId)
if mandateId not in mandatesMap:
mandate = rootInterface.getMandate(mandateId)
+ if mandate and not getattr(mandate, "enabled", True):
+ continue
if mandate:
mandatesMap[mandateId] = {
"id": mandateId,
diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py
index e98fd1cc..1615a03a 100644
--- a/modules/routes/routeDataMandates.py
+++ b/modules/routes/routeDataMandates.py
@@ -125,11 +125,11 @@ def get_mandates(
# SysAdmin: all mandates
result = appInterface.getAllMandates(pagination=paginationParams)
else:
- # MandateAdmin: only their mandates
+ # MandateAdmin: only their enabled mandates
allMandates = []
for mandateId in adminMandateIds:
mandate = appInterface.getMandate(mandateId)
- if mandate:
+ if mandate and getattr(mandate, "enabled", True):
mandateDict = mandate if isinstance(mandate, dict) else mandate.model_dump() if hasattr(mandate, 'model_dump') else vars(mandate)
allMandates.append(mandateDict)
result = allMandates
@@ -411,41 +411,47 @@ def update_mandate(
def delete_mandate(
request: Request,
mandateId: str = Path(..., description="ID of the mandate to delete"),
+ force: bool = Query(False, description="Hard-delete with full cascade (irreversible)"),
currentUser: User = Depends(requireSysAdminRole)
) -> Dict[str, Any]:
"""
Delete a mandate.
+ Default: soft-delete (sets enabled=False, 30-day retention).
+ With ?force=true: hard-delete with full cascade (irreversible).
+ Requires X-Confirm-Name header matching the mandate name for hard-delete.
MULTI-TENANT: SysAdmin-only.
"""
try:
appInterface = interfaceDbApp.getRootInterface()
-
- # Check if mandate exists
+
existingMandate = appInterface.getMandate(mandateId)
if not existingMandate:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Mandate {mandateId} not found"
)
-
- # MULTI-TENANT: Delete all UserMandate entries for this mandate first
- userMandates = appInterface.getUserMandatesByMandate(mandateId)
- for um in userMandates:
- appInterface.deleteUserMandate(str(um.userId), mandateId)
- logger.info(f"Deleted {len(userMandates)} UserMandate entries for mandate {mandateId}")
-
- # Delete mandate
+
+ if force:
+ confirmName = request.headers.get("X-Confirm-Name", "")
+ mandateName = getattr(existingMandate, "name", "") or ""
+ if confirmName != mandateName:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Hard-delete requires X-Confirm-Name header matching the mandate name"
+ )
+
try:
- appInterface.deleteMandate(mandateId)
+ appInterface.deleteMandate(mandateId, force=force)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
-
- logger.info(f"Mandate {mandateId} deleted by SysAdmin {currentUser.id}")
-
- return {"message": f"Mandate {mandateId} deleted successfully"}
+
+ mode = "hard-deleted" if force else "soft-deleted"
+ logger.info(f"Mandate {mandateId} {mode} by SysAdmin {currentUser.id}")
+
+ return {"message": f"Mandate {mandateId} {mode} successfully"}
except HTTPException:
raise
except Exception as e:
diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py
index ccefcc87..8e3be0ba 100644
--- a/modules/routes/routeInvitations.py
+++ b/modules/routes/routeInvitations.py
@@ -678,8 +678,15 @@ def validate_invitation(
roleLabels = []
targetUsername = invitation.targetUsername
- # Get mandate name
mandate = rootInterface.getMandate(str(mandateId)) if mandateId else None
+ if mandate and not getattr(mandate, "enabled", True):
+ return InvitationValidation(
+ valid=False,
+ reason="Mandate is disabled",
+ mandateId=None,
+ featureInstanceId=None,
+ roleIds=[]
+ )
if mandate:
mandateName = mandate.label or mandate.name
diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py
index 4af0f6b7..ab50087c 100644
--- a/modules/routes/routeStore.py
+++ b/modules/routes/routeStore.py
@@ -87,6 +87,35 @@ def _isUserAdminInMandate(db, userId: str, mandateId: str) -> bool:
return False
+def _autoActivatePending(subInterface, pendingSub: Dict[str, Any]) -> None:
+ """Auto-activate a PENDING subscription to its target operative status."""
+ from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum, BUILTIN_PLANS
+ from datetime import datetime, timezone, timedelta
+
+ subId = pendingSub.get("id")
+ planKey = pendingSub.get("planKey", "")
+ plan = BUILTIN_PLANS.get(planKey)
+ now = datetime.now(timezone.utc)
+ targetStatus = SubscriptionStatusEnum.TRIALING if plan and plan.trialDays else SubscriptionStatusEnum.ACTIVE
+
+ additionalData = {"currentPeriodStart": now.isoformat()}
+ if plan and plan.trialDays:
+ trialEnd = now + timedelta(days=plan.trialDays)
+ additionalData["trialEndsAt"] = trialEnd.isoformat()
+ additionalData["currentPeriodEnd"] = trialEnd.isoformat()
+
+ try:
+ subInterface.transitionStatus(
+ subId,
+ expectedFromStatus=SubscriptionStatusEnum.PENDING,
+ toStatus=targetStatus,
+ additionalData=additionalData,
+ )
+ logger.info("Auto-activated PENDING subscription %s -> %s for mandate", subId, targetStatus.value)
+ except Exception as e:
+ logger.warning("Failed to auto-activate PENDING subscription %s: %s", subId, e)
+
+
def _getUserAdminMandateIds(db, userId: str) -> List[str]:
"""Get all mandate IDs where user is admin."""
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
@@ -150,6 +179,8 @@ def listUserMandates(
records = db.getRecordset(Mandate, recordFilter={"id": mid})
if records:
m = records[0]
+ if not m.get("enabled", True):
+ continue
result.append({
"id": mid,
"name": m.get("name", ""),
@@ -200,13 +231,15 @@ def getSubscriptionInfo(
"budgetAiCHF": None,
}
- sub = allSubs[0]
+ operative = subInterface.getOperativeForMandate(mandateId)
+ sub = operative or 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"),
+ "operative": operative is not None,
"maxDataVolumeMB": plan.maxDataVolumeMB if plan else None,
"maxFeatureInstances": plan.maxFeatureInstances if plan else None,
"budgetAiCHF": plan.budgetAiCHF if plan else None,
@@ -241,7 +274,7 @@ def listStoreFeatures(
for um in userMandates:
mid = um.get("mandateId")
mRecord = db.getRecordset(Mandate, recordFilter={"id": mid})
- if mRecord and not mRecord[0].get("isSystem"):
+ if mRecord and not mRecord[0].get("isSystem") and mRecord[0].get("enabled", True):
userMandateIds.append(mid)
storeFeatures = _getStoreFeatures(catalogService)
@@ -302,7 +335,22 @@ def activateStoreFeature(
subInterface = _getSubRoot()
operative = subInterface.getOperativeForMandate(mandateId)
+
if not operative:
+ allSubs = subInterface.listForMandate(mandateId)
+ pendingSubs = [s for s in allSubs if s.get("status") == SubscriptionStatusEnum.PENDING.value]
+ if pendingSubs:
+ _autoActivatePending(subInterface, pendingSubs[0])
+ operative = subInterface.getOperativeForMandate(mandateId)
+
+ if not operative:
+ allSubs = subInterface.listForMandate(mandateId)
+ statuses = [s.get("status") for s in allSubs] if allSubs else []
+ logger.warning(
+ "Store activate 402: no operative subscription for mandate %s. "
+ "Found %d subscription(s) with statuses: %s",
+ mandateId, len(allSubs), statuses,
+ )
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail="Kein aktives Abonnement. Bitte zuerst ein Abo abschliessen.",
@@ -310,14 +358,8 @@ def activateStoreFeature(
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.",
- )
+ hasStripeIds = bool(operative.get("stripeSubscriptionId") and operative.get("stripeItemIdInstances"))
+ isBillable = hasStripeIds and plan is not None and (plan.pricePerFeatureInstanceCHF or 0) > 0
# ── 2. Capacity check ───────────────────────────────────────────
if plan and plan.maxFeatureInstances is not None:
diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py
index 5a08202c..f287d908 100644
--- a/modules/routes/routeSystem.py
+++ b/modules/routes/routeSystem.py
@@ -168,6 +168,8 @@ def _buildDynamicBlock(
mandateId = str(instance.mandateId)
if mandateId not in mandatesMap:
mandate = rootInterface.getMandate(mandateId)
+ if not mandate or not getattr(mandate, "enabled", True):
+ continue
mandateName = (mandate.label or mandate.name) if mandate else mandateId
mandatesMap[mandateId] = {
"id": mandateId,