Merge pull request #89 from valueonag/feat/automation-template-handling

Feat/automation template handling
This commit is contained in:
Patrick Motsch 2026-02-03 23:44:35 +01:00 committed by GitHub
commit c69019c9bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1551 additions and 696 deletions

View file

@ -12,7 +12,7 @@ Multi-Tenant Design:
import uuid import uuid
from typing import Optional, List from typing import Optional, List
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field, EmailStr, field_validator from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
@ -114,6 +114,20 @@ class UserConnection(BaseModel):
{"value": "none", "label": {"en": "None", "fr": "Aucun"}}, {"value": "none", "label": {"en": "None", "fr": "Aucun"}},
]}) ]})
tokenExpiresAt: Optional[float] = Field(None, description="When the current token expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}) tokenExpiresAt: Optional[float] = Field(None, description="When the current token expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
@computed_field
@computed_field
@property
def connectionReference(self) -> str:
"""Generate connection reference string in format: connection:{authority}:{username}"""
return f"connection:{self.authority.value}:{self.externalUsername}"
@computed_field
@property
def displayLabel(self) -> str:
"""Human-readable label for display in dropdowns"""
authorityLabels = {"msft": "Microsoft", "google": "Google", "local": "Local"}
return f"{authorityLabels.get(self.authority.value, self.authority.value)}: {self.externalUsername}"
registerModelLabels( registerModelLabels(
@ -132,6 +146,8 @@ registerModelLabels(
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"}, "expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"}, "tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"},
"tokenExpiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"}, "tokenExpiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"connectionReference": {"en": "Connection Reference", "de": "Verbindungsreferenz", "fr": "Référence de connexion"},
"displayLabel": {"en": "Display Label", "de": "Anzeigebezeichnung", "fr": "Libellé d'affichage"},
}, },
) )

View file

