From 9d6aa5be937cba17c56b4b3195996aad5b3bec80 Mon Sep 17 00:00:00 2001 From: Davide Silvestri <75379892+silvestrid@users.noreply.github.com> Date: Tue, 3 Feb 2026 09:45:47 +0100 Subject: [PATCH 1/6] chore: add dependabot.yml (#4607) * Add dependabot.yml * Address copilot feedback --- .github/dependabot.yml | 47 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..9390bb3045 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,47 @@ +version: 2 +updates: + # ============================================================================= + # npm ecosystems + # ============================================================================= + # Note: We only monitor directories with actual dependencies declared. + # /enterprise/web-frontend and /premium/web-frontend are excluded because + # they currently have no dependencies in their package.json files. + # /integrations/zapier and /plugin-boilerplate are excluded intentionally. + + - package-ecosystem: "npm" + directory: "/web-frontend" + schedule: + interval: "daily" + + - package-ecosystem: "npm" + directory: "/backend/email_compiler" + schedule: + interval: "daily" + + - package-ecosystem: "npm" + directory: "/e2e-tests" + schedule: + interval: "daily" + + # ============================================================================= + # uv (Python) ecosystem + # ============================================================================= + # Note: We only monitor /backend because it manages the uv workspace centrally. + # The workspace is defined in backend/pyproject.toml with members including + # ../premium/backend and ../enterprise/backend, and backend/uv.lock contains + # all resolved dependencies. Separate entries for enterprise/premium backends + # would be redundant and cause duplicate update runs. + + - package-ecosystem: "uv" + directory: "/backend" + schedule: + interval: "daily" + + # ============================================================================= + # GitHub Actions + # ============================================================================= + + - package-ecosystem: "github-actions" + directory: "/.github/workflows" + schedule: + interval: "daily" From 3e774f88f11f43aba564b58d0dd321444beea45b Mon Sep 17 00:00:00 2001 From: Davide Silvestri <75379892+silvestrid@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:54:20 +0100 Subject: [PATCH 2/6] fix(realtime): Realtime updates for data sources and workflow actions (#4587) * Add realtime signals for data-sources and workflow-actions * Address feedback for data sources and other handlers * Add realtime update for theme * apply Peter's patch * Fix flaky test --- backend/src/baserow/config/db_routers.py | 4 +- .../builder/api/data_sources/serializers.py | 2 +- .../contrib/builder/data_sources/service.py | 3 +- .../builder/ws/data_sources/signals.py | 97 ++++++++ .../src/baserow/contrib/builder/ws/signals.py | 18 ++ .../contrib/builder/ws/theme/signals.py | 29 +++ .../builder/ws/workflow_actions/signals.py | 98 ++++++++ .../contrib/database/fields/handler.py | 8 +- .../data_sources/test_data_source_service.py | 3 +- .../ws/test_ws_data_sources_signals.py | 100 ++++++++ .../builder/ws/test_ws_theme_signals.py | 25 ++ .../ws/test_ws_workflow_actions_signals.py | 86 +++++++ .../components/settings/ThemeSettings.vue | 1 - web-frontend/modules/builder/realtime.js | 215 ++++++++++++------ .../modules/builder/store/dataSource.js | 3 +- web-frontend/modules/builder/store/theme.js | 23 +- .../modules/core/plugins/realTimeHandler.js | 9 +- 17 files changed, 633 insertions(+), 91 deletions(-) create mode 100644 backend/src/baserow/contrib/builder/ws/data_sources/signals.py create mode 100644 backend/src/baserow/contrib/builder/ws/theme/signals.py create mode 100644 backend/src/baserow/contrib/builder/ws/workflow_actions/signals.py create mode 100644 backend/tests/baserow/contrib/builder/ws/test_ws_data_sources_signals.py create mode 100644 backend/tests/baserow/contrib/builder/ws/test_ws_theme_signals.py create mode 100644 backend/tests/baserow/contrib/builder/ws/test_ws_workflow_actions_signals.py diff --git a/backend/src/baserow/config/db_routers.py b/backend/src/baserow/config/db_routers.py index e8f03304bf..4147fb51f1 100644 --- a/backend/src/baserow/config/db_routers.py +++ b/backend/src/baserow/config/db_routers.py @@ -1,12 +1,10 @@ import random from django.conf import settings -from django.db import transaction +from django.db import DEFAULT_DB_ALIAS, transaction from asgiref.local import Local -DEFAULT_DB_ALIAS = "default" - _db_state = Local() diff --git a/backend/src/baserow/contrib/builder/api/data_sources/serializers.py b/backend/src/baserow/contrib/builder/api/data_sources/serializers.py index e349c82e1a..bf4d7cd3a7 100644 --- a/backend/src/baserow/contrib/builder/api/data_sources/serializers.py +++ b/backend/src/baserow/contrib/builder/api/data_sources/serializers.py @@ -65,7 +65,7 @@ def get_page_id(self, instance): @extend_schema_field(OpenApiTypes.FLOAT) def get_order(self, instance): - return self.context["data_source"].order + return str(self.context["data_source"].order) @extend_schema_field(OpenApiTypes.OBJECT) def get_schema(self, instance): diff --git a/backend/src/baserow/contrib/builder/data_sources/service.py b/backend/src/baserow/contrib/builder/data_sources/service.py index 37df31de7f..ece9045612 100644 --- a/backend/src/baserow/contrib/builder/data_sources/service.py +++ b/backend/src/baserow/contrib/builder/data_sources/service.py @@ -272,10 +272,11 @@ def delete_data_source(self, user: AbstractUser, data_source: DataSourceForUpdat context=data_source, ) + data_source_id = data_source.id self.handler.delete_data_source(data_source) data_source_deleted.send( - self, data_source_id=data_source.id, page=page, user=user + self, data_source_id=data_source_id, page=page, user=user ) def dispatch_data_sources( diff --git a/backend/src/baserow/contrib/builder/ws/data_sources/signals.py b/backend/src/baserow/contrib/builder/ws/data_sources/signals.py new file mode 100644 index 0000000000..e42be6dcb6 --- /dev/null +++ b/backend/src/baserow/contrib/builder/ws/data_sources/signals.py @@ -0,0 +1,97 @@ +from django.contrib.auth.models import AbstractUser +from django.db import transaction +from django.dispatch import receiver + +from baserow.contrib.builder.api.data_sources.serializers import DataSourceSerializer +from baserow.contrib.builder.data_sources import signals as data_source_signals +from baserow.contrib.builder.data_sources.models import DataSource +from baserow.contrib.builder.data_sources.object_scopes import ( + BuilderDataSourceObjectScopeType, +) +from baserow.contrib.builder.data_sources.operations import ( + ListDataSourcesPageOperationType, + ReadDataSourceOperationType, +) +from baserow.contrib.builder.pages.models import Page +from baserow.contrib.builder.pages.object_scopes import BuilderPageObjectScopeType +from baserow.core.services.registries import service_type_registry +from baserow.ws.tasks import broadcast_to_permitted_users + + +@receiver(data_source_signals.data_source_created) +def data_source_created( + sender, data_source: DataSource, user: AbstractUser, before_id=None, **kwargs +): + if data_source.service: + serializer = service_type_registry.get_serializer( + data_source.service, + DataSourceSerializer, + context={"data_source": data_source}, + ) + else: + serializer = DataSourceSerializer( + data_source, context={"data_source": data_source} + ) + + transaction.on_commit( + lambda: broadcast_to_permitted_users.delay( + data_source.page.builder.workspace_id, + ReadDataSourceOperationType.type, + BuilderDataSourceObjectScopeType.type, + data_source.id, + { + "type": "data_source_created", + "data_source": serializer.data, + "before_id": before_id, + }, + getattr(user, "web_socket_id", None), + ) + ) + + +@receiver(data_source_signals.data_source_updated) +def data_source_updated(sender, data_source: DataSource, user: AbstractUser, **kwargs): + if data_source.service: + serializer = service_type_registry.get_serializer( + data_source.service, + DataSourceSerializer, + context={"data_source": data_source}, + ) + else: + serializer = DataSourceSerializer( + data_source, context={"data_source": data_source} + ) + + transaction.on_commit( + lambda: broadcast_to_permitted_users.delay( + data_source.page.builder.workspace_id, + ReadDataSourceOperationType.type, + BuilderDataSourceObjectScopeType.type, + data_source.id, + { + "type": "data_source_updated", + "data_source": serializer.data, + }, + getattr(user, "web_socket_id", None), + ) + ) + + +@receiver(data_source_signals.data_source_deleted) +def data_source_deleted( + sender, data_source_id: int, page: Page, user: AbstractUser, **kwargs +): + transaction.on_commit( + lambda: broadcast_to_permitted_users.delay( + page.builder.workspace_id, + ListDataSourcesPageOperationType.type, + BuilderPageObjectScopeType.type, + page.id, + { + "type": "data_source_deleted", + "data_source_id": data_source_id, + "page_id": page.id, + }, + getattr(user, "web_socket_id", None), + ) + ) diff --git a/backend/src/baserow/contrib/builder/ws/signals.py b/backend/src/baserow/contrib/builder/ws/signals.py index d5d44c432f..f4daaea18d 100644 --- a/backend/src/baserow/contrib/builder/ws/signals.py +++ b/backend/src/baserow/contrib/builder/ws/signals.py @@ -1,3 +1,8 @@ +from .data_sources.signals import ( + data_source_created, + data_source_deleted, + data_source_updated, +) from .element.signals import ( element_created, element_deleted, @@ -5,8 +10,17 @@ element_updated, ) from .page.signals import page_created, page_deleted, page_reordered, page_updated +from .theme.signals import theme_updated +from .workflow_actions.signals import ( + workflow_action_created, + workflow_action_deleted, + workflow_action_updated, +) __all__ = [ + "data_source_created", + "data_source_updated", + "data_source_deleted", "page_created", "page_deleted", "page_updated", @@ -15,4 +29,8 @@ "element_deleted", "element_updated", "element_orders_recalculated", + "theme_updated", + "workflow_action_created", + "workflow_action_updated", + "workflow_action_deleted", ] diff --git a/backend/src/baserow/contrib/builder/ws/theme/signals.py b/backend/src/baserow/contrib/builder/ws/theme/signals.py new file mode 100644 index 0000000000..f8c9c42d22 --- /dev/null +++ b/backend/src/baserow/contrib/builder/ws/theme/signals.py @@ -0,0 +1,29 @@ +from django.contrib.auth.models import AbstractUser +from django.db import transaction +from django.dispatch import receiver + +from baserow.contrib.builder.models import Builder +from baserow.contrib.builder.object_scopes import BuilderObjectScopeType +from baserow.contrib.builder.theme import signals as theme_signals +from baserow.contrib.builder.theme.operations import UpdateThemeOperationType +from baserow.ws.tasks import broadcast_to_permitted_users + + +@receiver(theme_signals.theme_updated) +def theme_updated( + sender, builder: Builder, user: AbstractUser, properties: dict, **kwargs +): + transaction.on_commit( + lambda: broadcast_to_permitted_users.delay( + builder.workspace_id, + UpdateThemeOperationType.type, + BuilderObjectScopeType.type, + builder.id, + { + "type": "theme_updated", + "builder_id": builder.id, + "properties": properties, + }, + getattr(user, "web_socket_id", None), + ) + ) diff --git a/backend/src/baserow/contrib/builder/ws/workflow_actions/signals.py b/backend/src/baserow/contrib/builder/ws/workflow_actions/signals.py new file mode 100644 index 0000000000..5ab2bcbd21 --- /dev/null +++ b/backend/src/baserow/contrib/builder/ws/workflow_actions/signals.py @@ -0,0 +1,98 @@ +from django.contrib.auth.models import AbstractUser +from django.db import transaction +from django.dispatch import receiver + +from baserow.contrib.builder.api.workflow_actions.serializers import ( + BuilderWorkflowActionSerializer, +) +from baserow.contrib.builder.pages.models import Page +from baserow.contrib.builder.pages.object_scopes import BuilderPageObjectScopeType +from baserow.contrib.builder.workflow_actions import signals as workflow_action_signals +from baserow.contrib.builder.workflow_actions.object_scopes import ( + BuilderWorkflowActionScopeType, +) +from baserow.contrib.builder.workflow_actions.operations import ( + ListBuilderWorkflowActionsPageOperationType, + ReadBuilderWorkflowActionOperationType, +) +from baserow.contrib.builder.workflow_actions.registries import ( + builder_workflow_action_type_registry, +) +from baserow.core.workflow_actions.models import WorkflowAction +from baserow.ws.tasks import broadcast_to_permitted_users + + +@receiver(workflow_action_signals.workflow_action_created) +def workflow_action_created( + sender, + workflow_action: WorkflowAction, + user: AbstractUser, + before_id=None, + **kwargs, +): + transaction.on_commit( + lambda: broadcast_to_permitted_users.delay( + workflow_action.page.builder.workspace_id, + ReadBuilderWorkflowActionOperationType.type, + BuilderWorkflowActionScopeType.type, + workflow_action.id, + { + "type": "workflow_action_created", + "page_id": workflow_action.page_id, + "workflow_action": builder_workflow_action_type_registry.get_serializer( + workflow_action, BuilderWorkflowActionSerializer + ).data, + "before_id": before_id, + }, + getattr(user, "web_socket_id", None), + ) + ) + + +@receiver(workflow_action_signals.workflow_action_updated) +def workflow_action_updated( + sender, + workflow_action: WorkflowAction, + user: AbstractUser, + **kwargs, +): + transaction.on_commit( + lambda: broadcast_to_permitted_users.delay( + workflow_action.page.builder.workspace_id, + ReadBuilderWorkflowActionOperationType.type, + BuilderWorkflowActionScopeType.type, + workflow_action.id, + { + "type": "workflow_action_updated", + "page_id": workflow_action.page_id, + "workflow_action": builder_workflow_action_type_registry.get_serializer( + workflow_action, BuilderWorkflowActionSerializer + ).data, + }, + getattr(user, "web_socket_id", None), + ) + ) + + +@receiver(workflow_action_signals.workflow_action_deleted) +def workflow_action_deleted( + sender, + workflow_action_id: int, + page: Page, + user: AbstractUser, + **kwargs, +): + transaction.on_commit( + lambda: broadcast_to_permitted_users.delay( + page.builder.workspace_id, + ListBuilderWorkflowActionsPageOperationType.type, + BuilderPageObjectScopeType.type, + page.id, + { + "type": "workflow_action_deleted", + "workflow_action_id": workflow_action_id, + "page_id": page.id, + }, + getattr(user, "web_socket_id", None), + ) + ) diff --git a/backend/src/baserow/contrib/database/fields/handler.py b/backend/src/baserow/contrib/database/fields/handler.py index f5c0178b18..5bb6464852 100644 --- a/backend/src/baserow/contrib/database/fields/handler.py +++ b/backend/src/baserow/contrib/database/fields/handler.py @@ -16,7 +16,7 @@ from django.conf import settings from django.contrib.auth.models import AbstractUser -from django.db import connection +from django.db import DEFAULT_DB_ALIAS, connection from django.db.models import Prefetch, QuerySet from django.db.utils import DatabaseError, DataError, ProgrammingError @@ -1179,7 +1179,11 @@ def update_field_select_options(self, user, field, select_options): ) if to_delete: - SelectOption.objects.filter(field=field, id__in=to_delete).delete() + # before_field_options_update already set deleted options to NULL, + # so we can safely call _raw_delete without violating any constraints. + SelectOption.objects.filter(field=field, id__in=to_delete)._raw_delete( + using=DEFAULT_DB_ALIAS + ) instance_to_create = [] for order, select_option in enumerate(select_options): diff --git a/backend/tests/baserow/contrib/builder/data_sources/test_data_source_service.py b/backend/tests/baserow/contrib/builder/data_sources/test_data_source_service.py index f898431ea8..2014e7c627 100644 --- a/backend/tests/baserow/contrib/builder/data_sources/test_data_source_service.py +++ b/backend/tests/baserow/contrib/builder/data_sources/test_data_source_service.py @@ -247,12 +247,13 @@ def exclude_data_source_1( def test_delete_data_source(data_source_deleted_mock, data_fixture): user = data_fixture.create_user() data_source = data_fixture.create_builder_data_source(user=user) + data_source_id = data_source.id service = DataSourceService() service.delete_data_source(user, data_source) data_source_deleted_mock.send.assert_called_once_with( - service, data_source_id=data_source.id, page=data_source.page, user=user + service, data_source_id=data_source_id, page=data_source.page, user=user ) diff --git a/backend/tests/baserow/contrib/builder/ws/test_ws_data_sources_signals.py b/backend/tests/baserow/contrib/builder/ws/test_ws_data_sources_signals.py new file mode 100644 index 0000000000..84acfda72b --- /dev/null +++ b/backend/tests/baserow/contrib/builder/ws/test_ws_data_sources_signals.py @@ -0,0 +1,100 @@ +from unittest.mock import patch + +import pytest + +from baserow.contrib.builder.data_sources.service import DataSourceService +from baserow.core.services.registries import service_type_registry + + +@pytest.mark.django_db(transaction=True) +@patch("baserow.contrib.builder.ws.data_sources.signals.broadcast_to_permitted_users") +def test_data_source_created(mock_broadcast_to_permitted_users, data_fixture): + user = data_fixture.create_user() + page = data_fixture.create_builder_page(user=user) + table = data_fixture.create_database_table(user=user) + integration = data_fixture.create_local_baserow_integration( + application=page.builder, user=user + ) + + service_type = service_type_registry.get("local_baserow_list_rows") + data_source = DataSourceService().create_data_source( + user=user, + page=page, + service_type=service_type, + integration=integration, + table=table, + ) + + mock_broadcast_to_permitted_users.delay.assert_called_once() + args = mock_broadcast_to_permitted_users.delay.call_args + assert args[0][4]["type"] == "data_source_created" + assert args[0][4]["data_source"]["id"] == data_source.id + assert args[0][4]["data_source"]["page_id"] == page.id + assert args[0][4]["before_id"] is None + + +@pytest.mark.django_db(transaction=True) +@patch("baserow.contrib.builder.ws.data_sources.signals.broadcast_to_permitted_users") +def test_data_source_created_before(mock_broadcast_to_permitted_users, data_fixture): + user = data_fixture.create_user() + page = data_fixture.create_builder_page(user=user) + existing_data_source = data_fixture.create_builder_data_source(page=page) + + service_type = service_type_registry.get("local_baserow_list_rows") + data_source = DataSourceService().create_data_source( + user=user, + page=page, + service_type=service_type, + before=existing_data_source, + ) + + mock_broadcast_to_permitted_users.delay.assert_called_once() + args = mock_broadcast_to_permitted_users.delay.call_args + assert args[0][4]["type"] == "data_source_created" + assert args[0][4]["data_source"]["id"] == data_source.id + assert args[0][4]["before_id"] == existing_data_source.id + + +@pytest.mark.django_db(transaction=True) +@patch("baserow.contrib.builder.ws.data_sources.signals.broadcast_to_permitted_users") +def test_data_source_updated(mock_broadcast_to_permitted_users, data_fixture): + user = data_fixture.create_user() + page = data_fixture.create_builder_page(user=user) + data_source = data_fixture.create_builder_local_baserow_list_rows_data_source( + page=page + ) + + service_type = service_type_registry.get("local_baserow_list_rows") + DataSourceService().update_data_source( + user=user, + data_source=data_source, + service_type=service_type, + name="Updated name", + ) + + mock_broadcast_to_permitted_users.delay.assert_called_once() + args = mock_broadcast_to_permitted_users.delay.call_args + + assert args[0][4]["type"] == "data_source_updated" + assert args[0][4]["data_source"]["id"] == data_source.id + assert args[0][4]["data_source"]["name"] == "Updated name" + + +@pytest.mark.django_db(transaction=True) +@patch("baserow.contrib.builder.ws.data_sources.signals.broadcast_to_permitted_users") +def test_data_source_deleted(mock_broadcast_to_permitted_users, data_fixture): + user = data_fixture.create_user() + page = data_fixture.create_builder_page(user=user) + data_source = data_fixture.create_builder_local_baserow_list_rows_data_source( + page=page + ) + data_source_id = data_source.id + + DataSourceService().delete_data_source(user=user, data_source=data_source) + + mock_broadcast_to_permitted_users.delay.assert_called_once() + args = mock_broadcast_to_permitted_users.delay.call_args + + assert args[0][4]["type"] == "data_source_deleted" + assert args[0][4]["data_source_id"] == data_source_id + assert args[0][4]["page_id"] == page.id diff --git a/backend/tests/baserow/contrib/builder/ws/test_ws_theme_signals.py b/backend/tests/baserow/contrib/builder/ws/test_ws_theme_signals.py new file mode 100644 index 0000000000..9345929824 --- /dev/null +++ b/backend/tests/baserow/contrib/builder/ws/test_ws_theme_signals.py @@ -0,0 +1,25 @@ +from unittest.mock import patch + +import pytest + +from baserow.contrib.builder.theme.service import ThemeService + + +@pytest.mark.django_db(transaction=True) +@patch("baserow.contrib.builder.ws.theme.signals.broadcast_to_permitted_users") +def test_theme_updated(mock_broadcast_to_permitted_users, data_fixture): + user = data_fixture.create_user() + builder = data_fixture.create_builder_application(user=user) + + ThemeService().update_theme( + user=user, + builder=builder, + primary_color="#ff0000", + ) + + mock_broadcast_to_permitted_users.delay.assert_called_once() + args = mock_broadcast_to_permitted_users.delay.call_args + + assert args[0][4]["type"] == "theme_updated" + assert args[0][4]["builder_id"] == builder.id + assert args[0][4]["properties"] == {"primary_color": "#ff0000"} diff --git a/backend/tests/baserow/contrib/builder/ws/test_ws_workflow_actions_signals.py b/backend/tests/baserow/contrib/builder/ws/test_ws_workflow_actions_signals.py new file mode 100644 index 0000000000..a13eb2bbc6 --- /dev/null +++ b/backend/tests/baserow/contrib/builder/ws/test_ws_workflow_actions_signals.py @@ -0,0 +1,86 @@ +from unittest.mock import patch + +import pytest + +from baserow.contrib.builder.workflow_actions.registries import ( + builder_workflow_action_type_registry, +) +from baserow.contrib.builder.workflow_actions.service import ( + BuilderWorkflowActionService, +) + + +@pytest.mark.django_db(transaction=True) +@patch( + "baserow.contrib.builder.ws.workflow_actions.signals.broadcast_to_permitted_users" +) +def test_workflow_action_created(mock_broadcast_to_permitted_users, data_fixture): + user = data_fixture.create_user() + page = data_fixture.create_builder_page(user=user) + element = data_fixture.create_builder_button_element(page=page) + + workflow_action_type = builder_workflow_action_type_registry.get("notification") + workflow_action = BuilderWorkflowActionService().create_workflow_action( + user=user, + workflow_action_type=workflow_action_type, + page=page, + element=element, + event="click", + ) + + mock_broadcast_to_permitted_users.delay.assert_called_once() + args = mock_broadcast_to_permitted_users.delay.call_args + assert args[0][4]["type"] == "workflow_action_created" + assert args[0][4]["workflow_action"]["id"] == workflow_action.id + assert args[0][4]["page_id"] == page.id + + +@pytest.mark.django_db(transaction=True) +@patch( + "baserow.contrib.builder.ws.workflow_actions.signals.broadcast_to_permitted_users" +) +def test_workflow_action_updated(mock_broadcast_to_permitted_users, data_fixture): + user = data_fixture.create_user() + page = data_fixture.create_builder_page(user=user) + element = data_fixture.create_builder_button_element(page=page) + workflow_action = data_fixture.create_notification_workflow_action( + page=page, element=element + ) + + BuilderWorkflowActionService().update_workflow_action( + user=user, + workflow_action=workflow_action, + title="'Updated title'", + ) + + mock_broadcast_to_permitted_users.delay.assert_called_once() + args = mock_broadcast_to_permitted_users.delay.call_args + + assert args[0][4]["type"] == "workflow_action_updated" + assert args[0][4]["workflow_action"]["id"] == workflow_action.id + assert args[0][4]["page_id"] == page.id + + +@pytest.mark.django_db(transaction=True) +@patch( + "baserow.contrib.builder.ws.workflow_actions.signals.broadcast_to_permitted_users" +) +def test_workflow_action_deleted(mock_broadcast_to_permitted_users, data_fixture): + user = data_fixture.create_user() + page = data_fixture.create_builder_page(user=user) + element = data_fixture.create_builder_button_element(page=page) + workflow_action = data_fixture.create_notification_workflow_action( + page=page, element=element + ) + workflow_action_id = workflow_action.id + + BuilderWorkflowActionService().delete_workflow_action( + user=user, workflow_action=workflow_action + ) + + mock_broadcast_to_permitted_users.delay.assert_called_once() + args = mock_broadcast_to_permitted_users.delay.call_args + + assert args[0][4]["type"] == "workflow_action_deleted" + assert args[0][4]["workflow_action_id"] == workflow_action_id + assert args[0][4]["page_id"] == page.id diff --git a/web-frontend/modules/builder/components/settings/ThemeSettings.vue b/web-frontend/modules/builder/components/settings/ThemeSettings.vue index a48c2c10b9..67a9c8d709 100644 --- a/web-frontend/modules/builder/components/settings/ThemeSettings.vue +++ b/web-frontend/modules/builder/components/settings/ThemeSettings.vue @@ -50,7 +50,6 @@ export default { methods: { ...mapActions({ setThemeProperty: 'theme/setProperty', - forceSetThemeProperty: 'theme/forceSetProperty', }), async update(newValues) { const differences = Object.fromEntries( diff --git a/web-frontend/modules/builder/realtime.js b/web-frontend/modules/builder/realtime.js index 2a3ee6bc22..c8a4c7273f 100644 --- a/web-frontend/modules/builder/realtime.js +++ b/web-frontend/modules/builder/realtime.js @@ -1,5 +1,22 @@ import { generateHash } from '@baserow/modules/core/utils/hashing' +/** + * Returns the matching page and builder for a given page ID. + * Checks both the selected page and the shared page. + */ +const getPageContext = (store, pageId) => { + const selectedPage = store.getters['page/getSelected'] + const builder = store.getters['application/get'](selectedPage?.builder_id) + if (!builder) return null // Sometimes we don't have the builder somehow + + const sharedPage = store.getters['page/getSharedPage'](builder) + const pages = [selectedPage, sharedPage] + const page = pages.find((p) => p?.id === pageId) + if (!page) return null + + return { page, builder, pages } +} + export const registerRealtimeEvents = (realtime) => { // Page events realtime.registerEvent('page_created', ({ store }, data) => { @@ -53,63 +70,51 @@ export const registerRealtimeEvents = (realtime) => { // Element events realtime.registerEvent('element_created', ({ store }, data) => { - const selectedPage = store.getters['page/getSelected'] - if (selectedPage.id === data.element.page_id) { - store.dispatch('element/forceCreate', { - page: selectedPage, - element: data.element, - beforeId: data.before_id, - }) - } + const ctx = getPageContext(store, data.element.page_id) + if (!ctx) return + + store.dispatch('element/forceCreate', { + page: ctx.page, + element: data.element, + beforeId: data.before_id, + }) }) realtime.registerEvent('element_deleted', ({ store }, data) => { - const selectedPage = store.getters['page/getSelected'] - if (selectedPage.id === data.page_id) { - const builder = store.getters['application/get'](selectedPage.builder_id) - if (builder) { - // Sometimes we don't have the builder somehow - store.dispatch('element/forceDelete', { - builder, - page: selectedPage, - elementId: data.element_id, - }) - } - } + const ctx = getPageContext(store, data.page_id) + if (!ctx) return + + store.dispatch('element/forceDelete', { + builder: ctx.builder, + page: ctx.page, + elementId: data.element_id, + }) }) realtime.registerEvent('element_updated', ({ store }, { element }) => { - const selectedPage = store.getters['page/getSelected'] - if (selectedPage.id === element.page_id) { - const builder = store.getters['application/get'](selectedPage.builder_id) - if (builder) { - // Sometimes we don't have the builder somehow - store.dispatch('element/forceUpdate', { - builder, - page: selectedPage, - element, - values: element, - }) - } - } + const ctx = getPageContext(store, element.page_id) + if (!ctx) return + + store.dispatch('element/forceUpdate', { + builder: ctx.builder, + page: ctx.page, + element, + values: element, + }) }) realtime.registerEvent('element_moved', ({ store }, data) => { - const selectedPage = store.getters['page/getSelected'] - if (selectedPage.id === data.page_id) { - const builder = store.getters['application/get'](selectedPage.builder_id) - if (builder) { - // Sometimes we don't have the builder somehow - store.dispatch('element/forceMove', { - builder, - page: selectedPage, - elementId: data.element_id, - beforeElementId: data.before_id, - parentElementId: data.parent_element_id, - placeInContainer: data.place_in_container, - }) - } - } + const ctx = getPageContext(store, data.page_id) + if (!ctx) return + + store.dispatch('element/forceMove', { + builder: ctx.builder, + page: ctx.page, + elementId: data.element_id, + beforeElementId: data.before_id, + parentElementId: data.parent_element_id, + placeInContainer: data.place_in_container, + }) }) realtime.registerEvent( @@ -128,26 +133,102 @@ export const registerRealtimeEvents = (realtime) => { } ) - realtime.registerEvent('elements_moved', ({ store, app }, { elements }) => { + realtime.registerEvent('elements_moved', ({ store }, { elements }) => { elements.forEach((element) => { - const selectedPage = store.getters['page/getSelected'] - if (selectedPage.id === element.page_id) { - const builder = store.getters['application/get']( - selectedPage.builder_id - ) - if (builder) { - // Sometimes we don't have the builder somehow - store.dispatch('element/forceUpdate', { - builder, - page: selectedPage, - element, - values: { - order: element.order, - place_in_container: element.place_in_container, - }, - }) - } - } + const ctx = getPageContext(store, element.page_id) + if (!ctx) return + + store.dispatch('element/forceUpdate', { + builder: ctx.builder, + page: ctx.page, + element, + values: { + order: element.order, + place_in_container: element.place_in_container, + }, + }) + }) + }) + + // Data source events + realtime.registerEvent('data_source_created', ({ store }, data) => { + const ctx = getPageContext(store, data.data_source.page_id) + if (!ctx) return + + store.dispatch('dataSource/forceCreate', { + page: ctx.page, + dataSource: data.data_source, + beforeId: data.before_id, + }) + }) + + realtime.registerEvent('data_source_updated', ({ store }, data) => { + const ctx = getPageContext(store, data.data_source.page_id) + if (!ctx) return + + const dataSource = store.getters['dataSource/getPagesDataSourceById']( + ctx.pages, + data.data_source.id + ) + if (!dataSource) return + + store.dispatch('dataSource/forceUpdate', { + page: ctx.page, + dataSource, + values: data.data_source, + }) + }) + + realtime.registerEvent('data_source_deleted', ({ store }, data) => { + const ctx = getPageContext(store, data.page_id) + if (!ctx) return + + store.dispatch('dataSource/forceDelete', { + page: ctx.page, + dataSourceId: data.data_source_id, + }) + }) + + // Workflow action events + realtime.registerEvent('workflow_action_created', ({ store }, data) => { + const ctx = getPageContext(store, data.page_id) + if (!ctx) return + + store.dispatch('builderWorkflowAction/forceCreate', { + page: ctx.page, + workflowAction: data.workflow_action, + }) + }) + + realtime.registerEvent('workflow_action_updated', ({ store }, data) => { + const ctx = getPageContext(store, data.page_id) + if (!ctx) return + + store.dispatch('builderWorkflowAction/forceUpdate', { + page: ctx.page, + workflowAction: data.workflow_action, + values: data.workflow_action, + }) + }) + + realtime.registerEvent('workflow_action_deleted', ({ store }, data) => { + const ctx = getPageContext(store, data.page_id) + if (!ctx) return + + store.dispatch('builderWorkflowAction/forceDelete', { + page: ctx.page, + workflowActionId: data.workflow_action_id, + }) + }) + + // Theme events + realtime.registerEvent('theme_updated', ({ store }, data) => { + const builder = store.getters['application/get'](data.builder_id) + if (!builder) return + + store.dispatch('theme/forceUpdate', { + builder, + values: data.properties, }) }) } diff --git a/web-frontend/modules/builder/store/dataSource.js b/web-frontend/modules/builder/store/dataSource.js index 88ea67bf55..f355966a5a 100644 --- a/web-frontend/modules/builder/store/dataSource.js +++ b/web-frontend/modules/builder/store/dataSource.js @@ -55,8 +55,7 @@ const mutations = { ) if (index > -1) { - const moved = structuredClone(pageSource.dataSources[index]) - moved.page_id = pageDest.id + const moved = { ...pageSource.dataSources[index], page_id: pageDest.id } pageSource.dataSources.splice(index, 1) pageDest.dataSources.push(moved) } diff --git a/web-frontend/modules/builder/store/theme.js b/web-frontend/modules/builder/store/theme.js index 36b584b092..522f2a7b2a 100644 --- a/web-frontend/modules/builder/store/theme.js +++ b/web-frontend/modules/builder/store/theme.js @@ -9,8 +9,10 @@ let patchRequestProperties = {} let patchRequestOldProperties = {} const mutations = { - UPDATE_PROPERTY(state, { builder, key, value }) { - builder.theme[key] = value + UPDATE(state, { builder, values }) { + Object.keys(values).forEach((key) => { + builder.theme[key] = values[key] + }) }, } @@ -45,10 +47,9 @@ const actions = { resolve() } catch (error) { Object.keys(oldProperties).forEach((key) => { - commit('UPDATE_PROPERTY', { + commit('UPDATE', { builder, - key, - value: oldProperties[key], + values: { [key]: oldProperties[key] }, }) }) reject(error) @@ -63,16 +64,16 @@ const actions = { patchRequestOldProperties[key] = builder.theme[key] } patchRequestProperties[key] = value - commit('UPDATE_PROPERTY', { builder, key, value }) + commit('UPDATE', { builder, values: { [key]: value } }) }) }, /** - * Immediately updates the provided property, but it will not make a request to - * the backend. This is used to visually update an invalid value, or for real-time - * collaboration. + * Immediately updates the provided values, but it will not make a request to + * the backend. This is used to visually update multiple invalid values, or + * for real-time collaboration. */ - forceSetProperty({ commit }, { builder, key, value }) { - commit('UPDATE_PROPERTY', { builder, key, value }) + forceUpdate({ commit }, { builder, values }) { + commit('UPDATE', { builder, values }) }, } diff --git a/web-frontend/modules/core/plugins/realTimeHandler.js b/web-frontend/modules/core/plugins/realTimeHandler.js index 5bf3f6b9be..e03f54b2e8 100644 --- a/web-frontend/modules/core/plugins/realTimeHandler.js +++ b/web-frontend/modules/core/plugins/realTimeHandler.js @@ -92,7 +92,9 @@ export class RealTimeHandler { Object.prototype.hasOwnProperty.call(data, 'type') && Object.prototype.hasOwnProperty.call(this.events, data.type) ) { - this.events[data.type](this.context, data) + for (const callback of this.events[data.type]) { + callback(this.context, data) + } } } @@ -228,7 +230,10 @@ export class RealTimeHandler { * Registers a new event with the event registry. */ registerEvent(type, callback) { - this.events[type] = callback + if (!this.events[type]) { + this.events[type] = [] + } + this.events[type].push(callback) } /** From 77cbc86cf6a22c297aa8b396636727a5ed269949 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:49:57 +0100 Subject: [PATCH 3/6] Bump debugpy from 1.8.19 to 1.8.20 in /backend (#4630) Bumps [debugpy](https://github.com/microsoft/debugpy) from 1.8.19 to 1.8.20. - [Release notes](https://github.com/microsoft/debugpy/releases) - [Commits](https://github.com/microsoft/debugpy/compare/v1.8.19...v1.8.20) --- updated-dependencies: - dependency-name: debugpy dependency-version: 1.8.20 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- backend/pyproject.toml | 2 +- backend/uv.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8e3eac2ff7..1667458152 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -140,7 +140,7 @@ dev = [ "coverage==7.13.1", "pytest-split==0.10.0", "pytest-unordered==0.7.0", - "debugpy==1.8.19", + "debugpy==1.8.20", "backports.cached-property==1.0.2", "httpretty==1.1.4", "graphviz==0.21", diff --git a/backend/uv.lock b/backend/uv.lock index 9c52e9d19c..d15268fd9e 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -451,7 +451,7 @@ dev = [ { name = "backports-cached-property", specifier = "==1.0.2" }, { name = "build" }, { name = "coverage", specifier = "==7.13.1" }, - { name = "debugpy", specifier = "==1.8.19" }, + { name = "debugpy", specifier = "==1.8.20" }, { name = "django-extensions", specifier = "==4.1" }, { name = "django-silk", specifier = "==5.4.3" }, { name = "django-stubs", specifier = "==5.2.8" }, @@ -857,13 +857,13 @@ wheels = [ [[package]] name = "debugpy" -version = "1.8.19" +version = "1.8.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/75/9e12d4d42349b817cd545b89247696c67917aab907012ae5b64bbfea3199/debugpy-1.8.19.tar.gz", hash = "sha256:eea7e5987445ab0b5ed258093722d5ecb8bb72217c5c9b1e21f64efe23ddebdb", size = 1644590, upload-time = "2025-12-15T21:53:28.044Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/cd8080344452e4874aae67c40d8940e2b4d47b01601a8fd9f44786c757c7/debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33", size = 1645207, upload-time = "2026-01-29T23:03:28.199Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/e2/48531a609b5a2aa94c6b6853afdfec8da05630ab9aaa96f1349e772119e9/debugpy-1.8.19-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:c5dcfa21de1f735a4f7ced4556339a109aa0f618d366ede9da0a3600f2516d8b", size = 2207620, upload-time = "2025-12-15T21:53:37.1Z" }, - { url = "https://files.pythonhosted.org/packages/1b/d4/97775c01d56071969f57d93928899e5616a4cfbbf4c8cc75390d3a51c4a4/debugpy-1.8.19-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:806d6800246244004625d5222d7765874ab2d22f3ba5f615416cf1342d61c488", size = 3170796, upload-time = "2025-12-15T21:53:38.513Z" }, - { url = "https://files.pythonhosted.org/packages/25/3e/e27078370414ef35fafad2c06d182110073daaeb5d3bf734b0b1eeefe452/debugpy-1.8.19-py2.py3-none-any.whl", hash = "sha256:360ffd231a780abbc414ba0f005dad409e71c78637efe8f2bd75837132a41d38", size = 5292321, upload-time = "2025-12-15T21:54:16.024Z" }, + { url = "https://files.pythonhosted.org/packages/51/56/c3baf5cbe4dd77427fd9aef99fcdade259ad128feeb8a786c246adb838e5/debugpy-1.8.20-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:eada6042ad88fa1571b74bd5402ee8b86eded7a8f7b827849761700aff171f1b", size = 2208318, upload-time = "2026-01-29T23:03:36.481Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7d/4fa79a57a8e69fe0d9763e98d1110320f9ecd7f1f362572e3aafd7417c9d/debugpy-1.8.20-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:7de0b7dfeedc504421032afba845ae2a7bcc32ddfb07dae2c3ca5442f821c344", size = 3171493, upload-time = "2026-01-29T23:03:37.775Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c3/7f67dea8ccf8fdcb9c99033bbe3e90b9e7395415843accb81428c441be2d/debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7", size = 5337658, upload-time = "2026-01-29T23:04:17.404Z" }, ] [[package]] From 16974899cc5b7e1722256dabb71678357dd78181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20St=C5=99=C3=ADbn=C3=BD?= Date: Tue, 3 Feb 2026 14:00:05 +0100 Subject: [PATCH 4/6] Skip nonexisting jobs in run_export_job task (#4641) --- backend/src/baserow/contrib/database/export/tasks.py | 9 +++++++-- .../contrib/database/export/test_export_tasks.py | 11 +++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 backend/tests/baserow/contrib/database/export/test_export_tasks.py diff --git a/backend/src/baserow/contrib/database/export/tasks.py b/backend/src/baserow/contrib/database/export/tasks.py index a3fe02fd1e..8c582abf73 100644 --- a/backend/src/baserow/contrib/database/export/tasks.py +++ b/backend/src/baserow/contrib/database/export/tasks.py @@ -2,6 +2,8 @@ from django.conf import settings +from celery.exceptions import Ignore + from baserow.config.celery import app EXPORT_SOFT_TIME_LIMIT = 60 * 60 @@ -23,8 +25,11 @@ def run_export_job(self, job_id): from baserow.contrib.database.export.handler import ExportHandler from baserow.contrib.database.export.models import ExportJob - job = ExportJob.objects.get(id=job_id) - ExportHandler.run_export_job(job) + try: + job = ExportJob.objects.get(id=job_id) + ExportHandler.run_export_job(job) + except ExportJob.DoesNotExist: + raise Ignore("Task obsolete") # noinspection PyUnusedLocal diff --git a/backend/tests/baserow/contrib/database/export/test_export_tasks.py b/backend/tests/baserow/contrib/database/export/test_export_tasks.py new file mode 100644 index 0000000000..7fd953b660 --- /dev/null +++ b/backend/tests/baserow/contrib/database/export/test_export_tasks.py @@ -0,0 +1,11 @@ +import pytest +from celery.exceptions import Ignore + +from baserow.contrib.database.export.tasks import run_export_job + + +@pytest.mark.django_db +def test_run_export_job_skips_nonexisting_jobs(): + non_existing_job_id = 999 + with pytest.raises(Ignore): + run_export_job(non_existing_job_id) From 8247a3cd27e67fc5a652305f530b86f51e1f90b1 Mon Sep 17 00:00:00 2001 From: Davide Silvestri <75379892+silvestrid@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:29:51 +0100 Subject: [PATCH 5/6] chore: remove npm dependencies for email_compiler and e2e-tests (#4639) Removed npm package ecosystems for email_compiler and e2e-tests. --- .github/dependabot.yml | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9390bb3045..2bce7b7248 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,17 +11,7 @@ updates: - package-ecosystem: "npm" directory: "/web-frontend" schedule: - interval: "daily" - - - package-ecosystem: "npm" - directory: "/backend/email_compiler" - schedule: - interval: "daily" - - - package-ecosystem: "npm" - directory: "/e2e-tests" - schedule: - interval: "daily" + interval: "monthly" # ============================================================================= # uv (Python) ecosystem @@ -35,13 +25,4 @@ updates: - package-ecosystem: "uv" directory: "/backend" schedule: - interval: "daily" - - # ============================================================================= - # GitHub Actions - # ============================================================================= - - - package-ecosystem: "github-actions" - directory: "/.github/workflows" - schedule: - interval: "daily" + interval: "monthly" From 6de2e414a80291cea038689285f2bfdc1e6419a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:33:52 +0100 Subject: [PATCH 6/6] Bump django-stubs-ext from 5.2.8 to 5.2.9 in /backend (#4638) Bumps [django-stubs-ext](https://github.com/sponsors/typeddjango) from 5.2.8 to 5.2.9. - [Commits](https://github.com/sponsors/typeddjango/commits) --- updated-dependencies: - dependency-name: django-stubs-ext dependency-version: 5.2.9 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- backend/pyproject.toml | 2 +- backend/uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 1667458152..10d9c101d6 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -146,7 +146,7 @@ dev = [ "graphviz==0.21", "pytest-cov==7.0.0", "django-stubs==5.2.8", - "django-stubs-ext==5.2.8", + "django-stubs-ext==5.2.9", "mypy==1.19.1", "mypy-extensions==1.1.0", "ipython", diff --git a/backend/uv.lock b/backend/uv.lock index d15268fd9e..fcedcb82f6 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -455,7 +455,7 @@ dev = [ { name = "django-extensions", specifier = "==4.1" }, { name = "django-silk", specifier = "==5.4.3" }, { name = "django-stubs", specifier = "==5.2.8" }, - { name = "django-stubs-ext", specifier = "==5.2.8" }, + { name = "django-stubs-ext", specifier = "==5.2.9" }, { name = "fakeredis", extras = ["lua"], specifier = "==2.33.0" }, { name = "freezegun", specifier = "==1.5.5" }, { name = "graphviz", specifier = "==0.21" }, @@ -1084,15 +1084,15 @@ wheels = [ [[package]] name = "django-stubs-ext" -version = "5.2.8" +version = "5.2.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/14/a2/d67f4a5200ff7626b104eddceaf529761cba4ed318a73ffdb0677551be73/django_stubs_ext-5.2.8.tar.gz", hash = "sha256:b39938c46d7a547cd84e4a6378dbe51a3dd64d70300459087229e5fee27e5c6b", size = 6487, upload-time = "2025-12-01T08:12:37.486Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/03/9c2be939490d2282328db4611bc5956899f5ff7eabc3e88bd4b964a87373/django_stubs_ext-5.2.9.tar.gz", hash = "sha256:6db4054d1580657b979b7d391474719f1a978773e66c7070a5e246cd445a25a9", size = 6497, upload-time = "2026-01-20T23:58:59.462Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/2d/cb0151b780c3730cf0f2c0fcb1b065a5e88f877cf7a9217483c375353af1/django_stubs_ext-5.2.8-py3-none-any.whl", hash = "sha256:1dd5470c9675591362c78a157a3cf8aec45d0e7a7f0cf32f227a1363e54e0652", size = 9949, upload-time = "2025-12-01T08:12:36.397Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f7/0d5f7d7e76fe972d9f560f687fdc0cab4db9e1624fd90728ca29b4ed7a63/django_stubs_ext-5.2.9-py3-none-any.whl", hash = "sha256:230c51575551b0165be40177f0f6805f1e3ebf799b835c85f5d64c371ca6cf71", size = 9974, upload-time = "2026-01-20T23:58:58.438Z" }, ] [[package]]