automations page now shows alle workflows from all mandates

This commit is contained in:
Ida 2026-06-04 15:15:31 +02:00
parent b1d4137935
commit b35afa1a8b
3 changed files with 167 additions and 46 deletions

View file

@ -1275,6 +1275,9 @@ class DatabaseConnector:
for field, value in recordFilter.items(): for field, value in recordFilter.items():
if value is None: if value is None:
where_conditions.append(f'"{field}" IS NULL') where_conditions.append(f'"{field}" IS NULL')
elif field == "isTemplate" and value is False:
# NULL must count as non-template (legacy rows omit the flag)
where_conditions.append(f'"{field}" IS NOT TRUE')
elif isinstance(value, list): elif isinstance(value, list):
where_conditions.append(f'"{field}" = ANY(%s)') where_conditions.append(f'"{field}" = ANY(%s)')
where_values.append(value) where_values.append(value)
@ -1347,6 +1350,8 @@ class DatabaseConnector:
for field, value in recordFilter.items(): for field, value in recordFilter.items():
if value is None: if value is None:
where_parts.append(f'"{field}" IS NULL') where_parts.append(f'"{field}" IS NULL')
elif field == "isTemplate" and value is False:
where_parts.append(f'"{field}" IS NOT TRUE')
elif isinstance(value, list): elif isinstance(value, list):
where_parts.append(f'"{field}" = ANY(%s)') where_parts.append(f'"{field}" = ANY(%s)')
values.append(value) values.append(value)

View file

@ -234,6 +234,8 @@ class GraphicalEditorObjects:
data["targetFeatureInstanceId"] = self.featureInstanceId data["targetFeatureInstanceId"] = self.featureInstanceId
if "active" not in data or data.get("active") is None: if "active" not in data or data.get("active") is None:
data["active"] = True data["active"] = True
if data.get("isTemplate") is None:
data["isTemplate"] = False
data["invocations"] = invocations_synced_with_graph(data.get("graph") or {}, data.get("invocations")) data["invocations"] = invocations_synced_with_graph(data.get("graph") or {}, data.get("invocations"))
created = self.db.recordCreate(Automation2Workflow, data) created = self.db.recordCreate(Automation2Workflow, data)
out = dict(created) out = dict(created)

View file

