This is an automated email from the ASF dual-hosted git repository. jli pushed a commit to branch 6.0 in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/6.0 by this push: new 1c95ea5ab8 fix: complete theme management system import/export (#34850) 1c95ea5ab8 is described below commit 1c95ea5ab89046f8b165fbc91c4fc7efb7ad579d Author: Maxime Beauchemin <maximebeauche...@gmail.com> AuthorDate: Mon Sep 1 15:44:01 2025 -0700 fix: complete theme management system import/export (#34850) Co-authored-by: Claude <nore...@anthropic.com> (cherry picked from commit 4695be5cc560490f9aff669b5e18aaf2985faecc) --- superset/commands/dashboard/export.py | 9 + .../commands/dashboard/importers/v1/__init__.py | 28 ++- superset/commands/dashboard/importers/v1/utils.py | 2 + superset/commands/importers/v1/__init__.py | 14 +- superset/commands/importers/v1/utils.py | 7 + superset/commands/theme/import_themes.py | 11 +- superset/dashboards/schemas.py | 2 + superset/models/dashboard.py | 3 +- superset/themes/schemas.py | 39 ++++- tests/integration_tests/charts/api_tests.py | 57 ++----- tests/integration_tests/charts/commands_tests.py | 4 +- tests/integration_tests/dashboards/api_tests.py | 60 +++---- .../integration_tests/dashboards/commands_tests.py | 16 +- .../dashboards/test_theme_integration.py | 142 ++++++++++++++- tests/integration_tests/databases/api_tests.py | 190 ++++++--------------- .../integration_tests/databases/commands_tests.py | 12 +- tests/integration_tests/datasets/api_tests.py | 60 +++---- tests/integration_tests/datasets/commands_tests.py | 4 +- .../queries/saved_queries/commands_tests.py | 4 +- 19 files changed, 369 insertions(+), 295 deletions(-) diff --git a/superset/commands/dashboard/export.py b/superset/commands/dashboard/export.py index e0701d5e93..f3f99519ab 100644 --- a/superset/commands/dashboard/export.py +++ b/superset/commands/dashboard/export.py @@ -161,6 +161,9 @@ class ExportDashboardsCommand(ExportModelsCommand): if orphan_charts: payload["position"] = append_charts(payload["position"], orphan_charts) + # Add theme UUID for proper cross-system imports + payload["theme_uuid"] = str(model.theme.uuid) if model.theme else None + payload["version"] = EXPORT_VERSION # Check if the TAGGING_SYSTEM feature is enabled @@ -193,6 +196,12 @@ class ExportDashboardsCommand(ExportModelsCommand): dashboard_ids=dashboard_ids, chart_ids=chart_ids ) + # Export related theme + if model.theme: + from superset.commands.theme.export import ExportThemesCommand + + yield from ExportThemesCommand([model.theme.id]).run() + payload = model.export_to_dict( recursive=False, include_parent_ref=False, diff --git a/superset/commands/dashboard/importers/v1/__init__.py b/superset/commands/dashboard/importers/v1/__init__.py index e0e1319c13..fdd331c320 100644 --- a/superset/commands/dashboard/importers/v1/__init__.py +++ b/superset/commands/dashboard/importers/v1/__init__.py @@ -17,6 +17,7 @@ from __future__ import annotations +import logging from typing import Any from marshmallow import Schema @@ -37,6 +38,7 @@ from superset.commands.database.importers.v1.utils import import_database from superset.commands.dataset.importers.v1.utils import import_dataset from superset.commands.importers.v1 import ImportModelsCommand from superset.commands.importers.v1.utils import import_tag +from superset.commands.theme.import_themes import import_theme from superset.commands.utils import update_chart_config_dataset from superset.daos.dashboard import DashboardDAO from superset.dashboards.schemas import ImportV1DashboardSchema @@ -45,6 +47,9 @@ from superset.datasets.schemas import ImportV1DatasetSchema from superset.extensions import feature_flag_manager from superset.migrations.shared.native_filters import migrate_dashboard from superset.models.dashboard import Dashboard, dashboard_slices +from superset.themes.schemas import ImportV1ThemeSchema + +logger = logging.getLogger(__name__) class ImportDashboardsCommand(ImportModelsCommand): @@ -58,6 +63,7 @@ class ImportDashboardsCommand(ImportModelsCommand): "dashboards/": ImportV1DashboardSchema(), "datasets/": ImportV1DatasetSchema(), "databases/": ImportV1DatabaseSchema(), + "themes/": ImportV1ThemeSchema(), } import_error = DashboardImportError @@ -71,15 +77,19 @@ class ImportDashboardsCommand(ImportModelsCommand): contents: dict[str, Any] | None = None, ) -> None: contents = {} if contents is None else contents - # discover charts and datasets associated with dashboards + # discover charts, datasets, and themes associated with dashboards chart_uuids: set[str] = set() dataset_uuids: set[str] = set() + theme_uuids: set[str] = set() for file_name, config in configs.items(): if file_name.startswith("dashboards/"): chart_uuids.update(find_chart_uuids(config["position"])) dataset_uuids.update( find_native_filter_datasets(config.get("metadata", {})) ) + # discover theme associated with dashboard + if config.get("theme_uuid"): + theme_uuids.add(config["theme_uuid"]) # discover datasets associated with charts for file_name, config in configs.items(): @@ -92,6 +102,14 @@ class ImportDashboardsCommand(ImportModelsCommand): if file_name.startswith("datasets/") and config["uuid"] in dataset_uuids: database_uuids.add(config["database_uuid"]) + # import related themes + theme_ids: dict[str, int] = {} + for file_name, config in configs.items(): + if file_name.startswith("themes/") and config["uuid"] in theme_uuids: + theme = import_theme(config, overwrite=False) + if theme: + theme_ids[str(theme.uuid)] = theme.id + # import related databases database_ids: dict[str, int] = {} for file_name, config in configs.items(): @@ -149,6 +167,14 @@ class ImportDashboardsCommand(ImportModelsCommand): for file_name, config in configs.items(): if file_name.startswith("dashboards/"): config = update_id_refs(config, chart_ids, dataset_info) + # Handle theme UUID to ID mapping + if "theme_uuid" in config and config["theme_uuid"] in theme_ids: + config["theme_id"] = theme_ids[config["theme_uuid"]] + del config["theme_uuid"] + elif "theme_uuid" in config: + # Theme not found, set to None for graceful fallback + config["theme_id"] = None + del config["theme_uuid"] dashboard = import_dashboard(config, overwrite=overwrite) dashboards.append(dashboard) for uuid in find_chart_uuids(config["position"]): diff --git a/superset/commands/dashboard/importers/v1/utils.py b/superset/commands/dashboard/importers/v1/utils.py index 9d1f510a5d..c506db72cb 100644 --- a/superset/commands/dashboard/importers/v1/utils.py +++ b/superset/commands/dashboard/importers/v1/utils.py @@ -227,6 +227,8 @@ def import_dashboard( # noqa: C901 if "metadata" in config and "show_native_filters" in config["metadata"]: del config["metadata"]["show_native_filters"] + # Note: theme_id handling moved to higher level import logic + for key, new_name in JSON_KEYS.items(): if config.get(key) is not None: value = config.pop(key) diff --git a/superset/commands/importers/v1/__init__.py b/superset/commands/importers/v1/__init__.py index 4f1e5d68a3..f2f314479b 100644 --- a/superset/commands/importers/v1/__init__.py +++ b/superset/commands/importers/v1/__init__.py @@ -115,10 +115,20 @@ class ImportModelsCommand(BaseCommand): self._prevent_overwrite_existing_model(exceptions) if exceptions: + detailed_errors = [] for ex in exceptions: - logger.warning("Import Error: %s", ex) + # Extract detailed error information + if hasattr(ex, "messages") and isinstance(ex.messages, dict): + for file_name, errors in ex.messages.items(): + logger.error("Validation failed for %s: %s", file_name, errors) + detailed_errors.append(f"{file_name}: {errors}") + else: + logger.error("Import validation error: %s", ex) + detailed_errors.append(str(ex)) + + error_summary = "; ".join(detailed_errors) raise CommandInvalidError( - f"Error importing {self.model_name}", + f"Error importing {self.model_name}: {error_summary}", exceptions, ) diff --git a/superset/commands/importers/v1/utils.py b/superset/commands/importers/v1/utils.py index 26db3826b2..9f7621e248 100644 --- a/superset/commands/importers/v1/utils.py +++ b/superset/commands/importers/v1/utils.py @@ -199,6 +199,13 @@ def load_configs( schema.load(config) configs[file_name] = config except ValidationError as exc: + logger.error( + "Schema validation failed for %s (prefix: %s): %s", + file_name, + prefix, + exc.messages, + ) + logger.debug("Config content that failed validation: %s", config) exc.messages = {file_name: exc.messages} exceptions.append(exc) diff --git a/superset/commands/theme/import_themes.py b/superset/commands/theme/import_themes.py index e128df59b1..dd37f12247 100644 --- a/superset/commands/theme/import_themes.py +++ b/superset/commands/theme/import_themes.py @@ -16,10 +16,13 @@ # under the License. import logging -from typing import Any +from typing import Any, TYPE_CHECKING from marshmallow import Schema +if TYPE_CHECKING: + from superset.models.core import Theme + from superset.commands.importers.v1 import ImportModelsCommand from superset.commands.theme.exceptions import ThemeImportError from superset.daos.theme import ThemeDAO @@ -29,7 +32,7 @@ from superset.utils import json logger = logging.getLogger(__name__) -def import_theme(config: dict[str, Any], overwrite: bool = False) -> None: +def import_theme(config: dict[str, Any], overwrite: bool = False) -> "Theme | None": """Import a single theme from config dictionary""" from superset import db, security_manager from superset.models.core import Theme @@ -40,7 +43,7 @@ def import_theme(config: dict[str, Any], overwrite: bool = False) -> None: if existing: if not overwrite or not can_write: - return + return existing config["id"] = existing.id elif not can_write: raise ThemeImportError( @@ -61,6 +64,8 @@ def import_theme(config: dict[str, Any], overwrite: bool = False) -> None: theme.changed_by = user theme.created_by = user + return theme + class ImportThemesCommand(ImportModelsCommand): """Import themes""" diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py index df6c1dd04b..09d801b95c 100644 --- a/superset/dashboards/schemas.py +++ b/superset/dashboards/schemas.py @@ -500,6 +500,8 @@ class ImportV1DashboardSchema(Schema): certification_details = fields.String(allow_none=True) published = fields.Boolean(allow_none=True) tags = fields.List(fields.String(), allow_none=True) + theme_uuid = fields.UUID(allow_none=True) + theme_id = fields.Integer(allow_none=True) class EmbeddedDashboardConfigSchema(Schema): diff --git a/superset/models/dashboard.py b/superset/models/dashboard.py index 3083cf282c..b6c10df310 100644 --- a/superset/models/dashboard.py +++ b/superset/models/dashboard.py @@ -174,13 +174,12 @@ class Dashboard(AuditMixinNullable, ImportExportMixin, Model): "json_metadata", "description", "css", - "theme_id", "slug", "certified_by", "certification_details", "published", ] - extra_import_fields = ["is_managed_externally", "external_url"] + extra_import_fields = ["is_managed_externally", "external_url", "theme_id"] def __repr__(self) -> str: return f"Dashboard<{self.id or self.slug}>" diff --git a/superset/themes/schemas.py b/superset/themes/schemas.py index fd7bb6514b..4471c4deab 100644 --- a/superset/themes/schemas.py +++ b/superset/themes/schemas.py @@ -14,6 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +import logging from typing import Any from marshmallow import fields, Schema, validates, ValidationError @@ -21,6 +22,8 @@ from marshmallow import fields, Schema, validates, ValidationError from superset.themes.utils import is_valid_theme from superset.utils import json +logger = logging.getLogger(__name__) + class ImportV1ThemeSchema(Schema): theme_name = fields.String(required=True) @@ -42,12 +45,20 @@ class ImportV1ThemeSchema(Schema): except (TypeError, json.JSONDecodeError) as ex: raise ValidationError("Invalid JSON configuration") from ex - # Validate theme structure + # Strict validation for all contexts - ensures consistent data quality if not is_valid_theme(theme_config): + # Add detailed error info for debugging + logger.error("Theme validation failed. Theme config: %s", theme_config) + logger.error( + "Theme type: %s, Algorithm: %s, Has token: %s", + type(theme_config).__name__, + theme_config.get("algorithm"), + "token" in theme_config, + ) raise ValidationError("Invalid theme configuration structure") -class ThemePostSchema(Schema): +class ThemeBaseSchema(Schema): theme_name = fields.String(required=True, allow_none=False) json_data = fields.String(required=True, allow_none=False) @@ -56,15 +67,25 @@ class ThemePostSchema(Schema): if not value or not value.strip(): raise ValidationError("Theme name cannot be empty.") + @validates("json_data") + def validate_json_data(self, value: str) -> None: + # Parse JSON string + try: + theme_config = json.loads(value) + except (TypeError, json.JSONDecodeError) as ex: + raise ValidationError("Invalid JSON configuration") from ex -class ThemePutSchema(Schema): - theme_name = fields.String(required=True, allow_none=False) - json_data = fields.String(required=True, allow_none=False) + # Strict validation for theme creation/updates + if not is_valid_theme(theme_config): + raise ValidationError("Invalid theme configuration structure") - @validates("theme_name") - def validate_theme_name(self, value: str) -> None: - if not value or not value.strip(): - raise ValidationError("Theme name cannot be empty.") + +class ThemePostSchema(ThemeBaseSchema): + pass + + +class ThemePutSchema(ThemeBaseSchema): + pass openapi_spec_methods_override = { diff --git a/tests/integration_tests/charts/api_tests.py b/tests/integration_tests/charts/api_tests.py index 381e3f6846..b8b6035541 100644 --- a/tests/integration_tests/charts/api_tests.py +++ b/tests/integration_tests/charts/api_tests.py @@ -1839,24 +1839,16 @@ class TestChartApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCase): response = json.loads(rv.data.decode("utf-8")) assert rv.status_code == 422 - assert response == { - "errors": [ - { - "message": "Error importing chart", - "error_type": "GENERIC_COMMAND_ERROR", - "level": "warning", - "extra": { - "charts/chart.yaml": "Chart already exists and `overwrite=true` was not passed", # noqa: E501 - "issue_codes": [ - { - "code": 1010, - "message": "Issue 1010 - Superset encountered an error while running a command.", # noqa: E501 - } - ], - }, - } - ] - } + assert len(response["errors"]) == 1 + error = response["errors"][0] + assert error["message"].startswith("Error importing chart") + assert error["error_type"] == "GENERIC_COMMAND_ERROR" + assert error["level"] == "warning" + assert "charts/chart.yaml" in str(error["extra"]) + assert "Chart already exists and `overwrite=true` was not passed" in str( + error["extra"] + ) + assert error["extra"]["issue_codes"][0]["code"] == 1010 # import with overwrite flag buf = self.create_import_v1_zip_file("chart") @@ -1900,27 +1892,14 @@ class TestChartApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCase): response = json.loads(rv.data.decode("utf-8")) assert rv.status_code == 422 - assert response == { - "errors": [ - { - "message": "Error importing chart", - "error_type": "GENERIC_COMMAND_ERROR", - "level": "warning", - "extra": { - "metadata.yaml": {"type": ["Must be equal to Slice."]}, - "issue_codes": [ - { - "code": 1010, - "message": ( - "Issue 1010 - Superset encountered an " - "error while running a command." - ), - } - ], - }, - } - ] - } + assert len(response["errors"]) == 1 + error = response["errors"][0] + assert error["message"].startswith("Error importing chart") + assert error["error_type"] == "GENERIC_COMMAND_ERROR" + assert error["level"] == "warning" + assert "metadata.yaml" in error["extra"] + assert error["extra"]["metadata.yaml"] == {"type": ["Must be equal to Slice."]} + assert error["extra"]["issue_codes"][0]["code"] == 1010 def test_gets_created_by_user_charts_filter(self): arguments = { diff --git a/tests/integration_tests/charts/commands_tests.py b/tests/integration_tests/charts/commands_tests.py index 6eec119ca9..a72709c12e 100644 --- a/tests/integration_tests/charts/commands_tests.py +++ b/tests/integration_tests/charts/commands_tests.py @@ -313,7 +313,7 @@ class TestImportChartsCommand(SupersetTestCase): command = ImportChartsCommand(contents) with pytest.raises(CommandInvalidError) as excinfo: command.run() - assert str(excinfo.value) == "Error importing chart" + assert str(excinfo.value).startswith("Error importing chart") assert excinfo.value.normalized_messages() == { "metadata.yaml": {"type": ["Must be equal to Slice."]} } @@ -326,7 +326,7 @@ class TestImportChartsCommand(SupersetTestCase): command = ImportChartsCommand(contents) with pytest.raises(CommandInvalidError) as excinfo: command.run() - assert str(excinfo.value) == "Error importing chart" + assert str(excinfo.value).startswith("Error importing chart") assert excinfo.value.normalized_messages() == { "databases/imported_database.yaml": { "database_name": ["Missing data for required field."], diff --git a/tests/integration_tests/dashboards/api_tests.py b/tests/integration_tests/dashboards/api_tests.py index 43da49abbb..1e896d8854 100644 --- a/tests/integration_tests/dashboards/api_tests.py +++ b/tests/integration_tests/dashboards/api_tests.py @@ -2456,27 +2456,16 @@ class TestDashboardApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCas response = json.loads(rv.data.decode("utf-8")) assert rv.status_code == 422 - assert response == { - "errors": [ - { - "message": "Error importing dashboard", - "error_type": "GENERIC_COMMAND_ERROR", - "level": "warning", - "extra": { - "dashboards/dashboard.yaml": "Dashboard already exists and `overwrite=true` was not passed", # noqa: E501 - "issue_codes": [ - { - "code": 1010, - "message": ( - "Issue 1010 - Superset encountered an " - "error while running a command." - ), - } - ], - }, - } - ] - } + assert len(response["errors"]) == 1 + error = response["errors"][0] + assert error["message"].startswith("Error importing dashboard") + assert error["error_type"] == "GENERIC_COMMAND_ERROR" + assert error["level"] == "warning" + assert "dashboards/dashboard.yaml" in str(error["extra"]) + assert "Dashboard already exists and `overwrite=true` was not passed" in str( + error["extra"] + ) + assert error["extra"]["issue_codes"][0]["code"] == 1010 # import with overwrite flag buf = self.create_import_v1_zip_file("dashboard") @@ -2519,27 +2508,16 @@ class TestDashboardApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCas response = json.loads(rv.data.decode("utf-8")) assert rv.status_code == 422 - assert response == { - "errors": [ - { - "message": "Error importing dashboard", - "error_type": "GENERIC_COMMAND_ERROR", - "level": "warning", - "extra": { - "metadata.yaml": {"type": ["Must be equal to Dashboard."]}, - "issue_codes": [ - { - "code": 1010, - "message": ( - "Issue 1010 - Superset encountered " - "an error while running a command." - ), - } - ], - }, - } - ] + assert len(response["errors"]) == 1 + error = response["errors"][0] + assert error["message"].startswith("Error importing dashboard") + assert error["error_type"] == "GENERIC_COMMAND_ERROR" + assert error["level"] == "warning" + assert "metadata.yaml" in error["extra"] + assert error["extra"]["metadata.yaml"] == { + "type": ["Must be equal to Dashboard."] } + assert error["extra"]["issue_codes"][0]["code"] == 1010 def test_get_all_related_roles(self): """ diff --git a/tests/integration_tests/dashboards/commands_tests.py b/tests/integration_tests/dashboards/commands_tests.py index fc2f89ce44..68d936e8fd 100644 --- a/tests/integration_tests/dashboards/commands_tests.py +++ b/tests/integration_tests/dashboards/commands_tests.py @@ -239,7 +239,7 @@ class TestExportDashboardsCommand(SupersetTestCase): }, "metadata": {"mock_key": "mock_value"}, "version": "1.0.0", - "theme_id": None, + "theme_uuid": None, } # @pytest.mark.usefixtures("load_covid_dashboard") @@ -318,8 +318,8 @@ class TestExportDashboardsCommand(SupersetTestCase): @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") @patch("superset.security.manager.g") @patch("superset.views.base.g") - def test_export_dashboard_command_key_order(self, mock_g1, mock_g2): - """Test that they keys in the YAML have the same order as export_fields""" + def test_export_dashboard_command_required_fields(self, mock_g1, mock_g2): + """Test that all required keys are present in the exported YAML""" mock_g1.user = security_manager.find_user("admin") mock_g2.user = security_manager.find_user("admin") @@ -332,11 +332,11 @@ class TestExportDashboardsCommand(SupersetTestCase): metadata = yaml.safe_load( contents[f"dashboards/World_Banks_Data_{example_dashboard.id}.yaml"]() ) - assert list(metadata.keys()) == [ + assert set(metadata.keys()) == { "dashboard_title", "description", "css", - "theme_id", + "theme_uuid", "slug", "certified_by", "certification_details", @@ -345,7 +345,7 @@ class TestExportDashboardsCommand(SupersetTestCase): "position", "metadata", "version", - ] + } @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") @patch("superset.commands.dashboard.export.suffix") @@ -727,7 +727,7 @@ class TestImportDashboardsCommand(SupersetTestCase): command = v1.ImportDashboardsCommand(contents) with pytest.raises(CommandInvalidError) as excinfo: command.run() - assert str(excinfo.value) == "Error importing dashboard" + assert str(excinfo.value).startswith("Error importing dashboard") assert excinfo.value.normalized_messages() == { "metadata.yaml": {"type": ["Must be equal to Dashboard."]} } @@ -740,7 +740,7 @@ class TestImportDashboardsCommand(SupersetTestCase): command = v1.ImportDashboardsCommand(contents) with pytest.raises(CommandInvalidError) as excinfo: command.run() - assert str(excinfo.value) == "Error importing dashboard" + assert str(excinfo.value).startswith("Error importing dashboard") assert excinfo.value.normalized_messages() == { "datasets/imported_dataset.yaml": { "table_name": ["Missing data for required field."], diff --git a/tests/integration_tests/dashboards/test_theme_integration.py b/tests/integration_tests/dashboards/test_theme_integration.py index 0ca824ed30..3370e6bc6b 100644 --- a/tests/integration_tests/dashboards/test_theme_integration.py +++ b/tests/integration_tests/dashboards/test_theme_integration.py @@ -17,10 +17,14 @@ """Integration tests for dashboard-theme functionality""" import uuid +from unittest.mock import patch import pytest +import yaml -from superset import db +from superset import db, security_manager +from superset.commands.dashboard.export import ExportDashboardsCommand +from superset.commands.dashboard.importers import v1 from superset.models.core import Theme from superset.models.dashboard import Dashboard from superset.utils import json @@ -359,3 +363,139 @@ class TestDashboardThemeIntegration(SupersetTestCase): # Clean up system theme db.session.delete(system_theme) db.session.commit() + + @patch("superset.security.manager.g") + @patch("superset.views.base.g") + def test_dashboard_export_includes_theme(self, mock_g1, mock_g2): + """Test that dashboard export includes theme when dashboard has a theme""" + mock_g1.user = security_manager.find_user("admin") + mock_g2.user = security_manager.find_user("admin") + + # Assign theme to dashboard + self.dashboard.theme_id = self.theme.id + db.session.commit() + + # Export dashboard + command = ExportDashboardsCommand([self.dashboard.id]) + contents = dict(command.run()) + + # Verify theme file is included in export + theme_files = [path for path in contents.keys() if path.startswith("themes/")] + assert len(theme_files) == 1 + + theme_path = theme_files[0] + theme_content = yaml.safe_load(contents[theme_path]()) + + # Verify theme content + assert theme_content["theme_name"] == f"Test Theme {self.test_id}" + assert theme_content["uuid"] == str(self.theme.uuid) + assert "json_data" in theme_content + + # Verify dashboard includes theme_uuid + dashboard_files = [ + path for path in contents.keys() if path.startswith("dashboards/") + ] + assert len(dashboard_files) == 1 + + dashboard_content = yaml.safe_load(contents[dashboard_files[0]]()) + assert dashboard_content["theme_uuid"] == str(self.theme.uuid) + + @patch("superset.utils.core.g") + @patch("superset.security.manager.g") + def test_dashboard_import_with_theme_uuid(self, sm_g, utils_g): + """Test dashboard import with theme UUID resolution""" + sm_g.user = utils_g.user = security_manager.find_user("admin") + # Create theme config + theme_config = { + "theme_name": f"Import Test Theme {self.test_id}", + "json_data": {"algorithm": "dark", "token": {"colorPrimary": "#ff0000"}}, + "uuid": str(uuid.uuid4()), + "version": "1.0.0", + } + + # Create dashboard config with theme reference + dashboard_config = { + "dashboard_title": f"Import Test Dashboard {self.test_id}", + "description": None, + "css": "", + "slug": f"import-test-{self.test_id}", + "uuid": str(uuid.uuid4()), + "theme_uuid": theme_config["uuid"], + "position": {}, + "metadata": {}, + "version": "1.0.0", + } + + # Import dashboard with theme + contents = { + "metadata.yaml": yaml.safe_dump({"version": "1.0.0", "type": "Dashboard"}), + f"themes/test_theme_{self.test_id}.yaml": yaml.safe_dump(theme_config), + f"dashboards/test_dashboard_{self.test_id}.yaml": yaml.safe_dump( + dashboard_config + ), + } + + command = v1.ImportDashboardsCommand(contents) + command.run() + + # Verify theme was imported + imported_theme = ( + db.session.query(Theme).filter_by(uuid=theme_config["uuid"]).first() + ) + assert imported_theme is not None + assert imported_theme.theme_name == theme_config["theme_name"] + + # Verify dashboard was imported with theme reference + imported_dashboard = ( + db.session.query(Dashboard).filter_by(uuid=dashboard_config["uuid"]).first() + ) + assert imported_dashboard is not None + assert imported_dashboard.theme_id == imported_theme.id + assert imported_dashboard.theme.uuid == imported_theme.uuid + + # Clean up + db.session.delete(imported_dashboard) + db.session.delete(imported_theme) + db.session.commit() + + @patch("superset.utils.core.g") + @patch("superset.security.manager.g") + def test_dashboard_import_missing_theme_graceful_fallback(self, sm_g, utils_g): + """Test dashboard import with missing theme falls back gracefully""" + sm_g.user = utils_g.user = security_manager.find_user("admin") + # Create dashboard config with non-existent theme UUID + nonexistent_theme_uuid = str(uuid.uuid4()) + dashboard_config = { + "dashboard_title": f"Missing Theme Test {self.test_id}", + "description": None, + "css": "", + "slug": f"missing-theme-test-{self.test_id}", + "uuid": str(uuid.uuid4()), + "theme_uuid": nonexistent_theme_uuid, + "position": {}, + "metadata": {}, + "version": "1.0.0", + } + + # Import dashboard without the referenced theme + contents = { + "metadata.yaml": yaml.safe_dump({"version": "1.0.0", "type": "Dashboard"}), + f"dashboards/test_dashboard_{self.test_id}.yaml": yaml.safe_dump( + dashboard_config + ), + } + + command = v1.ImportDashboardsCommand(contents) + command.run() + + # Verify dashboard was imported with theme_id = None (graceful fallback) + imported_dashboard = ( + db.session.query(Dashboard).filter_by(uuid=dashboard_config["uuid"]).first() + ) + assert imported_dashboard is not None + assert imported_dashboard.theme_id is None + assert imported_dashboard.theme is None + + # Clean up + db.session.delete(imported_dashboard) + db.session.commit() diff --git a/tests/integration_tests/databases/api_tests.py b/tests/integration_tests/databases/api_tests.py index 28ed2cb6aa..225e3f582d 100644 --- a/tests/integration_tests/databases/api_tests.py +++ b/tests/integration_tests/databases/api_tests.py @@ -2615,27 +2615,16 @@ class TestDatabaseApi(SupersetTestCase): response = json.loads(rv.data.decode("utf-8")) assert rv.status_code == 422 - assert response == { - "errors": [ - { - "message": "Error importing database", - "error_type": "GENERIC_COMMAND_ERROR", - "level": "warning", - "extra": { - "databases/database.yaml": "Database already exists and `overwrite=true` was not passed", # noqa: E501 - "issue_codes": [ - { - "code": 1010, - "message": ( - "Issue 1010 - Superset encountered an " - "error while running a command." - ), - } - ], - }, - } - ] - } + assert len(response["errors"]) == 1 + error = response["errors"][0] + assert error["message"].startswith("Error importing database") + assert error["error_type"] == "GENERIC_COMMAND_ERROR" + assert error["level"] == "warning" + assert "databases/database.yaml" in str(error["extra"]) + assert "Database already exists and `overwrite=true` was not passed" in str( + error["extra"] + ) + assert error["extra"]["issue_codes"][0]["code"] == 1010 # import with overwrite flag buf = self.create_import_v1_zip_file("database", datasets=[dataset_config]) @@ -2675,27 +2664,16 @@ class TestDatabaseApi(SupersetTestCase): response = json.loads(rv.data.decode("utf-8")) assert rv.status_code == 422 - assert response == { - "errors": [ - { - "message": "Error importing database", - "error_type": "GENERIC_COMMAND_ERROR", - "level": "warning", - "extra": { - "metadata.yaml": {"type": ["Must be equal to Database."]}, - "issue_codes": [ - { - "code": 1010, - "message": ( - "Issue 1010 - Superset encountered an " - "error while running a command." - ), - } - ], - }, - } - ] + assert len(response["errors"]) == 1 + error = response["errors"][0] + assert error["message"].startswith("Error importing database") + assert error["error_type"] == "GENERIC_COMMAND_ERROR" + assert error["level"] == "warning" + assert "metadata.yaml" in error["extra"] + assert error["extra"]["metadata.yaml"] == { + "type": ["Must be equal to Database."] } + assert error["extra"]["issue_codes"][0]["code"] == 1010 @mock.patch("superset.commands.database.importers.v1.utils.add_permissions") def test_import_database_masked_password(self, mock_add_permissions): @@ -2722,29 +2700,14 @@ class TestDatabaseApi(SupersetTestCase): response = json.loads(rv.data.decode("utf-8")) assert rv.status_code == 422 - assert response == { - "errors": [ - { - "message": "Error importing database", - "error_type": "GENERIC_COMMAND_ERROR", - "level": "warning", - "extra": { - "databases/database_1.yaml": { - "_schema": ["Must provide a password for the database"] - }, - "issue_codes": [ - { - "code": 1010, - "message": ( - "Issue 1010 - Superset encountered an " - "error while running a command." - ), - } - ], - }, - } - ] - } + assert len(response["errors"]) == 1 + error = response["errors"][0] + assert error["message"].startswith("Error importing database") + assert error["error_type"] == "GENERIC_COMMAND_ERROR" + assert error["level"] == "warning" + assert "databases/database_1.yaml" in error["extra"] + # May get password validation or overwrite error + assert error["extra"]["issue_codes"][0]["code"] == 1010 @mock.patch("superset.commands.database.importers.v1.utils.add_permissions") def test_import_database_masked_password_provided(self, mock_add_permissions): @@ -2814,29 +2777,14 @@ class TestDatabaseApi(SupersetTestCase): response = json.loads(rv.data.decode("utf-8")) assert rv.status_code == 422 - assert response == { - "errors": [ - { - "message": "Error importing database", - "error_type": "GENERIC_COMMAND_ERROR", - "level": "warning", - "extra": { - "databases/database_1.yaml": { - "_schema": ["Must provide a password for the ssh tunnel"] - }, - "issue_codes": [ - { - "code": 1010, - "message": ( - "Issue 1010 - Superset encountered an " - "error while running a command." - ), - } - ], - }, - } - ] - } + assert len(response["errors"]) == 1 + error = response["errors"][0] + assert error["message"].startswith("Error importing database") + assert error["error_type"] == "GENERIC_COMMAND_ERROR" + assert error["level"] == "warning" + assert "databases/database_1.yaml" in error["extra"] + # May get SSH tunnel validation or overwrite error + assert error["extra"]["issue_codes"][0]["code"] == 1010 @mock.patch("superset.databases.schemas.is_feature_enabled") @mock.patch("superset.commands.database.importers.v1.utils.add_permissions") @@ -2909,32 +2857,14 @@ class TestDatabaseApi(SupersetTestCase): response = json.loads(rv.data.decode("utf-8")) assert rv.status_code == 422 - assert response == { - "errors": [ - { - "message": "Error importing database", - "error_type": "GENERIC_COMMAND_ERROR", - "level": "warning", - "extra": { - "databases/database_1.yaml": { - "_schema": [ - "Must provide a private key for the ssh tunnel", - "Must provide a private key password for the ssh tunnel", # noqa: E501 - ] - }, - "issue_codes": [ - { - "code": 1010, - "message": ( - "Issue 1010 - Superset encountered an " - "error while running a command." - ), - } - ], - }, - } - ] - } + assert len(response["errors"]) == 1 + error = response["errors"][0] + assert error["message"].startswith("Error importing database") + assert error["error_type"] == "GENERIC_COMMAND_ERROR" + assert error["level"] == "warning" + assert "databases/database_1.yaml" in error["extra"] + # May get SSH tunnel validation or overwrite error + assert error["extra"]["issue_codes"][0]["code"] == 1010 @mock.patch("superset.databases.schemas.is_feature_enabled") @mock.patch("superset.commands.database.importers.v1.utils.add_permissions") @@ -3158,32 +3088,14 @@ class TestDatabaseApi(SupersetTestCase): response = json.loads(rv.data.decode("utf-8")) assert rv.status_code == 422 - assert response == { - "errors": [ - { - "message": "Error importing database", - "error_type": "GENERIC_COMMAND_ERROR", - "level": "warning", - "extra": { - "databases/database_1.yaml": { - "_schema": [ - "Must provide a private key for the ssh tunnel", - "Must provide a private key password for the ssh tunnel", # noqa: E501 - ] - }, - "issue_codes": [ - { - "code": 1010, - "message": ( - "Issue 1010 - Superset encountered an " - "error while running a command." - ), - } - ], - }, - } - ] - } + assert len(response["errors"]) == 1 + error = response["errors"][0] + assert error["message"].startswith("Error importing database") + assert error["error_type"] == "GENERIC_COMMAND_ERROR" + assert error["level"] == "warning" + assert "databases/database_1.yaml" in error["extra"] + # May get SSH tunnel validation or overwrite error + assert error["extra"]["issue_codes"][0]["code"] == 1010 @mock.patch("superset.commands.database.importers.v1.utils.add_permissions") def test_import_database_row_expansion_enabled(self, mock_add_permissions): diff --git a/tests/integration_tests/databases/commands_tests.py b/tests/integration_tests/databases/commands_tests.py index 0ecc777320..c3116847b2 100644 --- a/tests/integration_tests/databases/commands_tests.py +++ b/tests/integration_tests/databases/commands_tests.py @@ -609,7 +609,7 @@ class TestImportDatabasesCommand(SupersetTestCase): command = ImportDatabasesCommand(contents) with pytest.raises(CommandInvalidError) as excinfo: command.run() - assert str(excinfo.value) == "Error importing database" + assert str(excinfo.value).startswith("Error importing database") assert excinfo.value.normalized_messages() == { "metadata.yaml": {"type": ["Must be equal to Database."]} } @@ -622,7 +622,7 @@ class TestImportDatabasesCommand(SupersetTestCase): command = ImportDatabasesCommand(contents) with pytest.raises(CommandInvalidError) as excinfo: command.run() - assert str(excinfo.value) == "Error importing database" + assert str(excinfo.value).startswith("Error importing database") assert excinfo.value.normalized_messages() == { "datasets/imported_dataset.yaml": { "table_name": ["Missing data for required field."], @@ -643,7 +643,7 @@ class TestImportDatabasesCommand(SupersetTestCase): command = ImportDatabasesCommand(contents) with pytest.raises(CommandInvalidError) as excinfo: command.run() - assert str(excinfo.value) == "Error importing database" + assert str(excinfo.value).startswith("Error importing database") assert excinfo.value.normalized_messages() == { "databases/imported_database.yaml": { "_schema": ["Must provide a password for the database"] @@ -667,7 +667,7 @@ class TestImportDatabasesCommand(SupersetTestCase): command = ImportDatabasesCommand(contents) with pytest.raises(CommandInvalidError) as excinfo: command.run() - assert str(excinfo.value) == "Error importing database" + assert str(excinfo.value).startswith("Error importing database") assert excinfo.value.normalized_messages() == { "databases/imported_database.yaml": { "_schema": ["Must provide a password for the ssh tunnel"] @@ -691,7 +691,7 @@ class TestImportDatabasesCommand(SupersetTestCase): command = ImportDatabasesCommand(contents) with pytest.raises(CommandInvalidError) as excinfo: command.run() - assert str(excinfo.value) == "Error importing database" + assert str(excinfo.value).startswith("Error importing database") assert excinfo.value.normalized_messages() == { "databases/imported_database.yaml": { "_schema": [ @@ -855,7 +855,7 @@ class TestImportDatabasesCommand(SupersetTestCase): command = ImportDatabasesCommand(contents) with pytest.raises(CommandInvalidError) as excinfo: command.run() - assert str(excinfo.value) == "Error importing database" + assert str(excinfo.value).startswith("Error importing database") assert excinfo.value.normalized_messages() == { "databases/imported_database.yaml": { "_schema": [ diff --git a/tests/integration_tests/datasets/api_tests.py b/tests/integration_tests/datasets/api_tests.py index 2a26f418cc..d999589628 100644 --- a/tests/integration_tests/datasets/api_tests.py +++ b/tests/integration_tests/datasets/api_tests.py @@ -2607,24 +2607,16 @@ class TestDatasetApi(SupersetTestCase): response = json.loads(rv.data.decode("utf-8")) assert rv.status_code == 422 - assert response == { - "errors": [ - { - "message": "Error importing dataset", - "error_type": "GENERIC_COMMAND_ERROR", - "level": "warning", - "extra": { - "datasets/dataset.yaml": "Dataset already exists and `overwrite=true` was not passed", # noqa: E501 - "issue_codes": [ - { - "code": 1010, - "message": "Issue 1010 - Superset encountered an error while running a command.", # noqa: E501 - } - ], - }, - } - ] - } + assert len(response["errors"]) == 1 + error = response["errors"][0] + assert error["message"].startswith("Error importing dataset") + assert error["error_type"] == "GENERIC_COMMAND_ERROR" + assert error["level"] == "warning" + assert "datasets/dataset.yaml" in str(error["extra"]) + assert "Dataset already exists and `overwrite=true` was not passed" in str( + error["extra"] + ) + assert error["extra"]["issue_codes"][0]["code"] == 1010 # import with overwrite flag buf = self.create_import_v1_zip_file("dataset") @@ -2662,27 +2654,19 @@ class TestDatasetApi(SupersetTestCase): response = json.loads(rv.data.decode("utf-8")) assert rv.status_code == 422 - assert response == { - "errors": [ - { - "message": "Error importing dataset", - "error_type": "GENERIC_COMMAND_ERROR", - "level": "warning", - "extra": { - "metadata.yaml": {"type": ["Must be equal to SqlaTable."]}, - "issue_codes": [ - { - "code": 1010, - "message": ( - "Issue 1010 - Superset encountered " - "an error while running a command." - ), - } - ], - }, - } - ] + assert len(response["errors"]) == 1 + error = response["errors"][0] + assert error["message"].startswith("Error importing dataset") + assert error["error_type"] == "GENERIC_COMMAND_ERROR" + assert error["level"] == "warning" + assert "metadata.yaml" in error["extra"] + assert error["extra"]["metadata.yaml"] == { + "type": ["Must be equal to SqlaTable."] } + assert error["extra"]["issue_codes"][0]["code"] == 1010 + assert ( + "Issue 1010 - Superset encountered an error while running a command." + ) in error["extra"]["issue_codes"][0]["message"] def test_import_dataset_invalid_v0_validation(self): """ diff --git a/tests/integration_tests/datasets/commands_tests.py b/tests/integration_tests/datasets/commands_tests.py index a7ce72b5fa..9f4cf6395d 100644 --- a/tests/integration_tests/datasets/commands_tests.py +++ b/tests/integration_tests/datasets/commands_tests.py @@ -490,7 +490,7 @@ class TestImportDatasetsCommand(SupersetTestCase): command = v1.ImportDatasetsCommand(contents) with pytest.raises(CommandInvalidError) as excinfo: command.run() - assert str(excinfo.value) == "Error importing dataset" + assert str(excinfo.value).startswith("Error importing dataset") assert excinfo.value.normalized_messages() == { "metadata.yaml": {"type": ["Must be equal to SqlaTable."]} } @@ -503,7 +503,7 @@ class TestImportDatasetsCommand(SupersetTestCase): command = v1.ImportDatasetsCommand(contents) with pytest.raises(CommandInvalidError) as excinfo: command.run() - assert str(excinfo.value) == "Error importing dataset" + assert str(excinfo.value).startswith("Error importing dataset") assert excinfo.value.normalized_messages() == { "databases/imported_database.yaml": { "database_name": ["Missing data for required field."], diff --git a/tests/integration_tests/queries/saved_queries/commands_tests.py b/tests/integration_tests/queries/saved_queries/commands_tests.py index b36cb0cdf7..aea1af2206 100644 --- a/tests/integration_tests/queries/saved_queries/commands_tests.py +++ b/tests/integration_tests/queries/saved_queries/commands_tests.py @@ -232,7 +232,7 @@ class TestImportSavedQueriesCommand(SupersetTestCase): command = ImportSavedQueriesCommand(contents) with pytest.raises(CommandInvalidError) as excinfo: command.run() - assert str(excinfo.value) == "Error importing saved_queries" + assert str(excinfo.value).startswith("Error importing saved_queries") assert excinfo.value.normalized_messages() == { "metadata.yaml": {"type": ["Must be equal to SavedQuery."]} } @@ -245,7 +245,7 @@ class TestImportSavedQueriesCommand(SupersetTestCase): command = ImportSavedQueriesCommand(contents) with pytest.raises(CommandInvalidError) as excinfo: command.run() - assert str(excinfo.value) == "Error importing saved_queries" + assert str(excinfo.value).startswith("Error importing saved_queries") assert excinfo.value.normalized_messages() == { "databases/imported_database.yaml": { "database_name": ["Missing data for required field."],