diff --git a/backend/src/baserow/contrib/automation/api/history/__init__.py b/backend/src/baserow/contrib/automation/api/history/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/baserow/contrib/automation/api/history/errors.py b/backend/src/baserow/contrib/automation/api/history/errors.py new file mode 100644 index 0000000000..9cc874551c --- /dev/null +++ b/backend/src/baserow/contrib/automation/api/history/errors.py @@ -0,0 +1,19 @@ +from rest_framework.status import HTTP_404_NOT_FOUND + +ERROR_AUTOMATION_WORKFLOW_HISTORY_DOES_NOT_EXIST = ( + "ERROR_AUTOMATION_WORKFLOW_HISTORY_DOES_NOT_EXIST", + HTTP_404_NOT_FOUND, + "The automation workflow history does not exist.", +) + +ERROR_AUTOMATION_NODE_HISTORY_DOES_NOT_EXIST = ( + "ERROR_AUTOMATION_NODE_HISTORY_DOES_NOT_EXIST", + HTTP_404_NOT_FOUND, + "The automation node history does not exist.", +) + +ERROR_AUTOMATION_NODE_RESULT_DOES_NOT_EXIST = ( + "ERROR_AUTOMATION_NODE_RESULT_DOES_NOT_EXIST", + HTTP_404_NOT_FOUND, + "The automation node result does not exist.", +) diff --git a/backend/src/baserow/contrib/automation/api/history/serializers.py b/backend/src/baserow/contrib/automation/api/history/serializers.py new file mode 100644 index 0000000000..d610063492 --- /dev/null +++ b/backend/src/baserow/contrib/automation/api/history/serializers.py @@ -0,0 +1,78 @@ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from baserow.contrib.automation.history.models import ( + AutomationNodeHistory, + AutomationNodeResult, +) + + +class AutomationNodeHistorySerializer(serializers.ModelSerializer): + node_type = serializers.SerializerMethodField() + node_label = serializers.SerializerMethodField() + parent_node_id = serializers.SerializerMethodField() + iteration = serializers.SerializerMethodField() + iteration_path = serializers.SerializerMethodField() + edge_label = serializers.SerializerMethodField() + + class Meta: + model = AutomationNodeHistory + fields = ( + "id", + "started_on", + "completed_on", + "message", + "status", + "workflow_history", + "node", + "node_type", + "node_label", + "parent_node_id", + "iteration", + "iteration_path", + "edge_label", + ) + + @extend_schema_field(OpenApiTypes.STR) + def get_node_type(self, obj): + return obj.node.get_type().type + + @extend_schema_field(OpenApiTypes.STR) + def get_node_label(self, obj): + return obj.node.label + + @extend_schema_field(OpenApiTypes.INT) + def get_parent_node_id(self, obj): + parent_nodes = obj.node.get_parent_nodes() + if not parent_nodes: + return None + return parent_nodes[-1].id + + def _get_first_node_result(self, obj): + results = obj.node_results.all() + return results[0] if results else None + + @extend_schema_field(OpenApiTypes.INT) + def get_iteration(self, obj): + result = self._get_first_node_result(obj) + if result is None: + return None + if result.iteration_path: + return int(result.iteration_path.rsplit(".", 1)[-1]) + return 0 + + @extend_schema_field(OpenApiTypes.STR) + def get_iteration_path(self, obj): + result = self._get_first_node_result(obj) + return result.iteration_path if result else "" + + @extend_schema_field(OpenApiTypes.STR) + def get_edge_label(self, obj): + return self.context.get("edge_labels", {}).get(obj.id, "") + + +class AutomationNodeResultSerializer(serializers.ModelSerializer): + class Meta: + model = AutomationNodeResult + fields = ("result",) diff --git a/backend/src/baserow/contrib/automation/api/history/urls.py b/backend/src/baserow/contrib/automation/api/history/urls.py new file mode 100644 index 0000000000..6feb99f901 --- /dev/null +++ b/backend/src/baserow/contrib/automation/api/history/urls.py @@ -0,0 +1,21 @@ +from django.urls import re_path + +from baserow.contrib.automation.api.history.views import ( + AutomationNodeHistoriesView, + AutomationNodeResultView, +) + +app_name = "baserow.contrib.automation.api.history" + +urlpatterns = [ + re_path( + r"workflow_histories/(?P[0-9]+)/node_histories/$", + AutomationNodeHistoriesView.as_view(), + name="node_histories", + ), + re_path( + r"node_histories/(?P[0-9]+)/result/$", + AutomationNodeResultView.as_view(), + name="node_result", + ), +] diff --git a/backend/src/baserow/contrib/automation/api/history/views.py b/backend/src/baserow/contrib/automation/api/history/views.py new file mode 100644 index 0000000000..9dff0d5726 --- /dev/null +++ b/backend/src/baserow/contrib/automation/api/history/views.py @@ -0,0 +1,109 @@ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from baserow.api.decorators import map_exceptions +from baserow.api.schemas import CLIENT_SESSION_ID_SCHEMA_PARAMETER, get_error_schema +from baserow.contrib.automation.api.history.errors import ( + ERROR_AUTOMATION_NODE_HISTORY_DOES_NOT_EXIST, + ERROR_AUTOMATION_NODE_RESULT_DOES_NOT_EXIST, + ERROR_AUTOMATION_WORKFLOW_HISTORY_DOES_NOT_EXIST, +) +from baserow.contrib.automation.api.history.serializers import ( + AutomationNodeHistorySerializer, + AutomationNodeResultSerializer, +) +from baserow.contrib.automation.history.exceptions import ( + AutomationNodeHistoryDoesNotExist, + AutomationWorkflowHistoryDoesNotExist, + AutomationWorkflowHistoryNodeResultDoesNotExist, +) +from baserow.contrib.automation.history.service import AutomationHistoryService + +AUTOMATION_HISTORY_TAG = "Automation history" + + +class AutomationNodeHistoriesView(APIView): + permission_classes = (IsAuthenticated,) + + @extend_schema( + parameters=[ + OpenApiParameter( + name="workflow_history_id", + location=OpenApiParameter.PATH, + type=OpenApiTypes.INT, + description="The id of the workflow history.", + ), + CLIENT_SESSION_ID_SCHEMA_PARAMETER, + ], + tags=[AUTOMATION_HISTORY_TAG], + operation_id="get_automation_node_histories", + description="Returns all node histories for the given workflow history.", + responses={ + 200: AutomationNodeHistorySerializer(many=True), + 404: get_error_schema(["ERROR_AUTOMATION_WORKFLOW_HISTORY_DOES_NOT_EXIST"]), + }, + ) + @map_exceptions( + { + AutomationWorkflowHistoryDoesNotExist: ( + ERROR_AUTOMATION_WORKFLOW_HISTORY_DOES_NOT_EXIST + ), + } + ) + def get(self, request, workflow_history_id: int): + service = AutomationHistoryService() + node_histories = service.get_node_histories(request.user, workflow_history_id) + edge_labels = service.get_edge_labels(request.user, node_histories) + serializer = AutomationNodeHistorySerializer( + node_histories, + many=True, + context={"edge_labels": edge_labels}, + ) + return Response(serializer.data) + + +class AutomationNodeResultView(APIView): + permission_classes = (IsAuthenticated,) + + @extend_schema( + parameters=[ + OpenApiParameter( + name="node_history_id", + location=OpenApiParameter.PATH, + type=OpenApiTypes.INT, + description="The id of the node history.", + ), + CLIENT_SESSION_ID_SCHEMA_PARAMETER, + ], + tags=[AUTOMATION_HISTORY_TAG], + operation_id="get_automation_node_result", + description="Returns the node history's result JSON.", + responses={ + 200: AutomationNodeResultSerializer, + 404: get_error_schema( + [ + "ERROR_AUTOMATION_NODE_HISTORY_DOES_NOT_EXIST", + "ERROR_AUTOMATION_NODE_RESULT_DOES_NOT_EXIST", + ] + ), + }, + ) + @map_exceptions( + { + AutomationNodeHistoryDoesNotExist: ( + ERROR_AUTOMATION_NODE_HISTORY_DOES_NOT_EXIST + ), + AutomationWorkflowHistoryNodeResultDoesNotExist: ( + ERROR_AUTOMATION_NODE_RESULT_DOES_NOT_EXIST + ), + } + ) + def get(self, request, node_history_id: int): + node_result = AutomationHistoryService().get_node_history_result( + request.user, node_history_id + ) + serializer = AutomationNodeResultSerializer(node_result) + return Response(serializer.data) diff --git a/backend/src/baserow/contrib/automation/api/urls.py b/backend/src/baserow/contrib/automation/api/urls.py index 384ee4396e..a694f45af0 100644 --- a/backend/src/baserow/contrib/automation/api/urls.py +++ b/backend/src/baserow/contrib/automation/api/urls.py @@ -1,5 +1,6 @@ from django.urls import include, path, re_path +from baserow.contrib.automation.api.history import urls as history_urls from baserow.contrib.automation.api.nodes import urls as node_urls from baserow.contrib.automation.api.workflows import urls as workflow_urls @@ -30,6 +31,13 @@ namespace="nodes", ), ), + path( + "", + include( + history_urls, + namespace="history", + ), + ), ] urlpatterns = [ diff --git a/backend/src/baserow/contrib/automation/api/workflows/serializers.py b/backend/src/baserow/contrib/automation/api/workflows/serializers.py index 5f3249a869..864e12d61b 100644 --- a/backend/src/baserow/contrib/automation/api/workflows/serializers.py +++ b/backend/src/baserow/contrib/automation/api/workflows/serializers.py @@ -5,7 +5,6 @@ from baserow.api.pagination import PageNumberPagination from baserow.contrib.automation.models import ( AutomationHistory, - AutomationNodeHistory, AutomationWorkflow, AutomationWorkflowHistory, ) @@ -118,70 +117,12 @@ class Meta: ) -class AutomationNodeHistorySerializer(AutomationHistorySerializer): - parent_node_id = serializers.SerializerMethodField() - iteration = serializers.SerializerMethodField() - result = serializers.SerializerMethodField() - node_type = serializers.SerializerMethodField() - node_label = serializers.SerializerMethodField() - - class Meta: - model = AutomationNodeHistory - fields = AutomationHistorySerializer.Meta.fields + ( - "workflow_history", - "node", - "node_type", - "node_label", - "parent_node_id", - "iteration", - "result", - ) - - def _get_first_node_result(self, obj): - results = obj.node_results.all() - return results[0] if results else None - - @extend_schema_field(OpenApiTypes.STR) - def get_node_type(self, obj): - return obj.node.get_type().type - - @extend_schema_field(OpenApiTypes.STR) - def get_node_label(self, obj): - return obj.node.label - - @extend_schema_field(OpenApiTypes.INT) - def get_parent_node_id(self, obj): - parent_nodes = obj.node.get_parent_nodes() - if not parent_nodes: - return None - return parent_nodes[-1].id - - @extend_schema_field(OpenApiTypes.INT) - def get_iteration(self, obj): - result = self._get_first_node_result(obj) - if result is None: - return None - - if result.iteration_path: - return int(result.iteration_path.rsplit(".", 1)[-1]) - - return 0 - - def get_result(self, obj): - result = self._get_first_node_result(obj) - return result.result if result else {} - - class AutomationWorkflowHistorySerializer(AutomationHistorySerializer): - node_histories = AutomationNodeHistorySerializer(read_only=True, many=True) - class Meta: model = AutomationWorkflowHistory fields = AutomationHistorySerializer.Meta.fields + ( "is_test_run", - "event_payload", "simulate_until_node", - "node_histories", ) diff --git a/backend/src/baserow/contrib/automation/history/exceptions.py b/backend/src/baserow/contrib/automation/history/exceptions.py index 9586b46ef0..396f65e7cb 100644 --- a/backend/src/baserow/contrib/automation/history/exceptions.py +++ b/backend/src/baserow/contrib/automation/history/exceptions.py @@ -19,3 +19,15 @@ def __init__(self, history_id=None, *args, **kwargs): class AutomationWorkflowHistoryNodeResultDoesNotExist(AutomationWorkflowHistoryError): """When the result entry doesn't exist for the given node/history.""" + + +class AutomationNodeHistoryDoesNotExist(AutomationWorkflowHistoryError): + """When the node history entry doesn't exist.""" + + def __init__(self, node_history_id=None, *args, **kwargs): + self.node_history_id = node_history_id + super().__init__( + f"The automation node history {node_history_id} does not exist.", + *args, + **kwargs, + ) diff --git a/backend/src/baserow/contrib/automation/history/handler.py b/backend/src/baserow/contrib/automation/history/handler.py index ad59be4cf7..36239219ef 100644 --- a/backend/src/baserow/contrib/automation/history/handler.py +++ b/backend/src/baserow/contrib/automation/history/handler.py @@ -5,6 +5,7 @@ from baserow.contrib.automation.history.constants import HistoryStatusChoices from baserow.contrib.automation.history.exceptions import ( + AutomationNodeHistoryDoesNotExist, AutomationWorkflowHistoryDoesNotExist, AutomationWorkflowHistoryNodeResultDoesNotExist, ) @@ -33,15 +34,6 @@ def get_workflow_histories( return base_queryset.filter( original_workflow=workflow, simulate_until_node__isnull=True, - ).prefetch_related( - Prefetch( - "node_histories", - queryset=AutomationNodeHistory.objects.select_related( - "node", "node__workflow" - ) - .prefetch_related("node_results") - .order_by("started_on"), - ), ) def get_workflow_history( @@ -143,3 +135,72 @@ def get_node_result(self, history, node, iteration_path): raise AutomationWorkflowHistoryNodeResultDoesNotExist() return node_result.result + + def get_node_history( + self, + node_history_id: int, + base_queryset: Optional[QuerySet] = None, + ) -> AutomationNodeHistory: + """Returns an AutomationNodeHistory by its ID.""" + + if base_queryset is None: + base_queryset = AutomationNodeHistory.objects.all() + + try: + return base_queryset.select_related( + "workflow_history__original_workflow__automation__workspace", + ).get(id=node_history_id) + except AutomationNodeHistory.DoesNotExist: + raise AutomationNodeHistoryDoesNotExist(node_history_id) + + def get_node_histories( + self, workflow_history: AutomationWorkflowHistory + ) -> QuerySet[AutomationNodeHistory]: + """Returns a queryset of AutomationNodeHistory by the workflow history.""" + + return ( + AutomationNodeHistory.objects.filter(workflow_history=workflow_history) + .select_related("node", "node__workflow") + .prefetch_related( + Prefetch( + "node_results", + queryset=AutomationNodeResult.objects.only( + "id", "node_history_id", "iteration_path" + ), + ) + ) + .order_by("started_on", "id") + ) + + def get_node_history_result( + self, node_history: AutomationNodeHistory + ) -> AutomationNodeResult: + """Returns the AutomationNodeResult for the given node history.""" + + try: + return AutomationNodeResult.objects.only("result").get( + node_history=node_history + ) + except AutomationNodeResult.DoesNotExist: + raise AutomationWorkflowHistoryNodeResultDoesNotExist() + + def get_edge_labels( + self, node_histories: List[AutomationNodeHistory] + ) -> Dict[int, str]: + """ + For each node history whose result has an edge label, return a + mapping of `node_history_id -> label` of the edge taken. + """ + + if not node_histories: + return {} + + results = AutomationNodeResult.objects.filter( + node_history_id__in=[nh.id for nh in node_histories], + ).only("node_history_id", "result") + + return { + nr.node_history_id: label + for nr in results + if (label := nr.result.get("edge", {}).get("label")) + } diff --git a/backend/src/baserow/contrib/automation/history/service.py b/backend/src/baserow/contrib/automation/history/service.py index 772ebe3d7f..5f8b754efd 100644 --- a/backend/src/baserow/contrib/automation/history/service.py +++ b/backend/src/baserow/contrib/automation/history/service.py @@ -1,9 +1,16 @@ +from typing import Dict, List + from django.contrib.auth.models import AbstractUser from django.db.models import QuerySet from baserow.contrib.automation.history.handler import AutomationHistoryHandler -from baserow.contrib.automation.history.models import AutomationWorkflowHistory +from baserow.contrib.automation.history.models import ( + AutomationNodeHistory, + AutomationNodeResult, + AutomationWorkflowHistory, +) from baserow.contrib.automation.workflows.handler import AutomationWorkflowHandler +from baserow.contrib.automation.workflows.models import AutomationWorkflow from baserow.contrib.automation.workflows.operations import ( ReadAutomationWorkflowOperationType, ) @@ -15,19 +22,9 @@ def __init__(self): self.handler = AutomationHistoryHandler() self.workflow_handler = AutomationWorkflowHandler() - def get_workflow_histories( - self, user: AbstractUser, workflow_id: int - ) -> QuerySet[AutomationWorkflowHistory]: - """ - Returns an AutomationWorkflowHistory queryset related to a workflow. - - :param user: The user requesting the workflow history. - :param workflow_id: The ID of the workflow. - :return: A queryset of workflow histories. - """ - - workflow = self.workflow_handler.get_workflow(workflow_id) - + def _check_workflow_permissions( + self, user: AbstractUser, workflow: AutomationWorkflow + ) -> None: CoreHandler().check_permissions( user, ReadAutomationWorkflowOperationType.type, @@ -35,4 +32,37 @@ def get_workflow_histories( context=workflow, ) + def get_workflow_histories( + self, user: AbstractUser, workflow_id: int + ) -> QuerySet[AutomationWorkflowHistory]: + workflow = self.workflow_handler.get_workflow(workflow_id) + self._check_workflow_permissions(user, workflow) return self.handler.get_workflow_histories(workflow) + + def get_node_histories( + self, user: AbstractUser, workflow_history_id: int + ) -> List[AutomationNodeHistory]: + workflow_history = self.handler.get_workflow_history(workflow_history_id) + workflow = workflow_history.original_workflow + self._check_workflow_permissions(user, workflow) + return list(self.handler.get_node_histories(workflow_history)) + + def get_node_history_result( + self, user: AbstractUser, node_history_id: int + ) -> AutomationNodeResult: + node_history = self.handler.get_node_history(node_history_id) + workflow = node_history.workflow_history.original_workflow + self._check_workflow_permissions(user, workflow) + return self.handler.get_node_history_result(node_history) + + def get_edge_labels( + self, + user: AbstractUser, + node_histories: List[AutomationNodeHistory], + ) -> Dict[int, str]: + if not node_histories: + return {} + + workflow = node_histories[0].workflow_history.original_workflow + self._check_workflow_permissions(user, workflow) + return self.handler.get_edge_labels(node_histories) diff --git a/backend/src/baserow/contrib/integrations/core/service_types.py b/backend/src/baserow/contrib/integrations/core/service_types.py index 0c43c8ce65..d9a1f1530b 100644 --- a/backend/src/baserow/contrib/integrations/core/service_types.py +++ b/backend/src/baserow/contrib/integrations/core/service_types.py @@ -1216,7 +1216,7 @@ def get_sample_data(self, service, dispatch_context): return super().get_sample_data(service, dispatch_context) - def get_edges(self, service): + def get_edges(self, service: Service) -> Dict[str, Dict[str, str]]: return {str(e.uid): {"label": e.label} for e in service.edges.all()} | { "": {"label": service.default_edge_label} } diff --git a/backend/src/baserow/core/services/registries.py b/backend/src/baserow/core/services/registries.py index 765de4c367..5c1de19ca9 100644 --- a/backend/src/baserow/core/services/registries.py +++ b/backend/src/baserow/core/services/registries.py @@ -524,7 +524,7 @@ def import_property_name( return property_name - def get_edges(self, service): + def get_edges(self, service: Service) -> Dict[str, Dict[str, str]]: return {"": {"label": ""}} diff --git a/backend/tests/baserow/contrib/automation/api/history/__init__.py b/backend/tests/baserow/contrib/automation/api/history/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/baserow/contrib/automation/api/history/test_history_views.py b/backend/tests/baserow/contrib/automation/api/history/test_history_views.py new file mode 100644 index 0000000000..cac6ab3e52 --- /dev/null +++ b/backend/tests/baserow/contrib/automation/api/history/test_history_views.py @@ -0,0 +1,174 @@ +from django.urls import reverse + +import pytest +from rest_framework.status import ( + HTTP_200_OK, + HTTP_401_UNAUTHORIZED, + HTTP_404_NOT_FOUND, +) + +from baserow.contrib.automation.history.constants import HistoryStatusChoices +from tests.baserow.contrib.automation.api.utils import get_api_kwargs + +API_URL_NODE_HISTORIES = "api:automation:history:node_histories" +API_URL_NODE_RESULT = "api:automation:history:node_result" + + +@pytest.mark.django_db +def test_get_node_histories(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + workflow = data_fixture.create_automation_workflow(user=user) + trigger = workflow.get_trigger() + workflow_history = data_fixture.create_automation_workflow_history( + workflow=workflow, + ) + trigger_history = data_fixture.create_automation_node_history( + workflow_history=workflow_history, + node=trigger, + status=HistoryStatusChoices.SUCCESS, + ) + + url = reverse( + API_URL_NODE_HISTORIES, kwargs={"workflow_history_id": workflow_history.id} + ) + response = api_client.get(url, **get_api_kwargs(token)) + + assert response.status_code == HTTP_200_OK + rows = response.json() + assert len(rows) == 1 + row = rows[0] + assert row["id"] == trigger_history.id + assert row["node"] == trigger.id + assert row["edge_label"] == "" + assert row["status"] == "success" + + +@pytest.mark.django_db +def test_get_node_histories_surfaces_router_edge_label(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + workflow = data_fixture.create_automation_workflow(user=user) + core_router = data_fixture.create_core_router_action_node_with_edges( + workflow=workflow, + ) + workflow_history = data_fixture.create_automation_workflow_history( + workflow=workflow, + ) + router_history = data_fixture.create_automation_node_history( + workflow_history=workflow_history, node=core_router.router + ) + data_fixture.create_automation_node_result( + node_history=router_history, + result={"edge": {"label": "Foo label"}}, + ) + + url = reverse( + API_URL_NODE_HISTORIES, kwargs={"workflow_history_id": workflow_history.id} + ) + response = api_client.get(url, **get_api_kwargs(token)) + + assert response.status_code == HTTP_200_OK + rows = {row["id"]: row for row in response.json()} + assert rows[router_history.id]["edge_label"] == "Foo label" + + +@pytest.mark.django_db +def test_get_node_histories_permission_error(api_client, data_fixture): + user = data_fixture.create_user() + workflow = data_fixture.create_automation_workflow(user=user) + workflow_history = data_fixture.create_automation_workflow_history( + workflow=workflow, + ) + + _, token = data_fixture.create_user_and_token() + + url = reverse( + API_URL_NODE_HISTORIES, kwargs={"workflow_history_id": workflow_history.id} + ) + response = api_client.get(url, **get_api_kwargs(token)) + + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json()["error"] == "PERMISSION_DENIED" + + +@pytest.mark.django_db +def test_get_node_histories_workflow_history_does_not_exist(api_client, data_fixture): + _, token = data_fixture.create_user_and_token() + + url = reverse(API_URL_NODE_HISTORIES, kwargs={"workflow_history_id": 999999}) + response = api_client.get(url, **get_api_kwargs(token)) + + assert response.status_code == HTTP_404_NOT_FOUND + assert ( + response.json()["error"] == "ERROR_AUTOMATION_WORKFLOW_HISTORY_DOES_NOT_EXIST" + ) + + +@pytest.mark.django_db +def test_get_node_result(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + workflow = data_fixture.create_automation_workflow(user=user) + workflow_history = data_fixture.create_automation_workflow_history( + workflow=workflow, + ) + node_history = data_fixture.create_automation_node_history( + workflow_history=workflow_history, node=workflow.get_trigger() + ) + data_fixture.create_automation_node_result( + node_history=node_history, result={"rows": [1, 2]} + ) + + url = reverse(API_URL_NODE_RESULT, kwargs={"node_history_id": node_history.id}) + response = api_client.get(url, **get_api_kwargs(token)) + + assert response.status_code == HTTP_200_OK + assert response.json() == {"result": {"rows": [1, 2]}} + + +@pytest.mark.django_db +def test_get_node_result_permission_error(api_client, data_fixture): + user = data_fixture.create_user() + workflow = data_fixture.create_automation_workflow(user=user) + workflow_history = data_fixture.create_automation_workflow_history( + workflow=workflow, + ) + node_history = data_fixture.create_automation_node_history( + workflow_history=workflow_history, node=workflow.get_trigger() + ) + data_fixture.create_automation_node_result(node_history=node_history) + + _, token = data_fixture.create_user_and_token() + + url = reverse(API_URL_NODE_RESULT, kwargs={"node_history_id": node_history.id}) + response = api_client.get(url, **get_api_kwargs(token)) + + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json()["error"] == "PERMISSION_DENIED" + + +@pytest.mark.django_db +def test_get_node_result_node_history_does_not_exist(api_client, data_fixture): + _, token = data_fixture.create_user_and_token() + + url = reverse(API_URL_NODE_RESULT, kwargs={"node_history_id": 999999}) + response = api_client.get(url, **get_api_kwargs(token)) + + assert response.status_code == HTTP_404_NOT_FOUND + assert response.json()["error"] == "ERROR_AUTOMATION_NODE_HISTORY_DOES_NOT_EXIST" + + +@pytest.mark.django_db +def test_get_node_result_does_not_exist(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + workflow = data_fixture.create_automation_workflow(user=user) + workflow_history = data_fixture.create_automation_workflow_history( + workflow=workflow, + ) + node_history = data_fixture.create_automation_node_history( + workflow_history=workflow_history, node=workflow.get_trigger() + ) + + url = reverse(API_URL_NODE_RESULT, kwargs={"node_history_id": node_history.id}) + response = api_client.get(url, **get_api_kwargs(token)) + + assert response.status_code == HTTP_404_NOT_FOUND + assert response.json()["error"] == "ERROR_AUTOMATION_NODE_RESULT_DOES_NOT_EXIST" diff --git a/backend/tests/baserow/contrib/automation/api/workflows/test_workflow_views.py b/backend/tests/baserow/contrib/automation/api/workflows/test_workflow_views.py index e71cbac17d..f4f8e03d54 100644 --- a/backend/tests/baserow/contrib/automation/api/workflows/test_workflow_views.py +++ b/backend/tests/baserow/contrib/automation/api/workflows/test_workflow_views.py @@ -654,8 +654,6 @@ def test_get_workflow_histories(api_client, data_fixture): "is_test_run": False, "message": "", "status": "success", - "event_payload": None, - "node_histories": [], "simulate_until_node": None, }, ], @@ -749,7 +747,7 @@ def _create_histories(count): _create_histories(3) local_cache.clear() - expected_queries = 10 + expected_queries = 2 with django_assert_num_queries(expected_queries): queryset = handler.get_workflow_histories(workflow) queryset.aggregate( @@ -770,79 +768,6 @@ def _create_histories(count): AutomationWorkflowHistorySerializer(list(queryset), many=True).data -@pytest.mark.django_db -def test_get_workflow_histories_with_node_histories(api_client, data_fixture): - user, token = data_fixture.create_user_and_token() - workflow = data_fixture.create_automation_workflow(user=user) - trigger = workflow.get_trigger() - action_node = data_fixture.create_local_baserow_create_row_action_node( - user=user, workflow=workflow, label="My Action" - ) - - now = timezone.now() - workflow_history = data_fixture.create_automation_workflow_history( - user=user, - workflow=workflow, - status=HistoryStatusChoices.SUCCESS, - completed_on=now, - ) - node_history_1 = data_fixture.create_automation_node_history( - user=user, - workflow_history=workflow_history, - node=trigger, - completed_on=now, - ) - node_result_1 = data_fixture.create_automation_node_result( - user=user, - node_history=node_history_1, - result={"rows": [1, 2]}, - ) - node_history_2 = data_fixture.create_automation_node_history( - user=user, - workflow_history=workflow_history, - node=action_node, - completed_on=now, - ) - node_result_2 = data_fixture.create_automation_node_result( - user=user, - node_history=node_history_2, - result={"created_row_id": 99}, - iteration_path="0.2.1", - ) - - url = reverse(API_URL_WORKFLOW_HISTORY, kwargs={"workflow_id": workflow.id}) - response = api_client.get(url, **get_api_kwargs(token)) - - assert response.status_code == HTTP_200_OK - data = response.json() - - assert data["count"] == 1 - assert data["success_count"] == 1 - assert data["fail_count"] == 0 - - w_history = data["results"][0] - assert w_history["id"] == workflow_history.id - assert w_history["status"] == "success" - assert len(w_history["node_histories"]) == 2 - - n_history_1 = w_history["node_histories"][0] - assert n_history_1["node"] == trigger.id - assert n_history_1["workflow_history"] == workflow_history.id - assert n_history_1["node_type"] == trigger.get_type().type - assert n_history_1["node_label"] == trigger.label - assert n_history_1["parent_node_id"] is None - assert n_history_1["iteration"] == 0 - assert n_history_1["result"] == {"rows": [1, 2]} - - n_history_2 = w_history["node_histories"][1] - assert n_history_2["node"] == action_node.id - assert n_history_2["workflow_history"] == workflow_history.id - assert n_history_2["node_type"] == action_node.get_type().type - assert n_history_2["node_label"] == "My Action" - assert n_history_2["iteration"] == 1 - assert n_history_2["result"] == {"created_row_id": 99} - - @pytest.mark.django_db def test_get_workflow_histories_has_success_and_fail_counts(api_client, data_fixture): user, token = data_fixture.create_user_and_token() diff --git a/backend/tests/baserow/contrib/automation/history/test_history_handler.py b/backend/tests/baserow/contrib/automation/history/test_history_handler.py index 20bff66ca7..56d37f2a7e 100644 --- a/backend/tests/baserow/contrib/automation/history/test_history_handler.py +++ b/backend/tests/baserow/contrib/automation/history/test_history_handler.py @@ -3,10 +3,15 @@ import pytest from baserow.contrib.automation.history.exceptions import ( + AutomationNodeHistoryDoesNotExist, AutomationWorkflowHistoryDoesNotExist, + AutomationWorkflowHistoryNodeResultDoesNotExist, ) from baserow.contrib.automation.history.handler import AutomationHistoryHandler -from baserow.contrib.automation.history.models import AutomationWorkflowHistory +from baserow.contrib.automation.history.models import ( + AutomationNodeHistory, + AutomationWorkflowHistory, +) from baserow.contrib.automation.workflows.constants import WorkflowState @@ -128,3 +133,254 @@ def test_get_workflow_history_respects_base_queryset(data_fixture): history_id=history.id, base_queryset=AutomationWorkflowHistory.objects.exclude(id=history.id), ) + + +@pytest.mark.django_db +def test_get_node_history(data_fixture): + user, _ = data_fixture.create_user_and_token() + workflow = data_fixture.create_automation_workflow(user=user) + trigger = workflow.get_trigger() + workflow_history = data_fixture.create_automation_workflow_history( + user=user, + workflow=workflow, + ) + node_history = data_fixture.create_automation_node_history( + user=user, + workflow_history=workflow_history, + node=trigger, + ) + + result = AutomationHistoryHandler().get_node_history( + node_history_id=node_history.id + ) + + assert result == node_history + + +@pytest.mark.django_db +def test_get_node_history_does_not_exist(): + with pytest.raises(AutomationNodeHistoryDoesNotExist) as e: + AutomationHistoryHandler().get_node_history(node_history_id=100) + + assert str(e.value) == "The automation node history 100 does not exist." + + +@pytest.mark.django_db +def test_get_node_history_respects_base_queryset(data_fixture): + user, _ = data_fixture.create_user_and_token() + workflow = data_fixture.create_automation_workflow(user=user) + trigger = workflow.get_trigger() + workflow_history = data_fixture.create_automation_workflow_history( + user=user, + workflow=workflow, + ) + + node_history = data_fixture.create_automation_node_history( + user=user, + workflow_history=workflow_history, + node=trigger, + ) + + with pytest.raises(AutomationNodeHistoryDoesNotExist): + AutomationHistoryHandler().get_node_history( + node_history_id=node_history.id, + base_queryset=AutomationNodeHistory.objects.exclude(id=node_history.id), + ) + + +@pytest.mark.django_db +def test_get_node_histories_returns_empty_when_no_histories(data_fixture): + user, _ = data_fixture.create_user_and_token() + workflow = data_fixture.create_automation_workflow(user=user) + workflow_history = data_fixture.create_automation_workflow_history( + user=user, + workflow=workflow, + ) + + result = AutomationHistoryHandler().get_node_histories(workflow_history) + + assert list(result) == [] + + +@pytest.mark.django_db +def test_get_node_histories_filters_by_workflow_history(data_fixture): + user, _ = data_fixture.create_user_and_token() + workflow = data_fixture.create_automation_workflow(user=user) + trigger = workflow.get_trigger() + workflow_history = data_fixture.create_automation_workflow_history( + user=user, + workflow=workflow, + ) + other_workflow_history = data_fixture.create_automation_workflow_history( + user=user, + workflow=workflow, + ) + + node_history = data_fixture.create_automation_node_history( + user=user, + workflow_history=workflow_history, + node=trigger, + ) + data_fixture.create_automation_node_history( + user=user, + workflow_history=other_workflow_history, + node=trigger, + ) + + result = AutomationHistoryHandler().get_node_histories(workflow_history) + + assert list(result) == [node_history] + + +@pytest.mark.django_db +def test_get_node_histories_ordering(data_fixture): + user, _ = data_fixture.create_user_and_token() + workflow = data_fixture.create_automation_workflow(user=user) + trigger = workflow.get_trigger() + workflow_history = data_fixture.create_automation_workflow_history( + user=user, + workflow=workflow, + ) + + earlier = timezone.now() + later = earlier + timezone.timedelta(seconds=10) + + node_history_2 = data_fixture.create_automation_node_history( + user=user, + workflow_history=workflow_history, + node=trigger, + started_on=later, + ) + node_history_1 = data_fixture.create_automation_node_history( + user=user, + workflow_history=workflow_history, + node=trigger, + started_on=earlier, + ) + node_history_3 = data_fixture.create_automation_node_history( + user=user, + workflow_history=workflow_history, + node=trigger, + started_on=later, + ) + + result = AutomationHistoryHandler().get_node_histories(workflow_history) + + assert list(result) == [node_history_1, node_history_2, node_history_3] + + +@pytest.mark.django_db +def test_get_node_histories_prefetches_node_results( + data_fixture, django_assert_num_queries +): + user, _ = data_fixture.create_user_and_token() + workflow = data_fixture.create_automation_workflow(user=user) + trigger = workflow.get_trigger() + workflow_history = data_fixture.create_automation_workflow_history( + user=user, + workflow=workflow, + ) + node_history = data_fixture.create_automation_node_history( + user=user, + workflow_history=workflow_history, + node=trigger, + ) + node_result = data_fixture.create_automation_node_result( + node_history=node_history, + ) + + result = list(AutomationHistoryHandler().get_node_histories(workflow_history)) + + with django_assert_num_queries(0): + result = list(result[0].node_results.all()) + + assert result == [node_result] + + +@pytest.mark.django_db +def test_get_node_history_result(data_fixture): + user, _ = data_fixture.create_user_and_token() + workflow = data_fixture.create_automation_workflow(user=user) + trigger = workflow.get_trigger() + workflow_history = data_fixture.create_automation_workflow_history( + user=user, + workflow=workflow, + ) + node_history = data_fixture.create_automation_node_history( + user=user, + workflow_history=workflow_history, + node=trigger, + ) + node_result = data_fixture.create_automation_node_result( + node_history=node_history, + result={"foo": "bar"}, + ) + + result = AutomationHistoryHandler().get_node_history_result(node_history) + + assert result == node_result + assert result.result == {"foo": "bar"} + + +@pytest.mark.django_db +def test_get_node_history_result_does_not_exist(data_fixture): + user, _ = data_fixture.create_user_and_token() + workflow = data_fixture.create_automation_workflow(user=user) + trigger = workflow.get_trigger() + workflow_history = data_fixture.create_automation_workflow_history( + user=user, + workflow=workflow, + ) + node_history = data_fixture.create_automation_node_history( + user=user, + workflow_history=workflow_history, + node=trigger, + ) + + with pytest.raises(AutomationWorkflowHistoryNodeResultDoesNotExist): + AutomationHistoryHandler().get_node_history_result(node_history) + + +@pytest.mark.django_db +def test_get_edge_labels_returns_empty_dict(): + assert AutomationHistoryHandler().get_edge_labels([]) == {} + + +@pytest.mark.django_db +def test_get_edge_labels_returns_expected_data(data_fixture): + user, _ = data_fixture.create_user_and_token() + workflow = data_fixture.create_automation_workflow(user=user) + trigger = workflow.get_trigger() + workflow_history = data_fixture.create_automation_workflow_history( + user=user, + workflow=workflow, + ) + + node_history_1 = data_fixture.create_automation_node_history( + user=user, + workflow_history=workflow_history, + node=trigger, + ) + data_fixture.create_automation_node_result( + node_history=node_history_1, + result={"edge": {"label": "foo label"}}, + ) + + node_history_2 = data_fixture.create_automation_node_history( + user=user, + workflow_history=workflow_history, + node=trigger, + ) + data_fixture.create_automation_node_result( + node_history=node_history_2, + result={"edge": {"label": "bar label"}}, + ) + + result = AutomationHistoryHandler().get_edge_labels( + [node_history_1, node_history_2] + ) + + assert result == { + node_history_1.id: "foo label", + node_history_2.id: "bar label", + } diff --git a/backend/tests/baserow/contrib/automation/history/test_history_service.py b/backend/tests/baserow/contrib/automation/history/test_history_service.py index cc6276b921..b29366a14c 100644 --- a/backend/tests/baserow/contrib/automation/history/test_history_service.py +++ b/backend/tests/baserow/contrib/automation/history/test_history_service.py @@ -38,3 +38,171 @@ def test_get_workflow_histories_returns_ordered_histories(data_fixture): ) assert list(result) == [history_2, history_1] + + +@pytest.mark.django_db +def test_get_node_histories_permission_error(data_fixture): + user = data_fixture.create_user() + history = data_fixture.create_workflow_history(user=user) + + user_2 = data_fixture.create_user() + + with pytest.raises(UserNotInWorkspace) as e: + AutomationHistoryService().get_node_histories(user_2, history.id) + + assert str(e.value) == ( + f"User {user_2.email} doesn't belong to " + f"workspace {history.workflow.automation.workspace}." + ) + + +@pytest.mark.django_db +def test_get_node_histories_returns_node_histories(data_fixture): + user = data_fixture.create_user() + workflow = data_fixture.create_automation_workflow(user=user) + trigger = workflow.get_trigger() + workflow_history = data_fixture.create_automation_workflow_history( + user=user, + workflow=workflow, + ) + + node_history_1 = data_fixture.create_automation_node_history( + user=user, + workflow_history=workflow_history, + node=trigger, + ) + node_history_2 = data_fixture.create_automation_node_history( + user=user, + workflow_history=workflow_history, + node=trigger, + ) + + result = AutomationHistoryService().get_node_histories(user, workflow_history.id) + + assert result == [node_history_1, node_history_2] + + +@pytest.mark.django_db +def test_get_node_history_result_permission_error(data_fixture): + user = data_fixture.create_user() + workflow = data_fixture.create_automation_workflow(user=user) + trigger = workflow.get_trigger() + workflow_history = data_fixture.create_automation_workflow_history( + user=user, + workflow=workflow, + ) + node_history = data_fixture.create_automation_node_history( + user=user, + workflow_history=workflow_history, + node=trigger, + ) + + user_2 = data_fixture.create_user() + + with pytest.raises(UserNotInWorkspace) as e: + AutomationHistoryService().get_node_history_result(user_2, node_history.id) + + assert str(e.value) == ( + f"User {user_2.email} doesn't belong to " + f"workspace {workflow.automation.workspace}." + ) + + +@pytest.mark.django_db +def test_get_node_history_result_returns_result(data_fixture): + user = data_fixture.create_user() + workflow = data_fixture.create_automation_workflow(user=user) + trigger = workflow.get_trigger() + workflow_history = data_fixture.create_automation_workflow_history( + user=user, + workflow=workflow, + ) + node_history = data_fixture.create_automation_node_history( + user=user, + workflow_history=workflow_history, + node=trigger, + ) + node_result = data_fixture.create_automation_node_result( + node_history=node_history, + result={"foo": "bar"}, + ) + + result = AutomationHistoryService().get_node_history_result(user, node_history.id) + + assert result == node_result + assert result.result == {"foo": "bar"} + + +@pytest.mark.django_db +def test_get_edge_labels_returns_empty_dict(data_fixture): + user = data_fixture.create_user() + + result = AutomationHistoryService().get_edge_labels(user, []) + + assert result == {} + + +@pytest.mark.django_db +def test_get_edge_labels_permission_error(data_fixture): + user = data_fixture.create_user() + workflow = data_fixture.create_automation_workflow(user=user) + trigger = workflow.get_trigger() + workflow_history = data_fixture.create_automation_workflow_history( + user=user, + workflow=workflow, + ) + node_history = data_fixture.create_automation_node_history( + user=user, + workflow_history=workflow_history, + node=trigger, + ) + + user_2 = data_fixture.create_user() + + with pytest.raises(UserNotInWorkspace) as e: + AutomationHistoryService().get_edge_labels(user_2, [node_history]) + + assert str(e.value) == ( + f"User {user_2.email} doesn't belong to " + f"workspace {workflow.automation.workspace}." + ) + + +@pytest.mark.django_db +def test_get_edge_labels_returns_mapping(data_fixture): + user = data_fixture.create_user() + workflow = data_fixture.create_automation_workflow(user=user) + trigger = workflow.get_trigger() + workflow_history = data_fixture.create_automation_workflow_history( + user=user, + workflow=workflow, + ) + + node_history_1 = data_fixture.create_automation_node_history( + user=user, + workflow_history=workflow_history, + node=trigger, + ) + data_fixture.create_automation_node_result( + node_history=node_history_1, + result={"edge": {"label": "foo label"}}, + ) + + node_history_2 = data_fixture.create_automation_node_history( + user=user, + workflow_history=workflow_history, + node=trigger, + ) + data_fixture.create_automation_node_result( + node_history=node_history_2, + result={"edge": {"label": "bar label"}}, + ) + + result = AutomationHistoryService().get_edge_labels( + user, [node_history_1, node_history_2] + ) + + assert result == { + node_history_1.id: "foo label", + node_history_2.id: "bar label", + } diff --git a/changelog/entries/unreleased/refactor/5239_improved_the_performance_of_the_automation_workflow_and_node.json b/changelog/entries/unreleased/refactor/5239_improved_the_performance_of_the_automation_workflow_and_node.json new file mode 100644 index 0000000000..a69feca716 --- /dev/null +++ b/changelog/entries/unreleased/refactor/5239_improved_the_performance_of_the_automation_workflow_and_node.json @@ -0,0 +1,9 @@ +{ + "type": "refactor", + "message": "Improved the performance of the Automation Workflow and Node history.", + "issue_origin": "github", + "issue_number": 5239, + "domain": "automation", + "bullet_points": [], + "created_at": "2026-05-21" +} \ No newline at end of file diff --git a/web-frontend/modules/automation/components/workflow/sidePanels/HistorySidePanel.vue b/web-frontend/modules/automation/components/workflow/sidePanels/HistorySidePanel.vue index e369a782b5..9a035fb46f 100644 --- a/web-frontend/modules/automation/components/workflow/sidePanels/HistorySidePanel.vue +++ b/web-frontend/modules/automation/components/workflow/sidePanels/HistorySidePanel.vue @@ -77,6 +77,7 @@ const workflowHistoryItems = computed(() => { const refreshData = async () => { loading.value = true try { + store.dispatch('automationHistory/invalidate') await store.dispatch('automationHistory/fetchWorkflowHistory', { workflowId: workflow.value.id, }) diff --git a/web-frontend/modules/automation/components/workflow/sidePanels/NodeHistory.vue b/web-frontend/modules/automation/components/workflow/sidePanels/NodeHistory.vue index 82c0b65cc9..db9db954fa 100644 --- a/web-frontend/modules/automation/components/workflow/sidePanels/NodeHistory.vue +++ b/web-frontend/modules/automation/components/workflow/sidePanels/NodeHistory.vue @@ -85,11 +85,12 @@
@@ -174,6 +175,7 @@ ref="nodeResultContextToggle" type="secondary" full-width + :loading="fetchingResult" icon="iconoir-code-brackets node-history__show-result-button-icon" @click="showNodeResultModal" > @@ -184,32 +186,39 @@
diff --git a/web-frontend/modules/automation/components/workflow/sidePanels/WorkflowHistory.vue b/web-frontend/modules/automation/components/workflow/sidePanels/WorkflowHistory.vue index bc064d7ed8..249dcd800c 100644 --- a/web-frontend/modules/automation/components/workflow/sidePanels/WorkflowHistory.vue +++ b/web-frontend/modules/automation/components/workflow/sidePanels/WorkflowHistory.vue @@ -1,5 +1,5 @@