fixed mandate routing

This commit is contained in:
patrick-motsch 2026-02-10 01:44:21 +01:00
parent 82badd9e4d
commit b16420db41
8 changed files with 133 additions and 30 deletions

View file

@ -46,9 +46,10 @@ class Invitation(BaseModel):
) )
# Einladungs-Details # Einladungs-Details
targetUsername: str = Field( targetUsername: Optional[str] = Field(
default=None,
description="Username of the invited user (must match on acceptance)", 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( email: Optional[str] = Field(
default=None, default=None,

View file

@ -54,6 +54,9 @@ def initBootstrap(db: DatabaseConnector) -> None:
# Migrate existing mandate records: description -> label # Migrate existing mandate records: description -> label
_migrateMandateDescriptionToLabel(db) _migrateMandateDescriptionToLabel(db)
# Clean up duplicate roles and fix corrupted templates FIRST
_deduplicateRoles(db)
# Initialize system role TEMPLATES (mandateId=None, isSystemRole=True) # Initialize system role TEMPLATES (mandateId=None, isSystemRole=True)
initRoles(db) initRoles(db)
@ -429,23 +432,89 @@ def initRoles(db: DatabaseConnector) -> None:
), ),
] ]
existingRoles = db.getRecordset(Role) # Check specifically for system template roles:
existingRoleLabels = {role.get("roleLabel"): role.get("id") for role in existingRoles} # 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: for role in standardRoles:
if role.roleLabel not in existingRoleLabels: if role.roleLabel not in existingTemplateLabels:
try: try:
createdRole = db.recordCreate(Role, role) createdRole = db.recordCreate(Role, role)
_roleIdCache[role.roleLabel] = createdRole.get("id") _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: except Exception as e:
logger.warning(f"Error creating role {role.roleLabel}: {e}") logger.warning(f"Error creating role {role.roleLabel}: {e}")
else: else:
_roleIdCache[role.roleLabel] = existingRoleLabels[role.roleLabel] _roleIdCache[role.roleLabel] = existingTemplateLabels[role.roleLabel]
logger.info("Roles initialization completed") 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: def _ensureAllMandatesHaveSystemRoles(db: DatabaseConnector) -> None:
""" """
Ensure all existing mandates have system-instance roles. Ensure all existing mandates have system-instance roles.
@ -453,11 +522,15 @@ def _ensureAllMandatesHaveSystemRoles(db: DatabaseConnector) -> None:
""" """
allMandates = db.getRecordset(Mandate) allMandates = db.getRecordset(Mandate)
if not allMandates: if not allMandates:
logger.info("No mandates found, skipping system role copy")
return return
logger.info(f"Ensuring system roles for {len(allMandates)} mandates...")
for mandate in allMandates: for mandate in allMandates:
mandateId = mandate.get("id") 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: def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
@ -477,22 +550,23 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
""" """
import uuid as _uuid import uuid as _uuid
# Find system template roles (global, no mandateId) # Find system template roles (global: mandateId=NULL, isSystemRole=True)
templateRoles = db.getRecordset( templateRoles = db.getRecordset(
Role, Role,
recordFilter={"isSystemRole": True, "mandateId": None} recordFilter={"isSystemRole": True, "mandateId": None}
) )
if not templateRoles: 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 return 0
# Check which roles already exist for this mandate # Check which mandate-level roles already exist for this mandate
existingMandateRoles = db.getRecordset( existingMandateRoles = db.getRecordset(
Role, Role,
recordFilter={"mandateId": mandateId, "featureInstanceId": None} recordFilter={"mandateId": mandateId, "featureInstanceId": None}
) )
existingLabels = {r.get("roleLabel") for r in existingMandateRoles} 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 # Load all AccessRules for template roles
templateRoleIds = [r.get("id") for r in templateRoles] templateRoleIds = [r.get("id") for r in templateRoles]
@ -520,7 +594,7 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
mandateId=mandateId, mandateId=mandateId,
featureInstanceId=None, featureInstanceId=None,
featureCode=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()) db.recordCreate(Role, newRole.model_dump())

View file

@ -3142,6 +3142,21 @@ class AppObjects:
logger.error(f"Error getting role by label and scope {roleLabel}: {str(e)}") logger.error(f"Error getting role by label and scope {roleLabel}: {str(e)}")
return None 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]: def getAllRoles(self, pagination: Optional[PaginationParams] = None) -> Union[List[Role], PaginatedResult]:
""" """
Get all roles with optional pagination, sorting, and filtering. Get all roles with optional pagination, sorting, and filtering.

View file

