# 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}") def upsertFeature(self, code: str, label: Any, icon: str = "mdi-puzzle") -> str: """Insert or update a Feature row for ``code``. Idempotent counterpart to :meth:`createFeature` used by the boot-time sync (see ``modules.system.registry.syncCatalogFeaturesToDb``) so the ``Feature`` DB-table stays consistent with the in-memory feature registry built from the code modules. Without this sync the ``FeatureInstance.featureCode`` FK would be dangling for every feature whose definition lives only in code (the user-reported false-positive orphans). Args: code: Unique feature code (e.g. ``"trustee"``). label: Either a string (the source label, will be wrapped as ``{"xx": label}``), a dict ``{"xx": ..., "de": ..., ...}`` or an existing TextMultilingual instance. icon: Icon identifier. Returns: One of ``"created"``, ``"updated"``, ``"unchanged"``. """ try: normalizedLabel = coerce_text_multilingual(label) if not isinstance(label, dict) else label existing = self.getFeature(code) if existing is None: self.createFeature(code, normalizedLabel.model_dump() if hasattr(normalizedLabel, "model_dump") else normalizedLabel, icon) return "created" existingLabel = existing.label.model_dump() if hasattr(existing.label, "model_dump") else existing.label desiredLabel = normalizedLabel.model_dump() if hasattr(normalizedLabel, "model_dump") else normalizedLabel updateData: Dict[str, Any] = {} if existingLabel != desiredLabel: updateData["label"] = desiredLabel if (existing.icon or "") != (icon or ""): updateData["icon"] = icon or "" if not updateData: return "unchanged" self.db.recordModify(Feature, code, updateData) return "updated" except Exception as e: logger.error(f"Error upserting feature {code}: {e}") raise ValueError(f"Failed to upsert 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). # WICHTIG: Workflow-Bootstrap darf die Instanz-Erstellung NICHT killen # (Instanz + Rollen sind primaer; Workflows kann Admin via Sync nachladen). # Fehler werden aber laut geloggt, damit sie nicht unbemerkt bleiben. try: self._copyTemplateWorkflows(featureCode, mandateId, instanceId) except Exception as wfErr: logger.error( f"createFeatureInstance: workflow bootstrap FAILED for feature " f"'{featureCode}' instance {instanceId} — instance was created but " f"workflows are missing. Use POST /api/features/instances/{instanceId}" f"/sync-workflows to recover. Reason: {wfErr}", exc_info=True, ) 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 Raises: RuntimeError: If templates exist but cannot be copied. Caller decides whether to swallow or re-raise. """ import json from modules.system.registry import loadFeatureMainModules mainModules = loadFeatureMainModules() featureModule = mainModules.get(featureCode) if not featureModule: logger.debug( f"_copyTemplateWorkflows: no main module loaded for feature '{featureCode}' — nothing to copy" ) return 0 getTemplateWorkflows = getattr(featureModule, "getTemplateWorkflows", None) if not getTemplateWorkflows: logger.debug( f"_copyTemplateWorkflows: feature '{featureCode}' has no getTemplateWorkflows() — nothing to copy" ) return 0 try: templateWorkflows = getTemplateWorkflows() or [] except Exception as e: logger.error( f"_copyTemplateWorkflows: getTemplateWorkflows() raised for feature '{featureCode}': {e}", exc_info=True, ) raise RuntimeError( f"Feature '{featureCode}' getTemplateWorkflows() failed: {e}" ) if not templateWorkflows: return 0 logger.info( f"_copyTemplateWorkflows: copying {len(templateWorkflows)} template workflow(s) " f"for feature '{featureCode}' to instance {instanceId} (mandate={mandateId})" ) from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface from modules.security.rootAccess import getRootUser rootUser = getRootUser() geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId) copied = 0 failed = 0 for template in templateWorkflows: templateId = template.get("id", "") try: 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": templateId, "templateScope": "instance", "active": True, }) copied += 1 except Exception as e: failed += 1 logger.error( f"_copyTemplateWorkflows: failed to create workflow '{templateId}' for " f"feature '{featureCode}' instance {instanceId}: {e}", exc_info=True, ) if copied: logger.info( f"_copyTemplateWorkflows: copied {copied}/{len(templateWorkflows)} workflow(s) " f"for feature '{featureCode}' instance {instanceId} (failed={failed})" ) if failed: raise RuntimeError( f"_copyTemplateWorkflows: {failed}/{len(templateWorkflows)} workflow(s) failed " f"for feature '{featureCode}' instance {instanceId}" ) return copied 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)