automation template management and fix admin center

This commit is contained in:
ValueOn AG 2026-02-03 21:29:50 +01:00
parent 3f2fd31998
commit f31e10496a
12 changed files with 1253 additions and 685 deletions

View file

@ -1,10 +1,11 @@
# Copyright (c) 2025 Patrick Motsch # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Automation models: AutomationDefinition.""" """Automation models: AutomationDefinition, AutomationTemplate."""
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.datamodels.datamodelUtils import TextMultilingual
import uuid import uuid
@ -28,18 +29,53 @@ class AutomationDefinition(BaseModel):
registerModelLabels( registerModelLabels(
"AutomationDefinition", "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"}, "id": {"en": "ID", "ge": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, "mandateId": {"en": "Mandate ID", "ge": "Mandanten-ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, "featureInstanceId": {"en": "Feature Instance ID", "ge": "Feature-Instanz-ID", "fr": "ID de l'instance de fonctionnalité"},
"label": {"en": "Label", "fr": "Libellé"}, "label": {"en": "Label", "ge": "Bezeichnung", "fr": "Libellé"},
"schedule": {"en": "Schedule", "fr": "Planification"}, "schedule": {"en": "Schedule", "ge": "Zeitplan", "fr": "Planification"},
"template": {"en": "Template", "fr": "Modèle"}, "template": {"en": "Template", "ge": "Vorlage", "fr": "Modèle"},
"placeholders": {"en": "Placeholders", "fr": "Espaces réservés"}, "placeholders": {"en": "Placeholders", "ge": "Platzhalter", "fr": "Espaces réservés"},
"active": {"en": "Active", "fr": "Actif"}, "active": {"en": "Active", "ge": "Aktiv", "fr": "Actif"},
"eventId": {"en": "Event ID", "fr": "ID de l'événement"}, "eventId": {"en": "Event ID", "ge": "Event-ID", "fr": "ID de l'événement"},
"status": {"en": "Status", "fr": "Statut"}, "status": {"en": "Status", "ge": "Status", "fr": "Statut"},
"executionLogs": {"en": "Execution Logs", "fr": "Journaux d'exécution"}, "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"},
}, },
) )

View file

@ -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]

View file

@ -13,14 +13,13 @@ import logging
import json import json
# Import interfaces and models # 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.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.datamodelChat import ChatWorkflow
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.workflows.automation import executeAutomation from modules.workflows.automation import executeAutomation
from .subAutomationTemplates import getAutomationTemplates
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -69,7 +68,7 @@ async def get_automations(
detail=f"Invalid pagination parameter: {str(e)}" 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) result = chatInterface.getAllAutomationDefinitions(pagination=paginationParams)
# If pagination was requested, result is PaginatedResult # If pagination was requested, result is PaginatedResult
@ -115,7 +114,7 @@ async def create_automation(
) -> AutomationDefinition: ) -> AutomationDefinition:
"""Create a new automation definition""" """Create a new automation definition"""
try: 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() automationData = automation.model_dump()
created = chatInterface.createAutomationDefinition(automationData) created = chatInterface.createAutomationDefinition(automationData)
return created return created
@ -128,26 +127,6 @@ async def create_automation(
detail=f"Error creating automation: {str(e)}" 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]) @router.get("/attributes", response_model=Dict[str, Any])
async def get_automation_attributes( async def get_automation_attributes(
request: Request request: Request
@ -164,7 +143,7 @@ async def get_automation(
) -> AutomationDefinition: ) -> AutomationDefinition:
"""Get a single automation definition by ID""" """Get a single automation definition by ID"""
try: 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) automation = chatInterface.getAutomationDefinition(automationId)
if not automation: if not automation:
raise HTTPException( raise HTTPException(
@ -192,7 +171,7 @@ async def update_automation(
) -> AutomationDefinition: ) -> AutomationDefinition:
"""Update an automation definition""" """Update an automation definition"""
try: 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() automationData = automation.model_dump()
updated = chatInterface.updateAutomationDefinition(automationId, automationData) updated = chatInterface.updateAutomationDefinition(automationId, automationData)
return updated return updated
@ -220,7 +199,7 @@ async def update_automation_status(
) -> AutomationDefinition: ) -> AutomationDefinition:
"""Update only the active status of an automation definition""" """Update only the active status of an automation definition"""
try: 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 # Get existing automation
automation = chatInterface.getAutomationDefinition(automationId) automation = chatInterface.getAutomationDefinition(automationId)
@ -260,7 +239,7 @@ async def delete_automation(
) -> Response: ) -> Response:
"""Delete an automation definition""" """Delete an automation definition"""
try: 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) success = chatInterface.deleteAutomationDefinition(automationId)
if success: if success:
return Response(status_code=204) 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)}"
)