@ -983,7 +983,7 @@ class BillingObjects:
if not mandate: if not mandate:
continue 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) settings = self.getSettings(mandateId)
if not settings: if not settings:
@ -1066,7 +1066,7 @@ class BillingObjects:
mandate = appInterface.getMandate(mandateId) mandate = appInterface.getMandate(mandateId)
mandateName = "" mandateName = ""
if mandate: 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: for t in transactions:
t["mandateId"] = mandateId t["mandateId"] = mandateId
@ -1118,7 +1118,7 @@ class BillingObjects:
mandate = appInterface.getMandate(mandateId) mandate = appInterface.getMandate(mandateId)
mandateName = "" mandateName = ""
if mandate: 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) # Get user accounts count (always exist now for audit trail)
userAccounts = self.db.getRecordset( userAccounts = self.db.getRecordset(
@ -1186,7 +1186,7 @@ class BillingObjects:
mandate = appInterface.getMandate(mandateId) mandate = appInterface.getMandate(mandateId)
mandateName = "" mandateName = ""
if mandate: 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: for t in transactions:
t["mandateId"] = mandateId t["mandateId"] = mandateId
@ -1251,7 +1251,7 @@ class BillingObjects:
for mandateId in mandateIdList: for mandateId in mandateIdList:
mandate = appInterface.getMandate(mandateId) mandate = appInterface.getMandate(mandateId)
if mandate: 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 mandateMap[mandateId] = mandateName
for account in allAccounts: for account in allAccounts:
@ -1335,7 +1335,7 @@ class BillingObjects:
for mandateId in mandateIdList: for mandateId in mandateIdList:
mandate = appInterface.getMandate(mandateId) mandate = appInterface.getMandate(mandateId)
if mandate: 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 mandateMap[mandateId] = mandateName
# Get transactions for all accounts and collect createdByUserIds # Get transactions for all accounts and collect createdByUserIds

View file

@ -70,20 +70,28 @@ router = APIRouter(
@limiter.limit("60/minute") @limiter.limit("60/minute")
def list_roles( def list_roles(
request: Request, request: Request,
mandateId: Optional[str] = Query(None, description="Filter roles by mandate ID"),
currentUser: User = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get list of all available roles with metadata. Get list of roles with metadata.
MULTI-TENANT: SysAdmin-only (roles are system resources).
Returns: Without mandateId: returns system template roles (mandateId=NULL).
- List of role dictionaries with role label, description, and user count With mandateId: returns mandate-level roles for that mandate (featureInstanceId=NULL).
""" """
try: try:
interface = getRootInterface() interface = getRootInterface()
# Get all roles from database # 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() dbRoles = interface.getAllRoles()
print(f"[DEBUG list_roles] getAllRoles returned {len(dbRoles)} roles")
# Count role assignments from UserMandateRole table # Count role assignments from UserMandateRole table
roleCounts = interface.countRoleAssignments() roleCounts = interface.countRoleAssignments()
@ -95,6 +103,8 @@ def list_roles(
"id": role.id, "id": role.id,
"roleLabel": role.roleLabel, "roleLabel": role.roleLabel,
"description": role.description, "description": role.description,
"mandateId": role.mandateId,
"featureInstanceId": role.featureInstanceId,
"userCount": roleCounts.get(str(role.id), 0), "userCount": roleCounts.get(str(role.id), 0),
"isSystemRole": role.isSystemRole "isSystemRole": role.isSystemRole
}) })

View file

@ -720,15 +720,18 @@ def list_roles(
# Get all roles from database # Get all roles from database
dbRoles = interface.getAllRoles(pagination=None) dbRoles = interface.getAllRoles(pagination=None)
# Count role assignments from UserMandateRole table # Count role assignments from UserMandateRole table
roleCounts = interface.countRoleAssignments() roleCounts = interface.countRoleAssignments()
# Helper function to compute scopeType # 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: def _computeScopeType(role) -> str:
if role.isSystemRole:
return "system"
if role.mandateId: if role.mandateId:
return "mandate" return "mandate"
if role.isSystemRole:
return "system"
return "global" return "global"
# Convert Role objects to dictionaries and add user counts # Convert Role objects to dictionaries and add user counts

View file

@ -666,16 +666,16 @@ def accept_invitation(
# Update invitation usage # Update invitation usage
rootInterface.db.recordModify( rootInterface.db.recordModify(
Invitation, Invitation,
invitation.get("id"), invitation.id,
{ {
"currentUses": invitation.get("currentUses", 0) + 1, "currentUses": (invitation.currentUses or 0) + 1,
"usedBy": str(currentUser.id), "usedBy": str(currentUser.id),
"usedAt": currentTime "usedAt": currentTime
} }
) )
logger.info( logger.info(
f"User {currentUser.id} accepted invitation {invitation.get('id')} " f"User {currentUser.id} accepted invitation {invitation.id} "
f"for mandate {mandateId}" f"for mandate {mandateId}"
) )

View file

@ -348,7 +348,7 @@ Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren."""
# Get inviter name # Get inviter name
inviterId = invitation.createdBy 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" inviterName = (inviter.fullName or inviter.username) if inviter else "PowerOn"
createInvitationNotification( createInvitationNotification(