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