From c5832b42a777b986a8586eefcf0e2380b21bdef1 Mon Sep 17 00:00:00 2001 From: Cezary Date: Wed, 26 Nov 2025 10:04:38 +0100 Subject: [PATCH 1/3] adjust image orientation based on exif data #2552 (#4310) adjust thumbnail orientation based on exif data --- backend/src/baserow/core/user_files/handler.py | 8 ++++++++ ...4247_adjust_image_orientation_based_on_exif_data.json | 9 +++++++++ 2 files changed, 17 insertions(+) create mode 100644 changelog/entries/unreleased/bug/4247_adjust_image_orientation_based_on_exif_data.json diff --git a/backend/src/baserow/core/user_files/handler.py b/backend/src/baserow/core/user_files/handler.py index d65b265d16..3db8931292 100644 --- a/backend/src/baserow/core/user_files/handler.py +++ b/backend/src/baserow/core/user_files/handler.py @@ -187,6 +187,14 @@ def generate_and_save_image_thumbnails( """ storage = storage or get_default_storage() + + # adjust image orientation, if exif data differs from the image data + try: + ImageOps.exif_transpose(image, in_place=True) + # ignore cases of incomplete images + except OSError: + pass + image_width = image.width image_height = image.height diff --git a/changelog/entries/unreleased/bug/4247_adjust_image_orientation_based_on_exif_data.json b/changelog/entries/unreleased/bug/4247_adjust_image_orientation_based_on_exif_data.json new file mode 100644 index 0000000000..b76cdd55f0 --- /dev/null +++ b/changelog/entries/unreleased/bug/4247_adjust_image_orientation_based_on_exif_data.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Adjust image orientation based on exif data", + "issue_origin": "github", + "issue_number": 4247, + "domain": "database", + "bullet_points": [], + "created_at": "2025-11-20" +} \ No newline at end of file From 2d54f8d33e5beb32e261eeffe51c093a43d7222b Mon Sep 17 00:00:00 2001 From: Jonathan Adeline Date: Wed, 26 Nov 2025 15:36:03 +0400 Subject: [PATCH 2/3] Fix FormulaInputField data record assignment in AB collection element (#4335) * fix: ensure arrays of inline content are wrapped in a `wrapper` node in `toTipTapVisitor` output. * refactor visitRoot * fix tests --- tests/cases/tip_tap_visitor_cases.json | 28 +++-- .../core/formula/tiptap/toTipTapVisitor.js | 119 ++++++++++-------- 2 files changed, 86 insertions(+), 61 deletions(-) diff --git a/tests/cases/tip_tap_visitor_cases.json b/tests/cases/tip_tap_visitor_cases.json index 9cc4d42c9a..86b130b3b8 100644 --- a/tests/cases/tip_tap_visitor_cases.json +++ b/tests/cases/tip_tap_visitor_cases.json @@ -59,16 +59,24 @@ "type": "doc", "content": [ { - "type": "text", - "text": "\u200B" - }, - { - "type": "get-formula-component", - "attrs": { "path": "data_source.hello.there", "isSelected": false } - }, - { - "type": "text", - "text": "\u200B" + "type": "wrapper", + "content": [ + { + "type": "text", + "text": "\u200B" + }, + { + "type": "get-formula-component", + "attrs": { + "path": "data_source.hello.there", + "isSelected": false + } + }, + { + "type": "text", + "text": "\u200B" + } + ] } ] } diff --git a/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js b/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js index 9391b3154b..8afc72da4e 100644 --- a/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js +++ b/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js @@ -11,66 +11,83 @@ export class ToTipTapVisitor extends BaserowFormulaVisitor { visitRoot(ctx) { const result = ctx.expr().accept(this) + return this.mode === 'advanced' + ? this._wrapForAdvancedMode(result) + : this._wrapForSimpleMode(result) + } - // In advanced mode, ensure all content is wrapped in a single wrapper - if (this.mode === 'advanced') { - const content = _.isArray(result) ? result : [result] - const flatContent = content.flatMap((item) => { - // Filter out null or undefined items - if (!item) return [] - - // If the item is an array (from functions without wrapper in advanced mode) - if (Array.isArray(item)) { - return item - } - - // If the item is a wrapper, extract its content - if (item.type === 'wrapper' && item.content) { - return item.content - } + /** + * Wraps content for advanced mode - flattens all content into a single wrapper + */ + _wrapForAdvancedMode(result) { + const content = _.isArray(result) ? result : [result] + const flatContent = this._flattenContent(content) + this._ensureStartsWithZWS(flatContent) + + return { + type: 'doc', + content: [ + { + type: 'wrapper', + content: flatContent, + }, + ], + } + } - // Return the item if it has a type - return item.type ? [item] : [] - }) + /** + * Wraps content for simple mode - preserves wrapper structure or creates one + */ + _wrapForSimpleMode(result) { + if (Array.isArray(result)) { + return this._isArrayOfWrappers(result) + ? { type: 'doc', content: result } + : { type: 'doc', content: [{ type: 'wrapper', content: result }] } + } - // Ensure content starts with ZWS - const firstNode = flatContent[0] - if ( - !firstNode || - firstNode.type !== 'text' || - firstNode.text !== '\u200B' - ) { - flatContent.unshift({ type: 'text', text: '\u200B' }) - } + if (result?.type === 'wrapper') { + return { type: 'doc', content: [result] } + } - return { - type: 'doc', - content: [ - { - type: 'wrapper', - content: flatContent, - }, - ], - } + return { + type: 'doc', + content: [{ type: 'wrapper', content: [result] }], } + } - // In simple mode, wrap inline content in a wrapper - // The result can be a wrapper, an array of wrappers, or inline content - if (Array.isArray(result)) { - // Array of wrappers (e.g., from concat with newlines) - return { type: 'doc', content: result } - } else if (result?.type === 'wrapper') { - // Already a wrapper - return { type: 'doc', content: [result] } - } else { - // Inline content (text, nodes, etc.) - wrap it - return { - type: 'doc', - content: [{ type: 'wrapper', content: [result] }], - } + /** + * Flattens nested content, extracting items from wrappers and arrays + */ + _flattenContent(content) { + return content.flatMap((item) => { + if (!item) return [] + if (Array.isArray(item)) return item + if (item.type === 'wrapper' && item.content) return item.content + return item.type ? [item] : [] + }) + } + + /** + * Ensures the content array starts with a Zero-Width Space text node + */ + _ensureStartsWithZWS(content) { + const firstNode = content[0] + if ( + !firstNode || + firstNode.type !== 'text' || + firstNode.text !== '\u200B' + ) { + content.unshift({ type: 'text', text: '\u200B' }) } } + /** + * Checks if an array contains only wrapper nodes + */ + _isArrayOfWrappers(array) { + return array.every((item) => item?.type === 'wrapper') + } + visitStringLiteral(ctx) { switch (ctx.getText()) { case "'\n'": From 336350bc71089b6a4d3315e376283ca243b5c05b Mon Sep 17 00:00:00 2001 From: Bram Date: Wed, 26 Nov 2025 13:07:09 +0100 Subject: [PATCH 3/3] Make read/write DB routing consistent for http requests and background tasks (#4077) Make read/write DB routing consistent for http requests and background tasks --- backend/pytest.ini | 1 + backend/src/baserow/config/db_routers.py | 66 +++++++++---- .../config/test_read_replica_router.py | 95 +++++++++++++++++++ ...ng_consistent_for_http_requests_and_b.json | 8 ++ 4 files changed, 154 insertions(+), 16 deletions(-) create mode 100644 backend/tests/baserow/config/test_read_replica_router.py create mode 100644 changelog/entries/unreleased/refactor/3848_make_readwrite_db_routing_consistent_for_http_requests_and_b.json diff --git a/backend/pytest.ini b/backend/pytest.ini index 1b4b0963b8..807858ae40 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -50,4 +50,5 @@ markers = websockets: All tests related to handeling web socket connections import_export_workspace: All tests related to importing and exporting workspaces data_sync: All tests related to data sync functionality + replica: All tests related to db replicas workspace_search: All tests related to workspace search functionality diff --git a/backend/src/baserow/config/db_routers.py b/backend/src/baserow/config/db_routers.py index 6ef0d2e6e6..e8f03304bf 100644 --- a/backend/src/baserow/config/db_routers.py +++ b/backend/src/baserow/config/db_routers.py @@ -1,28 +1,68 @@ import random from django.conf import settings +from django.db import transaction from asgiref.local import Local -DATABASE_READ_REPLICAS = settings.DATABASE_READ_REPLICAS DEFAULT_DB_ALIAS = "default" _db_state = Local() -def set_write_mode(): - _db_state.pinned = True +def set_db_alias(alias: str) -> str: + """ + Pin the current db connection alias to use for the current request or celery task. + + :param alias: The database alias to pin to. + :return: The database alias that was set. + """ + + _db_state.alias = alias + return alias + + +def get_db_alias() -> str | None: + """ + Get the pinned db connection alias for the current request or celery task. + """ + + return getattr(_db_state, "alias", None) + + +def set_db_alias_for_read(): + """ + Choose a read replica for read queries, unless we are in an atomic block, + in which case we should use the primary database to avoid replication lag issues + or trying to lock data in a read replica. + Once a read replica is chosen, it is pinned for the duration of the request or + celery task. + + :return: The database alias to use for read queries. + """ + + # Make sure LOCK always happen on the DEFAULT_DB_ALIAS + if transaction.get_connection().in_atomic_block: + return set_db_alias(DEFAULT_DB_ALIAS) + + # If we already have an alias set, return it + if (alias := get_db_alias()) is not None: + return alias + # Choose a random read replica if available, otherwise use the default + if settings.DATABASE_READ_REPLICAS: + alias = random.choice(settings.DATABASE_READ_REPLICAS) # nosec + else: + alias = DEFAULT_DB_ALIAS -def is_write_mode(): - return getattr(_db_state, "pinned", False) + return set_db_alias(alias) def clear_db_state(): """Should be called when a request or celery finishes.""" - if hasattr(_db_state, "pinned"): - del _db_state.pinned + if hasattr(_db_state, "alias"): + del _db_state.alias class ReadReplicaRouter: @@ -35,20 +75,14 @@ class ReadReplicaRouter: """ def db_for_read(self, model, **hints): - if is_write_mode(): - return DEFAULT_DB_ALIAS - if DATABASE_READ_REPLICAS: - read = random.choice(DATABASE_READ_REPLICAS) # nosec - return read - return DEFAULT_DB_ALIAS + return set_db_alias_for_read() def db_for_write(self, model, **hints): - set_write_mode() - return DEFAULT_DB_ALIAS + return set_db_alias(DEFAULT_DB_ALIAS) def allow_relation(self, obj1, obj2, **hints): db_set = {DEFAULT_DB_ALIAS} - db_set.update(DATABASE_READ_REPLICAS) + db_set.update(settings.DATABASE_READ_REPLICAS) if obj1._state.db in db_set and obj2._state.db in db_set: return True return None diff --git a/backend/tests/baserow/config/test_read_replica_router.py b/backend/tests/baserow/config/test_read_replica_router.py new file mode 100644 index 0000000000..10823c4d1b --- /dev/null +++ b/backend/tests/baserow/config/test_read_replica_router.py @@ -0,0 +1,95 @@ +from unittest.mock import MagicMock, patch + +from django.test.utils import override_settings + +import pytest + +from baserow.config.db_routers import ( + DEFAULT_DB_ALIAS, + ReadReplicaRouter, + clear_db_state, + get_db_alias, +) + + +@pytest.mark.replica +@pytest.mark.django_db +@override_settings(DATABASE_READ_REPLICAS=["replica1", "replica2"]) +def test_router_uses_replica_outside_transaction_and_sticks(): + router = ReadReplicaRouter() + clear_db_state() + + mock_conn = MagicMock() + mock_conn.get_autocommit.return_value = True + mock_conn.in_atomic_block = False + + with patch("django.db.transaction.get_connection", return_value=mock_conn): + # Outside transaction should use replica and stick to it + alias1 = router.db_for_read(model=None) + assert alias1 in ["replica1", "replica2"] + assert get_db_alias() == alias1 + + alias2 = router.db_for_read(model=None) + assert alias2 == alias1 + + +@pytest.mark.replica +@pytest.mark.django_db +@override_settings(DATABASE_READ_REPLICAS=["replica1", "replica2"]) +def test_router_switches_to_default_inside_transaction(): + router = ReadReplicaRouter() + clear_db_state() + + # Mock connection for outside transaction + mock_conn_outside = MagicMock() + mock_conn_outside.get_autocommit.return_value = True + mock_conn_outside.in_atomic_block = False + + with patch("django.db.transaction.get_connection", return_value=mock_conn_outside): + # Start outside transaction - should use replica + alias_outside = router.db_for_read(model=None) + assert alias_outside in ["replica1", "replica2"] + + # Mock connection for inside transaction + mock_conn_inside = MagicMock() + mock_conn_inside.get_autocommit.return_value = False + mock_conn_inside.in_atomic_block = True + + with patch("django.db.transaction.get_connection", return_value=mock_conn_inside): + # Enter transaction - should switch to default and stick + alias1 = router.db_for_read(model=None) + assert alias1 == DEFAULT_DB_ALIAS + + alias2 = router.db_for_read(model=None) + assert alias2 == DEFAULT_DB_ALIAS + + # After transaction, should still be default (sticky) + with patch("django.db.transaction.get_connection", return_value=mock_conn_outside): + alias3 = router.db_for_read(model=None) + assert alias3 == DEFAULT_DB_ALIAS + + +@pytest.mark.replica +@pytest.mark.django_db +@override_settings(DATABASE_READ_REPLICAS=["replica1", "replica2"]) +def test_write_switches_to_default_and_sticks(): + router = ReadReplicaRouter() + clear_db_state() + + # Mock connection for outside transaction + mock_conn = MagicMock() + mock_conn.get_autocommit.return_value = True + mock_conn.in_atomic_block = False + + with patch("django.db.transaction.get_connection", return_value=mock_conn): + # Start outside transaction - should use replica + alias_before_write = router.db_for_read(model=None) + assert alias_before_write in ["replica1", "replica2"] + + # Write should switch to default + write_alias = router.db_for_write(model=None) + assert write_alias == DEFAULT_DB_ALIAS + + # Read after write should still be default + read_after_write = router.db_for_read(model=None) + assert read_after_write == DEFAULT_DB_ALIAS diff --git a/changelog/entries/unreleased/refactor/3848_make_readwrite_db_routing_consistent_for_http_requests_and_b.json b/changelog/entries/unreleased/refactor/3848_make_readwrite_db_routing_consistent_for_http_requests_and_b.json new file mode 100644 index 0000000000..9cbad1ada3 --- /dev/null +++ b/changelog/entries/unreleased/refactor/3848_make_readwrite_db_routing_consistent_for_http_requests_and_b.json @@ -0,0 +1,8 @@ +{ + "type": "refactor", + "message": "Make read/write DB routing consistent for http requests and background tasks", + "domain": "database", + "issue_number": 3848, + "bullet_points": [], + "created_at": "2025-10-06" +} \ No newline at end of file