mirror of
https://github.com/apache/superset.git
synced 2026-04-29 04:54:21 +00:00
Compare commits
1 Commits
docs/testi
...
theme_uuid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fdc637533 |
@@ -32,11 +32,13 @@ import {
|
|||||||
Form,
|
Form,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Alert,
|
Alert,
|
||||||
|
Label,
|
||||||
} from '@superset-ui/core/components';
|
} from '@superset-ui/core/components';
|
||||||
import { useJsonValidation } from '@superset-ui/core/components/AsyncAceEditor';
|
import { useJsonValidation } from '@superset-ui/core/components/AsyncAceEditor';
|
||||||
import { Typography } from '@superset-ui/core/components/Typography';
|
import { Typography } from '@superset-ui/core/components/Typography';
|
||||||
|
|
||||||
import { OnlyKeyWithType } from 'src/utils/types';
|
import { OnlyKeyWithType } from 'src/utils/types';
|
||||||
|
import { CopyToClipboard } from 'src/components/CopyToClipboard';
|
||||||
import { ThemeObject } from './types';
|
import { ThemeObject } from './types';
|
||||||
|
|
||||||
interface ThemeModalProps {
|
interface ThemeModalProps {
|
||||||
@@ -340,6 +342,27 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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}>
|
<Form.Item label={t('JSON Configuration')} required={!isReadOnly}>
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
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.reports.models import ReportRecipientType
|
||||||
from superset.superset_typing import FlaskResponse
|
from superset.superset_typing import FlaskResponse
|
||||||
from superset.themes.utils import (
|
from superset.themes.utils import (
|
||||||
is_valid_theme,
|
|
||||||
is_valid_theme_settings,
|
is_valid_theme_settings,
|
||||||
)
|
)
|
||||||
from superset.utils import core as utils, json
|
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]:
|
def get_theme_bootstrap_data() -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Returns the theme data to be sent to the client.
|
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
|
# Get theme configs
|
||||||
default_theme_config = get_config_value("THEME_DEFAULT")
|
default_theme_config = get_config_value("THEME_DEFAULT")
|
||||||
dark_theme_config = get_config_value("THEME_DARK")
|
dark_theme_config = get_config_value("THEME_DARK")
|
||||||
theme_settings = get_config_value("THEME_SETTINGS")
|
theme_settings = get_config_value("THEME_SETTINGS")
|
||||||
|
|
||||||
# Validate theme configurations
|
# Resolve and upsert themes - command handles all error cases
|
||||||
default_theme = default_theme_config
|
default_theme = ResolveAndUpsertThemeCommand(
|
||||||
if not is_valid_theme(default_theme):
|
default_theme_config or {}, "THEME_DEFAULT"
|
||||||
logger.warning(
|
).run()
|
||||||
"Invalid THEME_DEFAULT configuration: %s, using empty theme",
|
|
||||||
default_theme_config,
|
|
||||||
)
|
|
||||||
default_theme = {}
|
|
||||||
|
|
||||||
dark_theme = dark_theme_config
|
dark_theme = ResolveAndUpsertThemeCommand(
|
||||||
if not is_valid_theme(dark_theme):
|
dark_theme_config or {}, "THEME_DARK"
|
||||||
logger.warning(
|
).run()
|
||||||
"Invalid THEME_DARK configuration: %s, using empty theme",
|
|
||||||
dark_theme_config,
|
|
||||||
)
|
|
||||||
dark_theme = {}
|
|
||||||
|
|
||||||
|
# Validate theme settings
|
||||||
if not is_valid_theme_settings(theme_settings):
|
if not is_valid_theme_settings(theme_settings):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Invalid THEME_SETTINGS configuration: %s, using defaults", theme_settings
|
"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