mandate admin fixes

This commit is contained in:
ValueOn AG 2026-03-31 13:31:25 +02:00
parent bc370ef475
commit 695c652a56
7 changed files with 162 additions and 38 deletions

View file

@ -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})

View file

@ -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 "")

View file

@ -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,

View file

@ -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,16 +411,19 @@ 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(
@ -428,24 +431,27 @@ def delete_mandate(
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}")
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"
)
# Delete mandate
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}")
mode = "hard-deleted" if force else "soft-deleted"
logger.info(f"Mandate {mandateId} {mode} by SysAdmin {currentUser.id}")
return {"message": f"Mandate {mandateId} deleted successfully"}
return {"message": f"Mandate {mandateId} {mode} successfully"}
except HTTPException:
raise
except Exception as e:

View file

@ -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

View file

@ -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:

View file

@ -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,