View file

@ -25,7 +25,6 @@ from modules.datamodels.datamodelChat import (
WorkflowModeEnum, WorkflowModeEnum,
UserInputRequest UserInputRequest
) )
from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition
import json import json
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
@ -1700,286 +1699,6 @@ class ChatObjects:
return {"items": items} 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': def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ChatObjects':
""" """

View file

@ -459,7 +459,8 @@ class TrusteeObjects:
recordFilter=None, recordFilter=None,
orderBy="id", orderBy="id",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
logger.debug(f"getAllOrganisations: getRecordsetWithRBAC returned {len(records)} records") logger.debug(f"getAllOrganisations: getRecordsetWithRBAC returned {len(records)} records")
@ -552,7 +553,8 @@ class TrusteeObjects:
recordFilter=None, recordFilter=None,
orderBy="id", orderBy="id",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
# Users with ALL access level (from system RBAC) see all roles # Users with ALL access level (from system RBAC) see all roles
@ -662,7 +664,8 @@ class TrusteeObjects:
recordFilter=None, recordFilter=None,
orderBy="id", orderBy="id",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
# Users with ALL access level (from system RBAC) see all records # Users with ALL access level (from system RBAC) see all records
@ -721,7 +724,8 @@ class TrusteeObjects:
recordFilter={"organisationId": organisationId}, recordFilter={"organisationId": organisationId},
orderBy="id", orderBy="id",
mandateId=self.mandateId, 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] 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}, recordFilter={"userId": userId},
orderBy="id", orderBy="id",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
# Users with ALL access level (from system RBAC) see all records # Users with ALL access level (from system RBAC) see all records
@ -855,7 +860,8 @@ class TrusteeObjects:
recordFilter=None, recordFilter=None,
orderBy="id", orderBy="id",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
totalItems = len(records) totalItems = len(records)
@ -888,7 +894,8 @@ class TrusteeObjects:
recordFilter={"organisationId": organisationId}, recordFilter={"organisationId": organisationId},
orderBy="label", orderBy="label",
mandateId=self.mandateId, 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] 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, recordFilter=None,
orderBy="documentName", orderBy="documentName",
mandateId=self.mandateId, 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 # Clean records (remove binary data and internal fields) - keep as dicts for filtering/sorting
@ -1056,7 +1064,8 @@ class TrusteeObjects:
recordFilter={"contractId": contractId}, recordFilter={"contractId": contractId},
orderBy="documentName", orderBy="documentName",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
result = [] result = []
@ -1165,7 +1174,8 @@ class TrusteeObjects:
recordFilter=None, recordFilter=None,
orderBy="valuta", orderBy="valuta",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
# Clean records (remove internal fields) - keep as dicts for filtering/sorting # Clean records (remove internal fields) - keep as dicts for filtering/sorting
@ -1214,7 +1224,8 @@ class TrusteeObjects:
recordFilter={"contractId": contractId}, recordFilter={"contractId": contractId},
orderBy="valuta", orderBy="valuta",
mandateId=self.mandateId, 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] 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}, recordFilter={"organisationId": organisationId},
orderBy="valuta", orderBy="valuta",
mandateId=self.mandateId, 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] 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", orderBy="id",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId, featureInstanceId=self.featureInstanceId,
enrichPermissions=True enrichPermissions=True,
featureCode=self.FEATURE_CODE
) )
totalItems = len(records) totalItems = len(records)
@ -1387,7 +1400,8 @@ class TrusteeObjects:
recordFilter={"positionId": positionId}, recordFilter={"positionId": positionId},
orderBy="id", orderBy="id",
mandateId=self.mandateId, 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] 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}, recordFilter={"documentId": documentId},
orderBy="id", orderBy="id",
mandateId=self.mandateId, 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] return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in links]

View file

