mirror of
https://github.com/apache/superset.git
synced 2026-08-02 20:12:27 +00:00
Compare commits
22 Commits
enxdev/fea
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e3ff37137 | ||
|
|
120b4420b9 | ||
|
|
0628b0b813 | ||
|
|
378634cceb | ||
|
|
af7472fd7e | ||
|
|
36e3ebecfa | ||
|
|
3f201c1c77 | ||
|
|
cc4cd3e98a | ||
|
|
322ae841e5 | ||
|
|
5ccccc8c69 | ||
|
|
3d0ee8b4c5 | ||
|
|
ff9bec2b99 | ||
|
|
9f66cb566b | ||
|
|
29ac93862e | ||
|
|
c9b159b4e7 | ||
|
|
d336d2a8b6 | ||
|
|
22c305f758 | ||
|
|
6929d032b8 | ||
|
|
f6c574edd8 | ||
|
|
b452c1634d | ||
|
|
1bfbce3cfd | ||
|
|
85bab0b07c |
54
.github/workflows/label-merge-conflicts.yml
vendored
Normal file
54
.github/workflows/label-merge-conflicts.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: Label Merge Conflicts
|
||||
|
||||
# Sweeps every open PR and labels the ones GitHub reports as CONFLICTING with
|
||||
# `requires:rebase` (removing it once a rebase makes the PR mergeable again),
|
||||
# so the label can be used to filter the PR backlog for the ones that need a
|
||||
# rebase before they can be reviewed/merged.
|
||||
#
|
||||
# The action itself always re-checks *every* open PR via GraphQL on each run
|
||||
# regardless of what triggered it (see eps1lon/actions-label-merge-conflict's
|
||||
# sources/main.ts) - there's no way to scope it to "just this PR". The
|
||||
# project's own README suggests triggering on `push` (to the default branch)
|
||||
# plus `pull_request_target: [synchronize]`, but on a repo with Superset's PR
|
||||
# volume that combination would re-sweep the entire open-PR list on every
|
||||
# merge to master *and* every push to *any* open PR - many times an hour.
|
||||
# A schedule bounds that to a fixed, predictable cadence instead; adjust it
|
||||
# if 2 hours turns out to be too slow or too chatty in practice.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 */2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
# Avoid two full backlog sweeps racing (a manual workflow_dispatch landing
|
||||
# mid-schedule-tick, say); queue rather than cancel so an in-progress
|
||||
# paginated sweep always runs to completion.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
label-merge-conflicts:
|
||||
# Scheduled/dispatch workflows still run on forks that carry this file;
|
||||
# skip anywhere but the canonical repo.
|
||||
if: github.repository == 'apache/superset'
|
||||
name: Label Merge Conflicts
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # to add/remove requires:rebase and need:merge
|
||||
steps:
|
||||
# ASF Infra allowlists this whole action via a wildcard
|
||||
# (eps1lon/actions-label-merge-conflict@*), so any pinned SHA/version
|
||||
# is already fine here - no Infra ticket needed for future bumps.
|
||||
- uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
|
||||
with:
|
||||
dirtyLabel: "requires:rebase"
|
||||
# A conflicting PR isn't actually ready to merge; strip that signal
|
||||
# if it was previously set so reviewers don't act on a stale one.
|
||||
removeOnDirtyLabel: "need:merge"
|
||||
repoToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Intentionally no commentOnDirty/commentOnClean: the label alone is
|
||||
# the signal (matches the label's existing description, and avoids
|
||||
# a one-time comment storm across the whole backlog on first run).
|
||||
@@ -1,3 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# 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
|
||||
@@ -15,8 +17,6 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
#!/bin/bash
|
||||
|
||||
# Function to determine Python command
|
||||
get_python_command() {
|
||||
if command -v python3 &>/dev/null; then
|
||||
|
||||
@@ -38,7 +38,7 @@ RESET='\033[0m'
|
||||
echo -e "${GREEN}Updating package lists...${RESET}"
|
||||
apt-get update -qq
|
||||
|
||||
echo -e "${GREEN}Installing packages: $@${RESET}"
|
||||
echo -e "${GREEN}Installing packages: $*${RESET}"
|
||||
apt-get install -yqq --no-install-recommends "$@"
|
||||
|
||||
echo -e "${GREEN}Autoremoving unnecessary packages...${RESET}"
|
||||
|
||||
@@ -163,10 +163,10 @@ do
|
||||
# Iterate through the components of the version strings
|
||||
for (( j=0; j<${#THIS_TAG_NAME_ARRAY[@]}; j++ )); do
|
||||
echo "Comparing ${THIS_TAG_NAME_ARRAY[$j]} to ${LATEST_RELEASE_TAG_ARRAY[$j]}"
|
||||
if [[ $((THIS_TAG_NAME_ARRAY[$j])) > $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
if [[ $((THIS_TAG_NAME_ARRAY[$j])) -gt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
compare_result="greater"
|
||||
break
|
||||
elif [[ $((THIS_TAG_NAME_ARRAY[$j])) < $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
elif [[ $((THIS_TAG_NAME_ARRAY[$j])) -lt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
compare_result="lesser"
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -42,13 +42,13 @@ dependencies = [
|
||||
# ``google-auth`` 2.53+ dropped it, so Superset must declare it
|
||||
# explicitly to keep fresh ``pip install apache-superset`` working
|
||||
# without the ``base.txt`` lock file (#40962).
|
||||
"cachetools>=7.1.4, <8",
|
||||
"cachetools>=7.1.6, <8",
|
||||
"celery>=5.6.3, <6.0.0",
|
||||
"click>=8.4.2",
|
||||
"click-option-group",
|
||||
"colorama",
|
||||
"flask-cors>=6.0.5, <7.0",
|
||||
"croniter>=6.2.2",
|
||||
"croniter>=6.2.4",
|
||||
"cron-descriptor",
|
||||
"cryptography>=49.0.0, <50.0.0",
|
||||
"deprecation>=2.1.0, <2.2.0",
|
||||
@@ -62,7 +62,7 @@ dependencies = [
|
||||
"flask-session>=0.4.0, <1.0",
|
||||
"flask-wtf>=1.3.0, <2.0",
|
||||
"geopy",
|
||||
"greenlet<=3.5.3, >=3.5.3",
|
||||
"greenlet<=3.5.4, >=3.5.4",
|
||||
"gunicorn>=26.0.0, <27; sys_platform != 'win32'",
|
||||
"hashids>=1.3.1, <2",
|
||||
# holidays>=0.45 required for security fix
|
||||
@@ -90,7 +90,7 @@ dependencies = [
|
||||
"paramiko>=3.4.0, <4.0", # 4.0 removed DSSKey, still referenced by sshtunnel
|
||||
"pgsanity",
|
||||
"Pillow>=11.0.0, <13",
|
||||
"polyline>=2.0.0, <3.0",
|
||||
"polyline>=2.0.4, <3.0",
|
||||
"pydantic>=2.8.0",
|
||||
"pyparsing>=3.3.2, <4",
|
||||
"python-dateutil",
|
||||
@@ -122,14 +122,14 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
athena = ["pyathena[pandas]>=2, <4"]
|
||||
athena = ["pyathena[pandas]>=3.35.2, <4"]
|
||||
aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
|
||||
bigquery = [
|
||||
"pandas-gbq>=0.35.0",
|
||||
"sqlalchemy-bigquery>=1.17.0",
|
||||
"google-cloud-bigquery>=3.42.2",
|
||||
]
|
||||
clickhouse = ["clickhouse-connect>=1.4.2, <2.0"]
|
||||
clickhouse = ["clickhouse-connect>=1.6.0, <2.0"]
|
||||
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
|
||||
crate = ["sqlalchemy-cratedb>=0.41.0, <1"]
|
||||
d1 = [
|
||||
@@ -139,7 +139,7 @@ d1 = [
|
||||
]
|
||||
databend = ["databend-sqlalchemy>=0.5.5, <1.0"]
|
||||
databricks = [
|
||||
"databricks-sql-connector>=4.2.6, <4.4.0",
|
||||
"databricks-sql-connector>=4.4.0, <4.5.0",
|
||||
"databricks-sqlalchemy==1.0.5",
|
||||
]
|
||||
datafusion = ["flightsql-dbapi>=0.2.2, <0.3"]
|
||||
@@ -173,7 +173,7 @@ hana = ["hdbcli==2.29.25", "sqlalchemy_hana==3.0.3"]
|
||||
hive = [
|
||||
"pyhive[hive_pure_sasl]>=0.7.0",
|
||||
"tableschema",
|
||||
"thrift>=0.23.0, <1.0.0",
|
||||
"thrift>=0.24.0, <1.0.0",
|
||||
"thrift_sasl>=0.4.3, < 1.0.0",
|
||||
]
|
||||
impala = ["impyla>=0.24.0, <0.25"]
|
||||
@@ -196,7 +196,7 @@ playwright = ["playwright>=1.61.0, <2"]
|
||||
postgres = ["psycopg2-binary==2.9.12"]
|
||||
presto = ["pyhive[presto]>=0.6.5"]
|
||||
trino = ["trino>=0.338.0"]
|
||||
prophet = ["prophet>=1.1.6, <2"]
|
||||
prophet = ["prophet>=1.3.0, <2"]
|
||||
redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"]
|
||||
risingwave = ["sqlalchemy-risingwave"]
|
||||
shillelagh = ["shillelagh[all]>=1.4.4, <2"]
|
||||
@@ -206,7 +206,7 @@ sqlite = ["syntaqlite>=0.7.0,<0.8.0"]
|
||||
spark = [
|
||||
"pyhive[hive_pure_sasl]>=0.7",
|
||||
"tableschema",
|
||||
"thrift>=0.23.0, <1",
|
||||
"thrift>=0.24.0, <1",
|
||||
]
|
||||
tdengine = [
|
||||
"taospy>=2.8.9",
|
||||
|
||||
@@ -46,7 +46,7 @@ cachelib==0.13.0
|
||||
# via
|
||||
# flask-caching
|
||||
# flask-session
|
||||
cachetools==7.1.4
|
||||
cachetools==7.1.6
|
||||
# via apache-superset (pyproject.toml)
|
||||
cattrs==25.1.1
|
||||
# via requests-cache
|
||||
@@ -86,7 +86,7 @@ colorama==0.4.6
|
||||
# flask-appbuilder
|
||||
cron-descriptor==1.4.5
|
||||
# via apache-superset (pyproject.toml)
|
||||
croniter==6.2.2
|
||||
croniter==6.2.4
|
||||
# via apache-superset (pyproject.toml)
|
||||
cryptography==49.0.0
|
||||
# via
|
||||
@@ -166,7 +166,7 @@ google-auth==2.53.0
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# shillelagh
|
||||
greenlet==3.5.3
|
||||
greenlet==3.5.4
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# shillelagh
|
||||
@@ -291,7 +291,7 @@ pillow==12.3.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
platformdirs==4.3.8
|
||||
# via requests-cache
|
||||
polyline==2.0.2
|
||||
polyline==2.0.4
|
||||
# via apache-superset (pyproject.toml)
|
||||
prison==0.2.1
|
||||
# via flask-appbuilder
|
||||
|
||||
@@ -101,7 +101,7 @@ cachelib==0.13.0
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-caching
|
||||
# flask-session
|
||||
cachetools==7.1.4
|
||||
cachetools==7.1.6
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -178,7 +178,7 @@ cron-descriptor==1.4.5
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
croniter==6.2.2
|
||||
croniter==6.2.4
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -377,7 +377,7 @@ googleapis-common-protos==1.66.0
|
||||
# via
|
||||
# google-api-core
|
||||
# grpcio-status
|
||||
greenlet==3.5.3
|
||||
greenlet==3.5.4
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -687,7 +687,7 @@ pluggy==1.5.0
|
||||
# via pytest
|
||||
polib==1.2.0
|
||||
# via apache-superset
|
||||
polyline==2.0.2
|
||||
polyline==2.0.4
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -703,7 +703,7 @@ prompt-toolkit==3.0.51
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# click-repl
|
||||
prophet==1.2.0
|
||||
prophet==1.3.0
|
||||
# via apache-superset
|
||||
proto-plus==1.25.0
|
||||
# via google-api-core
|
||||
|
||||
@@ -35,7 +35,7 @@ acquire_rat_jar () {
|
||||
wget --quiet ${URL} -O "$JAR_DL" && mv "$JAR_DL" "$JAR"
|
||||
else
|
||||
printf "You do not have curl or wget installed, please install rat manually.\n"
|
||||
exit -1
|
||||
exit 255
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -44,7 +44,7 @@ acquire_rat_jar () {
|
||||
# We failed to download
|
||||
rm "$JAR"
|
||||
printf "Our attempt to download rat locally to ${JAR} failed. Please install rat manually.\n"
|
||||
exit -1
|
||||
exit 255
|
||||
fi
|
||||
printf "Done downloading.\n"
|
||||
}
|
||||
|
||||
@@ -163,10 +163,10 @@ do
|
||||
# Iterate through the components of the version strings
|
||||
for (( j=0; j<${#THIS_TAG_NAME_ARRAY[@]}; j++ )); do
|
||||
echo "Comparing ${THIS_TAG_NAME_ARRAY[$j]} to ${LATEST_RELEASE_TAG_ARRAY[$j]}"
|
||||
if [[ $((THIS_TAG_NAME_ARRAY[$j])) > $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
if [[ $((THIS_TAG_NAME_ARRAY[$j])) -gt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
compare_result="greater"
|
||||
break
|
||||
elif [[ $((THIS_TAG_NAME_ARRAY[$j])) < $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
elif [[ $((THIS_TAG_NAME_ARRAY[$j])) -lt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
compare_result="lesser"
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -216,6 +216,12 @@ test('should render the error', async () => {
|
||||
.spyOn(SupersetClient, 'post')
|
||||
.mockRejectedValue(new Error('Something went wrong'));
|
||||
await waitForRender();
|
||||
// The error is wrapped in an Alert component with a stable headline and the
|
||||
// raw error text in the description — no more bare ``<pre>`` elements.
|
||||
expect(await screen.findByRole('alert')).toBeVisible();
|
||||
expect(
|
||||
await screen.findByText('Failed to load drill-to-detail rows'),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText('Error: Something went wrong')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import BooleanCell from '@superset-ui/core/components/Table/cell-renderers/Boole
|
||||
import NullCell from '@superset-ui/core/components/Table/cell-renderers/NullCell';
|
||||
import TimeCell from '@superset-ui/core/components/Table/cell-renderers/TimeCell';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { getDatasourceSamples } from 'src/components/Chart/chartAction';
|
||||
import Table, {
|
||||
ColumnsType,
|
||||
@@ -362,13 +363,18 @@ export default function DrillDetailPane({
|
||||
if (responseError) {
|
||||
// Render error if page download failed
|
||||
tableContent = (
|
||||
<pre
|
||||
<div
|
||||
css={css`
|
||||
margin-top: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
>
|
||||
{responseError}
|
||||
</pre>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('Failed to load drill-to-detail rows')}
|
||||
description={responseError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (bootstrapping) {
|
||||
// Render loading if first page hasn't loaded
|
||||
|
||||
@@ -50,6 +50,7 @@ const DISABLED_REASONS = {
|
||||
DATABASE: t(
|
||||
'Drill to detail is disabled for this database. Change the database settings to enable it.',
|
||||
),
|
||||
DATASOURCE: t('Drill to detail is not available for this datasource type.'),
|
||||
NO_AGGREGATIONS: t(
|
||||
'Drill to detail is disabled because this chart does not group data by dimension value.',
|
||||
),
|
||||
@@ -116,6 +117,17 @@ export const useDrillDetailMenuItems = ({
|
||||
datasources[formData.datasource]?.database?.disable_drill_to_detail,
|
||||
);
|
||||
|
||||
// Capability flag on the datasource itself. Datasources that don't model
|
||||
// raw rows (e.g. semantic views) opt out via ``supports_drill_to_detail``
|
||||
// in the explore data payload.
|
||||
const datasourceSupportsDrillToDetail = useSelector<
|
||||
RootState,
|
||||
boolean | undefined
|
||||
>(
|
||||
({ datasources }) =>
|
||||
datasources[formData.datasource]?.supports_drill_to_detail,
|
||||
);
|
||||
|
||||
const openModal = useCallback(
|
||||
(filters: BinaryQueryObjectFilterClause[], event: MouseEvent) => {
|
||||
onClick(event);
|
||||
@@ -158,7 +170,10 @@ export const useDrillDetailMenuItems = ({
|
||||
|
||||
let drillDisabled;
|
||||
let drillByDisabled;
|
||||
if (drillToDetailDisabled) {
|
||||
if (datasourceSupportsDrillToDetail === false) {
|
||||
drillDisabled = DISABLED_REASONS.DATASOURCE;
|
||||
drillByDisabled = DISABLED_REASONS.DATASOURCE;
|
||||
} else if (drillToDetailDisabled) {
|
||||
drillDisabled = DISABLED_REASONS.DATABASE;
|
||||
drillByDisabled = DISABLED_REASONS.DATABASE;
|
||||
} else if (handlesDimensionContextMenu) {
|
||||
|
||||
@@ -444,3 +444,45 @@ test('context menu renders <NULL> for null dimension values', async () => {
|
||||
await expectDrillToDetailByEnabled();
|
||||
await expectDrillToDetailByDimension(filterNull);
|
||||
});
|
||||
|
||||
const buildStateWithUnsupportedDatasource = () => {
|
||||
const baseState = getMockStoreWithNativeFilters().getState();
|
||||
const datasourceKey = defaultFormData.datasource as string;
|
||||
return {
|
||||
...baseState,
|
||||
datasources: {
|
||||
...baseState.datasources,
|
||||
[datasourceKey]: {
|
||||
...baseState.datasources[datasourceKey],
|
||||
supports_drill_to_detail: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
test('dropdown menu when datasource opts out via supports_drill_to_detail=false', async () => {
|
||||
cleanup();
|
||||
render(<MockRenderChart formData={defaultFormData} />, {
|
||||
useRouter: true,
|
||||
useRedux: true,
|
||||
initialState: buildStateWithUnsupportedDatasource(),
|
||||
});
|
||||
|
||||
await expectDrillToDetailDisabled(
|
||||
'Drill to detail is not available for this datasource type.',
|
||||
);
|
||||
await expectNoDrillToDetailBy();
|
||||
});
|
||||
|
||||
test('context menu when datasource opts out via supports_drill_to_detail=false', async () => {
|
||||
cleanup();
|
||||
render(<MockRenderChart formData={defaultFormData} isContextMenu />, {
|
||||
useRouter: true,
|
||||
useRedux: true,
|
||||
initialState: buildStateWithUnsupportedDatasource(),
|
||||
});
|
||||
|
||||
const message = 'Drill to detail is not available for this datasource type.';
|
||||
await expectDrillToDetailDisabled(message);
|
||||
await expectDrillToDetailByDisabled(message);
|
||||
});
|
||||
|
||||
@@ -62,6 +62,7 @@ export function ErrorMessageWithStackTrace({
|
||||
fallback,
|
||||
compact,
|
||||
closable = true,
|
||||
errorMitigationFunction,
|
||||
}: Props) {
|
||||
// Check if a custom error message component was registered for this message
|
||||
if (error) {
|
||||
@@ -77,6 +78,7 @@ export function ErrorMessageWithStackTrace({
|
||||
error={error}
|
||||
source={source}
|
||||
subtitle={subtitle}
|
||||
errorMitigationFunction={errorMitigationFunction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import * as reduxHooks from 'react-redux';
|
||||
import { Provider } from 'react-redux';
|
||||
import { createStore, Store } from 'redux';
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
import { act, render, waitFor } from 'spec/helpers/testing-library';
|
||||
import { ErrorLevel, ErrorSource, ErrorTypeEnum } from '@superset-ui/core';
|
||||
import { reRunQuery } from 'src/SqlLab/actions/sqlLab';
|
||||
import { triggerQuery } from 'src/components/Chart/chartAction';
|
||||
@@ -166,15 +166,55 @@ describe('OAuth2RedirectMessage Component', () => {
|
||||
render(setup());
|
||||
|
||||
simulateBroadcastMessage({ tabId: 'tabId' });
|
||||
simulateStorageMessage({ tabId: 'tabId' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(reRunQuery).toHaveBeenCalledWith({ sql: 'SELECT * FROM table' });
|
||||
});
|
||||
expect(reRunQuery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('dispatches reRunQuery action when storage event has matching tab ID', async () => {
|
||||
render(setup());
|
||||
|
||||
simulateStorageMessage({ tabId: 'tabId' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(reRunQuery).toHaveBeenCalledWith({ sql: 'SELECT * FROM table' });
|
||||
});
|
||||
});
|
||||
|
||||
test('dispatches reRunQuery action when storage event has matching tab ID', async () => {
|
||||
render(setup());
|
||||
test('waits for the SQL Lab query before consuming the completion', async () => {
|
||||
const initialState = {
|
||||
sqlLab: {
|
||||
queries: {},
|
||||
queryEditors: [{ id: 'editor-id', latestQueryId: 'query-id' }],
|
||||
tabHistory: ['editor-id'],
|
||||
},
|
||||
explore: { slice: null },
|
||||
charts: {},
|
||||
dashboardInfo: {},
|
||||
};
|
||||
const delayedQueryStore = createStore(
|
||||
(state: typeof initialState = initialState, action) =>
|
||||
action.type === 'load-query'
|
||||
? {
|
||||
...state,
|
||||
sqlLab: {
|
||||
...state.sqlLab,
|
||||
queries: { 'query-id': { sql: 'SELECT * FROM table' } },
|
||||
},
|
||||
}
|
||||
: state,
|
||||
);
|
||||
render(setup({}, delayedQueryStore));
|
||||
|
||||
simulateBroadcastMessage({ tabId: 'tabId' });
|
||||
expect(reRunQuery).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
delayedQueryStore.dispatch({ type: 'load-query' });
|
||||
});
|
||||
simulateStorageMessage({ tabId: 'tabId' });
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -234,4 +274,22 @@ describe('OAuth2RedirectMessage Component', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('runs scoped mitigation once instead of CRUD invalidation', async () => {
|
||||
const errorMitigationFunction = jest.fn();
|
||||
render(
|
||||
setup({
|
||||
source: 'crud' as ErrorSource,
|
||||
errorMitigationFunction,
|
||||
}),
|
||||
);
|
||||
|
||||
simulateBroadcastMessage({ tabId: 'tabId' });
|
||||
simulateStorageMessage({ tabId: 'tabId' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(errorMitigationFunction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(api.util.invalidateTags).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
|
||||
@@ -58,15 +58,16 @@ interface OAuth2RedirectExtra {
|
||||
*
|
||||
* After the token has been stored, the opened tab will broadcast a message to the
|
||||
* original tab and close itself. This component, running on the original tab, listens
|
||||
* on a same-origin BroadcastChannel and re-runs the query for the user once it
|
||||
* receives the success message — be it in SQL Lab, Explore, or a dashboard. Both tabs
|
||||
* share a "tab ID" (a UUID generated by the backend) which is echoed back through the
|
||||
* channel so the original tab only reacts to its own OAuth2 flow.
|
||||
* for same-origin BroadcastChannel and storage notifications and re-runs the query
|
||||
* for the user once it receives the success message — be it in SQL Lab, Explore, or
|
||||
* a dashboard. Both tabs share a "tab ID" (a UUID generated by the backend) which is
|
||||
* echoed back so the original tab only reacts to its own OAuth2 flow.
|
||||
*/
|
||||
export function OAuth2RedirectMessage({
|
||||
error,
|
||||
source,
|
||||
closable,
|
||||
errorMitigationFunction,
|
||||
}: ErrorMessageComponentProps<OAuth2RedirectExtra>) {
|
||||
const { extra, level } = error;
|
||||
|
||||
@@ -103,13 +104,17 @@ export function OAuth2RedirectMessage({
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const lastHandledTabIdRef = useRef<string>();
|
||||
|
||||
useEffect(() => {
|
||||
const handleOAuthComplete = (tabId?: string) => {
|
||||
if (tabId !== extra.tab_id) {
|
||||
if (tabId !== extra.tab_id || tabId === lastHandledTabIdRef.current) {
|
||||
return;
|
||||
}
|
||||
if (source === 'sqllab' && query) {
|
||||
|
||||
if (errorMitigationFunction) {
|
||||
errorMitigationFunction();
|
||||
} else if (source === 'sqllab' && query) {
|
||||
dispatch(reRunQuery(query));
|
||||
} else if (source === 'explore') {
|
||||
dispatch(triggerQuery(true, chartId));
|
||||
@@ -123,7 +128,11 @@ export function OAuth2RedirectMessage({
|
||||
'Tables',
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
lastHandledTabIdRef.current = tabId;
|
||||
};
|
||||
|
||||
const channel =
|
||||
@@ -156,7 +165,16 @@ export function OAuth2RedirectMessage({
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
channel?.close();
|
||||
};
|
||||
}, [source, extra.tab_id, dispatch, query, chartId, chartList, dashboardId]);
|
||||
}, [
|
||||
source,
|
||||
extra.tab_id,
|
||||
dispatch,
|
||||
query,
|
||||
chartId,
|
||||
chartList,
|
||||
dashboardId,
|
||||
errorMitigationFunction,
|
||||
]);
|
||||
|
||||
const body = (
|
||||
<p>
|
||||
|
||||
@@ -27,6 +27,7 @@ export type ErrorMessageComponentProps<ExtraType = Record<string, any> | null> =
|
||||
subtitle?: ReactNode;
|
||||
compact?: boolean;
|
||||
closable?: boolean;
|
||||
errorMitigationFunction?: () => void;
|
||||
};
|
||||
|
||||
export type ErrorMessageComponent = ComponentType<ErrorMessageComponentProps>;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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 { render, screen, fireEvent } from 'spec/helpers/testing-library';
|
||||
import DeleteComponentButton from './DeleteComponentButton';
|
||||
|
||||
test('exposes an accessible name without rendering visible label text', () => {
|
||||
render(<DeleteComponentButton onDelete={jest.fn()} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Delete component' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Delete component')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onDelete when clicked', () => {
|
||||
const onDelete = jest.fn();
|
||||
render(<DeleteComponentButton onDelete={onDelete} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete component' }));
|
||||
|
||||
expect(onDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
import { MouseEventHandler, FC } from 'react';
|
||||
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import type { IconType } from '@superset-ui/core/components/Icons/types';
|
||||
import IconButton from './IconButton';
|
||||
@@ -33,6 +34,8 @@ const DeleteComponentButton: FC<DeleteComponentButtonProps> = ({
|
||||
}) => (
|
||||
<IconButton
|
||||
onClick={onDelete}
|
||||
label={t('Delete component')}
|
||||
hideVisibleLabel
|
||||
icon={<Icons.DeleteOutlined iconSize={iconSize ?? 'l'} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -73,3 +73,17 @@ test('renders the provided label', () => {
|
||||
|
||||
expect(screen.getByText('My Label')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hideVisibleLabel suppresses visible text but keeps the accessible name', () => {
|
||||
render(
|
||||
<IconButton
|
||||
icon={icon}
|
||||
onClick={jest.fn()}
|
||||
label="My Label"
|
||||
hideVisibleLabel
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('My Label')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'My Label' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import { styled, SupersetTheme } from '@apache-superset/core/theme';
|
||||
interface IconButtonProps extends HTMLAttributes<HTMLButtonElement> {
|
||||
icon: JSX.Element;
|
||||
label?: string;
|
||||
hideVisibleLabel?: boolean;
|
||||
onClick: MouseEventHandler<HTMLButtonElement>;
|
||||
disabled?: boolean;
|
||||
'data-test'?: string;
|
||||
@@ -63,6 +64,7 @@ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
{
|
||||
icon,
|
||||
label,
|
||||
hideVisibleLabel,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
disabled,
|
||||
@@ -75,6 +77,7 @@ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
isDisabled={disabled}
|
||||
aria-disabled={disabled}
|
||||
data-test={dataTest}
|
||||
@@ -91,7 +94,7 @@ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label && <StyledSpan>{label}</StyledSpan>}
|
||||
{label && !hideVisibleLabel && <StyledSpan>{label}</StyledSpan>}
|
||||
</StyledButton>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -566,3 +566,31 @@ test('should pass filterState from dataMask to ChartContainer', () => {
|
||||
mockFilterState,
|
||||
);
|
||||
});
|
||||
|
||||
test('should pass chartStackTrace to ChartContainer so dashboard chart errors stay expandable', () => {
|
||||
// Regression guard for #31858: the dashboard chart wrapper stopped forwarding
|
||||
// the stack trace, so failed charts rendered a flat error with no "See more"
|
||||
// affordance while the same error in Explore stayed expandable.
|
||||
const stackTrace = 'Traceback (most recent call last): ValueError: boom';
|
||||
|
||||
setup(
|
||||
{},
|
||||
{
|
||||
...defaultState,
|
||||
charts: {
|
||||
...defaultState.charts,
|
||||
[queryId]: {
|
||||
...defaultState.charts[queryId],
|
||||
chartStatus: 'failed',
|
||||
chartAlert: 'Something went wrong',
|
||||
chartStackTrace: stackTrace,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(capturedChartContainerProps).toHaveProperty(
|
||||
'chartStackTrace',
|
||||
stackTrace,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -789,6 +789,7 @@ const Chart = (props: ChartProps) => {
|
||||
chartAlert={chart.chartAlert ?? undefined}
|
||||
chartId={props.id}
|
||||
chartStatus={chartStatus ?? undefined}
|
||||
chartStackTrace={chart.chartStackTrace ?? undefined}
|
||||
datasource={datasource}
|
||||
dashboardId={props.dashboardId}
|
||||
initialValues={EMPTY_OBJECT}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { fireEvent, render } from 'spec/helpers/testing-library';
|
||||
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
|
||||
|
||||
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
|
||||
import IconButton from 'src/dashboard/components/IconButton';
|
||||
@@ -200,6 +200,15 @@ test('should call deleteComponent when deleted', () => {
|
||||
expect(deleteComponent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('settings IconButton exposes an accessible name without visible label text', () => {
|
||||
setup({ component: columnWithoutChildren, editMode: true });
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Column settings' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Column settings')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should pass its own width as availableColumnCount to children', () => {
|
||||
const { getByTestId } = setup();
|
||||
expect(getByTestId('mock-dashboard-component')).toHaveTextContent(
|
||||
|
||||
@@ -247,6 +247,8 @@ const Column = (props: ColumnProps) => {
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => handleChangeFocus(true)}
|
||||
label={t('Column settings')}
|
||||
hideVisibleLabel
|
||||
icon={<Icons.SettingOutlined iconSize="m" />}
|
||||
/>
|
||||
</HoverMenu>
|
||||
|
||||
@@ -145,7 +145,9 @@ describe('Header', () => {
|
||||
const deleteComponent = jest.fn();
|
||||
setup({ editMode: true, deleteComponent });
|
||||
|
||||
const trashButton = screen.getByRole('button', { name: 'delete' });
|
||||
const trashButton = screen.getByRole('button', {
|
||||
name: 'Delete component',
|
||||
});
|
||||
fireEvent.click(trashButton);
|
||||
|
||||
expect(deleteComponent).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -240,6 +240,15 @@ test('should call deleteComponent when deleted', () => {
|
||||
expect(deleteComponent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('settings IconButton exposes an accessible name without visible label text', () => {
|
||||
setup({ component: rowWithoutChildren, editMode: true });
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Row settings' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Row settings')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should pass appropriate availableColumnCount to children', () => {
|
||||
const { getByTestId } = setup();
|
||||
expect(getByTestId('mock-dashboard-component')).toHaveTextContent(
|
||||
|
||||
@@ -293,6 +293,8 @@ const Row = memo((props: RowProps) => {
|
||||
<DeleteComponentButton onDelete={handleDeleteComponent} />
|
||||
<IconButton
|
||||
onClick={() => handleChangeFocus(true)}
|
||||
label={t('Row settings')}
|
||||
hideVisibleLabel
|
||||
icon={<Icons.SettingOutlined iconSize="l" />}
|
||||
/>
|
||||
</HoverMenu>
|
||||
|
||||
@@ -232,6 +232,10 @@ export type Datasource = Dataset & {
|
||||
// Populated by the dashboard datasets API alongside ``type``; declared here
|
||||
// so callers can rely on structural typing instead of casting.
|
||||
datasource_type?: DatasourceType;
|
||||
/** False when the datasource can't return row samples (e.g. semantic views). */
|
||||
supports_samples?: boolean;
|
||||
/** False when the datasource can't answer drill-to-detail requests. */
|
||||
supports_drill_to_detail?: boolean;
|
||||
};
|
||||
export type DatasourcesState = {
|
||||
[key: string]: Datasource;
|
||||
|
||||
@@ -241,25 +241,44 @@ export const DataTablesPane = ({
|
||||
}
|
||||
}, [resultsTabFallback]);
|
||||
|
||||
// Hide the Samples tab for datasources that don't expose raw rows
|
||||
// (e.g. semantic views). The check is intentionally ``=== false`` so that
|
||||
// datasources from older backends that don't send the flag still show the
|
||||
// tab and preserve current behavior.
|
||||
const showSamplesTab = datasource?.supports_samples !== false;
|
||||
|
||||
// If the datasource swaps to one that doesn't support samples while the
|
||||
// Samples tab is active (e.g. the user picks a semantic view), the tab
|
||||
// disappears from ``tabItems`` and ``activeTabKey`` is orphaned. Fall back
|
||||
// to Results so the panel keeps rendering content.
|
||||
useEffect(() => {
|
||||
if (!showSamplesTab && activeTabKey === ResultTypes.Samples) {
|
||||
setActiveTabKey(ResultTypes.Results);
|
||||
}
|
||||
}, [showSamplesTab, activeTabKey]);
|
||||
const tabItems = [
|
||||
...queryResultsPanes,
|
||||
{
|
||||
key: ResultTypes.Samples,
|
||||
label: t('Samples'),
|
||||
children: (
|
||||
<StyledDiv>
|
||||
<SamplesPane
|
||||
datasource={datasource}
|
||||
queryFormData={queryFormData}
|
||||
queryForce={queryForce}
|
||||
isRequest={isRequest.samples}
|
||||
setForceQuery={setForceQuery}
|
||||
isVisible={ResultTypes.Samples === activeTabKey}
|
||||
canDownload={canDownload}
|
||||
/>
|
||||
</StyledDiv>
|
||||
),
|
||||
},
|
||||
...(showSamplesTab
|
||||
? [
|
||||
{
|
||||
key: ResultTypes.Samples,
|
||||
label: t('Samples'),
|
||||
children: (
|
||||
<StyledDiv>
|
||||
<SamplesPane
|
||||
datasource={datasource}
|
||||
queryFormData={queryFormData}
|
||||
queryForce={queryForce}
|
||||
isRequest={isRequest.samples}
|
||||
setForceQuery={setForceQuery}
|
||||
isVisible={ResultTypes.Samples === activeTabKey}
|
||||
canDownload={canDownload}
|
||||
/>
|
||||
</StyledDiv>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,6 +21,7 @@ import { t } from '@apache-superset/core/translation';
|
||||
import { ensureIsArray } from '@superset-ui/core';
|
||||
import { datasetLabelLower } from 'src/features/semanticLayers/label';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { GridTable } from 'src/components/GridTable';
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
|
||||
import { SamplesPaneProps } from '../types';
|
||||
|
||||
const Error = styled.pre`
|
||||
const ErrorAlertWrapper = styled.div`
|
||||
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
|
||||
`;
|
||||
|
||||
@@ -155,7 +156,14 @@ export const SamplesPane = ({
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
<Error>{responseError}</Error>
|
||||
<ErrorAlertWrapper>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('Failed to load samples')}
|
||||
description={responseError}
|
||||
/>
|
||||
</ErrorAlertWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,13 +27,14 @@ import {
|
||||
QueryData,
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
import { ResultsPaneProps, QueryResultInterface } from '../types';
|
||||
import { SingleQueryResultPane } from './SingleQueryResultPane';
|
||||
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
|
||||
|
||||
const Error = styled.pre`
|
||||
const ErrorAlertWrapper = styled.div`
|
||||
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
|
||||
`;
|
||||
|
||||
@@ -199,7 +200,14 @@ export const useResultsPane = ({
|
||||
isLoading={false}
|
||||
canDownload={canDownload}
|
||||
/>
|
||||
<Error>{responseError}</Error>
|
||||
<ErrorAlertWrapper>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('Failed to load results')}
|
||||
description={responseError}
|
||||
/>
|
||||
</ErrorAlertWrapper>
|
||||
</>
|
||||
);
|
||||
return Array(queryCount).fill(err);
|
||||
|
||||
@@ -19,7 +19,12 @@
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { FeatureFlag } from '@superset-ui/core';
|
||||
import * as copyUtils from 'src/utils/copy';
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
|
||||
import { DataTablesPane } from '..';
|
||||
@@ -89,6 +94,48 @@ describe('DataTablesPane', () => {
|
||||
expect(await screen.findByLabelText('Collapse data panel')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Hides Samples tab when datasource opts out via supports_samples=false', async () => {
|
||||
const props = createDataTablesPaneProps(0);
|
||||
const propsWithoutSamples = {
|
||||
...props,
|
||||
datasource: { ...props.datasource, supports_samples: false },
|
||||
};
|
||||
render(<DataTablesPane {...propsWithoutSamples} />, { useRedux: true });
|
||||
expect(await screen.findByText('Results')).toBeVisible();
|
||||
expect(screen.queryByText('Samples')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Falls back to Results when active Samples tab disappears mid-session', async () => {
|
||||
// Regression for codeant Major finding on PR #41509: a datasource swap
|
||||
// that hides the Samples tab while it was the active tab used to leave
|
||||
// ``activeTabKey === 'samples'`` orphaned, rendering a blank panel.
|
||||
const props = createDataTablesPaneProps(0);
|
||||
const { rerender } = render(<DataTablesPane {...props} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Open the panel and pick the Samples tab.
|
||||
userEvent.click(screen.getByLabelText('Expand data panel'));
|
||||
userEvent.click(await screen.findByText('Samples'));
|
||||
expect(await screen.findByLabelText('Collapse data panel')).toBeVisible();
|
||||
|
||||
// Swap to a datasource that doesn't support samples (e.g. a semantic
|
||||
// view). The Samples tab should disappear and the panel should land on
|
||||
// Results with content still rendered.
|
||||
rerender(
|
||||
<DataTablesPane
|
||||
{...props}
|
||||
datasource={{ ...props.datasource, supports_samples: false }}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Samples')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Results')).toBeVisible();
|
||||
// Panel stays expanded and renders Results content rather than going blank.
|
||||
expect(screen.getByLabelText('Collapse data panel')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Should copy data table content correctly', async () => {
|
||||
fetchMock.post(
|
||||
'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D',
|
||||
|
||||
@@ -84,10 +84,14 @@ describe('SamplesPane', () => {
|
||||
const props = createSamplesPaneProps({
|
||||
datasourceId: 36,
|
||||
});
|
||||
const { findByText } = render(<SamplesPane {...props} />, {
|
||||
const { findByText, findByRole } = render(<SamplesPane {...props} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// The error is now rendered inside an Alert component, with a clear
|
||||
// headline message and the raw error text as the description.
|
||||
expect(await findByRole('alert')).toBeVisible();
|
||||
expect(await findByText('Failed to load samples')).toBeVisible();
|
||||
expect(await findByText('Error: Bad request')).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -74,6 +74,18 @@ export type Datasource = Dataset & {
|
||||
schema?: string;
|
||||
is_sqllab_view?: boolean;
|
||||
extra?: string | object;
|
||||
/**
|
||||
* False when the datasource (e.g. a semantic view) doesn't model raw rows
|
||||
* and therefore can't return a row sample. Defaults to true on the server
|
||||
* side; missing here means the explore UI keeps current behavior.
|
||||
*/
|
||||
supports_samples?: boolean;
|
||||
/**
|
||||
* False when the datasource doesn't model raw rows and therefore can't
|
||||
* answer a drill-to-detail query. Tracked separately from
|
||||
* ``supports_samples`` so the two capabilities can diverge.
|
||||
*/
|
||||
supports_drill_to_detail?: boolean;
|
||||
};
|
||||
|
||||
export interface ExplorePageInitialData {
|
||||
|
||||
@@ -35,6 +35,5 @@ export const Basic: StoryFn<typeof DatasetPanel> = args => (
|
||||
Basic.args = {
|
||||
tableName: 'example_table',
|
||||
loading: false,
|
||||
hasError: false,
|
||||
columnList: exampleColumns,
|
||||
};
|
||||
|
||||
@@ -77,7 +77,6 @@ test('View Dataset opens a single-prefixed URL under a subdirectory deployment',
|
||||
render(
|
||||
<DatasetPanel
|
||||
tableName="example_table"
|
||||
hasError={false}
|
||||
columnList={exampleColumns}
|
||||
loading={false}
|
||||
datasets={datasetWith(`${APP_ROOT}/explore/?datasource=1__table`)}
|
||||
@@ -97,7 +96,6 @@ test('View Dataset passes an external explore_url through unprefixed', async ()
|
||||
render(
|
||||
<DatasetPanel
|
||||
tableName="example_table"
|
||||
hasError={false}
|
||||
columnList={exampleColumns}
|
||||
loading={false}
|
||||
datasets={datasetWith('https://external.example.com/custom-endpoint')}
|
||||
|
||||
@@ -17,10 +17,12 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import { ErrorTypeEnum } from '@superset-ui/core';
|
||||
import DatasetPanel, {
|
||||
REFRESHING,
|
||||
tableColumnDefinition,
|
||||
COLUMN_TITLE,
|
||||
ERROR_TITLE,
|
||||
} from 'src/features/datasets/AddDataset/DatasetPanel/DatasetPanel';
|
||||
import { exampleColumns, exampleDataset } from './fixtures';
|
||||
import { ITableColumn } from './types';
|
||||
@@ -31,8 +33,6 @@ import {
|
||||
SELECT_TABLE_TITLE,
|
||||
NO_COLUMNS_TITLE,
|
||||
NO_COLUMNS_DESCRIPTION,
|
||||
ERROR_TITLE,
|
||||
ERROR_DESCRIPTION,
|
||||
} from './MessageContent';
|
||||
|
||||
jest.mock(
|
||||
@@ -47,7 +47,7 @@ jest.mock(
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('DatasetPanel', () => {
|
||||
test('renders a blank state DatasetPanel', () => {
|
||||
render(<DatasetPanel hasError={false} columnList={[]} loading={false} />, {
|
||||
render(<DatasetPanel columnList={[]} loading={false} />, {
|
||||
useRouter: true,
|
||||
});
|
||||
|
||||
@@ -70,17 +70,9 @@ describe('DatasetPanel', () => {
|
||||
});
|
||||
|
||||
test('renders a no columns screen', () => {
|
||||
render(
|
||||
<DatasetPanel
|
||||
tableName="Name"
|
||||
hasError={false}
|
||||
columnList={[]}
|
||||
loading={false}
|
||||
/>,
|
||||
{
|
||||
useRouter: true,
|
||||
},
|
||||
);
|
||||
render(<DatasetPanel tableName="Name" columnList={[]} loading={false} />, {
|
||||
useRouter: true,
|
||||
});
|
||||
|
||||
const blankDatasetImg = screen.getByRole('img', { name: /empty/i });
|
||||
expect(blankDatasetImg).toBeVisible();
|
||||
@@ -91,17 +83,9 @@ describe('DatasetPanel', () => {
|
||||
});
|
||||
|
||||
test('renders a loading screen', () => {
|
||||
render(
|
||||
<DatasetPanel
|
||||
tableName="Name"
|
||||
hasError={false}
|
||||
columnList={[]}
|
||||
loading
|
||||
/>,
|
||||
{
|
||||
useRouter: true,
|
||||
},
|
||||
);
|
||||
render(<DatasetPanel tableName="Name" columnList={[]} loading />, {
|
||||
useRouter: true,
|
||||
});
|
||||
|
||||
const loadingIndicator = screen.getByTestId('loading-indicator');
|
||||
expect(loadingIndicator).toBeVisible();
|
||||
@@ -113,7 +97,12 @@ describe('DatasetPanel', () => {
|
||||
render(
|
||||
<DatasetPanel
|
||||
tableName="Name"
|
||||
hasError
|
||||
error={{
|
||||
error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR,
|
||||
extra: null,
|
||||
level: 'error',
|
||||
message: 'Structured backend failure',
|
||||
}}
|
||||
columnList={[]}
|
||||
loading={false}
|
||||
/>,
|
||||
@@ -124,8 +113,9 @@ describe('DatasetPanel', () => {
|
||||
|
||||
const errorTitle = screen.getByText(ERROR_TITLE);
|
||||
expect(errorTitle).toBeVisible();
|
||||
const errorDescription = screen.getByText(ERROR_DESCRIPTION);
|
||||
const errorDescription = screen.getByText('Structured backend failure');
|
||||
expect(errorDescription).toBeVisible();
|
||||
expect(screen.getByTitle('Name')).toHaveStyle({ position: 'relative' });
|
||||
});
|
||||
|
||||
test('renders a table with columns displayed', async () => {
|
||||
@@ -133,7 +123,6 @@ describe('DatasetPanel', () => {
|
||||
render(
|
||||
<DatasetPanel
|
||||
tableName={tableName}
|
||||
hasError={false}
|
||||
columnList={exampleColumns}
|
||||
loading={false}
|
||||
/>,
|
||||
@@ -159,7 +148,6 @@ describe('DatasetPanel', () => {
|
||||
render(
|
||||
<DatasetPanel
|
||||
tableName="example_table"
|
||||
hasError={false}
|
||||
columnList={exampleColumns}
|
||||
loading={false}
|
||||
datasets={exampleDataset}
|
||||
|
||||
@@ -21,11 +21,13 @@ import { Alert } from '@apache-superset/core/components';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { Loading } from '@superset-ui/core/components';
|
||||
import type { SupersetError } from '@superset-ui/core';
|
||||
import Table, {
|
||||
ColumnsType,
|
||||
TableSize,
|
||||
} from '@superset-ui/core/components/Table';
|
||||
import { DatasetObject } from 'src/features/datasets/AddDataset/types';
|
||||
import { ErrorMessageWithStackTrace } from 'src/components';
|
||||
import { openInNewTab, stripAppRoot } from 'src/utils/navigationUtils';
|
||||
import { ITableColumn } from './types';
|
||||
import MessageContent from './MessageContent';
|
||||
@@ -146,6 +148,10 @@ const TableScrollContainer = styled.div`
|
||||
right: 0;
|
||||
`;
|
||||
|
||||
const ErrorContainer = styled.div`
|
||||
padding: 0 ${({ theme }) => theme.sizeUnit * 6}px;
|
||||
`;
|
||||
|
||||
const StyledAlert = styled(Alert)`
|
||||
${({ theme }) => `
|
||||
border: 1px solid ${theme.colorInfoText};
|
||||
@@ -167,6 +173,7 @@ const StyledAlert = styled(Alert)`
|
||||
|
||||
export const REFRESHING = t('Refreshing columns');
|
||||
export const COLUMN_TITLE = t('Table columns');
|
||||
export const ERROR_TITLE = t('An Error Occurred');
|
||||
|
||||
const pageSizeOptions = ['5', '10', '15', '25'];
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
@@ -201,9 +208,13 @@ export interface IDatasetPanelProps {
|
||||
*/
|
||||
columnList: ITableColumn[];
|
||||
/**
|
||||
* Boolean indicating if there is an error state
|
||||
* Error returned while loading the table metadata
|
||||
*/
|
||||
hasError: boolean;
|
||||
error?: SupersetError;
|
||||
/**
|
||||
* Function used to retry loading the table metadata after error mitigation
|
||||
*/
|
||||
errorMitigationFunction?: () => void;
|
||||
/**
|
||||
* Boolean indicating if the component is in a loading state
|
||||
*/
|
||||
@@ -256,11 +267,11 @@ const DatasetPanel = ({
|
||||
tableName,
|
||||
columnList,
|
||||
loading,
|
||||
hasError,
|
||||
error,
|
||||
errorMitigationFunction,
|
||||
datasets,
|
||||
}: IDatasetPanelProps) => {
|
||||
const hasColumns = Boolean(columnList?.length > 0);
|
||||
const datasetNames = datasets?.map(dataset => dataset.table_name);
|
||||
const hasColumns = columnList.length > 0;
|
||||
const tableWithDataset = datasets?.find(
|
||||
dataset => dataset.table_name === tableName,
|
||||
);
|
||||
@@ -278,7 +289,19 @@ const DatasetPanel = ({
|
||||
);
|
||||
}
|
||||
if (!loading) {
|
||||
if (!loading && tableName && hasColumns && !hasError) {
|
||||
if (error) {
|
||||
component = (
|
||||
<ErrorContainer>
|
||||
<ErrorMessageWithStackTrace
|
||||
error={error}
|
||||
errorMitigationFunction={errorMitigationFunction}
|
||||
source="crud"
|
||||
subtitle={error.message}
|
||||
title={ERROR_TITLE}
|
||||
/>
|
||||
</ErrorContainer>
|
||||
);
|
||||
} else if (tableName && hasColumns) {
|
||||
component = (
|
||||
<>
|
||||
<StyledTitle title={COLUMN_TITLE}>{COLUMN_TITLE}</StyledTitle>
|
||||
@@ -312,13 +335,7 @@ const DatasetPanel = ({
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
component = (
|
||||
<MessageContent
|
||||
hasColumns={hasColumns}
|
||||
hasError={hasError}
|
||||
tableName={tableName}
|
||||
/>
|
||||
);
|
||||
component = <MessageContent tableName={tableName} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,11 +343,12 @@ const DatasetPanel = ({
|
||||
<>
|
||||
{tableName && (
|
||||
<>
|
||||
{datasetNames?.includes(tableName) &&
|
||||
renderExistingDatasetAlert(tableWithDataset)}
|
||||
{tableWithDataset && renderExistingDatasetAlert(tableWithDataset)}
|
||||
<StyledHeader
|
||||
position={
|
||||
!loading && hasColumns ? EPosition.RELATIVE : EPosition.ABSOLUTE
|
||||
!loading && (hasColumns || error)
|
||||
? EPosition.RELATIVE
|
||||
: EPosition.ABSOLUTE
|
||||
}
|
||||
title={tableName || ''}
|
||||
>
|
||||
|
||||
@@ -16,8 +16,14 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { act, render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import { ErrorTypeEnum, SupersetClient } from '@superset-ui/core';
|
||||
import type { SupersetClientResponse } from '@superset-ui/core';
|
||||
import {
|
||||
DatabaseErrorMessage,
|
||||
getErrorMessageComponentRegistry,
|
||||
OAuth2RedirectMessage,
|
||||
} from 'src/components/ErrorMessage';
|
||||
import DatasetPanelWrapper from 'src/features/datasets/AddDataset/DatasetPanel';
|
||||
|
||||
jest.mock(
|
||||
@@ -29,17 +35,29 @@ jest.mock(
|
||||
),
|
||||
);
|
||||
|
||||
const errorMessageRegistry = getErrorMessageComponentRegistry();
|
||||
|
||||
afterEach(() => {
|
||||
errorMessageRegistry.remove(ErrorTypeEnum.GENERIC_BACKEND_ERROR);
|
||||
errorMessageRegistry.remove(ErrorTypeEnum.OAUTH2_REDIRECT);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const tableMetadataResponse = (
|
||||
name: string,
|
||||
columnName: string,
|
||||
): SupersetClientResponse => ({
|
||||
response: new Response(),
|
||||
json: {
|
||||
name,
|
||||
columns: [{ name: columnName, type: 'INTEGER', longType: 'INTEGER' }],
|
||||
},
|
||||
});
|
||||
|
||||
test('fetches table metadata for schema-less database without schema', async () => {
|
||||
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
|
||||
json: {
|
||||
name: 'my_table',
|
||||
columns: [{ name: 'id', type: 'INTEGER', longType: 'INTEGER' }],
|
||||
},
|
||||
} as any);
|
||||
const getSpy = jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockResolvedValue(tableMetadataResponse('my_table', 'id'));
|
||||
|
||||
render(
|
||||
<DatasetPanelWrapper
|
||||
@@ -58,3 +76,99 @@ test('fetches table metadata for schema-less database without schema', async ()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('renders a fallback message for an unstructured metadata error', async () => {
|
||||
jest.spyOn(SupersetClient, 'get').mockRejectedValue({
|
||||
response: new Response('{}', {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
});
|
||||
errorMessageRegistry.registerValue(
|
||||
ErrorTypeEnum.GENERIC_BACKEND_ERROR,
|
||||
DatabaseErrorMessage,
|
||||
);
|
||||
|
||||
render(
|
||||
<DatasetPanelWrapper
|
||||
tableName="broken_table"
|
||||
dbId={1}
|
||||
database={{ supports_schemas: false }}
|
||||
/>,
|
||||
{ useRouter: true },
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText('Unable to load columns for the selected table.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('retries only table metadata after matching OAuth completion', async () => {
|
||||
const oauthError = {
|
||||
error_type: ErrorTypeEnum.OAUTH2_REDIRECT,
|
||||
message: 'OAuth authorization is required.',
|
||||
extra: {
|
||||
url: 'https://example.com/authorize',
|
||||
tab_id: 'dataset-oauth-tab',
|
||||
},
|
||||
level: 'warning',
|
||||
};
|
||||
const getSpy = jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockRejectedValueOnce({
|
||||
response: new Response(JSON.stringify({ errors: [oauthError] }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce(tableMetadataResponse('oauth_table', 'oauth_id'));
|
||||
|
||||
errorMessageRegistry.registerValue(
|
||||
ErrorTypeEnum.OAUTH2_REDIRECT,
|
||||
OAuth2RedirectMessage,
|
||||
);
|
||||
|
||||
render(
|
||||
<DatasetPanelWrapper
|
||||
tableName="oauth_table"
|
||||
dbId={1}
|
||||
database={{ supports_schemas: false }}
|
||||
/>,
|
||||
{
|
||||
initialState: {
|
||||
charts: {},
|
||||
dashboardInfo: {},
|
||||
explore: {},
|
||||
sqlLab: {
|
||||
queries: {},
|
||||
queryEditors: [],
|
||||
tabHistory: [],
|
||||
},
|
||||
},
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
},
|
||||
);
|
||||
|
||||
const authorizationLink = await screen.findByRole('link', {
|
||||
name: /provide authorization/i,
|
||||
});
|
||||
expect(authorizationLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://example.com/authorize',
|
||||
);
|
||||
expect(getSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: 'oauth2_auth_complete',
|
||||
newValue: JSON.stringify({ tabId: 'dataset-oauth-tab' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(await screen.findByText('oauth_id')).toBeVisible();
|
||||
expect(getSpy).toHaveBeenCalledTimes(2);
|
||||
expect(getSpy.mock.calls[1]).toEqual(getSpy.mock.calls[0]);
|
||||
});
|
||||
|
||||
@@ -65,27 +65,17 @@ export const NO_COLUMNS_TITLE = t('No table columns');
|
||||
export const NO_COLUMNS_DESCRIPTION = t(
|
||||
'This database table does not contain any data. Please select a different table.',
|
||||
);
|
||||
export const ERROR_TITLE = t('An Error Occurred');
|
||||
export const ERROR_DESCRIPTION = t(
|
||||
'Unable to load columns for the selected table. Please select a different table.',
|
||||
);
|
||||
|
||||
interface MessageContentProps {
|
||||
hasError: boolean;
|
||||
tableName?: string | null;
|
||||
hasColumns: boolean;
|
||||
}
|
||||
|
||||
export const MessageContent = (props: MessageContentProps) => {
|
||||
const { hasError, tableName, hasColumns } = props;
|
||||
let currentImage: string | undefined = 'empty-dataset.svg';
|
||||
const { tableName } = props;
|
||||
let currentImage = 'empty-dataset.svg';
|
||||
let currentTitle = SELECT_TABLE_TITLE;
|
||||
let currentDescription = renderEmptyDescription();
|
||||
if (hasError) {
|
||||
currentTitle = ERROR_TITLE;
|
||||
currentDescription = <>{ERROR_DESCRIPTION}</>;
|
||||
currentImage = undefined;
|
||||
} else if (tableName && !hasColumns) {
|
||||
if (tableName) {
|
||||
currentImage = 'no-columns.svg';
|
||||
currentTitle = NO_COLUMNS_TITLE;
|
||||
currentDescription = <>{NO_COLUMNS_DESCRIPTION}</>;
|
||||
|
||||
@@ -16,9 +16,14 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import {
|
||||
ErrorTypeEnum,
|
||||
getClientErrorObject,
|
||||
SupersetClient,
|
||||
} from '@superset-ui/core';
|
||||
import type { SupersetError } from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { DatasetObject } from 'src/features/datasets/AddDataset/types';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
@@ -30,7 +35,7 @@ import { ITableColumn, IDatabaseTable, isIDatabaseTable } from './types';
|
||||
/**
|
||||
* Interface for the getTableMetadata API call
|
||||
*/
|
||||
interface IColumnProps {
|
||||
interface TableMetadataRequest {
|
||||
/**
|
||||
* Unique id of the database
|
||||
*/
|
||||
@@ -43,6 +48,10 @@ interface IColumnProps {
|
||||
* Name of the schema (optional for databases that don't support schemas)
|
||||
*/
|
||||
schema?: string | null;
|
||||
/**
|
||||
* Name of the catalog (optional for databases that don't support catalogs)
|
||||
*/
|
||||
catalog?: string | null;
|
||||
}
|
||||
|
||||
export interface IDatasetPanelWrapperProps {
|
||||
@@ -63,7 +72,7 @@ export interface IDatasetPanelWrapperProps {
|
||||
* The selected database object (used to check engine capabilities)
|
||||
*/
|
||||
database?: Partial<DatabaseObject> | null;
|
||||
setHasColumns?: Function;
|
||||
setHasColumns?: (hasColumns: boolean) => void;
|
||||
datasets?: DatasetObject[] | undefined;
|
||||
}
|
||||
|
||||
@@ -78,74 +87,131 @@ const DatasetPanelWrapper = ({
|
||||
}: IDatasetPanelWrapperProps) => {
|
||||
const [columnList, setColumnList] = useState<ITableColumn[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const tableNameRef = useRef(tableName);
|
||||
const [error, setError] = useState<SupersetError>();
|
||||
const requestIdRef = useRef(0);
|
||||
const currentRequestRef = useRef<TableMetadataRequest>();
|
||||
const supportsSchemas = database?.supports_schemas;
|
||||
|
||||
const getTableMetadata = async (props: IColumnProps) => {
|
||||
const { dbId, tableName, schema } = props;
|
||||
setLoading(true);
|
||||
setHasColumns?.(false);
|
||||
const path = `/api/v1/database/${dbId}/table_metadata/${toQueryString({
|
||||
name: tableName,
|
||||
catalog,
|
||||
schema,
|
||||
})}`;
|
||||
try {
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: path,
|
||||
});
|
||||
const getTableMetadata = useCallback(
|
||||
async (props: TableMetadataRequest) => {
|
||||
const { dbId, tableName, catalog, schema } = props;
|
||||
requestIdRef.current += 1;
|
||||
const requestId = requestIdRef.current;
|
||||
setLoading(true);
|
||||
setColumnList([]);
|
||||
setError(undefined);
|
||||
setHasColumns?.(false);
|
||||
const path = `/api/v1/database/${dbId}/table_metadata/${toQueryString({
|
||||
name: tableName,
|
||||
catalog,
|
||||
schema,
|
||||
})}`;
|
||||
try {
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: path,
|
||||
});
|
||||
|
||||
if (isIDatabaseTable(response?.json)) {
|
||||
const table: IDatabaseTable = response.json as IDatabaseTable;
|
||||
/**
|
||||
* The user is able to click other table columns while the http call for last selected table column is made
|
||||
* This check ensures we process the response that matches the last selected table name and ignore the others
|
||||
*/
|
||||
if (table.name === tableNameRef.current) {
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const table = isIDatabaseTable(response?.json)
|
||||
? (response.json as IDatabaseTable)
|
||||
: undefined;
|
||||
if (table?.name === tableName) {
|
||||
setColumnList(table.columns);
|
||||
setHasColumns?.(table.columns.length > 0);
|
||||
setHasError(false);
|
||||
setError(undefined);
|
||||
} else {
|
||||
const message = t(
|
||||
'The API response from %s does not match the IDatabaseTable interface.',
|
||||
path,
|
||||
);
|
||||
setColumnList([]);
|
||||
setHasColumns?.(false);
|
||||
setError({
|
||||
error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR,
|
||||
extra: null,
|
||||
level: 'error',
|
||||
message,
|
||||
});
|
||||
addDangerToast(message);
|
||||
logging.error(message);
|
||||
}
|
||||
} else {
|
||||
setColumnList([]);
|
||||
setHasColumns?.(false);
|
||||
setHasError(true);
|
||||
addDangerToast(
|
||||
t(
|
||||
'The API response from %s does not match the IDatabaseTable interface.',
|
||||
path,
|
||||
),
|
||||
);
|
||||
logging.error(
|
||||
t(
|
||||
'The API response from %s does not match the IDatabaseTable interface.',
|
||||
path,
|
||||
),
|
||||
} catch (caughtError) {
|
||||
const clientError = await getClientErrorObject(
|
||||
caughtError as Parameters<typeof getClientErrorObject>[0],
|
||||
);
|
||||
|
||||
if (requestId === requestIdRef.current) {
|
||||
const parsedError = clientError.errors?.[0] ?? {
|
||||
error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR,
|
||||
extra: null,
|
||||
level: 'error' as const,
|
||||
message:
|
||||
clientError.error ||
|
||||
clientError.message ||
|
||||
clientError.statusText ||
|
||||
t('Unable to load columns for the selected table.'),
|
||||
};
|
||||
|
||||
setColumnList([]);
|
||||
setHasColumns?.(false);
|
||||
setError(parsedError);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setColumnList([]);
|
||||
setHasColumns?.(false);
|
||||
setHasError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
},
|
||||
[setHasColumns],
|
||||
);
|
||||
|
||||
const retryGetTableMetadata = useCallback(() => {
|
||||
if (currentRequestRef.current) {
|
||||
getTableMetadata(currentRequestRef.current);
|
||||
}
|
||||
};
|
||||
}, [getTableMetadata]);
|
||||
|
||||
useEffect(() => {
|
||||
tableNameRef.current = tableName;
|
||||
const schemaRequired = database?.supports_schemas !== false;
|
||||
const schemaRequired = supportsSchemas !== false;
|
||||
if (tableName && dbId && (schema || !schemaRequired)) {
|
||||
getTableMetadata({ tableName, dbId, schema: schema || undefined });
|
||||
const request = {
|
||||
tableName,
|
||||
dbId,
|
||||
catalog,
|
||||
schema: schema || undefined,
|
||||
};
|
||||
currentRequestRef.current = request;
|
||||
getTableMetadata(request);
|
||||
} else if (currentRequestRef.current) {
|
||||
currentRequestRef.current = undefined;
|
||||
requestIdRef.current += 1;
|
||||
setColumnList([]);
|
||||
setError(undefined);
|
||||
setHasColumns?.(false);
|
||||
setLoading(false);
|
||||
}
|
||||
// getTableMetadata is a const and should not be in dependency array
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tableName, dbId, schema, database]);
|
||||
|
||||
return () => {
|
||||
requestIdRef.current += 1;
|
||||
};
|
||||
}, [
|
||||
tableName,
|
||||
dbId,
|
||||
catalog,
|
||||
schema,
|
||||
supportsSchemas,
|
||||
getTableMetadata,
|
||||
setHasColumns,
|
||||
]);
|
||||
|
||||
return (
|
||||
<DatasetPanel
|
||||
columnList={columnList}
|
||||
hasError={hasError}
|
||||
error={error}
|
||||
errorMitigationFunction={retryGetTableMetadata}
|
||||
loading={loading}
|
||||
tableName={tableName}
|
||||
datasets={datasets}
|
||||
|
||||
@@ -138,7 +138,6 @@ describe('DatasetLayout', () => {
|
||||
<DatasetPanelComponent
|
||||
tableName="large_table"
|
||||
columnList={manyColumns}
|
||||
hasError={false}
|
||||
loading={false}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from croniter import croniter
|
||||
from croniter import croniter, CroniterBadDateError
|
||||
from flask import current_app as app
|
||||
from flask_babel import gettext as _
|
||||
from marshmallow import ValidationError
|
||||
@@ -29,6 +29,7 @@ from superset.commands.report.exceptions import (
|
||||
ChartNotSavedValidationError,
|
||||
DashboardNotFoundValidationError,
|
||||
DashboardNotSavedValidationError,
|
||||
ReportScheduleCrontabNotValidError,
|
||||
ReportScheduleEitherChartOrDashboardError,
|
||||
ReportScheduleForbiddenError,
|
||||
ReportScheduleFrequencyNotAllowed,
|
||||
@@ -288,13 +289,18 @@ class BaseReportScheduleCommand(BaseCommand):
|
||||
return
|
||||
|
||||
iterations = 60 if minimum_interval <= 3660 else 24
|
||||
schedule = croniter(cron_schedule)
|
||||
current_exec = next(schedule)
|
||||
try:
|
||||
schedule = croniter(cron_schedule)
|
||||
current_exec = next(schedule)
|
||||
|
||||
for _i in range(iterations):
|
||||
next_exec = next(schedule)
|
||||
diff, current_exec = next_exec - current_exec, next_exec
|
||||
if int(diff) < minimum_interval:
|
||||
raise ReportScheduleFrequencyNotAllowed(
|
||||
report_type=report_type, minimum_interval=minimum_interval
|
||||
)
|
||||
for _i in range(iterations):
|
||||
next_exec = next(schedule)
|
||||
diff, current_exec = next_exec - current_exec, next_exec
|
||||
if int(diff) < minimum_interval:
|
||||
raise ReportScheduleFrequencyNotAllowed(
|
||||
report_type=report_type, minimum_interval=minimum_interval
|
||||
)
|
||||
except CroniterBadDateError as ex:
|
||||
raise ReportScheduleCrontabNotValidError(
|
||||
cron_schedule=cron_schedule
|
||||
) from ex
|
||||
|
||||
@@ -133,6 +133,23 @@ class ReportScheduleFrequencyNotAllowed(ValidationError): # noqa: N818
|
||||
)
|
||||
|
||||
|
||||
class ReportScheduleCrontabNotValidError(ValidationError): # noqa: N818
|
||||
"""
|
||||
Marshmallow validation error for a crontab that is syntactically valid
|
||||
but never matches a real calendar date (e.g. February 30th)
|
||||
"""
|
||||
|
||||
def __init__(self, cron_schedule: str = "") -> None:
|
||||
super().__init__(
|
||||
_(
|
||||
"Invalid crontab schedule: %(cron_schedule)s never matches"
|
||||
" a valid date",
|
||||
cron_schedule=cron_schedule,
|
||||
),
|
||||
field_name="crontab",
|
||||
)
|
||||
|
||||
|
||||
class ChartNotSavedValidationError(ValidationError):
|
||||
"""
|
||||
Marshmallow validation error for charts that haven't been saved yet
|
||||
|
||||
@@ -235,6 +235,15 @@ def _get_drill_detail(
|
||||
# todo(yongjie): Remove this function,
|
||||
# when determining whether samples should be applied to the time filter.
|
||||
datasource = _get_datasource(query_context, query_obj)
|
||||
# Refuse for datasource types that don't model raw rows (e.g. semantic
|
||||
# views). Mirrors the ``supports_samples`` gate on the ``/samples``
|
||||
# endpoint so drill-detail is hard-blocked on the backend, not just
|
||||
# hidden in the frontend menu. Defaults to ``True`` for any datasource
|
||||
# class that doesn't explicitly opt out.
|
||||
if not getattr(datasource, "supports_drill_to_detail", True):
|
||||
raise QueryObjectValidationError(
|
||||
_("Drill to detail is not available for this datasource type.")
|
||||
)
|
||||
query_obj = copy.copy(query_obj)
|
||||
query_obj.is_timeseries = False
|
||||
query_obj.metrics = None
|
||||
|
||||
@@ -193,6 +193,16 @@ class BaseDatasource(
|
||||
# Only some datasources support Row Level Security
|
||||
is_rls_supported: bool = False
|
||||
|
||||
# Datasources that can return raw row samples (anything backed by a SQL
|
||||
# table can; semantic-layer abstractions cannot, since they only expose
|
||||
# pre-defined metrics and dimensions).
|
||||
supports_samples: bool = True
|
||||
|
||||
# Datasources that can answer "drill to detail" requests — i.e. fetch the
|
||||
# raw rows underlying a chart cell. Conceptually similar to ``samples``
|
||||
# but kept as a separate capability so the two can diverge.
|
||||
supports_drill_to_detail: bool = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
# can be a Column or a property pointing to one
|
||||
@@ -486,6 +496,8 @@ class BaseDatasource(
|
||||
"order_by_choices": self.order_by_choices,
|
||||
"verbose_map": self.verbose_map,
|
||||
"select_star": self.select_star,
|
||||
"supports_samples": self.supports_samples,
|
||||
"supports_drill_to_detail": self.supports_drill_to_detail,
|
||||
}
|
||||
|
||||
def data_for_slices( # pylint: disable=too-many-locals # noqa: C901
|
||||
|
||||
@@ -113,6 +113,9 @@ class PinotEngineSpec(BaseEngineSpec):
|
||||
) -> str:
|
||||
# Pinot driver infers TIMESTAMP column as LONG, so make the quick fix.
|
||||
# When the Pinot driver fix this bug, current method could be removed.
|
||||
#
|
||||
# TODO: remove this override once startreedata/pinot-dbapi#224 is
|
||||
# merged and released, and pinotdb is bumped past that version.
|
||||
if isinstance(sqla_column_type, types.TIMESTAMP):
|
||||
return sqla_column_type.compile().upper()
|
||||
|
||||
|
||||
@@ -200,6 +200,12 @@ class SemanticView(AuditMixinNullable, Model):
|
||||
|
||||
__tablename__ = "semantic_views"
|
||||
|
||||
# Semantic views expose pre-defined metrics and dimensions, not raw rows,
|
||||
# so neither the "Samples" tab in Explore nor the "Drill to detail"
|
||||
# affordance from the chart 3-dots menu can return anything meaningful.
|
||||
supports_samples: bool = False
|
||||
supports_drill_to_detail: bool = False
|
||||
|
||||
# Use integer as the primary key for cross-database auto-increment
|
||||
# compatibility (sa.Identity() is not supported in MySQL or SQLite).
|
||||
# The uuid column is a secondary unique identifier used in URLs and perms.
|
||||
@@ -425,6 +431,8 @@ class SemanticView(AuditMixinNullable, Model):
|
||||
"sql": None,
|
||||
"select_star": None,
|
||||
"editors": [],
|
||||
"supports_samples": self.supports_samples,
|
||||
"supports_drill_to_detail": self.supports_drill_to_detail,
|
||||
"description": self.description,
|
||||
"table_name": self.name,
|
||||
"column_types": [
|
||||
|
||||
@@ -346,6 +346,11 @@ class ExplorableData(TypedDict, total=False):
|
||||
always_filter_main_dttm: bool
|
||||
normalize_columns: bool
|
||||
rls_filters: list[dict[str, Any]]
|
||||
# Set by datasources that cannot return raw row samples (e.g. semantic
|
||||
# views, which only expose pre-defined metrics and dimensions).
|
||||
supports_samples: bool
|
||||
# Set by datasources that cannot answer drill-to-detail requests.
|
||||
supports_drill_to_detail: bool
|
||||
|
||||
|
||||
VizData: TypeAlias = list[Any] | dict[Any, Any] | None
|
||||
|
||||
@@ -223,7 +223,7 @@ msgstr "% du total"
|
||||
|
||||
#, python-format
|
||||
msgid "%(alertType)s \"%(alertName)s\" triggered successfully"
|
||||
msgstr ""
|
||||
msgstr "%(alertType)s « %(alertName)s » déclenché avec succès"
|
||||
|
||||
#, python-format
|
||||
msgid "%(dialect)s cannot be used as a data source for security reasons."
|
||||
@@ -1390,10 +1390,10 @@ msgstr ""
|
||||
" ou négative par rapport à la valeur de comparaison."
|
||||
|
||||
msgid "Adhoc metric SQL expression is invalid"
|
||||
msgstr ""
|
||||
msgstr "L'expression SQL de la mesure ad hoc est invalide"
|
||||
|
||||
msgid "Adhoc metric aggregate is invalid"
|
||||
msgstr ""
|
||||
msgstr "L'agrégat de la mesure ad hoc est invalide"
|
||||
|
||||
msgid "Adjust how this database will interact with SQL Lab."
|
||||
msgstr "Ajuster la façon dont cette base de données interagira avec SQL Lab."
|
||||
@@ -2018,6 +2018,8 @@ msgid ""
|
||||
"Angle at which the first slice begins, in degrees. 90° starts at the top,"
|
||||
" 0°/360° at the right, 270° at the bottom, and 180° at the left."
|
||||
msgstr ""
|
||||
"Angle auquel commence la première part, en degrés. 90° démarre en haut, "
|
||||
"0°/360° à droite, 270° en bas et 180° à gauche."
|
||||
|
||||
msgid "Angle at which to end progress axis"
|
||||
msgstr "Angle de fin de l'axe de progression"
|
||||
@@ -2278,7 +2280,7 @@ msgid "Are you sure you want to delete the selected layers?"
|
||||
msgstr "Voulez-vous vraiment supprimer les couches sélectionnées?"
|
||||
|
||||
msgid "Are you sure you want to delete the selected queries?"
|
||||
msgstr ""
|
||||
msgstr "Voulez-vous vraiment supprimer les requêtes sélectionnées ?"
|
||||
|
||||
msgid "Are you sure you want to delete the selected roles?"
|
||||
msgstr "Voulez-vous vraiment supprimer les rôles sélectionnés ?"
|
||||
@@ -3527,7 +3529,7 @@ msgid "Clear local theme"
|
||||
msgstr "Supprimer le thème local"
|
||||
|
||||
msgid "Clear search"
|
||||
msgstr ""
|
||||
msgstr "Effacer la recherche"
|
||||
|
||||
msgid "Clear the selection to revert to the system default theme"
|
||||
msgstr "Effacer la sélection pour revenir au thème par défaut du système"
|
||||
@@ -4056,7 +4058,7 @@ msgid "Connection failed, please check your connection settings."
|
||||
msgstr "La connexion a échoué, veuillez vérifier vos paramètres de connexion"
|
||||
|
||||
msgid "Connection looks good!"
|
||||
msgstr ""
|
||||
msgstr "La connexion fonctionne !"
|
||||
|
||||
msgid "Contains"
|
||||
msgstr "Contient"
|
||||
@@ -4144,7 +4146,7 @@ msgid "Copy query"
|
||||
msgstr "Copier la requête"
|
||||
|
||||
msgid "Copy query URL"
|
||||
msgstr ""
|
||||
msgstr "Copier l'URL de la requête"
|
||||
|
||||
msgid "Copy query link to your clipboard"
|
||||
msgstr "Copier le lien de la requête vers le presse-papier"
|
||||
@@ -4274,6 +4276,8 @@ msgid ""
|
||||
"Create a new tag and assign it to existing entities like charts or "
|
||||
"dashboards"
|
||||
msgstr ""
|
||||
"Créer une balise et l'affecter à des entités existantes comme des "
|
||||
"graphiques ou des tableaux de bord"
|
||||
|
||||
msgid "Create and explore dataset"
|
||||
msgstr "Créer et explorer un jeu de données"
|
||||
@@ -4302,7 +4306,7 @@ msgstr "Créé par"
|
||||
msgid "Created by me"
|
||||
msgstr "Créé par moi"
|
||||
|
||||
, python-format
|
||||
#, python-format
|
||||
msgid "Created by: %s"
|
||||
msgstr "Créé par : %s"
|
||||
|
||||
@@ -4597,6 +4601,9 @@ msgid ""
|
||||
"Dashboard cannot be restored because its slug is now used by another "
|
||||
"active dashboard. Rename one of the dashboards and retry."
|
||||
msgstr ""
|
||||
"Le tableau de bord ne peut pas être restauré car son slug est désormais "
|
||||
"utilisé par un autre tableau de bord actif. Renommez l'un des deux "
|
||||
"tableaux de bord et réessayez."
|
||||
|
||||
msgid "Dashboard cannot be unfavorited."
|
||||
msgstr "Le tableau de bord n'a pas pu être retiré des favoris."
|
||||
@@ -5283,7 +5290,7 @@ msgid "Delete item"
|
||||
msgstr "Supprimer l'élément"
|
||||
|
||||
msgid "Delete query"
|
||||
msgstr ""
|
||||
msgstr "Supprimer la requête"
|
||||
|
||||
msgid "Delete role"
|
||||
msgstr "Supprimer le rôle"
|
||||
@@ -5595,6 +5602,9 @@ msgid ""
|
||||
"Display charts on a map. For using this plugin, users first have to "
|
||||
"create any other chart that can then be placed on the map."
|
||||
msgstr ""
|
||||
"Affiche des graphiques sur une carte. Pour utiliser ce module, il faut "
|
||||
"d'abord créer un autre graphique, qui pourra ensuite être placé sur la "
|
||||
"carte."
|
||||
|
||||
msgid "Display column in the chart"
|
||||
msgstr "Afficher la colonne dans le graphique"
|
||||
@@ -5990,7 +6000,7 @@ msgstr "ERREUR"
|
||||
|
||||
#, python-format
|
||||
msgid "ERROR: %s"
|
||||
msgstr ""
|
||||
msgstr "ERREUR : %s"
|
||||
|
||||
msgid "Edge length"
|
||||
msgstr "Longueur du bord"
|
||||
@@ -6082,7 +6092,7 @@ msgid "Edit properties"
|
||||
msgstr "Modifier les propriétés"
|
||||
|
||||
msgid "Edit query"
|
||||
msgstr ""
|
||||
msgstr "Modifier la requête"
|
||||
|
||||
msgid "Edit report"
|
||||
msgstr "Modifier le rapport"
|
||||
@@ -6172,7 +6182,7 @@ msgid "Email link"
|
||||
msgstr "Lien par courriel"
|
||||
|
||||
msgid "Email recipients"
|
||||
msgstr ""
|
||||
msgstr "Destinataires du courriel"
|
||||
|
||||
msgid "Email reports active"
|
||||
msgstr "Rapports par courriel actifs"
|
||||
@@ -6425,7 +6435,7 @@ msgid "Entity"
|
||||
msgstr "Entité"
|
||||
|
||||
msgid "Entries per page"
|
||||
msgstr ""
|
||||
msgstr "Entrées par page"
|
||||
|
||||
msgid "Equal Date Sizes"
|
||||
msgstr "Taille des dates égales"
|
||||
@@ -6693,7 +6703,7 @@ msgid "Export as Example"
|
||||
msgstr "Exporter comme exemple"
|
||||
|
||||
msgid "Export as PDF"
|
||||
msgstr ""
|
||||
msgstr "Exporter en PDF"
|
||||
|
||||
msgid "Export cancelled"
|
||||
msgstr "Export annulé"
|
||||
@@ -6712,7 +6722,7 @@ msgid "Export failed: %s"
|
||||
msgstr "Export échoué : %s"
|
||||
|
||||
msgid "Export query"
|
||||
msgstr ""
|
||||
msgstr "Exporter la requête"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: cs, de, ja,
|
||||
# lv, ro, ru, sk, sr, sr_Latn, tr, uk]
|
||||
@@ -6720,7 +6730,7 @@ msgid "Export screenshot (jpeg)"
|
||||
msgstr "Exporter la capture d'écran (jpeg)"
|
||||
|
||||
msgid "Export screenshot (png)"
|
||||
msgstr ""
|
||||
msgstr "Exporter la capture d'écran (png)"
|
||||
|
||||
#, python-format
|
||||
msgid "Export successful: %s"
|
||||
@@ -6764,11 +6774,15 @@ msgid ""
|
||||
"Exporting semantic views is not supported yet — %s semantic-view row(s) "
|
||||
"were skipped."
|
||||
msgstr ""
|
||||
"L'export des vues sémantiques n'est pas encore pris en charge — %s "
|
||||
"ligne(s) de vue sémantique ont été ignorées."
|
||||
|
||||
msgid ""
|
||||
"Exporting semantic views is not supported yet. Deselect the semantic-view"
|
||||
" rows and try again."
|
||||
msgstr ""
|
||||
"L'export des vues sémantiques n'est pas encore pris en charge. "
|
||||
"Désélectionnez les lignes de vue sémantique et réessayez."
|
||||
|
||||
msgid "Expose database in SQL Lab"
|
||||
msgstr "Exposer la base de données dans SQL Lab"
|
||||
@@ -6881,6 +6895,8 @@ msgid ""
|
||||
"Failed to export chart data. Please try again or contact your "
|
||||
"administrator."
|
||||
msgstr ""
|
||||
"Échec de l'export des données du graphique. Réessayez ou contactez votre "
|
||||
"administrateur."
|
||||
|
||||
msgid "Failed to fetch API keys"
|
||||
msgstr "Tout Dé-Sélectionner"
|
||||
@@ -6956,7 +6972,7 @@ msgstr "Échec du marquage des éléments"
|
||||
|
||||
#, python-format
|
||||
msgid "Failed to trigger %(alertType)s \"%(alertName)s\": %(error)s"
|
||||
msgstr ""
|
||||
msgstr "Échec du déclenchement de %(alertType)s « %(alertName)s » : %(error)s"
|
||||
|
||||
msgid "Failed to update report"
|
||||
msgstr "Échec de la mise à jour du rapport"
|
||||
@@ -7315,7 +7331,7 @@ msgid "Forecast periods"
|
||||
msgstr "Périodes de prévision"
|
||||
|
||||
msgid "Forecast requires at least 2 data points"
|
||||
msgstr ""
|
||||
msgstr "La prévision nécessite au moins 2 points de données"
|
||||
|
||||
msgid "Foreign key"
|
||||
msgstr "Clé étrangère"
|
||||
@@ -7371,10 +7387,10 @@ msgstr ""
|
||||
" sont présentes, le formatage revient aux nombres neutres."
|
||||
|
||||
msgid "Formatted CSV attached in email"
|
||||
msgstr "CSV formatté attaché dans le courriel"
|
||||
msgstr "Fichier CSV mis en forme joint au courriel"
|
||||
|
||||
msgid "Formatted Excel attached in email"
|
||||
msgstr ""
|
||||
msgstr "Fichier Excel mis en forme joint au courriel"
|
||||
|
||||
msgid "Formatted date"
|
||||
msgstr "Date formatée"
|
||||
@@ -8318,7 +8334,7 @@ msgid "Label for your query"
|
||||
msgstr "Label pour votre requête"
|
||||
|
||||
msgid "Label must not be empty."
|
||||
msgstr ""
|
||||
msgstr "L'étiquette ne doit pas être vide."
|
||||
|
||||
msgid "Label position"
|
||||
msgstr "Position de l'étiquette"
|
||||
@@ -8619,7 +8635,7 @@ msgid "Lines encoding"
|
||||
msgstr "Codage des lignes"
|
||||
|
||||
msgid "Link Copied!"
|
||||
msgstr ""
|
||||
msgstr "Lien copié !"
|
||||
|
||||
msgid "List"
|
||||
msgstr "Liste"
|
||||
@@ -9556,7 +9572,7 @@ msgstr ""
|
||||
"enregistrement temporel"
|
||||
|
||||
msgid "No data found"
|
||||
msgstr ""
|
||||
msgstr "Aucune donnée trouvée"
|
||||
|
||||
msgid "No data in file"
|
||||
msgstr "Pas de données dans le fichier"
|
||||
@@ -9972,7 +9988,7 @@ msgstr "Une ou plusieurs mesures n'existent pas"
|
||||
|
||||
#, python-format
|
||||
msgid "One or more parameters are missing: %(missing)s"
|
||||
msgstr ""
|
||||
msgstr "Un ou plusieurs paramètres sont manquants : %(missing)s"
|
||||
|
||||
msgid "One or more parameters needed to configure a database are missing."
|
||||
msgstr ""
|
||||
@@ -10675,6 +10691,11 @@ msgid ""
|
||||
"period (e.g. today so far) against complete prior periods (e.g. all of "
|
||||
"yesterday)."
|
||||
msgstr ""
|
||||
"Tracer chaque série décalée dans le temps sur toute sa plage temporelle "
|
||||
"au lieu de la tronquer à celle de la série principale. Utile pour "
|
||||
"comparer une période en cours partielle (par exemple aujourd'hui jusqu'à "
|
||||
"maintenant) à des périodes antérieures complètes (par exemple la journée "
|
||||
"d'hier entière)."
|
||||
|
||||
msgid "Plot the distance (like flight paths) between origin and destination."
|
||||
msgstr ""
|
||||
@@ -11135,6 +11156,12 @@ msgid ""
|
||||
"except the subjects defined in the filter, and can be used to define what"
|
||||
" users can see if no RLS filters within a filter group apply to them."
|
||||
msgstr ""
|
||||
"Les filtres classiques ajoutent des clauses WHERE aux requêtes lorsqu'un "
|
||||
"utilisateur correspond à un sujet référencé par le filtre. Les filtres de"
|
||||
" base appliquent des filtres à toutes les requêtes sauf pour les sujets "
|
||||
"définis dans le filtre ; ils permettent de définir ce que voient les "
|
||||
"utilisateurs auxquels aucun filtre RLS d'un groupe de filtres ne "
|
||||
"s'applique."
|
||||
|
||||
msgid "Relational"
|
||||
msgstr "Relationnel"
|
||||
@@ -11175,7 +11202,7 @@ msgid "Remove customization"
|
||||
msgstr "Supprimer la personnalisation"
|
||||
|
||||
msgid "Remove dependency"
|
||||
msgstr ""
|
||||
msgstr "Supprimer la dépendance"
|
||||
|
||||
msgid "Remove filter"
|
||||
msgstr "Supprimer le filtre"
|
||||
@@ -11184,13 +11211,13 @@ msgid "Remove item"
|
||||
msgstr "Supprimer l’élément"
|
||||
|
||||
msgid "Remove notification method"
|
||||
msgstr ""
|
||||
msgstr "Supprimer le mode de notification"
|
||||
|
||||
msgid "Remove query from log"
|
||||
msgstr "Supprimer la requête des journaux"
|
||||
|
||||
msgid "Remove sheet"
|
||||
msgstr ""
|
||||
msgstr "Supprimer la feuille"
|
||||
|
||||
#, python-format
|
||||
msgid "Removed 1 column from the virtual dataset"
|
||||
@@ -11234,7 +11261,7 @@ msgid "Report Schedule delete failed."
|
||||
msgstr "La planification de rapport n'a pas être supprimée."
|
||||
|
||||
msgid "Report Schedule execute now failed."
|
||||
msgstr ""
|
||||
msgstr "L'exécution immédiate de la planification de rapport a échoué."
|
||||
|
||||
msgid "Report Schedule execution failed when generating a csv."
|
||||
msgstr ""
|
||||
@@ -11258,6 +11285,8 @@ msgstr ""
|
||||
|
||||
msgid "Report Schedule execution failed when generating an Excel file."
|
||||
msgstr ""
|
||||
"L'exécution de la planification de rapport a échoué lors de la génération"
|
||||
" du fichier Excel."
|
||||
|
||||
msgid "Report Schedule execution got an unexpected error."
|
||||
msgstr ""
|
||||
@@ -11269,10 +11298,15 @@ msgid ""
|
||||
"Please configure a Celery broker (Redis or RabbitMQ) and worker "
|
||||
"processes."
|
||||
msgstr ""
|
||||
"L'exécution de la planification de rapport nécessite un backend Celery "
|
||||
"configuré. Configurez un broker Celery (Redis ou RabbitMQ) et des "
|
||||
"processus worker."
|
||||
|
||||
#, python-format
|
||||
msgid "Report Schedule executor user %(username)s was not found."
|
||||
msgstr ""
|
||||
"L'utilisateur %(username)s exécutant la planification de rapport est "
|
||||
"introuvable."
|
||||
|
||||
msgid "Report Schedule is still working, refusing to re-compute."
|
||||
msgstr ""
|
||||
@@ -11981,7 +12015,7 @@ msgid "Search Metrics & Columns"
|
||||
msgstr "Rechercher les mesures et les colonnes"
|
||||
|
||||
msgid "Search a channel by name, or paste a channel ID"
|
||||
msgstr ""
|
||||
msgstr "Rechercher un canal par son nom, ou coller un identifiant de canal"
|
||||
|
||||
msgid "Search all charts"
|
||||
msgstr "Rechercher tous les graphiques"
|
||||
@@ -12026,7 +12060,7 @@ msgid "Search owners"
|
||||
msgstr "Rechercher des propriétaires"
|
||||
|
||||
msgid "Search records"
|
||||
msgstr ""
|
||||
msgstr "Rechercher des enregistrements"
|
||||
|
||||
msgid "Search roles"
|
||||
msgstr "Recherche de rôles"
|
||||
@@ -12042,6 +12076,8 @@ msgstr "Rechercher…"
|
||||
|
||||
msgid "Searches all text fields: Name, Description, Database & Schema"
|
||||
msgstr ""
|
||||
"Recherche dans tous les champs texte : nom, description, base de données "
|
||||
"et schéma"
|
||||
|
||||
msgid "Second"
|
||||
msgstr "Seconde"
|
||||
@@ -12084,7 +12120,7 @@ msgid "See all %(tableName)s"
|
||||
msgstr "Voir tout %(tableName)s"
|
||||
|
||||
msgid "See all dashboards"
|
||||
msgstr ""
|
||||
msgstr "Voir tous les tableaux de bord"
|
||||
|
||||
msgid "See less"
|
||||
msgstr "Voir moins"
|
||||
@@ -12375,10 +12411,10 @@ msgid "Select operator"
|
||||
msgstr "Sélectionner l'opérateur"
|
||||
|
||||
msgid "Select or type BCC recipients"
|
||||
msgstr ""
|
||||
msgstr "Sélectionner ou saisir les destinataires en Cci"
|
||||
|
||||
msgid "Select or type CC recipients"
|
||||
msgstr ""
|
||||
msgstr "Sélectionner ou saisir les destinataires en Cc"
|
||||
|
||||
msgid "Select or type a custom value..."
|
||||
msgstr "Sélectionner ou renseigner une valeur personnalisé..."
|
||||
@@ -12390,7 +12426,7 @@ msgid "Select or type dataset name"
|
||||
msgstr "Sélectionner la base de données ou taper le nom du jeu de données"
|
||||
|
||||
msgid "Select or type email recipients"
|
||||
msgstr ""
|
||||
msgstr "Sélectionner ou saisir les destinataires du courriel"
|
||||
|
||||
msgid "Select page size"
|
||||
msgstr "Sélectionner la taille de la page"
|
||||
@@ -12639,7 +12675,7 @@ msgid "Send as CSV"
|
||||
msgstr "Envoyer comme CSV"
|
||||
|
||||
msgid "Send as Excel"
|
||||
msgstr ""
|
||||
msgstr "Envoyer comme Excel"
|
||||
|
||||
msgid "Send as PDF"
|
||||
msgstr "Envoyer comme PDF"
|
||||
@@ -12871,7 +12907,7 @@ msgid "Show Metric Names"
|
||||
msgstr "Afficher les noms de mesure"
|
||||
|
||||
msgid "Show Null Values"
|
||||
msgstr ""
|
||||
msgstr "Afficher les valeurs nulles"
|
||||
|
||||
msgid "Show Range Filter"
|
||||
msgstr "Afficher l'intervalle de filtre"
|
||||
@@ -12912,13 +12948,17 @@ msgstr ""
|
||||
"autrement min/max dans les données."
|
||||
|
||||
msgid "Show a draggable slider to control the visible range of the Y-axis."
|
||||
msgstr ""
|
||||
msgstr "Afficher un curseur déplaçable pour contrôler la plage visible de l'axe Y."
|
||||
|
||||
msgid ""
|
||||
"Show a summary row of total aggregations: the selected metrics in "
|
||||
"aggregate mode, or the sum of numeric columns in raw records mode. Note "
|
||||
"that row limit does not apply to the result."
|
||||
msgstr ""
|
||||
"Afficher une ligne de synthèse des agrégats totaux : les mesures "
|
||||
"sélectionnées en mode agrégé, ou la somme des colonnes numériques en mode"
|
||||
" enregistrements bruts. Notez que la limite de lignes ne s'applique pas "
|
||||
"au résultat."
|
||||
|
||||
msgid "Show all columns"
|
||||
msgstr "Afficher toutes les colonnes"
|
||||
@@ -12959,7 +12999,7 @@ msgid "Show entries per page"
|
||||
msgstr "Afficher le nombre d'éléments par page"
|
||||
|
||||
msgid "Show full range for time shift"
|
||||
msgstr ""
|
||||
msgstr "Afficher la plage complète pour le décalage temporel"
|
||||
|
||||
msgid ""
|
||||
"Show hierarchical relationships of data, with the value represented by "
|
||||
@@ -13046,6 +13086,8 @@ msgid ""
|
||||
"Showcases a metric along with a comparison of value, change, and percent "
|
||||
"change for a selected time period."
|
||||
msgstr ""
|
||||
"Met en avant une mesure avec une comparaison de la valeur, de l'écart et "
|
||||
"de l'écart en pourcentage sur une période sélectionnée."
|
||||
|
||||
msgid ""
|
||||
"Showcases a single metric front-and-center. Big number is best used to "
|
||||
@@ -13189,7 +13231,7 @@ msgid "Solid"
|
||||
msgstr "Solide"
|
||||
|
||||
msgid "Solid background"
|
||||
msgstr ""
|
||||
msgstr "Fond uni"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
@@ -13213,12 +13255,14 @@ msgstr ""
|
||||
"seront pas effacés"
|
||||
|
||||
msgid "Some tables are not shown. Refine your search."
|
||||
msgstr ""
|
||||
msgstr "Certaines tables ne sont pas affichées. Affinez votre recherche."
|
||||
|
||||
msgid ""
|
||||
"Something went wrong loading the dashboard. Check the dev console for "
|
||||
"details."
|
||||
msgstr ""
|
||||
"Un problème est survenu lors du chargement du tableau de bord. Consultez "
|
||||
"la console développeur pour plus de détails."
|
||||
|
||||
msgid "Something went wrong while saving the user info"
|
||||
msgstr "Une erreur s'est produite. Réessayez plus tard."
|
||||
@@ -13628,7 +13672,7 @@ msgid "Success"
|
||||
msgstr "Réussite"
|
||||
|
||||
msgid "Success message"
|
||||
msgstr ""
|
||||
msgstr "Message de succès"
|
||||
|
||||
#, python-format
|
||||
msgid "Successfully changed %s!"
|
||||
@@ -13702,7 +13746,7 @@ msgid "Swap rows and columns"
|
||||
msgstr "Échanger les rangées et les colonnes"
|
||||
|
||||
msgid "Sweep angle"
|
||||
msgstr ""
|
||||
msgstr "Angle de balayage"
|
||||
|
||||
msgid ""
|
||||
"Swiss army knife for visualizing data. Choose between step, line, "
|
||||
@@ -13870,7 +13914,7 @@ msgid "Tag created"
|
||||
msgstr "Balise créée"
|
||||
|
||||
msgid "Tag description"
|
||||
msgstr ""
|
||||
msgstr "Description de la balise"
|
||||
|
||||
msgid "Tag name"
|
||||
msgstr "Nom de la balise"
|
||||
@@ -14000,11 +14044,13 @@ msgid "Text align"
|
||||
msgstr "Alignement du texte"
|
||||
|
||||
msgid "Text embedded in email"
|
||||
msgstr "Text encapsulé dans le courriel"
|
||||
msgstr "Texte encapsulé dans le courriel"
|
||||
|
||||
#, python-format
|
||||
msgid "The %(key)s in metadata_cache_timeout must be a non-negative integer."
|
||||
msgstr ""
|
||||
"La valeur de %(key)s dans metadata_cache_timeout doit être un entier "
|
||||
"positif ou nul."
|
||||
|
||||
#, python-format
|
||||
msgid "The %s"
|
||||
@@ -14124,6 +14170,9 @@ msgid ""
|
||||
"The chart data is too large to download. Please try reducing the date "
|
||||
"range, limiting rows, or using fewer columns."
|
||||
msgstr ""
|
||||
"Les données du graphique sont trop volumineuses pour être téléchargées. "
|
||||
"Réduisez la plage de dates, limitez le nombre de lignes ou utilisez moins"
|
||||
" de colonnes."
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
@@ -14141,7 +14190,7 @@ msgstr ""
|
||||
" ou mettez à jour le rapport pour pointer vers un graphique actif."
|
||||
|
||||
msgid "The chat failed to load."
|
||||
msgstr ""
|
||||
msgstr "La conversation n'a pas pu être chargée."
|
||||
|
||||
msgid ""
|
||||
"The classic. Great for showing how much of a company each investor gets, "
|
||||
@@ -14239,6 +14288,8 @@ msgstr ""
|
||||
|
||||
msgid "The dashboard you are looking for may have been deleted or moved."
|
||||
msgstr ""
|
||||
"Le tableau de bord que vous recherchez a peut-être été supprimé ou "
|
||||
"déplacé."
|
||||
|
||||
msgid "The data source seems to have been deleted"
|
||||
msgstr "La source de données semble avoir été effacée"
|
||||
@@ -14523,6 +14574,8 @@ msgid ""
|
||||
"The metadata_cache_timeout must be a mapping from string keys to non-"
|
||||
"negative integer values."
|
||||
msgstr ""
|
||||
"metadata_cache_timeout doit être une correspondance entre des clés de "
|
||||
"type chaîne et des entiers positifs ou nuls."
|
||||
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -14759,6 +14812,8 @@ msgstr "Cette requête contient un ou plusieurs paramètres de modèle malformé
|
||||
|
||||
msgid "The query context datasource does not match the chart datasource"
|
||||
msgstr ""
|
||||
"La source de données du contexte de requête ne correspond pas à celle du "
|
||||
"graphique"
|
||||
|
||||
msgid "The query couldn't be loaded"
|
||||
msgstr "La requête ne peut pas être chargée"
|
||||
@@ -15200,7 +15255,7 @@ msgid "There was an error fetching the filtered charts and dashboards:"
|
||||
msgstr "Une erreur s’est produite lors de la récupération des graphiques et tableaux de bord filtrés :"
|
||||
|
||||
msgid "There was an error generating the permalink."
|
||||
msgstr ""
|
||||
msgstr "Une erreur s'est produite lors de la génération du lien permanent."
|
||||
|
||||
msgid "There was an error loading groups."
|
||||
msgstr "Une erreur s'est produite lors du chargement des groupes."
|
||||
@@ -15305,6 +15360,8 @@ msgstr ""
|
||||
#, python-format
|
||||
msgid "There was an issue deleting the selected queries: %s"
|
||||
msgstr ""
|
||||
"Un problème est survenu lors de la suppression des requêtes sélectionnées"
|
||||
" : %s"
|
||||
|
||||
#, python-format
|
||||
msgid "There was an issue deleting the selected templates: %s"
|
||||
@@ -15351,7 +15408,7 @@ msgid "There was an issue exporting the selected dashboards"
|
||||
msgstr "Il y a eu un problème lors de l'export des tableaux de bord sélectionnés"
|
||||
|
||||
msgid "There was an issue exporting the selected queries"
|
||||
msgstr ""
|
||||
msgstr "Un problème est survenu lors de l'export des requêtes sélectionnées"
|
||||
|
||||
msgid "There was an issue exporting the selected themes"
|
||||
msgstr "Il y a eu un problème lors de l'export des thèmes sélectionnés"
|
||||
@@ -15518,7 +15575,7 @@ msgstr ""
|
||||
"être transmis au graphique contenant les données d'annotation."
|
||||
|
||||
msgid "This dashboard does not exist"
|
||||
msgstr ""
|
||||
msgstr "Ce tableau de bord n'existe pas"
|
||||
|
||||
msgid "This dashboard is managed externally, and can't be edited in Superset"
|
||||
msgstr ""
|
||||
@@ -15906,6 +15963,8 @@ msgstr "Fragment de temps"
|
||||
|
||||
msgid "Time Grain must be specified when using Time Comparison."
|
||||
msgstr ""
|
||||
"Le fragment de temps doit être précisé lors de l'utilisation de la "
|
||||
"comparaison de temps."
|
||||
|
||||
msgid "Time Granularity"
|
||||
msgstr "Fragmentation de Temps"
|
||||
@@ -16195,6 +16254,10 @@ msgid ""
|
||||
" angle is a multiple of 90°, the chart is automatically re-centered to "
|
||||
"make use of the empty space."
|
||||
msgstr ""
|
||||
"Angle total couvert par le graphique, en degrés. 360° dessine un cercle "
|
||||
"complet et 180° un demi-anneau. Lorsque le balayage est inférieur ou égal"
|
||||
" à 180° et que l'angle de départ est un multiple de 90°, le graphique est"
|
||||
" automatiquement recentré pour exploiter l'espace vide."
|
||||
|
||||
msgid "Total color"
|
||||
msgstr "Couleur du total"
|
||||
@@ -16219,7 +16282,7 @@ msgid "Transparent"
|
||||
msgstr "Transparent"
|
||||
|
||||
msgid "Transparent background"
|
||||
msgstr ""
|
||||
msgstr "Fond transparent"
|
||||
|
||||
msgid "Transpose pivot"
|
||||
msgstr "Pivot de transposition"
|
||||
@@ -16249,7 +16312,7 @@ msgid "Trigger Alert If..."
|
||||
msgstr "Déclencher une alerte si…"
|
||||
|
||||
msgid "Trigger now"
|
||||
msgstr ""
|
||||
msgstr "Déclencher maintenant"
|
||||
|
||||
msgid "True"
|
||||
msgstr "Est vrai"
|
||||
@@ -16368,7 +16431,7 @@ msgid "URL parameters"
|
||||
msgstr "Paramètres URL"
|
||||
|
||||
msgid "UUID to track the execution status"
|
||||
msgstr ""
|
||||
msgstr "UUID permettant de suivre l'état de l'exécution"
|
||||
|
||||
msgid "Unable to calculate such a date delta"
|
||||
msgstr "Impossible de calculer un delta de date comme celui-ci"
|
||||
@@ -16435,7 +16498,7 @@ msgstr "Impossible de générer les données de téléchargement"
|
||||
|
||||
#, python-format
|
||||
msgid "Unable to generate forecast: %(error)s"
|
||||
msgstr ""
|
||||
msgstr "Impossible de générer la prévision : %(error)s"
|
||||
|
||||
msgid ""
|
||||
"Unable to identify temporal column for date range time comparison.Please "
|
||||
@@ -16450,6 +16513,8 @@ msgid ""
|
||||
"Unable to interpret the time offset: %(offset)s. Use a relative time such"
|
||||
" as \"1 month ago\"."
|
||||
msgstr ""
|
||||
"Impossible d'interpréter le décalage temporel : %(offset)s. Utilisez une "
|
||||
"expression relative telle que « 1 month ago »."
|
||||
|
||||
msgid ""
|
||||
"Unable to load columns for the selected table. Please select a different "
|
||||
@@ -17497,6 +17562,8 @@ msgstr "Affichage ou non des bulles au-dessus des pays"
|
||||
|
||||
msgid "Whether to display entries with null values in the hierarchy"
|
||||
msgstr ""
|
||||
"Afficher ou non les entrées dont les valeurs sont nulles dans la "
|
||||
"hiérarchie"
|
||||
|
||||
msgid "Whether to display in the chart"
|
||||
msgstr "Afficher ou non dans le graphique"
|
||||
@@ -17632,6 +17699,9 @@ msgid ""
|
||||
"Whether to sort tooltip by the selected metric in descending order. On "
|
||||
"stacked charts, values are shown in ascending order."
|
||||
msgstr ""
|
||||
"Trier ou non l'infobulle par la mesure sélectionnée dans l'ordre "
|
||||
"décroissant. Sur les graphiques empilés, les valeurs sont affichées dans "
|
||||
"l'ordre croissant."
|
||||
|
||||
msgid "Whether to truncate metrics"
|
||||
msgstr "Tronquer ou non les mesures"
|
||||
@@ -17791,7 +17861,7 @@ msgid "Y-axis bounds"
|
||||
msgstr "Limites de l’axe des ordonnées"
|
||||
|
||||
msgid "Y-axis range slider"
|
||||
msgstr ""
|
||||
msgstr "Curseur de plage de l'axe Y"
|
||||
|
||||
msgid "Y-scale interval"
|
||||
msgstr "Intervalle d'échelle Y"
|
||||
@@ -18066,26 +18136,41 @@ msgid ""
|
||||
"You must be a chart editor in order to delete. Please reach out to a "
|
||||
"chart editor to request modifications or edit access."
|
||||
msgstr ""
|
||||
"Vous devez être éditeur du graphique pour pouvoir supprimer. Contactez un"
|
||||
" éditeur du graphique pour demander des modifications ou un accès en "
|
||||
"modification."
|
||||
|
||||
msgid ""
|
||||
"You must be a chart editor in order to edit. Please reach out to a chart "
|
||||
"editor to request modifications or edit access."
|
||||
msgstr ""
|
||||
"Vous devez être éditeur du graphique pour pouvoir modifier. Contactez un "
|
||||
"éditeur du graphique pour demander des modifications ou un accès en "
|
||||
"modification."
|
||||
|
||||
msgid ""
|
||||
"You must be a dashboard editor in order to delete. Please reach out to a "
|
||||
"dashboard editor to request modifications or edit access."
|
||||
msgstr ""
|
||||
"Vous devez être éditeur du tableau de bord pour pouvoir supprimer. "
|
||||
"Contactez un éditeur du tableau de bord pour demander des modifications "
|
||||
"ou un accès en modification."
|
||||
|
||||
msgid ""
|
||||
"You must be a dashboard editor in order to edit. Please reach out to a "
|
||||
"dashboard editor to request modifications or edit access."
|
||||
msgstr ""
|
||||
"Vous devez être éditeur du tableau de bord pour pouvoir modifier. "
|
||||
"Contactez un éditeur du tableau de bord pour demander des modifications "
|
||||
"ou un accès en modification."
|
||||
|
||||
msgid ""
|
||||
"You must be a dataset editor in order to delete. Please reach out to a "
|
||||
"dataset editor to request modifications or edit access."
|
||||
msgstr ""
|
||||
"Vous devez être éditeur de l'ensemble de données pour pouvoir supprimer. "
|
||||
"Contactez un éditeur de l'ensemble de données pour demander des "
|
||||
"modifications ou un accès en modification."
|
||||
|
||||
msgid ""
|
||||
"You must be a dataset editor in order to edit. Please reach out to a "
|
||||
@@ -18172,6 +18257,11 @@ msgid ""
|
||||
"into multiple dashboards) or raise the "
|
||||
"SUPERSET_DASHBOARD_POSITION_DATA_LIMIT config setting."
|
||||
msgstr ""
|
||||
"Votre tableau de bord est trop volumineux pour être enregistré : la "
|
||||
"longueur sérialisée de la disposition est de %s alors que la limite est "
|
||||
"de %s. Réduisez la taille du tableau de bord (par exemple en le scindant "
|
||||
"en plusieurs tableaux de bord) ou augmentez le paramètre de configuration"
|
||||
" SUPERSET_DASHBOARD_POSITION_DATA_LIMIT."
|
||||
|
||||
msgid "Your query could not be saved"
|
||||
msgstr "Votre requête n'a pas pu être enregistrée"
|
||||
@@ -19026,7 +19116,7 @@ msgid "quarter"
|
||||
msgstr "trimestre"
|
||||
|
||||
msgid "queries"
|
||||
msgstr ""
|
||||
msgstr "requêtes"
|
||||
|
||||
msgid "query"
|
||||
msgstr "requête"
|
||||
@@ -19072,7 +19162,7 @@ msgid "seconds"
|
||||
msgstr "secondes"
|
||||
|
||||
msgid "semantic layer"
|
||||
msgstr ""
|
||||
msgstr "couche sémantique"
|
||||
|
||||
msgid "series"
|
||||
msgstr "série"
|
||||
|
||||
@@ -87,6 +87,29 @@ def resolve_screenshot_task_budget_seconds(
|
||||
return None
|
||||
|
||||
|
||||
# Fallback wall-clock budget, in seconds, for the entire tiled-screenshot
|
||||
# operation (element lookup plus all per-tile readiness/animation waits
|
||||
# combined), used when resolve_screenshot_task_budget_seconds() returns None
|
||||
# (no Celery task context -- e.g. synchronous thumbnail generation -- or no
|
||||
# usable task limit). The non-tiled readiness path treats None as "keep the
|
||||
# configured SCREENSHOT_LOAD_WAIT" because it makes exactly one bounded wait;
|
||||
# the tiled path cannot, because its per-tile waits accumulate: with N tiles,
|
||||
# an uncapped load_wait allows N * load_wait of total wall-clock time, so the
|
||||
# operation still needs one fixed total ceiling. Sized against the longest
|
||||
# Celery hard task_time_limit observed in production for report execution
|
||||
# (1740s), minus the same 300s cleanup margin the runtime derivation reserves
|
||||
# for combining tiles, building the PDF, and delivering the notification.
|
||||
TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS = 1440 # 1740s limit - 300s margin
|
||||
|
||||
|
||||
class ScreenshotTaskBudgetExceededError(RuntimeError):
|
||||
"""Raised when no safe task budget remains before screenshot capture."""
|
||||
|
||||
|
||||
class TiledScreenshotBudgetExceededError(ScreenshotTaskBudgetExceededError):
|
||||
"""Raised when the tiled-screenshot time budget runs out mid-capture."""
|
||||
|
||||
|
||||
try:
|
||||
from playwright.sync_api import TimeoutError as PlaywrightTimeout
|
||||
except ImportError:
|
||||
@@ -251,7 +274,7 @@ def combine_screenshot_tiles(screenshot_tiles: list[bytes]) -> bytes:
|
||||
return screenshot_tiles[0]
|
||||
|
||||
|
||||
def take_tiled_screenshot(
|
||||
def take_tiled_screenshot( # noqa: C901
|
||||
page: "Page",
|
||||
element_name: str,
|
||||
tile_height: int,
|
||||
@@ -274,6 +297,12 @@ def take_tiled_screenshot(
|
||||
|
||||
Returns:
|
||||
Combined screenshot bytes or None if failed
|
||||
|
||||
Raises:
|
||||
TiledScreenshotBudgetExceededError: If the total time budget for the
|
||||
tiled-screenshot operation runs out before every tile has been
|
||||
verifiably captured. Callers must treat this as a hard failure
|
||||
rather than fall back to an unchecked/partial screenshot.
|
||||
"""
|
||||
context_suffix = f" [{log_context}]" if log_context else ""
|
||||
# Set right before re-raising the per-tile readiness timeout below, and
|
||||
@@ -286,6 +315,15 @@ def take_tiled_screenshot(
|
||||
# match `except PlaywrightTimeout` and incorrectly propagate instead of
|
||||
# degrading to `None` like every other unexpected error in this function.
|
||||
readiness_timeout = False
|
||||
# Cap the whole tiled operation against the running Celery task's own
|
||||
# time limit, using the same runtime derivation as the non-tiled
|
||||
# readiness wait (#42253/#42427). Unlike that path, a None budget does
|
||||
# not mean "keep the configured timeout": per-tile waits accumulate, so
|
||||
# the operation falls back to a fixed total ceiling instead.
|
||||
wait_budget_seconds = resolve_screenshot_task_budget_seconds(log_context)
|
||||
if wait_budget_seconds is None:
|
||||
wait_budget_seconds = float(TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS)
|
||||
start_time = time.monotonic()
|
||||
try:
|
||||
# Get the target element
|
||||
element = page.locator(f".{element_name}")
|
||||
@@ -320,9 +358,44 @@ def take_tiled_screenshot(
|
||||
num_tiles = max(1, (dashboard_height + tile_height - 1) // tile_height)
|
||||
logger.info("Taking %s screenshot tiles", num_tiles)
|
||||
|
||||
screenshot_tiles = []
|
||||
screenshot_tiles: list[bytes] = []
|
||||
|
||||
def _raise_if_budget_exhausted(elapsed: float, remaining_budget: float) -> None:
|
||||
if remaining_budget > 0:
|
||||
return
|
||||
# A customer-side chart-loading issue (a slow/hung dashboard),
|
||||
# not a Superset system fault, so this is a WARNING rather
|
||||
# than an ERROR -- consistent with #38130/#38441, which
|
||||
# deliberately downgraded screenshot timeout logs the same way.
|
||||
logger.warning(
|
||||
"Tiled screenshot time budget exhausted on tile %s/%s: "
|
||||
"%s/%s tiles captured so far, %.1fs elapsed of a %.1fs "
|
||||
"budget. Aborting instead of capturing remaining tiles "
|
||||
"unchecked.%s",
|
||||
i + 1,
|
||||
num_tiles,
|
||||
len(screenshot_tiles),
|
||||
num_tiles,
|
||||
elapsed,
|
||||
wait_budget_seconds,
|
||||
context_suffix,
|
||||
)
|
||||
raise TiledScreenshotBudgetExceededError(
|
||||
f"Tiled screenshot budget of "
|
||||
f"{wait_budget_seconds:.1f}s exhausted "
|
||||
f"after {len(screenshot_tiles)}/{num_tiles} tiles"
|
||||
)
|
||||
|
||||
for i in range(num_tiles):
|
||||
# Check the time budget before starting this tile's readiness wait.
|
||||
# If it's already exhausted, we can no longer verify this (or any
|
||||
# later) tile is actually ready to capture -- fail loudly instead
|
||||
# of silently snapshotting a spinner or blank chart, or running
|
||||
# past the Celery task time limit and getting SIGKILLed.
|
||||
elapsed = time.monotonic() - start_time
|
||||
remaining_budget = wait_budget_seconds - elapsed
|
||||
_raise_if_budget_exhausted(elapsed, remaining_budget)
|
||||
|
||||
# Calculate scroll position to show this tile's content
|
||||
scroll_y = dashboard_top + (i * tile_height)
|
||||
|
||||
@@ -332,17 +405,31 @@ def take_tiled_screenshot(
|
||||
)
|
||||
# Wait for scroll to settle and content to load
|
||||
page.wait_for_timeout(SCROLL_SETTLE_TIMEOUT_MS)
|
||||
|
||||
# Recompute the remaining budget after the scroll-settle sleep --
|
||||
# which itself consumes real wall-clock time -- rather than
|
||||
# reusing the value from before it, so the readiness-check
|
||||
# timeout below is capped against a fresh number instead of a
|
||||
# stale one that would let each tile overrun the budget by up
|
||||
# to one settle interval.
|
||||
tile_wait_start = time.monotonic()
|
||||
elapsed = tile_wait_start - start_time
|
||||
remaining_budget = wait_budget_seconds - elapsed
|
||||
_raise_if_budget_exhausted(elapsed, remaining_budget)
|
||||
|
||||
# Wait for every chart holder visible in the current viewport to reach
|
||||
# a terminal state (rendered chart or error/empty state). Only check
|
||||
# a terminal state (rendered chart or error/empty state), capped at
|
||||
# whatever remains of the total time budget so a slow dashboard
|
||||
# degrades gracefully instead of exceeding it. Only check
|
||||
# viewport-visible chart holders to avoid blocking on virtualization
|
||||
# placeholders rendered for off-screen charts. A holder that hasn't
|
||||
# mounted anything yet does not satisfy this check -- unlike checking
|
||||
# for the absence of `.loading`, which passes vacuously in that case.
|
||||
tile_wait_start = time.monotonic()
|
||||
tile_load_wait = min(load_wait, remaining_budget)
|
||||
try:
|
||||
page.wait_for_function(
|
||||
CHART_HOLDERS_READY_JS,
|
||||
timeout=load_wait * 1000,
|
||||
timeout=tile_load_wait * 1000,
|
||||
)
|
||||
except PlaywrightTimeout:
|
||||
elapsed = time.monotonic() - tile_wait_start
|
||||
@@ -354,14 +441,21 @@ def take_tiled_screenshot(
|
||||
# made the same call for the other screenshot timeout paths.
|
||||
logger.warning(
|
||||
"Timed out after %.2fs waiting for %s chart container(s) to "
|
||||
"become ready on tile %s/%s (load_wait=%ss)%s; unready chart "
|
||||
"holders (chart id, state): %s. Aborting tiled screenshot "
|
||||
"rather than capturing a blank or partially-loaded tile.",
|
||||
"become ready on tile %s/%s (waited %.1fs of a %ss requested "
|
||||
"load_wait; %.1fs elapsed of a %.1fs total budget; %s/%s "
|
||||
"tiles captured so far)%s; unready chart holders (chart id, "
|
||||
"state): %s. Aborting tiled screenshot rather than capturing "
|
||||
"a blank or partially-loaded tile.",
|
||||
elapsed,
|
||||
len(unready_chart_holders),
|
||||
i + 1,
|
||||
num_tiles,
|
||||
tile_load_wait,
|
||||
load_wait,
|
||||
time.monotonic() - start_time,
|
||||
wait_budget_seconds,
|
||||
len(screenshot_tiles),
|
||||
num_tiles,
|
||||
context_suffix,
|
||||
unready_chart_holders,
|
||||
)
|
||||
@@ -377,12 +471,36 @@ def take_tiled_screenshot(
|
||||
load_wait,
|
||||
context_suffix,
|
||||
)
|
||||
readiness_wait_elapsed = time.monotonic() - tile_wait_start
|
||||
|
||||
# Wait for chart animations (e.g. ECharts) to finish after spinner clears.
|
||||
# The global animation wait before tiling only covers the first tile;
|
||||
# subsequent tiles need their own wait after data loads.
|
||||
# subsequent tiles need their own wait after data loads. Capped at
|
||||
# whatever remains of the budget; unlike the readiness wait above this
|
||||
# is cosmetic settling, not a readiness check, so we simply skip it
|
||||
# (rather than raise) once the budget runs out.
|
||||
animation_wait_elapsed = 0.0
|
||||
if animation_wait > 0:
|
||||
page.wait_for_timeout(animation_wait * 1000)
|
||||
elapsed = time.monotonic() - start_time
|
||||
remaining_budget = wait_budget_seconds - elapsed
|
||||
tile_animation_wait = max(0, min(animation_wait, remaining_budget))
|
||||
if tile_animation_wait > 0:
|
||||
animation_wait_start = time.monotonic()
|
||||
page.wait_for_timeout(tile_animation_wait * 1000)
|
||||
animation_wait_elapsed = time.monotonic() - animation_wait_start
|
||||
|
||||
# Per-tile timing breakdown so slow dashboards can be profiled from
|
||||
# logs alone. DEBUG rather than INFO: this fires once per tile, and
|
||||
# large dashboards can have dozens of tiles per report run.
|
||||
logger.debug(
|
||||
"Tile %s/%s timing: %.2fs waiting for chart readiness, "
|
||||
"%.2fs waiting for animations.%s",
|
||||
i + 1,
|
||||
num_tiles,
|
||||
readiness_wait_elapsed,
|
||||
animation_wait_elapsed,
|
||||
context_suffix,
|
||||
)
|
||||
|
||||
# Calculate what portion of the element we want to capture for this tile
|
||||
tile_start_in_element = i * tile_height
|
||||
@@ -431,6 +549,12 @@ def take_tiled_screenshot(
|
||||
|
||||
return combined_screenshot
|
||||
|
||||
except TiledScreenshotBudgetExceededError:
|
||||
# Budget exhaustion must fail cleanly, not be swallowed into the
|
||||
# generic `return None` degradation below -- the raise carries the
|
||||
# budget diagnostics to the caller, which fails the capture loudly
|
||||
# (#42273) instead of receiving an anonymous empty result.
|
||||
raise
|
||||
except Exception as e:
|
||||
if readiness_timeout:
|
||||
# Let the per-tile readiness timeout propagate so the caller
|
||||
|
||||
@@ -85,6 +85,26 @@ class ScreenshotCachePayloadType(TypedDict):
|
||||
status: str
|
||||
|
||||
|
||||
# Magic bytes for a cheap image sanity check. This is intentionally not a full
|
||||
# decode: it's meant to catch 0-byte/corrupt/blank payloads before they're
|
||||
# cached or served, not to validate the image is renderable.
|
||||
PNG_MAGIC_BYTES = b"\x89PNG\r\n\x1a\n"
|
||||
JPEG_MAGIC_BYTES = b"\xff\xd8\xff"
|
||||
|
||||
|
||||
def validate_screenshot_image(image: bytes | None) -> str | None:
|
||||
"""Cheaply validate screenshot bytes before they're cached or served.
|
||||
|
||||
:return: None if the bytes look like a usable image, otherwise a short
|
||||
reason ("empty" or "undecodable") suitable for logging.
|
||||
"""
|
||||
if not image:
|
||||
return "empty"
|
||||
if not image.startswith((PNG_MAGIC_BYTES, JPEG_MAGIC_BYTES)):
|
||||
return "undecodable"
|
||||
return None
|
||||
|
||||
|
||||
class ScreenshotCachePayload:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -147,6 +167,13 @@ class ScreenshotCachePayload:
|
||||
def get_status(self) -> str:
|
||||
return self.status.value
|
||||
|
||||
def get_invalid_image_reason(self) -> str | None:
|
||||
"""Reason this payload's image should not be served/cached, or None if
|
||||
it passes validation (or it isn't claiming a successful screenshot)."""
|
||||
if self.status != StatusValues.UPDATED:
|
||||
return None
|
||||
return validate_screenshot_image(self._image)
|
||||
|
||||
def is_error_cache_ttl_expired(self) -> bool:
|
||||
error_cache_ttl = app.config["THUMBNAIL_ERROR_CACHE_TTL"]
|
||||
return (
|
||||
@@ -263,6 +290,14 @@ class BaseScreenshot:
|
||||
elif isinstance(payload, dict):
|
||||
payload = cast(ScreenshotCachePayloadType, payload)
|
||||
payload = ScreenshotCachePayload.from_dict(payload)
|
||||
if invalid_reason := payload.get_invalid_image_reason():
|
||||
logger.warning(
|
||||
"Rejecting cached screenshot for %s: %s image payload; "
|
||||
"treating as a cache miss",
|
||||
cache_key,
|
||||
invalid_reason,
|
||||
)
|
||||
return None
|
||||
return payload
|
||||
logger.info("Failed at getting from cache: %s", cache_key)
|
||||
return None
|
||||
@@ -331,15 +366,28 @@ class BaseScreenshot:
|
||||
image = None
|
||||
|
||||
# Cache the result (success or error) to avoid immediate retries
|
||||
if image:
|
||||
invalid_reason = validate_screenshot_image(image)
|
||||
# `image and` is redundant at runtime (validate_screenshot_image
|
||||
# only returns None for truthy, well-formed bytes) but mypy can't
|
||||
# infer that image is non-None from invalid_reason being None
|
||||
# across the function-call boundary, so it's kept for narrowing.
|
||||
if image and invalid_reason is None:
|
||||
with event_logger.log_context(
|
||||
f"screenshot.cache.{self.thumbnail_type}"
|
||||
):
|
||||
cache_payload.update(image)
|
||||
elif cache_payload.status != StatusValues.ERROR:
|
||||
# Only call error() if not already set — avoids overwriting
|
||||
# the timestamp recorded when the actual failure occurred above.
|
||||
cache_payload.error()
|
||||
else:
|
||||
if invalid_reason:
|
||||
logger.warning(
|
||||
"Not caching screenshot result for %s: %s image payload",
|
||||
cache_key,
|
||||
invalid_reason,
|
||||
)
|
||||
if cache_payload.status != StatusValues.ERROR:
|
||||
# Only call error() if not already set — avoids overwriting
|
||||
# the timestamp recorded when the actual failure occurred
|
||||
# above.
|
||||
cache_payload.error()
|
||||
|
||||
logger.info("Caching thumbnail: %s", cache_key)
|
||||
self.cache.set(cache_key, cache_payload.to_dict())
|
||||
|
||||
@@ -47,6 +47,7 @@ from superset.utils.screenshot_utils import (
|
||||
CHART_HOLDERS_READY_JS,
|
||||
FIND_CHART_HOLDER_STATES_JS,
|
||||
resolve_screenshot_task_budget_seconds,
|
||||
ScreenshotTaskBudgetExceededError,
|
||||
take_tiled_screenshot,
|
||||
)
|
||||
|
||||
@@ -61,10 +62,6 @@ PLAYWRIGHT_INSTALL_MESSAGE = (
|
||||
)
|
||||
|
||||
|
||||
class ScreenshotTaskBudgetExceededError(RuntimeError):
|
||||
"""Raised when no safe task budget remains before screenshot capture."""
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
@@ -482,16 +479,31 @@ class WebDriverPlaywright(WebDriverProxy):
|
||||
logger.exception("Timed out requesting url %s", url)
|
||||
raise
|
||||
|
||||
slice_container_elems: list[Locator] = []
|
||||
rendered_chart_count = 0
|
||||
try:
|
||||
# chart containers didn't render
|
||||
logger.debug("Wait for chart containers to draw at url: %s", url)
|
||||
slice_container_locator = page.locator(".chart-container")
|
||||
for slice_container_elem in slice_container_locator.all():
|
||||
# One-time snapshot: containers mounting after this point
|
||||
# are neither waited on nor counted, so the progress
|
||||
# numbers below describe the snapshot, not the final DOM.
|
||||
slice_container_elems = slice_container_locator.all()
|
||||
for slice_container_elem in slice_container_elems:
|
||||
slice_container_elem.wait_for()
|
||||
rendered_chart_count += 1
|
||||
except PlaywrightTimeout:
|
||||
logger.exception(
|
||||
"Timed out waiting for chart containers to draw at url %s",
|
||||
# Customer-side chart loading is often just slow, not a
|
||||
# Superset bug, so this is a WARNING (matching the other
|
||||
# locate-wait timeouts below) rather than an ERROR -- but
|
||||
# it still fails the screenshot; see the `raise` below.
|
||||
logger.warning(
|
||||
"Timed out waiting for chart containers to draw at url %s "
|
||||
"(%s of %s chart containers rendered before the timeout)",
|
||||
url,
|
||||
rendered_chart_count,
|
||||
len(slice_container_elems),
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
selenium_animation_wait = app.config[
|
||||
@@ -529,19 +541,38 @@ class WebDriverPlaywright(WebDriverProxy):
|
||||
"SCREENSHOT_TILED_VIEWPORT_HEIGHT", viewport_height
|
||||
)
|
||||
|
||||
if dashboard_height == 0:
|
||||
logger.warning(
|
||||
# A height of 0 means the DOM query above found no matching
|
||||
# element (or it hadn't laid out yet), not that the
|
||||
# dashboard is actually empty. Treat it as "unknown" rather
|
||||
# than "fits in a single tile": chart_count alone already
|
||||
# tells us whether this looks like a large dashboard, and
|
||||
# that signal must not be silently vetoed just because we
|
||||
# couldn't measure height, or a large dashboard could skip
|
||||
# tiling and ship with unrendered below-the-fold charts.
|
||||
height_unknown = dashboard_height == 0
|
||||
likely_large_dashboard = (
|
||||
chart_count >= chart_threshold
|
||||
or dashboard_height > height_threshold
|
||||
)
|
||||
if height_unknown:
|
||||
log_fn = (
|
||||
logger.warning if likely_large_dashboard else logger.debug
|
||||
)
|
||||
log_fn(
|
||||
"Could not determine dashboard height for element %s "
|
||||
"at url %s; falling back to standard screenshot behavior",
|
||||
"at url %s (%s chart containers found); %s",
|
||||
element_name,
|
||||
url,
|
||||
chart_count,
|
||||
"attempting tiled screenshot anyway"
|
||||
if likely_large_dashboard
|
||||
else "falling back to standard screenshot behavior",
|
||||
)
|
||||
|
||||
# Use tiled screenshots for large dashboards
|
||||
use_tiled = (
|
||||
chart_count >= chart_threshold
|
||||
or dashboard_height > height_threshold
|
||||
) and dashboard_height > tile_height
|
||||
use_tiled = likely_large_dashboard and (
|
||||
height_unknown or dashboard_height > tile_height
|
||||
)
|
||||
|
||||
if use_tiled:
|
||||
logger.info(
|
||||
|
||||
@@ -202,6 +202,23 @@ class Datasource(BaseSupersetView):
|
||||
payload = SamplesPayloadSchema().load(request.json)
|
||||
except ValidationError as err:
|
||||
return json_error_response(err.messages, status=400)
|
||||
|
||||
# Refuse early for datasource types that don't model raw rows
|
||||
# (e.g. semantic views, which only expose pre-defined metrics and
|
||||
# dimensions). Without this gate the request would still go through
|
||||
# the standard query pipeline and fail with an opaque 500.
|
||||
# ``supports_samples`` defaults to True for any datasource class that
|
||||
# doesn't explicitly opt out, so SqlaTable/Query/SavedQuery continue
|
||||
# to work without needing the attribute declared on each class.
|
||||
ds_class = DatasourceDAO.sources.get(
|
||||
DatasourceType(params["datasource_type"]),
|
||||
)
|
||||
if ds_class is not None and not getattr(ds_class, "supports_samples", True):
|
||||
return json_error_response(
|
||||
_("Samples are not available for this datasource type."),
|
||||
status=400,
|
||||
)
|
||||
|
||||
dashboard_id = None
|
||||
if security_manager.is_guest_user():
|
||||
if not params["dashboard_id"]:
|
||||
|
||||
@@ -224,6 +224,7 @@ class TestChartRestoreApi(SupersetTestCase):
|
||||
chart_id = chart.id
|
||||
chart_uuid = str(chart.uuid)
|
||||
entity_uuid = chart.uuid
|
||||
assert entity_uuid is not None
|
||||
original_name = chart.slice_name
|
||||
original_created_by = chart.created_by_fk
|
||||
before_changed_on = chart.changed_on
|
||||
@@ -329,6 +330,8 @@ class TestChartRestoreApi(SupersetTestCase):
|
||||
assert alpha not in chart.editors
|
||||
|
||||
ver_cls = version_class(Slice)
|
||||
entity_uuid = chart.uuid
|
||||
assert entity_uuid is not None
|
||||
first_tx = (
|
||||
db.session.query(ver_cls.transaction_id)
|
||||
.filter(ver_cls.id == chart.id)
|
||||
@@ -337,7 +340,7 @@ class TestChartRestoreApi(SupersetTestCase):
|
||||
.scalar()
|
||||
)
|
||||
assert first_tx is not None
|
||||
target_uuid = str(derive_version_uuid(chart.uuid, first_tx))
|
||||
target_uuid = str(derive_version_uuid(entity_uuid, first_tx))
|
||||
|
||||
self.login(ALPHA_USERNAME)
|
||||
rv = self._restore(str(chart.uuid), target_uuid)
|
||||
@@ -387,6 +390,8 @@ class TestChartRestoreApi(SupersetTestCase):
|
||||
assert boys is not None
|
||||
|
||||
ver_cls = version_class(Slice)
|
||||
boys_uuid = boys.uuid
|
||||
assert boys_uuid is not None
|
||||
boys_tx = (
|
||||
db.session.query(ver_cls.transaction_id)
|
||||
.filter(ver_cls.id == boys.id)
|
||||
@@ -395,7 +400,7 @@ class TestChartRestoreApi(SupersetTestCase):
|
||||
.scalar()
|
||||
)
|
||||
assert boys_tx is not None
|
||||
boys_version_uuid = str(derive_version_uuid(boys.uuid, boys_tx))
|
||||
boys_version_uuid = str(derive_version_uuid(boys_uuid, boys_tx))
|
||||
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self._restore(str(girls.uuid), boys_version_uuid)
|
||||
@@ -421,6 +426,8 @@ class TestChartRestoreApi(SupersetTestCase):
|
||||
db.session.commit()
|
||||
|
||||
ver_cls = version_class(Slice)
|
||||
entity_uuid = chart.uuid
|
||||
assert entity_uuid is not None
|
||||
first_tx = (
|
||||
db.session.query(ver_cls.transaction_id)
|
||||
.filter(ver_cls.id == chart_id)
|
||||
@@ -428,7 +435,7 @@ class TestChartRestoreApi(SupersetTestCase):
|
||||
.limit(1)
|
||||
.scalar()
|
||||
)
|
||||
target_uuid = str(derive_version_uuid(chart.uuid, first_tx))
|
||||
target_uuid = str(derive_version_uuid(entity_uuid, first_tx))
|
||||
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self._restore(str(chart.uuid), target_uuid)
|
||||
|
||||
@@ -90,6 +90,7 @@ class TestDashboardRestoreApi(SupersetTestCase):
|
||||
original_title = dashboard.dashboard_title
|
||||
dashboard_id = dashboard.id
|
||||
entity_uuid = dashboard.uuid
|
||||
assert entity_uuid is not None
|
||||
|
||||
# Make two more edits so we have a known non-trivial history to
|
||||
# navigate: [initial, v1, v2].
|
||||
@@ -151,6 +152,7 @@ class TestDashboardRestoreApi(SupersetTestCase):
|
||||
dashboard_uuid = str(dashboard.uuid)
|
||||
dashboard_id = dashboard.id
|
||||
entity_uuid = dashboard.uuid
|
||||
assert entity_uuid is not None
|
||||
|
||||
original_slice_ids = sorted(s.id for s in dashboard.slices)
|
||||
assert len(original_slice_ids) >= 2, (
|
||||
@@ -225,6 +227,8 @@ class TestDashboardRestoreApi(SupersetTestCase):
|
||||
db.session.commit()
|
||||
|
||||
ver_cls = version_class(Dashboard)
|
||||
entity_uuid = dashboard.uuid
|
||||
assert entity_uuid is not None
|
||||
target_tx = (
|
||||
db.session.query(ver_cls.transaction_id)
|
||||
.filter(ver_cls.id == dashboard_id)
|
||||
@@ -232,7 +236,7 @@ class TestDashboardRestoreApi(SupersetTestCase):
|
||||
.limit(1)
|
||||
.scalar()
|
||||
)
|
||||
target_uuid = str(derive_version_uuid(dashboard.uuid, target_tx))
|
||||
target_uuid = str(derive_version_uuid(entity_uuid, target_tx))
|
||||
|
||||
# Edit the member chart AFTER the snapshot.
|
||||
member = db.session.query(Slice).filter(Slice.id == member_id).one()
|
||||
@@ -282,6 +286,8 @@ class TestDashboardRestoreApi(SupersetTestCase):
|
||||
db.session.commit()
|
||||
|
||||
ver_cls = version_class(Dashboard)
|
||||
entity_uuid = dashboard.uuid
|
||||
assert entity_uuid is not None
|
||||
target_tx = (
|
||||
db.session.query(ver_cls.transaction_id)
|
||||
.filter(ver_cls.id == dashboard_id)
|
||||
@@ -289,7 +295,7 @@ class TestDashboardRestoreApi(SupersetTestCase):
|
||||
.limit(1)
|
||||
.scalar()
|
||||
)
|
||||
target_uuid = str(derive_version_uuid(dashboard.uuid, target_tx))
|
||||
target_uuid = str(derive_version_uuid(entity_uuid, target_tx))
|
||||
|
||||
# Detach, then hard-delete the victim via raw SQL so no live row
|
||||
# remains (bypasses the soft-delete listener deliberately — the
|
||||
@@ -340,6 +346,8 @@ class TestDashboardRestoreApi(SupersetTestCase):
|
||||
assert alpha not in dashboard.editors
|
||||
|
||||
ver_cls = version_class(Dashboard)
|
||||
entity_uuid = dashboard.uuid
|
||||
assert entity_uuid is not None
|
||||
first_tx = (
|
||||
db.session.query(ver_cls.transaction_id)
|
||||
.filter(ver_cls.id == dashboard.id)
|
||||
@@ -348,7 +356,7 @@ class TestDashboardRestoreApi(SupersetTestCase):
|
||||
.scalar()
|
||||
)
|
||||
assert first_tx is not None
|
||||
target_uuid = str(derive_version_uuid(dashboard.uuid, first_tx))
|
||||
target_uuid = str(derive_version_uuid(entity_uuid, first_tx))
|
||||
|
||||
self.login(ALPHA_USERNAME)
|
||||
rv = self._restore(str(dashboard.uuid), target_uuid)
|
||||
|
||||
@@ -109,6 +109,7 @@ class TestDatasetRestoreApi(SupersetTestCase):
|
||||
assert table is not None
|
||||
table_uuid = str(table.uuid)
|
||||
entity_uuid = table.uuid
|
||||
assert entity_uuid is not None
|
||||
table_id = table.id
|
||||
original_description = table.description
|
||||
|
||||
@@ -164,6 +165,7 @@ class TestDatasetRestoreApi(SupersetTestCase):
|
||||
assert table is not None
|
||||
table_uuid = str(table.uuid)
|
||||
entity_uuid = table.uuid
|
||||
assert entity_uuid is not None
|
||||
table_id = table.id
|
||||
|
||||
col = table.columns[0]
|
||||
@@ -219,6 +221,7 @@ class TestDatasetRestoreApi(SupersetTestCase):
|
||||
table_id = table.id
|
||||
table_uuid = str(table.uuid)
|
||||
entity_uuid = table.uuid
|
||||
assert entity_uuid is not None
|
||||
|
||||
original_col_names = sorted(c.column_name for c in table.columns)
|
||||
removed_name = table.columns[0].column_name
|
||||
@@ -285,6 +288,7 @@ class TestDatasetRestoreApi(SupersetTestCase):
|
||||
table_id = table.id
|
||||
table_uuid = str(table.uuid)
|
||||
entity_uuid = table.uuid
|
||||
assert entity_uuid is not None
|
||||
removed_name = table.columns[0].column_name
|
||||
added_name = "__restore_full_diff_test__"
|
||||
|
||||
@@ -399,6 +403,7 @@ class TestDatasetRestoreApi(SupersetTestCase):
|
||||
table_id = table.id
|
||||
table_uuid = str(table.uuid)
|
||||
entity_uuid = table.uuid
|
||||
assert entity_uuid is not None
|
||||
original_description = table.description
|
||||
original_col_names = sorted(c.column_name for c in table.columns)
|
||||
|
||||
|
||||
@@ -26,7 +26,10 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from superset.commands.report.base import BaseReportScheduleCommand
|
||||
from superset.commands.report.exceptions import ReportScheduleFrequencyNotAllowed
|
||||
from superset.commands.report.exceptions import (
|
||||
ReportScheduleCrontabNotValidError,
|
||||
ReportScheduleFrequencyNotAllowed,
|
||||
)
|
||||
from superset.reports.models import ReportScheduleType
|
||||
|
||||
REPORT_TYPES = {
|
||||
@@ -174,6 +177,28 @@ def test_validate_report_frequency_report_only(schedule: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("report_type", REPORT_TYPES)
|
||||
@app_custom_config(
|
||||
alert_minimum_interval=int(timedelta(minutes=5).total_seconds()),
|
||||
report_minimum_interval=int(timedelta(minutes=5).total_seconds()),
|
||||
)
|
||||
def test_validate_report_frequency_never_matching_crontab(report_type: str) -> None:
|
||||
"""
|
||||
Test the ``validate_report_frequency`` method with a crontab that is
|
||||
syntactically valid but never matches a real calendar date (Feb 30th).
|
||||
|
||||
Such schedules pass ``croniter.is_valid()`` (purely syntactic) and thus
|
||||
marshmallow schema validation, but raise ``CroniterBadDateError`` when
|
||||
iterated. This should surface as a ``ValidationError`` rather than
|
||||
propagating the raw croniter exception.
|
||||
"""
|
||||
with pytest.raises(ReportScheduleCrontabNotValidError):
|
||||
BaseReportScheduleCommand().validate_report_frequency(
|
||||
"0 0 30 2 *",
|
||||
report_type,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("report_type", REPORT_TYPES)
|
||||
@pytest.mark.parametrize("schedule", TEST_SCHEDULES)
|
||||
@app_custom_config(
|
||||
|
||||
70
tests/unit_tests/common/test_query_actions_drill_detail.py
Normal file
70
tests/unit_tests/common/test_query_actions_drill_detail.py
Normal file
@@ -0,0 +1,70 @@
|
||||
# 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.common.query_actions import _get_drill_detail
|
||||
from superset.exceptions import QueryObjectValidationError
|
||||
|
||||
|
||||
def test_get_drill_detail_refuses_datasource_that_opts_out() -> None:
|
||||
"""
|
||||
A datasource with ``supports_drill_to_detail = False`` (e.g. semantic
|
||||
views) must be hard-blocked on the server. Without this gate the request
|
||||
would fall through to ``_get_full`` and fail with an opaque error, and
|
||||
the flag would only be enforced by the frontend menu — leaving the
|
||||
chart-data API endpoint accepting drill-detail requests it shouldn't.
|
||||
"""
|
||||
datasource = MagicMock()
|
||||
datasource.supports_drill_to_detail = False
|
||||
|
||||
query_obj = MagicMock()
|
||||
query_obj.datasource = datasource
|
||||
|
||||
query_context = MagicMock()
|
||||
|
||||
with pytest.raises(
|
||||
QueryObjectValidationError,
|
||||
match="Drill to detail is not available",
|
||||
):
|
||||
_get_drill_detail(query_context, query_obj)
|
||||
|
||||
|
||||
def test_get_drill_detail_allows_datasource_without_flag() -> None:
|
||||
"""
|
||||
Datasources that don't declare the flag (e.g. legacy ``SqlaTable``
|
||||
subclasses via ``getattr`` default) must continue to work — the gate
|
||||
only fires when the flag is explicitly ``False``.
|
||||
"""
|
||||
datasource = MagicMock(spec=["columns"])
|
||||
column = MagicMock()
|
||||
column.column_name = "id"
|
||||
datasource.columns = [column]
|
||||
|
||||
query_obj = MagicMock()
|
||||
query_obj.datasource = datasource
|
||||
query_obj.columns = []
|
||||
|
||||
query_context = MagicMock()
|
||||
|
||||
expected_payload: dict[str, list[dict[str, str]]] = {"data": []}
|
||||
with patch(
|
||||
"superset.common.query_actions._get_full", return_value=expected_payload
|
||||
) as mock_get_full:
|
||||
assert _get_drill_detail(query_context, query_obj) is expected_payload
|
||||
mock_get_full.assert_called_once()
|
||||
@@ -653,6 +653,15 @@ def test_semantic_view_data(
|
||||
assert data["table_name"] == "Orders View"
|
||||
assert data["datasource_name"] == "Orders View"
|
||||
assert data["offset"] == 0
|
||||
# Semantic views don't model raw rows, so neither samples nor
|
||||
# drill-to-detail are available.
|
||||
assert data["supports_samples"] is False
|
||||
assert data["supports_drill_to_detail"] is False
|
||||
|
||||
|
||||
def test_semantic_view_supports_samples_is_false() -> None:
|
||||
"""The class-level flag opts SemanticView out of the Samples affordance."""
|
||||
assert SemanticView.supports_samples is False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -767,6 +776,11 @@ def test_semantic_view_data_populates_time_grain_sqla(
|
||||
assert grain_durations == sorted(["PT1H", "P1D", "P1M"])
|
||||
|
||||
|
||||
def test_semantic_view_supports_drill_to_detail_is_false() -> None:
|
||||
"""The class-level flag opts SemanticView out of Drill to detail."""
|
||||
assert SemanticView.supports_drill_to_detail is False
|
||||
|
||||
|
||||
def test_semantic_view_get_query_result(
|
||||
mock_implementation: MagicMock,
|
||||
) -> None:
|
||||
|
||||
@@ -33,6 +33,10 @@ from superset.utils.screenshots import (
|
||||
|
||||
BASE_SCREENSHOT_PATH = "superset.utils.screenshots.BaseScreenshot"
|
||||
|
||||
# A minimal valid PNG header, used wherever a test needs bytes that pass
|
||||
# ScreenshotCachePayload's image validation.
|
||||
FAKE_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"fake-png-body"
|
||||
|
||||
|
||||
class MockCache:
|
||||
"""A class to manage screenshot cache."""
|
||||
@@ -92,7 +96,7 @@ def test_get_cache_key(app_context, screenshot_obj):
|
||||
def test_get_from_cache_key(mocker: MockerFixture, screenshot_obj):
|
||||
"""get_from_cache_key should always return a ScreenshotCachePayload Object"""
|
||||
# backwards compatibility test for retrieving plain bytes
|
||||
fake_bytes = b"fake_screenshot_data"
|
||||
fake_bytes = FAKE_PNG_BYTES
|
||||
BaseScreenshot.cache = MockCache()
|
||||
BaseScreenshot.cache.set("key", fake_bytes)
|
||||
cache_payload = screenshot_obj.get_from_cache_key("key")
|
||||
@@ -108,10 +112,10 @@ class TestComputeAndCache:
|
||||
BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None
|
||||
)
|
||||
get_screenshot = mocker.patch(
|
||||
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"new_image_data"
|
||||
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=FAKE_PNG_BYTES
|
||||
)
|
||||
resize_image = mocker.patch(
|
||||
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data"
|
||||
BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES
|
||||
)
|
||||
BaseScreenshot.cache = MockCache()
|
||||
return {
|
||||
|
||||
@@ -37,6 +37,10 @@ from superset.utils.screenshots import (
|
||||
BASE_SCREENSHOT_PATH = "superset.utils.screenshots.BaseScreenshot"
|
||||
DISTRIBUTED_LOCK_PATH = "superset.utils.screenshots.DistributedLock"
|
||||
|
||||
# A minimal valid PNG header, used wherever a test needs bytes that pass
|
||||
# ScreenshotCachePayload's image validation.
|
||||
FAKE_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"fake-png-body"
|
||||
|
||||
|
||||
class MockCache:
|
||||
"""A class to manage screenshot cache for testing."""
|
||||
@@ -83,11 +87,11 @@ class TestCacheOnlyOnSuccess:
|
||||
mocker.patch(DISTRIBUTED_LOCK_PATH)
|
||||
mocker.patch(BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None)
|
||||
get_screenshot = mocker.patch(
|
||||
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"image_data"
|
||||
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=FAKE_PNG_BYTES
|
||||
)
|
||||
# Mock resize_image to avoid PIL errors with fake image data
|
||||
mocker.patch(
|
||||
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data"
|
||||
BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES
|
||||
)
|
||||
BaseScreenshot.cache = MockCache()
|
||||
return get_screenshot
|
||||
@@ -161,13 +165,15 @@ class TestCacheOnlyOnSuccess:
|
||||
screenshot_obj: BaseScreenshot,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
"""Empty bytes from get_screenshot must set ERROR, not leave COMPUTING."""
|
||||
"""Empty bytes from get_screenshot must set ERROR, not leave COMPUTING,
|
||||
and must log a WARNING that includes the cache key."""
|
||||
mocker.patch(DISTRIBUTED_LOCK_PATH)
|
||||
mocker.patch(BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None)
|
||||
mocker.patch(
|
||||
BASE_SCREENSHOT_PATH + ".get_screenshot",
|
||||
return_value=b"",
|
||||
)
|
||||
mock_logger = mocker.patch("superset.utils.screenshots.logger")
|
||||
BaseScreenshot.cache = MockCache()
|
||||
|
||||
screenshot_obj.compute_and_cache(user=mock_user, force=True)
|
||||
@@ -177,6 +183,43 @@ class TestCacheOnlyOnSuccess:
|
||||
assert cached_value is not None
|
||||
assert cached_value["status"] == "Error"
|
||||
assert cached_value.get("image") is None
|
||||
assert any(
|
||||
cache_key in call.args and "empty" in call.args
|
||||
for call in mock_logger.warning.call_args_list
|
||||
)
|
||||
|
||||
def test_cache_error_status_when_screenshot_returns_garbage_bytes(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
screenshot_obj: BaseScreenshot,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
"""Non-empty bytes without a valid image header must set ERROR, not be
|
||||
cached as a success, and must log a WARNING that includes the cache key."""
|
||||
mocker.patch(DISTRIBUTED_LOCK_PATH)
|
||||
mocker.patch(BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None)
|
||||
mocker.patch(
|
||||
BASE_SCREENSHOT_PATH + ".get_screenshot",
|
||||
return_value=b"this-is-not-a-real-image",
|
||||
)
|
||||
mocker.patch(
|
||||
BASE_SCREENSHOT_PATH + ".resize_image",
|
||||
return_value=b"this-is-not-a-real-image",
|
||||
)
|
||||
mock_logger = mocker.patch("superset.utils.screenshots.logger")
|
||||
BaseScreenshot.cache = MockCache()
|
||||
|
||||
screenshot_obj.compute_and_cache(user=mock_user, force=True)
|
||||
|
||||
cache_key = screenshot_obj.get_cache_key()
|
||||
cached_value = BaseScreenshot.cache.get(cache_key)
|
||||
assert cached_value is not None
|
||||
assert cached_value["status"] == "Error"
|
||||
assert cached_value.get("image") is None
|
||||
assert any(
|
||||
cache_key in call.args and "undecodable" in call.args
|
||||
for call in mock_logger.warning.call_args_list
|
||||
)
|
||||
|
||||
def test_computing_status_written_to_cache_early(
|
||||
self,
|
||||
@@ -197,14 +240,14 @@ class TestCacheOnlyOnSuccess:
|
||||
"Cache should be set to COMPUTING before screenshot starts"
|
||||
)
|
||||
assert cached_value["status"] == "Computing"
|
||||
return b"image_data"
|
||||
return FAKE_PNG_BYTES
|
||||
|
||||
mocker.patch(
|
||||
BASE_SCREENSHOT_PATH + ".get_screenshot",
|
||||
side_effect=check_cache_during_screenshot,
|
||||
)
|
||||
mocker.patch(
|
||||
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data"
|
||||
BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES
|
||||
)
|
||||
|
||||
screenshot_obj.compute_and_cache(user=mock_user, force=True)
|
||||
@@ -429,11 +472,11 @@ class TestIntegrationCacheBugFix:
|
||||
BaseScreenshot.cache.set(cache_key, stale_payload.to_dict())
|
||||
|
||||
mocker.patch(
|
||||
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"recovered_image"
|
||||
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=FAKE_PNG_BYTES
|
||||
)
|
||||
# Mock resize to avoid PIL errors
|
||||
mocker.patch(
|
||||
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image"
|
||||
BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES
|
||||
)
|
||||
|
||||
# Should trigger task because COMPUTING is stale
|
||||
@@ -482,3 +525,72 @@ class TestIntegrationCacheBugFix:
|
||||
|
||||
assert payload._image == old_image
|
||||
assert payload.status == StatusValues.COMPUTING
|
||||
|
||||
|
||||
class TestReadSideImageValidation:
|
||||
"""A cached payload that claims a successful screenshot (status UPDATED)
|
||||
but carries invalid image bytes must be served as a cache miss, not
|
||||
returned to the caller — this is what the dashboard/chart screenshot
|
||||
endpoints call to fetch bytes to serve."""
|
||||
|
||||
def test_zero_byte_image_is_treated_as_cache_miss(
|
||||
self, mocker: MockerFixture, screenshot_obj: BaseScreenshot
|
||||
) -> None:
|
||||
mock_logger = mocker.patch("superset.utils.screenshots.logger")
|
||||
BaseScreenshot.cache = MockCache()
|
||||
cache_key = screenshot_obj.get_cache_key()
|
||||
stale_payload = ScreenshotCachePayload(image=b"", status=StatusValues.UPDATED)
|
||||
BaseScreenshot.cache.set(cache_key, stale_payload.to_dict())
|
||||
|
||||
result = screenshot_obj.get_from_cache_key(cache_key)
|
||||
|
||||
assert result is None
|
||||
assert any(
|
||||
cache_key in call.args and "empty" in call.args
|
||||
for call in mock_logger.warning.call_args_list
|
||||
)
|
||||
|
||||
def test_garbage_bytes_image_is_treated_as_cache_miss(
|
||||
self, mocker: MockerFixture, screenshot_obj: BaseScreenshot
|
||||
) -> None:
|
||||
mock_logger = mocker.patch("superset.utils.screenshots.logger")
|
||||
BaseScreenshot.cache = MockCache()
|
||||
cache_key = screenshot_obj.get_cache_key()
|
||||
garbage_payload = ScreenshotCachePayload(image=b"not-an-image-at-all")
|
||||
BaseScreenshot.cache.set(cache_key, garbage_payload.to_dict())
|
||||
|
||||
result = screenshot_obj.get_from_cache_key(cache_key)
|
||||
|
||||
assert result is None
|
||||
assert any(
|
||||
cache_key in call.args and "undecodable" in call.args
|
||||
for call in mock_logger.warning.call_args_list
|
||||
)
|
||||
|
||||
def test_valid_image_is_served_normally(
|
||||
self, screenshot_obj: BaseScreenshot
|
||||
) -> None:
|
||||
BaseScreenshot.cache = MockCache()
|
||||
cache_key = screenshot_obj.get_cache_key()
|
||||
valid_payload = ScreenshotCachePayload(image=FAKE_PNG_BYTES)
|
||||
BaseScreenshot.cache.set(cache_key, valid_payload.to_dict())
|
||||
|
||||
result = screenshot_obj.get_from_cache_key(cache_key)
|
||||
|
||||
assert result is not None
|
||||
assert result.get_image().read() == FAKE_PNG_BYTES
|
||||
|
||||
def test_pending_status_with_no_image_is_not_rejected(
|
||||
self, screenshot_obj: BaseScreenshot
|
||||
) -> None:
|
||||
"""Non-UPDATED statuses (e.g. PENDING/COMPUTING) aren't claiming a
|
||||
successful screenshot, so they should be returned as-is."""
|
||||
BaseScreenshot.cache = MockCache()
|
||||
cache_key = screenshot_obj.get_cache_key()
|
||||
pending_payload = ScreenshotCachePayload(status=StatusValues.PENDING)
|
||||
BaseScreenshot.cache.set(cache_key, pending_payload.to_dict())
|
||||
|
||||
result = screenshot_obj.get_from_cache_key(cache_key)
|
||||
|
||||
assert result is not None
|
||||
assert result.status == StatusValues.PENDING
|
||||
|
||||
@@ -25,8 +25,11 @@ from superset.utils.screenshot_utils import (
|
||||
combine_screenshot_tiles,
|
||||
resolve_screenshot_task_budget_seconds,
|
||||
SCREENSHOT_TASK_BUDGET_MAX_MARGIN_SECONDS,
|
||||
ScreenshotTaskBudgetExceededError,
|
||||
SCROLL_SETTLE_TIMEOUT_MS,
|
||||
take_tiled_screenshot,
|
||||
TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS,
|
||||
TiledScreenshotBudgetExceededError,
|
||||
)
|
||||
|
||||
|
||||
@@ -453,12 +456,17 @@ class TestTakeTiledScreenshot:
|
||||
assert warning_args[2] == 1 # count of unready chart containers
|
||||
assert warning_args[3] == 1 # tile index
|
||||
assert warning_args[4] == 3 # total tiles
|
||||
assert warning_args[5] == 30 # load_wait
|
||||
assert warning_args[6] == "" # no log_context passed
|
||||
assert warning_args[5] == 30 # tile_load_wait (uncapped: budget remains)
|
||||
assert warning_args[6] == 30 # requested load_wait
|
||||
assert isinstance(warning_args[7], float) # total elapsed vs budget
|
||||
assert warning_args[8] == 1440 # total budget (fixed fallback)
|
||||
assert warning_args[9] == 0 # tiles captured so far
|
||||
assert warning_args[10] == 3 # total tiles
|
||||
assert warning_args[11] == "" # no log_context passed
|
||||
# Diagnostic payload identifies chart id AND the state it's stuck in
|
||||
# (spinner mounted vs nothing mounted vs waiting-on-database) so a
|
||||
# slow query can be told apart from the virtualization race.
|
||||
assert warning_args[7] == [{"chartId": "42", "state": "waiting_on_database"}]
|
||||
assert warning_args[12] == [{"chartId": "42", "state": "waiting_on_database"}]
|
||||
|
||||
def test_timeout_warning_includes_log_context(self, mock_page):
|
||||
"""The log context (e.g. report execution id) is threaded through for
|
||||
@@ -484,7 +492,7 @@ class TestTakeTiledScreenshot:
|
||||
)
|
||||
|
||||
warning_args = mock_logger.warning.call_args[0]
|
||||
assert warning_args[6] == " [execution_id=abc-123]"
|
||||
assert warning_args[11] == " [execution_id=abc-123]"
|
||||
|
||||
def test_chart_holder_with_nothing_mounted_blocks_wait(self, mock_page):
|
||||
"""Regression test for the vacuous-pass race (PR #39895).
|
||||
@@ -646,3 +654,311 @@ class TestTakeTiledScreenshot:
|
||||
|
||||
sig = inspect.signature(take_tiled_screenshot)
|
||||
assert sig.parameters["animation_wait"].default == 0
|
||||
|
||||
|
||||
class TestTileWaitBudget:
|
||||
"""The tiled operation's cumulative per-tile waits are capped by one
|
||||
wall-clock budget derived from the running Celery task's own time limit
|
||||
(resolve_screenshot_task_budget_seconds), falling back to a fixed total
|
||||
ceiling outside Celery because per-tile waits accumulate."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_page(self):
|
||||
"""Create a mock Playwright page object for a 3-tile (5000px) dashboard."""
|
||||
page = MagicMock()
|
||||
element = MagicMock()
|
||||
page.locator.return_value = element
|
||||
page.evaluate.return_value = {
|
||||
"height": 5000,
|
||||
"top": 100,
|
||||
"left": 50,
|
||||
"width": 800,
|
||||
}
|
||||
page.screenshot.return_value = b"fake_screenshot_data"
|
||||
return page
|
||||
|
||||
class _FakeClock:
|
||||
"""Stateful monotonic() stand-in the test advances explicitly.
|
||||
|
||||
Robust to how many times the code under test samples the clock per
|
||||
tile (budget check, per-tile wait timing, animation budget) -- only
|
||||
explicit advances move time forward.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.now = 0.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
def test_budget_error_is_task_budget_error_subclass(self):
|
||||
"""Callers can catch the whole budget-error family with the base
|
||||
ScreenshotTaskBudgetExceededError type."""
|
||||
assert issubclass(
|
||||
TiledScreenshotBudgetExceededError, ScreenshotTaskBudgetExceededError
|
||||
)
|
||||
|
||||
def test_per_tile_wait_shrinks_as_budget_depletes(self, mock_page, monkeypatch):
|
||||
"""Each tile's readiness-wait timeout is capped at the remaining budget."""
|
||||
monkeypatch.setattr(
|
||||
"superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501
|
||||
1000,
|
||||
)
|
||||
clock = self._FakeClock()
|
||||
# Simulate slow tiles: the readiness wait itself consumes wall time,
|
||||
# so each subsequent tile sees less remaining budget.
|
||||
wait_durations = iter([950, 40, 5])
|
||||
|
||||
def slow_wait(*args, **kwargs):
|
||||
clock.now += next(wait_durations)
|
||||
|
||||
mock_page.wait_for_function.side_effect = slow_wait
|
||||
|
||||
with patch("superset.utils.screenshot_utils.current_task", None):
|
||||
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
|
||||
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
|
||||
result = take_tiled_screenshot(
|
||||
mock_page, "dashboard", tile_height=2000, load_wait=100
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
timeouts = [
|
||||
call[1]["timeout"] for call in mock_page.wait_for_function.call_args_list
|
||||
]
|
||||
# remaining budget at each tile's wait: 1000, 50, 10 seconds
|
||||
# -> capped timeouts shrink
|
||||
assert timeouts == [100 * 1000, 50 * 1000, 10 * 1000]
|
||||
assert timeouts == sorted(timeouts, reverse=True)
|
||||
|
||||
def test_readiness_wait_uses_budget_recomputed_after_scroll_settle(
|
||||
self, mock_page, monkeypatch
|
||||
):
|
||||
"""The readiness-wait timeout must be capped using the budget
|
||||
recomputed *after* the scroll-settle sleep, not the stale value from
|
||||
before it -- otherwise each tile could overrun the total budget by up
|
||||
to one settle interval."""
|
||||
monkeypatch.setattr(
|
||||
"superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501
|
||||
1000,
|
||||
)
|
||||
# A single-tile dashboard to keep the scenario simple.
|
||||
mock_page.evaluate.return_value = {
|
||||
"height": 1000,
|
||||
"top": 100,
|
||||
"left": 50,
|
||||
"width": 800,
|
||||
}
|
||||
clock = self._FakeClock()
|
||||
# The scroll-settle sleep itself consumes 950s of wall-clock time,
|
||||
# leaving only 50s of the 1000s budget by the time the readiness
|
||||
# wait is capped.
|
||||
mock_page.wait_for_timeout.side_effect = lambda *args, **kwargs: setattr(
|
||||
clock, "now", clock.now + 950
|
||||
)
|
||||
|
||||
with patch("superset.utils.screenshot_utils.current_task", None):
|
||||
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
|
||||
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
|
||||
take_tiled_screenshot(
|
||||
mock_page, "dashboard", tile_height=2000, load_wait=999
|
||||
)
|
||||
|
||||
timeout = mock_page.wait_for_function.call_args_list[0][1]["timeout"]
|
||||
# Must reflect the post-settle remaining budget (50s), not the
|
||||
# stale pre-settle value (1000s, which would have let load_wait's
|
||||
# full 999s through uncapped).
|
||||
assert timeout == 50 * 1000
|
||||
|
||||
def test_budget_exhausted_raises_and_stops_capturing(self, mock_page, monkeypatch):
|
||||
"""Exhausting the budget aborts cleanly instead of capturing unchecked."""
|
||||
monkeypatch.setattr(
|
||||
"superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501
|
||||
1000,
|
||||
)
|
||||
clock = self._FakeClock()
|
||||
# Tile 0's readiness wait consumes the whole budget; tile 1's budget
|
||||
# check then sees remaining <= 0 and raises before capturing.
|
||||
mock_page.wait_for_function.side_effect = lambda *args, **kwargs: setattr(
|
||||
clock, "now", 1000.0
|
||||
)
|
||||
|
||||
with patch("superset.utils.screenshot_utils.current_task", None):
|
||||
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
|
||||
with patch(
|
||||
"superset.utils.screenshot_utils.combine_screenshot_tiles"
|
||||
) as mock_combine:
|
||||
with patch("superset.utils.screenshot_utils.logger") as mock_logger:
|
||||
with pytest.raises(TiledScreenshotBudgetExceededError):
|
||||
take_tiled_screenshot(
|
||||
mock_page, "dashboard", tile_height=2000, load_wait=100
|
||||
)
|
||||
|
||||
# Only the first tile was captured before the budget ran out.
|
||||
assert mock_page.screenshot.call_count == 1
|
||||
# Tiles were never combined -- the function raised before that point.
|
||||
mock_combine.assert_not_called()
|
||||
|
||||
# Budget exhaustion is a customer chart-loading issue, not a Superset
|
||||
# system fault, so it must log at WARNING (not ERROR) -- consistent
|
||||
# with the #38130/#38441 precedent for screenshot timeout logging.
|
||||
assert mock_logger.error.call_count == 0
|
||||
mock_logger.warning.assert_called_once()
|
||||
warning_args = mock_logger.warning.call_args[0]
|
||||
assert "budget exhausted" in warning_args[0]
|
||||
# tile index, tiles total, tiles captured, tiles total,
|
||||
# elapsed seconds, budget seconds, log-context suffix
|
||||
assert warning_args[1] == 2
|
||||
assert warning_args[2] == 3
|
||||
assert warning_args[3] == 1
|
||||
assert warning_args[4] == 3
|
||||
assert warning_args[5] == 1000
|
||||
assert warning_args[6] == 1000
|
||||
assert warning_args[7] == ""
|
||||
|
||||
def test_budget_exhausted_warning_includes_log_context(
|
||||
self, mock_page, monkeypatch
|
||||
):
|
||||
"""log_context (e.g. report execution id) is appended to the warning."""
|
||||
monkeypatch.setattr(
|
||||
"superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501
|
||||
1000,
|
||||
)
|
||||
clock = self._FakeClock()
|
||||
# Tile 0's readiness wait consumes the whole budget; tile 1's budget
|
||||
# check then sees remaining <= 0 and raises.
|
||||
mock_page.wait_for_function.side_effect = lambda *args, **kwargs: setattr(
|
||||
clock, "now", 1000.0
|
||||
)
|
||||
with patch("superset.utils.screenshot_utils.current_task", None):
|
||||
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
|
||||
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
|
||||
with patch("superset.utils.screenshot_utils.logger") as mock_logger:
|
||||
with pytest.raises(TiledScreenshotBudgetExceededError):
|
||||
take_tiled_screenshot(
|
||||
mock_page,
|
||||
"dashboard",
|
||||
tile_height=2000,
|
||||
load_wait=100,
|
||||
log_context="execution_id=abc-123",
|
||||
)
|
||||
|
||||
warning_args = mock_logger.warning.call_args[0]
|
||||
assert warning_args[-1] == " [execution_id=abc-123]"
|
||||
|
||||
def test_budget_exhausted_before_first_tile_raises_without_capture(
|
||||
self, mock_page, monkeypatch
|
||||
):
|
||||
"""No budget floor: a budget already exhausted by setup (element
|
||||
lookup/dimension probing) raises before the first tile is captured,
|
||||
matching the non-tiled path's raise-before-capture semantics."""
|
||||
monkeypatch.setattr(
|
||||
"superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501
|
||||
1000,
|
||||
)
|
||||
clock = self._FakeClock()
|
||||
# The dashboard-dimension evaluate() itself consumes the whole budget.
|
||||
original_return = {"height": 5000, "top": 100, "left": 50, "width": 800}
|
||||
|
||||
def slow_evaluate(*args, **kwargs):
|
||||
clock.now = 1000.0
|
||||
return original_return
|
||||
|
||||
mock_page.evaluate.side_effect = slow_evaluate
|
||||
|
||||
with patch("superset.utils.screenshot_utils.current_task", None):
|
||||
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
|
||||
with patch(
|
||||
"superset.utils.screenshot_utils.combine_screenshot_tiles"
|
||||
) as mock_combine:
|
||||
with pytest.raises(TiledScreenshotBudgetExceededError):
|
||||
take_tiled_screenshot(
|
||||
mock_page, "dashboard", tile_height=2000, load_wait=100
|
||||
)
|
||||
|
||||
mock_page.screenshot.assert_not_called()
|
||||
mock_combine.assert_not_called()
|
||||
|
||||
def test_no_celery_context_uses_fixed_total_fallback(self, mock_page):
|
||||
"""Outside Celery the helper returns None; the tiled path must fall
|
||||
back to the fixed total ceiling rather than running uncapped, because
|
||||
per-tile waits accumulate across tiles."""
|
||||
clock = self._FakeClock()
|
||||
with patch("superset.utils.screenshot_utils.current_task", None):
|
||||
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
|
||||
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
|
||||
take_tiled_screenshot(
|
||||
mock_page,
|
||||
"dashboard",
|
||||
tile_height=2000,
|
||||
load_wait=10_000, # deliberately above the fallback
|
||||
)
|
||||
|
||||
first_timeout = mock_page.wait_for_function.call_args_list[0][1]["timeout"]
|
||||
assert first_timeout == TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS * 1000
|
||||
|
||||
def test_derived_task_budget_caps_tile_wait(self, mock_page):
|
||||
"""Inside Celery, the tiled path caps waits using the same
|
||||
task-derived budget as the non-tiled path (helper reuse, #42427)."""
|
||||
task = MagicMock()
|
||||
task.request.timelimit = (120, None) # (hard, soft): 120s hard limit
|
||||
|
||||
clock = self._FakeClock()
|
||||
with patch("superset.utils.screenshot_utils.current_task", task):
|
||||
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
|
||||
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
|
||||
take_tiled_screenshot(
|
||||
mock_page, "dashboard", tile_height=2000, load_wait=200
|
||||
)
|
||||
|
||||
# margin = min(300, 120 * 0.2) = 24; budget = 120 - 24 = 96
|
||||
first_timeout = mock_page.wait_for_function.call_args_list[0][1]["timeout"]
|
||||
assert first_timeout == 96 * 1000
|
||||
assert first_timeout < 200 * 1000
|
||||
|
||||
def test_fast_dashboard_matches_default_behavior(self, mock_page):
|
||||
"""Well under budget, waits are not capped and behavior is unchanged."""
|
||||
with patch("superset.utils.screenshot_utils.current_task", None):
|
||||
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
|
||||
result = take_tiled_screenshot(
|
||||
mock_page,
|
||||
"dashboard",
|
||||
tile_height=2000,
|
||||
load_wait=30,
|
||||
animation_wait=5,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert mock_page.screenshot.call_count == 3
|
||||
|
||||
for call in mock_page.wait_for_function.call_args_list:
|
||||
assert call[1]["timeout"] == 30 * 1000
|
||||
|
||||
animation_calls = [
|
||||
call
|
||||
for call in mock_page.wait_for_timeout.call_args_list
|
||||
if call[0][0] == 5 * 1000
|
||||
]
|
||||
assert len(animation_calls) == 3
|
||||
|
||||
def test_per_tile_timing_debug_line_logged(self, mock_page):
|
||||
"""Each tile logs a DEBUG timing breakdown (readiness wait, animation
|
||||
wait) so slow dashboards can be profiled from logs alone."""
|
||||
with patch("superset.utils.screenshot_utils.current_task", None):
|
||||
with patch("superset.utils.screenshot_utils.logger") as mock_logger:
|
||||
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
|
||||
take_tiled_screenshot(
|
||||
mock_page,
|
||||
"dashboard",
|
||||
tile_height=2000,
|
||||
log_context="cache_key=xyz",
|
||||
)
|
||||
|
||||
timing_calls = [
|
||||
call for call in mock_logger.debug.call_args_list if "timing" in call[0][0]
|
||||
]
|
||||
assert len(timing_calls) == 3
|
||||
for i, call in enumerate(timing_calls):
|
||||
args = call[0]
|
||||
assert args[1] == i + 1 # tile index
|
||||
assert args[2] == 3 # total tiles
|
||||
assert args[-1] == " [cache_key=xyz]"
|
||||
|
||||
@@ -919,12 +919,185 @@ class TestWebDriverPlaywrightErrorHandling:
|
||||
)
|
||||
|
||||
assert result == b"fake_screenshot"
|
||||
mock_logger.warning.assert_any_call(
|
||||
"Could not determine dashboard height for element %s at url %s; "
|
||||
"falling back to standard screenshot behavior",
|
||||
# chart_count (1) is well below the tiling threshold (20), so this is
|
||||
# the benign/expected case and must not be logged as a WARNING.
|
||||
mock_logger.debug.assert_any_call(
|
||||
"Could not determine dashboard height for element %s "
|
||||
"at url %s (%s chart containers found); %s",
|
||||
"dashboard",
|
||||
"http://example.com",
|
||||
1,
|
||||
"falling back to standard screenshot behavior",
|
||||
)
|
||||
assert not any(
|
||||
call.args and "Could not determine dashboard height" in call.args[0]
|
||||
for call in mock_logger.warning.call_args_list
|
||||
)
|
||||
|
||||
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
||||
@patch("superset.utils.webdriver._browser_manager")
|
||||
@patch("superset.utils.webdriver.logger")
|
||||
@patch("superset.utils.webdriver.take_tiled_screenshot")
|
||||
def test_unknown_height_does_not_veto_tiling_for_large_dashboard(
|
||||
self, mock_take_tiled, mock_logger, mock_browser_manager
|
||||
):
|
||||
"""
|
||||
A large dashboard (by chart_count) whose height can't be measured
|
||||
must still attempt tiling instead of being silently downgraded to a
|
||||
standard screenshot, since below-the-fold charts may not have
|
||||
rendered without the scroll-driven tiling pass.
|
||||
"""
|
||||
mock_user = MagicMock()
|
||||
mock_user.username = "test_user"
|
||||
|
||||
mock_browser = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_page = MagicMock()
|
||||
mock_element = MagicMock()
|
||||
mock_chart_container = MagicMock()
|
||||
|
||||
mock_browser_manager.get_browser.return_value = mock_browser
|
||||
mock_browser.new_context.return_value = mock_context
|
||||
mock_context.new_page.return_value = mock_page
|
||||
|
||||
def locator_side_effect(selector):
|
||||
if selector == ".chart-container":
|
||||
locator = MagicMock()
|
||||
locator.all.return_value = [mock_chart_container]
|
||||
return locator
|
||||
return mock_element
|
||||
|
||||
mock_page.locator.side_effect = locator_side_effect
|
||||
mock_element.wait_for.return_value = None
|
||||
mock_chart_container.wait_for.return_value = None
|
||||
mock_page.wait_for_timeout.return_value = None
|
||||
mock_take_tiled.return_value = b"tiled_screenshot"
|
||||
|
||||
def evaluate_side_effect(script):
|
||||
if script == 'document.querySelectorAll(".chart-container").length':
|
||||
return 25 # chart_count >= threshold
|
||||
if "const target = document.querySelector" in script:
|
||||
return 0 # height could not be determined
|
||||
return None
|
||||
|
||||
mock_page.evaluate.side_effect = evaluate_side_effect
|
||||
|
||||
with patch("superset.utils.webdriver.app") as mock_app:
|
||||
mock_app.config = {
|
||||
"WEBDRIVER_OPTION_ARGS": [],
|
||||
"WEBDRIVER_WINDOW": {"pixel_density": 1},
|
||||
"SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT": 30000,
|
||||
"SCREENSHOT_PLAYWRIGHT_WAIT_EVENT": "networkidle",
|
||||
"SCREENSHOT_SELENIUM_HEADSTART": 5,
|
||||
"SCREENSHOT_SELENIUM_ANIMATION_WAIT": 1,
|
||||
"SCREENSHOT_LOCATE_WAIT": 10,
|
||||
"SCREENSHOT_LOAD_WAIT": 10,
|
||||
"SCREENSHOT_WAIT_FOR_ERROR_MODAL_VISIBLE": 10,
|
||||
"SCREENSHOT_WAIT_FOR_ERROR_MODAL_INVISIBLE": 10,
|
||||
"SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False,
|
||||
"SCREENSHOT_TILED_ENABLED": True,
|
||||
"SCREENSHOT_TILED_CHART_THRESHOLD": 20,
|
||||
"SCREENSHOT_TILED_HEIGHT_THRESHOLD": 5000,
|
||||
"SCREENSHOT_TILED_VIEWPORT_HEIGHT": 600,
|
||||
}
|
||||
|
||||
with patch.object(WebDriverPlaywright, "auth") as mock_auth:
|
||||
mock_auth.return_value = mock_context
|
||||
|
||||
driver = WebDriverPlaywright("chrome")
|
||||
result = driver.get_screenshot(
|
||||
"http://example.com", "dashboard", mock_user
|
||||
)
|
||||
|
||||
assert result == b"tiled_screenshot"
|
||||
mock_take_tiled.assert_called_once()
|
||||
mock_logger.warning.assert_any_call(
|
||||
"Could not determine dashboard height for element %s "
|
||||
"at url %s (%s chart containers found); %s",
|
||||
"dashboard",
|
||||
"http://example.com",
|
||||
25,
|
||||
"attempting tiled screenshot anyway",
|
||||
)
|
||||
|
||||
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
||||
@patch("superset.utils.webdriver._browser_manager")
|
||||
@patch("superset.utils.webdriver.logger")
|
||||
def test_chart_container_timeout_logs_warning_with_progress_and_raises(
|
||||
self, mock_logger, mock_browser_manager
|
||||
):
|
||||
"""
|
||||
Timing out while waiting for `.chart-container` elements to draw must
|
||||
be logged as a WARNING (matching the other locate-wait timeouts in
|
||||
this method, and the customer-side-slowness convention established
|
||||
for these Playwright timeouts) with rendered/total progress, and must
|
||||
still fail the screenshot by re-raising.
|
||||
"""
|
||||
from superset.utils.webdriver import PlaywrightTimeout
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.username = "test_user"
|
||||
|
||||
mock_browser = MagicMock()
|
||||
mock_context = MagicMock()
|
||||
mock_page = MagicMock()
|
||||
mock_element = MagicMock()
|
||||
|
||||
mock_browser_manager.get_browser.return_value = mock_browser
|
||||
mock_browser.new_context.return_value = mock_context
|
||||
mock_context.new_page.return_value = mock_page
|
||||
|
||||
timeout = PlaywrightTimeout()
|
||||
rendered_ok = MagicMock()
|
||||
rendered_ok.wait_for.return_value = None
|
||||
never_renders = MagicMock()
|
||||
never_renders.wait_for.side_effect = timeout
|
||||
|
||||
def locator_side_effect(selector):
|
||||
if selector == ".chart-container":
|
||||
locator = MagicMock()
|
||||
locator.all.return_value = [rendered_ok, never_renders]
|
||||
return locator
|
||||
return mock_element
|
||||
|
||||
mock_page.locator.side_effect = locator_side_effect
|
||||
mock_element.wait_for.return_value = None
|
||||
|
||||
with patch("superset.utils.webdriver.app") as mock_app:
|
||||
mock_app.config = {
|
||||
"WEBDRIVER_OPTION_ARGS": [],
|
||||
"WEBDRIVER_WINDOW": {"pixel_density": 1},
|
||||
"SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT": 30000,
|
||||
"SCREENSHOT_PLAYWRIGHT_WAIT_EVENT": "networkidle",
|
||||
"SCREENSHOT_SELENIUM_HEADSTART": 5,
|
||||
"SCREENSHOT_SELENIUM_ANIMATION_WAIT": 1,
|
||||
"SCREENSHOT_LOCATE_WAIT": 10,
|
||||
"SCREENSHOT_LOAD_WAIT": 10,
|
||||
"SCREENSHOT_WAIT_FOR_ERROR_MODAL_VISIBLE": 10,
|
||||
"SCREENSHOT_WAIT_FOR_ERROR_MODAL_INVISIBLE": 10,
|
||||
"SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False,
|
||||
"SCREENSHOT_TILED_ENABLED": False,
|
||||
}
|
||||
|
||||
with patch.object(WebDriverPlaywright, "auth") as mock_auth:
|
||||
mock_auth.return_value = mock_context
|
||||
|
||||
driver = WebDriverPlaywright("chrome")
|
||||
with pytest.raises(PlaywrightTimeout) as exc_info:
|
||||
driver.get_screenshot(
|
||||
"http://example.com", "test-element", mock_user
|
||||
)
|
||||
|
||||
assert exc_info.value is timeout
|
||||
mock_logger.warning.assert_any_call(
|
||||
"Timed out waiting for chart containers to draw at url %s "
|
||||
"(%s of %s chart containers rendered before the timeout)",
|
||||
"http://example.com",
|
||||
1,
|
||||
2,
|
||||
exc_info=True,
|
||||
)
|
||||
mock_logger.exception.assert_not_called()
|
||||
|
||||
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
|
||||
@patch("superset.utils.webdriver._browser_manager")
|
||||
|
||||
@@ -310,3 +310,66 @@ def test_save_non_editor_with_editors_field_is_rejected(
|
||||
raw_save(_view_self())
|
||||
|
||||
mock_security_manager.raise_for_editorship.assert_called_once_with(mock_orm)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Datasource.samples
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch("superset.views.datasource.views._", lambda s: s)
|
||||
@patch("superset.views.datasource.views.get_samples")
|
||||
@patch("superset.views.datasource.views.json_error_response")
|
||||
@patch("superset.views.datasource.views.security_manager", new_callable=MagicMock)
|
||||
def test_samples_returns_400_for_unsupported_datasource_type(
|
||||
mock_security_manager: MagicMock,
|
||||
mock_json_error_response: MagicMock,
|
||||
mock_get_samples: MagicMock,
|
||||
) -> None:
|
||||
"""Semantic views can't return raw samples — endpoint should refuse with 400."""
|
||||
from flask import Flask
|
||||
|
||||
mock_security_manager.is_guest_user.return_value = False
|
||||
mock_json_error_response.return_value = "error-response"
|
||||
|
||||
raw_samples = _get_view_func("samples")
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/datasource/samples?datasource_type=semantic_view&datasource_id=1",
|
||||
method="POST",
|
||||
json={},
|
||||
):
|
||||
result = raw_samples(_view_self())
|
||||
|
||||
assert result == "error-response"
|
||||
mock_json_error_response.assert_called_once()
|
||||
_, kwargs = mock_json_error_response.call_args
|
||||
assert kwargs.get("status") == 400
|
||||
# The bail-out must happen before any sample fetching is attempted.
|
||||
mock_get_samples.assert_not_called()
|
||||
|
||||
|
||||
@patch("superset.views.datasource.views.get_samples")
|
||||
@patch("superset.views.datasource.views.security_manager", new_callable=MagicMock)
|
||||
def test_samples_proceeds_for_supported_datasource_type(
|
||||
mock_security_manager: MagicMock,
|
||||
mock_get_samples: MagicMock,
|
||||
) -> None:
|
||||
"""A `query` datasource (supports_samples=True) bypasses the 400 short-circuit."""
|
||||
from flask import Flask
|
||||
|
||||
mock_security_manager.is_guest_user.return_value = False
|
||||
mock_get_samples.return_value = {"rows": []}
|
||||
|
||||
view = _view_self()
|
||||
raw_samples = _get_view_func("samples")
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/datasource/samples?datasource_type=query&datasource_id=1",
|
||||
method="POST",
|
||||
json={},
|
||||
):
|
||||
raw_samples(view)
|
||||
|
||||
mock_get_samples.assert_called_once()
|
||||
view.json_response.assert_called_once_with({"result": {"rows": []}})
|
||||
|
||||
Reference in New Issue
Block a user