diff --git a/backend/src/baserow/api/sessions.py b/backend/src/baserow/api/sessions.py index a92d64ea9b..19d9f15529 100644 --- a/backend/src/baserow/api/sessions.py +++ b/backend/src/baserow/api/sessions.py @@ -9,6 +9,7 @@ InvalidClientSessionIdAPIException, InvalidUndoRedoActionGroupIdAPIException, ) +from baserow.core.utils import get_user_remote_ip_address_from_request UNTRUSTED_CLIENT_SESSION_ID_USER_ATTR = "untrusted_client_session_id" UNDO_REDO_ACTION_GROUP_ID = "untrusted_client_action_group" @@ -95,20 +96,6 @@ def _set_user_websocket_id(user, websocket_id): user.web_socket_id = websocket_id -def get_user_remote_ip_address_from_request(request): - x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") - if x_forwarded_for: - # X-Forwarded-For can contain multiple IPs: client, proxy1, proxy2. - # The first one is the original client IP. - return x_forwarded_for.split(",")[0].strip() - - x_real_ip = request.META.get("HTTP_X_REAL_IP") - if x_real_ip: - return x_real_ip.strip() - - return request.META.get("REMOTE_ADDR") - - def set_user_remote_addr_ip_from_request(user, request): ip_address = get_user_remote_ip_address_from_request(request) set_user_remote_addr_ip(user, ip_address) diff --git a/backend/src/baserow/api/user/views.py b/backend/src/baserow/api/user/views.py index cef3a924b0..2dd3bad5ad 100755 --- a/backend/src/baserow/api/user/views.py +++ b/backend/src/baserow/api/user/views.py @@ -36,7 +36,6 @@ from baserow.api.schemas import get_error_schema from baserow.api.sessions import ( get_untrusted_client_session_id, - get_user_remote_ip_address_from_request, set_user_session_data_from_request, ) from baserow.api.user.registries import user_data_registry @@ -90,6 +89,7 @@ ) from baserow.core.user.handler import UserHandler from baserow.core.user.utils import generate_session_tokens_for_user +from baserow.core.utils import get_user_remote_ip_address_from_request from .errors import ( ERROR_ALREADY_EXISTS, diff --git a/backend/src/baserow/contrib/database/formula/ast/function_defs.py b/backend/src/baserow/contrib/database/formula/ast/function_defs.py index 4023f6d5c2..7ce4dcbb41 100644 --- a/backend/src/baserow/contrib/database/formula/ast/function_defs.py +++ b/backend/src/baserow/contrib/database/formula/ast/function_defs.py @@ -2423,10 +2423,10 @@ def type_function( func_call: BaserowFunctionCall[UnTyped], arg: BaserowExpression[BaserowFormulaValidType], ) -> BaserowExpression[BaserowFormulaType]: - if BaserowGetFileCount().can_accept_arg(arg): + if BaserowGetFileCount().can_accept_arg(arg) and not arg.many: return BaserowGetFileCount()(arg) - if isinstance(arg.expression_type, BaserowFormulaArrayType): + if isinstance(arg.expression_type, BaserowFormulaArrayType) and not arg.many: return BaserowArrayLength()(arg) return arg.expression_type.count(func_call, arg).with_valid_type( diff --git a/backend/src/baserow/core/telemetry/telemetry.py b/backend/src/baserow/core/telemetry/telemetry.py index 04e7c43c5a..dbc8ffbaca 100644 --- a/backend/src/baserow/core/telemetry/telemetry.py +++ b/backend/src/baserow/core/telemetry/telemetry.py @@ -1,14 +1,20 @@ import logging import sys +from django.http import HttpRequest + from celery import signals from opentelemetry import metrics, trace from opentelemetry._logs import set_logger_provider from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler +from opentelemetry.trace import Span from baserow.core.psycopg import is_psycopg3 from baserow.core.telemetry.provider import DifferentSamplerPerLibraryTracerProvider from baserow.core.telemetry.utils import BatchBaggageSpanProcessor, otel_is_enabled +from baserow.core.utils import get_user_remote_ip_address_from_request + +OTEL_CLIENT_IP_ATTRIBUTE_NAMES = ("client.address", "net.peer.ip") class LogGuruCompatibleLoggerHandler(LoggingHandler): @@ -167,4 +173,12 @@ def _setup_standard_backend_instrumentation(): def _setup_django_process_instrumentation(): from opentelemetry.instrumentation.django import DjangoInstrumentor - DjangoInstrumentor().instrument() + DjangoInstrumentor().instrument(request_hook=_set_real_client_ip_on_request_span) + + +def _set_real_client_ip_on_request_span(span: Span, request: HttpRequest): + if span and span.is_recording(): + ip_address = get_user_remote_ip_address_from_request(request) + if ip_address: + for attribute_name in OTEL_CLIENT_IP_ATTRIBUTE_NAMES: + span.set_attribute(attribute_name, ip_address) diff --git a/backend/src/baserow/core/utils.py b/backend/src/baserow/core/utils.py index ea333ad7d3..afc73fd42a 100644 --- a/backend/src/baserow/core/utils.py +++ b/backend/src/baserow/core/utils.py @@ -16,7 +16,18 @@ from fractions import Fraction from itertools import chain, islice from numbers import Number -from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Type, Union +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Iterable, + List, + Optional, + Set, + Tuple, + Type, + Union, +) from django.conf import settings from django.db import transaction @@ -30,6 +41,9 @@ from .exceptions import CannotCalculateIntermediateOrder +if TYPE_CHECKING: + from django.http import HttpRequest + RE_ESCAPE_CHAR = re.compile(r"\\(\\)?") RE_PROP_NAME = re.compile( # Match anything that isn't a dot or bracket. @@ -61,6 +75,31 @@ def flatten(nested_list: List[Any]): ] +def get_user_remote_ip_address_from_request( + request: HttpRequest, +) -> Optional[str]: + """ + Extracts the remote IP address of the user from the request. It checks for + X-Forwarded-For and X-Real-IP headers first (commonly set by proxies), and falls + back to the REMOTE_ADDR if neither is available. + + :param request: The HTTP request object. + :return: The remote IP address as a string, or None if it is unavailable. + """ + + x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") + if x_forwarded_for: + # X-Forwarded-For can contain multiple IPs: client, proxy1, proxy2. + # The first one is the original client IP. + return x_forwarded_for.split(",")[0].strip() + + x_real_ip = request.META.get("HTTP_X_REAL_IP") + if x_real_ip: + return x_real_ip.strip() + + return request.META.get("REMOTE_ADDR") + + def split_attrs_and_m2m_fields( field_names: List[str], instance: Type[Model] ) -> Tuple[List[str], List[str]]: diff --git a/backend/src/baserow/throttling/handler.py b/backend/src/baserow/throttling/handler.py index 8b99d84baf..1593eb7939 100644 --- a/backend/src/baserow/throttling/handler.py +++ b/backend/src/baserow/throttling/handler.py @@ -11,7 +11,7 @@ from rest_framework.throttling import SimpleRateThrottle from baserow.api.exceptions import ThrottledAPIException -from baserow.api.sessions import get_user_remote_ip_address_from_request +from baserow.core.utils import get_user_remote_ip_address_from_request from .blacklist import blacklist_ip, blacklist_token from .exceptions import RateLimitExceededException diff --git a/backend/src/baserow/throttling/middleware.py b/backend/src/baserow/throttling/middleware.py index 36b783c64c..65c2211940 100644 --- a/backend/src/baserow/throttling/middleware.py +++ b/backend/src/baserow/throttling/middleware.py @@ -7,7 +7,7 @@ ThrottledAPIException, api_exception_to_json_response, ) -from baserow.api.sessions import get_user_remote_ip_address_from_request +from baserow.core.utils import get_user_remote_ip_address_from_request from baserow.throttling.handler import ConcurrentUserRequestsThrottle from .blacklist import get_token_cooldown_time, is_ip_blacklisted diff --git a/backend/tests/baserow/contrib/database/field/test_formula_field_type.py b/backend/tests/baserow/contrib/database/field/test_formula_field_type.py index adbd02218c..2d09cb6b4d 100644 --- a/backend/tests/baserow/contrib/database/field/test_formula_field_type.py +++ b/backend/tests/baserow/contrib/database/field/test_formula_field_type.py @@ -2340,3 +2340,116 @@ def test_count_formula_for_link_row_field_with_null_values(data_fixture): ) assert getattr(row_a, formula_field.db_column) == 3 + + +@pytest.mark.django_db +def test_count_formula_for_link_row_field_with_array_primary_field(data_fixture): + """ + A links to B, and B's primary field is a lookup array through a B to C link. + count(field('')) must count linked B rows, not each B row's inner + primary lookup array length (github issue #5276) + """ + + user = data_fixture.create_user() + + table_a, table_b, link_a_to_b = data_fixture.create_two_linked_tables(user=user) + table_b_primary = table_b.field_set.get(primary=True) + table_b_primary.primary = False + table_b_primary.save() + + table_c = data_fixture.create_database_table(user=user) + primary_c = data_fixture.create_text_field( + table=table_c, name="Field C", primary=True + ) + link_b_to_c = data_fixture.create_link_row_field( + table=table_b, + link_row_table=table_c, + name="Link C", + ) + data_fixture.create_formula_field( + table=table_b, + name="Primary lookup", + primary=True, + formula=f"lookup('{link_b_to_c.name}', '{primary_c.name}')", + ) + count_formula = data_fixture.create_formula_field( + table=table_a, + name="Count", + formula=f"count(field('{link_a_to_b.name}'))", + ) + + row_handler = RowHandler() + rows_c = row_handler.force_create_rows( + user=user, + table=table_c, + rows_values=[{primary_c.db_column: "C1"}, {primary_c.db_column: "C2"}], + model=table_c.get_model(), + ).created_rows + rows_b = row_handler.force_create_rows( + user=user, + table=table_b, + rows_values=[ + {link_b_to_c.db_column: [rows_c[0].id]}, + {link_b_to_c.db_column: [rows_c[1].id]}, + ], + model=table_b.get_model(), + ).created_rows + row_a = row_handler.force_create_rows( + user=user, + table=table_a, + rows_values=[{link_a_to_b.db_column: [row.id for row in rows_b]}], + model=table_a.get_model(), + ).created_rows[0] + + assert getattr(row_a, count_formula.db_column) == 2 + + +@pytest.mark.django_db +def test_count_formula_for_link_row_field_with_file_primary_field(data_fixture): + """ + A links to B, and B's primary field is a file field. count(field('')) + must count linked B rows, not each B row's primary file count. + """ + + user = data_fixture.create_user() + + table_a, table_b, link_a_to_b = data_fixture.create_two_linked_tables(user=user) + table_b_primary = table_b.field_set.get(primary=True) + table_b_primary.primary = False + table_b_primary.save() + + primary_b = data_fixture.create_file_field( + table=table_b, name="Files", primary=True + ) + count_formula = data_fixture.create_formula_field( + table=table_a, + name="Count", + formula=f"count(field('{link_a_to_b.name}'))", + ) + + user_file_1 = data_fixture.create_user_file() + user_file_2 = data_fixture.create_user_file() + user_file_3 = data_fixture.create_user_file() + row_handler = RowHandler() + rows_b = row_handler.force_create_rows( + user=user, + table=table_b, + rows_values=[ + { + primary_b.db_column: [ + {"name": user_file_1.name}, + {"name": user_file_2.name}, + ] + }, + {primary_b.db_column: [{"name": user_file_3.name}]}, + ], + model=table_b.get_model(), + ).created_rows + row_a = row_handler.force_create_rows( + user=user, + table=table_a, + rows_values=[{link_a_to_b.db_column: [row.id for row in rows_b]}], + model=table_a.get_model(), + ).created_rows[0] + + assert getattr(row_a, count_formula.db_column) == 2 diff --git a/backend/tests/baserow/core/telemetry/test_telemetry.py b/backend/tests/baserow/core/telemetry/test_telemetry.py new file mode 100644 index 0000000000..e02ecdc984 --- /dev/null +++ b/backend/tests/baserow/core/telemetry/test_telemetry.py @@ -0,0 +1,47 @@ +from unittest.mock import MagicMock, patch + +from baserow.core.telemetry.telemetry import ( + _set_real_client_ip_on_request_span, + _setup_django_process_instrumentation, +) + + +def test_set_real_client_ip_on_request_span_uses_forwarded_for(api_request_factory): + request = api_request_factory.get( + "/api/workspaces/", + REMOTE_ADDR="10.0.0.1", + HTTP_X_FORWARDED_FOR="203.0.113.50, 70.41.3.18", + ) + span = MagicMock() + span.is_recording.return_value = True + + _set_real_client_ip_on_request_span(span, request) + + span.set_attribute.assert_any_call("client.address", "203.0.113.50") + span.set_attribute.assert_any_call("net.peer.ip", "203.0.113.50") + assert span.set_attribute.call_count == 2 + + +def test_set_real_client_ip_on_request_span_falls_back_to_real_ip(api_request_factory): + request = api_request_factory.get( + "/api/workspaces/", + REMOTE_ADDR="10.0.0.1", + HTTP_X_REAL_IP="203.0.113.51", + ) + span = MagicMock() + span.is_recording.return_value = True + + _set_real_client_ip_on_request_span(span, request) + + span.set_attribute.assert_any_call("client.address", "203.0.113.51") + span.set_attribute.assert_any_call("net.peer.ip", "203.0.113.51") + assert span.set_attribute.call_count == 2 + + +def test_setup_django_process_instrumentation_registers_real_ip_request_hook(): + with patch( + "opentelemetry.instrumentation.django.DjangoInstrumentor.instrument" + ) as instrument: + _setup_django_process_instrumentation() + + instrument.assert_called_once_with(request_hook=_set_real_client_ip_on_request_span) diff --git a/backend/uv.lock b/backend/uv.lock index 90188c26bf..e6fd5349ce 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -3190,11 +3190,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] diff --git a/changelog/entries/unreleased/bug/5276_fix_count_formula_returning_array_for_linked_array_primary_fields.json b/changelog/entries/unreleased/bug/5276_fix_count_formula_returning_array_for_linked_array_primary_fields.json new file mode 100644 index 0000000000..bee3425b39 --- /dev/null +++ b/changelog/entries/unreleased/bug/5276_fix_count_formula_returning_array_for_linked_array_primary_fields.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Fixed count() returning an array instead of a number for link row fields whose linked primary field is an array formula or lookup.", + "issue_origin": "github", + "issue_number": 5276, + "domain": "database", + "bullet_points": [], + "created_at": "2026-04-29" +} diff --git a/changelog/entries/unreleased/bug/5277_use_real_client_ip_in_opentelemetry_request_traces.json b/changelog/entries/unreleased/bug/5277_use_real_client_ip_in_opentelemetry_request_traces.json new file mode 100644 index 0000000000..b3ed3840e6 --- /dev/null +++ b/changelog/entries/unreleased/bug/5277_use_real_client_ip_in_opentelemetry_request_traces.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Use the real client IP address in OpenTelemetry request traces when proxy headers are present.", + "issue_origin": "github", + "issue_number": 5277, + "domain": "core", + "bullet_points": [], + "created_at": "2026-04-29" +} diff --git a/changelog/entries/unreleased/bug/5310_fix_custom_code_editors_when_deleting_the_first_line.json b/changelog/entries/unreleased/bug/5310_fix_custom_code_editors_when_deleting_the_first_line.json new file mode 100644 index 0000000000..d417187667 --- /dev/null +++ b/changelog/entries/unreleased/bug/5310_fix_custom_code_editors_when_deleting_the_first_line.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Fix custom code editors when deleting the first line", + "issue_origin": "github", + "issue_number": 5310, + "domain": "builder", + "bullet_points": [], + "created_at": "2026-05-05" +} \ No newline at end of file diff --git a/changelog/entries/unreleased/feature/423_excel_import.json b/changelog/entries/unreleased/feature/423_excel_import.json new file mode 100644 index 0000000000..718ee22c83 --- /dev/null +++ b/changelog/entries/unreleased/feature/423_excel_import.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "message": "Add support for importing Excel files into database tables", + "domain": "database", + "bullet_points": [], + "created_at": "2026-04-26" +} diff --git a/web-frontend/locales/en.json b/web-frontend/locales/en.json index 33c1eef2a6..90f1bcd401 100644 --- a/web-frontend/locales/en.json +++ b/web-frontend/locales/en.json @@ -476,7 +476,8 @@ "csv": "Import a CSV file", "paste": "Paste table data", "xml": "Import an XML file", - "json": "Import a JSON file" + "json": "Import a JSON file", + "excel": "Import an Excel file" }, "apiDocs": { "intro": "Introduction", diff --git a/web-frontend/modules/core/assets/scss/components/code_editor.scss b/web-frontend/modules/core/assets/scss/components/code_editor.scss index e2dfca5e21..3deba9c8f0 100644 --- a/web-frontend/modules/core/assets/scss/components/code_editor.scss +++ b/web-frontend/modules/core/assets/scss/components/code_editor.scss @@ -16,4 +16,8 @@ padding: 0; overflow-y: auto; } + + &__code-wrapper { + margin: 2px 0; + } } diff --git a/web-frontend/modules/core/components/CodeEditor.vue b/web-frontend/modules/core/components/CodeEditor.vue index 1ad069f0fd..0ba71e4288 100644 --- a/web-frontend/modules/core/components/CodeEditor.vue +++ b/web-frontend/modules/core/components/CodeEditor.vue @@ -8,9 +8,12 @@ import { Editor, EditorContent } from '@tiptap/vue-3' import { Document } from '@tiptap/extension-document' -import { Paragraph } from '@tiptap/extension-paragraph' import { Text } from '@tiptap/extension-text' +const CodeEditorDocument = Document.extend({ + content: 'codeBlock', +}) + export default { name: 'CodeEditor', components: { @@ -36,13 +39,23 @@ export default { watch: { modelValue(newCode) { if (this.editor && newCode !== this.getCurrentCode()) { - this.editor.commands.setContent(this.generateCodeBlock(newCode)) + this.editor.commands.setContent( + this.generateCodeBlock(newCode), + false, + { + preserveWhitespace: 'full', + } + ) } }, language() { if (this.editor) { this.editor.commands.setContent( - this.generateCodeBlock(this.getCurrentCode()) + this.generateCodeBlock(this.getCurrentCode()), + false, + { + preserveWhitespace: 'full', + } ) } }, @@ -60,17 +73,44 @@ export default { lowlight.register('javascript', javascript) lowlight.register('css', css) + const LockedCodeBlockLowlight = CodeBlockLowlight.extend({ + addKeyboardShortcuts() { + const parentShortcuts = this.parent?.() || {} + + return { + ...parentShortcuts, + Backspace: () => { + const { empty, $anchor } = this.editor.state.selection + + if (!empty || $anchor.parent.type.name !== this.name) { + return false + } + + if ($anchor.pos === 1 || !$anchor.parent.textContent.length) { + return true + } + + return false + }, + } + }, + }) + this.editor = new Editor({ extensions: [ - Document, - Paragraph, + CodeEditorDocument, Text, - CodeBlockLowlight.configure({ + LockedCodeBlockLowlight.configure({ lowlight, + exitOnTripleEnter: false, + exitOnArrowDown: false, + HTMLAttributes: { + class: 'code-editor__code-wrapper', + }, }), ], content: this.generateCodeBlock(this.modelValue), - onUpdate: ({ editor }) => { + onUpdate: () => { this.$emit('update:modelValue', this.getCurrentCode()) }, }) @@ -80,21 +120,21 @@ export default { }, methods: { generateCodeBlock(code) { - return `
${this.escapeHtml(code)}`
+ return {
+ type: 'doc',
+ content: [
+ {
+ type: 'codeBlock',
+ attrs: {
+ language: this.language,
+ },
+ content: code ? [{ type: 'text', text: code }] : [],
+ },
+ ],
+ }
},
getCurrentCode() {
- const codeNode = this.editor?.getJSON()?.content?.[0]?.content?.[0]
- return codeNode?.text || ''
- },
- escapeHtml(string) {
- return string
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
+ return this.editor?.getText() || ''
},
},
}
diff --git a/web-frontend/modules/database/components/onboarding/DatabaseImportStep.vue b/web-frontend/modules/database/components/onboarding/DatabaseImportStep.vue
index 098b03d598..807650f3e7 100644
--- a/web-frontend/modules/database/components/onboarding/DatabaseImportStep.vue
+++ b/web-frontend/modules/database/components/onboarding/DatabaseImportStep.vue
@@ -55,6 +55,7 @@
v-if="dataLoaded"
:rows="previewFileData"
:fields="fileFields"
+ :field-options="fileFieldOptions"
:border="true"
/>
@@ -103,6 +104,11 @@ export default {
order: index,
}))
},
+ fileFieldOptions() {
+ return Object.fromEntries(
+ this.fileFields.map((field) => [field.id, { hidden: false }])
+ )
+ },
previewFileData() {
return this.previewData.map((row) => {
const newRow = Object.fromEntries(
diff --git a/web-frontend/modules/database/components/table/CreateTable.vue b/web-frontend/modules/database/components/table/CreateTable.vue
index dd0d5d55c6..05bc82176c 100644
--- a/web-frontend/modules/database/components/table/CreateTable.vue
+++ b/web-frontend/modules/database/components/table/CreateTable.vue
@@ -23,6 +23,7 @@
class="import-modal__preview margin-bottom-2"
:rows="previewFileData"
:fields="fileFields"
+ :field-options="fileFieldOptions"
/>