Skip to content

Commit a52550d

Browse files
paljortjrmi
andauthored
feat: Automation Node history (baserow#4787)
* WIP - node history * Add migrations * Fix test * Re-generate migrations * Node history should show result, not payload. * Ensure nested node history scrolls * get_previous_positions() may return None * Fix get_iteration() to use the new iteration_path * The scrollbar should be lighter, hide the background container. * Only show expandable if the node history is completed * Optimize node history endpoint * Return the node type and label from the backend, so that we don't have to lookup published node which the frontend doesn't know about * Use history status icons from design spec * Remove debugging code * Display workflow run error * Don't expand history if workflow is in progress * Find the most specific error * Remove old HistorySection and unused translations * Remove old _NodeHistory.vue, replaced by NodeHistory.vue * Ensure node type label is truncated * Align node type header icon * Ensure Node Explorer's content name is truncated * Add router label to node type, ensure max-width for label * Make sure header expand icon is centered * Truncate error preview * Ensure node result is created when there is an error. This is needed to show the error in the node history. * Lint fix * Ensure parent nodes show error indicator (red text) if child has error. * Clean up styles * Clean up * Handle case when router node has error * Don't render Context or Modal if not final node * Document success_count and fail_count in the API response * When workflow times out, ensure child node histories are also updated. * Remove unused css definitions * Use computed props * Add test to check query count for workflow history * Update test to ensure node history is updated when workflow times out * use a single timezone.now() instance * getNodeType should be a computed prop * getHistoryIconPath should be a computed prop * Add test for node history in api response * Add test for success/fail counts * Add assertion to existing tests to ensure node result is created on workflow error * Update the workflow when a node is modified * Add original_workflow to AutomationWorkflowHistory model, migration, and backfill original_workflow * Delete any published automations that don't have history. * Create a snapshot of the history via publish flow. * Add id to automationnodehistory ordering. Update migrations. * Fix tests * Ensure sample data is saved from snapshot to the draft workflow. * Fix test * Add tests for snapshots * Remove unnecessary guard. * Handle list vs flat result data. * Ensure workflow is updated for snapshot when we CRUD a node. * Make the edge detection more generic. * Add new history_clone workflow state choice * Refactor common logic to new _clone_workflow(). * Simplify async_start_workflow() * Exclude history clones from being deleted during publishing. * Ensure active_published only filters for live workflows * Refactor existing tests + write new ones * Fix test * Add real-time for dispatch start/end, and update history in frontend. * Increase size of the triple-dot link that reveals the Show result button. * Ensure original_workflow is ZDM compatible * Squash local migrations * Simplify * Add spinner when history is loading. * Improve clean_up_previously_published_automations() logic * Use global cache instead of relying on updated_on for cloned workflow. * Rename history clone to test clone. * Create clone only for test, not for simulation. * Update docstring/comments. * Move clone creation to toggle_test_run() * Post-migration fixes * Ensure async_start_workflow() always receives a draf or live workflow. For tests, it should get/create a clone. * Fix state confusion * Add filter to prevent deleting newly created test clone. * Fix test --------- Co-authored-by: Jeremie Pardou <571533+jrmi@users.noreply.github.com>
1 parent 15fb2f8 commit a52550d

40 files changed

Lines changed: 2015 additions & 255 deletions

backend/src/baserow/contrib/automation/api/workflows/serializers.py

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
from drf_spectacular.utils import extend_schema_field
33
from rest_framework import serializers
44

5+
from baserow.api.pagination import PageNumberPagination
56
from baserow.contrib.automation.models import (
7+
AutomationHistory,
8+
AutomationNodeHistory,
69
AutomationWorkflow,
710
AutomationWorkflowHistory,
811
)
@@ -103,14 +106,88 @@ class OrderAutomationWorkflowsSerializer(serializers.Serializer):
103106
)
104107

105108

