mirror of
https://github.com/apache/superset.git
synced 2026-07-13 10:15:58 +00:00
Compare commits
5 Commits
feat/i18n-
...
chore/sqla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b652b996a4 | ||
|
|
3818152191 | ||
|
|
4dde4d2c70 | ||
|
|
35712ff977 | ||
|
|
bc80d138fd |
@@ -106,8 +106,8 @@ dependencies = [
|
||||
"shillelagh[gsheetsapi]>=1.4.4, <2.0",
|
||||
"sshtunnel>=0.4.0, <0.5",
|
||||
"simplejson>=4.1.1",
|
||||
"slack_sdk>=3.42.0, <4",
|
||||
"sqlalchemy>=1.4, <2",
|
||||
"slack_sdk>=3.43.0, <4",
|
||||
"sqlalchemy>=1.4.43, <2", # 1.4.43 adds the python-oracledb (oracle+oracledb) dialect
|
||||
"sqlalchemy-continuum>=1.6.0, <2.0.0",
|
||||
"sqlalchemy-utils>=0.42.1, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=30.8.0, <31",
|
||||
|
||||
@@ -390,7 +390,7 @@ six==1.17.0
|
||||
# python-dateutil
|
||||
# rfc3339-validator
|
||||
# wtforms-json
|
||||
slack-sdk==3.42.0
|
||||
slack-sdk==3.43.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
sniffio==1.3.1
|
||||
# via trio
|
||||
|
||||
@@ -959,7 +959,7 @@ six==1.17.0
|
||||
# python-dateutil
|
||||
# rfc3339-validator
|
||||
# wtforms-json
|
||||
slack-sdk==3.42.0
|
||||
slack-sdk==3.43.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -42,9 +42,9 @@ const createProps = (props = {}) => ({
|
||||
...props,
|
||||
});
|
||||
|
||||
const getTableMockFunction = () =>
|
||||
const getTableMockFunction = (count = 4) =>
|
||||
({
|
||||
count: 4,
|
||||
count,
|
||||
result: [
|
||||
{ label: 'table_a', value: 'table_a' },
|
||||
{ label: 'table_b', value: 'table_b' },
|
||||
@@ -82,6 +82,35 @@ afterEach(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
test('shows a helper message when some tables are not shown', async () => {
|
||||
fetchMock.get(catalogApiRoute, { result: [] });
|
||||
fetchMock.get(schemaApiRoute, { result: ['test_schema'] });
|
||||
fetchMock.get(tablesApiRoute, getTableMockFunction(5));
|
||||
|
||||
const props = createProps();
|
||||
render(<TableSelector {...props} />, { useRedux: true, store });
|
||||
|
||||
expect(
|
||||
await screen.findByText('Some tables are not shown. Refine your search.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not show the helper message when table search is read-only', async () => {
|
||||
fetchMock.get(catalogApiRoute, { result: [] });
|
||||
fetchMock.get(schemaApiRoute, { result: ['test_schema'] });
|
||||
fetchMock.get(tablesApiRoute, getTableMockFunction(5));
|
||||
|
||||
const props = createProps({ readOnly: true });
|
||||
render(<TableSelector {...props} />, { useRedux: true, store });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.callHistory.called(tablesApiRoute)).toBe(true);
|
||||
});
|
||||
expect(
|
||||
screen.queryByText('Some tables are not shown. Refine your search.'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders with default props', async () => {
|
||||
fetchMock.get(catalogApiRoute, { result: [] });
|
||||
fetchMock.get(schemaApiRoute, { result: [] });
|
||||
@@ -205,10 +234,10 @@ test('table select retain value if not in SQL Lab mode', async () => {
|
||||
);
|
||||
}, 15000);
|
||||
|
||||
test('renders disabled without schema', async () => {
|
||||
test('renders disabled without schema or helper message', async () => {
|
||||
fetchMock.get(catalogApiRoute, { result: [] });
|
||||
fetchMock.get(schemaApiRoute, { result: [] });
|
||||
fetchMock.get(tablesApiRoute, getTableMockFunction());
|
||||
fetchMock.get(tablesApiRoute, getTableMockFunction(5));
|
||||
|
||||
const props = createProps();
|
||||
render(<TableSelector {...props} schema={undefined} />, {
|
||||
@@ -221,6 +250,9 @@ test('renders disabled without schema', async () => {
|
||||
await waitFor(() => {
|
||||
expect(tableSelect).toBeDisabled();
|
||||
});
|
||||
expect(
|
||||
screen.queryByText('Some tables are not shown. Refine your search.'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('table multi select retain all the values selected', async () => {
|
||||
|
||||
@@ -221,6 +221,7 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
|
||||
: [],
|
||||
[data, customTableOptionLabelRenderer],
|
||||
);
|
||||
const hasMoreTables = data?.hasMore;
|
||||
|
||||
useEffect(() => {
|
||||
// reset selections
|
||||
@@ -339,6 +340,11 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
|
||||
<>
|
||||
<StyledFormLabel>{label}</StyledFormLabel>
|
||||
{renderSelectRow(select, refreshLabel)}
|
||||
{hasMoreTables && !disabled && (
|
||||
<div className="table-length">
|
||||
{t('Some tables are not shown. Refine your search.')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import type { LanguagePack } from '@apache-superset/core/translation';
|
||||
|
||||
const mockConfigure = jest.fn();
|
||||
const mockInitFeatureFlags = jest.fn();
|
||||
const mockMakeApi = jest.fn(() => jest.fn());
|
||||
@@ -113,83 +111,3 @@ test('falls back to en when passing locale to setupFormatters', async () => {
|
||||
|
||||
expect(mockSetupFormatters).toHaveBeenCalledWith({}, {}, 'en');
|
||||
});
|
||||
|
||||
// --- language pack loading semantics (issues #35330, PR #41780) -----------
|
||||
// English: nothing is loaded at all. Non-English: the pack is expected to
|
||||
// already be on window (set by the versioned classic <script> tag spa.html
|
||||
// emits before the entry bundle), so configuration is synchronous and no
|
||||
// English flash can occur. The async fetch is a fallback only, and it only
|
||||
// ever requests the selected locale.
|
||||
|
||||
const FAKE_PACK: LanguagePack = {
|
||||
domain: 'superset',
|
||||
locale_data: {
|
||||
superset: {
|
||||
'': {
|
||||
domain: 'superset',
|
||||
lang: 'fr',
|
||||
plural_forms: 'nplurals=2; plural=(n > 1);',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__SUPERSET_LANGUAGE_PACK__;
|
||||
});
|
||||
|
||||
test('configures synchronously from the window pack without fetching', async () => {
|
||||
mockGetBootstrapData.mockReturnValue(bootstrapData('fr'));
|
||||
window.__SUPERSET_LANGUAGE_PACK__ = FAKE_PACK;
|
||||
const fetchSpy = jest.spyOn(global, 'fetch');
|
||||
|
||||
await runPreamble();
|
||||
|
||||
expect(mockConfigure).toHaveBeenCalledWith({ languagePack: FAKE_PACK });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(mockDayjsLocale).toHaveBeenCalledWith('fr');
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('prefers an operator-supplied bootstrap pack over the window pack', async () => {
|
||||
const overridePack = { ...FAKE_PACK, domain: 'override' };
|
||||
const data = bootstrapData('fr');
|
||||
(data.common as Record<string, unknown>).language_pack = overridePack;
|
||||
mockGetBootstrapData.mockReturnValue(data);
|
||||
window.__SUPERSET_LANGUAGE_PACK__ = FAKE_PACK;
|
||||
|
||||
await runPreamble();
|
||||
|
||||
expect(mockConfigure).toHaveBeenCalledWith({ languagePack: overridePack });
|
||||
});
|
||||
|
||||
test('loads no language pack at all for English', async () => {
|
||||
mockGetBootstrapData.mockReturnValue(bootstrapData('en'));
|
||||
const fetchSpy = jest.spyOn(global, 'fetch');
|
||||
|
||||
await runPreamble();
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
// The translator is configured bare (identity translations), never with
|
||||
// a pack: English pays zero payload.
|
||||
expect(mockConfigure).toHaveBeenCalledWith();
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('fallback fetch requests only the selected locale and blocks until configured', async () => {
|
||||
mockGetBootstrapData.mockReturnValue(bootstrapData('fr'));
|
||||
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(FAKE_PACK),
|
||||
} as unknown as Response);
|
||||
|
||||
await runPreamble();
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mockMakeUrl).toHaveBeenCalledWith('/language_pack/fr/');
|
||||
// configure() resolves before initPreamble() does, so nothing renders
|
||||
// untranslated even on the fallback path.
|
||||
expect(mockConfigure).toHaveBeenCalledWith({ languagePack: FAKE_PACK });
|
||||
expect(window.__SUPERSET_LANGUAGE_PACK__).toEqual(FAKE_PACK);
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -65,14 +65,12 @@ export default function initPreamble(): Promise<void> {
|
||||
setupClient({ appRoot: applicationRoot() });
|
||||
|
||||
// Load language pack before rendering.
|
||||
// Prefer the pack already on hand: either an operator-supplied
|
||||
// common.language_pack (COMMON_BOOTSTRAP_OVERRIDES_FUNC) or the
|
||||
// window global set by the versioned script tag spa.html loads
|
||||
// before this bundle. Either way, module-level `const X = t('...')`
|
||||
// calls in code-split chunks all see a configured translator. Fall
|
||||
// back to the async fetch only when neither is present (e.g. the
|
||||
// script tag failed to load, or a legacy entry that doesn't extend
|
||||
// spa.html). See issue #35330.
|
||||
// Prefer the bootstrap-injected pack (stashed on window by the inline
|
||||
// script in spa.html, sourced from common.language_pack) so
|
||||
// module-level `const X = t('...')` calls in code-split chunks all
|
||||
// see a configured translator. Fall back to the async fetch only
|
||||
// when the bootstrap payload didn't carry the pack (e.g. embedded
|
||||
// or a legacy entry that doesn't extend spa.html). See issue #35330.
|
||||
if (lang !== 'en') {
|
||||
const bootstrapPack =
|
||||
(bootstrapData.common as { language_pack?: LanguagePack })
|
||||
|
||||
@@ -25,11 +25,7 @@ from superset import event_logger, is_feature_enabled
|
||||
from superset.daos.dashboard import EmbeddedDashboardDAO
|
||||
from superset.superset_typing import FlaskResponse
|
||||
from superset.utils import json
|
||||
from superset.views.base import (
|
||||
BaseSupersetView,
|
||||
common_bootstrap_payload,
|
||||
get_language_pack_template_context,
|
||||
)
|
||||
from superset.views.base import BaseSupersetView, common_bootstrap_payload
|
||||
|
||||
|
||||
class EmbeddedView(BaseSupersetView):
|
||||
@@ -114,5 +110,4 @@ class EmbeddedView(BaseSupersetView):
|
||||
bootstrap_data=json.dumps(
|
||||
bootstrap_data, default=json.pessimistic_json_iso_dttm_ser
|
||||
),
|
||||
**get_language_pack_template_context(bootstrap_data["common"]),
|
||||
)
|
||||
|
||||
@@ -114,6 +114,10 @@ class MCPNoAuthSourceError(ValueError):
|
||||
# scope. When introducing a new method permission, add it here.
|
||||
_METHOD_TO_REQUIRED_SCOPE = {
|
||||
"read": "superset:read",
|
||||
# "get" is the read-class permission FAB registers on its security API
|
||||
# views (User/Role) — those views have no can_read, so tools targeting
|
||||
# them declare method_permission_name="get".
|
||||
"get": "superset:read",
|
||||
"write": "superset:write",
|
||||
"delete": "superset:write",
|
||||
# SQL execution (execute_sql, get_chart_sql) runs arbitrary queries and is
|
||||
|
||||
@@ -37,6 +37,10 @@ logger = logging.getLogger(__name__)
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
class_permission_name="Role",
|
||||
# FAB's security API views register can_get/can_info/... — never
|
||||
# can_read — so the default "read" method permission can never be
|
||||
# granted on User/Role, not even to Admin.
|
||||
method_permission_name="get",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get role info",
|
||||
readOnlyHint=True,
|
||||
|
||||
@@ -45,6 +45,10 @@ _DEFAULT_LIST_ROLES_REQUEST = ListRolesRequest()
|
||||
@tool(
|
||||
tags=["core"],
|
||||
class_permission_name="Role",
|
||||
# FAB's security API views register can_get/can_info/... — never
|
||||
# can_read — so the default "read" method permission can never be
|
||||
# granted on User/Role, not even to Admin.
|
||||
method_permission_name="get",
|
||||
annotations=ToolAnnotations(
|
||||
title="List roles",
|
||||
readOnlyHint=True,
|
||||
|
||||
@@ -38,6 +38,10 @@ logger = logging.getLogger(__name__)
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
class_permission_name="User",
|
||||
# FAB's security API views register can_get/can_info/... — never
|
||||
# can_read — so the default "read" method permission can never be
|
||||
# granted on User/Role, not even to Admin.
|
||||
method_permission_name="get",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get user info",
|
||||
readOnlyHint=True,
|
||||
|
||||
@@ -46,6 +46,10 @@ _DEFAULT_LIST_USERS_REQUEST = ListUsersRequest()
|
||||
@tool(
|
||||
tags=["core"],
|
||||
class_permission_name="User",
|
||||
# FAB's security API views register can_get/can_info/... — never
|
||||
# can_read — so the default "read" method permission can never be
|
||||
# granted on User/Role, not even to Admin.
|
||||
method_permission_name="get",
|
||||
annotations=ToolAnnotations(
|
||||
title="List users",
|
||||
readOnlyHint=True,
|
||||
|
||||
@@ -150,41 +150,35 @@
|
||||
|
||||
{% block tail_js %}
|
||||
{#
|
||||
Load the language pack BEFORE the entry bundle so module-level
|
||||
`const X = t('...')` calls across webpack-split chunks all find a
|
||||
configured translator on first access (upstream issue #35330). The
|
||||
script sets window.__SUPERSET_LANGUAGE_PACK__ and is content-addressed
|
||||
(/language_pack/<lang>/<version>/script.js), so browsers cache it as
|
||||
immutable across page loads and cache-bust automatically whenever
|
||||
translations change. English emits no tag at all. If the script fails
|
||||
to load, preamble.ts falls back to the async language pack fetch.
|
||||
Expose the language pack on window BEFORE the entry bundle loads,
|
||||
so module-level `const X = t('...')` calls across webpack-split
|
||||
chunks all find a configured translator on first access. The
|
||||
bootstrap div is the single source of truth; we parse it once and
|
||||
stash the pack on a global that the translator reads lazily.
|
||||
See upstream issue #35330.
|
||||
|
||||
Trade-off: this ships the full Jed pack inline in every full-page
|
||||
HTML response (only for non-English locales; English injects null)
|
||||
rather than via the separately-cacheable `/language_pack/` fetch.
|
||||
That synchronous availability is the whole point — the async fetch
|
||||
can't guarantee the pack is configured before a code-split chunk
|
||||
evaluates. Acceptable for an SPA where hard reloads are rare.
|
||||
#}
|
||||
{% if language_pack_src %}
|
||||
<script src="{{ language_pack_src }}" nonce="{{ macros.get_nonce() }}"></script>
|
||||
{% endif %}
|
||||
{#
|
||||
Operators can still supply a pack directly in the bootstrap payload
|
||||
via COMMON_BOOTSTRAP_OVERRIDES_FUNC (the historical #35330
|
||||
workaround). When one is present, stash it on window here instead of
|
||||
emitting the versioned script tag, so early chunks see the override.
|
||||
#}
|
||||
{% if language_pack_inline %}
|
||||
<script nonce="{{ macros.get_nonce() }}">
|
||||
(function () {
|
||||
try {
|
||||
var el = document.getElementById('app');
|
||||
if (!el) return;
|
||||
var data = JSON.parse(el.getAttribute('data-bootstrap') || '{}');
|
||||
var pack = data && data.common && data.common.language_pack;
|
||||
if (pack) {
|
||||
window.__SUPERSET_LANGUAGE_PACK__ = pack;
|
||||
}
|
||||
} catch (e) {
|
||||
/* swallow: fall back to async fetch in preamble.ts */
|
||||
<script nonce="{{ macros.get_nonce() }}">
|
||||
(function () {
|
||||
try {
|
||||
var el = document.getElementById('app');
|
||||
if (!el) return;
|
||||
var data = JSON.parse(el.getAttribute('data-bootstrap') || '{}');
|
||||
var pack = data && data.common && data.common.language_pack;
|
||||
if (pack) {
|
||||
window.__SUPERSET_LANGUAGE_PACK__ = pack;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
} catch (e) {
|
||||
/* swallow: fall back to async fetch in preamble.ts */
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% if entry %}
|
||||
{{ js_bundle(assets_prefix, entry) }}
|
||||
{% endif %}
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -25,41 +24,9 @@ logger = logging.getLogger(__name__)
|
||||
# Global caching for JSON language packs
|
||||
ALL_LANGUAGE_PACKS: dict[str, dict[str, Any]] = {"en": {}}
|
||||
|
||||
# Global caching for language pack content hashes, used to build
|
||||
# content-addressed (cache-busting) asset URLs
|
||||
ALL_LANGUAGE_PACK_VERSIONS: dict[str, str] = {}
|
||||
|
||||
DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def get_language_pack_filename(locale: str) -> str:
|
||||
"""Resolve the on-disk JSON pack for a locale (empty pack for English)"""
|
||||
if not locale or locale == "en":
|
||||
# Forcing a dummy, quasi-empty language pack for English since the file
|
||||
# in the en directory contains data with empty mappings
|
||||
return DIR + "/empty_language_pack.json"
|
||||
return DIR + f"/{locale}/LC_MESSAGES/messages.json"
|
||||
|
||||
|
||||
def get_language_pack_version(locale: str) -> Optional[str]:
|
||||
"""Get/cache a short content hash of the language pack file
|
||||
|
||||
The hash is embedded in the pack's asset URL so browsers can cache it
|
||||
as immutable and pick up a fresh copy whenever translations change.
|
||||
Returns None when the pack file cannot be read.
|
||||
"""
|
||||
version = ALL_LANGUAGE_PACK_VERSIONS.get(locale)
|
||||
if not version:
|
||||
try:
|
||||
with open(get_language_pack_filename(locale), "rb") as f:
|
||||
version = hashlib.sha256(f.read()).hexdigest()[:12]
|
||||
ALL_LANGUAGE_PACK_VERSIONS[locale] = version
|
||||
except OSError:
|
||||
logger.warning("No language pack file to version for locale %s", locale)
|
||||
return None
|
||||
return version
|
||||
|
||||
|
||||
def get_language_pack(locale: str) -> Optional[dict[str, Any]]:
|
||||
"""Get/cache a language pack
|
||||
|
||||
@@ -70,7 +37,11 @@ def get_language_pack(locale: str) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
pack = ALL_LANGUAGE_PACKS.get(locale)
|
||||
if not pack:
|
||||
filename = get_language_pack_filename(locale)
|
||||
filename = DIR + f"/{locale}/LC_MESSAGES/messages.json"
|
||||
if not locale or locale == "en":
|
||||
# Forcing a dummy, quasy-empty language pack for English since the file
|
||||
# in the en directory is contains data with empty mappings
|
||||
filename = DIR + "/empty_language_pack.json"
|
||||
try:
|
||||
with open(filename, encoding="utf8") as f:
|
||||
pack = json.load(f)
|
||||
|
||||
@@ -48,16 +48,23 @@ logger = logging.getLogger(__name__)
|
||||
def insert_baseline_and_children(
|
||||
session: Session, obj: Any, version_table: Any
|
||||
) -> None:
|
||||
"""Insert the parent baseline row, then baseline the parent's child
|
||||
collections under the same transaction id.
|
||||
"""Insert one atomic baseline for a parent and its children.
|
||||
|
||||
Wrapped in ``no_autoflush`` so ``session.connection()`` inside
|
||||
``_insert_baseline_row`` does not trigger a flush of Continuum's
|
||||
pending Transaction object before our direct-SQL insert claims its
|
||||
tx_id.
|
||||
tx_id. A connection-level SAVEPOINT is required because this helper runs
|
||||
inside ``before_flush``, where ``Session.begin_nested()`` would flush
|
||||
recursively. One SAVEPOINT contains the transaction row, parent shadow,
|
||||
and every child shadow so optional baseline capture is all-or-nothing and
|
||||
a failed PostgreSQL statement cannot poison the user's transaction.
|
||||
|
||||
Continuum runs later in the same ``before_flush`` listener chain. The
|
||||
PostgreSQL failure tests assert that releasing or rolling back this
|
||||
SAVEPOINT preserves Continuum's canonical update shadow and transaction.
|
||||
"""
|
||||
try:
|
||||
with session.no_autoflush:
|
||||
with session.no_autoflush, session.connection().begin_nested():
|
||||
tx_id = _insert_baseline_row(session, obj, version_table)
|
||||
if tx_id is None:
|
||||
return
|
||||
@@ -132,18 +139,12 @@ def _baseline_children_for_parent(
|
||||
|
||||
Dispatches via the
|
||||
:data:`~superset.versioning.baseline.children.CHILD_BASELINE_HANDLERS`
|
||||
table to per-entity handlers. A handler failure is logged but does
|
||||
not block the parent baseline.
|
||||
table to per-entity handlers. Handler failures propagate to
|
||||
:func:`insert_baseline_and_children`, which rolls back the complete
|
||||
optional baseline unit before logging the failure.
|
||||
"""
|
||||
parent_name = type(parent_obj).__name__
|
||||
handler = CHILD_BASELINE_HANDLERS.get(parent_name)
|
||||
if handler is None:
|
||||
return
|
||||
try:
|
||||
handler(session, parent_obj, tx_id)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception(
|
||||
"baseline_listener: failed to baseline children of %s id=%s",
|
||||
parent_name,
|
||||
getattr(parent_obj, "id", None),
|
||||
)
|
||||
handler(session, parent_obj, tx_id)
|
||||
|
||||
@@ -66,7 +66,7 @@ from superset.themes.types import Theme, ThemeMode
|
||||
from superset.themes.utils import (
|
||||
is_valid_theme,
|
||||
)
|
||||
from superset.translations.utils import get_language_pack_version
|
||||
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.utils.version import get_version_metadata, visible_version_metadata
|
||||
@@ -611,50 +611,25 @@ def common_bootstrap_payload() -> dict[str, Any]:
|
||||
# Convert locale to string for proper cache key hashing
|
||||
locale_str = str(locale) if locale else None
|
||||
payload = dict(cached_common_bootstrap_data(utils.get_user_id(), locale_str))
|
||||
# The language pack itself is NOT embedded in the payload: spa.html loads
|
||||
# it through the content-addressed /language_pack/<lang>/<version>/script.js
|
||||
# tag before the entry bundle, keeping HTML small while still configuring
|
||||
# the translator synchronously (upstream issue #35330). A pack provided via
|
||||
# COMMON_BOOTSTRAP_OVERRIDES_FUNC (the historical workaround) is respected
|
||||
# and takes precedence over the script tag.
|
||||
payload.setdefault("language_pack", None)
|
||||
# Inject the Jed language pack outside the per-user memoize so the cached
|
||||
# payload stays small and the pack is shared across users for the same
|
||||
# locale. The frontend uses it to configure the translator synchronously,
|
||||
# before any code-split chunk evaluates a module-level `const X = t('...')`
|
||||
# (upstream issue #35330).
|
||||
language = payload.get("locale")
|
||||
if language and language != "en":
|
||||
# Respect a pack already provided via COMMON_BOOTSTRAP_OVERRIDES_FUNC
|
||||
# (the workaround in #35330 does exactly that), otherwise load the
|
||||
# shared one. `get_language_pack` returns the empty English pack on a
|
||||
# miss, which is the right result (English) when no translation file
|
||||
# exists.
|
||||
pack = payload.get("language_pack") or get_language_pack(language)
|
||||
else:
|
||||
pack = None
|
||||
payload["language_pack"] = pack
|
||||
return payload
|
||||
|
||||
|
||||
def get_language_pack_template_context(common: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Template vars controlling how spa.html delivers the language pack.
|
||||
|
||||
``common`` is the already-built common bootstrap payload for the request
|
||||
(passed in rather than re-derived, so callers that assembled or mocked
|
||||
their own payload stay consistent with what the template sees).
|
||||
|
||||
Three mutually exclusive outcomes:
|
||||
- English (or no locale): no script tag, nothing to stash.
|
||||
- Operator supplied a pack via COMMON_BOOTSTRAP_OVERRIDES_FUNC: no script
|
||||
tag; spa.html stashes the bootstrap pack on window instead.
|
||||
- Otherwise: emit the content-addressed script URL so the browser can
|
||||
cache the pack as immutable and cache-bust on translation changes.
|
||||
"""
|
||||
language = common.get("locale")
|
||||
if not language or language == "en":
|
||||
return {"language_pack_src": None, "language_pack_inline": False}
|
||||
if common.get("language_pack"):
|
||||
return {"language_pack_src": None, "language_pack_inline": True}
|
||||
version = get_language_pack_version(language)
|
||||
return {
|
||||
"language_pack_src": (
|
||||
url_for(
|
||||
"Superset.language_pack_script",
|
||||
lang=language,
|
||||
version=version,
|
||||
)
|
||||
if version
|
||||
else None
|
||||
),
|
||||
"language_pack_inline": False,
|
||||
}
|
||||
|
||||
|
||||
def get_spa_payload(extra_data: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Generate standardized payload for spa.html template rendering.
|
||||
|
||||
@@ -753,7 +728,6 @@ def get_spa_template_context(
|
||||
"dark_theme_bg": dark_theme_bg,
|
||||
"spinner_svg": spinner_svg,
|
||||
"default_title": default_title,
|
||||
**get_language_pack_template_context(payload.get("common") or {}),
|
||||
**template_kwargs,
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, cast, Optional
|
||||
from typing import Any, Callable, cast
|
||||
from urllib import parse
|
||||
|
||||
from flask import (
|
||||
@@ -82,7 +82,6 @@ from superset.superset_typing import (
|
||||
FlaskResponse,
|
||||
)
|
||||
from superset.tasks.utils import get_current_user
|
||||
from superset.translations.utils import get_language_pack, get_language_pack_version
|
||||
from superset.utils import core as utils, json
|
||||
from superset.utils.cache import etag_cache
|
||||
from superset.utils.core import (
|
||||
@@ -130,13 +129,6 @@ PARAMETER_MISSING_ERR = __(
|
||||
|
||||
SqlResults = dict[str, Any]
|
||||
|
||||
# Matches the locale codes Superset actually ships: a 2-3 letter language
|
||||
# subtag, optionally followed by a 2-letter region subtag ("pt_BR") or a
|
||||
# 4-letter script subtag ("sr_Latn").
|
||||
LANGUAGE_CODE_RE: re.Pattern[str] = re.compile(
|
||||
r"^[a-z]{2,3}(_[A-Z]{2}|_[A-Z][a-z]{3})?$"
|
||||
)
|
||||
|
||||
|
||||
class Superset(BaseSupersetView):
|
||||
"""The base views for Superset!"""
|
||||
@@ -927,7 +919,7 @@ class Superset(BaseSupersetView):
|
||||
@expose("/language_pack/<lang>/")
|
||||
def language_pack(self, lang: str) -> FlaskResponse:
|
||||
# Only allow expected language formats like "en", "pt_BR", etc.
|
||||
if not LANGUAGE_CODE_RE.match(lang):
|
||||
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")
|
||||
@@ -940,52 +932,6 @@ class Superset(BaseSupersetView):
|
||||
"Language pack doesn't exist on the server", status=404
|
||||
)
|
||||
|
||||
@expose("/language_pack/<lang>/<version>/script.js")
|
||||
def language_pack_script(self, lang: str, version: str) -> FlaskResponse:
|
||||
"""Serve the language pack as a content-addressed classic script.
|
||||
|
||||
spa.html loads this BEFORE the entry bundle so translations are
|
||||
configured synchronously (no race with code-split chunks), while the
|
||||
versioned URL lets browsers cache the pack as immutable and pick up a
|
||||
fresh copy whenever translations change.
|
||||
|
||||
Deliberately unauthenticated: translation catalogs are static, public
|
||||
content shipped in the Superset repo, contain no user or tenant data,
|
||||
and must load for anonymous principals (login page, embedded).
|
||||
"""
|
||||
# Only allow expected language formats like "en", "pt_BR", etc.
|
||||
if not LANGUAGE_CODE_RE.match(lang):
|
||||
abort(400, "Invalid language code")
|
||||
if not re.match(r"^[0-9a-f]{12}$", version):
|
||||
abort(400, "Invalid language pack version")
|
||||
|
||||
current_version: Optional[str] = get_language_pack_version(lang)
|
||||
if current_version is None:
|
||||
return json_error_response(
|
||||
"Language pack doesn't exist on the server", status=404
|
||||
)
|
||||
pack: Optional[dict[str, Any]] = get_language_pack(lang)
|
||||
if pack is None:
|
||||
return json_error_response(
|
||||
"Language pack doesn't exist on the server", status=404
|
||||
)
|
||||
|
||||
response: Response = Response(
|
||||
f"window.__SUPERSET_LANGUAGE_PACK__ = {json.dumps(pack)};",
|
||||
mimetype="application/javascript; charset=utf-8",
|
||||
)
|
||||
if version == current_version:
|
||||
# Content-addressed URL: safe to cache forever.
|
||||
response.cache_control.public = True
|
||||
response.cache_control.max_age = 31536000
|
||||
response.cache_control.immutable = True
|
||||
else:
|
||||
# Stale or unknown version (e.g. HTML rendered before an upgrade):
|
||||
# serve the current pack but keep caches from pinning it under the
|
||||
# wrong address.
|
||||
response.cache_control.no_cache = True
|
||||
return response
|
||||
|
||||
@event_logger.log_this
|
||||
@expose("/welcome/")
|
||||
def welcome(self) -> FlaskResponse:
|
||||
|
||||
@@ -1740,10 +1740,6 @@ class TestRolePermission(SupersetTestCase):
|
||||
# Serves the PWA web app manifest unauthenticated (PWA install
|
||||
# fetches have no session); mirrors the RedirectView precedent.
|
||||
["PwaManifestView", "manifest"],
|
||||
# Serves translation catalogs (static public repo content, no
|
||||
# user/tenant data) as content-addressed scripts; must load for
|
||||
# anonymous principals (login page, embedded dashboards).
|
||||
["Superset", "language_pack_script"],
|
||||
]
|
||||
unsecured_views = []
|
||||
for view_class in appbuilder.baseviews:
|
||||
|
||||
@@ -65,7 +65,8 @@ def _assert_table_list_covers_schema() -> None:
|
||||
)
|
||||
|
||||
|
||||
def _clear_version_tables() -> None:
|
||||
def clear_version_tables() -> None:
|
||||
"""Delete all entity-versioning capture rows and commit the cleanup."""
|
||||
with app.app_context():
|
||||
_assert_table_list_covers_schema()
|
||||
for table in _VERSION_TABLES:
|
||||
@@ -88,6 +89,6 @@ def clean_version_tables() -> Iterator[None]:
|
||||
Table names are interpolated from the fixed ``_VERSION_TABLES`` tuple, not
|
||||
from any test input, so the ``DELETE`` is not an injection surface.
|
||||
"""
|
||||
_clear_version_tables()
|
||||
clear_version_tables()
|
||||
yield
|
||||
_clear_version_tables()
|
||||
clear_version_tables()
|
||||
|
||||
@@ -28,9 +28,13 @@ The suite runs with ``ENABLE_VERSIONING_CAPTURE=True`` (see
|
||||
autouse fixture clears the version tables around each test.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from superset import db
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
@@ -43,6 +47,7 @@ from tests.integration_tests.fixtures.birth_names_dashboard import (
|
||||
load_birth_names_dashboard_with_slices, # noqa: F401
|
||||
load_birth_names_data, # noqa: F401
|
||||
)
|
||||
from tests.integration_tests.versioning.conftest import clear_version_tables
|
||||
|
||||
# A syntactically valid UUID that matches no entity / version.
|
||||
MISSING_UUID = str(uuid4())
|
||||
@@ -202,6 +207,73 @@ class TestChartVersionsApi(SupersetTestCase):
|
||||
finally:
|
||||
self.client.put(f"/api/v1/chart/{chart_id}", json={"slice_name": "Girls"})
|
||||
|
||||
def test_parent_baseline_sql_failure_does_not_break_save(self) -> None:
|
||||
"""A failed optional parent baseline leaves the save committable."""
|
||||
chart_id = self._girls_chart().id
|
||||
clear_version_tables()
|
||||
self.login(ADMIN_USERNAME)
|
||||
|
||||
def fail_parent_baseline(
|
||||
conn: Connection, *args: object, **kwargs: object
|
||||
) -> None:
|
||||
conn.execute(
|
||||
sa.text("INSERT INTO __missing_parent_shadow__ (x) VALUES (1)")
|
||||
)
|
||||
|
||||
try:
|
||||
with patch(
|
||||
"superset.versioning.baseline.insertion.insert_baseline_shadow_row",
|
||||
side_effect=fail_parent_baseline,
|
||||
):
|
||||
rv = self.client.put(
|
||||
f"/api/v1/chart/{chart_id}",
|
||||
json={"slice_name": "Girls survives parent baseline failure"},
|
||||
)
|
||||
|
||||
assert rv.status_code == 200, rv.data
|
||||
got = json.loads(self.client.get(f"/api/v1/chart/{chart_id}").data)[
|
||||
"result"
|
||||
]
|
||||
assert got["slice_name"] == "Girls survives parent baseline failure"
|
||||
baseline_count = db.session.scalar(
|
||||
sa.text(
|
||||
"SELECT count(*) FROM slices_version "
|
||||
"WHERE id = :id AND operation_type = 0"
|
||||
),
|
||||
{"id": chart_id},
|
||||
)
|
||||
assert baseline_count == 0
|
||||
canonical_updates = db.session.scalar(
|
||||
sa.text(
|
||||
"SELECT count(*) FROM slices_version "
|
||||
"WHERE id = :id AND operation_type = 1 "
|
||||
"AND slice_name = :name"
|
||||
),
|
||||
{
|
||||
"id": chart_id,
|
||||
"name": "Girls survives parent baseline failure",
|
||||
},
|
||||
)
|
||||
assert canonical_updates == 1
|
||||
assert (
|
||||
db.session.scalar(sa.text("SELECT count(*) FROM version_transaction"))
|
||||
== 1
|
||||
)
|
||||
|
||||
listing = json.loads(
|
||||
self.client.get(f"/api/v1/chart/{got['uuid']}/versions/").data
|
||||
)
|
||||
assert listing["count"] == 1
|
||||
assert listing["result"][0]["operation_type"] == "update"
|
||||
|
||||
follow_up = self.client.put(
|
||||
f"/api/v1/chart/{chart_id}",
|
||||
json={"slice_name": "Girls session remains usable"},
|
||||
)
|
||||
assert follow_up.status_code == 200, follow_up.data
|
||||
finally:
|
||||
self.client.put(f"/api/v1/chart/{chart_id}", json={"slice_name": "Girls"})
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
class TestDashboardVersionsApi(SupersetTestCase):
|
||||
@@ -301,3 +373,86 @@ class TestDatasetVersionsApi(SupersetTestCase):
|
||||
body = json.loads(rv.data.decode("utf-8"))
|
||||
assert set(body) >= {"old_version", "new_version", "new_version_uuid"}
|
||||
assert body["new_version_uuid"] is not None
|
||||
|
||||
def test_child_baseline_sql_failure_rolls_back_complete_optional_unit(
|
||||
self,
|
||||
) -> None:
|
||||
"""A child failure preserves the save without partial baseline rows."""
|
||||
dataset = self._dataset()
|
||||
dataset_id = dataset.id
|
||||
original = dataset.description
|
||||
clear_version_tables()
|
||||
self.login(ADMIN_USERNAME)
|
||||
|
||||
def fail_children(session: Session, _parent: object, _tx_id: int) -> None:
|
||||
session.connection().execute(
|
||||
sa.text("INSERT INTO __missing_child_shadow__ (x) VALUES (1)")
|
||||
)
|
||||
|
||||
try:
|
||||
with patch.dict(
|
||||
"superset.versioning.baseline.insertion.CHILD_BASELINE_HANDLERS",
|
||||
{"SqlaTable": fail_children},
|
||||
):
|
||||
rv = self.client.put(
|
||||
f"/api/v1/dataset/{dataset_id}",
|
||||
json={"description": "survives child baseline failure"},
|
||||
)
|
||||
|
||||
assert rv.status_code == 200, rv.data
|
||||
got = json.loads(self.client.get(f"/api/v1/dataset/{dataset_id}").data)[
|
||||
"result"
|
||||
]
|
||||
assert got["description"] == "survives child baseline failure"
|
||||
|
||||
parent_baselines = db.session.scalar(
|
||||
sa.text(
|
||||
"SELECT count(*) FROM tables_version "
|
||||
"WHERE id = :id AND operation_type = 0"
|
||||
),
|
||||
{"id": dataset_id},
|
||||
)
|
||||
child_baselines = db.session.scalar(
|
||||
sa.text(
|
||||
"SELECT count(*) FROM table_columns_version "
|
||||
"WHERE table_id = :id AND operation_type = 0"
|
||||
),
|
||||
{"id": dataset_id},
|
||||
)
|
||||
metric_baselines = db.session.scalar(
|
||||
sa.text(
|
||||
"SELECT count(*) FROM sql_metrics_version "
|
||||
"WHERE table_id = :id AND operation_type = 0"
|
||||
),
|
||||
{"id": dataset_id},
|
||||
)
|
||||
assert parent_baselines == 0
|
||||
assert child_baselines == 0
|
||||
assert metric_baselines == 0
|
||||
canonical_updates = db.session.scalar(
|
||||
sa.text(
|
||||
"SELECT count(*) FROM tables_version "
|
||||
"WHERE id = :id AND operation_type = 1 "
|
||||
"AND description = :description"
|
||||
),
|
||||
{
|
||||
"id": dataset_id,
|
||||
"description": "survives child baseline failure",
|
||||
},
|
||||
)
|
||||
assert canonical_updates == 1
|
||||
assert (
|
||||
db.session.scalar(sa.text("SELECT count(*) FROM version_transaction"))
|
||||
== 1
|
||||
)
|
||||
|
||||
listing = json.loads(
|
||||
self.client.get(f"/api/v1/dataset/{got['uuid']}/versions/").data
|
||||
)
|
||||
assert listing["count"] == 1
|
||||
assert listing["result"][0]["operation_type"] == "update"
|
||||
finally:
|
||||
self.client.put(
|
||||
f"/api/v1/dataset/{dataset_id}",
|
||||
json={"description": original},
|
||||
)
|
||||
|
||||
@@ -478,3 +478,69 @@ def test_scope_execute_sql_query_requires_write_scope(app_context) -> None:
|
||||
assert check_tool_permission(func) is False
|
||||
with _patch_token_scopes(["superset:write"]):
|
||||
assert check_tool_permission(func) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User/Role tools must request a permission FAB actually registers.
|
||||
#
|
||||
# FAB's security API views (UserApi, RoleApi) register only
|
||||
# can_get/can_info/can_post/can_put/can_delete — never can_read. Superset's
|
||||
# own ModelRestApi subclasses remap methods onto read/write, which is why
|
||||
# "can_read on Chart" exists while "can_read on User" cannot. A tool that
|
||||
# demands can_read on User/Role is therefore dead for every principal,
|
||||
# including Admin (fails closed).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The full set of permissions FAB registers on its security API views.
|
||||
_FAB_SECURITY_VIEW_PERMISSIONS = {
|
||||
"can_get",
|
||||
"can_info",
|
||||
"can_post",
|
||||
"can_put",
|
||||
"can_delete",
|
||||
}
|
||||
|
||||
|
||||
def _fab_security_view_can_access(permission_str: str, view_name: str) -> bool:
|
||||
"""Simulate can_access for a user holding every permission FAB actually
|
||||
registers on the User/Role security views (i.e. a stock Admin)."""
|
||||
return permission_str in _FAB_SECURITY_VIEW_PERMISSIONS
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"module_path,func_name",
|
||||
[
|
||||
("superset.mcp_service.user.tool.list_users", "list_users"),
|
||||
("superset.mcp_service.user.tool.get_user_info", "get_user_info"),
|
||||
("superset.mcp_service.role.tool.list_roles", "list_roles"),
|
||||
("superset.mcp_service.role.tool.get_role_info", "get_role_info"),
|
||||
],
|
||||
)
|
||||
def test_user_role_tools_usable_by_stock_admin(
|
||||
app_context, module_path: str, func_name: str
|
||||
) -> None:
|
||||
"""A stock Admin (holding exactly the FAB-registered permissions on the
|
||||
User/Role views) must be able to use the user/role tools."""
|
||||
import importlib
|
||||
|
||||
func = getattr(importlib.import_module(module_path), func_name)
|
||||
g.user = MagicMock(username="admin")
|
||||
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.can_access = MagicMock(side_effect=_fab_security_view_can_access)
|
||||
with patch("superset.mcp_service.auth.security_manager", mock_sm):
|
||||
assert check_tool_permission(func) is True, (
|
||||
f"{func_name} requests can_"
|
||||
f"{getattr(func, METHOD_PERMISSION_ATTR, 'read')} on "
|
||||
f"{getattr(func, CLASS_PERMISSION_ATTR, None)}, which FAB never "
|
||||
"registers on its security views — the tool is dead even for Admin"
|
||||
)
|
||||
|
||||
|
||||
def test_get_method_permission_is_scope_mapped(app_context) -> None:
|
||||
"""The 'get' method permission must be in _METHOD_TO_REQUIRED_SCOPE:
|
||||
unmapped method permissions are denied for scoped tokens (fail closed),
|
||||
which would leave the user/role tools dead for scoped-JWT deployments."""
|
||||
from superset.mcp_service.auth import _METHOD_TO_REQUIRED_SCOPE
|
||||
|
||||
assert _METHOD_TO_REQUIRED_SCOPE.get("get") == "superset:read"
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,71 +0,0 @@
|
||||
# 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 hashlib
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.translations import utils as translations_utils
|
||||
from superset.translations.utils import (
|
||||
get_language_pack_filename,
|
||||
get_language_pack_version,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_version_cache() -> Iterator[None]:
|
||||
translations_utils.ALL_LANGUAGE_PACK_VERSIONS.clear()
|
||||
yield
|
||||
translations_utils.ALL_LANGUAGE_PACK_VERSIONS.clear()
|
||||
|
||||
|
||||
def test_language_pack_filename_resolution() -> None:
|
||||
assert get_language_pack_filename("fr").endswith("/fr/LC_MESSAGES/messages.json")
|
||||
assert get_language_pack_filename("en").endswith("/empty_language_pack.json")
|
||||
assert get_language_pack_filename("").endswith("/empty_language_pack.json")
|
||||
|
||||
|
||||
def test_version_is_short_content_hash(tmp_path, monkeypatch) -> None:
|
||||
pack_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.json"
|
||||
pack_file.parent.mkdir(parents=True)
|
||||
pack_file.write_bytes(b'{"domain": "superset"}')
|
||||
monkeypatch.setattr(translations_utils, "DIR", str(tmp_path))
|
||||
|
||||
version = get_language_pack_version("fr")
|
||||
|
||||
expected = hashlib.sha256(b'{"domain": "superset"}').hexdigest()[:12]
|
||||
assert version == expected
|
||||
|
||||
|
||||
def test_version_is_cached_and_changes_with_content(tmp_path, monkeypatch) -> None:
|
||||
pack_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.json"
|
||||
pack_file.parent.mkdir(parents=True)
|
||||
pack_file.write_bytes(b"{}")
|
||||
monkeypatch.setattr(translations_utils, "DIR", str(tmp_path))
|
||||
|
||||
first = get_language_pack_version("fr")
|
||||
pack_file.write_bytes(b'{"changed": true}')
|
||||
# Cached within the process lifetime: same version until cache cleared.
|
||||
assert get_language_pack_version("fr") == first
|
||||
|
||||
translations_utils.ALL_LANGUAGE_PACK_VERSIONS.clear()
|
||||
assert get_language_pack_version("fr") != first
|
||||
|
||||
|
||||
def test_version_none_when_pack_missing(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(translations_utils, "DIR", str(tmp_path))
|
||||
assert get_language_pack_version("xx") is None
|
||||
@@ -156,108 +156,71 @@ def test_bootstrap_does_not_crash_without_recaptcha_key(
|
||||
assert "RECAPTCHA_PUBLIC_KEY" not in payload["conf"]
|
||||
|
||||
|
||||
# --- language pack delivery ----------------------------------------------
|
||||
# --- language_pack injection --------------------------------------------
|
||||
#
|
||||
# The pack is NOT embedded in the bootstrap payload; spa.html loads it via a
|
||||
# content-addressed script tag whose URL comes from
|
||||
# `get_language_pack_template_context`. A pack supplied through
|
||||
# COMMON_BOOTSTRAP_OVERRIDES_FUNC still rides the payload and suppresses the
|
||||
# script tag.
|
||||
# The Jed pack is injected by `common_bootstrap_payload` (outside the
|
||||
# memoized `cached_common_bootstrap_data`) using the shared
|
||||
# `superset.translations.utils.get_language_pack`. Tests here cover the
|
||||
# wrapper to confirm the pack lands on the payload for non-English
|
||||
# locales and is None for English.
|
||||
|
||||
|
||||
def test_common_bootstrap_payload_does_not_embed_language_pack(
|
||||
def test_common_bootstrap_payload_includes_language_pack_for_non_english(
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""The payload stays small: no full pack even for non-English locales."""
|
||||
"""common.language_pack carries the shared utility's pack for non-en."""
|
||||
fake_pack = {"domain": "superset", "locale_data": {"superset": {}}}
|
||||
with (
|
||||
patch(
|
||||
"superset.views.base.cached_common_bootstrap_data",
|
||||
return_value={"locale": "fr"},
|
||||
),
|
||||
patch(
|
||||
"superset.views.base.get_language_pack",
|
||||
return_value=fake_pack,
|
||||
) as mock_get,
|
||||
patch("superset.views.base.utils.get_user_id", return_value=1),
|
||||
patch("superset.views.base.get_locale", return_value="fr"),
|
||||
):
|
||||
payload: dict[str, Any] = common_bootstrap_payload()
|
||||
payload = common_bootstrap_payload()
|
||||
|
||||
assert payload["language_pack"] is None
|
||||
assert payload["language_pack"] == fake_pack
|
||||
mock_get.assert_called_once_with("fr")
|
||||
|
||||
|
||||
def test_common_bootstrap_payload_preserves_override_pack(
|
||||
def test_common_bootstrap_payload_skips_pack_for_english(
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""A pack from COMMON_BOOTSTRAP_OVERRIDES_FUNC is passed through."""
|
||||
fake_pack: dict[str, Any] = {"domain": "superset", "locale_data": {"superset": {}}}
|
||||
"""English short-circuits: pack is None and the utility is not called."""
|
||||
with (
|
||||
patch(
|
||||
"superset.views.base.cached_common_bootstrap_data",
|
||||
return_value={"locale": "fr", "language_pack": fake_pack},
|
||||
return_value={"locale": "en"},
|
||||
),
|
||||
patch("superset.views.base.get_language_pack") as mock_get,
|
||||
patch("superset.views.base.utils.get_user_id", return_value=1),
|
||||
patch("superset.views.base.get_locale", return_value="fr"),
|
||||
patch("superset.views.base.get_locale", return_value="en"),
|
||||
):
|
||||
payload: dict[str, Any] = common_bootstrap_payload()
|
||||
payload = common_bootstrap_payload()
|
||||
|
||||
assert payload["language_pack"] == fake_pack
|
||||
assert payload["language_pack"] is None
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
def test_common_bootstrap_payload_does_not_mutate_memoized_dict(
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""Defaulting language_pack must not write back into the memoize cache."""
|
||||
"""Injecting language_pack must not write back into the memoize cache."""
|
||||
cached: dict[str, Any] = {"locale": "fr"}
|
||||
with (
|
||||
patch(
|
||||
"superset.views.base.cached_common_bootstrap_data",
|
||||
return_value=cached,
|
||||
),
|
||||
patch("superset.views.base.get_language_pack", return_value={"x": 1}),
|
||||
patch("superset.views.base.utils.get_user_id", return_value=1),
|
||||
patch("superset.views.base.get_locale", return_value="fr"),
|
||||
):
|
||||
common_bootstrap_payload()
|
||||
|
||||
assert "language_pack" not in cached
|
||||
|
||||
|
||||
def _language_pack_context(
|
||||
locale: str, payload_extra: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
from superset.views.base import get_language_pack_template_context
|
||||
|
||||
with (
|
||||
patch(
|
||||
"superset.views.base.get_language_pack_version",
|
||||
return_value="abc123def456",
|
||||
),
|
||||
patch(
|
||||
"superset.views.base.url_for",
|
||||
return_value="/language_pack/fr/abc123def456/script.js",
|
||||
),
|
||||
):
|
||||
return get_language_pack_template_context({"locale": locale, **payload_extra})
|
||||
|
||||
|
||||
def test_language_pack_template_context_versioned_src_for_non_english(
|
||||
app_context: None,
|
||||
) -> None:
|
||||
context: dict[str, Any] = _language_pack_context("fr", {})
|
||||
assert context == {
|
||||
"language_pack_src": "/language_pack/fr/abc123def456/script.js",
|
||||
"language_pack_inline": False,
|
||||
}
|
||||
|
||||
|
||||
def test_language_pack_template_context_none_for_english(
|
||||
app_context: None,
|
||||
) -> None:
|
||||
context: dict[str, Any] = _language_pack_context("en", {})
|
||||
assert context == {"language_pack_src": None, "language_pack_inline": False}
|
||||
|
||||
|
||||
def test_language_pack_template_context_inline_for_override_pack(
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""An operator-supplied pack suppresses the script tag; spa.html inlines."""
|
||||
context: dict[str, Any] = _language_pack_context(
|
||||
"fr", {"language_pack": {"domain": "superset"}}
|
||||
)
|
||||
assert context == {"language_pack_src": None, "language_pack_inline": True}
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
# 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 pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
FAKE_PACK = {"domain": "superset", "locale_data": {"superset": {"": {}}}}
|
||||
FAKE_VERSION = "abc123def456"
|
||||
|
||||
|
||||
def test_script_served_immutable_when_version_matches(client: Any) -> None:
|
||||
with (
|
||||
patch(
|
||||
"superset.views.core.get_language_pack_version",
|
||||
return_value=FAKE_VERSION,
|
||||
),
|
||||
patch("superset.views.core.get_language_pack", return_value=FAKE_PACK),
|
||||
):
|
||||
response = client.get(f"/language_pack/fr/{FAKE_VERSION}/script.js")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.mimetype == "application/javascript"
|
||||
body = response.get_data(as_text=True)
|
||||
assert body.startswith("window.__SUPERSET_LANGUAGE_PACK__ = ")
|
||||
assert '"domain": "superset"' in body
|
||||
cache_control = response.headers["Cache-Control"]
|
||||
assert "immutable" in cache_control
|
||||
assert "max-age=31536000" in cache_control
|
||||
assert "public" in cache_control
|
||||
|
||||
|
||||
def test_script_not_cacheable_when_version_stale(client: Any) -> None:
|
||||
"""A pre-upgrade HTML page may reference an old version: serve fresh
|
||||
content, but do not let caches pin it under the stale address."""
|
||||
with (
|
||||
patch(
|
||||
"superset.views.core.get_language_pack_version",
|
||||
return_value=FAKE_VERSION,
|
||||
),
|
||||
patch("superset.views.core.get_language_pack", return_value=FAKE_PACK),
|
||||
):
|
||||
response = client.get("/language_pack/fr/000000000000/script.js")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "no-cache" in response.headers["Cache-Control"]
|
||||
assert "immutable" not in response.headers["Cache-Control"]
|
||||
|
||||
|
||||
def test_script_404_when_pack_missing(client: Any) -> None:
|
||||
with patch(
|
||||
"superset.views.core.get_language_pack_version",
|
||||
return_value=None,
|
||||
):
|
||||
response = client.get(f"/language_pack/xx/{FAKE_VERSION}/script.js")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_script_rejects_malformed_lang_and_version(client: Any) -> None:
|
||||
assert client.get(f"/language_pack/../{FAKE_VERSION}/script.js").status_code in (
|
||||
400,
|
||||
404,
|
||||
)
|
||||
assert client.get("/language_pack/fr/not-a-hash!/script.js").status_code == 400
|
||||
|
||||
|
||||
def test_script_accepts_script_subtag_locale(client: Any) -> None:
|
||||
"""Script-subtag locales Superset ships (e.g. sr_Latn) must not be
|
||||
rejected by the language-code validation."""
|
||||
with (
|
||||
patch(
|
||||
"superset.views.core.get_language_pack_version",
|
||||
return_value=FAKE_VERSION,
|
||||
),
|
||||
patch("superset.views.core.get_language_pack", return_value=FAKE_PACK),
|
||||
):
|
||||
response = client.get(f"/language_pack/sr_Latn/{FAKE_VERSION}/script.js")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_script_serves_only_the_requested_locale(client: Any) -> None:
|
||||
"""The endpoint resolves exactly one pack: the locale in the URL."""
|
||||
with (
|
||||
patch(
|
||||
"superset.views.core.get_language_pack_version",
|
||||
return_value=FAKE_VERSION,
|
||||
) as mock_version,
|
||||
patch(
|
||||
"superset.views.core.get_language_pack", return_value=FAKE_PACK
|
||||
) as mock_pack,
|
||||
):
|
||||
client.get(f"/language_pack/pt_BR/{FAKE_VERSION}/script.js")
|
||||
|
||||
mock_version.assert_called_once_with("pt_BR")
|
||||
mock_pack.assert_called_once_with("pt_BR")
|
||||
|
||||
|
||||
def test_spa_template_loads_pack_before_entry_bundle() -> None:
|
||||
"""Static guard on spa.html: the language pack script tag must precede
|
||||
the entry bundle and stay a classic script (no async/defer). A deferred
|
||||
or reordered tag would let code-split chunks evaluate module-level
|
||||
`t('...')` calls before the translator is configured (issue #35330)."""
|
||||
import superset
|
||||
|
||||
template: str = (
|
||||
Path(superset.__file__).parent / "templates" / "superset" / "spa.html"
|
||||
).read_text()
|
||||
|
||||
tag_start: int = template.index('<script src="{{ language_pack_src }}"')
|
||||
entry_start: int = template.index("js_bundle(assets_prefix, entry)")
|
||||
assert tag_start < entry_start
|
||||
|
||||
script_tag: str = template[tag_start : template.index(">", tag_start)]
|
||||
assert "async" not in script_tag
|
||||
assert "defer" not in script_tag
|
||||
Reference in New Issue
Block a user