mirror of
https://github.com/apache/superset.git
synced 2026-04-28 12:34:23 +00:00
Compare commits
1 Commits
docs/testi
...
theme_uuid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fdc637533 |
@@ -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"
|
||||
|
||||
126
superset/commands/theme/resolve.py
Normal file
126
superset/commands/theme/resolve.py
Normal file
@@ -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")
|
||||
@@ -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
|
||||
|
||||
16
tests/unit_tests/commands/theme/__init__.py
Normal file
16
tests/unit_tests/commands/theme/__init__.py
Normal file
@@ -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.
|
||||
184
tests/unit_tests/commands/theme/test_resolve.py
Normal file
184
tests/unit_tests/commands/theme/test_resolve.py
Normal file
@@ -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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_theme_dao():
|
||||
with patch("superset.commands.theme.resolve.ThemeDAO") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
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)
|
||||
Reference in New Issue
Block a user