@ -57,6 +57,99 @@ def _getUserMandateIds(userId: str) -> list[str]:
return [um.mandateId for um in memberships if um.mandateId and um.enabled] return [um.mandateId for um in memberships if um.mandateId and um.enabled]
def _getAccessibleMandateIds(userId: str) -> list[str]:
"""Mandate IDs visible on the cross-mandate automations dashboard.
Union of explicit UserMandate memberships and mandates that own feature
instances the user has enabled FeatureAccess for (same idea as the
automation workspace list).
"""
mandateIds: set[str] = set(_getUserMandateIds(userId))
rootIface = getRootInterface()
from modules.interfaces.interfaceFeatures import getFeatureInterface
featureIf = getFeatureInterface(rootIface.db)
for access in rootIface.getFeatureAccessesForUser(userId) or []:
if not access.enabled or not access.featureInstanceId:
continue
instance = featureIf.getFeatureInstance(access.featureInstanceId)
if instance and instance.mandateId:
mandateIds.add(str(instance.mandateId))
return sorted(mandateIds)
def _getUserAccessibleInstanceIds(userId: str) -> list[str]:
"""Feature instance IDs with enabled FeatureAccess (workspace parity)."""
rootIface = getRootInterface()
return [
str(a.featureInstanceId)
for a in (rootIface.getFeatureAccessesForUser(userId) or [])
if a.enabled and a.featureInstanceId
]
def _getWorkflowVisibilityScope(userId: str) -> dict:
"""Scope for which AutoWorkflow rows a user may see on the dashboard."""
return {
"mandateIds": _getAccessibleMandateIds(userId),
"instanceIds": _getUserAccessibleInstanceIds(userId),
}
def _workflowVisibilityForContext(context: RequestContext) -> Optional[dict]:
"""None = platform admin (no visibility restriction)."""
if context.isPlatformAdmin:
return None
userId = str(context.user.id) if context.user else None
if not userId:
return {"mandateIds": [], "instanceIds": []}
return _getWorkflowVisibilityScope(userId)
def _workflowIsVisible(row: dict, visibility_scope: Optional[dict]) -> bool:
if not visibility_scope:
return True
mandate_ids = set(visibility_scope.get("mandateIds") or [])
instance_ids = set(visibility_scope.get("instanceIds") or [])
mid = row.get("mandateId")
if mid and str(mid) in mandate_ids:
return True
fi = row.get("featureInstanceId")
if fi and str(fi) in instance_ids:
return True
tid = row.get("targetFeatureInstanceId") or fi
if tid and str(tid) in instance_ids:
return True
return False
def _appendWorkflowVisibilityWhere(
whereParts: list,
values: list,
visibility_scope: Optional[dict],
table_alias: str = "w",
) -> None:
"""Append (mandate IN … OR featureInstanceId IN … OR targetFeatureInstanceId IN …)."""
if not visibility_scope:
return
mandate_ids = visibility_scope.get("mandateIds") or []
instance_ids = visibility_scope.get("instanceIds") or []
vis_parts: list[str] = []
if mandate_ids:
vis_parts.append(f'{table_alias}."mandateId" = ANY(%s)')
values.append(mandate_ids)
if instance_ids:
vis_parts.append(
f'({table_alias}."featureInstanceId" = ANY(%s) '
f'OR {table_alias}."targetFeatureInstanceId" = ANY(%s))'
)
values.extend([instance_ids, instance_ids])
if not vis_parts:
whereParts.append("FALSE")
else:
whereParts.append("(" + " OR ".join(vis_parts) + ")")
def _getAdminMandateIds(userId: str, mandateIds: list) -> list: def _getAdminMandateIds(userId: str, mandateIds: list) -> list:
"""Batch-check which mandates the user is admin for (UserMandate → UserMandateRole → Role).""" """Batch-check which mandates the user is admin for (UserMandate → UserMandateRole → Role)."""
if not mandateIds: if not mandateIds:
@ -126,8 +219,8 @@ def _scopedRunFilter(context: RequestContext) -> Optional[dict]:
""" """
Build a DB filter dict based on RBAC: Build a DB filter dict based on RBAC:
- sysadmin: None (no filter) - sysadmin: None (no filter)
- mandate admin: mandateId IN user's mandates - mandate admin: mandateId IN admin mandates (from accessible mandate set)
- normal user: ownerId = userId - normal user: ownerId = userId (own runs across all mandates)
""" """
if context.isPlatformAdmin: if context.isPlatformAdmin:
return None return None
@ -136,7 +229,7 @@ def _scopedRunFilter(context: RequestContext) -> Optional[dict]:
if not userId: if not userId:
return {"ownerId": "__impossible__"} return {"ownerId": "__impossible__"}
mandateIds = _getUserMandateIds(userId) mandateIds = _getAccessibleMandateIds(userId)
adminMandateIds = _getAdminMandateIds(userId, mandateIds) adminMandateIds = _getAdminMandateIds(userId, mandateIds)
if adminMandateIds: if adminMandateIds:
@ -147,22 +240,14 @@ def _scopedRunFilter(context: RequestContext) -> Optional[dict]:
def _scopedWorkflowFilter(context: RequestContext) -> Optional[dict]: def _scopedWorkflowFilter(context: RequestContext) -> Optional[dict]:
""" """
Build a DB filter for AutoWorkflow based on RBAC: Legacy hook for filterValues/ids modes only applies isTemplate.
- sysadmin: None (no filter, sees all)
- normal user: mandateId IN user's mandates Row visibility uses _workflowVisibilityForContext + SQL OR clause
(mandate membership OR accessible feature instances), not mandateId alone.
""" """
if context.isPlatformAdmin: if context.isPlatformAdmin:
return None return None
return {}
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 _userMayDeleteWorkflow(context: RequestContext, wfMandateId: Optional[str]) -> bool: def _userMayDeleteWorkflow(context: RequestContext, wfMandateId: Optional[str]) -> bool:
@ -436,6 +521,7 @@ def _buildJoinedWorkflowWhereOrderLimit(
recordFilter: dict, recordFilter: dict,
pagination, pagination,
wfFields: dict, wfFields: dict,
visibility_scope: Optional[dict] = None,
) -> tuple: ) -> tuple:
"""WHERE / ORDER BY / LIMIT for joined AutoWorkflow + run stats listing.""" """WHERE / ORDER BY / LIMIT for joined AutoWorkflow + run stats listing."""
wfFieldNames = set(wfFields.keys()) wfFieldNames = set(wfFields.keys())
@ -445,6 +531,8 @@ def _buildJoinedWorkflowWhereOrderLimit(
for field, value in (recordFilter or {}).items(): for field, value in (recordFilter or {}).items():
if value is None: if value is None:
whereParts.append(f'w."{field}" IS NULL') whereParts.append(f'w."{field}" IS NULL')
elif field == "isTemplate" and value is False:
whereParts.append('w."isTemplate" IS NOT TRUE')
elif isinstance(value, list): elif isinstance(value, list):
whereParts.append(f'w."{field}" = ANY(%s)') whereParts.append(f'w."{field}" = ANY(%s)')
values.append(value) values.append(value)
@ -452,6 +540,7 @@ def _buildJoinedWorkflowWhereOrderLimit(
whereParts.append(f'w."{field}" = %s') whereParts.append(f'w."{field}" = %s')
values.append(value) values.append(value)
_appendWorkflowVisibilityWhere(whereParts, values, visibility_scope, table_alias="w")
_appendJoinedListingFilters(whereParts, values, pagination, wfFields) _appendJoinedListingFilters(whereParts, values, pagination, wfFields)
whereClause = " WHERE " + " AND ".join(whereParts) if whereParts else "" whereClause = " WHERE " + " AND ".join(whereParts) if whereParts else ""
@ -481,17 +570,38 @@ def _buildJoinedWorkflowWhereOrderLimit(
return whereClause, orderClause, limitClause, values return whereClause, orderClause, limitClause, values
def _countWorkflowsJoined(
db: DatabaseConnector,
recordFilter: dict,
visibility_scope: Optional[dict] = None,
) -> int:
"""COUNT for AutoWorkflow with the same visibility clause as the joined listing."""
from modules.connectors.connectorDbPostgre import getModelFields
wfFields = getModelFields(AutoWorkflow)
whereClause, _, _, values = _buildJoinedWorkflowWhereOrderLimit(
recordFilter, None, wfFields, visibility_scope=visibility_scope,
)
fromSql = f'"AutoWorkflow" w LEFT JOIN {_RUN_STATS_SUBQUERY.strip()} ON rs."workflowId" = w."id"'
countSql = f"SELECT COUNT(*) AS cnt FROM {fromSql}{whereClause}"
db._ensure_connection()
with db.borrowCursor() as cursor:
cursor.execute(countSql, values)
return int(cursor.fetchone()["cnt"])
def _getWorkflowsJoinedPaginated( def _getWorkflowsJoinedPaginated(
db: DatabaseConnector, db: DatabaseConnector,
recordFilter: dict, recordFilter: dict,
paginationParams: PaginationParams, paginationParams: PaginationParams,
visibility_scope: Optional[dict] = None,
) -> dict: ) -> dict:
"""SQL listing: AutoWorkflow LEFT JOIN aggregated AutoRun stats (one query + count).""" """SQL listing: AutoWorkflow LEFT JOIN aggregated AutoRun stats (one query + count)."""
from modules.connectors.connectorDbPostgre import getModelFields, parseRecordFields from modules.connectors.connectorDbPostgre import getModelFields, parseRecordFields
wfFields = getModelFields(AutoWorkflow) wfFields = getModelFields(AutoWorkflow)
whereClause, orderClause, limitClause, values = _buildJoinedWorkflowWhereOrderLimit( whereClause, orderClause, limitClause, values = _buildJoinedWorkflowWhereOrderLimit(
recordFilter, paginationParams, wfFields, recordFilter, paginationParams, wfFields, visibility_scope=visibility_scope,
) )
countValues = list(values) countValues = list(values)
@ -663,27 +773,16 @@ def get_workflow_metrics(
""" """
db = _getDb() db = _getDb()
# --- Workflow counts (same filter as /workflows endpoint) --- # --- Workflow counts (same visibility as /workflows endpoint) ---
workflowCount = 0 workflowCount = 0
activeWorkflows = 0 activeWorkflows = 0
if db._ensureTableExists(AutoWorkflow): if db._ensureTableExists(AutoWorkflow):
wfBaseFilter = _scopedWorkflowFilter(context) visibility_scope = _workflowVisibilityForContext(context)
wfFilter = dict(wfBaseFilter) if wfBaseFilter else {} wfFilter = {"isTemplate": False}
wfFilter["isTemplate"] = False workflowCount = _countWorkflowsJoined(db, wfFilter, visibility_scope)
wfCount = db.getRecordsetPaginated(
AutoWorkflow, pagination=PaginationParams(page=1, pageSize=1),
recordFilter=wfFilter if wfFilter else None,
)
workflowCount = wfCount.get("totalItems", 0) if isinstance(wfCount, dict) else wfCount.totalItems
activeFilter = dict(wfFilter) activeFilter = dict(wfFilter)
activeFilter["active"] = True activeFilter["active"] = True
activeCount = db.getRecordsetPaginated( activeWorkflows = _countWorkflowsJoined(db, activeFilter, visibility_scope)
AutoWorkflow, pagination=PaginationParams(page=1, pageSize=1),
recordFilter=activeFilter,
)
activeWorkflows = activeCount.get("totalItems", 0) if isinstance(activeCount, dict) else activeCount.totalItems
# --- Run counts (same filter as /runs endpoint) --- # --- Run counts (same filter as /runs endpoint) ---
if not db._ensureTableExists(AutoRun): if not db._ensureTableExists(AutoRun):
@ -760,15 +859,16 @@ def get_system_workflows(
return _enrichedFilterValues(db, context, AutoWorkflow, _scopedWorkflowFilter, column) return _enrichedFilterValues(db, context, AutoWorkflow, _scopedWorkflowFilter, column)
if mode == "ids": if mode == "ids":
from modules.routes.routeHelpers import handleIdsMode from fastapi.responses import JSONResponse
baseFilter = _scopedWorkflowFilter(context) visibility_scope = _workflowVisibilityForContext(context)
recordFilter = dict(baseFilter) if baseFilter else {} rf = {"isTemplate": False}
recordFilter["isTemplate"] = False rows = db.getRecordset(AutoWorkflow, recordFilter=rf) or []
return handleIdsMode(db, AutoWorkflow, pagination, recordFilter) if visibility_scope:
rows = [r for r in rows if _workflowIsVisible(dict(r), visibility_scope)]
return JSONResponse(content=[str(r["id"]) for r in rows if r.get("id")])
baseFilter = _scopedWorkflowFilter(context) visibility_scope = _workflowVisibilityForContext(context)
recordFilter = dict(baseFilter) if baseFilter else {} recordFilter: dict = {"isTemplate": False}
recordFilter["isTemplate"] = False
if active is not None: if active is not None:
recordFilter["active"] = active recordFilter["active"] = active
@ -806,10 +906,11 @@ def get_system_workflows(
userId = str(context.user.id) if context.user else None userId = str(context.user.id) if context.user else None
adminMandateIds = [] adminMandateIds = []
if userId and not context.isPlatformAdmin: if userId and not context.isPlatformAdmin:
userMandateIds = _getUserMandateIds(userId) accessibleMandateIds = _getAccessibleMandateIds(userId)
adminMandateIds = _getAdminMandateIds(userId, userMandateIds) adminMandateIds = _getAdminMandateIds(userId, accessibleMandateIds)
fkSortField = _firstFkSortFieldForWorkflows(paginationParams) # Visibility uses SQL OR (mandate + instance); FK-label sort path cannot express that.
fkSortField = _firstFkSortFieldForWorkflows(paginationParams) if not visibility_scope else None
if fkSortField: if fkSortField:
from modules.routes.routeHelpers import getRecordsetPaginatedWithFkSort, applyFiltersAndSort from modules.routes.routeHelpers import getRecordsetPaginatedWithFkSort, applyFiltersAndSort
_COMPUTED_FIELDS = {"lastStartedAt", "runCount", "isRunning"} _COMPUTED_FIELDS = {"lastStartedAt", "runCount", "isRunning"}
@ -906,6 +1007,7 @@ def get_system_workflows(
else: else:
result = _getWorkflowsJoinedPaginated( result = _getWorkflowsJoinedPaginated(
db, recordFilter if recordFilter else {}, paginationParams, db, recordFilter if recordFilter else {}, paginationParams,
visibility_scope=visibility_scope,
) )
pageItems = result.get("items", []) pageItems = result.get("items", [])
totalItems = result.get("totalItems", 0) totalItems = result.get("totalItems", 0)
@ -1052,12 +1154,18 @@ def _enrichedFilterValues(
if _isTimestampColumn(modelClass, column): if _isTimestampColumn(modelClass, column):
return JSONResponse(content=[]) return JSONResponse(content=[])
visibility_scope = (
_workflowVisibilityForContext(context) if modelClass == AutoWorkflow else None
)
if column in ("mandateLabel", "mandateId"): if column in ("mandateLabel", "mandateId"):
baseFilter = scopeFilter(context) baseFilter = scopeFilter(context)
recordFilter = dict(baseFilter) if baseFilter else {} recordFilter = dict(baseFilter) if baseFilter else {}
if modelClass == AutoWorkflow: if modelClass == AutoWorkflow:
recordFilter["isTemplate"] = False recordFilter["isTemplate"] = False
items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["mandateId"]) or [] items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["mandateId"]) or []
if visibility_scope:
items = [r for r in items if _workflowIsVisible(r, visibility_scope)]
allVals = {r.get("mandateId") for r in items} allVals = {r.get("mandateId") for r in items}
mandateIds = sorted(v for v in allVals if v) mandateIds = sorted(v for v in allVals if v)
hasEmpty = None in allVals or "" in allVals hasEmpty = None in allVals or "" in allVals
@ -1072,7 +1180,9 @@ def _enrichedFilterValues(
recordFilter = dict(baseFilter) if baseFilter else {} recordFilter = dict(baseFilter) if baseFilter else {}
if modelClass == AutoWorkflow: if modelClass == AutoWorkflow:
recordFilter["isTemplate"] = False recordFilter["isTemplate"] = False
items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["featureInstanceId"]) or [] items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["featureInstanceId", "targetFeatureInstanceId", "mandateId"]) or []
if visibility_scope:
items = [r for r in items if _workflowIsVisible(r, visibility_scope)]
allVals = {r.get("featureInstanceId") for r in items} allVals = {r.get("featureInstanceId") for r in items}
instanceIds = sorted(v for v in allVals if v) instanceIds = sorted(v for v in allVals if v)
hasEmpty = None in allVals or "" in allVals hasEmpty = None in allVals or "" in allVals
@ -1095,7 +1205,11 @@ def _enrichedFilterValues(
if column == "workflowLabel": if column == "workflowLabel":
baseFilter = scopeFilter(context) baseFilter = scopeFilter(context)
recordFilter = dict(baseFilter) if baseFilter else {} recordFilter = dict(baseFilter) if baseFilter else {}
items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["workflowId", "label"]) or [] if modelClass == AutoWorkflow:
recordFilter["isTemplate"] = False
items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["workflowId", "label", "mandateId", "featureInstanceId", "targetFeatureInstanceId"]) or []
if visibility_scope:
items = [r for r in items if _workflowIsVisible(r, visibility_scope)]
labels = set() labels = set()
wfIds = set() wfIds = set()
hasEmpty = False hasEmpty = False