diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py index 3ff756589d..fe90e5dcf6 100644 --- a/backend/src/baserow/config/settings/base.py +++ b/backend/src/baserow/config/settings/base.py @@ -27,7 +27,6 @@ try_float, try_int, ) -from baserow.core.feature_flags import FF_AUTOMATION from baserow.core.telemetry.utils import otel_is_enabled from baserow.throttling_types import RateLimit from baserow.version import VERSION @@ -1138,11 +1137,10 @@ def __setitem__(self, key, value): "write_field_values", "role", "basic", + "automation_workflow", + "automation_node", ] -if "*" in FEATURE_FLAGS or FF_AUTOMATION.lower() in FEATURE_FLAGS: - PERMISSION_MANAGERS.extend(["automation_workflow", "automation_node"]) - if "baserow_enterprise" not in INSTALLED_APPS: PERMISSION_MANAGERS.remove("write_field_values") PERMISSION_MANAGERS.remove("role") diff --git a/backend/src/baserow/contrib/automation/apps.py b/backend/src/baserow/contrib/automation/apps.py index 58e56543ff..6d3edfeb87 100644 --- a/backend/src/baserow/contrib/automation/apps.py +++ b/backend/src/baserow/contrib/automation/apps.py @@ -1,7 +1,5 @@ from django.apps import AppConfig -from baserow.core.feature_flags import FF_AUTOMATION, feature_flag_is_enabled - class AutomationConfig(AppConfig): name = "baserow.contrib.automation" @@ -102,122 +100,112 @@ def ready(self): ) from baserow.core.trash.registries import trash_item_type_registry - if feature_flag_is_enabled(FF_AUTOMATION): - application_type_registry.register(AutomationApplicationType()) - - object_scope_type_registry.register(AutomationObjectScopeType()) - object_scope_type_registry.register(AutomationWorkflowObjectScopeType()) - object_scope_type_registry.register(AutomationNodeObjectScopeType()) - - operation_type_registry.register(CreateAutomationWorkflowOperationType()) - operation_type_registry.register(DeleteAutomationWorkflowOperationType()) - operation_type_registry.register(DuplicateAutomationWorkflowOperationType()) - operation_type_registry.register(ReadAutomationWorkflowOperationType()) - operation_type_registry.register(UpdateAutomationWorkflowOperationType()) - operation_type_registry.register(ListAutomationWorkflowsOperationType()) - operation_type_registry.register(OrderAutomationWorkflowsOperationType()) - operation_type_registry.register(RestoreAutomationWorkflowOperationType()) - operation_type_registry.register(PublishAutomationWorkflowOperationType()) - operation_type_registry.register(ListAutomationNodeOperationType()) - operation_type_registry.register(CreateAutomationNodeOperationType()) - operation_type_registry.register(UpdateAutomationNodeOperationType()) - operation_type_registry.register(ReadAutomationNodeOperationType()) - operation_type_registry.register(DeleteAutomationNodeOperationType()) - operation_type_registry.register(RestoreAutomationNodeOperationType()) - operation_type_registry.register(DuplicateAutomationNodeOperationType()) - operation_type_registry.register(OrderAutomationNodeOperationType()) - - job_type_registry.register(DuplicateAutomationWorkflowJobType()) - job_type_registry.register(PublishAutomationWorkflowJobType()) - - trash_item_type_registry.register(AutomationTrashableItemType()) - trash_item_type_registry.register(AutomationWorkflowTrashableItemType()) - trash_item_type_registry.register(AutomationNodeTrashableItemType()) - - action_type_registry.register(CreateAutomationWorkflowActionType()) - action_type_registry.register(UpdateAutomationWorkflowActionType()) - action_type_registry.register(DeleteAutomationWorkflowActionType()) - action_type_registry.register(DuplicateAutomationWorkflowActionType()) - action_type_registry.register(OrderAutomationWorkflowActionType()) - action_type_registry.register(CreateAutomationNodeActionType()) - action_type_registry.register(UpdateAutomationNodeActionType()) - action_type_registry.register(DeleteAutomationNodeActionType()) - action_type_registry.register(DuplicateAutomationNodeActionType()) - action_type_registry.register(ReplaceAutomationNodeActionType()) - action_type_registry.register(MoveAutomationNodeActionType()) - - action_scope_registry.register(WorkflowActionScopeType()) - - automation_node_type_registry.register(LocalBaserowCreateRowNodeType()) - automation_node_type_registry.register(LocalBaserowUpdateRowNodeType()) - automation_node_type_registry.register(LocalBaserowDeleteRowNodeType()) - automation_node_type_registry.register(LocalBaserowGetRowNodeType()) - automation_node_type_registry.register(LocalBaserowListRowsNodeType()) - automation_node_type_registry.register(LocalBaserowAggregateRowsNodeType()) - automation_node_type_registry.register(CoreHttpRequestNodeType()) - automation_node_type_registry.register(CoreIteratorNodeType()) - automation_node_type_registry.register(CoreSMTPEmailNodeType()) - automation_node_type_registry.register(CoreRouterActionNodeType()) - automation_node_type_registry.register( - LocalBaserowRowsCreatedNodeTriggerType() - ) - automation_node_type_registry.register( - LocalBaserowRowsUpdatedNodeTriggerType() - ) - automation_node_type_registry.register( - LocalBaserowRowsDeletedNodeTriggerType() - ) - automation_node_type_registry.register(CorePeriodicTriggerNodeType()) - automation_node_type_registry.register(SlackWriteMessageActionNodeType()) - automation_node_type_registry.register(CoreHTTPTriggerNodeType()) - automation_node_type_registry.register(AIAgentActionNodeType()) - - from baserow.core.trash.registries import trash_operation_type_registry - - trash_operation_type_registry.register( - ReplaceAutomationNodeTrashOperationType() - ) - - from baserow.contrib.automation.data_providers.data_provider_types import ( - CurrentIterationDataProviderType, - PreviousNodeProviderType, - ) - from baserow.contrib.automation.data_providers.registries import ( - automation_data_provider_type_registry, - ) - - automation_data_provider_type_registry.register(PreviousNodeProviderType()) - automation_data_provider_type_registry.register( - CurrentIterationDataProviderType() - ) - - from baserow.contrib.automation.nodes.permission_manager import ( - AutomationNodePermissionManager, - ) - from baserow.contrib.automation.workflows.permission_manager import ( - AutomationWorkflowPermissionManager, - ) - from baserow.core.registries import permission_manager_type_registry - - permission_manager_type_registry.register( - AutomationWorkflowPermissionManager() - ) - permission_manager_type_registry.register(AutomationNodePermissionManager()) - - # The signals must always be imported last because they use - # the registries which need to be filled first. - import baserow.contrib.automation.nodes.ws.signals # noqa: F403, F401 - import baserow.contrib.automation.workflows.signals # noqa: F403, F401 - import baserow.contrib.automation.workflows.ws.signals # noqa: F403, F401 - import baserow.contrib.integrations.tasks # noqa: F403, F401 - from baserow.contrib.automation.nodes.receivers import ( - connect_to_node_pre_delete_signal, - ) - - connect_to_node_pre_delete_signal() + application_type_registry.register(AutomationApplicationType()) + + object_scope_type_registry.register(AutomationObjectScopeType()) + object_scope_type_registry.register(AutomationWorkflowObjectScopeType()) + object_scope_type_registry.register(AutomationNodeObjectScopeType()) + + operation_type_registry.register(CreateAutomationWorkflowOperationType()) + operation_type_registry.register(DeleteAutomationWorkflowOperationType()) + operation_type_registry.register(DuplicateAutomationWorkflowOperationType()) + operation_type_registry.register(ReadAutomationWorkflowOperationType()) + operation_type_registry.register(UpdateAutomationWorkflowOperationType()) + operation_type_registry.register(ListAutomationWorkflowsOperationType()) + operation_type_registry.register(OrderAutomationWorkflowsOperationType()) + operation_type_registry.register(RestoreAutomationWorkflowOperationType()) + operation_type_registry.register(PublishAutomationWorkflowOperationType()) + operation_type_registry.register(ListAutomationNodeOperationType()) + operation_type_registry.register(CreateAutomationNodeOperationType()) + operation_type_registry.register(UpdateAutomationNodeOperationType()) + operation_type_registry.register(ReadAutomationNodeOperationType()) + operation_type_registry.register(DeleteAutomationNodeOperationType()) + operation_type_registry.register(RestoreAutomationNodeOperationType()) + operation_type_registry.register(DuplicateAutomationNodeOperationType()) + operation_type_registry.register(OrderAutomationNodeOperationType()) + + job_type_registry.register(DuplicateAutomationWorkflowJobType()) + job_type_registry.register(PublishAutomationWorkflowJobType()) + + trash_item_type_registry.register(AutomationTrashableItemType()) + trash_item_type_registry.register(AutomationWorkflowTrashableItemType()) + trash_item_type_registry.register(AutomationNodeTrashableItemType()) + + action_type_registry.register(CreateAutomationWorkflowActionType()) + action_type_registry.register(UpdateAutomationWorkflowActionType()) + action_type_registry.register(DeleteAutomationWorkflowActionType()) + action_type_registry.register(DuplicateAutomationWorkflowActionType()) + action_type_registry.register(OrderAutomationWorkflowActionType()) + action_type_registry.register(CreateAutomationNodeActionType()) + action_type_registry.register(UpdateAutomationNodeActionType()) + action_type_registry.register(DeleteAutomationNodeActionType()) + action_type_registry.register(DuplicateAutomationNodeActionType()) + action_type_registry.register(ReplaceAutomationNodeActionType()) + action_type_registry.register(MoveAutomationNodeActionType()) + + action_scope_registry.register(WorkflowActionScopeType()) + + automation_node_type_registry.register(LocalBaserowCreateRowNodeType()) + automation_node_type_registry.register(LocalBaserowUpdateRowNodeType()) + automation_node_type_registry.register(LocalBaserowDeleteRowNodeType()) + automation_node_type_registry.register(LocalBaserowGetRowNodeType()) + automation_node_type_registry.register(LocalBaserowListRowsNodeType()) + automation_node_type_registry.register(LocalBaserowAggregateRowsNodeType()) + automation_node_type_registry.register(CoreHttpRequestNodeType()) + automation_node_type_registry.register(CoreIteratorNodeType()) + automation_node_type_registry.register(CoreSMTPEmailNodeType()) + automation_node_type_registry.register(CoreRouterActionNodeType()) + automation_node_type_registry.register(LocalBaserowRowsCreatedNodeTriggerType()) + automation_node_type_registry.register(LocalBaserowRowsUpdatedNodeTriggerType()) + automation_node_type_registry.register(LocalBaserowRowsDeletedNodeTriggerType()) + automation_node_type_registry.register(CorePeriodicTriggerNodeType()) + automation_node_type_registry.register(CoreHTTPTriggerNodeType()) + automation_node_type_registry.register(AIAgentActionNodeType()) + automation_node_type_registry.register(SlackWriteMessageActionNodeType()) + + from baserow.core.trash.registries import trash_operation_type_registry + + trash_operation_type_registry.register( + ReplaceAutomationNodeTrashOperationType() + ) - from baserow.core.search.registries import workspace_search_registry + from baserow.contrib.automation.data_providers.data_provider_types import ( + CurrentIterationDataProviderType, + PreviousNodeProviderType, + ) + from baserow.contrib.automation.data_providers.registries import ( + automation_data_provider_type_registry, + ) + + automation_data_provider_type_registry.register(PreviousNodeProviderType()) + automation_data_provider_type_registry.register( + CurrentIterationDataProviderType() + ) - from .search_types import AutomationSearchType + from baserow.contrib.automation.nodes.permission_manager import ( + AutomationNodePermissionManager, + ) + from baserow.contrib.automation.workflows.permission_manager import ( + AutomationWorkflowPermissionManager, + ) + from baserow.core.registries import permission_manager_type_registry + + permission_manager_type_registry.register(AutomationWorkflowPermissionManager()) + permission_manager_type_registry.register(AutomationNodePermissionManager()) + + # The signals must always be imported last because they use + # the registries which need to be filled first. + import baserow.contrib.automation.nodes.ws.signals # noqa: F403, F401 + import baserow.contrib.automation.workflows.signals # noqa: F403, F401 + import baserow.contrib.automation.workflows.ws.signals # noqa: F403, F401 + import baserow.contrib.integrations.tasks # noqa: F403, F401 + from baserow.contrib.automation.nodes.receivers import ( + connect_to_node_pre_delete_signal, + ) + + connect_to_node_pre_delete_signal() + + from baserow.contrib.automation.search_types import AutomationSearchType + from baserow.core.search.registries import workspace_search_registry workspace_search_registry.register(AutomationSearchType()) diff --git a/backend/src/baserow/contrib/automation/migrations/0001_squashed_0023_initial.py b/backend/src/baserow/contrib/automation/migrations/0001_squashed_0023_initial.py new file mode 100644 index 0000000000..bd92cd1c39 --- /dev/null +++ b/backend/src/baserow/contrib/automation/migrations/0001_squashed_0023_initial.py @@ -0,0 +1,891 @@ +# Generated by Django 5.0.14 on 2025-11-13 16:29 + +import django.db.migrations.operations.special +import django.db.models.deletion +from django.db import migrations, models + +import baserow.contrib.automation.nodes.models +import baserow.core.fields +import baserow.core.mixins + + +# baserow.contrib.automation.migrations.0013_apply_previous_node_ids +def apply_previous_node_ids_0013_forward(apps, schema_editor): + from loguru import logger + + AutomationNode = apps.get_model("automation", "AutomationNode") + AutomationWorkflow = apps.get_model("automation", "AutomationWorkflow") + + automation_node_updates = [] + workflows = AutomationWorkflow.objects.all() + for workflow in workflows: + nodes = AutomationNode.objects.filter(workflow=workflow).order_by("order") + for index, node in enumerate(nodes): + if node.previous_node_id is None: + node.previous_node_id = nodes[index - 1].id if index > 0 else None + automation_node_updates.append(node) + else: + # If the previous_node_id is already set, we don't want to change it. + # The migration is unexpectedly being run on an instance that has + # already been migrated. + logger.debug( + f"Skipping node {node.id} with previous_node_id " + f"{node.previous_node_id}, unexpected state found." + ) + continue + + AutomationNode.objects.bulk_update(automation_node_updates, ["previous_node_id"]) + + +# baserow.contrib.automation.migrations.0017_remove_automationworkflow_disabled_on_and_more +def fill_state_0017_forwards(apps, schema_editor): + from baserow.contrib.automation.workflows.constants import WorkflowState + + AutomationWorkflow = apps.get_model("automation", "AutomationWorkflow") + + for workflow in AutomationWorkflow.objects.only( + "id", "published", "paused", "disabled_on" + ): + new_state = WorkflowState.DRAFT + if workflow.disabled_on: + new_state = WorkflowState.DISABLED + elif workflow.paused: + new_state = WorkflowState.PAUSED + elif workflow.published: + new_state = WorkflowState.LIVE + + AutomationWorkflow.objects.filter(id=workflow.id).update(state=new_state) + + +# baserow.contrib.automation.migrations.0021_coreiteratoractionnode_alter_automationnode_options_and_more +def graph_migration_0021_forward(apps, schema_editor): + Workflow = apps.get_model("automation", "automationworkflow") + AutomationNode = apps.get_model("automation", "automationnode") + + def _find(list_, predicate): + return next((n for n in list_ if predicate(n)), None) + + def _add_node_to_graph(graph, nodes, current_node): + graph[str(current_node.id)] = {} + + next_nodes = [n for n in nodes if n.previous_node_id == current_node.id] + if next_nodes: + graph[str(current_node.id)]["next"] = { + n.previous_node_output: [n.id] for n in next_nodes + } + + for next_node in next_nodes: + _add_node_to_graph(graph, nodes, next_node) + + all_nodes = list(AutomationNode.objects.filter(trashed=False)) + + for workflow in Workflow.objects.all(): + graph = {} + + nodes = [n for n in all_nodes if n.workflow_id == workflow.id] + trigger = _find(nodes, lambda n: n.previous_node_id is None) + + if trigger: + graph["0"] = trigger.id + _add_node_to_graph(graph, nodes, trigger) + + workflow.graph = graph + workflow.save(update_fields=["graph"]) + + +# baserow.contrib.automation.migrations.0021_coreiteratoractionnode_alter_automationnode_options_and_more +def graph_migration_0021_reverse(apps, schema_editor): + Workflow = apps.get_model("automation", "automationworkflow") + AutomationNode = apps.get_model("automation", "automationnode") + + for workflow in Workflow.objects.all(): + graph = workflow.graph + for key, info in graph.items(): + if key == "0": + continue + for output, nodes in info.get("next", {}).items(): + AutomationNode.objects.filter(id__in=nodes).update( + previous_node_id=key, previous_node_output=output + ) + + +class Migration(migrations.Migration): + replaces = [ + ("automation", "0001_initial"), + ("automation", "0002_automationworkflow_duplicateautomationworkflowjob"), + ("automation", "0003_automationnode_localbaserowcreaterowactionnode_and_more"), + ("automation", "0004_recreate_models"), + ("automation", "0005_rows_updated_deleted_triggers"), + ("automation", "0006_automationworkflow_allow_test_run_until"), + ("automation", "0007_localbaserowupdaterowactionnode"), + ("automation", "0008_localbaserowdeleterowactionnode"), + ("automation", "0009_corehttprequestactionnode"), + ("automation", "0010_automation_published_from_and_more"), + ("automation", "0011_coresmtpemailactionnode"), + ("automation", "0012_get_list_aggregate_rows_nodes"), + ("automation", "0013_apply_previous_node_ids"), + ("automation", "0014_automationworkflowhistory"), + ("automation", "0015_add_node_label"), + ("automation", "0016_corerouteractionnode"), + ("automation", "0017_remove_automationworkflow_disabled_on_and_more"), + ("automation", "0018_automationworkflow_simulate_until_node"), + ("automation", "0019_coreperiodictriggernode"), + ("automation", "0020_corehttptriggernode"), + ( + "automation", + "0021_coreiteratoractionnode_alter_automationnode_options_and_more", + ), + ("automation", "0022_aiagentactionnode"), + ("automation", "0023_slack_write_message_node"), + ] + + initial = True + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("core", "0094_alter_importexportresource_size"), + ("core", "0097_userprofile_completed_guided_tours"), + ("core", "0098_alter_userfile_size"), + ("core", "0099_mcpendpoint"), + ("core", "0100_auto_20250610_1917"), + ] + + operations = [ + migrations.CreateModel( + name="Automation", + fields=[ + ( + "application_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="core.application", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("core.application",), + ), + migrations.CreateModel( + name="AutomationWorkflow", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", baserow.core.fields.SyncedDateTimeField(auto_now=True)), + ("trashed", models.BooleanField(db_index=True, default=False)), + ("name", models.CharField(max_length=255)), + ("order", models.PositiveIntegerField()), + ( + "automation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workflows", + to="automation.automation", + ), + ), + ( + "published", + models.BooleanField( + default=False, help_text="Whether the workflow is published." + ), + ), + ("allow_test_run_until", models.DateTimeField(blank=True, null=True)), + ("disabled_on", models.DateTimeField(blank=True, null=True)), + ( + "paused", + models.BooleanField( + default=False, + help_text="Whether the published workflow is paused.", + ), + ), + ], + options={ + "ordering": ("order",), + "unique_together": {("automation", "name")}, + }, + bases=(models.Model, baserow.core.mixins.OrderableMixin), + ), + migrations.CreateModel( + name="DuplicateAutomationWorkflowJob", + fields=[ + ( + "job_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="core.job", + ), + ), + ( + "user_ip_address", + models.GenericIPAddressField( + help_text="The user IP address.", null=True + ), + ), + ( + "user_websocket_id", + models.CharField( + help_text="The user websocket uuid needed to manage signals sent correctly.", + max_length=36, + null=True, + ), + ), + ( + "user_session_id", + models.CharField( + help_text="The user session uuid needed for undo/redo functionality.", + max_length=36, + null=True, + ), + ), + ( + "user_action_group_id", + models.CharField( + help_text="The user session uuid needed for undo/redo action group functionality.", + max_length=36, + null=True, + ), + ), + ( + "duplicated_automation_workflow", + models.OneToOneField( + help_text="The duplicated automation workflow.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="duplicated_from_jobs", + to="automation.automationworkflow", + ), + ), + ( + "original_automation_workflow", + models.ForeignKey( + help_text="The automation workflow to duplicate.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="duplicated_by_jobs", + to="automation.automationworkflow", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("core.job", models.Model), + ), + migrations.CreateModel( + name="AutomationNode", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", baserow.core.fields.SyncedDateTimeField(auto_now=True)), + ("trashed", models.BooleanField(db_index=True, default=False)), + ( + "order", + models.DecimalField( + decimal_places=20, + default=1, + editable=False, + help_text="Lowest first.", + max_digits=40, + ), + ), + ("previous_node_output", models.CharField(default="")), + ( + "content_type", + models.ForeignKey( + on_delete=models.SET( + baserow.contrib.automation.nodes.models.get_default_node_content_type + ), + related_name="automation_workflow_node_content_types", + to="contenttypes.contenttype", + verbose_name="content type", + ), + ), + ( + "parent_node", + models.ForeignKey( + blank=True, + help_text="The parent automation node.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="automation_workflow_child_nodes", + to="automation.automationnode", + ), + ), + ( + "previous_node", + models.ForeignKey( + blank=True, + help_text="The previous automation node.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="automation_workflow_previous_nodes", + to="automation.automationnode", + ), + ), + ( + "workflow", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="automation_workflow_nodes", + to="automation.automationworkflow", + ), + ), + ( + "service", + models.OneToOneField( + help_text="The service which this node is associated with.", + on_delete=django.db.models.deletion.CASCADE, + related_name="automation_workflow_node", + to="core.service", + ), + ), + ], + options={ + "ordering": ("order", "id"), + }, + bases=( + models.Model, + baserow.core.mixins.FractionOrderableMixin, + baserow.core.mixins.PolymorphicContentTypeMixin, + baserow.core.mixins.WithRegistry, + ), + ), + migrations.CreateModel( + name="LocalBaserowCreateRowActionNode", + fields=[ + ( + "automationnode_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="automation.automationnode", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("automation.automationnode",), + ), + migrations.CreateModel( + name="LocalBaserowRowsDeletedTriggerNode", + fields=[ + ( + "automationnode_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="automation.automationnode", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("automation.automationnode",), + ), + migrations.CreateModel( + name="LocalBaserowRowsUpdatedTriggerNode", + fields=[ + ( + "automationnode_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="automation.automationnode", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("automation.automationnode",), + ), + migrations.CreateModel( + name="LocalBaserowRowsCreatedTriggerNode", + fields=[ + ( + "automationnode_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="automation.automationnode", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("automation.automationnode",), + ), + migrations.CreateModel( + name="LocalBaserowUpdateRowActionNode", + fields=[ + ( + "automationnode_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="automation.automationnode", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("automation.automationnode",), + ), + migrations.CreateModel( + name="LocalBaserowDeleteRowActionNode", + fields=[ + ( + "automationnode_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="automation.automationnode", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("automation.automationnode",), + ), + migrations.CreateModel( + name="CoreHTTPRequestActionNode", + fields=[ + ( + "automationnode_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="automation.automationnode", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("automation.automationnode",), + ), + migrations.AddField( + model_name="automation", + name="published_from", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="published_to", + to="automation.automationworkflow", + ), + ), + migrations.CreateModel( + name="PublishAutomationWorkflowJob", + fields=[ + ( + "job_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="core.job", + ), + ), + ( + "user_ip_address", + models.GenericIPAddressField( + help_text="The user IP address.", null=True + ), + ), + ( + "automation_workflow", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="automation.automationworkflow", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("core.job", models.Model), + ), + migrations.CreateModel( + name="CoreSMTPEmailActionNode", + fields=[ + ( + "automationnode_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="automation.automationnode", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("automation.automationnode",), + ), + migrations.CreateModel( + name="LocalBaserowAggregateRowsActionNode", + fields=[ + ( + "automationnode_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="automation.automationnode", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("automation.automationnode",), + ), + migrations.CreateModel( + name="LocalBaserowGetRowActionNode", + fields=[ + ( + "automationnode_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="automation.automationnode", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("automation.automationnode",), + ), + migrations.CreateModel( + name="LocalBaserowListRowsActionNode", + fields=[ + ( + "automationnode_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="automation.automationnode", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("automation.automationnode",), + ), + migrations.RunPython( + code=apply_previous_node_ids_0013_forward, + reverse_code=django.db.migrations.operations.special.RunPython.noop, + ), + migrations.CreateModel( + name="AutomationWorkflowHistory", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("started_on", models.DateTimeField()), + ("completed_on", models.DateTimeField(blank=True, null=True)), + ("message", models.TextField()), + ("is_test_run", models.BooleanField()), + ( + "status", + models.CharField( + choices=[ + ("success", "Success"), + ("error", "Error"), + ("disabled", "Disabled"), + ("started", "Started"), + ], + max_length=8, + ), + ), + ( + "workflow", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workflow_history", + to="automation.automationworkflow", + ), + ), + ], + options={ + "ordering": ("-started_on",), + "abstract": False, + }, + ), + migrations.AddField( + model_name="automationnode", + name="label", + field=models.CharField( + blank=True, + db_default="", + default="", + help_text="A label to use when displaying this node in a graph.", + max_length=75, + ), + ), + migrations.CreateModel( + name="CoreRouterActionNode", + fields=[ + ( + "automationnode_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="automation.automationnode", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("automation.automationnode",), + ), + migrations.AddField( + model_name="automationworkflow", + name="state", + field=models.CharField( + choices=[ + ("draft", "Draft"), + ("live", "Live"), + ("paused", "Paused"), + ("disabled", "Disabled"), + ], + db_default="draft", + default="draft", + max_length=20, + ), + ), + migrations.RunPython( + code=fill_state_0017_forwards, + reverse_code=django.db.migrations.operations.special.RunPython.noop, + ), + migrations.RemoveField( + model_name="automationworkflow", + name="disabled_on", + ), + migrations.RemoveField( + model_name="automationworkflow", + name="paused", + ), + migrations.RemoveField( + model_name="automationworkflow", + name="published", + ), + migrations.AddField( + model_name="automationworkflow", + name="simulate_until_node", + field=models.ForeignKey( + blank=True, + help_text="When set, upon the next workflow run, simulates the dispatch of the workflow until this node and updates the sample_data of the node's service.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="automation.automationnode", + ), + ), + migrations.CreateModel( + name="CorePeriodicTriggerNode", + fields=[ + ( + "automationnode_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="automation.automationnode", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("automation.automationnode",), + ), + migrations.CreateModel( + name="CoreHTTPTriggerNode", + fields=[ + ( + "automationnode_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="automation.automationnode", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("automation.automationnode",), + ), + migrations.CreateModel( + name="CoreIteratorActionNode", + fields=[ + ( + "automationnode_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="automation.automationnode", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("automation.automationnode",), + ), + migrations.AlterModelOptions( + name="automationnode", + options={"ordering": ("id",)}, + ), + migrations.RemoveField( + model_name="automationnode", + name="order", + ), + migrations.RemoveField( + model_name="automationnode", + name="parent_node", + ), + migrations.AddField( + model_name="automationworkflow", + name="graph", + field=models.JSONField(default=dict, help_text="Contains the node graph."), + ), + migrations.RunPython( + code=graph_migration_0021_forward, + reverse_code=graph_migration_0021_reverse, + ), + migrations.RemoveField( + model_name="automationnode", + name="previous_node", + ), + migrations.RemoveField( + model_name="automationnode", + name="previous_node_output", + ), + migrations.CreateModel( + name="AIAgentActionNode", + fields=[ + ( + "automationnode_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="automation.automationnode", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("automation.automationnode",), + ), + migrations.CreateModel( + name="SlackWriteMessageActionNode", + fields=[ + ( + "automationnode_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="automation.automationnode", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("automation.automationnode",), + ), + ] diff --git a/backend/src/baserow/contrib/automation/nodes/service.py b/backend/src/baserow/contrib/automation/nodes/service.py index de863c83f2..6fa3a5493c 100644 --- a/backend/src/baserow/contrib/automation/nodes/service.py +++ b/backend/src/baserow/contrib/automation/nodes/service.py @@ -400,6 +400,10 @@ def replace_node( workflow.get_graph().replace(node_to_replace, new_node) + if workflow.simulate_until_node_id == node_to_replace.id: + workflow.simulate_until_node = None + workflow.save(update_fields=["simulate_until_node"]) + automation_node_deleted.send( self, workflow=workflow, diff --git a/backend/src/baserow/core/feature_flags.py b/backend/src/baserow/core/feature_flags.py index ba9ecb7f51..06a6918236 100644 --- a/backend/src/baserow/core/feature_flags.py +++ b/backend/src/baserow/core/feature_flags.py @@ -2,7 +2,6 @@ from baserow.core.exceptions import FeatureDisabledException -FF_AUTOMATION = "automation" FF_ASSISTANT = "assistant" FF_WORKSPACE_SEARCH = "workspace-search" FF_DATE_DEPENDENCY = "date_dependency" diff --git a/backend/tests/baserow/contrib/automation/nodes/test_node_actions.py b/backend/tests/baserow/contrib/automation/nodes/test_node_actions.py index aec7bb9eae..8e491acf7e 100644 --- a/backend/tests/baserow/contrib/automation/nodes/test_node_actions.py +++ b/backend/tests/baserow/contrib/automation/nodes/test_node_actions.py @@ -109,7 +109,10 @@ def test_replace_automation_action_node_type(data_fixture): node = data_fixture.create_automation_node( workflow=workflow, type=LocalBaserowCreateRowNodeType.type, label="To replace" ) - node_after = data_fixture.create_automation_node( + workflow.simulate_until_node_id = node.id + workflow.save() + + data_fixture.create_automation_node( workflow=workflow, type=LocalBaserowCreateRowNodeType.type, label="After" ) @@ -126,6 +129,9 @@ def test_replace_automation_action_node_type(data_fixture): user, node.id, LocalBaserowUpdateRowNodeType.type ) + workflow.refresh_from_db() + assert workflow.simulate_until_node_id is None + workflow.assert_reference( { "0": "local_baserow_rows_created", @@ -209,10 +215,12 @@ def test_replace_automation_trigger_node_type(data_fixture): automation = data_fixture.create_automation_application(workspace=workspace) workflow = data_fixture.create_automation_workflow(user, automation=automation) original_trigger = workflow.get_trigger() - action_node = data_fixture.create_automation_node( + data_fixture.create_automation_node( workflow=workflow, type=LocalBaserowCreateRowNodeType.type, ) + workflow.simulate_until_node_id = original_trigger.id + workflow.save() workflow.assert_reference( { @@ -226,6 +234,9 @@ def test_replace_automation_trigger_node_type(data_fixture): user, original_trigger.id, LocalBaserowRowsUpdatedNodeTriggerType.type ) + workflow.refresh_from_db() + assert workflow.simulate_until_node_id is None + workflow.assert_reference( { "0": "local_baserow_rows_updated", diff --git a/changelog/entries/unreleased/feature/3258_support_advanced_formulas_formulas_can_now_use_functions_and.json b/changelog/entries/unreleased/feature/3258_support_advanced_formulas_formulas_can_now_use_functions_and.json new file mode 100644 index 0000000000..8a7d0ad760 --- /dev/null +++ b/changelog/entries/unreleased/feature/3258_support_advanced_formulas_formulas_can_now_use_functions_and.json @@ -0,0 +1,9 @@ +{ + "type": "feature", + "message": "Support advanced formulas. Formulas can now use functions and operators.", + "issue_origin": "github", + "issue_number": 3258, + "domain": "builder", + "bullet_points": [], + "created_at": "2025-11-17" +} \ No newline at end of file diff --git a/changelog/entries/unreleased/feature/introducing_automation_builder_automate_repetitive_tasks_and.json b/changelog/entries/unreleased/feature/introducing_automation_builder_automate_repetitive_tasks_and.json new file mode 100644 index 0000000000..1c6a84909f --- /dev/null +++ b/changelog/entries/unreleased/feature/introducing_automation_builder_automate_repetitive_tasks_and.json @@ -0,0 +1,8 @@ +{ + "type": "feature", + "message": "Introducing automation builder; automate repetitive tasks and workflows.", + "domain": "automation", + "issue_number": null, + "bullet_points": [], + "created_at": "2025-11-13" +} \ No newline at end of file diff --git a/web-frontend/modules/automation/components/sidebar/SampleDataModal.vue b/web-frontend/modules/automation/components/sidebar/SampleDataModal.vue index 22816e335f..32ec92da2a 100644 --- a/web-frontend/modules/automation/components/sidebar/SampleDataModal.vue +++ b/web-frontend/modules/automation/components/sidebar/SampleDataModal.vue @@ -3,16 +3,15 @@
{{ sampleData }}