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