automation template/definition editor

This commit is contained in:
ValueOn AG 2026-02-03 23:42:27 +01:00
parent f31e10496a
commit edeaf64fa4
11 changed files with 419 additions and 132 deletions

View file

@ -12,7 +12,7 @@ Multi-Tenant Design:
import uuid import uuid
from typing import Optional, List from typing import Optional, List
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field, EmailStr, field_validator from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
@ -115,6 +115,20 @@ class UserConnection(BaseModel):
]}) ]})
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(
"UserConnection", "UserConnection",
@ -132,6 +146,8 @@ registerModelLabels(
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"}, "expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"}, "tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"},
"tokenExpiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"}, "tokenExpiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"connectionReference": {"en": "Connection Reference", "de": "Verbindungsreferenz", "fr": "Référence de connexion"},
"displayLabel": {"en": "Display Label", "de": "Anzeigebezeichnung", "fr": "Libellé d'affichage"},
}, },
) )

View file

@ -52,7 +52,7 @@ class AutomationObjects:
"""Initializes the database connection with proper configuration.""" """Initializes the database connection with proper configuration."""
# Get configuration values # Get configuration values
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data") dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = "poweron_app" dbDatabase = "poweron_automation"
dbUser = APP_CONFIG.get("DB_USER") dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) dbPort = int(APP_CONFIG.get("DB_PORT", 5432))

View file

@ -134,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(
@ -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) # AutomationTemplate Routes (DB-persistiert)
# ============================================================================= # =============================================================================

View file

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

View file

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

View file

@ -1178,10 +1178,12 @@ class TrusteeObjects:
featureCode=self.FEATURE_CODE 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)

View file

