Compare commits

...

7 Commits

Author SHA1 Message Date
Amin Ghadersohi
3712a308b3 test(jinja): model a realistic anonymous user in the cache-key test
Address review feedback: the anonymous case now carries the Public role (as a
real anonymous request does) instead of forcing an empty role list, and asserts
the actual properties — the absent id/username/email add nothing, two anonymous
requests share a cache entry, and neither collides with a logged-in user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:01:38 -07:00
Evan
59cf883cfa test(jinja): add -> None return annotations to new cache-key tests
Address a codeant-ai review nit: the four new test functions in this PR
were missing the -> None return type annotation used consistently
elsewhere in this file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 22:41:28 -07:00
Amin Ghadersohi
562533e6a0 test(jinja): prove current_user_* macros produce collision-free cache keys
A review of #33924 raised a concern that the user-metadata macros
(`current_user_id`, `current_username`, `current_user_email`,
`current_user_roles`) only contribute to the query cache key when their value is
present, skipping the key when the value is absent, and that this could cause
cache collisions across users.

It doesn't: the skipped path is safe because an absent value renders identically
for every user (the macro returns `None`), so absent users correctly share one
cache entry rather than colliding, while present values are always distinct in
the key. Add tests locking in that property:

- distinct users contribute disjoint cache-key values (no cross-user serving)
- identical users contribute identical values (correct sharing, no fragmentation)
- an anonymous render contributes nothing, so it never collides with a logged-in
  user's cache entry
- a change in any single metadata field yields a distinct cache key

No behavior change; these tests document and guard the existing, correct
behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:31:36 -07:00
yousoph
8603048518 fix(dashboard): block dependent filter from fetching until defaultToFirstItem parent selects (#40978)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:01:56 -07:00
Mike Bridge
635b18103d fix(reports): null-guard execution against missing target (#39973)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-15 22:00:46 -07:00
gr33nak
d57569c54a feat(reports): add XLSX (Excel) attachments for Alerts & Reports (#41424)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Martin Kominek <martin.kominek@stratox.cz>
Co-authored-by: kominma3 <127758497+kominma3@users.noreply.github.com>
2026-07-15 21:49:47 -07:00
Benedict Jin
409605de70 fix(plugin-chart-echarts): key Mixed Timeseries label maps by rendered series names (#41933) 2026-07-15 21:37:59 -07:00
27 changed files with 1324 additions and 98 deletions

View File

@@ -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

View File

@@ -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);

View File

@@ -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,

View File

@@ -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);
});

View File

@@ -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

View File

@@ -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

View File

@@ -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(() => {

View File

@@ -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,

View File

@@ -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],
)

View File

@@ -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>

View File

@@ -34,6 +34,7 @@ export enum NotificationFormats {
Text = 'TEXT',
PNG = 'PNG',
CSV = 'CSV',
XLSX = 'XLSX',
}
export interface ReportObject {
id?: number;

View File

@@ -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.")

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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:

View File

@@ -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:

View File

@@ -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"))

View File

@@ -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"
)

View File

@@ -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"

View File

@@ -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

View File

@@ -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.

View File

@@ -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}

View File

@@ -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"

View File

@@ -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

View File

@@ -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