diff --git a/backend/src/baserow/contrib/automation/automation_dispatch_context.py b/backend/src/baserow/contrib/automation/automation_dispatch_context.py index 0d05c87585..ee3ea6a578 100644 --- a/backend/src/baserow/contrib/automation/automation_dispatch_context.py +++ b/backend/src/baserow/contrib/automation/automation_dispatch_context.py @@ -3,7 +3,10 @@ from baserow.contrib.automation.data_providers.registries import ( automation_data_provider_type_registry, ) -from baserow.contrib.automation.history.models import AutomationNodeResult +from baserow.contrib.automation.history.handler import AutomationHistoryHandler +from baserow.contrib.automation.history.models import ( + AutomationNodeHistory, +) from baserow.contrib.automation.nodes.models import AutomationActionNode from baserow.contrib.automation.workflows.models import AutomationWorkflow from baserow.core.cache import local_cache @@ -13,12 +16,12 @@ class AutomationDispatchContext(DispatchContext): - own_properties = ["workflow", "event_payload", "history_id"] + own_properties = ["workflow", "event_payload", "history"] def __init__( self, workflow: AutomationWorkflow, - history_id: int, + history: AutomationNodeHistory, event_payload: Optional[Union[Dict, List[Dict]]] = None, simulate_until_node: Optional[AutomationActionNode] = None, current_iterations: Optional[Dict[int, int]] = None, @@ -29,7 +32,7 @@ def __init__( node's changes. :param workflow: The workflow that this dispatch context is associated with. - :param history_id: The AutomationWorkflowHistory ID from which the + :param history: The AutomationWorkflowHistory from which the workflow's event payload and node results are derived. :param event_payload: The event data from the trigger node, if any was provided, as this is optional. @@ -39,7 +42,7 @@ def __init__( """ self.workflow = workflow - self.history_id = history_id + self.history = history self.simulate_until_node = simulate_until_node self.current_iterations: Dict[int, int] = {} @@ -72,36 +75,30 @@ def clone(self, **kwargs): new_context.current_iterations = {**self.current_iterations} return new_context - def _get_previous_results_cache_key(self) -> Optional[str]: - return f"wa_previous_nodes_results_{self.history_id}" - - def _load_previous_results(self) -> Dict[int, Any]: + def get_iteration_path(self, node): """ - Returns a dict where keys are the node IDs and values are the results - of the previous_nodes_results. + Compute the current iteration path for the given node. """ + parent_nodes = node.get_parent_nodes() - results = {} - previous_results = AutomationNodeResult.objects.filter( - node_history__workflow_history_id=self.history_id - ).select_related("node_history__node") - for result in previous_results: - results[result.node_history.node_id] = result.result + return ".".join([str(self.current_iterations[p.id]) for p in parent_nodes]) - return results + def _get_previous_result_cache_key(self, node) -> Optional[str]: + return f"wa_previous_node_result_{self.history.id}_{node.id}" @property def data_provider_registry(self): return automation_data_provider_type_registry - @property - def previous_nodes_results(self) -> Dict[int, Any]: - if cache_key := self._get_previous_results_cache_key(): - return local_cache.get( - cache_key, - lambda: self._load_previous_results(), - ) - return {} + def get_previous_node_result(self, node) -> Dict[int, Any]: + # We don't need to cache per iteration path because it won't change in this + # dispatch + return local_cache.get( + self._get_previous_result_cache_key(node), + lambda: AutomationHistoryHandler().get_node_result( + self.history, node, self.get_iteration_path(node) + ), + ) def get_timezone_name(self) -> str: """ @@ -120,17 +117,21 @@ def sortings(self) -> Optional[str]: def filters(self) -> Optional[str]: return None + @property def is_publicly_sortable(self) -> bool: return False + @property def is_publicly_filterable(self) -> bool: return False + @property def is_publicly_searchable(self) -> bool: return False + @property def public_allowed_properties(self) -> Optional[Dict[str, Dict[int, List[str]]]]: - return {} + return None def search_query(self) -> Optional[str]: return None diff --git a/backend/src/baserow/contrib/automation/data_providers/data_provider_types.py b/backend/src/baserow/contrib/automation/data_providers/data_provider_types.py index 49283c1e53..14cd8ac642 100644 --- a/backend/src/baserow/contrib/automation/data_providers/data_provider_types.py +++ b/backend/src/baserow/contrib/automation/data_providers/data_provider_types.py @@ -4,6 +4,9 @@ from baserow.contrib.automation.automation_dispatch_context import ( AutomationDispatchContext, ) +from baserow.contrib.automation.history.exceptions import ( + AutomationWorkflowHistoryNodeResultDoesNotExist, +) from baserow.contrib.automation.nodes.exceptions import AutomationNodeDoesNotExist from baserow.contrib.automation.nodes.handler import AutomationNodeHandler from baserow.core.formula.exceptions import InvalidFormulaContext @@ -33,10 +36,10 @@ def get_data_chunk( raise InvalidFormulaContext(message) from exc try: - previous_node_results = dispatch_context.previous_nodes_results[ - int(previous_node.id) - ] - except KeyError as exc: + previous_node_result = dispatch_context.get_previous_node_result( + previous_node + ) + except AutomationWorkflowHistoryNodeResultDoesNotExist as exc: message = ( "The previous node id is not present in the dispatch context results" ) @@ -45,7 +48,7 @@ def get_data_chunk( service = previous_node.service.specific if service.get_type().returns_list: - previous_node_results = previous_node_results["results"] + previous_node_result = previous_node_result["results"] if len(rest) >= 2: prepared_path = [ rest[0], @@ -56,7 +59,7 @@ def get_data_chunk( else: prepared_path = service.get_type().prepare_value_path(service, rest) - return get_value_at_path(previous_node_results, prepared_path) + return get_value_at_path(previous_node_result, prepared_path) def import_path(self, path, id_mapping, **kwargs): """ @@ -99,9 +102,7 @@ def get_data_chunk( raise InvalidFormulaContext(message) from exc try: - parent_node_results = dispatch_context.previous_nodes_results[ - parent_node.id - ] + parent_node_result = dispatch_context.get_previous_node_result(parent_node) except KeyError as exc: message = ( "The parent node id is not present in the dispatch context results" @@ -116,7 +117,7 @@ def get_data_chunk( ) raise InvalidFormulaContext(message) from exc - current_item = parent_node_results["results"][current_iteration] + current_item = parent_node_result["results"][current_iteration] data = {"index": current_iteration, "item": current_item} return get_value_at_path(data, rest) diff --git a/backend/src/baserow/contrib/automation/history/exceptions.py b/backend/src/baserow/contrib/automation/history/exceptions.py index aeacd0b2cd..9586b46ef0 100644 --- a/backend/src/baserow/contrib/automation/history/exceptions.py +++ b/backend/src/baserow/contrib/automation/history/exceptions.py @@ -15,3 +15,7 @@ def __init__(self, history_id=None, *args, **kwargs): *args, **kwargs, ) + + +class AutomationWorkflowHistoryNodeResultDoesNotExist(AutomationWorkflowHistoryError): + """When the result entry doesn't exist for the given node/history.""" diff --git a/backend/src/baserow/contrib/automation/history/handler.py b/backend/src/baserow/contrib/automation/history/handler.py index d1bafeea68..dac591f612 100644 --- a/backend/src/baserow/contrib/automation/history/handler.py +++ b/backend/src/baserow/contrib/automation/history/handler.py @@ -6,6 +6,7 @@ from baserow.contrib.automation.history.constants import HistoryStatusChoices from baserow.contrib.automation.history.exceptions import ( AutomationWorkflowHistoryDoesNotExist, + AutomationWorkflowHistoryNodeResultDoesNotExist, ) from baserow.contrib.automation.history.models import ( AutomationNodeHistory, @@ -105,13 +106,29 @@ def create_node_result( self, node_history: AutomationNodeHistory, result: Optional[Union[Dict, List[Dict]]] = None, - iteration: int = 0, + iteration_path: str = "", ) -> AutomationNodeResult: """Saves the result of a Node dispatch.""" result = result if result else {} return AutomationNodeResult.objects.create( node_history=node_history, - iteration=iteration, + iteration_path=iteration_path, result=result, ) + + def get_node_result(self, history, node, iteration_path): + """ + Returns the result for the given history/node/iteration_path. + """ + + try: + node_result = AutomationNodeResult.objects.only("result").get( + node_history__workflow_history_id=history.id, + node_history__node_id=node.id, + iteration_path=iteration_path, + ) + except AutomationNodeResult.DoesNotExist: + raise AutomationWorkflowHistoryNodeResultDoesNotExist() + + return node_result.result diff --git a/backend/src/baserow/contrib/automation/history/models.py b/backend/src/baserow/contrib/automation/history/models.py index c88eb6e483..0e1746eb42 100644 --- a/backend/src/baserow/contrib/automation/history/models.py +++ b/backend/src/baserow/contrib/automation/history/models.py @@ -9,10 +9,6 @@ class AutomationHistory(models.Model): message = models.TextField() - is_test_run = models.BooleanField( - db_default=False - ) # TODO ZDM: Remove after next release - status = models.CharField( choices=HistoryStatusChoices.choices, max_length=8, @@ -78,6 +74,12 @@ class AutomationNodeResult(models.Model): iteration = models.PositiveIntegerField( db_default=0, help_text="Keeps track of the current iteration of the Iterator node.", + ) # TODO ZDM: Remove after next release + + iteration_path = models.CharField( + db_default="", + default="", + help_text="Keeps track of the iteration path that generated the result.", ) result = models.JSONField( diff --git a/backend/src/baserow/contrib/automation/migrations/0025_automationnoderesult_iteration_path.py b/backend/src/baserow/contrib/automation/migrations/0025_automationnoderesult_iteration_path.py new file mode 100644 index 0000000000..0460a832c7 --- /dev/null +++ b/backend/src/baserow/contrib/automation/migrations/0025_automationnoderesult_iteration_path.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.12 on 2026-03-19 10:23 + +from django.db import migrations, models +from django.db.models import CharField +from django.db.models.functions import Cast + + +def populate_iteration_path(apps, schema_editor): + AutomationNodeResult = apps.get_model("automation", "AutomationNodeResult") + AutomationNodeResult.objects.update( + iteration_path=Cast("iteration", output_field=CharField()) + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('automation', '0024_automationworkflowhistory_event_payload_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='automationnoderesult', + name='iteration_path', + field=models.CharField(db_default='', default='', help_text='Keeps track of the iteration path that generated the result.'), + ), + migrations.RunPython(populate_iteration_path, migrations.RunPython.noop), + migrations.RemoveField( + model_name='automationnodehistory', + name='is_test_run', + ), + ] diff --git a/backend/src/baserow/contrib/automation/nodes/handler.py b/backend/src/baserow/contrib/automation/nodes/handler.py index d155e19884..8c3756a9f6 100644 --- a/backend/src/baserow/contrib/automation/nodes/handler.py +++ b/backend/src/baserow/contrib/automation/nodes/handler.py @@ -449,7 +449,7 @@ def dispatch_node( dispatch_context = AutomationDispatchContext( node.workflow, - history_id, + workflow_history, event_payload=workflow_history.event_payload, simulate_until_node=workflow_history.simulate_until_node, current_iterations=current_iterations, @@ -485,12 +485,6 @@ def dispatch_node( self._handle_simulation_notify(simulate_until_node, node) return None - iteration_index = 0 - parent_nodes = node.get_parent_nodes() - if parent_nodes: - # Use the normalized iteration index from the context. - iteration_index = dispatch_context.current_iterations[parent_nodes[-1].id] - # Return early if this is a simulation as we've reached the # simulated node. if self._handle_simulation_notify(simulate_until_node, node): @@ -499,7 +493,7 @@ def dispatch_node( history_handler.create_node_result( node_history=node_history, result=dispatch_result.data, - iteration=iteration_index, + iteration_path=dispatch_context.get_iteration_path(node), ) to_chain = [] diff --git a/backend/src/baserow/contrib/automation/workflows/handler.py b/backend/src/baserow/contrib/automation/workflows/handler.py index 9784cf1d18..522687e2e5 100644 --- a/backend/src/baserow/contrib/automation/workflows/handler.py +++ b/backend/src/baserow/contrib/automation/workflows/handler.py @@ -768,7 +768,7 @@ def toggle_test_run( # 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. - history_id=0, + history=None, simulate_until_node=simulate_until_node, ) if workflow.can_immediately_be_tested() or ( diff --git a/backend/src/baserow/contrib/database/api/views/views.py b/backend/src/baserow/contrib/database/api/views/views.py index 9bca76a6cb..af1c229d2f 100644 --- a/backend/src/baserow/contrib/database/api/views/views.py +++ b/backend/src/baserow/contrib/database/api/views/views.py @@ -2122,11 +2122,14 @@ def get(self, request: Request, slug: str) -> Response: raise ViewDoesNotExist() field_options = view_type.get_visible_field_options_in_order(view_specific) + ordered_field_ids = list(field_options.values_list("field_id", flat=True)) fields = specific_iterator( - Field.objects.filter(id__in=field_options.values_list("field_id")) + Field.objects.filter(id__in=ordered_field_ids) .select_related("content_type") .prefetch_related("select_options") ) + field_id_order = {fid: idx for idx, fid in enumerate(ordered_field_ids)} + fields = sorted(fields, key=lambda f: field_id_order.get(f.id, 0)) return Response( PublicViewInfoSerializer( diff --git a/backend/src/baserow/contrib/database/views/view_types.py b/backend/src/baserow/contrib/database/views/view_types.py index a0502b94f8..c916466dc0 100644 --- a/backend/src/baserow/contrib/database/views/view_types.py +++ b/backend/src/baserow/contrib/database/views/view_types.py @@ -263,9 +263,12 @@ def after_fields_type_change(self, fields): ) def get_visible_field_options_in_order(self, grid_view): + group_by_field_ids = grid_view.viewgroupby_set.values_list( + "field_id", flat=True + ) return ( grid_view.get_field_options(create_if_missing=True) - .filter(hidden=False) + .filter(Q(hidden=False) | Q(field_id__in=group_by_field_ids)) .order_by("-field__primary", "order", "field__id") ) diff --git a/backend/src/baserow/contrib/database/ws/public/views/signals.py b/backend/src/baserow/contrib/database/ws/public/views/signals.py index b62bde29e5..3ba4aecb03 100644 --- a/backend/src/baserow/contrib/database/ws/public/views/signals.py +++ b/backend/src/baserow/contrib/database/ws/public/views/signals.py @@ -77,6 +77,23 @@ def public_view_filter_deleted(sender, view_filter_id, view_filter, user, **kwar _send_force_rows_refresh_if_view_public(view_filter.view) +@receiver(view_signals.view_group_by_created) +def public_view_group_by_created(sender, view_group_by, user, **kwargs): + _send_force_view_refresh_if_view_public(view_group_by.view) + + +@receiver(view_signals.view_group_by_updated) +def public_view_group_by_updated(sender, view_group_by, user, **kwargs): + _send_force_view_refresh_if_view_public(view_group_by.view) + + +@receiver(view_signals.view_group_by_deleted) +def public_view_group_by_deleted( + sender, view_group_by_id, view_group_by, user, **kwargs +): + _send_force_view_refresh_if_view_public(view_group_by.view) + + @receiver(view_signals.view_field_options_updated) def public_view_field_options_updated(sender, view, user, **kwargs): _send_force_view_refresh_if_view_public(view) diff --git a/backend/src/baserow/test_utils/fixtures/automation_node.py b/backend/src/baserow/test_utils/fixtures/automation_node.py index 24d470e516..0be5f25570 100644 --- a/backend/src/baserow/test_utils/fixtures/automation_node.py +++ b/backend/src/baserow/test_utils/fixtures/automation_node.py @@ -328,3 +328,184 @@ def iterator_graph_fixture(self, create_after_iteration_node: bool = True): "after_iteration_table": after_iteration_table, "after_iteration_table_fields": after_iteration_table_fields, } + + def nested_iterator_graph_fixture(self, create_after_iteration_node: bool = True): + """ + Fixture that creates the following graph: + - trigger_node + - parent_iterator_node + - child_iterator_node + - child_iterator_child_1 + - child_iterator_child_2 + - after_iteration_node + + trigger sample data are + [ + { + "Name": "Apple", + "Items": [ + {"Name": "Fuji", "Color": "Red"}, + {"Name": "Granny Smith", "Color": "Green"}, + ], + }, + { + "Name": "Banana", + "Items": [ + {"Name": "Cavendish", "Color": "Yellow"}, + {"Name": "Plantain", "Color": "Green"}, + ], + }, + ] + """ + + user = self.create_user() + + trigger_table, trigger_table_fields, _ = self.build_table( + user=user, + columns=[("Name", "text"), ("Items", "text")], + rows=[], + ) + child_iterator_child_1_table, child_iterator_child_1_table_fields, _ = ( + self.build_table( + user=user, + columns=[("Name", "text")], + rows=[], + ) + ) + child_iterator_child_2_table, child_iterator_child_2_table_fields, _ = ( + self.build_table( + user=user, + columns=[("Color", "text")], + rows=[], + ) + ) + after_iteration_table, after_iteration_table_fields, _ = self.build_table( + user=user, + columns=[("Name", "text")], + rows=[], + ) + + integration = self.create_local_baserow_integration(user=user) + + workflow = self.create_automation_workflow( + user=user, + state=WorkflowState.LIVE, + trigger_type="local_baserow_rows_created", + trigger_service_kwargs={ + "table": trigger_table, + "integration": integration, + "sample_data": { + "data": { + "results": [ + { + trigger_table_fields[0].name: "Apple", + trigger_table_fields[1].name: [ + {"Name": "Fuji", "Color": "Red"}, + {"Name": "Granny Smith", "Color": "Green"}, + ], + }, + { + trigger_table_fields[0].name: "Banana", + trigger_table_fields[1].name: [ + {"Name": "Cavendish", "Color": "Yellow"}, + {"Name": "Plantain", "Color": "Green"}, + ], + }, + ] + } + }, + }, + ) + + trigger = workflow.get_trigger() + + parent_iterator_node = self.create_core_iterator_action_node( + workflow=workflow, + reference_node=trigger, + position="south", + output="", + service_kwargs={ + "source": f'get("previous_node.{trigger.id}")', + "integration": integration, + }, + ) + + child_iterator_node = self.create_core_iterator_action_node( + workflow=workflow, + reference_node=parent_iterator_node, + position="child", + output="", + service_kwargs={ + "source": f'get("current_iteration.{parent_iterator_node.id}.item.{trigger_table_fields[1].name}")', + "integration": integration, + }, + ) + + child_iterator_child_1_node = self.create_local_baserow_create_row_action_node( + workflow=workflow, + reference_node=child_iterator_node, + position="child", + output="", + label="First child iterator child", + service_kwargs={ + "table": child_iterator_child_1_table, + "integration": integration, + }, + ) + child_iterator_child_1_node.service.specific.field_mappings.create( + field=child_iterator_child_1_table_fields[0], + value=f'get("current_iteration.{child_iterator_node.id}.item.Name")', + ) + + child_iterator_child_2_node = self.create_local_baserow_create_row_action_node( + workflow=workflow, + reference_node=child_iterator_child_1_node, + position="south", + output="", + label="Second child iterator child", + service_kwargs={ + "table": child_iterator_child_2_table, + "integration": integration, + }, + ) + child_iterator_child_2_node.service.specific.field_mappings.create( + field=child_iterator_child_2_table_fields[0], + value=f'get("previous_node.{child_iterator_child_1_node.id}.{child_iterator_child_1_table_fields[0].db_column}")', + ) + + if create_after_iteration_node: + after_iteration_node = self.create_local_baserow_create_row_action_node( + workflow=workflow, + reference_node=parent_iterator_node, + position="south", + output="", + label="After parent iterator", + service_kwargs={ + "table": after_iteration_table, + "integration": integration, + }, + ) + after_iteration_node.service.specific.field_mappings.create( + field=after_iteration_table_fields[0], + value=f'get("previous_node.{parent_iterator_node.id}.*.{trigger_table_fields[0].name}")', + ) + else: + after_iteration_node = None + + return { + "workflow": workflow, + "trigger_node": trigger, + "trigger_table": trigger_table, + "trigger_table_fields": trigger_table_fields, + "parent_iterator_node": parent_iterator_node, + "child_iterator_node": child_iterator_node, + "child_iterator_child_1_node": child_iterator_child_1_node, + "child_iterator_child_1_table": child_iterator_child_1_table, + "child_iterator_child_1_table_fields": child_iterator_child_1_table_fields, + "child_iterator_child_2_node": child_iterator_child_2_node, + "child_iterator_child_2_table": child_iterator_child_2_table, + "child_iterator_child_2_table_fields": child_iterator_child_2_table_fields, + "after_iteration_node": after_iteration_node, + "after_iteration_table": after_iteration_table, + "after_iteration_table_fields": after_iteration_table_fields, + } diff --git a/backend/tests/baserow/contrib/automation/data_providers/test_data_provider_types.py b/backend/tests/baserow/contrib/automation/data_providers/test_data_provider_types.py index cb99c4bed4..ef7bfed141 100644 --- a/backend/tests/baserow/contrib/automation/data_providers/test_data_provider_types.py +++ b/backend/tests/baserow/contrib/automation/data_providers/test_data_provider_types.py @@ -52,7 +52,7 @@ def test_previous_node_data_provider_get_data_chunk(data_fixture): dispatch_context = AutomationDispatchContext( workflow, - workflow_history.id, + workflow_history, event_payload=workflow_history.event_payload, ) @@ -94,7 +94,7 @@ def test_previous_node_data_provider_get_data_chunk(data_fixture): dispatch_context = AutomationDispatchContext( workflow, - workflow_history_2.id, + workflow_history_2, ) # Existing node but after @@ -150,12 +150,11 @@ def test_current_iteration_data_provider_get_data_chunk(data_fixture): AutomationHistoryHandler().create_node_result( node_history=node_history, result={"results": [{"field_1": "Horse"}, {"field_1": "Duck"}]}, - iteration=0, ) dispatch_context = AutomationDispatchContext( workflow, - workflow_history.id, + workflow_history, event_payload=workflow_history.event_payload, current_iterations={iterator.id: 0}, ) diff --git a/backend/tests/baserow/contrib/automation/nodes/test_node_dispatch_async.py b/backend/tests/baserow/contrib/automation/nodes/test_node_dispatch_async.py index 3d829f2f97..af3d69c695 100644 --- a/backend/tests/baserow/contrib/automation/nodes/test_node_dispatch_async.py +++ b/backend/tests/baserow/contrib/automation/nodes/test_node_dispatch_async.py @@ -38,6 +38,27 @@ def assert_dispatches_next_node(result, *expected_tasks): assert task.args == (node.id, history.id, iterations) +def execute_dispatch_signature_tree(result): + """ + Execute the returned Celery canvas in-process by recursively dispatching each + leaf node in order. + """ + + if result is None: + return + + assert isinstance(result, Signature) + + if hasattr(result, "tasks"): + for task in result.tasks: + execute_dispatch_signature_tree(task) + return + + next_result = AutomationNodeHandler().dispatch_node(*result.args) + clear_local() + execute_dispatch_signature_tree(next_result) + + def create_workflow( data_fixture, user=None, @@ -424,6 +445,75 @@ def test_dispatch_node_dispatches_iterator_children(data_fixture): assert workflow_history.status == HistoryStatusChoices.SUCCESS +@pytest.mark.django_db +def test_dispatch_node_fully_dispatches_nested_iterator_workflow(data_fixture): + data = data_fixture.nested_iterator_graph_fixture() + trigger_node = data["trigger_node"] + trigger_table_fields = data["trigger_table_fields"] + child_iterator_child_1_table = data["child_iterator_child_1_table"] + child_iterator_child_1_table_fields = data["child_iterator_child_1_table_fields"] + child_iterator_child_2_table = data["child_iterator_child_2_table"] + child_iterator_child_2_table_fields = data["child_iterator_child_2_table_fields"] + after_iteration_table = data["after_iteration_table"] + + original_workflow = trigger_node.workflow.get_original() + workflow_history = data_fixture.create_automation_workflow_history( + workflow=original_workflow, + event_payload={ + "results": [ + { + "id": 100, + "order": "10.00000000000000000000", + trigger_table_fields[0].name: "Apple", + trigger_table_fields[1].name: [ + {"Name": "Fuji", "Color": "Red"}, + {"Name": "Granny Smith", "Color": "Green"}, + ], + }, + { + "id": 101, + "order": "20.00000000000000000000", + trigger_table_fields[0].name: "Banana", + trigger_table_fields[1].name: [ + {"Name": "Cavendish", "Color": "Yellow"}, + {"Name": "Plantain", "Color": "Green"}, + ], + }, + ], + "has_next_page": False, + }, + ) + + result = AutomationNodeHandler().dispatch_node( + trigger_node.id, + history_id=workflow_history.id, + ) + clear_local() + execute_dispatch_signature_tree(result) + + handle_workflow_dispatch_done(history_id=workflow_history.id) + + workflow_history.refresh_from_db() + assert workflow_history.message == "" + assert workflow_history.status == HistoryStatusChoices.SUCCESS + + child_1_rows = list( + child_iterator_child_1_table.get_model() + .objects.order_by("id") + .values_list(child_iterator_child_1_table_fields[0].db_column, flat=True) + ) + assert child_1_rows == ["Fuji", "Granny Smith", "Cavendish", "Plantain"] + + child_2_rows = list( + child_iterator_child_2_table.get_model() + .objects.order_by("id") + .values_list(child_iterator_child_2_table_fields[0].db_column, flat=True) + ) + assert child_2_rows == ["Fuji", "Granny Smith", "Cavendish", "Plantain"] + + assert after_iteration_table.get_model().objects.count() == 1 + + @pytest.mark.django_db @patch(f"{NODE_HANDLER_PATH}.automation_node_updated") def test_dispatch_node_dispatches_trigger_simulation( diff --git a/backend/tests/baserow/contrib/automation/nodes/test_node_types.py b/backend/tests/baserow/contrib/automation/nodes/test_node_types.py index 8fedb27598..58b975b1fd 100644 --- a/backend/tests/baserow/contrib/automation/nodes/test_node_types.py +++ b/backend/tests/baserow/contrib/automation/nodes/test_node_types.py @@ -127,7 +127,7 @@ def test_automation_node_type_create_row_dispatch(mock_dispatch, data_fixture): user=user, type="local_baserow_create_row" ) - dispatch_context = AutomationDispatchContext(node.workflow, 100) + dispatch_context = AutomationDispatchContext(node.workflow, None) result = node.get_type().dispatch(node, dispatch_context) assert result == mock_dispatch_result @@ -191,7 +191,7 @@ def test_automation_node_type_update_row_dispatch(mock_dispatch, data_fixture): user=user, type="local_baserow_update_row" ) - dispatch_context = AutomationDispatchContext(node.workflow, 100) + dispatch_context = AutomationDispatchContext(node.workflow, None) result = node.get_type().dispatch(node, dispatch_context) assert result == mock_dispatch_result @@ -247,7 +247,7 @@ def test_automation_node_type_delete_row_dispatch(mock_dispatch, data_fixture): user=user, type="local_baserow_delete_row" ) - dispatch_context = AutomationDispatchContext(node.workflow, 100) + dispatch_context = AutomationDispatchContext(node.workflow, None) result = node.get_type().dispatch(node, dispatch_context) assert result == mock_dispatch_result @@ -452,7 +452,7 @@ def test_trigger_node_dispatch_returns_event_payload_if_not_simulated(data_fixtu trigger = workflow.get_trigger().specific - dispatch_context = AutomationDispatchContext(workflow, 100, event_payload="foo") + dispatch_context = AutomationDispatchContext(workflow, None, event_payload="foo") result = trigger.get_type().dispatch(trigger, dispatch_context) @@ -474,7 +474,7 @@ def test_trigger_node_dispatch_returns_sample_data_if_simulated(data_fixture): trigger = workflow.get_trigger() dispatch_context = AutomationDispatchContext( - workflow, 100, simulate_until_node=trigger + workflow, None, simulate_until_node=trigger ) # If we don't reset this value, the trigger is considered as updatable and will # be dispatched. diff --git a/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py b/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py index 84bfa4442b..2f27e6868e 100644 --- a/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py +++ b/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py @@ -3236,14 +3236,16 @@ def test_get_public_grid_view(api_client, data_fixture): data_fixture.create_grid_view_field_option(grid_view, public_field, hidden=False) data_fixture.create_grid_view_field_option(grid_view, hidden_field, hidden=True) - # This view sort shouldn't be exposed as it is for a hidden field - data_fixture.create_view_sort(view=grid_view, field=hidden_field, order="ASC") + hidden_sort = data_fixture.create_view_sort( + view=grid_view, field=hidden_field, order="ASC" + ) visible_sort = data_fixture.create_view_sort( view=grid_view, field=public_field, order="DESC" ) - # This group by shouldn't be exposed as it is for a hidden field - data_fixture.create_view_group_by(view=grid_view, field=hidden_field, order="ASC") + hidden_group_by = data_fixture.create_view_group_by( + view=grid_view, field=hidden_field, order="ASC" + ) visible_group_by = data_fixture.create_view_group_by( view=grid_view, field=public_field, order="DESC" ) @@ -3281,7 +3283,24 @@ def test_get_public_grid_view(api_client, data_fixture): "workspace_id": PUBLIC_PLACEHOLDER_ENTITY_ID, "db_index": False, "field_constraints": [], - } + }, + { + "id": hidden_field.id, + "table_id": PUBLIC_PLACEHOLDER_ENTITY_ID, + "name": "hidden", + "order": 0, + "primary": False, + "text_default": "", + "type": "text", + "read_only": False, + "description": None, + "immutable_properties": False, + "immutable_type": False, + "database_id": PUBLIC_PLACEHOLDER_ENTITY_ID, + "workspace_id": PUBLIC_PLACEHOLDER_ENTITY_ID, + "db_index": False, + "field_constraints": [], + }, ], "view": { "id": grid_view.slug, @@ -3290,17 +3309,30 @@ def test_get_public_grid_view(api_client, data_fixture): "public": True, "slug": grid_view.slug, "sortings": [ - # Note the sorting for the hidden field is not returned + { + "field": hidden_sort.field.id, + "id": hidden_sort.id, + "order": "ASC", + "type": "default", + "view": grid_view.slug, + }, { "field": visible_sort.field.id, "id": visible_sort.id, "order": "DESC", "type": "default", "view": grid_view.slug, - } + }, ], "group_bys": [ - # Note the group by for the hidden field is not returned + { + "field": hidden_group_by.field.id, + "id": hidden_group_by.id, + "order": "ASC", + "view": grid_view.slug, + "width": 200, + "type": "default", + }, { "field": visible_group_by.field.id, "id": visible_group_by.id, @@ -3308,7 +3340,7 @@ def test_get_public_grid_view(api_client, data_fixture): "view": grid_view.slug, "width": 200, "type": "default", - } + }, ], "table": { "database_id": PUBLIC_PLACEHOLDER_ENTITY_ID, @@ -3875,6 +3907,64 @@ def test_list_rows_public_with_query_param_group_by(api_client, data_fixture): assert response_json["error"] == "ERROR_ORDER_BY_FIELD_NOT_POSSIBLE" +@pytest.mark.django_db +def test_list_rows_public_with_query_param_group_by_hidden_field_with_stored_group_by( + api_client, data_fixture +): + user, token = data_fixture.create_user_and_token() + table = data_fixture.create_database_table(user=user) + public_field = data_fixture.create_text_field(table=table, name="public") + hidden_field = data_fixture.create_text_field(table=table, name="hidden") + grid_view = data_fixture.create_grid_view( + table=table, user=user, public=True, create_options=False + ) + data_fixture.create_grid_view_field_option(grid_view, public_field, hidden=False) + data_fixture.create_grid_view_field_option(grid_view, hidden_field, hidden=True) + + data_fixture.create_view_group_by(view=grid_view, field=hidden_field, order="ASC") + + first_row = RowHandler().create_row( + user, + table, + values={"public": "a", "hidden": "y"}, + user_field_names=True, + ) + second_row = RowHandler().create_row( + user, + table, + values={"public": "b", "hidden": "x"}, + user_field_names=True, + ) + third_row = RowHandler().create_row( + user, + table, + values={"public": "c", "hidden": "y"}, + user_field_names=True, + ) + + url = reverse( + "api:database:views:grid:public_rows", kwargs={"slug": grid_view.slug} + ) + response = api_client.get( + f"{url}?group_by=field_{hidden_field.id}", + ) + response_json = response.json() + assert response.status_code == HTTP_200_OK + assert len(response_json["results"]) == 3 + assert response_json["results"][0]["id"] == second_row.id + assert response_json["results"][1]["id"] == first_row.id + assert response_json["results"][2]["id"] == third_row.id + assert response_json["group_by_metadata"] == { + f"field_{hidden_field.id}": unordered( + [ + {"count": 1, f"field_{hidden_field.id}": "x"}, + {"count": 2, f"field_{hidden_field.id}": "y"}, + ] + ) + } + assert f"field_{hidden_field.id}" in response_json["results"][0] + + @pytest.mark.django_db def test_list_rows_public_with_query_param_group_by_and_empty_order_by( api_client, data_fixture diff --git a/backend/tests/baserow/contrib/integrations/slack/test_slack_write_message_service_type.py b/backend/tests/baserow/contrib/integrations/slack/test_slack_write_message_service_type.py index 607053408e..22041deb6b 100644 --- a/backend/tests/baserow/contrib/integrations/slack/test_slack_write_message_service_type.py +++ b/backend/tests/baserow/contrib/integrations/slack/test_slack_write_message_service_type.py @@ -175,7 +175,7 @@ def test_dispatch_slack_write_message_with_formulas(data_fixture): service_type = service.get_type() dispatch_context = AutomationDispatchContext( workflow, - workflow_history.id, + workflow_history, ) mock_response = Mock() diff --git a/changelog/entries/unreleased/bug/4858_fix_public_view_with_hidden_group_by_field.json b/changelog/entries/unreleased/bug/4858_fix_public_view_with_hidden_group_by_field.json new file mode 100644 index 0000000000..6db420058c --- /dev/null +++ b/changelog/entries/unreleased/bug/4858_fix_public_view_with_hidden_group_by_field.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Fix public view with hidden group by field", + "issue_origin": "github", + "issue_number": 4858, + "domain": "database", + "bullet_points": [], + "created_at": "2026-02-24" +} \ No newline at end of file diff --git a/changelog/entries/unreleased/bug/fix_bad_previous_node_result_inside_an_iteration.json b/changelog/entries/unreleased/bug/fix_bad_previous_node_result_inside_an_iteration.json new file mode 100644 index 0000000000..4d9dc07e78 --- /dev/null +++ b/changelog/entries/unreleased/bug/fix_bad_previous_node_result_inside_an_iteration.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Fix bad previous node result inside an iteration", + "issue_origin": "github", + "issue_number": null, + "domain": "automation", + "bullet_points": [], + "created_at": "2026-03-19" +} \ No newline at end of file diff --git a/web-frontend/modules/database/components/view/grid/GridViewGroup.vue b/web-frontend/modules/database/components/view/grid/GridViewGroup.vue index 56cd61d65c..64970b4b0d 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewGroup.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewGroup.vue @@ -19,8 +19,8 @@ export default { type: Object, required: true, }, - allFieldsInTable: { - type: Array, + field: { + type: Object, required: true, }, value: { @@ -33,21 +33,9 @@ export default { }, }, computed: { - field() { - return this.getField(this.allFieldsInTable, this.groupBy) - }, groupByComponent() { - return this.getGroupByComponent(this.field, this) - }, - }, - methods: { - getField(allFieldsInTable, groupBy) { - const field = allFieldsInTable.find((f) => f.id === groupBy.field) - return field - }, - getGroupByComponent(field, parent) { - const fieldType = parent.$registry.get('field', field.type) - return fieldType.getGroupByComponent(field) + const fieldType = this.$registry.get('field', this.field.type) + return fieldType.getGroupByComponent(this.field) }, }, } diff --git a/web-frontend/modules/database/components/view/grid/GridViewGroups.vue b/web-frontend/modules/database/components/view/grid/GridViewGroups.vue index df0dbbd537..215237ef61 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewGroups.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewGroups.vue @@ -20,7 +20,7 @@ > @@ -40,14 +40,6 @@ export default { components: { GridViewGroup }, mixins: [gridViewHelpers], props: { - /** - * All the fields in the table, regardless of the visibility, or whether they - * should be rendered. - */ - allFieldsInTable: { - type: Array, - required: true, - }, groupByValueSets: { type: Array, required: true, diff --git a/web-frontend/modules/database/components/view/grid/GridViewHead.vue b/web-frontend/modules/database/components/view/grid/GridViewHead.vue index 89b882cf03..f09c44bb9d 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewHead.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewHead.vue @@ -1,11 +1,10 @@