@ -1,10 +1,11 @@
# Copyright (c) 2025 Patrick Motsch # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Automation models: AutomationDefinition.""" """Automation models: AutomationDefinition, AutomationTemplate."""
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.datamodels.datamodelUtils import TextMultilingual
import uuid import uuid
@ -28,18 +29,53 @@ class AutomationDefinition(BaseModel):
registerModelLabels( registerModelLabels(
"AutomationDefinition", "AutomationDefinition",
{"en": "Automation Definition", "fr": "Définition d'automatisation"}, {"en": "Automation Definition", "ge": "Automatisierungs-Definition", "fr": "Définition d'automatisation"},
{ {
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "ge": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, "mandateId": {"en": "Mandate ID", "ge": "Mandanten-ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, "featureInstanceId": {"en": "Feature Instance ID", "ge": "Feature-Instanz-ID", "fr": "ID de l'instance de fonctionnalité"},
"label": {"en": "Label", "fr": "Libellé"}, "label": {"en": "Label", "ge": "Bezeichnung", "fr": "Libellé"},
"schedule": {"en": "Schedule", "fr": "Planification"}, "schedule": {"en": "Schedule", "ge": "Zeitplan", "fr": "Planification"},
"template": {"en": "Template", "fr": "Modèle"}, "template": {"en": "Template", "ge": "Vorlage", "fr": "Modèle"},
"placeholders": {"en": "Placeholders", "fr": "Espaces réservés"}, "placeholders": {"en": "Placeholders", "ge": "Platzhalter", "fr": "Espaces réservés"},
"active": {"en": "Active", "fr": "Actif"}, "active": {"en": "Active", "ge": "Aktiv", "fr": "Actif"},
"eventId": {"en": "Event ID", "fr": "ID de l'événement"}, "eventId": {"en": "Event ID", "ge": "Event-ID", "fr": "ID de l'événement"},
"status": {"en": "Status", "fr": "Statut"}, "status": {"en": "Status", "ge": "Status", "fr": "Statut"},
"executionLogs": {"en": "Execution Logs", "fr": "Journaux d'exécution"}, "executionLogs": {"en": "Execution Logs", "ge": "Ausführungsprotokolle", "fr": "Journaux d'exécution"},
},
)
class AutomationTemplate(BaseModel):
"""Automation-Vorlage ohne scharfe Placeholder-Werte (DB-persistiert)."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True}
)
label: TextMultilingual = Field(
description="Template name (multilingual)",
json_schema_extra={"frontend_type": "multilingual", "frontend_required": True}
)
overview: Optional[TextMultilingual] = Field(
None,
description="Short description (multilingual)",
json_schema_extra={"frontend_type": "multilingual", "frontend_required": False}
)
template: str = Field(
description="JSON workflow structure with {{KEY:...}} placeholders",
json_schema_extra={"frontend_type": "textarea", "frontend_required": True}
)
# System fields (_createdAt, _createdBy, etc.) werden automatisch vom DB-Connector gesetzt
registerModelLabels(
"AutomationTemplate",
{"en": "Automation Template", "ge": "Automation-Vorlage", "fr": "Modèle d'automatisation"},
{
"id": {"en": "ID", "ge": "ID", "fr": "ID"},
"label": {"en": "Label", "ge": "Bezeichnung", "fr": "Libellé"},
"overview": {"en": "Overview", "ge": "Übersicht", "fr": "Aperçu"},
"template": {"en": "Template", "ge": "Vorlage", "fr": "Modèle"},
}, },
) )

View file

@ -0,0 +1,655 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Interface for Automation feature - manages AutomationDefinition and AutomationTemplate.
Uses the PostgreSQL connector for data access with user/mandate filtering.
"""
import logging
import uuid
import math
import asyncio
from typing import Dict, Any, List, Optional, Union
from modules.security.rbac import RbacClass
from modules.datamodels.datamodelRbac import AccessRuleContext
from modules.datamodels.datamodelUam import AccessLevel, User
from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition, AutomationTemplate
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC, buildDataObjectKey
from modules.shared.configuration import APP_CONFIG
logger = logging.getLogger(__name__)
# Singleton factory for Automation instances
_automationInterfaces = {}
class AutomationObjects:
"""
Interface for Automation database operations.
Manages AutomationDefinition and AutomationTemplate with RBAC support.
"""
def __init__(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
self.currentUser = currentUser
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
self.userId = currentUser.id if currentUser else None
# Initialize database with proper configuration
self._initializeDatabase()
# Initialize RBAC (dbApp = self.db since we use poweron_app)
self.rbac = RbacClass(self.db, dbApp=self.db)
# Update database context
self.db.updateContext(self.userId)
def _initializeDatabase(self):
"""Initializes the database connection with proper configuration."""
# Get configuration values
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = "poweron_automation"
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
# Create database connector with full configuration
self.db = DatabaseConnector(
dbHost=dbHost,
dbDatabase=dbDatabase,
dbUser=dbUser,
dbPassword=dbPassword,
dbPort=dbPort,
userId=self.userId,
)
# Initialize database system
self.db.initDbSystem()
logger.debug(f"Automation database initialized for user {self.userId}")
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""Update user context for the interface."""
self.currentUser = currentUser
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
self.userId = currentUser.id if currentUser else None
if hasattr(self.db, 'updateContext'):
self.db.updateContext(self.userId)
def checkRbacPermission(self, model, action: str, recordId: str = None) -> bool:
"""Check RBAC permission for a specific action on a model."""
objectKey = buildDataObjectKey(model.__name__)
permissions = self.rbac.getUserPermissions(
user=self.currentUser,
context=AccessRuleContext.DATA,
item=objectKey
)
accessLevel = getattr(permissions, action, AccessLevel.NONE)
if accessLevel == AccessLevel.ALL:
return True
elif accessLevel == AccessLevel.GROUP:
return True
elif accessLevel == AccessLevel.MY:
if recordId:
record = self.db.getRecordset(model, {"id": recordId})
if record:
return record[0].get("_createdBy") == self.userId
return True
return False
# =========================================================================
# AutomationDefinition CRUD methods
# =========================================================================
def _computeAutomationStatus(self, automation: Dict[str, Any]) -> str:
"""Compute status field based on eventId presence"""
eventId = automation.get("eventId")
return "Running" if eventId else "Idle"
def _enrichAutomationsWithUserAndMandate(self, automations: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Batch enrich automations with user names and mandate names for display.
Uses AppObjects interface to fetch users and mandates with proper access control.
"""
if not automations:
return automations
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
# Collect all unique user IDs and mandate IDs
userIds = set()
mandateIds = set()
for automation in automations:
createdBy = automation.get("_createdBy")
if createdBy:
userIds.add(createdBy)
mandateId = automation.get("mandateId")
if mandateId:
mandateIds.add(mandateId)
# Use AppObjects interface to fetch users (respects access control)
appInterface = getAppInterface(self.currentUser)
usersMap = {}
if userIds:
for userId in userIds:
user = appInterface.getUser(userId)
if user:
usersMap[userId] = user.username or user.email or userId
# Use AppObjects interface to fetch mandates (respects access control)
mandatesMap = {}
if mandateIds:
for mandateId in mandateIds:
mandate = appInterface.getMandate(mandateId)
if mandate:
mandatesMap[mandateId] = mandate.name or mandateId
# Enrich each automation with the fetched data
for automation in automations:
createdBy = automation.get("_createdBy")
if createdBy:
automation["_createdByUserName"] = usersMap.get(createdBy, createdBy)
else:
automation["_createdByUserName"] = "-"
mandateId = automation.get("mandateId")
if mandateId:
automation["mandateName"] = mandatesMap.get(mandateId, mandateId)
else:
automation["mandateName"] = "-"
return automations
def _enrichAutomationWithUserAndMandate(self, automation: Dict[str, Any]) -> Dict[str, Any]:
"""
Enrich a single automation with user name and mandate name for display.
For multiple automations, use _enrichAutomationsWithUserAndMandate for better performance.
"""
return self._enrichAutomationsWithUserAndMandate([automation])[0]
def getAllAutomationDefinitions(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]:
"""
Returns automation definitions based on user access level.
Supports optional pagination, sorting, and filtering.
Computes status field for each automation.
"""
# Use RBAC filtering
filteredAutomations = getRecordsetWithRBAC(
self.db,
AutomationDefinition,
self.currentUser
)
# Compute status for each automation and normalize executionLogs
for automation in filteredAutomations:
automation["status"] = self._computeAutomationStatus(automation)
# Ensure executionLogs is always a list, not None
if automation.get("executionLogs") is None:
automation["executionLogs"] = []
# Batch enrich with user and mandate names
self._enrichAutomationsWithUserAndMandate(filteredAutomations)
# If no pagination requested, return all items
if pagination is None:
return filteredAutomations
# Apply filtering (if filters provided)
if pagination.filters:
filteredAutomations = self._applyFilters(filteredAutomations, pagination.filters)
# Apply sorting (in order of sortFields)
if pagination.sort:
filteredAutomations = self._applySorting(filteredAutomations, pagination.sort)
# Count total items after filters
totalItems = len(filteredAutomations)
totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0
# Apply pagination (skip/limit)
startIdx = (pagination.page - 1) * pagination.pageSize
endIdx = startIdx + pagination.pageSize
pagedAutomations = filteredAutomations[startIdx:endIdx]
return PaginatedResult(
items=pagedAutomations,
totalItems=totalItems,
totalPages=totalPages
)
def _applyFilters(self, items: List[Dict], filters: Dict[str, Any]) -> List[Dict]:
"""Apply filters to a list of items."""
if not filters:
return items
filtered = []
for item in items:
match = True
for key, value in filters.items():
itemValue = item.get(key)
if isinstance(value, str) and isinstance(itemValue, str):
if value.lower() not in itemValue.lower():
match = False
break
elif itemValue != value:
match = False
break
if match:
filtered.append(item)
return filtered
def _applySorting(self, items: List[Dict], sortFields: List[Dict]) -> List[Dict]:
"""Apply sorting to a list of items."""
if not sortFields:
return items
for sortField in reversed(sortFields):
field = sortField.get("field", "")
direction = sortField.get("direction", "asc")
reverse = direction.lower() == "desc"
items = sorted(items, key=lambda x: x.get(field, ""), reverse=reverse)
return items
def getAutomationDefinition(self, automationId: str, includeSystemFields: bool = False) -> Optional[AutomationDefinition]:
"""Returns an automation definition by ID if user has access, with computed status.
Args:
automationId: ID of the automation to get
includeSystemFields: If True, returns raw dict with system fields (_createdBy, etc).
If False (default), returns Pydantic model without system fields.
"""
try:
# Use RBAC filtering
filtered = getRecordsetWithRBAC(
self.db,
AutomationDefinition,
self.currentUser,
recordFilter={"id": automationId}
)
if not filtered:
return None
automation = filtered[0]
automation["status"] = self._computeAutomationStatus(automation)
# Ensure executionLogs is always a list, not None
if automation.get("executionLogs") is None:
automation["executionLogs"] = []
# Enrich with user and mandate names
self._enrichAutomationWithUserAndMandate(automation)
# For internal use (execution), return raw dict with system fields
if includeSystemFields:
# Return as simple namespace object so getattr works
class AutomationWithSystemFields:
def __init__(self, data):
for key, value in data.items():
setattr(self, key, value)
return AutomationWithSystemFields(automation)
# Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")}
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error getting automation definition: {str(e)}")
return None
def createAutomationDefinition(self, automationData: Dict[str, Any]) -> AutomationDefinition:
"""Creates a new automation definition, then triggers sync."""
try:
# Ensure ID is present
if "id" not in automationData or not automationData["id"]:
automationData["id"] = str(uuid.uuid4())
# Ensure mandateId and featureInstanceId are set for proper data isolation
if "mandateId" not in automationData or not automationData.get("mandateId"):
# Use request context mandateId, or fall back to Root mandate
effectiveMandateId = self.mandateId
if not effectiveMandateId:
# Fall back to Root mandate (first mandate in system)
try:
from modules.datamodels.datamodelUam import Mandate
from modules.security.rootAccess import getRootDbAppConnector
dbAppConn = getRootDbAppConnector()
allMandates = dbAppConn.getRecordset(Mandate)
if allMandates:
effectiveMandateId = allMandates[0].get("id")
logger.debug(f"createAutomationDefinition: Using Root mandate {effectiveMandateId}")
except Exception as e:
logger.warning(f"Could not get Root mandate: {e}")
automationData["mandateId"] = effectiveMandateId
if "featureInstanceId" not in automationData:
automationData["featureInstanceId"] = self.featureInstanceId
# Ensure database connector has correct userId context
if not self.userId:
logger.error(f"createAutomationDefinition: userId is not set! Cannot set _createdBy. currentUser={self.currentUser}")
elif hasattr(self.db, 'updateContext'):
try:
self.db.updateContext(self.userId)
logger.debug(f"createAutomationDefinition: Updated database context with userId={self.userId}")
except Exception as e:
logger.warning(f"Could not update database context: {e}")
# Create automation in database
createdAutomation = self.db.recordCreate(AutomationDefinition, automationData)
# Compute status
createdAutomation["status"] = self._computeAutomationStatus(createdAutomation)
# Ensure executionLogs is always a list, not None
if createdAutomation.get("executionLogs") is None:
createdAutomation["executionLogs"] = []
# Trigger automation change callback (async, don't wait)
asyncio.create_task(self._notifyAutomationChanged())
# Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")}
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error creating automation definition: {str(e)}")
raise
def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> AutomationDefinition:
"""Updates an automation definition, then triggers sync."""
try:
# Check access
existing = self.getAutomationDefinition(automationId)
if not existing:
raise PermissionError(f"No access to automation {automationId}")
if not self.checkRbacPermission(AutomationDefinition, "update", automationId):
raise PermissionError(f"No permission to modify automation {automationId}")
# Update automation in database
updatedAutomation = self.db.recordModify(AutomationDefinition, automationId, automationData)
# Compute status
updatedAutomation["status"] = self._computeAutomationStatus(updatedAutomation)
# Ensure executionLogs is always a list, not None
if updatedAutomation.get("executionLogs") is None:
updatedAutomation["executionLogs"] = []
# Trigger automation change callback (async, don't wait)
asyncio.create_task(self._notifyAutomationChanged())
# Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")}
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error updating automation definition: {str(e)}")
raise
def deleteAutomationDefinition(self, automationId: str) -> bool:
"""Deletes an automation definition, then triggers sync."""
try:
# Check access
existing = self.getAutomationDefinition(automationId)
if not existing:
raise PermissionError(f"No access to automation {automationId}")
if not self.checkRbacPermission(AutomationDefinition, "delete", automationId):
raise PermissionError(f"No permission to delete automation {automationId}")
# Delete automation from database
self.db.recordDelete(AutomationDefinition, automationId)
# Trigger automation change callback (async, don't wait)
asyncio.create_task(self._notifyAutomationChanged())
return True
except Exception as e:
logger.error(f"Error deleting automation definition: {str(e)}")
raise
def getAllAutomationDefinitionsWithRBAC(self, user: User) -> List[Dict[str, Any]]:
"""
Get all automation definitions filtered by RBAC for a specific user.
This method encapsulates getRecordsetWithRBAC() to avoid exposing the connector.
Args:
user: User object for RBAC filtering
Returns:
List of automation definition dictionaries filtered by RBAC
"""
return getRecordsetWithRBAC(
self.db,
AutomationDefinition,
user
)
# =========================================================================
# AutomationTemplate CRUD methods
# =========================================================================
def getAllAutomationTemplates(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]:
"""
Returns automation templates filtered by RBAC (MY = own templates).
Supports optional pagination, sorting, and filtering.
"""
# Use RBAC filtering
filteredTemplates = getRecordsetWithRBAC(
self.db,
AutomationTemplate,
self.currentUser
)
# Enrich with user names
self._enrichTemplatesWithUserName(filteredTemplates)
# If no pagination requested, return all items
if pagination is None:
return filteredTemplates
# Apply filtering (if filters provided)
if pagination.filters:
filteredTemplates = self._applyFilters(filteredTemplates, pagination.filters)
# Apply sorting (in order of sortFields)
if pagination.sort:
filteredTemplates = self._applySorting(filteredTemplates, pagination.sort)
# Count total items after filters
totalItems = len(filteredTemplates)
totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0
# Apply pagination (skip/limit)
startIdx = (pagination.page - 1) * pagination.pageSize
endIdx = startIdx + pagination.pageSize
pagedTemplates = filteredTemplates[startIdx:endIdx]
return PaginatedResult(
items=pagedTemplates,
totalItems=totalItems,
totalPages=totalPages
)
def _enrichTemplatesWithUserName(self, templates: List[Dict[str, Any]]) -> None:
"""Batch enrich templates with creator user names."""
if not templates:
return
# Collect unique user IDs
userIds = set()
for template in templates:
createdBy = template.get("_createdBy")
if createdBy:
userIds.add(createdBy)
if not userIds:
return
# Batch fetch users
try:
from modules.datamodels.datamodelUam import UserInDB
from modules.security.rootAccess import getRootDbAppConnector
dbAppConn = getRootDbAppConnector()
userNameMap = {}
for userId in userIds:
users = dbAppConn.getRecordset(UserInDB, {"id": userId})
if users:
user = users[0]
fullName = f"{user.get('firstName', '')} {user.get('lastName', '')}".strip()
userNameMap[userId] = fullName or user.get("email", "Unknown")
# Apply to templates
for template in templates:
createdBy = template.get("_createdBy")
if createdBy and createdBy in userNameMap:
template["_createdByUserName"] = userNameMap[createdBy]
except Exception as e:
logger.warning(f"Could not enrich templates with user names: {e}")
def getAutomationTemplate(self, templateId: str) -> Optional[Dict[str, Any]]:
"""Returns an automation template by ID if user has access."""
try:
filtered = getRecordsetWithRBAC(
self.db,
AutomationTemplate,
self.currentUser,
recordFilter={"id": templateId}
)
if not filtered:
return None
template = filtered[0]
self._enrichTemplatesWithUserName([template])
return template
except Exception as e:
logger.error(f"Error getting automation template: {str(e)}")
return None
def createAutomationTemplate(self, templateData: Dict[str, Any]) -> Dict[str, Any]:
"""Creates a new automation template."""
try:
# Ensure ID is present
if "id" not in templateData or not templateData["id"]:
templateData["id"] = str(uuid.uuid4())
# RBAC check
if not self.checkRbacPermission(AutomationTemplate, "create"):
raise PermissionError("No permission to create template")
# Ensure database connector has correct userId context
if self.userId and hasattr(self.db, 'updateContext'):
try:
self.db.updateContext(self.userId)
except Exception as e:
logger.warning(f"Could not update database context: {e}")
# Convert template field to string if it's a dict (frontend may send parsed JSON)
if "template" in templateData and isinstance(templateData["template"], dict):
import json
templateData["template"] = json.dumps(templateData["template"])
# Validate through Pydantic model to ensure proper type conversion
# This converts dict fields like TextMultilingual to proper Pydantic objects
validatedTemplate = AutomationTemplate(**templateData)
# Create template in database using model_dump for proper serialization
createdTemplate = self.db.recordCreate(AutomationTemplate, validatedTemplate.model_dump())
return createdTemplate
except Exception as e:
logger.error(f"Error creating automation template: {str(e)}")
raise
def updateAutomationTemplate(self, templateId: str, templateData: Dict[str, Any]) -> Dict[str, Any]:
"""Updates an automation template."""
try:
# Check access
existing = self.getAutomationTemplate(templateId)
if not existing:
raise PermissionError(f"No access to template {templateId}")
if not self.checkRbacPermission(AutomationTemplate, "update", templateId):
raise PermissionError(f"No permission to modify template {templateId}")
# Convert template field to string if it's a dict (frontend may send parsed JSON)
if "template" in templateData and isinstance(templateData["template"], dict):
import json
templateData["template"] = json.dumps(templateData["template"])
# Merge existing data with update data for partial updates
mergedData = {**existing, **templateData}
mergedData["id"] = templateId # Ensure ID is preserved
# Validate through Pydantic model to ensure proper type conversion
validatedTemplate = AutomationTemplate(**mergedData)
# Update template in database using model_dump for proper serialization
updatedTemplate = self.db.recordModify(AutomationTemplate, templateId, validatedTemplate.model_dump())
return updatedTemplate
except Exception as e:
logger.error(f"Error updating automation template: {str(e)}")
raise
def deleteAutomationTemplate(self, templateId: str) -> bool:
"""Deletes an automation template."""
try:
# Check access using RBAC
existing = self.getAutomationTemplate(templateId)
if not existing:
return False
if not self.checkRbacPermission(AutomationTemplate, "delete", templateId):
raise PermissionError(f"No permission to delete template {templateId}")
# Delete template from database
self.db.recordDelete(AutomationTemplate, templateId)
return True
except Exception as e:
logger.error(f"Error deleting automation template: {str(e)}")
raise
async def _notifyAutomationChanged(self):
"""Notify registered callbacks about automation changes (decoupled from features)."""
try:
from modules.shared.callbackRegistry import callbackRegistry
# Trigger callbacks without knowing which features are listening
await callbackRegistry.trigger('automation.changed', self)
except Exception as e:
logger.error(f"Error notifying automation change: {str(e)}")
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'AutomationObjects':
"""
Returns an AutomationObjects instance for the current user.
Handles initialization of database and records.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header).
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header).
"""
if not currentUser:
raise ValueError("Invalid user context: user is required")
effectiveMandateId = str(mandateId) if mandateId else None
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
# Create context key including featureInstanceId for proper isolation
contextKey = f"automation_{effectiveMandateId}_{effectiveFeatureInstanceId}_{currentUser.id}"
# Create new instance if not exists
if contextKey not in _automationInterfaces:
_automationInterfaces[contextKey] = AutomationObjects(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
else:
# Update user context if needed
_automationInterfaces[contextKey].setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
return _automationInterfaces[contextKey]

View file

@ -13,14 +13,13 @@ import logging
import json import json
# Import interfaces and models # Import interfaces and models
from modules.interfaces.interfaceDbChat import getInterface as getChatInterface from modules.features.automation.interfaceFeatureAutomation import getInterface as getAutomationInterface
from modules.auth import limiter, getRequestContext, RequestContext from modules.auth import limiter, getRequestContext, RequestContext
from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition, AutomationTemplate
from modules.datamodels.datamodelChat import ChatWorkflow from modules.datamodels.datamodelChat import ChatWorkflow
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.workflows.automation import executeAutomation from modules.workflows.automation import executeAutomation
from .subAutomationTemplates import getAutomationTemplates
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -69,7 +68,7 @@ async def get_automations(
detail=f"Invalid pagination parameter: {str(e)}" detail=f"Invalid pagination parameter: {str(e)}"
) )
chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
result = chatInterface.getAllAutomationDefinitions(pagination=paginationParams) result = chatInterface.getAllAutomationDefinitions(pagination=paginationParams)
# If pagination was requested, result is PaginatedResult # If pagination was requested, result is PaginatedResult
@ -115,7 +114,7 @@ async def create_automation(
) -> AutomationDefinition: ) -> AutomationDefinition:
"""Create a new automation definition""" """Create a new automation definition"""
try: try:
chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
automationData = automation.model_dump() automationData = automation.model_dump()
created = chatInterface.createAutomationDefinition(automationData) created = chatInterface.createAutomationDefinition(automationData)
return created return created
@ -128,26 +127,6 @@ async def create_automation(
detail=f"Error creating automation: {str(e)}" detail=f"Error creating automation: {str(e)}"
) )
@router.get("/templates")
@limiter.limit("30/minute")
async def get_automation_templates(
request: Request,
context: RequestContext = Depends(getRequestContext)
) -> JSONResponse:
"""
Get automation templates from backend module.
The UI should fetch these templates regularly to get the latest versions.
"""
try:
templatesData = getAutomationTemplates()
return JSONResponse(content=templatesData)
except Exception as e:
logger.error(f"Error getting automation templates: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error getting automation templates: {str(e)}"
)
@router.get("/attributes", response_model=Dict[str, Any]) @router.get("/attributes", response_model=Dict[str, Any])
async def get_automation_attributes( async def get_automation_attributes(
request: Request request: Request
@ -155,6 +134,100 @@ async def get_automation_attributes(
"""Get attribute definitions for AutomationDefinition model""" """Get attribute definitions for AutomationDefinition model"""
return {"attributes": automationAttributes} return {"attributes": automationAttributes}
@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)}"
)
@router.get("/{automationId}", response_model=AutomationDefinition) @router.get("/{automationId}", response_model=AutomationDefinition)
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def get_automation( async def get_automation(
@ -164,7 +237,7 @@ async def get_automation(
) -> AutomationDefinition: ) -> AutomationDefinition:
"""Get a single automation definition by ID""" """Get a single automation definition by ID"""
try: try:
chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
automation = chatInterface.getAutomationDefinition(automationId) automation = chatInterface.getAutomationDefinition(automationId)
if not automation: if not automation:
raise HTTPException( raise HTTPException(
@ -192,7 +265,7 @@ async def update_automation(
) -> AutomationDefinition: ) -> AutomationDefinition:
"""Update an automation definition""" """Update an automation definition"""
try: try:
chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
automationData = automation.model_dump() automationData = automation.model_dump()
updated = chatInterface.updateAutomationDefinition(automationId, automationData) updated = chatInterface.updateAutomationDefinition(automationId, automationData)
return updated return updated
@ -220,7 +293,7 @@ async def update_automation_status(
) -> AutomationDefinition: ) -> AutomationDefinition:
"""Update only the active status of an automation definition""" """Update only the active status of an automation definition"""
try: try:
chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
# Get existing automation # Get existing automation
automation = chatInterface.getAutomationDefinition(automationId) automation = chatInterface.getAutomationDefinition(automationId)
@ -260,7 +333,7 @@ async def delete_automation(
) -> Response: ) -> Response:
"""Delete an automation definition""" """Delete an automation definition"""
try: try:
chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
success = chatInterface.deleteAutomationDefinition(automationId) success = chatInterface.deleteAutomationDefinition(automationId)
if success: if success:
return Response(status_code=204) return Response(status_code=204)
@ -311,3 +384,228 @@ async def execute_automation_route(
) )
# =============================================================================
# AutomationTemplate Routes (DB-persistiert)
# =============================================================================
# Separater Router für /api/automation-templates
templateRouter = APIRouter(
prefix="/api/automation-templates",
tags=["Manage Automation Templates"],
responses={
404: {"description": "Not found"},
400: {"description": "Bad request"},
401: {"description": "Unauthorized"},
403: {"description": "Forbidden"},
500: {"description": "Internal server error"}
}
)
# Model attributes for AutomationTemplate
templateAttributes = getModelAttributeDefinitions(AutomationTemplate)
@templateRouter.get("", response_model=PaginatedResponse[AutomationTemplate])
@limiter.limit("30/minute")
async def get_db_templates(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
context: RequestContext = Depends(getRequestContext)
) -> JSONResponse:
"""
Get automation templates from database (RBAC-filtered, MY = own templates).
Query Parameters:
- pagination: JSON-encoded PaginationParams object, or None for no pagination
"""
try:
# Parse pagination parameter
paginationParams = None
if pagination:
try:
paginationDict = json.loads(pagination)
if paginationDict:
paginationDict = normalize_pagination_dict(paginationDict)
paginationParams = PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,
detail=f"Invalid pagination parameter: {str(e)}"
)
chatInterface = getAutomationInterface(
context.user,
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
)
result = chatInterface.getAllAutomationTemplates(pagination=paginationParams)
if paginationParams:
response_data = {
"items": result.items,
"pagination": {
"currentPage": paginationParams.page,
"pageSize": paginationParams.pageSize,
"totalItems": result.totalItems,
"totalPages": result.totalPages,
"sort": paginationParams.sort,
"filters": paginationParams.filters
}
}
else:
response_data = {
"items": result,
"pagination": None
}
return JSONResponse(content=response_data)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting templates: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error getting templates: {str(e)}"
)
@templateRouter.get("/attributes", response_model=Dict[str, Any])
async def get_template_attributes(
request: Request
) -> Dict[str, Any]:
"""Get attribute definitions for AutomationTemplate model"""
return {"attributes": templateAttributes}
@templateRouter.get("/{templateId}")
@limiter.limit("30/minute")
async def get_db_template(
request: Request,
templateId: str = Path(..., description="Template ID"),
context: RequestContext = Depends(getRequestContext)
) -> JSONResponse:
"""Get a single automation template by ID"""
try:
chatInterface = getAutomationInterface(
context.user,
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
)
template = chatInterface.getAutomationTemplate(templateId)
if not template:
raise HTTPException(
status_code=404,
detail=f"Template {templateId} not found"
)
return JSONResponse(content=template)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting template: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error getting template: {str(e)}"
)
@templateRouter.post("")
@limiter.limit("10/minute")
async def create_db_template(
request: Request,
templateData: Dict[str, Any] = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> JSONResponse:
"""Create a new automation template"""
try:
chatInterface = getAutomationInterface(
context.user,
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
)
created = chatInterface.createAutomationTemplate(templateData)
return JSONResponse(content=created)
except HTTPException:
raise
except PermissionError as e:
raise HTTPException(
status_code=403,
detail=str(e)
)
except Exception as e:
logger.error(f"Error creating template: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error creating template: {str(e)}"
)
@templateRouter.put("/{templateId}")
@limiter.limit("10/minute")
async def update_db_template(
request: Request,
templateId: str = Path(..., description="Template ID"),
templateData: Dict[str, Any] = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> JSONResponse:
"""Update an automation template"""
try:
chatInterface = getAutomationInterface(
context.user,
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
)
updated = chatInterface.updateAutomationTemplate(templateId, templateData)
return JSONResponse(content=updated)
except HTTPException:
raise
except PermissionError as e:
raise HTTPException(
status_code=403,
detail=str(e)
)
except Exception as e:
logger.error(f"Error updating template: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error updating template: {str(e)}"
)
@templateRouter.delete("/{templateId}")
@limiter.limit("10/minute")
async def delete_db_template(
request: Request,
templateId: str = Path(..., description="Template ID"),
context: RequestContext = Depends(getRequestContext)
) -> Response:
"""Delete an automation template"""
try:
chatInterface = getAutomationInterface(
context.user,
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
)
success = chatInterface.deleteAutomationTemplate(templateId)
if success:
return Response(status_code=204)
else:
raise HTTPException(
status_code=404,
detail="Template not found or no permission"
)
except HTTPException:
raise
except PermissionError as e:
raise HTTPException(
status_code=403,
detail=str(e)
)
except Exception as e:
logger.error(f"Error deleting template: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error deleting template: {str(e)}"
)

View file

@ -25,7 +25,6 @@ from modules.datamodels.datamodelChat import (
WorkflowModeEnum, WorkflowModeEnum,
UserInputRequest UserInputRequest
) )
from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition
import json import json
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
@ -1700,286 +1699,6 @@ class ChatObjects:
return {"items": items} return {"items": items}
# ===== Automation Methods =====
def _computeAutomationStatus(self, automation: Dict[str, Any]) -> str:
"""Compute status field based on eventId presence"""
eventId = automation.get("eventId")
return "Running" if eventId else "Idle"
def _enrichAutomationsWithUserAndMandate(self, automations: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Batch enrich automations with user names and mandate names for display.
Uses AppObjects interface to fetch users and mandates with proper access control.
"""
if not automations:
return automations
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
# Collect all unique user IDs and mandate IDs
userIds = set()
mandateIds = set()
for automation in automations:
createdBy = automation.get("_createdBy")
if createdBy:
userIds.add(createdBy)
mandateId = automation.get("mandateId")
if mandateId:
mandateIds.add(mandateId)
# Use AppObjects interface to fetch users (respects access control)
appInterface = getAppInterface(self.currentUser)
usersMap = {}
if userIds:
for user_id in userIds:
user = appInterface.getUser(user_id)
if user:
usersMap[user_id] = user.username or user.email or user_id
# Use AppObjects interface to fetch mandates (respects access control)
mandatesMap = {}
if mandateIds:
for mandate_id in mandateIds:
mandate = appInterface.getMandate(mandate_id)
if mandate:
mandatesMap[mandate_id] = mandate.name or mandate_id
# Enrich each automation with the fetched data
for automation in automations:
createdBy = automation.get("_createdBy")
if createdBy:
automation["_createdByUserName"] = usersMap.get(createdBy, createdBy)
else:
automation["_createdByUserName"] = "-"
mandateId = automation.get("mandateId")
if mandateId:
automation["mandateName"] = mandatesMap.get(mandateId, mandateId)
else:
automation["mandateName"] = "-"
return automations
def _enrichAutomationWithUserAndMandate(self, automation: Dict[str, Any]) -> Dict[str, Any]:
"""
Enrich a single automation with user name and mandate name for display.
For multiple automations, use _enrichAutomationsWithUserAndMandate for better performance.
"""
return self._enrichAutomationsWithUserAndMandate([automation])[0]
def getAllAutomationDefinitions(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]:
"""
Returns automation definitions based on user access level.
Supports optional pagination, sorting, and filtering.
Computes status field for each automation.
"""
# Use RBAC filtering
filteredAutomations = getRecordsetWithRBAC(self.db,
AutomationDefinition,
self.currentUser
)
# Compute status for each automation and normalize executionLogs
for automation in filteredAutomations:
automation["status"] = self._computeAutomationStatus(automation)
# Ensure executionLogs is always a list, not None
if automation.get("executionLogs") is None:
automation["executionLogs"] = []
# Batch enrich with user and mandate names
self._enrichAutomationsWithUserAndMandate(filteredAutomations)
# If no pagination requested, return all items
if pagination is None:
return filteredAutomations
# Apply filtering (if filters provided)
if pagination.filters:
filteredAutomations = self._applyFilters(filteredAutomations, pagination.filters)
# Apply sorting (in order of sortFields)
if pagination.sort:
filteredAutomations = self._applySorting(filteredAutomations, pagination.sort)
# Count total items after filters
totalItems = len(filteredAutomations)
totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0
# Apply pagination (skip/limit)
startIdx = (pagination.page - 1) * pagination.pageSize
endIdx = startIdx + pagination.pageSize
pagedAutomations = filteredAutomations[startIdx:endIdx]
return PaginatedResult(
items=pagedAutomations,
totalItems=totalItems,
totalPages=totalPages
)
def getAutomationDefinition(self, automationId: str) -> Optional[AutomationDefinition]:
"""Returns an automation definition by ID if user has access, with computed status."""
try:
# Use RBAC filtering
filtered = getRecordsetWithRBAC(self.db,
AutomationDefinition,
self.currentUser,
recordFilter={"id": automationId}
)
if not filtered:
return None
automation = filtered[0]
automation["status"] = self._computeAutomationStatus(automation)
# Ensure executionLogs is always a list, not None
if automation.get("executionLogs") is None:
automation["executionLogs"] = []
# Enrich with user and mandate names
self._enrichAutomationWithUserAndMandate(automation)
# Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")}
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error getting automation definition: {str(e)}")
return None
def createAutomationDefinition(self, automationData: Dict[str, Any]) -> AutomationDefinition:
"""Creates a new automation definition, then triggers sync."""
try:
# Ensure ID is present
if "id" not in automationData or not automationData["id"]:
automationData["id"] = str(uuid.uuid4())
# Ensure mandateId and featureInstanceId are set for proper data isolation
if "mandateId" not in automationData:
automationData["mandateId"] = self.mandateId
if "featureInstanceId" not in automationData:
automationData["featureInstanceId"] = self.featureInstanceId
# Ensure database connector has correct userId context
# The connector should have been initialized with userId, but ensure it's updated
if self.userId and hasattr(self.db, 'updateContext'):
try:
self.db.updateContext(self.userId)
except Exception as e:
logger.warning(f"Could not update database context: {e}")
# Note: _createdBy will be set automatically by connector's _saveRecord method
# when _createdAt is not present. We don't need to set it manually here.
# Use generic field separation
simpleFields, objectFields = self._separateObjectFields(AutomationDefinition, automationData)
# Create automation in database
createdAutomation = self.db.recordCreate(AutomationDefinition, simpleFields)
# Compute status
createdAutomation["status"] = self._computeAutomationStatus(createdAutomation)
# Ensure executionLogs is always a list, not None
if createdAutomation.get("executionLogs") is None:
createdAutomation["executionLogs"] = []
# Trigger automation change callback (async, don't wait)
asyncio.create_task(self._notifyAutomationChanged())
# Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")}
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error creating automation definition: {str(e)}")
raise
def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> AutomationDefinition:
"""Updates an automation definition, then triggers sync."""
try:
# Check access
existing = self.getAutomationDefinition(automationId)
if not existing:
raise PermissionError(f"No access to automation {automationId}")
if not self.checkRbacPermission(AutomationDefinition, "update", automationId):
raise PermissionError(f"No permission to modify automation {automationId}")
# Use generic field separation
simpleFields, objectFields = self._separateObjectFields(AutomationDefinition, automationData)
# Update automation in database
updatedAutomation = self.db.recordModify(AutomationDefinition, automationId, simpleFields)
# Compute status
updatedAutomation["status"] = self._computeAutomationStatus(updatedAutomation)
# Ensure executionLogs is always a list, not None
if updatedAutomation.get("executionLogs") is None:
updatedAutomation["executionLogs"] = []
# Trigger automation change callback (async, don't wait)
asyncio.create_task(self._notifyAutomationChanged())
# Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")}
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error updating automation definition: {str(e)}")
raise
def deleteAutomationDefinition(self, automationId: str) -> bool:
"""Deletes an automation definition, then triggers sync."""
try:
# Check access
existing = self.getAutomationDefinition(automationId)
if not existing:
raise PermissionError(f"No access to automation {automationId}")
if not self.checkRbacPermission(AutomationDefinition, "delete", automationId):
raise PermissionError(f"No permission to delete automation {automationId}")
# Remove event if exists
if existing.get("eventId"):
from modules.shared.eventManagement import eventManager
try:
eventManager.remove(existing["eventId"])
except Exception as e:
logger.warning(f"Error removing event {existing['eventId']}: {str(e)}")
# Delete automation from database
self.db.recordDelete(AutomationDefinition, automationId)
# Trigger automation change callback (async, don't wait)
asyncio.create_task(self._notifyAutomationChanged())
return True
except Exception as e:
logger.error(f"Error deleting automation definition: {str(e)}")
raise
def getAllAutomationDefinitionsWithRBAC(self, user: User) -> List[Dict[str, Any]]:
"""
Get all automation definitions filtered by RBAC for a specific user.
This method encapsulates getRecordsetWithRBAC() to avoid exposing the connector.
Args:
user: User object for RBAC filtering
Returns:
List of automation definition dictionaries filtered by RBAC
"""
return getRecordsetWithRBAC(
self.db,
AutomationDefinition,
user
)
async def _notifyAutomationChanged(self):
"""Notify registered callbacks about automation changes (decoupled from features)."""
try:
from modules.shared.callbackRegistry import callbackRegistry
# Trigger callbacks without knowing which features are listening
await callbackRegistry.trigger('automation.changed', self)
except Exception as e:
logger.error(f"Error notifying automation change: {str(e)}")
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ChatObjects': def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ChatObjects':
""" """

View file

@ -53,7 +53,7 @@ class InterfaceFeatureNeutralizer:
try: try:
# Use same database config pattern as other feature interfaces # Use same database config pattern as other feature interfaces
dbHost = APP_CONFIG.get("DB_HOST", "localhost") dbHost = APP_CONFIG.get("DB_HOST", "localhost")
dbDatabase = APP_CONFIG.get("DB_DATABASE_NEUTRALIZATION", APP_CONFIG.get("DB_DATABASE", "poweron")) dbDatabase = "poweron_neutralization"
dbUser = APP_CONFIG.get("DB_USER", "postgres") dbUser = APP_CONFIG.get("DB_USER", "postgres")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) dbPort = int(APP_CONFIG.get("DB_PORT", 5432))

View file

@ -528,7 +528,9 @@ class TrusteePosition(BaseModel):
"frontend_hidden": True "frontend_hidden": True
} }
) )
# System attributes are automatically set by DatabaseConnector
# Allow extra fields like _createdAt from database
model_config = {"extra": "allow"}
registerModelLabels( registerModelLabels(

View file

@ -459,7 +459,8 @@ class TrusteeObjects:
recordFilter=None, recordFilter=None,
orderBy="id", orderBy="id",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
logger.debug(f"getAllOrganisations: getRecordsetWithRBAC returned {len(records)} records") logger.debug(f"getAllOrganisations: getRecordsetWithRBAC returned {len(records)} records")
@ -552,7 +553,8 @@ class TrusteeObjects:
recordFilter=None, recordFilter=None,
orderBy="id", orderBy="id",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
# Users with ALL access level (from system RBAC) see all roles # Users with ALL access level (from system RBAC) see all roles
@ -662,7 +664,8 @@ class TrusteeObjects:
recordFilter=None, recordFilter=None,
orderBy="id", orderBy="id",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
# Users with ALL access level (from system RBAC) see all records # Users with ALL access level (from system RBAC) see all records
@ -721,7 +724,8 @@ class TrusteeObjects:
recordFilter={"organisationId": organisationId}, recordFilter={"organisationId": organisationId},
orderBy="id", orderBy="id",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records] return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records]
@ -738,7 +742,8 @@ class TrusteeObjects:
recordFilter={"userId": userId}, recordFilter={"userId": userId},
orderBy="id", orderBy="id",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
# Users with ALL access level (from system RBAC) see all records # Users with ALL access level (from system RBAC) see all records
@ -855,7 +860,8 @@ class TrusteeObjects:
recordFilter=None, recordFilter=None,
orderBy="id", orderBy="id",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
totalItems = len(records) totalItems = len(records)
@ -888,7 +894,8 @@ class TrusteeObjects:
recordFilter={"organisationId": organisationId}, recordFilter={"organisationId": organisationId},
orderBy="label", orderBy="label",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
return [TrusteeContract(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records] return [TrusteeContract(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records]
@ -1007,7 +1014,8 @@ class TrusteeObjects:
recordFilter=None, recordFilter=None,
orderBy="documentName", orderBy="documentName",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
# Clean records (remove binary data and internal fields) - keep as dicts for filtering/sorting # Clean records (remove binary data and internal fields) - keep as dicts for filtering/sorting
@ -1056,7 +1064,8 @@ class TrusteeObjects:
recordFilter={"contractId": contractId}, recordFilter={"contractId": contractId},
orderBy="documentName", orderBy="documentName",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
result = [] result = []
@ -1165,13 +1174,16 @@ class TrusteeObjects:
recordFilter=None, recordFilter=None,
orderBy="valuta", orderBy="valuta",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
# Clean records (remove internal fields) - keep as dicts for filtering/sorting # Clean records (remove internal fields except _createdAt) - keep as dicts for filtering/sorting
# Keep _createdAt for display in frontend
keepFields = {'_createdAt'}
cleanedRecords = [] cleanedRecords = []
for record in records: for record in records:
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")} cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_") or k in keepFields}
cleanedRecords.append(cleanedRecord) cleanedRecords.append(cleanedRecord)
# Step 2: Apply filters (search and field filters) # Step 2: Apply filters (search and field filters)
@ -1214,7 +1226,8 @@ class TrusteeObjects:
recordFilter={"contractId": contractId}, recordFilter={"contractId": contractId},
orderBy="valuta", orderBy="valuta",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records] return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records]
@ -1228,7 +1241,8 @@ class TrusteeObjects:
recordFilter={"organisationId": organisationId}, recordFilter={"organisationId": organisationId},
orderBy="valuta", orderBy="valuta",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records] return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records]
@ -1354,7 +1368,8 @@ class TrusteeObjects:
orderBy="id", orderBy="id",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId, featureInstanceId=self.featureInstanceId,
enrichPermissions=True enrichPermissions=True,
featureCode=self.FEATURE_CODE
) )
totalItems = len(records) totalItems = len(records)
@ -1387,7 +1402,8 @@ class TrusteeObjects:
recordFilter={"positionId": positionId}, recordFilter={"positionId": positionId},
orderBy="id", orderBy="id",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in links] return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in links]
@ -1401,7 +1417,8 @@ class TrusteeObjects:
recordFilter={"documentId": documentId}, recordFilter={"documentId": documentId},
orderBy="id", orderBy="id",
mandateId=self.mandateId, mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
) )
return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in links] return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in links]

View file

@ -70,6 +70,86 @@ def initBootstrap(db: DatabaseConnector) -> None:
# Apply multi-tenant database optimizations (indexes, triggers, FKs) # Apply multi-tenant database optimizations (indexes, triggers, FKs)
_applyDatabaseOptimizations(db) _applyDatabaseOptimizations(db)
# Seed automation templates (after admin user exists)
initAutomationTemplates(db, adminUserId)
def initAutomationTemplates(dbApp: 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).
NOTE: AutomationTemplate lives in poweron_automation database, not poweron_app!
Args:
dbApp: Database connector for poweron_app (used to get admin user if needed)
adminUserId: Admin user ID for _createdBy field
"""
import json
from modules.features.automation.subAutomationTemplates import AUTOMATION_TEMPLATES
from modules.features.automation.datamodelFeatureAutomation import AutomationTemplate
from modules.shared.configuration import APP_CONFIG
# Create connector for poweron_automation database (where templates live)
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = "poweron_automation"
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
dbAutomation = DatabaseConnector(
dbHost=dbHost,
dbDatabase=dbDatabase,
dbUser=dbUser,
dbPassword=dbPassword,
dbPort=dbPort,
userId=adminUserId,
)
dbAutomation.initDbSystem()
# Check if templates already exist in poweron_automation
existing = dbAutomation.getRecordset(AutomationTemplate)
if existing:
logger.info(f"Automation templates already seeded ({len(existing)} templates)")
return
# Get admin user ID if not provided (from poweron_app)
if not adminUserId:
adminUsers = dbApp.getRecordset(UserInDB, {"email": APP_CONFIG.ADMIN_EMAIL})
adminUserId = adminUsers[0]["id"] if adminUsers else None
# Update context with admin user
if adminUserId:
dbAutomation.updateContext(adminUserId)
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:
dbAutomation.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 in poweron_automation database")
logger.info("System bootstrap completed") logger.info("System bootstrap completed")
@ -677,6 +757,41 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
delete=AccessLevel.NONE, delete=AccessLevel.NONE,
)) ))
# AutomationTemplate: Admin sees ALL (system templates), User sees only MY
if adminId:
tableRules.append(AccessRule(
roleId=adminId,
context=AccessRuleContext.DATA,
item="data.automation.AutomationTemplate",
view=True,
read=AccessLevel.ALL, # SysAdmin sees all templates
create=AccessLevel.ALL,
update=AccessLevel.ALL,
delete=AccessLevel.ALL,
))
if userId:
tableRules.append(AccessRule(
roleId=userId,
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.ALL, # Viewer can see all templates (read-only)
create=AccessLevel.NONE,
update=AccessLevel.NONE,
delete=AccessLevel.NONE,
))
# Create all table-specific rules # Create all table-specific rules
for rule in tableRules: for rule in tableRules:
db.recordCreate(AccessRule, rule) db.recordCreate(AccessRule, rule)
@ -840,13 +955,20 @@ def _ensureDataContextRules(db: DatabaseConnector) -> None:
# Define tables that need rules (user-owned, no mandate context) # Define tables that need rules (user-owned, no mandate context)
# Users can only manage their own records (MY-level access) # Users can only manage their own records (MY-level access)
tablesNeedingRules = [ tablesNeedingMyRules = [
"data.chat.ChatWorkflow", "data.chat.ChatWorkflow",
"data.automation.AutomationDefinition", "data.automation.AutomationDefinition",
] ]
# Tables where admin sees ALL (system-wide templates)
tablesNeedingAllRulesForAdmin = [
"data.automation.AutomationTemplate",
]
missingRules = [] missingRules = []
for objectKey in tablesNeedingRules:
# MY-level rules for user-owned tables
for objectKey in tablesNeedingMyRules:
# Admin: MY-level access (user-owned, no mandate context) # Admin: MY-level access (user-owned, no mandate context)
if adminId and (adminId, objectKey) not in existingCombinations: if adminId and (adminId, objectKey) not in existingCombinations:
missingRules.append(AccessRule( missingRules.append(AccessRule(
@ -886,6 +1008,47 @@ def _ensureDataContextRules(db: DatabaseConnector) -> None:
delete=AccessLevel.NONE, delete=AccessLevel.NONE,
)) ))
# ALL-level rules for admin on system templates
for objectKey in tablesNeedingAllRulesForAdmin:
# Admin: ALL-level access (sees all templates)
if adminId and (adminId, objectKey) not in existingCombinations:
missingRules.append(AccessRule(
roleId=adminId,
context=AccessRuleContext.DATA,
item=objectKey,
view=True,
read=AccessLevel.ALL,
create=AccessLevel.ALL,
update=AccessLevel.ALL,
delete=AccessLevel.ALL,
))
# User: MY-level access
if userId and (userId, objectKey) not in existingCombinations:
missingRules.append(AccessRule(
roleId=userId,
context=AccessRuleContext.DATA,
item=objectKey,
view=True,
read=AccessLevel.MY,
create=AccessLevel.MY,
update=AccessLevel.MY,
delete=AccessLevel.MY,
))
# Viewer: ALL read-only (can see all templates)
if viewerId and (viewerId, objectKey) not in existingCombinations:
missingRules.append(AccessRule(
roleId=viewerId,
context=AccessRuleContext.DATA,
item=objectKey,
view=True,
read=AccessLevel.ALL,
create=AccessLevel.NONE,
update=AccessLevel.NONE,
delete=AccessLevel.NONE,
))
# Create missing rules # Create missing rules
if missingRules: if missingRules:
for rule in missingRules: for rule in missingRules:
@ -893,6 +1056,44 @@ def _ensureDataContextRules(db: DatabaseConnector) -> None:
logger.info(f"Created {len(missingRules)} missing DATA context rules") logger.info(f"Created {len(missingRules)} missing DATA context rules")
else: else:
logger.debug("All DATA context rules already exist") logger.debug("All DATA context rules already exist")
# Update existing AutomationTemplate rules for admin/viewer to ALL access
_updateAutomationTemplateRulesToAll(db, adminId, viewerId)
def _updateAutomationTemplateRulesToAll(db: DatabaseConnector, adminId: Optional[str], viewerId: Optional[str]) -> None:
"""
Update existing AutomationTemplate RBAC rules from MY to ALL for admin and viewer.
This ensures sysadmins can see all templates (including system-seeded ones).
"""
if not adminId and not viewerId:
return
templateObjectKey = "data.automation.AutomationTemplate"
# Find existing rules for AutomationTemplate
existingRules = db.getRecordset(
AccessRule,
recordFilter={
"context": AccessRuleContext.DATA.value,
"item": templateObjectKey
}
)
updatedCount = 0
for rule in existingRules:
ruleId = rule.get("id")
roleId = rule.get("roleId")
currentReadLevel = rule.get("read")
# Update admin and viewer rules from MY to ALL
if roleId in [adminId, viewerId] and currentReadLevel == AccessLevel.MY.value:
db.recordModify(AccessRule, ruleId, {"read": AccessLevel.ALL.value})
updatedCount += 1
logger.debug(f"Updated AutomationTemplate rule {ruleId} for role {roleId} to ALL access")
if updatedCount > 0:
logger.info(f"Updated {updatedCount} AutomationTemplate RBAC rules to ALL access")
def _createResourceContextRules(db: DatabaseConnector) -> None: def _createResourceContextRules(db: DatabaseConnector) -> None:

