automation template/definition editor
This commit is contained in:
parent
f31e10496a
commit
edeaf64fa4
11 changed files with 419 additions and 132 deletions
|
|
@ -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"},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue