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
|
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"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue