platform-core/modules/workflowAutomation/helpers.py
ValueOn AG ce612ffcfc
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m0s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s
import referencing fixes
2026-06-08 14:46:52 +02:00

625 lines
24 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Shared helpers for WorkflowAutomation route files.
Extracted from routeWorkflowDashboard.py and routeWorkflowAutomation.py to
avoid code duplication across route files. Contains DB access, RBAC scoping,
pagination helpers, and FK label resolver setup.
"""
import json
import logging
import math
import re
from datetime import datetime, timezone
from typing import Optional, List, Dict, Any
from fastapi import HTTPException
from modules.auth.authentication import RequestContext
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.datamodels.datamodelPagination import PaginationParams, normalize_pagination_dict
from modules.datamodels.datamodelWorkflowAutomation import (
AutoRun, AutoStepLog, AutoWorkflow, AutoTask, AutoVersion,
GRAPHICAL_EDITOR_DATABASE,
)
from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface
from modules.shared.configuration import APP_CONFIG
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# DB access
# ---------------------------------------------------------------------------
def _getWorkflowAutomationDb() -> DatabaseConnector:
"""Get a DatabaseConnector for the WorkflowAutomation (graphicaleditor) DB."""
return DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase=GRAPHICAL_EDITOR_DATABASE,
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 _getAppDb() -> DatabaseConnector:
"""Get the root interface DB (poweron_app) for FK label resolution."""
return _getRootIface().db
# ---------------------------------------------------------------------------
# RBAC helpers
# ---------------------------------------------------------------------------
def _getUserMandateIds(userId: str) -> List[str]:
"""Get mandate IDs the user is a member of."""
rootIface = _getRootIface()
memberships = rootIface.getUserMandates(userId)
return [um.mandateId for um in memberships if um.mandateId and um.enabled]
def _getAdminMandateIds(userId: str, mandateIds: List[str]) -> List[str]:
"""Batch-check which mandates the user is admin for."""
if not mandateIds:
return []
rootIface = _getRootIface()
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
memberships = rootIface.db.getRecordset(
UserMandate,
recordFilter={"userId": userId, "mandateId": mandateIds, "enabled": True},
)
if not memberships:
return []
umIdToMandateId: Dict[str, str] = {}
for m in memberships:
row = m if isinstance(m, dict) else m.__dict__
um_id = row.get("id")
mid = row.get("mandateId")
if um_id and mid:
umIdToMandateId[str(um_id)] = str(mid)
userMandateIds = list(umIdToMandateId.keys())
allRoles = rootIface.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateIds},
)
if not allRoles:
return []
roleIds: set = set()
roleToMandate: Dict[str, set] = {}
for r in allRoles:
row = r if isinstance(r, dict) else r.__dict__
rid = row.get("roleId")
um_id = row.get("userMandateId")
mid = umIdToMandateId.get(str(um_id)) if um_id else None
if rid and mid:
roleIds.add(rid)
roleToMandate.setdefault(rid, set()).add(mid)
if not roleIds:
return []
from modules.datamodels.datamodelRbac import Role
roleRecords = rootIface.db.getRecordset(Role, recordFilter={"id": list(roleIds)})
adminMandates: set = set()
for role in (roleRecords or []):
row = role if isinstance(role, dict) else role.__dict__
rid = row.get("id")
if not rid or rid not in roleToMandate:
continue
if row.get("roleLabel") == "admin" and not row.get("featureInstanceId"):
adminMandates.update(roleToMandate[rid])
return [mid for mid in mandateIds if mid in adminMandates]
def _isUserMandateAdmin(userId: str, mandateId: str) -> bool:
"""Check if user is admin for a specific mandate."""
return mandateId in _getAdminMandateIds(userId, [mandateId])
def _scopedWorkflowFilter(context: RequestContext) -> Optional[Dict[str, Any]]:
"""Build DB filter for listing workflows: mandate-scoped for members, None for sysadmin."""
if context.isPlatformAdmin:
return None
userId = str(context.user.id) if context.user else None
if not userId:
return {"mandateId": "__impossible__"}
mandateIds = _getUserMandateIds(userId)
if mandateIds:
return {"mandateId": mandateIds}
return {"mandateId": "__impossible__"}
def _scopedRunFilter(context: RequestContext) -> Optional[Dict[str, Any]]:
"""Build DB filter for listing runs: admin sees mandate runs, user sees own."""
if context.isPlatformAdmin:
return None
userId = str(context.user.id) if context.user else None
if not userId:
return {"ownerId": "__impossible__"}
mandateIds = _getUserMandateIds(userId)
adminMandateIds = _getAdminMandateIds(userId, mandateIds)
if adminMandateIds:
return {"mandateId": adminMandateIds}
return {"ownerId": userId}
def _userMayDeleteWorkflow(context: RequestContext, wfMandateId: Optional[str]) -> bool:
"""Check if user may delete a workflow in the given mandate."""
if context.isPlatformAdmin:
return True
userId = str(context.user.id) if context.user else None
if not userId or not wfMandateId:
return False
userMandateIds = _getUserMandateIds(userId)
adminMandateIds = _getAdminMandateIds(userId, userMandateIds)
return wfMandateId in adminMandateIds
def _validateWorkflowAccess(
context: RequestContext,
workflow: Optional[Dict[str, Any]],
action: str = "read",
) -> None:
"""Validate access to a workflow. Raises HTTPException(403) on denial.
Actions:
- 'read': mandate membership
- 'write'/'delete': mandate admin
- 'execute': mandate membership + FeatureAccess on targetInstanceId
"""
if context.isPlatformAdmin:
return
userId = str(context.user.id) if context.user else None
if not userId:
raise HTTPException(status_code=403, detail="Authentication required")
if workflow is None:
raise HTTPException(status_code=404, detail="Workflow not found")
wfMandateId = workflow.get("mandateId") or ""
if not wfMandateId:
if action == "read":
return
raise HTTPException(status_code=403, detail="Workflow has no mandate — admin only")
userMandateIds = _getUserMandateIds(userId)
if wfMandateId not in userMandateIds:
raise HTTPException(status_code=403, detail="Not a member of the workflow's mandate")
if action == "read":
return
if action == "execute":
targetInstanceId = workflow.get("targetFeatureInstanceId")
if targetInstanceId:
from modules.interfaces.interfaceDbApp import getRootInterface
access = getRootInterface().getFeatureAccess(userId, targetInstanceId)
if access and access.get("enabled"):
return
adminMandateIds = _getAdminMandateIds(userId, [wfMandateId])
if wfMandateId not in adminMandateIds:
raise HTTPException(
status_code=403,
detail=f"Mandate admin required for '{action}' on workflows",
)
# ---------------------------------------------------------------------------
# Pagination
# ---------------------------------------------------------------------------
def _parsePaginationOr400(pagination: Optional[str]) -> Optional[PaginationParams]:
"""Parse a JSON pagination query string. Raises 400 on parse errors."""
if not pagination:
return None
try:
paginationDict = json.loads(pagination)
except json.JSONDecodeError as e:
raise HTTPException(
status_code=400,
detail=f"Invalid 'pagination' query: not valid JSON ({e.msg})",
)
if not paginationDict:
return None
try:
paginationDict = normalize_pagination_dict(paginationDict)
return PaginationParams(**paginationDict)
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Invalid 'pagination' payload: {e}",
)
# ---------------------------------------------------------------------------
# FK label resolver setup (cross-DB: poweron_app vs poweron_graphicaleditor)
# ---------------------------------------------------------------------------
def _resolveFkLabels(rows: list, model, labelResolvers: Optional[dict] = None) -> list:
"""Resolve FK labels for a list of rows using the app DB for user/mandate/instance lookups."""
if not rows:
return rows
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
appDb = _getAppDb()
enrichRowsWithFkLabels(rows, model, db=appDb, labelResolvers=labelResolvers)
return rows
def _buildStandardLabelResolvers() -> dict:
"""Standard FK label resolvers for mandateId, featureInstanceId, ownerId."""
from modules.dbHelpers.fkLabelResolver import (
resolveMandateLabels,
resolveInstanceLabels,
resolveUserLabels,
)
appDb = _getAppDb()
return {
"mandateId": lambda ids: resolveMandateLabels(ids, db=appDb),
"featureInstanceId": lambda ids: resolveInstanceLabels(ids, db=appDb),
"ownerId": lambda ids: resolveUserLabels(ids, db=appDb),
"sysCreatedBy": lambda ids: resolveUserLabels(ids, db=appDb),
}
# ---------------------------------------------------------------------------
# Cascade delete
# ---------------------------------------------------------------------------
def _cascadeDeleteWorkflow(db: DatabaseConnector, workflowId: str) -> None:
"""Delete AutoWorkflow and all dependent rows (versions, runs, step logs, tasks)."""
for v in db.getRecordset(AutoVersion, recordFilter={"workflowId": workflowId}) or []:
vid = v.get("id")
if vid:
db.recordDelete(AutoVersion, vid)
for run in db.getRecordset(AutoRun, recordFilter={"workflowId": workflowId}) or []:
runId = run.get("id")
if not runId:
continue
for sl in db.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
slid = sl.get("id")
if slid:
db.recordDelete(AutoStepLog, slid)
db.recordDelete(AutoRun, runId)
for task in db.getRecordset(AutoTask, recordFilter={"workflowId": workflowId}) or []:
tid = task.get("id")
if tid:
db.recordDelete(AutoTask, tid)
db.recordDelete(AutoWorkflow, workflowId)
# ---------------------------------------------------------------------------
# SQL join helpers for workflow listing with run stats
# ---------------------------------------------------------------------------
_RUN_STATS_SUBQUERY = """
(
SELECT s."workflowId" AS "workflowId",
MAX(COALESCE(s."startedAt", s."sysCreatedAt")) AS "lastStartedAt",
COUNT(s."id")::bigint AS "runCount",
MAX(CASE WHEN s."status" IN ('running', 'paused') THEN s."id" END) AS "activeRunId"
FROM "AutoRun" s
GROUP BY s."workflowId"
) rs
"""
def _firstFkSortFieldForWorkflows(pagination) -> Optional[str]:
"""First sort field that requires FK label resolution (cross-DB), or None."""
from modules.dbHelpers.fkLabelResolver import buildLabelResolversFromModel
if not pagination or not pagination.sort:
return None
resolvers = buildLabelResolversFromModel(AutoWorkflow)
if not resolvers:
return None
for sf in pagination.sort:
sfField = sf.get("field") if isinstance(sf, dict) else getattr(sf, "field", None)
if sfField and sfField in resolvers:
return sfField
return None
def _batchRunStatsForWorkflowIds(db: DatabaseConnector, workflowIds: List[str]) -> dict:
"""One grouped query: lastStartedAt, runCount, activeRunId per workflow."""
if not workflowIds or not db._ensureTableExists(AutoRun):
return {}
db._ensure_connection()
sql = """
SELECT "workflowId",
MAX(COALESCE("startedAt", "sysCreatedAt")) AS "lastStartedAt",
COUNT("id")::bigint AS "runCount",
MAX(CASE WHEN "status" IN ('running', 'paused') THEN "id" END) AS "activeRunId"
FROM "AutoRun"
WHERE "workflowId" = ANY(%s)
GROUP BY "workflowId"
"""
out: dict = {}
with db.borrowCursor() as cursor:
cursor.execute(sql, (workflowIds,))
for row in cursor.fetchall():
r = dict(row)
wid = r.get("workflowId")
if wid:
out[str(wid)] = r
return out
def _listingColSql(key: str, wfFieldNames: set) -> Optional[str]:
if key == "lastStartedAt":
return 'rs."lastStartedAt"'
if key == "runCount":
return 'COALESCE(rs."runCount", 0::bigint)'
if key == "isRunning":
return '(rs."activeRunId" IS NOT NULL)'
if key in wfFieldNames:
return f'w."{key}"'
return None
def _listingOrderExpr(key: str, wfFieldNames: set, wfFields: dict) -> Optional[str]:
if key == "lastStartedAt":
return 'rs."lastStartedAt"'
if key == "runCount":
return 'COALESCE(rs."runCount", 0::bigint)'
if key == "isRunning":
return 'CASE WHEN rs."activeRunId" IS NOT NULL THEN 1 ELSE 0 END'
if key in wfFieldNames:
colType = wfFields.get(key, "TEXT")
if colType == "BOOLEAN":
return f'COALESCE(w."{key}", FALSE)'
return f'w."{key}"'
return None
def _appendJoinedListingFilters(whereParts: list, values: list, pagination, wfFields: dict) -> None:
"""Append WHERE fragments for joined workflow listing (w + rs)."""
wfFieldNames = set(wfFields.keys())
validCols = wfFieldNames | {"lastStartedAt", "runCount", "isRunning"}
if not pagination or not pagination.filters:
return
for key, val in pagination.filters.items():
if key == "search" and isinstance(val, str) and val.strip():
term = f"%{val.strip()}%"
textCols = [c for c, t in wfFields.items() if t == "TEXT"]
if textCols:
orParts = [f'COALESCE(w."{c}"::TEXT, \'\') ILIKE %s' for c in textCols]
whereParts.append(f"({' OR '.join(orParts)})")
values.extend([term] * len(textCols))
continue
if key not in validCols:
continue
if key == "isRunning":
if isinstance(val, dict):
op = val.get("operator", "equals")
v = val.get("value", "")
isTrue = str(v).lower() == "true"
if op in ("equals", "eq"):
whereParts.append('(rs."activeRunId" IS NOT NULL)' if isTrue else '(rs."activeRunId" IS NULL)')
elif val is None:
whereParts.append('(rs."activeRunId" IS NULL)')
else:
whereParts.append(
'(rs."activeRunId" IS NOT NULL)' if str(val).lower() == "true" else '(rs."activeRunId" IS NULL)'
)
continue
colRef = _listingColSql(key, wfFieldNames)
if not colRef:
continue
colType = wfFields.get(key, "TEXT") if key in wfFieldNames else (
"DOUBLE PRECISION" if key == "lastStartedAt" else "BIGINT" if key == "runCount" else "TEXT"
)
if val is None:
if key == "lastStartedAt":
whereParts.append(f'({colRef} IS NULL)')
elif key == "runCount":
whereParts.append(f'({colRef} = 0)')
else:
whereParts.append(f'({colRef} IS NULL OR {colRef}::TEXT = \'\')')
continue
if not isinstance(val, dict):
if colType == "BOOLEAN" or key == "isRunning":
whereParts.append(f'COALESCE({colRef}, FALSE) = %s')
values.append(str(val).lower() == "true")
else:
whereParts.append(f'{colRef}::TEXT ILIKE %s')
values.append(str(val))
continue
op = val.get("operator", "equals")
v = val.get("value", "")
if op in ("equals", "eq"):
if colType == "BOOLEAN":
whereParts.append(f'COALESCE({colRef}, FALSE) = %s')
values.append(str(v).lower() == "true")
else:
whereParts.append(f'{colRef}::TEXT = %s')
values.append(str(v))
elif op == "contains":
whereParts.append(f'{colRef}::TEXT ILIKE %s')
values.append(f"%{v}%")
elif op == "startsWith":
whereParts.append(f'{colRef}::TEXT ILIKE %s')
values.append(f"{v}%")
elif op == "endsWith":
whereParts.append(f'{colRef}::TEXT ILIKE %s')
values.append(f"%{v}")
elif op in ("gt", "gte", "lt", "lte"):
sqlOp = {"gt": ">", "gte": ">=", "lt": "<", "lte": "<="}[op]
if colType in ("INTEGER", "DOUBLE PRECISION", "BIGINT") or key in ("lastStartedAt", "runCount"):
try:
whereParts.append(f'{colRef}::double precision {sqlOp} %s')
values.append(float(v))
except (ValueError, TypeError):
continue
else:
whereParts.append(f'{colRef}::TEXT {sqlOp} %s')
values.append(str(v))
elif op == "between":
fromVal = v.get("from", "") if isinstance(v, dict) else ""
toVal = v.get("to", "") if isinstance(v, dict) else ""
if not fromVal and not toVal:
continue
isNumericCol = colType in ("INTEGER", "DOUBLE PRECISION", "BIGINT") or key in ("lastStartedAt", "runCount")
isDateVal = bool(fromVal and re.match(r"^\d{4}-\d{2}-\d{2}$", str(fromVal))) or bool(
toVal and re.match(r"^\d{4}-\d{2}-\d{2}$", str(toVal))
)
if isNumericCol and isDateVal:
if fromVal and toVal:
fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()
toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace(
hour=23, minute=59, second=59, tzinfo=timezone.utc
).timestamp()
whereParts.append(f"({colRef} >= %s AND {colRef} <= %s)")
values.extend([fromTs, toTs])
elif fromVal:
fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()
whereParts.append(f"({colRef} >= %s)")
values.append(fromTs)
else:
toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace(
hour=23, minute=59, second=59, tzinfo=timezone.utc
).timestamp()
whereParts.append(f"({colRef} <= %s)")
values.append(toTs)
elif isNumericCol:
try:
if fromVal and toVal:
whereParts.append(
f"({colRef}::double precision >= %s AND {colRef}::double precision <= %s)"
)
values.extend([float(fromVal), float(toVal)])
elif fromVal:
whereParts.append(f"{colRef}::double precision >= %s")
values.append(float(fromVal))
elif toVal:
whereParts.append(f"{colRef}::double precision <= %s")
values.append(float(toVal))
except (ValueError, TypeError):
continue
else:
if fromVal and toVal:
whereParts.append(f"({colRef}::TEXT >= %s AND {colRef}::TEXT <= %s)")
values.extend([str(fromVal), str(toVal)])
elif fromVal:
whereParts.append(f"{colRef}::TEXT >= %s")
values.append(str(fromVal))
elif toVal:
whereParts.append(f"{colRef}::TEXT <= %s")
values.append(str(toVal))
def _buildJoinedWorkflowWhereOrderLimit(
recordFilter: dict,
pagination,
wfFields: dict,
) -> tuple:
"""WHERE / ORDER BY / LIMIT for joined AutoWorkflow + run stats listing."""
wfFieldNames = set(wfFields.keys())
whereParts: list = []
values: list = []
for field, value in (recordFilter or {}).items():
if value is None:
whereParts.append(f'w."{field}" IS NULL')
elif isinstance(value, list):
whereParts.append(f'w."{field}" = ANY(%s)')
values.append(value)
else:
whereParts.append(f'w."{field}" = %s')
values.append(value)
_appendJoinedListingFilters(whereParts, values, pagination, wfFields)
whereClause = " WHERE " + " AND ".join(whereParts) if whereParts else ""
orderParts: list = []
if pagination and pagination.sort:
for sf in pagination.sort:
sfField = sf.get("field") if isinstance(sf, dict) else getattr(sf, "field", None)
sfDir = sf.get("direction", "asc") if isinstance(sf, dict) else getattr(sf, "direction", "asc")
if not sfField:
continue
expr = _listingOrderExpr(sfField, wfFieldNames, wfFields)
if not expr:
continue
direction = "DESC" if str(sfDir).lower() == "desc" else "ASC"
orderParts.append(f"{expr} {direction} NULLS LAST")
if not orderParts:
orderParts.append('w."sysCreatedAt" DESC NULLS LAST')
orderClause = " ORDER BY " + ", ".join(orderParts)
limitClause = ""
if pagination:
offset = (pagination.page - 1) * pagination.pageSize
limitClause = f" LIMIT {pagination.pageSize} OFFSET {offset}"
return whereClause, orderClause, limitClause, values
def _getWorkflowsJoinedPaginated(
db: DatabaseConnector,
recordFilter: dict,
paginationParams: PaginationParams,
) -> dict:
"""SQL listing: AutoWorkflow LEFT JOIN aggregated AutoRun stats (one query + count)."""
from modules.connectors.connectorDbPostgre import getModelFields, parseRecordFields
wfFields = getModelFields(AutoWorkflow)
whereClause, orderClause, limitClause, values = _buildJoinedWorkflowWhereOrderLimit(
recordFilter, paginationParams, wfFields,
)
countValues = list(values)
fromSql = f'"AutoWorkflow" w LEFT JOIN {_RUN_STATS_SUBQUERY.strip()} ON rs."workflowId" = w."id"'
countSql = f"SELECT COUNT(*) AS cnt FROM {fromSql}{whereClause}"
dataSql = f"SELECT w.*, rs.\"lastStartedAt\", rs.\"runCount\", rs.\"activeRunId\" FROM {fromSql}{whereClause}{orderClause}{limitClause}"
db._ensure_connection()
with db.borrowCursor() as cursor:
cursor.execute(countSql, countValues)
totalItems = int(cursor.fetchone()["cnt"])
cursor.execute(dataSql, values)
rawRows = [dict(row) for row in cursor.fetchall()]
pageSize = paginationParams.pageSize if paginationParams else max(totalItems, 1)
totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0
modelFields = AutoWorkflow.model_fields
for record in rawRows:
parseRecordFields(record, wfFields, "table AutoWorkflow joined listing")
for fieldName, fieldType in wfFields.items():
if fieldType == "JSONB" and fieldName in record and record[fieldName] is None:
fieldInfo = modelFields.get(fieldName)
if fieldInfo:
fieldAnnotation = fieldInfo.annotation
if fieldAnnotation == list or (
hasattr(fieldAnnotation, "__origin__") and fieldAnnotation.__origin__ is list
):
record[fieldName] = []
elif fieldAnnotation == dict or (
hasattr(fieldAnnotation, "__origin__") and fieldAnnotation.__origin__ is dict
):
record[fieldName] = {}
return {"items": rawRows, "totalItems": totalItems, "totalPages": totalPages}