From 6c1e3e45e857429667f6f842f8463b13cb945f24 Mon Sep 17 00:00:00 2001 From: alamin-br Date: Wed, 16 Sep 2026 11:05:50 +0200 Subject: [PATCH 1/3] feat(database): Button field: record who started a workflow run (#6068) * feat: record who triggered an automation workflow run * feat: name the clicker on a workflow a button started * feat: carry the trigger user on the automation dispatch context * feat: expose who triggered a run on the workflow history API * feat: audit log a button field click * feat: show who started a run in the workflow history * docs: say a periodic trigger can be started and how a click is recorded * fix: give the history actor label room for a full name * fix: keep the trigger user off the dispatch context actor A Local Baserow node with no integration acts as the context actor, so a run would act as whoever clicked. * fix: audit a click only once it holds the lock * feat: record who started a test run * fix: honour is_test_run in the workflow history fixture * refactor: resolve who started a run through the workspace user store * test: drop a start workflow test whose deny patch never fires * test: e2e for who started a workflow a button or a test run began * test: e2e for picking a workflow in the button field editor * refactor: read the trigger user lazily from the run's history No per-node user join; the context exposes history.triggered_by on demand. * refactor: plain serializer for who triggered a run * test: forward history fixture kwargs and hoist imports * fix: put the run's starter on its own line in the history panel The header row is 327px wide; a name beside the title wrapped it. Name resolution reuses the collaborator mixin. * docs: say exactly when a click is audited and what a nested run records * test: share e2e job polling and AI panel suppression * fix: annotate the automation dispatch context's history as a workflow history * fix: cap the button action form so a helper sentence cannot stretch the editor * fix: keep the automation dispatch context's history required The simulation's history=None is a sample-data-only exception, not the norm. * refactor: carry the dispatch context actor through own_properties Contexts that take an actor list it like any other property; the rest keep the base None. * refactor: record who started a workflow run as a subject Stores id, type and name instead of a user foreign key, so an agent can start a run without a data migration and the name survives the user being deleted. * fix: record who started a test run that waits for its event The starter is kept on the workflow with the other test state, so the event that starts the run later can still name them. * fix: forget a test run's starter when its window closes Opening or closing the test run window through a workflow update left the earlier starter in place, so a later run could name the wrong person. * fix: keep a test run's starter when undo restores an unchanged window Undo and redo send every workflow field back, so only a real change to the test run window may replace who started it. * fix: reset a test run's starter type along with its id * fix: return None from get_subject for subject types without a table anonymous, user_source.user and builder_preview_actor have no manager, so the lookup raised AttributeError. * refactor: resolve collaborator names through a core util The history panel called a mixin method without its component, which only worked while the method ignored this. * refactor: name the history's started-by line after what it shows * docs: note that a button with no actions logs no click * refactor: drop the unused triggered_by from the automation dispatch context Left over from when the clicker was the context's actor; the history already records who started the run. --- .../automation/api/workflows/serializers.py | 18 ++ .../automation/automation_dispatch_context.py | 6 +- .../contrib/automation/history/handler.py | 22 +- .../contrib/automation/history/models.py | 22 ++ .../migrations/0036_triggered_by.py | 38 +++ .../contrib/automation/nodes/node_types.py | 4 + .../contrib/automation/workflows/handler.py | 104 +++++++- .../contrib/automation/workflows/models.py | 14 ++ .../contrib/automation/workflows/service.py | 8 +- backend/src/baserow/contrib/database/apps.py | 4 + .../database/workflow_actions/actions.py | 79 ++++++ .../workflow_actions/dispatch_context.py | 10 +- .../database/workflow_actions/service.py | 7 + .../integrations/core/service_types.py | 8 +- backend/src/baserow/core/registries.py | 15 ++ .../baserow/core/services/dispatch_context.py | 6 +- .../test_utils/fixtures/automation_history.py | 5 +- .../src/baserow/test_utils/pytest_conftest.py | 1 + .../automation/api/nodes/test_nodes_views.py | 4 +- .../api/workflows/test_workflow_views.py | 50 +++- .../history/test_history_handler.py | 54 ++++ .../nodes/test_node_dispatch_trigger_user.py | 37 +++ .../workflows/test_workflow_handler.py | 234 ++++++++++++++++++ .../test_dashboard_dispatch_context.py | 2 + .../workflow_actions/test_dispatch.py | 109 ++++++++ .../test_start_workflow_action.py | 3 +- .../core/test_core_periodic_service_type.py | 2 + .../test_core_start_workflow_service_type.py | 24 +- .../baserow/core/service/test_service_type.py | 11 +- .../tests/baserow/core/test_core_registry.py | 22 ++ .../006-button-field-workflow-actions.md | 26 +- .../fixtures/automation/automationWorkflow.ts | 16 ++ e2e-tests/fixtures/database/field.ts | 24 +- e2e-tests/fixtures/database/workflowAction.ts | 12 + e2e-tests/fixtures/job.ts | 28 +++ e2e-tests/pages/baserowPage.ts | 4 + e2e-tests/pages/workspacePage.ts | 3 +- e2e-tests/tests/baserowTest.ts | 5 - .../button_field_start_workflow.spec.ts | 179 ++++++++++++++ .../tests/database/realtimeReplay.spec.ts | 4 - .../workflow/sidePanels/WorkflowHistory.vue | 13 + .../modules/automation/locales/en.json | 1 + .../automation/workflow/workflow_history.scss | 10 + .../modules/core/utils/collaborator.js | 29 +++ .../components/button_field_action_list.scss | 4 + .../database/mixins/collaboratorName.js | 30 +-- .../modules/integrations/locales/en.json | 2 +- .../workflow/WorkflowHistory.spec.js | 81 ++++++ 48 files changed, 1290 insertions(+), 104 deletions(-) create mode 100644 backend/src/baserow/contrib/automation/migrations/0036_triggered_by.py create mode 100644 backend/src/baserow/contrib/database/workflow_actions/actions.py create mode 100644 backend/tests/baserow/contrib/automation/nodes/test_node_dispatch_trigger_user.py create mode 100644 e2e-tests/fixtures/job.ts create mode 100644 e2e-tests/tests/database/button_field_start_workflow.spec.ts create mode 100644 web-frontend/modules/core/utils/collaborator.js create mode 100644 web-frontend/test/unit/automation/components/workflow/WorkflowHistory.spec.js diff --git a/backend/src/baserow/contrib/automation/api/workflows/serializers.py b/backend/src/baserow/contrib/automation/api/workflows/serializers.py index 21e1fc2928..b3ace055fc 100644 --- a/backend/src/baserow/contrib/automation/api/workflows/serializers.py +++ b/backend/src/baserow/contrib/automation/api/workflows/serializers.py @@ -124,8 +124,17 @@ class Meta: ) +class AutomationWorkflowHistoryTriggeredBySerializer(serializers.Serializer): + """Who started the run, as `{id, type, name}` for the collaborator UI.""" + + id = serializers.IntegerField(source="triggered_by_id", read_only=True) + type = serializers.CharField(source="triggered_by_type", read_only=True) + name = serializers.CharField(source="triggered_by_name", read_only=True) + + class AutomationWorkflowHistorySerializer(AutomationHistorySerializer): plugin_data = serializers.SerializerMethodField() + triggered_by = serializers.SerializerMethodField() class Meta: model = AutomationWorkflowHistory @@ -133,12 +142,21 @@ class Meta: "is_test_run", "simulate_until_node", "plugin_data", + "triggered_by", ) @extend_schema_field(serializers.DictField()) def get_plugin_data(self, obj): return self.context.get("workflow_history_plugin_data", {}).get(obj.id, {}) + @extend_schema_field( + AutomationWorkflowHistoryTriggeredBySerializer(allow_null=True) + ) + def get_triggered_by(self, obj): + if obj.triggered_by_id is None: + return None + return AutomationWorkflowHistoryTriggeredBySerializer(obj).data + class AutomationWorkflowHistoryPagination(PageNumberPagination): def get_paginated_response(self, data, *, success_count: int, fail_count: int): diff --git a/backend/src/baserow/contrib/automation/automation_dispatch_context.py b/backend/src/baserow/contrib/automation/automation_dispatch_context.py index c282f73a7f..8dc8a12902 100644 --- a/backend/src/baserow/contrib/automation/automation_dispatch_context.py +++ b/backend/src/baserow/contrib/automation/automation_dispatch_context.py @@ -4,9 +4,7 @@ automation_data_provider_type_registry, ) from baserow.contrib.automation.history.handler import AutomationHistoryHandler -from baserow.contrib.automation.history.models import ( - AutomationNodeHistory, -) +from baserow.contrib.automation.history.models import AutomationWorkflowHistory from baserow.contrib.automation.nodes.models import AutomationActionNode from baserow.contrib.automation.workflows.models import AutomationWorkflow from baserow.core.cache import local_cache @@ -19,7 +17,7 @@ class AutomationDispatchContext(DispatchContext): def __init__( self, workflow: AutomationWorkflow, - history: AutomationNodeHistory, + history: AutomationWorkflowHistory, event_payload: Optional[Union[Dict, List[Dict]]] = None, simulate_until_node: Optional[AutomationActionNode] = None, current_iterations: Optional[Dict[int, int]] = None, diff --git a/backend/src/baserow/contrib/automation/history/handler.py b/backend/src/baserow/contrib/automation/history/handler.py index b38d6a140b..5704650f90 100644 --- a/backend/src/baserow/contrib/automation/history/handler.py +++ b/backend/src/baserow/contrib/automation/history/handler.py @@ -17,8 +17,10 @@ from baserow.contrib.automation.nodes.models import AutomationNode from baserow.contrib.automation.workflows.models import AutomationWorkflow from baserow.core.db import specific_iterator +from baserow.core.registries import subject_type_registry from baserow.core.services.handler import ServiceHandler from baserow.core.services.models import Service +from baserow.core.types import Subject class AutomationHistoryHandler: @@ -73,8 +75,25 @@ def create_workflow_history( status: HistoryStatusChoices = HistoryStatusChoices.STARTED, completed_on: Optional[datetime] = None, message: str = "", + triggered_by: Optional[Subject] = None, ) -> AutomationWorkflowHistory: - """Creates a history entry for a Workflow run.""" + """ + Creates a history entry for a Workflow run. + + :param triggered_by: The subject who deliberately started the run, when + one did. An event-started run has none. + """ + + triggered_by_values = {} + if triggered_by is not None: + subject_type = subject_type_registry.get_by_model(triggered_by) + triggered_by_values = { + "triggered_by_id": triggered_by.id, + "triggered_by_type": subject_type.type, + # TODO: use `subject_type.get_display_name` once agents are + # subjects (#6064). Users are the only subject starting a run. + "triggered_by_name": triggered_by.first_name, + } return AutomationWorkflowHistory.objects.create( workflow=workflow, @@ -86,6 +105,7 @@ def create_workflow_history( status=status, completed_on=completed_on, message=message, + **triggered_by_values, ) def create_node_history( diff --git a/backend/src/baserow/contrib/automation/history/models.py b/backend/src/baserow/contrib/automation/history/models.py index fca3cd715b..e0c4ee9a4e 100644 --- a/backend/src/baserow/contrib/automation/history/models.py +++ b/backend/src/baserow/contrib/automation/history/models.py @@ -1,6 +1,7 @@ from django.db import models from baserow.contrib.automation.history.constants import HistoryStatusChoices +from baserow.core.subjects import UserSubjectType class AutomationHistory(models.Model): @@ -50,6 +51,27 @@ class AutomationWorkflowHistory(AutomationHistory): help_text="Event payload received by the workflow.", ) + # Who started the run, as a subject rather than a user foreign key so an + # agent can be recorded too. The id is only meaningful together with the + # type, since user and agent ids can overlap. The name is kept so the entry + # still reads after the subject is deleted. + triggered_by_id = models.PositiveIntegerField( + null=True, + help_text="The id of the subject who started this run. Null when an " + "event started it.", + ) + triggered_by_type = models.CharField( + max_length=255, + db_default=UserSubjectType.type, + help_text="The subject type of who started this run.", + ) + triggered_by_name = models.CharField( + max_length=160, + blank=True, + db_default="", + help_text="The name of who started this run, when it started.", + ) + class Meta(AutomationHistory.Meta): indexes = [ models.Index( diff --git a/backend/src/baserow/contrib/automation/migrations/0036_triggered_by.py b/backend/src/baserow/contrib/automation/migrations/0036_triggered_by.py new file mode 100644 index 0000000000..d37a6078a7 --- /dev/null +++ b/backend/src/baserow/contrib/automation/migrations/0036_triggered_by.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.16 on 2026-09-14 11:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('automation', '0035_coregotoactionnode'), + ] + + operations = [ + migrations.AddField( + model_name='automationworkflow', + name='test_run_triggered_by_id', + field=models.PositiveIntegerField(blank=True, help_text='The id of the subject who asked for the pending test run.', null=True), + ), + migrations.AddField( + model_name='automationworkflow', + name='test_run_triggered_by_type', + field=models.CharField(db_default='auth.User', help_text='The subject type of who asked for the pending test run.', max_length=255), + ), + migrations.AddField( + model_name='automationworkflowhistory', + name='triggered_by_id', + field=models.PositiveIntegerField(help_text='The id of the subject who started this run. Null when an event started it.', null=True), + ), + migrations.AddField( + model_name='automationworkflowhistory', + name='triggered_by_name', + field=models.CharField(blank=True, db_default='', help_text='The name of who started this run, when it started.', max_length=160), + ), + migrations.AddField( + model_name='automationworkflowhistory', + name='triggered_by_type', + field=models.CharField(db_default='auth.User', help_text='The subject type of who started this run.', max_length=255), + ), + ] diff --git a/backend/src/baserow/contrib/automation/nodes/node_types.py b/backend/src/baserow/contrib/automation/nodes/node_types.py index cb3521346d..890b8d5243 100644 --- a/backend/src/baserow/contrib/automation/nodes/node_types.py +++ b/backend/src/baserow/contrib/automation/nodes/node_types.py @@ -702,6 +702,10 @@ def on_event( AutomationWorkflowHandler().async_start_workflow( workflow, service_payload, + # Read before the reset below clears it. + triggered_by=AutomationWorkflowHandler().get_test_run_triggered_by( + workflow + ), ) # We don't want subsequent events to trigger a new test run diff --git a/backend/src/baserow/contrib/automation/workflows/handler.py b/backend/src/baserow/contrib/automation/workflows/handler.py index 587e73d37d..f7d58bcba7 100644 --- a/backend/src/baserow/contrib/automation/workflows/handler.py +++ b/backend/src/baserow/contrib/automation/workflows/handler.py @@ -58,10 +58,12 @@ from baserow.contrib.automation.workflows.types import UpdatedAutomationWorkflow from baserow.core.cache import global_cache, local_cache from baserow.core.exceptions import IdDoesNotExist -from baserow.core.registries import ImportExportConfig +from baserow.core.registries import ImportExportConfig, subject_type_registry from baserow.core.storage import ExportZipFile, get_default_storage +from baserow.core.subjects import UserSubjectType from baserow.core.telemetry.utils import baserow_trace, baserow_trace_handler from baserow.core.trash.handler import TrashHandler +from baserow.core.types import Subject from baserow.core.utils import ( ChildProgressBuilder, MirrorDict, @@ -228,12 +230,17 @@ def export_prepared_values(self, workflow: AutomationWorkflow) -> Dict[Any, Any] return prepared_values def update_workflow( - self, workflow: AutomationWorkflow, **kwargs + self, + workflow: AutomationWorkflow, + triggered_by: Optional[Subject] = None, + **kwargs, ) -> UpdatedAutomationWorkflow: """ Updates fields of the provided AutomationWorkflow. :param workflow: The AutomationWorkflow that should be updated. + :param triggered_by: Who makes the update, recorded as the starter of + the test run when the update opens the test run window. :param kwargs: The fields that should be updated with their corresponding values. :return: The updated AutomationWorkflow. @@ -254,9 +261,21 @@ def update_workflow( published_workflow.state = WorkflowState(state) published_workflow.save(update_fields=["state"]) + previous_allow_test_run_until = workflow.allow_test_run_until for key, value in extract_allowed(allowed_values, attr_fields).items(): setattr(workflow, key, value) + # Opening the window records who opened it; closing it forgets them, so a + # later run never names someone from an earlier window. Undo and redo + # send every field back, as JSON, so an unchanged window keeps its starter. + allow_test_run_until = AutomationWorkflow._meta.get_field( + "allow_test_run_until" + ).to_python(workflow.allow_test_run_until) + if allow_test_run_until != previous_allow_test_run_until: + self._set_test_run_triggered_by( + workflow, triggered_by if workflow.allow_test_run_until else None + ) + workflow.save() set_allowed_m2m_fields(allowed_values, m2m_fields, workflow) @@ -756,7 +775,12 @@ def disable_workflow(self, workflow: AutomationWorkflow) -> None: automation_workflow_updated.send(self, user=None, workflow=original_workflow) - def set_workflow_temporary_states(self, workflow, simulate_until_node=None): + def set_workflow_temporary_states( + self, + workflow, + simulate_until_node=None, + triggered_by: Optional[Subject] = None, + ): """ Sets the temporary states necessary to allow an unpublished workflow to be ran by the next event. By default a full test run is scheduled unless the @@ -764,9 +788,13 @@ def set_workflow_temporary_states(self, workflow, simulate_until_node=None): :param workflow: The workflow to consider. :param simulate_until_node: If set, schedules a simulation run instead. + :param triggered_by: Who asked for the run, recorded on its history + even when the run waits for an event. """ - fields_to_save = [] + # Always written, so a run never names the starter of an earlier one. + fields_to_save = self._set_test_run_triggered_by(workflow, triggered_by) + if simulate_until_node is not None: # Switch to simulate until the given node workflow.simulate_until_node = simulate_until_node @@ -786,6 +814,44 @@ def set_workflow_temporary_states(self, workflow, simulate_until_node=None): workflow.save(update_fields=fields_to_save) automation_workflow_updated.send(self, user=None, workflow=workflow) + def _set_test_run_triggered_by( + self, workflow: AutomationWorkflow, triggered_by: Optional[Subject] + ) -> List[str]: + """ + Sets who asked for the workflow's pending test run, without saving. + + :param workflow: The workflow waiting for its test run. + :param triggered_by: The subject, or None to record nobody. + :return: The fields to save. + """ + + if triggered_by is None: + workflow.test_run_triggered_by_id = None + workflow.test_run_triggered_by_type = UserSubjectType.type + return ["test_run_triggered_by_id", "test_run_triggered_by_type"] + + workflow.test_run_triggered_by_id = triggered_by.id + workflow.test_run_triggered_by_type = subject_type_registry.get_by_model( + triggered_by + ).type + return ["test_run_triggered_by_id", "test_run_triggered_by_type"] + + def get_test_run_triggered_by(self, workflow) -> Optional[Subject]: + """ + Returns who asked for the workflow's pending test run or simulation. + + :param workflow: The workflow waiting for its test run. + :return: The subject, or None when nobody is recorded or it no longer + exists. + """ + + if workflow.test_run_triggered_by_id is None: + return None + + return subject_type_registry.get_subject( + workflow.test_run_triggered_by_type, workflow.test_run_triggered_by_id + ) + def reset_workflow_temporary_states(self, workflow): """ Reset the temporary states set when we want to test or simulate a workflow. @@ -801,6 +867,9 @@ def reset_workflow_temporary_states(self, workflow): workflow.simulate_until_node = None fields_to_save.append("simulate_until_node") + if workflow.test_run_triggered_by_id is not None: + fields_to_save += self._set_test_run_triggered_by(workflow, None) + if fields_to_save: workflow.save(update_fields=fields_to_save) automation_workflow_updated.send(self, user=None, workflow=workflow) @@ -810,6 +879,7 @@ def toggle_test_run( self, workflow: AutomationWorkflow, simulate_until_node: AutomationNode | None, + triggered_by: Optional[AbstractUser] = None, ): """ Trigger a test run if none is in progress or cancel the planned run. If the @@ -820,6 +890,8 @@ def toggle_test_run( :param workflow: The workflow we want to trigger the test run for. :param simulate_until_node: If we want to simulate until a particular node. + :param triggered_by: The person starting the test run, recorded on its + history entry. """ if workflow.simulate_until_node is not None or workflow.allow_test_run_until: @@ -828,22 +900,27 @@ def toggle_test_run( return if simulate_until_node is None: # Full test - AutomationWorkflowHandler().set_workflow_temporary_states(workflow) + AutomationWorkflowHandler().set_workflow_temporary_states( + workflow, triggered_by=triggered_by + ) if workflow.can_be_immediately_dispatched(): # If the service related to the trigger can immediately dispatch, # we immediately trigger the workflow run. - self.async_start_workflow(workflow) + self.async_start_workflow(workflow, triggered_by=triggered_by) else: AutomationWorkflowHandler().set_workflow_temporary_states( - workflow, simulate_until_node=simulate_until_node + workflow, + simulate_until_node=simulate_until_node, + triggered_by=triggered_by, ) trigger = workflow.get_trigger() dispatch_context = AutomationDispatchContext( workflow, - # This is a placeholder value, no actual history exists yet - # (it's created later in start_workflow). This is fine - # for now, because get_sample_data() doesn't use history. + # No history exists yet, start_workflow creates it. This context + # only serves the get_sample_data() call below, which never reads + # history, and is never used for a run. Every other context + # needs a real history. history=None, simulate_until_node=simulate_until_node, ) @@ -857,7 +934,7 @@ def toggle_test_run( # If the trigger is immediately dispatchable or if we already have # the sample data for it we can immediately dispatch the workflow # except if we are updating the trigger sample data by itself - self.async_start_workflow(workflow) + self.async_start_workflow(workflow, triggered_by=triggered_by) @baserow_trace(tracer) def clear_old_history(self) -> None: @@ -1143,12 +1220,15 @@ def async_start_workflow( self, workflow: AutomationWorkflow, event_payload: Optional[List[Dict]] = None, + triggered_by: Optional[AbstractUser] = None, ) -> None: """ Runs the provided workflow in a celery task. :param workflow: The AutomationWorkflow ID that should be executed. :param event_payload: The payload from the action. + :param triggered_by: The person who started the run, recorded on the + history entry. """ error = None @@ -1216,6 +1296,7 @@ def async_start_workflow( completed_on=now, message=error, status=history_status, + triggered_by=triggered_by, ) return @@ -1226,6 +1307,7 @@ def async_start_workflow( is_test_run=is_test_run, event_payload=event_payload, simulate_until_node=simulate_until_node, + triggered_by=triggered_by, ) automation_workflow_dispatch_started.send( diff --git a/backend/src/baserow/contrib/automation/workflows/models.py b/backend/src/baserow/contrib/automation/workflows/models.py index af1d9aadfa..30fa9f6f78 100644 --- a/backend/src/baserow/contrib/automation/workflows/models.py +++ b/backend/src/baserow/contrib/automation/workflows/models.py @@ -19,6 +19,7 @@ OrderableMixin, TrashableModelMixin, ) +from baserow.core.subjects import UserSubjectType if TYPE_CHECKING: from baserow.contrib.automation.models import Automation @@ -80,6 +81,19 @@ class AutomationWorkflow( allow_test_run_until = models.DateTimeField(null=True, blank=True) + # Who asked for the pending test run or simulation, so a run that waits for + # its trigger's event still records them. Cleared with the temporary states. + test_run_triggered_by_id = models.PositiveIntegerField( + null=True, + blank=True, + help_text="The id of the subject who asked for the pending test run.", + ) + test_run_triggered_by_type = models.CharField( + max_length=255, + db_default=UserSubjectType.type, + help_text="The subject type of who asked for the pending test run.", + ) + notification_recipients = models.ManyToManyField( settings.AUTH_USER_MODEL, blank=True, diff --git a/backend/src/baserow/contrib/automation/workflows/service.py b/backend/src/baserow/contrib/automation/workflows/service.py index 8ab5aef301..162c309d0a 100644 --- a/backend/src/baserow/contrib/automation/workflows/service.py +++ b/backend/src/baserow/contrib/automation/workflows/service.py @@ -200,7 +200,9 @@ def update_workflow( workflow.automation.workspace, kwargs ) - updated_workflow = self.handler.update_workflow(workflow, **kwargs) + updated_workflow = self.handler.update_workflow( + workflow, triggered_by=user, **kwargs + ) automation_workflow_updated.send( self, user=user, workflow=updated_workflow.workflow ) @@ -354,4 +356,6 @@ def toggle_test_run( context=workflow, ) - self.handler.toggle_test_run(workflow, simulate_until_node=simulate_until_node) + self.handler.toggle_test_run( + workflow, simulate_until_node=simulate_until_node, triggered_by=user + ) diff --git a/backend/src/baserow/contrib/database/apps.py b/backend/src/baserow/contrib/database/apps.py index 08e40ede7d..f1448c73b9 100755 --- a/backend/src/baserow/contrib/database/apps.py +++ b/backend/src/baserow/contrib/database/apps.py @@ -61,6 +61,10 @@ def ready(self): action_type_registry.register(ExportTableActionType()) + from .workflow_actions.actions import DispatchButtonFieldActionType + + action_type_registry.register(DispatchButtonFieldActionType()) + from .airtable.actions import ImportDatabaseFromAirtableActionType action_type_registry.register(ImportDatabaseFromAirtableActionType()) diff --git a/backend/src/baserow/contrib/database/workflow_actions/actions.py b/backend/src/baserow/contrib/database/workflow_actions/actions.py new file mode 100644 index 0000000000..9ea873b0be --- /dev/null +++ b/backend/src/baserow/contrib/database/workflow_actions/actions.py @@ -0,0 +1,79 @@ +import dataclasses +from typing import Any + +from django.contrib.auth.models import AbstractUser +from django.utils.translation import gettext_lazy as _ + +from baserow.contrib.database.action.scopes import ( + TABLE_ACTION_CONTEXT, + TableActionScopeType, +) +from baserow.contrib.database.fields.models import ButtonField +from baserow.core.action.registries import ActionType, ActionTypeDescription + + +class DispatchButtonFieldActionType(ActionType): + """A button click, recorded for the audit log. Not undoable (ADR 006 s.8).""" + + type = "dispatch_button_field" + description = ActionTypeDescription( + _("Click button"), + _('Button "%(field_name)s" (%(field_id)s) clicked on row %(row_id)s'), + TABLE_ACTION_CONTEXT, + ) + analytics_params = [ + "table_id", + "database_id", + "workspace_id", + "field_id", + "action_count", + ] + + @dataclasses.dataclass + class Params: + table_id: int + table_name: str + database_id: int + database_name: str + workspace_id: int + workspace_name: str + field_id: int + field_name: str + row_id: int + action_count: int + + @classmethod + def do(cls, user: AbstractUser, field: ButtonField, row: Any, action_count: int): + """ + Records the click. + + :param user: The clicker. + :param field: The clicked button field. + :param row: The clicked row, a generated table model instance. + :param action_count: How many actions the button carried at the click. + """ + + table = field.table + database = table.database + workspace = database.workspace + cls.register_action( + user, + cls.Params( + table.id, + table.name, + database.id, + database.name, + workspace.id, + workspace.name, + field.id, + field.name, + row.id, + action_count, + ), + cls.scope(table.id), + workspace, + ) + + @classmethod + def scope(cls, table_id: int): + return TableActionScopeType.value(table_id) diff --git a/backend/src/baserow/contrib/database/workflow_actions/dispatch_context.py b/backend/src/baserow/contrib/database/workflow_actions/dispatch_context.py index 54da406833..a3c1cc5926 100644 --- a/backend/src/baserow/contrib/database/workflow_actions/dispatch_context.py +++ b/backend/src/baserow/contrib/database/workflow_actions/dispatch_context.py @@ -27,7 +27,7 @@ class DatabaseDispatchContext(DispatchContext): context, and nothing dispatched by a click is a paginated list service. """ - own_properties = ["field", "row"] + own_properties = ["field", "row", "actor"] def __init__( self, @@ -43,17 +43,15 @@ def __init__( :param row: The clicked row, as a generated table model instance. """ - # Everything defaults to None only so `clone()` can rebuild this class - # without passing `actor`. Neither `field` nor `row` is optional for a - # real dispatch, so fail here rather than inside a data provider later. + # `field` and `row` default to None only because they follow `actor`. + # Neither is optional for a real dispatch, so fail here rather than + # inside a data provider later. if field is None or row is None: raise TypeError("DatabaseDispatchContext requires field and row") self.field = field self.row = row - # `clone()` carries `actor` over itself, hence its absence from - # `own_properties`. super().__init__(actor=actor, **kwargs) # Holds the row read for the action that is running. It has to be made diff --git a/backend/src/baserow/contrib/database/workflow_actions/service.py b/backend/src/baserow/contrib/database/workflow_actions/service.py index d6ca145bf0..15a2cf9cc5 100644 --- a/backend/src/baserow/contrib/database/workflow_actions/service.py +++ b/backend/src/baserow/contrib/database/workflow_actions/service.py @@ -15,6 +15,9 @@ ReadFieldOperationType, UpdateFieldOperationType, ) +from baserow.contrib.database.workflow_actions.actions import ( + DispatchButtonFieldActionType, +) from baserow.contrib.database.workflow_actions.dispatch_context import ( DatabaseDispatchContext, ) @@ -579,6 +582,7 @@ def dispatch_workflow_actions( # Nothing server side means no state to protect, so no lock: a button # that only opens a URL must not reject a second click. if not server_actions: + DispatchButtonFieldActionType.do(user, field, row, len(workflow_actions)) return WorkflowActionsDispatchResult( client_actions=client_actions, positions=positions ) @@ -606,6 +610,9 @@ def dispatch_workflow_actions( raise WorkflowActionDispatchInProgress() try: + # Inside the lock, so a click refused as already running leaves no entry. + DispatchButtonFieldActionType.do(user, field, row, len(workflow_actions)) + # Remembering a result edits the button's configuration, so it # follows the field's update permission rather than the lower bar # for clicking (ADR 006 section 7). Only asked when an action of diff --git a/backend/src/baserow/contrib/integrations/core/service_types.py b/backend/src/baserow/contrib/integrations/core/service_types.py index 8fc03232aa..2d9bf3b9c2 100644 --- a/backend/src/baserow/contrib/integrations/core/service_types.py +++ b/backend/src/baserow/contrib/integrations/core/service_types.py @@ -2517,7 +2517,7 @@ class CoreStartWorkflowServiceType(CoreServiceType): WORKFLOW_DOES_NOT_EXIST_ERROR = "The workflow with ID {workflow_id} does not exist." TRIGGER_NOT_ON_DEMAND_ERROR = ( "Only workflows whose trigger can start on demand, such as a manual " - "trigger, can be started." + "or periodic trigger, can be started." ) allowed_fields = ["workflow"] @@ -2621,7 +2621,11 @@ def dispatch_data( self.TRIGGER_NOT_ON_DEMAND_ERROR ) - AutomationWorkflowHandler().async_start_workflow(published_workflow) + # Only a button click has an actor here; a nested start-workflow node + # and the builder action do not, so their runs record nobody. + AutomationWorkflowHandler().async_start_workflow( + published_workflow, triggered_by=dispatch_context.actor + ) return None def dispatch_transform( diff --git a/backend/src/baserow/core/registries.py b/backend/src/baserow/core/registries.py index a4d6bf8534..16192d3170 100755 --- a/backend/src/baserow/core/registries.py +++ b/backend/src/baserow/core/registries.py @@ -1292,6 +1292,21 @@ def get_serializer(self, model_instance, **kwargs) -> Serializer: instance_type = self.get_by_model(model_instance) return instance_type.get_serializer(model_instance, **kwargs) + def get_subject(self, type_name: str, subject_id: int) -> Optional[Subject]: + """ + Returns the subject stored as a type and id pair. + + :param type_name: The subject type, for example `auth.User`. + :param subject_id: The id of the subject. + :return: The subject, or None when it no longer exists or its type is not + stored in the database. + """ + + manager = getattr(self.get(type_name).model_class, "objects", None) + if manager is None: + return None + return manager.filter(id=subject_id).first() + class OperationType(abc.ABC, Instance): """ diff --git a/backend/src/baserow/core/services/dispatch_context.py b/backend/src/baserow/core/services/dispatch_context.py index 505c1e9343..f0260032da 100644 --- a/backend/src/baserow/core/services/dispatch_context.py +++ b/backend/src/baserow/core/services/dispatch_context.py @@ -16,6 +16,7 @@ class DispatchContext(RuntimeFormulaContext, ABC): "use_sample_data", "force_outputs", "event_payload", + "actor", ] """ @@ -76,9 +77,6 @@ def clone(self, **kwargs) -> RuntimeFormulaContextSubClass: """ Return a new DispatchContext instance cloned from the current context, without losing the original cached data and call stack but updating some properties. - - The actor is carried over explicitly rather than via `own_properties`, so - it survives subclasses that replace that list. """ new_values = {} @@ -86,9 +84,7 @@ def clone(self, **kwargs) -> RuntimeFormulaContextSubClass: new_values[prop] = getattr(self, prop) new_values.update(kwargs) - actor = new_values.pop("actor", self.actor) new_context = self.__class__(**new_values) - new_context.actor = actor new_context.cache = {**self.cache} new_context.call_stack = set(self.call_stack) diff --git a/backend/src/baserow/test_utils/fixtures/automation_history.py b/backend/src/baserow/test_utils/fixtures/automation_history.py index f2930e32e9..327c3a1703 100644 --- a/backend/src/baserow/test_utils/fixtures/automation_history.py +++ b/backend/src/baserow/test_utils/fixtures/automation_history.py @@ -35,17 +35,20 @@ def create_workflow_history(self, user=None, **kwargs): if status is None: status = HistoryStatusChoices.SUCCESS - is_test_run = kwargs.pop("status", False) + is_test_run = kwargs.pop("is_test_run", False) self.create_local_baserow_create_row_action_node( user=user, workflow=original_workflow ) + # Anything left is the handler's to accept or refuse, so a misspelt + # name fails here instead of quietly building a default row. history = AutomationHistoryHandler().create_workflow_history( original_workflow=original_workflow, workflow=original_workflow, started_on=started_on, is_test_run=is_test_run, + **kwargs, ) history.completed_on = completed_on diff --git a/backend/src/baserow/test_utils/pytest_conftest.py b/backend/src/baserow/test_utils/pytest_conftest.py index d17b69d47f..50f8fd9251 100755 --- a/backend/src/baserow/test_utils/pytest_conftest.py +++ b/backend/src/baserow/test_utils/pytest_conftest.py @@ -991,6 +991,7 @@ class FakeDispatchContext(DispatchContext): "_is_publicly_sortable", "_filters", "_sortings", + "actor", ] def __init__(self, **kwargs): diff --git a/backend/tests/baserow/contrib/automation/api/nodes/test_nodes_views.py b/backend/tests/baserow/contrib/automation/api/nodes/test_nodes_views.py index f734d68c9c..b9f754fa8c 100644 --- a/backend/tests/baserow/contrib/automation/api/nodes/test_nodes_views.py +++ b/backend/tests/baserow/contrib/automation/api/nodes/test_nodes_views.py @@ -1005,7 +1005,7 @@ def test_simulate_dispatch_trigger_node_immediate_dispatch( assert workflow.simulate_until_node_id == trigger_node.id # In case of an immediate dispatch we want to trigger immediately the workflow - mock_async_start_workflow.assert_called_with(workflow) + mock_async_start_workflow.assert_called_with(workflow, triggered_by=user) trigger_node.service.get_type().can_be_immediately_dispatched = old_imm @@ -1166,7 +1166,7 @@ def test_simulate_dispatch_action_node_with_sample_data( assert response.status_code == HTTP_202_ACCEPTED # As the trigger node has sample data we can immediately trigger the workflow - mock_async_start_workflow.assert_called_with(workflow) + mock_async_start_workflow.assert_called_with(workflow, triggered_by=user) workflow.refresh_from_db() assert workflow.simulate_until_node_id == action_node.id 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 27d9bff843..4add8a1508 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 @@ -1,4 +1,5 @@ import datetime +from unittest.mock import patch from django.db.models import Count, Q from django.urls import reverse @@ -20,7 +21,11 @@ ) from baserow.contrib.automation.history.constants import HistoryStatusChoices from baserow.contrib.automation.history.handler import AutomationHistoryHandler -from baserow.contrib.automation.nodes.node_types import CorePeriodicTriggerNodeType +from baserow.contrib.automation.history.models import AutomationWorkflowHistory +from baserow.contrib.automation.nodes.node_types import ( + CoreManualTriggerNodeType, + CorePeriodicTriggerNodeType, +) from baserow.contrib.automation.workflows.constants import ALLOW_TEST_RUN_MINUTES from baserow.contrib.database.rows.handler import RowHandler from baserow.core.cache import local_cache @@ -478,6 +483,27 @@ def test_enable_workflow_test_run(api_client, data_fixture): ) +@pytest.mark.django_db +def test_a_test_run_records_who_started_it(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + workflow = data_fixture.create_automation_workflow( + user, trigger_type=CoreManualTriggerNodeType.type + ) + url = reverse(API_URL_WORKFLOW_TEST, kwargs={"workflow_id": workflow.id}) + + with patch( + "baserow.contrib.automation.workflows.handler.start_workflow_celery_task" + ): + response = api_client.post( + url, format="json", HTTP_AUTHORIZATION=f"JWT {token}" + ) + + assert response.status_code == HTTP_202_ACCEPTED + history = AutomationWorkflowHistory.objects.get(original_workflow=workflow) + assert history.is_test_run is True + assert history.triggered_by_id == user.id + + @pytest.mark.django_db def test_disable_workflow_test_run(api_client, data_fixture): user, token = data_fixture.create_user_and_token() @@ -681,11 +707,28 @@ def test_get_workflow_histories(api_client, data_fixture): "status": "success", "simulate_until_node": None, "plugin_data": {}, + "triggered_by": None, }, ], } +@pytest.mark.django_db +def test_get_workflow_histories_names_who_triggered_the_run(api_client, data_fixture): + user, token = data_fixture.create_user_and_token(first_name="Ada") + history = data_fixture.create_workflow_history(user=user, triggered_by=user) + + url = reverse(API_URL_WORKFLOW_HISTORY, kwargs={"workflow_id": history.workflow.id}) + response = api_client.get(url, **get_api_kwargs(token)) + + assert response.status_code == HTTP_200_OK + assert response.json()["results"][0]["triggered_by"] == { + "id": user.id, + "type": "auth.User", + "name": "Ada", + } + + @pytest.mark.django_db def test_get_workflow_histories_invalid_workflow(api_client, data_fixture): user, token = data_fixture.create_user_and_token() @@ -753,12 +796,15 @@ def test_get_workflow_histories_query_count(data_fixture, django_assert_num_quer handler = AutomationHistoryHandler() def _create_histories(count): - for _ in range(count): + for index in range(count): workflow_history = handler.create_workflow_history( original_workflow=workflow, workflow=workflow, started_on=timezone.now(), is_test_run=False, + # Every other run names its user, so the serializer's + # nested starter is exercised without a query per row. + triggered_by=user if index % 2 else None, ) node_history = handler.create_node_history( workflow_history=workflow_history, 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 f366dd5af7..6b51e235bf 100644 --- a/backend/tests/baserow/contrib/automation/history/test_history_handler.py +++ b/backend/tests/baserow/contrib/automation/history/test_history_handler.py @@ -559,3 +559,57 @@ def test_get_edge_labels_returns_expected_data(data_fixture): node_history_1.id: "foo label", node_history_2.id: "bar label", } + + +@pytest.mark.django_db +def test_create_workflow_history_records_who_triggered_it(data_fixture): + user = data_fixture.create_user() + workflow = data_fixture.create_automation_workflow(user=user) + + history = AutomationHistoryHandler().create_workflow_history( + original_workflow=workflow, + workflow=workflow, + started_on=timezone.now(), + is_test_run=False, + triggered_by=user, + ) + + history.refresh_from_db() + assert history.triggered_by_id == user.id + assert history.triggered_by_type == "auth.User" + assert history.triggered_by_name == user.first_name + + +@pytest.mark.django_db +def test_create_workflow_history_defaults_to_nobody(data_fixture): + workflow = data_fixture.create_automation_workflow() + + history = AutomationHistoryHandler().create_workflow_history( + original_workflow=workflow, + workflow=workflow, + started_on=timezone.now(), + is_test_run=False, + ) + + assert history.triggered_by_id is None + assert history.triggered_by_name == "" + + +@pytest.mark.django_db +def test_deleting_the_user_keeps_who_started_the_run(data_fixture): + user = data_fixture.create_user(first_name="Ada") + workflow = data_fixture.create_automation_workflow() + + history = AutomationHistoryHandler().create_workflow_history( + original_workflow=workflow, + workflow=workflow, + started_on=timezone.now(), + is_test_run=False, + triggered_by=user, + ) + user_id = user.id + user.delete() + + history.refresh_from_db() + assert history.triggered_by_id == user_id + assert history.triggered_by_name == "Ada" diff --git a/backend/tests/baserow/contrib/automation/nodes/test_node_dispatch_trigger_user.py b/backend/tests/baserow/contrib/automation/nodes/test_node_dispatch_trigger_user.py new file mode 100644 index 0000000000..8f7bdb7292 --- /dev/null +++ b/backend/tests/baserow/contrib/automation/nodes/test_node_dispatch_trigger_user.py @@ -0,0 +1,37 @@ +from unittest.mock import patch + +import pytest + +from baserow.contrib.automation.nodes.handler import AutomationNodeHandler +from baserow.contrib.automation.nodes.node_types import CoreManualTriggerNodeType +from baserow.core.services.types import DispatchResult + + +def _capture_context(captured): + """A node type `dispatch` that records the context it was handed.""" + + def dispatch(self, node, dispatch_context): + captured.append(dispatch_context) + return DispatchResult(data={}) + + return dispatch + + +@pytest.mark.django_db +def test_dispatch_node_does_not_act_as_who_started_the_run(data_fixture): + user = data_fixture.create_user() + workflow = data_fixture.create_automation_workflow( + user=user, trigger_type=CoreManualTriggerNodeType.type + ) + trigger = workflow.get_trigger() + history = data_fixture.create_automation_workflow_history( + workflow=workflow, triggered_by_id=user.id, triggered_by_type="auth.User" + ) + captured = [] + + with patch.object( + CoreManualTriggerNodeType, "dispatch", _capture_context(captured) + ): + AutomationNodeHandler().dispatch_node(trigger.id, history.id) + + assert [context.actor for context in captured] == [None] diff --git a/backend/tests/baserow/contrib/automation/workflows/test_workflow_handler.py b/backend/tests/baserow/contrib/automation/workflows/test_workflow_handler.py index 0e8428d609..e498807197 100644 --- a/backend/tests/baserow/contrib/automation/workflows/test_workflow_handler.py +++ b/backend/tests/baserow/contrib/automation/workflows/test_workflow_handler.py @@ -20,6 +20,9 @@ CorePeriodicTriggerNodeType, LocalBaserowRowsCreatedNodeTriggerType, ) +from baserow.contrib.automation.workflows.actions import ( + UpdateAutomationWorkflowActionType, +) from baserow.contrib.automation.workflows.constants import ( ALLOW_TEST_RUN_MINUTES, WORKFLOW_DIRTY_CACHE_KEY, @@ -33,9 +36,11 @@ AutomationWorkflowTooManyErrors, ) from baserow.contrib.automation.workflows.handler import AutomationWorkflowHandler +from baserow.core.action.handler import ActionHandler from baserow.core.cache import global_cache, local_cache from baserow.core.notifications.models import Notification, NotificationRecipient from baserow.core.registries import ImportExportConfig +from baserow.core.subjects import UserSubjectType from baserow.core.trash.handler import TrashHandler from tests.baserow.contrib.automation.history.utils import assert_history @@ -1946,3 +1951,232 @@ def test_async_start_workflow_test_run_creates_test_clone( mock_start_workflow_celery_task.delay.assert_called_once_with( history.workflow_id, history.id ) + + +@pytest.mark.django_db +def test_async_start_workflow_records_who_triggered_it(data_fixture): + user = data_fixture.create_user() + workflow = data_fixture.create_automation_workflow(user=user) + published = AutomationWorkflowHandler().publish(workflow) + + with patch( + "baserow.contrib.automation.workflows.handler.start_workflow_celery_task" + ): + AutomationWorkflowHandler().async_start_workflow(published, triggered_by=user) + + history = AutomationWorkflowHistory.objects.get(original_workflow=workflow) + assert history.triggered_by_id == user.id + + +@pytest.mark.django_db +def test_async_start_workflow_records_the_trigger_on_a_refused_run(data_fixture): + """The rate limited branch writes its own history row; it names the user too.""" + + user = data_fixture.create_user() + workflow = data_fixture.create_automation_workflow(user=user) + published = AutomationWorkflowHandler().publish(workflow) + + with patch.object( + AutomationWorkflowHandler, + "before_run", + side_effect=AutomationWorkflowRateLimited("too fast"), + ): + AutomationWorkflowHandler().async_start_workflow(published, triggered_by=user) + + history = AutomationWorkflowHistory.objects.get(original_workflow=workflow) + assert history.status == "error" + assert history.triggered_by_id == user.id + + +def _fire_rows_created_event(workflow, table): + trigger = workflow.get_trigger() + service = trigger.service.specific + trigger.get_type().on_event( + service.get_type().model_class.objects.filter(table=table), + [{"id": 1, "order": "1.00000000000000000000"}], + ) + + +@pytest.mark.django_db +def test_a_test_run_waiting_for_its_event_records_who_started_it(data_fixture): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + workflow = data_fixture.create_automation_workflow( + user, + trigger_type=LocalBaserowRowsCreatedNodeTriggerType.type, + trigger_service_kwargs={"table": table}, + ) + + with patch(f"{WORKFLOWS_MODULE}.handler.start_workflow_celery_task"): + AutomationWorkflowHandler().toggle_test_run( + workflow, simulate_until_node=None, triggered_by=user + ) + assert not AutomationWorkflowHistory.objects.filter( + original_workflow=workflow + ).exists() + + _fire_rows_created_event(workflow, table) + + history = AutomationWorkflowHistory.objects.get(original_workflow=workflow) + assert history.is_test_run is True + assert history.triggered_by_id == user.id + workflow.refresh_from_db() + assert workflow.test_run_triggered_by_id is None + + +@pytest.mark.django_db +def test_a_simulation_waiting_for_its_event_records_who_started_it(data_fixture): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + workflow = data_fixture.create_automation_workflow( + user, + trigger_type=LocalBaserowRowsCreatedNodeTriggerType.type, + trigger_service_kwargs={"table": table}, + ) + + with patch(f"{WORKFLOWS_MODULE}.handler.start_workflow_celery_task"): + AutomationWorkflowHandler().toggle_test_run( + workflow, simulate_until_node=workflow.get_trigger(), triggered_by=user + ) + assert not AutomationWorkflowHistory.objects.filter( + original_workflow=workflow + ).exists() + + _fire_rows_created_event(workflow, table) + + history = AutomationWorkflowHistory.objects.get(original_workflow=workflow) + assert history.simulate_until_node_id == workflow.get_trigger().id + assert history.triggered_by_id == user.id + workflow.refresh_from_db() + assert workflow.test_run_triggered_by_id is None + + +@pytest.mark.django_db +def test_cancelling_a_waiting_test_run_forgets_who_started_it(data_fixture): + user = data_fixture.create_user() + workflow = data_fixture.create_automation_workflow( + user, trigger_type=LocalBaserowRowsCreatedNodeTriggerType.type + ) + handler = AutomationWorkflowHandler() + + handler.toggle_test_run(workflow, simulate_until_node=None, triggered_by=user) + workflow.refresh_from_db() + assert workflow.test_run_triggered_by_id == user.id + + handler.toggle_test_run(workflow, simulate_until_node=None) + workflow.refresh_from_db() + assert workflow.test_run_triggered_by_id is None + assert handler.get_test_run_triggered_by(workflow) is None + + +@pytest.mark.django_db +def test_a_waiting_test_run_whose_starter_was_deleted_records_nobody(data_fixture): + user = data_fixture.create_user() + starter = data_fixture.create_user() + workflow = data_fixture.create_automation_workflow( + user, trigger_type=LocalBaserowRowsCreatedNodeTriggerType.type + ) + + AutomationWorkflowHandler().toggle_test_run( + workflow, simulate_until_node=None, triggered_by=starter + ) + starter.delete() + workflow.refresh_from_db() + + assert AutomationWorkflowHandler().get_test_run_triggered_by(workflow) is None + + +@pytest.mark.django_db +def test_opening_the_test_run_window_through_an_update_records_who_opened_it( + data_fixture, +): + first = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=first) + second = data_fixture.create_user(workspace=workspace) + automation = data_fixture.create_automation_application(workspace=workspace) + table = data_fixture.create_database_table(user=first) + workflow = data_fixture.create_automation_workflow( + first, + automation=automation, + trigger_type=LocalBaserowRowsCreatedNodeTriggerType.type, + trigger_service_kwargs={"table": table}, + ) + + AutomationWorkflowHandler().toggle_test_run( + workflow, simulate_until_node=None, triggered_by=first + ) + UpdateAutomationWorkflowActionType.do(first, workflow.id, {"allow_test_run": False}) + workflow.refresh_from_db() + assert workflow.test_run_triggered_by_id is None + + UpdateAutomationWorkflowActionType.do(second, workflow.id, {"allow_test_run": True}) + workflow.refresh_from_db() + assert workflow.test_run_triggered_by_id == second.id + + with patch(f"{WORKFLOWS_MODULE}.handler.start_workflow_celery_task"): + _fire_rows_created_event(workflow, table) + + history = AutomationWorkflowHistory.objects.get(original_workflow=workflow) + assert history.triggered_by_id == second.id + + +@pytest.mark.django_db +def test_resetting_temporary_states_forgets_the_starter_type(data_fixture): + user = data_fixture.create_user() + workflow = data_fixture.create_automation_workflow( + user, trigger_type=LocalBaserowRowsCreatedNodeTriggerType.type + ) + workflow.test_run_triggered_by_id = 1 + workflow.test_run_triggered_by_type = "core.Token" + workflow.save() + + AutomationWorkflowHandler().reset_workflow_temporary_states(workflow) + + workflow.refresh_from_db() + assert workflow.test_run_triggered_by_id is None + assert workflow.test_run_triggered_by_type == UserSubjectType.type + + +@pytest.mark.django_db +def test_scheduling_a_test_run_without_a_starter_forgets_an_earlier_one( + data_fixture, +): + user = data_fixture.create_user() + workflow = data_fixture.create_automation_workflow( + user, trigger_type=LocalBaserowRowsCreatedNodeTriggerType.type + ) + handler = AutomationWorkflowHandler() + workflow.test_run_triggered_by_id = user.id + workflow.save() + + handler.set_workflow_temporary_states(workflow) + + workflow.refresh_from_db() + assert workflow.test_run_triggered_by_id is None + + +@pytest.mark.django_db +@pytest.mark.undo_redo +def test_undoing_an_unrelated_update_keeps_the_test_run_starter(data_fixture): + first = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=first) + session_id = "undo-keeps-starter" + second = data_fixture.create_user(workspace=workspace, session_id=session_id) + automation = data_fixture.create_automation_application(workspace=workspace) + workflow = data_fixture.create_automation_workflow( + first, + automation=automation, + trigger_type=LocalBaserowRowsCreatedNodeTriggerType.type, + ) + AutomationWorkflowHandler().toggle_test_run( + workflow, simulate_until_node=None, triggered_by=first + ) + + UpdateAutomationWorkflowActionType.do(second, workflow.id, {"name": "Renamed"}) + ActionHandler.undo( + second, [UpdateAutomationWorkflowActionType.scope(automation.id)], session_id + ) + + workflow.refresh_from_db() + assert workflow.name != "Renamed" + assert workflow.test_run_triggered_by_id == first.id diff --git a/backend/tests/baserow/contrib/dashboard/data_sources/test_dashboard_dispatch_context.py b/backend/tests/baserow/contrib/dashboard/data_sources/test_dashboard_dispatch_context.py index 27f11bf3ef..9f862e4dc8 100644 --- a/backend/tests/baserow/contrib/dashboard/data_sources/test_dashboard_dispatch_context.py +++ b/backend/tests/baserow/contrib/dashboard/data_sources/test_dashboard_dispatch_context.py @@ -20,6 +20,8 @@ def test_dispatch_context_clone(data_fixture): assert new_dispatch_context.request is request assert new_dispatch_context.widget == widget assert new_dispatch_context.cache == {"key": "value"} + # Only contexts that take an actor carry it; the rest keep the base None. + assert new_dispatch_context.actor is None @pytest.mark.django_db diff --git a/backend/tests/baserow/contrib/database/workflow_actions/test_dispatch.py b/backend/tests/baserow/contrib/database/workflow_actions/test_dispatch.py index e04c21f021..b1f9d69309 100644 --- a/backend/tests/baserow/contrib/database/workflow_actions/test_dispatch.py +++ b/backend/tests/baserow/contrib/database/workflow_actions/test_dispatch.py @@ -8,6 +8,9 @@ from baserow.contrib.database.fields.handler import FieldHandler from baserow.contrib.database.rows.signals import rows_created from baserow.contrib.database.table.handler import TableHandler +from baserow.contrib.database.workflow_actions.actions import ( + DispatchButtonFieldActionType, +) from baserow.contrib.database.workflow_actions.exceptions import ( WorkflowActionDispatchError, WorkflowActionDispatchInProgress, @@ -23,6 +26,7 @@ from baserow.contrib.database.workflow_actions.service import ( DatabaseWorkflowActionService, ) +from baserow.core.action.signals import action_done from baserow.core.exceptions import PermissionException @@ -916,3 +920,108 @@ def test_a_later_action_reads_an_earlier_action_result(data_fixture): created = list(table.get_model().objects.exclude(id=row.id).order_by("id")) assert getattr(created[0], f"field_{name_field.id}") == "Ada" assert getattr(created[1], f"field_{name_field.id}") == f"Ada {created[0].id}" + + +@pytest.fixture +def audited_clicks(): + """The `action_params` and workspace of every button click registration.""" + + received = [] + + def receiver(sender, action_type, action_params, workspace, **kwargs): + if action_type is DispatchButtonFieldActionType: + received.append((action_params, workspace)) + + action_done.connect(receiver) + yield received + action_done.disconnect(receiver) + + +@pytest.mark.django_db +def test_a_click_sends_action_done_for_the_audit_log(data_fixture, audited_clicks): + user = data_fixture.create_user() + table, name_field = _table_with_name(data_fixture, user) + button_field = data_fixture.create_button_field(table=table, label="Go") + row = table.get_model().objects.create() + _create_row_action(data_fixture, button_field, table, name_field, "first") + + DatabaseWorkflowActionService().dispatch_workflow_actions(user, button_field, row) + + assert len(audited_clicks) == 1 + params, workspace = audited_clicks[0] + assert workspace == table.database.workspace + assert params["field_id"] == button_field.id + assert params["row_id"] == row.id + assert params["action_count"] == 1 + + +@pytest.mark.django_db +def test_a_refused_click_sends_nothing_to_the_audit_log(data_fixture, audited_clicks): + owner = data_fixture.create_user() + outsider = data_fixture.create_user() + table, name_field = _table_with_name(data_fixture, owner) + button_field = data_fixture.create_button_field(table=table, label="Go") + row = table.get_model().objects.create() + _create_row_action(data_fixture, button_field, table, name_field, "first") + + with pytest.raises(PermissionException): + DatabaseWorkflowActionService().dispatch_workflow_actions( + outsider, button_field, row + ) + + assert audited_clicks == [] + + +@pytest.mark.django_db +def test_a_click_refused_for_a_run_in_progress_is_not_audited( + data_fixture, audited_clicks +): + user = data_fixture.create_user() + table, name_field = _table_with_name(data_fixture, user) + button_field = data_fixture.create_button_field(table=table, label="Go") + row = table.get_model().objects.create() + _create_row_action(data_fixture, button_field, table, name_field, "first") + cache.add(f"button_dispatch_{button_field.id}_{row.id}", True, timeout=30) + + with pytest.raises(WorkflowActionDispatchInProgress): + DatabaseWorkflowActionService().dispatch_workflow_actions( + user, button_field, row + ) + + assert audited_clicks == [] + + +@pytest.mark.django_db +def test_a_click_with_only_client_actions_is_audited(data_fixture, audited_clicks): + user = data_fixture.create_user() + table, _ = _table_with_name(data_fixture, user) + button_field = data_fixture.create_button_field(table=table, label="Go") + row = table.get_model().objects.create() + data_fixture.create_database_workflow_action( + OpenUrlWorkflowAction, field=button_field + ) + + DatabaseWorkflowActionService().dispatch_workflow_actions(user, button_field, row) + + assert len(audited_clicks) == 1 + + +@pytest.mark.django_db +def test_a_click_that_fails_mid_sequence_is_still_audited(data_fixture, audited_clicks): + user = data_fixture.create_user() + table, name_field = _table_with_name(data_fixture, user) + button_field = data_fixture.create_button_field(table=table, label="Go") + row = table.get_model().objects.create() + _create_row_action(data_fixture, button_field, table, name_field, "first") + # A delete-row action with no table configured fails at dispatch. + data_fixture.create_database_workflow_action( + LocalBaserowDeleteRowWorkflowAction, field=button_field + ) + + with pytest.raises(WorkflowActionDispatchError): + DatabaseWorkflowActionService().dispatch_workflow_actions( + user, button_field, row + ) + + assert len(audited_clicks) == 1 + assert audited_clicks[0][0]["action_count"] == 2 diff --git a/backend/tests/baserow/contrib/database/workflow_actions/test_start_workflow_action.py b/backend/tests/baserow/contrib/database/workflow_actions/test_start_workflow_action.py index 29254f0c6a..4a3a48c9df 100644 --- a/backend/tests/baserow/contrib/database/workflow_actions/test_start_workflow_action.py +++ b/backend/tests/baserow/contrib/database/workflow_actions/test_start_workflow_action.py @@ -411,7 +411,7 @@ def test_a_click_starts_the_published_workflow(data_fixture): ) as async_start_workflow: DatabaseWorkflowActionService().dispatch_workflow_actions(user, field, row) - async_start_workflow.assert_called_once_with(published) + async_start_workflow.assert_called_once_with(published, triggered_by=user) @pytest.mark.django_db @@ -459,6 +459,7 @@ def test_a_click_through_the_api_queues_the_published_workflow( history = AutomationWorkflowHistory.objects.get(original_workflow=workflow) assert history.workflow_id == published.id assert history.status == "started" + assert history.triggered_by_id == clicker.id celery_task.delay.assert_called_once_with(published.id, history.id) diff --git a/backend/tests/baserow/contrib/integrations/core/test_core_periodic_service_type.py b/backend/tests/baserow/contrib/integrations/core/test_core_periodic_service_type.py index fa8e985217..09b819fee2 100644 --- a/backend/tests/baserow/contrib/integrations/core/test_core_periodic_service_type.py +++ b/backend/tests/baserow/contrib/integrations/core/test_core_periodic_service_type.py @@ -322,6 +322,7 @@ def test_call_multiple_periodic_services_that_are_due( "triggered_at": "2025-02-15T10:30:00+00:00", "next_run_at": "2025-02-15T10:31:00+00:00", }, + triggered_by=None, ), call( workflow_2, @@ -329,6 +330,7 @@ def test_call_multiple_periodic_services_that_are_due( "triggered_at": "2025-02-15T10:30:00+00:00", "next_run_at": "2025-02-15T10:31:00+00:00", }, + triggered_by=None, ), ] ) diff --git a/backend/tests/baserow/contrib/integrations/core/test_core_start_workflow_service_type.py b/backend/tests/baserow/contrib/integrations/core/test_core_start_workflow_service_type.py index dadf2342c4..466dcf6933 100644 --- a/backend/tests/baserow/contrib/integrations/core/test_core_start_workflow_service_type.py +++ b/backend/tests/baserow/contrib/integrations/core/test_core_start_workflow_service_type.py @@ -69,7 +69,7 @@ def test_start_workflow_service_dispatch_starts_configured_workflow(data_fixture ) as async_start_workflow: result = ServiceHandler().dispatch_service(service, fake_dispatch_context()) - async_start_workflow.assert_called_once_with(published_workflow) + async_start_workflow.assert_called_once_with(published_workflow, triggered_by=None) assert result.data is None @@ -125,7 +125,7 @@ def test_start_workflow_service_dispatch_starts_immediate_dispatch_workflow( ) as async_start_workflow: result = ServiceHandler().dispatch_service(service, fake_dispatch_context()) - async_start_workflow.assert_called_once_with(published_workflow) + async_start_workflow.assert_called_once_with(published_workflow, triggered_by=None) assert result.data is None @@ -220,3 +220,23 @@ def test_start_workflow_service_dispatch_without_workflow_raises(data_fixture): with pytest.raises(ServiceImproperlyConfiguredDispatchException): ServiceHandler().dispatch_service(service, fake_dispatch_context()) + + +@pytest.mark.django_db +def test_start_workflow_service_dispatch_names_the_context_actor(data_fixture): + user = data_fixture.create_user() + workflow = data_fixture.create_automation_workflow( + user=user, trigger_type=CoreManualTriggerNodeType.type + ) + published_workflow = AutomationWorkflowHandler().publish(workflow) + service = data_fixture.create_core_start_workflow_service(workflow=workflow) + dispatch_context = fake_dispatch_context() + dispatch_context.actor = user + + with patch( + "baserow.contrib.automation.workflows.handler." + "AutomationWorkflowHandler.async_start_workflow" + ) as async_start_workflow: + ServiceHandler().dispatch_service(service, dispatch_context) + + async_start_workflow.assert_called_once_with(published_workflow, triggered_by=user) diff --git a/backend/tests/baserow/core/service/test_service_type.py b/backend/tests/baserow/core/service/test_service_type.py index 7122b49e61..5aab77dbe5 100644 --- a/backend/tests/baserow/core/service/test_service_type.py +++ b/backend/tests/baserow/core/service/test_service_type.py @@ -369,7 +369,9 @@ def test_dispatch_context_actor_survives_clone(data_fixture): @pytest.mark.django_db -def test_dispatch_context_actor_survives_clone_without_own_properties(data_fixture): +def test_dispatch_context_without_actor_in_own_properties_clones_without_one( + data_fixture, +): from baserow.test_utils.pytest_conftest import FakeDispatchContext class StrictDispatchContext(FakeDispatchContext): @@ -380,10 +382,7 @@ class StrictDispatchContext(FakeDispatchContext): def __init__(self, context=None): super().__init__(context=context or {}) - user = data_fixture.create_user() - other_user = data_fixture.create_user() dispatch_context = StrictDispatchContext() - dispatch_context.actor = user + dispatch_context.actor = data_fixture.create_user() - assert dispatch_context.clone().actor == user - assert dispatch_context.clone(actor=other_user).actor == other_user + assert dispatch_context.clone().actor is None diff --git a/backend/tests/baserow/core/test_core_registry.py b/backend/tests/baserow/core/test_core_registry.py index 5713d6d31d..358de53d76 100644 --- a/backend/tests/baserow/core/test_core_registry.py +++ b/backend/tests/baserow/core/test_core_registry.py @@ -9,6 +9,7 @@ InstanceTypeAlreadyRegistered, InstanceTypeDoesNotExist, ) +from baserow.core.registries import subject_type_registry from baserow.core.registry import ( CustomFieldsInstanceMixin, CustomFieldsRegistryMixin, @@ -18,6 +19,7 @@ ModelRegistryMixin, Registry, ) +from baserow.core.subjects import AnonymousUserSubjectType, UserSubjectType class FakeModel(object): @@ -199,3 +201,23 @@ def test_get_serializer(data_fixture): serializer = registry.get_serializer(database, request=True) assert "order" in serializer.data + + +@pytest.mark.django_db +def test_get_subject_returns_the_stored_subject(data_fixture): + user = data_fixture.create_user() + + assert subject_type_registry.get_subject(UserSubjectType.type, user.id) == user + + +@pytest.mark.django_db +def test_get_subject_returns_none_for_a_deleted_subject(data_fixture): + user = data_fixture.create_user() + user_id = user.id + user.delete() + + assert subject_type_registry.get_subject(UserSubjectType.type, user_id) is None + + +def test_get_subject_returns_none_for_a_type_not_stored_in_the_database(): + assert subject_type_registry.get_subject(AnonymousUserSubjectType.type, 1) is None diff --git a/docs/decisions/006-button-field-workflow-actions.md b/docs/decisions/006-button-field-workflow-actions.md index cb273640ca..65e0967154 100644 --- a/docs/decisions/006-button-field-workflow-actions.md +++ b/docs/decisions/006-button-field-workflow-actions.md @@ -314,9 +314,10 @@ down to the services: - No integration attached, which is every database button in v1: the service authorizes and executes as the actor, and fails if there is none. - Integration present, which is the builder, automation, and any future opt-in: - `authorized_user` authorizes, unchanged. Also recording the actor for auditing in this - branch is future work owned by the builder and automation teams, since today's - pipeline carries a single user; nothing in v1 depends on it. + `authorized_user` authorizes, unchanged. Who started the run is recorded beside that, + not instead of it: the run's history carries the person as `triggered_by`, and the + dispatch context's `actor` stays empty, so a node that happens to have no integration + does not start acting as whoever clicked. Whether "no integration attached" is modeled as a nullable foreign key on the service or as a small purpose-built object with the same interface is an implementation choice, not @@ -405,6 +406,18 @@ for every other action type: the row actions, the HTTP request, the email and th message all act as the clicker or as this installation, and none of them borrows another user's reach. +The click itself is not anonymous, though. The run's history records the clicker as a +subject (`triggered_by_id`, `triggered_by_type` and `triggered_by_name`, so an agent can +start a run later and the name survives the user being deleted), and the click is +registered as a `dispatch_button_field` action, so the audit log holds who clicked which +button on which row. None of that changes who the nodes act as: the context's `actor` slot, which a Local +Baserow node without an integration would act as, stays empty for a run. A workflow that +another workflow starts records nobody, since the person is not a member of wherever +that second workflow lives. A test run or simulation started from the editor records +whoever pressed the button, including when it waits for its trigger's event: the +workflow keeps that subject beside its other temporary test state until the run starts +or is cancelled. + Not charging the button rate limit has one consequence worth stating plainly. When the automation module's own limits are what refuse a run, the clicker is not told: `async_start_workflow` catches the rate limit and the too-many-errors cases after dispatch @@ -563,6 +576,13 @@ the natural place to narrow this further when it is wanted. and leaves the actions as they were saved. Builder workflow actions are the same, and making either undoable needs a way to restore a deleted action with its service, which neither has. +- **Audit log.** A click registers one `dispatch_button_field` action after every + permission check has passed and before the first action runs, inside the lock when + there is one, so a click refused for permission or as already running leaves no entry + and a click that fails half way still does. A button with no actions returns before + registering anything, so its clicks leave no entry. A button with only client actions + takes no lock, so a double click on it is two entries. Clicks stay out of the undo + stack. - **Deleting a user.** Nothing breaks: actions run as whoever clicks, and v1 services have no integration, so no button depends on any particular account. - **Failure mid-sequence.** Execution stops, later actions are skipped, completed diff --git a/e2e-tests/fixtures/automation/automationWorkflow.ts b/e2e-tests/fixtures/automation/automationWorkflow.ts index c68d088281..9ae1f88138 100644 --- a/e2e-tests/fixtures/automation/automationWorkflow.ts +++ b/e2e-tests/fixtures/automation/automationWorkflow.ts @@ -1,4 +1,5 @@ import { getClient } from "../../client" +import { waitForJob } from "../job" import { Automation } from "./automation" export class AutomationWorkflow { @@ -25,3 +26,18 @@ export async function createAutomationWorkflow( automation, ) } + +/** + * Publishes a workflow and waits for the publish job to finish, so a button + * or a test run can start it the moment this returns. + */ +export async function publishAutomationWorkflow( + workflow: AutomationWorkflow, +): Promise { + const client = getClient(workflow.automation.workspace.user); + const job: any = await client.post( + `automation/workflows/${workflow.id}/publish/async/`, + {}, + ); + await waitForJob(client, job.data.id, `Publishing "${workflow.name}"`); +} diff --git a/e2e-tests/fixtures/database/field.ts b/e2e-tests/fixtures/database/field.ts index 29f03dd2ea..ca2d4093c8 100644 --- a/e2e-tests/fixtures/database/field.ts +++ b/e2e-tests/fixtures/database/field.ts @@ -1,4 +1,5 @@ import { getClient } from "../../client"; +import { waitForJob } from "../job"; import { User } from "../user"; import { Table } from "./table"; @@ -112,21 +113,10 @@ export async function duplicateField( { duplicate_data: options.copyData ?? false }, ); - const deadline = Date.now() + 30_000; - while (Date.now() < deadline) { - const poll: any = await client.get(`jobs/${job.data.id}/`); - if (poll.data.state === "failed") { - throw new Error( - `Duplicating "${field.name}" failed: ${ - poll.data.human_readable_error || "" - }`, - ); - } - if (poll.data.state === "finished") { - return poll.data.duplicated_field as Field; - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - - throw new Error(`Duplicating "${field.name}" did not finish in time`); + const finished = await waitForJob( + client, + job.data.id, + `Duplicating "${field.name}"`, + ); + return finished.duplicated_field as Field; } diff --git a/e2e-tests/fixtures/database/workflowAction.ts b/e2e-tests/fixtures/database/workflowAction.ts index cf3e19abc8..d1369625ba 100644 --- a/e2e-tests/fixtures/database/workflowAction.ts +++ b/e2e-tests/fixtures/database/workflowAction.ts @@ -120,6 +120,18 @@ export async function createDeleteRowAction( }); } +/** An action that starts a published automation workflow by id. */ +export async function createStartWorkflowAction( + user: User, + buttonField: Field, + workflowId: number, +): Promise { + const action = await createWorkflowAction(user, buttonField, "start_workflow"); + return updateWorkflowAction(user, action, { + service: { workflow_id: workflowId }, + }); +} + /** An action the browser runs itself, rather than the dispatch running it. */ export async function createOpenUrlAction( user: User, diff --git a/e2e-tests/fixtures/job.ts b/e2e-tests/fixtures/job.ts new file mode 100644 index 0000000000..f62c555afe --- /dev/null +++ b/e2e-tests/fixtures/job.ts @@ -0,0 +1,28 @@ +import { AxiosInstance } from "axios"; + +/** + * Polls an async job until it finishes and returns its final payload, or + * throws with the job's own error when it fails or runs out of time. + */ +export async function waitForJob( + client: AxiosInstance, + jobId: number, + description: string, + { timeoutMs = 30_000, intervalMs = 500 } = {} +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const poll: any = await client.get(`jobs/${jobId}/`); + if (poll.data.state === "failed") { + throw new Error( + `${description} failed: ${poll.data.human_readable_error || ""}` + ); + } + if (poll.data.state === "finished") { + return poll.data; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error(`${description} did not finish in time`); +} diff --git a/e2e-tests/pages/baserowPage.ts b/e2e-tests/pages/baserowPage.ts index 30aa064b71..28306f7446 100644 --- a/e2e-tests/pages/baserowPage.ts +++ b/e2e-tests/pages/baserowPage.ts @@ -22,6 +22,10 @@ export class BaserowPage { async authenticate(user: User) { await this.page.goto(`${this.baseUrl}?token=${user.refreshToken}`); await this.recoverFromNuxtError(); + // The AI panel would otherwise open over the page header on every visit. + await this.page.evaluate(() => { + localStorage.setItem("baserow.rightSidebarOpen", "false"); + }); } async goto(params = {}) { diff --git a/e2e-tests/pages/workspacePage.ts b/e2e-tests/pages/workspacePage.ts index 13d7afc1fe..912f450c4c 100644 --- a/e2e-tests/pages/workspacePage.ts +++ b/e2e-tests/pages/workspacePage.ts @@ -17,8 +17,7 @@ export class WorkspacePage extends BaserowPage { } async authenticate() { - await this.page.goto(`${this.baseUrl}?token=${this.user.refreshToken}`); - await this.recoverFromNuxtError(); + return super.authenticate(this.user); } getFullUrl() { diff --git a/e2e-tests/tests/baserowTest.ts b/e2e-tests/tests/baserowTest.ts index 06f8b21ea2..3c8fa76216 100644 --- a/e2e-tests/tests/baserowTest.ts +++ b/e2e-tests/tests/baserowTest.ts @@ -37,11 +37,6 @@ export const test = base.extend({ const workspacePage = new WorkspacePage({ page, goto }, user, workspace); await workspacePage.authenticate(); - await page.evaluate(() => { - // Prevent the AI panel to automatically open in all tests - localStorage.setItem("baserow.rightSidebarOpen", "false"); - }); - // Use the fixture value in the test. await use(workspacePage); diff --git a/e2e-tests/tests/database/button_field_start_workflow.spec.ts b/e2e-tests/tests/database/button_field_start_workflow.spec.ts new file mode 100644 index 0000000000..791d1bcedb --- /dev/null +++ b/e2e-tests/tests/database/button_field_start_workflow.spec.ts @@ -0,0 +1,179 @@ +/** + * Button field, start workflow action: a real click, the real worker, and a + * builder reading "Started by" in the History panel. + */ + +import { Page } from "@playwright/test"; +import { test, expect } from "../baserowTest"; +import { GridPage } from "../../pages/database/gridPage"; +import { AutomationWorkflowPage } from "../../pages/automation/automationWorkflowPage"; +import { PageConfig } from "../../pages/baserowPage"; +import { + addAction, + openFieldEditor, + saveField, +} from "../../pages/database/buttonFieldEditor"; +import { setupGrid, GridSetupResult } from "../../fixtures/database/gridSetup"; +import { + createStartWorkflowAction, + listWorkflowActions, +} from "../../fixtures/database/workflowAction"; +import { createAutomation } from "../../fixtures/automation/automation"; +import { + AutomationWorkflow, + createAutomationWorkflow, + publishAutomationWorkflow, +} from "../../fixtures/automation/automationWorkflow"; +import { createAutomationNode } from "../../fixtures/automation/automationNode"; +import { User, createUser } from "../../fixtures/user"; +import { addUserToWorkspace } from "../../fixtures/workspace"; + +// Index of Start in the right-hand section; the first field `beforeAll` creates. +const START_FIELD_INDEX = 0; + +let g: GridSetupResult; +let workflow: AutomationWorkflow; +/** A workflow only an event can start, so the picker must leave it out. */ +let eventWorkflow: AutomationWorkflow; +/** A member of the workspace who can click the button but not open the automation. */ +let editor: User; + +/** The builder, who owns the automation, on the workflow page. */ +async function builderOnWorkflow(page: Page, goto: PageConfig["goto"]) { + const workflowPage = new AutomationWorkflowPage( + { page, goto }, + workflow.automation, + workflow + ); + await workflowPage.authenticate(g.user); + await workflowPage.goto(); + return workflowPage; +} + +async function openHistoryPanel(page: Page) { + await page.locator('[data-item-type="history"]').click(); + await expect(page.locator(".history-side-panel__title")).toBeVisible(); +} + +/** The history entries in the panel, newest first, as the panel orders them. */ +function historyEntries(page: Page) { + return page.locator(".workflow-history__header"); +} + +test.describe("Button field, start workflow action", () => { + test.beforeAll(async () => { + g = await setupGrid({ + dbName: "Start workflow DB", + tableName: "Jobs", + fields: [ + { name: "Start", type: "button", settings: { label: "Start" } }, + { name: "Configure", type: "button", settings: { label: "Configure" } }, + ], + rows: [{ Name: "row one" }], + }); + + const automation = await createAutomation( + "Start workflow automation", + g.database.workspace + ); + workflow = await createAutomationWorkflow("Started by button", automation); + await createAutomationNode(workflow, "manual"); + await publishAutomationWorkflow(workflow); + eventWorkflow = await createAutomationWorkflow( + "Waits for rows", + automation + ); + await createAutomationNode(eventWorkflow, "local_baserow_rows_created"); + await createStartWorkflowAction( + g.user, + g.fieldByName["Start"], + workflow.id + ); + + editor = await createUser(); + await addUserToWorkspace(g.user, g.database.workspace, editor, "MEMBER"); + }); + + test("a click by an editor shows up as started by them in the workflow history", async ({ + page, + browser, + goto, + }) => { + // The editor clicks, in their own browser. + const editorContext = await browser.newContext(); + try { + const editorPage = await editorContext.newPage(); + const grid = new GridPage(editorPage, editor); + await grid.goTo(g.database, g.table); + await grid.fieldCellAt(0, START_FIELD_INDEX).locator("button").click(); + // The cell settles once the dispatch has returned. + await expect( + grid.fieldCellAt(0, START_FIELD_INDEX).locator("button") + ).toBeEnabled(); + } finally { + await editorContext.close(); + } + + // The builder, who owns the automation, reads the run's history. + await builderOnWorkflow(page, goto); + await openHistoryPanel(page); + + const entry = historyEntries(page).first(); + await expect( + entry.locator("..").locator(".workflow-history__started-by") + ).toHaveText(`Started by ${editor.name}`); + // Not a test run: no prefix on the title. The worker in the e2e stack + // runs the workflow for real, so it also completes. + await expect(entry.locator(".workflow-history__header-title")).toHaveText( + "Ran successfully" + ); + }); + + test("a test run from the editor is started by whoever pressed the button", async ({ + page, + goto, + }) => { + await builderOnWorkflow(page, goto); + + await page.locator('[data-highlight="automation-test-run"]').click(); + await openHistoryPanel(page); + + const entry = historyEntries(page).first(); + await expect( + entry.locator(".workflow-history__header-title") + ).toContainText("[Test]"); + await expect( + entry.locator("..").locator(".workflow-history__started-by") + ).toHaveText(`Started by ${g.user.name}`); + }); + + test("the editor offers only workflows a click can start, and saves the pick", async ({ + page, + }) => { + const grid = new GridPage(page, g.user); + await grid.goTo(g.database, g.table); + await openFieldEditor(page, "Configure"); + const added = await addAction(page, "Start workflow"); + + await added + .locator(".button-field-action-list__form .dropdown") + .first() + .click(); + const items = page.locator(".dropdown__items:visible"); + await expect(items).toContainText(workflow.name); + // A rows-created trigger cannot start on demand, so it is not on offer. + await expect(items).not.toContainText(eventWorkflow.name); + await items.getByText(workflow.name).click(); + + await saveField(page); + await expect(page.locator(".button-field-action-list")).toBeHidden(); + + const actions = await listWorkflowActions( + g.user, + g.fieldByName["Configure"] + ); + const saved = actions.find((action) => action.type === "start_workflow"); + expect(saved, "the start workflow action was not saved").toBeDefined(); + expect(saved.service.workflow_id).toBe(workflow.id); + }); +}); diff --git a/e2e-tests/tests/database/realtimeReplay.spec.ts b/e2e-tests/tests/database/realtimeReplay.spec.ts index 5718f4f293..022e769adf 100644 --- a/e2e-tests/tests/database/realtimeReplay.spec.ts +++ b/e2e-tests/tests/database/realtimeReplay.spec.ts @@ -73,10 +73,6 @@ async function setupRealtimeReplayScenario( const workspacePage = new WorkspacePage(pageConfig, user, workspace); await workspacePage.authenticate(); - await page.evaluate(() => { - localStorage.setItem("baserow.rightSidebarOpen", "false"); - }); - const tablePage = new TablePage(pageConfig); await tablePage.goToTable(table); await tablePage.waitForLoadingOverlayToDisappear(); diff --git a/web-frontend/modules/automation/components/workflow/sidePanels/WorkflowHistory.vue b/web-frontend/modules/automation/components/workflow/sidePanels/WorkflowHistory.vue index 6c04f224d4..04c034c327 100644 --- a/web-frontend/modules/automation/components/workflow/sidePanels/WorkflowHistory.vue +++ b/web-frontend/modules/automation/components/workflow/sidePanels/WorkflowHistory.vue @@ -21,6 +21,9 @@ type="secondary" /> +
+ {{ $t('historySidePanel.startedBy', { name: triggeredByName }) }} +