106-
class AutomationWorkflowHistorySerializer(serializers.ModelSerializer):
109+
class AutomationHistorySerializer(serializers.ModelSerializer):
107110
class Meta:
108-
model = AutomationWorkflowHistory
111+
model = AutomationHistory
109112
fields = (
110113
"id",
111114
"started_on",
112115
"completed_on",
113-
"is_test_run",
114116
"message",
115117
"status",
116118
)
119+
120+
121+
class AutomationNodeHistorySerializer(AutomationHistorySerializer):
122+
parent_node_id = serializers.SerializerMethodField()
123+
iteration = serializers.SerializerMethodField()
124+
result = serializers.SerializerMethodField()
125+
node_type = serializers.SerializerMethodField()
126+
node_label = serializers.SerializerMethodField()
127+
128+
class Meta:
129+
model = AutomationNodeHistory
130+
fields = AutomationHistorySerializer.Meta.fields + (
131+
"workflow_history",
132+
"node",
133+
"node_type",
134+
"node_label",
135+
"parent_node_id",
136+
"iteration",
137+
"result",
138+
)
139+
140+
def _get_first_node_result(self, obj):
141+
results = obj.node_results.all()
142+
return results[0] if results else None
143+
144+
@extend_schema_field(OpenApiTypes.STR)
145+
def get_node_type(self, obj):
146+
return obj.node.get_type().type
147+
148+
@extend_schema_field(OpenApiTypes.STR)
149+
def get_node_label(self, obj):
150+
return obj.node.label
151+
152+
@extend_schema_field(OpenApiTypes.INT)
153+
def get_parent_node_id(self, obj):
154+
parent_nodes = obj.node.get_parent_nodes()
155+
if not parent_nodes:
156+
return None
157+
return parent_nodes[-1].id
158+
159+
@extend_schema_field(OpenApiTypes.INT)
160+
def get_iteration(self, obj):
161+
result = self._get_first_node_result(obj)
162+
if result is None:
163+
return None
164+
165+
if result.iteration_path:
166+
return int(result.iteration_path.rsplit(".", 1)[-1])
167+
168+
return 0
169+
170+
def get_result(self, obj):
171+
result = self._get_first_node_result(obj)
172+
return result.result if result else {}
173+
174+
175+
class AutomationWorkflowHistorySerializer(AutomationHistorySerializer):
176+
node_histories = AutomationNodeHistorySerializer(read_only=True, many=True)
177+
178+
class Meta:
179+
model = AutomationWorkflowHistory
180+
fields = AutomationHistorySerializer.Meta.fields + (
181+
"is_test_run",
182+
"event_payload",
183+
"simulate_until_node",
184+
"node_histories",
185+
)
186+
187+
188+
class AutomationWorkflowHistoryPagination(PageNumberPagination):
189+
def get_paginated_response(self, data, *, success_count: int, fail_count: int):
190+
response = super().get_paginated_response(data)
191+
response.data["success_count"] = success_count
192+
response.data["fail_count"] = fail_count
193+
return response

backend/src/baserow/contrib/automation/api/workflows/views.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,20 @@
22

33
from django.conf import settings
44
from django.db import transaction
5+
from django.db.models import Count, Q
56

67
from drf_spectacular.types import OpenApiTypes
78
from drf_spectacular.utils import OpenApiParameter, extend_schema
89
from rest_framework.permissions import IsAuthenticated
910
from rest_framework.response import Response
11+
from rest_framework.serializers import IntegerField
1012
from rest_framework.status import HTTP_202_ACCEPTED
1113
from rest_framework.views import APIView
1214

