From f31e10496a7fe32a91962a10f77cee4fba41e86a Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 3 Feb 2026 21:29:50 +0100 Subject: [PATCH] automation template management and fix admin center --- .../automation/datamodelFeatureAutomation.py | 62 +- .../automation/interfaceFeatureAutomation.py | 655 ++++++++++++++++++ .../automation/routeFeatureAutomation.py | 355 +++++++++- .../chatbot/interfaceFeatureChatbot.py | 281 -------- .../trustee/interfaceFeatureTrustee.py | 45 +- modules/interfaces/interfaceBootstrap.py | 88 +++ modules/interfaces/interfaceDbChat.py | 306 -------- modules/interfaces/interfaceRbac.py | 87 ++- modules/routes/routeAdminAutomationEvents.py | 9 +- modules/system/mainSystem.py | 9 + modules/system/registry.py | 25 +- modules/workflows/automation/mainWorkflow.py | 16 +- 12 files changed, 1253 insertions(+), 685 deletions(-) create mode 100644 modules/features/automation/interfaceFeatureAutomation.py diff --git a/modules/features/automation/datamodelFeatureAutomation.py b/modules/features/automation/datamodelFeatureAutomation.py index 7988cadf..6d1e906f 100644 --- a/modules/features/automation/datamodelFeatureAutomation.py +++ b/modules/features/automation/datamodelFeatureAutomation.py @@ -1,10 +1,11 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. -"""Automation models: AutomationDefinition.""" +"""Automation models: AutomationDefinition, AutomationTemplate.""" from typing import List, Dict, Any, Optional from pydantic import BaseModel, Field from modules.shared.attributeUtils import registerModelLabels +from modules.datamodels.datamodelUtils import TextMultilingual import uuid @@ -28,18 +29,53 @@ class AutomationDefinition(BaseModel): registerModelLabels( "AutomationDefinition", - {"en": "Automation Definition", "fr": "Définition d'automatisation"}, + {"en": "Automation Definition", "ge": "Automatisierungs-Definition", "fr": "Définition d'automatisation"}, { - "id": {"en": "ID", "fr": "ID"}, - "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, - "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, - "label": {"en": "Label", "fr": "Libellé"}, - "schedule": {"en": "Schedule", "fr": "Planification"}, - "template": {"en": "Template", "fr": "Modèle"}, - "placeholders": {"en": "Placeholders", "fr": "Espaces réservés"}, - "active": {"en": "Active", "fr": "Actif"}, - "eventId": {"en": "Event ID", "fr": "ID de l'événement"}, - "status": {"en": "Status", "fr": "Statut"}, - "executionLogs": {"en": "Execution Logs", "fr": "Journaux d'exécution"}, + "id": {"en": "ID", "ge": "ID", "fr": "ID"}, + "mandateId": {"en": "Mandate ID", "ge": "Mandanten-ID", "fr": "ID du mandat"}, + "featureInstanceId": {"en": "Feature Instance ID", "ge": "Feature-Instanz-ID", "fr": "ID de l'instance de fonctionnalité"}, + "label": {"en": "Label", "ge": "Bezeichnung", "fr": "Libellé"}, + "schedule": {"en": "Schedule", "ge": "Zeitplan", "fr": "Planification"}, + "template": {"en": "Template", "ge": "Vorlage", "fr": "Modèle"}, + "placeholders": {"en": "Placeholders", "ge": "Platzhalter", "fr": "Espaces réservés"}, + "active": {"en": "Active", "ge": "Aktiv", "fr": "Actif"}, + "eventId": {"en": "Event ID", "ge": "Event-ID", "fr": "ID de l'événement"}, + "status": {"en": "Status", "ge": "Status", "fr": "Statut"}, + "executionLogs": {"en": "Execution Logs", "ge": "Ausführungsprotokolle", "fr": "Journaux d'exécution"}, + }, +) + + +class AutomationTemplate(BaseModel): + """Automation-Vorlage ohne scharfe Placeholder-Werte (DB-persistiert).""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True} + ) + label: TextMultilingual = Field( + description="Template name (multilingual)", + json_schema_extra={"frontend_type": "multilingual", "frontend_required": True} + ) + overview: Optional[TextMultilingual] = Field( + None, + description="Short description (multilingual)", + json_schema_extra={"frontend_type": "multilingual", "frontend_required": False} + ) + template: str = Field( + description="JSON workflow structure with {{KEY:...}} placeholders", + json_schema_extra={"frontend_type": "textarea", "frontend_required": True} + ) + # System fields (_createdAt, _createdBy, etc.) werden automatisch vom DB-Connector gesetzt + + +registerModelLabels( + "AutomationTemplate", + {"en": "Automation Template", "ge": "Automation-Vorlage", "fr": "Modèle d'automatisation"}, + { + "id": {"en": "ID", "ge": "ID", "fr": "ID"}, + "label": {"en": "Label", "ge": "Bezeichnung", "fr": "Libellé"}, + "overview": {"en": "Overview", "ge": "Übersicht", "fr": "Aperçu"}, + "template": {"en": "Template", "ge": "Vorlage", "fr": "Modèle"}, }, ) diff --git a/modules/features/automation/interfaceFeatureAutomation.py b/modules/features/automation/interfaceFeatureAutomation.py new file mode 100644 index 00000000..e169c86c --- /dev/null +++ b/modules/features/automation/interfaceFeatureAutomation.py @@ -0,0 +1,655 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Interface for Automation feature - manages AutomationDefinition and AutomationTemplate. +Uses the PostgreSQL connector for data access with user/mandate filtering. +""" + +import logging +import uuid +import math +import asyncio +from typing import Dict, Any, List, Optional, Union + +from modules.security.rbac import RbacClass +from modules.datamodels.datamodelRbac import AccessRuleContext +from modules.datamodels.datamodelUam import AccessLevel, User +from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition, AutomationTemplate +from modules.connectors.connectorDbPostgre import DatabaseConnector +from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult +from modules.interfaces.interfaceRbac import getRecordsetWithRBAC, buildDataObjectKey + +from modules.shared.configuration import APP_CONFIG + +logger = logging.getLogger(__name__) + +# Singleton factory for Automation instances +_automationInterfaces = {} + + +class AutomationObjects: + """ + Interface for Automation database operations. + Manages AutomationDefinition and AutomationTemplate with RBAC support. + """ + + def __init__(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None): + self.currentUser = currentUser + self.mandateId = mandateId + self.featureInstanceId = featureInstanceId + self.userId = currentUser.id if currentUser else None + + # Initialize database with proper configuration + self._initializeDatabase() + + # Initialize RBAC (dbApp = self.db since we use poweron_app) + self.rbac = RbacClass(self.db, dbApp=self.db) + + # Update database context + self.db.updateContext(self.userId) + + def _initializeDatabase(self): + """Initializes the database connection with proper configuration.""" + # Get configuration values + dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data") + dbDatabase = "poweron_app" + dbUser = APP_CONFIG.get("DB_USER") + dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") + dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) + + # Create database connector with full configuration + self.db = DatabaseConnector( + dbHost=dbHost, + dbDatabase=dbDatabase, + dbUser=dbUser, + dbPassword=dbPassword, + dbPort=dbPort, + userId=self.userId, + ) + + # Initialize database system + self.db.initDbSystem() + logger.debug(f"Automation database initialized for user {self.userId}") + + def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None): + """Update user context for the interface.""" + self.currentUser = currentUser + self.mandateId = mandateId + self.featureInstanceId = featureInstanceId + self.userId = currentUser.id if currentUser else None + if hasattr(self.db, 'updateContext'): + self.db.updateContext(self.userId) + + def checkRbacPermission(self, model, action: str, recordId: str = None) -> bool: + """Check RBAC permission for a specific action on a model.""" + objectKey = buildDataObjectKey(model.__name__) + permissions = self.rbac.getUserPermissions( + user=self.currentUser, + context=AccessRuleContext.DATA, + item=objectKey + ) + + accessLevel = getattr(permissions, action, AccessLevel.NONE) + + if accessLevel == AccessLevel.ALL: + return True + elif accessLevel == AccessLevel.GROUP: + return True + elif accessLevel == AccessLevel.MY: + if recordId: + record = self.db.getRecordset(model, {"id": recordId}) + if record: + return record[0].get("_createdBy") == self.userId + return True + return False + + # ========================================================================= + # AutomationDefinition CRUD methods + # ========================================================================= + + def _computeAutomationStatus(self, automation: Dict[str, Any]) -> str: + """Compute status field based on eventId presence""" + eventId = automation.get("eventId") + return "Running" if eventId else "Idle" + + def _enrichAutomationsWithUserAndMandate(self, automations: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Batch enrich automations with user names and mandate names for display. + Uses AppObjects interface to fetch users and mandates with proper access control. + """ + if not automations: + return automations + + from modules.interfaces.interfaceDbApp import getInterface as getAppInterface + + # Collect all unique user IDs and mandate IDs + userIds = set() + mandateIds = set() + + for automation in automations: + createdBy = automation.get("_createdBy") + if createdBy: + userIds.add(createdBy) + + mandateId = automation.get("mandateId") + if mandateId: + mandateIds.add(mandateId) + + # Use AppObjects interface to fetch users (respects access control) + appInterface = getAppInterface(self.currentUser) + usersMap = {} + if userIds: + for userId in userIds: + user = appInterface.getUser(userId) + if user: + usersMap[userId] = user.username or user.email or userId + + # Use AppObjects interface to fetch mandates (respects access control) + mandatesMap = {} + if mandateIds: + for mandateId in mandateIds: + mandate = appInterface.getMandate(mandateId) + if mandate: + mandatesMap[mandateId] = mandate.name or mandateId + + # Enrich each automation with the fetched data + for automation in automations: + createdBy = automation.get("_createdBy") + if createdBy: + automation["_createdByUserName"] = usersMap.get(createdBy, createdBy) + else: + automation["_createdByUserName"] = "-" + + mandateId = automation.get("mandateId") + if mandateId: + automation["mandateName"] = mandatesMap.get(mandateId, mandateId) + else: + automation["mandateName"] = "-" + + return automations + + def _enrichAutomationWithUserAndMandate(self, automation: Dict[str, Any]) -> Dict[str, Any]: + """ + Enrich a single automation with user name and mandate name for display. + For multiple automations, use _enrichAutomationsWithUserAndMandate for better performance. + """ + return self._enrichAutomationsWithUserAndMandate([automation])[0] + + def getAllAutomationDefinitions(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]: + """ + Returns automation definitions based on user access level. + Supports optional pagination, sorting, and filtering. + Computes status field for each automation. + """ + # Use RBAC filtering + filteredAutomations = getRecordsetWithRBAC( + self.db, + AutomationDefinition, + self.currentUser + ) + + # Compute status for each automation and normalize executionLogs + for automation in filteredAutomations: + automation["status"] = self._computeAutomationStatus(automation) + # Ensure executionLogs is always a list, not None + if automation.get("executionLogs") is None: + automation["executionLogs"] = [] + + # Batch enrich with user and mandate names + self._enrichAutomationsWithUserAndMandate(filteredAutomations) + + # If no pagination requested, return all items + if pagination is None: + return filteredAutomations + + # Apply filtering (if filters provided) + if pagination.filters: + filteredAutomations = self._applyFilters(filteredAutomations, pagination.filters) + + # Apply sorting (in order of sortFields) + if pagination.sort: + filteredAutomations = self._applySorting(filteredAutomations, pagination.sort) + + # Count total items after filters + totalItems = len(filteredAutomations) + totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0 + + # Apply pagination (skip/limit) + startIdx = (pagination.page - 1) * pagination.pageSize + endIdx = startIdx + pagination.pageSize + pagedAutomations = filteredAutomations[startIdx:endIdx] + + return PaginatedResult( + items=pagedAutomations, + totalItems=totalItems, + totalPages=totalPages + ) + + def _applyFilters(self, items: List[Dict], filters: Dict[str, Any]) -> List[Dict]: + """Apply filters to a list of items.""" + if not filters: + return items + + filtered = [] + for item in items: + match = True + for key, value in filters.items(): + itemValue = item.get(key) + if isinstance(value, str) and isinstance(itemValue, str): + if value.lower() not in itemValue.lower(): + match = False + break + elif itemValue != value: + match = False + break + if match: + filtered.append(item) + return filtered + + def _applySorting(self, items: List[Dict], sortFields: List[Dict]) -> List[Dict]: + """Apply sorting to a list of items.""" + if not sortFields: + return items + + for sortField in reversed(sortFields): + field = sortField.get("field", "") + direction = sortField.get("direction", "asc") + reverse = direction.lower() == "desc" + items = sorted(items, key=lambda x: x.get(field, ""), reverse=reverse) + + return items + + def getAutomationDefinition(self, automationId: str, includeSystemFields: bool = False) -> Optional[AutomationDefinition]: + """Returns an automation definition by ID if user has access, with computed status. + + Args: + automationId: ID of the automation to get + includeSystemFields: If True, returns raw dict with system fields (_createdBy, etc). + If False (default), returns Pydantic model without system fields. + """ + try: + # Use RBAC filtering + filtered = getRecordsetWithRBAC( + self.db, + AutomationDefinition, + self.currentUser, + recordFilter={"id": automationId} + ) + + if not filtered: + return None + + automation = filtered[0] + automation["status"] = self._computeAutomationStatus(automation) + # Ensure executionLogs is always a list, not None + if automation.get("executionLogs") is None: + automation["executionLogs"] = [] + # Enrich with user and mandate names + self._enrichAutomationWithUserAndMandate(automation) + + # For internal use (execution), return raw dict with system fields + if includeSystemFields: + # Return as simple namespace object so getattr works + class AutomationWithSystemFields: + def __init__(self, data): + for key, value in data.items(): + setattr(self, key, value) + return AutomationWithSystemFields(automation) + + # Clean metadata fields and return Pydantic model + cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")} + return AutomationDefinition(**cleanedRecord) + except Exception as e: + logger.error(f"Error getting automation definition: {str(e)}") + return None + + def createAutomationDefinition(self, automationData: Dict[str, Any]) -> AutomationDefinition: + """Creates a new automation definition, then triggers sync.""" + try: + # Ensure ID is present + if "id" not in automationData or not automationData["id"]: + automationData["id"] = str(uuid.uuid4()) + + # Ensure mandateId and featureInstanceId are set for proper data isolation + if "mandateId" not in automationData or not automationData.get("mandateId"): + # Use request context mandateId, or fall back to Root mandate + effectiveMandateId = self.mandateId + if not effectiveMandateId: + # Fall back to Root mandate (first mandate in system) + try: + from modules.datamodels.datamodelUam import Mandate + from modules.security.rootAccess import getRootDbAppConnector + dbAppConn = getRootDbAppConnector() + allMandates = dbAppConn.getRecordset(Mandate) + if allMandates: + effectiveMandateId = allMandates[0].get("id") + logger.debug(f"createAutomationDefinition: Using Root mandate {effectiveMandateId}") + except Exception as e: + logger.warning(f"Could not get Root mandate: {e}") + automationData["mandateId"] = effectiveMandateId + if "featureInstanceId" not in automationData: + automationData["featureInstanceId"] = self.featureInstanceId + + # Ensure database connector has correct userId context + if not self.userId: + logger.error(f"createAutomationDefinition: userId is not set! Cannot set _createdBy. currentUser={self.currentUser}") + elif hasattr(self.db, 'updateContext'): + try: + self.db.updateContext(self.userId) + logger.debug(f"createAutomationDefinition: Updated database context with userId={self.userId}") + except Exception as e: + logger.warning(f"Could not update database context: {e}") + + # Create automation in database + createdAutomation = self.db.recordCreate(AutomationDefinition, automationData) + + # Compute status + createdAutomation["status"] = self._computeAutomationStatus(createdAutomation) + # Ensure executionLogs is always a list, not None + if createdAutomation.get("executionLogs") is None: + createdAutomation["executionLogs"] = [] + + # Trigger automation change callback (async, don't wait) + asyncio.create_task(self._notifyAutomationChanged()) + + # Clean metadata fields and return Pydantic model + cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")} + return AutomationDefinition(**cleanedRecord) + except Exception as e: + logger.error(f"Error creating automation definition: {str(e)}") + raise + + def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> AutomationDefinition: + """Updates an automation definition, then triggers sync.""" + try: + # Check access + existing = self.getAutomationDefinition(automationId) + if not existing: + raise PermissionError(f"No access to automation {automationId}") + + if not self.checkRbacPermission(AutomationDefinition, "update", automationId): + raise PermissionError(f"No permission to modify automation {automationId}") + + # Update automation in database + updatedAutomation = self.db.recordModify(AutomationDefinition, automationId, automationData) + + # Compute status + updatedAutomation["status"] = self._computeAutomationStatus(updatedAutomation) + # Ensure executionLogs is always a list, not None + if updatedAutomation.get("executionLogs") is None: + updatedAutomation["executionLogs"] = [] + + # Trigger automation change callback (async, don't wait) + asyncio.create_task(self._notifyAutomationChanged()) + + # Clean metadata fields and return Pydantic model + cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")} + return AutomationDefinition(**cleanedRecord) + except Exception as e: + logger.error(f"Error updating automation definition: {str(e)}") + raise + + def deleteAutomationDefinition(self, automationId: str) -> bool: + """Deletes an automation definition, then triggers sync.""" + try: + # Check access + existing = self.getAutomationDefinition(automationId) + if not existing: + raise PermissionError(f"No access to automation {automationId}") + + if not self.checkRbacPermission(AutomationDefinition, "delete", automationId): + raise PermissionError(f"No permission to delete automation {automationId}") + + # Delete automation from database + self.db.recordDelete(AutomationDefinition, automationId) + + # Trigger automation change callback (async, don't wait) + asyncio.create_task(self._notifyAutomationChanged()) + + return True + except Exception as e: + logger.error(f"Error deleting automation definition: {str(e)}") + raise + + def getAllAutomationDefinitionsWithRBAC(self, user: User) -> List[Dict[str, Any]]: + """ + Get all automation definitions filtered by RBAC for a specific user. + This method encapsulates getRecordsetWithRBAC() to avoid exposing the connector. + + Args: + user: User object for RBAC filtering + + Returns: + List of automation definition dictionaries filtered by RBAC + """ + return getRecordsetWithRBAC( + self.db, + AutomationDefinition, + user + ) + + # ========================================================================= + # AutomationTemplate CRUD methods + # ========================================================================= + + def getAllAutomationTemplates(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]: + """ + Returns automation templates filtered by RBAC (MY = own templates). + Supports optional pagination, sorting, and filtering. + """ + # Use RBAC filtering + filteredTemplates = getRecordsetWithRBAC( + self.db, + AutomationTemplate, + self.currentUser + ) + + # Enrich with user names + self._enrichTemplatesWithUserName(filteredTemplates) + + # If no pagination requested, return all items + if pagination is None: + return filteredTemplates + + # Apply filtering (if filters provided) + if pagination.filters: + filteredTemplates = self._applyFilters(filteredTemplates, pagination.filters) + + # Apply sorting (in order of sortFields) + if pagination.sort: + filteredTemplates = self._applySorting(filteredTemplates, pagination.sort) + + # Count total items after filters + totalItems = len(filteredTemplates) + totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0 + + # Apply pagination (skip/limit) + startIdx = (pagination.page - 1) * pagination.pageSize + endIdx = startIdx + pagination.pageSize + pagedTemplates = filteredTemplates[startIdx:endIdx] + + return PaginatedResult( + items=pagedTemplates, + totalItems=totalItems, + totalPages=totalPages + ) + + def _enrichTemplatesWithUserName(self, templates: List[Dict[str, Any]]) -> None: + """Batch enrich templates with creator user names.""" + if not templates: + return + + # Collect unique user IDs + userIds = set() + for template in templates: + createdBy = template.get("_createdBy") + if createdBy: + userIds.add(createdBy) + + if not userIds: + return + + # Batch fetch users + try: + from modules.datamodels.datamodelUam import UserInDB + from modules.security.rootAccess import getRootDbAppConnector + dbAppConn = getRootDbAppConnector() + + userNameMap = {} + for userId in userIds: + users = dbAppConn.getRecordset(UserInDB, {"id": userId}) + if users: + user = users[0] + fullName = f"{user.get('firstName', '')} {user.get('lastName', '')}".strip() + userNameMap[userId] = fullName or user.get("email", "Unknown") + + # Apply to templates + for template in templates: + createdBy = template.get("_createdBy") + if createdBy and createdBy in userNameMap: + template["_createdByUserName"] = userNameMap[createdBy] + except Exception as e: + logger.warning(f"Could not enrich templates with user names: {e}") + + def getAutomationTemplate(self, templateId: str) -> Optional[Dict[str, Any]]: + """Returns an automation template by ID if user has access.""" + try: + filtered = getRecordsetWithRBAC( + self.db, + AutomationTemplate, + self.currentUser, + recordFilter={"id": templateId} + ) + + if not filtered: + return None + + template = filtered[0] + self._enrichTemplatesWithUserName([template]) + return template + except Exception as e: + logger.error(f"Error getting automation template: {str(e)}") + return None + + def createAutomationTemplate(self, templateData: Dict[str, Any]) -> Dict[str, Any]: + """Creates a new automation template.""" + try: + # Ensure ID is present + if "id" not in templateData or not templateData["id"]: + templateData["id"] = str(uuid.uuid4()) + + # RBAC check + if not self.checkRbacPermission(AutomationTemplate, "create"): + raise PermissionError("No permission to create template") + + # Ensure database connector has correct userId context + if self.userId and hasattr(self.db, 'updateContext'): + try: + self.db.updateContext(self.userId) + except Exception as e: + logger.warning(f"Could not update database context: {e}") + + # Convert template field to string if it's a dict (frontend may send parsed JSON) + if "template" in templateData and isinstance(templateData["template"], dict): + import json + templateData["template"] = json.dumps(templateData["template"]) + + # Validate through Pydantic model to ensure proper type conversion + # This converts dict fields like TextMultilingual to proper Pydantic objects + validatedTemplate = AutomationTemplate(**templateData) + + # Create template in database using model_dump for proper serialization + createdTemplate = self.db.recordCreate(AutomationTemplate, validatedTemplate.model_dump()) + + return createdTemplate + except Exception as e: + logger.error(f"Error creating automation template: {str(e)}") + raise + + def updateAutomationTemplate(self, templateId: str, templateData: Dict[str, Any]) -> Dict[str, Any]: + """Updates an automation template.""" + try: + # Check access + existing = self.getAutomationTemplate(templateId) + if not existing: + raise PermissionError(f"No access to template {templateId}") + + if not self.checkRbacPermission(AutomationTemplate, "update", templateId): + raise PermissionError(f"No permission to modify template {templateId}") + + # Convert template field to string if it's a dict (frontend may send parsed JSON) + if "template" in templateData and isinstance(templateData["template"], dict): + import json + templateData["template"] = json.dumps(templateData["template"]) + + # Merge existing data with update data for partial updates + mergedData = {**existing, **templateData} + mergedData["id"] = templateId # Ensure ID is preserved + + # Validate through Pydantic model to ensure proper type conversion + validatedTemplate = AutomationTemplate(**mergedData) + + # Update template in database using model_dump for proper serialization + updatedTemplate = self.db.recordModify(AutomationTemplate, templateId, validatedTemplate.model_dump()) + + return updatedTemplate + except Exception as e: + logger.error(f"Error updating automation template: {str(e)}") + raise + + def deleteAutomationTemplate(self, templateId: str) -> bool: + """Deletes an automation template.""" + try: + # Check access using RBAC + existing = self.getAutomationTemplate(templateId) + if not existing: + return False + + if not self.checkRbacPermission(AutomationTemplate, "delete", templateId): + raise PermissionError(f"No permission to delete template {templateId}") + + # Delete template from database + self.db.recordDelete(AutomationTemplate, templateId) + + return True + except Exception as e: + logger.error(f"Error deleting automation template: {str(e)}") + raise + + async def _notifyAutomationChanged(self): + """Notify registered callbacks about automation changes (decoupled from features).""" + try: + from modules.shared.callbackRegistry import callbackRegistry + # Trigger callbacks without knowing which features are listening + await callbackRegistry.trigger('automation.changed', self) + except Exception as e: + logger.error(f"Error notifying automation change: {str(e)}") + + +def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'AutomationObjects': + """ + Returns an AutomationObjects instance for the current user. + Handles initialization of database and records. + + Args: + currentUser: The authenticated user + mandateId: The mandate ID from RequestContext (X-Mandate-Id header). + featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header). + """ + if not currentUser: + raise ValueError("Invalid user context: user is required") + + effectiveMandateId = str(mandateId) if mandateId else None + effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None + + # Create context key including featureInstanceId for proper isolation + contextKey = f"automation_{effectiveMandateId}_{effectiveFeatureInstanceId}_{currentUser.id}" + + # Create new instance if not exists + if contextKey not in _automationInterfaces: + _automationInterfaces[contextKey] = AutomationObjects(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId) + else: + # Update user context if needed + _automationInterfaces[contextKey].setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId) + + return _automationInterfaces[contextKey] diff --git a/modules/features/automation/routeFeatureAutomation.py b/modules/features/automation/routeFeatureAutomation.py index c92200de..ef2dac62 100644 --- a/modules/features/automation/routeFeatureAutomation.py +++ b/modules/features/automation/routeFeatureAutomation.py @@ -13,14 +13,13 @@ import logging import json # Import interfaces and models -from modules.interfaces.interfaceDbChat import getInterface as getChatInterface +from modules.features.automation.interfaceFeatureAutomation import getInterface as getAutomationInterface from modules.auth import limiter, getRequestContext, RequestContext -from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition +from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition, AutomationTemplate from modules.datamodels.datamodelChat import ChatWorkflow from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.workflows.automation import executeAutomation -from .subAutomationTemplates import getAutomationTemplates # Configure logger logger = logging.getLogger(__name__) @@ -69,7 +68,7 @@ async def get_automations( detail=f"Invalid pagination parameter: {str(e)}" ) - chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) + chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) result = chatInterface.getAllAutomationDefinitions(pagination=paginationParams) # If pagination was requested, result is PaginatedResult @@ -115,7 +114,7 @@ async def create_automation( ) -> AutomationDefinition: """Create a new automation definition""" try: - chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) + chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) automationData = automation.model_dump() created = chatInterface.createAutomationDefinition(automationData) return created @@ -128,26 +127,6 @@ async def create_automation( detail=f"Error creating automation: {str(e)}" ) -@router.get("/templates") -@limiter.limit("30/minute") -async def get_automation_templates( - request: Request, - context: RequestContext = Depends(getRequestContext) -) -> JSONResponse: - """ - Get automation templates from backend module. - The UI should fetch these templates regularly to get the latest versions. - """ - try: - templatesData = getAutomationTemplates() - return JSONResponse(content=templatesData) - except Exception as e: - logger.error(f"Error getting automation templates: {str(e)}") - raise HTTPException( - status_code=500, - detail=f"Error getting automation templates: {str(e)}" - ) - @router.get("/attributes", response_model=Dict[str, Any]) async def get_automation_attributes( request: Request @@ -164,7 +143,7 @@ async def get_automation( ) -> AutomationDefinition: """Get a single automation definition by ID""" try: - chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) + chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) automation = chatInterface.getAutomationDefinition(automationId) if not automation: raise HTTPException( @@ -192,7 +171,7 @@ async def update_automation( ) -> AutomationDefinition: """Update an automation definition""" try: - chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) + chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) automationData = automation.model_dump() updated = chatInterface.updateAutomationDefinition(automationId, automationData) return updated @@ -220,7 +199,7 @@ async def update_automation_status( ) -> AutomationDefinition: """Update only the active status of an automation definition""" try: - chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) + chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) # Get existing automation automation = chatInterface.getAutomationDefinition(automationId) @@ -260,7 +239,7 @@ async def delete_automation( ) -> Response: """Delete an automation definition""" try: - chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) + chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) success = chatInterface.deleteAutomationDefinition(automationId) if success: return Response(status_code=204) @@ -311,3 +290,321 @@ async def execute_automation_route( ) +@router.get("/actions") +@limiter.limit("30/minute") +async def get_available_actions( + request: Request, + context: RequestContext = Depends(getRequestContext) +) -> JSONResponse: + """ + Get available workflow actions for template editor. + Returns action definitions with parameters and example JSON snippets. + """ + try: + from modules.workflows.processing.shared.methodDiscovery import methods, discoverMethods + from modules.services import getInterface as getServices + + # Ensure methods are discovered (need a service center for discovery) + if not methods: + # Create a lightweight service center for method discovery + services = getServices(context.user, context.mandateId) + discoverMethods(services) + + actionsList = [] + processedMethods = set() + + for methodName, methodInfo in methods.items(): + # Skip short name aliases - only process full class names (MethodXxx) + if not methodName.startswith('Method'): + continue + + shortName = methodName.replace('Method', '').lower() + + # Skip if already processed + if shortName in processedMethods: + continue + processedMethods.add(shortName) + + methodInstance = methodInfo.get('instance') + if not methodInstance: + continue + + # Get actions from method instance + for actionName, actionDef in methodInstance._actions.items(): + # Build action info + actionInfo = { + "method": shortName, + "action": actionName, + "actionId": actionDef.actionId if hasattr(actionDef, 'actionId') else f"{shortName}.{actionName}", + "description": actionDef.description if hasattr(actionDef, 'description') else "", + "category": actionDef.category if hasattr(actionDef, 'category') else "general", + "parameters": [] + } + + # Add parameters from WorkflowActionParameter + parametersDef = actionDef.parameters if hasattr(actionDef, 'parameters') else {} + for paramName, paramDef in parametersDef.items(): + paramInfo = { + "name": paramName, + "type": paramDef.type if hasattr(paramDef, 'type') else "Any", + "frontendType": paramDef.frontendType.value if hasattr(paramDef, 'frontendType') and paramDef.frontendType else "text", + "required": paramDef.required if hasattr(paramDef, 'required') else False, + "default": paramDef.default if hasattr(paramDef, 'default') else None, + "description": paramDef.description if hasattr(paramDef, 'description') else "", + } + if hasattr(paramDef, 'frontendOptions') and paramDef.frontendOptions: + paramInfo["frontendOptions"] = paramDef.frontendOptions + actionInfo["parameters"].append(paramInfo) + + # Build example JSON snippet for copy/paste + exampleParams = {} + for paramName, paramDef in parametersDef.items(): + if hasattr(paramDef, 'required') and paramDef.required: + exampleParams[paramName] = f"{{{{KEY:{paramName}}}}}" + else: + default = paramDef.default if hasattr(paramDef, 'default') else None + exampleParams[paramName] = default or f"{{{{KEY:{paramName}}}}}" + + actionInfo["exampleJson"] = { + "execMethod": shortName, + "execAction": actionName, + "execParameters": exampleParams, + "execResultLabel": f"{shortName}_{actionName}_result" + } + + actionsList.append(actionInfo) + + return JSONResponse(content={"actions": actionsList}) + except Exception as e: + logger.error(f"Error getting available actions: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error getting available actions: {str(e)}" + ) + + +# ============================================================================= +# AutomationTemplate Routes (DB-persistiert) +# ============================================================================= +# Separater Router für /api/automation-templates + +templateRouter = APIRouter( + prefix="/api/automation-templates", + tags=["Manage Automation Templates"], + responses={ + 404: {"description": "Not found"}, + 400: {"description": "Bad request"}, + 401: {"description": "Unauthorized"}, + 403: {"description": "Forbidden"}, + 500: {"description": "Internal server error"} + } +) + +# Model attributes for AutomationTemplate +templateAttributes = getModelAttributeDefinitions(AutomationTemplate) + + +@templateRouter.get("", response_model=PaginatedResponse[AutomationTemplate]) +@limiter.limit("30/minute") +async def get_db_templates( + request: Request, + pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), + context: RequestContext = Depends(getRequestContext) +) -> JSONResponse: + """ + Get automation templates from database (RBAC-filtered, MY = own templates). + + Query Parameters: + - pagination: JSON-encoded PaginationParams object, or None for no pagination + """ + try: + # Parse pagination parameter + paginationParams = None + if pagination: + try: + paginationDict = json.loads(pagination) + if paginationDict: + paginationDict = normalize_pagination_dict(paginationDict) + paginationParams = PaginationParams(**paginationDict) + except (json.JSONDecodeError, ValueError) as e: + raise HTTPException( + status_code=400, + detail=f"Invalid pagination parameter: {str(e)}" + ) + + chatInterface = getAutomationInterface( + context.user, + mandateId=str(context.mandateId) if context.mandateId else None, + featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None + ) + result = chatInterface.getAllAutomationTemplates(pagination=paginationParams) + + if paginationParams: + response_data = { + "items": result.items, + "pagination": { + "currentPage": paginationParams.page, + "pageSize": paginationParams.pageSize, + "totalItems": result.totalItems, + "totalPages": result.totalPages, + "sort": paginationParams.sort, + "filters": paginationParams.filters + } + } + else: + response_data = { + "items": result, + "pagination": None + } + + return JSONResponse(content=response_data) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting templates: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error getting templates: {str(e)}" + ) + + +@templateRouter.get("/attributes", response_model=Dict[str, Any]) +async def get_template_attributes( + request: Request +) -> Dict[str, Any]: + """Get attribute definitions for AutomationTemplate model""" + return {"attributes": templateAttributes} + + +@templateRouter.get("/{templateId}") +@limiter.limit("30/minute") +async def get_db_template( + request: Request, + templateId: str = Path(..., description="Template ID"), + context: RequestContext = Depends(getRequestContext) +) -> JSONResponse: + """Get a single automation template by ID""" + try: + chatInterface = getAutomationInterface( + context.user, + mandateId=str(context.mandateId) if context.mandateId else None, + featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None + ) + template = chatInterface.getAutomationTemplate(templateId) + if not template: + raise HTTPException( + status_code=404, + detail=f"Template {templateId} not found" + ) + + return JSONResponse(content=template) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting template: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error getting template: {str(e)}" + ) + + +@templateRouter.post("") +@limiter.limit("10/minute") +async def create_db_template( + request: Request, + templateData: Dict[str, Any] = Body(...), + context: RequestContext = Depends(getRequestContext) +) -> JSONResponse: + """Create a new automation template""" + try: + chatInterface = getAutomationInterface( + context.user, + mandateId=str(context.mandateId) if context.mandateId else None, + featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None + ) + created = chatInterface.createAutomationTemplate(templateData) + return JSONResponse(content=created) + except HTTPException: + raise + except PermissionError as e: + raise HTTPException( + status_code=403, + detail=str(e) + ) + except Exception as e: + logger.error(f"Error creating template: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error creating template: {str(e)}" + ) + + +@templateRouter.put("/{templateId}") +@limiter.limit("10/minute") +async def update_db_template( + request: Request, + templateId: str = Path(..., description="Template ID"), + templateData: Dict[str, Any] = Body(...), + context: RequestContext = Depends(getRequestContext) +) -> JSONResponse: + """Update an automation template""" + try: + chatInterface = getAutomationInterface( + context.user, + mandateId=str(context.mandateId) if context.mandateId else None, + featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None + ) + updated = chatInterface.updateAutomationTemplate(templateId, templateData) + return JSONResponse(content=updated) + except HTTPException: + raise + except PermissionError as e: + raise HTTPException( + status_code=403, + detail=str(e) + ) + except Exception as e: + logger.error(f"Error updating template: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error updating template: {str(e)}" + ) + + +@templateRouter.delete("/{templateId}") +@limiter.limit("10/minute") +async def delete_db_template( + request: Request, + templateId: str = Path(..., description="Template ID"), + context: RequestContext = Depends(getRequestContext) +) -> Response: + """Delete an automation template""" + try: + chatInterface = getAutomationInterface( + context.user, + mandateId=str(context.mandateId) if context.mandateId else None, + featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None + ) + success = chatInterface.deleteAutomationTemplate(templateId) + if success: + return Response(status_code=204) + else: + raise HTTPException( + status_code=404, + detail="Template not found or no permission" + ) + except HTTPException: + raise + except PermissionError as e: + raise HTTPException( + status_code=403, + detail=str(e) + ) + except Exception as e: + logger.error(f"Error deleting template: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error deleting template: {str(e)}" + ) + + diff --git a/modules/features/chatbot/interfaceFeatureChatbot.py b/modules/features/chatbot/interfaceFeatureChatbot.py index 50fdf4ef..c1e5977e 100644 --- a/modules/features/chatbot/interfaceFeatureChatbot.py +++ b/modules/features/chatbot/interfaceFeatureChatbot.py @@ -25,7 +25,6 @@ from modules.datamodels.datamodelChat import ( WorkflowModeEnum, UserInputRequest ) -from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition import json from modules.datamodels.datamodelUam import User @@ -1700,286 +1699,6 @@ class ChatObjects: return {"items": items} - # ===== Automation Methods ===== - - def _computeAutomationStatus(self, automation: Dict[str, Any]) -> str: - """Compute status field based on eventId presence""" - eventId = automation.get("eventId") - return "Running" if eventId else "Idle" - - def _enrichAutomationsWithUserAndMandate(self, automations: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """ - Batch enrich automations with user names and mandate names for display. - Uses AppObjects interface to fetch users and mandates with proper access control. - """ - if not automations: - return automations - - from modules.interfaces.interfaceDbApp import getInterface as getAppInterface - - # Collect all unique user IDs and mandate IDs - userIds = set() - mandateIds = set() - - for automation in automations: - createdBy = automation.get("_createdBy") - if createdBy: - userIds.add(createdBy) - - mandateId = automation.get("mandateId") - if mandateId: - mandateIds.add(mandateId) - - # Use AppObjects interface to fetch users (respects access control) - appInterface = getAppInterface(self.currentUser) - usersMap = {} - if userIds: - for user_id in userIds: - user = appInterface.getUser(user_id) - if user: - usersMap[user_id] = user.username or user.email or user_id - - # Use AppObjects interface to fetch mandates (respects access control) - mandatesMap = {} - if mandateIds: - for mandate_id in mandateIds: - mandate = appInterface.getMandate(mandate_id) - if mandate: - mandatesMap[mandate_id] = mandate.name or mandate_id - - # Enrich each automation with the fetched data - for automation in automations: - createdBy = automation.get("_createdBy") - if createdBy: - automation["_createdByUserName"] = usersMap.get(createdBy, createdBy) - else: - automation["_createdByUserName"] = "-" - - mandateId = automation.get("mandateId") - if mandateId: - automation["mandateName"] = mandatesMap.get(mandateId, mandateId) - else: - automation["mandateName"] = "-" - - return automations - - def _enrichAutomationWithUserAndMandate(self, automation: Dict[str, Any]) -> Dict[str, Any]: - """ - Enrich a single automation with user name and mandate name for display. - For multiple automations, use _enrichAutomationsWithUserAndMandate for better performance. - """ - return self._enrichAutomationsWithUserAndMandate([automation])[0] - - def getAllAutomationDefinitions(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]: - """ - Returns automation definitions based on user access level. - Supports optional pagination, sorting, and filtering. - Computes status field for each automation. - """ - # Use RBAC filtering - filteredAutomations = getRecordsetWithRBAC(self.db, - AutomationDefinition, - self.currentUser - ) - - # Compute status for each automation and normalize executionLogs - for automation in filteredAutomations: - automation["status"] = self._computeAutomationStatus(automation) - # Ensure executionLogs is always a list, not None - if automation.get("executionLogs") is None: - automation["executionLogs"] = [] - - # Batch enrich with user and mandate names - self._enrichAutomationsWithUserAndMandate(filteredAutomations) - - # If no pagination requested, return all items - if pagination is None: - return filteredAutomations - - # Apply filtering (if filters provided) - if pagination.filters: - filteredAutomations = self._applyFilters(filteredAutomations, pagination.filters) - - # Apply sorting (in order of sortFields) - if pagination.sort: - filteredAutomations = self._applySorting(filteredAutomations, pagination.sort) - - # Count total items after filters - totalItems = len(filteredAutomations) - totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0 - - # Apply pagination (skip/limit) - startIdx = (pagination.page - 1) * pagination.pageSize - endIdx = startIdx + pagination.pageSize - pagedAutomations = filteredAutomations[startIdx:endIdx] - - return PaginatedResult( - items=pagedAutomations, - totalItems=totalItems, - totalPages=totalPages - ) - - def getAutomationDefinition(self, automationId: str) -> Optional[AutomationDefinition]: - """Returns an automation definition by ID if user has access, with computed status.""" - try: - # Use RBAC filtering - filtered = getRecordsetWithRBAC(self.db, - AutomationDefinition, - self.currentUser, - recordFilter={"id": automationId} - ) - - if not filtered: - return None - - automation = filtered[0] - automation["status"] = self._computeAutomationStatus(automation) - # Ensure executionLogs is always a list, not None - if automation.get("executionLogs") is None: - automation["executionLogs"] = [] - # Enrich with user and mandate names - self._enrichAutomationWithUserAndMandate(automation) - # Clean metadata fields and return Pydantic model - cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")} - return AutomationDefinition(**cleanedRecord) - except Exception as e: - logger.error(f"Error getting automation definition: {str(e)}") - return None - - def createAutomationDefinition(self, automationData: Dict[str, Any]) -> AutomationDefinition: - """Creates a new automation definition, then triggers sync.""" - try: - # Ensure ID is present - if "id" not in automationData or not automationData["id"]: - automationData["id"] = str(uuid.uuid4()) - - # Ensure mandateId and featureInstanceId are set for proper data isolation - if "mandateId" not in automationData: - automationData["mandateId"] = self.mandateId - if "featureInstanceId" not in automationData: - automationData["featureInstanceId"] = self.featureInstanceId - - # Ensure database connector has correct userId context - # The connector should have been initialized with userId, but ensure it's updated - if self.userId and hasattr(self.db, 'updateContext'): - try: - self.db.updateContext(self.userId) - except Exception as e: - logger.warning(f"Could not update database context: {e}") - - # Note: _createdBy will be set automatically by connector's _saveRecord method - # when _createdAt is not present. We don't need to set it manually here. - # Use generic field separation - simpleFields, objectFields = self._separateObjectFields(AutomationDefinition, automationData) - - # Create automation in database - createdAutomation = self.db.recordCreate(AutomationDefinition, simpleFields) - - # Compute status - createdAutomation["status"] = self._computeAutomationStatus(createdAutomation) - # Ensure executionLogs is always a list, not None - if createdAutomation.get("executionLogs") is None: - createdAutomation["executionLogs"] = [] - - # Trigger automation change callback (async, don't wait) - asyncio.create_task(self._notifyAutomationChanged()) - - # Clean metadata fields and return Pydantic model - cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")} - return AutomationDefinition(**cleanedRecord) - except Exception as e: - logger.error(f"Error creating automation definition: {str(e)}") - raise - - def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> AutomationDefinition: - """Updates an automation definition, then triggers sync.""" - try: - # Check access - existing = self.getAutomationDefinition(automationId) - if not existing: - raise PermissionError(f"No access to automation {automationId}") - - if not self.checkRbacPermission(AutomationDefinition, "update", automationId): - raise PermissionError(f"No permission to modify automation {automationId}") - - # Use generic field separation - simpleFields, objectFields = self._separateObjectFields(AutomationDefinition, automationData) - - # Update automation in database - updatedAutomation = self.db.recordModify(AutomationDefinition, automationId, simpleFields) - - # Compute status - updatedAutomation["status"] = self._computeAutomationStatus(updatedAutomation) - # Ensure executionLogs is always a list, not None - if updatedAutomation.get("executionLogs") is None: - updatedAutomation["executionLogs"] = [] - - # Trigger automation change callback (async, don't wait) - asyncio.create_task(self._notifyAutomationChanged()) - - # Clean metadata fields and return Pydantic model - cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")} - return AutomationDefinition(**cleanedRecord) - except Exception as e: - logger.error(f"Error updating automation definition: {str(e)}") - raise - - def deleteAutomationDefinition(self, automationId: str) -> bool: - """Deletes an automation definition, then triggers sync.""" - try: - # Check access - existing = self.getAutomationDefinition(automationId) - if not existing: - raise PermissionError(f"No access to automation {automationId}") - - if not self.checkRbacPermission(AutomationDefinition, "delete", automationId): - raise PermissionError(f"No permission to delete automation {automationId}") - - # Remove event if exists - if existing.get("eventId"): - from modules.shared.eventManagement import eventManager - try: - eventManager.remove(existing["eventId"]) - except Exception as e: - logger.warning(f"Error removing event {existing['eventId']}: {str(e)}") - - # Delete automation from database - self.db.recordDelete(AutomationDefinition, automationId) - - # Trigger automation change callback (async, don't wait) - asyncio.create_task(self._notifyAutomationChanged()) - - return True - except Exception as e: - logger.error(f"Error deleting automation definition: {str(e)}") - raise - - def getAllAutomationDefinitionsWithRBAC(self, user: User) -> List[Dict[str, Any]]: - """ - Get all automation definitions filtered by RBAC for a specific user. - This method encapsulates getRecordsetWithRBAC() to avoid exposing the connector. - - Args: - user: User object for RBAC filtering - - Returns: - List of automation definition dictionaries filtered by RBAC - """ - return getRecordsetWithRBAC( - self.db, - AutomationDefinition, - user - ) - - async def _notifyAutomationChanged(self): - """Notify registered callbacks about automation changes (decoupled from features).""" - try: - from modules.shared.callbackRegistry import callbackRegistry - # Trigger callbacks without knowing which features are listening - await callbackRegistry.trigger('automation.changed', self) - except Exception as e: - logger.error(f"Error notifying automation change: {str(e)}") - def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ChatObjects': """ diff --git a/modules/features/trustee/interfaceFeatureTrustee.py b/modules/features/trustee/interfaceFeatureTrustee.py index b5e08e2b..b76bd164 100644 --- a/modules/features/trustee/interfaceFeatureTrustee.py +++ b/modules/features/trustee/interfaceFeatureTrustee.py @@ -459,7 +459,8 @@ class TrusteeObjects: recordFilter=None, orderBy="id", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) logger.debug(f"getAllOrganisations: getRecordsetWithRBAC returned {len(records)} records") @@ -552,7 +553,8 @@ class TrusteeObjects: recordFilter=None, orderBy="id", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) # Users with ALL access level (from system RBAC) see all roles @@ -662,7 +664,8 @@ class TrusteeObjects: recordFilter=None, orderBy="id", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) # Users with ALL access level (from system RBAC) see all records @@ -721,7 +724,8 @@ class TrusteeObjects: recordFilter={"organisationId": organisationId}, orderBy="id", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records] @@ -738,7 +742,8 @@ class TrusteeObjects: recordFilter={"userId": userId}, orderBy="id", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) # Users with ALL access level (from system RBAC) see all records @@ -855,7 +860,8 @@ class TrusteeObjects: recordFilter=None, orderBy="id", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) totalItems = len(records) @@ -888,7 +894,8 @@ class TrusteeObjects: recordFilter={"organisationId": organisationId}, orderBy="label", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) return [TrusteeContract(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records] @@ -1007,7 +1014,8 @@ class TrusteeObjects: recordFilter=None, orderBy="documentName", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) # Clean records (remove binary data and internal fields) - keep as dicts for filtering/sorting @@ -1056,7 +1064,8 @@ class TrusteeObjects: recordFilter={"contractId": contractId}, orderBy="documentName", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) result = [] @@ -1165,7 +1174,8 @@ class TrusteeObjects: recordFilter=None, orderBy="valuta", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) # Clean records (remove internal fields) - keep as dicts for filtering/sorting @@ -1214,7 +1224,8 @@ class TrusteeObjects: recordFilter={"contractId": contractId}, orderBy="valuta", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records] @@ -1228,7 +1239,8 @@ class TrusteeObjects: recordFilter={"organisationId": organisationId}, orderBy="valuta", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records] @@ -1354,7 +1366,8 @@ class TrusteeObjects: orderBy="id", mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, - enrichPermissions=True + enrichPermissions=True, + featureCode=self.FEATURE_CODE ) totalItems = len(records) @@ -1387,7 +1400,8 @@ class TrusteeObjects: recordFilter={"positionId": positionId}, orderBy="id", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in links] @@ -1401,7 +1415,8 @@ class TrusteeObjects: recordFilter={"documentId": documentId}, orderBy="id", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in links] diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index c73c4dd1..12cfd41d 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -70,6 +70,68 @@ def initBootstrap(db: DatabaseConnector) -> None: # Apply multi-tenant database optimizations (indexes, triggers, FKs) _applyDatabaseOptimizations(db) + # Seed automation templates (after admin user exists) + initAutomationTemplates(db, adminUserId) + + +def initAutomationTemplates(db: DatabaseConnector, adminUserId: Optional[str] = None) -> None: + """ + Seed initial automation templates from subAutomationTemplates.py. + Only runs if no templates exist yet (bootstrap). + Creates templates with _createdBy = admin user (SysAdmin privilege). + + Args: + db: Database connector instance + adminUserId: Admin user ID for _createdBy field + """ + import json + from modules.features.automation.subAutomationTemplates import AUTOMATION_TEMPLATES + from modules.features.automation.datamodelFeatureAutomation import AutomationTemplate + + # Check if templates already exist + existing = db.getRecordset(AutomationTemplate) + if existing: + logger.info(f"Automation templates already seeded ({len(existing)} templates)") + return + + # Get admin user ID if not provided + if not adminUserId: + from modules.shared.configuration import APP_CONFIG + adminUsers = db.getRecordset(UserInDB, {"email": APP_CONFIG.ADMIN_EMAIL}) + adminUserId = adminUsers[0]["id"] if adminUsers else None + + templates = AUTOMATION_TEMPLATES.get("sets", []) + createdCount = 0 + + for i, templateSet in enumerate(templates): + templateContent = templateSet.get("template", {}) + overview = templateContent.get("overview", f"Template {i+1}") + + # Create multilingual label from overview (use as German since current templates are German) + # English is required by TextMultilingual, so we use the same value + labelDict = {"en": overview, "ge": overview} + overviewDict = {"en": overview, "ge": overview} + + # Create template WITHOUT parameters (no sharp values) + templateData = { + "label": labelDict, + "overview": overviewDict, + "template": json.dumps(templateContent), # Store entire template JSON + } + + try: + # Update context to set _createdBy to admin + if adminUserId and hasattr(db, 'updateContext'): + db.updateContext(adminUserId) + + db.recordCreate(AutomationTemplate, templateData) + createdCount += 1 + logger.debug(f"Created automation template: {overview}") + except Exception as e: + logger.error(f"Failed to create automation template '{overview}': {e}") + + logger.info(f"Seeded {createdCount} automation templates") + logger.info("System bootstrap completed") @@ -677,6 +739,31 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None: delete=AccessLevel.NONE, )) + # AutomationTemplate: Only MY-level access (user-owned) + for roleId in [adminId, userId]: + if roleId: + tableRules.append(AccessRule( + roleId=roleId, + context=AccessRuleContext.DATA, + item="data.automation.AutomationTemplate", + view=True, + read=AccessLevel.MY, + create=AccessLevel.MY, + update=AccessLevel.MY, + delete=AccessLevel.MY, + )) + if viewerId: + tableRules.append(AccessRule( + roleId=viewerId, + context=AccessRuleContext.DATA, + item="data.automation.AutomationTemplate", + view=True, + read=AccessLevel.MY, + create=AccessLevel.NONE, + update=AccessLevel.NONE, + delete=AccessLevel.NONE, + )) + # Create all table-specific rules for rule in tableRules: db.recordCreate(AccessRule, rule) @@ -843,6 +930,7 @@ def _ensureDataContextRules(db: DatabaseConnector) -> None: tablesNeedingRules = [ "data.chat.ChatWorkflow", "data.automation.AutomationDefinition", + "data.automation.AutomationTemplate", ] missingRules = [] diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py index 4e8736c8..0aec7fe0 100644 --- a/modules/interfaces/interfaceDbChat.py +++ b/modules/interfaces/interfaceDbChat.py @@ -25,7 +25,6 @@ from modules.datamodels.datamodelChat import ( WorkflowModeEnum, UserInputRequest ) -from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition import json from modules.datamodels.datamodelUam import User @@ -1654,311 +1653,6 @@ class ChatObjects: return {"items": items} - # ===== Automation Methods ===== - - def _computeAutomationStatus(self, automation: Dict[str, Any]) -> str: - """Compute status field based on eventId presence""" - eventId = automation.get("eventId") - return "Running" if eventId else "Idle" - - def _enrichAutomationsWithUserAndMandate(self, automations: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """ - Batch enrich automations with user names and mandate names for display. - Uses AppObjects interface to fetch users and mandates with proper access control. - """ - if not automations: - return automations - - from modules.interfaces.interfaceDbApp import getInterface as getAppInterface - - # Collect all unique user IDs and mandate IDs - userIds = set() - mandateIds = set() - - for automation in automations: - createdBy = automation.get("_createdBy") - if createdBy: - userIds.add(createdBy) - - mandateId = automation.get("mandateId") - if mandateId: - mandateIds.add(mandateId) - - # Use AppObjects interface to fetch users (respects access control) - appInterface = getAppInterface(self.currentUser) - usersMap = {} - if userIds: - for user_id in userIds: - user = appInterface.getUser(user_id) - if user: - usersMap[user_id] = user.username or user.email or user_id - - # Use AppObjects interface to fetch mandates (respects access control) - mandatesMap = {} - if mandateIds: - for mandate_id in mandateIds: - mandate = appInterface.getMandate(mandate_id) - if mandate: - mandatesMap[mandate_id] = mandate.name or mandate_id - - # Enrich each automation with the fetched data - for automation in automations: - createdBy = automation.get("_createdBy") - if createdBy: - automation["_createdByUserName"] = usersMap.get(createdBy, createdBy) - else: - automation["_createdByUserName"] = "-" - - mandateId = automation.get("mandateId") - if mandateId: - automation["mandateName"] = mandatesMap.get(mandateId, mandateId) - else: - automation["mandateName"] = "-" - - return automations - - def _enrichAutomationWithUserAndMandate(self, automation: Dict[str, Any]) -> Dict[str, Any]: - """ - Enrich a single automation with user name and mandate name for display. - For multiple automations, use _enrichAutomationsWithUserAndMandate for better performance. - """ - return self._enrichAutomationsWithUserAndMandate([automation])[0] - - def getAllAutomationDefinitions(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]: - """ - Returns automation definitions based on user access level. - Supports optional pagination, sorting, and filtering. - Computes status field for each automation. - """ - # Use RBAC filtering - filteredAutomations = getRecordsetWithRBAC(self.db, - AutomationDefinition, - self.currentUser - ) - - # Compute status for each automation and normalize executionLogs - for automation in filteredAutomations: - automation["status"] = self._computeAutomationStatus(automation) - # Ensure executionLogs is always a list, not None - if automation.get("executionLogs") is None: - automation["executionLogs"] = [] - - # Batch enrich with user and mandate names - self._enrichAutomationsWithUserAndMandate(filteredAutomations) - - # If no pagination requested, return all items - if pagination is None: - return filteredAutomations - - # Apply filtering (if filters provided) - if pagination.filters: - filteredAutomations = self._applyFilters(filteredAutomations, pagination.filters) - - # Apply sorting (in order of sortFields) - if pagination.sort: - filteredAutomations = self._applySorting(filteredAutomations, pagination.sort) - - # Count total items after filters - totalItems = len(filteredAutomations) - totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0 - - # Apply pagination (skip/limit) - startIdx = (pagination.page - 1) * pagination.pageSize - endIdx = startIdx + pagination.pageSize - pagedAutomations = filteredAutomations[startIdx:endIdx] - - return PaginatedResult( - items=pagedAutomations, - totalItems=totalItems, - totalPages=totalPages - ) - - def getAutomationDefinition(self, automationId: str, includeSystemFields: bool = False) -> Optional[AutomationDefinition]: - """Returns an automation definition by ID if user has access, with computed status. - - Args: - automationId: ID of the automation to get - includeSystemFields: If True, returns raw dict with system fields (_createdBy, etc). - If False (default), returns Pydantic model without system fields. - """ - try: - # Use RBAC filtering - filtered = getRecordsetWithRBAC(self.db, - AutomationDefinition, - self.currentUser, - recordFilter={"id": automationId} - ) - - if not filtered: - return None - - automation = filtered[0] - automation["status"] = self._computeAutomationStatus(automation) - # Ensure executionLogs is always a list, not None - if automation.get("executionLogs") is None: - automation["executionLogs"] = [] - # Enrich with user and mandate names - self._enrichAutomationWithUserAndMandate(automation) - - # For internal use (execution), return raw dict with system fields - if includeSystemFields: - # Return as simple namespace object so getattr works - class AutomationWithSystemFields: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - return AutomationWithSystemFields(automation) - - # Clean metadata fields and return Pydantic model - cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")} - return AutomationDefinition(**cleanedRecord) - except Exception as e: - logger.error(f"Error getting automation definition: {str(e)}") - return None - - def createAutomationDefinition(self, automationData: Dict[str, Any]) -> AutomationDefinition: - """Creates a new automation definition, then triggers sync.""" - try: - # Ensure ID is present - if "id" not in automationData or not automationData["id"]: - automationData["id"] = str(uuid.uuid4()) - - # Ensure mandateId and featureInstanceId are set for proper data isolation - if "mandateId" not in automationData or not automationData.get("mandateId"): - # Use request context mandateId, or fall back to Root mandate - effectiveMandateId = self.mandateId - if not effectiveMandateId: - # Fall back to Root mandate (first mandate in system) - try: - from modules.datamodels.datamodelUam import Mandate - from modules.security.rootAccess import getRootDbAppConnector - dbAppConn = getRootDbAppConnector() - allMandates = dbAppConn.getRecordset(Mandate) - if allMandates: - effectiveMandateId = allMandates[0].get("id") - logger.debug(f"createAutomationDefinition: Using Root mandate {effectiveMandateId}") - except Exception as e: - logger.warning(f"Could not get Root mandate: {e}") - automationData["mandateId"] = effectiveMandateId - if "featureInstanceId" not in automationData: - automationData["featureInstanceId"] = self.featureInstanceId - - # Ensure database connector has correct userId context - # The connector should have been initialized with userId, but ensure it's updated - if not self.userId: - logger.error(f"createAutomationDefinition: userId is not set! Cannot set _createdBy. currentUser={self.currentUser}") - elif hasattr(self.db, 'updateContext'): - try: - self.db.updateContext(self.userId) - logger.debug(f"createAutomationDefinition: Updated database context with userId={self.userId}") - except Exception as e: - logger.warning(f"Could not update database context: {e}") - - # Note: _createdBy will be set automatically by connector's _saveRecord method - # when _createdAt is not present. We don't need to set it manually here. - # Use generic field separation - simpleFields, objectFields = self._separateObjectFields(AutomationDefinition, automationData) - - # Create automation in database - createdAutomation = self.db.recordCreate(AutomationDefinition, simpleFields) - - # Compute status - createdAutomation["status"] = self._computeAutomationStatus(createdAutomation) - # Ensure executionLogs is always a list, not None - if createdAutomation.get("executionLogs") is None: - createdAutomation["executionLogs"] = [] - - # Trigger automation change callback (async, don't wait) - asyncio.create_task(self._notifyAutomationChanged()) - - # Clean metadata fields and return Pydantic model - cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")} - return AutomationDefinition(**cleanedRecord) - except Exception as e: - logger.error(f"Error creating automation definition: {str(e)}") - raise - - def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> AutomationDefinition: - """Updates an automation definition, then triggers sync.""" - try: - # Check access - existing = self.getAutomationDefinition(automationId) - if not existing: - raise PermissionError(f"No access to automation {automationId}") - - if not self.checkRbacPermission(AutomationDefinition, "update", automationId): - raise PermissionError(f"No permission to modify automation {automationId}") - - # Use generic field separation - simpleFields, objectFields = self._separateObjectFields(AutomationDefinition, automationData) - - # Update automation in database - updatedAutomation = self.db.recordModify(AutomationDefinition, automationId, simpleFields) - - # Compute status - updatedAutomation["status"] = self._computeAutomationStatus(updatedAutomation) - # Ensure executionLogs is always a list, not None - if updatedAutomation.get("executionLogs") is None: - updatedAutomation["executionLogs"] = [] - - # Trigger automation change callback (async, don't wait) - asyncio.create_task(self._notifyAutomationChanged()) - - # Clean metadata fields and return Pydantic model - cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")} - return AutomationDefinition(**cleanedRecord) - except Exception as e: - logger.error(f"Error updating automation definition: {str(e)}") - raise - - def deleteAutomationDefinition(self, automationId: str) -> bool: - """Deletes an automation definition, then triggers sync.""" - try: - # Check access - existing = self.getAutomationDefinition(automationId) - if not existing: - raise PermissionError(f"No access to automation {automationId}") - - if not self.checkRbacPermission(AutomationDefinition, "delete", automationId): - raise PermissionError(f"No permission to delete automation {automationId}") - - # Delete automation from database - self.db.recordDelete(AutomationDefinition, automationId) - - # Trigger automation change callback (async, don't wait) - asyncio.create_task(self._notifyAutomationChanged()) - - return True - except Exception as e: - logger.error(f"Error deleting automation definition: {str(e)}") - raise - - def getAllAutomationDefinitionsWithRBAC(self, user: User) -> List[Dict[str, Any]]: - """ - Get all automation definitions filtered by RBAC for a specific user. - This method encapsulates getRecordsetWithRBAC() to avoid exposing the connector. - - Args: - user: User object for RBAC filtering - - Returns: - List of automation definition dictionaries filtered by RBAC - """ - return getRecordsetWithRBAC( - self.db, - AutomationDefinition, - user - ) - - async def _notifyAutomationChanged(self): - """Notify registered callbacks about automation changes (decoupled from features).""" - try: - from modules.shared.callbackRegistry import callbackRegistry - # Trigger callbacks without knowing which features are listening - await callbackRegistry.trigger('automation.changed', self) - except Exception as e: - logger.error(f"Error notifying automation change: {str(e)}") - def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ChatObjects': """ diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py index 3e062048..21fd6fa2 100644 --- a/modules/interfaces/interfaceRbac.py +++ b/modules/interfaces/interfaceRbac.py @@ -66,6 +66,7 @@ TABLE_NAMESPACE = { "FileData": "files", # Automation - benutzer-eigen "AutomationDefinition": "automation", + "AutomationTemplate": "automation", } # Namespaces ohne Mandantenkontext - GROUP wird auf MY gemappt @@ -145,17 +146,8 @@ def getRecordsetWithRBAC( if not connector._ensureTableExists(modelClass): return [] - # SysAdmin bypass: SysAdmin users have full access to all tables - isSysAdmin = getattr(currentUser, 'isSysAdmin', False) - if isSysAdmin: - # Direct access without RBAC filtering - # Note: getRecordset doesn't support orderBy/limit - these are only used in RBAC path - records = connector.getRecordset(modelClass, recordFilter=recordFilter) - if enrichPermissions: - # SysAdmin has full permissions on all records - for record in records: - record["_permissions"] = {"canUpdate": True, "canDelete": True} - return records + # All users (including SysAdmins) go through RBAC filtering + # SysAdmin flag does NOT grant automatic data access - proper RBAC rules must exist # Get RBAC permissions for this table using full objectKey # AccessRule table is always in DbApp database @@ -184,7 +176,8 @@ def getRecordsetWithRBAC( currentUser, table, connector, - mandateId=effectiveMandateId + mandateId=effectiveMandateId, + featureInstanceId=featureInstanceId ) if rbacWhereClause: whereConditions.append(rbacWhereClause["condition"]) @@ -281,13 +274,15 @@ def buildRbacWhereClause( currentUser: User, table: str, connector, # DatabaseConnector instance for connection access - mandateId: Optional[str] = None + mandateId: Optional[str] = None, + featureInstanceId: Optional[str] = None ) -> Optional[Dict[str, Any]]: """ Build RBAC WHERE clause based on permissions and access level. Multi-Tenant Design: - mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header) + - featureInstanceId wird für Feature-Tabellen zusätzlich gefiltert Args: permissions: UserPermissions object @@ -295,6 +290,7 @@ def buildRbacWhereClause( table: Table name connector: DatabaseConnector instance (needed for GROUP queries) mandateId: Explicit mandate context (from request header). Required for GROUP access. + featureInstanceId: Feature instance context for feature-level data isolation. Returns: Dictionary with "condition" and "values" keys, or None if no filtering needed @@ -308,8 +304,20 @@ def buildRbacWhereClause( if readLevel == AccessLevel.NONE: return {"condition": "1 = 0", "values": []} - # All records - no filtering needed + # CRITICAL: featureInstanceId filter is ALWAYS required when provided + # This ensures data isolation between feature instances regardless of access level + baseConditions = [] + baseValues = [] + + if featureInstanceId: + # Strict filter: only records for this exact feature instance + baseConditions.append('"featureInstanceId" = %s') + baseValues.append(featureInstanceId) + + # All records within the feature instance - only featureInstanceId filtering if readLevel == AccessLevel.ALL: + if baseConditions: + return {"condition": " AND ".join(baseConditions), "values": baseValues} return None # My records - filter by _createdBy or userId field @@ -323,9 +331,14 @@ def buildRbacWhereClause( else: userIdField = "_createdBy" + conditions = list(baseConditions) + values = list(baseValues) + conditions.append(f'"{userIdField}" = %s') + values.append(currentUser.id) + return { - "condition": f'"{userIdField}" = %s', - "values": [currentUser.id] + "condition": " AND ".join(conditions), + "values": values } # Group records - filter by mandateId or ownership based on namespace @@ -335,8 +348,10 @@ def buildRbacWhereClause( # For user-owned namespaces (chat, files, automation): # GROUP has no meaning - these tables have no mandate context - # Simply ignore GROUP (no filtering) + # But still apply featureInstanceId filter if provided if namespace in USER_OWNED_NAMESPACES: + if baseConditions: + return {"condition": " AND ".join(baseConditions), "values": baseValues} return None # For UAM and other namespaces: GROUP filters by mandate @@ -373,9 +388,14 @@ def buildRbacWhereClause( if not userIds: return {"condition": "1 = 0", "values": []} placeholders = ",".join(["%s"] * len(userIds)) + # Combine with base conditions (featureInstanceId) + conditions = list(baseConditions) + values = list(baseValues) + conditions.append(f'"id" IN ({placeholders})') + values.extend(userIds) return { - "condition": f'"id" IN ({placeholders})', - "values": userIds + "condition": " AND ".join(conditions) if conditions else f'"id" IN ({placeholders})', + "values": values } except Exception as e: logger.error(f"Error building GROUP filter for UserInDB via UserMandate: {e}") @@ -395,28 +415,45 @@ def buildRbacWhereClause( if not userIds: return {"condition": "1 = 0", "values": []} placeholders = ",".join(["%s"] * len(userIds)) + # Combine with base conditions (featureInstanceId) + conditions = list(baseConditions) + values = list(baseValues) + conditions.append(f'"userId" IN ({placeholders})') + values.extend(userIds) return { - "condition": f'"userId" IN ({placeholders})', - "values": userIds + "condition": " AND ".join(conditions) if conditions else f'"userId" IN ({placeholders})', + "values": values } except Exception as e: logger.error(f"Error building GROUP filter for UserConnection: {e}") return {"condition": "1 = 0", "values": []} # For system tables without mandateId column (Mandate, Role, etc.): - # No row-level filtering - GROUP access = ALL access for these + # No row-level filtering based on mandate, but still apply featureInstanceId if provided elif table in ("Mandate", "Role"): + if baseConditions: + return {"condition": " AND ".join(baseConditions), "values": baseValues} return None # For other tables, filter by mandateId field # Also include records with NULL mandateId for backwards compatibility else: + # Start with base conditions (includes strict featureInstanceId filter) + conditions = list(baseConditions) + values = list(baseValues) + + # Add mandate filter + conditions.append('("mandateId" = %s OR "mandateId" IS NULL)') + values.append(effectiveMandateId) + return { - "condition": '("mandateId" = %s OR "mandateId" IS NULL)', - "values": [effectiveMandateId] + "condition": " AND ".join(conditions), + "values": values } - return None + # Unknown access level - deny access (security: deny by default) + logger.warning(f"Unknown access level '{readLevel}' for user {currentUser.id} - denying access") + return {"condition": "1 = 0", "values": []} def _enrichRecordsWithPermissions( diff --git a/modules/routes/routeAdminAutomationEvents.py b/modules/routes/routeAdminAutomationEvents.py index e829f986..e8bb9291 100644 --- a/modules/routes/routeAdminAutomationEvents.py +++ b/modules/routes/routeAdminAutomationEvents.py @@ -11,7 +11,7 @@ from fastapi import status import logging # Import interfaces and models from feature containers -import modules.interfaces.interfaceDbChat as interfaceDbChat +import modules.features.automation.interfaceFeatureAutomation as interfaceAutomation from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext from modules.datamodels.datamodelUam import User @@ -79,7 +79,6 @@ async def sync_all_automation_events( from modules.interfaces.interfaceDbApp import getRootInterface from modules.workflows.automation import syncAutomationEvents - chatInterface = getChatInterface(currentUser) # Get event user for sync operation (routes can import from interfaces) rootInterface = getRootInterface() eventUser = rootInterface.getUserByUsername("event") @@ -126,10 +125,10 @@ async def remove_event( # Update automation's eventId if it exists if eventId.startswith("automation."): automation_id = eventId.replace("automation.", "") - chatInterface = interfaceDbChat.getInterface(currentUser) - automation = chatInterface.getAutomationDefinition(automation_id) + automationInterface = interfaceAutomation.getInterface(currentUser) + automation = automationInterface.getAutomationDefinition(automation_id) if automation and getattr(automation, "eventId", None) == eventId: - chatInterface.updateAutomationDefinition(automation_id, {"eventId": None}) + automationInterface.updateAutomationDefinition(automation_id, {"eventId": None}) return { "success": True, diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py index 82424daf..113fa903 100644 --- a/modules/system/mainSystem.py +++ b/modules/system/mainSystem.py @@ -92,6 +92,15 @@ NAVIGATION_SECTIONS = [ "order": 30, "public": True, }, + { + "id": "automation-templates", + "objectKey": "ui.system.automation-templates", + "label": {"en": "Templates", "de": "Vorlagen", "fr": "Modèles"}, + "icon": "FaFileAlt", + "path": "/workflows/automation-templates", + "order": 35, + "public": True, + }, ], }, { diff --git a/modules/system/registry.py b/modules/system/registry.py index 8477b045..f7e50524 100644 --- a/modules/system/registry.py +++ b/modules/system/registry.py @@ -40,6 +40,11 @@ def loadFeatureRouters(app: FastAPI) -> Dict[str, Any]: """ Dynamically load and register routers from all discovered feature containers. Also registers feature template roles and AccessRules in the database. + + Searches for: + - 'router' (main feature router) + - 'templateRouter' (additional router for templates) + - Any other *Router named exports """ results = {} pattern = os.path.join(FEATURES_DIR, "*", "routeFeature*.py") @@ -55,12 +60,26 @@ def loadFeatureRouters(app: FastAPI) -> Dict[str, Any]: modulePath = f"modules.features.{featureDir}.{routerFile}" module = importlib.import_module(modulePath) + loadedRouters = [] + + # Load main router if hasattr(module, "router"): app.include_router(module.router) - logger.info(f"Loaded router: {featureDir}") - results[featureDir] = {"status": "loaded", "module": modulePath} + loadedRouters.append("router") + + # Load additional named routers (e.g., templateRouter) + for attrName in dir(module): + if attrName.endswith("Router") and attrName != "APIRouter": + routerObj = getattr(module, attrName) + if hasattr(routerObj, "routes"): # Check if it's a FastAPI router + app.include_router(routerObj) + loadedRouters.append(attrName) + + if loadedRouters: + logger.info(f"Loaded routers from {featureDir}: {loadedRouters}") + results[featureDir] = {"status": "loaded", "module": modulePath, "routers": loadedRouters} else: - logger.warning(f"No 'router' in {modulePath}") + logger.warning(f"No routers found in {modulePath}") results[featureDir] = {"status": "no_router_object"} except Exception as e: diff --git a/modules/workflows/automation/mainWorkflow.py b/modules/workflows/automation/mainWorkflow.py index 19cd1004..291f3120 100644 --- a/modules/workflows/automation/mainWorkflow.py +++ b/modules/workflows/automation/mainWorkflow.py @@ -78,7 +78,7 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow: try: # 1. Load automation definition (with system fields for _createdBy access) - automation = services.interfaceDbChat.getAutomationDefinition(automationId, includeSystemFields=True) + automation = services.interfaceDbAutomation.getAutomationDefinition(automationId, includeSystemFields=True) if not automation: raise ValueError(f"Automation {automationId} not found") @@ -160,7 +160,7 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow: if len(executionLogs) > 50: executionLogs = executionLogs[-50:] - services.interfaceDbChat.updateAutomationDefinition( + services.interfaceDbAutomation.updateAutomationDefinition( automationId, {"executionLogs": executionLogs} ) @@ -173,13 +173,13 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow: # Update automation with execution log even on error try: - automation = services.interfaceDbChat.getAutomationDefinition(automationId) + automation = services.interfaceDbAutomation.getAutomationDefinition(automationId) if automation: executionLogs = list(automation.executionLogs or []) executionLogs.append(executionLog) if len(executionLogs) > 50: executionLogs = executionLogs[-50:] - services.interfaceDbChat.updateAutomationDefinition( + services.interfaceDbAutomation.updateAutomationDefinition( automationId, {"executionLogs": executionLogs} ) @@ -200,7 +200,7 @@ async def syncAutomationEvents(services, eventUser) -> Dict[str, Any]: Dictionary with sync results (synced count and event IDs) """ # Get all automation definitions filtered by RBAC (for current mandate) - filtered = services.interfaceDbChat.getAllAutomationDefinitionsWithRBAC(eventUser) + filtered = services.interfaceDbAutomation.getAllAutomationDefinitionsWithRBAC(eventUser) registeredEvents = {} @@ -249,7 +249,7 @@ async def syncAutomationEvents(services, eventUser) -> Dict[str, Any]: # Update automation with new eventId if currentEventId != newEventId: - services.interfaceDbChat.updateAutomationDefinition( + services.interfaceDbAutomation.updateAutomationDefinition( automationId, {"eventId": newEventId} ) @@ -260,7 +260,7 @@ async def syncAutomationEvents(services, eventUser) -> Dict[str, Any]: if currentEventId: try: eventManager.remove(currentEventId) - services.interfaceDbChat.updateAutomationDefinition( + services.interfaceDbAutomation.updateAutomationDefinition( automationId, {"eventId": None} ) @@ -295,7 +295,7 @@ def createAutomationEventHandler(automationId: str, eventUser): eventServices = getServices(eventUser, None) # Load automation using event user context (with system fields for _createdBy access) - automation = eventServices.interfaceDbChat.getAutomationDefinition(automationId, includeSystemFields=True) + automation = eventServices.interfaceDbAutomation.getAutomationDefinition(automationId, includeSystemFields=True) if not automation or not getattr(automation, "active", False): logger.warning(f"Automation {automationId} not found or not active, skipping execution") return