From 45ea11c1b65887755f30e5945ea280abc0847929 Mon Sep 17 00:00:00 2001 From: Radovenchyk Date: Tue, 25 Mar 2025 21:49:48 +0200 Subject: [PATCH 1/9] docs: added a link to badge releases (#32822) Co-authored-by: Maxime Beauchemin --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e0bb2915399..c23fd3fdbe3 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ under the License. # Superset [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/license/apache-2-0) -[![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/apache/superset?sort=semver)](https://github.com/apache/superset/tree/latest) +[![Latest Release on Github](https://img.shields.io/github/v/release/apache/superset?sort=semver)](https://github.com/apache/superset/releases/latest) [![Build Status](https://github.com/apache/superset/actions/workflows/superset-python-unittest.yml/badge.svg)](https://github.com/apache/superset/actions) [![PyPI version](https://badge.fury.io/py/apache-superset.svg)](https://badge.fury.io/py/apache-superset) [![Coverage Status](https://codecov.io/github/apache/superset/coverage.svg?branch=master)](https://codecov.io/github/apache/superset) From 5222f940cc6675d3c78217be77304f2bd2f5fb6c Mon Sep 17 00:00:00 2001 From: Vitor Avila <96086495+Vitor-Avila@users.noreply.github.com> Date: Wed, 26 Mar 2025 08:17:43 -0300 Subject: [PATCH 2/9] fix(echarts): Sort series by name using naturalCompare (#32850) --- .../plugins/plugin-chart-echarts/package.json | 4 +- .../plugin-chart-echarts/src/utils/series.ts | 12 +- .../test/utils/series.test.ts | 111 ++++++++++++++++++ 3 files changed, 122 insertions(+), 5 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-echarts/package.json b/superset-frontend/plugins/plugin-chart-echarts/package.json index 64da86afdcc..01763f3078a 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/package.json +++ b/superset-frontend/plugins/plugin-chart-echarts/package.json @@ -24,10 +24,10 @@ "lib" ], "dependencies": { + "@types/react-redux": "^7.1.10", "d3-array": "^1.2.0", - "lodash": "^4.17.21", "dayjs": "^1.11.13", - "@types/react-redux": "^7.1.10" + "lodash": "^4.17.21" }, "peerDependencies": { "@superset-ui/chart-controls": "*", diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts index ef10fb9d3ba..157ab46f483 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts @@ -156,9 +156,15 @@ export function sortAndFilterSeries( case SortSeriesType.Avg: aggregator = name => ({ name, value: meanBy(rows, name) }); break; - default: - aggregator = name => ({ name, value: name.toLowerCase() }); - break; + default: { + const collator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', + }); + return seriesNames.sort((a, b) => + sortSeriesAscending ? collator.compare(a, b) : collator.compare(b, a), + ); + } } const sortedValues = seriesNames.map(aggregator); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts index 7054f6019ad..67a0bab9e60 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts @@ -67,6 +67,39 @@ const sortData: DataRecord[] = [ { my_x_axis: null, x: 4, y: 3, z: 7 }, ]; +const sortDataWithNumbers: DataRecord[] = [ + { + my_x_axis: 'my_axis', + '9. September': 6, + 6: 1, + '11. November': 8, + 8: 2, + '10. October': 1, + 10: 4, + '3. March': 2, + '8. August': 6, + 2: 1, + 12: 3, + 9: 1, + '1. January': 1, + '4. April': 12, + '2. February': 9, + 5: 4, + 3: 1, + 11: 2, + '12. December': 4, + 1: 7, + '6. June': 1, + 4: 5, + 7: 2, + c: 0, + '7. July': 2, + d: 0, + '5. May': 4, + a: 1, + }, +]; + const totalStackedValues = [3, 15, 14]; test('sortRows by name ascending', () => { @@ -288,6 +321,84 @@ test('sortAndFilterSeries by name descending', () => { sortAndFilterSeries(sortData, 'my_x_axis', [], SortSeriesType.Name, false), ).toEqual(['z', 'y', 'x']); }); +test('sortAndFilterSeries by name with numbers asc', () => { + expect( + sortAndFilterSeries( + sortDataWithNumbers, + 'my_x_axis', + [], + SortSeriesType.Name, + true, + ), + ).toEqual([ + '1', + '1. January', + '2', + '2. February', + '3', + '3. March', + '4', + '4. April', + '5', + '5. May', + '6', + '6. June', + '7', + '7. July', + '8', + '8. August', + '9', + '9. September', + '10', + '10. October', + '11', + '11. November', + '12', + '12. December', + 'a', + 'c', + 'd', + ]); +}); +test('sortAndFilterSeries by name with numbers desc', () => { + expect( + sortAndFilterSeries( + sortDataWithNumbers, + 'my_x_axis', + [], + SortSeriesType.Name, + false, + ), + ).toEqual([ + 'd', + 'c', + 'a', + '12. December', + '12', + '11. November', + '11', + '10. October', + '10', + '9. September', + '9', + '8. August', + '8', + '7. July', + '7', + '6. June', + '6', + '5. May', + '5', + '4. April', + '4', + '3. March', + '3', + '2. February', + '2', + '1. January', + '1', + ]); +}); describe('extractSeries', () => { it('should generate a valid ECharts timeseries series object', () => { From 68a81c39891ea871ea8553f0fc054de0db5d270d Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Wed, 26 Mar 2025 08:56:02 -0400 Subject: [PATCH 3/9] fix: update dataset/query catalog on DB changes (#32829) --- superset/commands/database/update.py | 44 +++++++++++-- .../commands/databases/update_test.py | 62 +++++++++++++++++++ 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/superset/commands/database/update.py b/superset/commands/database/update.py index a1accf4df10..f562b5d7802 100644 --- a/superset/commands/database/update.py +++ b/superset/commands/database/update.py @@ -23,7 +23,7 @@ from typing import Any from flask_appbuilder.models.sqla import Model -from superset import is_feature_enabled +from superset import db, is_feature_enabled from superset.commands.base import BaseCommand from superset.commands.database.exceptions import ( DatabaseExistsValidationError, @@ -79,13 +79,26 @@ class UpdateDatabaseCommand(BaseCommand): # existing personal tokens. self._handle_oauth2() - # if the database name changed we need to update any existing permissions, - # since they're name based + # build new DB original_database_name = self._model.database_name - + original_catalog = self._model.get_default_catalog() database = DatabaseDAO.update(self._model, self._properties) database.set_sqlalchemy_uri(database.sqlalchemy_uri) ssh_tunnel = self._handle_ssh_tunnel(database) + new_catalog = database.get_default_catalog() + + # update assets when the database catalog changes, if the database was not + # configured with multi-catalog support; if it was enabled or is enabled in the + # update we don't update the assets + if ( + new_catalog != original_catalog + and not self._model.allow_multi_catalog + and not database.allow_multi_catalog + ): + self._update_catalog_attribute(self._model.id, new_catalog) + + # if the database name changed we need to update any existing permissions, + # since they're name based try: current_username = get_username() SyncPermissionsCommand( @@ -159,6 +172,29 @@ class UpdateDatabaseCommand(BaseCommand): ssh_tunnel_properties, ).run() + def _update_catalog_attribute( + self, + database_id: int, + new_catalog: str | None, + ) -> None: + """ + Update the catalog of the datasets that are associated with database. + """ + from superset.connectors.sqla.models import SqlaTable + from superset.models.sql_lab import Query, SavedQuery, TableSchema, TabState + + for model in [ + SqlaTable, + Query, + SavedQuery, + TabState, + TableSchema, + ]: + fk = "db_id" if model == SavedQuery else "database_id" + predicate = {fk: database_id} + update = {"catalog": new_catalog} + db.session.query(model).filter_by(**predicate).update(update) + def validate(self) -> None: if database_name := self._properties.get("database_name"): if not DatabaseDAO.validate_update_uniqueness( diff --git a/tests/unit_tests/commands/databases/update_test.py b/tests/unit_tests/commands/databases/update_test.py index a74ff3c5c06..5e6b0869d72 100644 --- a/tests/unit_tests/commands/databases/update_test.py +++ b/tests/unit_tests/commands/databases/update_test.py @@ -580,3 +580,65 @@ def test_update_other_fields_dont_affect_oauth( add_pvm.assert_not_called() database_needs_oauth2.purge_oauth2_tokens.assert_not_called() + + +def test_update_with_catalog_change(mocker: MockerFixture) -> None: + """ + Test that assets are updated when the main catalog changes. + """ + old_database = mocker.MagicMock(allow_multi_catalog=False) + old_database.get_default_catalog.return_value = "project-A" + old_database.id = 1 + + new_database = mocker.MagicMock(allow_multi_catalog=False) + new_database.get_default_catalog.return_value = "project-B" + + database_dao = mocker.patch("superset.commands.database.update.DatabaseDAO") + database_dao.find_by_id.return_value = old_database + database_dao.update.return_value = new_database + + mocker.patch("superset.commands.database.update.SyncPermissionsCommand") + mocker.patch.object( + UpdateDatabaseCommand, + "validate", + ) + update_catalog_attribute = mocker.patch.object( + UpdateDatabaseCommand, + "_update_catalog_attribute", + ) + + UpdateDatabaseCommand(1, {}).run() + + update_catalog_attribute.assert_called_once_with(1, "project-B") + + +def test_update_without_catalog_change(mocker: MockerFixture) -> None: + """ + Test that assets are not updated when the main catalog doesn't change. + """ + old_database = mocker.MagicMock(allow_multi_catalog=False) + old_database.database_name = "Ye Old DB" + old_database.get_default_catalog.return_value = "project-A" + old_database.id = 1 + + new_database = mocker.MagicMock(allow_multi_catalog=False) + new_database.database_name = "Fancy new DB" + new_database.get_default_catalog.return_value = "project-A" + + database_dao = mocker.patch("superset.commands.database.update.DatabaseDAO") + database_dao.find_by_id.return_value = old_database + database_dao.update.return_value = new_database + + mocker.patch("superset.commands.database.update.SyncPermissionsCommand") + mocker.patch.object( + UpdateDatabaseCommand, + "validate", + ) + update_catalog_attribute = mocker.patch.object( + UpdateDatabaseCommand, + "_update_catalog_attribute", + ) + + UpdateDatabaseCommand(1, {}).run() + + update_catalog_attribute.assert_not_called() From 6c7f089ebb7abf57b2f21ce87bf328ae7196f3c6 Mon Sep 17 00:00:00 2001 From: Christiaan Baartse Date: Wed, 26 Mar 2025 16:13:50 +0100 Subject: [PATCH 4/9] fix(translation): Dutch translations for Current datetime filter (#31869) --- .../translations/nl/LC_MESSAGES/messages.po | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/superset/translations/nl/LC_MESSAGES/messages.po b/superset/translations/nl/LC_MESSAGES/messages.po index e652b9b74f4..ffdc05c88ba 100644 --- a/superset/translations/nl/LC_MESSAGES/messages.po +++ b/superset/translations/nl/LC_MESSAGES/messages.po @@ -3056,9 +3056,8 @@ msgstr "Configuratie" msgid "Configure Advanced Time Range " msgstr "Configureer geavanceerd tijdbereik " -#, fuzzy msgid "Configure Time Range: Current..." -msgstr "Configureer Tijdbereik: Laatste..." +msgstr "Configureer Tijdbereik: Huidige..." msgid "Configure Time Range: Last..." msgstr "Configureer Tijdbereik: Laatste..." @@ -3366,29 +3365,23 @@ msgstr "Valuta voorvoegsel of achtervoegsel" msgid "Currency symbol" msgstr "Valuta symbool" -#, fuzzy msgid "Current" -msgstr "Valuta" +msgstr "Huidig" -#, fuzzy msgid "Current day" -msgstr "Valuta" +msgstr "Huidige dag" -#, fuzzy msgid "Current month" -msgstr "Valuta symbool" +msgstr "Huidige maand" -#, fuzzy msgid "Current quarter" -msgstr "Voer huidige query uit" +msgstr "Huidig kwartaal" -#, fuzzy msgid "Current week" -msgstr "Voer huidige query uit" +msgstr "Huidige week" -#, fuzzy msgid "Current year" -msgstr "Valuta" +msgstr "Huidig jaar" #, python-format msgid "Currently rendered: %s" From f0dc1e7527b3d13888c7bb4833da56c12c6aaa3c Mon Sep 17 00:00:00 2001 From: Vitor Avila <96086495+Vitor-Avila@users.noreply.github.com> Date: Wed, 26 Mar 2025 13:28:22 -0300 Subject: [PATCH 5/9] fix(table-chart): Do not show comparison columns config if time_compare is set to [] (#32863) --- .../plugins/plugin-chart-table/src/controlPanel.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx index 933ee6a0c1d..ef3a8e700c2 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx @@ -486,8 +486,9 @@ const config: ControlPanelConfig = { return true; }, mapStateToProps(explore, _, chart) { - const timeComparisonStatus = - !!explore?.controls?.time_compare?.value; + const timeComparisonStatus = !isEmpty( + explore?.controls?.time_compare?.value, + ); const { colnames: _colnames, coltypes: _coltypes } = chart?.queriesResponse?.[0] ?? {}; From b1693f625a058f87a892beea0c4d5640aeae3eae Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" <70410625+michael-s-molina@users.noreply.github.com> Date: Wed, 26 Mar 2025 13:35:14 -0300 Subject: [PATCH 6/9] chore: Removes unused file (#32860) --- null_byte.csv | Bin 6 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 null_byte.csv diff --git a/null_byte.csv b/null_byte.csv deleted file mode 100644 index 55132aaa6398b76cf42aa1473f9959dd09b08b03..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6 NcmZ?dQesfz0ssP(0Ga>* From 37f626f5e2970e5b65a5ad5f157021fa82ed55cc Mon Sep 17 00:00:00 2001 From: SBIN2010 <132096459+SBIN2010@users.noreply.github.com> Date: Wed, 26 Mar 2025 20:28:07 +0300 Subject: [PATCH 7/9] fix(ColorPickerControl): change color picker control width (#32851) --- .../src/explore/components/controls/ColorPickerControl.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/superset-frontend/src/explore/components/controls/ColorPickerControl.jsx b/superset-frontend/src/explore/components/controls/ColorPickerControl.jsx index 1d140db0ae7..56b86e58947 100644 --- a/superset-frontend/src/explore/components/controls/ColorPickerControl.jsx +++ b/superset-frontend/src/explore/components/controls/ColorPickerControl.jsx @@ -78,7 +78,7 @@ export default class ColorPickerControl extends Component { renderPopover() { const presetColors = getCategoricalSchemeRegistry() .get() - .colors.filter((s, i) => i < 7); + .colors.filter((s, i) => i < 9); return (
Date: Wed, 26 Mar 2025 10:29:04 -0700 Subject: [PATCH 8/9] chore: update migrations to use utils (#32852) --- .../2024-09-25_17-59_7b17aa722e30_uuidmixin.py | 11 ++++++----- ...0-52_94e7a3499973_add_folders_column_to_dataset.py | 7 ++++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/superset/migrations/versions/2024-09-25_17-59_7b17aa722e30_uuidmixin.py b/superset/migrations/versions/2024-09-25_17-59_7b17aa722e30_uuidmixin.py index 2366d3bf261..9cc3735f28c 100644 --- a/superset/migrations/versions/2024-09-25_17-59_7b17aa722e30_uuidmixin.py +++ b/superset/migrations/versions/2024-09-25_17-59_7b17aa722e30_uuidmixin.py @@ -24,7 +24,8 @@ Create Date: 2024-09-25 17:59:21.028426 import sqlalchemy as sa import sqlalchemy_utils -from alembic import op + +from superset.migrations.shared.utils import add_columns, drop_columns # revision identifiers, used by Alembic. revision = "7b17aa722e30" @@ -32,16 +33,16 @@ down_revision = "48cbb571fa3a" def upgrade(): - op.add_column( + add_columns( "css_templates", sa.Column("uuid", sqlalchemy_utils.types.uuid.UUIDType(), nullable=True), ) - op.add_column( + add_columns( "favstar", sa.Column("uuid", sqlalchemy_utils.types.uuid.UUIDType(), nullable=True), ) def downgrade(): - op.drop_column("css_templates", "uuid") - op.drop_column("favstar", "uuid") + drop_columns("css_templates", "uuid") + drop_columns("favstar", "uuid") diff --git a/superset/migrations/versions/2025-03-03_20-52_94e7a3499973_add_folders_column_to_dataset.py b/superset/migrations/versions/2025-03-03_20-52_94e7a3499973_add_folders_column_to_dataset.py index 3e767f36a6f..e9f7d387c51 100644 --- a/superset/migrations/versions/2025-03-03_20-52_94e7a3499973_add_folders_column_to_dataset.py +++ b/superset/migrations/versions/2025-03-03_20-52_94e7a3499973_add_folders_column_to_dataset.py @@ -23,20 +23,21 @@ Create Date: 2025-03-03 20:52:24.585143 """ import sqlalchemy as sa -from alembic import op from sqlalchemy.types import JSON +from superset.migrations.shared.utils import add_columns, drop_columns + # revision identifiers, used by Alembic. revision = "94e7a3499973" down_revision = "74ad1125881c" def upgrade(): - op.add_column( + add_columns( "tables", sa.Column("folders", JSON, nullable=True), ) def downgrade(): - op.drop_column("tables", "folders") + drop_columns("tables", "folders") From 103fedaf922292567d7082f3febe24d46b1516d3 Mon Sep 17 00:00:00 2001 From: Luke Hart <50547373+lohart13@users.noreply.github.com> Date: Fri, 28 Mar 2025 06:01:14 +1300 Subject: [PATCH 9/9] fix: use role_model from security manager (#32873) --- superset/security/manager.py | 4 +- .../unit_tests/commands/dashboard/__init__.py | 16 +++ .../commands/dashboard/create_test.py | 98 +++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/commands/dashboard/__init__.py create mode 100644 tests/unit_tests/commands/dashboard/create_test.py diff --git a/superset/security/manager.py b/superset/security/manager.py index 80a1fb957dd..d11e0f3e41c 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -1126,7 +1126,9 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods """ Find a List of models by a list of ids, if defined applies `base_filter` """ - query = self.get_session.query(Role).filter(Role.id.in_(role_ids)) + query = self.get_session.query(self.role_model).filter( + self.role_model.id.in_(role_ids) + ) return query.all() def copy_role( diff --git a/tests/unit_tests/commands/dashboard/__init__.py b/tests/unit_tests/commands/dashboard/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/tests/unit_tests/commands/dashboard/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/tests/unit_tests/commands/dashboard/create_test.py b/tests/unit_tests/commands/dashboard/create_test.py new file mode 100644 index 00000000000..9bb0cff9ab9 --- /dev/null +++ b/tests/unit_tests/commands/dashboard/create_test.py @@ -0,0 +1,98 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import pytest +from flask_appbuilder.security.sqla.models import Role, User +from pytest_mock import MockerFixture + +from superset.commands.dashboard.create import CreateDashboardCommand +from superset.extensions import security_manager +from tests.unit_tests.fixtures.common import admin_user, after_each # noqa: F401 + + +def test_validate_custom_role_class( + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + admin_user: User, # noqa: F811 + after_each: None, # noqa: F811 +) -> None: + class CustomRoleModel(Role): + __tablename__ = "ab_role" + + monkeypatch.setattr(security_manager, "role_model", CustomRoleModel) + mocker.patch.object(security_manager, "is_admin", return_value=True) + + owner_ids = [admin_user.id] + role_ids = [role.id for role in admin_user.roles] + + command = CreateDashboardCommand(data={"owners": owner_ids, "roles": role_ids}) + command.validate() + + for role in command._properties["roles"]: + assert isinstance(role, CustomRoleModel) + + +def test_validate_custom_user_class( + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + admin_user: User, # noqa: F811 + after_each: None, # noqa: F811 +) -> None: + class CustomUserModel(User): + __tablename__ = "ab_user" + + monkeypatch.setattr(security_manager, "user_model", CustomUserModel) + mocker.patch.object(security_manager, "is_admin", return_value=True) + + owner_ids = [admin_user.id] + role_ids = [role.id for role in admin_user.roles] + + command = CreateDashboardCommand(data={"owners": owner_ids, "roles": role_ids}) + command.validate() + + for owner in command._properties["owners"]: + assert isinstance(owner, CustomUserModel) + + +def test_validate_custom_role_and_user_class( + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + admin_user: User, # noqa: F811 + after_each: None, # noqa: F811 +) -> None: + class CustomRoleModel(Role): + __tablename__ = "ab_role" + + class CustomUserModel(User): + __tablename__ = "ab_user" + + monkeypatch.setattr(security_manager, "role_model", CustomRoleModel) + monkeypatch.setattr(security_manager, "user_model", CustomUserModel) + mocker.patch.object(security_manager, "is_admin", return_value=True) + + owner_ids = [admin_user.id] + role_ids = [role.id for role in admin_user.roles] + + command = CreateDashboardCommand(data={"owners": owner_ids, "roles": role_ids}) + command.validate() + + for role in command._properties["roles"]: + assert isinstance(role, CustomRoleModel) + + for owner in command._properties["owners"]: + assert isinstance(owner, CustomUserModel)