1315
from baserow.api.applications.errors import ERROR_APPLICATION_DOES_NOT_EXIST
1416
from baserow.api.decorators import map_exceptions, validate_body
1517
from baserow.api.jobs.errors import ERROR_MAX_JOB_COUNT_EXCEEDED
1618
from baserow.api.jobs.serializers import JobSerializer
17-
from baserow.api.pagination import PageNumberPagination
1819
from baserow.api.schemas import CLIENT_SESSION_ID_SCHEMA_PARAMETER, get_error_schema
1920
from baserow.api.serializers import get_example_pagination_serializer_class
2021
from baserow.contrib.automation.api.workflows.errors import (
@@ -23,12 +24,14 @@
2324
ERROR_AUTOMATION_WORKFLOW_NOTIFICATION_RECIPIENTS_INVALID,
2425
)
2526
from baserow.contrib.automation.api.workflows.serializers import (
27+
AutomationWorkflowHistoryPagination,
2628
AutomationWorkflowHistorySerializer,
2729
AutomationWorkflowSerializer,
2830
CreateAutomationWorkflowSerializer,
2931
OrderAutomationWorkflowsSerializer,
3032
UpdateAutomationWorkflowSerializer,
3133
)
34+
from baserow.contrib.automation.history.constants import HistoryStatusChoices
3235
from baserow.contrib.automation.history.service import AutomationHistoryService
3336
from baserow.contrib.automation.workflows.actions import (
3437
CreateAutomationWorkflowActionType,
@@ -231,7 +234,15 @@ class AutomationWorkflowHistoryView(APIView):
231234
description="Retrieve the history for a workflow.",
232235
responses={
233236
200: get_example_pagination_serializer_class(
234-
AutomationWorkflowHistorySerializer
237+
AutomationWorkflowHistorySerializer,
238+
additional_fields={
239+
"success_count": IntegerField(
240+
help_text="The total number of successful workflow runs."
241+
),
242+
"fail_count": IntegerField(
243+
help_text="The total number of failed workflow runs."
244+
),
245+
},
235246
),
236247
404: get_error_schema(
237248
[
@@ -251,16 +262,26 @@ def get(self, request, workflow_id: int):
251262
request.user, workflow_id
252263
)
253264

254-
paginator = PageNumberPagination(
265+
counts = queryset.aggregate(
266+
success_count=Count("id", filter=Q(status=HistoryStatusChoices.SUCCESS)),
267+
fail_count=Count("id", filter=Q(status=HistoryStatusChoices.ERROR)),
268+
)
269+
270+
paginator = AutomationWorkflowHistoryPagination(
255271
limit_page_size=settings.AUTOMATION_HISTORY_PAGE_SIZE_LIMIT
256272
)
273+
257274
page = paginator.paginate_queryset(queryset, request, self)
258275
serializer = AutomationWorkflowHistorySerializer(
259276
page,
260277
many=True,
261278
)
262279

263-
return paginator.get_paginated_response(serializer.data)
280+
return paginator.get_paginated_response(
281+
serializer.data,
282+
success_count=counts["success_count"],
283+
fail_count=counts["fail_count"],
284+
)
264285

265286

266287
class OrderAutomationWorkflowsView(APIView):

backend/src/baserow/contrib/automation/history/handler.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from datetime import datetime
22
from typing import Dict, List, Optional, Union
33

4-
from django.db.models import QuerySet
4+
from django.db.models import Prefetch, QuerySet
55

66
from baserow.contrib.automation.history.constants import HistoryStatusChoices
77
from baserow.contrib.automation.history.exceptions import (
@@ -31,9 +31,18 @@ def get_workflow_histories(
3131
base_queryset = AutomationWorkflowHistory.objects.all()
3232

3333
return base_queryset.filter(
34-
workflow=workflow,
34+
original_workflow=workflow,
3535
simulate_until_node__isnull=True,
36-
).prefetch_related("workflow__automation__workspace")
36+
).prefetch_related(
37+
Prefetch(
38+
"node_histories",
39+
queryset=AutomationNodeHistory.objects.select_related(
40+
"node", "node__workflow"
41+
)
42+
.prefetch_related("node_results")
43+
.order_by("started_on"),
44+
),
45+
)
3746

3847
def get_workflow_history(
3948
self, history_id: int, base_queryset: Optional[QuerySet] = None
@@ -60,6 +69,7 @@ def get_workflow_history(
6069

6170
def create_workflow_history(
6271
self,
72+
original_workflow: AutomationWorkflow,
6373
workflow: AutomationWorkflow,
6474
started_on: datetime,
6575
is_test_run: bool,
@@ -73,6 +83,7 @@ def create_workflow_history(
7383

7484
return AutomationWorkflowHistory.objects.create(
7585
workflow=workflow,
86+
original_workflow=original_workflow,
7687
started_on=started_on,
7788
is_test_run=is_test_run,
7889
simulate_until_node=simulate_until_node,

backend/src/baserow/contrib/automation/history/models.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,18 @@ class Meta:
2020

2121

2222
class AutomationWorkflowHistory(AutomationHistory):
23-
workflow = models.ForeignKey(
23+
original_workflow = models.ForeignKey(
2424
"automation.AutomationWorkflow",
2525
on_delete=models.CASCADE,
2626
related_name="workflow_histories",
27+
# TODO ZDM: Make non-nullable after next release and add backfill
28+
# migration. See: https://github.com/baserow/baserow/issues/5236
29+
null=True,
30+
)
31+
workflow = models.ForeignKey(
32+
"automation.AutomationWorkflow",
33+
on_delete=models.CASCADE,
34+
related_name="cloned_workflow_histories",
2735
)
2836
simulate_until_node = models.ForeignKey(
2937
"automation.AutomationNode",
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import django.db.models.deletion
2+
from django.db import migrations, models
3+
4+
5+
def backfill_original_workflow(apps, schema_editor):
6+
AutomationWorkflowHistory = apps.get_model(
7+
"automation", "AutomationWorkflowHistory"
8+
)
9+
AutomationWorkflowHistory.objects.filter(original_workflow__isnull=True).update(
10+
original_workflow_id=models.F("workflow_id")
11+
)
12+
13+
14+
class Migration(migrations.Migration):
15+
16+
dependencies = [
17+
("automation", "0027_alter_automationnodehistory_options_and_more"),
18+
]
19+
20+
operations = [
21+
migrations.AddField(
22+
model_name="automationworkflowhistory",
23+
name="original_workflow",
24+
field=models.ForeignKey(
25+
null=True,
26+
on_delete=django.db.models.deletion.CASCADE,
27+
related_name="workflow_histories",
28+
to="automation.automationworkflow",
29+
),
30+
),
31+
migrations.RunPython(
32+
backfill_original_workflow,
33+
reverse_code=migrations.RunPython.noop,
34+
),
35+
migrations.AlterField(
36+
model_name="automationworkflowhistory",
37+
name="workflow",
38+
field=models.ForeignKey(
39+
on_delete=django.db.models.deletion.CASCADE,
40+
related_name="cloned_workflow_histories",
41+
to="automation.automationworkflow",
42+
),
43+
),
44+
migrations.AlterField(
45+
model_name='automationworkflow',
46+
name='state',
47+
field=models.CharField(choices=[('draft', 'Draft'), ('live', 'Live'), ('paused', 'Paused'), ('disabled', 'Disabled'), ('test_clone', 'Test Clone')], db_default='draft', default='draft', max_length=20),
48+
),
49+
]

backend/src/baserow/contrib/automation/models.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
from django.db import models
22

3-
from baserow.contrib.automation.history.models import AutomationWorkflowHistory
3+
from baserow.contrib.automation.history.models import (
4+
AutomationHistory,
5+
AutomationNodeHistory,
6+
AutomationNodeResult,
7+
AutomationWorkflowHistory,
8+
)
49
from baserow.contrib.automation.workflows.models import (
510
AutomationWorkflow,
611
DuplicateAutomationWorkflowJob,
@@ -11,7 +16,10 @@
1116
"Automation",
1217
"AutomationWorkflow",
1318
"DuplicateAutomationWorkflowJob",
19+
"AutomationHistory",
1420
"AutomationWorkflowHistory",
21+
"AutomationNodeHistory",
22+
"AutomationNodeResult",
1523
]
1624

1725

0 commit comments

Comments
 (0)