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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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