@ -74,31 +74,53 @@ def initBootstrap(db: DatabaseConnector) -> None:
initAutomationTemplates(db, adminUserId) 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. Seed initial automation templates from subAutomationTemplates.py.
Only runs if no templates exist yet (bootstrap). Only runs if no templates exist yet (bootstrap).
Creates templates with _createdBy = admin user (SysAdmin privilege). Creates templates with _createdBy = admin user (SysAdmin privilege).
NOTE: AutomationTemplate lives in poweron_automation database, not poweron_app!
Args: 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 adminUserId: Admin user ID for _createdBy field
""" """
import json import json
from modules.features.automation.subAutomationTemplates import AUTOMATION_TEMPLATES from modules.features.automation.subAutomationTemplates import AUTOMATION_TEMPLATES
from modules.features.automation.datamodelFeatureAutomation import AutomationTemplate from modules.features.automation.datamodelFeatureAutomation import AutomationTemplate
from modules.shared.configuration import APP_CONFIG
# Check if templates already exist # Create connector for poweron_automation database (where templates live)
existing = db.getRecordset(AutomationTemplate) 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: if existing:
logger.info(f"Automation templates already seeded ({len(existing)} templates)") logger.info(f"Automation templates already seeded ({len(existing)} templates)")
return return
# Get admin user ID if not provided # Get admin user ID if not provided (from poweron_app)
if not adminUserId: if not adminUserId:
from modules.shared.configuration import APP_CONFIG adminUsers = dbApp.getRecordset(UserInDB, {"email": APP_CONFIG.ADMIN_EMAIL})
adminUsers = db.getRecordset(UserInDB, {"email": APP_CONFIG.ADMIN_EMAIL})
adminUserId = adminUsers[0]["id"] if adminUsers else None adminUserId = adminUsers[0]["id"] if adminUsers else None
# Update context with admin user
if adminUserId:
dbAutomation.updateContext(adminUserId)
templates = AUTOMATION_TEMPLATES.get("sets", []) templates = AUTOMATION_TEMPLATES.get("sets", [])
createdCount = 0 createdCount = 0
@ -120,17 +142,13 @@ def initAutomationTemplates(db: DatabaseConnector, adminUserId: Optional[str] =
} }
try: try:
# Update context to set _createdBy to admin dbAutomation.recordCreate(AutomationTemplate, templateData)
if adminUserId and hasattr(db, 'updateContext'):
db.updateContext(adminUserId)
db.recordCreate(AutomationTemplate, templateData)
createdCount += 1 createdCount += 1
logger.debug(f"Created automation template: {overview}") logger.debug(f"Created automation template: {overview}")
except Exception as e: except Exception as e:
logger.error(f"Failed to create automation template '{overview}': {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") logger.info("System bootstrap completed")
@ -739,26 +757,36 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
delete=AccessLevel.NONE, delete=AccessLevel.NONE,
)) ))
# AutomationTemplate: Only MY-level access (user-owned) # AutomationTemplate: Admin sees ALL (system templates), User sees only MY
for roleId in [adminId, userId]: if adminId:
if roleId: tableRules.append(AccessRule(
tableRules.append(AccessRule( roleId=adminId,
roleId=roleId, context=AccessRuleContext.DATA,
context=AccessRuleContext.DATA, item="data.automation.AutomationTemplate",
item="data.automation.AutomationTemplate", view=True,
view=True, read=AccessLevel.ALL, # SysAdmin sees all templates
read=AccessLevel.MY, create=AccessLevel.ALL,
create=AccessLevel.MY, update=AccessLevel.ALL,
update=AccessLevel.MY, delete=AccessLevel.ALL,
delete=AccessLevel.MY, ))
)) 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: if viewerId:
tableRules.append(AccessRule( tableRules.append(AccessRule(
roleId=viewerId, roleId=viewerId,
context=AccessRuleContext.DATA, context=AccessRuleContext.DATA,
item="data.automation.AutomationTemplate", item="data.automation.AutomationTemplate",
view=True, view=True,
read=AccessLevel.MY, read=AccessLevel.ALL, # Viewer can see all templates (read-only)
create=AccessLevel.NONE, create=AccessLevel.NONE,
update=AccessLevel.NONE, update=AccessLevel.NONE,
delete=AccessLevel.NONE, delete=AccessLevel.NONE,
@ -927,14 +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", "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(
@ -974,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:
@ -982,6 +1057,44 @@ def _ensureDataContextRules(db: DatabaseConnector) -> None:
else: else:
logger.debug("All DATA context rules already exist") logger.debug("All DATA context rules already exist")
# Update existing AutomationTemplate rules for admin/viewer to ALL access
_updateAutomationTemplateRulesToAll(db, adminId, viewerId)
def _updateAutomationTemplateRulesToAll(db: DatabaseConnector, adminId: Optional[str], viewerId: Optional[str]) -> None:
"""
Update existing AutomationTemplate RBAC rules from MY to ALL for admin and viewer.
This ensures sysadmins can see all templates (including system-seeded ones).
"""
if not adminId and not viewerId:
return
templateObjectKey = "data.automation.AutomationTemplate"
# Find existing rules for AutomationTemplate
existingRules = db.getRecordset(
AccessRule,
recordFilter={
"context": AccessRuleContext.DATA.value,
"item": templateObjectKey
}
)
updatedCount = 0
for rule in existingRules:
ruleId = rule.get("id")
roleId = rule.get("roleId")
currentReadLevel = rule.get("read")
# Update admin and viewer rules from MY to ALL
if roleId in [adminId, viewerId] and currentReadLevel == AccessLevel.MY.value:
db.recordModify(AccessRule, ruleId, {"read": AccessLevel.ALL.value})
updatedCount += 1
logger.debug(f"Updated AutomationTemplate rule {ruleId} for role {roleId} to ALL access")
if updatedCount > 0:
logger.info(f"Updated {updatedCount} AutomationTemplate RBAC rules to ALL access")
def _createResourceContextRules(db: DatabaseConnector) -> None: def _createResourceContextRules(db: DatabaseConnector) -> None:
""" """

View file

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

View file

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

View file

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

View file

@ -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)"
) )