This is an automated email from the ASF dual-hosted git repository. maximebeauchemin pushed a commit to branch theme_uuid in repository https://gitbox.apache.org/repos/asf/superset.git
commit 0fdc637533a4f717ec1afe83331c6c03366ddbcc Author: Maxime Beauchemin <[email protected]> AuthorDate: Fri Aug 1 13:50:37 2025 -0700 feat(themes): Add UUID reference system for dynamic theme configuration Implements a UUID-based reference system that enables Superset themes to be dynamically configured through external systems like Split.io, overcoming payload size limitations. ## Problem Split.io has a 1KB limit for JSON payloads, but Superset theme configurations typically exceed 3KB. This prevented teams from managing themes dynamically through feature flags without deployments. ## Solution Instead of storing full theme objects, store lightweight UUID references: - Before: `{"algorithm": "dark", "token": {...}, ...}` (3KB+) - After: `{"uuid": "a7f3c8e2-4d1b-4c7a-9f8e-2b5d6c8a9e1f"}` (<100 bytes) ## Implementation Details ### Backend Changes - **ResolveAndUpsertThemeCommand**: New command that resolves UUID references to full theme configurations and upserts them as system themes - **Enhanced bootstrap data**: Modified `get_theme_bootstrap_data()` to dynamically resolve UUID references on every page load - **Fallback support**: Graceful degradation to safe defaults if UUID resolution fails (empty object for default theme, dark algorithm for dark theme) ### Frontend Changes - **UUID display in Theme Modal**: Added read-only UUID field with copy-to-clipboard functionality using existing CopyToClipboard component - **Minimal styling**: Uses Label component with monospace font, following existing patterns ### How It Works 1. Store themes in Superset's Theme CRUD system (each gets a UUID) 2. Reference themes by UUID in Split.io or other configuration systems 3. On page load, the system: - Detects UUID references in theme configuration - Resolves them to full theme definitions from the database - Upserts as system themes for consistency - Falls back to safe defaults on errors ### Testing Comprehensive test coverage including: - UUID resolution scenarios (found, not found, invalid JSON) - System theme upsert behavior (create new, update existing) - Fallback configurations for different theme types - UUID precedence over inline configuration This architecture transforms Split.io's constraint into a feature, enabling truly dynamic theme management with instant updates and no deployment required. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> --- .../src/features/themes/ThemeModal.tsx | 23 +++ superset/commands/theme/resolve.py | 126 ++++++++++++++ superset/views/base.py | 27 ++- tests/unit_tests/commands/theme/__init__.py | 16 ++ tests/unit_tests/commands/theme/test_resolve.py | 184 +++++++++++++++++++++ 5 files changed, 360 insertions(+), 16 deletions(-) diff --git a/superset-frontend/src/features/themes/ThemeModal.tsx b/superset-frontend/src/features/themes/ThemeModal.tsx index f4cc68ec2d..0f47c9c009 100644 --- a/superset-frontend/src/features/themes/ThemeModal.tsx +++ b/superset-frontend/src/features/themes/ThemeModal.tsx @@ -32,11 +32,13 @@ import { Form, Tooltip, Alert, + Label, } from '@superset-ui/core/components'; import { useJsonValidation } from '@superset-ui/core/components/AsyncAceEditor'; import { Typography } from '@superset-ui/core/components/Typography'; import { OnlyKeyWithType } from 'src/utils/types'; +import { CopyToClipboard } from 'src/components/CopyToClipboard'; import { ThemeObject } from './types'; interface ThemeModalProps { @@ -340,6 +342,27 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({ /> </Form.Item> + {currentTheme?.uuid && ( + <Form.Item label={t('UUID')}> + <div + css={css` + display: flex; + align-items: center; + gap: ${supersetTheme.sizeUnit * 2}px; + `} + > + <Label monospace>{currentTheme.uuid}</Label> + <CopyToClipboard + text={currentTheme.uuid} + shouldShowText={false} + wrapped={false} + copyNode={<Icons.CopyOutlined iconSize="m" />} + tooltipText={t('Copy UUID to clipboard')} + /> + </div> + </Form.Item> + )} + <Form.Item label={t('JSON Configuration')} required={!isReadOnly}> <Alert type="info" diff --git a/superset/commands/theme/resolve.py b/superset/commands/theme/resolve.py new file mode 100644 index 0000000000..da99599a63 --- /dev/null +++ b/superset/commands/theme/resolve.py @@ -0,0 +1,126 @@ +# 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. +import logging +from typing import Any + +from superset.commands.base import BaseCommand +from superset.daos.theme import ThemeDAO +from superset.extensions import db +from superset.models.core import Theme +from superset.utils import json +from superset.utils.decorators import transaction + +logger = logging.getLogger(__name__) + + +class ResolveAndUpsertThemeCommand(BaseCommand): + """Command to resolve theme configuration and upsert to system theme.""" + + def __init__(self, theme_config: dict[str, Any], theme_name: str): + self._theme_config = theme_config + self._theme_name = theme_name + self._fallback_config = self._get_fallback_config() + + def run(self) -> dict[str, Any]: + """Resolve theme configuration and upsert to system theme.""" + try: + self.validate() + + # First resolve the theme configuration + resolved_config = self._resolve_theme_config() + + # Then upsert to system theme + self._upsert_system_theme(resolved_config) + + return resolved_config + except Exception as ex: + logger.error( + "Failed to resolve and upsert theme %s: %s. Using fallback.", + self._theme_name, + ex, + ) + return self._fallback_config + + def _get_fallback_config(self) -> dict[str, Any]: + """Get fallback configuration based on theme name.""" + if self._theme_name == "THEME_DARK": + return {"algorithm": "dark"} + return {} + + def _resolve_theme_config(self) -> dict[str, Any]: + """Resolve theme configuration, looking up UUID references if present.""" + # Check if config contains a UUID reference + if isinstance(self._theme_config, dict) and "uuid" in self._theme_config: + uuid = self._theme_config["uuid"] + referenced_theme = ThemeDAO.find_by_uuid(uuid) + + if referenced_theme and referenced_theme.json_data: + try: + resolved_config = json.loads(referenced_theme.json_data) + logger.debug( + "Resolved UUID reference %s for %s to theme definition", + uuid, + self._theme_name, + ) + return resolved_config + except (ValueError, TypeError) as ex: + logger.error( + "Failed to parse theme JSON for UUID %s: %s", + uuid, + ex, + ) + return self._fallback_config + else: + logger.error( + "Referenced theme with UUID %s not found for %s", + uuid, + self._theme_name, + ) + return self._fallback_config + + # Not a UUID reference, return as-is + return self._theme_config + + @transaction() + def _upsert_system_theme(self, theme_config: dict[str, Any]) -> None: + """Upsert the resolved theme configuration as a system theme.""" + existing_theme = ( + db.session.query(Theme) + .filter(Theme.theme_name == self._theme_name, Theme.is_system) + .first() + ) + + json_data = json.dumps(theme_config) + + if existing_theme: + existing_theme.json_data = json_data + logger.info(f"Updated system theme: {self._theme_name}") + else: + new_theme = Theme( + theme_name=self._theme_name, + json_data=json_data, + is_system=True, + ) + db.session.add(new_theme) + logger.info(f"Created system theme: {self._theme_name}") + + def validate(self) -> None: + """Validate that the theme config is a dictionary.""" + if not isinstance(self._theme_config, dict): + self._theme_config = {} + if not self._theme_name: + raise ValueError("Theme name is required") diff --git a/superset/views/base.py b/superset/views/base.py index 2318efe1f9..90db015070 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -62,7 +62,6 @@ from superset.extensions import cache_manager from superset.reports.models import ReportRecipientType from superset.superset_typing import FlaskResponse from superset.themes.utils import ( - is_valid_theme, is_valid_theme_settings, ) from superset.utils import core as utils, json @@ -307,29 +306,25 @@ def menu_data(user: User) -> dict[str, Any]: def get_theme_bootstrap_data() -> dict[str, Any]: """ Returns the theme data to be sent to the client. + Resolves UUID references and upserts system themes. """ + from superset.commands.theme.resolve import ResolveAndUpsertThemeCommand + # Get theme configs default_theme_config = get_config_value("THEME_DEFAULT") dark_theme_config = get_config_value("THEME_DARK") theme_settings = get_config_value("THEME_SETTINGS") - # Validate theme configurations - default_theme = default_theme_config - if not is_valid_theme(default_theme): - logger.warning( - "Invalid THEME_DEFAULT configuration: %s, using empty theme", - default_theme_config, - ) - default_theme = {} + # Resolve and upsert themes - command handles all error cases + default_theme = ResolveAndUpsertThemeCommand( + default_theme_config or {}, "THEME_DEFAULT" + ).run() - dark_theme = dark_theme_config - if not is_valid_theme(dark_theme): - logger.warning( - "Invalid THEME_DARK configuration: %s, using empty theme", - dark_theme_config, - ) - dark_theme = {} + dark_theme = ResolveAndUpsertThemeCommand( + dark_theme_config or {}, "THEME_DARK" + ).run() + # Validate theme settings if not is_valid_theme_settings(theme_settings): logger.warning( "Invalid THEME_SETTINGS configuration: %s, using defaults", theme_settings diff --git a/tests/unit_tests/commands/theme/__init__.py b/tests/unit_tests/commands/theme/__init__.py new file mode 100644 index 0000000000..13a83393a9 --- /dev/null +++ b/tests/unit_tests/commands/theme/__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/theme/test_resolve.py b/tests/unit_tests/commands/theme/test_resolve.py new file mode 100644 index 0000000000..feee842730 --- /dev/null +++ b/tests/unit_tests/commands/theme/test_resolve.py @@ -0,0 +1,184 @@ +# 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 unittest.mock import MagicMock, patch + +import pytest + +from superset.commands.theme.resolve import ResolveAndUpsertThemeCommand +from superset.models.core import Theme +from superset.utils import json + + [email protected] +def mock_theme_dao(): + with patch("superset.commands.theme.resolve.ThemeDAO") as mock: + yield mock + + [email protected] +def mock_db(): + with patch("superset.commands.theme.resolve.db") as mock: + yield mock + + +class TestResolveAndUpsertThemeCommand: + def test_resolve_uuid_reference_found(self, mock_theme_dao, mock_db): + """Test resolving a UUID reference when theme is found.""" + # Setup + uuid = "test-uuid-123" + theme_config = {"uuid": uuid} + expected_config = {"algorithm": "dark", "token": {"colorPrimary": "#1890ff"}} + + mock_theme = MagicMock(spec=Theme) + mock_theme.json_data = json.dumps(expected_config) + mock_theme_dao.find_by_uuid.return_value = mock_theme + + # Execute + command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT") + result = command.run() + + # Assert + assert result == expected_config + mock_theme_dao.find_by_uuid.assert_called_once_with(uuid) + + def test_resolve_uuid_reference_not_found(self, mock_theme_dao, mock_db): + """Test resolving a UUID reference when theme is not found.""" + # Setup + uuid = "missing-uuid-123" + theme_config = {"uuid": uuid} + mock_theme_dao.find_by_uuid.return_value = None + + # Execute + command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT") + result = command.run() + + # Assert - should return empty dict fallback + assert result == {} + mock_theme_dao.find_by_uuid.assert_called_once_with(uuid) + + def test_resolve_uuid_reference_invalid_json(self, mock_theme_dao, mock_db): + """Test resolving a UUID reference with invalid JSON data.""" + # Setup + uuid = "test-uuid-123" + theme_config = {"uuid": uuid} + + mock_theme = MagicMock(spec=Theme) + mock_theme.json_data = "invalid json" + mock_theme_dao.find_by_uuid.return_value = mock_theme + + # Execute + command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT") + result = command.run() + + # Assert - should return empty dict fallback + assert result == {} + + def test_resolve_non_uuid_config(self, mock_theme_dao, mock_db): + """Test resolving a regular theme config (not UUID reference).""" + # Setup + theme_config = {"algorithm": "default", "token": {"colorPrimary": "#ff0000"}} + + # Execute + command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT") + result = command.run() + + # Assert - should return config as-is + assert result == theme_config + mock_theme_dao.find_by_uuid.assert_not_called() + + def test_upsert_creates_new_system_theme(self, mock_theme_dao, mock_db): + """Test upserting creates a new system theme when none exists.""" + # Setup + theme_config = {"algorithm": "default"} + mock_db.session.query.return_value.filter.return_value.first.return_value = None + + # Execute + command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT") + command.run() + + # Assert + mock_db.session.add.assert_called_once() + added_theme = mock_db.session.add.call_args[0][0] + assert added_theme.theme_name == "THEME_DEFAULT" + assert added_theme.is_system is True + assert added_theme.json_data == json.dumps(theme_config) + + def test_upsert_updates_existing_system_theme(self, mock_theme_dao, mock_db): + """Test upserting updates an existing system theme.""" + # Setup + theme_config = {"algorithm": "dark"} + existing_theme = MagicMock(spec=Theme) + mock_db.session.query.return_value.filter.return_value.first.return_value = ( + existing_theme + ) + + # Execute + command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DARK") + command.run() + + # Assert + assert existing_theme.json_data == json.dumps(theme_config) + mock_db.session.add.assert_not_called() + + def test_fallback_for_theme_default(self, mock_theme_dao, mock_db): + """Test fallback returns empty dict for THEME_DEFAULT.""" + # Setup - simulate UUID lookup failure + theme_config = {"uuid": "non-existent-uuid"} + mock_theme_dao.find_by_uuid.return_value = None + + # Execute + command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT") + result = command.run() + + # Assert + assert result == {} + + def test_fallback_for_theme_dark(self, mock_theme_dao, mock_db): + """Test fallback returns dark algorithm for THEME_DARK.""" + # Setup - simulate UUID lookup failure + theme_config = {"uuid": "non-existent-uuid"} + mock_theme_dao.find_by_uuid.return_value = None + + # Execute + command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DARK") + result = command.run() + + # Assert + assert result == {"algorithm": "dark"} + + def test_uuid_with_additional_fields(self, mock_theme_dao, mock_db): + """Test that UUID takes precedence even with additional fields.""" + # Setup + uuid = "test-uuid-123" + theme_config = { + "uuid": uuid, + "algorithm": "ignored", # This should be ignored + "token": {"ignored": True}, # This should be ignored + } + expected_config = {"algorithm": "dark", "token": {"colorPrimary": "#1890ff"}} + + mock_theme = MagicMock(spec=Theme) + mock_theme.json_data = json.dumps(expected_config) + mock_theme_dao.find_by_uuid.return_value = mock_theme + + # Execute + command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT") + result = command.run() + + # Assert - should return the resolved config, not the input + assert result == expected_config + mock_theme_dao.find_by_uuid.assert_called_once_with(uuid)
