refactor(ReportModal): simplify state reducer and improve error handling (#19942)

This commit is contained in:
Jesse Yang 2022-05-05 10:06:40 -07:00 committed by GitHub
parent 58e65ad5bb
commit 7b88ec7e25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 317 additions and 315 deletions

View File

@ -19,16 +19,15 @@
import React, { import React, {
useState, useState,
useEffect, useEffect,
useCallback,
useReducer, useReducer,
Reducer,
FunctionComponent, FunctionComponent,
useCallback,
useMemo,
} from 'react'; } from 'react';
import { t, SupersetTheme } from '@superset-ui/core'; import { t, SupersetTheme } from '@superset-ui/core';
import { getClientErrorObject } from 'src/utils/getClientErrorObject'; import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { addReport, editReport } from 'src/reports/actions/reports'; import { addReport, editReport } from 'src/reports/actions/reports';
import { AlertObject } from 'src/views/CRUD/alert/types';
import Alert from 'src/components/Alert'; import Alert from 'src/components/Alert';
import TimezoneSelector from 'src/components/TimezoneSelector'; import TimezoneSelector from 'src/components/TimezoneSelector';
@ -37,6 +36,12 @@ import Icons from 'src/components/Icons';
import withToasts from 'src/components/MessageToasts/withToasts'; import withToasts from 'src/components/MessageToasts/withToasts';
import { CronError } from 'src/components/CronPicker'; import { CronError } from 'src/components/CronPicker';
import { RadioChangeEvent } from 'src/components'; import { RadioChangeEvent } from 'src/components';
import { ChartState } from 'src/explore/types';
import {
ReportCreationMethod,
ReportRecipientType,
ReportScheduleType,
} from 'src/reports/types';
import { import {
antDErrorAlertStyles, antDErrorAlertStyles,
StyledModal, StyledModal,
@ -65,30 +70,17 @@ export interface ReportObject {
log_retention: number; log_retention: number;
name: string; name: string;
owners: number[]; owners: number[];
recipients: [{ recipient_config_json: { target: string }; type: string }]; recipients: [
{ recipient_config_json: { target: string }; type: ReportRecipientType },
];
report_format: string; report_format: string;
timezone: string; timezone: string;
type: string; type: ReportScheduleType;
validator_config_json: {} | null; validator_config_json: {} | null;
validator_type: string; validator_type: string;
working_timeout: number; working_timeout: number;
creation_method: string; creation_method: string;
force_screenshot: boolean; force_screenshot: boolean;
error: string;
}
interface ChartObject {
id: number;
chartAlert: string;
chartStatus: string;
chartUpdateEndTime: number;
chartUpdateStartTime: number;
latestQueryFormData: object;
sliceFormData: Record<string, any>;
queryController: { abort: () => {} };
queriesResponse: object;
triggerQuery: boolean;
lastRendered: number;
} }
interface ReportProps { interface ReportProps {
@ -98,40 +90,13 @@ interface ReportProps {
show: boolean; show: boolean;
userId: number; userId: number;
userEmail: string; userEmail: string;
chart?: ChartState;
chartName?: string;
dashboardId?: number; dashboardId?: number;
chart?: ChartObject; dashboardName?: string;
creationMethod: string; creationMethod: ReportCreationMethod;
} }
interface ReportPayloadType {
name: string;
value: string;
}
enum ActionType {
inputChange,
fetched,
reset,
error,
}
type ReportActionType =
| {
type: ActionType.inputChange;
payload: ReportPayloadType;
}
| {
type: ActionType.fetched;
payload: Partial<ReportObject>;
}
| {
type: ActionType.reset;
}
| {
type: ActionType.error;
payload: { name?: string[] };
};
const TEXT_BASED_VISUALIZATION_TYPES = [ const TEXT_BASED_VISUALIZATION_TYPES = [
'pivot_table', 'pivot_table',
'pivot_table_v2', 'pivot_table_v2',
@ -145,41 +110,16 @@ const NOTIFICATION_FORMATS = {
CSV: 'CSV', CSV: 'CSV',
}; };
const defaultErrorMsg = t( const INITIAL_STATE = {
'We were unable to create your report. Please try again.', crontab: '0 12 * * 1',
); };
const reportReducer = ( type ReportObjectState = Partial<ReportObject> & {
state: Partial<ReportObject> | null, error?: string;
action: ReportActionType, /**
): Partial<ReportObject> | null => { * Is submitting changes to the backend.
const initialState = { */
name: 'Weekly Report', isSubmitting?: boolean;
crontab: '0 12 * * 1',
};
switch (action.type) {
case ActionType.inputChange:
return {
...initialState,
...state,
[action.payload.name]: action.payload.value,
};
case ActionType.fetched:
return {
...initialState,
...action.payload,
};
case ActionType.reset:
return { ...initialState };
case ActionType.error:
return {
...state,
error: action.payload?.name?.[0] || defaultErrorMsg,
};
default:
return state;
}
}; };
const ReportModal: FunctionComponent<ReportProps> = ({ const ReportModal: FunctionComponent<ReportProps> = ({
@ -190,45 +130,64 @@ const ReportModal: FunctionComponent<ReportProps> = ({
}) => { }) => {
const vizType = props.chart?.sliceFormData?.viz_type; const vizType = props.chart?.sliceFormData?.viz_type;
const isChart = !!props.chart; const isChart = !!props.chart;
const defaultNotificationFormat = const isTextBasedChart =
isChart && TEXT_BASED_VISUALIZATION_TYPES.includes(vizType) isChart && vizType && TEXT_BASED_VISUALIZATION_TYPES.includes(vizType);
? NOTIFICATION_FORMATS.TEXT const defaultNotificationFormat = isTextBasedChart
: NOTIFICATION_FORMATS.PNG; ? NOTIFICATION_FORMATS.TEXT
const [currentReport, setCurrentReport] = useReducer< : NOTIFICATION_FORMATS.PNG;
Reducer<Partial<ReportObject> | null, ReportActionType> const entityName = props.dashboardName || props.chartName;
>(reportReducer, null); const initialState: ReportObjectState = useMemo(
const onReducerChange = useCallback((type: any, payload: any) => { () => ({
setCurrentReport({ type, payload }); ...INITIAL_STATE,
}, []); name: entityName
? t('Weekly Report for %s', entityName)
: t('Weekly Report'),
}),
[entityName],
);
const reportReducer = useCallback(
(state: ReportObjectState | null, action: 'reset' | ReportObjectState) => {
if (action === 'reset') {
return initialState;
}
return {
...state,
...action,
};
},
[initialState],
);
const [currentReport, setCurrentReport] = useReducer(
reportReducer,
initialState,
);
const [cronError, setCronError] = useState<CronError>(); const [cronError, setCronError] = useState<CronError>();
const dispatch = useDispatch(); const dispatch = useDispatch();
// Report fetch logic const reports = useSelector<any, ReportObject>(state => state.reports);
const reports = useSelector<any, AlertObject>(state => state.reports);
const isEditMode = reports && Object.keys(reports).length; const isEditMode = reports && Object.keys(reports).length;
useEffect(() => { useEffect(() => {
if (isEditMode) { if (isEditMode) {
const reportsIds = Object.keys(reports); const reportsIds = Object.keys(reports);
const report = reports[reportsIds[0]]; const report = reports[reportsIds[0]];
setCurrentReport({ setCurrentReport(report);
type: ActionType.fetched,
payload: report,
});
} else { } else {
setCurrentReport({ setCurrentReport('reset');
type: ActionType.reset,
});
} }
}, [reports]); }, [isEditMode, reports]);
const onSave = async () => { const onSave = async () => {
// Create new Report // Create new Report
const newReportValues: Partial<ReportObject> = { const newReportValues: Partial<ReportObject> = {
crontab: currentReport?.crontab, type: 'Report',
active: true,
force_screenshot: false,
creation_method: props.creationMethod,
dashboard: props.dashboardId, dashboard: props.dashboardId,
chart: props.chart?.id, chart: props.chart?.id,
description: currentReport?.description,
name: currentReport?.name,
owners: [props.userId], owners: [props.userId],
recipients: [ recipients: [
{ {
@ -236,28 +195,28 @@ const ReportModal: FunctionComponent<ReportProps> = ({
type: 'Email', type: 'Email',
}, },
], ],
type: 'Report', name: currentReport.name,
creation_method: props.creationMethod, description: currentReport.description,
active: true, crontab: currentReport.crontab,
report_format: currentReport?.report_format || defaultNotificationFormat, report_format: currentReport.report_format || defaultNotificationFormat,
timezone: currentReport?.timezone, timezone: currentReport.timezone,
force_screenshot: false,
}; };
if (isEditMode) { setCurrentReport({ isSubmitting: true, error: undefined });
await dispatch( try {
editReport(currentReport?.id, newReportValues as ReportObject), if (isEditMode) {
); await dispatch(
onHide(); editReport(currentReport.id, newReportValues as ReportObject),
} else { );
try { } else {
await dispatch(addReport(newReportValues as ReportObject)); await dispatch(addReport(newReportValues as ReportObject));
onHide();
} catch (e) {
const { message } = await getClientErrorObject(e);
onReducerChange(ActionType.error, message);
} }
onHide();
} catch (e) {
const { error } = await getClientErrorObject(e);
setCurrentReport({ error });
} }
setCurrentReport({ isSubmitting: false });
if (onReportAdd) onReportAdd(); if (onReportAdd) onReportAdd();
}; };
@ -266,7 +225,7 @@ const ReportModal: FunctionComponent<ReportProps> = ({
<StyledIconWrapper> <StyledIconWrapper>
<Icons.Calendar /> <Icons.Calendar />
<span className="text"> <span className="text">
{isEditMode ? t('Edit Email Report') : t('New Email Report')} {isEditMode ? t('Edit email report') : t('Schedule a new email report')}
</span> </span>
</StyledIconWrapper> </StyledIconWrapper>
); );
@ -280,7 +239,8 @@ const ReportModal: FunctionComponent<ReportProps> = ({
key="submit" key="submit"
buttonStyle="primary" buttonStyle="primary"
onClick={onSave} onClick={onSave}
disabled={!currentReport?.name} disabled={!currentReport.name}
loading={currentReport.isSubmitting}
> >
{isEditMode ? t('Save') : t('Add')} {isEditMode ? t('Save') : t('Add')}
</StyledFooterButton> </StyledFooterButton>
@ -290,19 +250,16 @@ const ReportModal: FunctionComponent<ReportProps> = ({
const renderMessageContentSection = ( const renderMessageContentSection = (
<> <>
<StyledMessageContentTitle> <StyledMessageContentTitle>
<h4>{t('Message Content')}</h4> <h4>{t('Message content')}</h4>
</StyledMessageContentTitle> </StyledMessageContentTitle>
<div className="inline-container"> <div className="inline-container">
<StyledRadioGroup <StyledRadioGroup
onChange={(event: RadioChangeEvent) => { onChange={(event: RadioChangeEvent) => {
onReducerChange(ActionType.inputChange, { setCurrentReport({ report_format: event.target.value });
name: 'report_format',
value: event.target.value,
});
}} }}
value={currentReport?.report_format || defaultNotificationFormat} value={currentReport.report_format || defaultNotificationFormat}
> >
{TEXT_BASED_VISUALIZATION_TYPES.includes(vizType) && ( {isTextBasedChart && (
<StyledRadio value={NOTIFICATION_FORMATS.TEXT}> <StyledRadio value={NOTIFICATION_FORMATS.TEXT}>
{t('Text embedded in email')} {t('Text embedded in email')}
</StyledRadio> </StyledRadio>
@ -318,15 +275,6 @@ const ReportModal: FunctionComponent<ReportProps> = ({
</> </>
); );
const errorAlert = () => (
<Alert
type="error"
css={(theme: SupersetTheme) => antDErrorAlertStyles(theme)}
message={t('Report Creation Error')}
description={currentReport?.error}
/>
);
return ( return (
<StyledModal <StyledModal
show={show} show={show}
@ -340,15 +288,12 @@ const ReportModal: FunctionComponent<ReportProps> = ({
<LabeledErrorBoundInput <LabeledErrorBoundInput
id="name" id="name"
name="name" name="name"
value={currentReport?.name || ''} value={currentReport.name || ''}
placeholder={t('Weekly Report')} placeholder={initialState.name}
required required
validationMethods={{ validationMethods={{
onChange: ({ target }: { target: HTMLInputElement }) => onChange: ({ target }: { target: HTMLInputElement }) =>
onReducerChange(ActionType.inputChange, { setCurrentReport({ name: target.value }),
name: target.name,
value: target.value,
}),
}} }}
label="Report Name" label="Report Name"
data-test="report-name-test" data-test="report-name-test"
@ -358,11 +303,9 @@ const ReportModal: FunctionComponent<ReportProps> = ({
name="description" name="description"
value={currentReport?.description || ''} value={currentReport?.description || ''}
validationMethods={{ validationMethods={{
onChange: ({ target }: { target: HTMLInputElement }) => onChange: ({ target }: { target: HTMLInputElement }) => {
onReducerChange(ActionType.inputChange, { setCurrentReport({ description: target.value });
name: target.name, },
value: target.value,
}),
}} }}
label={t('Description')} label={t('Description')}
placeholder={t( placeholder={t(
@ -378,17 +321,16 @@ const ReportModal: FunctionComponent<ReportProps> = ({
<h4 css={(theme: SupersetTheme) => SectionHeaderStyle(theme)}> <h4 css={(theme: SupersetTheme) => SectionHeaderStyle(theme)}>
{t('Schedule')} {t('Schedule')}
</h4> </h4>
<p>{t('Scheduled reports will be sent to your email as a PNG')}</p> <p>
{t('A screenshot of the dashboard will be sent to your email at')}
</p>
</StyledScheduleTitle> </StyledScheduleTitle>
<StyledCronPicker <StyledCronPicker
clearButton={false} clearButton={false}
value={t(currentReport?.crontab || '0 12 * * 1')} value={currentReport.crontab || '0 12 * * 1'}
setValue={(newValue: string) => { setValue={(newValue: string) => {
onReducerChange(ActionType.inputChange, { setCurrentReport({ crontab: newValue });
name: 'crontab',
value: newValue,
});
}} }}
onError={setCronError} onError={setCronError}
/> />
@ -400,17 +342,25 @@ const ReportModal: FunctionComponent<ReportProps> = ({
{t('Timezone')} {t('Timezone')}
</div> </div>
<TimezoneSelector <TimezoneSelector
timezone={currentReport.timezone}
onTimezoneChange={value => { onTimezoneChange={value => {
setCurrentReport({ setCurrentReport({ timezone: value });
type: ActionType.inputChange,
payload: { name: 'timezone', value },
});
}} }}
timezone={currentReport?.timezone}
/> />
{isChart && renderMessageContentSection} {isChart && renderMessageContentSection}
</StyledBottomSection> </StyledBottomSection>
{currentReport?.error && errorAlert()} {currentReport.error && (
<Alert
type="error"
css={(theme: SupersetTheme) => antDErrorAlertStyles(theme)}
message={
isEditMode
? t('Failed to update report')
: t('Failed to create report')
}
description={currentReport.error}
/>
)}
</StyledModal> </StyledModal>
); );
}; };

View File

@ -111,7 +111,8 @@ export const StyledRadioGroup = styled(Radio.Group)`
export const antDErrorAlertStyles = (theme: SupersetTheme) => css` export const antDErrorAlertStyles = (theme: SupersetTheme) => css`
border: ${theme.colors.error.base} 1px solid; border: ${theme.colors.error.base} 1px solid;
padding: ${theme.gridUnit * 4}px; padding: ${theme.gridUnit * 4}px;
margin: ${theme.gridUnit * 8}px ${theme.gridUnit * 4}px; margin: ${theme.gridUnit * 4}px;
margin-top: 0;
color: ${theme.colors.error.dark2}; color: ${theme.colors.error.dark2};
.ant-alert-message { .ant-alert-message {
font-size: ${theme.typography.sizes.m}px; font-size: ${theme.typography.sizes.m}px;

View File

@ -665,6 +665,7 @@ class Header extends React.PureComponent {
userId={user.userId} userId={user.userId}
userEmail={user.email} userEmail={user.email}
dashboardId={dashboardInfo.id} dashboardId={dashboardInfo.id}
dashboardName={dashboardInfo.name}
creationMethod="dashboards" creationMethod="dashboards"
/> />
)} )}

View File

@ -24,7 +24,6 @@ import ReportModal from 'src/components/ReportModal';
import { ExplorePageState } from 'src/explore/reducers/getInitialState'; import { ExplorePageState } from 'src/explore/reducers/getInitialState';
import DeleteModal from 'src/components/DeleteModal'; import DeleteModal from 'src/components/DeleteModal';
import { deleteActiveReport } from 'src/reports/actions/reports'; import { deleteActiveReport } from 'src/reports/actions/reports';
import { ChartState } from 'src/explore/types';
type ReportMenuItemsProps = { type ReportMenuItemsProps = {
report: Record<string, any>; report: Record<string, any>;
@ -41,16 +40,11 @@ export const ExploreReport = ({
setIsDeleting, setIsDeleting,
}: ReportMenuItemsProps) => { }: ReportMenuItemsProps) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const chart = useSelector<ExplorePageState, ChartState | undefined>(state => { const { chart, chartName } = useSelector((state: ExplorePageState) => ({
if (!state.charts) { chart: Object.values(state.charts || {})[0],
return undefined; chartName: state.explore.sliceName,
} }));
const charts = Object.values(state.charts);
if (charts.length > 0) {
return charts[0];
}
return undefined;
});
const { userId, email } = useSelector< const { userId, email } = useSelector<
ExplorePageState, ExplorePageState,
{ userId?: number; email?: string } { userId?: number; email?: string }
@ -69,6 +63,7 @@ export const ExploreReport = ({
userId={userId} userId={userId}
userEmail={email} userEmail={email}
chart={chart} chart={chart}
chartName={chartName}
creationMethod="charts" creationMethod="charts"
/> />
{isDeleting && ( {isDeleting && (

View File

@ -111,22 +111,14 @@ export const addReport = report => dispatch =>
export const EDIT_REPORT = 'EDIT_REPORT'; export const EDIT_REPORT = 'EDIT_REPORT';
export function editReport(id, report) { export const editReport = (id, report) => dispatch =>
return function (dispatch) { SupersetClient.put({
SupersetClient.put({ endpoint: `/api/v1/report/${id}`,
endpoint: `/api/v1/report/${id}`, jsonPayload: report,
jsonPayload: report, }).then(({ json }) => {
}) dispatch({ type: EDIT_REPORT, json });
.then(({ json }) => { dispatch(addSuccessToast(t('Report updated')));
dispatch({ type: EDIT_REPORT, json }); });
})
.catch(() =>
dispatch(
addDangerToast(t('An error occurred while editing this report.')),
),
);
};
}
export function toggleActive(report, isActive) { export function toggleActive(report, isActive) {
return function toggleActiveThunk(dispatch) { return function toggleActiveThunk(dispatch) {

View File

@ -0,0 +1,26 @@
/**
* 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.
*/
/**
* Types mirroring enums in `superset/reports/models.py`:
*/
export type ReportScheduleType = 'Alert' | 'Report';
export type ReportCreationMethod = 'charts' | 'dashboards' | 'alerts_reports';
export type ReportRecipientType = 'Email' | 'Slack';

View File

@ -29,15 +29,15 @@ export type ClientErrorObject = {
error: string; error: string;
errors?: SupersetError[]; errors?: SupersetError[];
link?: string; link?: string;
// marshmallow field validation returns the error mssage in the format
// of { field: [msg1, msg2] }
message?: string; message?: string;
severity?: string; severity?: string;
stacktrace?: string; stacktrace?: string;
statusText?: string; statusText?: string;
} & Partial<SupersetClientResponse>; } & Partial<SupersetClientResponse>;
interface ResponseWithTimeout extends Response { // see rejectAfterTimeout.ts
interface TimeoutError {
statusText: 'timeout';
timeout: number; timeout: number;
} }
@ -48,7 +48,13 @@ export function parseErrorJson(responseObject: JsonObject): ClientErrorObject {
error.error = error.description = error.errors[0].message; error.error = error.description = error.errors[0].message;
error.link = error.errors[0]?.extra?.link; error.link = error.errors[0]?.extra?.link;
} }
// Marshmallow field validation returns the error mssage in the format
// of { message: { field1: [msg1, msg2], field2: [msg], } }
if (error.message && typeof error.message === 'object' && !error.error) {
error.error =
Object.values(error.message as Record<string, string[]>)[0]?.[0] ||
t('Invalid input');
}
if (error.stack) { if (error.stack) {
error = { error = {
...error, ...error,
@ -68,78 +74,95 @@ export function parseErrorJson(responseObject: JsonObject): ClientErrorObject {
} }
export function getClientErrorObject( export function getClientErrorObject(
response: SupersetClientResponse | ResponseWithTimeout | string, response:
| SupersetClientResponse
| TimeoutError
| { response: Response }
| string,
): Promise<ClientErrorObject> { ): Promise<ClientErrorObject> {
// takes a SupersetClientResponse as input, attempts to read response as Json if possible, // takes a SupersetClientResponse as input, attempts to read response as Json if possible,
// and returns a Promise that resolves to a plain object with error key and text value. // and returns a Promise that resolves to a plain object with error key and text value.
return new Promise(resolve => { return new Promise(resolve => {
if (typeof response === 'string') { if (typeof response === 'string') {
resolve({ error: response }); resolve({ error: response });
} else { return;
const responseObject =
response instanceof Response ? response : response.response;
if (responseObject && !responseObject.bodyUsed) {
// attempt to read the body as json, and fallback to text. we must clone the
// response in order to fallback to .text() because Response is single-read
responseObject
.clone()
.json()
.then(errorJson => {
const error = { ...responseObject, ...errorJson };
resolve(parseErrorJson(error));
})
.catch(() => {
// fall back to reading as text
responseObject.text().then((errorText: any) => {
resolve({ ...responseObject, error: errorText });
});
});
} else if (
'statusText' in response &&
response.statusText === 'timeout' &&
'timeout' in response
) {
resolve({
...responseObject,
error: 'Request timed out',
errors: [
{
error_type: ErrorTypeEnum.FRONTEND_TIMEOUT_ERROR,
extra: {
timeout: response.timeout / 1000,
issue_codes: [
{
code: 1000,
message: t(
'Issue 1000 - The dataset is too large to query.',
),
},
{
code: 1001,
message: t(
'Issue 1001 - The database is under an unusual load.',
),
},
],
},
level: 'error',
message: 'Request timed out',
},
],
});
} else {
// fall back to Response.statusText or generic error of we cannot read the response
let error = (response as any).statusText || (response as any).message;
if (!error) {
// eslint-disable-next-line no-console
console.error('non-standard error:', response);
error = t('An error occurred');
}
resolve({
...responseObject,
error,
});
}
} }
if (
response instanceof TypeError &&
response.message === 'Failed to fetch'
) {
resolve({
error: t('Network error'),
});
return;
}
if (
'timeout' in response &&
'statusText' in response &&
response.statusText === 'timeout'
) {
resolve({
...response,
error: t('Request timed out'),
errors: [
{
error_type: ErrorTypeEnum.FRONTEND_TIMEOUT_ERROR,
extra: {
timeout: response.timeout / 1000,
issue_codes: [
{
code: 1000,
message: t('Issue 1000 - The dataset is too large to query.'),
},
{
code: 1001,
message: t(
'Issue 1001 - The database is under an unusual load.',
),
},
],
},
level: 'error',
message: 'Request timed out',
},
],
});
return;
}
const responseObject =
response instanceof Response ? response : response.response;
if (responseObject && !responseObject.bodyUsed) {
// attempt to read the body as json, and fallback to text. we must clone the
// response in order to fallback to .text() because Response is single-read
responseObject
.clone()
.json()
.then(errorJson => {
const error = { ...responseObject, ...errorJson };
resolve(parseErrorJson(error));
})
.catch(() => {
// fall back to reading as text
responseObject.text().then((errorText: any) => {
resolve({ ...responseObject, error: errorText });
});
});
return;
}
// fall back to Response.statusText or generic error of we cannot read the response
let error = (response as any).statusText || (response as any).message;
if (!error) {
// eslint-disable-next-line no-console
console.error('non-standard error:', response);
error = t('An error occurred');
}
resolve({
...responseObject,
error,
});
}); });
} }

View File

@ -77,7 +77,7 @@ class ReportDataFormat(str, enum.Enum):
TEXT = "TEXT" TEXT = "TEXT"
class ReportCreationMethodType(str, enum.Enum): class ReportCreationMethod(str, enum.Enum):
CHARTS = "charts" CHARTS = "charts"
DASHBOARDS = "dashboards" DASHBOARDS = "dashboards"
ALERTS_REPORTS = "alerts_reports" ALERTS_REPORTS = "alerts_reports"
@ -112,7 +112,7 @@ class ReportSchedule(Model, AuditMixinNullable):
active = Column(Boolean, default=True, index=True) active = Column(Boolean, default=True, index=True)
crontab = Column(String(1000), nullable=False) crontab = Column(String(1000), nullable=False)
creation_method = Column( creation_method = Column(
String(255), server_default=ReportCreationMethodType.ALERTS_REPORTS String(255), server_default=ReportCreationMethod.ALERTS_REPORTS
) )
timezone = Column(String(100), default="UTC", nullable=False) timezone = Column(String(100), default="UTC", nullable=False)
report_format = Column(String(50), default=ReportDataFormat.VISUALIZATION) report_format = Column(String(50), default=ReportDataFormat.VISUALIZATION)

View File

@ -22,7 +22,7 @@ from marshmallow import ValidationError
from superset.charts.dao import ChartDAO from superset.charts.dao import ChartDAO
from superset.commands.base import BaseCommand from superset.commands.base import BaseCommand
from superset.dashboards.dao import DashboardDAO from superset.dashboards.dao import DashboardDAO
from superset.models.reports import ReportCreationMethodType from superset.models.reports import ReportCreationMethod
from superset.reports.commands.exceptions import ( from superset.reports.commands.exceptions import (
ChartNotFoundValidationError, ChartNotFoundValidationError,
ChartNotSavedValidationError, ChartNotSavedValidationError,
@ -52,12 +52,12 @@ class BaseReportScheduleCommand(BaseCommand):
dashboard_id = self._properties.get("dashboard") dashboard_id = self._properties.get("dashboard")
creation_method = self._properties.get("creation_method") creation_method = self._properties.get("creation_method")
if creation_method == ReportCreationMethodType.CHARTS and not chart_id: if creation_method == ReportCreationMethod.CHARTS and not chart_id:
# User has not saved chart yet in Explore view # User has not saved chart yet in Explore view
exceptions.append(ChartNotSavedValidationError()) exceptions.append(ChartNotSavedValidationError())
return return
if creation_method == ReportCreationMethodType.DASHBOARDS and not dashboard_id: if creation_method == ReportCreationMethod.DASHBOARDS and not dashboard_id:
exceptions.append(DashboardNotSavedValidationError()) exceptions.append(DashboardNotSavedValidationError())
return return

View File

@ -25,7 +25,7 @@ from marshmallow import ValidationError
from superset.commands.base import CreateMixin from superset.commands.base import CreateMixin
from superset.dao.exceptions import DAOCreateFailedError from superset.dao.exceptions import DAOCreateFailedError
from superset.databases.dao import DatabaseDAO from superset.databases.dao import DatabaseDAO
from superset.models.reports import ReportCreationMethodType, ReportScheduleType from superset.models.reports import ReportCreationMethod, ReportScheduleType
from superset.reports.commands.base import BaseReportScheduleCommand from superset.reports.commands.base import BaseReportScheduleCommand
from superset.reports.commands.exceptions import ( from superset.reports.commands.exceptions import (
DatabaseNotFoundValidationError, DatabaseNotFoundValidationError,
@ -73,7 +73,11 @@ class CreateReportScheduleCommand(CreateMixin, BaseReportScheduleCommand):
if report_type and not ReportScheduleDAO.validate_update_uniqueness( if report_type and not ReportScheduleDAO.validate_update_uniqueness(
name, report_type name, report_type
): ):
exceptions.append(ReportScheduleNameUniquenessValidationError()) exceptions.append(
ReportScheduleNameUniquenessValidationError(
report_type=report_type, name=name
)
)
# validate relation by report type # validate relation by report type
if report_type == ReportScheduleType.ALERT: if report_type == ReportScheduleType.ALERT:
@ -93,7 +97,7 @@ class CreateReportScheduleCommand(CreateMixin, BaseReportScheduleCommand):
# Validate that each chart or dashboard only has one report with # Validate that each chart or dashboard only has one report with
# the respective creation method. # the respective creation method.
if ( if (
creation_method != ReportCreationMethodType.ALERTS_REPORTS creation_method != ReportCreationMethod.ALERTS_REPORTS
and not ReportScheduleDAO.validate_unique_creation_method( and not ReportScheduleDAO.validate_unique_creation_method(
user_id, dashboard_id, chart_id user_id, dashboard_id, chart_id
) )

View File

@ -24,6 +24,7 @@ from superset.commands.exceptions import (
ForbiddenError, ForbiddenError,
ValidationError, ValidationError,
) )
from superset.models.reports import ReportScheduleType
class DatabaseNotFoundValidationError(ValidationError): class DatabaseNotFoundValidationError(ValidationError):
@ -163,13 +164,16 @@ class ReportScheduleNameUniquenessValidationError(ValidationError):
Marshmallow validation error for Report Schedule name and type already exists Marshmallow validation error for Report Schedule name and type already exists
""" """
def __init__(self) -> None: def __init__(self, report_type: ReportScheduleType, name: str) -> None:
super().__init__([_("Name must be unique")], field_name="name") message = _('A report named "%(name)s" already exists', name=name)
if report_type == ReportScheduleType.ALERT:
message = _('An alert named "%(name)s" already exists', name=name)
super().__init__([message], field_name="name")
class ReportScheduleCreationMethodUniquenessValidationError(CommandException): class ReportScheduleCreationMethodUniquenessValidationError(CommandException):
status = 409 status = 409
message = "Resource already has an attached report." message = _("Resource already has an attached report.")
class AlertQueryMultipleRowsError(CommandException): class AlertQueryMultipleRowsError(CommandException):

View File

@ -86,9 +86,13 @@ class UpdateReportScheduleCommand(UpdateMixin, BaseReportScheduleCommand):
# Validate name type uniqueness # Validate name type uniqueness
if not ReportScheduleDAO.validate_update_uniqueness( if not ReportScheduleDAO.validate_update_uniqueness(
name, report_type, report_schedule_id=self._model_id name, report_type, expect_id=self._model_id
): ):
exceptions.append(ReportScheduleNameUniquenessValidationError()) exceptions.append(
ReportScheduleNameUniquenessValidationError(
report_type=report_type, name=name
)
)
if report_type == ReportScheduleType.ALERT: if report_type == ReportScheduleType.ALERT:
database_id = self._properties.get("database") database_id = self._properties.get("database")

View File

@ -30,6 +30,7 @@ from superset.models.reports import (
ReportExecutionLog, ReportExecutionLog,
ReportRecipients, ReportRecipients,
ReportSchedule, ReportSchedule,
ReportScheduleType,
ReportState, ReportState,
) )
@ -133,23 +134,24 @@ class ReportScheduleDAO(BaseDAO):
@staticmethod @staticmethod
def validate_update_uniqueness( def validate_update_uniqueness(
name: str, report_type: str, report_schedule_id: Optional[int] = None name: str, report_type: ReportScheduleType, expect_id: Optional[int] = None
) -> bool: ) -> bool:
""" """
Validate if this name and type is unique. Validate if this name and type is unique.
:param name: The report schedule name :param name: The report schedule name
:param report_type: The report schedule type :param report_type: The report schedule type
:param report_schedule_id: The report schedule current id :param expect_id: The id of the expected report schedule with the
(only for validating on updates) name + type combination. Useful for validating existing report schedule.
:return: bool :return: bool
""" """
query = db.session.query(ReportSchedule).filter( found_id = (
ReportSchedule.name == name, ReportSchedule.type == report_type db.session.query(ReportSchedule.id)
.filter(ReportSchedule.name == name, ReportSchedule.type == report_type)
.limit(1)
.scalar()
) )
if report_schedule_id: return found_id is None or found_id == expect_id
query = query.filter(ReportSchedule.id != report_schedule_id)
return not db.session.query(query.exists()).scalar()
@classmethod @classmethod
def create(cls, properties: Dict[str, Any], commit: bool = True) -> Model: def create(cls, properties: Dict[str, Any], commit: bool = True) -> Model:

View File

@ -24,7 +24,7 @@ from marshmallow_enum import EnumField
from pytz import all_timezones from pytz import all_timezones
from superset.models.reports import ( from superset.models.reports import (
ReportCreationMethodType, ReportCreationMethod,
ReportDataFormat, ReportDataFormat,
ReportRecipientType, ReportRecipientType,
ReportScheduleType, ReportScheduleType,
@ -164,7 +164,7 @@ class ReportSchedulePostSchema(Schema):
) )
chart = fields.Integer(required=False, allow_none=True) chart = fields.Integer(required=False, allow_none=True)
creation_method = EnumField( creation_method = EnumField(
ReportCreationMethodType, ReportCreationMethod,
by_value=True, by_value=True,
required=False, required=False,
description=creation_method_description, description=creation_method_description,
@ -256,7 +256,7 @@ class ReportSchedulePutSchema(Schema):
) )
chart = fields.Integer(required=False, allow_none=True) chart = fields.Integer(required=False, allow_none=True)
creation_method = EnumField( creation_method = EnumField(
ReportCreationMethodType, ReportCreationMethod,
by_value=True, by_value=True,
allow_none=True, allow_none=True,
description=creation_method_description, description=creation_method_description,

View File

@ -31,7 +31,7 @@ from superset.models.slice import Slice
from superset.models.dashboard import Dashboard from superset.models.dashboard import Dashboard
from superset.models.reports import ( from superset.models.reports import (
ReportSchedule, ReportSchedule,
ReportCreationMethodType, ReportCreationMethod,
ReportRecipients, ReportRecipients,
ReportExecutionLog, ReportExecutionLog,
ReportScheduleType, ReportScheduleType,
@ -452,7 +452,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"name": "new3", "name": "new3",
"description": "description", "description": "description",
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"creation_method": ReportCreationMethodType.ALERTS_REPORTS, "creation_method": ReportCreationMethod.ALERTS_REPORTS,
"recipients": [ "recipients": [
{ {
"type": ReportRecipientType.EMAIL, "type": ReportRecipientType.EMAIL,
@ -499,7 +499,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"type": ReportScheduleType.ALERT, "type": ReportScheduleType.ALERT,
"name": "name3", "name": "name3",
"description": "description", "description": "description",
"creation_method": ReportCreationMethodType.ALERTS_REPORTS, "creation_method": ReportCreationMethod.ALERTS_REPORTS,
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"chart": chart.id, "chart": chart.id,
"database": example_db.id, "database": example_db.id,
@ -508,7 +508,7 @@ class TestReportSchedulesApi(SupersetTestCase):
rv = self.client.post(uri, json=report_schedule_data) rv = self.client.post(uri, json=report_schedule_data)
assert rv.status_code == 422 assert rv.status_code == 422
data = json.loads(rv.data.decode("utf-8")) data = json.loads(rv.data.decode("utf-8"))
assert data == {"message": {"name": ["Name must be unique"]}} assert data == {"message": {"name": ['An alert named "name3" already exists']}}
# Check that uniqueness is composed by name and type # Check that uniqueness is composed by name and type
report_schedule_data = { report_schedule_data = {
@ -516,7 +516,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"name": "name3", "name": "name3",
"description": "description", "description": "description",
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"creation_method": ReportCreationMethodType.ALERTS_REPORTS, "creation_method": ReportCreationMethod.ALERTS_REPORTS,
"chart": chart.id, "chart": chart.id,
} }
uri = "api/v1/report/" uri = "api/v1/report/"
@ -546,7 +546,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"type": ReportScheduleType.REPORT, "type": ReportScheduleType.REPORT,
"name": "name3", "name": "name3",
"description": "description", "description": "description",
"creation_method": ReportCreationMethodType.ALERTS_REPORTS, "creation_method": ReportCreationMethod.ALERTS_REPORTS,
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"chart": chart.id, "chart": chart.id,
"database": example_db.id, "database": example_db.id,
@ -560,7 +560,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"type": ReportScheduleType.ALERT, "type": ReportScheduleType.ALERT,
"name": "new3", "name": "new3",
"description": "description", "description": "description",
"creation_method": ReportCreationMethodType.ALERTS_REPORTS, "creation_method": ReportCreationMethod.ALERTS_REPORTS,
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"recipients": [ "recipients": [
{ {
@ -585,7 +585,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"type": ReportScheduleType.ALERT, "type": ReportScheduleType.ALERT,
"name": "new3", "name": "new3",
"description": "description", "description": "description",
"creation_method": ReportCreationMethodType.ALERTS_REPORTS, "creation_method": ReportCreationMethod.ALERTS_REPORTS,
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"recipients": [ "recipients": [
{ {
@ -609,7 +609,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"type": ReportScheduleType.ALERT, "type": ReportScheduleType.ALERT,
"name": "new3", "name": "new3",
"description": "description", "description": "description",
"creation_method": ReportCreationMethodType.ALERTS_REPORTS, "creation_method": ReportCreationMethod.ALERTS_REPORTS,
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"recipients": [ "recipients": [
{ {
@ -635,7 +635,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"type": ReportScheduleType.ALERT, "type": ReportScheduleType.ALERT,
"name": "new4", "name": "new4",
"description": "description", "description": "description",
"creation_method": ReportCreationMethodType.ALERTS_REPORTS, "creation_method": ReportCreationMethod.ALERTS_REPORTS,
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"recipients": [ "recipients": [
{ {
@ -661,7 +661,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"type": ReportScheduleType.ALERT, "type": ReportScheduleType.ALERT,
"name": "new5", "name": "new5",
"description": "description", "description": "description",
"creation_method": ReportCreationMethodType.ALERTS_REPORTS, "creation_method": ReportCreationMethod.ALERTS_REPORTS,
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"recipients": [ "recipients": [
{ {
@ -687,7 +687,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"type": ReportScheduleType.ALERT, "type": ReportScheduleType.ALERT,
"name": "new5", "name": "new5",
"description": "description", "description": "description",
"creation_method": ReportCreationMethodType.ALERTS_REPORTS, "creation_method": ReportCreationMethod.ALERTS_REPORTS,
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"recipients": [ "recipients": [
{ {
@ -714,7 +714,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"type": ReportScheduleType.ALERT, "type": ReportScheduleType.ALERT,
"name": "new5", "name": "new5",
"description": "description", "description": "description",
"creation_method": ReportCreationMethodType.ALERTS_REPORTS, "creation_method": ReportCreationMethod.ALERTS_REPORTS,
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"recipients": [ "recipients": [
{ {
@ -745,7 +745,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"type": ReportScheduleType.ALERT, "type": ReportScheduleType.ALERT,
"name": "new6", "name": "new6",
"description": "description", "description": "description",
"creation_method": ReportCreationMethodType.ALERTS_REPORTS, "creation_method": ReportCreationMethod.ALERTS_REPORTS,
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"recipients": [ "recipients": [
{ {
@ -784,7 +784,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"type": ReportScheduleType.REPORT, "type": ReportScheduleType.REPORT,
"name": "name3", "name": "name3",
"description": "description", "description": "description",
"creation_method": ReportCreationMethodType.CHARTS, "creation_method": ReportCreationMethod.CHARTS,
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"chart": 0, "chart": 0,
} }
@ -812,7 +812,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"type": ReportScheduleType.REPORT, "type": ReportScheduleType.REPORT,
"name": "name3", "name": "name3",
"description": "description", "description": "description",
"creation_method": ReportCreationMethodType.DASHBOARDS, "creation_method": ReportCreationMethod.DASHBOARDS,
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
} }
uri = "api/v1/report/" uri = "api/v1/report/"
@ -839,7 +839,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"type": ReportScheduleType.REPORT, "type": ReportScheduleType.REPORT,
"name": "name4", "name": "name4",
"description": "description", "description": "description",
"creation_method": ReportCreationMethodType.CHARTS, "creation_method": ReportCreationMethod.CHARTS,
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"working_timeout": 3600, "working_timeout": 3600,
"chart": chart.id, "chart": chart.id,
@ -855,7 +855,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"type": ReportScheduleType.REPORT, "type": ReportScheduleType.REPORT,
"name": "name5", "name": "name5",
"description": "description", "description": "description",
"creation_method": ReportCreationMethodType.CHARTS, "creation_method": ReportCreationMethod.CHARTS,
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"working_timeout": 3600, "working_timeout": 3600,
"chart": chart.id, "chart": chart.id,
@ -897,7 +897,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"type": ReportScheduleType.REPORT, "type": ReportScheduleType.REPORT,
"name": "name4", "name": "name4",
"description": "description", "description": "description",
"creation_method": ReportCreationMethodType.DASHBOARDS, "creation_method": ReportCreationMethod.DASHBOARDS,
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"working_timeout": 3600, "working_timeout": 3600,
"dashboard": dashboard.id, "dashboard": dashboard.id,
@ -913,7 +913,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"type": ReportScheduleType.REPORT, "type": ReportScheduleType.REPORT,
"name": "name5", "name": "name5",
"description": "description", "description": "description",
"creation_method": ReportCreationMethodType.DASHBOARDS, "creation_method": ReportCreationMethod.DASHBOARDS,
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"working_timeout": 3600, "working_timeout": 3600,
"dashboard": dashboard.id, "dashboard": dashboard.id,
@ -956,7 +956,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"name": "new3", "name": "new3",
"description": "description", "description": "description",
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"creation_method": ReportCreationMethodType.ALERTS_REPORTS, "creation_method": ReportCreationMethod.ALERTS_REPORTS,
"chart": chart.id, "chart": chart.id,
"dashboard": dashboard.id, "dashboard": dashboard.id,
"database": example_db.id, "database": example_db.id,
@ -980,7 +980,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"type": ReportScheduleType.ALERT, "type": ReportScheduleType.ALERT,
"name": "new3", "name": "new3",
"description": "description", "description": "description",
"creation_method": ReportCreationMethodType.ALERTS_REPORTS, "creation_method": ReportCreationMethod.ALERTS_REPORTS,
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"chart": chart.id, "chart": chart.id,
} }
@ -1006,7 +1006,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"type": ReportScheduleType.ALERT, "type": ReportScheduleType.ALERT,
"name": "new3", "name": "new3",
"description": "description", "description": "description",
"creation_method": ReportCreationMethodType.ALERTS_REPORTS, "creation_method": ReportCreationMethod.ALERTS_REPORTS,
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"chart": chart_max_id + 1, "chart": chart_max_id + 1,
"database": database_max_id + 1, "database": database_max_id + 1,
@ -1029,7 +1029,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"name": "new3", "name": "new3",
"description": "description", "description": "description",
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"creation_method": ReportCreationMethodType.ALERTS_REPORTS, "creation_method": ReportCreationMethod.ALERTS_REPORTS,
"dashboard": dashboard_max_id + 1, "dashboard": dashboard_max_id + 1,
"database": examples_db.id, "database": examples_db.id,
} }
@ -1197,7 +1197,7 @@ class TestReportSchedulesApi(SupersetTestCase):
rv = self.client.put(uri, json=report_schedule_data) rv = self.client.put(uri, json=report_schedule_data)
data = json.loads(rv.data.decode("utf-8")) data = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 422 assert rv.status_code == 422
assert data == {"message": {"name": ["Name must be unique"]}} assert data == {"message": {"name": ['An alert named "name3" already exists']}}
@pytest.mark.usefixtures("create_report_schedules") @pytest.mark.usefixtures("create_report_schedules")
def test_update_report_schedule_not_found(self): def test_update_report_schedule_not_found(self):
@ -1546,7 +1546,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"name": "new3", "name": "new3",
"description": "description", "description": "description",
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"creation_method": ReportCreationMethodType.ALERTS_REPORTS, "creation_method": ReportCreationMethod.ALERTS_REPORTS,
"recipients": [ "recipients": [
{ {
"type": ReportRecipientType.EMAIL, "type": ReportRecipientType.EMAIL,
@ -1581,7 +1581,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"name": "new3", "name": "new3",
"description": "description", "description": "description",
"crontab": "0 9 * * *", "crontab": "0 9 * * *",
"creation_method": ReportCreationMethodType.ALERTS_REPORTS, "creation_method": ReportCreationMethod.ALERTS_REPORTS,
"recipients": [ "recipients": [
{ {
"type": ReportRecipientType.EMAIL, "type": ReportRecipientType.EMAIL,