From b35afa1a8b809c20429e79fd77d66e354db7c67a Mon Sep 17 00:00:00 2001 From: Ida Date: Thu, 4 Jun 2026 15:15:31 +0200 Subject: [PATCH] automations page now shows alle workflows from all mandates --- modules/connectors/connectorDbPostgre.py | 5 + .../interfaceFeatureGraphicalEditor.py | 2 + modules/routes/routeWorkflowDashboard.py | 206 ++++++++++++++---- 3 files changed, 167 insertions(+), 46 deletions(-) diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py index 1f37e24a..2cba9ec0 100644 --- a/modules/connectors/connectorDbPostgre.py +++ b/modules/connectors/connectorDbPostgre.py @@ -1275,6 +1275,9 @@ class DatabaseConnector: for field, value in recordFilter.items(): if value is None: 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): where_conditions.append(f'"{field}" = ANY(%s)') where_values.append(value) @@ -1347,6 +1350,8 @@ class DatabaseConnector: for field, value in recordFilter.items(): if value is None: 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): where_parts.append(f'"{field}" = ANY(%s)') values.append(value) diff --git a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py index 09192d2e..7672fa26 100644 --- a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py +++ b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py @@ -234,6 +234,8 @@ class GraphicalEditorObjects: data["targetFeatureInstanceId"] = self.featureInstanceId if "active" not in data or data.get("active") is None: 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")) created = self.db.recordCreate(Automation2Workflow, data) out = dict(created) diff --git a/modules/routes/routeWorkflowDashboard.py b/modules/routes/routeWorkflowDashboard.py index ea4b8854..ae3a5b64 100644 --- a/modules/routes/routeWorkflowDashboard.py +++ b/modules/routes/routeWorkflowDashboard.py @@ -57,6 +57,99 @@ def _getUserMandateIds(userId: str) -> list[str]: 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: """Batch-check which mandates the user is admin for (UserMandate → UserMandateRole → Role).""" if not mandateIds: @@ -126,8 +219,8 @@ def _scopedRunFilter(context: RequestContext) -> Optional[dict]: """ Build a DB filter dict based on RBAC: - sysadmin: None (no filter) - - mandate admin: mandateId IN user's mandates - - normal user: ownerId = userId + - mandate admin: mandateId IN admin mandates (from accessible mandate set) + - normal user: ownerId = userId (own runs across all mandates) """ if context.isPlatformAdmin: return None @@ -136,7 +229,7 @@ def _scopedRunFilter(context: RequestContext) -> Optional[dict]: if not userId: return {"ownerId": "__impossible__"} - mandateIds = _getUserMandateIds(userId) + mandateIds = _getAccessibleMandateIds(userId) adminMandateIds = _getAdminMandateIds(userId, mandateIds) if adminMandateIds: @@ -147,22 +240,14 @@ def _scopedRunFilter(context: RequestContext) -> Optional[dict]: def _scopedWorkflowFilter(context: RequestContext) -> Optional[dict]: """ - Build a DB filter for AutoWorkflow based on RBAC: - - sysadmin: None (no filter, sees all) - - normal user: mandateId IN user's mandates + Legacy hook for filterValues/ids modes — only applies isTemplate. + + Row visibility uses _workflowVisibilityForContext + SQL OR clause + (mandate membership OR accessible feature instances), not mandateId alone. """ 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__"} + return {} def _userMayDeleteWorkflow(context: RequestContext, wfMandateId: Optional[str]) -> bool: @@ -436,6 +521,7 @@ def _buildJoinedWorkflowWhereOrderLimit( recordFilter: dict, pagination, wfFields: dict, + visibility_scope: Optional[dict] = None, ) -> tuple: """WHERE / ORDER BY / LIMIT for joined AutoWorkflow + run stats listing.""" wfFieldNames = set(wfFields.keys()) @@ -445,6 +531,8 @@ def _buildJoinedWorkflowWhereOrderLimit( for field, value in (recordFilter or {}).items(): if value is None: 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): whereParts.append(f'w."{field}" = ANY(%s)') values.append(value) @@ -452,6 +540,7 @@ def _buildJoinedWorkflowWhereOrderLimit( whereParts.append(f'w."{field}" = %s') values.append(value) + _appendWorkflowVisibilityWhere(whereParts, values, visibility_scope, table_alias="w") _appendJoinedListingFilters(whereParts, values, pagination, wfFields) whereClause = " WHERE " + " AND ".join(whereParts) if whereParts else "" @@ -481,17 +570,38 @@ def _buildJoinedWorkflowWhereOrderLimit( 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( db: DatabaseConnector, recordFilter: dict, paginationParams: PaginationParams, + visibility_scope: Optional[dict] = None, ) -> 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, + recordFilter, paginationParams, wfFields, visibility_scope=visibility_scope, ) countValues = list(values) @@ -663,27 +773,16 @@ def get_workflow_metrics( """ db = _getDb() - # --- Workflow counts (same filter as /workflows endpoint) --- + # --- Workflow counts (same visibility as /workflows endpoint) --- workflowCount = 0 activeWorkflows = 0 if db._ensureTableExists(AutoWorkflow): - wfBaseFilter = _scopedWorkflowFilter(context) - wfFilter = dict(wfBaseFilter) if wfBaseFilter else {} - wfFilter["isTemplate"] = False - - 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 - + visibility_scope = _workflowVisibilityForContext(context) + wfFilter = {"isTemplate": False} + workflowCount = _countWorkflowsJoined(db, wfFilter, visibility_scope) activeFilter = dict(wfFilter) activeFilter["active"] = True - activeCount = db.getRecordsetPaginated( - AutoWorkflow, pagination=PaginationParams(page=1, pageSize=1), - recordFilter=activeFilter, - ) - activeWorkflows = activeCount.get("totalItems", 0) if isinstance(activeCount, dict) else activeCount.totalItems + activeWorkflows = _countWorkflowsJoined(db, activeFilter, visibility_scope) # --- Run counts (same filter as /runs endpoint) --- if not db._ensureTableExists(AutoRun): @@ -760,15 +859,16 @@ def get_system_workflows( return _enrichedFilterValues(db, context, AutoWorkflow, _scopedWorkflowFilter, column) if mode == "ids": - from modules.routes.routeHelpers import handleIdsMode - baseFilter = _scopedWorkflowFilter(context) - recordFilter = dict(baseFilter) if baseFilter else {} - recordFilter["isTemplate"] = False - return handleIdsMode(db, AutoWorkflow, pagination, recordFilter) + from fastapi.responses import JSONResponse + visibility_scope = _workflowVisibilityForContext(context) + rf = {"isTemplate": False} + rows = db.getRecordset(AutoWorkflow, recordFilter=rf) or [] + 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) - recordFilter = dict(baseFilter) if baseFilter else {} - recordFilter["isTemplate"] = False + visibility_scope = _workflowVisibilityForContext(context) + recordFilter: dict = {"isTemplate": False} if active is not None: recordFilter["active"] = active @@ -806,10 +906,11 @@ def get_system_workflows( userId = str(context.user.id) if context.user else None adminMandateIds = [] if userId and not context.isPlatformAdmin: - userMandateIds = _getUserMandateIds(userId) - adminMandateIds = _getAdminMandateIds(userId, userMandateIds) + accessibleMandateIds = _getAccessibleMandateIds(userId) + 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: from modules.routes.routeHelpers import getRecordsetPaginatedWithFkSort, applyFiltersAndSort _COMPUTED_FIELDS = {"lastStartedAt", "runCount", "isRunning"} @@ -906,6 +1007,7 @@ def get_system_workflows( else: result = _getWorkflowsJoinedPaginated( db, recordFilter if recordFilter else {}, paginationParams, + visibility_scope=visibility_scope, ) pageItems = result.get("items", []) totalItems = result.get("totalItems", 0) @@ -1052,12 +1154,18 @@ def _enrichedFilterValues( if _isTimestampColumn(modelClass, column): return JSONResponse(content=[]) + visibility_scope = ( + _workflowVisibilityForContext(context) if modelClass == AutoWorkflow else None + ) + if column in ("mandateLabel", "mandateId"): baseFilter = scopeFilter(context) recordFilter = dict(baseFilter) if baseFilter else {} if modelClass == AutoWorkflow: recordFilter["isTemplate"] = False 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} mandateIds = sorted(v for v in allVals if v) hasEmpty = None in allVals or "" in allVals @@ -1072,7 +1180,9 @@ def _enrichedFilterValues( recordFilter = dict(baseFilter) if baseFilter else {} if modelClass == AutoWorkflow: 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} instanceIds = sorted(v for v in allVals if v) hasEmpty = None in allVals or "" in allVals @@ -1095,7 +1205,11 @@ def _enrichedFilterValues( if column == "workflowLabel": baseFilter = scopeFilter(context) 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() wfIds = set() hasEmpty = False