mandate admin fixes
This commit is contained in:
parent
bc370ef475
commit
695c652a56
7 changed files with 162 additions and 38 deletions
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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 "")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue