From edeaf64fa4f27a0f340835791eaea3438039cd5f Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 3 Feb 2026 23:42:27 +0100
Subject: [PATCH] automation template/definition editor
---
modules/datamodels/datamodelUam.py | 18 +-
.../automation/interfaceFeatureAutomation.py | 2 +-
.../automation/routeFeatureAutomation.py | 187 +++++++++---------
.../interfaceFeatureNeutralizer.py | 2 +-
.../trustee/datamodelFeatureTrustee.py | 4 +-
.../trustee/interfaceFeatureTrustee.py | 6 +-
modules/interfaces/interfaceBootstrap.py | 171 +++++++++++++---
modules/routes/routeSharepoint.py | 145 ++++++++++++++
modules/routes/routeSystem.py | 2 +-
modules/shared/frontendTypes.py | 10 +-
.../methodSharepoint/methodSharepoint.py | 4 +-
11 files changed, 419 insertions(+), 132 deletions(-)
diff --git a/modules/datamodels/datamodelUam.py b/modules/datamodels/datamodelUam.py
index b7878532..b0e6b468 100644
--- a/modules/datamodels/datamodelUam.py
+++ b/modules/datamodels/datamodelUam.py
@@ -12,7 +12,7 @@ Multi-Tenant Design:
import uuid
from typing import Optional, List
from enum import Enum
-from pydantic import BaseModel, Field, EmailStr, field_validator
+from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
@@ -114,6 +114,20 @@ class UserConnection(BaseModel):
{"value": "none", "label": {"en": "None", "fr": "Aucun"}},
]})
tokenExpiresAt: Optional[float] = Field(None, description="When the current token expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
+
+ @computed_field
+ @computed_field
+ @property
+ def connectionReference(self) -> str:
+ """Generate connection reference string in format: connection:{authority}:{username}"""
+ return f"connection:{self.authority.value}:{self.externalUsername}"
+
+ @computed_field
+ @property
+ def displayLabel(self) -> str:
+ """Human-readable label for display in dropdowns"""
+ authorityLabels = {"msft": "Microsoft", "google": "Google", "local": "Local"}
+ return f"{authorityLabels.get(self.authority.value, self.authority.value)}: {self.externalUsername}"
registerModelLabels(
@@ -132,6 +146,8 @@ registerModelLabels(
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"},
"tokenExpiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
+ "connectionReference": {"en": "Connection Reference", "de": "Verbindungsreferenz", "fr": "Référence de connexion"},
+ "displayLabel": {"en": "Display Label", "de": "Anzeigebezeichnung", "fr": "Libellé d'affichage"},
},
)
diff --git a/modules/features/automation/interfaceFeatureAutomation.py b/modules/features/automation/interfaceFeatureAutomation.py
index e169c86c..34102f5e 100644
--- a/modules/features/automation/interfaceFeatureAutomation.py
+++ b/modules/features/automation/interfaceFeatureAutomation.py
@@ -52,7 +52,7 @@ class AutomationObjects:
"""Initializes the database connection with proper configuration."""
# Get configuration values
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
- dbDatabase = "poweron_app"
+ dbDatabase = "poweron_automation"
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
diff --git a/modules/features/automation/routeFeatureAutomation.py b/modules/features/automation/routeFeatureAutomation.py
index ef2dac62..d9c0b758 100644
--- a/modules/features/automation/routeFeatureAutomation.py
+++ b/modules/features/automation/routeFeatureAutomation.py
@@ -134,6 +134,100 @@ async def get_automation_attributes(
"""Get attribute definitions for AutomationDefinition model"""
return {"attributes": automationAttributes}
+
+@router.get("/actions")
+@limiter.limit("30/minute")
+async def get_available_actions(
+ request: Request,
+ context: RequestContext = Depends(getRequestContext)
+) -> JSONResponse:
+ """
+ Get available workflow actions for template editor.
+ Returns action definitions with parameters and example JSON snippets.
+ """
+ try:
+ from modules.workflows.processing.shared.methodDiscovery import methods, discoverMethods
+ from modules.services import getInterface as getServices
+
+ # Ensure methods are discovered (need a service center for discovery)
+ if not methods:
+ # Create a lightweight service center for method discovery
+ services = getServices(context.user, context.mandateId)
+ discoverMethods(services)
+
+ actionsList = []
+ processedMethods = set()
+
+ for methodName, methodInfo in methods.items():
+ # Skip short name aliases - only process full class names (MethodXxx)
+ if not methodName.startswith('Method'):
+ continue
+
+ shortName = methodName.replace('Method', '').lower()
+
+ # Skip if already processed
+ if shortName in processedMethods:
+ continue
+ processedMethods.add(shortName)
+
+ methodInstance = methodInfo.get('instance')
+ if not methodInstance:
+ continue
+
+ # Get actions from method instance
+ for actionName, actionDef in methodInstance._actions.items():
+ # Build action info
+ actionInfo = {
+ "method": shortName,
+ "action": actionName,
+ "actionId": actionDef.actionId if hasattr(actionDef, 'actionId') else f"{shortName}.{actionName}",
+ "description": actionDef.description if hasattr(actionDef, 'description') else "",
+ "category": actionDef.category if hasattr(actionDef, 'category') else "general",
+ "parameters": []
+ }
+
+ # Add parameters from WorkflowActionParameter
+ parametersDef = actionDef.parameters if hasattr(actionDef, 'parameters') else {}
+ for paramName, paramDef in parametersDef.items():
+ paramInfo = {
+ "name": paramName,
+ "type": paramDef.type if hasattr(paramDef, 'type') else "Any",
+ "frontendType": paramDef.frontendType.value if hasattr(paramDef, 'frontendType') and paramDef.frontendType else "text",
+ "required": paramDef.required if hasattr(paramDef, 'required') else False,
+ "default": paramDef.default if hasattr(paramDef, 'default') else None,
+ "description": paramDef.description if hasattr(paramDef, 'description') else "",
+ }
+ if hasattr(paramDef, 'frontendOptions') and paramDef.frontendOptions:
+ paramInfo["frontendOptions"] = paramDef.frontendOptions
+ actionInfo["parameters"].append(paramInfo)
+
+ # Build example JSON snippet for copy/paste
+ exampleParams = {}
+ for paramName, paramDef in parametersDef.items():
+ if hasattr(paramDef, 'required') and paramDef.required:
+ exampleParams[paramName] = f"{{{{KEY:{paramName}}}}}"
+ else:
+ default = paramDef.default if hasattr(paramDef, 'default') else None
+ exampleParams[paramName] = default or f"{{{{KEY:{paramName}}}}}"
+
+ actionInfo["exampleJson"] = {
+ "execMethod": shortName,
+ "execAction": actionName,
+ "execParameters": exampleParams,
+ "execResultLabel": f"{shortName}_{actionName}_result"
+ }
+
+ actionsList.append(actionInfo)
+
+ return JSONResponse(content={"actions": actionsList})
+ except Exception as e:
+ logger.error(f"Error getting available actions: {str(e)}")
+ raise HTTPException(
+ status_code=500,
+ detail=f"Error getting available actions: {str(e)}"
+ )
+
+
@router.get("/{automationId}", response_model=AutomationDefinition)
@limiter.limit("30/minute")
async def get_automation(
@@ -290,99 +384,6 @@ async def execute_automation_route(
)
-@router.get("/actions")
-@limiter.limit("30/minute")
-async def get_available_actions(
- request: Request,
- context: RequestContext = Depends(getRequestContext)
-) -> JSONResponse:
- """
- Get available workflow actions for template editor.
- Returns action definitions with parameters and example JSON snippets.
- """
- try:
- from modules.workflows.processing.shared.methodDiscovery import methods, discoverMethods
- from modules.services import getInterface as getServices
-
- # Ensure methods are discovered (need a service center for discovery)
- if not methods:
- # Create a lightweight service center for method discovery
- services = getServices(context.user, context.mandateId)
- discoverMethods(services)
-
- actionsList = []
- processedMethods = set()
-
- for methodName, methodInfo in methods.items():
- # Skip short name aliases - only process full class names (MethodXxx)
- if not methodName.startswith('Method'):
- continue
-
- shortName = methodName.replace('Method', '').lower()
-
- # Skip if already processed
- if shortName in processedMethods:
- continue
- processedMethods.add(shortName)
-
- methodInstance = methodInfo.get('instance')
- if not methodInstance:
- continue
-
- # Get actions from method instance
- for actionName, actionDef in methodInstance._actions.items():
- # Build action info
- actionInfo = {
- "method": shortName,
- "action": actionName,
- "actionId": actionDef.actionId if hasattr(actionDef, 'actionId') else f"{shortName}.{actionName}",
- "description": actionDef.description if hasattr(actionDef, 'description') else "",
- "category": actionDef.category if hasattr(actionDef, 'category') else "general",
- "parameters": []
- }
-
- # Add parameters from WorkflowActionParameter
- parametersDef = actionDef.parameters if hasattr(actionDef, 'parameters') else {}
- for paramName, paramDef in parametersDef.items():
- paramInfo = {
- "name": paramName,
- "type": paramDef.type if hasattr(paramDef, 'type') else "Any",
- "frontendType": paramDef.frontendType.value if hasattr(paramDef, 'frontendType') and paramDef.frontendType else "text",
- "required": paramDef.required if hasattr(paramDef, 'required') else False,
- "default": paramDef.default if hasattr(paramDef, 'default') else None,
- "description": paramDef.description if hasattr(paramDef, 'description') else "",
- }
- if hasattr(paramDef, 'frontendOptions') and paramDef.frontendOptions:
- paramInfo["frontendOptions"] = paramDef.frontendOptions
- actionInfo["parameters"].append(paramInfo)
-
- # Build example JSON snippet for copy/paste
- exampleParams = {}
- for paramName, paramDef in parametersDef.items():
- if hasattr(paramDef, 'required') and paramDef.required:
- exampleParams[paramName] = f"{{{{KEY:{paramName}}}}}"
- else:
- default = paramDef.default if hasattr(paramDef, 'default') else None
- exampleParams[paramName] = default or f"{{{{KEY:{paramName}}}}}"
-
- actionInfo["exampleJson"] = {
- "execMethod": shortName,
- "execAction": actionName,
- "execParameters": exampleParams,
- "execResultLabel": f"{shortName}_{actionName}_result"
- }
-
- actionsList.append(actionInfo)
-
- return JSONResponse(content={"actions": actionsList})
- except Exception as e:
- logger.error(f"Error getting available actions: {str(e)}")
- raise HTTPException(
- status_code=500,
- detail=f"Error getting available actions: {str(e)}"
- )
-
-
# =============================================================================
# AutomationTemplate Routes (DB-persistiert)
# =============================================================================
diff --git a/modules/features/neutralization/interfaceFeatureNeutralizer.py b/modules/features/neutralization/interfaceFeatureNeutralizer.py
index 54533166..98b85fdb 100644
--- a/modules/features/neutralization/interfaceFeatureNeutralizer.py
+++ b/modules/features/neutralization/interfaceFeatureNeutralizer.py
@@ -53,7 +53,7 @@ class InterfaceFeatureNeutralizer:
try:
# Use same database config pattern as other feature interfaces
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
- dbDatabase = APP_CONFIG.get("DB_DATABASE_NEUTRALIZATION", APP_CONFIG.get("DB_DATABASE", "poweron"))
+ dbDatabase = "poweron_neutralization"
dbUser = APP_CONFIG.get("DB_USER", "postgres")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
diff --git a/modules/features/trustee/datamodelFeatureTrustee.py b/modules/features/trustee/datamodelFeatureTrustee.py
index d729c1e5..1402f91e 100644
--- a/modules/features/trustee/datamodelFeatureTrustee.py
+++ b/modules/features/trustee/datamodelFeatureTrustee.py
@@ -528,7 +528,9 @@ class TrusteePosition(BaseModel):
"frontend_hidden": True
}
)
- # System attributes are automatically set by DatabaseConnector
+
+ # Allow extra fields like _createdAt from database
+ model_config = {"extra": "allow"}
registerModelLabels(
diff --git a/modules/features/trustee/interfaceFeatureTrustee.py b/modules/features/trustee/interfaceFeatureTrustee.py
index b76bd164..bb6695d3 100644
--- a/modules/features/trustee/interfaceFeatureTrustee.py
+++ b/modules/features/trustee/interfaceFeatureTrustee.py
@@ -1178,10 +1178,12 @@ class TrusteeObjects:
featureCode=self.FEATURE_CODE
)
- # Clean records (remove internal fields) - keep as dicts for filtering/sorting
+ # Clean records (remove internal fields except _createdAt) - keep as dicts for filtering/sorting
+ # Keep _createdAt for display in frontend
+ keepFields = {'_createdAt'}
cleanedRecords = []
for record in records:
- cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
+ cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_") or k in keepFields}
cleanedRecords.append(cleanedRecord)
# Step 2: Apply filters (search and field filters)
diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py
index 12cfd41d..3836d674 100644
--- a/modules/interfaces/interfaceBootstrap.py
+++ b/modules/interfaces/interfaceBootstrap.py
@@ -74,31 +74,53 @@ def initBootstrap(db: DatabaseConnector) -> None:
initAutomationTemplates(db, adminUserId)
-def initAutomationTemplates(db: DatabaseConnector, adminUserId: Optional[str] = None) -> None:
+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:
- db: Database connector instance
+ 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
- # Check if templates already exist
- existing = db.getRecordset(AutomationTemplate)
+ # 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
+ # Get admin user ID if not provided (from poweron_app)
if not adminUserId:
- from modules.shared.configuration import APP_CONFIG
- adminUsers = db.getRecordset(UserInDB, {"email": APP_CONFIG.ADMIN_EMAIL})
+ 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
@@ -120,17 +142,13 @@ def initAutomationTemplates(db: DatabaseConnector, adminUserId: Optional[str] =
}
try:
- # Update context to set _createdBy to admin
- if adminUserId and hasattr(db, 'updateContext'):
- db.updateContext(adminUserId)
-
- db.recordCreate(AutomationTemplate, templateData)
+ 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")
+ logger.info(f"Seeded {createdCount} automation templates in poweron_automation database")
logger.info("System bootstrap completed")
@@ -739,26 +757,36 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
delete=AccessLevel.NONE,
))
- # AutomationTemplate: Only MY-level access (user-owned)
- for roleId in [adminId, userId]:
- if roleId:
- tableRules.append(AccessRule(
- roleId=roleId,
- context=AccessRuleContext.DATA,
- item="data.automation.AutomationTemplate",
- view=True,
- read=AccessLevel.MY,
- create=AccessLevel.MY,
- update=AccessLevel.MY,
- delete=AccessLevel.MY,
- ))
+ # 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.MY,
+ read=AccessLevel.ALL, # Viewer can see all templates (read-only)
create=AccessLevel.NONE,
update=AccessLevel.NONE,
delete=AccessLevel.NONE,
@@ -927,14 +955,20 @@ def _ensureDataContextRules(db: DatabaseConnector) -> None:
# Define tables that need rules (user-owned, no mandate context)
# Users can only manage their own records (MY-level access)
- tablesNeedingRules = [
+ tablesNeedingMyRules = [
"data.chat.ChatWorkflow",
"data.automation.AutomationDefinition",
+ ]
+
+ # Tables where admin sees ALL (system-wide templates)
+ tablesNeedingAllRulesForAdmin = [
"data.automation.AutomationTemplate",
]
missingRules = []
- for objectKey in tablesNeedingRules:
+
+ # MY-level rules for user-owned tables
+ for objectKey in tablesNeedingMyRules:
# Admin: MY-level access (user-owned, no mandate context)
if adminId and (adminId, objectKey) not in existingCombinations:
missingRules.append(AccessRule(
@@ -974,6 +1008,47 @@ def _ensureDataContextRules(db: DatabaseConnector) -> None:
delete=AccessLevel.NONE,
))
+ # ALL-level rules for admin on system templates
+ for objectKey in tablesNeedingAllRulesForAdmin:
+ # Admin: ALL-level access (sees all templates)
+ if adminId and (adminId, objectKey) not in existingCombinations:
+ missingRules.append(AccessRule(
+ roleId=adminId,
+ context=AccessRuleContext.DATA,
+ item=objectKey,
+ view=True,
+ read=AccessLevel.ALL,
+ create=AccessLevel.ALL,
+ update=AccessLevel.ALL,
+ delete=AccessLevel.ALL,
+ ))
+
+ # User: MY-level access
+ if userId and (userId, objectKey) not in existingCombinations:
+ missingRules.append(AccessRule(
+ roleId=userId,
+ context=AccessRuleContext.DATA,
+ item=objectKey,
+ view=True,
+ read=AccessLevel.MY,
+ create=AccessLevel.MY,
+ update=AccessLevel.MY,
+ delete=AccessLevel.MY,
+ ))
+
+ # Viewer: ALL read-only (can see all templates)
+ if viewerId and (viewerId, objectKey) not in existingCombinations:
+ missingRules.append(AccessRule(
+ roleId=viewerId,
+ context=AccessRuleContext.DATA,
+ item=objectKey,
+ view=True,
+ read=AccessLevel.ALL,
+ create=AccessLevel.NONE,
+ update=AccessLevel.NONE,
+ delete=AccessLevel.NONE,
+ ))
+
# Create missing rules
if missingRules:
for rule in missingRules:
@@ -981,6 +1056,44 @@ def _ensureDataContextRules(db: DatabaseConnector) -> None:
logger.info(f"Created {len(missingRules)} missing DATA context rules")
else:
logger.debug("All DATA context rules already exist")
+
+ # Update existing AutomationTemplate rules for admin/viewer to ALL access
+ _updateAutomationTemplateRulesToAll(db, adminId, viewerId)
+
+
+def _updateAutomationTemplateRulesToAll(db: DatabaseConnector, adminId: Optional[str], viewerId: Optional[str]) -> None:
+ """
+ Update existing AutomationTemplate RBAC rules from MY to ALL for admin and viewer.
+ This ensures sysadmins can see all templates (including system-seeded ones).
+ """
+ if not adminId and not viewerId:
+ return
+
+ templateObjectKey = "data.automation.AutomationTemplate"
+
+ # Find existing rules for AutomationTemplate
+ existingRules = db.getRecordset(
+ AccessRule,
+ recordFilter={
+ "context": AccessRuleContext.DATA.value,
+ "item": templateObjectKey
+ }
+ )
+
+ updatedCount = 0
+ for rule in existingRules:
+ ruleId = rule.get("id")
+ roleId = rule.get("roleId")
+ currentReadLevel = rule.get("read")
+
+ # Update admin and viewer rules from MY to ALL
+ if roleId in [adminId, viewerId] and currentReadLevel == AccessLevel.MY.value:
+ db.recordModify(AccessRule, ruleId, {"read": AccessLevel.ALL.value})
+ updatedCount += 1
+ logger.debug(f"Updated AutomationTemplate rule {ruleId} for role {roleId} to ALL access")
+
+ if updatedCount > 0:
+ logger.info(f"Updated {updatedCount} AutomationTemplate RBAC rules to ALL access")
def _createResourceContextRules(db: DatabaseConnector) -> None:
diff --git a/modules/routes/routeSharepoint.py b/modules/routes/routeSharepoint.py
index 32c72597..2719562a 100644
--- a/modules/routes/routeSharepoint.py
+++ b/modules/routes/routeSharepoint.py
@@ -39,6 +39,44 @@ def _getUserConnection(interface, connectionId: str, userId: str) -> Optional[Us
logger.error(f"Error getting user connection: {str(e)}")
return None
+def _getUserConnectionByReference(interface, connectionReference: str, userId: str) -> Optional[UserConnection]:
+ """
+ Get a user connection by reference string (format: connection:authority:username).
+
+ Args:
+ interface: Database interface
+ connectionReference: Reference string like 'connection:msft:user@email.com'
+ userId: User ID to verify ownership
+
+ Returns:
+ UserConnection if found and belongs to user, None otherwise
+ """
+ try:
+ # Parse reference format: connection:{authority}:{username} [status:..., token:...]
+ # Remove state information if present
+ baseReference = connectionReference.split(' [')[0]
+
+ parts = baseReference.split(':')
+ if len(parts) < 3 or parts[0] != "connection":
+ logger.warning(f"Invalid connection reference format: {connectionReference}")
+ return None
+
+ authority = parts[1] # e.g., 'msft'
+ username = ':'.join(parts[2:]) # Handle usernames with colons
+
+ # Get user connections and find matching one
+ connections = interface.getUserConnections(userId)
+ for conn in connections:
+ connAuthority = conn.authority.value if hasattr(conn.authority, 'value') else str(conn.authority)
+ if connAuthority.lower() == authority.lower() and conn.externalUsername == username:
+ return conn
+
+ logger.debug(f"No connection found for reference: {connectionReference}")
+ return None
+ except Exception as e:
+ logger.error(f"Error getting user connection by reference: {str(e)}")
+ return None
+
@router.get("/{connectionId}/sites", response_model=List[Dict[str, Any]])
@limiter.limit("30/minute")
async def get_sharepoint_sites(
@@ -251,3 +289,110 @@ def _extractSitePath(webUrl: str) -> str:
return "/sites/" + webUrl.split("/sites/")[1].split("/")[0]
return ""
+
+# ============================================================================
+# Universal folder-options endpoint (by connectionReference)
+# ============================================================================
+
+@router.get("/folder-options", response_model=List[Dict[str, Any]])
+@limiter.limit("30/minute")
+async def getSharepointFolderOptionsByReference(
+ request: Request,
+ connectionReference: str = Query(..., description="Connection reference string (e.g., 'connection:msft:user@email.com')"),
+ siteId: Optional[str] = Query(None, description="Specific site ID to browse (if omitted, returns sites only)"),
+ path: Optional[str] = Query(None, description="Folder path within site to browse"),
+ currentUser: User = Depends(getCurrentUser)
+) -> List[Dict[str, Any]]:
+ """
+ Get SharePoint folders formatted as dropdown options (universal endpoint).
+
+ Uses connectionReference instead of connectionId for easier integration.
+
+ Two modes:
+ 1. If siteId is not provided: Returns list of sites (for site selection)
+ 2. If siteId is provided: Returns folders within that site (optionally at specific path)
+
+ Args:
+ connectionReference: Connection reference string (e.g., 'connection:msft:user@email.com')
+ siteId: Optional site ID to browse folders within
+ path: Optional folder path within site
+ """
+ try:
+ interface = getInterface(currentUser)
+
+ # Get the connection by reference
+ connection = _getUserConnectionByReference(interface, connectionReference, currentUser.id)
+ if not connection:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Connection not found for reference: {connectionReference}"
+ )
+
+ # Verify it's a Microsoft connection
+ authority = connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority)
+ if authority.lower() != 'msft':
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Connection is not a Microsoft connection (authority: {authority})"
+ )
+
+ # Initialize services
+ services = getServices(currentUser, None)
+
+ # Set access token on SharePoint service
+ if not services.sharepoint.setAccessTokenFromConnection(connection):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Failed to set SharePoint access token. Connection may be expired or invalid."
+ )
+
+ # Mode 1: Return sites list if no siteId specified
+ if not siteId:
+ sites = await services.sharepoint.discoverSites()
+ return [
+ {
+ "type": "site",
+ "value": site.get("id"),
+ "label": site.get("displayName", "Unknown Site"),
+ "siteId": site.get("id"),
+ "siteName": site.get("displayName", "Unknown Site"),
+ "webUrl": site.get("webUrl", ""),
+ "path": _extractSitePath(site.get("webUrl", ""))
+ }
+ for site in sites
+ ]
+
+ # Mode 2: Return folders within specific site
+ folderPath = path or ""
+ items = await services.sharepoint.listFolderContents(siteId, folderPath)
+
+ if not items:
+ return []
+
+ folderOptions = []
+ for item in items:
+ if item.get("type") == "folder":
+ folderName = item.get("name", "")
+ itemPath = f"{folderPath}/{folderName}" if folderPath else folderName
+
+ folderOptions.append({
+ "type": "folder",
+ "value": itemPath,
+ "label": folderName,
+ "siteId": siteId,
+ "folderName": folderName,
+ "path": itemPath,
+ "hasChildren": True # Assume folders may have children
+ })
+
+ return folderOptions
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error getting SharePoint folder options by reference: {str(e)}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Error getting SharePoint folder options: {str(e)}"
+ )
+
diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py
index b4a40fc2..4e2f9f8f 100644
--- a/modules/routes/routeSystem.py
+++ b/modules/routes/routeSystem.py
@@ -106,7 +106,7 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]:
from modules.features.trustee.mainTrustee import UI_OBJECTS
return UI_OBJECTS
elif featureCode == "realestate":
- from modules.features.realEstate.mainRealEstate import UI_OBJECTS
+ from modules.features.realestate.mainRealEstate import UI_OBJECTS
return UI_OBJECTS
else:
logger.warning(f"Unknown feature code: {featureCode}")
diff --git a/modules/shared/frontendTypes.py b/modules/shared/frontendTypes.py
index 06a81570..9b6d04e7 100644
--- a/modules/shared/frontendTypes.py
+++ b/modules/shared/frontendTypes.py
@@ -54,9 +54,11 @@ class FrontendType(str, Enum):
WORKFLOW_ACTION = "workflowAction"
"""Workflow action selector - fetches available actions from workflow context"""
+ SHAREPOINT_FOLDER = "sharepointFolder"
+ """SharePoint folder selector - requires connectionReference parameter in same action to load folders"""
+
# Additional custom types can be added here as needed
# Examples:
- # SHAREPOINT_FOLDER = "sharepointFolder"
# OUTLOOK_FOLDER = "outlookFolder"
# JIRA_PROJECT = "jiraProject"
@@ -66,6 +68,7 @@ CUSTOM_TYPE_OPTIONS_API: Dict[FrontendType, str] = {
FrontendType.USER_CONNECTION: "user.connection",
FrontendType.DOCUMENT_REFERENCE: "workflow.documentReference", # To be implemented
FrontendType.WORKFLOW_ACTION: "workflow.action", # To be implemented
+ FrontendType.SHAREPOINT_FOLDER: "sharepoint.folder", # Dynamic - requires connectionReference
}
# Mapping of custom types to their description
@@ -85,6 +88,11 @@ CUSTOM_TYPE_DESCRIPTIONS: Dict[FrontendType, Dict[str, str]] = {
"fr": "Action de workflow",
"de": "Workflow-Aktion"
},
+ FrontendType.SHAREPOINT_FOLDER: {
+ "en": "SharePoint Folder",
+ "fr": "Dossier SharePoint",
+ "de": "SharePoint-Ordner"
+ },
}
diff --git a/modules/workflows/methods/methodSharepoint/methodSharepoint.py b/modules/workflows/methods/methodSharepoint/methodSharepoint.py
index 5b765fe5..9ca4c3e5 100644
--- a/modules/workflows/methods/methodSharepoint/methodSharepoint.py
+++ b/modules/workflows/methods/methodSharepoint/methodSharepoint.py
@@ -109,7 +109,7 @@ class MethodSharepoint(MethodBase):
"pathQuery": WorkflowActionParameter(
name="pathQuery",
type="str",
- frontendType=FrontendType.TEXT,
+ frontendType=FrontendType.SHAREPOINT_FOLDER,
required=False,
description="Direct path query if no documentList (e.g., /sites/SiteName/FolderPath)"
),
@@ -146,7 +146,7 @@ class MethodSharepoint(MethodBase):
"pathQuery": WorkflowActionParameter(
name="pathQuery",
type="str",
- frontendType=FrontendType.TEXT,
+ frontendType=FrontendType.SHAREPOINT_FOLDER,
required=False,
description="Direct upload target path if documentList doesn't contain findDocumentPath result (e.g., /sites/SiteName/FolderPath)"
)