From b16420db41a0c94cb353a312ee328313e0fa1f69 Mon Sep 17 00:00:00 2001 From: patrick-motsch Date: Tue, 10 Feb 2026 01:44:21 +0100 Subject: [PATCH] fixed mandate routing --- modules/datamodels/datamodelInvitation.py | 5 +- modules/interfaces/interfaceBootstrap.py | 94 ++++++++++++++++++++--- modules/interfaces/interfaceDbApp.py | 15 ++++ modules/interfaces/interfaceDbBilling.py | 12 +-- modules/routes/routeAdminRbacRoles.py | 22 ++++-- modules/routes/routeAdminRbacRules.py | 7 +- modules/routes/routeInvitations.py | 6 +- modules/routes/routeSecurityLocal.py | 2 +- 8 files changed, 133 insertions(+), 30 deletions(-) diff --git a/modules/datamodels/datamodelInvitation.py b/modules/datamodels/datamodelInvitation.py index 348ef532..472318af 100644 --- a/modules/datamodels/datamodelInvitation.py +++ b/modules/datamodels/datamodelInvitation.py @@ -46,9 +46,10 @@ class Invitation(BaseModel): ) # Einladungs-Details - targetUsername: str = Field( + targetUsername: Optional[str] = Field( + default=None, description="Username of the invited user (must match on acceptance)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True} + json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False} ) email: Optional[str] = Field( default=None, diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index 2b55af51..8b51940c 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -54,6 +54,9 @@ def initBootstrap(db: DatabaseConnector) -> None: # Migrate existing mandate records: description -> label _migrateMandateDescriptionToLabel(db) + # Clean up duplicate roles and fix corrupted templates FIRST + _deduplicateRoles(db) + # Initialize system role TEMPLATES (mandateId=None, isSystemRole=True) initRoles(db) @@ -429,23 +432,89 @@ def initRoles(db: DatabaseConnector) -> None: ), ] - existingRoles = db.getRecordset(Role) - existingRoleLabels = {role.get("roleLabel"): role.get("id") for role in existingRoles} + # Check specifically for system template roles: + # mandateId=NULL, isSystemRole=True, featureCode=NULL + # Feature templates (e.g. chatplayground admin) share the same labels but have featureCode set! + allTemplates = db.getRecordset( + Role, + recordFilter={"mandateId": None, "isSystemRole": True} + ) + # Filter for SYSTEM templates only (featureCode=None), not feature templates + systemTemplates = [r for r in allTemplates if r.get("featureCode") is None] + existingTemplateLabels = {role.get("roleLabel"): role.get("id") for role in systemTemplates} for role in standardRoles: - if role.roleLabel not in existingRoleLabels: + if role.roleLabel not in existingTemplateLabels: try: createdRole = db.recordCreate(Role, role) _roleIdCache[role.roleLabel] = createdRole.get("id") - logger.info(f"Created role: {role.roleLabel} with ID {createdRole.get('id')}") + logger.info(f"Created template role: {role.roleLabel} with ID {createdRole.get('id')}") except Exception as e: logger.warning(f"Error creating role {role.roleLabel}: {e}") else: - _roleIdCache[role.roleLabel] = existingRoleLabels[role.roleLabel] + _roleIdCache[role.roleLabel] = existingTemplateLabels[role.roleLabel] logger.info("Roles initialization completed") +def _deduplicateRoles(db: DatabaseConnector) -> None: + """ + Remove duplicate roles (same roleLabel + mandateId + featureInstanceId). + Keeps the oldest role (smallest ID) and deletes newer duplicates. + """ + allRoles = db.getRecordset(Role) + + # Group by (roleLabel, mandateId, featureInstanceId, featureCode) + # featureCode is essential: system template ('admin', None, None, None) + # must NOT be grouped with feature template ('admin', None, None, 'chatplayground') + groups: dict = {} + for role in allRoles: + key = (role.get("roleLabel"), role.get("mandateId"), role.get("featureInstanceId"), role.get("featureCode")) + if key not in groups: + groups[key] = [] + groups[key].append(role) + + deletedCount = 0 + for key, roles in groups.items(): + if len(roles) > 1: + # Sort by id to keep the first (oldest), delete the rest + roles.sort(key=lambda r: r.get("id", "")) + for duplicate in roles[1:]: + try: + db.recordDelete(Role, duplicate.get("id")) + deletedCount += 1 + logger.info(f"Deleted duplicate role: label='{key[0]}', mandateId={key[1]}, id={duplicate.get('id')}") + except Exception as e: + logger.warning(f"Failed to delete duplicate role {duplicate.get('id')}: {e}") + + if deletedCount > 0: + logger.info(f"Deduplicated roles: removed {deletedCount} duplicates") + + # Migration: Fix isSystemRole flags + fixedMandateCount = 0 + fixedTemplateCount = 0 + for role in allRoles: + # Mandate-level roles should NOT be isSystemRole=True + if role.get("mandateId") is not None and role.get("isSystemRole") is True: + try: + db.recordModify(Role, role.get("id"), {"isSystemRole": False}) + fixedMandateCount += 1 + except Exception as e: + logger.warning(f"Failed to fix mandate role {role.get('id')}: {e}") + # Template roles (mandateId=None, standard labels) MUST be isSystemRole=True + if role.get("mandateId") is None and role.get("isSystemRole") is not True: + if role.get("roleLabel") in ("admin", "user", "viewer"): + try: + db.recordModify(Role, role.get("id"), {"isSystemRole": True}) + fixedTemplateCount += 1 + except Exception as e: + logger.warning(f"Failed to fix template role {role.get('id')}: {e}") + if fixedMandateCount > 0: + logger.info(f"Fixed {fixedMandateCount} mandate-level roles: isSystemRole → False") + if fixedTemplateCount > 0: + logger.info(f"Fixed {fixedTemplateCount} template roles: isSystemRole → True") + + def _ensureAllMandatesHaveSystemRoles(db: DatabaseConnector) -> None: """ Ensure all existing mandates have system-instance roles. @@ -453,11 +522,15 @@ def _ensureAllMandatesHaveSystemRoles(db: DatabaseConnector) -> None: """ allMandates = db.getRecordset(Mandate) if not allMandates: + logger.info("No mandates found, skipping system role copy") return + logger.info(f"Ensuring system roles for {len(allMandates)} mandates...") for mandate in allMandates: mandateId = mandate.get("id") - copySystemRolesToMandate(db, mandateId) + copiedCount = copySystemRolesToMandate(db, mandateId) + if copiedCount > 0: + logger.info(f"Copied {copiedCount} system roles to mandate {mandateId}") def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int: @@ -477,22 +550,23 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int: """ import uuid as _uuid - # Find system template roles (global, no mandateId) + # Find system template roles (global: mandateId=NULL, isSystemRole=True) templateRoles = db.getRecordset( Role, recordFilter={"isSystemRole": True, "mandateId": None} ) if not templateRoles: - logger.debug("No system template roles found to copy") + logger.warning(f"No system template roles found (mandateId IS NULL, isSystemRole=True)") return 0 - # Check which roles already exist for this mandate + # Check which mandate-level roles already exist for this mandate existingMandateRoles = db.getRecordset( Role, recordFilter={"mandateId": mandateId, "featureInstanceId": None} ) existingLabels = {r.get("roleLabel") for r in existingMandateRoles} + logger.info(f"copySystemRolesToMandate: mandate={mandateId}, templates={len(templateRoles)}, existing={len(existingMandateRoles)}, labels={existingLabels}") # Load all AccessRules for template roles templateRoleIds = [r.get("id") for r in templateRoles] @@ -520,7 +594,7 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int: mandateId=mandateId, featureInstanceId=None, featureCode=None, - isSystemRole=True # Still a system role, but bound to this mandate + isSystemRole=False # Mandate-level role, not a system template ) db.recordCreate(Role, newRole.model_dump()) diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index d6e4c6c0..ecb1af1b 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -3142,6 +3142,21 @@ class AppObjects: logger.error(f"Error getting role by label and scope {roleLabel}: {str(e)}") return None + def getRolesForMandate(self, mandateId: str) -> List[Role]: + """ + Get mandate-level roles for a specific mandate (featureInstanceId=NULL). + These are the roles created by copySystemRolesToMandate during bootstrap. + """ + try: + roles = self.db.getRecordset( + Role, + recordFilter={"mandateId": mandateId, "featureInstanceId": None} + ) + return [Role(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in roles] + except Exception as e: + logger.error(f"Error getting roles for mandate {mandateId}: {e}") + return [] + def getAllRoles(self, pagination: Optional[PaginationParams] = None) -> Union[List[Role], PaginatedResult]: """ Get all roles with optional pagination, sorting, and filtering. diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py index 41be662c..1fd14307 100644 --- a/modules/interfaces/interfaceDbBilling.py +++ b/modules/interfaces/interfaceDbBilling.py @@ -983,7 +983,7 @@ class BillingObjects: if not mandate: continue - mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "") + mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "") settings = self.getSettings(mandateId) if not settings: @@ -1066,7 +1066,7 @@ class BillingObjects: mandate = appInterface.getMandate(mandateId) mandateName = "" if mandate: - mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "") + mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "") for t in transactions: t["mandateId"] = mandateId @@ -1118,7 +1118,7 @@ class BillingObjects: mandate = appInterface.getMandate(mandateId) mandateName = "" if mandate: - mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "") + mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "") # Get user accounts count (always exist now for audit trail) userAccounts = self.db.getRecordset( @@ -1186,7 +1186,7 @@ class BillingObjects: mandate = appInterface.getMandate(mandateId) mandateName = "" if mandate: - mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "") + mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "") for t in transactions: t["mandateId"] = mandateId @@ -1251,7 +1251,7 @@ class BillingObjects: for mandateId in mandateIdList: mandate = appInterface.getMandate(mandateId) if mandate: - mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "") + mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "") mandateMap[mandateId] = mandateName for account in allAccounts: @@ -1335,7 +1335,7 @@ class BillingObjects: for mandateId in mandateIdList: mandate = appInterface.getMandate(mandateId) if mandate: - mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "") + mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "") mandateMap[mandateId] = mandateName # Get transactions for all accounts and collect createdByUserIds diff --git a/modules/routes/routeAdminRbacRoles.py b/modules/routes/routeAdminRbacRoles.py index 97830991..91dafb38 100644 --- a/modules/routes/routeAdminRbacRoles.py +++ b/modules/routes/routeAdminRbacRoles.py @@ -70,20 +70,28 @@ router = APIRouter( @limiter.limit("60/minute") def list_roles( request: Request, + mandateId: Optional[str] = Query(None, description="Filter roles by mandate ID"), currentUser: User = Depends(requireSysAdmin) ) -> List[Dict[str, Any]]: """ - Get list of all available roles with metadata. - MULTI-TENANT: SysAdmin-only (roles are system resources). + Get list of roles with metadata. - Returns: - - List of role dictionaries with role label, description, and user count + Without mandateId: returns system template roles (mandateId=NULL). + With mandateId: returns mandate-level roles for that mandate (featureInstanceId=NULL). """ try: interface = getRootInterface() - # Get all roles from database - dbRoles = interface.getAllRoles() + # Get roles filtered by scope + print(f"[DEBUG list_roles] mandateId={mandateId}") + if mandateId: + # Mandate-specific roles (mandate-level only, no feature-instance roles) + dbRoles = interface.getRolesForMandate(mandateId) + print(f"[DEBUG list_roles] getRolesForMandate returned {len(dbRoles)} roles") + else: + # System template roles only + dbRoles = interface.getAllRoles() + print(f"[DEBUG list_roles] getAllRoles returned {len(dbRoles)} roles") # Count role assignments from UserMandateRole table roleCounts = interface.countRoleAssignments() @@ -95,6 +103,8 @@ def list_roles( "id": role.id, "roleLabel": role.roleLabel, "description": role.description, + "mandateId": role.mandateId, + "featureInstanceId": role.featureInstanceId, "userCount": roleCounts.get(str(role.id), 0), "isSystemRole": role.isSystemRole }) diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py index 5a639431..25719c5d 100644 --- a/modules/routes/routeAdminRbacRules.py +++ b/modules/routes/routeAdminRbacRules.py @@ -720,15 +720,18 @@ def list_roles( # Get all roles from database dbRoles = interface.getAllRoles(pagination=None) + # Count role assignments from UserMandateRole table roleCounts = interface.countRoleAssignments() # Helper function to compute scopeType + # Note: mandateId takes precedence — a role with mandateId is always "mandate" scope, + # even if isSystemRole=True (which just means it was copied from a system template) def _computeScopeType(role) -> str: - if role.isSystemRole: - return "system" if role.mandateId: return "mandate" + if role.isSystemRole: + return "system" return "global" # Convert Role objects to dictionaries and add user counts diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py index 2454db44..c4241c0f 100644 --- a/modules/routes/routeInvitations.py +++ b/modules/routes/routeInvitations.py @@ -666,16 +666,16 @@ def accept_invitation( # Update invitation usage rootInterface.db.recordModify( Invitation, - invitation.get("id"), + invitation.id, { - "currentUses": invitation.get("currentUses", 0) + 1, + "currentUses": (invitation.currentUses or 0) + 1, "usedBy": str(currentUser.id), "usedAt": currentTime } ) logger.info( - f"User {currentUser.id} accepted invitation {invitation.get('id')} " + f"User {currentUser.id} accepted invitation {invitation.id} " f"for mandate {mandateId}" ) diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py index 34bffb64..dd04a67a 100644 --- a/modules/routes/routeSecurityLocal.py +++ b/modules/routes/routeSecurityLocal.py @@ -348,7 +348,7 @@ Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren.""" # Get inviter name inviterId = invitation.createdBy - inviter = appInterface.getUserById(inviterId) if inviterId else None + inviter = appInterface.getUser(inviterId) if inviterId else None inviterName = (inviter.fullName or inviter.username) if inviter else "PowerOn" createInvitationNotification(