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