fixed automation and trustee

This commit is contained in:
ValueOn AG 2026-01-24 02:06:49 +01:00
parent a0b9a6e4b5
commit 50e3fce12b
8 changed files with 296 additions and 35 deletions

3
app.py
View file

@ -47,6 +47,9 @@ class DailyRotatingFileHandler(RotatingFileHandler):
def _updateFileIfNeeded(self): def _updateFileIfNeeded(self):
"""Update the log file if the date has changed""" """Update the log file if the date has changed"""
# Guard against interpreter shutdown when datetime may be None
if datetime is None:
return False
today = datetime.now().strftime("%Y%m%d") today = datetime.now().strftime("%Y%m%d")
if self.currentDate != today: if self.currentDate != today:

View file

@ -14,7 +14,7 @@ import json
# Import interfaces and models # Import interfaces and models
from modules.interfaces.interfaceDbChat import getInterface as getChatInterface from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
from modules.auth import getCurrentUser, limiter from modules.auth import getCurrentUser, limiter, getRequestContext, RequestContext
from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition
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
@ -46,7 +46,7 @@ router = APIRouter(
async def get_automations( async def get_automations(
request: Request, request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[AutomationDefinition]: ) -> PaginatedResponse[AutomationDefinition]:
""" """
Get automation definitions with optional pagination, sorting, and filtering. Get automation definitions with optional pagination, sorting, and filtering.
@ -69,7 +69,7 @@ async def get_automations(
detail=f"Invalid pagination parameter: {str(e)}" detail=f"Invalid pagination parameter: {str(e)}"
) )
chatInterface = getChatInterface(currentUser) chatInterface = getChatInterface(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
@ -111,11 +111,11 @@ async def get_automations(
async def create_automation( async def create_automation(
request: Request, request: Request,
automation: AutomationDefinition, automation: AutomationDefinition,
currentUser = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> AutomationDefinition: ) -> AutomationDefinition:
"""Create a new automation definition""" """Create a new automation definition"""
try: try:
chatInterface = getChatInterface(currentUser) chatInterface = getChatInterface(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
@ -132,7 +132,7 @@ async def create_automation(
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def get_automation_templates( async def get_automation_templates(
request: Request, request: Request,
currentUser = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> JSONResponse: ) -> JSONResponse:
""" """
Get automation templates from backend module. Get automation templates from backend module.
@ -160,11 +160,11 @@ async def get_automation_attributes(
async def get_automation( async def get_automation(
request: Request, request: Request,
automationId: str = Path(..., description="Automation ID"), automationId: str = Path(..., description="Automation ID"),
currentUser = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> AutomationDefinition: ) -> AutomationDefinition:
"""Get a single automation definition by ID""" """Get a single automation definition by ID"""
try: try:
chatInterface = getChatInterface(currentUser) chatInterface = getChatInterface(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(
@ -188,11 +188,11 @@ async def update_automation(
request: Request, request: Request,
automationId: str = Path(..., description="Automation ID"), automationId: str = Path(..., description="Automation ID"),
automation: AutomationDefinition = Body(...), automation: AutomationDefinition = Body(...),
currentUser = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> AutomationDefinition: ) -> AutomationDefinition:
"""Update an automation definition""" """Update an automation definition"""
try: try:
chatInterface = getChatInterface(currentUser) chatInterface = getChatInterface(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
@ -215,11 +215,11 @@ async def update_automation(
async def delete_automation( async def delete_automation(
request: Request, request: Request,
automationId: str = Path(..., description="Automation ID"), automationId: str = Path(..., description="Automation ID"),
currentUser = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> Response: ) -> Response:
"""Delete an automation definition""" """Delete an automation definition"""
try: try:
chatInterface = getChatInterface(currentUser) chatInterface = getChatInterface(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)
@ -244,15 +244,15 @@ async def delete_automation(
@router.post("/{automationId}/execute", response_model=ChatWorkflow) @router.post("/{automationId}/execute", response_model=ChatWorkflow)
@limiter.limit("5/minute") @limiter.limit("5/minute")
async def execute_automation( async def execute_automation_route(
request: Request, request: Request,
automationId: str = Path(..., description="Automation ID"), automationId: str = Path(..., description="Automation ID"),
currentUser = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> ChatWorkflow: ) -> ChatWorkflow:
"""Execute an automation immediately (test mode)""" """Execute an automation immediately (test mode)"""
try: try:
from modules.services import getInterface as getServices from modules.services import getInterface as getServices
services = getServices(currentUser, None) services = getServices(context.user, context.mandateId)
workflow = await executeAutomation(automationId, services) workflow = await executeAutomation(automationId, services)
return workflow return workflow
except HTTPException: except HTTPException:

View file

@ -37,6 +37,7 @@ def discoverFeatureContainers() -> List[str]:
def loadFeatureRouters(app: FastAPI) -> Dict[str, Any]: 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.
""" """
results = {} results = {}
pattern = os.path.join(FEATURES_DIR, "*", "routeFeature*.py") pattern = os.path.join(FEATURES_DIR, "*", "routeFeature*.py")
@ -64,6 +65,15 @@ def loadFeatureRouters(app: FastAPI) -> Dict[str, Any]:
logger.error(f"Failed to load router from {featureDir}: {e}") logger.error(f"Failed to load router from {featureDir}: {e}")
results[featureDir] = {"status": "error", "error": str(e)} results[featureDir] = {"status": "error", "error": str(e)}
# Register features in RBAC catalog and sync template roles to database
from modules.security.rbacCatalog import getCatalogService
catalogService = getCatalogService()
registrationResults = registerAllFeaturesInCatalog(catalogService)
for featureName, success in registrationResults.items():
if featureName in results:
results[featureName]["rbac_registered"] = success
return results return results

View file

@ -103,7 +103,8 @@ RESOURCE_OBJECTS = [
}, },
] ]
# Template roles for this feature # Template roles for this feature with AccessRules
# Each role defines default UI and DATA permissions
TEMPLATE_ROLES = [ TEMPLATE_ROLES = [
{ {
"roleLabel": "trustee-admin", "roleLabel": "trustee-admin",
@ -111,7 +112,13 @@ TEMPLATE_ROLES = [
"en": "Trustee Administrator - Full access to all trustee data and settings", "en": "Trustee Administrator - Full access to all trustee data and settings",
"de": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen", "de": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen",
"fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires" "fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires"
} },
"accessRules": [
# Full UI access
{"context": "UI", "item": None, "view": True},
# Full DATA access
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
]
}, },
{ {
"roleLabel": "trustee-accountant", "roleLabel": "trustee-accountant",
@ -119,7 +126,13 @@ TEMPLATE_ROLES = [
"en": "Trustee Accountant - Manage accounting and financial data", "en": "Trustee Accountant - Manage accounting and financial data",
"de": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten", "de": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten",
"fr": "Comptable fiduciaire - Gérer les données comptables et financières" "fr": "Comptable fiduciaire - Gérer les données comptables et financières"
} },
"accessRules": [
# Full UI access
{"context": "UI", "item": None, "view": True},
# Group-level DATA access
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
]
}, },
{ {
"roleLabel": "trustee-client", "roleLabel": "trustee-client",
@ -127,7 +140,13 @@ TEMPLATE_ROLES = [
"en": "Trustee Client - View own accounting data and documents", "en": "Trustee Client - View own accounting data and documents",
"de": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen", "de": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen",
"fr": "Client fiduciaire - Consulter ses propres données comptables et documents" "fr": "Client fiduciaire - Consulter ses propres données comptables et documents"
} },
"accessRules": [
# Full UI access
{"context": "UI", "item": None, "view": True},
# Own records only (MY level)
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
]
}, },
] ]
@ -185,9 +204,198 @@ def registerFeature(catalogService) -> bool:
meta=resObj.get("meta") meta=resObj.get("meta")
) )
# Sync template roles to database (with AccessRules)
_syncTemplateRolesToDb()
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects") logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects")
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}") logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}")
return False return False
def _syncTemplateRolesToDb() -> int:
"""
Sync template roles and their AccessRules to the database.
Creates global template roles (mandateId=None) if they don't exist.
Returns:
Number of roles created/updated
"""
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
rootInterface = getRootInterface()
db = rootInterface.db
# Get existing template roles for this feature
existingRoles = db.getRecordset(
Role,
recordFilter={"featureCode": FEATURE_CODE, "mandateId": None}
)
existingRoleLabels = {r.get("roleLabel"): r.get("id") for r in existingRoles}
createdCount = 0
for roleTemplate in TEMPLATE_ROLES:
roleLabel = roleTemplate["roleLabel"]
if roleLabel in existingRoleLabels:
roleId = existingRoleLabels[roleLabel]
logger.debug(f"Template role '{roleLabel}' already exists with ID {roleId}")
# Ensure AccessRules exist for this role
_ensureAccessRulesForRole(db, roleId, roleTemplate.get("accessRules", []))
else:
# Create new template role
newRole = Role(
roleLabel=roleLabel,
description=roleTemplate.get("description", {}),
featureCode=FEATURE_CODE,
mandateId=None, # Global template
featureInstanceId=None,
isSystemRole=False
)
createdRole = db.recordCreate(Role, newRole.model_dump())
roleId = createdRole.get("id")
# Create AccessRules for this role
_ensureAccessRulesForRole(db, roleId, roleTemplate.get("accessRules", []))
logger.info(f"Created template role '{roleLabel}' with ID {roleId}")
createdCount += 1
if createdCount > 0:
logger.info(f"Feature '{FEATURE_CODE}': Created {createdCount} template roles")
# Repair instance-specific roles that are missing AccessRules
_repairInstanceRolesAccessRules(db, existingRoleLabels)
return createdCount
except Exception as e:
logger.error(f"Error syncing template roles for feature '{FEATURE_CODE}': {e}")
return 0
def _repairInstanceRolesAccessRules(db, templateRoleLabels: Dict[str, str]) -> int:
"""
Repair instance-specific roles by copying AccessRules from their template roles.
This ensures instance roles created before AccessRules were defined get updated.
Args:
db: Database connector
templateRoleLabels: Dict mapping roleLabel to template role ID
Returns:
Number of instance roles repaired
"""
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
repairedCount = 0
# Get all instance-specific roles for this feature (mandateId is NOT None)
allRoles = db.getRecordset(Role, recordFilter={"featureCode": FEATURE_CODE})
instanceRoles = [r for r in allRoles if r.get("mandateId") is not None]
for instanceRole in instanceRoles:
roleLabel = instanceRole.get("roleLabel")
instanceRoleId = instanceRole.get("id")
# Find matching template role
templateRoleId = templateRoleLabels.get(roleLabel)
if not templateRoleId:
continue
# Check if instance role has AccessRules
existingRules = db.getRecordset(AccessRule, recordFilter={"roleId": instanceRoleId})
if existingRules:
continue # Already has rules, skip
# Copy AccessRules from template role
templateRules = db.getRecordset(AccessRule, recordFilter={"roleId": templateRoleId})
if not templateRules:
continue # Template has no rules
for rule in templateRules:
newRule = AccessRule(
roleId=instanceRoleId,
context=rule.get("context"),
item=rule.get("item"),
view=rule.get("view", False),
read=rule.get("read"),
create=rule.get("create"),
update=rule.get("update"),
delete=rule.get("delete"),
)
db.recordCreate(AccessRule, newRule.model_dump())
logger.info(f"Repaired instance role '{roleLabel}' (ID: {instanceRoleId}): copied {len(templateRules)} AccessRules from template")
repairedCount += 1
if repairedCount > 0:
logger.info(f"Feature '{FEATURE_CODE}': Repaired {repairedCount} instance roles with missing AccessRules")
return repairedCount
def _ensureAccessRulesForRole(db, roleId: str, ruleTemplates: List[Dict[str, Any]]) -> int:
"""
Ensure AccessRules exist for a role based on templates.
Args:
db: Database connector
roleId: Role ID
ruleTemplates: List of rule templates
Returns:
Number of rules created
"""
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
# Get existing rules for this role
existingRules = db.getRecordset(AccessRule, recordFilter={"roleId": roleId})
# Create a set of existing rule signatures to avoid duplicates
existingSignatures = set()
for rule in existingRules:
sig = (rule.get("context"), rule.get("item"))
existingSignatures.add(sig)
createdCount = 0
for template in ruleTemplates:
context = template.get("context", "UI")
item = template.get("item")
sig = (context, item)
if sig in existingSignatures:
continue
# Map context string to enum
if context == "UI":
contextEnum = AccessRuleContext.UI
elif context == "DATA":
contextEnum = AccessRuleContext.DATA
elif context == "RESOURCE":
contextEnum = AccessRuleContext.RESOURCE
else:
contextEnum = context
newRule = AccessRule(
roleId=roleId,
context=contextEnum,
item=item,
view=template.get("view", False),
read=template.get("read"),
create=template.get("create"),
update=template.get("update"),
delete=template.get("delete"),
)
db.recordCreate(AccessRule, newRule.model_dump())
createdCount += 1
if createdCount > 0:
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
return createdCount

View file

@ -1729,8 +1729,14 @@ class ChatObjects:
totalPages=totalPages totalPages=totalPages
) )
def getAutomationDefinition(self, automationId: str) -> Optional[AutomationDefinition]: def getAutomationDefinition(self, automationId: str, includeSystemFields: bool = False) -> Optional[AutomationDefinition]:
"""Returns an automation definition by ID if user has access, with computed status.""" """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: try:
# Use RBAC filtering # Use RBAC filtering
filtered = getRecordsetWithRBAC(self.db, filtered = getRecordsetWithRBAC(self.db,
@ -1749,6 +1755,16 @@ class ChatObjects:
automation["executionLogs"] = [] automation["executionLogs"] = []
# Enrich with user and mandate names # Enrich with user and mandate names
self._enrichAutomationWithUserAndMandate(automation) 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 # Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")} cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")}
return AutomationDefinition(**cleanedRecord) return AutomationDefinition(**cleanedRecord)
@ -1771,9 +1787,12 @@ class ChatObjects:
# Ensure database connector has correct userId context # Ensure database connector has correct userId context
# The connector should have been initialized with userId, but ensure it's updated # The connector should have been initialized with userId, but ensure it's updated
if self.userId and hasattr(self.db, 'updateContext'): if not self.userId:
logger.error(f"createAutomationDefinition: userId is not set! Cannot set _createdBy. currentUser={self.currentUser}")
elif hasattr(self.db, 'updateContext'):
try: try:
self.db.updateContext(self.userId) self.db.updateContext(self.userId)
logger.debug(f"createAutomationDefinition: Updated database context with userId={self.userId}")
except Exception as e: except Exception as e:
logger.warning(f"Could not update database context: {e}") logger.warning(f"Could not update database context: {e}")

View file

@ -248,7 +248,10 @@ def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict
recordFilter={"userId": userId, "featureInstanceId": instanceId} recordFilter={"userId": userId, "featureInstanceId": instanceId}
) )
logger.debug(f"_getInstancePermissions: userId={userId}, instanceId={instanceId}, featureAccesses={len(featureAccesses) if featureAccesses else 0}")
if not featureAccesses: if not featureAccesses:
logger.debug(f"_getInstancePermissions: No FeatureAccess found for user {userId} and instance {instanceId}")
return permissions return permissions
# Get role IDs via FeatureAccessRole junction table # Get role IDs via FeatureAccessRole junction table
@ -259,7 +262,10 @@ def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict
) )
roleIds = [far.get("roleId") for far in featureAccessRoles] roleIds = [far.get("roleId") for far in featureAccessRoles]
logger.debug(f"_getInstancePermissions: featureAccessId={featureAccessId}, roleIds={roleIds}")
if not roleIds: if not roleIds:
logger.debug(f"_getInstancePermissions: No roles found for FeatureAccess {featureAccessId}")
return permissions return permissions
# Get permissions (AccessRules) for all roles # Get permissions (AccessRules) for all roles
@ -269,6 +275,8 @@ def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict
recordFilter={"roleId": roleId} recordFilter={"roleId": roleId}
) )
logger.debug(f"_getInstancePermissions: roleId={roleId}, accessRules={len(accessRules) if accessRules else 0}")
for rule in accessRules: for rule in accessRules:
context = rule.get("context", "") context = rule.get("context", "")
item = rule.get("item", "") item = rule.get("item", "")
@ -303,8 +311,13 @@ def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict
# Handle UI context (views) # Handle UI context (views)
elif context == "UI" or context == AccessRuleContext.UI: elif context == "UI" or context == AccessRuleContext.UI:
ruleView = rule.get("view", False)
if item: if item:
permissions["views"][item] = permissions["views"].get(item, False) or rule.get("view", False) # Specific view rule
permissions["views"][item] = permissions["views"].get(item, False) or ruleView
elif ruleView:
# item=None means all views - set a wildcard flag
permissions["views"]["_all"] = True
return permissions return permissions

View file

@ -5,6 +5,7 @@ Shared utilities for model attributes and labels.
""" """
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, Field, ConfigDict
from pydantic_core import PydanticUndefined
from typing import Dict, Any, List, Type, Optional, Union from typing import Dict, Any, List, Type, Optional, Union
import inspect import inspect
import importlib import importlib
@ -212,7 +213,8 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
# Don't call it here - it's meant to be called per-instance # Don't call it here - it's meant to be called per-instance
# Instead, store a marker that indicates it exists # Instead, store a marker that indicates it exists
field_default = None # Frontend should use first option or specific logic field_default = None # Frontend should use first option or specific logic
elif default_value is not ...: # Ellipsis means no default elif default_value is not ... and default_value is not PydanticUndefined:
# Ellipsis or PydanticUndefined means no default
# Convert enum to its value if it's an enum # Convert enum to its value if it's an enum
if hasattr(default_value, 'value'): if hasattr(default_value, 'value'):
field_default = default_value.value field_default = default_value.value

View file

@ -76,8 +76,8 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
} }
try: try:
# 1. Load automation definition # 1. Load automation definition (with system fields for _createdBy access)
automation = services.interfaceDbChat.getAutomationDefinition(automationId) automation = services.interfaceDbChat.getAutomationDefinition(automationId, includeSystemFields=True)
if not automation: if not automation:
raise ValueError(f"Automation {automationId} not found") raise ValueError(f"Automation {automationId} not found")
@ -105,18 +105,17 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
# 3. Get user who created automation # 3. Get user who created automation
creatorUserId = getattr(automation, "_createdBy", None) creatorUserId = getattr(automation, "_createdBy", None)
# CRITICAL: Automation MUST run as creator user only, or fail # _createdBy is a system attribute - must be present
if not creatorUserId: if not creatorUserId:
errorMsg = f"Automation {automationId} has no creator user (_createdBy field missing). Cannot execute automation." errorMsg = f"Automation {automationId} has no creator user (_createdBy field missing). Cannot execute automation."
logger.error(errorMsg) logger.error(errorMsg)
executionLog["messages"].append(errorMsg) executionLog["messages"].append(errorMsg)
raise ValueError(errorMsg) raise ValueError(errorMsg)
# Get user from database using services # Get creator user from database
creatorUser = services.interfaceDbApp.getUser(creatorUserId) creatorUser = services.interfaceDbApp.getUser(creatorUserId)
if not creatorUser: if not creatorUser:
raise ValueError(f"Creator user {creatorUserId} not found") raise ValueError(f"Creator user {creatorUserId} not found")
executionLog["messages"].append(f"Using creator user: {creatorUserId}") executionLog["messages"].append(f"Using creator user: {creatorUserId}")
# 4. Create UserInputRequest from plan # 4. Create UserInputRequest from plan
@ -205,10 +204,17 @@ async def syncAutomationEvents(services, eventUser) -> Dict[str, Any]:
registeredEvents = {} registeredEvents = {}
for automation in filtered: for automation in filtered:
automationId = automation.id # Handle both dict and object access patterns
isActive = automation.active if hasattr(automation, 'active') else False if isinstance(automation, dict):
currentEventId = automation.eventId if hasattr(automation, 'eventId') else None automationId = automation.get('id')
schedule = automation.schedule if hasattr(automation, 'schedule') else None isActive = automation.get('active', False)
currentEventId = automation.get('eventId')
schedule = automation.get('schedule')
else:
automationId = automation.id
isActive = automation.active if hasattr(automation, 'active') else False
currentEventId = automation.eventId if hasattr(automation, 'eventId') else None
schedule = automation.schedule if hasattr(automation, 'schedule') else None
if not schedule: if not schedule:
logger.warning(f"Automation {automationId} has no schedule, skipping") logger.warning(f"Automation {automationId} has no schedule, skipping")
@ -287,8 +293,8 @@ def createAutomationEventHandler(automationId: str, eventUser):
# Get services for event user (provides access to interfaces) # Get services for event user (provides access to interfaces)
eventServices = getServices(eventUser, None) eventServices = getServices(eventUser, None)
# Load automation using event user context # Load automation using event user context (with system fields for _createdBy access)
automation = eventServices.interfaceDbChat.getAutomationDefinition(automationId) automation = eventServices.interfaceDbChat.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