diff --git a/modules/datamodels/datamodelUam.py b/modules/datamodels/datamodelUam.py index b7878532..b0e6b468 100644 --- a/modules/datamodels/datamodelUam.py +++ b/modules/datamodels/datamodelUam.py @@ -12,7 +12,7 @@ Multi-Tenant Design: import uuid from typing import Optional, List 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.timeUtils import getUtcTimestamp @@ -114,6 +114,20 @@ class UserConnection(BaseModel): {"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}) + + @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( @@ -132,6 +146,8 @@ registerModelLabels( "expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"}, "tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"}, "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"}, }, ) diff --git a/modules/features/automation/datamodelFeatureAutomation.py b/modules/features/automation/datamodelFeatureAutomation.py index 7988cadf..6d1e906f 100644 --- a/modules/features/automation/datamodelFeatureAutomation.py +++ b/modules/features/automation/datamodelFeatureAutomation.py @@ -1,10 +1,11 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. -"""Automation models: AutomationDefinition.""" +"""Automation models: AutomationDefinition, AutomationTemplate.""" from typing import List, Dict, Any, Optional from pydantic import BaseModel, Field from modules.shared.attributeUtils import registerModelLabels +from modules.datamodels.datamodelUtils import TextMultilingual import uuid @@ -28,18 +29,53 @@ class AutomationDefinition(BaseModel): registerModelLabels( "AutomationDefinition", - {"en": "Automation Definition", "fr": "Définition d'automatisation"}, + {"en": "Automation Definition", "ge": "Automatisierungs-Definition", "fr": "Définition d'automatisation"}, { - "id": {"en": "ID", "fr": "ID"}, - "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, - "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, - "label": {"en": "Label", "fr": "Libellé"}, - "schedule": {"en": "Schedule", "fr": "Planification"}, - "template": {"en": "Template", "fr": "Modèle"}, - "placeholders": {"en": "Placeholders", "fr": "Espaces réservés"}, - "active": {"en": "Active", "fr": "Actif"}, - "eventId": {"en": "Event ID", "fr": "ID de l'événement"}, - "status": {"en": "Status", "fr": "Statut"}, - "executionLogs": {"en": "Execution Logs", "fr": "Journaux d'exécution"}, + "id": {"en": "ID", "ge": "ID", "fr": "ID"}, + "mandateId": {"en": "Mandate ID", "ge": "Mandanten-ID", "fr": "ID du mandat"}, + "featureInstanceId": {"en": "Feature Instance ID", "ge": "Feature-Instanz-ID", "fr": "ID de l'instance de fonctionnalité"}, + "label": {"en": "Label", "ge": "Bezeichnung", "fr": "Libellé"}, + "schedule": {"en": "Schedule", "ge": "Zeitplan", "fr": "Planification"}, + "template": {"en": "Template", "ge": "Vorlage", "fr": "Modèle"}, + "placeholders": {"en": "Placeholders", "ge": "Platzhalter", "fr": "Espaces réservés"}, + "active": {"en": "Active", "ge": "Aktiv", "fr": "Actif"}, + "eventId": {"en": "Event ID", "ge": "Event-ID", "fr": "ID de l'événement"}, + "status": {"en": "Status", "ge": "Status", "fr": "Statut"}, + "executionLogs": {"en": "Execution Logs", "ge": "Ausführungsprotokolle", "fr": "Journaux d'exécution"}, + }, +) + + +class AutomationTemplate(BaseModel): + """Automation-Vorlage ohne scharfe Placeholder-Werte (DB-persistiert).""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True} + ) + label: TextMultilingual = Field( + description="Template name (multilingual)", + json_schema_extra={"frontend_type": "multilingual", "frontend_required": True} + ) + overview: Optional[TextMultilingual] = Field( + None, + description="Short description (multilingual)", + json_schema_extra={"frontend_type": "multilingual", "frontend_required": False} + ) + template: str = Field( + description="JSON workflow structure with {{KEY:...}} placeholders", + json_schema_extra={"frontend_type": "textarea", "frontend_required": True} + ) + # System fields (_createdAt, _createdBy, etc.) werden automatisch vom DB-Connector gesetzt + + +registerModelLabels( + "AutomationTemplate", + {"en": "Automation Template", "ge": "Automation-Vorlage", "fr": "Modèle d'automatisation"}, + { + "id": {"en": "ID", "ge": "ID", "fr": "ID"}, + "label": {"en": "Label", "ge": "Bezeichnung", "fr": "Libellé"}, + "overview": {"en": "Overview", "ge": "Übersicht", "fr": "Aperçu"}, + "template": {"en": "Template", "ge": "Vorlage", "fr": "Modèle"}, }, ) diff --git a/modules/features/automation/interfaceFeatureAutomation.py b/modules/features/automation/interfaceFeatureAutomation.py new file mode 100644 index 00000000..34102f5e --- /dev/null +++ b/modules/features/automation/interfaceFeatureAutomation.py @@ -0,0 +1,655 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Interface for Automation feature - manages AutomationDefinition and AutomationTemplate. +Uses the PostgreSQL connector for data access with user/mandate filtering. +""" + +import logging +import uuid +import math +import asyncio +from typing import Dict, Any, List, Optional, Union + +from modules.security.rbac import RbacClass +from modules.datamodels.datamodelRbac import AccessRuleContext +from modules.datamodels.datamodelUam import AccessLevel, User +from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition, AutomationTemplate +from modules.connectors.connectorDbPostgre import DatabaseConnector +from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult +from modules.interfaces.interfaceRbac import getRecordsetWithRBAC, buildDataObjectKey + +from modules.shared.configuration import APP_CONFIG + +logger = logging.getLogger(__name__) + +# Singleton factory for Automation instances +_automationInterfaces = {} + + +class AutomationObjects: + """ + Interface for Automation database operations. + Manages AutomationDefinition and AutomationTemplate with RBAC support. + """ + + def __init__(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None): + self.currentUser = currentUser + self.mandateId = mandateId + self.featureInstanceId = featureInstanceId + self.userId = currentUser.id if currentUser else None + + # Initialize database with proper configuration + self._initializeDatabase() + + # Initialize RBAC (dbApp = self.db since we use poweron_app) + self.rbac = RbacClass(self.db, dbApp=self.db) + + # Update database context + self.db.updateContext(self.userId) + + def _initializeDatabase(self): + """Initializes the database connection with proper configuration.""" + # Get configuration values + dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data") + dbDatabase = "poweron_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] diff --git a/modules/features/automation/routeFeatureAutomation.py b/modules/features/automation/routeFeatureAutomation.py index c92200de..d9c0b758 100644 --- a/modules/features/automation/routeFeatureAutomation.py +++ b/modules/features/automation/routeFeatureAutomation.py @@ -13,14 +13,13 @@ import logging import json # Import interfaces and models -from modules.interfaces.interfaceDbChat import getInterface as getChatInterface +from modules.features.automation.interfaceFeatureAutomation import getInterface as getAutomationInterface from modules.auth import limiter, getRequestContext, RequestContext -from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition +from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition, AutomationTemplate from modules.datamodels.datamodelChat import ChatWorkflow from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.workflows.automation import executeAutomation -from .subAutomationTemplates import getAutomationTemplates # Configure logger logger = logging.getLogger(__name__) @@ -69,7 +68,7 @@ async def get_automations( detail=f"Invalid pagination parameter: {str(e)}" ) - chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) + chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) result = chatInterface.getAllAutomationDefinitions(pagination=paginationParams) # If pagination was requested, result is PaginatedResult @@ -115,7 +114,7 @@ async def create_automation( ) -> AutomationDefinition: """Create a new automation definition""" try: - chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) + chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) automationData = automation.model_dump() created = chatInterface.createAutomationDefinition(automationData) return created @@ -128,26 +127,6 @@ async def create_automation( detail=f"Error creating automation: {str(e)}" ) -@router.get("/templates") -@limiter.limit("30/minute") -async def get_automation_templates( - request: Request, - context: RequestContext = Depends(getRequestContext) -) -> JSONResponse: - """ - Get automation templates from backend module. - The UI should fetch these templates regularly to get the latest versions. - """ - try: - templatesData = getAutomationTemplates() - return JSONResponse(content=templatesData) - except Exception as e: - logger.error(f"Error getting automation templates: {str(e)}") - raise HTTPException( - status_code=500, - detail=f"Error getting automation templates: {str(e)}" - ) - @router.get("/attributes", response_model=Dict[str, Any]) async def get_automation_attributes( request: Request @@ -155,6 +134,100 @@ async def get_automation_attributes( """Get attribute definitions for AutomationDefinition model""" 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) @limiter.limit("30/minute") async def get_automation( @@ -164,7 +237,7 @@ async def get_automation( ) -> AutomationDefinition: """Get a single automation definition by ID""" try: - chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) + chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) automation = chatInterface.getAutomationDefinition(automationId) if not automation: raise HTTPException( @@ -192,7 +265,7 @@ async def update_automation( ) -> AutomationDefinition: """Update an automation definition""" try: - chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) + chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) automationData = automation.model_dump() updated = chatInterface.updateAutomationDefinition(automationId, automationData) return updated @@ -220,7 +293,7 @@ async def update_automation_status( ) -> AutomationDefinition: """Update only the active status of an automation definition""" try: - chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) + chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) # Get existing automation automation = chatInterface.getAutomationDefinition(automationId) @@ -260,7 +333,7 @@ async def delete_automation( ) -> Response: """Delete an automation definition""" try: - chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) + chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) success = chatInterface.deleteAutomationDefinition(automationId) if success: return Response(status_code=204) @@ -311,3 +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)}" + ) + + diff --git a/modules/features/chatbot/interfaceFeatureChatbot.py b/modules/features/chatbot/interfaceFeatureChatbot.py index 50fdf4ef..c1e5977e 100644 --- a/modules/features/chatbot/interfaceFeatureChatbot.py +++ b/modules/features/chatbot/interfaceFeatureChatbot.py @@ -25,7 +25,6 @@ from modules.datamodels.datamodelChat import ( WorkflowModeEnum, UserInputRequest ) -from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition import json from modules.datamodels.datamodelUam import User @@ -1700,286 +1699,6 @@ class ChatObjects: return {"items": items} - # ===== Automation Methods ===== - - def _computeAutomationStatus(self, automation: Dict[str, Any]) -> str: - """Compute status field based on eventId presence""" - eventId = automation.get("eventId") - return "Running" if eventId else "Idle" - - def _enrichAutomationsWithUserAndMandate(self, automations: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """ - Batch enrich automations with user names and mandate names for display. - Uses AppObjects interface to fetch users and mandates with proper access control. - """ - if not automations: - return automations - - from modules.interfaces.interfaceDbApp import getInterface as getAppInterface - - # Collect all unique user IDs and mandate IDs - userIds = set() - mandateIds = set() - - for automation in automations: - createdBy = automation.get("_createdBy") - if createdBy: - userIds.add(createdBy) - - mandateId = automation.get("mandateId") - if mandateId: - mandateIds.add(mandateId) - - # Use AppObjects interface to fetch users (respects access control) - appInterface = getAppInterface(self.currentUser) - usersMap = {} - if userIds: - for user_id in userIds: - user = appInterface.getUser(user_id) - if user: - usersMap[user_id] = user.username or user.email or user_id - - # Use AppObjects interface to fetch mandates (respects access control) - mandatesMap = {} - if mandateIds: - for mandate_id in mandateIds: - mandate = appInterface.getMandate(mandate_id) - if mandate: - mandatesMap[mandate_id] = mandate.name or mandate_id - - # Enrich each automation with the fetched data - for automation in automations: - createdBy = automation.get("_createdBy") - if createdBy: - automation["_createdByUserName"] = usersMap.get(createdBy, createdBy) - else: - automation["_createdByUserName"] = "-" - - mandateId = automation.get("mandateId") - if mandateId: - automation["mandateName"] = mandatesMap.get(mandateId, mandateId) - else: - automation["mandateName"] = "-" - - return automations - - def _enrichAutomationWithUserAndMandate(self, automation: Dict[str, Any]) -> Dict[str, Any]: - """ - Enrich a single automation with user name and mandate name for display. - For multiple automations, use _enrichAutomationsWithUserAndMandate for better performance. - """ - return self._enrichAutomationsWithUserAndMandate([automation])[0] - - def getAllAutomationDefinitions(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]: - """ - Returns automation definitions based on user access level. - Supports optional pagination, sorting, and filtering. - Computes status field for each automation. - """ - # Use RBAC filtering - filteredAutomations = getRecordsetWithRBAC(self.db, - AutomationDefinition, - self.currentUser - ) - - # Compute status for each automation and normalize executionLogs - for automation in filteredAutomations: - automation["status"] = self._computeAutomationStatus(automation) - # Ensure executionLogs is always a list, not None - if automation.get("executionLogs") is None: - automation["executionLogs"] = [] - - # Batch enrich with user and mandate names - self._enrichAutomationsWithUserAndMandate(filteredAutomations) - - # If no pagination requested, return all items - if pagination is None: - return filteredAutomations - - # Apply filtering (if filters provided) - if pagination.filters: - filteredAutomations = self._applyFilters(filteredAutomations, pagination.filters) - - # Apply sorting (in order of sortFields) - if pagination.sort: - filteredAutomations = self._applySorting(filteredAutomations, pagination.sort) - - # Count total items after filters - totalItems = len(filteredAutomations) - totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0 - - # Apply pagination (skip/limit) - startIdx = (pagination.page - 1) * pagination.pageSize - endIdx = startIdx + pagination.pageSize - pagedAutomations = filteredAutomations[startIdx:endIdx] - - return PaginatedResult( - items=pagedAutomations, - totalItems=totalItems, - totalPages=totalPages - ) - - def getAutomationDefinition(self, automationId: str) -> Optional[AutomationDefinition]: - """Returns an automation definition by ID if user has access, with computed status.""" - try: - # Use RBAC filtering - filtered = getRecordsetWithRBAC(self.db, - AutomationDefinition, - self.currentUser, - recordFilter={"id": automationId} - ) - - if not filtered: - return None - - automation = filtered[0] - automation["status"] = self._computeAutomationStatus(automation) - # Ensure executionLogs is always a list, not None - if automation.get("executionLogs") is None: - automation["executionLogs"] = [] - # Enrich with user and mandate names - self._enrichAutomationWithUserAndMandate(automation) - # Clean metadata fields and return Pydantic model - cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")} - return AutomationDefinition(**cleanedRecord) - except Exception as e: - logger.error(f"Error getting automation definition: {str(e)}") - return None - - def createAutomationDefinition(self, automationData: Dict[str, Any]) -> AutomationDefinition: - """Creates a new automation definition, then triggers sync.""" - try: - # Ensure ID is present - if "id" not in automationData or not automationData["id"]: - automationData["id"] = str(uuid.uuid4()) - - # Ensure mandateId and featureInstanceId are set for proper data isolation - if "mandateId" not in automationData: - automationData["mandateId"] = self.mandateId - if "featureInstanceId" not in automationData: - automationData["featureInstanceId"] = self.featureInstanceId - - # Ensure database connector has correct userId context - # The connector should have been initialized with userId, but ensure it's updated - if self.userId and hasattr(self.db, 'updateContext'): - try: - self.db.updateContext(self.userId) - except Exception as e: - logger.warning(f"Could not update database context: {e}") - - # Note: _createdBy will be set automatically by connector's _saveRecord method - # when _createdAt is not present. We don't need to set it manually here. - # Use generic field separation - simpleFields, objectFields = self._separateObjectFields(AutomationDefinition, automationData) - - # Create automation in database - createdAutomation = self.db.recordCreate(AutomationDefinition, simpleFields) - - # Compute status - createdAutomation["status"] = self._computeAutomationStatus(createdAutomation) - # Ensure executionLogs is always a list, not None - if createdAutomation.get("executionLogs") is None: - createdAutomation["executionLogs"] = [] - - # Trigger automation change callback (async, don't wait) - asyncio.create_task(self._notifyAutomationChanged()) - - # Clean metadata fields and return Pydantic model - cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")} - return AutomationDefinition(**cleanedRecord) - except Exception as e: - logger.error(f"Error creating automation definition: {str(e)}") - raise - - def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> AutomationDefinition: - """Updates an automation definition, then triggers sync.""" - try: - # Check access - existing = self.getAutomationDefinition(automationId) - if not existing: - raise PermissionError(f"No access to automation {automationId}") - - if not self.checkRbacPermission(AutomationDefinition, "update", automationId): - raise PermissionError(f"No permission to modify automation {automationId}") - - # Use generic field separation - simpleFields, objectFields = self._separateObjectFields(AutomationDefinition, automationData) - - # Update automation in database - updatedAutomation = self.db.recordModify(AutomationDefinition, automationId, simpleFields) - - # Compute status - updatedAutomation["status"] = self._computeAutomationStatus(updatedAutomation) - # Ensure executionLogs is always a list, not None - if updatedAutomation.get("executionLogs") is None: - updatedAutomation["executionLogs"] = [] - - # Trigger automation change callback (async, don't wait) - asyncio.create_task(self._notifyAutomationChanged()) - - # Clean metadata fields and return Pydantic model - cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")} - return AutomationDefinition(**cleanedRecord) - except Exception as e: - logger.error(f"Error updating automation definition: {str(e)}") - raise - - def deleteAutomationDefinition(self, automationId: str) -> bool: - """Deletes an automation definition, then triggers sync.""" - try: - # Check access - existing = self.getAutomationDefinition(automationId) - if not existing: - raise PermissionError(f"No access to automation {automationId}") - - if not self.checkRbacPermission(AutomationDefinition, "delete", automationId): - raise PermissionError(f"No permission to delete automation {automationId}") - - # Remove event if exists - if existing.get("eventId"): - from modules.shared.eventManagement import eventManager - try: - eventManager.remove(existing["eventId"]) - except Exception as e: - logger.warning(f"Error removing event {existing['eventId']}: {str(e)}") - - # Delete automation from database - self.db.recordDelete(AutomationDefinition, automationId) - - # Trigger automation change callback (async, don't wait) - asyncio.create_task(self._notifyAutomationChanged()) - - return True - except Exception as e: - logger.error(f"Error deleting automation definition: {str(e)}") - raise - - def getAllAutomationDefinitionsWithRBAC(self, user: User) -> List[Dict[str, Any]]: - """ - Get all automation definitions filtered by RBAC for a specific user. - This method encapsulates getRecordsetWithRBAC() to avoid exposing the connector. - - Args: - user: User object for RBAC filtering - - Returns: - List of automation definition dictionaries filtered by RBAC - """ - return getRecordsetWithRBAC( - self.db, - AutomationDefinition, - user - ) - - async def _notifyAutomationChanged(self): - """Notify registered callbacks about automation changes (decoupled from features).""" - try: - from modules.shared.callbackRegistry import callbackRegistry - # Trigger callbacks without knowing which features are listening - await callbackRegistry.trigger('automation.changed', self) - except Exception as e: - logger.error(f"Error notifying automation change: {str(e)}") - def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ChatObjects': """ diff --git a/modules/features/neutralization/interfaceFeatureNeutralizer.py b/modules/features/neutralization/interfaceFeatureNeutralizer.py index 54533166..98b85fdb 100644 --- a/modules/features/neutralization/interfaceFeatureNeutralizer.py +++ b/modules/features/neutralization/interfaceFeatureNeutralizer.py @@ -53,7 +53,7 @@ class InterfaceFeatureNeutralizer: try: # Use same database config pattern as other feature interfaces 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") dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) diff --git a/modules/features/trustee/datamodelFeatureTrustee.py b/modules/features/trustee/datamodelFeatureTrustee.py index d729c1e5..1402f91e 100644 --- a/modules/features/trustee/datamodelFeatureTrustee.py +++ b/modules/features/trustee/datamodelFeatureTrustee.py @@ -528,7 +528,9 @@ class TrusteePosition(BaseModel): "frontend_hidden": True } ) - # System attributes are automatically set by DatabaseConnector + + # Allow extra fields like _createdAt from database + model_config = {"extra": "allow"} registerModelLabels( diff --git a/modules/features/trustee/interfaceFeatureTrustee.py b/modules/features/trustee/interfaceFeatureTrustee.py index b5e08e2b..bb6695d3 100644 --- a/modules/features/trustee/interfaceFeatureTrustee.py +++ b/modules/features/trustee/interfaceFeatureTrustee.py @@ -459,7 +459,8 @@ class TrusteeObjects: recordFilter=None, orderBy="id", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) logger.debug(f"getAllOrganisations: getRecordsetWithRBAC returned {len(records)} records") @@ -552,7 +553,8 @@ class TrusteeObjects: recordFilter=None, orderBy="id", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) # Users with ALL access level (from system RBAC) see all roles @@ -662,7 +664,8 @@ class TrusteeObjects: recordFilter=None, orderBy="id", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) # Users with ALL access level (from system RBAC) see all records @@ -721,7 +724,8 @@ class TrusteeObjects: recordFilter={"organisationId": organisationId}, orderBy="id", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records] @@ -738,7 +742,8 @@ class TrusteeObjects: recordFilter={"userId": userId}, orderBy="id", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) # Users with ALL access level (from system RBAC) see all records @@ -855,7 +860,8 @@ class TrusteeObjects: recordFilter=None, orderBy="id", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) totalItems = len(records) @@ -888,7 +894,8 @@ class TrusteeObjects: recordFilter={"organisationId": organisationId}, orderBy="label", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) return [TrusteeContract(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records] @@ -1007,7 +1014,8 @@ class TrusteeObjects: recordFilter=None, orderBy="documentName", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) # Clean records (remove binary data and internal fields) - keep as dicts for filtering/sorting @@ -1056,7 +1064,8 @@ class TrusteeObjects: recordFilter={"contractId": contractId}, orderBy="documentName", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) result = [] @@ -1165,13 +1174,16 @@ class TrusteeObjects: recordFilter=None, orderBy="valuta", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) - # Clean records (remove internal fields) - keep as dicts for filtering/sorting + # Clean records (remove internal fields except _createdAt) - keep as dicts for filtering/sorting + # Keep _createdAt for display in frontend + keepFields = {'_createdAt'} cleanedRecords = [] 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) # Step 2: Apply filters (search and field filters) @@ -1214,7 +1226,8 @@ class TrusteeObjects: recordFilter={"contractId": contractId}, orderBy="valuta", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records] @@ -1228,7 +1241,8 @@ class TrusteeObjects: recordFilter={"organisationId": organisationId}, orderBy="valuta", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records] @@ -1354,7 +1368,8 @@ class TrusteeObjects: orderBy="id", mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, - enrichPermissions=True + enrichPermissions=True, + featureCode=self.FEATURE_CODE ) totalItems = len(records) @@ -1387,7 +1402,8 @@ class TrusteeObjects: recordFilter={"positionId": positionId}, orderBy="id", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in links] @@ -1401,7 +1417,8 @@ class TrusteeObjects: recordFilter={"documentId": documentId}, orderBy="id", mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId + featureInstanceId=self.featureInstanceId, + featureCode=self.FEATURE_CODE ) return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in links] diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index c73c4dd1..3836d674 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -70,6 +70,86 @@ def initBootstrap(db: DatabaseConnector) -> None: # Apply multi-tenant database optimizations (indexes, triggers, FKs) _applyDatabaseOptimizations(db) + # Seed automation templates (after admin user exists) + initAutomationTemplates(db, adminUserId) + + +def initAutomationTemplates(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") @@ -677,6 +757,41 @@ def _createTableSpecificRules(db: DatabaseConnector) -> 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 for rule in tableRules: db.recordCreate(AccessRule, rule) @@ -840,13 +955,20 @@ def _ensureDataContextRules(db: DatabaseConnector) -> None: # Define tables that need rules (user-owned, no mandate context) # Users can only manage their own records (MY-level access) - tablesNeedingRules = [ + tablesNeedingMyRules = [ "data.chat.ChatWorkflow", "data.automation.AutomationDefinition", ] + # Tables where admin sees ALL (system-wide templates) + tablesNeedingAllRulesForAdmin = [ + "data.automation.AutomationTemplate", + ] + 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) if adminId and (adminId, objectKey) not in existingCombinations: missingRules.append(AccessRule( @@ -886,6 +1008,47 @@ def _ensureDataContextRules(db: DatabaseConnector) -> 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 if missingRules: for rule in missingRules: @@ -893,6 +1056,44 @@ def _ensureDataContextRules(db: DatabaseConnector) -> None: logger.info(f"Created {len(missingRules)} missing DATA context rules") else: 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: diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py index 4e8736c8..0aec7fe0 100644 --- a/modules/interfaces/interfaceDbChat.py +++ b/modules/interfaces/interfaceDbChat.py @@ -25,7 +25,6 @@ from modules.datamodels.datamodelChat import ( WorkflowModeEnum, UserInputRequest ) -from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition import json from modules.datamodels.datamodelUam import User @@ -1654,311 +1653,6 @@ class ChatObjects: return {"items": items} - # ===== Automation Methods ===== - - def _computeAutomationStatus(self, automation: Dict[str, Any]) -> str: - """Compute status field based on eventId presence""" - eventId = automation.get("eventId") - return "Running" if eventId else "Idle" - - def _enrichAutomationsWithUserAndMandate(self, automations: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """ - Batch enrich automations with user names and mandate names for display. - Uses AppObjects interface to fetch users and mandates with proper access control. - """ - if not automations: - return automations - - from modules.interfaces.interfaceDbApp import getInterface as getAppInterface - - # Collect all unique user IDs and mandate IDs - userIds = set() - mandateIds = set() - - for automation in automations: - createdBy = automation.get("_createdBy") - if createdBy: - userIds.add(createdBy) - - mandateId = automation.get("mandateId") - if mandateId: - mandateIds.add(mandateId) - - # Use AppObjects interface to fetch users (respects access control) - appInterface = getAppInterface(self.currentUser) - usersMap = {} - if userIds: - for user_id in userIds: - user = appInterface.getUser(user_id) - if user: - usersMap[user_id] = user.username or user.email or user_id - - # Use AppObjects interface to fetch mandates (respects access control) - mandatesMap = {} - if mandateIds: - for mandate_id in mandateIds: - mandate = appInterface.getMandate(mandate_id) - if mandate: - mandatesMap[mandate_id] = mandate.name or mandate_id - - # Enrich each automation with the fetched data - for automation in automations: - createdBy = automation.get("_createdBy") - if createdBy: - automation["_createdByUserName"] = usersMap.get(createdBy, createdBy) - else: - automation["_createdByUserName"] = "-" - - mandateId = automation.get("mandateId") - if mandateId: - automation["mandateName"] = mandatesMap.get(mandateId, mandateId) - else: - automation["mandateName"] = "-" - - return automations - - def _enrichAutomationWithUserAndMandate(self, automation: Dict[str, Any]) -> Dict[str, Any]: - """ - Enrich a single automation with user name and mandate name for display. - For multiple automations, use _enrichAutomationsWithUserAndMandate for better performance. - """ - return self._enrichAutomationsWithUserAndMandate([automation])[0] - - def getAllAutomationDefinitions(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]: - """ - Returns automation definitions based on user access level. - Supports optional pagination, sorting, and filtering. - Computes status field for each automation. - """ - # Use RBAC filtering - filteredAutomations = getRecordsetWithRBAC(self.db, - AutomationDefinition, - self.currentUser - ) - - # Compute status for each automation and normalize executionLogs - for automation in filteredAutomations: - automation["status"] = self._computeAutomationStatus(automation) - # Ensure executionLogs is always a list, not None - if automation.get("executionLogs") is None: - automation["executionLogs"] = [] - - # Batch enrich with user and mandate names - self._enrichAutomationsWithUserAndMandate(filteredAutomations) - - # If no pagination requested, return all items - if pagination is None: - return filteredAutomations - - # Apply filtering (if filters provided) - if pagination.filters: - filteredAutomations = self._applyFilters(filteredAutomations, pagination.filters) - - # Apply sorting (in order of sortFields) - if pagination.sort: - filteredAutomations = self._applySorting(filteredAutomations, pagination.sort) - - # Count total items after filters - totalItems = len(filteredAutomations) - totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0 - - # Apply pagination (skip/limit) - startIdx = (pagination.page - 1) * pagination.pageSize - endIdx = startIdx + pagination.pageSize - pagedAutomations = filteredAutomations[startIdx:endIdx] - - return PaginatedResult( - items=pagedAutomations, - totalItems=totalItems, - totalPages=totalPages - ) - - def getAutomationDefinition(self, automationId: str, includeSystemFields: bool = False) -> Optional[AutomationDefinition]: - """Returns an automation definition by ID if user has access, with computed status. - - Args: - automationId: ID of the automation to get - includeSystemFields: If True, returns raw dict with system fields (_createdBy, etc). - If False (default), returns Pydantic model without system fields. - """ - try: - # Use RBAC filtering - filtered = getRecordsetWithRBAC(self.db, - AutomationDefinition, - self.currentUser, - recordFilter={"id": automationId} - ) - - if not filtered: - return None - - automation = filtered[0] - automation["status"] = self._computeAutomationStatus(automation) - # Ensure executionLogs is always a list, not None - if automation.get("executionLogs") is None: - automation["executionLogs"] = [] - # Enrich with user and mandate names - self._enrichAutomationWithUserAndMandate(automation) - - # For internal use (execution), return raw dict with system fields - if includeSystemFields: - # Return as simple namespace object so getattr works - class AutomationWithSystemFields: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - return AutomationWithSystemFields(automation) - - # Clean metadata fields and return Pydantic model - cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")} - return AutomationDefinition(**cleanedRecord) - except Exception as e: - logger.error(f"Error getting automation definition: {str(e)}") - return None - - def createAutomationDefinition(self, automationData: Dict[str, Any]) -> AutomationDefinition: - """Creates a new automation definition, then triggers sync.""" - try: - # Ensure ID is present - if "id" not in automationData or not automationData["id"]: - automationData["id"] = str(uuid.uuid4()) - - # Ensure mandateId and featureInstanceId are set for proper data isolation - if "mandateId" not in automationData or not automationData.get("mandateId"): - # Use request context mandateId, or fall back to Root mandate - effectiveMandateId = self.mandateId - if not effectiveMandateId: - # Fall back to Root mandate (first mandate in system) - try: - from modules.datamodels.datamodelUam import Mandate - from modules.security.rootAccess import getRootDbAppConnector - dbAppConn = getRootDbAppConnector() - allMandates = dbAppConn.getRecordset(Mandate) - if allMandates: - effectiveMandateId = allMandates[0].get("id") - logger.debug(f"createAutomationDefinition: Using Root mandate {effectiveMandateId}") - except Exception as e: - logger.warning(f"Could not get Root mandate: {e}") - automationData["mandateId"] = effectiveMandateId - if "featureInstanceId" not in automationData: - automationData["featureInstanceId"] = self.featureInstanceId - - # Ensure database connector has correct userId context - # The connector should have been initialized with userId, but ensure it's updated - if not self.userId: - logger.error(f"createAutomationDefinition: userId is not set! Cannot set _createdBy. currentUser={self.currentUser}") - elif hasattr(self.db, 'updateContext'): - try: - self.db.updateContext(self.userId) - logger.debug(f"createAutomationDefinition: Updated database context with userId={self.userId}") - except Exception as e: - logger.warning(f"Could not update database context: {e}") - - # Note: _createdBy will be set automatically by connector's _saveRecord method - # when _createdAt is not present. We don't need to set it manually here. - # Use generic field separation - simpleFields, objectFields = self._separateObjectFields(AutomationDefinition, automationData) - - # Create automation in database - createdAutomation = self.db.recordCreate(AutomationDefinition, simpleFields) - - # Compute status - createdAutomation["status"] = self._computeAutomationStatus(createdAutomation) - # Ensure executionLogs is always a list, not None - if createdAutomation.get("executionLogs") is None: - createdAutomation["executionLogs"] = [] - - # Trigger automation change callback (async, don't wait) - asyncio.create_task(self._notifyAutomationChanged()) - - # Clean metadata fields and return Pydantic model - cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")} - return AutomationDefinition(**cleanedRecord) - except Exception as e: - logger.error(f"Error creating automation definition: {str(e)}") - raise - - def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> AutomationDefinition: - """Updates an automation definition, then triggers sync.""" - try: - # Check access - existing = self.getAutomationDefinition(automationId) - if not existing: - raise PermissionError(f"No access to automation {automationId}") - - if not self.checkRbacPermission(AutomationDefinition, "update", automationId): - raise PermissionError(f"No permission to modify automation {automationId}") - - # Use generic field separation - simpleFields, objectFields = self._separateObjectFields(AutomationDefinition, automationData) - - # Update automation in database - updatedAutomation = self.db.recordModify(AutomationDefinition, automationId, simpleFields) - - # Compute status - updatedAutomation["status"] = self._computeAutomationStatus(updatedAutomation) - # Ensure executionLogs is always a list, not None - if updatedAutomation.get("executionLogs") is None: - updatedAutomation["executionLogs"] = [] - - # Trigger automation change callback (async, don't wait) - asyncio.create_task(self._notifyAutomationChanged()) - - # Clean metadata fields and return Pydantic model - cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")} - return AutomationDefinition(**cleanedRecord) - except Exception as e: - logger.error(f"Error updating automation definition: {str(e)}") - raise - - def deleteAutomationDefinition(self, automationId: str) -> bool: - """Deletes an automation definition, then triggers sync.""" - try: - # Check access - existing = self.getAutomationDefinition(automationId) - if not existing: - raise PermissionError(f"No access to automation {automationId}") - - if not self.checkRbacPermission(AutomationDefinition, "delete", automationId): - raise PermissionError(f"No permission to delete automation {automationId}") - - # Delete automation from database - self.db.recordDelete(AutomationDefinition, automationId) - - # Trigger automation change callback (async, don't wait) - asyncio.create_task(self._notifyAutomationChanged()) - - return True - except Exception as e: - logger.error(f"Error deleting automation definition: {str(e)}") - raise - - def getAllAutomationDefinitionsWithRBAC(self, user: User) -> List[Dict[str, Any]]: - """ - Get all automation definitions filtered by RBAC for a specific user. - This method encapsulates getRecordsetWithRBAC() to avoid exposing the connector. - - Args: - user: User object for RBAC filtering - - Returns: - List of automation definition dictionaries filtered by RBAC - """ - return getRecordsetWithRBAC( - self.db, - AutomationDefinition, - user - ) - - async def _notifyAutomationChanged(self): - """Notify registered callbacks about automation changes (decoupled from features).""" - try: - from modules.shared.callbackRegistry import callbackRegistry - # Trigger callbacks without knowing which features are listening - await callbackRegistry.trigger('automation.changed', self) - except Exception as e: - logger.error(f"Error notifying automation change: {str(e)}") - def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ChatObjects': """ diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py index 3e062048..21fd6fa2 100644 --- a/modules/interfaces/interfaceRbac.py +++ b/modules/interfaces/interfaceRbac.py @@ -66,6 +66,7 @@ TABLE_NAMESPACE = { "FileData": "files", # Automation - benutzer-eigen "AutomationDefinition": "automation", + "AutomationTemplate": "automation", } # Namespaces ohne Mandantenkontext - GROUP wird auf MY gemappt @@ -145,17 +146,8 @@ def getRecordsetWithRBAC( if not connector._ensureTableExists(modelClass): return [] - # SysAdmin bypass: SysAdmin users have full access to all tables - isSysAdmin = getattr(currentUser, 'isSysAdmin', False) - if isSysAdmin: - # Direct access without RBAC filtering - # Note: getRecordset doesn't support orderBy/limit - these are only used in RBAC path - records = connector.getRecordset(modelClass, recordFilter=recordFilter) - if enrichPermissions: - # SysAdmin has full permissions on all records - for record in records: - record["_permissions"] = {"canUpdate": True, "canDelete": True} - return records + # All users (including SysAdmins) go through RBAC filtering + # SysAdmin flag does NOT grant automatic data access - proper RBAC rules must exist # Get RBAC permissions for this table using full objectKey # AccessRule table is always in DbApp database @@ -184,7 +176,8 @@ def getRecordsetWithRBAC( currentUser, table, connector, - mandateId=effectiveMandateId + mandateId=effectiveMandateId, + featureInstanceId=featureInstanceId ) if rbacWhereClause: whereConditions.append(rbacWhereClause["condition"]) @@ -281,13 +274,15 @@ def buildRbacWhereClause( currentUser: User, table: str, connector, # DatabaseConnector instance for connection access - mandateId: Optional[str] = None + mandateId: Optional[str] = None, + featureInstanceId: Optional[str] = None ) -> Optional[Dict[str, Any]]: """ Build RBAC WHERE clause based on permissions and access level. Multi-Tenant Design: - mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header) + - featureInstanceId wird für Feature-Tabellen zusätzlich gefiltert Args: permissions: UserPermissions object @@ -295,6 +290,7 @@ def buildRbacWhereClause( table: Table name connector: DatabaseConnector instance (needed for GROUP queries) mandateId: Explicit mandate context (from request header). Required for GROUP access. + featureInstanceId: Feature instance context for feature-level data isolation. Returns: Dictionary with "condition" and "values" keys, or None if no filtering needed @@ -308,8 +304,20 @@ def buildRbacWhereClause( if readLevel == AccessLevel.NONE: return {"condition": "1 = 0", "values": []} - # All records - no filtering needed + # CRITICAL: featureInstanceId filter is ALWAYS required when provided + # This ensures data isolation between feature instances regardless of access level + baseConditions = [] + baseValues = [] + + if featureInstanceId: + # Strict filter: only records for this exact feature instance + baseConditions.append('"featureInstanceId" = %s') + baseValues.append(featureInstanceId) + + # All records within the feature instance - only featureInstanceId filtering if readLevel == AccessLevel.ALL: + if baseConditions: + return {"condition": " AND ".join(baseConditions), "values": baseValues} return None # My records - filter by _createdBy or userId field @@ -323,9 +331,14 @@ def buildRbacWhereClause( else: userIdField = "_createdBy" + conditions = list(baseConditions) + values = list(baseValues) + conditions.append(f'"{userIdField}" = %s') + values.append(currentUser.id) + return { - "condition": f'"{userIdField}" = %s', - "values": [currentUser.id] + "condition": " AND ".join(conditions), + "values": values } # Group records - filter by mandateId or ownership based on namespace @@ -335,8 +348,10 @@ def buildRbacWhereClause( # For user-owned namespaces (chat, files, automation): # GROUP has no meaning - these tables have no mandate context - # Simply ignore GROUP (no filtering) + # But still apply featureInstanceId filter if provided if namespace in USER_OWNED_NAMESPACES: + if baseConditions: + return {"condition": " AND ".join(baseConditions), "values": baseValues} return None # For UAM and other namespaces: GROUP filters by mandate @@ -373,9 +388,14 @@ def buildRbacWhereClause( if not userIds: return {"condition": "1 = 0", "values": []} placeholders = ",".join(["%s"] * len(userIds)) + # Combine with base conditions (featureInstanceId) + conditions = list(baseConditions) + values = list(baseValues) + conditions.append(f'"id" IN ({placeholders})') + values.extend(userIds) return { - "condition": f'"id" IN ({placeholders})', - "values": userIds + "condition": " AND ".join(conditions) if conditions else f'"id" IN ({placeholders})', + "values": values } except Exception as e: logger.error(f"Error building GROUP filter for UserInDB via UserMandate: {e}") @@ -395,28 +415,45 @@ def buildRbacWhereClause( if not userIds: return {"condition": "1 = 0", "values": []} placeholders = ",".join(["%s"] * len(userIds)) + # Combine with base conditions (featureInstanceId) + conditions = list(baseConditions) + values = list(baseValues) + conditions.append(f'"userId" IN ({placeholders})') + values.extend(userIds) return { - "condition": f'"userId" IN ({placeholders})', - "values": userIds + "condition": " AND ".join(conditions) if conditions else f'"userId" IN ({placeholders})', + "values": values } except Exception as e: logger.error(f"Error building GROUP filter for UserConnection: {e}") return {"condition": "1 = 0", "values": []} # For system tables without mandateId column (Mandate, Role, etc.): - # No row-level filtering - GROUP access = ALL access for these + # No row-level filtering based on mandate, but still apply featureInstanceId if provided elif table in ("Mandate", "Role"): + if baseConditions: + return {"condition": " AND ".join(baseConditions), "values": baseValues} return None # For other tables, filter by mandateId field # Also include records with NULL mandateId for backwards compatibility else: + # Start with base conditions (includes strict featureInstanceId filter) + conditions = list(baseConditions) + values = list(baseValues) + + # Add mandate filter + conditions.append('("mandateId" = %s OR "mandateId" IS NULL)') + values.append(effectiveMandateId) + return { - "condition": '("mandateId" = %s OR "mandateId" IS NULL)', - "values": [effectiveMandateId] + "condition": " AND ".join(conditions), + "values": values } - return None + # Unknown access level - deny access (security: deny by default) + logger.warning(f"Unknown access level '{readLevel}' for user {currentUser.id} - denying access") + return {"condition": "1 = 0", "values": []} def _enrichRecordsWithPermissions( diff --git a/modules/routes/routeAdminAutomationEvents.py b/modules/routes/routeAdminAutomationEvents.py index e829f986..e8bb9291 100644 --- a/modules/routes/routeAdminAutomationEvents.py +++ b/modules/routes/routeAdminAutomationEvents.py @@ -11,7 +11,7 @@ from fastapi import status import logging # Import interfaces and models from feature containers -import modules.interfaces.interfaceDbChat as interfaceDbChat +import modules.features.automation.interfaceFeatureAutomation as interfaceAutomation from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext from modules.datamodels.datamodelUam import User @@ -79,7 +79,6 @@ async def sync_all_automation_events( from modules.interfaces.interfaceDbApp import getRootInterface from modules.workflows.automation import syncAutomationEvents - chatInterface = getChatInterface(currentUser) # Get event user for sync operation (routes can import from interfaces) rootInterface = getRootInterface() eventUser = rootInterface.getUserByUsername("event") @@ -126,10 +125,10 @@ async def remove_event( # Update automation's eventId if it exists if eventId.startswith("automation."): automation_id = eventId.replace("automation.", "") - chatInterface = interfaceDbChat.getInterface(currentUser) - automation = chatInterface.getAutomationDefinition(automation_id) + automationInterface = interfaceAutomation.getInterface(currentUser) + automation = automationInterface.getAutomationDefinition(automation_id) if automation and getattr(automation, "eventId", None) == eventId: - chatInterface.updateAutomationDefinition(automation_id, {"eventId": None}) + automationInterface.updateAutomationDefinition(automation_id, {"eventId": None}) return { "success": True, diff --git a/modules/routes/routeSharepoint.py b/modules/routes/routeSharepoint.py index 32c72597..2719562a 100644 --- a/modules/routes/routeSharepoint.py +++ b/modules/routes/routeSharepoint.py @@ -39,6 +39,44 @@ def _getUserConnection(interface, connectionId: str, userId: str) -> Optional[Us logger.error(f"Error getting user connection: {str(e)}") 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]]) @limiter.limit("30/minute") async def get_sharepoint_sites( @@ -251,3 +289,110 @@ def _extractSitePath(webUrl: str) -> str: return "/sites/" + webUrl.split("/sites/")[1].split("/")[0] 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)}" + ) + diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py index b4a40fc2..4e2f9f8f 100644 --- a/modules/routes/routeSystem.py +++ b/modules/routes/routeSystem.py @@ -106,7 +106,7 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]: from modules.features.trustee.mainTrustee import UI_OBJECTS return UI_OBJECTS elif featureCode == "realestate": - from modules.features.realEstate.mainRealEstate import UI_OBJECTS + from modules.features.realestate.mainRealEstate import UI_OBJECTS return UI_OBJECTS else: logger.warning(f"Unknown feature code: {featureCode}") diff --git a/modules/shared/frontendTypes.py b/modules/shared/frontendTypes.py index 06a81570..9b6d04e7 100644 --- a/modules/shared/frontendTypes.py +++ b/modules/shared/frontendTypes.py @@ -54,9 +54,11 @@ class FrontendType(str, Enum): WORKFLOW_ACTION = "workflowAction" """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 # Examples: - # SHAREPOINT_FOLDER = "sharepointFolder" # OUTLOOK_FOLDER = "outlookFolder" # JIRA_PROJECT = "jiraProject" @@ -66,6 +68,7 @@ CUSTOM_TYPE_OPTIONS_API: Dict[FrontendType, str] = { FrontendType.USER_CONNECTION: "user.connection", FrontendType.DOCUMENT_REFERENCE: "workflow.documentReference", # 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 @@ -85,6 +88,11 @@ CUSTOM_TYPE_DESCRIPTIONS: Dict[FrontendType, Dict[str, str]] = { "fr": "Action de workflow", "de": "Workflow-Aktion" }, + FrontendType.SHAREPOINT_FOLDER: { + "en": "SharePoint Folder", + "fr": "Dossier SharePoint", + "de": "SharePoint-Ordner" + }, } diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py index 82424daf..113fa903 100644 --- a/modules/system/mainSystem.py +++ b/modules/system/mainSystem.py @@ -92,6 +92,15 @@ NAVIGATION_SECTIONS = [ "order": 30, "public": True, }, + { + "id": "automation-templates", + "objectKey": "ui.system.automation-templates", + "label": {"en": "Templates", "de": "Vorlagen", "fr": "Modèles"}, + "icon": "FaFileAlt", + "path": "/workflows/automation-templates", + "order": 35, + "public": True, + }, ], }, { diff --git a/modules/system/registry.py b/modules/system/registry.py index 8477b045..f7e50524 100644 --- a/modules/system/registry.py +++ b/modules/system/registry.py @@ -40,6 +40,11 @@ def loadFeatureRouters(app: FastAPI) -> Dict[str, Any]: """ Dynamically load and register routers from all discovered feature containers. Also registers feature template roles and AccessRules in the database. + + Searches for: + - 'router' (main feature router) + - 'templateRouter' (additional router for templates) + - Any other *Router named exports """ results = {} pattern = os.path.join(FEATURES_DIR, "*", "routeFeature*.py") @@ -55,12 +60,26 @@ def loadFeatureRouters(app: FastAPI) -> Dict[str, Any]: modulePath = f"modules.features.{featureDir}.{routerFile}" module = importlib.import_module(modulePath) + loadedRouters = [] + + # Load main router if hasattr(module, "router"): app.include_router(module.router) - logger.info(f"Loaded router: {featureDir}") - results[featureDir] = {"status": "loaded", "module": modulePath} + loadedRouters.append("router") + + # Load additional named routers (e.g., templateRouter) + for attrName in dir(module): + if attrName.endswith("Router") and attrName != "APIRouter": + routerObj = getattr(module, attrName) + if hasattr(routerObj, "routes"): # Check if it's a FastAPI router + app.include_router(routerObj) + loadedRouters.append(attrName) + + if loadedRouters: + logger.info(f"Loaded routers from {featureDir}: {loadedRouters}") + results[featureDir] = {"status": "loaded", "module": modulePath, "routers": loadedRouters} else: - logger.warning(f"No 'router' in {modulePath}") + logger.warning(f"No routers found in {modulePath}") results[featureDir] = {"status": "no_router_object"} except Exception as e: diff --git a/modules/workflows/automation/mainWorkflow.py b/modules/workflows/automation/mainWorkflow.py index 19cd1004..291f3120 100644 --- a/modules/workflows/automation/mainWorkflow.py +++ b/modules/workflows/automation/mainWorkflow.py @@ -78,7 +78,7 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow: try: # 1. Load automation definition (with system fields for _createdBy access) - automation = services.interfaceDbChat.getAutomationDefinition(automationId, includeSystemFields=True) + automation = services.interfaceDbAutomation.getAutomationDefinition(automationId, includeSystemFields=True) if not automation: raise ValueError(f"Automation {automationId} not found") @@ -160,7 +160,7 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow: if len(executionLogs) > 50: executionLogs = executionLogs[-50:] - services.interfaceDbChat.updateAutomationDefinition( + services.interfaceDbAutomation.updateAutomationDefinition( automationId, {"executionLogs": executionLogs} ) @@ -173,13 +173,13 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow: # Update automation with execution log even on error try: - automation = services.interfaceDbChat.getAutomationDefinition(automationId) + automation = services.interfaceDbAutomation.getAutomationDefinition(automationId) if automation: executionLogs = list(automation.executionLogs or []) executionLogs.append(executionLog) if len(executionLogs) > 50: executionLogs = executionLogs[-50:] - services.interfaceDbChat.updateAutomationDefinition( + services.interfaceDbAutomation.updateAutomationDefinition( automationId, {"executionLogs": executionLogs} ) @@ -200,7 +200,7 @@ async def syncAutomationEvents(services, eventUser) -> Dict[str, Any]: Dictionary with sync results (synced count and event IDs) """ # Get all automation definitions filtered by RBAC (for current mandate) - filtered = services.interfaceDbChat.getAllAutomationDefinitionsWithRBAC(eventUser) + filtered = services.interfaceDbAutomation.getAllAutomationDefinitionsWithRBAC(eventUser) registeredEvents = {} @@ -249,7 +249,7 @@ async def syncAutomationEvents(services, eventUser) -> Dict[str, Any]: # Update automation with new eventId if currentEventId != newEventId: - services.interfaceDbChat.updateAutomationDefinition( + services.interfaceDbAutomation.updateAutomationDefinition( automationId, {"eventId": newEventId} ) @@ -260,7 +260,7 @@ async def syncAutomationEvents(services, eventUser) -> Dict[str, Any]: if currentEventId: try: eventManager.remove(currentEventId) - services.interfaceDbChat.updateAutomationDefinition( + services.interfaceDbAutomation.updateAutomationDefinition( automationId, {"eventId": None} ) @@ -295,7 +295,7 @@ def createAutomationEventHandler(automationId: str, eventUser): eventServices = getServices(eventUser, None) # Load automation using event user context (with system fields for _createdBy access) - automation = eventServices.interfaceDbChat.getAutomationDefinition(automationId, includeSystemFields=True) + automation = eventServices.interfaceDbAutomation.getAutomationDefinition(automationId, includeSystemFields=True) if not automation or not getattr(automation, "active", False): logger.warning(f"Automation {automationId} not found or not active, skipping execution") return diff --git a/modules/workflows/methods/methodSharepoint/methodSharepoint.py b/modules/workflows/methods/methodSharepoint/methodSharepoint.py index 5b765fe5..9ca4c3e5 100644 --- a/modules/workflows/methods/methodSharepoint/methodSharepoint.py +++ b/modules/workflows/methods/methodSharepoint/methodSharepoint.py @@ -109,7 +109,7 @@ class MethodSharepoint(MethodBase): "pathQuery": WorkflowActionParameter( name="pathQuery", type="str", - frontendType=FrontendType.TEXT, + frontendType=FrontendType.SHAREPOINT_FOLDER, required=False, description="Direct path query if no documentList (e.g., /sites/SiteName/FolderPath)" ), @@ -146,7 +146,7 @@ class MethodSharepoint(MethodBase): "pathQuery": WorkflowActionParameter( name="pathQuery", type="str", - frontendType=FrontendType.TEXT, + frontendType=FrontendType.SHAREPOINT_FOLDER, required=False, description="Direct upload target path if documentList doesn't contain findDocumentPath result (e.g., /sites/SiteName/FolderPath)" )