feat(alerts): enable tab selection for dashboard alerts/reports (#29096)

This commit is contained in:
Jack 2024-07-30 17:25:19 -05:00 committed by GitHub
parent 6bf8596d68
commit d21d7591c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 284 additions and 56 deletions

View File

@ -44,6 +44,7 @@ export {
Steps, Steps,
Tag, Tag,
Tree, Tree,
TreeSelect,
Typography, Typography,
Upload, Upload,
} from 'antd'; } from 'antd';

View File

@ -106,11 +106,18 @@ const ownersEndpoint = 'glob:*/api/v1/alert/related/owners?*';
const databaseEndpoint = 'glob:*/api/v1/alert/related/database?*'; const databaseEndpoint = 'glob:*/api/v1/alert/related/database?*';
const dashboardEndpoint = 'glob:*/api/v1/alert/related/dashboard?*'; const dashboardEndpoint = 'glob:*/api/v1/alert/related/dashboard?*';
const chartEndpoint = 'glob:*/api/v1/alert/related/chart?*'; const chartEndpoint = 'glob:*/api/v1/alert/related/chart?*';
const tabsEndpoint = 'glob:*/api/v1/dashboard/1/tabs';
fetchMock.get(ownersEndpoint, { result: [] }); fetchMock.get(ownersEndpoint, { result: [] });
fetchMock.get(databaseEndpoint, { result: [] }); fetchMock.get(databaseEndpoint, { result: [] });
fetchMock.get(dashboardEndpoint, { result: [] }); fetchMock.get(dashboardEndpoint, { result: [] });
fetchMock.get(chartEndpoint, { result: [{ text: 'table chart', value: 1 }] }); fetchMock.get(chartEndpoint, { result: [{ text: 'table chart', value: 1 }] });
fetchMock.get(tabsEndpoint, {
result: {
all_tabs: {},
tab_tree: [],
},
});
// Create a valid alert with all required fields entered for validation check // Create a valid alert with all required fields entered for validation check
@ -413,6 +420,21 @@ test('renders screenshot options when dashboard is selected', async () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
test('renders tab selection when Dashboard is selected', async () => {
render(<AlertReportModal {...generateMockedProps(false, true, true)} />, {
useRedux: true,
});
userEvent.click(screen.getByTestId('contents-panel'));
await screen.findByText(/test dashboard/i);
expect(
screen.getByRole('combobox', { name: /select content type/i }),
).toBeInTheDocument();
expect(
screen.getByRole('combobox', { name: /dashboard/i }),
).toBeInTheDocument();
expect(screen.getByText(/select tab/i)).toBeInTheDocument();
});
test('changes to content options when chart is selected', async () => { test('changes to content options when chart is selected', async () => {
render(<AlertReportModal {...generateMockedProps(false, true, true)} />, { render(<AlertReportModal {...generateMockedProps(false, true, true)} />, {
useRedux: true, useRedux: true,

View File

@ -46,7 +46,7 @@ import TimezoneSelector from 'src/components/TimezoneSelector';
import { propertyComparator } from 'src/components/Select/utils'; import { propertyComparator } from 'src/components/Select/utils';
import withToasts from 'src/components/MessageToasts/withToasts'; import withToasts from 'src/components/MessageToasts/withToasts';
import Owner from 'src/types/Owner'; import Owner from 'src/types/Owner';
import { AntdCheckbox, AsyncSelect, Select } from 'src/components'; import { AntdCheckbox, AsyncSelect, Select, TreeSelect } from 'src/components';
import TextAreaControl from 'src/explore/components/controls/TextAreaControl'; import TextAreaControl from 'src/explore/components/controls/TextAreaControl';
import { useCommonConf } from 'src/features/databases/state'; import { useCommonConf } from 'src/features/databases/state';
import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls'; import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls';
@ -57,12 +57,16 @@ import {
ChartObject, ChartObject,
DashboardObject, DashboardObject,
DatabaseObject, DatabaseObject,
Extra,
MetaObject, MetaObject,
Operator, Operator,
Recipient, Recipient,
AlertsReportsConfig, AlertsReportsConfig,
ValidationObject, ValidationObject,
Sections, Sections,
TabNode,
SelectValue,
ContentType,
} from 'src/features/alerts/types'; } from 'src/features/alerts/types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
@ -80,11 +84,6 @@ const TEXT_BASED_VISUALIZATION_TYPES = [
'paired_ttest', 'paired_ttest',
]; ];
type SelectValue = {
value: string;
label: string;
};
export interface AlertReportModalProps { export interface AlertReportModalProps {
addSuccessToast: (msg: string) => void; addSuccessToast: (msg: string) => void;
addDangerToast: (msg: string) => void; addDangerToast: (msg: string) => void;
@ -104,6 +103,12 @@ const DEFAULT_NOTIFICATION_METHODS: NotificationMethodOption[] = [
NotificationMethodOption.Email, NotificationMethodOption.Email,
]; ];
const DEFAULT_NOTIFICATION_FORMAT = 'PNG'; const DEFAULT_NOTIFICATION_FORMAT = 'PNG';
const DEFAULT_EXTRA_DASHBOARD_OPTIONS: Extra = {
dashboard: {
anchor: '',
},
};
const CONDITIONS = [ const CONDITIONS = [
{ {
label: t('< (Smaller than)'), label: t('< (Smaller than)'),
@ -218,6 +223,10 @@ const StyledModal = styled(Modal)`
} }
`; `;
const StyledTreeSelect = styled(TreeSelect)`
width: 100%;
`;
const StyledSwitchContainer = styled.div` const StyledSwitchContainer = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
@ -441,6 +450,8 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
const [sourceOptions, setSourceOptions] = useState<MetaObject[]>([]); const [sourceOptions, setSourceOptions] = useState<MetaObject[]>([]);
const [dashboardOptions, setDashboardOptions] = useState<MetaObject[]>([]); const [dashboardOptions, setDashboardOptions] = useState<MetaObject[]>([]);
const [chartOptions, setChartOptions] = useState<MetaObject[]>([]); const [chartOptions, setChartOptions] = useState<MetaObject[]>([]);
const [tabOptions, setTabOptions] = useState<TabNode[]>([]);
// Validation // Validation
const [validationStatus, setValidationStatus] = useState<ValidationObject>({ const [validationStatus, setValidationStatus] = useState<ValidationObject>({
[Sections.General]: { [Sections.General]: {
@ -491,6 +502,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
const isEditMode = alert !== null; const isEditMode = alert !== null;
const formatOptionEnabled = const formatOptionEnabled =
isFeatureEnabled(FeatureFlag.AlertsAttachReports) || isReport; isFeatureEnabled(FeatureFlag.AlertsAttachReports) || isReport;
const tabsEnabled = isFeatureEnabled(FeatureFlag.AlertReportTabs);
const [notificationAddState, setNotificationAddState] = const [notificationAddState, setNotificationAddState] =
useState<NotificationAddStatus>('active'); useState<NotificationAddStatus>('active');
@ -547,6 +559,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
active: true, active: true,
creation_method: 'alerts_reports', creation_method: 'alerts_reports',
crontab: ALERT_REPORTS_DEFAULT_CRON_VALUE, crontab: ALERT_REPORTS_DEFAULT_CRON_VALUE,
extra: DEFAULT_EXTRA_DASHBOARD_OPTIONS,
log_retention: ALERT_REPORTS_DEFAULT_RETENTION, log_retention: ALERT_REPORTS_DEFAULT_RETENTION,
working_timeout: ALERT_REPORTS_DEFAULT_WORKING_TIMEOUT, working_timeout: ALERT_REPORTS_DEFAULT_WORKING_TIMEOUT,
name: '', name: '',
@ -595,6 +608,22 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
setNotificationAddState('active'); setNotificationAddState('active');
}; };
const updateAnchorState = (value: any) => {
setCurrentAlert(currentAlertData => {
const dashboardState = currentAlertData?.extra?.dashboard;
const extra = {
dashboard: {
...dashboardState,
anchor: value,
},
};
return {
...currentAlertData,
extra,
};
});
};
// Alert fetch logic // Alert fetch logic
const { const {
state: { loading, resource, error: fetchError }, state: { loading, resource, error: fetchError },
@ -631,7 +660,8 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
} }
}); });
const shouldEnableForceScreenshot = contentType === 'chart' && !isReport; const shouldEnableForceScreenshot =
contentType === ContentType.Chart && !isReport;
const data: any = { const data: any = {
...currentAlert, ...currentAlert,
type: isReport ? 'Report' : 'Alert', type: isReport ? 'Report' : 'Alert',
@ -640,9 +670,12 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
validator_config_json: conditionNotNull validator_config_json: conditionNotNull
? {} ? {}
: currentAlert?.validator_config_json, : currentAlert?.validator_config_json,
chart: contentType === 'chart' ? currentAlert?.chart?.value : null, chart:
contentType === ContentType.Chart ? currentAlert?.chart?.value : null,
dashboard: dashboard:
contentType === 'dashboard' ? currentAlert?.dashboard?.value : null, contentType === ContentType.Dashboard
? currentAlert?.dashboard?.value
: null,
custom_width: isScreenshot ? currentAlert?.custom_width : undefined, custom_width: isScreenshot ? currentAlert?.custom_width : undefined,
database: currentAlert?.database?.value, database: currentAlert?.database?.value,
owners: (currentAlert?.owners || []).map( owners: (currentAlert?.owners || []).map(
@ -650,6 +683,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
), ),
recipients, recipients,
report_format: reportFormat || DEFAULT_NOTIFICATION_FORMAT, report_format: reportFormat || DEFAULT_NOTIFICATION_FORMAT,
extra: contentType === ContentType.Dashboard ? currentAlert?.extra : null,
}; };
if (data.recipients && !data.recipients.length) { if (data.recipients && !data.recipients.length) {
@ -657,7 +691,6 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
} }
data.context_markdown = 'string'; data.context_markdown = 'string';
if (isEditMode) { if (isEditMode) {
// Edit // Edit
if (currentAlert?.id) { if (currentAlert?.id) {
@ -780,6 +813,28 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
[], [],
); );
const dashboard = currentAlert?.dashboard;
useEffect(() => {
if (!tabsEnabled) return;
if (dashboard?.value) {
SupersetClient.get({
endpoint: `/api/v1/dashboard/${dashboard.value}/tabs`,
})
.then(response => {
const { tab_tree: tabTree, all_tabs: allTabs } = response.json.result;
setTabOptions(tabTree);
const anchor = currentAlert?.extra?.dashboard?.anchor;
if (anchor && !(anchor in allTabs)) {
updateAnchorState(undefined);
}
})
.catch(() => {
addDangerToast(t('There was an error retrieving dashboard tabs.'));
});
}
}, [dashboard, tabsEnabled, currentAlert?.extra, addDangerToast]);
const databaseLabel = currentAlert?.database && !currentAlert.database.label; const databaseLabel = currentAlert?.database && !currentAlert.database.label;
useEffect(() => { useEffect(() => {
// Find source if current alert has one set // Find source if current alert has one set
@ -891,6 +946,27 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
endpoint: `/api/v1/chart/${chart.value}`, endpoint: `/api/v1/chart/${chart.value}`,
}).then(response => setChartVizType(response.json.result.viz_type)); }).then(response => setChartVizType(response.json.result.viz_type));
const updateEmailSubject = () => {
const chartLabel = currentAlert?.chart?.label;
const dashboardLabel = currentAlert?.dashboard?.label;
if (!currentAlert?.name) {
setEmailSubject('');
return;
}
switch (contentType) {
case ContentType.Chart:
setEmailSubject(`${currentAlert?.name}: ${chartLabel || ''}`);
break;
case ContentType.Dashboard:
setEmailSubject(`${currentAlert?.name}: ${dashboardLabel || ''}`);
break;
default:
setEmailSubject('');
}
};
// Handle input/textarea updates // Handle input/textarea updates
const onInputChange = ( const onInputChange = (
event: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>, event: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>,
@ -943,6 +1019,10 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
const onDashboardChange = (dashboard: SelectValue) => { const onDashboardChange = (dashboard: SelectValue) => {
updateAlertState('dashboard', dashboard || undefined); updateAlertState('dashboard', dashboard || undefined);
updateAlertState('chart', null); updateAlertState('chart', null);
if (tabsEnabled) {
setTabOptions([]);
updateAnchorState('');
}
}; };
const onChartChange = (chart: SelectValue) => { const onChartChange = (chart: SelectValue) => {
@ -1057,8 +1137,8 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
const errors = []; const errors = [];
if ( if (
!( !(
(contentType === 'dashboard' && !!currentAlert?.dashboard) || (contentType === ContentType.Dashboard && !!currentAlert?.dashboard) ||
(contentType === 'chart' && !!currentAlert?.chart) (contentType === ContentType.Chart && !!currentAlert?.chart)
) )
) { ) {
errors.push(TRANSLATIONS.CONTENT_ERROR_TEXT); errors.push(TRANSLATIONS.CONTENT_ERROR_TEXT);
@ -1206,7 +1286,9 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
? 'hidden' ? 'hidden'
: 'active', : 'active',
); );
setContentType(resource.chart ? 'chart' : 'dashboard'); setContentType(
resource.chart ? ContentType.Chart : ContentType.Dashboard,
);
setReportFormat(resource.report_format || DEFAULT_NOTIFICATION_FORMAT); setReportFormat(resource.report_format || DEFAULT_NOTIFICATION_FORMAT);
const validatorConfig = const validatorConfig =
typeof resource.validator_config_json === 'string' typeof resource.validator_config_json === 'string'
@ -1321,28 +1403,6 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
return titleText; return titleText;
}; };
const updateEmailSubject = () => {
if (contentType === 'chart') {
if (currentAlert?.name || currentAlert?.chart?.label) {
setEmailSubject(
`${currentAlert?.name}: ${currentAlert?.chart?.label || ''}`,
);
} else {
setEmailSubject('');
}
} else if (contentType === 'dashboard') {
if (currentAlert?.name || currentAlert?.dashboard?.label) {
setEmailSubject(
`${currentAlert?.name}: ${currentAlert?.dashboard?.label || ''}`,
);
} else {
setEmailSubject('');
}
} else {
setEmailSubject('');
}
};
const handleErrorUpdate = (hasError: boolean) => { const handleErrorUpdate = (hasError: boolean) => {
setEmailError(hasError); setEmailError(hasError);
}; };
@ -1586,7 +1646,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
/> />
</StyledInputContainer> </StyledInputContainer>
<StyledInputContainer> <StyledInputContainer>
{contentType === 'chart' ? ( {contentType === ContentType.Chart ? (
<> <>
<div className="control-label"> <div className="control-label">
{t('Select chart')} {t('Select chart')}
@ -1649,7 +1709,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
onChange={onFormatChange} onChange={onFormatChange}
value={reportFormat} value={reportFormat}
options={ options={
contentType === 'dashboard' contentType === ContentType.Dashboard
? ['pdf', 'png'].map(key => FORMAT_OPTIONS[key]) ? ['pdf', 'png'].map(key => FORMAT_OPTIONS[key])
: /* If chart is of text based viz type: show text : /* If chart is of text based viz type: show text
format option */ format option */
@ -1662,9 +1722,25 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
</> </>
)} )}
</StyledInputContainer> </StyledInputContainer>
{tabsEnabled && contentType === ContentType.Dashboard && (
<StyledInputContainer>
<>
<div className="control-label">{t('Select tab')}</div>
<StyledTreeSelect
disabled={tabOptions?.length === 0}
treeData={tabOptions}
value={currentAlert?.extra?.dashboard?.anchor}
onSelect={updateAnchorState}
placeholder={t('Select a tab')}
/>
</>
</StyledInputContainer>
)}
{isScreenshot && ( {isScreenshot && (
<StyledInputContainer <StyledInputContainer
css={!isReport && contentType === 'chart' && noMarginBottom} css={
!isReport && contentType === ContentType.Chart && noMarginBottom
}
> >
<div className="control-label">{t('Screenshot width')}</div> <div className="control-label">{t('Screenshot width')}</div>
<div className="input-container"> <div className="input-container">
@ -1680,7 +1756,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
</div> </div>
</StyledInputContainer> </StyledInputContainer>
)} )}
{(isReport || contentType === 'dashboard') && ( {(isReport || contentType === ContentType.Dashboard) && (
<div className="inline-container"> <div className="inline-container">
<StyledCheckbox <StyledCheckbox
data-test="bypass-cache" data-test="bypass-cache"

View File

@ -47,6 +47,11 @@ export enum NotificationMethodOption {
SlackV2 = 'SlackV2', SlackV2 = 'SlackV2',
} }
export type SelectValue = {
value: string;
label: string;
};
export type NotificationSetting = { export type NotificationSetting = {
method?: NotificationMethodOption; method?: NotificationMethodOption;
recipients: string; recipients: string;
@ -62,6 +67,12 @@ export type SlackChannel = {
is_private: boolean; is_private: boolean;
}; };
export type TabNode = {
title: string;
value: string;
children?: TabNode[];
};
export type Recipient = { export type Recipient = {
recipient_config_json: { recipient_config_json: {
target: string; target: string;
@ -77,6 +88,16 @@ export type MetaObject = {
value?: number | string; value?: number | string;
}; };
export type DashboardState = {
activeTabs?: Array<string>;
dataMask?: Object;
anchor?: string;
};
export type Extra = {
dashboard?: DashboardState;
};
export type Operator = '<' | '>' | '<=' | '>=' | '==' | '!=' | 'not null'; export type Operator = '<' | '>' | '<=' | '>=' | '==' | '!=' | 'not null';
export type AlertObject = { export type AlertObject = {
@ -96,6 +117,7 @@ export type AlertObject = {
description?: string; description?: string;
email_subject?: string; email_subject?: string;
error?: string; error?: string;
extra?: Extra;
force_screenshot: boolean; force_screenshot: boolean;
grace_period?: number; grace_period?: number;
id: number; id: number;
@ -164,3 +186,8 @@ export enum Sections {
Schedule = 'scheduleSection', Schedule = 'scheduleSection',
Notification = 'notificationSection', Notification = 'notificationSection',
} }
export enum ContentType {
Dashboard = 'dashboard',
Chart = 'chart',
}

View File

@ -137,7 +137,7 @@ function AlertList({
toggleBulkSelect, toggleBulkSelect,
} = useListViewResource<AlertObject>( } = useListViewResource<AlertObject>(
'report', 'report',
t('reports'), t('report'),
addDangerToast, addDangerToast,
true, true,
undefined, undefined,

View File

@ -207,15 +207,15 @@ class BaseReportState:
force=force, force=force,
**kwargs, **kwargs,
) )
# If we need to render dashboard in a specific state, use stateful permalink # If we need to render dashboard in a specific state, use stateful permalink
if dashboard_state := self._report_schedule.extra.get("dashboard"): if (
dashboard_state := self._report_schedule.extra.get("dashboard")
) and feature_flag_manager.is_feature_enabled("ALERT_REPORT_TABS"):
permalink_key = CreateDashboardPermalinkCommand( permalink_key = CreateDashboardPermalinkCommand(
dashboard_id=str(self._report_schedule.dashboard.uuid), dashboard_id=str(self._report_schedule.dashboard.uuid),
state=dashboard_state, state=dashboard_state,
).run() ).run()
return get_url_path("Superset.dashboard_permalink", key=permalink_key) return get_url_path("Superset.dashboard_permalink", key=permalink_key)
dashboard = self._report_schedule.dashboard dashboard = self._report_schedule.dashboard
dashboard_id_or_slug = ( dashboard_id_or_slug = (
dashboard.uuid if dashboard and dashboard.uuid else dashboard.id dashboard.uuid if dashboard and dashboard.uuid else dashboard.id

View File

@ -284,6 +284,7 @@ class TabSchema(Schema):
children = fields.List(fields.Nested(lambda: TabSchema())) children = fields.List(fields.Nested(lambda: TabSchema()))
value = fields.Str() value = fields.Str()
title = fields.Str() title = fields.Str()
parents = fields.List(fields.Str())
class TabsPayloadSchema(Schema): class TabsPayloadSchema(Schema):

View File

@ -248,7 +248,6 @@ class DashboardScreenshot(BaseScreenshot):
url, url,
standalone=DashboardStandaloneMode.REPORT.value, standalone=DashboardStandaloneMode.REPORT.value,
) )
super().__init__(url, digest) super().__init__(url, digest)
self.window_size = window_size or DEFAULT_DASHBOARD_WINDOW_SIZE self.window_size = window_size or DEFAULT_DASHBOARD_WINDOW_SIZE
self.thumb_size = thumb_size or DEFAULT_DASHBOARD_THUMBNAIL_SIZE self.thumb_size = thumb_size or DEFAULT_DASHBOARD_THUMBNAIL_SIZE

View File

@ -18,12 +18,14 @@ from datetime import datetime
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from uuid import uuid4 from uuid import uuid4
import pytest
from flask import current_app from flask import current_app
from superset.commands.dashboard.permalink.create import CreateDashboardPermalinkCommand from superset.commands.dashboard.permalink.create import CreateDashboardPermalinkCommand
from superset.commands.report.execute import AsyncExecuteReportScheduleCommand from superset.commands.report.execute import AsyncExecuteReportScheduleCommand
from superset.models.dashboard import Dashboard from superset.models.dashboard import Dashboard
from superset.reports.models import ReportSourceFormat from superset.reports.models import ReportSourceFormat
from superset.utils.urls import get_url_path
from tests.integration_tests.fixtures.tabbed_dashboard import ( from tests.integration_tests.fixtures.tabbed_dashboard import (
tabbed_dashboard, # noqa: F401 tabbed_dashboard, # noqa: F401
) )
@ -34,22 +36,21 @@ from tests.integration_tests.reports.utils import create_dashboard_report
@patch( @patch(
"superset.commands.report.execute.DashboardScreenshot", "superset.commands.report.execute.DashboardScreenshot",
) )
@patch( @patch.dict(
"superset.commands.dashboard.permalink.create.CreateDashboardPermalinkCommand.run" "superset.extensions.feature_flag_manager._feature_flags", ALERT_REPORT_TABS=True
) )
@pytest.mark.usefixtures("login_as_admin")
def test_report_for_dashboard_with_tabs( def test_report_for_dashboard_with_tabs(
create_dashboard_permalink_mock: MagicMock,
dashboard_screenshot_mock: MagicMock, dashboard_screenshot_mock: MagicMock,
send_email_smtp_mock: MagicMock, send_email_smtp_mock: MagicMock,
tabbed_dashboard: Dashboard, # noqa: F811 tabbed_dashboard: Dashboard, # noqa: F811
) -> None: ) -> None:
create_dashboard_permalink_mock.return_value = "permalink"
dashboard_screenshot_mock.get_screenshot.return_value = b"test-image" dashboard_screenshot_mock.get_screenshot.return_value = b"test-image"
current_app.config["ALERT_REPORTS_NOTIFICATION_DRY_RUN"] = False current_app.config["ALERT_REPORTS_NOTIFICATION_DRY_RUN"] = False
with create_dashboard_report( with create_dashboard_report(
dashboard=tabbed_dashboard, dashboard=tabbed_dashboard,
extra={"active_tabs": ["TAB-L1B", "TAB-L2BB"]}, extra={"dashboard": {"active_tabs": ["TAB-L1B", "TAB-L2BB"]}},
name="test report tabbed dashboard", name="test report tabbed dashboard",
) as report_schedule: ) as report_schedule:
dashboard: Dashboard = report_schedule.dashboard dashboard: Dashboard = report_schedule.dashboard
@ -61,9 +62,12 @@ def test_report_for_dashboard_with_tabs(
str(dashboard.id), dashboard_state str(dashboard.id), dashboard_state
).run() ).run()
expected_url = get_url_path("Superset.dashboard_permalink", key=permalink_key)
assert dashboard_screenshot_mock.call_count == 1 assert dashboard_screenshot_mock.call_count == 1
url = dashboard_screenshot_mock.call_args.args[0] called_url = dashboard_screenshot_mock.call_args.args[0]
assert url.endswith(f"/superset/dashboard/p/{permalink_key}/")
assert called_url == expected_url
assert send_email_smtp_mock.call_count == 1 assert send_email_smtp_mock.call_count == 1
assert len(send_email_smtp_mock.call_args.kwargs["images"]) == 1 assert len(send_email_smtp_mock.call_args.kwargs["images"]) == 1
@ -72,22 +76,21 @@ def test_report_for_dashboard_with_tabs(
@patch( @patch(
"superset.commands.report.execute.DashboardScreenshot", "superset.commands.report.execute.DashboardScreenshot",
) )
@patch( @patch.dict(
"superset.commands.dashboard.permalink.create.CreateDashboardPermalinkCommand.run" "superset.extensions.feature_flag_manager._feature_flags", ALERT_REPORT_TABS=True
) )
@pytest.mark.usefixtures("login_as_admin")
def test_report_with_header_data( def test_report_with_header_data(
create_dashboard_permalink_mock: MagicMock,
dashboard_screenshot_mock: MagicMock, dashboard_screenshot_mock: MagicMock,
send_email_smtp_mock: MagicMock, send_email_smtp_mock: MagicMock,
tabbed_dashboard: Dashboard, # noqa: F811 tabbed_dashboard: Dashboard, # noqa: F811
) -> None: ) -> None:
create_dashboard_permalink_mock.return_value = "permalink"
dashboard_screenshot_mock.get_screenshot.return_value = b"test-image" dashboard_screenshot_mock.get_screenshot.return_value = b"test-image"
current_app.config["ALERT_REPORTS_NOTIFICATION_DRY_RUN"] = False current_app.config["ALERT_REPORTS_NOTIFICATION_DRY_RUN"] = False
with create_dashboard_report( with create_dashboard_report(
dashboard=tabbed_dashboard, dashboard=tabbed_dashboard,
extra={"active_tabs": ["TAB-L1B"]}, extra={"dashboard": {"active_tabs": ["TAB-L1B", "TAB-L2BB"]}},
name="test report tabbed dashboard", name="test report tabbed dashboard",
) as report_schedule: ) as report_schedule:
dashboard: Dashboard = report_schedule.dashboard dashboard: Dashboard = report_schedule.dashboard
@ -101,6 +104,7 @@ def test_report_with_header_data(
assert dashboard_screenshot_mock.call_count == 1 assert dashboard_screenshot_mock.call_count == 1
url = dashboard_screenshot_mock.call_args.args[0] url = dashboard_screenshot_mock.call_args.args[0]
assert url.endswith(f"/superset/dashboard/p/{permalink_key}/") assert url.endswith(f"/superset/dashboard/p/{permalink_key}/")
assert send_email_smtp_mock.call_count == 1 assert send_email_smtp_mock.call_count == 1
header_data = send_email_smtp_mock.call_args.kwargs["header_data"] header_data = send_email_smtp_mock.call_args.kwargs["header_data"]

View File

@ -60,6 +60,7 @@ from superset.commands.report.execute import (
) )
from superset.commands.report.log_prune import AsyncPruneReportScheduleLogCommand from superset.commands.report.log_prune import AsyncPruneReportScheduleLogCommand
from superset.exceptions import SupersetException from superset.exceptions import SupersetException
from superset.key_value.models import KeyValueEntry
from superset.models.core import Database from superset.models.core import Database
from superset.models.dashboard import Dashboard from superset.models.dashboard import Dashboard
from superset.models.slice import Slice from superset.models.slice import Slice
@ -82,6 +83,9 @@ from tests.integration_tests.fixtures.birth_names_dashboard import (
load_birth_names_dashboard_with_slices, # noqa: F401 load_birth_names_dashboard_with_slices, # noqa: F401
load_birth_names_data, # noqa: F401 load_birth_names_data, # noqa: F401
) )
from tests.integration_tests.fixtures.tabbed_dashboard import (
tabbed_dashboard, # noqa: F401
)
from tests.integration_tests.fixtures.world_bank_dashboard import ( from tests.integration_tests.fixtures.world_bank_dashboard import (
load_world_bank_dashboard_with_slices_module_scope, # noqa: F401 load_world_bank_dashboard_with_slices_module_scope, # noqa: F401
load_world_bank_data, # noqa: F401 load_world_bank_data, # noqa: F401
@ -91,6 +95,7 @@ from tests.integration_tests.reports.utils import (
create_report_notification, create_report_notification,
CSV_FILE, CSV_FILE,
DEFAULT_OWNER_EMAIL, DEFAULT_OWNER_EMAIL,
reset_key_values,
SCREENSHOT_FILE, SCREENSHOT_FILE,
TEST_ID, TEST_ID,
) )
@ -1170,6 +1175,93 @@ def test_email_dashboard_report_schedule(
statsd_mock.assert_called_once_with("reports.email.send.ok", 1) statsd_mock.assert_called_once_with("reports.email.send.ok", 1)
@pytest.mark.usefixtures("tabbed_dashboard")
@patch("superset.utils.screenshots.DashboardScreenshot.get_screenshot")
@patch("superset.reports.notifications.email.send_email_smtp")
@patch.dict(
"superset.extensions.feature_flag_manager._feature_flags", ALERT_REPORT_TABS=True
)
def test_email_dashboard_report_schedule_with_tab_anchor(
_email_mock,
_screenshot_mock,
):
"""
ExecuteReport Command: Test dashboard email report schedule with tab metadata
"""
with freeze_time("2020-01-01T00:00:00Z"):
with patch.object(current_app.config["STATS_LOGGER"], "gauge") as statsd_mock:
# get tabbed dashboard fixture
dashboard = db.session.query(Dashboard).all()[1]
# build report_schedule
report_schedule = create_report_notification(
email_target="target@email.com",
dashboard=dashboard,
extra={"dashboard": {"anchor": "TAB-L2AB"}},
)
AsyncExecuteReportScheduleCommand(
TEST_ID, report_schedule.id, datetime.utcnow()
).run()
# Assert logs are correct
assert_log(ReportState.SUCCESS)
statsd_mock.assert_called_once_with("reports.email.send.ok", 1)
pl = (
db.session.query(KeyValueEntry)
.order_by(KeyValueEntry.id.desc())
.first()
)
value = json.loads(pl.value)
# test that report schedule extra json matches permalink state
assert report_schedule.extra["dashboard"] == value["state"]
# remove report_schedule
cleanup_report_schedule(report_schedule)
# remove permalink kvalues
reset_key_values()
@pytest.mark.usefixtures("tabbed_dashboard")
@patch("superset.utils.screenshots.DashboardScreenshot.get_screenshot")
@patch("superset.reports.notifications.email.send_email_smtp")
@patch.dict(
"superset.extensions.feature_flag_manager._feature_flags", ALERT_REPORT_TABS=False
)
def test_email_dashboard_report_schedule_disabled_tabs(
_email_mock,
_screenshot_mock,
):
"""
ExecuteReport Command: Test dashboard email report schedule with tab metadata
"""
with freeze_time("2020-01-01T00:00:00Z"):
with patch.object(current_app.config["STATS_LOGGER"], "gauge") as statsd_mock:
# get tabbed dashboard fixture
dashboard = db.session.query(Dashboard).all()[1]
# build report_schedule
report_schedule = create_report_notification(
email_target="target@email.com",
dashboard=dashboard,
extra={"dashboard": {"anchor": "TAB-L2AB"}},
)
AsyncExecuteReportScheduleCommand(
TEST_ID, report_schedule.id, datetime.utcnow()
).run()
# Assert logs are correct
assert_log(ReportState.SUCCESS)
statsd_mock.assert_called_once_with("reports.email.send.ok", 1)
permalinks = db.session.query(KeyValueEntry).all()
# test that report schedule extra json matches permalink state
assert len(permalinks) == 0
# remove report_schedule
cleanup_report_schedule(report_schedule)
@pytest.mark.usefixtures( @pytest.mark.usefixtures(
"load_birth_names_dashboard_with_slices", "load_birth_names_dashboard_with_slices",
"create_report_email_dashboard_force_screenshot", "create_report_email_dashboard_force_screenshot",

View File

@ -22,6 +22,7 @@ from uuid import uuid4
from flask_appbuilder.security.sqla.models import User from flask_appbuilder.security.sqla.models import User
from superset import db, security_manager from superset import db, security_manager
from superset.key_value.models import KeyValueEntry
from superset.models.core import Database from superset.models.core import Database
from superset.models.dashboard import Dashboard from superset.models.dashboard import Dashboard
from superset.models.slice import Slice from superset.models.slice import Slice
@ -203,3 +204,8 @@ def create_dashboard_report(dashboard, extra, **kwargs):
if error: if error:
raise error raise error
def reset_key_values() -> None:
db.session.query(KeyValueEntry).delete()
db.session.commit()