Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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'),
),
],
),
]
19 changes: 8 additions & 11 deletions backend/src/baserow/core/notifications/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)

Expand Down
8 changes: 4 additions & 4 deletions backend/src/baserow/core/notifications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
]

Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
9 changes: 9 additions & 0 deletions changelog/entries/unreleased/bug/parsed_data_reload.json
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
)
Expand Down
Loading