View file

@ -25,7 +25,6 @@ from modules.datamodels.datamodelChat import (
WorkflowModeEnum, WorkflowModeEnum,
UserInputRequest UserInputRequest
) )
from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition
import json import json
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
@ -1654,311 +1653,6 @@ class ChatObjects:
return {"items": items} return {"items": items}
# ===== Automation Methods =====
def _computeAutomationStatus(self, automation: Dict[str, Any]) -> str:
"""Compute status field based on eventId presence"""
eventId = automation.get("eventId")
return "Running" if eventId else "Idle"
def _enrichAutomationsWithUserAndMandate(self, automations: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Batch enrich automations with user names and mandate names for display.
Uses AppObjects interface to fetch users and mandates with proper access control.
"""
if not automations:
return automations
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
# Collect all unique user IDs and mandate IDs
userIds = set()
mandateIds = set()
for automation in automations:
createdBy = automation.get("_createdBy")
if createdBy:
userIds.add(createdBy)
mandateId = automation.get("mandateId")
if mandateId:
mandateIds.add(mandateId)
# Use AppObjects interface to fetch users (respects access control)
appInterface = getAppInterface(self.currentUser)
usersMap = {}
if userIds:
for user_id in userIds:
user = appInterface.getUser(user_id)
if user:
usersMap[user_id] = user.username or user.email or user_id
# Use AppObjects interface to fetch mandates (respects access control)
mandatesMap = {}
if mandateIds:
for mandate_id in mandateIds:
mandate = appInterface.getMandate(mandate_id)
if mandate:
mandatesMap[mandate_id] = mandate.name or mandate_id
# Enrich each automation with the fetched data
for automation in automations:
createdBy = automation.get("_createdBy")
if createdBy:
automation["_createdByUserName"] = usersMap.get(createdBy, createdBy)
else:
automation["_createdByUserName"] = "-"
mandateId = automation.get("mandateId")
if mandateId:
automation["mandateName"] = mandatesMap.get(mandateId, mandateId)
else:
automation["mandateName"] = "-"
return automations
def _enrichAutomationWithUserAndMandate(self, automation: Dict[str, Any]) -> Dict[str, Any]:
"""
Enrich a single automation with user name and mandate name for display.
For multiple automations, use _enrichAutomationsWithUserAndMandate for better performance.
"""
return self._enrichAutomationsWithUserAndMandate([automation])[0]
def getAllAutomationDefinitions(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]:
"""
Returns automation definitions based on user access level.
Supports optional pagination, sorting, and filtering.
Computes status field for each automation.
"""
# Use RBAC filtering
filteredAutomations = getRecordsetWithRBAC(self.db,
AutomationDefinition,
self.currentUser
)
# Compute status for each automation and normalize executionLogs
for automation in filteredAutomations:
automation["status"] = self._computeAutomationStatus(automation)
# Ensure executionLogs is always a list, not None
if automation.get("executionLogs") is None:
automation["executionLogs"] = []
# Batch enrich with user and mandate names
self._enrichAutomationsWithUserAndMandate(filteredAutomations)
# If no pagination requested, return all items
if pagination is None:
return filteredAutomations
# Apply filtering (if filters provided)
if pagination.filters:
filteredAutomations = self._applyFilters(filteredAutomations, pagination.filters)
# Apply sorting (in order of sortFields)
if pagination.sort:
filteredAutomations = self._applySorting(filteredAutomations, pagination.sort)
# Count total items after filters
totalItems = len(filteredAutomations)
totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0
# Apply pagination (skip/limit)
startIdx = (pagination.page - 1) * pagination.pageSize
endIdx = startIdx + pagination.pageSize
pagedAutomations = filteredAutomations[startIdx:endIdx]
return PaginatedResult(
items=pagedAutomations,
totalItems=totalItems,
totalPages=totalPages
)
def getAutomationDefinition(self, automationId: str, includeSystemFields: bool = False) -> Optional[AutomationDefinition]:
"""Returns an automation definition by ID if user has access, with computed status.
Args:
automationId: ID of the automation to get
includeSystemFields: If True, returns raw dict with system fields (_createdBy, etc).
If False (default), returns Pydantic model without system fields.
"""
try:
# Use RBAC filtering
filtered = getRecordsetWithRBAC(self.db,
AutomationDefinition,
self.currentUser,
recordFilter={"id": automationId}
)
if not filtered:
return None
automation = filtered[0]
automation["status"] = self._computeAutomationStatus(automation)
# Ensure executionLogs is always a list, not None
if automation.get("executionLogs") is None:
automation["executionLogs"] = []
# Enrich with user and mandate names
self._enrichAutomationWithUserAndMandate(automation)
# For internal use (execution), return raw dict with system fields
if includeSystemFields:
# Return as simple namespace object so getattr works
class AutomationWithSystemFields:
def __init__(self, data):
for key, value in data.items():
setattr(self, key, value)
return AutomationWithSystemFields(automation)
# Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")}
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error getting automation definition: {str(e)}")
return None
def createAutomationDefinition(self, automationData: Dict[str, Any]) -> AutomationDefinition:
"""Creates a new automation definition, then triggers sync."""
try:
# Ensure ID is present
if "id" not in automationData or not automationData["id"]:
automationData["id"] = str(uuid.uuid4())
# Ensure mandateId and featureInstanceId are set for proper data isolation
if "mandateId" not in automationData or not automationData.get("mandateId"):
# Use request context mandateId, or fall back to Root mandate
effectiveMandateId = self.mandateId
if not effectiveMandateId:
# Fall back to Root mandate (first mandate in system)
try:
from modules.datamodels.datamodelUam import Mandate
from modules.security.rootAccess import getRootDbAppConnector
dbAppConn = getRootDbAppConnector()
allMandates = dbAppConn.getRecordset(Mandate)
if allMandates:
effectiveMandateId = allMandates[0].get("id")
logger.debug(f"createAutomationDefinition: Using Root mandate {effectiveMandateId}")
except Exception as e:
logger.warning(f"Could not get Root mandate: {e}")
automationData["mandateId"] = effectiveMandateId
if "featureInstanceId" not in automationData:
automationData["featureInstanceId"] = self.featureInstanceId
# Ensure database connector has correct userId context
# The connector should have been initialized with userId, but ensure it's updated
if not self.userId:
logger.error(f"createAutomationDefinition: userId is not set! Cannot set _createdBy. currentUser={self.currentUser}")
elif hasattr(self.db, 'updateContext'):
try:
self.db.updateContext(self.userId)
logger.debug(f"createAutomationDefinition: Updated database context with userId={self.userId}")
except Exception as e:
logger.warning(f"Could not update database context: {e}")
# Note: _createdBy will be set automatically by connector's _saveRecord method
# when _createdAt is not present. We don't need to set it manually here.
# Use generic field separation
simpleFields, objectFields = self._separateObjectFields(AutomationDefinition, automationData)
# Create automation in database
createdAutomation = self.db.recordCreate(AutomationDefinition, simpleFields)
# Compute status
createdAutomation["status"] = self._computeAutomationStatus(createdAutomation)
# Ensure executionLogs is always a list, not None
if createdAutomation.get("executionLogs") is None:
createdAutomation["executionLogs"] = []
# Trigger automation change callback (async, don't wait)
asyncio.create_task(self._notifyAutomationChanged())
# Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")}
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error creating automation definition: {str(e)}")
raise
def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> AutomationDefinition:
"""Updates an automation definition, then triggers sync."""
try:
# Check access
existing = self.getAutomationDefinition(automationId)
if not existing:
raise PermissionError(f"No access to automation {automationId}")
if not self.checkRbacPermission(AutomationDefinition, "update", automationId):
raise PermissionError(f"No permission to modify automation {automationId}")
# Use generic field separation
simpleFields, objectFields = self._separateObjectFields(AutomationDefinition, automationData)
# Update automation in database
updatedAutomation = self.db.recordModify(AutomationDefinition, automationId, simpleFields)
# Compute status
updatedAutomation["status"] = self._computeAutomationStatus(updatedAutomation)
# Ensure executionLogs is always a list, not None
if updatedAutomation.get("executionLogs") is None:
updatedAutomation["executionLogs"] = []
# Trigger automation change callback (async, don't wait)
asyncio.create_task(self._notifyAutomationChanged())
# Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")}
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error updating automation definition: {str(e)}")
raise
def deleteAutomationDefinition(self, automationId: str) -> bool:
"""Deletes an automation definition, then triggers sync."""
try:
# Check access
existing = self.getAutomationDefinition(automationId)
if not existing:
raise PermissionError(f"No access to automation {automationId}")
if not self.checkRbacPermission(AutomationDefinition, "delete", automationId):
raise PermissionError(f"No permission to delete automation {automationId}")
# Delete automation from database
self.db.recordDelete(AutomationDefinition, automationId)
# Trigger automation change callback (async, don't wait)
asyncio.create_task(self._notifyAutomationChanged())
return True
except Exception as e:
logger.error(f"Error deleting automation definition: {str(e)}")
raise
def getAllAutomationDefinitionsWithRBAC(self, user: User) -> List[Dict[str, Any]]:
"""
Get all automation definitions filtered by RBAC for a specific user.
This method encapsulates getRecordsetWithRBAC() to avoid exposing the connector.
Args:
user: User object for RBAC filtering
Returns:
List of automation definition dictionaries filtered by RBAC
"""
return getRecordsetWithRBAC(
self.db,
AutomationDefinition,
user
)
async def _notifyAutomationChanged(self):
"""Notify registered callbacks about automation changes (decoupled from features)."""
try:
from modules.shared.callbackRegistry import callbackRegistry
# Trigger callbacks without knowing which features are listening
await callbackRegistry.trigger('automation.changed', self)
except Exception as e:
logger.error(f"Error notifying automation change: {str(e)}")
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ChatObjects': def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ChatObjects':
""" """

View file

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

View file

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

View file

@ -39,6 +39,44 @@ def _getUserConnection(interface, connectionId: str, userId: str) -> Optional[Us
logger.error(f"Error getting user connection: {str(e)}") logger.error(f"Error getting user connection: {str(e)}")
return None return None
def _getUserConnectionByReference(interface, connectionReference: str, userId: str) -> Optional[UserConnection]:
"""
Get a user connection by reference string (format: connection:authority:username).
Args:
interface: Database interface
connectionReference: Reference string like 'connection:msft:user@email.com'
userId: User ID to verify ownership
Returns:
UserConnection if found and belongs to user, None otherwise
"""
try:
# Parse reference format: connection:{authority}:{username} [status:..., token:...]
# Remove state information if present
baseReference = connectionReference.split(' [')[0]
parts = baseReference.split(':')
if len(parts) < 3 or parts[0] != "connection":
logger.warning(f"Invalid connection reference format: {connectionReference}")
return None
authority = parts[1] # e.g., 'msft'
username = ':'.join(parts[2:]) # Handle usernames with colons
# Get user connections and find matching one
connections = interface.getUserConnections(userId)
for conn in connections:
connAuthority = conn.authority.value if hasattr(conn.authority, 'value') else str(conn.authority)
if connAuthority.lower() == authority.lower() and conn.externalUsername == username:
return conn
logger.debug(f"No connection found for reference: {connectionReference}")
return None
except Exception as e:
logger.error(f"Error getting user connection by reference: {str(e)}")
return None
@router.get("/{connectionId}/sites", response_model=List[Dict[str, Any]]) @router.get("/{connectionId}/sites", response_model=List[Dict[str, Any]])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def get_sharepoint_sites( async def get_sharepoint_sites(
@ -251,3 +289,110 @@ def _extractSitePath(webUrl: str) -> str:
return "/sites/" + webUrl.split("/sites/")[1].split("/")[0] return "/sites/" + webUrl.split("/sites/")[1].split("/")[0]
return "" return ""
# ============================================================================
# Universal folder-options endpoint (by connectionReference)
# ============================================================================
@router.get("/folder-options", response_model=List[Dict[str, Any]])
@limiter.limit("30/minute")
async def getSharepointFolderOptionsByReference(
request: Request,
connectionReference: str = Query(..., description="Connection reference string (e.g., 'connection:msft:user@email.com')"),
siteId: Optional[str] = Query(None, description="Specific site ID to browse (if omitted, returns sites only)"),
path: Optional[str] = Query(None, description="Folder path within site to browse"),
currentUser: User = Depends(getCurrentUser)
) -> List[Dict[str, Any]]:
"""
Get SharePoint folders formatted as dropdown options (universal endpoint).
Uses connectionReference instead of connectionId for easier integration.
Two modes:
1. If siteId is not provided: Returns list of sites (for site selection)
2. If siteId is provided: Returns folders within that site (optionally at specific path)
Args:
connectionReference: Connection reference string (e.g., 'connection:msft:user@email.com')
siteId: Optional site ID to browse folders within
path: Optional folder path within site
"""
try:
interface = getInterface(currentUser)
# Get the connection by reference
connection = _getUserConnectionByReference(interface, connectionReference, currentUser.id)
if not connection:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Connection not found for reference: {connectionReference}"
)
# Verify it's a Microsoft connection
authority = connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority)
if authority.lower() != 'msft':
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Connection is not a Microsoft connection (authority: {authority})"
)
# Initialize services
services = getServices(currentUser, None)
# Set access token on SharePoint service
if not services.sharepoint.setAccessTokenFromConnection(connection):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Failed to set SharePoint access token. Connection may be expired or invalid."
)
# Mode 1: Return sites list if no siteId specified
if not siteId:
sites = await services.sharepoint.discoverSites()
return [
{
"type": "site",
"value": site.get("id"),
"label": site.get("displayName", "Unknown Site"),
"siteId": site.get("id"),
"siteName": site.get("displayName", "Unknown Site"),
"webUrl": site.get("webUrl", ""),
"path": _extractSitePath(site.get("webUrl", ""))
}
for site in sites
]
# Mode 2: Return folders within specific site
folderPath = path or ""
items = await services.sharepoint.listFolderContents(siteId, folderPath)
if not items:
return []
folderOptions = []
for item in items:
if item.get("type") == "folder":
folderName = item.get("name", "")
itemPath = f"{folderPath}/{folderName}" if folderPath else folderName
folderOptions.append({
"type": "folder",
"value": itemPath,
"label": folderName,
"siteId": siteId,
"folderName": folderName,
"path": itemPath,
"hasChildren": True # Assume folders may have children
})
return folderOptions
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting SharePoint folder options by reference: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error getting SharePoint folder options: {str(e)}"
)

View file

@ -106,7 +106,7 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]:
from modules.features.trustee.mainTrustee import UI_OBJECTS from modules.features.trustee.mainTrustee import UI_OBJECTS
return UI_OBJECTS return UI_OBJECTS
elif featureCode == "realestate": elif featureCode == "realestate":
from modules.features.realEstate.mainRealEstate import UI_OBJECTS from modules.features.realestate.mainRealEstate import UI_OBJECTS
return UI_OBJECTS return UI_OBJECTS
else: else:
logger.warning(f"Unknown feature code: {featureCode}") logger.warning(f"Unknown feature code: {featureCode}")

View file

@ -54,9 +54,11 @@ class FrontendType(str, Enum):
WORKFLOW_ACTION = "workflowAction" WORKFLOW_ACTION = "workflowAction"
"""Workflow action selector - fetches available actions from workflow context""" """Workflow action selector - fetches available actions from workflow context"""
SHAREPOINT_FOLDER = "sharepointFolder"
"""SharePoint folder selector - requires connectionReference parameter in same action to load folders"""
# Additional custom types can be added here as needed # Additional custom types can be added here as needed
# Examples: # Examples:
# SHAREPOINT_FOLDER = "sharepointFolder"
# OUTLOOK_FOLDER = "outlookFolder" # OUTLOOK_FOLDER = "outlookFolder"
# JIRA_PROJECT = "jiraProject" # JIRA_PROJECT = "jiraProject"
@ -66,6 +68,7 @@ CUSTOM_TYPE_OPTIONS_API: Dict[FrontendType, str] = {
FrontendType.USER_CONNECTION: "user.connection", FrontendType.USER_CONNECTION: "user.connection",
FrontendType.DOCUMENT_REFERENCE: "workflow.documentReference", # To be implemented FrontendType.DOCUMENT_REFERENCE: "workflow.documentReference", # To be implemented
FrontendType.WORKFLOW_ACTION: "workflow.action", # To be implemented FrontendType.WORKFLOW_ACTION: "workflow.action", # To be implemented
FrontendType.SHAREPOINT_FOLDER: "sharepoint.folder", # Dynamic - requires connectionReference
} }
# Mapping of custom types to their description # Mapping of custom types to their description
@ -85,6 +88,11 @@ CUSTOM_TYPE_DESCRIPTIONS: Dict[FrontendType, Dict[str, str]] = {
"fr": "Action de workflow", "fr": "Action de workflow",
"de": "Workflow-Aktion" "de": "Workflow-Aktion"
}, },
FrontendType.SHAREPOINT_FOLDER: {
"en": "SharePoint Folder",
"fr": "Dossier SharePoint",
"de": "SharePoint-Ordner"
},
} }

View file

@ -92,6 +92,15 @@ NAVIGATION_SECTIONS = [
"order": 30, "order": 30,
"public": True, "public": True,
}, },
{
"id": "automation-templates",
"objectKey": "ui.system.automation-templates",
"label": {"en": "Templates", "de": "Vorlagen", "fr": "Modèles"},
"icon": "FaFileAlt",
"path": "/workflows/automation-templates",
"order": 35,
"public": True,
},
], ],
}, },
{ {

View file

@ -40,6 +40,11 @@ def loadFeatureRouters(app: FastAPI) -> Dict[str, Any]:
""" """
Dynamically load and register routers from all discovered feature containers. Dynamically load and register routers from all discovered feature containers.
Also registers feature template roles and AccessRules in the database. Also registers feature template roles and AccessRules in the database.
Searches for:
- 'router' (main feature router)
- 'templateRouter' (additional router for templates)
- Any other *Router named exports
""" """
results = {} results = {}
pattern = os.path.join(FEATURES_DIR, "*", "routeFeature*.py") pattern = os.path.join(FEATURES_DIR, "*", "routeFeature*.py")
@ -55,12 +60,26 @@ def loadFeatureRouters(app: FastAPI) -> Dict[str, Any]:
modulePath = f"modules.features.{featureDir}.{routerFile}" modulePath = f"modules.features.{featureDir}.{routerFile}"
module = importlib.import_module(modulePath) module = importlib.import_module(modulePath)
loadedRouters = []
# Load main router
if hasattr(module, "router"): if hasattr(module, "router"):
app.include_router(module.router) app.include_router(module.router)
logger.info(f"Loaded router: {featureDir}") loadedRouters.append("router")
results[featureDir] = {"status": "loaded", "module": modulePath}
# Load additional named routers (e.g., templateRouter)
for attrName in dir(module):
if attrName.endswith("Router") and attrName != "APIRouter":
routerObj = getattr(module, attrName)
if hasattr(routerObj, "routes"): # Check if it's a FastAPI router
app.include_router(routerObj)
loadedRouters.append(attrName)
if loadedRouters:
logger.info(f"Loaded routers from {featureDir}: {loadedRouters}")
results[featureDir] = {"status": "loaded", "module": modulePath, "routers": loadedRouters}
else: else:
logger.warning(f"No 'router' in {modulePath}") logger.warning(f"No routers found in {modulePath}")
results[featureDir] = {"status": "no_router_object"} results[featureDir] = {"status": "no_router_object"}
except Exception as e: except Exception as e:

View file

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

View file

@ -109,7 +109,7 @@ class MethodSharepoint(MethodBase):
"pathQuery": WorkflowActionParameter( "pathQuery": WorkflowActionParameter(
name="pathQuery", name="pathQuery",
type="str", type="str",
frontendType=FrontendType.TEXT, frontendType=FrontendType.SHAREPOINT_FOLDER,
required=False, required=False,
description="Direct path query if no documentList (e.g., /sites/SiteName/FolderPath)" description="Direct path query if no documentList (e.g., /sites/SiteName/FolderPath)"
), ),
@ -146,7 +146,7 @@ class MethodSharepoint(MethodBase):
"pathQuery": WorkflowActionParameter( "pathQuery": WorkflowActionParameter(
name="pathQuery", name="pathQuery",
type="str", type="str",
frontendType=FrontendType.TEXT, frontendType=FrontendType.SHAREPOINT_FOLDER,
required=False, required=False,
description="Direct upload target path if documentList doesn't contain findDocumentPath result (e.g., /sites/SiteName/FolderPath)" description="Direct upload target path if documentList doesn't contain findDocumentPath result (e.g., /sites/SiteName/FolderPath)"
) )