Merge pull request #89 from valueonag/feat/automation-template-handling
Feat/automation template handling
This commit is contained in:
commit
c69019c9bb
19 changed files with 1551 additions and 696 deletions
|
|
@ -12,7 +12,7 @@ Multi-Tenant Design:
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pydantic import BaseModel, Field, EmailStr, field_validator
|
from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
|
|
||||||
|
|
@ -114,6 +114,20 @@ class UserConnection(BaseModel):
|
||||||
{"value": "none", "label": {"en": "None", "fr": "Aucun"}},
|
{"value": "none", "label": {"en": "None", "fr": "Aucun"}},
|
||||||
]})
|
]})
|
||||||
tokenExpiresAt: Optional[float] = Field(None, description="When the current token expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
tokenExpiresAt: Optional[float] = Field(None, description="When the current token expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def connectionReference(self) -> str:
|
||||||
|
"""Generate connection reference string in format: connection:{authority}:{username}"""
|
||||||
|
return f"connection:{self.authority.value}:{self.externalUsername}"
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def displayLabel(self) -> str:
|
||||||
|
"""Human-readable label for display in dropdowns"""
|
||||||
|
authorityLabels = {"msft": "Microsoft", "google": "Google", "local": "Local"}
|
||||||
|
return f"{authorityLabels.get(self.authority.value, self.authority.value)}: {self.externalUsername}"
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
|
|
@ -132,6 +146,8 @@ registerModelLabels(
|
||||||
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
|
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
|
||||||
"tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"},
|
"tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"},
|
||||||
"tokenExpiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
|
"tokenExpiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
|
||||||
|
"connectionReference": {"en": "Connection Reference", "de": "Verbindungsreferenz", "fr": "Référence de connexion"},
|
||||||
|
"displayLabel": {"en": "Display Label", "de": "Anzeigebezeichnung", "fr": "Libellé d'affichage"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Automation models: AutomationDefinition."""
|
"""Automation models: AutomationDefinition, AutomationTemplate."""
|
||||||
|
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
|
from modules.datamodels.datamodelUtils import TextMultilingual
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -28,18 +29,53 @@ class AutomationDefinition(BaseModel):
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
"AutomationDefinition",
|
"AutomationDefinition",
|
||||||
{"en": "Automation Definition", "fr": "Définition d'automatisation"},
|
{"en": "Automation Definition", "ge": "Automatisierungs-Definition", "fr": "Définition d'automatisation"},
|
||||||
{
|
{
|
||||||
"id": {"en": "ID", "fr": "ID"},
|
"id": {"en": "ID", "ge": "ID", "fr": "ID"},
|
||||||
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
|
"mandateId": {"en": "Mandate ID", "ge": "Mandanten-ID", "fr": "ID du mandat"},
|
||||||
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
|
"featureInstanceId": {"en": "Feature Instance ID", "ge": "Feature-Instanz-ID", "fr": "ID de l'instance de fonctionnalité"},
|
||||||
"label": {"en": "Label", "fr": "Libellé"},
|
"label": {"en": "Label", "ge": "Bezeichnung", "fr": "Libellé"},
|
||||||
"schedule": {"en": "Schedule", "fr": "Planification"},
|
"schedule": {"en": "Schedule", "ge": "Zeitplan", "fr": "Planification"},
|
||||||
"template": {"en": "Template", "fr": "Modèle"},
|
"template": {"en": "Template", "ge": "Vorlage", "fr": "Modèle"},
|
||||||
"placeholders": {"en": "Placeholders", "fr": "Espaces réservés"},
|
"placeholders": {"en": "Placeholders", "ge": "Platzhalter", "fr": "Espaces réservés"},
|
||||||
"active": {"en": "Active", "fr": "Actif"},
|
"active": {"en": "Active", "ge": "Aktiv", "fr": "Actif"},
|
||||||
"eventId": {"en": "Event ID", "fr": "ID de l'événement"},
|
"eventId": {"en": "Event ID", "ge": "Event-ID", "fr": "ID de l'événement"},
|
||||||
"status": {"en": "Status", "fr": "Statut"},
|
"status": {"en": "Status", "ge": "Status", "fr": "Statut"},
|
||||||
"executionLogs": {"en": "Execution Logs", "fr": "Journaux d'exécution"},
|
"executionLogs": {"en": "Execution Logs", "ge": "Ausführungsprotokolle", "fr": "Journaux d'exécution"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationTemplate(BaseModel):
|
||||||
|
"""Automation-Vorlage ohne scharfe Placeholder-Werte (DB-persistiert)."""
|
||||||
|
id: str = Field(
|
||||||
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
description="Primary key",
|
||||||
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True}
|
||||||
|
)
|
||||||
|
label: TextMultilingual = Field(
|
||||||
|
description="Template name (multilingual)",
|
||||||
|
json_schema_extra={"frontend_type": "multilingual", "frontend_required": True}
|
||||||
|
)
|
||||||
|
overview: Optional[TextMultilingual] = Field(
|
||||||
|
None,
|
||||||
|
description="Short description (multilingual)",
|
||||||
|
json_schema_extra={"frontend_type": "multilingual", "frontend_required": False}
|
||||||
|
)
|
||||||
|
template: str = Field(
|
||||||
|
description="JSON workflow structure with {{KEY:...}} placeholders",
|
||||||
|
json_schema_extra={"frontend_type": "textarea", "frontend_required": True}
|
||||||
|
)
|
||||||
|
# System fields (_createdAt, _createdBy, etc.) werden automatisch vom DB-Connector gesetzt
|
||||||
|
|
||||||
|
|
||||||
|
registerModelLabels(
|
||||||
|
"AutomationTemplate",
|
||||||
|
{"en": "Automation Template", "ge": "Automation-Vorlage", "fr": "Modèle d'automatisation"},
|
||||||
|
{
|
||||||
|
"id": {"en": "ID", "ge": "ID", "fr": "ID"},
|
||||||
|
"label": {"en": "Label", "ge": "Bezeichnung", "fr": "Libellé"},
|
||||||
|
"overview": {"en": "Overview", "ge": "Übersicht", "fr": "Aperçu"},
|
||||||
|
"template": {"en": "Template", "ge": "Vorlage", "fr": "Modèle"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
655
modules/features/automation/interfaceFeatureAutomation.py
Normal file
655
modules/features/automation/interfaceFeatureAutomation.py
Normal file
|
|
@ -0,0 +1,655 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Interface for Automation feature - manages AutomationDefinition and AutomationTemplate.
|
||||||
|
Uses the PostgreSQL connector for data access with user/mandate filtering.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
import math
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, Any, List, Optional, Union
|
||||||
|
|
||||||
|
from modules.security.rbac import RbacClass
|
||||||
|
from modules.datamodels.datamodelRbac import AccessRuleContext
|
||||||
|
from modules.datamodels.datamodelUam import AccessLevel, User
|
||||||
|
from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition, AutomationTemplate
|
||||||
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
|
||||||
|
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC, buildDataObjectKey
|
||||||
|
|
||||||
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Singleton factory for Automation instances
|
||||||
|
_automationInterfaces = {}
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationObjects:
|
||||||
|
"""
|
||||||
|
Interface for Automation database operations.
|
||||||
|
Manages AutomationDefinition and AutomationTemplate with RBAC support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
|
||||||
|
self.currentUser = currentUser
|
||||||
|
self.mandateId = mandateId
|
||||||
|
self.featureInstanceId = featureInstanceId
|
||||||
|
self.userId = currentUser.id if currentUser else None
|
||||||
|
|
||||||
|
# Initialize database with proper configuration
|
||||||
|
self._initializeDatabase()
|
||||||
|
|
||||||
|
# Initialize RBAC (dbApp = self.db since we use poweron_app)
|
||||||
|
self.rbac = RbacClass(self.db, dbApp=self.db)
|
||||||
|
|
||||||
|
# Update database context
|
||||||
|
self.db.updateContext(self.userId)
|
||||||
|
|
||||||
|
def _initializeDatabase(self):
|
||||||
|
"""Initializes the database connection with proper configuration."""
|
||||||
|
# Get configuration values
|
||||||
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||||
|
dbDatabase = "poweron_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]
|
||||||
|
|
@ -13,14 +13,13 @@ import logging
|
||||||
import json
|
import json
|
||||||
|
|
||||||
# Import interfaces and models
|
# Import interfaces and models
|
||||||
from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
|
from modules.features.automation.interfaceFeatureAutomation import getInterface as getAutomationInterface
|
||||||
from modules.auth import limiter, getRequestContext, RequestContext
|
from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition
|
from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition, AutomationTemplate
|
||||||
from modules.datamodels.datamodelChat import ChatWorkflow
|
from modules.datamodels.datamodelChat import ChatWorkflow
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
||||||
from modules.shared.attributeUtils import getModelAttributeDefinitions
|
from modules.shared.attributeUtils import getModelAttributeDefinitions
|
||||||
from modules.workflows.automation import executeAutomation
|
from modules.workflows.automation import executeAutomation
|
||||||
from .subAutomationTemplates import getAutomationTemplates
|
|
||||||
|
|
||||||
# Configure logger
|
# Configure logger
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -69,7 +68,7 @@ async def get_automations(
|
||||||
detail=f"Invalid pagination parameter: {str(e)}"
|
detail=f"Invalid pagination parameter: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
|
chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
|
||||||
result = chatInterface.getAllAutomationDefinitions(pagination=paginationParams)
|
result = chatInterface.getAllAutomationDefinitions(pagination=paginationParams)
|
||||||
|
|
||||||
# If pagination was requested, result is PaginatedResult
|
# If pagination was requested, result is PaginatedResult
|
||||||
|
|
@ -115,7 +114,7 @@ async def create_automation(
|
||||||
) -> AutomationDefinition:
|
) -> AutomationDefinition:
|
||||||
"""Create a new automation definition"""
|
"""Create a new automation definition"""
|
||||||
try:
|
try:
|
||||||
chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
|
chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
|
||||||
automationData = automation.model_dump()
|
automationData = automation.model_dump()
|
||||||
created = chatInterface.createAutomationDefinition(automationData)
|
created = chatInterface.createAutomationDefinition(automationData)
|
||||||
return created
|
return created
|
||||||
|
|
@ -128,26 +127,6 @@ async def create_automation(
|
||||||
detail=f"Error creating automation: {str(e)}"
|
detail=f"Error creating automation: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/templates")
|
|
||||||
@limiter.limit("30/minute")
|
|
||||||
async def get_automation_templates(
|
|
||||||
request: Request,
|
|
||||||
context: RequestContext = Depends(getRequestContext)
|
|
||||||
) -> JSONResponse:
|
|
||||||
"""
|
|
||||||
Get automation templates from backend module.
|
|
||||||
The UI should fetch these templates regularly to get the latest versions.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
templatesData = getAutomationTemplates()
|
|
||||||
return JSONResponse(content=templatesData)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting automation templates: {str(e)}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Error getting automation templates: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.get("/attributes", response_model=Dict[str, Any])
|
@router.get("/attributes", response_model=Dict[str, Any])
|
||||||
async def get_automation_attributes(
|
async def get_automation_attributes(
|
||||||
request: Request
|
request: Request
|
||||||
|
|
@ -155,6 +134,100 @@ async def get_automation_attributes(
|
||||||
"""Get attribute definitions for AutomationDefinition model"""
|
"""Get attribute definitions for AutomationDefinition model"""
|
||||||
return {"attributes": automationAttributes}
|
return {"attributes": automationAttributes}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/actions")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def get_available_actions(
|
||||||
|
request: Request,
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Get available workflow actions for template editor.
|
||||||
|
Returns action definitions with parameters and example JSON snippets.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from modules.workflows.processing.shared.methodDiscovery import methods, discoverMethods
|
||||||
|
from modules.services import getInterface as getServices
|
||||||
|
|
||||||
|
# Ensure methods are discovered (need a service center for discovery)
|
||||||
|
if not methods:
|
||||||
|
# Create a lightweight service center for method discovery
|
||||||
|
services = getServices(context.user, context.mandateId)
|
||||||
|
discoverMethods(services)
|
||||||
|
|
||||||
|
actionsList = []
|
||||||
|
processedMethods = set()
|
||||||
|
|
||||||
|
for methodName, methodInfo in methods.items():
|
||||||
|
# Skip short name aliases - only process full class names (MethodXxx)
|
||||||
|
if not methodName.startswith('Method'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
shortName = methodName.replace('Method', '').lower()
|
||||||
|
|
||||||
|
# Skip if already processed
|
||||||
|
if shortName in processedMethods:
|
||||||
|
continue
|
||||||
|
processedMethods.add(shortName)
|
||||||
|
|
||||||
|
methodInstance = methodInfo.get('instance')
|
||||||
|
if not methodInstance:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get actions from method instance
|
||||||
|
for actionName, actionDef in methodInstance._actions.items():
|
||||||
|
# Build action info
|
||||||
|
actionInfo = {
|
||||||
|
"method": shortName,
|
||||||
|
"action": actionName,
|
||||||
|
"actionId": actionDef.actionId if hasattr(actionDef, 'actionId') else f"{shortName}.{actionName}",
|
||||||
|
"description": actionDef.description if hasattr(actionDef, 'description') else "",
|
||||||
|
"category": actionDef.category if hasattr(actionDef, 'category') else "general",
|
||||||
|
"parameters": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add parameters from WorkflowActionParameter
|
||||||
|
parametersDef = actionDef.parameters if hasattr(actionDef, 'parameters') else {}
|
||||||
|
for paramName, paramDef in parametersDef.items():
|
||||||
|
paramInfo = {
|
||||||
|
"name": paramName,
|
||||||
|
"type": paramDef.type if hasattr(paramDef, 'type') else "Any",
|
||||||
|
"frontendType": paramDef.frontendType.value if hasattr(paramDef, 'frontendType') and paramDef.frontendType else "text",
|
||||||
|
"required": paramDef.required if hasattr(paramDef, 'required') else False,
|
||||||
|
"default": paramDef.default if hasattr(paramDef, 'default') else None,
|
||||||
|
"description": paramDef.description if hasattr(paramDef, 'description') else "",
|
||||||
|
}
|
||||||
|
if hasattr(paramDef, 'frontendOptions') and paramDef.frontendOptions:
|
||||||
|
paramInfo["frontendOptions"] = paramDef.frontendOptions
|
||||||
|
actionInfo["parameters"].append(paramInfo)
|
||||||
|
|
||||||
|
# Build example JSON snippet for copy/paste
|
||||||
|
exampleParams = {}
|
||||||
|
for paramName, paramDef in parametersDef.items():
|
||||||
|
if hasattr(paramDef, 'required') and paramDef.required:
|
||||||
|
exampleParams[paramName] = f"{{{{KEY:{paramName}}}}}"
|
||||||
|
else:
|
||||||
|
default = paramDef.default if hasattr(paramDef, 'default') else None
|
||||||
|
exampleParams[paramName] = default or f"{{{{KEY:{paramName}}}}}"
|
||||||
|
|
||||||
|
actionInfo["exampleJson"] = {
|
||||||
|
"execMethod": shortName,
|
||||||
|
"execAction": actionName,
|
||||||
|
"execParameters": exampleParams,
|
||||||
|
"execResultLabel": f"{shortName}_{actionName}_result"
|
||||||
|
}
|
||||||
|
|
||||||
|
actionsList.append(actionInfo)
|
||||||
|
|
||||||
|
return JSONResponse(content={"actions": actionsList})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting available actions: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error getting available actions: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{automationId}", response_model=AutomationDefinition)
|
@router.get("/{automationId}", response_model=AutomationDefinition)
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def get_automation(
|
async def get_automation(
|
||||||
|
|
@ -164,7 +237,7 @@ async def get_automation(
|
||||||
) -> AutomationDefinition:
|
) -> AutomationDefinition:
|
||||||
"""Get a single automation definition by ID"""
|
"""Get a single automation definition by ID"""
|
||||||
try:
|
try:
|
||||||
chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
|
chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
|
||||||
automation = chatInterface.getAutomationDefinition(automationId)
|
automation = chatInterface.getAutomationDefinition(automationId)
|
||||||
if not automation:
|
if not automation:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -192,7 +265,7 @@ async def update_automation(
|
||||||
) -> AutomationDefinition:
|
) -> AutomationDefinition:
|
||||||
"""Update an automation definition"""
|
"""Update an automation definition"""
|
||||||
try:
|
try:
|
||||||
chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
|
chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
|
||||||
automationData = automation.model_dump()
|
automationData = automation.model_dump()
|
||||||
updated = chatInterface.updateAutomationDefinition(automationId, automationData)
|
updated = chatInterface.updateAutomationDefinition(automationId, automationData)
|
||||||
return updated
|
return updated
|
||||||
|
|
@ -220,7 +293,7 @@ async def update_automation_status(
|
||||||
) -> AutomationDefinition:
|
) -> AutomationDefinition:
|
||||||
"""Update only the active status of an automation definition"""
|
"""Update only the active status of an automation definition"""
|
||||||
try:
|
try:
|
||||||
chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
|
chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
|
||||||
|
|
||||||
# Get existing automation
|
# Get existing automation
|
||||||
automation = chatInterface.getAutomationDefinition(automationId)
|
automation = chatInterface.getAutomationDefinition(automationId)
|
||||||
|
|
@ -260,7 +333,7 @@ async def delete_automation(
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Delete an automation definition"""
|
"""Delete an automation definition"""
|
||||||
try:
|
try:
|
||||||
chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
|
chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
|
||||||
success = chatInterface.deleteAutomationDefinition(automationId)
|
success = chatInterface.deleteAutomationDefinition(automationId)
|
||||||
if success:
|
if success:
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
@ -311,3 +384,228 @@ async def execute_automation_route(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# AutomationTemplate Routes (DB-persistiert)
|
||||||
|
# =============================================================================
|
||||||
|
# Separater Router für /api/automation-templates
|
||||||
|
|
||||||
|
templateRouter = APIRouter(
|
||||||
|
prefix="/api/automation-templates",
|
||||||
|
tags=["Manage Automation Templates"],
|
||||||
|
responses={
|
||||||
|
404: {"description": "Not found"},
|
||||||
|
400: {"description": "Bad request"},
|
||||||
|
401: {"description": "Unauthorized"},
|
||||||
|
403: {"description": "Forbidden"},
|
||||||
|
500: {"description": "Internal server error"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Model attributes for AutomationTemplate
|
||||||
|
templateAttributes = getModelAttributeDefinitions(AutomationTemplate)
|
||||||
|
|
||||||
|
|
||||||
|
@templateRouter.get("", response_model=PaginatedResponse[AutomationTemplate])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def get_db_templates(
|
||||||
|
request: Request,
|
||||||
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Get automation templates from database (RBAC-filtered, MY = own templates).
|
||||||
|
|
||||||
|
Query Parameters:
|
||||||
|
- pagination: JSON-encoded PaginationParams object, or None for no pagination
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Parse pagination parameter
|
||||||
|
paginationParams = None
|
||||||
|
if pagination:
|
||||||
|
try:
|
||||||
|
paginationDict = json.loads(pagination)
|
||||||
|
if paginationDict:
|
||||||
|
paginationDict = normalize_pagination_dict(paginationDict)
|
||||||
|
paginationParams = PaginationParams(**paginationDict)
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid pagination parameter: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
chatInterface = getAutomationInterface(
|
||||||
|
context.user,
|
||||||
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||||
|
)
|
||||||
|
result = chatInterface.getAllAutomationTemplates(pagination=paginationParams)
|
||||||
|
|
||||||
|
if paginationParams:
|
||||||
|
response_data = {
|
||||||
|
"items": result.items,
|
||||||
|
"pagination": {
|
||||||
|
"currentPage": paginationParams.page,
|
||||||
|
"pageSize": paginationParams.pageSize,
|
||||||
|
"totalItems": result.totalItems,
|
||||||
|
"totalPages": result.totalPages,
|
||||||
|
"sort": paginationParams.sort,
|
||||||
|
"filters": paginationParams.filters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
response_data = {
|
||||||
|
"items": result,
|
||||||
|
"pagination": None
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSONResponse(content=response_data)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting templates: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error getting templates: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@templateRouter.get("/attributes", response_model=Dict[str, Any])
|
||||||
|
async def get_template_attributes(
|
||||||
|
request: Request
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Get attribute definitions for AutomationTemplate model"""
|
||||||
|
return {"attributes": templateAttributes}
|
||||||
|
|
||||||
|
|
||||||
|
@templateRouter.get("/{templateId}")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def get_db_template(
|
||||||
|
request: Request,
|
||||||
|
templateId: str = Path(..., description="Template ID"),
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Get a single automation template by ID"""
|
||||||
|
try:
|
||||||
|
chatInterface = getAutomationInterface(
|
||||||
|
context.user,
|
||||||
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||||
|
)
|
||||||
|
template = chatInterface.getAutomationTemplate(templateId)
|
||||||
|
if not template:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Template {templateId} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(content=template)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting template: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error getting template: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@templateRouter.post("")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def create_db_template(
|
||||||
|
request: Request,
|
||||||
|
templateData: Dict[str, Any] = Body(...),
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Create a new automation template"""
|
||||||
|
try:
|
||||||
|
chatInterface = getAutomationInterface(
|
||||||
|
context.user,
|
||||||
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||||
|
)
|
||||||
|
created = chatInterface.createAutomationTemplate(templateData)
|
||||||
|
return JSONResponse(content=created)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating template: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error creating template: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@templateRouter.put("/{templateId}")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def update_db_template(
|
||||||
|
request: Request,
|
||||||
|
templateId: str = Path(..., description="Template ID"),
|
||||||
|
templateData: Dict[str, Any] = Body(...),
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Update an automation template"""
|
||||||
|
try:
|
||||||
|
chatInterface = getAutomationInterface(
|
||||||
|
context.user,
|
||||||
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||||
|
)
|
||||||
|
updated = chatInterface.updateAutomationTemplate(templateId, templateData)
|
||||||
|
return JSONResponse(content=updated)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating template: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error updating template: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@templateRouter.delete("/{templateId}")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def delete_db_template(
|
||||||
|
request: Request,
|
||||||
|
templateId: str = Path(..., description="Template ID"),
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> Response:
|
||||||
|
"""Delete an automation template"""
|
||||||
|
try:
|
||||||
|
chatInterface = getAutomationInterface(
|
||||||
|
context.user,
|
||||||
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||||
|
)
|
||||||
|
success = chatInterface.deleteAutomationTemplate(templateId)
|
||||||
|
if success:
|
||||||
|
return Response(status_code=204)
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Template not found or no permission"
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting template: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error deleting template: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ from modules.datamodels.datamodelChat import (
|
||||||
WorkflowModeEnum,
|
WorkflowModeEnum,
|
||||||
UserInputRequest
|
UserInputRequest
|
||||||
)
|
)
|
||||||
from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition
|
|
||||||
import json
|
import json
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
|
|
||||||
|
|
@ -1700,286 +1699,6 @@ class ChatObjects:
|
||||||
|
|
||||||
return {"items": items}
|
return {"items": items}
|
||||||
|
|
||||||
# ===== Automation Methods =====
|
|
||||||
|
|
||||||
def _computeAutomationStatus(self, automation: Dict[str, Any]) -> str:
|
|
||||||
"""Compute status field based on eventId presence"""
|
|
||||||
eventId = automation.get("eventId")
|
|
||||||
return "Running" if eventId else "Idle"
|
|
||||||
|
|
||||||
def _enrichAutomationsWithUserAndMandate(self, automations: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Batch enrich automations with user names and mandate names for display.
|
|
||||||
Uses AppObjects interface to fetch users and mandates with proper access control.
|
|
||||||
"""
|
|
||||||
if not automations:
|
|
||||||
return automations
|
|
||||||
|
|
||||||
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
|
||||||
|
|
||||||
# Collect all unique user IDs and mandate IDs
|
|
||||||
userIds = set()
|
|
||||||
mandateIds = set()
|
|
||||||
|
|
||||||
for automation in automations:
|
|
||||||
createdBy = automation.get("_createdBy")
|
|
||||||
if createdBy:
|
|
||||||
userIds.add(createdBy)
|
|
||||||
|
|
||||||
mandateId = automation.get("mandateId")
|
|
||||||
if mandateId:
|
|
||||||
mandateIds.add(mandateId)
|
|
||||||
|
|
||||||
# Use AppObjects interface to fetch users (respects access control)
|
|
||||||
appInterface = getAppInterface(self.currentUser)
|
|
||||||
usersMap = {}
|
|
||||||
if userIds:
|
|
||||||
for user_id in userIds:
|
|
||||||
user = appInterface.getUser(user_id)
|
|
||||||
if user:
|
|
||||||
usersMap[user_id] = user.username or user.email or user_id
|
|
||||||
|
|
||||||
# Use AppObjects interface to fetch mandates (respects access control)
|
|
||||||
mandatesMap = {}
|
|
||||||
if mandateIds:
|
|
||||||
for mandate_id in mandateIds:
|
|
||||||
mandate = appInterface.getMandate(mandate_id)
|
|
||||||
if mandate:
|
|
||||||
mandatesMap[mandate_id] = mandate.name or mandate_id
|
|
||||||
|
|
||||||
# Enrich each automation with the fetched data
|
|
||||||
for automation in automations:
|
|
||||||
createdBy = automation.get("_createdBy")
|
|
||||||
if createdBy:
|
|
||||||
automation["_createdByUserName"] = usersMap.get(createdBy, createdBy)
|
|
||||||
else:
|
|
||||||
automation["_createdByUserName"] = "-"
|
|
||||||
|
|
||||||
mandateId = automation.get("mandateId")
|
|
||||||
if mandateId:
|
|
||||||
automation["mandateName"] = mandatesMap.get(mandateId, mandateId)
|
|
||||||
else:
|
|
||||||
automation["mandateName"] = "-"
|
|
||||||
|
|
||||||
return automations
|
|
||||||
|
|
||||||
def _enrichAutomationWithUserAndMandate(self, automation: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Enrich a single automation with user name and mandate name for display.
|
|
||||||
For multiple automations, use _enrichAutomationsWithUserAndMandate for better performance.
|
|
||||||
"""
|
|
||||||
return self._enrichAutomationsWithUserAndMandate([automation])[0]
|
|
||||||
|
|
||||||
def getAllAutomationDefinitions(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]:
|
|
||||||
"""
|
|
||||||
Returns automation definitions based on user access level.
|
|
||||||
Supports optional pagination, sorting, and filtering.
|
|
||||||
Computes status field for each automation.
|
|
||||||
"""
|
|
||||||
# Use RBAC filtering
|
|
||||||
filteredAutomations = getRecordsetWithRBAC(self.db,
|
|
||||||
AutomationDefinition,
|
|
||||||
self.currentUser
|
|
||||||
)
|
|
||||||
|
|
||||||
# Compute status for each automation and normalize executionLogs
|
|
||||||
for automation in filteredAutomations:
|
|
||||||
automation["status"] = self._computeAutomationStatus(automation)
|
|
||||||
# Ensure executionLogs is always a list, not None
|
|
||||||
if automation.get("executionLogs") is None:
|
|
||||||
automation["executionLogs"] = []
|
|
||||||
|
|
||||||
# Batch enrich with user and mandate names
|
|
||||||
self._enrichAutomationsWithUserAndMandate(filteredAutomations)
|
|
||||||
|
|
||||||
# If no pagination requested, return all items
|
|
||||||
if pagination is None:
|
|
||||||
return filteredAutomations
|
|
||||||
|
|
||||||
# Apply filtering (if filters provided)
|
|
||||||
if pagination.filters:
|
|
||||||
filteredAutomations = self._applyFilters(filteredAutomations, pagination.filters)
|
|
||||||
|
|
||||||
# Apply sorting (in order of sortFields)
|
|
||||||
if pagination.sort:
|
|
||||||
filteredAutomations = self._applySorting(filteredAutomations, pagination.sort)
|
|
||||||
|
|
||||||
# Count total items after filters
|
|
||||||
totalItems = len(filteredAutomations)
|
|
||||||
totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0
|
|
||||||
|
|
||||||
# Apply pagination (skip/limit)
|
|
||||||
startIdx = (pagination.page - 1) * pagination.pageSize
|
|
||||||
endIdx = startIdx + pagination.pageSize
|
|
||||||
pagedAutomations = filteredAutomations[startIdx:endIdx]
|
|
||||||
|
|
||||||
return PaginatedResult(
|
|
||||||
items=pagedAutomations,
|
|
||||||
totalItems=totalItems,
|
|
||||||
totalPages=totalPages
|
|
||||||
)
|
|
||||||
|
|
||||||
def getAutomationDefinition(self, automationId: str) -> Optional[AutomationDefinition]:
|
|
||||||
"""Returns an automation definition by ID if user has access, with computed status."""
|
|
||||||
try:
|
|
||||||
# Use RBAC filtering
|
|
||||||
filtered = getRecordsetWithRBAC(self.db,
|
|
||||||
AutomationDefinition,
|
|
||||||
self.currentUser,
|
|
||||||
recordFilter={"id": automationId}
|
|
||||||
)
|
|
||||||
|
|
||||||
if not filtered:
|
|
||||||
return None
|
|
||||||
|
|
||||||
automation = filtered[0]
|
|
||||||
automation["status"] = self._computeAutomationStatus(automation)
|
|
||||||
# Ensure executionLogs is always a list, not None
|
|
||||||
if automation.get("executionLogs") is None:
|
|
||||||
automation["executionLogs"] = []
|
|
||||||
# Enrich with user and mandate names
|
|
||||||
self._enrichAutomationWithUserAndMandate(automation)
|
|
||||||
# Clean metadata fields and return Pydantic model
|
|
||||||
cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")}
|
|
||||||
return AutomationDefinition(**cleanedRecord)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting automation definition: {str(e)}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def createAutomationDefinition(self, automationData: Dict[str, Any]) -> AutomationDefinition:
|
|
||||||
"""Creates a new automation definition, then triggers sync."""
|
|
||||||
try:
|
|
||||||
# Ensure ID is present
|
|
||||||
if "id" not in automationData or not automationData["id"]:
|
|
||||||
automationData["id"] = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Ensure mandateId and featureInstanceId are set for proper data isolation
|
|
||||||
if "mandateId" not in automationData:
|
|
||||||
automationData["mandateId"] = self.mandateId
|
|
||||||
if "featureInstanceId" not in automationData:
|
|
||||||
automationData["featureInstanceId"] = self.featureInstanceId
|
|
||||||
|
|
||||||
# Ensure database connector has correct userId context
|
|
||||||
# The connector should have been initialized with userId, but ensure it's updated
|
|
||||||
if self.userId and hasattr(self.db, 'updateContext'):
|
|
||||||
try:
|
|
||||||
self.db.updateContext(self.userId)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not update database context: {e}")
|
|
||||||
|
|
||||||
# Note: _createdBy will be set automatically by connector's _saveRecord method
|
|
||||||
# when _createdAt is not present. We don't need to set it manually here.
|
|
||||||
# Use generic field separation
|
|
||||||
simpleFields, objectFields = self._separateObjectFields(AutomationDefinition, automationData)
|
|
||||||
|
|
||||||
# Create automation in database
|
|
||||||
createdAutomation = self.db.recordCreate(AutomationDefinition, simpleFields)
|
|
||||||
|
|
||||||
# Compute status
|
|
||||||
createdAutomation["status"] = self._computeAutomationStatus(createdAutomation)
|
|
||||||
# Ensure executionLogs is always a list, not None
|
|
||||||
if createdAutomation.get("executionLogs") is None:
|
|
||||||
createdAutomation["executionLogs"] = []
|
|
||||||
|
|
||||||
# Trigger automation change callback (async, don't wait)
|
|
||||||
asyncio.create_task(self._notifyAutomationChanged())
|
|
||||||
|
|
||||||
# Clean metadata fields and return Pydantic model
|
|
||||||
cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")}
|
|
||||||
return AutomationDefinition(**cleanedRecord)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error creating automation definition: {str(e)}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> AutomationDefinition:
|
|
||||||
"""Updates an automation definition, then triggers sync."""
|
|
||||||
try:
|
|
||||||
# Check access
|
|
||||||
existing = self.getAutomationDefinition(automationId)
|
|
||||||
if not existing:
|
|
||||||
raise PermissionError(f"No access to automation {automationId}")
|
|
||||||
|
|
||||||
if not self.checkRbacPermission(AutomationDefinition, "update", automationId):
|
|
||||||
raise PermissionError(f"No permission to modify automation {automationId}")
|
|
||||||
|
|
||||||
# Use generic field separation
|
|
||||||
simpleFields, objectFields = self._separateObjectFields(AutomationDefinition, automationData)
|
|
||||||
|
|
||||||
# Update automation in database
|
|
||||||
updatedAutomation = self.db.recordModify(AutomationDefinition, automationId, simpleFields)
|
|
||||||
|
|
||||||
# Compute status
|
|
||||||
updatedAutomation["status"] = self._computeAutomationStatus(updatedAutomation)
|
|
||||||
# Ensure executionLogs is always a list, not None
|
|
||||||
if updatedAutomation.get("executionLogs") is None:
|
|
||||||
updatedAutomation["executionLogs"] = []
|
|
||||||
|
|
||||||
# Trigger automation change callback (async, don't wait)
|
|
||||||
asyncio.create_task(self._notifyAutomationChanged())
|
|
||||||
|
|
||||||
# Clean metadata fields and return Pydantic model
|
|
||||||
cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")}
|
|
||||||
return AutomationDefinition(**cleanedRecord)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating automation definition: {str(e)}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def deleteAutomationDefinition(self, automationId: str) -> bool:
|
|
||||||
"""Deletes an automation definition, then triggers sync."""
|
|
||||||
try:
|
|
||||||
# Check access
|
|
||||||
existing = self.getAutomationDefinition(automationId)
|
|
||||||
if not existing:
|
|
||||||
raise PermissionError(f"No access to automation {automationId}")
|
|
||||||
|
|
||||||
if not self.checkRbacPermission(AutomationDefinition, "delete", automationId):
|
|
||||||
raise PermissionError(f"No permission to delete automation {automationId}")
|
|
||||||
|
|
||||||
# Remove event if exists
|
|
||||||
if existing.get("eventId"):
|
|
||||||
from modules.shared.eventManagement import eventManager
|
|
||||||
try:
|
|
||||||
eventManager.remove(existing["eventId"])
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Error removing event {existing['eventId']}: {str(e)}")
|
|
||||||
|
|
||||||
# Delete automation from database
|
|
||||||
self.db.recordDelete(AutomationDefinition, automationId)
|
|
||||||
|
|
||||||
# Trigger automation change callback (async, don't wait)
|
|
||||||
asyncio.create_task(self._notifyAutomationChanged())
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error deleting automation definition: {str(e)}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def getAllAutomationDefinitionsWithRBAC(self, user: User) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Get all automation definitions filtered by RBAC for a specific user.
|
|
||||||
This method encapsulates getRecordsetWithRBAC() to avoid exposing the connector.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User object for RBAC filtering
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of automation definition dictionaries filtered by RBAC
|
|
||||||
"""
|
|
||||||
return getRecordsetWithRBAC(
|
|
||||||
self.db,
|
|
||||||
AutomationDefinition,
|
|
||||||
user
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _notifyAutomationChanged(self):
|
|
||||||
"""Notify registered callbacks about automation changes (decoupled from features)."""
|
|
||||||
try:
|
|
||||||
from modules.shared.callbackRegistry import callbackRegistry
|
|
||||||
# Trigger callbacks without knowing which features are listening
|
|
||||||
await callbackRegistry.trigger('automation.changed', self)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error notifying automation change: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ChatObjects':
|
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ChatObjects':
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ class InterfaceFeatureNeutralizer:
|
||||||
try:
|
try:
|
||||||
# Use same database config pattern as other feature interfaces
|
# Use same database config pattern as other feature interfaces
|
||||||
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
|
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
|
||||||
dbDatabase = APP_CONFIG.get("DB_DATABASE_NEUTRALIZATION", APP_CONFIG.get("DB_DATABASE", "poweron"))
|
dbDatabase = "poweron_neutralization"
|
||||||
dbUser = APP_CONFIG.get("DB_USER", "postgres")
|
dbUser = APP_CONFIG.get("DB_USER", "postgres")
|
||||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
|
||||||
|
|
@ -528,7 +528,9 @@ class TrusteePosition(BaseModel):
|
||||||
"frontend_hidden": True
|
"frontend_hidden": True
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
# System attributes are automatically set by DatabaseConnector
|
|
||||||
|
# Allow extra fields like _createdAt from database
|
||||||
|
model_config = {"extra": "allow"}
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
|
|
|
||||||
|
|
@ -459,7 +459,8 @@ class TrusteeObjects:
|
||||||
recordFilter=None,
|
recordFilter=None,
|
||||||
orderBy="id",
|
orderBy="id",
|
||||||
mandateId=self.mandateId,
|
mandateId=self.mandateId,
|
||||||
featureInstanceId=self.featureInstanceId
|
featureInstanceId=self.featureInstanceId,
|
||||||
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
logger.debug(f"getAllOrganisations: getRecordsetWithRBAC returned {len(records)} records")
|
logger.debug(f"getAllOrganisations: getRecordsetWithRBAC returned {len(records)} records")
|
||||||
|
|
||||||
|
|
@ -552,7 +553,8 @@ class TrusteeObjects:
|
||||||
recordFilter=None,
|
recordFilter=None,
|
||||||
orderBy="id",
|
orderBy="id",
|
||||||
mandateId=self.mandateId,
|
mandateId=self.mandateId,
|
||||||
featureInstanceId=self.featureInstanceId
|
featureInstanceId=self.featureInstanceId,
|
||||||
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
|
|
||||||
# Users with ALL access level (from system RBAC) see all roles
|
# Users with ALL access level (from system RBAC) see all roles
|
||||||
|
|
@ -662,7 +664,8 @@ class TrusteeObjects:
|
||||||
recordFilter=None,
|
recordFilter=None,
|
||||||
orderBy="id",
|
orderBy="id",
|
||||||
mandateId=self.mandateId,
|
mandateId=self.mandateId,
|
||||||
featureInstanceId=self.featureInstanceId
|
featureInstanceId=self.featureInstanceId,
|
||||||
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
|
|
||||||
# Users with ALL access level (from system RBAC) see all records
|
# Users with ALL access level (from system RBAC) see all records
|
||||||
|
|
@ -721,7 +724,8 @@ class TrusteeObjects:
|
||||||
recordFilter={"organisationId": organisationId},
|
recordFilter={"organisationId": organisationId},
|
||||||
orderBy="id",
|
orderBy="id",
|
||||||
mandateId=self.mandateId,
|
mandateId=self.mandateId,
|
||||||
featureInstanceId=self.featureInstanceId
|
featureInstanceId=self.featureInstanceId,
|
||||||
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records]
|
return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records]
|
||||||
|
|
||||||
|
|
@ -738,7 +742,8 @@ class TrusteeObjects:
|
||||||
recordFilter={"userId": userId},
|
recordFilter={"userId": userId},
|
||||||
orderBy="id",
|
orderBy="id",
|
||||||
mandateId=self.mandateId,
|
mandateId=self.mandateId,
|
||||||
featureInstanceId=self.featureInstanceId
|
featureInstanceId=self.featureInstanceId,
|
||||||
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
|
|
||||||
# Users with ALL access level (from system RBAC) see all records
|
# Users with ALL access level (from system RBAC) see all records
|
||||||
|
|
@ -855,7 +860,8 @@ class TrusteeObjects:
|
||||||
recordFilter=None,
|
recordFilter=None,
|
||||||
orderBy="id",
|
orderBy="id",
|
||||||
mandateId=self.mandateId,
|
mandateId=self.mandateId,
|
||||||
featureInstanceId=self.featureInstanceId
|
featureInstanceId=self.featureInstanceId,
|
||||||
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
|
|
||||||
totalItems = len(records)
|
totalItems = len(records)
|
||||||
|
|
@ -888,7 +894,8 @@ class TrusteeObjects:
|
||||||
recordFilter={"organisationId": organisationId},
|
recordFilter={"organisationId": organisationId},
|
||||||
orderBy="label",
|
orderBy="label",
|
||||||
mandateId=self.mandateId,
|
mandateId=self.mandateId,
|
||||||
featureInstanceId=self.featureInstanceId
|
featureInstanceId=self.featureInstanceId,
|
||||||
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
return [TrusteeContract(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records]
|
return [TrusteeContract(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records]
|
||||||
|
|
||||||
|
|
@ -1007,7 +1014,8 @@ class TrusteeObjects:
|
||||||
recordFilter=None,
|
recordFilter=None,
|
||||||
orderBy="documentName",
|
orderBy="documentName",
|
||||||
mandateId=self.mandateId,
|
mandateId=self.mandateId,
|
||||||
featureInstanceId=self.featureInstanceId
|
featureInstanceId=self.featureInstanceId,
|
||||||
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
|
|
||||||
# Clean records (remove binary data and internal fields) - keep as dicts for filtering/sorting
|
# Clean records (remove binary data and internal fields) - keep as dicts for filtering/sorting
|
||||||
|
|
@ -1056,7 +1064,8 @@ class TrusteeObjects:
|
||||||
recordFilter={"contractId": contractId},
|
recordFilter={"contractId": contractId},
|
||||||
orderBy="documentName",
|
orderBy="documentName",
|
||||||
mandateId=self.mandateId,
|
mandateId=self.mandateId,
|
||||||
featureInstanceId=self.featureInstanceId
|
featureInstanceId=self.featureInstanceId,
|
||||||
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
|
|
@ -1165,13 +1174,16 @@ class TrusteeObjects:
|
||||||
recordFilter=None,
|
recordFilter=None,
|
||||||
orderBy="valuta",
|
orderBy="valuta",
|
||||||
mandateId=self.mandateId,
|
mandateId=self.mandateId,
|
||||||
featureInstanceId=self.featureInstanceId
|
featureInstanceId=self.featureInstanceId,
|
||||||
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
|
|
||||||
# Clean records (remove internal fields) - keep as dicts for filtering/sorting
|
# Clean records (remove internal fields except _createdAt) - keep as dicts for filtering/sorting
|
||||||
|
# Keep _createdAt for display in frontend
|
||||||
|
keepFields = {'_createdAt'}
|
||||||
cleanedRecords = []
|
cleanedRecords = []
|
||||||
for record in records:
|
for record in records:
|
||||||
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
|
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_") or k in keepFields}
|
||||||
cleanedRecords.append(cleanedRecord)
|
cleanedRecords.append(cleanedRecord)
|
||||||
|
|
||||||
# Step 2: Apply filters (search and field filters)
|
# Step 2: Apply filters (search and field filters)
|
||||||
|
|
@ -1214,7 +1226,8 @@ class TrusteeObjects:
|
||||||
recordFilter={"contractId": contractId},
|
recordFilter={"contractId": contractId},
|
||||||
orderBy="valuta",
|
orderBy="valuta",
|
||||||
mandateId=self.mandateId,
|
mandateId=self.mandateId,
|
||||||
featureInstanceId=self.featureInstanceId
|
featureInstanceId=self.featureInstanceId,
|
||||||
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records]
|
return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records]
|
||||||
|
|
||||||
|
|
@ -1228,7 +1241,8 @@ class TrusteeObjects:
|
||||||
recordFilter={"organisationId": organisationId},
|
recordFilter={"organisationId": organisationId},
|
||||||
orderBy="valuta",
|
orderBy="valuta",
|
||||||
mandateId=self.mandateId,
|
mandateId=self.mandateId,
|
||||||
featureInstanceId=self.featureInstanceId
|
featureInstanceId=self.featureInstanceId,
|
||||||
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records]
|
return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records]
|
||||||
|
|
||||||
|
|
@ -1354,7 +1368,8 @@ class TrusteeObjects:
|
||||||
orderBy="id",
|
orderBy="id",
|
||||||
mandateId=self.mandateId,
|
mandateId=self.mandateId,
|
||||||
featureInstanceId=self.featureInstanceId,
|
featureInstanceId=self.featureInstanceId,
|
||||||
enrichPermissions=True
|
enrichPermissions=True,
|
||||||
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
|
|
||||||
totalItems = len(records)
|
totalItems = len(records)
|
||||||
|
|
@ -1387,7 +1402,8 @@ class TrusteeObjects:
|
||||||
recordFilter={"positionId": positionId},
|
recordFilter={"positionId": positionId},
|
||||||
orderBy="id",
|
orderBy="id",
|
||||||
mandateId=self.mandateId,
|
mandateId=self.mandateId,
|
||||||
featureInstanceId=self.featureInstanceId
|
featureInstanceId=self.featureInstanceId,
|
||||||
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in links]
|
return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in links]
|
||||||
|
|
||||||
|
|
@ -1401,7 +1417,8 @@ class TrusteeObjects:
|
||||||
recordFilter={"documentId": documentId},
|
recordFilter={"documentId": documentId},
|
||||||
orderBy="id",
|
orderBy="id",
|
||||||
mandateId=self.mandateId,
|
mandateId=self.mandateId,
|
||||||
featureInstanceId=self.featureInstanceId
|
featureInstanceId=self.featureInstanceId,
|
||||||
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in links]
|
return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in links]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,86 @@ def initBootstrap(db: DatabaseConnector) -> None:
|
||||||
# Apply multi-tenant database optimizations (indexes, triggers, FKs)
|
# Apply multi-tenant database optimizations (indexes, triggers, FKs)
|
||||||
_applyDatabaseOptimizations(db)
|
_applyDatabaseOptimizations(db)
|
||||||
|
|
||||||
|
# Seed automation templates (after admin user exists)
|
||||||
|
initAutomationTemplates(db, adminUserId)
|
||||||
|
|
||||||
|
|
||||||
|
def initAutomationTemplates(dbApp: DatabaseConnector, adminUserId: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
Seed initial automation templates from subAutomationTemplates.py.
|
||||||
|
Only runs if no templates exist yet (bootstrap).
|
||||||
|
Creates templates with _createdBy = admin user (SysAdmin privilege).
|
||||||
|
|
||||||
|
NOTE: AutomationTemplate lives in poweron_automation database, not poweron_app!
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dbApp: Database connector for poweron_app (used to get admin user if needed)
|
||||||
|
adminUserId: Admin user ID for _createdBy field
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from modules.features.automation.subAutomationTemplates import AUTOMATION_TEMPLATES
|
||||||
|
from modules.features.automation.datamodelFeatureAutomation import AutomationTemplate
|
||||||
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
||||||
|
# Create connector for poweron_automation database (where templates live)
|
||||||
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||||
|
dbDatabase = "poweron_automation"
|
||||||
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
||||||
|
dbAutomation = DatabaseConnector(
|
||||||
|
dbHost=dbHost,
|
||||||
|
dbDatabase=dbDatabase,
|
||||||
|
dbUser=dbUser,
|
||||||
|
dbPassword=dbPassword,
|
||||||
|
dbPort=dbPort,
|
||||||
|
userId=adminUserId,
|
||||||
|
)
|
||||||
|
dbAutomation.initDbSystem()
|
||||||
|
|
||||||
|
# Check if templates already exist in poweron_automation
|
||||||
|
existing = dbAutomation.getRecordset(AutomationTemplate)
|
||||||
|
if existing:
|
||||||
|
logger.info(f"Automation templates already seeded ({len(existing)} templates)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get admin user ID if not provided (from poweron_app)
|
||||||
|
if not adminUserId:
|
||||||
|
adminUsers = dbApp.getRecordset(UserInDB, {"email": APP_CONFIG.ADMIN_EMAIL})
|
||||||
|
adminUserId = adminUsers[0]["id"] if adminUsers else None
|
||||||
|
# Update context with admin user
|
||||||
|
if adminUserId:
|
||||||
|
dbAutomation.updateContext(adminUserId)
|
||||||
|
|
||||||
|
templates = AUTOMATION_TEMPLATES.get("sets", [])
|
||||||
|
createdCount = 0
|
||||||
|
|
||||||
|
for i, templateSet in enumerate(templates):
|
||||||
|
templateContent = templateSet.get("template", {})
|
||||||
|
overview = templateContent.get("overview", f"Template {i+1}")
|
||||||
|
|
||||||
|
# Create multilingual label from overview (use as German since current templates are German)
|
||||||
|
# English is required by TextMultilingual, so we use the same value
|
||||||
|
labelDict = {"en": overview, "ge": overview}
|
||||||
|
overviewDict = {"en": overview, "ge": overview}
|
||||||
|
|
||||||
|
# Create template WITHOUT parameters (no sharp values)
|
||||||
|
templateData = {
|
||||||
|
"label": labelDict,
|
||||||
|
"overview": overviewDict,
|
||||||
|
"template": json.dumps(templateContent), # Store entire template JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
dbAutomation.recordCreate(AutomationTemplate, templateData)
|
||||||
|
createdCount += 1
|
||||||
|
logger.debug(f"Created automation template: {overview}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create automation template '{overview}': {e}")
|
||||||
|
|
||||||
|
logger.info(f"Seeded {createdCount} automation templates in poweron_automation database")
|
||||||
|
|
||||||
logger.info("System bootstrap completed")
|
logger.info("System bootstrap completed")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -677,6 +757,41 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
|
||||||
delete=AccessLevel.NONE,
|
delete=AccessLevel.NONE,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# AutomationTemplate: Admin sees ALL (system templates), User sees only MY
|
||||||
|
if adminId:
|
||||||
|
tableRules.append(AccessRule(
|
||||||
|
roleId=adminId,
|
||||||
|
context=AccessRuleContext.DATA,
|
||||||
|
item="data.automation.AutomationTemplate",
|
||||||
|
view=True,
|
||||||
|
read=AccessLevel.ALL, # SysAdmin sees all templates
|
||||||
|
create=AccessLevel.ALL,
|
||||||
|
update=AccessLevel.ALL,
|
||||||
|
delete=AccessLevel.ALL,
|
||||||
|
))
|
||||||
|
if userId:
|
||||||
|
tableRules.append(AccessRule(
|
||||||
|
roleId=userId,
|
||||||
|
context=AccessRuleContext.DATA,
|
||||||
|
item="data.automation.AutomationTemplate",
|
||||||
|
view=True,
|
||||||
|
read=AccessLevel.MY,
|
||||||
|
create=AccessLevel.MY,
|
||||||
|
update=AccessLevel.MY,
|
||||||
|
delete=AccessLevel.MY,
|
||||||
|
))
|
||||||
|
if viewerId:
|
||||||
|
tableRules.append(AccessRule(
|
||||||
|
roleId=viewerId,
|
||||||
|
context=AccessRuleContext.DATA,
|
||||||
|
item="data.automation.AutomationTemplate",
|
||||||
|
view=True,
|
||||||
|
read=AccessLevel.ALL, # Viewer can see all templates (read-only)
|
||||||
|
create=AccessLevel.NONE,
|
||||||
|
update=AccessLevel.NONE,
|
||||||
|
delete=AccessLevel.NONE,
|
||||||
|
))
|
||||||
|
|
||||||
# Create all table-specific rules
|
# Create all table-specific rules
|
||||||
for rule in tableRules:
|
for rule in tableRules:
|
||||||
db.recordCreate(AccessRule, rule)
|
db.recordCreate(AccessRule, rule)
|
||||||
|
|
@ -840,13 +955,20 @@ def _ensureDataContextRules(db: DatabaseConnector) -> None:
|
||||||
|
|
||||||
# Define tables that need rules (user-owned, no mandate context)
|
# Define tables that need rules (user-owned, no mandate context)
|
||||||
# Users can only manage their own records (MY-level access)
|
# Users can only manage their own records (MY-level access)
|
||||||
tablesNeedingRules = [
|
tablesNeedingMyRules = [
|
||||||
"data.chat.ChatWorkflow",
|
"data.chat.ChatWorkflow",
|
||||||
"data.automation.AutomationDefinition",
|
"data.automation.AutomationDefinition",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Tables where admin sees ALL (system-wide templates)
|
||||||
|
tablesNeedingAllRulesForAdmin = [
|
||||||
|
"data.automation.AutomationTemplate",
|
||||||
|
]
|
||||||
|
|
||||||
missingRules = []
|
missingRules = []
|
||||||
for objectKey in tablesNeedingRules:
|
|
||||||
|
# MY-level rules for user-owned tables
|
||||||
|
for objectKey in tablesNeedingMyRules:
|
||||||
# Admin: MY-level access (user-owned, no mandate context)
|
# Admin: MY-level access (user-owned, no mandate context)
|
||||||
if adminId and (adminId, objectKey) not in existingCombinations:
|
if adminId and (adminId, objectKey) not in existingCombinations:
|
||||||
missingRules.append(AccessRule(
|
missingRules.append(AccessRule(
|
||||||
|
|
@ -886,6 +1008,47 @@ def _ensureDataContextRules(db: DatabaseConnector) -> None:
|
||||||
delete=AccessLevel.NONE,
|
delete=AccessLevel.NONE,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# ALL-level rules for admin on system templates
|
||||||
|
for objectKey in tablesNeedingAllRulesForAdmin:
|
||||||
|
# Admin: ALL-level access (sees all templates)
|
||||||
|
if adminId and (adminId, objectKey) not in existingCombinations:
|
||||||
|
missingRules.append(AccessRule(
|
||||||
|
roleId=adminId,
|
||||||
|
context=AccessRuleContext.DATA,
|
||||||
|
item=objectKey,
|
||||||
|
view=True,
|
||||||
|
read=AccessLevel.ALL,
|
||||||
|
create=AccessLevel.ALL,
|
||||||
|
update=AccessLevel.ALL,
|
||||||
|
delete=AccessLevel.ALL,
|
||||||
|
))
|
||||||
|
|
||||||
|
# User: MY-level access
|
||||||
|
if userId and (userId, objectKey) not in existingCombinations:
|
||||||
|
missingRules.append(AccessRule(
|
||||||
|
roleId=userId,
|
||||||
|
context=AccessRuleContext.DATA,
|
||||||
|
item=objectKey,
|
||||||
|
view=True,
|
||||||
|
read=AccessLevel.MY,
|
||||||
|
create=AccessLevel.MY,
|
||||||
|
update=AccessLevel.MY,
|
||||||
|
delete=AccessLevel.MY,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Viewer: ALL read-only (can see all templates)
|
||||||
|
if viewerId and (viewerId, objectKey) not in existingCombinations:
|
||||||
|
missingRules.append(AccessRule(
|
||||||
|
roleId=viewerId,
|
||||||
|
context=AccessRuleContext.DATA,
|
||||||
|
item=objectKey,
|
||||||
|
view=True,
|
||||||
|
read=AccessLevel.ALL,
|
||||||
|
create=AccessLevel.NONE,
|
||||||
|
update=AccessLevel.NONE,
|
||||||
|
delete=AccessLevel.NONE,
|
||||||
|
))
|
||||||
|
|
||||||
# Create missing rules
|
# Create missing rules
|
||||||
if missingRules:
|
if missingRules:
|
||||||
for rule in missingRules:
|
for rule in missingRules:
|
||||||
|
|
@ -893,6 +1056,44 @@ def _ensureDataContextRules(db: DatabaseConnector) -> None:
|
||||||
logger.info(f"Created {len(missingRules)} missing DATA context rules")
|
logger.info(f"Created {len(missingRules)} missing DATA context rules")
|
||||||
else:
|
else:
|
||||||
logger.debug("All DATA context rules already exist")
|
logger.debug("All DATA context rules already exist")
|
||||||
|
|
||||||
|
# Update existing AutomationTemplate rules for admin/viewer to ALL access
|
||||||
|
_updateAutomationTemplateRulesToAll(db, adminId, viewerId)
|
||||||
|
|
||||||
|
|
||||||
|
def _updateAutomationTemplateRulesToAll(db: DatabaseConnector, adminId: Optional[str], viewerId: Optional[str]) -> None:
|
||||||
|
"""
|
||||||
|
Update existing AutomationTemplate RBAC rules from MY to ALL for admin and viewer.
|
||||||
|
This ensures sysadmins can see all templates (including system-seeded ones).
|
||||||
|
"""
|
||||||
|
if not adminId and not viewerId:
|
||||||
|
return
|
||||||
|
|
||||||
|
templateObjectKey = "data.automation.AutomationTemplate"
|
||||||
|
|
||||||
|
# Find existing rules for AutomationTemplate
|
||||||
|
existingRules = db.getRecordset(
|
||||||
|
AccessRule,
|
||||||
|
recordFilter={
|
||||||
|
"context": AccessRuleContext.DATA.value,
|
||||||
|
"item": templateObjectKey
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
updatedCount = 0
|
||||||
|
for rule in existingRules:
|
||||||
|
ruleId = rule.get("id")
|
||||||
|
roleId = rule.get("roleId")
|
||||||
|
currentReadLevel = rule.get("read")
|
||||||
|
|
||||||
|
# Update admin and viewer rules from MY to ALL
|
||||||
|
if roleId in [adminId, viewerId] and currentReadLevel == AccessLevel.MY.value:
|
||||||
|
db.recordModify(AccessRule, ruleId, {"read": AccessLevel.ALL.value})
|
||||||
|
updatedCount += 1
|
||||||
|
logger.debug(f"Updated AutomationTemplate rule {ruleId} for role {roleId} to ALL access")
|
||||||
|
|
||||||
|
if updatedCount > 0:
|
||||||
|
logger.info(f"Updated {updatedCount} AutomationTemplate RBAC rules to ALL access")
|
||||||
|
|
||||||
|
|
||||||
def _createResourceContextRules(db: DatabaseConnector) -> None:
|
def _createResourceContextRules(db: DatabaseConnector) -> None:
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ from modules.datamodels.datamodelChat import (
|
||||||
WorkflowModeEnum,
|
WorkflowModeEnum,
|
||||||
UserInputRequest
|
UserInputRequest
|
||||||
)
|
)
|
||||||
from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition
|
|
||||||
import json
|
import json
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
|
|
||||||
|
|
@ -1654,311 +1653,6 @@ class ChatObjects:
|
||||||
|
|
||||||
return {"items": items}
|
return {"items": items}
|
||||||
|
|
||||||
# ===== Automation Methods =====
|
|
||||||
|
|
||||||
def _computeAutomationStatus(self, automation: Dict[str, Any]) -> str:
|
|
||||||
"""Compute status field based on eventId presence"""
|
|
||||||
eventId = automation.get("eventId")
|
|
||||||
return "Running" if eventId else "Idle"
|
|
||||||
|
|
||||||
def _enrichAutomationsWithUserAndMandate(self, automations: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Batch enrich automations with user names and mandate names for display.
|
|
||||||
Uses AppObjects interface to fetch users and mandates with proper access control.
|
|
||||||
"""
|
|
||||||
if not automations:
|
|
||||||
return automations
|
|
||||||
|
|
||||||
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
|
||||||
|
|
||||||
# Collect all unique user IDs and mandate IDs
|
|
||||||
userIds = set()
|
|
||||||
mandateIds = set()
|
|
||||||
|
|
||||||
for automation in automations:
|
|
||||||
createdBy = automation.get("_createdBy")
|
|
||||||
if createdBy:
|
|
||||||
userIds.add(createdBy)
|
|
||||||
|
|
||||||
mandateId = automation.get("mandateId")
|
|
||||||
if mandateId:
|
|
||||||
mandateIds.add(mandateId)
|
|
||||||
|
|
||||||
# Use AppObjects interface to fetch users (respects access control)
|
|
||||||
appInterface = getAppInterface(self.currentUser)
|
|
||||||
usersMap = {}
|
|
||||||
if userIds:
|
|
||||||
for user_id in userIds:
|
|
||||||
user = appInterface.getUser(user_id)
|
|
||||||
if user:
|
|
||||||
usersMap[user_id] = user.username or user.email or user_id
|
|
||||||
|
|
||||||
# Use AppObjects interface to fetch mandates (respects access control)
|
|
||||||
mandatesMap = {}
|
|
||||||
if mandateIds:
|
|
||||||
for mandate_id in mandateIds:
|
|
||||||
mandate = appInterface.getMandate(mandate_id)
|
|
||||||
if mandate:
|
|
||||||
mandatesMap[mandate_id] = mandate.name or mandate_id
|
|
||||||
|
|
||||||
# Enrich each automation with the fetched data
|
|
||||||
for automation in automations:
|
|
||||||
createdBy = automation.get("_createdBy")
|
|
||||||
if createdBy:
|
|
||||||
automation["_createdByUserName"] = usersMap.get(createdBy, createdBy)
|
|
||||||
else:
|
|
||||||
automation["_createdByUserName"] = "-"
|
|
||||||
|
|
||||||
mandateId = automation.get("mandateId")
|
|
||||||
if mandateId:
|
|
||||||
automation["mandateName"] = mandatesMap.get(mandateId, mandateId)
|
|
||||||
else:
|
|
||||||
automation["mandateName"] = "-"
|
|
||||||
|
|
||||||
return automations
|
|
||||||
|
|
||||||
def _enrichAutomationWithUserAndMandate(self, automation: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Enrich a single automation with user name and mandate name for display.
|
|
||||||
For multiple automations, use _enrichAutomationsWithUserAndMandate for better performance.
|
|
||||||
"""
|
|
||||||
return self._enrichAutomationsWithUserAndMandate([automation])[0]
|
|
||||||
|
|
||||||
def getAllAutomationDefinitions(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]:
|
|
||||||
"""
|
|
||||||
Returns automation definitions based on user access level.
|
|
||||||
Supports optional pagination, sorting, and filtering.
|
|
||||||
Computes status field for each automation.
|
|
||||||
"""
|
|
||||||
# Use RBAC filtering
|
|
||||||
filteredAutomations = getRecordsetWithRBAC(self.db,
|
|
||||||
AutomationDefinition,
|
|
||||||
self.currentUser
|
|
||||||
)
|
|
||||||
|
|
||||||
# Compute status for each automation and normalize executionLogs
|
|
||||||
for automation in filteredAutomations:
|
|
||||||
automation["status"] = self._computeAutomationStatus(automation)
|
|
||||||
# Ensure executionLogs is always a list, not None
|
|
||||||
if automation.get("executionLogs") is None:
|
|
||||||
automation["executionLogs"] = []
|
|
||||||
|
|
||||||
# Batch enrich with user and mandate names
|
|
||||||
self._enrichAutomationsWithUserAndMandate(filteredAutomations)
|
|
||||||
|
|
||||||
# If no pagination requested, return all items
|
|
||||||
if pagination is None:
|
|
||||||
return filteredAutomations
|
|
||||||
|
|
||||||
# Apply filtering (if filters provided)
|
|
||||||
if pagination.filters:
|
|
||||||
filteredAutomations = self._applyFilters(filteredAutomations, pagination.filters)
|
|
||||||
|
|
||||||
# Apply sorting (in order of sortFields)
|
|
||||||
if pagination.sort:
|
|
||||||
filteredAutomations = self._applySorting(filteredAutomations, pagination.sort)
|
|
||||||
|
|
||||||
# Count total items after filters
|
|
||||||
totalItems = len(filteredAutomations)
|
|
||||||
totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0
|
|
||||||
|
|
||||||
# Apply pagination (skip/limit)
|
|
||||||
startIdx = (pagination.page - 1) * pagination.pageSize
|
|
||||||
endIdx = startIdx + pagination.pageSize
|
|
||||||
pagedAutomations = filteredAutomations[startIdx:endIdx]
|
|
||||||
|
|
||||||
return PaginatedResult(
|
|
||||||
items=pagedAutomations,
|
|
||||||
totalItems=totalItems,
|
|
||||||
totalPages=totalPages
|
|
||||||
)
|
|
||||||
|
|
||||||
def getAutomationDefinition(self, automationId: str, includeSystemFields: bool = False) -> Optional[AutomationDefinition]:
|
|
||||||
"""Returns an automation definition by ID if user has access, with computed status.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
automationId: ID of the automation to get
|
|
||||||
includeSystemFields: If True, returns raw dict with system fields (_createdBy, etc).
|
|
||||||
If False (default), returns Pydantic model without system fields.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Use RBAC filtering
|
|
||||||
filtered = getRecordsetWithRBAC(self.db,
|
|
||||||
AutomationDefinition,
|
|
||||||
self.currentUser,
|
|
||||||
recordFilter={"id": automationId}
|
|
||||||
)
|
|
||||||
|
|
||||||
if not filtered:
|
|
||||||
return None
|
|
||||||
|
|
||||||
automation = filtered[0]
|
|
||||||
automation["status"] = self._computeAutomationStatus(automation)
|
|
||||||
# Ensure executionLogs is always a list, not None
|
|
||||||
if automation.get("executionLogs") is None:
|
|
||||||
automation["executionLogs"] = []
|
|
||||||
# Enrich with user and mandate names
|
|
||||||
self._enrichAutomationWithUserAndMandate(automation)
|
|
||||||
|
|
||||||
# For internal use (execution), return raw dict with system fields
|
|
||||||
if includeSystemFields:
|
|
||||||
# Return as simple namespace object so getattr works
|
|
||||||
class AutomationWithSystemFields:
|
|
||||||
def __init__(self, data):
|
|
||||||
for key, value in data.items():
|
|
||||||
setattr(self, key, value)
|
|
||||||
return AutomationWithSystemFields(automation)
|
|
||||||
|
|
||||||
# Clean metadata fields and return Pydantic model
|
|
||||||
cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")}
|
|
||||||
return AutomationDefinition(**cleanedRecord)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting automation definition: {str(e)}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def createAutomationDefinition(self, automationData: Dict[str, Any]) -> AutomationDefinition:
|
|
||||||
"""Creates a new automation definition, then triggers sync."""
|
|
||||||
try:
|
|
||||||
# Ensure ID is present
|
|
||||||
if "id" not in automationData or not automationData["id"]:
|
|
||||||
automationData["id"] = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Ensure mandateId and featureInstanceId are set for proper data isolation
|
|
||||||
if "mandateId" not in automationData or not automationData.get("mandateId"):
|
|
||||||
# Use request context mandateId, or fall back to Root mandate
|
|
||||||
effectiveMandateId = self.mandateId
|
|
||||||
if not effectiveMandateId:
|
|
||||||
# Fall back to Root mandate (first mandate in system)
|
|
||||||
try:
|
|
||||||
from modules.datamodels.datamodelUam import Mandate
|
|
||||||
from modules.security.rootAccess import getRootDbAppConnector
|
|
||||||
dbAppConn = getRootDbAppConnector()
|
|
||||||
allMandates = dbAppConn.getRecordset(Mandate)
|
|
||||||
if allMandates:
|
|
||||||
effectiveMandateId = allMandates[0].get("id")
|
|
||||||
logger.debug(f"createAutomationDefinition: Using Root mandate {effectiveMandateId}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not get Root mandate: {e}")
|
|
||||||
automationData["mandateId"] = effectiveMandateId
|
|
||||||
if "featureInstanceId" not in automationData:
|
|
||||||
automationData["featureInstanceId"] = self.featureInstanceId
|
|
||||||
|
|
||||||
# Ensure database connector has correct userId context
|
|
||||||
# The connector should have been initialized with userId, but ensure it's updated
|
|
||||||
if not self.userId:
|
|
||||||
logger.error(f"createAutomationDefinition: userId is not set! Cannot set _createdBy. currentUser={self.currentUser}")
|
|
||||||
elif hasattr(self.db, 'updateContext'):
|
|
||||||
try:
|
|
||||||
self.db.updateContext(self.userId)
|
|
||||||
logger.debug(f"createAutomationDefinition: Updated database context with userId={self.userId}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not update database context: {e}")
|
|
||||||
|
|
||||||
# Note: _createdBy will be set automatically by connector's _saveRecord method
|
|
||||||
# when _createdAt is not present. We don't need to set it manually here.
|
|
||||||
# Use generic field separation
|
|
||||||
simpleFields, objectFields = self._separateObjectFields(AutomationDefinition, automationData)
|
|
||||||
|
|
||||||
# Create automation in database
|
|
||||||
createdAutomation = self.db.recordCreate(AutomationDefinition, simpleFields)
|
|
||||||
|
|
||||||
# Compute status
|
|
||||||
createdAutomation["status"] = self._computeAutomationStatus(createdAutomation)
|
|
||||||
# Ensure executionLogs is always a list, not None
|
|
||||||
if createdAutomation.get("executionLogs") is None:
|
|
||||||
createdAutomation["executionLogs"] = []
|
|
||||||
|
|
||||||
# Trigger automation change callback (async, don't wait)
|
|
||||||
asyncio.create_task(self._notifyAutomationChanged())
|
|
||||||
|
|
||||||
# Clean metadata fields and return Pydantic model
|
|
||||||
cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")}
|
|
||||||
return AutomationDefinition(**cleanedRecord)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error creating automation definition: {str(e)}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> AutomationDefinition:
|
|
||||||
"""Updates an automation definition, then triggers sync."""
|
|
||||||
try:
|
|
||||||
# Check access
|
|
||||||
existing = self.getAutomationDefinition(automationId)
|
|
||||||
if not existing:
|
|
||||||
raise PermissionError(f"No access to automation {automationId}")
|
|
||||||
|
|
||||||
if not self.checkRbacPermission(AutomationDefinition, "update", automationId):
|
|
||||||
raise PermissionError(f"No permission to modify automation {automationId}")
|
|
||||||
|
|
||||||
# Use generic field separation
|
|
||||||
simpleFields, objectFields = self._separateObjectFields(AutomationDefinition, automationData)
|
|
||||||
|
|
||||||
# Update automation in database
|
|
||||||
updatedAutomation = self.db.recordModify(AutomationDefinition, automationId, simpleFields)
|
|
||||||
|
|
||||||
# Compute status
|
|
||||||
updatedAutomation["status"] = self._computeAutomationStatus(updatedAutomation)
|
|
||||||
# Ensure executionLogs is always a list, not None
|
|
||||||
if updatedAutomation.get("executionLogs") is None:
|
|
||||||
updatedAutomation["executionLogs"] = []
|
|
||||||
|
|
||||||
# Trigger automation change callback (async, don't wait)
|
|
||||||
asyncio.create_task(self._notifyAutomationChanged())
|
|
||||||
|
|
||||||
# Clean metadata fields and return Pydantic model
|
|
||||||
cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")}
|
|
||||||
return AutomationDefinition(**cleanedRecord)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating automation definition: {str(e)}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def deleteAutomationDefinition(self, automationId: str) -> bool:
|
|
||||||
"""Deletes an automation definition, then triggers sync."""
|
|
||||||
try:
|
|
||||||
# Check access
|
|
||||||
existing = self.getAutomationDefinition(automationId)
|
|
||||||
if not existing:
|
|
||||||
raise PermissionError(f"No access to automation {automationId}")
|
|
||||||
|
|
||||||
if not self.checkRbacPermission(AutomationDefinition, "delete", automationId):
|
|
||||||
raise PermissionError(f"No permission to delete automation {automationId}")
|
|
||||||
|
|
||||||
# Delete automation from database
|
|
||||||
self.db.recordDelete(AutomationDefinition, automationId)
|
|
||||||
|
|
||||||
# Trigger automation change callback (async, don't wait)
|
|
||||||
asyncio.create_task(self._notifyAutomationChanged())
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error deleting automation definition: {str(e)}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def getAllAutomationDefinitionsWithRBAC(self, user: User) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Get all automation definitions filtered by RBAC for a specific user.
|
|
||||||
This method encapsulates getRecordsetWithRBAC() to avoid exposing the connector.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User object for RBAC filtering
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of automation definition dictionaries filtered by RBAC
|
|
||||||
"""
|
|
||||||
return getRecordsetWithRBAC(
|
|
||||||
self.db,
|
|
||||||
AutomationDefinition,
|
|
||||||
user
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _notifyAutomationChanged(self):
|
|
||||||
"""Notify registered callbacks about automation changes (decoupled from features)."""
|
|
||||||
try:
|
|
||||||
from modules.shared.callbackRegistry import callbackRegistry
|
|
||||||
# Trigger callbacks without knowing which features are listening
|
|
||||||
await callbackRegistry.trigger('automation.changed', self)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error notifying automation change: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ChatObjects':
|
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ChatObjects':
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ TABLE_NAMESPACE = {
|
||||||
"FileData": "files",
|
"FileData": "files",
|
||||||
# Automation - benutzer-eigen
|
# Automation - benutzer-eigen
|
||||||
"AutomationDefinition": "automation",
|
"AutomationDefinition": "automation",
|
||||||
|
"AutomationTemplate": "automation",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Namespaces ohne Mandantenkontext - GROUP wird auf MY gemappt
|
# Namespaces ohne Mandantenkontext - GROUP wird auf MY gemappt
|
||||||
|
|
@ -145,17 +146,8 @@ def getRecordsetWithRBAC(
|
||||||
if not connector._ensureTableExists(modelClass):
|
if not connector._ensureTableExists(modelClass):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# SysAdmin bypass: SysAdmin users have full access to all tables
|
# All users (including SysAdmins) go through RBAC filtering
|
||||||
isSysAdmin = getattr(currentUser, 'isSysAdmin', False)
|
# SysAdmin flag does NOT grant automatic data access - proper RBAC rules must exist
|
||||||
if isSysAdmin:
|
|
||||||
# Direct access without RBAC filtering
|
|
||||||
# Note: getRecordset doesn't support orderBy/limit - these are only used in RBAC path
|
|
||||||
records = connector.getRecordset(modelClass, recordFilter=recordFilter)
|
|
||||||
if enrichPermissions:
|
|
||||||
# SysAdmin has full permissions on all records
|
|
||||||
for record in records:
|
|
||||||
record["_permissions"] = {"canUpdate": True, "canDelete": True}
|
|
||||||
return records
|
|
||||||
|
|
||||||
# Get RBAC permissions for this table using full objectKey
|
# Get RBAC permissions for this table using full objectKey
|
||||||
# AccessRule table is always in DbApp database
|
# AccessRule table is always in DbApp database
|
||||||
|
|
@ -184,7 +176,8 @@ def getRecordsetWithRBAC(
|
||||||
currentUser,
|
currentUser,
|
||||||
table,
|
table,
|
||||||
connector,
|
connector,
|
||||||
mandateId=effectiveMandateId
|
mandateId=effectiveMandateId,
|
||||||
|
featureInstanceId=featureInstanceId
|
||||||
)
|
)
|
||||||
if rbacWhereClause:
|
if rbacWhereClause:
|
||||||
whereConditions.append(rbacWhereClause["condition"])
|
whereConditions.append(rbacWhereClause["condition"])
|
||||||
|
|
@ -281,13 +274,15 @@ def buildRbacWhereClause(
|
||||||
currentUser: User,
|
currentUser: User,
|
||||||
table: str,
|
table: str,
|
||||||
connector, # DatabaseConnector instance for connection access
|
connector, # DatabaseConnector instance for connection access
|
||||||
mandateId: Optional[str] = None
|
mandateId: Optional[str] = None,
|
||||||
|
featureInstanceId: Optional[str] = None
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Build RBAC WHERE clause based on permissions and access level.
|
Build RBAC WHERE clause based on permissions and access level.
|
||||||
|
|
||||||
Multi-Tenant Design:
|
Multi-Tenant Design:
|
||||||
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
|
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
|
||||||
|
- featureInstanceId wird für Feature-Tabellen zusätzlich gefiltert
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
permissions: UserPermissions object
|
permissions: UserPermissions object
|
||||||
|
|
@ -295,6 +290,7 @@ def buildRbacWhereClause(
|
||||||
table: Table name
|
table: Table name
|
||||||
connector: DatabaseConnector instance (needed for GROUP queries)
|
connector: DatabaseConnector instance (needed for GROUP queries)
|
||||||
mandateId: Explicit mandate context (from request header). Required for GROUP access.
|
mandateId: Explicit mandate context (from request header). Required for GROUP access.
|
||||||
|
featureInstanceId: Feature instance context for feature-level data isolation.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with "condition" and "values" keys, or None if no filtering needed
|
Dictionary with "condition" and "values" keys, or None if no filtering needed
|
||||||
|
|
@ -308,8 +304,20 @@ def buildRbacWhereClause(
|
||||||
if readLevel == AccessLevel.NONE:
|
if readLevel == AccessLevel.NONE:
|
||||||
return {"condition": "1 = 0", "values": []}
|
return {"condition": "1 = 0", "values": []}
|
||||||
|
|
||||||
# All records - no filtering needed
|
# CRITICAL: featureInstanceId filter is ALWAYS required when provided
|
||||||
|
# This ensures data isolation between feature instances regardless of access level
|
||||||
|
baseConditions = []
|
||||||
|
baseValues = []
|
||||||
|
|
||||||
|
if featureInstanceId:
|
||||||
|
# Strict filter: only records for this exact feature instance
|
||||||
|
baseConditions.append('"featureInstanceId" = %s')
|
||||||
|
baseValues.append(featureInstanceId)
|
||||||
|
|
||||||
|
# All records within the feature instance - only featureInstanceId filtering
|
||||||
if readLevel == AccessLevel.ALL:
|
if readLevel == AccessLevel.ALL:
|
||||||
|
if baseConditions:
|
||||||
|
return {"condition": " AND ".join(baseConditions), "values": baseValues}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# My records - filter by _createdBy or userId field
|
# My records - filter by _createdBy or userId field
|
||||||
|
|
@ -323,9 +331,14 @@ def buildRbacWhereClause(
|
||||||
else:
|
else:
|
||||||
userIdField = "_createdBy"
|
userIdField = "_createdBy"
|
||||||
|
|
||||||
|
conditions = list(baseConditions)
|
||||||
|
values = list(baseValues)
|
||||||
|
conditions.append(f'"{userIdField}" = %s')
|
||||||
|
values.append(currentUser.id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"condition": f'"{userIdField}" = %s',
|
"condition": " AND ".join(conditions),
|
||||||
"values": [currentUser.id]
|
"values": values
|
||||||
}
|
}
|
||||||
|
|
||||||
# Group records - filter by mandateId or ownership based on namespace
|
# Group records - filter by mandateId or ownership based on namespace
|
||||||
|
|
@ -335,8 +348,10 @@ def buildRbacWhereClause(
|
||||||
|
|
||||||
# For user-owned namespaces (chat, files, automation):
|
# For user-owned namespaces (chat, files, automation):
|
||||||
# GROUP has no meaning - these tables have no mandate context
|
# GROUP has no meaning - these tables have no mandate context
|
||||||
# Simply ignore GROUP (no filtering)
|
# But still apply featureInstanceId filter if provided
|
||||||
if namespace in USER_OWNED_NAMESPACES:
|
if namespace in USER_OWNED_NAMESPACES:
|
||||||
|
if baseConditions:
|
||||||
|
return {"condition": " AND ".join(baseConditions), "values": baseValues}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# For UAM and other namespaces: GROUP filters by mandate
|
# For UAM and other namespaces: GROUP filters by mandate
|
||||||
|
|
@ -373,9 +388,14 @@ def buildRbacWhereClause(
|
||||||
if not userIds:
|
if not userIds:
|
||||||
return {"condition": "1 = 0", "values": []}
|
return {"condition": "1 = 0", "values": []}
|
||||||
placeholders = ",".join(["%s"] * len(userIds))
|
placeholders = ",".join(["%s"] * len(userIds))
|
||||||
|
# Combine with base conditions (featureInstanceId)
|
||||||
|
conditions = list(baseConditions)
|
||||||
|
values = list(baseValues)
|
||||||
|
conditions.append(f'"id" IN ({placeholders})')
|
||||||
|
values.extend(userIds)
|
||||||
return {
|
return {
|
||||||
"condition": f'"id" IN ({placeholders})',
|
"condition": " AND ".join(conditions) if conditions else f'"id" IN ({placeholders})',
|
||||||
"values": userIds
|
"values": values
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error building GROUP filter for UserInDB via UserMandate: {e}")
|
logger.error(f"Error building GROUP filter for UserInDB via UserMandate: {e}")
|
||||||
|
|
@ -395,28 +415,45 @@ def buildRbacWhereClause(
|
||||||
if not userIds:
|
if not userIds:
|
||||||
return {"condition": "1 = 0", "values": []}
|
return {"condition": "1 = 0", "values": []}
|
||||||
placeholders = ",".join(["%s"] * len(userIds))
|
placeholders = ",".join(["%s"] * len(userIds))
|
||||||
|
# Combine with base conditions (featureInstanceId)
|
||||||
|
conditions = list(baseConditions)
|
||||||
|
values = list(baseValues)
|
||||||
|
conditions.append(f'"userId" IN ({placeholders})')
|
||||||
|
values.extend(userIds)
|
||||||
return {
|
return {
|
||||||
"condition": f'"userId" IN ({placeholders})',
|
"condition": " AND ".join(conditions) if conditions else f'"userId" IN ({placeholders})',
|
||||||
"values": userIds
|
"values": values
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error building GROUP filter for UserConnection: {e}")
|
logger.error(f"Error building GROUP filter for UserConnection: {e}")
|
||||||
return {"condition": "1 = 0", "values": []}
|
return {"condition": "1 = 0", "values": []}
|
||||||
|
|
||||||
# For system tables without mandateId column (Mandate, Role, etc.):
|
# For system tables without mandateId column (Mandate, Role, etc.):
|
||||||
# No row-level filtering - GROUP access = ALL access for these
|
# No row-level filtering based on mandate, but still apply featureInstanceId if provided
|
||||||
elif table in ("Mandate", "Role"):
|
elif table in ("Mandate", "Role"):
|
||||||
|
if baseConditions:
|
||||||
|
return {"condition": " AND ".join(baseConditions), "values": baseValues}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# For other tables, filter by mandateId field
|
# For other tables, filter by mandateId field
|
||||||
# Also include records with NULL mandateId for backwards compatibility
|
# Also include records with NULL mandateId for backwards compatibility
|
||||||
else:
|
else:
|
||||||
|
# Start with base conditions (includes strict featureInstanceId filter)
|
||||||
|
conditions = list(baseConditions)
|
||||||
|
values = list(baseValues)
|
||||||
|
|
||||||
|
# Add mandate filter
|
||||||
|
conditions.append('("mandateId" = %s OR "mandateId" IS NULL)')
|
||||||
|
values.append(effectiveMandateId)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"condition": '("mandateId" = %s OR "mandateId" IS NULL)',
|
"condition": " AND ".join(conditions),
|
||||||
"values": [effectiveMandateId]
|
"values": values
|
||||||
}
|
}
|
||||||
|
|
||||||
return None
|
# Unknown access level - deny access (security: deny by default)
|
||||||
|
logger.warning(f"Unknown access level '{readLevel}' for user {currentUser.id} - denying access")
|
||||||
|
return {"condition": "1 = 0", "values": []}
|
||||||
|
|
||||||
|
|
||||||
def _enrichRecordsWithPermissions(
|
def _enrichRecordsWithPermissions(
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from fastapi import status
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# Import interfaces and models from feature containers
|
# Import interfaces and models from feature containers
|
||||||
import modules.interfaces.interfaceDbChat as interfaceDbChat
|
import modules.features.automation.interfaceFeatureAutomation as interfaceAutomation
|
||||||
from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext
|
from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
|
|
||||||
|
|
@ -79,7 +79,6 @@ async def sync_all_automation_events(
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
from modules.workflows.automation import syncAutomationEvents
|
from modules.workflows.automation import syncAutomationEvents
|
||||||
|
|
||||||
chatInterface = getChatInterface(currentUser)
|
|
||||||
# Get event user for sync operation (routes can import from interfaces)
|
# Get event user for sync operation (routes can import from interfaces)
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
eventUser = rootInterface.getUserByUsername("event")
|
eventUser = rootInterface.getUserByUsername("event")
|
||||||
|
|
@ -126,10 +125,10 @@ async def remove_event(
|
||||||
# Update automation's eventId if it exists
|
# Update automation's eventId if it exists
|
||||||
if eventId.startswith("automation."):
|
if eventId.startswith("automation."):
|
||||||
automation_id = eventId.replace("automation.", "")
|
automation_id = eventId.replace("automation.", "")
|
||||||
chatInterface = interfaceDbChat.getInterface(currentUser)
|
automationInterface = interfaceAutomation.getInterface(currentUser)
|
||||||
automation = chatInterface.getAutomationDefinition(automation_id)
|
automation = automationInterface.getAutomationDefinition(automation_id)
|
||||||
if automation and getattr(automation, "eventId", None) == eventId:
|
if automation and getattr(automation, "eventId", None) == eventId:
|
||||||
chatInterface.updateAutomationDefinition(automation_id, {"eventId": None})
|
automationInterface.updateAutomationDefinition(automation_id, {"eventId": None})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,44 @@ def _getUserConnection(interface, connectionId: str, userId: str) -> Optional[Us
|
||||||
logger.error(f"Error getting user connection: {str(e)}")
|
logger.error(f"Error getting user connection: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _getUserConnectionByReference(interface, connectionReference: str, userId: str) -> Optional[UserConnection]:
|
||||||
|
"""
|
||||||
|
Get a user connection by reference string (format: connection:authority:username).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
interface: Database interface
|
||||||
|
connectionReference: Reference string like 'connection:msft:user@email.com'
|
||||||
|
userId: User ID to verify ownership
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UserConnection if found and belongs to user, None otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Parse reference format: connection:{authority}:{username} [status:..., token:...]
|
||||||
|
# Remove state information if present
|
||||||
|
baseReference = connectionReference.split(' [')[0]
|
||||||
|
|
||||||
|
parts = baseReference.split(':')
|
||||||
|
if len(parts) < 3 or parts[0] != "connection":
|
||||||
|
logger.warning(f"Invalid connection reference format: {connectionReference}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
authority = parts[1] # e.g., 'msft'
|
||||||
|
username = ':'.join(parts[2:]) # Handle usernames with colons
|
||||||
|
|
||||||
|
# Get user connections and find matching one
|
||||||
|
connections = interface.getUserConnections(userId)
|
||||||
|
for conn in connections:
|
||||||
|
connAuthority = conn.authority.value if hasattr(conn.authority, 'value') else str(conn.authority)
|
||||||
|
if connAuthority.lower() == authority.lower() and conn.externalUsername == username:
|
||||||
|
return conn
|
||||||
|
|
||||||
|
logger.debug(f"No connection found for reference: {connectionReference}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting user connection by reference: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
@router.get("/{connectionId}/sites", response_model=List[Dict[str, Any]])
|
@router.get("/{connectionId}/sites", response_model=List[Dict[str, Any]])
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def get_sharepoint_sites(
|
async def get_sharepoint_sites(
|
||||||
|
|
@ -251,3 +289,110 @@ def _extractSitePath(webUrl: str) -> str:
|
||||||
return "/sites/" + webUrl.split("/sites/")[1].split("/")[0]
|
return "/sites/" + webUrl.split("/sites/")[1].split("/")[0]
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Universal folder-options endpoint (by connectionReference)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/folder-options", response_model=List[Dict[str, Any]])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def getSharepointFolderOptionsByReference(
|
||||||
|
request: Request,
|
||||||
|
connectionReference: str = Query(..., description="Connection reference string (e.g., 'connection:msft:user@email.com')"),
|
||||||
|
siteId: Optional[str] = Query(None, description="Specific site ID to browse (if omitted, returns sites only)"),
|
||||||
|
path: Optional[str] = Query(None, description="Folder path within site to browse"),
|
||||||
|
currentUser: User = Depends(getCurrentUser)
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get SharePoint folders formatted as dropdown options (universal endpoint).
|
||||||
|
|
||||||
|
Uses connectionReference instead of connectionId for easier integration.
|
||||||
|
|
||||||
|
Two modes:
|
||||||
|
1. If siteId is not provided: Returns list of sites (for site selection)
|
||||||
|
2. If siteId is provided: Returns folders within that site (optionally at specific path)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connectionReference: Connection reference string (e.g., 'connection:msft:user@email.com')
|
||||||
|
siteId: Optional site ID to browse folders within
|
||||||
|
path: Optional folder path within site
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
interface = getInterface(currentUser)
|
||||||
|
|
||||||
|
# Get the connection by reference
|
||||||
|
connection = _getUserConnectionByReference(interface, connectionReference, currentUser.id)
|
||||||
|
if not connection:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Connection not found for reference: {connectionReference}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify it's a Microsoft connection
|
||||||
|
authority = connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority)
|
||||||
|
if authority.lower() != 'msft':
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Connection is not a Microsoft connection (authority: {authority})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize services
|
||||||
|
services = getServices(currentUser, None)
|
||||||
|
|
||||||
|
# Set access token on SharePoint service
|
||||||
|
if not services.sharepoint.setAccessTokenFromConnection(connection):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Failed to set SharePoint access token. Connection may be expired or invalid."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mode 1: Return sites list if no siteId specified
|
||||||
|
if not siteId:
|
||||||
|
sites = await services.sharepoint.discoverSites()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": "site",
|
||||||
|
"value": site.get("id"),
|
||||||
|
"label": site.get("displayName", "Unknown Site"),
|
||||||
|
"siteId": site.get("id"),
|
||||||
|
"siteName": site.get("displayName", "Unknown Site"),
|
||||||
|
"webUrl": site.get("webUrl", ""),
|
||||||
|
"path": _extractSitePath(site.get("webUrl", ""))
|
||||||
|
}
|
||||||
|
for site in sites
|
||||||
|
]
|
||||||
|
|
||||||
|
# Mode 2: Return folders within specific site
|
||||||
|
folderPath = path or ""
|
||||||
|
items = await services.sharepoint.listFolderContents(siteId, folderPath)
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
return []
|
||||||
|
|
||||||
|
folderOptions = []
|
||||||
|
for item in items:
|
||||||
|
if item.get("type") == "folder":
|
||||||
|
folderName = item.get("name", "")
|
||||||
|
itemPath = f"{folderPath}/{folderName}" if folderPath else folderName
|
||||||
|
|
||||||
|
folderOptions.append({
|
||||||
|
"type": "folder",
|
||||||
|
"value": itemPath,
|
||||||
|
"label": folderName,
|
||||||
|
"siteId": siteId,
|
||||||
|
"folderName": folderName,
|
||||||
|
"path": itemPath,
|
||||||
|
"hasChildren": True # Assume folders may have children
|
||||||
|
})
|
||||||
|
|
||||||
|
return folderOptions
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting SharePoint folder options by reference: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Error getting SharePoint folder options: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]:
|
||||||
from modules.features.trustee.mainTrustee import UI_OBJECTS
|
from modules.features.trustee.mainTrustee import UI_OBJECTS
|
||||||
return UI_OBJECTS
|
return UI_OBJECTS
|
||||||
elif featureCode == "realestate":
|
elif featureCode == "realestate":
|
||||||
from modules.features.realEstate.mainRealEstate import UI_OBJECTS
|
from modules.features.realestate.mainRealEstate import UI_OBJECTS
|
||||||
return UI_OBJECTS
|
return UI_OBJECTS
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Unknown feature code: {featureCode}")
|
logger.warning(f"Unknown feature code: {featureCode}")
|
||||||
|
|
|
||||||
|
|
@ -54,9 +54,11 @@ class FrontendType(str, Enum):
|
||||||
WORKFLOW_ACTION = "workflowAction"
|
WORKFLOW_ACTION = "workflowAction"
|
||||||
"""Workflow action selector - fetches available actions from workflow context"""
|
"""Workflow action selector - fetches available actions from workflow context"""
|
||||||
|
|
||||||
|
SHAREPOINT_FOLDER = "sharepointFolder"
|
||||||
|
"""SharePoint folder selector - requires connectionReference parameter in same action to load folders"""
|
||||||
|
|
||||||
# Additional custom types can be added here as needed
|
# Additional custom types can be added here as needed
|
||||||
# Examples:
|
# Examples:
|
||||||
# SHAREPOINT_FOLDER = "sharepointFolder"
|
|
||||||
# OUTLOOK_FOLDER = "outlookFolder"
|
# OUTLOOK_FOLDER = "outlookFolder"
|
||||||
# JIRA_PROJECT = "jiraProject"
|
# JIRA_PROJECT = "jiraProject"
|
||||||
|
|
||||||
|
|
@ -66,6 +68,7 @@ CUSTOM_TYPE_OPTIONS_API: Dict[FrontendType, str] = {
|
||||||
FrontendType.USER_CONNECTION: "user.connection",
|
FrontendType.USER_CONNECTION: "user.connection",
|
||||||
FrontendType.DOCUMENT_REFERENCE: "workflow.documentReference", # To be implemented
|
FrontendType.DOCUMENT_REFERENCE: "workflow.documentReference", # To be implemented
|
||||||
FrontendType.WORKFLOW_ACTION: "workflow.action", # To be implemented
|
FrontendType.WORKFLOW_ACTION: "workflow.action", # To be implemented
|
||||||
|
FrontendType.SHAREPOINT_FOLDER: "sharepoint.folder", # Dynamic - requires connectionReference
|
||||||
}
|
}
|
||||||
|
|
||||||
# Mapping of custom types to their description
|
# Mapping of custom types to their description
|
||||||
|
|
@ -85,6 +88,11 @@ CUSTOM_TYPE_DESCRIPTIONS: Dict[FrontendType, Dict[str, str]] = {
|
||||||
"fr": "Action de workflow",
|
"fr": "Action de workflow",
|
||||||
"de": "Workflow-Aktion"
|
"de": "Workflow-Aktion"
|
||||||
},
|
},
|
||||||
|
FrontendType.SHAREPOINT_FOLDER: {
|
||||||
|
"en": "SharePoint Folder",
|
||||||
|
"fr": "Dossier SharePoint",
|
||||||
|
"de": "SharePoint-Ordner"
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,15 @@ NAVIGATION_SECTIONS = [
|
||||||
"order": 30,
|
"order": 30,
|
||||||
"public": True,
|
"public": True,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "automation-templates",
|
||||||
|
"objectKey": "ui.system.automation-templates",
|
||||||
|
"label": {"en": "Templates", "de": "Vorlagen", "fr": "Modèles"},
|
||||||
|
"icon": "FaFileAlt",
|
||||||
|
"path": "/workflows/automation-templates",
|
||||||
|
"order": 35,
|
||||||
|
"public": True,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,11 @@ def loadFeatureRouters(app: FastAPI) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Dynamically load and register routers from all discovered feature containers.
|
Dynamically load and register routers from all discovered feature containers.
|
||||||
Also registers feature template roles and AccessRules in the database.
|
Also registers feature template roles and AccessRules in the database.
|
||||||
|
|
||||||
|
Searches for:
|
||||||
|
- 'router' (main feature router)
|
||||||
|
- 'templateRouter' (additional router for templates)
|
||||||
|
- Any other *Router named exports
|
||||||
"""
|
"""
|
||||||
results = {}
|
results = {}
|
||||||
pattern = os.path.join(FEATURES_DIR, "*", "routeFeature*.py")
|
pattern = os.path.join(FEATURES_DIR, "*", "routeFeature*.py")
|
||||||
|
|
@ -55,12 +60,26 @@ def loadFeatureRouters(app: FastAPI) -> Dict[str, Any]:
|
||||||
modulePath = f"modules.features.{featureDir}.{routerFile}"
|
modulePath = f"modules.features.{featureDir}.{routerFile}"
|
||||||
module = importlib.import_module(modulePath)
|
module = importlib.import_module(modulePath)
|
||||||
|
|
||||||
|
loadedRouters = []
|
||||||
|
|
||||||
|
# Load main router
|
||||||
if hasattr(module, "router"):
|
if hasattr(module, "router"):
|
||||||
app.include_router(module.router)
|
app.include_router(module.router)
|
||||||
logger.info(f"Loaded router: {featureDir}")
|
loadedRouters.append("router")
|
||||||
results[featureDir] = {"status": "loaded", "module": modulePath}
|
|
||||||
|
# Load additional named routers (e.g., templateRouter)
|
||||||
|
for attrName in dir(module):
|
||||||
|
if attrName.endswith("Router") and attrName != "APIRouter":
|
||||||
|
routerObj = getattr(module, attrName)
|
||||||
|
if hasattr(routerObj, "routes"): # Check if it's a FastAPI router
|
||||||
|
app.include_router(routerObj)
|
||||||
|
loadedRouters.append(attrName)
|
||||||
|
|
||||||
|
if loadedRouters:
|
||||||
|
logger.info(f"Loaded routers from {featureDir}: {loadedRouters}")
|
||||||
|
results[featureDir] = {"status": "loaded", "module": modulePath, "routers": loadedRouters}
|
||||||
else:
|
else:
|
||||||
logger.warning(f"No 'router' in {modulePath}")
|
logger.warning(f"No routers found in {modulePath}")
|
||||||
results[featureDir] = {"status": "no_router_object"}
|
results[featureDir] = {"status": "no_router_object"}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. Load automation definition (with system fields for _createdBy access)
|
# 1. Load automation definition (with system fields for _createdBy access)
|
||||||
automation = services.interfaceDbChat.getAutomationDefinition(automationId, includeSystemFields=True)
|
automation = services.interfaceDbAutomation.getAutomationDefinition(automationId, includeSystemFields=True)
|
||||||
if not automation:
|
if not automation:
|
||||||
raise ValueError(f"Automation {automationId} not found")
|
raise ValueError(f"Automation {automationId} not found")
|
||||||
|
|
||||||
|
|
@ -160,7 +160,7 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
|
||||||
if len(executionLogs) > 50:
|
if len(executionLogs) > 50:
|
||||||
executionLogs = executionLogs[-50:]
|
executionLogs = executionLogs[-50:]
|
||||||
|
|
||||||
services.interfaceDbChat.updateAutomationDefinition(
|
services.interfaceDbAutomation.updateAutomationDefinition(
|
||||||
automationId,
|
automationId,
|
||||||
{"executionLogs": executionLogs}
|
{"executionLogs": executionLogs}
|
||||||
)
|
)
|
||||||
|
|
@ -173,13 +173,13 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
|
||||||
|
|
||||||
# Update automation with execution log even on error
|
# Update automation with execution log even on error
|
||||||
try:
|
try:
|
||||||
automation = services.interfaceDbChat.getAutomationDefinition(automationId)
|
automation = services.interfaceDbAutomation.getAutomationDefinition(automationId)
|
||||||
if automation:
|
if automation:
|
||||||
executionLogs = list(automation.executionLogs or [])
|
executionLogs = list(automation.executionLogs or [])
|
||||||
executionLogs.append(executionLog)
|
executionLogs.append(executionLog)
|
||||||
if len(executionLogs) > 50:
|
if len(executionLogs) > 50:
|
||||||
executionLogs = executionLogs[-50:]
|
executionLogs = executionLogs[-50:]
|
||||||
services.interfaceDbChat.updateAutomationDefinition(
|
services.interfaceDbAutomation.updateAutomationDefinition(
|
||||||
automationId,
|
automationId,
|
||||||
{"executionLogs": executionLogs}
|
{"executionLogs": executionLogs}
|
||||||
)
|
)
|
||||||
|
|
@ -200,7 +200,7 @@ async def syncAutomationEvents(services, eventUser) -> Dict[str, Any]:
|
||||||
Dictionary with sync results (synced count and event IDs)
|
Dictionary with sync results (synced count and event IDs)
|
||||||
"""
|
"""
|
||||||
# Get all automation definitions filtered by RBAC (for current mandate)
|
# Get all automation definitions filtered by RBAC (for current mandate)
|
||||||
filtered = services.interfaceDbChat.getAllAutomationDefinitionsWithRBAC(eventUser)
|
filtered = services.interfaceDbAutomation.getAllAutomationDefinitionsWithRBAC(eventUser)
|
||||||
|
|
||||||
registeredEvents = {}
|
registeredEvents = {}
|
||||||
|
|
||||||
|
|
@ -249,7 +249,7 @@ async def syncAutomationEvents(services, eventUser) -> Dict[str, Any]:
|
||||||
|
|
||||||
# Update automation with new eventId
|
# Update automation with new eventId
|
||||||
if currentEventId != newEventId:
|
if currentEventId != newEventId:
|
||||||
services.interfaceDbChat.updateAutomationDefinition(
|
services.interfaceDbAutomation.updateAutomationDefinition(
|
||||||
automationId,
|
automationId,
|
||||||
{"eventId": newEventId}
|
{"eventId": newEventId}
|
||||||
)
|
)
|
||||||
|
|
@ -260,7 +260,7 @@ async def syncAutomationEvents(services, eventUser) -> Dict[str, Any]:
|
||||||
if currentEventId:
|
if currentEventId:
|
||||||
try:
|
try:
|
||||||
eventManager.remove(currentEventId)
|
eventManager.remove(currentEventId)
|
||||||
services.interfaceDbChat.updateAutomationDefinition(
|
services.interfaceDbAutomation.updateAutomationDefinition(
|
||||||
automationId,
|
automationId,
|
||||||
{"eventId": None}
|
{"eventId": None}
|
||||||
)
|
)
|
||||||
|
|
@ -295,7 +295,7 @@ def createAutomationEventHandler(automationId: str, eventUser):
|
||||||
eventServices = getServices(eventUser, None)
|
eventServices = getServices(eventUser, None)
|
||||||
|
|
||||||
# Load automation using event user context (with system fields for _createdBy access)
|
# Load automation using event user context (with system fields for _createdBy access)
|
||||||
automation = eventServices.interfaceDbChat.getAutomationDefinition(automationId, includeSystemFields=True)
|
automation = eventServices.interfaceDbAutomation.getAutomationDefinition(automationId, includeSystemFields=True)
|
||||||
if not automation or not getattr(automation, "active", False):
|
if not automation or not getattr(automation, "active", False):
|
||||||
logger.warning(f"Automation {automationId} not found or not active, skipping execution")
|
logger.warning(f"Automation {automationId} not found or not active, skipping execution")
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ class MethodSharepoint(MethodBase):
|
||||||
"pathQuery": WorkflowActionParameter(
|
"pathQuery": WorkflowActionParameter(
|
||||||
name="pathQuery",
|
name="pathQuery",
|
||||||
type="str",
|
type="str",
|
||||||
frontendType=FrontendType.TEXT,
|
frontendType=FrontendType.SHAREPOINT_FOLDER,
|
||||||
required=False,
|
required=False,
|
||||||
description="Direct path query if no documentList (e.g., /sites/SiteName/FolderPath)"
|
description="Direct path query if no documentList (e.g., /sites/SiteName/FolderPath)"
|
||||||
),
|
),
|
||||||
|
|
@ -146,7 +146,7 @@ class MethodSharepoint(MethodBase):
|
||||||
"pathQuery": WorkflowActionParameter(
|
"pathQuery": WorkflowActionParameter(
|
||||||
name="pathQuery",
|
name="pathQuery",
|
||||||
type="str",
|
type="str",
|
||||||
frontendType=FrontendType.TEXT,
|
frontendType=FrontendType.SHAREPOINT_FOLDER,
|
||||||
required=False,
|
required=False,
|
||||||
description="Direct upload target path if documentList doesn't contain findDocumentPath result (e.g., /sites/SiteName/FolderPath)"
|
description="Direct upload target path if documentList doesn't contain findDocumentPath result (e.g., /sites/SiteName/FolderPath)"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue