From d4a31d1d78a2f05a4e1c384c8f56c6dbdb65170c Mon Sep 17 00:00:00 2001 From: innovark Date: Tue, 14 Jul 2026 00:01:14 +0300 Subject: [PATCH] fix: add subdirectory deployment support for brandSpinnerUrl (#37523) Co-authored-by: Evan Rusackas --- superset/views/base.py | 27 ++++ .../views/test_base_theme_helpers.py | 132 ++++++++++++++++++ 2 files changed, 159 insertions(+) diff --git a/superset/views/base.py b/superset/views/base.py index c8106a9e6ba..4100976df13 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -649,6 +649,27 @@ def get_spa_payload(extra_data: dict[str, Any] | None = None) -> dict[str, Any]: return payload +def _ensure_static_assets_prefix(url_or_path: str) -> str: + """Add the configured static asset prefix to root-relative asset paths.""" + static_assets_prefix = app.config.get("STATIC_ASSETS_PREFIX", "") + + if ( + url_or_path.startswith("//") + or not url_or_path.startswith("/") + or not static_assets_prefix + ): + return url_or_path + + normalized_prefix = static_assets_prefix.rstrip("/") + if normalized_prefix and ( + url_or_path == normalized_prefix + or url_or_path.startswith(f"{normalized_prefix}/") + ): + return url_or_path + + return f"{normalized_prefix}{url_or_path}" + + def get_spa_template_context( entry: str | None = "spa", extra_bootstrap_data: dict[str, Any] | None = None, @@ -694,6 +715,12 @@ def get_spa_template_context( # User has customized APP_NAME, use it as brandAppName theme_tokens["brandAppName"] = app_name_from_config + brand_spinner_url = theme_tokens.get("brandSpinnerUrl") + if isinstance(brand_spinner_url, str) and brand_spinner_url: + theme_tokens["brandSpinnerUrl"] = _ensure_static_assets_prefix( + brand_spinner_url + ) + # Write the modified theme data back to payload if "common" not in payload: payload["common"] = {} diff --git a/tests/unit_tests/views/test_base_theme_helpers.py b/tests/unit_tests/views/test_base_theme_helpers.py index fb968d5d1e4..08b714ae2ec 100644 --- a/tests/unit_tests/views/test_base_theme_helpers.py +++ b/tests/unit_tests/views/test_base_theme_helpers.py @@ -18,6 +18,7 @@ from unittest.mock import MagicMock, mock_open, patch from superset.themes.types import ThemeMode +from superset.utils import json from superset.views.base import ( _load_theme_from_model, _merge_theme_dicts, @@ -892,6 +893,137 @@ class TestBrandAppNameFallback: assert result["default_title"] == "Superset" +class TestBrandSpinnerUrlPrefix: + """Test brandSpinnerUrl static asset prefix handling.""" + + @patch("superset.views.base.get_spa_payload") + @patch("superset.views.base.app") + def test_brandspinnerurl_adds_static_assets_prefix(self, mock_app, mock_payload): + """Test that root-relative spinner URLs include the static assets prefix.""" + from superset.views.base import get_spa_template_context + + mock_app.config = { + "STATIC_ASSETS_PREFIX": "/analytics", + } + mock_payload.return_value = { + "common": { + "theme": { + "default": { + "token": { + "brandSpinnerUrl": "/static/assets/spinner.gif", + } + }, + "dark": { + "token": { + "brandSpinnerUrl": "/static/assets/dark-spinner.gif", + } + }, + } + } + } + + result = get_spa_template_context("app") + + assert ( + result["theme_tokens"]["brandSpinnerUrl"] + == "/analytics/static/assets/spinner.gif" + ) + bootstrap_data = json.loads(result["bootstrap_data"]) + assert ( + bootstrap_data["common"]["theme"]["default"]["token"]["brandSpinnerUrl"] + == "/analytics/static/assets/spinner.gif" + ) + assert ( + bootstrap_data["common"]["theme"]["dark"]["token"]["brandSpinnerUrl"] + == "/analytics/static/assets/dark-spinner.gif" + ) + + @patch("superset.views.base.get_spa_payload") + @patch("superset.views.base.app") + def test_brandspinnerurl_keeps_absolute_url(self, mock_app, mock_payload): + """Test that absolute spinner URLs are not prefixed.""" + from superset.views.base import get_spa_template_context + + mock_app.config = { + "STATIC_ASSETS_PREFIX": "/analytics", + } + mock_payload.return_value = { + "common": { + "theme": { + "default": { + "token": { + "brandSpinnerUrl": "https://cdn.example.com/spinner.gif", + } + } + } + } + } + + result = get_spa_template_context("app") + + assert ( + result["theme_tokens"]["brandSpinnerUrl"] + == "https://cdn.example.com/spinner.gif" + ) + + @patch("superset.views.base.get_spa_payload") + @patch("superset.views.base.app") + def test_brandspinnerurl_keeps_protocol_relative_url(self, mock_app, mock_payload): + """Test that protocol-relative spinner URLs are not prefixed.""" + from superset.views.base import get_spa_template_context + + mock_app.config = { + "STATIC_ASSETS_PREFIX": "/analytics", + } + mock_payload.return_value = { + "common": { + "theme": { + "default": { + "token": { + "brandSpinnerUrl": "//cdn.example.com/spinner.gif", + } + } + } + } + } + + result = get_spa_template_context("app") + + assert ( + result["theme_tokens"]["brandSpinnerUrl"] == "//cdn.example.com/spinner.gif" + ) + + @patch("superset.views.base.get_spa_payload") + @patch("superset.views.base.app") + def test_brandspinnerurl_does_not_duplicate_static_assets_prefix( + self, mock_app, mock_payload + ): + """Test that already-prefixed spinner URLs are not prefixed again.""" + from superset.views.base import get_spa_template_context + + mock_app.config = { + "STATIC_ASSETS_PREFIX": "/analytics", + } + mock_payload.return_value = { + "common": { + "theme": { + "default": { + "token": { + "brandSpinnerUrl": "/analytics/static/assets/spinner.gif", + } + } + } + } + } + + result = get_spa_template_context("app") + + assert ( + result["theme_tokens"]["brandSpinnerUrl"] + == "/analytics/static/assets/spinner.gif" + ) + + class TestGetDefaultSpinnerSvg: """Test get_default_spinner_svg function"""