From b5c2a239b9d3aa839c78c8cd4fdec07cca2a48a7 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Thu, 10 Jul 2025 00:34:53 -0700 Subject: [PATCH 1/7] feat(i18n): load language pack asynchronously Since forever we've been doing this dubious thing of passing the language pack through the dom. The server fetches the json file, and serializes it into a dom node as part of the "bootstrap data". Frontend picks it up deserializes it and hooks things up. In this PR: - creating a super simple flask endpoint to serve the json files - changing the "preamble" to run an async call to fetch the language pack before doing anything --- superset-frontend/src/preamble.ts | 110 +++++++++++++++++------------- superset/config.py | 2 +- superset/views/base.py | 2 - superset/views/core.py | 20 +++++- 4 files changed, 83 insertions(+), 51 deletions(-) diff --git a/superset-frontend/src/preamble.ts b/superset-frontend/src/preamble.ts index c46efb134d8a..dd72dfea0def 100644 --- a/superset-frontend/src/preamble.ts +++ b/superset-frontend/src/preamble.ts @@ -1,25 +1,31 @@ /** * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file + * 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 + * 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 + * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ + import { setConfig as setHotLoaderConfig } from 'react-hot-loader'; import dayjs from 'dayjs'; // eslint-disable-next-line no-restricted-imports -import { configure, makeApi, initFeatureFlags } from '@superset-ui/core'; +import { + configure, + makeApi, + initFeatureFlags, + SupersetClient, +} from '@superset-ui/core'; import setupClient from './setup/setupClient'; import setupColors from './setup/setupColors'; import setupFormatters from './setup/setupFormatters'; @@ -27,57 +33,67 @@ import setupDashboardComponents from './setup/setupDashboardComponents'; import { User } from './types/bootstrapTypes'; import getBootstrapData, { applicationRoot } from './utils/getBootstrapData'; +configure(); + +// Set hot reloader config if (process.env.WEBPACK_MODE === 'development') { setHotLoaderConfig({ logLevel: 'debug', trackTailUpdates: false }); } -// eslint-disable-next-line import/no-mutable-exports +// Grab initial bootstrap data const bootstrapData = getBootstrapData(); -// Configure translation -if (typeof window !== 'undefined') { - configure({ languagePack: bootstrapData.common.language_pack }); - dayjs.locale(bootstrapData.common.locale); -} else { - configure(); -} - -// Configure feature flags -initFeatureFlags(bootstrapData.common.feature_flags); - -// Setup SupersetClient +// Setup SupersetClient early so we can fetch language pack setupClient({ appRoot: applicationRoot() }); -setupColors( - bootstrapData.common.extra_categorical_color_schemes, - bootstrapData.common.extra_sequential_color_schemes, -); +// Load language pack before anything else +(async () => { + const lang = bootstrapData.common.locale || 'en'; + if (lang !== 'en') { + try { + const { json: languagePack } = await SupersetClient.get({ + endpoint: `/superset/language_pack/${lang}/`, + }); + // Second call to configure to set the language pack + configure({ languagePack }); + dayjs.locale(lang); + } catch (err) { + console.warn( + 'Failed to fetch language pack, falling back to default.', + err, + ); + configure(); + dayjs.locale('en'); + } + } -// Setup number formatters -setupFormatters( - bootstrapData.common.d3_format, - bootstrapData.common.d3_time_format, -); + // Continue with rest of setup + initFeatureFlags(bootstrapData.common.feature_flags); -setupDashboardComponents(); + setupColors( + bootstrapData.common.extra_categorical_color_schemes, + bootstrapData.common.extra_sequential_color_schemes, + ); -const getMe = makeApi({ - method: 'GET', - endpoint: '/api/v1/me/', -}); + setupFormatters( + bootstrapData.common.d3_format, + bootstrapData.common.d3_time_format, + ); -/** - * When you re-open the window, we check if you are still logged in. - * If your session expired or you signed out, we'll redirect to login. - * If you aren't logged in in the first place (!isActive), then we shouldn't do this. - */ -if (bootstrapData.user?.isActive) { - document.addEventListener('visibilitychange', () => { - // we only care about the tab becoming visible, not vice versa - if (document.visibilityState !== 'visible') return; + setupDashboardComponents(); - getMe().catch(() => { - // ignore error, SupersetClient will redirect to login on a 401 - }); + const getMe = makeApi({ + method: 'GET', + endpoint: '/api/v1/me/', }); -} + + if (bootstrapData.user?.isActive) { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + getMe().catch(() => { + // SupersetClient will redirect to login on 401 + }); + } + }); + } +})(); diff --git a/superset/config.py b/superset/config.py index 0e336b34ea4b..556edb91f1e6 100644 --- a/superset/config.py +++ b/superset/config.py @@ -400,7 +400,7 @@ def _try_json_readsha(filepath: str, length: int) -> str | None: } # Turning off i18n by default as translation in most languages are # incomplete and not well maintained. -LANGUAGES = {} +# LANGUAGES = {} # Override the default d3 locale format diff --git a/superset/views/base.py b/superset/views/base.py index 8eb201d58601..40877e1b2607 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.translations.utils import get_language_pack from superset.utils import core as utils, json from superset.utils.filters import get_dataset_access_filters from superset.views.error_handling import json_error_response @@ -364,7 +363,6 @@ def cached_common_bootstrap_data( # pylint: disable=unused-argument "static_assets_prefix": conf["STATIC_ASSETS_PREFIX"], "conf": frontend_config, "locale": language, - "language_pack": get_language_pack(language), "d3_format": conf.get("D3_FORMAT"), "d3_time_format": conf.get("D3_TIME_FORMAT"), "currencies": conf.get("CURRENCIES"), diff --git a/superset/views/core.py b/superset/views/core.py index 2d9cfabd4ce3..c792aee30a28 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -19,11 +19,12 @@ import contextlib import logging +import os from datetime import datetime from typing import Any, Callable, cast from urllib import parse -from flask import abort, flash, g, redirect, request, Response, url_for +from flask import abort, flash, g, redirect, request, Response, send_file, url_for from flask_appbuilder import expose from flask_appbuilder.security.decorators import ( has_access, @@ -891,6 +892,23 @@ def fetch_datasource_metadata(self) -> FlaskResponse: datasource.raise_for_access() return json_success(json.dumps(sanitize_datasource_data(datasource.data))) + @event_logger.log_this + @expose("/language_pack//") + def language_pack(self, lang: str) -> FlaskResponse: + base_dir = os.path.join( + os.path.dirname(__file__), + "..", + "translations", + lang, + "LC_MESSAGES", + "messages.json", + ) + if os.path.isfile(base_dir): + return send_file(base_dir, mimetype="application/json") + return json_error_response( + "Language pack doesn't exist on the server", status=404 + ) + @event_logger.log_this @expose("/welcome/") def welcome(self) -> FlaskResponse: From 982c6273273eb60d493e97c382b14e0727494368 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Thu, 10 Jul 2025 00:42:46 -0700 Subject: [PATCH 2/7] tabs --- superset-frontend/src/preamble.ts | 13 ++++++------ superset/views/core.py | 33 ++++++++++++++++++++----------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/superset-frontend/src/preamble.ts b/superset-frontend/src/preamble.ts index dd72dfea0def..6c1fcd7ac7d1 100644 --- a/superset-frontend/src/preamble.ts +++ b/superset-frontend/src/preamble.ts @@ -1,22 +1,21 @@ /** * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file + * 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 + * 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 + * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ - import { setConfig as setHotLoaderConfig } from 'react-hot-loader'; import dayjs from 'dayjs'; // eslint-disable-next-line no-restricted-imports diff --git a/superset/views/core.py b/superset/views/core.py index c792aee30a28..3995e618292f 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -20,11 +20,22 @@ import contextlib import logging import os +import re from datetime import datetime from typing import Any, Callable, cast from urllib import parse -from flask import abort, flash, g, redirect, request, Response, send_file, url_for +from flask import ( + abort, + flash, + g, + redirect, + request, + Response, + safe_join, + send_file, + url_for, +) from flask_appbuilder import expose from flask_appbuilder.security.decorators import ( has_access, @@ -895,16 +906,16 @@ def fetch_datasource_metadata(self) -> FlaskResponse: @event_logger.log_this @expose("/language_pack//") def language_pack(self, lang: str) -> FlaskResponse: - base_dir = os.path.join( - os.path.dirname(__file__), - "..", - "translations", - lang, - "LC_MESSAGES", - "messages.json", - ) - if os.path.isfile(base_dir): - return send_file(base_dir, mimetype="application/json") + # Only allow expected language formats like "en", "pt_BR", etc. + if not re.match(r"^[a-z]{2,3}(_[A-Z]{2})?$", lang): + abort(400, "Invalid language code") + + base_dir = os.path.join(os.path.dirname(__file__), "..", "translations") + file_path = safe_join(base_dir, lang, "LC_MESSAGES", "messages.json") + + if file_path and os.path.isfile(file_path): + return send_file(file_path, mimetype="application/json") + return json_error_response( "Language pack doesn't exist on the server", status=404 ) From 4f5469d08b7ed87e2fadc9c07fea31db7e23ae06 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Thu, 10 Jul 2025 00:44:23 -0700 Subject: [PATCH 3/7] tabs --- superset/views/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset/views/core.py b/superset/views/core.py index 3995e618292f..94259cbcf4dd 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -32,7 +32,6 @@ redirect, request, Response, - safe_join, send_file, url_for, ) @@ -44,6 +43,7 @@ ) from flask_babel import gettext as __, lazy_gettext as _ from sqlalchemy.exc import SQLAlchemyError +from werkzeug.utils import safe_join from superset import ( app, From af869ff9d8bc4b6963c8af7b876687a909c0e481 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Thu, 10 Jul 2025 09:52:09 -0700 Subject: [PATCH 4/7] fix type --- superset-frontend/src/preamble.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/superset-frontend/src/preamble.ts b/superset-frontend/src/preamble.ts index 6c1fcd7ac7d1..32010d6c0cc0 100644 --- a/superset-frontend/src/preamble.ts +++ b/superset-frontend/src/preamble.ts @@ -24,6 +24,7 @@ import { makeApi, initFeatureFlags, SupersetClient, + LanguagePack, } from '@superset-ui/core'; import setupClient from './setup/setupClient'; import setupColors from './setup/setupColors'; @@ -50,11 +51,11 @@ setupClient({ appRoot: applicationRoot() }); const lang = bootstrapData.common.locale || 'en'; if (lang !== 'en') { try { - const { json: languagePack } = await SupersetClient.get({ + // Second call to configure to set the language pack + const { json } = await SupersetClient.get({ endpoint: `/superset/language_pack/${lang}/`, }); - // Second call to configure to set the language pack - configure({ languagePack }); + configure({ languagePack: json as LanguagePack }); dayjs.locale(lang); } catch (err) { console.warn( From 29feb7bfd836042e972a62a08582fdc96ee8d828 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Fri, 11 Jul 2025 14:26:33 -0700 Subject: [PATCH 5/7] set language back to default --- superset/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset/config.py b/superset/config.py index 556edb91f1e6..0e336b34ea4b 100644 --- a/superset/config.py +++ b/superset/config.py @@ -400,7 +400,7 @@ def _try_json_readsha(filepath: str, length: int) -> str | None: } # Turning off i18n by default as translation in most languages are # incomplete and not well maintained. -# LANGUAGES = {} +LANGUAGES = {} # Override the default d3 locale format From 8708edcf03a098c55709b04d0a8cddb03e781833 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Fri, 11 Jul 2025 15:15:23 -0700 Subject: [PATCH 6/7] decorate new view with @has_access --- superset/views/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/superset/views/core.py b/superset/views/core.py index 94259cbcf4dd..c77f8dc4fe98 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -904,6 +904,7 @@ def fetch_datasource_metadata(self) -> FlaskResponse: return json_success(json.dumps(sanitize_datasource_data(datasource.data))) @event_logger.log_this + @has_access @expose("/language_pack//") def language_pack(self, lang: str) -> FlaskResponse: # Only allow expected language formats like "en", "pt_BR", etc. From 550a62d63a06f06add83cec855c68ffcf55a83f5 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Sat, 12 Jul 2025 15:32:32 -0700 Subject: [PATCH 7/7] fix flake --- tests/integration_tests/reports/api_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration_tests/reports/api_tests.py b/tests/integration_tests/reports/api_tests.py index 7737e346e154..8b595f563cdf 100644 --- a/tests/integration_tests/reports/api_tests.py +++ b/tests/integration_tests/reports/api_tests.py @@ -1698,7 +1698,7 @@ def test_update_report_preserve_ownership(self): .filter(ReportSchedule.name == "name1") .one_or_none() ) - assert updated_report.owners == current_owners + assert set(updated_report.owners) == set(current_owners) @pytest.mark.usefixtures("create_report_schedules") def test_update_report_clear_owner_list(self):