From 9ef0d430915e9b7870eec2c224d5abf44f822dc1 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 22 Mar 2026 22:19:50 +0100 Subject: [PATCH] fixed tables and forms --- app.py | 3 + modules/connectors/connectorDbPostgre.py | 8 + .../automation/interfaceFeatureAutomation.py | 2 + modules/features/automation/mainAutomation.py | 5 - .../automation/routeFeatureAutomation.py | 1 + modules/routes/routeAdminAutomationLogs.py | 207 ++++++++++++++++++ modules/shared/attributeUtils.py | 21 ++ modules/system/mainSystem.py | 12 +- 8 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 modules/routes/routeAdminAutomationLogs.py diff --git a/app.py b/app.py index c1400353..e69d7a0f 100644 --- a/app.py +++ b/app.py @@ -568,6 +568,9 @@ app.include_router(sharepointRouter) from modules.routes.routeAdminAutomationEvents import router as adminAutomationEventsRouter app.include_router(adminAutomationEventsRouter) +from modules.routes.routeAdminAutomationLogs import router as adminAutomationLogsRouter +app.include_router(adminAutomationLogsRouter) + from modules.routes.routeAdminLogs import router as adminLogsRouter app.include_router(adminLogsRouter) diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py index 9675ffca..67cceb45 100644 --- a/modules/connectors/connectorDbPostgre.py +++ b/modules/connectors/connectorDbPostgre.py @@ -992,6 +992,10 @@ class DatabaseConnector: Returns (where_clause, order_clause, limit_clause, values, count_values). """ fields = _get_model_fields(model_class) + fields["_createdAt"] = "DOUBLE PRECISION" + fields["_modifiedAt"] = "DOUBLE PRECISION" + fields["_createdBy"] = "TEXT" + fields["_modifiedBy"] = "TEXT" validColumns = set(fields.keys()) where_parts: List[str] = [] values: List[Any] = [] @@ -1186,6 +1190,10 @@ class DatabaseConnector: """ table = model_class.__name__ fields = _get_model_fields(model_class) + fields["_createdAt"] = "DOUBLE PRECISION" + fields["_modifiedAt"] = "DOUBLE PRECISION" + fields["_createdBy"] = "TEXT" + fields["_modifiedBy"] = "TEXT" if column not in fields: return [] diff --git a/modules/features/automation/interfaceFeatureAutomation.py b/modules/features/automation/interfaceFeatureAutomation.py index 3fc2420b..4091bc28 100644 --- a/modules/features/automation/interfaceFeatureAutomation.py +++ b/modules/features/automation/interfaceFeatureAutomation.py @@ -418,6 +418,8 @@ class AutomationObjects: if not self.checkRbacPermission(AutomationDefinition, "update", automationId): raise PermissionError(f"No permission to modify automation {automationId}") + automationData.pop("executionLogs", None) + # If deactivating: immediately remove scheduler job (don't rely on async callback) isBeingDeactivated = "active" in automationData and not automationData["active"] if isBeingDeactivated: diff --git a/modules/features/automation/mainAutomation.py b/modules/features/automation/mainAutomation.py index 35a61512..4bb30f7f 100644 --- a/modules/features/automation/mainAutomation.py +++ b/modules/features/automation/mainAutomation.py @@ -27,11 +27,6 @@ UI_OBJECTS = [ "label": {"en": "Templates", "de": "Vorlagen", "fr": "Modèles"}, "meta": {"area": "templates"} }, - { - "objectKey": "ui.feature.automation.logs", - "label": {"en": "Execution Logs", "de": "Ausführungsprotokolle", "fr": "Journaux d'exécution"}, - "meta": {"area": "logs"} - }, ] # Resource Objects for RBAC catalog diff --git a/modules/features/automation/routeFeatureAutomation.py b/modules/features/automation/routeFeatureAutomation.py index 81f0852b..48f53eea 100644 --- a/modules/features/automation/routeFeatureAutomation.py +++ b/modules/features/automation/routeFeatureAutomation.py @@ -785,6 +785,7 @@ def update_automation( try: chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None) automationData = automation.model_dump() + automationData.pop("executionLogs", None) updated = chatInterface.updateAutomationDefinition(automationId, automationData) return updated except HTTPException: diff --git a/modules/routes/routeAdminAutomationLogs.py b/modules/routes/routeAdminAutomationLogs.py new file mode 100644 index 00000000..8b4d897b --- /dev/null +++ b/modules/routes/routeAdminAutomationLogs.py @@ -0,0 +1,207 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Admin automation execution logs routes. +SysAdmin-only endpoints for viewing consolidated automation execution history +across all mandates and feature instances. +""" + +from fastapi import APIRouter, HTTPException, Depends, Request, Query +from typing import List, Dict, Any, Optional +import logging +import json +import math +import uuid + +from modules.auth import limiter, requireSysAdminRole +from modules.datamodels.datamodelUam import User +from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict +from modules.routes.routeDataUsers import _applyFiltersAndSort, _extractDistinctValues + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/api/admin/automation-logs", + tags=["Admin Automation Logs"], + responses={ + 401: {"description": "Unauthorized"}, + 403: {"description": "Forbidden - Sysadmin only"}, + 500: {"description": "Internal server error"}, + }, +) + + +def _buildFlattenedExecutionLogs(currentUser: User) -> List[Dict[str, Any]]: + """Flatten executionLogs from all AutomationDefinitions across all mandates. + Called from a SysAdmin-only endpoint — bypasses RBAC, reads directly from DB.""" + from modules.interfaces.interfaceDbApp import getRootInterface + from modules.features.automation.mainAutomation import getAutomationServices + from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition + + rootInterface = getRootInterface() + services = getAutomationServices(currentUser, mandateId=None, featureInstanceId=None) + allAutomations = services.interfaceDbAutomation.db.getRecordset(AutomationDefinition) + + userCache: Dict[str, str] = {} + mandateCache: Dict[str, str] = {} + featureCache: Dict[str, str] = {} + + def _resolveUsername(userId: str) -> str: + if not userId: + return "" + if userId not in userCache: + try: + user = rootInterface.getUser(userId) + userCache[userId] = user.username if user else userId[:8] + except Exception: + userCache[userId] = userId[:8] + return userCache[userId] + + def _resolveMandateLabel(mandateId: str) -> str: + if not mandateId: + return "" + if mandateId not in mandateCache: + try: + mandate = rootInterface.getMandate(mandateId) + mandateCache[mandateId] = getattr(mandate, "label", None) or mandateId[:8] + except Exception: + mandateCache[mandateId] = mandateId[:8] + return mandateCache[mandateId] + + def _resolveFeatureLabel(featureInstanceId: str) -> str: + if not featureInstanceId: + return "" + if featureInstanceId not in featureCache: + try: + instance = rootInterface.getFeatureInstance(featureInstanceId) + featureCache[featureInstanceId] = ( + getattr(instance, "label", None) + or getattr(instance, "featureCode", None) + or featureInstanceId[:8] + ) + except Exception: + featureCache[featureInstanceId] = featureInstanceId[:8] + return featureCache[featureInstanceId] + + flatLogs: List[Dict[str, Any]] = [] + + for automation in allAutomations: + if isinstance(automation, dict): + automationId = automation.get("id", "") + automationLabel = automation.get("label", "") + mandateId = automation.get("mandateId", "") + featureInstanceId = automation.get("featureInstanceId", "") + createdBy = automation.get("_createdBy", "") + logs = automation.get("executionLogs") or [] + else: + automationId = getattr(automation, "id", "") + automationLabel = getattr(automation, "label", "") + mandateId = getattr(automation, "mandateId", "") + featureInstanceId = getattr(automation, "featureInstanceId", "") + createdBy = getattr(automation, "_createdBy", "") + logs = getattr(automation, "executionLogs", None) or [] + + mandateName = _resolveMandateLabel(mandateId) + featureInstanceName = _resolveFeatureLabel(featureInstanceId) + executedByName = _resolveUsername(createdBy) + + for log in logs: + timestamp = log.get("timestamp", 0) if isinstance(log, dict) else 0 + status = log.get("status", "") if isinstance(log, dict) else "" + workflowId = log.get("workflowId", "") if isinstance(log, dict) else "" + messages = log.get("messages", []) if isinstance(log, dict) else [] + + flatLogs.append({ + "id": str(uuid.uuid4()), + "timestamp": timestamp, + "automationId": automationId, + "automationLabel": automationLabel, + "mandateName": mandateName, + "featureInstanceName": featureInstanceName, + "executedBy": executedByName, + "status": status, + "workflowId": workflowId, + "messages": "; ".join(messages) if messages else "", + }) + + flatLogs.sort(key=lambda x: x.get("timestamp", 0), reverse=True) + return flatLogs + + +@router.get("") +@limiter.limit("30/minute") +def get_all_automation_logs( + request: Request, + pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"), + currentUser: User = Depends(requireSysAdminRole), +): + """Get consolidated execution logs from all automations (sysadmin only).""" + try: + paginationParams: Optional[PaginationParams] = None + if pagination: + try: + paginationDict = json.loads(pagination) + if paginationDict: + paginationDict = normalize_pagination_dict(paginationDict) + paginationParams = PaginationParams(**paginationDict) + except (json.JSONDecodeError, ValueError) as e: + raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}") + + logs = _buildFlattenedExecutionLogs(currentUser) + filtered = _applyFiltersAndSort(logs, paginationParams) + + if paginationParams: + totalItems = len(filtered) + totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 + startIdx = (paginationParams.page - 1) * paginationParams.pageSize + endIdx = startIdx + paginationParams.pageSize + return { + "items": filtered[startIdx:endIdx], + "pagination": PaginationMetadata( + currentPage=paginationParams.page, + pageSize=paginationParams.pageSize, + totalItems=totalItems, + totalPages=totalPages, + sort=paginationParams.sort, + filters=paginationParams.filters, + ).model_dump(), + } + + return {"items": logs, "pagination": None} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting automation logs: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error getting automation logs: {str(e)}") + + +@router.get("/filter-values") +@limiter.limit("60/minute") +def get_automation_log_filter_values( + request: Request, + column: str = Query(..., description="Column key"), + pagination: Optional[str] = Query(None, description="JSON-encoded current filters"), + currentUser: User = Depends(requireSysAdminRole), +): + """Return distinct filter values for a column in automation logs.""" + try: + crossFilterParams: Optional[PaginationParams] = None + if pagination: + try: + paginationDict = json.loads(pagination) + if paginationDict: + paginationDict = normalize_pagination_dict(paginationDict) + filters = paginationDict.get("filters", {}) + filters.pop(column, None) + paginationDict["filters"] = filters + paginationDict.pop("sort", None) + crossFilterParams = PaginationParams(**paginationDict) + except (json.JSONDecodeError, ValueError): + pass + + logs = _buildFlattenedExecutionLogs(currentUser) + crossFiltered = _applyFiltersAndSort(logs, crossFilterParams) + return _extractDistinctValues(crossFiltered, column) + except Exception as e: + logger.error(f"Error getting filter values: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/modules/shared/attributeUtils.py b/modules/shared/attributeUtils.py index 863d7f36..6a857d85 100644 --- a/modules/shared/attributeUtils.py +++ b/modules/shared/attributeUtils.py @@ -258,6 +258,27 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag attributes.append(attr_def) + # Append system timestamp fields (set automatically by DatabaseConnector) + systemTimestampFields = [ + ("_createdAt", {"en": "Created at", "de": "Erstellt am", "fr": "Créé le"}), + ("_modifiedAt", {"en": "Modified at", "de": "Geändert am", "fr": "Modifié le"}), + ] + for sysName, sysLabels in systemTimestampFields: + attributes.append({ + "name": sysName, + "type": "timestamp", + "required": False, + "description": "", + "label": sysLabels.get(userLanguage, sysLabels["en"]), + "placeholder": "", + "editable": False, + "visible": True, + "order": len(attributes), + "readonly": True, + "options": None, + "default": None, + }) + return {"model": model_label, "attributes": attributes} diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py index bfa3e23f..1325f060 100644 --- a/modules/system/mainSystem.py +++ b/modules/system/mainSystem.py @@ -196,7 +196,7 @@ NAVIGATION_SECTIONS = [ "objectKey": "ui.admin.subscriptions", "label": {"en": "Subscriptions", "de": "Abonnements", "fr": "Abonnements"}, "icon": "FaFileContract", - "path": "/admin/billing/subscriptions", + "path": "/admin/subscriptions", "order": 50, "adminOnly": True, }, @@ -282,6 +282,16 @@ NAVIGATION_SECTIONS = [ "adminOnly": True, "sysAdminOnly": True, }, + { + "id": "admin-automation-logs", + "objectKey": "ui.admin.automationLogs", + "label": {"en": "Execution Logs", "de": "Ausführungsprotokolle", "fr": "Journaux d'exécution"}, + "icon": "FaClipboardList", + "path": "/admin/automation-logs", + "order": 85, + "adminOnly": True, + "sysAdminOnly": True, + }, { "id": "admin-logs", "objectKey": "ui.admin.logs",