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
4 changes: 2 additions & 2 deletions backend/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -576,11 +576,11 @@ alias mk := makemigrations

# Open Django shell with shell_plus and SQL logging. Pass --print-sql to enable SQL query logging.
[group('2 - development')]
shell_plus: _check-dev
shell_plus *args: _check-dev
#!/usr/bin/env bash
set -euo pipefail
{{ _load_env }}
{{ uv_run }} baserow shell_plus
{{ uv_run }} baserow shell_plus {{ args }}

alias sp := shell_plus

Expand Down
6 changes: 3 additions & 3 deletions backend/src/baserow/core/notifications/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,9 +702,9 @@ def filter_and_annotate_users_with_notifications_to_send_by_email(

notifications_to_send_by_email_prefetch = Prefetch(
"notifications",
queryset=Notification.objects.filter(
id__in=unsent_notification_subquery
).distinct(),
queryset=Notification.objects.filter(id__in=unsent_notification_subquery)
.distinct()
.select_related("sender"),
to_attr="unsent_email_notifications",
)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from unittest.mock import MagicMock, call, patch

from django.conf import settings
from django.db import connection
from django.db.models import Q
from django.test import override_settings
from django.test.utils import CaptureQueriesContext

import pytest

Expand All @@ -14,6 +16,11 @@
UserNotificationsGrouper,
)
from baserow.core.notifications.models import Notification, NotificationRecipient
from baserow.core.notifications.registries import (
EmailNotificationTypeMixin,
NotificationType,
notification_type_registry,
)
from baserow.core.user.handler import UserHandler

