# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Trustee Feature Container - Main Module. Handles feature initialization and RBAC catalog registration. """ import logging from typing import Dict, List, Any logger = logging.getLogger(__name__) # Feature metadata FEATURE_CODE = "trustee" FEATURE_LABEL = {"en": "Trustee", "de": "Treuhand", "fr": "Fiduciaire"} FEATURE_ICON = "mdi-briefcase" # UI Objects for RBAC catalog # Note: organisations and contracts removed - feature instance = organisation UI_OBJECTS = [ { "objectKey": "ui.feature.trustee.dashboard", "label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"}, "meta": {"area": "dashboard"} }, { "objectKey": "ui.feature.trustee.positions", "label": {"en": "Positions", "de": "Positionen", "fr": "Positions"}, "meta": {"area": "positions"} }, { "objectKey": "ui.feature.trustee.documents", "label": {"en": "Documents", "de": "Dokumente", "fr": "Documents"}, "meta": {"area": "documents"} }, { "objectKey": "ui.feature.trustee.expense-import", "label": {"en": "Expense Import", "de": "Spesen Import", "fr": "Import de dépenses"}, "meta": {"area": "expense-import"} }, { "objectKey": "ui.feature.trustee.scan-upload", "label": {"en": "Scan / Upload", "de": "Scannen / Hochladen", "fr": "Scanner / Téléverser"}, "meta": {"area": "scan-upload"} }, { "objectKey": "ui.feature.trustee.settings", "label": {"en": "Accounting Settings", "de": "Buchhaltungs-Einstellungen", "fr": "Paramètres comptables"}, "meta": {"area": "settings", "admin_only": True} }, { "objectKey": "ui.feature.trustee.instance-roles", "label": {"en": "Instance Roles & Permissions", "de": "Instanz-Rollen & Berechtigungen", "fr": "Rôles et permissions d'instance"}, "meta": {"area": "admin", "admin_only": True} }, ] # DATA Objects for RBAC catalog (tables/entities) # Used for AccessRules on data-level permissions DATA_OBJECTS = [ { "objectKey": "data.feature.trustee.TrusteePosition", "label": {"en": "Position", "de": "Position", "fr": "Position"}, "meta": {"table": "TrusteePosition", "fields": ["id", "label", "description", "organisationId"]} }, { "objectKey": "data.feature.trustee.TrusteeDocument", "label": {"en": "Document", "de": "Dokument", "fr": "Document"}, "meta": {"table": "TrusteeDocument", "fields": ["id", "filename", "mimeType", "fileSize", "uploadDate"]} }, { "objectKey": "data.feature.trustee.TrusteeAccountingConfig", "label": {"en": "Accounting Config", "de": "Buchhaltungs-Konfiguration", "fr": "Config. comptable"}, "meta": {"table": "TrusteeAccountingConfig", "fields": ["id", "connectorType", "displayLabel", "encryptedConfig", "isActive"]} }, { "objectKey": "data.feature.trustee.TrusteeAccountingSync", "label": {"en": "Accounting Sync", "de": "Buchhaltungs-Synchronisation", "fr": "Sync. comptable"}, "meta": {"table": "TrusteeAccountingSync", "fields": ["id", "positionId", "syncStatus", "externalId"]} }, { "objectKey": "data.feature.trustee.TrusteeDataAccount", "label": {"en": "Accounts (Synced)", "de": "Kontenplan (Sync)", "fr": "Plan comptable (Sync)"}, "meta": {"table": "TrusteeDataAccount", "fields": ["id", "accountNumber", "label", "accountType", "accountGroup", "currency", "isActive"]} }, { "objectKey": "data.feature.trustee.TrusteeDataJournalEntry", "label": {"en": "Journal Entries (Synced)", "de": "Buchungen (Sync)", "fr": "Écritures (Sync)"}, "meta": {"table": "TrusteeDataJournalEntry", "fields": ["id", "externalId", "bookingDate", "reference", "description", "currency", "totalAmount"]} }, { "objectKey": "data.feature.trustee.TrusteeDataJournalLine", "label": {"en": "Journal Lines (Synced)", "de": "Buchungszeilen (Sync)", "fr": "Lignes écriture (Sync)"}, "meta": {"table": "TrusteeDataJournalLine", "fields": ["id", "journalEntryId", "accountNumber", "debitAmount", "creditAmount", "currency", "taxCode", "costCenter", "description"]} }, { "objectKey": "data.feature.trustee.TrusteeDataContact", "label": {"en": "Contacts (Synced)", "de": "Kontakte (Sync)", "fr": "Contacts (Sync)"}, "meta": {"table": "TrusteeDataContact", "fields": ["id", "externalId", "contactType", "contactNumber", "name", "address", "zip", "city", "country", "email", "phone", "vatNumber"]} }, { "objectKey": "data.feature.trustee.TrusteeDataAccountBalance", "label": {"en": "Account Balances (Synced)", "de": "Kontosalden (Sync)", "fr": "Soldes comptes (Sync)"}, "meta": {"table": "TrusteeDataAccountBalance", "fields": ["id", "accountNumber", "periodYear", "periodMonth", "openingBalance", "debitTotal", "creditTotal", "closingBalance", "currency"]} }, { "objectKey": "data.feature.trustee.*", "label": {"en": "All Trustee Data", "de": "Alle Treuhand-Daten", "fr": "Toutes les données fiduciaires"}, "meta": {"wildcard": True, "description": "Wildcard for all trustee data tables"} }, ] # Resource Objects for RBAC catalog # Note: organisations and contracts removed - feature instance = organisation RESOURCE_OBJECTS = [ { "objectKey": "resource.feature.trustee.documents.create", "label": {"en": "Upload Document", "de": "Dokument hochladen", "fr": "Télécharger document"}, "meta": {"endpoint": "/api/trustee/{instanceId}/documents", "method": "POST"} }, { "objectKey": "resource.feature.trustee.documents.update", "label": {"en": "Update Document", "de": "Dokument aktualisieren", "fr": "Modifier document"}, "meta": {"endpoint": "/api/trustee/{instanceId}/documents/{documentId}", "method": "PUT"} }, { "objectKey": "resource.feature.trustee.documents.delete", "label": {"en": "Delete Document", "de": "Dokument löschen", "fr": "Supprimer document"}, "meta": {"endpoint": "/api/trustee/{instanceId}/documents/{documentId}", "method": "DELETE"} }, { "objectKey": "resource.feature.trustee.positions.create", "label": {"en": "Create Position", "de": "Position erstellen", "fr": "Créer position"}, "meta": {"endpoint": "/api/trustee/{instanceId}/positions", "method": "POST"} }, { "objectKey": "resource.feature.trustee.positions.update", "label": {"en": "Update Position", "de": "Position aktualisieren", "fr": "Modifier position"}, "meta": {"endpoint": "/api/trustee/{instanceId}/positions/{positionId}", "method": "PUT"} }, { "objectKey": "resource.feature.trustee.positions.delete", "label": {"en": "Delete Position", "de": "Position löschen", "fr": "Supprimer position"}, "meta": {"endpoint": "/api/trustee/{instanceId}/positions/{positionId}", "method": "DELETE"} }, { "objectKey": "resource.feature.trustee.instance-roles.manage", "label": {"en": "Manage Instance Roles", "de": "Instanz-Rollen verwalten", "fr": "Gérer les rôles d'instance"}, "meta": {"endpoint": "/api/trustee/{instanceId}/instance-roles", "method": "ALL", "admin_only": True} }, { "objectKey": "resource.feature.trustee.accounting.manage", "label": {"en": "Manage Accounting Integration", "de": "Buchhaltungs-Integration verwalten", "fr": "Gérer l'intégration comptable"}, "meta": {"endpoint": "/api/trustee/{instanceId}/accounting/config", "method": "ALL", "admin_only": True} }, { "objectKey": "resource.feature.trustee.accounting.sync", "label": {"en": "Sync to Accounting", "de": "Buchhaltung synchronisieren", "fr": "Synchroniser la comptabilité"}, "meta": {"endpoint": "/api/trustee/{instanceId}/accounting/sync", "method": "POST"} }, { "objectKey": "resource.feature.trustee.accounting.view", "label": {"en": "View Sync Status", "de": "Sync-Status einsehen", "fr": "Voir le statut de synchronisation"}, "meta": {"endpoint": "/api/trustee/{instanceId}/accounting/sync-status", "method": "GET"} }, ] # Template roles for this feature with AccessRules # Each role defines default UI and DATA permissions # Note: UI item=None means ALL views, specific items restrict to named views # IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept) TEMPLATE_ROLES = [ { "roleLabel": "trustee-viewer", "description": { "en": "Trustee Viewer - View trustee data (read-only)", "de": "Treuhand-Betrachter - Treuhand-Daten einsehen (nur lesen)", "fr": "Visualiseur fiduciaire - Consulter les données fiduciaires (lecture seule)", }, "accessRules": [ {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.trustee.positions", "view": True}, {"context": "UI", "item": "ui.feature.trustee.documents", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, ], }, { "roleLabel": "trustee-user", "description": { "en": "Trustee User - Create and manage own trustee records", "de": "Treuhand-Benutzer - Eigene Treuhand-Daten erstellen und verwalten", "fr": "Utilisateur fiduciaire - Créer et gérer ses propres données fiduciaires", }, "accessRules": [ {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.trustee.positions", "view": True}, {"context": "UI", "item": "ui.feature.trustee.documents", "view": True}, {"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, ], }, { "roleLabel": "trustee-admin", "description": { "en": "Trustee Administrator - Full access to all trustee data and settings", "de": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen", "fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires", }, "accessRules": [ {"context": "UI", "item": None, "view": True}, {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, {"context": "RESOURCE", "item": "resource.feature.trustee.instance-roles.manage", "view": True}, ], }, { "roleLabel": "trustee-accountant", "description": { "en": "Trustee Accountant - Manage accounting and financial data", "de": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten", "fr": "Comptable fiduciaire - Gérer les données comptables et financières", }, "accessRules": [ {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.trustee.positions", "view": True}, {"context": "UI", "item": "ui.feature.trustee.documents", "view": True}, {"context": "UI", "item": "ui.feature.trustee.settings", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"}, {"context": "RESOURCE", "item": "resource.feature.trustee.accounting.sync", "view": True}, {"context": "RESOURCE", "item": "resource.feature.trustee.accounting.view", "view": True}, ], }, { "roleLabel": "trustee-client", "description": { "en": "Trustee Client - View own accounting data and documents", "de": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen", "fr": "Client fiduciaire - Consulter ses propres données comptables et documents", }, "accessRules": [ {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.trustee.positions", "view": True}, {"context": "UI", "item": "ui.feature.trustee.documents", "view": True}, {"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True}, {"context": "UI", "item": "ui.feature.trustee.scan-upload", "view": True}, {"context": "DATA", "item": "data.feature.trustee.TrusteePosition", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, {"context": "DATA", "item": "data.feature.trustee.TrusteeDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, ], }, ] def getFeatureDefinition() -> Dict[str, Any]: """Return the feature definition for registration.""" return { "code": FEATURE_CODE, "label": FEATURE_LABEL, "icon": FEATURE_ICON } def getUiObjects() -> List[Dict[str, Any]]: """Return UI objects for RBAC catalog registration.""" return UI_OBJECTS def getResourceObjects() -> List[Dict[str, Any]]: """Return resource objects for RBAC catalog registration.""" return RESOURCE_OBJECTS def getTemplateRoles() -> List[Dict[str, Any]]: """Return template roles for this feature.""" return TEMPLATE_ROLES def getDataObjects() -> List[Dict[str, Any]]: """Return DATA objects for RBAC catalog registration.""" return DATA_OBJECTS def registerFeature(catalogService) -> bool: """ Register this feature's RBAC objects in the catalog. Args: catalogService: The RBAC catalog service instance Returns: True if registration was successful """ try: # Register UI objects for uiObj in UI_OBJECTS: catalogService.registerUiObject( featureCode=FEATURE_CODE, objectKey=uiObj["objectKey"], label=uiObj["label"], meta=uiObj.get("meta") ) # Register Resource objects for resObj in RESOURCE_OBJECTS: catalogService.registerResourceObject( featureCode=FEATURE_CODE, objectKey=resObj["objectKey"], label=resObj["label"], meta=resObj.get("meta") ) # Register DATA objects (tables/entities) for dataObj in DATA_OBJECTS: catalogService.registerDataObject( featureCode=FEATURE_CODE, objectKey=dataObj["objectKey"], label=dataObj["label"], meta=dataObj.get("meta") ) # Sync template roles to database (with AccessRules) _syncTemplateRolesToDb() logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects") return True except Exception as e: logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}") return False def _syncTemplateRolesToDb() -> int: """ Sync template roles and their AccessRules to the database. Creates global template roles (mandateId=None) if they don't exist. Returns: Number of roles created/updated """ try: from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext rootInterface = getRootInterface() # Get existing template roles for this feature (Pydantic models) existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE) templateRoles = [r for r in existingRoles if r.mandateId is None] existingRoleLabels = {r.roleLabel: str(r.id) for r in templateRoles} createdCount = 0 for roleTemplate in TEMPLATE_ROLES: roleLabel = roleTemplate["roleLabel"] if roleLabel in existingRoleLabels: roleId = existingRoleLabels[roleLabel] # Ensure AccessRules exist for this role _ensureAccessRulesForRole(rootInterface, roleId, roleTemplate.get("accessRules", [])) else: # Create new template role newRole = Role( roleLabel=roleLabel, description=roleTemplate.get("description", {}), featureCode=FEATURE_CODE, mandateId=None, # Global template featureInstanceId=None, isSystemRole=False ) createdRole = rootInterface.db.recordCreate(Role, newRole.model_dump()) roleId = createdRole.get("id") # Create AccessRules for this role _ensureAccessRulesForRole(rootInterface, roleId, roleTemplate.get("accessRules", [])) logger.info(f"Created template role '{roleLabel}' with ID {roleId}") createdCount += 1 if createdCount > 0: logger.info(f"Feature '{FEATURE_CODE}': Created {createdCount} template roles") # Repair instance-specific roles that are missing AccessRules _repairInstanceRolesAccessRules(rootInterface, existingRoleLabels) return createdCount except Exception as e: logger.error(f"Error syncing template roles for feature '{FEATURE_CODE}': {e}") return 0 def _repairInstanceRolesAccessRules(rootInterface, templateRoleLabels: Dict[str, str]) -> int: """ Repair instance-specific roles by copying AccessRules from their template roles. This ensures instance roles created before AccessRules were defined get updated. Args: rootInterface: Root interface instance templateRoleLabels: Dict mapping roleLabel to template role ID Returns: Number of instance roles repaired """ from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext repairedCount = 0 # Get all instance-specific roles for this feature (Pydantic models) allRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE) instanceRoles = [r for r in allRoles if r.mandateId is not None] for instanceRole in instanceRoles: roleLabel = instanceRole.roleLabel instanceRoleId = str(instanceRole.id) # Find matching template role templateRoleId = templateRoleLabels.get(roleLabel) if not templateRoleId: continue # Check if instance role has AccessRules (Pydantic models) existingRules = rootInterface.getAccessRulesByRole(instanceRoleId) if existingRules: continue # Already has rules, skip # Copy AccessRules from template role (Pydantic models) templateRules = rootInterface.getAccessRulesByRole(templateRoleId) if not templateRules: continue # Template has no rules for rule in templateRules: newRule = AccessRule( roleId=instanceRoleId, context=rule.context, item=rule.item, view=rule.view if rule.view else False, read=rule.read, create=rule.create, update=rule.update, delete=rule.delete, ) rootInterface.db.recordCreate(AccessRule, newRule.model_dump()) logger.info(f"Repaired instance role '{roleLabel}' (ID: {instanceRoleId}): copied {len(templateRules)} AccessRules from template") repairedCount += 1 if repairedCount > 0: logger.info(f"Feature '{FEATURE_CODE}': Repaired {repairedCount} instance roles with missing AccessRules") return repairedCount def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Dict[str, Any]]) -> int: """ Ensure AccessRules exist for a role based on templates. Args: rootInterface: Root interface instance roleId: Role ID ruleTemplates: List of rule templates Returns: Number of rules created """ from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext # Get existing rules for this role (Pydantic models) existingRules = rootInterface.getAccessRulesByRole(roleId) # Create a set of existing rule signatures to avoid duplicates # IMPORTANT: Use .value for enum comparison, not str() which gives "AccessRuleContext.DATA" in Python 3.11+ existingSignatures = set() for rule in existingRules: sig = (rule.context.value if rule.context else None, rule.item) existingSignatures.add(sig) createdCount = 0 for template in ruleTemplates: context = template.get("context", "UI") item = template.get("item") sig = (context, item) if sig in existingSignatures: continue # Map context string to enum if context == "UI": contextEnum = AccessRuleContext.UI elif context == "DATA": contextEnum = AccessRuleContext.DATA elif context == "RESOURCE": contextEnum = AccessRuleContext.RESOURCE else: contextEnum = context newRule = AccessRule( roleId=roleId, context=contextEnum, item=item, view=template.get("view", False), read=template.get("read"), create=template.get("create"), update=template.get("update"), delete=template.get("delete"), ) rootInterface.db.recordCreate(AccessRule, newRule.model_dump()) createdCount += 1 if createdCount > 0: logger.debug(f"Created {createdCount} AccessRules for role {roleId}") return createdCount