diff --git a/backend/src/baserow/core/migrations/0113_alter_notification_options_and_more.py b/backend/src/baserow/core/migrations/0113_alter_notification_options_and_more.py new file mode 100644 index 0000000000..0d34ba2d0e --- /dev/null +++ b/backend/src/baserow/core/migrations/0113_alter_notification_options_and_more.py @@ -0,0 +1,53 @@ +# Generated by Django 5.2.11 on 2026-02-23 09:50 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [ + ('core', '0112_alter_userprofile_language'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='notification', + options={'ordering': ['-created_on', '-id']}, + ), + migrations.AlterModelOptions( + name='notificationrecipient', + options={'ordering': ['-created_on', '-id']}, + ), + migrations.RemoveIndex( + model_name='notification', + name='core_notifi_created_8ed1ac_idx', + ), + migrations.RemoveIndex( + model_name='notificationrecipient', + name='core_notifi_created_06e5e6_idx', + ), + migrations.RunSQL( + sql='CREATE INDEX CONCURRENTLY "core_notifi_created_7f4b88_idx" ON "core_notification" ("created_on" DESC, "id" DESC)', + reverse_sql='DROP INDEX IF EXISTS "core_notifi_created_7f4b88_idx"', + state_operations=[ + migrations.AddIndex( + model_name='notification', + index=models.Index(fields=['-created_on', '-id'], name='core_notifi_created_7f4b88_idx'), + ), + ], + ), + migrations.RunSQL( + sql='CREATE INDEX CONCURRENTLY "core_notifi_created_4b2233_idx" ON "core_notificationrecipient" ("created_on" DESC, "id" DESC)', + reverse_sql='DROP INDEX IF EXISTS "core_notifi_created_4b2233_idx"', + state_operations=[ + migrations.AddIndex( + model_name='notificationrecipient', + index=models.Index(fields=['-created_on', '-id'], name='core_notifi_created_4b2233_idx'), + ), + ], + ), + ] diff --git a/backend/src/baserow/core/notifications/handler.py b/backend/src/baserow/core/notifications/handler.py index 01bc03984d..5c60163578 100644 --- a/backend/src/baserow/core/notifications/handler.py +++ b/backend/src/baserow/core/notifications/handler.py @@ -691,20 +691,17 @@ def filter_and_annotate_users_with_notifications_to_send_by_email( if not limit_notifications_per_user: limit_notifications_per_user = settings.MAX_NOTIFICATIONS_LISTED_PER_EMAIL - unsent_notification_subquery = Subquery( - NotificationRecipient.objects.filter( - recipient_id=OuterRef("notificationrecipient__recipient_id"), - **NOTIFICATIONS_WITH_EMAIL_SCHEDULED_FILTERS, - ) - .order_by("-created_on") - .values_list("notification_id", flat=True)[:limit_notifications_per_user] - ) - notifications_to_send_by_email_prefetch = Prefetch( "notifications", - queryset=Notification.objects.filter(id__in=unsent_notification_subquery) + queryset=Notification.objects.filter( + **{ + f"notificationrecipient__{k}": v + for k, v in NOTIFICATIONS_WITH_EMAIL_SCHEDULED_FILTERS.items() + }, + ) .distinct() - .select_related("sender"), + .select_related("sender") + .order_by("-created_on", "-id")[:limit_notifications_per_user], to_attr="unsent_email_notifications", ) diff --git a/backend/src/baserow/core/notifications/models.py b/backend/src/baserow/core/notifications/models.py index 3a199811ce..e465eab3d7 100644 --- a/backend/src/baserow/core/notifications/models.py +++ b/backend/src/baserow/core/notifications/models.py @@ -63,9 +63,9 @@ def web_frontend_url(self): return notification_type.get_web_frontend_url(self) class Meta: - ordering = ["-created_on"] + ordering = ["-created_on", "-id"] indexes = [ - models.Index(fields=["-created_on"]), + models.Index(fields=["-created_on", "-id"]), GinIndex(name="notification_data", fields=["data"]), ] @@ -128,10 +128,10 @@ class NotificationRecipient(models.Model): ) class Meta: - ordering = ["-created_on"] + ordering = ["-created_on", "-id"] unique_together = ("notification", "recipient") indexes = [ - models.Index(fields=["-created_on"]), + models.Index(fields=["-created_on", "-id"]), models.Index( fields=[ "broadcast", diff --git a/backend/tests/baserow/contrib/database/field/test_field_notification_types.py b/backend/tests/baserow/contrib/database/field/test_field_notification_types.py index ad58503e7a..42d6641583 100644 --- a/backend/tests/baserow/contrib/database/field/test_field_notification_types.py +++ b/backend/tests/baserow/contrib/database/field/test_field_notification_types.py @@ -297,8 +297,8 @@ def test_notifications_are_grouped_when_user_is_added_to_multiple_rows( }, "workspace": {"id": workspace.id}, "data": { - "row_id": row_1_id, - "row_name": f"unnamed row {row_1_id}", + "row_id": row_2_id, + "row_name": f"unnamed row {row_2_id}", "field_id": collaborator_field.id, "field_name": collaborator_field.name, "table_id": table.id, @@ -319,8 +319,8 @@ def test_notifications_are_grouped_when_user_is_added_to_multiple_rows( }, "workspace": {"id": workspace.id}, "data": { - "row_id": row_2_id, - "row_name": f"unnamed row {row_2_id}", + "row_id": row_1_id, + "row_name": f"unnamed row {row_1_id}", "field_id": collaborator_field.id, "field_name": collaborator_field.name, "table_id": table.id, diff --git a/backend/tests/baserow/core/notifications/test_notifications_handler.py b/backend/tests/baserow/core/notifications/test_notifications_handler.py index 5420b9c944..f63bce024e 100644 --- a/backend/tests/baserow/core/notifications/test_notifications_handler.py +++ b/backend/tests/baserow/core/notifications/test_notifications_handler.py @@ -904,3 +904,82 @@ def get_notification_description_for_email(cls, notification, context): assert len(ctx_large) == len(ctx_small) finally: notification_type_registry.unregister(SenderAccessingNotification.type) + + +@pytest.mark.django_db(transaction=True) +@patch("baserow.core.notifications.handler.get_mail_connection") +@override_settings(MAX_NOTIFICATIONS_LISTED_PER_EMAIL=3) +def test_email_notifications_per_user_limit_with_multiple_users( + mock_get_mail_connection, data_fixture, mutable_notification_type_registry +): + """ + When multiple users each have more unsent notifications than the per-user + limit, every user must receive their own top N notifications (not a global + top-N) and their own correct total_unsent_count. + """ + + mock_connection = MagicMock() + mock_get_mail_connection.return_value = mock_connection + limit = settings.MAX_NOTIFICATIONS_LISTED_PER_EMAIL # 3 + + with custom_notification_types_registered() as (TestNotification, _): + user_1 = data_fixture.create_user() + user_2 = data_fixture.create_user() + + # Create 5 notifications for user_1 (each is a separate Notification) + user_1_notifications = [] + for _ in range(5): + n = data_fixture.create_notification_for_users( + recipients=[user_1], notification_type=TestNotification.type + ) + user_1_notifications.append(n) + + # Create 4 notifications for user_2 + user_2_notifications = [] + for _ in range(4): + n = data_fixture.create_notification_for_users( + recipients=[user_2], notification_type=TestNotification.type + ) + user_2_notifications.append(n) + + res = NotificationHandler.send_unread_notifications_by_email_to_users_matching_filters( + Q(pk__in=[user_1.pk, user_2.pk]) + ) + + # Both users must appear in the result. + result_by_user = {u.id: u for u in res.users_with_notifications} + assert set(result_by_user.keys()) == {user_1.id, user_2.id} + + # --- user_1: 5 total, top 3 shown --- + u1 = result_by_user[user_1.id] + assert len(u1.unsent_email_notifications) == limit + assert u1.total_unsent_count == 5 + # The 3 most recent notifications for user_1 (highest id = most recent) + expected_u1_ids = {n.id for n in user_1_notifications[-limit:]} + actual_u1_ids = {n.id for n in u1.unsent_email_notifications} + assert actual_u1_ids == expected_u1_ids + + # --- user_2: 4 total, top 3 shown --- + u2 = result_by_user[user_2.id] + assert len(u2.unsent_email_notifications) == limit + assert u2.total_unsent_count == 4 + expected_u2_ids = {n.id for n in user_2_notifications[-limit:]} + actual_u2_ids = {n.id for n in u2.unsent_email_notifications} + assert actual_u2_ids == expected_u2_ids + + # Verify two separate emails were sent, one per user. + mock_get_mail_connection.assert_called_once_with(fail_silently=False) + summary_emails = mock_connection.send_messages.call_args[0][0] + assert len(summary_emails) == 2 + + email_by_recipient = {e.to[0]: e for e in summary_emails} + + u1_email = email_by_recipient[user_1.email] + assert u1_email.get_subject() == "You have 5 new notifications - Baserow" + assert u1_email.get_context()["new_notifications_count"] == 5 + assert u1_email.get_context()["unlisted_notifications_count"] == 2 + + u2_email = email_by_recipient[user_2.email] + assert u2_email.get_subject() == "You have 4 new notifications - Baserow" + assert u2_email.get_context()["new_notifications_count"] == 4 + assert u2_email.get_context()["unlisted_notifications_count"] == 1 diff --git a/changelog/entries/unreleased/bug/fix_deleting_rows_with_filters_hidden_fields.json b/changelog/entries/unreleased/bug/fix_deleting_rows_with_filters_hidden_fields.json new file mode 100644 index 0000000000..4af164595b --- /dev/null +++ b/changelog/entries/unreleased/bug/fix_deleting_rows_with_filters_hidden_fields.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Fixed deleting multiple rows with active filters and hidden filtered field.", + "issue_origin": "github", + "issue_number": null, + "domain": "core", + "bullet_points": [], + "created_at": "2026-02-23" +} diff --git a/changelog/entries/unreleased/bug/fix_slow_query_fetching_notifications.json b/changelog/entries/unreleased/bug/fix_slow_query_fetching_notifications.json new file mode 100644 index 0000000000..d4bea8cad9 --- /dev/null +++ b/changelog/entries/unreleased/bug/fix_slow_query_fetching_notifications.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Fix slow query fetching notifications to send.", + "issue_origin": "github", + "issue_number": null, + "domain": "core", + "bullet_points": [], + "created_at": "2026-02-20" +} diff --git a/changelog/entries/unreleased/bug/parsed_data_reload.json b/changelog/entries/unreleased/bug/parsed_data_reload.json new file mode 100644 index 0000000000..3b6d9ca112 --- /dev/null +++ b/changelog/entries/unreleased/bug/parsed_data_reload.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Handle missing parsedData in reloadPreview().", + "issue_origin": "github", + "issue_number": null, + "domain": "database", + "bullet_points": [], + "created_at": "2026-02-20" +} diff --git a/web-frontend/modules/database/components/table/TableCSVImporter.vue b/web-frontend/modules/database/components/table/TableCSVImporter.vue index f99ac0f74c..4a4426610a 100644 --- a/web-frontend/modules/database/components/table/TableCSVImporter.vue +++ b/web-frontend/modules/database/components/table/TableCSVImporter.vue @@ -316,6 +316,10 @@ export default { * Reload the preview without re-parsing the raw data. */ reloadPreview() { + if (!Array.isArray(this.parsedData) || this.parsedData.length === 0) { + return + } + const [rawHeader, ...rawData] = this.firstRowHeader ? this.parsedData : [[], ...this.parsedData] diff --git a/web-frontend/modules/database/components/view/grid/GridView.vue b/web-frontend/modules/database/components/view/grid/GridView.vue index d6229f8969..88d236600a 100644 --- a/web-frontend/modules/database/components/view/grid/GridView.vue +++ b/web-frontend/modules/database/components/view/grid/GridView.vue @@ -1635,7 +1635,7 @@ export default { { table: this.table, view: this.view, - fields: this.allVisibleFields, + fields: this.fields, getScrollTop: () => this.$refs.left.$refs.body.scrollTop, } )