from .utils import custom_notification_types_registered
Expand Down Expand Up @@ -813,3 +820,87 @@ def test_email_notifications_are_not_sent_if_already_cleared_by_user(
assert res.remaining_users_to_notify_count == 0

mock_get_mail_connection.assert_not_called()


@pytest.mark.django_db(transaction=True)
@patch("baserow.core.notifications.handler.get_mail_connection")
def test_email_notifications_queries_are_limited(
mock_get_mail_connection,
data_fixture,
mutable_notification_type_registry,
):
"""
The query count for sending notification emails must not scale with the
number of notifications or the number of users. In particular the
prefetch must include select_related("sender") so that accessing
notification.sender during email rendering doesn't cause one query per
notification.
"""

mock_connection = MagicMock()
mock_get_mail_connection.return_value = mock_connection

class SenderAccessingNotification(EmailNotificationTypeMixin, NotificationType):
type = "test_sender_accessing_notification"

@classmethod
def get_notification_title_for_email(cls, notification, context):
return f"Notification from {notification.sender.first_name}"

@classmethod
def get_notification_description_for_email(cls, notification, context):
return None

notification_type_registry.register(SenderAccessingNotification())
try:
sender = data_fixture.create_user(first_name="Sender")

# --- Warm-up run to prime internal caches (license cache, etc.) ---
warmup_user = data_fixture.create_user()
data_fixture.create_notification_for_users(
recipients=[warmup_user],
sender=sender,
notification_type=SenderAccessingNotification.type,
)
NotificationHandler.send_unread_notifications_by_email_to_users_matching_filters(
Q(pk=warmup_user.pk)
)
mock_get_mail_connection.reset_mock()
mock_connection.reset_mock()

# --- Run 1: 1 user, 1 notification ---
user_a = data_fixture.create_user()
data_fixture.create_notification_for_users(
recipients=[user_a],
sender=sender,
notification_type=SenderAccessingNotification.type,
)

with CaptureQueriesContext(connection) as ctx_small:
NotificationHandler.send_unread_notifications_by_email_to_users_matching_filters(
Q(pk=user_a.pk)
)
assert len(ctx_small) # sanity

# --- Run 2: 2 users, 5 notifications each ---
mock_get_mail_connection.reset_mock()
mock_connection.reset_mock()

user_b = data_fixture.create_user()
user_c = data_fixture.create_user()
for _ in range(5):
data_fixture.create_notification_for_users(
recipients=[user_b, user_c],
sender=sender,
notification_type=SenderAccessingNotification.type,
)

with CaptureQueriesContext(connection) as ctx_large:
NotificationHandler.send_unread_notifications_by_email_to_users_matching_filters(
Q(pk__in=[user_b.pk, user_c.pk])
)

# Query count must be identical: no N+1 on sender, users, or notifications.
assert len(ctx_large) == len(ctx_small)
finally:
notification_type_registry.unregister(SenderAccessingNotification.type)
38 changes: 35 additions & 3 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,9 @@ services:
# reducing the chance the containers screw up the bind mounted folders.
UID: $UID
GID: $GID
command: "nuxt-dev-with-storybook"
command: "nuxt-dev"
ports:
- "${HOST_PUBLISH_IP:-127.0.0.1}:3000:3000"
- "${HOST_PUBLISH_IP:-127.0.0.1}:6006:6006"
volumes:
- ./web-frontend:/baserow/web-frontend
- ./premium/web-frontend/:/baserow/premium/web-frontend
Expand All @@ -79,12 +78,45 @@ services:
# overwriting existing node_modules.
- node_modules:/baserow/web-frontend/node_modules
- nuxt:/baserow/web-frontend/.nuxt/
- storybook:/baserow/web-frontend/.nuxt-storybook/
- cache:/baserow/web-frontend/.cache/
# Open stdin and tty so when attaching key input works as expected.
stdin_open: true
tty: true

storybook:
image: baserow_web-frontend:dev
profiles: ["optional"]
build:
dockerfile: ./web-frontend/Dockerfile
context: .
target: dev
args:
# We allow configuring the UID/GID here so you can run as the dev's actual user
# reducing the chance the containers screw up the bind mounted folders.
UID: $UID
GID: $GID
command: "storybook-dev"
ports:
- "${HOST_PUBLISH_IP:-127.0.0.1}:6006:6006"
volumes:
- ./web-frontend:/baserow/web-frontend
- ./premium/web-frontend/:/baserow/premium/web-frontend
- ./enterprise/web-frontend/:/baserow/enterprise/web-frontend
- ./tests/:/baserow/tests
- ./deploy/plugins:/baserow/plugins
# dev volumes to avoid polluting your local filesystem with build files or
# overwriting existing node_modules.
- node_modules:/baserow/web-frontend/node_modules
# Open stdin and tty so when attaching key input works as expected.
stdin_open: true
tty: true
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:6006/"]
interval: 60s
timeout: 5s
retries: 3
start_period: 30s

celery:
image: baserow_backend:dev
user: "${UID}:${GID}"
Expand Down
6 changes: 6 additions & 0 deletions web-frontend/docker/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ case "$1" in
yarn storybook &
attachable_exec_retry yarn dev
;;
storybook-dev)
startup_plugin_setup
setup_additional_modules
# Retry the command over and over to work around heap crash.
attachable_exec_retry yarn storybook
;;
lint)
exec yarn lint
;;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,10 @@ onMove(() => {

const editNodeContext = ref(null)
const editNodeContextToggle = ref(null)

const openEditContext = () => {
if (editNodeContext.value && editNodeContextToggle.value) {
activeNodeContext.value = editNodeContext
activeNodeContext.value = editNodeContext.value
editNodeContext.value.toggle(
editNodeContextToggle.value,
'bottom',
Expand All @@ -179,12 +180,13 @@ const openEditContext = () => {
}

const replaceNodeContext = ref(null)

const openReplaceContext = async () => {
editNodeContext.value.hide()
// As the target isn't the element that triggered the show of the context it is not
// ignored by the click outside handler and it immediately closes the context
await flushPromises()
activeNodeContext.value = replaceNodeContext
activeNodeContext.value = replaceNodeContext.value
replaceNodeContext.value.toggle(
editNodeContextToggle.value,
'bottom',
Expand Down
136 changes: 136 additions & 0 deletions web-frontend/modules/builder/components/PageEditorContent.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<template>
<div class="page-editor">
<PageHeader />
<div class="layout__col-2-2 page-editor__content">
<div :style="{ width: `calc(100% - ${panelWidth}px)` }">
<PagePreview />
</div>
<div
class="page-editor__side-panel"
:style="{ width: `${panelWidth}px` }"
>
<PageSidePanels />
</div>
</div>
</div>
</template>

<script setup>
import { ref, computed, watch, provide } from 'vue'
import PageHeader from '@baserow/modules/builder/components/page/header/PageHeader'
import PagePreview from '@baserow/modules/builder/components/page/PagePreview'
import PageSidePanels from '@baserow/modules/builder/components/page/PageSidePanels'
import { DataProviderType } from '@baserow/modules/core/dataProviderTypes'
import ApplicationBuilderFormulaInput from '@baserow/modules/builder/components/ApplicationBuilderFormulaInput'
import _ from 'lodash'

const props = defineProps({
workspace: {
type: Object,
required: true,
},
builder: {
type: Object,
required: true,
},
page: {
type: Object,
required: true,
},
})

const mode = 'editing'
const { $store, $registry } = useNuxtApp()

const panelWidth = ref(360)

const applicationContext = computed(() => ({
workspace: props.workspace,
builder: props.builder,
mode,
}))

const sharedPage = computed(() =>
$store.getters['page/getSharedPage'](props.builder)
)

const dataSources = computed(() => {
return $store.getters['dataSource/getPageDataSources'](props.page)
})

const sharedDataSources = computed(() => {
return $store.getters['dataSource/getPageDataSources'](sharedPage.value)
})

const dispatchContext = computed(() => {
return DataProviderType.getAllDataSourceDispatchContext(
$registry.getAll('builderDataProvider'),
{ ...applicationContext.value, page: props.page }
)
})

const applicationDispatchContext = computed(() => {
return DataProviderType.getAllDataSourceDispatchContext(
$registry.getAll('builderDataProvider'),
{ builder: props.builder, mode }
)
})

provide('workspace', props.workspace)
provide('builder', props.builder)
provide('currentPage', props.page)
provide('mode', mode)
provide('formulaComponent', ApplicationBuilderFormulaInput)
provide('applicationContext', applicationContext)

// Watchers
watch(
dataSources,
() => {
$store.dispatch('dataSourceContent/debouncedFetchPageDataSourceContent', {
page: props.page,
data: dispatchContext.value,
mode,
})
},
{ deep: true }
)

watch(
sharedDataSources,
() => {
$store.dispatch('dataSourceContent/debouncedFetchPageDataSourceContent', {
page: sharedPage.value,
data: dispatchContext.value,
})
},
{ deep: true }
)

watch(
dispatchContext,
(newDispatchContext, oldDispatchContext) => {
if (!_.isEqual(newDispatchContext, oldDispatchContext)) {
$store.dispatch('dataSourceContent/debouncedFetchPageDataSourceContent', {
page: props.page,
data: newDispatchContext,
mode,
})
}
},
{ deep: true }
)

watch(
applicationDispatchContext,
(newDispatchContext, oldDispatchContext) => {
if (!_.isEqual(newDispatchContext, oldDispatchContext)) {
$store.dispatch('dataSourceContent/debouncedFetchPageDataSourceContent', {
page: sharedPage.value,
data: newDispatchContext,
})
}
},
{ deep: true }
)
</script>
Loading
Loading