@ -70,6 +70,68 @@ def initBootstrap(db: DatabaseConnector) -> None:
# Apply multi-tenant database optimizations (indexes, triggers, FKs) # Apply multi-tenant database optimizations (indexes, triggers, FKs)
_applyDatabaseOptimizations(db) _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") logger.info("System bootstrap completed")
@ -677,6 +739,31 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
delete=AccessLevel.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 # Create all table-specific rules
for rule in tableRules: for rule in tableRules:
db.recordCreate(AccessRule, rule) db.recordCreate(AccessRule, rule)
@ -843,6 +930,7 @@ def _ensureDataContextRules(db: DatabaseConnector) -> None:
tablesNeedingRules = [ tablesNeedingRules = [
"data.chat.ChatWorkflow", "data.chat.ChatWorkflow",
"data.automation.AutomationDefinition", "data.automation.AutomationDefinition",
"data.automation.AutomationTemplate",
] ]
missingRules = [] missingRules = []

View file

@ -25,7 +25,6 @@ from modules.datamodels.datamodelChat import (
WorkflowModeEnum, WorkflowModeEnum,
UserInputRequest UserInputRequest
) )
from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition
import json import json
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
@ -1654,311 +1653,6 @@ class ChatObjects:
return {"items": items} 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': def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ChatObjects':
""" """

View file

