diff --git a/.agents/skills/create-changelog/SKILL.md b/.agents/skills/create-changelog/SKILL.md new file mode 100644 index 0000000000..a946830376 --- /dev/null +++ b/.agents/skills/create-changelog/SKILL.md @@ -0,0 +1,122 @@ +--- +name: create-changelog +description: Generate a changelog entry for the current Git branch by inspecting changed files, classifying the domain and type, writing a short public-friendly message, and running `just changelog add` after the user confirms. Use this skill whenever the user asks to "create a changelog", "add a changelog entry", "changelog this branch", "write changelog", or otherwise signals they want to record what their branch changes — even if they don't mention `just` or the exact command. +--- + +# create-changelog + +Create a changelog entry for the work on the current Git branch. The entry is registered by running: + +``` +just changelog add --domain {domain} --type {type} --message '{message}' +``` + +## Workflow + +Follow these steps in order. Do not skip ahead — the user confirms before anything is committed. + +### 1. Gather the diff + +Run these commands from the repository root to understand what has changed on this branch relative to `develop`: + +```bash +git rev-parse --abbrev-ref HEAD +git diff --name-status develop...HEAD +git diff --stat develop...HEAD +git log develop..HEAD --oneline +``` + +`develop...HEAD` (three dots) gives the changes introduced on this branch since it diverged from `develop`, which is what a changelog entry should describe. + +If any of the following is true, stop and tell the user there's nothing to changelog: + +- `git diff --name-status develop...HEAD` is empty. +- The current branch is `develop` itself, or is `main`/`master`. +- `develop` does not exist as a ref (in which case mention it and ask how they'd like to proceed). + +If the diff is large enough that the file list alone isn't informative, also peek at `git diff develop...HEAD -- ` for a few of the most-changed files to get a sense of the substance of the change. Prefer looking at a handful of representative files over dumping the entire diff. + +### 2. Pick the domain + +The domain must be exactly one of: `core`, `database`, `dashboard`, `builder`, `automation`, `integration`. These correspond to modules in the codebase. + +Choose the domain with the most changed files. Use directory names and file paths as the primary signal — e.g. files under a `dashboard/` directory belong to `dashboard`. If paths are ambiguous, fall back to the substance of the changes (e.g. migration files → `database`, webhook/third-party API code → `integration`). + +If there's a close tie, prefer the domain that contains the change with the most lines modified, or the one the branch name and commit messages most clearly point at. Don't overthink it — pick the single best match. + +### 3. Pick the type + +The type must be exactly one of: `bug`, `feature`, `refactor`, `breaking_change`. + +Rough guide: + +- `bug` — fixes incorrect behavior. Signals: commit messages with "fix", "bug", "regression"; small targeted changes; tests added alongside a fix. +- `feature` — adds capability that wasn't there before. Signals: new files, new endpoints, new UI, new configuration options. +- `refactor` — reshapes existing code without changing behavior. Signals: moves/renames, no new functionality, no bug being fixed, tests largely unchanged. +- `breaking_change` — changes that require consumers to update. Signals: removed/renamed public APIs, changed function signatures, database migrations that drop columns, config format changes. + +A breaking change beats the other labels when it applies — if a refactor removes a public function, it's a `breaking_change`. Otherwise, pick the single best fit and move on. + +### 4. Write the message + +The message describes what changed, in plain English, for a mixed audience of developers and non-technical users reading a public changelog. + +Rules: + +- **Max 100 characters**, including spaces. Count them. +- Plain English, no jargon, no internal code names, no file paths, no class names. +- Present tense, sentence case, no trailing period. Start with a verb when natural. +- Describe the user-visible effect, not the implementation. "Speeds up dashboard loading" beats "Adds index to dashboard_widgets.user_id". +- Don't start with the type or domain (e.g. don't write "Fix: ..." — the type and domain are separate fields). +- Single line, no newlines. + +**Examples:** + +Good: +- `Dashboards now load faster when you have many widgets` +- `Fixes an error that could lose changes when saving automations` +- `Adds a bulk import option for contacts` + +Too technical: +- `Refactor DashboardWidget.render() to use memoization` +- `Patch NPE in AutomationSaver.persist()` + +Too vague: +- `Various improvements` +- `Bug fixes` + +### 5. Show the proposal and get confirmation + +Present the chosen values to the user in this exact shape so they're easy to scan: + +``` +Domain: +Type: +Message: +``` + +Follow with a one- or two-sentence rationale explaining *why* you picked that domain and type (e.g. "7 of 9 changed files are under `builder/`, and the branch adds a new step type, so I called it a new feature.") and then the full command that will be run: + +``` +just changelog add --domain --type --message '' +``` + +Ask the user to confirm, or to tell you what to change. If they ask for changes, redo whichever steps are affected and present an updated proposal — don't run the command until the user explicitly confirms. + +### 6. Execute + +Once the user confirms, run the command: + +```bash +just changelog add --domain --type --message '' +``` + +Wrap the message in single quotes so apostrophes, spaces, and punctuation pass through correctly. If the message itself contains a single quote, close-escape-open it (`'\''`) or rewrite the message to avoid it — preferably the latter, since changelog messages rarely need apostrophes. + +The issue number is automatically extracted from the branch name (e.g. a branch named `1234-fix-something` yields issue `1234`). If no issue number is found in the branch name, the entry is created without one. + +Show the command's output to the user and confirm it succeeded. If `just` errors (for example, the arguments aren't accepted), surface the error and offer to adjust and retry. + +## Notes + +- If the user runs this skill multiple times in one session with different intents, re-run the diff — don't reuse cached results, because the branch state may have changed. diff --git a/backend/src/baserow/contrib/builder/elements/permission_manager.py b/backend/src/baserow/contrib/builder/elements/permission_manager.py index 0b063b7e42..4b5e80e4dc 100755 --- a/backend/src/baserow/contrib/builder/elements/permission_manager.py +++ b/backend/src/baserow/contrib/builder/elements/permission_manager.py @@ -17,11 +17,9 @@ User = get_user_model() -# For now there can be up to three levels of nested elements. -# E.g. a RepeatElement might contain a ColumnElement, which might contain a -# HeadingElement. -# However, later this number could be dynamic depending on the page itself. -MAX_ELEMENT_NESTING_DEPTH = 3 +# Later this number could be dynamic but for now we set it arbitrary +# high enough to cover most usages. +MAX_ELEMENT_NESTING_DEPTH = 9 class ElementVisibilityPermissionManager(PermissionManagerType): @@ -182,11 +180,13 @@ def exclude_elements_with_page_visibility( return queryset.exclude(page__visibility=Page.VISIBILITY_TYPES.LOGGED_IN) return queryset.exclude( - page__role_type=Page.ROLE_TYPES.ALLOW_ALL_EXCEPT, - page__roles__contains=actor.role, + Q(page__visibility=Page.VISIBILITY_TYPES.LOGGED_IN) + & Q(page__role_type=Page.ROLE_TYPES.ALLOW_ALL_EXCEPT) + & Q(page__roles__contains=[actor.role]) ).exclude( - Q(page__role_type=Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT) - & ~Q(page__roles__contains=actor.role), + Q(page__visibility=Page.VISIBILITY_TYPES.LOGGED_IN) + & Q(page__role_type=Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT) + & ~Q(page__roles__contains=[actor.role]), ) def exclude_elements_with_visibility( diff --git a/backend/src/baserow/contrib/integrations/core/service_types.py b/backend/src/baserow/contrib/integrations/core/service_types.py index b8fe0c4893..0c43c8ce65 100644 --- a/backend/src/baserow/contrib/integrations/core/service_types.py +++ b/backend/src/baserow/contrib/integrations/core/service_types.py @@ -1317,6 +1317,48 @@ def prepare_values( return super().prepare_values(values, user, instance) + def serialize_property( + self, + service: CorePeriodicService, + prop_name: str, + files_zip=None, + storage=None, + cache=None, + ): + if prop_name == "next_run_at": + return ( + service.next_run_at.isoformat() + if service.next_run_at is not None + else None + ) + + return super().serialize_property( + service, prop_name, files_zip=files_zip, storage=storage, cache=cache + ) + + def deserialize_property( + self, + prop_name: str, + value: Any, + id_mapping: Dict[str, Any], + files_zip=None, + storage=None, + cache=None, + **kwargs, + ): + if prop_name == "next_run_at" and value is not None: + return datetime.fromisoformat(value) + + return super().deserialize_property( + prop_name, + value, + id_mapping, + files_zip=files_zip, + storage=storage, + cache=cache, + **kwargs, + ) + def can_immediately_be_tested(self, service): return True diff --git a/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py b/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py index 05ef30024b..c8b5dfd7bd 100644 --- a/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py +++ b/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py @@ -1294,91 +1294,176 @@ def test_public_dispatch_data_sources_list_rows_with_elements_and_role( @pytest.mark.django_db @pytest.mark.parametrize( - "user_role,page_role_type,page_roles,element_role,expect_fields", + "user_role,page_visibility,page_role_type,page_roles," + "element_role_type,element_roles,expect_fields", [ + # Page visibility = all: page role settings should be ignored. ( "foo_role", + Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.ALLOW_ALL, [], - "", + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, + [], True, ), ( "foo_role", + Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.ALLOW_ALL_EXCEPT, [], - "", + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, + [], True, ), ( "foo_role", + Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.ALLOW_ALL_EXCEPT, ["foo_role"], - "", - False, + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, + [], + True, ), ( "foo_role", + Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT, [], - "", - False, + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, + [], + True, ), ( "foo_role", + Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT, ["foo_role"], - "", + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, + [], True, ), - # The following should all fail (no field info returned) because - # although the Page visiblity allows access, the Element visibility - # does not. ( "foo_role", + Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.ALLOW_ALL, [], - "foo_role", + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, + ["foo_role"], False, ), ( "foo_role", + Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.ALLOW_ALL_EXCEPT, [], - "foo_role", + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, + ["foo_role"], False, ), ( "foo_role", + Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.ALLOW_ALL_EXCEPT, ["foo_role"], + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, + ["foo_role"], + False, + ), + ( "foo_role", + Page.VISIBILITY_TYPES.ALL, + Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + [], + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, + ["foo_role"], False, ), ( "foo_role", + Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + ["foo_role"], + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, + ["foo_role"], + False, + ), + # Page visibility = logged-in: page role settings should be applied. + ( + "foo_role", + Page.VISIBILITY_TYPES.LOGGED_IN, + Page.ROLE_TYPES.ALLOW_ALL, [], + Element.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + ["foo_role"], + True, + ), + ( + "foo_role", + Page.VISIBILITY_TYPES.LOGGED_IN, + Page.ROLE_TYPES.ALLOW_ALL_EXCEPT, + [], + Element.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + ["foo_role"], + True, + ), + ( "foo_role", + Page.VISIBILITY_TYPES.LOGGED_IN, + Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + ["foo_role"], + Element.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + ["foo_role"], + True, + ), + ( + "foo_role", + Page.VISIBILITY_TYPES.LOGGED_IN, + Page.ROLE_TYPES.ALLOW_ALL_EXCEPT, + [], + Element.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + ["bar_role"], False, ), ( "foo_role", + Page.VISIBILITY_TYPES.LOGGED_IN, Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT, ["foo_role"], + Element.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + ["bar_role"], + False, + ), + ( + "foo_role", + Page.VISIBILITY_TYPES.LOGGED_IN, + Page.ROLE_TYPES.ALLOW_ALL_EXCEPT, + ["foo_role"], + Element.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + ["foo_role"], + False, + ), + ( "foo_role", + Page.VISIBILITY_TYPES.LOGGED_IN, + Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + [], + Element.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + ["foo_role"], False, ), ], ) -def test_public_dispatch_data_sources_list_rows_with_page_visibility_all( +def test_public_dispatch_data_sources_list_rows_with_page_visibility( api_client, data_fixture, data_source_element_roles_fixture, user_role, + page_visibility, page_role_type, page_roles, - element_role, + element_role_type, + element_roles, expect_fields, ): """ @@ -1388,14 +1473,10 @@ def test_public_dispatch_data_sources_list_rows_with_page_visibility_all( This test checks that the page's visibility setting is correctly evaluated when filtering the elements for the API response. The response should only contain field data if the page's visibility settings allow it. - - When the visibility_type is 'all', the API should return fields regardless - of the role_type or roles list. However, it should still respect the element - level visibility. """ page = data_source_element_roles_fixture["page"] - page.visibility = Page.VISIBILITY_TYPES.ALL + page.visibility = page_visibility page.role_type = page_role_type page.roles = page_roles page.save() @@ -1406,7 +1487,7 @@ def test_public_dispatch_data_sources_list_rows_with_page_visibility_all( user_role, ) user_source_user = UserSourceUser( - user_source, None, 1, "foo_username", "foo@bar.com" + user_source, None, 1, "foo_username", "foo@bar.com", role=user_role ) token = user_source_user.get_refresh_token().access_token @@ -1424,8 +1505,8 @@ def test_public_dispatch_data_sources_list_rows_with_page_visibility_all( page=page, data_source=data_source, visibility=Element.VISIBILITY_TYPES.LOGGED_IN, - roles=[element_role], - role_type=Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, + roles=element_roles, + role_type=element_role_type, fields=[ { "name": "FieldA", @@ -1474,382 +1555,176 @@ def test_public_dispatch_data_sources_list_rows_with_page_visibility_all( @pytest.mark.django_db @pytest.mark.parametrize( - "user_role,page_role_type,page_roles,element_role,expect_fields", + "user_role,page_visibility,page_role_type,page_roles," + "element_role_type,element_roles,expect_fields", [ + # Page visibility = all: page role settings should be ignored. ( "foo_role", + Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.ALLOW_ALL, [], - "", + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, + [], True, ), ( "foo_role", + Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.ALLOW_ALL_EXCEPT, [], - "", + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, + [], True, ), ( "foo_role", + Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.ALLOW_ALL_EXCEPT, ["foo_role"], - "", - False, + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, + [], + True, ), ( "foo_role", + Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT, [], - "", - False, + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, + [], + True, ), ( "foo_role", + Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT, ["foo_role"], - "", + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, + [], True, ), - # The following should all fail (no field info returned) because - # although the Page visiblity allows access, the Element visibility - # does not. ( "foo_role", + Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.ALLOW_ALL, [], - "foo_role", + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, + ["foo_role"], False, ), ( "foo_role", + Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.ALLOW_ALL_EXCEPT, [], - "foo_role", + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, + ["foo_role"], False, ), ( "foo_role", + Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.ALLOW_ALL_EXCEPT, ["foo_role"], - "foo_role", - False, - ), - ( - "foo_role", - Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT, - [], - "foo_role", - False, - ), - ( - "foo_role", - Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, ["foo_role"], - "foo_role", False, ), - ], -) -def test_public_dispatch_data_sources_get_row_with_page_visibility_all( - api_client, - data_fixture, - data_source_element_roles_fixture, - user_role, - page_role_type, - page_roles, - element_role, - expect_fields, -): - """ - Test the DispatchDataSourcesView endpoint when using a Data Source type - of Get Row. - - This test checks that the page's visibility setting is correctly evaluated - when filtering the elements for the API response. The response should only - contain field data if the page's visibility settings allow it. - - When the visibility_type is 'all', the API should return fields regardless - of the role_type or roles list. However, it should still respect the element - level visibility. - """ - - page = data_source_element_roles_fixture["page"] - page.visibility = Page.VISIBILITY_TYPES.ALL - page.role_type = page_role_type - page.roles = page_roles - page.save() - - user_source, integration = data_fixture.create_user_table_and_role( - data_source_element_roles_fixture["user"], - data_source_element_roles_fixture["builder_to"], - user_role, - ) - user_source_user = UserSourceUser( - user_source, None, 1, "foo_username", "foo@bar.com", role=user_role - ) - token = user_source_user.get_refresh_token().access_token - - data_source = data_fixture.create_builder_local_baserow_get_row_data_source( - user=data_source_element_roles_fixture["user"], - page=page, - integration=integration, - table=data_source_element_roles_fixture["table"], - row_id="1", - ) - - # Create an element that uses a formula referencing the data source - field = data_source_element_roles_fixture["fields"][0] - field_id = field.id - data_fixture.create_builder_heading_element( - page=page, - value=f"get('data_source.{data_source.id}.field_{field_id}')", - visibility=Element.VISIBILITY_TYPES.LOGGED_IN, - roles=[element_role], - role_type=Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, - ) - - url = reverse( - "api:builder:domains:public_dispatch_all", - kwargs={"page_id": page.id}, - ) - - response = api_client.post( - url, - {}, - format="json", - HTTP_AUTHORIZATION=f"JWT {token}", - ) - - assert response.status_code == HTTP_200_OK - - if expect_fields: - assert response.json() == { - str(data_source.id): {field.name: "Apple"}, - } - else: - assert response.json() == {str(data_source.id): {}} - - -@pytest.mark.django_db -@pytest.mark.parametrize( - "user_role,page_role_type,page_roles,element_role,expect_fields", - [ - ( - "foo_role", - Page.ROLE_TYPES.ALLOW_ALL, - [], - "foo_role", - True, - ), - ( - "foo_role", - Page.ROLE_TYPES.ALLOW_ALL_EXCEPT, - [], - "foo_role", - True, - ), ( "foo_role", + Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT, - ["foo_role"], - "foo_role", - True, - ), - ( - "foo_role", - Page.ROLE_TYPES.ALLOW_ALL_EXCEPT, [], - "bar_role", + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, + ["foo_role"], False, ), ( "foo_role", + Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT, ["foo_role"], - "bar_role", - False, - ), - ( - "foo_role", - Page.ROLE_TYPES.ALLOW_ALL_EXCEPT, + Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, ["foo_role"], - "foo_role", - False, - ), - ( - "foo_role", - Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT, - [], - "foo_role", False, ), - ], -) -def test_public_dispatch_data_sources_list_rows_with_page_visibility_logged_in( - api_client, - data_fixture, - data_source_element_roles_fixture, - user_role, - page_role_type, - page_roles, - element_role, - expect_fields, -): - """ - Test the DispatchDataSourcesView endpoint when using a Data Source type - of List Rows. - - This test checks that the page's visibility setting is correctly evaluated - when filtering the elements for the API response. The response should only - contain field data if the page's visibility settings allow it. - - When the visibility_type is 'logged-in', the API should return fields only - when the user is logged in and has an allowed roles. It should also still - respect the element level visibility. - """ - - page = data_source_element_roles_fixture["page"] - page.visibility = Page.VISIBILITY_TYPES.LOGGED_IN - page.role_type = page_role_type - page.roles = page_roles - page.save() - - user_source, integration = data_fixture.create_user_table_and_role( - data_source_element_roles_fixture["user"], - data_source_element_roles_fixture["builder_to"], - user_role, - ) - user_source_user = UserSourceUser( - user_source, None, 1, "foo_username", "foo@bar.com" - ) - token = user_source_user.get_refresh_token().access_token - - data_source = data_fixture.create_builder_local_baserow_list_rows_data_source( - user=data_source_element_roles_fixture["user"], - page=page, - integration=integration, - table=data_source_element_roles_fixture["table"], - ) - - field_id = data_source_element_roles_fixture["fields"][0].id - - # Create an element that uses a formula referencing the data source - data_fixture.create_builder_table_element( - page=page, - data_source=data_source, - visibility=Element.VISIBILITY_TYPES.LOGGED_IN, - roles=[element_role], - role_type=Element.ROLE_TYPES.DISALLOW_ALL_EXCEPT, - fields=[ - { - "name": "FieldA", - "type": "text", - "config": {"value": f"get('current_record.field_{field_id}')"}, - }, - ], - ) - - url = reverse( - "api:builder:domains:public_dispatch_all", - kwargs={"page_id": page.id}, - ) - - response = api_client.post( - url, - {}, - format="json", - HTTP_AUTHORIZATION=f"JWT {token}", - ) - - assert response.status_code == HTTP_200_OK - - rows = data_source_element_roles_fixture["rows"] - - if expect_fields: - field_name = data_source_element_roles_fixture["fields"][0].name - assert response.json() == { - str(data_source.id): { - "has_next_page": False, - "results": [ - {field_name: "Apple", "id": rows[0].id}, - {field_name: "Banana", "id": rows[1].id}, - {field_name: "Cherry", "id": rows[2].id}, - ], - }, - } - else: - assert response.json() == { - str(data_source.id): { - "has_next_page": False, - "results": [], - }, - } - - -@pytest.mark.django_db -@pytest.mark.parametrize( - "user_role,page_role_type,page_roles,element_role,expect_fields", - [ + # Page visibility = logged-in: page role settings should be applied. ( "foo_role", + Page.VISIBILITY_TYPES.LOGGED_IN, Page.ROLE_TYPES.ALLOW_ALL, [], - "foo_role", + Element.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + ["foo_role"], True, ), ( "foo_role", + Page.VISIBILITY_TYPES.LOGGED_IN, Page.ROLE_TYPES.ALLOW_ALL_EXCEPT, [], - "foo_role", + Element.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + ["foo_role"], True, ), ( "foo_role", + Page.VISIBILITY_TYPES.LOGGED_IN, Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT, ["foo_role"], - "foo_role", + Element.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + ["foo_role"], True, ), ( "foo_role", + Page.VISIBILITY_TYPES.LOGGED_IN, Page.ROLE_TYPES.ALLOW_ALL_EXCEPT, [], - "bar_role", + Element.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + ["bar_role"], False, ), ( "foo_role", + Page.VISIBILITY_TYPES.LOGGED_IN, Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT, ["foo_role"], - "bar_role", + Element.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + ["bar_role"], False, ), ( "foo_role", + Page.VISIBILITY_TYPES.LOGGED_IN, Page.ROLE_TYPES.ALLOW_ALL_EXCEPT, ["foo_role"], - "foo_role", + Element.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + ["foo_role"], False, ), ( "foo_role", + Page.VISIBILITY_TYPES.LOGGED_IN, Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT, [], - "foo_role", + Element.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + ["foo_role"], False, ), ], ) -def test_public_dispatch_data_sources_get_row_with_page_visibility_logged_in( +def test_public_dispatch_data_sources_get_row_with_page_visibility( api_client, data_fixture, data_source_element_roles_fixture, user_role, + page_visibility, page_role_type, page_roles, - element_role, + element_role_type, + element_roles, expect_fields, ): """ @@ -1860,13 +1735,10 @@ def test_public_dispatch_data_sources_get_row_with_page_visibility_logged_in( when filtering the elements for the API response. The response should only contain field data if the page's visibility settings allow it. - When the visibility_type is 'logged-in', the API should return fields only - when the user is logged in and has an allowed roles. It should also still - respect the element level visibility. """ page = data_source_element_roles_fixture["page"] - page.visibility = Page.VISIBILITY_TYPES.LOGGED_IN + page.visibility = page_visibility page.role_type = page_role_type page.roles = page_roles page.save() @@ -1890,13 +1762,14 @@ def test_public_dispatch_data_sources_get_row_with_page_visibility_logged_in( ) # Create an element that uses a formula referencing the data source - field_id = data_source_element_roles_fixture["fields"][0].id + field = data_source_element_roles_fixture["fields"][0] + field_id = field.id data_fixture.create_builder_heading_element( page=page, value=f"get('data_source.{data_source.id}.field_{field_id}')", visibility=Element.VISIBILITY_TYPES.LOGGED_IN, - roles=[element_role], - role_type=Element.ROLE_TYPES.DISALLOW_ALL_EXCEPT, + roles=element_roles, + role_type=element_role_type, ) url = reverse( @@ -1915,9 +1788,7 @@ def test_public_dispatch_data_sources_get_row_with_page_visibility_logged_in( if expect_fields: assert response.json() == { - str(data_source.id): { - data_source_element_roles_fixture["fields"][0].name: "Apple" - }, + str(data_source.id): {field.name: "Apple"}, } else: assert response.json() == {str(data_source.id): {}} diff --git a/backend/tests/baserow/contrib/builder/elements/test_element_permission_manager.py b/backend/tests/baserow/contrib/builder/elements/test_element_permission_manager.py index b7f8ee9278..da88e1a546 100644 --- a/backend/tests/baserow/contrib/builder/elements/test_element_permission_manager.py +++ b/backend/tests/baserow/contrib/builder/elements/test_element_permission_manager.py @@ -992,7 +992,7 @@ def test_auth_user_can_view_element_returns_true( ( Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.ALLOW_ALL_EXCEPT, - [], + ["foo_role"], Element.VISIBILITY_TYPES.LOGGED_IN, Element.ROLE_TYPES.ALLOW_ALL_EXCEPT, [], @@ -1014,12 +1014,12 @@ def test_auth_user_can_view_element_returns_true( ( Page.VISIBILITY_TYPES.ALL, Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT, - ["foo_role"], + [], Element.VISIBILITY_TYPES.LOGGED_IN, Element.ROLE_TYPES.DISALLOW_ALL_EXCEPT, - [], + ["foo_role"], "foo_role", - 0, + 1, ), # Page disallows visibility due to role, so despite the Element allowing # access, it shouldn't be returned. diff --git a/backend/tests/baserow/contrib/integrations/core/test_core_periodic_service_type.py b/backend/tests/baserow/contrib/integrations/core/test_core_periodic_service_type.py index 4daa834cad..7f36706f54 100644 --- a/backend/tests/baserow/contrib/integrations/core/test_core_periodic_service_type.py +++ b/backend/tests/baserow/contrib/integrations/core/test_core_periodic_service_type.py @@ -1,3 +1,4 @@ +import json from datetime import datetime, timezone from unittest.mock import MagicMock, call, patch @@ -112,6 +113,21 @@ def test_periodic_trigger_node_creation_and_property_updates(data_fixture): assert updated_service_specific.next_run_at is None +@pytest.mark.django_db +def test_periodic_service_export_serializes_next_run_at_as_iso(data_fixture): + service = data_fixture.create_core_periodic_service( + interval=PERIODIC_INTERVAL_MINUTE, + minute=15, + next_run_at=datetime(2025, 2, 15, 10, 30, 0, tzinfo=timezone.utc), + ) + + serialized = json.loads( + json.dumps(CorePeriodicServiceType().export_serialized(service)) + ) + + assert serialized["next_run_at"] == "2025-02-15T10:30:00+00:00" + + @pytest.mark.django_db @patch( "baserow.contrib.integrations.core.service_types.settings.INTEGRATIONS_PERIODIC_MINUTE_MIN", diff --git a/changelog/entries/unreleased/bug/2953_fix_example_data_json_xml_import_dialogs.json b/changelog/entries/unreleased/bug/2953_fix_example_data_json_xml_import_dialogs.json new file mode 100644 index 0000000000..34f5af667c --- /dev/null +++ b/changelog/entries/unreleased/bug/2953_fix_example_data_json_xml_import_dialogs.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Replace placeholder example data in the JSON and XML import dialogs with clearer contact records so users can see a typical table import format", + "issue_origin": "github", + "issue_number": 2953, + "domain": "database", + "bullet_points": [], + "created_at": "2026-04-16" +} diff --git a/changelog/entries/unreleased/bug/fix_export_workflow_with_periodic_triggers.json b/changelog/entries/unreleased/bug/fix_export_workflow_with_periodic_triggers.json new file mode 100644 index 0000000000..8e20aa7805 --- /dev/null +++ b/changelog/entries/unreleased/bug/fix_export_workflow_with_periodic_triggers.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Fix export workflow with periodic triggers", + "issue_origin": "github", + "issue_number": null, + "domain": "automation", + "bullet_points": [], + "created_at": "2026-05-06" +} \ No newline at end of file diff --git a/changelog/entries/unreleased/bug/fix_missing_data_during_dispatch_because_of_previous_page_vi.json b/changelog/entries/unreleased/bug/fix_missing_data_during_dispatch_because_of_previous_page_vi.json new file mode 100644 index 0000000000..0868795f2e --- /dev/null +++ b/changelog/entries/unreleased/bug/fix_missing_data_during_dispatch_because_of_previous_page_vi.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Fix missing data during dispatch because of previous page visibility", + "issue_origin": "github", + "issue_number": null, + "domain": "builder", + "bullet_points": [], + "created_at": "2026-05-06" +} \ No newline at end of file diff --git a/changelog/src/changelog.py b/changelog/src/changelog.py index 2506353707..da57ee2d09 100755 --- a/changelog/src/changelog.py +++ b/changelog/src/changelog.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 + import os import shutil from pathlib import Path @@ -17,38 +18,69 @@ # Parent directory default_path = str(Path(os.path.dirname(__file__)).parent) +DOMAIN_TYPES = list(domain_types.keys()) +ENTRY_TYPES = list(changelog_entry_types.keys()) + @app.command() -def add(working_dir: Optional[str] = typer.Option(default=default_path)): - domain_type = typer.prompt( +def add( + working_dir: Optional[str] = typer.Option(default=default_path), + domain: Optional[str] = typer.Option(None, "--domain", help=f"The module domain this changelog pertains to. One of: {', '.join(DOMAIN_TYPES)}."), + entry_type: Optional[str] = typer.Option( + None, "--type", help=f"The entry type this changelog is. One of: {', '.join(ENTRY_TYPES)}.", + ), + message: Optional[str] = typer.Option( + None, "--message", help="The changelog message. Describe in non-technical language what the bug, feature or refactor accomplishes." + ), + issue: Optional[str] = typer.Option(None, "--issue", help="The GitHub issue ID. Defaults to finding it through the branch name prefix."), +): + domain_type = domain or typer.prompt( "Domain", - type=Choice(list(domain_types.keys())), + type=Choice(DOMAIN_TYPES), default=DatabaseDomain.type, ) - changelog_type = typer.prompt( + if domain_type not in DOMAIN_TYPES: + raise typer.BadParameter( + f"must be one of: {', '.join(DOMAIN_TYPES)}", param_hint="--domain" + ) + + changelog_type = entry_type or typer.prompt( "Type of changelog", - type=Choice(list(changelog_entry_types.keys())), + type=Choice(ENTRY_TYPES), default=BugChangelogEntry.type, ) - issue_number = typer.prompt( - "Issue number", type=str, default=ChangelogHandler.get_issue_number() or "" - ) - - message = typer.prompt("Message", default="") - - if issue_number.isdigit(): + if changelog_type not in ENTRY_TYPES: + raise typer.BadParameter( + f"must be one of: {', '.join(ENTRY_TYPES)}", + param_hint="--type", + ) + if issue is not None: + issue_number = issue + elif domain is not None and entry_type is not None and message is not None: + issue_number = ChangelogHandler.get_issue_number() or "" + else: + issue_number = typer.prompt( + "Issue number", type=str, default=ChangelogHandler.get_issue_number() or "" + ) + final_message = message or typer.prompt("Message", default="") + + if issue_number and not str(issue_number).isdigit(): + raise typer.BadParameter("must be a numeric GitHub issue ID", param_hint="--issue") + + if str(issue_number).isdigit(): issue_number = int(issue_number) if issue_number == "": issue_number = None - ChangelogHandler(working_dir).add_entry( + path = ChangelogHandler(working_dir).add_entry( domain_type, changelog_type, - message, + final_message, issue_number=issue_number, issue_origin="github", # All new changelogs originate from GitHub ) + typer.echo(path) @app.command() diff --git a/changelog/src/changelog_entry.py b/changelog/src/changelog_entry.py index bd138f9111..48aef4ceaf 100644 --- a/changelog/src/changelog_entry.py +++ b/changelog/src/changelog_entry.py @@ -1,7 +1,7 @@ import abc import os from datetime import datetime, timezone -from typing import Dict, List, Optional, Union +from typing import Dict, List, Optional, Union, Any GITLAB_URL = os.environ.get("GITLAB_URL", "https://gitlab.com/baserow/baserow") GITHUB_URL = os.environ.get("GITHUB_URL", "https://github.com/baserow/baserow") @@ -21,7 +21,7 @@ def generate_entry_dict( issue_origin: str, issue_number: Optional[int] = None, bullet_points: List[str] = None, - ) -> Dict[str, any]: + ) -> Dict[str, Any]: if bullet_points is None: bullet_points = [] diff --git a/changelog/tests/changelog/test_changelog_add.py b/changelog/tests/changelog/test_changelog_add.py new file mode 100644 index 0000000000..42a43c7279 --- /dev/null +++ b/changelog/tests/changelog/test_changelog_add.py @@ -0,0 +1,80 @@ +import json + +from typer.testing import CliRunner + +from src.changelog import app + +runner = CliRunner() + + +def _invoke_add(tmp_path, domain="database", entry_type="bug", message="A test fix", issue="42"): + return runner.invoke( + app, + [ + "add", + "--working-dir", str(tmp_path), + "--domain", domain, + "--type", entry_type, + "--message", message, + "--issue", issue, + ], + ) + + +def test_add_creates_entry_file(tmp_path): + result = _invoke_add(tmp_path) + assert result.exit_code == 0 + file_path = result.output.strip() + assert file_path.endswith(".json") + with open(file_path) as f: + entry = json.load(f) + assert entry["message"] == "A test fix" + assert entry["domain"] == "database" + + +def test_add_includes_issue_number(tmp_path): + result = _invoke_add(tmp_path, issue="99") + assert result.exit_code == 0 + file_path = result.output.strip() + with open(file_path) as f: + entry = json.load(f) + assert entry["issue_number"] == 99 + + +def test_add_without_issue(tmp_path): + result = _invoke_add(tmp_path, issue="") + assert result.exit_code == 0 + file_path = result.output.strip() + with open(file_path) as f: + entry = json.load(f) + assert entry.get("issue_number") is None + + +def test_add_invalid_domain(tmp_path): + result = _invoke_add(tmp_path, domain="not_a_real_domain") + assert result.exit_code != 0 + + +def test_add_invalid_type(tmp_path): + result = _invoke_add(tmp_path, entry_type="not_a_real_type") + assert result.exit_code != 0 + + +def test_add_invalid_issue(tmp_path): + result = _invoke_add(tmp_path, issue="not-a-number") + assert result.exit_code != 0 + + +def test_add_without_issue_flag_runs_noninteractively(tmp_path): + result = runner.invoke( + app, + [ + "add", + "--working-dir", str(tmp_path), + "--domain", "database", + "--type", "bug", + "--message", "A test fix", + ], + ) + assert result.exit_code == 0 + assert result.output.strip().endswith(".json") \ No newline at end of file diff --git a/justfile b/justfile index 9029cc437b..aaee6386e9 100644 --- a/justfile +++ b/justfile @@ -1175,10 +1175,11 @@ env-clear: fi # Run changelog command (e.g., just changelog add, just changelog release 2.3.0) +[positional-arguments] [group('5 - utilities')] [doc("Changelog: just changelog ")] changelog *args: - cd backend && uv run --group changelog python ../changelog/src/changelog.py {{ args }} + cd backend && uv run --group changelog python ../changelog/src/changelog.py "$@" # Run changelog tests [group('4 - testing')] diff --git a/web-frontend/modules/builder/components/elements/baseComponents/ABDateTimePicker.vue b/web-frontend/modules/builder/components/elements/baseComponents/ABDateTimePicker.vue index 84fcd371e6..dbd04d6548 100644 --- a/web-frontend/modules/builder/components/elements/baseComponents/ABDateTimePicker.vue +++ b/web-frontend/modules/builder/components/elements/baseComponents/ABDateTimePicker.vue @@ -241,7 +241,7 @@ export default { */ handleDateBlur(event) { this.updateDate(event.target.value) - this.$refs.dateContext.hide() + this.$refs.dateContext?.hide() }, /** * Handle blur event on the time input field. diff --git a/web-frontend/modules/database/components/table/TableJSONImporter.vue b/web-frontend/modules/database/components/table/TableJSONImporter.vue index aabb48b0f8..fd73ca5a3c 100644 --- a/web-frontend/modules/database/components/table/TableJSONImporter.vue +++ b/web-frontend/modules/database/components/table/TableJSONImporter.vue @@ -10,16 +10,14 @@
 [
   {
-    "to": "Tove",
-    "from": "Jani",
-    "heading": "Reminder",
-    "body": "Don't forget me this weekend!"
+    "name": "John Doe",
+    "email": "john@example.com",
+    "phone": "123-456-7890"
   },
   {
-    "to": "Bram",
-    "from": "Nigel",
-    "heading": "Reminder",
-    "body": "Don't forget about the export feature this week"
+    "name": "Jane Smith",
+    "email": "jane@example.com",
+    "phone": "987-654-3210"
   }
 ]
         
{{ $t('tableXMLImporter.fileDescription') }}
-<notes>
-  <note>
-    <to>Tove</to>
-    <from>Jani</from>
-    <heading>Reminder</heading>
-    <body>Don't forget me this weekend!</body>
-  </note>
-  <note>
-    <heading>Reminder</heading>
-    <heading2>Reminder2</heading2>
-    <to>Tove</to>
-    <from>Jani</from>
-    <body>Don't forget me this weekend!</body>
-  </note>
-</notes>