305 lines
11 KiB
Python
305 lines
11 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
User-facing Automation Workspace API.
|
|
|
|
Lists workflow runs the user can access (via FeatureAccess on
|
|
targetFeatureInstanceId) and provides detail views with step logs
|
|
and linked files. Designed for the "Workspace" tab under
|
|
Nutzung > Automation.
|
|
"""
|
|
|
|
import logging
|
|
import math
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, Request, Query, Path, HTTPException
|
|
from slowapi import Limiter
|
|
from slowapi.util import get_remote_address
|
|
|
|
from modules.auth.authentication import getRequestContext, RequestContext
|
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
|
from modules.shared.configuration import APP_CONFIG
|
|
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
|
AutoRun,
|
|
AutoStepLog,
|
|
AutoWorkflow,
|
|
)
|
|
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase
|
|
from modules.shared.i18nRegistry import apiRouteContext
|
|
|
|
routeApiMsg = apiRouteContext("routeAutomationWorkspace")
|
|
logger = logging.getLogger(__name__)
|
|
limiter = Limiter(key_func=get_remote_address)
|
|
|
|
router = APIRouter(prefix="/api/automations/runs", tags=["AutomationWorkspace"])
|
|
|
|
|
|
def _getDb() -> DatabaseConnector:
|
|
return DatabaseConnector(
|
|
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
|
dbDatabase=graphicalEditorDatabase,
|
|
dbUser=APP_CONFIG.get("DB_USER"),
|
|
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
|
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
|
|
userId=None,
|
|
)
|
|
|
|
|
|
def _getUserAccessibleInstanceIds(userId: str) -> list[str]:
|
|
"""Return all featureInstanceIds the user has enabled FeatureAccess for."""
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
rootIface = getRootInterface()
|
|
allAccess = rootIface.getFeatureAccessesForUser(userId) or []
|
|
return [
|
|
a.featureInstanceId
|
|
for a in allAccess
|
|
if a.featureInstanceId and a.enabled
|
|
]
|
|
|
|
|
|
_FILE_REF_KEYS = ("fileId", "documentId", "fileIds", "documents")
|
|
|
|
|
|
def _extractFileIdsFromValue(value, accumulator: set[str]) -> None:
|
|
"""Recursively scan a value (dict/list/str) for file id references."""
|
|
if isinstance(value, dict):
|
|
for key, sub in value.items():
|
|
if key in _FILE_REF_KEYS:
|
|
_collectFileIdsFromRef(sub, accumulator)
|
|
else:
|
|
_extractFileIdsFromValue(sub, accumulator)
|
|
elif isinstance(value, list):
|
|
for item in value:
|
|
_extractFileIdsFromValue(item, accumulator)
|
|
|
|
|
|
def _collectFileIdsFromRef(val, accumulator: set[str]) -> None:
|
|
"""Add file ids from a value located under a known file-reference key."""
|
|
if isinstance(val, str) and val:
|
|
accumulator.add(val)
|
|
elif isinstance(val, list):
|
|
for v in val:
|
|
if isinstance(v, str) and v:
|
|
accumulator.add(v)
|
|
elif isinstance(v, dict) and v.get("id"):
|
|
accumulator.add(v["id"])
|
|
elif isinstance(val, dict) and val.get("id"):
|
|
accumulator.add(val["id"])
|
|
|
|
|
|
@router.get("")
|
|
@limiter.limit("60/minute")
|
|
def listWorkspaceRuns(
|
|
request: Request,
|
|
scope: str = Query("mine", description="mine = own runs, mandate = all accessible"),
|
|
status: Optional[str] = Query(None, description="Filter by run status"),
|
|
targetInstanceId: Optional[str] = Query(None, description="Filter by targetFeatureInstanceId"),
|
|
workflowId: Optional[str] = Query(None, description="Filter by workflow"),
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> dict:
|
|
"""List workflow runs visible to the user.
|
|
|
|
scope=mine: only runs owned by the user.
|
|
scope=mandate: all runs where the user has FeatureAccess on the
|
|
workflow's targetFeatureInstanceId.
|
|
"""
|
|
db = _getDb()
|
|
if not db._ensureTableExists(AutoRun):
|
|
return {"runs": [], "total": 0, "limit": limit, "offset": offset}
|
|
|
|
userId = str(context.user.id) if context.user else None
|
|
if not userId:
|
|
raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required"))
|
|
|
|
accessibleInstanceIds = _getUserAccessibleInstanceIds(userId)
|
|
if not accessibleInstanceIds:
|
|
return {"runs": [], "total": 0, "limit": limit, "offset": offset}
|
|
|
|
if not db._ensureTableExists(AutoWorkflow):
|
|
return {"runs": [], "total": 0, "limit": limit, "offset": offset}
|
|
|
|
wfFilter: dict = {}
|
|
if targetInstanceId:
|
|
if targetInstanceId not in accessibleInstanceIds:
|
|
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied to target instance"))
|
|
wfFilter["targetFeatureInstanceId"] = targetInstanceId
|
|
workflows = db.getRecordset(AutoWorkflow, recordFilter=wfFilter or None) or []
|
|
|
|
visibleWfIds: set[str] = set()
|
|
wfMap: dict = {}
|
|
for wf in workflows:
|
|
wfDict = dict(wf)
|
|
tid = wfDict.get("targetFeatureInstanceId") or wfDict.get("featureInstanceId")
|
|
if tid and tid in accessibleInstanceIds:
|
|
wfId = wfDict.get("id")
|
|
if wfId:
|
|
visibleWfIds.add(wfId)
|
|
wfMap[wfId] = wfDict
|
|
|
|
if workflowId:
|
|
if workflowId not in visibleWfIds:
|
|
return {"runs": [], "total": 0, "limit": limit, "offset": offset}
|
|
visibleWfIds = {workflowId}
|
|
|
|
if not visibleWfIds:
|
|
return {"runs": [], "total": 0, "limit": limit, "offset": offset}
|
|
|
|
allRuns = db.getRecordset(AutoRun, recordFilter={}) or []
|
|
filtered = []
|
|
for r in allRuns:
|
|
row = dict(r)
|
|
if row.get("workflowId") not in visibleWfIds:
|
|
continue
|
|
if scope == "mine" and row.get("ownerId") != userId:
|
|
continue
|
|
if status and row.get("status") != status:
|
|
continue
|
|
filtered.append(row)
|
|
|
|
filtered.sort(
|
|
key=lambda x: x.get("startedAt") or x.get("sysCreatedAt") or 0,
|
|
reverse=True,
|
|
)
|
|
total = len(filtered)
|
|
page = filtered[offset: offset + limit]
|
|
|
|
from modules.routes.routeHelpers import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels
|
|
|
|
for row in page:
|
|
wf = wfMap.get(row.get("workflowId"), {})
|
|
row["workflowLabel"] = row.get("label") or wf.get("label") or row.get("workflowId", "")
|
|
row["targetFeatureInstanceId"] = wf.get("targetFeatureInstanceId") or wf.get("featureInstanceId")
|
|
|
|
enrichRowsWithFkLabels(
|
|
page,
|
|
labelResolvers={
|
|
"mandateId": resolveMandateLabels,
|
|
"targetFeatureInstanceId": resolveInstanceLabels,
|
|
},
|
|
)
|
|
for row in page:
|
|
row["targetInstanceLabel"] = row.pop("targetFeatureInstanceIdLabel", None)
|
|
row["mandateLabel"] = row.pop("mandateIdLabel", None)
|
|
|
|
return {"runs": page, "total": total, "limit": limit, "offset": offset}
|
|
|
|
|
|
@router.get("/{runId}/detail")
|
|
@limiter.limit("60/minute")
|
|
def getWorkspaceRunDetail(
|
|
request: Request,
|
|
runId: str = Path(..., description="Run ID"),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> dict:
|
|
"""Get full detail for a single run: metadata, step logs, linked files."""
|
|
db = _getDb()
|
|
userId = str(context.user.id) if context.user else None
|
|
if not userId:
|
|
raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required"))
|
|
|
|
if not db._ensureTableExists(AutoRun):
|
|
raise HTTPException(status_code=404, detail=routeApiMsg("Run not found"))
|
|
|
|
runs = db.getRecordset(AutoRun, recordFilter={"id": runId})
|
|
if not runs:
|
|
raise HTTPException(status_code=404, detail=routeApiMsg("Run not found"))
|
|
run = dict(runs[0])
|
|
|
|
wfId = run.get("workflowId")
|
|
workflow: dict = {}
|
|
if wfId and db._ensureTableExists(AutoWorkflow):
|
|
wfs = db.getRecordset(AutoWorkflow, recordFilter={"id": wfId})
|
|
if wfs:
|
|
workflow = dict(wfs[0])
|
|
|
|
tid = workflow.get("targetFeatureInstanceId") or workflow.get("featureInstanceId")
|
|
accessibleIds = _getUserAccessibleInstanceIds(userId)
|
|
isOwner = run.get("ownerId") == userId
|
|
|
|
if not isOwner and (not tid or tid not in accessibleIds) and not context.isPlatformAdmin:
|
|
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied"))
|
|
|
|
steps: list = []
|
|
if db._ensureTableExists(AutoStepLog):
|
|
stepRecords = db.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []
|
|
steps = [dict(s) for s in stepRecords]
|
|
steps.sort(key=lambda s: s.get("startedAt") or 0)
|
|
|
|
allFileIds: set[str] = set()
|
|
perStepFileIds: list[tuple[set[str], set[str]]] = []
|
|
for step in steps:
|
|
inputIds: set[str] = set()
|
|
outputIds: set[str] = set()
|
|
_extractFileIdsFromValue(step.get("inputSnapshot") or {}, inputIds)
|
|
_extractFileIdsFromValue(step.get("output") or {}, outputIds)
|
|
perStepFileIds.append((inputIds, outputIds))
|
|
allFileIds.update(inputIds)
|
|
allFileIds.update(outputIds)
|
|
|
|
nodeOutputs = run.get("nodeOutputs") or {}
|
|
runLevelIds: set[str] = set()
|
|
_extractFileIdsFromValue(nodeOutputs, runLevelIds)
|
|
allFileIds.update(runLevelIds)
|
|
|
|
fileMetaById: dict[str, dict] = {}
|
|
try:
|
|
from modules.datamodels.datamodelFiles import FileItem
|
|
from modules.interfaces.interfaceDbManagement import ComponentObjects
|
|
mgmtDb = ComponentObjects().db
|
|
if mgmtDb._ensureTableExists(FileItem):
|
|
for fid in allFileIds:
|
|
try:
|
|
rec = mgmtDb.getRecord(FileItem, fid)
|
|
if rec:
|
|
recDict = dict(rec)
|
|
fileMetaById[fid] = {
|
|
"id": fid,
|
|
"fileName": recDict.get("fileName") or recDict.get("name"),
|
|
}
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
logger.warning("getWorkspaceRunDetail: file lookup failed: %s", e)
|
|
|
|
def _resolveFileList(ids: set[str]) -> list[dict]:
|
|
return [fileMetaById[fid] for fid in ids if fid in fileMetaById]
|
|
|
|
assignedFileIds: set[str] = set()
|
|
for step, (inputIds, outputIds) in zip(steps, perStepFileIds):
|
|
step["inputFiles"] = _resolveFileList(inputIds)
|
|
step["outputFiles"] = _resolveFileList(outputIds)
|
|
assignedFileIds.update(inputIds)
|
|
assignedFileIds.update(outputIds)
|
|
|
|
unassignedFiles = _resolveFileList(allFileIds - assignedFileIds)
|
|
allFiles = _resolveFileList(allFileIds)
|
|
|
|
run["workflowLabel"] = run.get("label") or workflow.get("label") or wfId
|
|
run["targetFeatureInstanceId"] = tid
|
|
|
|
targetInstanceLabel = None
|
|
if tid:
|
|
try:
|
|
from modules.routes.routeHelpers import resolveInstanceLabels
|
|
labelMap = resolveInstanceLabels([tid])
|
|
targetInstanceLabel = labelMap.get(tid)
|
|
except Exception:
|
|
pass
|
|
run["targetInstanceLabel"] = targetInstanceLabel
|
|
|
|
return {
|
|
"run": run,
|
|
"workflow": {
|
|
"id": workflow.get("id"),
|
|
"label": workflow.get("label"),
|
|
"targetFeatureInstanceId": tid,
|
|
"featureInstanceId": workflow.get("featureInstanceId"),
|
|
"tags": workflow.get("tags", []),
|
|
} if workflow else None,
|
|
"steps": steps,
|
|
"files": allFiles,
|
|
"unassignedFiles": unassignedFiles,
|
|
}
|