| # 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. |
| """Integration tests for dashboard-theme functionality""" |
| |
| import uuid |
| from unittest.mock import patch |
| |
| import pytest |
| import yaml |
| |
| 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 |
| from tests.integration_tests.base_tests import SupersetTestCase |
| from tests.integration_tests.constants import ADMIN_USERNAME |
| |
| |
| class TestDashboardThemeIntegration(SupersetTestCase): |
| """Test dashboard-theme integration functionality""" |
| |
| def setUp(self): |
| """Set up test fixtures""" |
| super().setUp() |
| |
| # Generate unique identifier for this test |
| self.test_id = str(uuid.uuid4())[:8] |
| |
| # Create a test theme |
| self.theme = Theme( |
| theme_name=f"Test Theme {self.test_id}", |
| json_data=json.dumps( |
| {"colors": {"primary": "#1890ff"}, "typography": {"fontSize": 14}} |
| ), |
| created_by=self.get_user("admin"), |
| changed_by=self.get_user("admin"), |
| ) |
| db.session.add(self.theme) |
| db.session.commit() |
| |
| # Create a test dashboard with unique slug |
| self.dashboard = Dashboard( |
| dashboard_title=f"Test Dashboard {self.test_id}", |
| slug=f"test-dashboard-{self.test_id}", |
| position_json="{}", |
| owners=[self.get_user("admin")], |
| created_by=self.get_user("admin"), |
| changed_by=self.get_user("admin"), |
| ) |
| db.session.add(self.dashboard) |
| db.session.commit() |
| |
| def tearDown(self): |
| """Clean up test fixtures""" |
| # Remove theme reference from dashboard first |
| if hasattr(self, "dashboard") and self.dashboard: |
| self.dashboard.theme_id = None |
| db.session.commit() |
| db.session.delete(self.dashboard) |
| |
| if hasattr(self, "theme") and self.theme: |
| db.session.delete(self.theme) |
| |
| try: |
| db.session.commit() |
| except Exception: |
| db.session.rollback() |
| |
| super().tearDown() |
| |
| def test_dashboard_theme_assignment(self): |
| """Test that themes can be assigned to dashboards""" |
| # Assign theme to dashboard |
| self.dashboard.theme_id = self.theme.id |
| db.session.commit() |
| |
| # Verify the assignment |
| dashboard = db.session.query(Dashboard).filter_by(id=self.dashboard.id).first() |
| assert dashboard.theme_id == self.theme.id |
| assert dashboard.theme.theme_name == f"Test Theme {self.test_id}" |
| |
| def test_dashboard_api_includes_theme(self): |
| """Test that dashboard API includes theme information""" |
| # Assign theme to dashboard |
| self.dashboard.theme_id = self.theme.id |
| db.session.commit() |
| |
| # Login as admin |
| self.login(ADMIN_USERNAME) |
| |
| # Get dashboard via API |
| response = self.client.get(f"/api/v1/dashboard/{self.dashboard.id}") |
| assert response.status_code == 200 |
| |
| data = response.get_json() |
| result = data["result"] |
| |
| # Verify theme is included |
| assert "theme" in result |
| assert result["theme"]["id"] == self.theme.id |
| assert result["theme"]["theme_name"] == f"Test Theme {self.test_id}" |
| |
| def test_dashboard_update_with_theme(self): |
| """Test updating dashboard with theme via API""" |
| # Login as admin |
| self.login(ADMIN_USERNAME) |
| |
| # Update dashboard with theme |
| response = self.client.put( |
| f"/api/v1/dashboard/{self.dashboard.id}", |
| json={"theme_id": self.theme.id}, |
| ) |
| assert response.status_code == 200 |
| |
| # Verify theme was assigned |
| dashboard = db.session.query(Dashboard).filter_by(id=self.dashboard.id).first() |
| assert dashboard.theme_id == self.theme.id |
| |
| def test_dashboard_theme_removal(self): |
| """Test removing theme from dashboard""" |
| # First assign theme |
| self.dashboard.theme_id = self.theme.id |
| db.session.commit() |
| |
| # Login as admin |
| self.login(ADMIN_USERNAME) |
| |
| # Remove theme |
| response = self.client.put( |
| f"/api/v1/dashboard/{self.dashboard.id}", |
| json={"theme_id": None}, |
| ) |
| assert response.status_code == 200 |
| |
| # Verify theme was removed |
| dashboard = db.session.query(Dashboard).filter_by(id=self.dashboard.id).first() |
| assert dashboard.theme_id is None |
| |
| def test_dashboard_with_deleted_theme(self): |
| """Test dashboard behavior when assigned theme is deleted""" |
| # Assign theme to dashboard |
| self.dashboard.theme_id = self.theme.id |
| db.session.commit() |
| |
| # Remove theme reference and delete the theme |
| self.dashboard.theme_id = None |
| db.session.commit() |
| db.session.delete(self.theme) |
| db.session.commit() |
| |
| # Login as admin |
| self.login(ADMIN_USERNAME) |
| |
| # Get dashboard via API - should still work |
| response = self.client.get(f"/api/v1/dashboard/{self.dashboard.id}") |
| assert response.status_code == 200 |
| |
| data = response.get_json() |
| result = data["result"] |
| |
| # Theme should be null |
| assert result["theme"] is None |
| |
| def test_theme_schema_serialization(self): |
| """Test that theme schema properly serializes theme data""" |
| # Assign theme to dashboard |
| self.dashboard.theme_id = self.theme.id |
| db.session.commit() |
| |
| # Login as admin |
| self.login(ADMIN_USERNAME) |
| |
| # Get dashboard via API |
| response = self.client.get(f"/api/v1/dashboard/{self.dashboard.id}") |
| assert response.status_code == 200 |
| |
| data = response.get_json() |
| result = data["result"] |
| theme_data = result["theme"] |
| |
| # Verify theme data structure |
| assert "id" in theme_data |
| assert "theme_name" in theme_data |
| assert "json_data" in theme_data |
| |
| # Verify json_data is properly serialized |
| json_data = json.loads(theme_data["json_data"]) |
| assert json_data["colors"]["primary"] == "#1890ff" |
| assert json_data["typography"]["fontSize"] == 14 |
| |
| def test_theme_deletion_dissociates_dashboards(self): |
| """Test that deleting a theme dissociates it from dashboards""" |
| from superset.commands.theme.delete import DeleteThemeCommand |
| |
| # Assign theme to dashboard |
| self.dashboard.theme_id = self.theme.id |
| db.session.commit() |
| |
| # Verify theme is assigned |
| dashboard = db.session.query(Dashboard).filter_by(id=self.dashboard.id).first() |
| assert dashboard.theme_id == self.theme.id |
| |
| # Delete theme using command |
| command = DeleteThemeCommand([self.theme.id]) |
| command.run() |
| |
| # Verify dashboard is dissociated from theme |
| dashboard = db.session.query(Dashboard).filter_by(id=self.dashboard.id).first() |
| assert dashboard.theme_id is None |
| |
| # Verify theme is deleted |
| theme = db.session.query(Theme).filter_by(id=self.theme.id).first() |
| assert theme is None |
| |
| # Set theme to None so tearDown doesn't try to delete it |
| self.theme = None |
| |
| def test_theme_deletion_dashboard_usage_detection(self): |
| """Test that theme deletion detects dashboard usage""" |
| from superset.commands.theme.delete import DeleteThemeCommand |
| |
| # Create another dashboard for testing |
| dashboard2 = Dashboard( |
| dashboard_title=f"Test Dashboard 2 {self.test_id}", |
| slug=f"test-dashboard-2-{self.test_id}", |
| position_json="{}", |
| owners=[self.get_user("admin")], |
| created_by=self.get_user("admin"), |
| changed_by=self.get_user("admin"), |
| ) |
| db.session.add(dashboard2) |
| db.session.commit() |
| |
| try: |
| # Assign theme to both dashboards |
| self.dashboard.theme_id = self.theme.id |
| dashboard2.theme_id = self.theme.id |
| db.session.commit() |
| |
| # Create command and validate (this populates usage info) |
| command = DeleteThemeCommand([self.theme.id]) |
| command.validate() |
| |
| # Check dashboard usage detection |
| usage = command.get_dashboard_usage() |
| assert self.theme.id in usage |
| assert len(usage[self.theme.id]) == 2 |
| dashboard_titles = usage[self.theme.id] |
| assert f"Test Dashboard {self.test_id}" in dashboard_titles |
| assert f"Test Dashboard 2 {self.test_id}" in dashboard_titles |
| |
| finally: |
| # Clean up dashboard2 |
| db.session.delete(dashboard2) |
| db.session.commit() |
| |
| def test_multiple_themes_deletion_with_dashboard_dissociation(self): |
| """Test deletion of multiple themes with dashboard associations""" |
| from superset.commands.theme.delete import DeleteThemeCommand |
| |
| # Create another theme |
| theme2 = Theme( |
| theme_name=f"Test Theme 2 {self.test_id}", |
| json_data=json.dumps({"colors": {"primary": "#ff4d4f"}}), |
| created_by=self.get_user("admin"), |
| changed_by=self.get_user("admin"), |
| ) |
| db.session.add(theme2) |
| |
| # Create another dashboard |
| dashboard2 = Dashboard( |
| dashboard_title=f"Test Dashboard 2 {self.test_id}", |
| slug=f"test-dashboard-2-{self.test_id}", |
| position_json="{}", |
| owners=[self.get_user("admin")], |
| created_by=self.get_user("admin"), |
| changed_by=self.get_user("admin"), |
| ) |
| db.session.add(dashboard2) |
| db.session.commit() |
| |
| try: |
| # Assign different themes to dashboards |
| self.dashboard.theme_id = self.theme.id |
| dashboard2.theme_id = theme2.id |
| db.session.commit() |
| |
| # Delete both themes |
| command = DeleteThemeCommand([self.theme.id, theme2.id]) |
| command.run() |
| |
| # Verify both dashboards are dissociated |
| dashboard1 = ( |
| db.session.query(Dashboard).filter_by(id=self.dashboard.id).first() |
| ) |
| dashboard2_refreshed = ( |
| db.session.query(Dashboard).filter_by(id=dashboard2.id).first() |
| ) |
| assert dashboard1.theme_id is None |
| assert dashboard2_refreshed.theme_id is None |
| |
| # Verify both themes are deleted |
| theme1_check = db.session.query(Theme).filter_by(id=self.theme.id).first() |
| theme2_check = db.session.query(Theme).filter_by(id=theme2.id).first() |
| assert theme1_check is None |
| assert theme2_check is None |
| |
| finally: |
| # Clean up |
| db.session.delete(dashboard2) |
| try: |
| db.session.commit() |
| except Exception: |
| db.session.rollback() |
| |
| # Set theme to None so tearDown doesn't try to delete it |
| self.theme = None |
| |
| def test_system_theme_deletion_protection(self): |
| """Test that system themes cannot be deleted""" |
| from superset.commands.theme.delete import DeleteThemeCommand |
| from superset.commands.theme.exceptions import SystemThemeProtectedError |
| |
| # Create a system theme |
| system_theme = Theme( |
| theme_name=f"System Theme {self.test_id}", |
| json_data=json.dumps({"colors": {"primary": "#000000"}}), |
| is_system=True, |
| created_by=self.get_user("admin"), |
| changed_by=self.get_user("admin"), |
| ) |
| db.session.add(system_theme) |
| db.session.commit() |
| |
| try: |
| # Attempt to delete system theme should raise exception |
| command = DeleteThemeCommand([system_theme.id]) |
| with pytest.raises(SystemThemeProtectedError): |
| command.run() |
| |
| # Verify system theme still exists |
| theme_check = db.session.query(Theme).filter_by(id=system_theme.id).first() |
| assert theme_check is not None |
| assert theme_check.is_system is True |
| |
| finally: |
| # 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() |