diff --git a/backend/src/baserow/api/admin/users/serializers.py b/backend/src/baserow/api/admin/users/serializers.py index b287d67f7d..8ebcd14415 100644 --- a/backend/src/baserow/api/admin/users/serializers.py +++ b/backend/src/baserow/api/admin/users/serializers.py @@ -156,9 +156,9 @@ class BaserowImpersonateAuthTokenSerializer(serializers.Serializer): """ User = get_user_model() - # It's not allowed to impersonate a superuser or staff. + # It's not allowed to impersonate a superuser, staff or deactivated user. user = serializers.PrimaryKeyRelatedField( - queryset=User.objects.filter(is_superuser=False, is_staff=False) + queryset=User.objects.filter(is_superuser=False, is_staff=False, is_active=True) ) class Meta: diff --git a/backend/src/baserow/api/admin/users/views.py b/backend/src/baserow/api/admin/users/views.py index 04a506961e..be3ed02abd 100644 --- a/backend/src/baserow/api/admin/users/views.py +++ b/backend/src/baserow/api/admin/users/views.py @@ -299,7 +299,8 @@ class UserAdminImpersonateView(GenericAPIView): description=( "This endpoint allows staff to impersonate another user by requesting a " "JWT token and user object. The requesting user must have staff access in " - "order to do this. It's not possible to impersonate a superuser or staff." + "order to do this. It's not possible to impersonate a superuser, staff " + "or deactivated user." ), request=BaserowImpersonateAuthTokenSerializer, responses={ diff --git a/backend/tests/baserow/api/admin/users/test_users_admin_views.py b/backend/tests/baserow/api/admin/users/test_users_admin_views.py index b2110ea657..c32048f891 100644 --- a/backend/tests/baserow/api/admin/users/test_users_admin_views.py +++ b/backend/tests/baserow/api/admin/users/test_users_admin_views.py @@ -1006,6 +1006,32 @@ def test_admin_impersonate_staff_or_superuser(api_client, data_fixture): assert response_json["user"][0].startswith("Invalid pk") +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_admin_impersonate_deactivated_user(api_client, data_fixture): + _, token = data_fixture.create_user_and_token( + email="test@test.nl", + password="password", + first_name="Test1", + is_staff=True, + ) + user_to_impersonate = data_fixture.create_user( + email="specific_user@test.nl", + password="password", + first_name="Test1", + is_active=False, + ) + response = api_client.post( + reverse("api:admin:users:impersonate"), + {"user": user_to_impersonate.id}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_400_BAD_REQUEST + response_json = response.json() + assert response_json["user"][0].startswith("Invalid pk") + + @pytest.mark.django_db @override_settings(DEBUG=True) def test_admin_users_endpoint_includes_two_factor_auth(api_client, data_fixture): diff --git a/changelog/entries/unreleased/bug/5735_fix_link_row_field_modal_unselecting_newly_created_rows_due_.json b/changelog/entries/unreleased/bug/5735_fix_link_row_field_modal_unselecting_newly_created_rows_due_.json new file mode 100644 index 0000000000..1c70ed8f0b --- /dev/null +++ b/changelog/entries/unreleased/bug/5735_fix_link_row_field_modal_unselecting_newly_created_rows_due_.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Fix link row field modal unselecting newly created rows due to a race condition with real-time updates", + "issue_origin": "github", + "issue_number": 5735, + "domain": "database", + "bullet_points": [], + "created_at": "2026-07-17" +} diff --git a/changelog/entries/unreleased/bug/prevent_impersonating_deactivated_users_via_the_admin_impers.json b/changelog/entries/unreleased/bug/prevent_impersonating_deactivated_users_via_the_admin_impers.json new file mode 100644 index 0000000000..e62a9ab3da --- /dev/null +++ b/changelog/entries/unreleased/bug/prevent_impersonating_deactivated_users_via_the_admin_impers.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Prevent impersonating deactivated users via the admin impersonate endpoint", + "issue_origin": "github", + "issue_number": null, + "domain": "core", + "bullet_points": [], + "created_at": "2026-07-20" +} diff --git a/changelog/entries/unreleased/refactor/updated_formula_references_in_builder_automation__integratio.json b/changelog/entries/unreleased/refactor/updated_formula_references_in_builder_automation__integratio.json new file mode 100644 index 0000000000..f0f034f19b --- /dev/null +++ b/changelog/entries/unreleased/refactor/updated_formula_references_in_builder_automation__integratio.json @@ -0,0 +1,9 @@ +{ + "type": "refactor", + "message": "Updated 'formula' references in builder, automation & integrations module so that they now refer to 'expressions'.", + "issue_origin": "github", + "issue_number": null, + "domain": "builder", + "bullet_points": [], + "created_at": "2026-04-23" +} \ No newline at end of file diff --git a/docs/decisions/005-formulas-vs-expressions-terminology.md b/docs/decisions/005-formulas-vs-expressions-terminology.md new file mode 100644 index 0000000000..0fb812c075 --- /dev/null +++ b/docs/decisions/005-formulas-vs-expressions-terminology.md @@ -0,0 +1,73 @@ +# Distinguishing "Formulas" and "Expressions" in Baserow + +## Summary + +Baserow uses the word "formula" across two fundamentally different evaluation systems. +This decision splits the terminology: **Formula** is retained for the database engine, +and **Expression** replaces "formula" everywhere it refers to the runtime-evaluated +engine used in Builder, Automation, and the AI Field. This is a UX and documentation +change — the underlying code and data model are not renamed. + +Originates from a dev retro suggestion in February 2026. + +## The Problem + +Both systems are surfaced to users as "formulas" today, but they behave very +differently: + +- **Different function sets.** The database formula engine exposes functions that map to + SQL and Django ORM expressions. The runtime engine exposes a different set suited to + dynamic data resolution (`get`, `concat`, data source accessors, etc.). Users report + confusion when a function they know from one context doesn't exist or behaves + differently in another. + +- **Different evaluation models.** Database formulas are compiled into SQL and evaluated + by the database at query time. Runtime expressions can be evaluated client-side (for + simple, data-independent expressions) or server-side during automation or AI prompt + resolution. Calling both systems "formulas" obscures this distinction. + +- **Support and documentation burden.** Without a shared vocabulary, support staff, + documentation authors, and engineers cannot easily triage whether a problem is with + the database formula engine or the runtime expression engine. + +## The Proposed Solution + +### Terminology split + +| System | Term | Where it appears | +|---|---|---| +| Database `FormulaField` engine | **Formula** | Database module, formula editor | +| Runtime-evaluated engine | **Expression** | Builder, Automation, AI Field | + +**Formulas** are persistent, computed columns defined on a `FormulaField`. They are +parsed and compiled at save time, evaluated by the database at query time, and have +their own type system, validation, and dependency graph. The editor in the Database +module continues to be called the *Formula editor*. + +**Expressions** are inline dynamic values accepted by element configuration forms, +automation node forms, and the AI Field prompt editor. They are evaluated at runtime — +either in the browser or on the server — and use a different function registry from the +database formula engine. The editor for these becomes the *Expression editor*. + +### Scope of changes + +1. **UI labels** — Rename "Formula" to "Expression" in Builder element forms, + automation node forms, and the AI Field prompt editor. Update tooltips, placeholder + text, and error messages in those contexts. The Database `FormulaField` UI is + unchanged. + +2. **Documentation** — Audit and update all help articles that describe the Builder or + Automation engine to use "expression". Add a disambiguation article: *Formulas vs. + Expressions — what's the difference?* + +3. **Translations (Weblate)** — Language maintainers will need to agree on the best + translation for "expression" in each locale. + +4. **Internal alignment** — Update technical documentation and code comments. Agree on + a cut-off date so engineering and support adopt the new terms together. + +### What this does not change + +- The underlying code and data model are not renamed. +- The function sets are not merged or altered. +- Existing user data (saved formulas/expressions) is unaffected. \ No newline at end of file diff --git a/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json b/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json index a552b3c667..05d988009c 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json +++ b/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json @@ -34,11 +34,11 @@ "codePlaceholder": "Enter the code to execute", "editCode": "Edit code", "injectionsLabel": "Data injections", - "injectionsHelperText": "Injections are formulas evaluated before the code runs. Each injection name becomes a property of context. For example an injection named customerName is available as context.customerName.", + "injectionsHelperText": "Injections are expressions evaluated before the code runs. Each injection name becomes a property of context. For example an injection named customerName is available as context.customerName.", "nameLabel": "Name", "namePlaceholder": "Variable name", "valueLabel": "Value", - "valuePlaceholder": "Value formula", + "valuePlaceholder": "Value expression", "addInjection": "Add injection", "nameFieldRequired": "The name field is required.", "nameFieldInvalid": "The name must be a valid variable name." diff --git a/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue b/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue index c533880806..e69145791b 100644 --- a/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue +++ b/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue @@ -106,9 +106,9 @@ const nodesHierarchy = computed(() => { }) /** - * Extract the formula string from the value object, the FormulaInputField - * component only needs the formula string itself. - * @returns {String} The formula string. + * Extract the expression string from the value object, the FormulaInputField + * component only needs the expression string itself. + * @returns {String} The expression string. */ const formulaStr = computed(() => { return currentValue.value.formula @@ -142,9 +142,9 @@ const dataExplorerLoading = computed(() => { }) /** - * When `FormulaInputField` emits a new formula string, we need to emit the - * entire value object with the updated formula string. - * @param {String} newFormulaStr The new formula string. + * When `FormulaInputField` emits a new expression string, we need to emit the + * entire value object with the updated expression string. + * @param {String} newFormulaStr The new expression string. */ const updatedFormulaStr = (newFormulaStr) => { emit('input', { diff --git a/web-frontend/modules/builder/components/elements/components/ChoiceElement.vue b/web-frontend/modules/builder/components/elements/components/ChoiceElement.vue index e0c420f24d..85b453504e 100644 --- a/web-frontend/modules/builder/components/elements/components/ChoiceElement.vue +++ b/web-frontend/modules/builder/components/elements/components/ChoiceElement.vue @@ -74,8 +74,8 @@ export default { * @property {boolean} show_as_dropdown - If the choice element should be displayed as a dropdown * @property {Array} options - The options of the choice element * @property {string} option_type - The type of the options - * @property {string} formula_name - The formula for the name of the option - * @property {string} formula_value - The formula for the value of the option + * @property {string} formula_name - The expression for the name of the option + * @property {string} formula_value - The expression for the value of the option */ element: { type: Object, diff --git a/web-frontend/modules/builder/components/elements/components/RecordSelectorElement.vue b/web-frontend/modules/builder/components/elements/components/RecordSelectorElement.vue index eb3b45c060..e27f9cd0f1 100644 --- a/web-frontend/modules/builder/components/elements/components/RecordSelectorElement.vue +++ b/web-frontend/modules/builder/components/elements/components/RecordSelectorElement.vue @@ -87,10 +87,10 @@ export default { * @property {string} data_source_id - The data source for the record selector element * @property {number} items_per_page - Number of items to show per page * @property {string} label - The label displayed above the record selector element - * @property {string} default_value - The formula to generate the displayed name + * @property {string} default_value - The expression to generate the displayed name * @property {string} placeholder - The placeholder text which should be applied to the element * @property {boolean} multiple - Whether this element can hold multiple values - * @property {string} option_name_suffix - The formula to generate the displayed suffix name + * @property {string} option_name_suffix - The expression to generate the displayed suffix name */ element: { type: Object, @@ -145,7 +145,7 @@ export default { }, resolvedOptions() { // Fill the dropdown options with an array containing - // the record id and name from the resolved formula + // the record id and name from the resolved expression const options = this.elementContent.map((record, recordIndex) => ({ value: record?.id, nameSuffix: ensureString( diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/ChoiceElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/ChoiceElementForm.vue index d033aac450..d8cb34c89e 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/ChoiceElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/ChoiceElementForm.vue @@ -251,7 +251,7 @@ export default { icon: 'iconoir-open-select-hand-gesture', }, { - label: this.$t('choiceOptionSelector.formulas'), + label: this.$t('choiceOptionSelector.expressions'), value: CHOICE_OPTION_TYPES.FORMULAS, icon: 'iconoir-sigma-function', }, diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue index e30daabfb9..ce2f2f7d7e 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue @@ -39,8 +39,8 @@
({ + default: vi.fn(), +})) + +vi.mock('@baserow/modules/database/services/field', () => ({ + default: vi.fn(), +})) + +vi.mock('@baserow/modules/database/services/view', () => ({ + default: vi.fn(), +})) + +vi.mock('@baserow/modules/core/utils/indexedDB', () => ({ + getData: vi.fn().mockResolvedValue(null), + setData: vi.fn().mockResolvedValue(undefined), +})) + +const TABLE_ID = 758 +const LINKED_TABLE_ID = 755 +const PRIMARY_FIELD = { + id: 100, + table_id: TABLE_ID, + name: 'Name', + type: 'text', + primary: true, + text_default: '', + _: { loading: false }, +} +const SECONDARY_FIELD = { + id: 101, + table_id: TABLE_ID, + name: 'Email', + type: 'text', + primary: false, + text_default: '', + _: { loading: false }, +} + +function setupServiceMocks() { + const createFn = vi.fn() + const fetchAllRows = vi.fn().mockResolvedValue({ + data: { count: 0, results: [], next: null, previous: null }, + }) + RowService.mockReturnValue({ create: createFn, fetchAll: fetchAllRows }) + + FieldService.mockReturnValue({ + fetchAll: vi.fn().mockResolvedValue({ + data: [{ ...PRIMARY_FIELD }, { ...SECONDARY_FIELD }], + }), + }) + + ViewService.mockReturnValue({ + fetchAll: vi.fn().mockResolvedValue({ data: [] }), + fetchFieldOptions: vi + .fn() + .mockResolvedValue({ data: { field_options: {} } }), + }) + + return { createFn, fetchAllRows } +} + +describe('SelectRowContent', () => { + let testApp = null + + beforeEach(() => { + testApp = new TestApp() + }) + + afterEach(async () => { + await testApp.afterEach() + vi.restoreAllMocks() + }) + + function setupStoreWithDatabase({ withView = false } = {}) { + const store = testApp.getStore() + store.commit('application/SET_ITEMS', [ + { + id: 1, + type: 'database', + workspace: { id: 1 }, + tables: [{ id: TABLE_ID, name: 'Team members' }], + }, + ]) + if (withView) { + store.state.view.selected = { + id: 3387, + type: 'grid', + table_id: LINKED_TABLE_ID, + } + } else { + store.state.view.selected = { id: 0 } + } + } + + async function mountContent(valueArray = []) { + const wrapper = await testApp.mount(SelectRowContent, { + props: { + tableId: TABLE_ID, + value: valueArray, + multiple: true, + }, + global: { + stubs: { + RowCreateModal: true, + SimpleGrid: true, + ViewFieldsContext: true, + Paginator: true, + }, + }, + }) + await flushPromises() + return wrapper + } + + test('select() toggles existing row to unselected in multiple mode', async () => { + setupStoreWithDatabase() + setupServiceMocks() + + const wrapper = await mountContent([{ id: 42, value: 'Existing' }]) + + wrapper.vm.select({ id: 42 }) + + const events = wrapper.emitted() + expect(events.unselected).toHaveLength(1) + expect(events.unselected[0][0].row.id).toBe(42) + expect(events.selected).toBeUndefined() + }) + + test('select() emits selected for new row in multiple mode', async () => { + setupStoreWithDatabase() + setupServiceMocks() + + const wrapper = await mountContent([]) + + wrapper.vm.select({ id: 42 }) + + const events = wrapper.emitted() + expect(events.selected).toHaveLength(1) + expect(events.selected[0][0].row.id).toBe(42) + expect(events.unselected).toBeUndefined() + }) + + test('createRow does not unselect when WebSocket already added row to value', async () => { + setupStoreWithDatabase({ withView: true }) + const { createFn, fetchAllRows } = setupServiceMocks() + + const createdRowId = 99 + createFn.mockResolvedValue({ + data: { id: createdRowId, field_100: 'New Member', field_101: '' }, + }) + fetchAllRows.mockResolvedValue({ + data: { + count: 1, + next: null, + previous: null, + results: [{ id: createdRowId, field_100: 'New Member', field_101: '' }], + }, + }) + + // Mount with value already containing the new row — simulates WebSocket + // having delivered rows_updated before createRow finishes. + const wrapper = await mountContent([ + { id: createdRowId, value: 'New Member' }, + ]) + + const callback = vi.fn() + await wrapper.vm.createRow({ + row: { field_100: 'New Member' }, + callback, + }) + await flushPromises() + + expect(callback).toHaveBeenCalledTimes(1) + + const events = wrapper.emitted() + // Must NOT emit 'unselected' — that's the bug + expect(events.unselected).toBeUndefined() + // Should NOT emit 'selected' either — row is already linked via WebSocket + expect(events.selected).toBeUndefined() + }) + + test('createRow emits selected when WebSocket has not yet arrived', async () => { + setupStoreWithDatabase({ withView: true }) + const { createFn, fetchAllRows } = setupServiceMocks() + + const createdRowId = 99 + createFn.mockResolvedValue({ + data: { id: createdRowId, field_100: 'New Member', field_101: '' }, + }) + fetchAllRows.mockResolvedValue({ + data: { + count: 1, + next: null, + previous: null, + results: [{ id: createdRowId, field_100: 'New Member', field_101: '' }], + }, + }) + + // Mount with empty value — WebSocket hasn't arrived yet + const wrapper = await mountContent([]) + + const callback = vi.fn() + await wrapper.vm.createRow({ + row: { field_100: 'New Member' }, + callback, + }) + await flushPromises() + + expect(callback).toHaveBeenCalledTimes(1) + + const events = wrapper.emitted() + // Must emit 'selected' to link the row + expect(events.selected).toHaveLength(1) + expect(events.selected[0][0].row.id).toBe(createdRowId) + // Must NOT emit 'unselected' + expect(events.unselected).toBeUndefined() + }) +})