mirror of
https://github.com/apache/superset.git
synced 2026-07-17 20:25:35 +00:00
Compare commits
7 Commits
chore/remo
...
fix/jinja-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3712a308b3 | ||
|
|
59cf883cfa | ||
|
|
562533e6a0 | ||
|
|
8603048518 | ||
|
|
635b18103d | ||
|
|
d57569c54a | ||
|
|
409605de70 |
@@ -135,7 +135,7 @@ Superset sends an HTTP POST with `Content-Type: application/json`:
|
||||
}
|
||||
```
|
||||
|
||||
When a report includes file attachments (CSV, PDF, or PNG screenshots), the request is sent as `multipart/form-data` instead. In that case, each top-level payload field (`name`, `text`, `description`, `url`) becomes its own form field, and nested structures like `header` are serialized as a JSON-encoded string in their own field. Every attachment is added as a repeated form field named `files`:
|
||||
When a report includes file attachments (CSV, Excel, PDF, or PNG screenshots), the request is sent as `multipart/form-data` instead. In that case, each top-level payload field (`name`, `text`, `description`, `url`) becomes its own form field, and nested structures like `header` are serialized as a JSON-encoded string in their own field. Every attachment is added as a repeated form field named `files`:
|
||||
|
||||
```
|
||||
POST /webhook HTTP/1.1
|
||||
|
||||
@@ -75,8 +75,12 @@ export default function EchartsMixedTimeseries({
|
||||
return {
|
||||
dataMask: {
|
||||
extraFormData: {
|
||||
// An empty lookup result means the clicked series could not be
|
||||
// resolved through the label map; emitting column filters anyway
|
||||
// would produce bogus `IS NULL` clauses (an empty array makes the
|
||||
// `every` below vacuously true), so clear the filters instead.
|
||||
filters:
|
||||
values.length === 0
|
||||
values.length === 0 || groupbyValues.length === 0
|
||||
? []
|
||||
: currentGroupBy.map((col, idx) => {
|
||||
const val: DataRecordValue[] = groupbyValues.map(v => {
|
||||
@@ -148,10 +152,11 @@ export default function EchartsMixedTimeseries({
|
||||
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
|
||||
const drillByFilters: BinaryQueryObjectFilterClause[] = [];
|
||||
const isFirst = isFirstQuery(seriesIndex);
|
||||
const values = [
|
||||
...(eventParams.name ? [eventParams.name] : []),
|
||||
...((isFirst ? labelMap : labelMapB)[eventParams.seriesName] || []),
|
||||
];
|
||||
const currentGroupBy = isFirst ? formData.groupby : formData.groupbyB;
|
||||
const seriesValues = (isFirst ? labelMap : labelMapB)[seriesName] || [];
|
||||
// Label map values may carry metric/offset labels ahead of the
|
||||
// dimension values — anchor from the tail, like getCrossFilterDataMask.
|
||||
const metricsCount = seriesValues.length - currentGroupBy.length;
|
||||
if (data && xAxis.type === AxisType.Time) {
|
||||
drillToDetailFilters.push({
|
||||
col:
|
||||
@@ -164,31 +169,39 @@ export default function EchartsMixedTimeseries({
|
||||
formattedVal: xValueFormatter(data[0]),
|
||||
});
|
||||
}
|
||||
[
|
||||
...(data && xAxis.type === AxisType.Category ? [xAxis.label] : []),
|
||||
...(isFirst ? formData.groupby : formData.groupbyB),
|
||||
].forEach((dimension, i) =>
|
||||
if (
|
||||
data &&
|
||||
xAxis.type === AxisType.Category &&
|
||||
eventParams.name != null
|
||||
) {
|
||||
drillToDetailFilters.push({
|
||||
col: dimension,
|
||||
col: xAxis.label,
|
||||
op: '==',
|
||||
val: values[i],
|
||||
formattedVal: String(values[i]),
|
||||
}),
|
||||
);
|
||||
|
||||
[...(isFirst ? formData.groupby : formData.groupbyB)].forEach(
|
||||
(dimension, i) =>
|
||||
val: eventParams.name,
|
||||
formattedVal: String(eventParams.name),
|
||||
});
|
||||
}
|
||||
if (metricsCount >= 0) {
|
||||
currentGroupBy.forEach((dimension, i) => {
|
||||
const value = seriesValues[metricsCount + i];
|
||||
drillToDetailFilters.push({
|
||||
col: dimension,
|
||||
op: '==',
|
||||
val: value,
|
||||
formattedVal: String(value),
|
||||
});
|
||||
drillByFilters.push({
|
||||
col: dimension,
|
||||
op: '==',
|
||||
val: values[i],
|
||||
formattedVal: formatSeriesName(values[i], {
|
||||
val: value,
|
||||
formattedVal: formatSeriesName(value, {
|
||||
timeFormatter: getTimeFormatter(formData.dateFormat),
|
||||
numberFormatter: getNumberFormatter(formData.numberFormat),
|
||||
coltype: coltypeMapping?.[getColumnLabel(dimension)],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
const hasCrossFilter =
|
||||
(isFirst && groupby.length > 0) || (!isFirst && groupbyB.length > 0);
|
||||
|
||||
|
||||
@@ -146,10 +146,14 @@ export default function transformProps(
|
||||
columnFormats = {},
|
||||
currencyCodeColumn,
|
||||
} = datasource;
|
||||
const { label_map: labelMap, detected_currency: backendDetectedCurrency } =
|
||||
// "raw" because these are keyed by the backend column labels; the maps
|
||||
// returned to the component are re-keyed by the rendered series names below.
|
||||
const { label_map: rawLabelMap, detected_currency: backendDetectedCurrency } =
|
||||
queriesData[0] as TimeseriesChartDataResponseResult;
|
||||
const { label_map: labelMapB, detected_currency: backendDetectedCurrencyB } =
|
||||
queriesData[1] as TimeseriesChartDataResponseResult;
|
||||
const {
|
||||
label_map: rawLabelMapB,
|
||||
detected_currency: backendDetectedCurrencyB,
|
||||
} = queriesData[1] as TimeseriesChartDataResponseResult;
|
||||
const data1 = (queriesData[0].data || []) as TimeseriesDataRecord[];
|
||||
const data2 = (queriesData[1].data || []) as TimeseriesDataRecord[];
|
||||
const annotationData = getAnnotationData(chartProps);
|
||||
@@ -438,6 +442,16 @@ export default function transformProps(
|
||||
const array = ensureIsArray(chartProps.rawFormData?.time_compare);
|
||||
const inverted = invert(verboseMap);
|
||||
|
||||
// The rendered ECharts series names are display names that can diverge from
|
||||
// the backend `label_map` keys: the metric display name is prepended when
|
||||
// dimensions are present, query identifiers may be appended, and verbose
|
||||
// names replace the raw column labels. Cross-filtering and drill lookups in
|
||||
// EchartsMixedTimeseries resolve the clicked series name through the label
|
||||
// map, so expose maps re-keyed by the rendered series names to keep those
|
||||
// lookups working (#41622).
|
||||
const displayLabelMap: Record<string, string[]> = {};
|
||||
const displayLabelMapB: Record<string, string[]> = {};
|
||||
|
||||
rawSeriesA.forEach(entry => {
|
||||
const entryName = String(entry.name || '');
|
||||
const seriesName = inverted[entryName] || entryName;
|
||||
@@ -457,13 +471,19 @@ export default function transformProps(
|
||||
// When no groupby, format as just the entry name with optional query identifier
|
||||
displayName = showQueryIdentifiers ? `${entryName} (Query A)` : entryName;
|
||||
}
|
||||
|
||||
const labelMapValues = rawLabelMap?.[seriesName];
|
||||
if (labelMapValues) {
|
||||
displayLabelMap[displayName] = labelMapValues;
|
||||
}
|
||||
|
||||
const axisFormatterConfig = getAxisFormatterConfig(yAxisIndex);
|
||||
|
||||
const seriesFormatter = getFormatter(
|
||||
axisFormatterConfig.customFormatters,
|
||||
axisFormatterConfig.formatter,
|
||||
metrics,
|
||||
labelMap?.[seriesName]?.[0],
|
||||
labelMapValues?.[0],
|
||||
!!contributionMode,
|
||||
);
|
||||
|
||||
@@ -514,7 +534,6 @@ export default function transformProps(
|
||||
rawSeriesB.forEach(entry => {
|
||||
const entryName = String(entry.name || '');
|
||||
const seriesEntry = inverted[entryName] || entryName;
|
||||
const seriesName = `${seriesEntry} (1)`;
|
||||
const colorScaleKey = getOriginalSeries(seriesEntry, array);
|
||||
|
||||
let displayName: string;
|
||||
@@ -531,13 +550,19 @@ export default function transformProps(
|
||||
// When no groupby, format as just the entry name with optional query identifier
|
||||
displayName = showQueryIdentifiers ? `${entryName} (Query B)` : entryName;
|
||||
}
|
||||
|
||||
const labelMapValuesB = rawLabelMapB?.[seriesEntry];
|
||||
if (labelMapValuesB) {
|
||||
displayLabelMapB[displayName] = labelMapValuesB;
|
||||
}
|
||||
|
||||
const axisFormatterConfig = getAxisFormatterConfig(yAxisIndexB);
|
||||
|
||||
const seriesFormatter = getFormatter(
|
||||
axisFormatterConfig.customFormatters,
|
||||
axisFormatterConfig.formatter,
|
||||
metricsB,
|
||||
labelMapB?.[seriesName]?.[0],
|
||||
labelMapValuesB?.[0],
|
||||
!!contributionMode,
|
||||
);
|
||||
|
||||
@@ -809,15 +834,15 @@ export default function transformProps(
|
||||
.filter(key => keys.includes(key))
|
||||
.forEach(key => {
|
||||
const value = forecastValues[key];
|
||||
// if there are no dimensions, key is a verbose name of a metric,
|
||||
// otherwise it is a comma separated string where the first part is metric name
|
||||
// The tooltip key is the rendered series name; resolve it through
|
||||
// the display-keyed maps, whose values lead with the raw metric
|
||||
// label both with and without dimensions. Fall back to the
|
||||
// verbose-name inversion for series absent from the maps.
|
||||
let formatterKey;
|
||||
if (primarySeries.has(key)) {
|
||||
formatterKey =
|
||||
groupby.length === 0 ? inverted[key] : labelMap[key]?.[0];
|
||||
formatterKey = displayLabelMap[key]?.[0] ?? inverted[key];
|
||||
} else {
|
||||
formatterKey =
|
||||
groupbyB.length === 0 ? inverted[key] : labelMapB[key]?.[0];
|
||||
formatterKey = displayLabelMapB[key]?.[0] ?? inverted[key];
|
||||
}
|
||||
const tooltipFormatter = getFormatter(
|
||||
customFormatters,
|
||||
@@ -912,8 +937,8 @@ export default function transformProps(
|
||||
echartOptions: mergedEchartOptions,
|
||||
setDataMask,
|
||||
emitCrossFilters,
|
||||
labelMap,
|
||||
labelMapB,
|
||||
labelMap: displayLabelMap,
|
||||
labelMapB: displayLabelMapB,
|
||||
groupby,
|
||||
groupbyB,
|
||||
seriesBreakdown: rawSeriesA.length,
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 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 } from '@testing-library/react';
|
||||
import {
|
||||
ChartDataResponseResult,
|
||||
DataRecord,
|
||||
VizType,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import transformProps from '../../src/MixedTimeseries/transformProps';
|
||||
import EchartsMixedTimeseries from '../../src/MixedTimeseries/EchartsMixedTimeseries';
|
||||
import {
|
||||
DEFAULT_FORM_DATA,
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps,
|
||||
} from '../../src/MixedTimeseries/types';
|
||||
import Echart from '../../src/components/Echart';
|
||||
import { createEchartsTimeseriesTestChartProps } from '../helpers';
|
||||
|
||||
jest.mock('../../src/components/Echart', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
const mockedEchart = jest.mocked(Echart);
|
||||
|
||||
const ts1 = 1704067200000;
|
||||
const ts2 = 1704153600000;
|
||||
|
||||
/**
|
||||
* Backend-shaped fixture: flattened column names keep the metric label, so
|
||||
* label_map values lead with the metric label ahead of the dimension value.
|
||||
*/
|
||||
function createQueryData(): ChartDataResponseResult {
|
||||
const rows = [
|
||||
{ ds: ts1, 'sum__num, boy': 1, 'sum__num, girl': 2 },
|
||||
{ ds: ts2, 'sum__num, boy': 3, 'sum__num, girl': 4 },
|
||||
];
|
||||
return {
|
||||
annotation_data: null,
|
||||
cache_key: null,
|
||||
cache_timeout: null,
|
||||
cached_dttm: null,
|
||||
queried_dttm: null,
|
||||
data: rows as DataRecord[],
|
||||
colnames: ['ds', 'sum__num, boy', 'sum__num, girl'],
|
||||
coltypes: [
|
||||
GenericDataType.Temporal,
|
||||
GenericDataType.Numeric,
|
||||
GenericDataType.Numeric,
|
||||
],
|
||||
error: null,
|
||||
is_cached: false,
|
||||
query: '',
|
||||
rowcount: rows.length,
|
||||
sql_rowcount: rows.length,
|
||||
stacktrace: null,
|
||||
status: 'success',
|
||||
from_dttm: null,
|
||||
to_dttm: null,
|
||||
label_map: {
|
||||
ds: ['ds'],
|
||||
'sum__num, boy': ['sum__num', 'boy'],
|
||||
'sum__num, girl': ['sum__num', 'girl'],
|
||||
},
|
||||
} as ChartDataResponseResult;
|
||||
}
|
||||
|
||||
function setup(
|
||||
formDataOverrides: Partial<EchartsMixedTimeseriesFormData> = {},
|
||||
) {
|
||||
const queryData = createQueryData();
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
defaultFormData: DEFAULT_FORM_DATA as EchartsMixedTimeseriesFormData,
|
||||
defaultVizType: 'mixed_timeseries',
|
||||
defaultQueriesData: [queryData, queryData],
|
||||
formData: {
|
||||
colorScheme: 'bnbColors',
|
||||
metrics: ['sum__num'],
|
||||
metricsB: ['sum__num'],
|
||||
groupby: ['gender'],
|
||||
groupbyB: ['gender'],
|
||||
x_axis: 'ds',
|
||||
viz_type: VizType.MixedTimeseries,
|
||||
...formDataOverrides,
|
||||
},
|
||||
queriesData: [queryData, queryData],
|
||||
});
|
||||
const transformed = transformProps(chartProps);
|
||||
const setDataMask = jest.fn();
|
||||
const onContextMenu = jest.fn();
|
||||
const onFocusedSeries = jest.fn();
|
||||
render(
|
||||
<EchartsMixedTimeseries
|
||||
{...transformed}
|
||||
setDataMask={setDataMask}
|
||||
onContextMenu={onContextMenu}
|
||||
onFocusedSeries={onFocusedSeries}
|
||||
emitCrossFilters
|
||||
/>,
|
||||
);
|
||||
const lastCall = mockedEchart.mock.calls[mockedEchart.mock.calls.length - 1];
|
||||
const { eventHandlers } = lastCall[0] as any;
|
||||
return { eventHandlers, setDataMask, onContextMenu, onFocusedSeries };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedEchart.mockClear();
|
||||
});
|
||||
|
||||
test('EchartsMixedTimeseries click emits cross-filter with tail-anchored dimension values', () => {
|
||||
const { eventHandlers, setDataMask } = setup();
|
||||
|
||||
eventHandlers.click({ seriesName: 'sum__num, boy', seriesIndex: 0 });
|
||||
|
||||
expect(setDataMask).toHaveBeenCalledTimes(1);
|
||||
const dataMask = setDataMask.mock.calls[0][0];
|
||||
// label_map values are ['sum__num', 'boy'] — the metric label must be
|
||||
// skipped and the dimension value emitted.
|
||||
expect(dataMask.extraFormData.filters).toEqual([
|
||||
{ col: 'gender', op: 'IN', val: ['boy'] },
|
||||
]);
|
||||
expect(dataMask.filterState.selectedValues).toEqual(['sum__num, boy']);
|
||||
});
|
||||
|
||||
test('EchartsMixedTimeseries click clears filters when the series misses the label map', () => {
|
||||
const { eventHandlers, setDataMask } = setup();
|
||||
|
||||
eventHandlers.click({ seriesName: 'not a series', seriesIndex: 0 });
|
||||
|
||||
expect(setDataMask).toHaveBeenCalledTimes(1);
|
||||
const dataMask = setDataMask.mock.calls[0][0];
|
||||
// An unresolvable series must not emit bogus IS NULL filters (#41622).
|
||||
expect(dataMask.extraFormData.filters).toEqual([]);
|
||||
expect(dataMask.filterState.value).toBeNull();
|
||||
});
|
||||
|
||||
test('EchartsMixedTimeseries context menu drills with tail-anchored dimension values', async () => {
|
||||
const { eventHandlers, onContextMenu } = setup();
|
||||
|
||||
await eventHandlers.contextmenu({
|
||||
data: [ts1, 1],
|
||||
seriesName: 'sum__num, boy',
|
||||
seriesIndex: 0,
|
||||
name: '',
|
||||
event: { stop: jest.fn(), event: { clientX: 11, clientY: 22 } },
|
||||
});
|
||||
|
||||
expect(onContextMenu).toHaveBeenCalledTimes(1);
|
||||
const [x, y, payload] = onContextMenu.mock.calls[0];
|
||||
expect(x).toBe(11);
|
||||
expect(y).toBe(22);
|
||||
expect(payload.drillToDetail).toEqual([
|
||||
expect.objectContaining({ col: 'ds', op: '==', val: ts1 }),
|
||||
expect.objectContaining({
|
||||
col: 'gender',
|
||||
op: '==',
|
||||
val: 'boy',
|
||||
formattedVal: 'boy',
|
||||
}),
|
||||
]);
|
||||
expect(payload.drillBy.filters).toEqual([
|
||||
expect.objectContaining({ col: 'gender', op: '==', val: 'boy' }),
|
||||
]);
|
||||
expect(payload.drillBy.groupbyFieldName).toBe('groupby');
|
||||
expect(payload.crossFilter.dataMask.extraFormData.filters).toEqual([
|
||||
{ col: 'gender', op: 'IN', val: ['boy'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('EchartsMixedTimeseries context menu emits the category x-axis filter', async () => {
|
||||
const { eventHandlers, onContextMenu } = setup({
|
||||
xAxisForceCategorical: true,
|
||||
});
|
||||
|
||||
await eventHandlers.contextmenu({
|
||||
data: ['boy-cat', 1],
|
||||
seriesName: 'sum__num, girl',
|
||||
seriesIndex: 1,
|
||||
name: 'boy-cat',
|
||||
event: { stop: jest.fn(), event: { clientX: 0, clientY: 0 } },
|
||||
});
|
||||
|
||||
const payload = onContextMenu.mock.calls[0][2];
|
||||
expect(payload.drillToDetail).toEqual([
|
||||
expect.objectContaining({
|
||||
col: 'ds',
|
||||
op: '==',
|
||||
val: 'boy-cat',
|
||||
formattedVal: 'boy-cat',
|
||||
}),
|
||||
expect.objectContaining({ col: 'gender', op: '==', val: 'girl' }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('EchartsMixedTimeseries hover focuses and unfocuses the series', () => {
|
||||
const { eventHandlers, onFocusedSeries } = setup();
|
||||
|
||||
eventHandlers.mouseover({ seriesName: 'sum__num, boy' });
|
||||
expect(onFocusedSeries).toHaveBeenLastCalledWith('sum__num, boy');
|
||||
|
||||
eventHandlers.mouseout();
|
||||
expect(onFocusedSeries).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
@@ -777,6 +777,198 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype',
|
||||
expect(xAxis.type).toBe(AxisType.Category);
|
||||
});
|
||||
|
||||
// labelMap/labelMapB must be keyed by the rendered series names or the
|
||||
// cross-filter/drill lookups in EchartsMixedTimeseries miss (#41622);
|
||||
// see the re-key comment in transformProps.ts.
|
||||
test('cross-filter label maps are keyed by the rendered series names', () => {
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: queriesData,
|
||||
formData: { ...formData, showQueryIdentifiers: false },
|
||||
queriesData,
|
||||
});
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
// The backend label_map is keyed by the flattened column names
|
||||
// ("boy"/"girl") while the rendered series are "sum__num, boy" etc.
|
||||
expect(transformed.labelMap).toEqual({
|
||||
'sum__num, boy': ['boy'],
|
||||
'sum__num, girl': ['girl'],
|
||||
});
|
||||
expect(transformed.labelMapB).toEqual({
|
||||
'sum__num, boy': ['boy'],
|
||||
'sum__num, girl': ['girl'],
|
||||
});
|
||||
});
|
||||
|
||||
test('cross-filter label maps resolve every rendered series name', () => {
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: queriesData,
|
||||
formData: { ...formData, showQueryIdentifiers: true },
|
||||
queriesData,
|
||||
});
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
const names = (transformed.echartOptions.series as SeriesOption[]).map(
|
||||
series => String(series.name),
|
||||
);
|
||||
expect(names).toHaveLength(4);
|
||||
names
|
||||
.slice(0, transformed.seriesBreakdown)
|
||||
.forEach(name => expect(transformed.labelMap[name]).toBeDefined());
|
||||
names
|
||||
.slice(transformed.seriesBreakdown)
|
||||
.forEach(name => expect(transformed.labelMapB[name]).toBeDefined());
|
||||
});
|
||||
|
||||
test('cross-filter label maps resolve verbose series names to raw label_map values', () => {
|
||||
const verboseRows = [
|
||||
{ ds: 599616000000, sum__num: 1 },
|
||||
{ ds: 599916000000, sum__num: 3 },
|
||||
];
|
||||
const verboseQueryData = createTestQueryData(verboseRows, {
|
||||
label_map: { ds: ['ds'], sum__num: ['sum__num'] },
|
||||
});
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: [verboseQueryData, verboseQueryData],
|
||||
formData: { ...formData, groupby: [], groupbyB: [] },
|
||||
queriesData: [verboseQueryData, verboseQueryData],
|
||||
datasource: {
|
||||
verboseMap: { sum__num: 'Total Births' },
|
||||
},
|
||||
});
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
// rebaseForecastDatum renames data columns to their verbose names, so the
|
||||
// rendered series is "Total Births" while label_map stays keyed by
|
||||
// "sum__num" — the display-keyed map bridges the two.
|
||||
expect(transformed.labelMap['Total Births']).toEqual(['sum__num']);
|
||||
expect(transformed.labelMapB['Total Births']).toEqual(['sum__num']);
|
||||
});
|
||||
|
||||
test('tooltip resolves per-metric formats through the display-keyed label map', () => {
|
||||
// Multi-metric so getCustomFormatter cannot short-circuit on a single
|
||||
// saved metric: the formatter key must come from resolving the rendered
|
||||
// series name through the display-keyed map.
|
||||
const rows = [{ ds: 599616000000, 'sum__num, boy': 0.5, 'avg__num, boy': 1 }];
|
||||
const queryData = createTestQueryData(rows, {
|
||||
colnames: ['ds', 'sum__num, boy', 'avg__num, boy'],
|
||||
coltypes: [
|
||||
GenericDataType.Temporal,
|
||||
GenericDataType.Numeric,
|
||||
GenericDataType.Numeric,
|
||||
],
|
||||
label_map: {
|
||||
ds: ['ds'],
|
||||
'sum__num, boy': ['sum__num', 'boy'],
|
||||
'avg__num, boy': ['avg__num', 'boy'],
|
||||
},
|
||||
});
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: [queryData, queryData],
|
||||
formData: {
|
||||
...formData,
|
||||
metrics: ['sum__num', 'avg__num'],
|
||||
x_axis: 'ds',
|
||||
yAxisFormat: undefined,
|
||||
},
|
||||
queriesData: [queryData, queryData],
|
||||
datasource: {
|
||||
columnFormats: { sum__num: '.2%' },
|
||||
},
|
||||
});
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
const formatter = (transformed.echartOptions.tooltip as any).formatter as (
|
||||
params: unknown,
|
||||
) => string;
|
||||
const html = formatter({
|
||||
value: [599616000000, 0.5],
|
||||
seriesId: 'sum__num, boy',
|
||||
marker: '',
|
||||
color: '#333',
|
||||
});
|
||||
|
||||
expect(html).toContain('50.00%');
|
||||
});
|
||||
|
||||
test('tooltip resolves per-metric formats for secondary-query series', () => {
|
||||
const rowsA = [
|
||||
{ ds: 599616000000, 'sum__num, boy': 0.5, 'avg__num, boy': 1 },
|
||||
];
|
||||
const queryDataA = createTestQueryData(rowsA, {
|
||||
colnames: ['ds', 'sum__num, boy', 'avg__num, boy'],
|
||||
coltypes: [
|
||||
GenericDataType.Temporal,
|
||||
GenericDataType.Numeric,
|
||||
GenericDataType.Numeric,
|
||||
],
|
||||
label_map: {
|
||||
ds: ['ds'],
|
||||
'sum__num, boy': ['sum__num', 'boy'],
|
||||
'avg__num, boy': ['avg__num', 'boy'],
|
||||
},
|
||||
});
|
||||
const rowsB = [{ ds: 599616000000, 'count__num, boy': 2.5 }];
|
||||
const queryDataB = createTestQueryData(rowsB, {
|
||||
colnames: ['ds', 'count__num, boy'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
label_map: {
|
||||
ds: ['ds'],
|
||||
'count__num, boy': ['count__num', 'boy'],
|
||||
},
|
||||
});
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: [queryDataA, queryDataB],
|
||||
formData: {
|
||||
...formData,
|
||||
metrics: ['sum__num', 'avg__num'],
|
||||
metricsB: ['count__num', 'max__num'],
|
||||
x_axis: 'ds',
|
||||
yAxisFormat: undefined,
|
||||
yAxisFormatSecondary: undefined,
|
||||
yAxisIndex: 0,
|
||||
yAxisIndexB: 1,
|
||||
},
|
||||
queriesData: [queryDataA, queryDataB],
|
||||
datasource: {
|
||||
columnFormats: { count__num: '.1f' },
|
||||
},
|
||||
});
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
const formatter = (transformed.echartOptions.tooltip as any).formatter as (
|
||||
params: unknown,
|
||||
) => string;
|
||||
const html = formatter({
|
||||
value: [599616000000, 2.5],
|
||||
seriesId: 'count__num, boy',
|
||||
marker: '',
|
||||
color: '#333',
|
||||
});
|
||||
|
||||
expect(html).toContain('2.5');
|
||||
});
|
||||
|
||||
test('temporal x coltype wires the time formatter and Time axis', () => {
|
||||
// Regression guard: the happy path for mixed-timeseries charts. Ensures
|
||||
// Temporal coltype still routes through the TimeFormatter so the time axis
|
||||
|
||||
@@ -190,6 +190,115 @@ test('does not render loading spinner when filter has no data source', () => {
|
||||
expect(screen.getByTestId('mock-super-chart')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const defaultFirstItemParentFilter = createMockFilter({
|
||||
id: 'NATIVE_FILTER-PARENT',
|
||||
controlValues: { defaultToFirstItem: true },
|
||||
});
|
||||
const guardChildFilter = createMockFilter({
|
||||
id: 'NATIVE_FILTER-CHILD',
|
||||
cascadeParentIds: ['NATIVE_FILTER-PARENT'],
|
||||
});
|
||||
const stateWithDefaultFirstItemParent = {
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
'NATIVE_FILTER-CHILD': guardChildFilter,
|
||||
'NATIVE_FILTER-PARENT': defaultFirstItemParentFilter,
|
||||
},
|
||||
filterSets: {},
|
||||
},
|
||||
};
|
||||
|
||||
test('guard: does not fetch while a defaultToFirstItem parent has not yet auto-selected', () => {
|
||||
// Reproduces sc-108451: B should not fetch from unfiltered data before A selects.
|
||||
mockUseTransitiveParentIds.mockReturnValue(['NATIVE_FILTER-PARENT']);
|
||||
mockUseFilterDependencies.mockReturnValue({});
|
||||
|
||||
renderFilterValue(
|
||||
{
|
||||
filter: guardChildFilter,
|
||||
// Parent entry exists but filterState.value is undefined (no selection yet).
|
||||
dataMaskSelected: {
|
||||
'NATIVE_FILTER-PARENT': { filterState: {}, extraFormData: {} },
|
||||
},
|
||||
},
|
||||
stateWithDefaultFirstItemParent,
|
||||
);
|
||||
|
||||
expect(mockGetChartDataRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('guard: fetches once a defaultToFirstItem parent has set its first value', async () => {
|
||||
mockGetChartDataRequest.mockResolvedValue({
|
||||
response: { status: 200 },
|
||||
json: { result: [{ data: [{ model: 'Corolla' }] }] },
|
||||
});
|
||||
mockUseTransitiveParentIds.mockReturnValue(['NATIVE_FILTER-PARENT']);
|
||||
mockUseFilterDependencies.mockReturnValue({
|
||||
filters: [{ col: 'make', op: 'IN', val: ['Toyota'] }],
|
||||
});
|
||||
|
||||
renderFilterValue(
|
||||
{
|
||||
filter: guardChildFilter,
|
||||
dataMaskSelected: {
|
||||
'NATIVE_FILTER-PARENT': {
|
||||
// Parent has auto-selected its first value → guard should pass.
|
||||
filterState: { value: ['Toyota'] },
|
||||
extraFormData: {
|
||||
filters: [{ col: 'make', op: 'IN', val: ['Toyota'] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
stateWithDefaultFirstItemParent,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetChartDataRequest).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('guard: does not block fetch for a parent without defaultToFirstItem', async () => {
|
||||
// Non-defaultToFirstItem parents with values should pass the guard as before.
|
||||
mockGetChartDataRequest.mockResolvedValue({
|
||||
response: { status: 200 },
|
||||
json: { result: [{ data: [] }] },
|
||||
});
|
||||
mockUseTransitiveParentIds.mockReturnValue(['NATIVE_FILTER-PARENT']);
|
||||
mockUseFilterDependencies.mockReturnValue({
|
||||
filters: [{ col: 'make', op: 'IN', val: ['Toyota'] }],
|
||||
});
|
||||
|
||||
const regularParent = createMockFilter({ id: 'NATIVE_FILTER-PARENT' });
|
||||
|
||||
renderFilterValue(
|
||||
{
|
||||
filter: guardChildFilter,
|
||||
dataMaskSelected: {
|
||||
'NATIVE_FILTER-PARENT': {
|
||||
filterState: { value: ['Toyota'] },
|
||||
extraFormData: {
|
||||
filters: [{ col: 'make', op: 'IN', val: ['Toyota'] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
'NATIVE_FILTER-CHILD': guardChildFilter,
|
||||
'NATIVE_FILTER-PARENT': regularParent,
|
||||
},
|
||||
filterSets: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetChartDataRequest).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('skips data fetch when cascade parent filters have no values selected', () => {
|
||||
// useFilterDependencies returns dependencies with a filter (from parent defaults),
|
||||
// but dataMaskSelected has no extraFormData for the parent -- counts disagree, so
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { styled, SupersetTheme } from '@apache-superset/core/theme';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector, shallowEqual } from 'react-redux';
|
||||
import { isEqual, isEqualWith } from 'lodash-es';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
import { ErrorAlert, ErrorMessageWithStackTrace } from 'src/components';
|
||||
@@ -125,6 +125,19 @@ const FilterValue: FC<FilterValueProps> = ({
|
||||
const transitiveParentIds = useTransitiveParentIds(id);
|
||||
const shouldRefresh = useShouldFilterRefresh();
|
||||
|
||||
// Derive only the defaultToFirstItem flag per filter to avoid re-renders
|
||||
// when unrelated filter config fields change.
|
||||
const parentDefaultToFirstItem = useSelector(
|
||||
(state: RootState) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(state.nativeFilters?.filters ?? {}).map(([fId, f]) => [
|
||||
fId,
|
||||
Boolean(f.controlValues?.defaultToFirstItem),
|
||||
]),
|
||||
),
|
||||
shallowEqual,
|
||||
);
|
||||
|
||||
const behaviors = useMemo(
|
||||
() => [
|
||||
isCustomization ? Behavior.ChartCustomization : Behavior.NativeFilter,
|
||||
@@ -203,6 +216,21 @@ const FilterValue: FC<FilterValueProps> = ({
|
||||
// direct parents) so the counts line up with `dependencies`, which is
|
||||
// itself built from the transitive chain by `useFilterDependencies`.
|
||||
|
||||
// Block if any parent with defaultToFirstItem hasn't auto-selected yet.
|
||||
// Without this, the child fetches unfiltered options before the parent
|
||||
// auto-selects, leading to a stale first-value dispatch that never
|
||||
// gets corrected because subsequent re-selections are not first-initialization.
|
||||
const hasDefaultFirstParentPending = transitiveParentIds.some(pId => {
|
||||
const parentMask = dataMaskSelected?.[pId];
|
||||
return (
|
||||
parentDefaultToFirstItem[pId] &&
|
||||
parentMask?.filterState?.value === undefined
|
||||
);
|
||||
});
|
||||
if (hasDefaultFirstParentPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
let selectedParentFilterValueCounts = 0;
|
||||
let isTimeRangeSelected = false;
|
||||
transitiveParentIds.forEach(pId => {
|
||||
@@ -307,6 +335,7 @@ const FilterValue: FC<FilterValueProps> = ({
|
||||
dataMaskSelected,
|
||||
setHasDepsFilterValue,
|
||||
transitiveParentIds,
|
||||
parentDefaultToFirstItem,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -807,6 +807,29 @@ test('does not show screenshot width when csv is selected', async () => {
|
||||
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not show screenshot width when Excel is selected', async () => {
|
||||
render(<AlertReportModal {...generateMockedProps(false, true, false)} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
userEvent.click(screen.getByTestId('contents-panel'));
|
||||
await screen.findByText(/test chart/i);
|
||||
const contentTypeSelector = screen.getByRole('combobox', {
|
||||
name: /select content type/i,
|
||||
});
|
||||
await comboboxSelect(contentTypeSelector, 'Chart', () =>
|
||||
screen.getByText(/select chart/i),
|
||||
);
|
||||
const reportFormatSelector = screen.getByRole('combobox', {
|
||||
name: /select format/i,
|
||||
});
|
||||
await comboboxSelect(
|
||||
reportFormatSelector,
|
||||
'Excel',
|
||||
() => screen.getAllByText(/Send as Excel/i)[0],
|
||||
);
|
||||
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clearing the chart selection resets the combobox value', async () => {
|
||||
render(<AlertReportModal {...generateMockedProps(false, true, false)} />, {
|
||||
useRedux: true,
|
||||
|
||||
@@ -232,6 +232,10 @@ const FORMAT_OPTIONS = {
|
||||
label: t('Send as CSV'),
|
||||
value: 'CSV',
|
||||
},
|
||||
xlsx: {
|
||||
label: t('Send as Excel'),
|
||||
value: 'XLSX',
|
||||
},
|
||||
txt: {
|
||||
label: t('Send as text'),
|
||||
value: 'TEXT',
|
||||
@@ -2407,7 +2411,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
||||
</StyledInputContainer>
|
||||
<StyledInputContainer
|
||||
css={
|
||||
['PDF', 'TEXT', 'CSV'].includes(reportFormat) &&
|
||||
['PDF', 'TEXT', 'CSV', 'XLSX'].includes(reportFormat) &&
|
||||
noMarginBottom
|
||||
}
|
||||
>
|
||||
@@ -2433,7 +2437,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
||||
chartVizType,
|
||||
)
|
||||
? Object.values(FORMAT_OPTIONS)
|
||||
: ['pdf', 'png', 'csv'].map(
|
||||
: ['pdf', 'png', 'csv', 'xlsx'].map(
|
||||
key =>
|
||||
FORMAT_OPTIONS[key as FORMAT_OPTIONS_KEY],
|
||||
)
|
||||
|
||||
@@ -284,6 +284,10 @@ function ReportModal({
|
||||
label: t('Formatted CSV attached in email'),
|
||||
value: NotificationFormats.CSV,
|
||||
},
|
||||
{
|
||||
label: t('Formatted Excel attached in email'),
|
||||
value: NotificationFormats.XLSX,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@ export enum NotificationFormats {
|
||||
Text = 'TEXT',
|
||||
PNG = 'PNG',
|
||||
CSV = 'CSV',
|
||||
XLSX = 'XLSX',
|
||||
}
|
||||
export interface ReportObject {
|
||||
id?: number;
|
||||
|
||||
@@ -197,6 +197,12 @@ class ReportScheduleCsvFailedError(CommandException):
|
||||
message = _("Report Schedule execution failed when generating a csv.")
|
||||
|
||||
|
||||
class ReportScheduleXlsxFailedError(CommandException):
|
||||
"""Raised when generating the Excel (xlsx) attachment for a report fails."""
|
||||
|
||||
message = _("Report Schedule execution failed when generating an Excel file.")
|
||||
|
||||
|
||||
class ReportScheduleDataFrameFailedError(CommandException):
|
||||
message = _("Report Schedule execution failed when generating a dataframe.")
|
||||
|
||||
@@ -310,6 +316,13 @@ class ReportScheduleCsvTimeout(CommandException):
|
||||
message = _("A timeout occurred while generating a csv.")
|
||||
|
||||
|
||||
class ReportScheduleXlsxTimeout(CommandException):
|
||||
"""Raised when generating the Excel (xlsx) attachment for a report times out."""
|
||||
|
||||
status: int = 408
|
||||
message = _("A timeout occurred while generating an Excel file.")
|
||||
|
||||
|
||||
class ReportScheduleDataFrameTimeout(CommandException):
|
||||
status = 408
|
||||
message = _("A timeout occurred while generating a dataframe.")
|
||||
|
||||
@@ -52,6 +52,8 @@ from superset.commands.report.exceptions import (
|
||||
ReportScheduleTargetDashboardDeletedError,
|
||||
ReportScheduleUnexpectedError,
|
||||
ReportScheduleWorkingTimeoutError,
|
||||
ReportScheduleXlsxFailedError,
|
||||
ReportScheduleXlsxTimeout,
|
||||
)
|
||||
from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
|
||||
from superset.daos.report import (
|
||||
@@ -263,6 +265,9 @@ class BaseReportState:
|
||||
"""
|
||||
Get the url for this report schedule: chart or dashboard
|
||||
"""
|
||||
chart = self._report_schedule.chart
|
||||
dashboard = self._report_schedule.dashboard
|
||||
|
||||
# Soft delete removed the FK-level guarantee that a report's target
|
||||
# chart exists: ``chart`` is a visibility-filtered relationship, so a
|
||||
# chart soft-deleted after this report was created (or attached via a
|
||||
@@ -274,25 +279,32 @@ class BaseReportState:
|
||||
# Every content path (_get_screenshots, _get_csv_data,
|
||||
# _get_embedded_data, _get_notification_content) funnels through this
|
||||
# method, so this is the single choke point.
|
||||
if (
|
||||
self._report_schedule.chart_id is not None
|
||||
and self._report_schedule.chart is None
|
||||
):
|
||||
raise ReportScheduleTargetChartDeletedError()
|
||||
# Symmetric guard for dashboard targets. Dashboard soft delete lands
|
||||
# in the sibling rollout; until then this cannot fire (a dashboard
|
||||
# with dependent reports cannot be deleted), which makes it inert
|
||||
# rather than wrong — and it keeps the report-target error vocabulary
|
||||
# parallel across entities from day one.
|
||||
if (
|
||||
self._report_schedule.dashboard_id is not None
|
||||
and self._report_schedule.dashboard is None
|
||||
):
|
||||
raise ReportScheduleTargetDashboardDeletedError()
|
||||
if chart is None and dashboard is None:
|
||||
if self._report_schedule.chart_id is not None:
|
||||
raise ReportScheduleTargetChartDeletedError()
|
||||
# Symmetric guard for dashboard targets. Dashboard soft delete lands
|
||||
# in the sibling rollout; until then this cannot fire (a dashboard
|
||||
# with dependent reports cannot be deleted), which makes it inert
|
||||
# rather than wrong — and it keeps the report-target error vocabulary
|
||||
# parallel across entities from day one.
|
||||
if self._report_schedule.dashboard_id is not None:
|
||||
raise ReportScheduleTargetDashboardDeletedError()
|
||||
# Defensive fallback for a malformed report with no target IDs.
|
||||
# Missing relationships with a target ID are handled by the
|
||||
# dedicated deleted-target errors above.
|
||||
raise ReportScheduleUnexpectedError(
|
||||
f"Report schedule {self._report_schedule.id} "
|
||||
f"({self._report_schedule.name!r}) has no resolvable target "
|
||||
f"(chart_id={self._report_schedule.chart_id}, "
|
||||
f"dashboard_id={self._report_schedule.dashboard_id}); "
|
||||
"the report has neither a chart nor a dashboard."
|
||||
)
|
||||
|
||||
force = "true" if self._report_schedule.force_screenshot else "false"
|
||||
if self._report_schedule.chart:
|
||||
if chart:
|
||||
if result_format in {
|
||||
ChartDataResultFormat.CSV,
|
||||
ChartDataResultFormat.XLSX,
|
||||
ChartDataResultFormat.JSON,
|
||||
}:
|
||||
return get_url_path(
|
||||
@@ -315,9 +327,8 @@ class BaseReportState:
|
||||
) and feature_flag_manager.is_feature_enabled("ALERT_REPORT_TABS"):
|
||||
return self._get_tab_url(dashboard_state, user_friendly=user_friendly)
|
||||
|
||||
dashboard = self._report_schedule.dashboard
|
||||
dashboard_id_or_slug = (
|
||||
dashboard.uuid if dashboard and dashboard.uuid else dashboard.id
|
||||
dashboard.uuid if dashboard.uuid is not None else dashboard.id
|
||||
)
|
||||
return get_url_path(
|
||||
"Superset.dashboard",
|
||||
@@ -698,30 +709,51 @@ class BaseReportState:
|
||||
raise URLError(response.getcode())
|
||||
return content or None
|
||||
|
||||
def _get_csv_data(self) -> bytes:
|
||||
def _get_data(self, result_format: ChartDataResultFormat) -> bytes:
|
||||
"""
|
||||
Fetch tabular chart data (CSV or Excel) as raw bytes.
|
||||
|
||||
Both formats are produced by the chart data export endpoint, so the
|
||||
bytes are fetched the same way and only differ by ``result_format``.
|
||||
This reuses the export path's post-processing and index handling,
|
||||
keeping report output consistent with a chart's manual export.
|
||||
"""
|
||||
timeout_error: type[CommandException]
|
||||
failed_error: type[CommandException]
|
||||
if result_format == ChartDataResultFormat.XLSX:
|
||||
label, timeout_error, failed_error = (
|
||||
"Excel",
|
||||
ReportScheduleXlsxTimeout,
|
||||
ReportScheduleXlsxFailedError,
|
||||
)
|
||||
else:
|
||||
label, timeout_error, failed_error = (
|
||||
"CSV",
|
||||
ReportScheduleCsvTimeout,
|
||||
ReportScheduleCsvFailedError,
|
||||
)
|
||||
|
||||
start_time = datetime.utcnow()
|
||||
user, username = resolve_executor_user(self._report_schedule)
|
||||
auth_cookies = machine_auth_provider_factory.instance.get_auth_cookies(user)
|
||||
|
||||
if self._report_schedule.chart.query_context is None:
|
||||
logger.warning("No query context found, taking a screenshot to generate it")
|
||||
self._update_query_context()
|
||||
self._update_query_context(failed_error)
|
||||
db.session.refresh(self._report_schedule.chart)
|
||||
|
||||
try:
|
||||
if self._report_schedule.chart.query_context is None:
|
||||
url = self._get_url(result_format=ChartDataResultFormat.CSV)
|
||||
csv_data = get_chart_csv_data(
|
||||
url = self._get_url(result_format=result_format)
|
||||
data = get_chart_csv_data(
|
||||
chart_url=url,
|
||||
auth_cookies=auth_cookies,
|
||||
timeout=app.config["ALERT_REPORTS_CSV_REQUEST_TIMEOUT"],
|
||||
)
|
||||
else:
|
||||
request_payload = self._get_chart_data_request_payload(
|
||||
ChartDataResultFormat.CSV
|
||||
)
|
||||
request_payload = self._get_chart_data_request_payload(result_format)
|
||||
url = get_url_path("ChartDataRestApi.data")
|
||||
csv_data = self._post_chart_data(
|
||||
data = self._post_chart_data(
|
||||
chart_url=url,
|
||||
auth_cookies=auth_cookies,
|
||||
request_payload=request_payload,
|
||||
@@ -729,7 +761,8 @@ class BaseReportState:
|
||||
)
|
||||
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
|
||||
logger.info(
|
||||
"CSV data generation from %s as user %s took %.2fs - execution_id: %s",
|
||||
"%s data generation from %s as user %s took %.2fs - execution_id: %s",
|
||||
label,
|
||||
url,
|
||||
username,
|
||||
elapsed_seconds,
|
||||
@@ -738,24 +771,24 @@ class BaseReportState:
|
||||
except SoftTimeLimitExceeded as ex:
|
||||
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
|
||||
logger.warning(
|
||||
"CSV generation timeout after %.2fs - execution_id: %s",
|
||||
"%s generation timeout after %.2fs - execution_id: %s",
|
||||
label,
|
||||
elapsed_seconds,
|
||||
self._execution_id,
|
||||
)
|
||||
raise ReportScheduleCsvTimeout() from ex
|
||||
raise timeout_error() from ex
|
||||
except Exception as ex:
|
||||
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
|
||||
logger.exception(
|
||||
"CSV generation failed after %.2fs - execution_id: %s",
|
||||
"%s generation failed after %.2fs - execution_id: %s",
|
||||
label,
|
||||
elapsed_seconds,
|
||||
self._execution_id,
|
||||
)
|
||||
raise ReportScheduleCsvFailedError(
|
||||
f"Failed generating csv {str(ex)}"
|
||||
) from ex
|
||||
if not csv_data:
|
||||
raise ReportScheduleCsvFailedError()
|
||||
return csv_data
|
||||
raise failed_error(f"Failed generating {label.lower()} {str(ex)}") from ex
|
||||
if not data:
|
||||
raise failed_error()
|
||||
return data
|
||||
|
||||
def _get_embedded_data(self) -> pd.DataFrame:
|
||||
"""
|
||||
@@ -807,14 +840,18 @@ class BaseReportState:
|
||||
raise ReportScheduleCsvFailedError()
|
||||
return dataframe
|
||||
|
||||
def _update_query_context(self) -> None:
|
||||
def _update_query_context(
|
||||
self,
|
||||
failed_error: type[CommandException] = ReportScheduleCsvFailedError,
|
||||
) -> None:
|
||||
"""
|
||||
Update chart query context.
|
||||
|
||||
To load CSV data from the endpoint the chart must have been saved
|
||||
To load data from the endpoint the chart must have been saved
|
||||
with its query context. For charts without saved query context we
|
||||
get a screenshot to force the chart to produce and save the query
|
||||
context.
|
||||
context. ``failed_error`` lets the caller surface a format-specific
|
||||
failure (e.g. Excel vs CSV) when the screenshot fallback fails.
|
||||
"""
|
||||
try:
|
||||
self._get_screenshots()
|
||||
@@ -822,7 +859,7 @@ class BaseReportState:
|
||||
ReportScheduleScreenshotFailedError,
|
||||
ReportScheduleScreenshotTimeout,
|
||||
) as ex:
|
||||
raise ReportScheduleCsvFailedError(
|
||||
raise failed_error(
|
||||
"Unable to fetch data because the chart has no query context "
|
||||
"saved, and an error occurred when fetching it via a screenshot. "
|
||||
"Please try loading the chart and saving it again."
|
||||
@@ -870,7 +907,8 @@ class BaseReportState:
|
||||
|
||||
:raises: ReportScheduleScreenshotFailedError
|
||||
"""
|
||||
csv_data = None
|
||||
csv_data: bytes | None = None
|
||||
xlsx_data: bytes | None = None
|
||||
screenshot_data = []
|
||||
pdf_data = None
|
||||
embedded_data = None
|
||||
@@ -892,11 +930,16 @@ class BaseReportState:
|
||||
error_text = "Unexpected missing pdf"
|
||||
elif (
|
||||
self._report_schedule.chart
|
||||
and self._report_schedule.report_format == ReportDataFormat.CSV
|
||||
and self._report_schedule.report_format in ReportDataFormat.tabular()
|
||||
):
|
||||
csv_data = self._get_csv_data()
|
||||
if not csv_data:
|
||||
error_text = "Unexpected missing csv file"
|
||||
if self._report_schedule.report_format == ReportDataFormat.XLSX:
|
||||
xlsx_data = self._get_data(ChartDataResultFormat.XLSX)
|
||||
if not xlsx_data:
|
||||
error_text = "Unexpected missing Excel file"
|
||||
else:
|
||||
csv_data = self._get_data(ChartDataResultFormat.CSV)
|
||||
if not csv_data:
|
||||
error_text = "Unexpected missing csv file"
|
||||
if error_text:
|
||||
return NotificationContent(
|
||||
name=sanitize_title(self._report_schedule.name),
|
||||
@@ -932,6 +975,7 @@ class BaseReportState:
|
||||
pdf=pdf_data,
|
||||
description=self._report_schedule.description,
|
||||
csv=csv_data,
|
||||
xlsx=xlsx_data,
|
||||
embedded_data=embedded_data,
|
||||
header_data=header_data,
|
||||
)
|
||||
@@ -1377,7 +1421,7 @@ class AsyncExecuteReportScheduleCommand(BaseCommand):
|
||||
# user (find_user -> None) so the state machine still runs and its
|
||||
# error envelope writes the ERROR execution-log row and sends the
|
||||
# editor notification. The dedicated ReportScheduleExecutorNotFoundError
|
||||
# guard lives at the content sites (_get_screenshots / _get_csv_data /
|
||||
# guard lives at the content sites (_get_screenshots / _get_data /
|
||||
# _get_embedded_data), which raise inside that envelope. Guarding here
|
||||
# instead would surface the executor error above the state machine,
|
||||
# suppressing both the log row and the editor notification. The
|
||||
|
||||
@@ -83,8 +83,14 @@ class ReportDataFormat(StrEnum):
|
||||
PDF = "PDF"
|
||||
PNG = "PNG"
|
||||
CSV = "CSV"
|
||||
XLSX = "XLSX"
|
||||
TEXT = "TEXT"
|
||||
|
||||
@classmethod
|
||||
def tabular(cls: type["ReportDataFormat"]) -> set["ReportDataFormat"]:
|
||||
"""Formats produced from tabular chart data via the chart export path."""
|
||||
return {cls.CSV, cls.XLSX}
|
||||
|
||||
|
||||
class ReportCreationMethod(StrEnum):
|
||||
CHARTS = "charts"
|
||||
|
||||
@@ -28,6 +28,7 @@ class NotificationContent:
|
||||
name: str
|
||||
header_data: HeaderDataType # this is optional to account for error states
|
||||
csv: Optional[bytes] = None # bytes for csv file
|
||||
xlsx: Optional[bytes] = None # bytes for Excel file
|
||||
pdf: Optional[bytes] = None # bytes for PDF file
|
||||
screenshots: Optional[list[bytes]] = None # bytes for a list of screenshots
|
||||
text: Optional[str] = None
|
||||
|
||||
@@ -205,9 +205,13 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
|
||||
</html>
|
||||
"""
|
||||
)
|
||||
csv_data = None
|
||||
# CSV and Excel are mutually exclusive (a report has a single format),
|
||||
# so at most one tabular attachment is present in the data dict.
|
||||
attachment_data: dict[str, bytes] | None = None
|
||||
if self._content.csv:
|
||||
csv_data = {__("%(name)s.csv", name=self._name): self._content.csv}
|
||||
attachment_data = {__("%(name)s.csv", name=self._name): self._content.csv}
|
||||
elif self._content.xlsx:
|
||||
attachment_data = {__("%(name)s.xlsx", name=self._name): self._content.xlsx}
|
||||
|
||||
pdf_data = None
|
||||
if self._content.pdf:
|
||||
@@ -217,7 +221,7 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
|
||||
body=body,
|
||||
images=images,
|
||||
pdf=pdf_data,
|
||||
data=csv_data,
|
||||
data=attachment_data,
|
||||
header_data=self._content.header_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -82,6 +82,8 @@ class SlackNotification(SlackMixin, BaseNotification): # pylint: disable=too-fe
|
||||
) -> tuple[Union[str, None], Sequence[Union[str, IOBase, bytes]]]:
|
||||
if self._content.csv:
|
||||
return ("csv", [self._content.csv])
|
||||
if self._content.xlsx:
|
||||
return ("xlsx", [self._content.xlsx])
|
||||
if self._content.screenshots:
|
||||
return ("png", self._content.screenshots)
|
||||
if self._content.pdf:
|
||||
|
||||
@@ -120,6 +120,8 @@ class SlackV2Notification(SlackMixin, BaseNotification): # pylint: disable=too-
|
||||
) -> tuple[Union[str, None], Sequence[Union[str, IOBase, bytes]]]:
|
||||
if self._content.csv:
|
||||
return ("csv", [self._content.csv])
|
||||
if self._content.xlsx:
|
||||
return ("xlsx", [self._content.xlsx])
|
||||
if self._content.screenshots:
|
||||
return ("png", self._content.screenshots)
|
||||
if self._content.pdf:
|
||||
|
||||
@@ -80,6 +80,18 @@ class WebhookNotification(BaseNotification):
|
||||
files = []
|
||||
if self._content.csv:
|
||||
files.append(("files", ("report.csv", self._content.csv, "text/csv")))
|
||||
if self._content.xlsx:
|
||||
files.append(
|
||||
(
|
||||
"files",
|
||||
(
|
||||
"report.xlsx",
|
||||
self._content.xlsx,
|
||||
"application/vnd.openxmlformats-officedocument."
|
||||
"spreadsheetml.sheet",
|
||||
),
|
||||
)
|
||||
)
|
||||
if self._content.pdf:
|
||||
files.append(
|
||||
("files", ("report.pdf", self._content.pdf, "application/pdf"))
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
@@ -105,6 +106,7 @@ from tests.integration_tests.reports.utils import (
|
||||
reset_key_values,
|
||||
SCREENSHOT_FILE,
|
||||
TEST_ID,
|
||||
XLSX_FILE,
|
||||
)
|
||||
from tests.integration_tests.test_app import app
|
||||
|
||||
@@ -256,6 +258,20 @@ def create_report_email_chart_with_csv():
|
||||
cleanup_report_schedule(report_schedule)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_report_email_chart_with_xlsx() -> Iterator[ReportSchedule]:
|
||||
"""Email report schedule on a chart with the XLSX (Excel) attachment format."""
|
||||
chart = db.session.query(Slice).first()
|
||||
chart.query_context = '{"mock": "query_context"}'
|
||||
report_schedule = create_report_notification(
|
||||
email_target="target@email.com",
|
||||
chart=chart,
|
||||
report_format=ReportDataFormat.XLSX,
|
||||
)
|
||||
yield report_schedule
|
||||
cleanup_report_schedule(report_schedule)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_report_email_chart_with_text():
|
||||
chart = db.session.query(Slice).first()
|
||||
@@ -344,6 +360,21 @@ def create_report_slack_chart_with_csv():
|
||||
cleanup_report_schedule(report_schedule)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_report_slack_chart_with_xlsx() -> Iterator[ReportSchedule]:
|
||||
"""Slack report schedule on a chart with the XLSX (Excel) attachment format."""
|
||||
chart = db.session.query(Slice).first()
|
||||
chart.query_context = '{"mock": "query_context"}'
|
||||
report_schedule = create_report_notification(
|
||||
slack_channel="slack_channel",
|
||||
chart=chart,
|
||||
report_format=ReportDataFormat.XLSX,
|
||||
)
|
||||
yield report_schedule
|
||||
|
||||
cleanup_report_schedule(report_schedule)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_report_slack_chart_with_text():
|
||||
chart = db.session.query(Slice).first()
|
||||
@@ -988,6 +1019,50 @@ def test_email_chart_report_schedule_with_csv(
|
||||
assert_log(ReportState.SUCCESS)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"load_birth_names_dashboard_with_slices",
|
||||
"create_report_email_chart_with_xlsx",
|
||||
)
|
||||
@patch("superset.utils.csv.urllib.request.urlopen")
|
||||
@patch("superset.utils.csv.urllib.request.OpenerDirector.open")
|
||||
@patch("superset.reports.notifications.email.send_email_smtp")
|
||||
@patch("superset.utils.csv.get_chart_csv_data")
|
||||
def test_email_chart_report_schedule_with_xlsx(
|
||||
xlsx_mock: Mock,
|
||||
email_mock: Mock,
|
||||
mock_open: Mock,
|
||||
mock_urlopen: Mock,
|
||||
create_report_email_chart_with_xlsx: ReportSchedule,
|
||||
) -> None:
|
||||
"""
|
||||
ExecuteReport Command: Test chart email report schedule with Excel
|
||||
"""
|
||||
# setup xlsx mock
|
||||
response = Mock()
|
||||
mock_open.return_value = response
|
||||
mock_urlopen.return_value = response
|
||||
mock_urlopen.return_value.getcode.return_value = 200
|
||||
response.read.return_value = XLSX_FILE
|
||||
|
||||
with freeze_time("2020-01-01T00:00:00Z"):
|
||||
AsyncExecuteReportScheduleCommand(
|
||||
TEST_ID, create_report_email_chart_with_xlsx.id, datetime.utcnow()
|
||||
).run()
|
||||
|
||||
notification_targets = get_target_from_report_schedule(
|
||||
create_report_email_chart_with_xlsx
|
||||
)
|
||||
# Assert the email smtp address
|
||||
assert email_mock.call_args[0][0] == notification_targets[0]
|
||||
# Assert the Excel attachment is sent with an .xlsx filename
|
||||
attachments = email_mock.call_args[1]["data"]
|
||||
attachment_name = list(attachments.keys())[0]
|
||||
assert attachment_name.endswith(".xlsx")
|
||||
assert attachments[attachment_name] == XLSX_FILE
|
||||
# Assert logs are correct
|
||||
assert_log(ReportState.SUCCESS)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"load_birth_names_dashboard_with_slices",
|
||||
"create_report_email_chart_with_csv_no_query_context",
|
||||
@@ -1654,6 +1729,56 @@ def test_slack_chart_report_schedule_with_csv(
|
||||
assert_log(ReportState.SUCCESS)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"load_birth_names_dashboard_with_slices", "create_report_slack_chart_with_xlsx"
|
||||
)
|
||||
@patch("superset.reports.notifications.slack.should_use_v2_api", return_value=False)
|
||||
@patch("superset.reports.notifications.slack.get_slack_client")
|
||||
@patch("superset.utils.csv.urllib.request.urlopen")
|
||||
@patch("superset.utils.csv.urllib.request.OpenerDirector.open")
|
||||
@patch("superset.utils.csv.get_chart_csv_data")
|
||||
def test_slack_chart_report_schedule_with_xlsx(
|
||||
xlsx_mock: Mock,
|
||||
mock_open: Mock,
|
||||
mock_urlopen: Mock,
|
||||
slack_client_mock_class: Mock,
|
||||
slack_should_use_v2_api_mock: Mock,
|
||||
create_report_slack_chart_with_xlsx: ReportSchedule,
|
||||
) -> None:
|
||||
"""
|
||||
ExecuteReport Command: Test chart slack report V1 schedule with Excel
|
||||
"""
|
||||
# setup xlsx mock
|
||||
response = Mock()
|
||||
mock_open.return_value = response
|
||||
mock_urlopen.return_value = response
|
||||
mock_urlopen.return_value.getcode.return_value = 200
|
||||
response.read.return_value = XLSX_FILE
|
||||
|
||||
notification_targets = get_target_from_report_schedule(
|
||||
create_report_slack_chart_with_xlsx
|
||||
)
|
||||
|
||||
channel_name = notification_targets[0]
|
||||
|
||||
with freeze_time("2020-01-01T00:00:00Z"):
|
||||
AsyncExecuteReportScheduleCommand(
|
||||
TEST_ID, create_report_slack_chart_with_xlsx.id, datetime.utcnow()
|
||||
).run()
|
||||
|
||||
assert (
|
||||
slack_client_mock_class.return_value.files_upload.call_args[1]["channels"]
|
||||
== channel_name
|
||||
)
|
||||
assert (
|
||||
slack_client_mock_class.return_value.files_upload.call_args[1]["file"]
|
||||
== XLSX_FILE
|
||||
)
|
||||
|
||||
# Assert logs are correct
|
||||
assert_log(ReportState.SUCCESS)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"load_birth_names_dashboard_with_slices", "create_report_slack_chart_with_text"
|
||||
)
|
||||
|
||||
@@ -44,6 +44,9 @@ from tests.integration_tests.utils import read_fixture
|
||||
|
||||
TEST_ID = str(uuid4())
|
||||
CSV_FILE = read_fixture("trends.csv")
|
||||
# Reports fetch tabular data as opaque bytes from the (mocked) chart export
|
||||
# endpoint, so any distinct non-empty payload is sufficient for Excel tests.
|
||||
XLSX_FILE: bytes = b"PK\x03\x04 mock xlsx bytes"
|
||||
SCREENSHOT_FILE = read_fixture("sample.png")
|
||||
DEFAULT_OWNER_EMAIL = "admin@fab.org"
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
|
||||
import json # noqa: TID251
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import Mock, patch
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
from urllib.error import URLError
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
@@ -37,6 +38,8 @@ from superset.commands.report.exceptions import (
|
||||
ReportScheduleStateNotFoundError,
|
||||
ReportScheduleUnexpectedError,
|
||||
ReportScheduleWorkingTimeoutError,
|
||||
ReportScheduleXlsxFailedError,
|
||||
ReportScheduleXlsxTimeout,
|
||||
)
|
||||
from superset.commands.report.execute import (
|
||||
BaseReportState,
|
||||
@@ -1248,7 +1251,7 @@ def test_get_csv_data_posts_prepared_chart_data_payload(
|
||||
return_value=b"csv-data",
|
||||
)
|
||||
|
||||
assert report_state._get_csv_data() == b"csv-data"
|
||||
assert report_state._get_data(ChartDataResultFormat.CSV) == b"csv-data"
|
||||
|
||||
get_url_path.assert_called_once_with("ChartDataRestApi.data")
|
||||
post_chart_data.assert_called_once()
|
||||
@@ -1310,7 +1313,7 @@ def test_get_csv_data_keeps_screenshot_fallback_without_query_context(
|
||||
)
|
||||
post_chart_data = mocker.patch.object(report_state, "_post_chart_data")
|
||||
|
||||
assert report_state._get_csv_data() == b"csv-data"
|
||||
assert report_state._get_data(ChartDataResultFormat.CSV) == b"csv-data"
|
||||
|
||||
update_query_context.assert_called_once()
|
||||
refresh.assert_called_once_with(report_state._report_schedule.chart)
|
||||
@@ -1416,7 +1419,7 @@ def test_screenshot_width_calculation(
|
||||
|
||||
def _executor_report_state(mocker: MockerFixture) -> BaseReportState:
|
||||
report_schedule = create_report_schedule(mocker)
|
||||
# _get_csv_data/_get_embedded_data build a chart-data URL from chart_id
|
||||
# _get_data/_get_embedded_data build a chart-data URL from chart_id
|
||||
# before resolving the executor; give it a concrete value so URL building
|
||||
# succeeds and the executor resolution is actually reached.
|
||||
report_schedule.chart_id = 1
|
||||
@@ -1429,11 +1432,18 @@ def _executor_report_state(mocker: MockerFixture) -> BaseReportState:
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method_name",
|
||||
["_get_screenshots", "_get_csv_data", "_get_embedded_data"],
|
||||
("method_name", "method_args"),
|
||||
[
|
||||
("_get_screenshots", ()),
|
||||
("_get_data", (ChartDataResultFormat.CSV,)),
|
||||
("_get_embedded_data", ()),
|
||||
],
|
||||
)
|
||||
def test_get_content_raises_when_executor_user_missing(
|
||||
app: SupersetApp, mocker: MockerFixture, method_name: str
|
||||
app: SupersetApp,
|
||||
mocker: MockerFixture,
|
||||
method_name: str,
|
||||
method_args: tuple[Any, ...],
|
||||
) -> None:
|
||||
"""
|
||||
When the configured executor user cannot be resolved
|
||||
@@ -1460,7 +1470,38 @@ def test_get_content_raises_when_executor_user_missing(
|
||||
mock_sm.find_user = mocker.MagicMock(return_value=None)
|
||||
|
||||
with pytest.raises(ReportScheduleExecutorNotFoundError, match="ghost_user"):
|
||||
getattr(report_state, method_name)()
|
||||
getattr(report_state, method_name)(*method_args)
|
||||
|
||||
|
||||
def test_get_data_xlsx_wraps_soft_time_limit_as_xlsx_timeout(
|
||||
app: SupersetApp, mocker: MockerFixture
|
||||
) -> None:
|
||||
"""
|
||||
A ``SoftTimeLimitExceeded`` during XLSX fetch surfaces as
|
||||
``ReportScheduleXlsxTimeout`` (not the CSV timeout class), so Excel report
|
||||
timeouts are classified under the format-specific error.
|
||||
"""
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
|
||||
app.config.update({"ALERT_REPORTS_CSV_REQUEST_TIMEOUT": 60})
|
||||
report_state = _executor_report_state(mocker)
|
||||
# Non-None query context so _get_data skips the screenshot fallback and
|
||||
# reaches the _post_chart_data call this test drives to time out.
|
||||
report_state._report_schedule.chart.query_context = '{"mock": "qc"}'
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.report.execute.resolve_executor_user",
|
||||
return_value=(mocker.MagicMock(), "executor"),
|
||||
)
|
||||
mocker.patch("superset.commands.report.execute.machine_auth_provider_factory")
|
||||
mocker.patch.object(
|
||||
report_state,
|
||||
"_post_chart_data",
|
||||
side_effect=SoftTimeLimitExceeded(),
|
||||
)
|
||||
|
||||
with pytest.raises(ReportScheduleXlsxTimeout):
|
||||
report_state._get_data(ChartDataResultFormat.XLSX)
|
||||
|
||||
|
||||
def test_executor_not_found_error_message_without_username() -> None:
|
||||
@@ -1757,6 +1798,22 @@ def test_update_query_context_wraps_screenshot_failure(mocker: MockerFixture) ->
|
||||
state._update_query_context()
|
||||
|
||||
|
||||
def test_update_query_context_wraps_screenshot_failure_xlsx(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""_update_query_context surfaces the caller's error class (XLSX, not CSV)."""
|
||||
schedule = mocker.Mock(spec=ReportSchedule)
|
||||
state = BaseReportState(schedule, datetime.utcnow(), uuid4())
|
||||
state._report_schedule = schedule
|
||||
mocker.patch.object(
|
||||
state,
|
||||
"_get_screenshots",
|
||||
side_effect=ReportScheduleScreenshotFailedError("boom"),
|
||||
)
|
||||
with pytest.raises(ReportScheduleXlsxFailedError, match="query context"):
|
||||
state._update_query_context(ReportScheduleXlsxFailedError)
|
||||
|
||||
|
||||
def test_update_query_context_wraps_screenshot_timeout(mocker: MockerFixture) -> None:
|
||||
"""_update_query_context wraps ScreenshotTimeout as CsvFailedError."""
|
||||
schedule = mocker.Mock(spec=ReportSchedule)
|
||||
@@ -1876,10 +1933,27 @@ def test_get_notification_content_csv_format(mock_ff, mocker: MockerFixture) ->
|
||||
state = _make_notification_state(
|
||||
mocker, report_format=ReportDataFormat.CSV, has_chart=True
|
||||
)
|
||||
mocker.patch.object(state, "_get_csv_data", return_value=b"col1,col2\n1,2")
|
||||
mocker.patch.object(state, "_get_data", return_value=b"col1,col2\n1,2")
|
||||
|
||||
content = state._get_notification_content()
|
||||
assert content.csv == b"col1,col2\n1,2"
|
||||
assert content.xlsx is None
|
||||
|
||||
|
||||
@patch("superset.commands.report.execute.feature_flag_manager")
|
||||
def test_get_notification_content_xlsx_format(
|
||||
mock_ff: MagicMock, mocker: MockerFixture
|
||||
) -> None:
|
||||
"""XLSX-format reports populate ``NotificationContent.xlsx`` (not ``csv``)."""
|
||||
mock_ff.is_feature_enabled.return_value = False
|
||||
state = _make_notification_state(
|
||||
mocker, report_format=ReportDataFormat.XLSX, has_chart=True
|
||||
)
|
||||
mocker.patch.object(state, "_get_data", return_value=b"xlsx_bytes")
|
||||
|
||||
content = state._get_notification_content()
|
||||
assert content.xlsx == b"xlsx_bytes"
|
||||
assert content.csv is None
|
||||
|
||||
|
||||
@patch("superset.commands.report.execute.feature_flag_manager")
|
||||
@@ -2312,6 +2386,8 @@ def test_get_url_raises_when_target_chart_soft_deleted(
|
||||
report_schedule = mocker.MagicMock()
|
||||
report_schedule.chart_id = 42
|
||||
report_schedule.chart = None
|
||||
report_schedule.dashboard_id = None
|
||||
report_schedule.dashboard = None
|
||||
|
||||
state = BaseReportState(report_schedule, datetime.utcnow(), uuid4())
|
||||
with pytest.raises(ReportScheduleTargetChartDeletedError):
|
||||
@@ -2341,6 +2417,33 @@ def test_get_url_raises_when_target_dashboard_soft_deleted(
|
||||
state._get_url()
|
||||
|
||||
|
||||
def test_get_url_uses_valid_chart_with_stale_dashboard_reference(
|
||||
mocker: MockerFixture,
|
||||
app_context: None,
|
||||
) -> None:
|
||||
"""A stale non-target dashboard must not block a valid chart report."""
|
||||
report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
|
||||
report_schedule.chart_id = 42
|
||||
report_schedule.chart = mocker.sentinel.chart
|
||||
report_schedule.dashboard_id = 7
|
||||
report_schedule.dashboard = None
|
||||
report_schedule.force_screenshot = False
|
||||
get_url_path = mocker.patch(
|
||||
"superset.commands.report.execute.get_url_path",
|
||||
return_value="/chart",
|
||||
)
|
||||
|
||||
state = BaseReportState(report_schedule, datetime.utcnow(), uuid4())
|
||||
|
||||
assert state._get_url() == "/chart"
|
||||
get_url_path.assert_called_once_with(
|
||||
"ExploreView.root",
|
||||
user_friendly=False,
|
||||
form_data=json.dumps({"slice_id": 42}),
|
||||
force="false",
|
||||
)
|
||||
|
||||
|
||||
def test_get_dashboard_urls_raises_when_target_dashboard_soft_deleted(
|
||||
mocker: MockerFixture,
|
||||
app_context: None,
|
||||
@@ -2359,3 +2462,32 @@ def test_get_dashboard_urls_raises_when_target_dashboard_soft_deleted(
|
||||
state = BaseReportState(report_schedule, datetime.utcnow(), uuid4())
|
||||
with pytest.raises(ReportScheduleTargetDashboardDeletedError):
|
||||
state.get_dashboard_urls()
|
||||
|
||||
|
||||
def test_get_url_raises_unexpected_error_when_target_is_missing(
|
||||
mocker: MockerFixture,
|
||||
app: SupersetApp,
|
||||
) -> None:
|
||||
"""A malformed schedule without either target raises a useful error."""
|
||||
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
|
||||
mock_report_schedule.id = 42
|
||||
mock_report_schedule.name = "orphan_report"
|
||||
mock_report_schedule.chart = None
|
||||
mock_report_schedule.chart_id = None
|
||||
mock_report_schedule.dashboard = None
|
||||
mock_report_schedule.dashboard_id = None
|
||||
mock_report_schedule.force_screenshot = False
|
||||
|
||||
class_instance: BaseReportState = BaseReportState(
|
||||
mock_report_schedule, "January 1, 2021", "execution_id_example"
|
||||
)
|
||||
class_instance._report_schedule = mock_report_schedule
|
||||
|
||||
with pytest.raises(ReportScheduleUnexpectedError) as excinfo:
|
||||
class_instance._get_url()
|
||||
|
||||
message: str = str(excinfo.value)
|
||||
assert "Report schedule 42" in message
|
||||
assert "orphan_report" in message
|
||||
assert "chart_id=None" in message
|
||||
assert "dashboard_id=None" in message
|
||||
|
||||
@@ -684,6 +684,117 @@ def test_user_macros_without_user_info(mocker: MockerFixture):
|
||||
assert cache.current_user_rls_rules() is None
|
||||
|
||||
|
||||
def _user_metadata_cache_keys(
|
||||
mocker: MockerFixture,
|
||||
*,
|
||||
user_id: int | None,
|
||||
username: str | None,
|
||||
email: str | None,
|
||||
roles: list[str],
|
||||
) -> list[Any]:
|
||||
"""
|
||||
Render the user-metadata macros for a given user and return the values they
|
||||
contributed to the query cache key.
|
||||
"""
|
||||
mock_g = mocker.patch("superset.utils.core.g")
|
||||
if user_id is None:
|
||||
mock_g.user = None
|
||||
else:
|
||||
mock_g.user.id = user_id
|
||||
mock_g.user.username = username
|
||||
mock_g.user.email = email
|
||||
mocker.patch(
|
||||
"superset.security_manager.get_user_roles",
|
||||
return_value=[Role(name=name) for name in roles],
|
||||
)
|
||||
keys: list[Any] = []
|
||||
cache = ExtraCache(extra_cache_keys=keys, table=mocker.MagicMock())
|
||||
cache.current_user_id()
|
||||
cache.current_username()
|
||||
cache.current_user_email()
|
||||
cache.current_user_roles()
|
||||
return keys
|
||||
|
||||
|
||||
def test_user_metadata_cache_keys_isolate_distinct_users(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
Two different users contribute disjoint values to the cache key, so neither
|
||||
can be served the other's cached result. This is the property that keeps the
|
||||
``current_user_*`` macro family safe for per-user (and multi-tenant) queries.
|
||||
"""
|
||||
alice = _user_metadata_cache_keys(
|
||||
mocker, user_id=1, username="alice", email="alice@example.com", roles=["Admin"]
|
||||
)
|
||||
bob = _user_metadata_cache_keys(
|
||||
mocker, user_id=2, username="bob", email="bob@example.com", roles=["Gamma"]
|
||||
)
|
||||
assert alice
|
||||
assert bob
|
||||
assert set(alice).isdisjoint(set(bob))
|
||||
|
||||
|
||||
def test_user_metadata_cache_keys_match_for_identical_users(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
The same user always contributes the same values, so identical renders
|
||||
correctly share a cache entry (no needless fragmentation).
|
||||
"""
|
||||
first = _user_metadata_cache_keys(
|
||||
mocker, user_id=1, username="alice", email="alice@example.com", roles=["Admin"]
|
||||
)
|
||||
second = _user_metadata_cache_keys(
|
||||
mocker, user_id=1, username="alice", email="alice@example.com", roles=["Admin"]
|
||||
)
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_anonymous_user_never_collides_with_a_logged_in_user(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
Refutes the "skip cache key when the value is absent" collision concern. A
|
||||
real anonymous request has no user object, so ``current_user_id`` /
|
||||
``current_username`` / ``current_user_email`` return ``None`` and add nothing
|
||||
to the key, while ``current_user_roles`` still contributes the Public role.
|
||||
Two anonymous requests therefore render identically and correctly share one
|
||||
cache entry, and neither can be served a logged-in user's cached result.
|
||||
"""
|
||||
logged_in = _user_metadata_cache_keys(
|
||||
mocker, user_id=1, username="alice", email="alice@example.com", roles=["Admin"]
|
||||
)
|
||||
anon_first = _user_metadata_cache_keys(
|
||||
mocker, user_id=None, username=None, email=None, roles=["Public"]
|
||||
)
|
||||
anon_second = _user_metadata_cache_keys(
|
||||
mocker, user_id=None, username=None, email=None, roles=["Public"]
|
||||
)
|
||||
# The absent id/username/email contribute nothing, so an anonymous request's
|
||||
# key carries only its role, never a stray value for the missing fields.
|
||||
assert anon_first == [json.dumps(["Public"])]
|
||||
# Two anonymous requests share a cache entry; neither collides with a user.
|
||||
assert anon_first == anon_second
|
||||
assert set(anon_first).isdisjoint(set(logged_in))
|
||||
|
||||
|
||||
def test_user_metadata_cache_keys_track_each_field_independently(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
Two users who differ in a single field (here only the roles) still get
|
||||
distinct cache keys, so a change in any one metadata field is reflected.
|
||||
"""
|
||||
admin = _user_metadata_cache_keys(
|
||||
mocker, user_id=1, username="alice", email="alice@example.com", roles=["Admin"]
|
||||
)
|
||||
gamma = _user_metadata_cache_keys(
|
||||
mocker, user_id=1, username="alice", email="alice@example.com", roles=["Gamma"]
|
||||
)
|
||||
assert admin != gamma
|
||||
|
||||
|
||||
def test_current_user_rls_rules_with_no_table(mocker: MockerFixture):
|
||||
"""
|
||||
Test the ``current_user_rls_rules`` macro when no table is provided.
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from superset.reports.models import ReportSchedule
|
||||
from superset.reports.models import ReportDataFormat, ReportSchedule
|
||||
|
||||
|
||||
def test_get_native_filters_params():
|
||||
@@ -748,3 +748,10 @@ def test_generate_native_filter_time_empty_id():
|
||||
assert "" in result
|
||||
assert result[""]["id"] == ""
|
||||
assert warning is None
|
||||
|
||||
|
||||
def test_tabular_returns_csv_and_xlsx():
|
||||
"""
|
||||
Test the ``tabular`` method.
|
||||
"""
|
||||
assert ReportDataFormat.tabular() == {ReportDataFormat.CSV, ReportDataFormat.XLSX}
|
||||
|
||||
@@ -144,3 +144,35 @@ def test_email_subject_with_datetime() -> None:
|
||||
subject = notification._get_subject()
|
||||
assert datetime_pattern not in subject
|
||||
assert frozen_now.strftime(datetime_pattern) in subject
|
||||
|
||||
|
||||
def test_email_content_with_xlsx_attachment() -> None:
|
||||
"""Email content attaches xlsx bytes under an ``.xlsx`` filename."""
|
||||
# `superset.models.helpers`, a dependency of following imports,
|
||||
# requires app context
|
||||
from superset.reports.models import ReportRecipients, ReportRecipientType
|
||||
from superset.reports.notifications.base import NotificationContent
|
||||
from superset.reports.notifications.email import EmailNotification
|
||||
|
||||
content = NotificationContent(
|
||||
name="test report",
|
||||
xlsx=b"xlsx_content",
|
||||
header_data={
|
||||
"notification_format": "XLSX",
|
||||
"notification_type": "Report",
|
||||
"editors": [1],
|
||||
"notification_source": None,
|
||||
"chart_id": None,
|
||||
"dashboard_id": None,
|
||||
"slack_channels": None,
|
||||
"execution_id": "test-execution-id",
|
||||
},
|
||||
)
|
||||
email_content = EmailNotification(
|
||||
recipient=ReportRecipients(type=ReportRecipientType.EMAIL), content=content
|
||||
)._get_content()
|
||||
|
||||
assert email_content.data is not None
|
||||
attachment_name = list(email_content.data.keys())[0]
|
||||
assert attachment_name.endswith(".xlsx")
|
||||
assert email_content.data[attachment_name] == b"xlsx_content"
|
||||
|
||||
@@ -179,6 +179,84 @@ def test_get_inline_files_with_screenshots(mock_header_data) -> None:
|
||||
|
||||
assert result == ("png", [b"screenshot1", b"screenshot2"])
|
||||
|
||||
|
||||
def test_get_inline_files_with_csv(mock_header_data: HeaderDataType) -> None:
|
||||
"""
|
||||
Test the _get_inline_files function to ensure it returns the correct tuple
|
||||
when content has a CSV attachment
|
||||
"""
|
||||
from superset.reports.models import ReportRecipients, ReportRecipientType
|
||||
from superset.reports.notifications.base import NotificationContent
|
||||
from superset.reports.notifications.slack import SlackNotification
|
||||
|
||||
content = NotificationContent(
|
||||
name="test alert",
|
||||
header_data=mock_header_data,
|
||||
csv=b"csv_content",
|
||||
)
|
||||
slack_notification = SlackNotification(
|
||||
recipient=ReportRecipients(
|
||||
type=ReportRecipientType.SLACK,
|
||||
recipient_config_json='{"target": "some_channel"}',
|
||||
),
|
||||
content=content,
|
||||
)
|
||||
|
||||
result = slack_notification._get_inline_files()
|
||||
|
||||
assert result == ("csv", [b"csv_content"])
|
||||
|
||||
|
||||
def test_get_inline_files_with_xlsx(mock_header_data: HeaderDataType) -> None:
|
||||
"""
|
||||
Test the _get_inline_files function to ensure it returns the correct tuple
|
||||
when content has an Excel attachment
|
||||
"""
|
||||
from superset.reports.models import ReportRecipients, ReportRecipientType
|
||||
from superset.reports.notifications.base import NotificationContent
|
||||
from superset.reports.notifications.slack import SlackNotification
|
||||
|
||||
content = NotificationContent(
|
||||
name="test alert",
|
||||
header_data=mock_header_data,
|
||||
xlsx=b"xlsx_content",
|
||||
)
|
||||
slack_notification = SlackNotification(
|
||||
recipient=ReportRecipients(
|
||||
type=ReportRecipientType.SLACK,
|
||||
recipient_config_json='{"target": "some_channel"}',
|
||||
),
|
||||
content=content,
|
||||
)
|
||||
|
||||
result = slack_notification._get_inline_files()
|
||||
|
||||
assert result == ("xlsx", [b"xlsx_content"])
|
||||
|
||||
|
||||
def test_get_inline_files_with_xlsx_slackv2(mock_header_data: HeaderDataType) -> None:
|
||||
"""SlackV2 _get_inline_files returns the xlsx tuple when content has Excel."""
|
||||
from superset.reports.models import ReportRecipients, ReportRecipientType
|
||||
from superset.reports.notifications.base import NotificationContent
|
||||
from superset.reports.notifications.slackv2 import SlackV2Notification
|
||||
|
||||
content = NotificationContent(
|
||||
name="test alert",
|
||||
header_data=mock_header_data,
|
||||
xlsx=b"xlsx_content",
|
||||
)
|
||||
slack_notification = SlackV2Notification(
|
||||
recipient=ReportRecipients(
|
||||
type=ReportRecipientType.SLACKV2,
|
||||
recipient_config_json='{"target": "some_channel"}',
|
||||
),
|
||||
content=content,
|
||||
)
|
||||
|
||||
result = slack_notification._get_inline_files()
|
||||
|
||||
assert result == ("xlsx", [b"xlsx_content"])
|
||||
|
||||
# Ensure _get_inline_files function returns None when content has no screenshots or csv # noqa: E501
|
||||
|
||||
|
||||
|
||||
@@ -171,6 +171,37 @@ def test_get_files_includes_all_content_types(mock_header_data) -> None:
|
||||
assert mime_types.count("image/png") == 2
|
||||
|
||||
|
||||
def test_get_files_includes_xlsx(mock_header_data: HeaderDataType) -> None:
|
||||
"""_get_files attaches xlsx bytes as report.xlsx with the spreadsheet MIME type."""
|
||||
from superset.reports.models import ReportRecipients, ReportRecipientType
|
||||
from superset.reports.notifications.base import NotificationContent
|
||||
|
||||
xlsx_bytes: bytes = b"PK\x03\x04 mock xlsx bytes"
|
||||
|
||||
content = NotificationContent(
|
||||
name="file test",
|
||||
header_data=mock_header_data,
|
||||
xlsx=xlsx_bytes,
|
||||
description="xlsx files test",
|
||||
)
|
||||
webhook_notification = WebhookNotification(
|
||||
recipient=ReportRecipients(
|
||||
type=ReportRecipientType.WEBHOOK,
|
||||
recipient_config_json='{"target": "https://webhook.com"}',
|
||||
),
|
||||
content=content,
|
||||
)
|
||||
files = webhook_notification._get_files()
|
||||
|
||||
assert len(files) == 1
|
||||
file_name, file_bytes, mime_type = files[0][1]
|
||||
assert file_name == "report.xlsx"
|
||||
assert file_bytes == xlsx_bytes
|
||||
assert mime_type == (
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
)
|
||||
|
||||
|
||||
def test_get_files_empty_when_no_content(mock_header_data) -> None:
|
||||
"""
|
||||
Test that _get_files returns empty list when no files present
|
||||
|
||||
Reference in New Issue
Block a user