# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Feature Instance Management Interface. Multi-Tenant Design: - Feature-Instanzen gehören zu Mandanten - Template-Rollen werden bei Erstellung kopiert - Synchronisation von Templates ist explizit (nicht automatisch) """ import logging import uuid from typing import List, Dict, Any, Optional from modules.datamodels.datamodelFeatures import Feature, FeatureInstance from modules.datamodels.datamodelRbac import Role, AccessRule from modules.datamodels.datamodelUtils import coerce_text_multilingual from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.i18nRegistry import resolveText logger = logging.getLogger(__name__) class FeatureInterface: """ Interface for Feature and FeatureInstance management. Responsibilities: - CRUD operations for Features and FeatureInstances - Template role copying on instance creation - Template synchronization for existing instances """ def __init__(self, db: DatabaseConnector): """ Initialize Feature interface. Args: db: DatabaseConnector instance (DbApp database) """ self.db = db # ============================================ # Feature Methods (Global Feature Definitions) # ============================================ def getFeature(self, featureCode: str) -> Optional[Feature]: """ Get a feature by code. Args: featureCode: Feature code (e.g., "trustee", "chatbot") Returns: Feature object or None """ try: records = self.db.getRecordset(Feature, recordFilter={"code": featureCode}) if not records: return None cleanedRecord = dict(records[0]) return Feature(**cleanedRecord) except Exception as e: logger.error(f"Error getting feature {featureCode}: {e}") return None def getAllFeatures(self) -> List[Feature]: """ Get all available features. Returns: List of Feature objects """ try: records = self.db.getRecordset(Feature) result = [] for record in records: cleanedRecord = dict(record) result.append(Feature(**cleanedRecord)) return result except Exception as e: logger.error(f"Error getting all features: {e}") return [] def createFeature(self, code: str, label: Dict[str, str], icon: str = "mdi-puzzle") -> Feature: """ Create a new feature definition. Args: code: Unique feature code (e.g., "trustee") label: I18n labels (e.g., {"en": "Trustee", "de": "Treuhand"}) icon: Icon identifier Returns: Created Feature object """ try: feature = Feature(code=code, label=label, icon=icon) createdRecord = self.db.recordCreate(Feature, feature.model_dump()) cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")} return Feature(**cleanedRecord) except Exception as e: logger.error(f"Error creating feature {code}: {e}") raise ValueError(f"Failed to create feature: {e}") # ============================================ # Feature Instance Methods # ============================================ def getFeatureInstance(self, instanceId: str) -> Optional[FeatureInstance]: """ Get a feature instance by ID. Args: instanceId: FeatureInstance ID Returns: FeatureInstance object or None """ try: records = self.db.getRecordset(FeatureInstance, recordFilter={"id": instanceId}) if not records: return None cleanedRecord = dict(records[0]) return FeatureInstance(**cleanedRecord) except Exception as e: logger.error(f"Error getting feature instance {instanceId}: {e}") return None def getFeatureInstancesForMandate(self, mandateId: str, featureCode: Optional[str] = None) -> List[FeatureInstance]: """ Get all feature instances for a mandate. Args: mandateId: Mandate ID featureCode: Optional filter by feature code Returns: List of FeatureInstance objects """ try: recordFilter = {"mandateId": mandateId} if featureCode: recordFilter["featureCode"] = featureCode records = self.db.getRecordset(FeatureInstance, recordFilter=recordFilter) result = [] for record in records: cleanedRecord = dict(record) result.append(FeatureInstance(**cleanedRecord)) return result except Exception as e: logger.error(f"Error getting feature instances for mandate {mandateId}: {e}") return [] def createFeatureInstance( self, featureCode: str, mandateId: str, label: str, enabled: bool = True, copyTemplateRoles: bool = True, config: Optional[Dict[str, Any]] = None ) -> FeatureInstance: """ Create a new feature instance for a mandate. Optionally copies global template roles for this feature. WICHTIG: Templates werden NUR bei Erstellung kopiert. Spätere Template-Änderungen werden NICHT automatisch propagiert. Für manuelle Nachsynchronisation siehe syncRolesFromTemplate(). Args: featureCode: Feature code (e.g., "trustee") mandateId: Mandate ID label: Instance label (e.g., "Buchhaltung 2025") enabled: Whether the instance is enabled copyTemplateRoles: Whether to copy template roles Returns: Created FeatureInstance object """ try: # Create instance instance = FeatureInstance( featureCode=featureCode, mandateId=mandateId, label=label, enabled=enabled, config=config ) createdInstance = self.db.recordCreate(FeatureInstance, instance.model_dump()) if not createdInstance: raise ValueError("Failed to create feature instance record") instanceId = createdInstance.get("id") # Copy template roles if requested if copyTemplateRoles: self._copyTemplateRoles(featureCode, mandateId, instanceId) # Copy template workflows (if feature defines TEMPLATE_WORKFLOWS) self._copyTemplateWorkflows(featureCode, mandateId, instanceId) cleanedRecord = dict(createdInstance) return FeatureInstance(**cleanedRecord) except Exception as e: logger.error(f"Error creating feature instance: {e}") raise ValueError(f"Failed to create feature instance: {e}") def _copyTemplateWorkflows(self, featureCode: str, mandateId: str, instanceId: str) -> int: """ Copy feature-specific template workflows to a new instance. Loads TEMPLATE_WORKFLOWS from the feature module and creates AutoWorkflow records in the graphicalEditor DB, scoped to (mandateId, instanceId). The placeholder {{featureInstanceId}} in graph parameters is replaced with the actual instanceId. Args: featureCode: Feature code (e.g. "trustee") mandateId: Mandate ID instanceId: New FeatureInstance ID Returns: Number of workflows copied """ import json import importlib try: featureModule = importlib.import_module(f"modules.features.{featureCode}.main{featureCode.capitalize()}") getTemplateWorkflows = getattr(featureModule, "getTemplateWorkflows", None) if not getTemplateWorkflows: return 0 templateWorkflows = getTemplateWorkflows() if not templateWorkflows: return 0 from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface from modules.interfaces.interfaceDbApp import getRootInterface rootUser = getRootInterface().currentUser geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId) copied = 0 for template in templateWorkflows: graphJson = json.dumps(template.get("graph", {})) graphJson = graphJson.replace("{{featureInstanceId}}", instanceId) graph = json.loads(graphJson) label = resolveText(template.get("label")) geInterface.createWorkflow({ "label": label, "graph": graph, "tags": template.get("tags", [f"feature:{featureCode}"]), "isTemplate": False, "templateSourceId": template["id"], "templateScope": "instance", "active": True, }) copied += 1 if copied > 0: logger.info(f"Feature '{featureCode}': Copied {copied} template workflows to instance {instanceId}") return copied except ImportError: logger.debug(f"No feature module found for '{featureCode}' — skipping workflow bootstrap") return 0 except Exception as e: logger.warning(f"Error copying template workflows for '{featureCode}' instance {instanceId}: {e}") return 0 def _copyTemplateRoles(self, featureCode: str, mandateId: str, instanceId: str) -> int: """ Copy feature-specific template roles to a new instance. INVARIANT: Feature instances MUST receive feature-specific roles (e.g. workspace-admin, workspace-user). NEVER generic mandate roles. Feature templates have featureCode set and isSystemRole=False. Args: featureCode: Feature code mandateId: Mandate ID instanceId: New FeatureInstance ID Returns: Number of roles copied Raises: ValueError: If no feature-specific template roles exist """ try: allTemplates = self.db.getRecordset( Role, recordFilter={"featureCode": featureCode} ) featureTemplates = [ r for r in allTemplates if r.get("mandateId") is None and r.get("featureInstanceId") is None ] if not featureTemplates: raise ValueError( f"No feature-specific template roles found for '{featureCode}'. " f"Each feature module must define TEMPLATE_ROLES and sync them to DB on startup." ) logger.info(f"Found {len(featureTemplates)} feature-specific template roles for '{featureCode}'") templateRoleIds = [r.get("id") for r in featureTemplates] # BULK: Load all template AccessRules in one query allTemplateRules = [] for roleId in templateRoleIds: rules = self.db.getRecordset(AccessRule, recordFilter={"roleId": roleId}) allTemplateRules.extend([(roleId, r) for r in rules]) # Index for fast lookup: roleId -> rules rulesByRoleId = {} for roleId, rule in allTemplateRules: if roleId not in rulesByRoleId: rulesByRoleId[roleId] = [] rulesByRoleId[roleId].append(rule) # Copy roles and their AccessRules copiedCount = 0 for templateRole in featureTemplates: newRoleId = str(uuid.uuid4()) # Create new role for this instance newRole = Role( id=newRoleId, roleLabel=templateRole.get("roleLabel"), description=coerce_text_multilingual(templateRole.get("description", {})), featureCode=featureCode, mandateId=mandateId, featureInstanceId=instanceId, isSystemRole=False ) self.db.recordCreate(Role, newRole.model_dump()) # Copy AccessRules for this role templateRulesForRole = rulesByRoleId.get(templateRole.get("id"), []) for rule in templateRulesForRole: newRule = AccessRule( id=str(uuid.uuid4()), roleId=newRoleId, context=rule.get("context"), item=rule.get("item"), view=rule.get("view", False), read=rule.get("read"), create=rule.get("create"), update=rule.get("update"), delete=rule.get("delete") ) self.db.recordCreate(AccessRule, newRule.model_dump()) copiedCount += 1 logger.info(f"Copied {copiedCount} template roles for instance {instanceId}") return copiedCount except ValueError: raise except Exception as e: logger.error(f"Error copying template roles: {e}") raise ValueError(f"Failed to copy template roles for '{featureCode}': {e}") def syncRolesFromTemplate(self, featureInstanceId: str, addOnly: bool = True) -> Dict[str, int]: """ Synchronize roles of a feature instance with current templates. WICHTIG: Templates werden NUR bei Erstellung einer neuen FeatureInstance kopiert. Diese Sync-Funktion ist für manuelle Nachsynchronisation gedacht, nicht für automatische Propagation von Template-Änderungen. Args: featureInstanceId: ID of the instance to sync addOnly: If True, only add missing roles. If False, also remove extras. Returns: Dict with added/removed/unchanged counts """ try: instance = self.getFeatureInstance(featureInstanceId) if not instance: raise ValueError(f"FeatureInstance {featureInstanceId} not found") featureCode = instance.featureCode mandateId = instance.mandateId # Get feature-specific template roles (mandateId=None, featureInstanceId=None) allForFeature = self.db.getRecordset( Role, recordFilter={"featureCode": featureCode} ) templateRoles = [ r for r in allForFeature if r.get("mandateId") is None and r.get("featureInstanceId") is None ] templateLabels = {r.get("roleLabel") for r in templateRoles} # Get current instance roles instanceRoles = self.db.getRecordset( Role, recordFilter={"featureInstanceId": featureInstanceId} ) instanceLabels = {r.get("roleLabel") for r in instanceRoles} result = {"added": 0, "removed": 0, "unchanged": 0} # Add missing roles for templateRole in templateRoles: if templateRole.get("roleLabel") not in instanceLabels: # Copy this role newRoleId = str(uuid.uuid4()) newRole = Role( id=newRoleId, roleLabel=templateRole.get("roleLabel"), description=coerce_text_multilingual(templateRole.get("description", {})), featureCode=featureCode, mandateId=mandateId, featureInstanceId=featureInstanceId, isSystemRole=False ) self.db.recordCreate(Role, newRole.model_dump()) # Copy AccessRules templateRules = self.db.getRecordset( AccessRule, recordFilter={"roleId": templateRole.get("id")} ) for rule in templateRules: newRule = AccessRule( id=str(uuid.uuid4()), roleId=newRoleId, context=rule.get("context"), item=rule.get("item"), view=rule.get("view", False), read=rule.get("read"), create=rule.get("create"), update=rule.get("update"), delete=rule.get("delete") ) self.db.recordCreate(AccessRule, newRule.model_dump()) result["added"] += 1 else: result["unchanged"] += 1 # Remove extra roles (optional) if not addOnly: from modules.datamodels.datamodelMembership import FeatureAccessRole for instanceRole in instanceRoles: if instanceRole.get("roleLabel") not in templateLabels: # Check if role is still in use usages = self.db.getRecordset( FeatureAccessRole, recordFilter={"roleId": instanceRole.get("id")} ) if not usages: self.db.recordDelete(Role, instanceRole.get("id")) result["removed"] += 1 logger.info(f"Synced roles for instance {featureInstanceId}: {result}") return result except Exception as e: logger.error(f"Error syncing roles from template: {e}") raise ValueError(f"Failed to sync roles: {e}") def updateFeatureInstance(self, instanceId: str, updateData: Dict[str, Any]) -> Optional[FeatureInstance]: """ Update a feature instance. Only label and enabled fields can be updated. featureCode and mandateId are immutable. Args: instanceId: FeatureInstance ID updateData: Dictionary with fields to update (label, enabled) Returns: Updated FeatureInstance object or None if not found """ try: instance = self.getFeatureInstance(instanceId) if not instance: return None # Only allow updating specific fields allowedFields = {"label", "enabled", "config"} filteredData = {k: v for k, v in updateData.items() if k in allowedFields} if not filteredData: return instance updated = self.db.recordModify(FeatureInstance, instanceId, filteredData) if updated: cleanedRecord = dict(updated) return FeatureInstance(**cleanedRecord) return None except Exception as e: logger.error(f"Error updating feature instance {instanceId}: {e}") raise ValueError(f"Failed to update feature instance: {e}") def deleteFeatureInstance(self, instanceId: str) -> bool: """ Delete a feature instance. CASCADE will delete associated roles and access records. Args: instanceId: FeatureInstance ID Returns: True if deleted """ try: instance = self.getFeatureInstance(instanceId) if not instance: return False return self.db.recordDelete(FeatureInstance, instanceId) except Exception as e: logger.error(f"Error deleting feature instance {instanceId}: {e}") raise ValueError(f"Failed to delete feature instance: {e}") # ============================================ # Template Role Methods (Global) # ============================================ def getTemplateRoles(self, featureCode: Optional[str] = None) -> List[Role]: """ Get global template roles (mandateId=None). Args: featureCode: Optional filter by feature code Returns: List of Role objects """ try: recordFilter = {"mandateId": None} if featureCode: recordFilter["featureCode"] = featureCode records = self.db.getRecordset(Role, recordFilter=recordFilter) result = [] for record in records: cleanedRecord = dict(record) result.append(Role(**cleanedRecord)) return result except Exception as e: logger.error(f"Error getting template roles: {e}") return [] def createTemplateRole( self, roleLabel: str, featureCode: str, description: Dict[str, str] = None ) -> Role: """ Create a global template role for a feature. Args: roleLabel: Role label (e.g., "admin", "viewer") featureCode: Feature code this role belongs to description: I18n descriptions Returns: Created Role object """ try: role = Role( roleLabel=roleLabel, description=description or {}, featureCode=featureCode, mandateId=None, # Global template featureInstanceId=None, isSystemRole=False ) createdRecord = self.db.recordCreate(Role, role.model_dump()) cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")} return Role(**cleanedRecord) except Exception as e: logger.error(f"Error creating template role: {e}") raise ValueError(f"Failed to create template role: {e}") def getFeatureInterface(db: DatabaseConnector) -> FeatureInterface: """ Factory function to get a FeatureInterface instance. Args: db: DatabaseConnector instance (DbApp database) Returns: FeatureInterface instance """ return FeatureInterface(db)