@ -66,6 +66,7 @@ TABLE_NAMESPACE = {
"FileData": "files", "FileData": "files",
# Automation - benutzer-eigen # Automation - benutzer-eigen
"AutomationDefinition": "automation", "AutomationDefinition": "automation",
"AutomationTemplate": "automation",
} }
# Namespaces ohne Mandantenkontext - GROUP wird auf MY gemappt # Namespaces ohne Mandantenkontext - GROUP wird auf MY gemappt
@ -145,17 +146,8 @@ def getRecordsetWithRBAC(
if not connector._ensureTableExists(modelClass): if not connector._ensureTableExists(modelClass):
return [] return []
# SysAdmin bypass: SysAdmin users have full access to all tables # All users (including SysAdmins) go through RBAC filtering
isSysAdmin = getattr(currentUser, 'isSysAdmin', False) # SysAdmin flag does NOT grant automatic data access - proper RBAC rules must exist
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
# Get RBAC permissions for this table using full objectKey # Get RBAC permissions for this table using full objectKey
# AccessRule table is always in DbApp database # AccessRule table is always in DbApp database
@ -184,7 +176,8 @@ def getRecordsetWithRBAC(
currentUser, currentUser,
table, table,
connector, connector,
mandateId=effectiveMandateId mandateId=effectiveMandateId,
featureInstanceId=featureInstanceId
) )
if rbacWhereClause: if rbacWhereClause:
whereConditions.append(rbacWhereClause["condition"]) whereConditions.append(rbacWhereClause["condition"])
@ -281,13 +274,15 @@ def buildRbacWhereClause(
currentUser: User, currentUser: User,
table: str, table: str,
connector, # DatabaseConnector instance for connection access connector, # DatabaseConnector instance for connection access
mandateId: Optional[str] = None mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
""" """
Build RBAC WHERE clause based on permissions and access level. Build RBAC WHERE clause based on permissions and access level.
Multi-Tenant Design: Multi-Tenant Design:
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header) - mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
- featureInstanceId wird für Feature-Tabellen zusätzlich gefiltert
Args: Args:
permissions: UserPermissions object permissions: UserPermissions object
@ -295,6 +290,7 @@ def buildRbacWhereClause(
table: Table name table: Table name
connector: DatabaseConnector instance (needed for GROUP queries) connector: DatabaseConnector instance (needed for GROUP queries)
mandateId: Explicit mandate context (from request header). Required for GROUP access. mandateId: Explicit mandate context (from request header). Required for GROUP access.
featureInstanceId: Feature instance context for feature-level data isolation.
Returns: Returns:
Dictionary with "condition" and "values" keys, or None if no filtering needed Dictionary with "condition" and "values" keys, or None if no filtering needed
@ -308,8 +304,20 @@ def buildRbacWhereClause(
if readLevel == AccessLevel.NONE: if readLevel == AccessLevel.NONE:
return {"condition": "1 = 0", "values": []} 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 readLevel == AccessLevel.ALL:
if baseConditions:
return {"condition": " AND ".join(baseConditions), "values": baseValues}
return None return None
# My records - filter by _createdBy or userId field # My records - filter by _createdBy or userId field
@ -323,9 +331,14 @@ def buildRbacWhereClause(
else: else:
userIdField = "_createdBy" userIdField = "_createdBy"
conditions = list(baseConditions)
values = list(baseValues)
conditions.append(f'"{userIdField}" = %s')
values.append(currentUser.id)
return { return {
"condition": f'"{userIdField}" = %s', "condition": " AND ".join(conditions),
"values": [currentUser.id] "values": values
} }
# Group records - filter by mandateId or ownership based on namespace # Group records - filter by mandateId or ownership based on namespace
@ -335,8 +348,10 @@ def buildRbacWhereClause(
# For user-owned namespaces (chat, files, automation): # For user-owned namespaces (chat, files, automation):
# GROUP has no meaning - these tables have no mandate context # 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 namespace in USER_OWNED_NAMESPACES:
if baseConditions:
return {"condition": " AND ".join(baseConditions), "values": baseValues}
return None return None
# For UAM and other namespaces: GROUP filters by mandate # For UAM and other namespaces: GROUP filters by mandate
@ -373,9 +388,14 @@ def buildRbacWhereClause(
if not userIds: if not userIds:
return {"condition": "1 = 0", "values": []} return {"condition": "1 = 0", "values": []}
placeholders = ",".join(["%s"] * len(userIds)) 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 { return {
"condition": f'"id" IN ({placeholders})', "condition": " AND ".join(conditions) if conditions else f'"id" IN ({placeholders})',
"values": userIds "values": values
} }
except Exception as e: except Exception as e:
logger.error(f"Error building GROUP filter for UserInDB via UserMandate: {e}") logger.error(f"Error building GROUP filter for UserInDB via UserMandate: {e}")
@ -395,28 +415,45 @@ def buildRbacWhereClause(
if not userIds: if not userIds:
return {"condition": "1 = 0", "values": []} return {"condition": "1 = 0", "values": []}
placeholders = ",".join(["%s"] * len(userIds)) 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 { return {
"condition": f'"userId" IN ({placeholders})', "condition": " AND ".join(conditions) if conditions else f'"userId" IN ({placeholders})',
"values": userIds "values": values
} }
except Exception as e: except Exception as e:
logger.error(f"Error building GROUP filter for UserConnection: {e}") logger.error(f"Error building GROUP filter for UserConnection: {e}")
return {"condition": "1 = 0", "values": []} return {"condition": "1 = 0", "values": []}
# For system tables without mandateId column (Mandate, Role, etc.): # 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"): elif table in ("Mandate", "Role"):
if baseConditions:
return {"condition": " AND ".join(baseConditions), "values": baseValues}
return None return None
# For other tables, filter by mandateId field # For other tables, filter by mandateId field
# Also include records with NULL mandateId for backwards compatibility # Also include records with NULL mandateId for backwards compatibility
else: 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 { return {
"condition": '("mandateId" = %s OR "mandateId" IS NULL)', "condition": " AND ".join(conditions),
"values": [effectiveMandateId] "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( def _enrichRecordsWithPermissions(

View file

@ -11,7 +11,7 @@ from fastapi import status
import logging import logging
# Import interfaces and models from feature containers # 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.auth import limiter, getRequestContext, requireSysAdmin, RequestContext
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
@ -79,7 +79,6 @@ async def sync_all_automation_events(
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.workflows.automation import syncAutomationEvents from modules.workflows.automation import syncAutomationEvents
chatInterface = getChatInterface(currentUser)
# Get event user for sync operation (routes can import from interfaces) # Get event user for sync operation (routes can import from interfaces)
rootInterface = getRootInterface() rootInterface = getRootInterface()
eventUser = rootInterface.getUserByUsername("event") eventUser = rootInterface.getUserByUsername("event")
@ -126,10 +125,10 @@ async def remove_event(
# Update automation's eventId if it exists # Update automation's eventId if it exists
if eventId.startswith("automation."): if eventId.startswith("automation."):
automation_id = eventId.replace("automation.", "") automation_id = eventId.replace("automation.", "")
chatInterface = interfaceDbChat.getInterface(currentUser) automationInterface = interfaceAutomation.getInterface(currentUser)
automation = chatInterface.getAutomationDefinition(automation_id) automation = automationInterface.getAutomationDefinition(automation_id)
if automation and getattr(automation, "eventId", None) == eventId: if automation and getattr(automation, "eventId", None) == eventId:
chatInterface.updateAutomationDefinition(automation_id, {"eventId": None}) automationInterface.updateAutomationDefinition(automation_id, {"eventId": None})
return { return {
"success": True, "success": True,

View file

@ -92,6 +92,15 @@ NAVIGATION_SECTIONS = [
"order": 30, "order": 30,
"public": True, "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,
},
], ],
}, },
{ {

View file

@ -40,6 +40,11 @@ def loadFeatureRouters(app: FastAPI) -> Dict[str, Any]:
""" """
Dynamically load and register routers from all discovered feature containers. Dynamically load and register routers from all discovered feature containers.
Also registers feature template roles and AccessRules in the database. 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 = {} results = {}
pattern = os.path.join(FEATURES_DIR, "*", "routeFeature*.py") 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}" modulePath = f"modules.features.{featureDir}.{routerFile}"
module = importlib.import_module(modulePath) module = importlib.import_module(modulePath)
loadedRouters = []
# Load main router
if hasattr(module, "router"): if hasattr(module, "router"):
app.include_router(module.router) app.include_router(module.router)
logger.info(f"Loaded router: {featureDir}") loadedRouters.append("router")
results[featureDir] = {"status": "loaded", "module": modulePath}
# 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: else:
logger.warning(f"No 'router' in {modulePath}") logger.warning(f"No routers found in {modulePath}")
results[featureDir] = {"status": "no_router_object"} results[featureDir] = {"status": "no_router_object"}
except Exception as e: except Exception as e:

View file

@ -78,7 +78,7 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
try: try:
# 1. Load automation definition (with system fields for _createdBy access) # 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: if not automation:
raise ValueError(f"Automation {automationId} not found") raise ValueError(f"Automation {automationId} not found")
@ -160,7 +160,7 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
if len(executionLogs) > 50: if len(executionLogs) > 50:
executionLogs = executionLogs[-50:] executionLogs = executionLogs[-50:]
services.interfaceDbChat.updateAutomationDefinition( services.interfaceDbAutomation.updateAutomationDefinition(
automationId, automationId,
{"executionLogs": executionLogs} {"executionLogs": executionLogs}
) )
@ -173,13 +173,13 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
# Update automation with execution log even on error # Update automation with execution log even on error
try: try:
automation = services.interfaceDbChat.getAutomationDefinition(automationId) automation = services.interfaceDbAutomation.getAutomationDefinition(automationId)
if automation: if automation:
executionLogs = list(automation.executionLogs or []) executionLogs = list(automation.executionLogs or [])
executionLogs.append(executionLog) executionLogs.append(executionLog)
if len(executionLogs) > 50: if len(executionLogs) > 50:
executionLogs = executionLogs[-50:] executionLogs = executionLogs[-50:]
services.interfaceDbChat.updateAutomationDefinition( services.interfaceDbAutomation.updateAutomationDefinition(
automationId, automationId,
{"executionLogs": executionLogs} {"executionLogs": executionLogs}
) )
@ -200,7 +200,7 @@ async def syncAutomationEvents(services, eventUser) -> Dict[str, Any]:
Dictionary with sync results (synced count and event IDs) Dictionary with sync results (synced count and event IDs)
""" """
# Get all automation definitions filtered by RBAC (for current mandate) # Get all automation definitions filtered by RBAC (for current mandate)
filtered = services.interfaceDbChat.getAllAutomationDefinitionsWithRBAC(eventUser) filtered = services.interfaceDbAutomation.getAllAutomationDefinitionsWithRBAC(eventUser)
registeredEvents = {} registeredEvents = {}
@ -249,7 +249,7 @@ async def syncAutomationEvents(services, eventUser) -> Dict[str, Any]:
# Update automation with new eventId # Update automation with new eventId
if currentEventId != newEventId: if currentEventId != newEventId:
services.interfaceDbChat.updateAutomationDefinition( services.interfaceDbAutomation.updateAutomationDefinition(
automationId, automationId,
{"eventId": newEventId} {"eventId": newEventId}
) )
@ -260,7 +260,7 @@ async def syncAutomationEvents(services, eventUser) -> Dict[str, Any]:
if currentEventId: if currentEventId:
try: try:
eventManager.remove(currentEventId) eventManager.remove(currentEventId)
services.interfaceDbChat.updateAutomationDefinition( services.interfaceDbAutomation.updateAutomationDefinition(
automationId, automationId,
{"eventId": None} {"eventId": None}
) )
@ -295,7 +295,7 @@ def createAutomationEventHandler(automationId: str, eventUser):
eventServices = getServices(eventUser, None) eventServices = getServices(eventUser, None)
# Load automation using event user context (with system fields for _createdBy access) # 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): if not automation or not getattr(automation, "active", False):
logger.warning(f"Automation {automationId} not found or not active, skipping execution") logger.warning(f"Automation {automationId} not found or not active, skipping execution")
return return