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,