mirror of
https://github.com/apache/superset.git
synced 2026-06-15 04:29:18 +00:00
Compare commits
7 Commits
docs/testi
...
fire-alert
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58a2eb465a | ||
|
|
3c51194bb2 | ||
|
|
19be7020c7 | ||
|
|
7c9794cc2f | ||
|
|
7a6b084ff7 | ||
|
|
17eeeaccac | ||
|
|
4dd1e80f4c |
@@ -184,8 +184,10 @@ services:
|
|||||||
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
|
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
|
||||||
# configuring the dev-server to use the host.docker.internal to connect to the backend
|
# configuring the dev-server to use the host.docker.internal to connect to the backend
|
||||||
superset: "http://superset:8088"
|
superset: "http://superset:8088"
|
||||||
|
WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-0.0.0.0}"
|
||||||
|
WEBPACK_DEVSERVER_PORT: "${WEBPACK_DEVSERVER_PORT:-9000}"
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:9000:9000" # exposing the dynamic webpack dev server
|
- "9000:9000" # exposing the dynamic webpack dev server
|
||||||
container_name: superset_node
|
container_name: superset_node
|
||||||
command: ["/app/docker/docker-frontend.sh"]
|
command: ["/app/docker/docker-frontend.sh"]
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
@@ -33,8 +33,11 @@ interface ModalFormFieldProps {
|
|||||||
hasFeedback?: boolean;
|
hasFeedback?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledFieldContainer = styled.div<{ bottomSpacing: boolean }>`
|
const StyledFieldContainer = styled.div<{
|
||||||
${({ theme, bottomSpacing }) => css`
|
bottomSpacing: boolean;
|
||||||
|
hasError: boolean;
|
||||||
|
}>`
|
||||||
|
${({ theme, bottomSpacing, hasError }) => css`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-top: 0px;
|
margin-top: 0px;
|
||||||
margin-bottom: ${bottomSpacing ? theme.sizeUnit * 4 : 0}px;
|
margin-bottom: ${bottomSpacing ? theme.sizeUnit * 4 : 0}px;
|
||||||
@@ -48,7 +51,7 @@ const StyledFieldContainer = styled.div<{ bottomSpacing: boolean }>`
|
|||||||
|
|
||||||
.required {
|
.required {
|
||||||
margin-left: ${theme.sizeUnit / 2}px;
|
margin-left: ${theme.sizeUnit / 2}px;
|
||||||
color: ${theme.colorError};
|
color: ${hasError ? theme.colorError : theme.colorIcon};
|
||||||
}
|
}
|
||||||
|
|
||||||
.helper {
|
.helper {
|
||||||
@@ -128,8 +131,14 @@ export function ModalFormField({
|
|||||||
validateStatus,
|
validateStatus,
|
||||||
hasFeedback = false,
|
hasFeedback = false,
|
||||||
}: ModalFormFieldProps) {
|
}: ModalFormFieldProps) {
|
||||||
|
const hasError = !!(error || validateStatus === 'error');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledFieldContainer bottomSpacing={bottomSpacing} data-test={testId}>
|
<StyledFieldContainer
|
||||||
|
bottomSpacing={bottomSpacing}
|
||||||
|
hasError={hasError}
|
||||||
|
data-test={testId}
|
||||||
|
>
|
||||||
<div className="control-label">
|
<div className="control-label">
|
||||||
{label}
|
{label}
|
||||||
{tooltip && <InfoTooltip tooltip={tooltip} />}
|
{tooltip && <InfoTooltip tooltip={tooltip} />}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
FeatureFlag,
|
FeatureFlag,
|
||||||
styled,
|
styled,
|
||||||
SupersetClient,
|
SupersetClient,
|
||||||
SupersetTheme,
|
|
||||||
t,
|
t,
|
||||||
VizType,
|
VizType,
|
||||||
useTheme,
|
useTheme,
|
||||||
@@ -41,7 +40,6 @@ import rison from 'rison';
|
|||||||
import { useSingleViewResource } from 'src/views/CRUD/hooks';
|
import { useSingleViewResource } from 'src/views/CRUD/hooks';
|
||||||
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 { Form as AntdForm } from 'src/components/Form';
|
|
||||||
import { propertyComparator } from '@superset-ui/core/components/Select/utils';
|
import { propertyComparator } from '@superset-ui/core/components/Select/utils';
|
||||||
import {
|
import {
|
||||||
AsyncSelect,
|
AsyncSelect,
|
||||||
@@ -266,11 +264,6 @@ export const StyledInputContainer = styled.div`
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.required {
|
|
||||||
margin-left: ${theme.sizeUnit / 2}px;
|
|
||||||
color: ${theme.colorError};
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-label {
|
.control-label {
|
||||||
margin-bottom: ${theme.sizeUnit * 2}px;
|
margin-bottom: ${theme.sizeUnit * 2}px;
|
||||||
color: ${theme.colorText};
|
color: ${theme.colorText};
|
||||||
@@ -404,10 +397,6 @@ const StyledNotificationMethodWrapper = styled.div`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const inputSpacer = (theme: SupersetTheme) => css`
|
|
||||||
margin-right: ${theme.sizeUnit * 3}px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
type NotificationAddStatus = 'active' | 'disabled' | 'hidden';
|
type NotificationAddStatus = 'active' | 'disabled' | 'hidden';
|
||||||
|
|
||||||
interface NotificationMethodAddProps {
|
interface NotificationMethodAddProps {
|
||||||
@@ -2042,6 +2031,15 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
|||||||
<ModalFormField
|
<ModalFormField
|
||||||
label={isReport ? t('Report name') : t('Alert name')}
|
label={isReport ? t('Report name') : t('Alert name')}
|
||||||
required
|
required
|
||||||
|
error={
|
||||||
|
validationStatus[Sections.General]?.hasErrors &&
|
||||||
|
!currentAlert?.name?.trim()
|
||||||
|
? t(
|
||||||
|
'%s name is required',
|
||||||
|
isReport ? t('Report') : t('Alert'),
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
name="name"
|
name="name"
|
||||||
@@ -2054,7 +2052,17 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
|||||||
onChange={onInputChange}
|
onChange={onInputChange}
|
||||||
/>
|
/>
|
||||||
</ModalFormField>
|
</ModalFormField>
|
||||||
<ModalFormField label={t('Owners')} required>
|
<ModalFormField
|
||||||
|
label={t('Owners')}
|
||||||
|
required
|
||||||
|
error={
|
||||||
|
validationStatus[Sections.General]?.hasErrors &&
|
||||||
|
(!currentAlert?.owners ||
|
||||||
|
currentAlert.owners.length === 0)
|
||||||
|
? t('Owners are required')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<AsyncSelect
|
<AsyncSelect
|
||||||
ariaLabel={t('Owners')}
|
ariaLabel={t('Owners')}
|
||||||
allowClear
|
allowClear
|
||||||
@@ -2115,40 +2123,46 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
|||||||
),
|
),
|
||||||
children: (
|
children: (
|
||||||
<div>
|
<div>
|
||||||
<StyledInputContainer>
|
<ModalFormField
|
||||||
<div className="control-label">
|
label={t('Database')}
|
||||||
{t('Database')}
|
required
|
||||||
<span className="required">*</span>
|
error={
|
||||||
</div>
|
validationStatus[Sections.Alert]?.hasErrors &&
|
||||||
<div className="input-container">
|
!currentAlert?.database
|
||||||
<AsyncSelect
|
? t('Database is required')
|
||||||
ariaLabel={t('Database')}
|
: undefined
|
||||||
name="source"
|
}
|
||||||
placeholder={t('Select database')}
|
>
|
||||||
value={
|
<AsyncSelect
|
||||||
currentAlert?.database?.label &&
|
ariaLabel={t('Database')}
|
||||||
currentAlert?.database?.value
|
name="source"
|
||||||
? {
|
placeholder={t('Select database')}
|
||||||
value: currentAlert.database.value,
|
value={
|
||||||
label: currentAlert.database.label,
|
currentAlert?.database?.label &&
|
||||||
}
|
currentAlert?.database?.value
|
||||||
: undefined
|
? {
|
||||||
}
|
value: currentAlert.database.value,
|
||||||
options={loadSourceOptions}
|
label: currentAlert.database.label,
|
||||||
onChange={onSourceChange}
|
}
|
||||||
/>
|
: undefined
|
||||||
</div>
|
}
|
||||||
</StyledInputContainer>
|
options={loadSourceOptions}
|
||||||
<StyledInputContainer>
|
onChange={onSourceChange}
|
||||||
<div className="control-label">
|
/>
|
||||||
{t('SQL Query')}
|
</ModalFormField>
|
||||||
<InfoTooltip
|
<ModalFormField
|
||||||
tooltip={t(
|
label={t('SQL Query')}
|
||||||
'The result of this query must be a value capable of numeric interpretation e.g. 1, 1.0, or "1" (compatible with Python\'s float() function).',
|
required
|
||||||
)}
|
tooltip={t(
|
||||||
/>
|
'The result of this query must be a value capable of numeric interpretation e.g. 1, 1.0, or "1" (compatible with Python\'s float() function).',
|
||||||
<span className="required">*</span>
|
)}
|
||||||
</div>
|
error={
|
||||||
|
validationStatus[Sections.Alert]?.hasErrors &&
|
||||||
|
!currentAlert?.sql?.length
|
||||||
|
? t('SQL Query is required')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<TextAreaControl
|
<TextAreaControl
|
||||||
name="sql"
|
name="sql"
|
||||||
language="sql"
|
language="sql"
|
||||||
@@ -2160,57 +2174,60 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
|||||||
initialValue={resource?.sql}
|
initialValue={resource?.sql}
|
||||||
key={currentAlert?.id}
|
key={currentAlert?.id}
|
||||||
/>
|
/>
|
||||||
</StyledInputContainer>
|
</ModalFormField>
|
||||||
<div
|
<div
|
||||||
className="inline-container wrap"
|
className="inline-container wrap"
|
||||||
css={css`
|
css={css`
|
||||||
gap: ${theme.sizeUnit}px;
|
gap: ${theme.sizeUnit}px;
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<StyledInputContainer css={noMarginBottom}>
|
<ModalFormField
|
||||||
<div className="control-label" css={inputSpacer}>
|
label={t('Trigger Alert If...')}
|
||||||
{t('Trigger Alert If...')}
|
required
|
||||||
<span className="required">*</span>
|
error={
|
||||||
</div>
|
validationStatus[Sections.Alert]?.hasErrors &&
|
||||||
<div className="input-container">
|
!currentAlert?.validator_config_json?.op
|
||||||
<Select
|
? t('Condition is required')
|
||||||
ariaLabel={t('Condition')}
|
: undefined
|
||||||
onChange={onConditionChange}
|
}
|
||||||
placeholder={t('Condition')}
|
>
|
||||||
value={
|
<Select
|
||||||
currentAlert?.validator_config_json?.op ||
|
ariaLabel={t('Condition')}
|
||||||
undefined
|
onChange={onConditionChange}
|
||||||
}
|
placeholder={t('Condition')}
|
||||||
options={CONDITIONS}
|
value={
|
||||||
/>
|
currentAlert?.validator_config_json?.op ||
|
||||||
</div>
|
undefined
|
||||||
</StyledInputContainer>
|
}
|
||||||
<StyledInputContainer css={noMarginBottom}>
|
options={CONDITIONS}
|
||||||
<div className="control-label">
|
/>
|
||||||
{t('Value')}{' '}
|
</ModalFormField>
|
||||||
{!conditionNotNull && (
|
<ModalFormField
|
||||||
<span className="required">*</span>
|
label={t('Value')}
|
||||||
)}
|
required={!conditionNotNull}
|
||||||
</div>
|
error={
|
||||||
<div className="input-container">
|
validationStatus[Sections.Alert]?.hasErrors &&
|
||||||
<InputNumber
|
!conditionNotNull &&
|
||||||
disabled={conditionNotNull}
|
!currentAlert?.validator_config_json?.threshold
|
||||||
type="number"
|
? t('Value is required')
|
||||||
name="threshold"
|
: undefined
|
||||||
value={
|
}
|
||||||
currentAlert?.validator_config_json
|
>
|
||||||
?.threshold !== undefined &&
|
<InputNumber
|
||||||
!conditionNotNull
|
disabled={conditionNotNull}
|
||||||
? currentAlert.validator_config_json
|
type="number"
|
||||||
.threshold
|
name="threshold"
|
||||||
: ''
|
value={
|
||||||
}
|
currentAlert?.validator_config_json
|
||||||
min={0}
|
?.threshold !== undefined && !conditionNotNull
|
||||||
placeholder={t('Value')}
|
? currentAlert.validator_config_json.threshold
|
||||||
onChange={onThresholdChange}
|
: ''
|
||||||
/>
|
}
|
||||||
</div>
|
min={0}
|
||||||
</StyledInputContainer>
|
placeholder={t('Value')}
|
||||||
|
onChange={onThresholdChange}
|
||||||
|
/>
|
||||||
|
</ModalFormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -2468,7 +2485,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
|||||||
<InputNumber
|
<InputNumber
|
||||||
type="number"
|
type="number"
|
||||||
name="custom_width"
|
name="custom_width"
|
||||||
value={currentAlert?.custom_width || undefined}
|
value={currentAlert?.custom_width || 1600}
|
||||||
min={600}
|
min={600}
|
||||||
max={2400}
|
max={2400}
|
||||||
placeholder={t('Input custom width in pixels')}
|
placeholder={t('Input custom width in pixels')}
|
||||||
@@ -2511,66 +2528,66 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
|||||||
value={currentAlert?.crontab || ''}
|
value={currentAlert?.crontab || ''}
|
||||||
onChange={newVal => updateAlertState('crontab', newVal)}
|
onChange={newVal => updateAlertState('crontab', newVal)}
|
||||||
/>
|
/>
|
||||||
<StyledInputContainer>
|
<ModalFormField
|
||||||
<div className="control-label">
|
label={t('Timezone')}
|
||||||
{t('Timezone')} <span className="required">*</span>
|
required
|
||||||
</div>
|
error={
|
||||||
|
validationStatus[Sections.Schedule]?.hasErrors &&
|
||||||
|
!currentAlert?.timezone
|
||||||
|
? t('Timezone is required')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<TimezoneSelector
|
<TimezoneSelector
|
||||||
onTimezoneChange={onTimezoneChange}
|
onTimezoneChange={onTimezoneChange}
|
||||||
timezone={currentAlert?.timezone}
|
timezone={currentAlert?.timezone}
|
||||||
minWidth="100%"
|
minWidth="100%"
|
||||||
/>
|
/>
|
||||||
</StyledInputContainer>
|
</ModalFormField>
|
||||||
<StyledInputContainer>
|
<ModalFormField
|
||||||
<div className="control-label">
|
label={t('Log retention')}
|
||||||
{t('Log retention')}
|
required
|
||||||
<span className="required">*</span>
|
error={
|
||||||
</div>
|
validationStatus[Sections.Schedule]?.hasErrors &&
|
||||||
<div className="input-container">
|
!currentAlert?.log_retention
|
||||||
<Select
|
? t('Log retention is required')
|
||||||
ariaLabel={t('Log retention')}
|
: undefined
|
||||||
placeholder={t('Log retention')}
|
}
|
||||||
onChange={onLogRetentionChange}
|
>
|
||||||
value={currentAlert?.log_retention}
|
<Select
|
||||||
options={RETENTION_OPTIONS}
|
ariaLabel={t('Log retention')}
|
||||||
sortComparator={propertyComparator('value')}
|
placeholder={t('Log retention')}
|
||||||
/>
|
onChange={onLogRetentionChange}
|
||||||
</div>
|
value={currentAlert?.log_retention}
|
||||||
</StyledInputContainer>
|
options={RETENTION_OPTIONS}
|
||||||
<StyledInputContainer css={noMarginBottom}>
|
sortComparator={propertyComparator('value')}
|
||||||
{isReport ? (
|
/>
|
||||||
<>
|
</ModalFormField>
|
||||||
<div className="control-label">
|
<ModalFormField
|
||||||
{t('Working timeout')}
|
label={isReport ? t('Working timeout') : t('Grace period')}
|
||||||
<span className="required">*</span>
|
required={isReport}
|
||||||
</div>
|
error={
|
||||||
<div className="input-container">
|
validationStatus[Sections.Schedule]?.hasErrors &&
|
||||||
<NumberInput
|
isReport &&
|
||||||
min={1}
|
!currentAlert?.working_timeout
|
||||||
name="working_timeout"
|
? t('Working timeout is required')
|
||||||
value={currentAlert?.working_timeout || ''}
|
: undefined
|
||||||
placeholder={t('Time in seconds')}
|
}
|
||||||
onChange={onTimeoutVerifyChange}
|
bottomSpacing={false}
|
||||||
timeUnit={t('seconds')}
|
>
|
||||||
/>
|
<NumberInput
|
||||||
</div>
|
min={1}
|
||||||
</>
|
name={isReport ? 'working_timeout' : 'grace_period'}
|
||||||
) : (
|
value={
|
||||||
<>
|
isReport
|
||||||
<div className="control-label">{t('Grace period')}</div>
|
? currentAlert?.working_timeout || ''
|
||||||
<div className="input-container">
|
: currentAlert?.grace_period || ''
|
||||||
<NumberInput
|
}
|
||||||
min={1}
|
placeholder={t('Time in seconds')}
|
||||||
name="grace_period"
|
onChange={onTimeoutVerifyChange}
|
||||||
value={currentAlert?.grace_period || ''}
|
timeUnit={t('seconds')}
|
||||||
placeholder={t('Time in seconds')}
|
/>
|
||||||
onChange={onTimeoutVerifyChange}
|
</ModalFormField>
|
||||||
timeUnit={t('seconds')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</StyledInputContainer>
|
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* 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 { act } from '@testing-library/react';
|
||||||
|
import { renderHook } from '@testing-library/react-hooks';
|
||||||
|
import fetchMock from 'fetch-mock';
|
||||||
|
import { SupersetClient } from '@superset-ui/core';
|
||||||
|
|
||||||
|
import { useExecuteReportSchedule } from './useExecuteReportSchedule';
|
||||||
|
|
||||||
|
const mockExecuteResponse = {
|
||||||
|
execution_id: 'test-uuid-123',
|
||||||
|
message: 'Report schedule execution started successfully',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
SupersetClient.configure().init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fetchMock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successfully executes a report', async () => {
|
||||||
|
const reportId = 123;
|
||||||
|
fetchMock.post(
|
||||||
|
`glob:*/api/v1/report/${reportId}/execute`,
|
||||||
|
mockExecuteResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useExecuteReportSchedule());
|
||||||
|
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(result.current.error).toBe(null);
|
||||||
|
|
||||||
|
let executeResult: any;
|
||||||
|
await act(async () => {
|
||||||
|
executeResult = await result.current.executeReport(reportId);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(result.current.error).toBe(null);
|
||||||
|
expect(executeResult).toEqual(mockExecuteResponse);
|
||||||
|
expect(fetchMock.calls()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles execution errors', async () => {
|
||||||
|
const reportId = 123;
|
||||||
|
const errorMessage = 'Report not found';
|
||||||
|
fetchMock.post(`glob:*/api/v1/report/${reportId}/execute`, {
|
||||||
|
status: 404,
|
||||||
|
body: { message: errorMessage },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useExecuteReportSchedule());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
try {
|
||||||
|
await result.current.executeReport(reportId);
|
||||||
|
} catch (error) {
|
||||||
|
// Expected to throw
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(result.current.error).toBe(errorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls success callback on successful execution', async () => {
|
||||||
|
const reportId = 123;
|
||||||
|
const onSuccess = jest.fn();
|
||||||
|
fetchMock.post(
|
||||||
|
`glob:*/api/v1/report/${reportId}/execute`,
|
||||||
|
mockExecuteResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useExecuteReportSchedule());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.executeReport(reportId, onSuccess);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onSuccess).toHaveBeenCalledWith(mockExecuteResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls error callback on failed execution', async () => {
|
||||||
|
const reportId = 123;
|
||||||
|
const onError = jest.fn();
|
||||||
|
const errorMessage = 'Execution failed';
|
||||||
|
fetchMock.post(`glob:*/api/v1/report/${reportId}/execute`, {
|
||||||
|
status: 500,
|
||||||
|
body: { message: errorMessage },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useExecuteReportSchedule());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
try {
|
||||||
|
await result.current.executeReport(reportId, undefined, onError);
|
||||||
|
} catch (error) {
|
||||||
|
// Expected to throw
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onError).toHaveBeenCalledWith(errorMessage);
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* 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 { useState, useCallback } from 'react';
|
||||||
|
import { SupersetClient, t } from '@superset-ui/core';
|
||||||
|
|
||||||
|
interface ExecuteResponse {
|
||||||
|
execution_id: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseExecuteReportScheduleState {
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExecuteReportSchedule() {
|
||||||
|
const [state, setState] = useState<UseExecuteReportScheduleState>({
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const executeReport = useCallback(
|
||||||
|
async (
|
||||||
|
reportId: number,
|
||||||
|
onSuccess?: (response: ExecuteResponse) => void,
|
||||||
|
onError?: (error: string) => void,
|
||||||
|
) => {
|
||||||
|
setState({ loading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await SupersetClient.post({
|
||||||
|
endpoint: `/api/v1/report/${reportId}/execute`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = response.json as ExecuteResponse;
|
||||||
|
setState({ loading: false, error: null });
|
||||||
|
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
let errorMessage = t('An error occurred while triggering the report');
|
||||||
|
|
||||||
|
if (error && typeof error === 'object' && 'json' in error) {
|
||||||
|
const errorJson = error.json as any;
|
||||||
|
if (errorJson?.message) {
|
||||||
|
errorMessage = errorJson.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({ loading: false, error: errorMessage });
|
||||||
|
|
||||||
|
if (onError) {
|
||||||
|
onError(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
executeReport,
|
||||||
|
loading: state.loading,
|
||||||
|
error: state.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
|
|||||||
import Owner from 'src/types/Owner';
|
import Owner from 'src/types/Owner';
|
||||||
import AlertReportModal from 'src/features/alerts/AlertReportModal';
|
import AlertReportModal from 'src/features/alerts/AlertReportModal';
|
||||||
import { AlertObject, AlertState } from 'src/features/alerts/types';
|
import { AlertObject, AlertState } from 'src/features/alerts/types';
|
||||||
|
import { useExecuteReportSchedule } from 'src/features/alerts/hooks/useExecuteReportSchedule';
|
||||||
import { QueryObjectColumns } from 'src/views/CRUD/types';
|
import { QueryObjectColumns } from 'src/views/CRUD/types';
|
||||||
import { Icons } from '@superset-ui/core/components/Icons';
|
import { Icons } from '@superset-ui/core/components/Icons';
|
||||||
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
|
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
|
||||||
@@ -157,12 +158,16 @@ function AlertList({
|
|||||||
addDangerToast,
|
addDangerToast,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Execute hook for Fire Now functionality
|
||||||
|
const { executeReport } = useExecuteReportSchedule();
|
||||||
|
|
||||||
const [alertModalOpen, setAlertModalOpen] = useState<boolean>(false);
|
const [alertModalOpen, setAlertModalOpen] = useState<boolean>(false);
|
||||||
const [currentAlert, setCurrentAlert] = useState<Partial<AlertObject> | null>(
|
const [currentAlert, setCurrentAlert] = useState<Partial<AlertObject> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [currentAlertDeleting, setCurrentAlertDeleting] =
|
const [currentAlertDeleting, setCurrentAlertDeleting] =
|
||||||
useState<AlertObject | null>(null);
|
useState<AlertObject | null>(null);
|
||||||
|
const [executingIds, setExecutingIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
function handleAlertEdit(alert: AlertObject | null) {
|
function handleAlertEdit(alert: AlertObject | null) {
|
||||||
@@ -246,6 +251,51 @@ function AlertList({
|
|||||||
[alerts, setResourceCollection, updateResource],
|
[alerts, setResourceCollection, updateResource],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleExecuteReport = useCallback(
|
||||||
|
async (alert: AlertObject) => {
|
||||||
|
const alertId = alert.id;
|
||||||
|
if (!alertId || executingIds.has(alertId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to executing set
|
||||||
|
setExecutingIds(prev => new Set(prev).add(alertId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await executeReport(
|
||||||
|
alertId,
|
||||||
|
response => {
|
||||||
|
addSuccessToast(
|
||||||
|
t('%(alertType)s "%(alertName)s" triggered successfully', {
|
||||||
|
alertType: alert.type,
|
||||||
|
alertName: alert.name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
error => {
|
||||||
|
addDangerToast(
|
||||||
|
t('Failed to trigger %(alertType)s "%(alertName)s": %(error)s', {
|
||||||
|
alertType: alert.type,
|
||||||
|
alertName: alert.name,
|
||||||
|
error,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Error already handled by onError callback
|
||||||
|
} finally {
|
||||||
|
// Remove from executing set
|
||||||
|
setExecutingIds(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(alertId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[executeReport, executingIds, addSuccessToast, addDangerToast],
|
||||||
|
);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -397,6 +447,16 @@ function AlertList({
|
|||||||
onClick: handleEdit,
|
onClick: handleEdit,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
allowEdit
|
||||||
|
? {
|
||||||
|
label: 'trigger-now-action',
|
||||||
|
tooltip: t('Trigger Now'),
|
||||||
|
placement: 'bottom',
|
||||||
|
icon: 'ThunderboltOutlined',
|
||||||
|
loading: executingIds.has(original.id),
|
||||||
|
onClick: () => handleExecuteReport(original),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
allowEdit && canDelete
|
allowEdit && canDelete
|
||||||
? {
|
? {
|
||||||
label: 'delete-action',
|
label: 'delete-action',
|
||||||
@@ -424,7 +484,14 @@ function AlertList({
|
|||||||
id: QueryObjectColumns.ChangedBy,
|
id: QueryObjectColumns.ChangedBy,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[canDelete, canEdit, isReportEnabled, toggleActive],
|
[
|
||||||
|
canDelete,
|
||||||
|
canEdit,
|
||||||
|
isReportEnabled,
|
||||||
|
toggleActive,
|
||||||
|
executingIds,
|
||||||
|
handleExecuteReport,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const subMenuButtons: SubMenuProps['buttons'] = [];
|
const subMenuButtons: SubMenuProps['buttons'] = [];
|
||||||
|
|||||||
@@ -309,3 +309,15 @@ class ReportScheduleForbiddenError(ForbiddenError):
|
|||||||
|
|
||||||
class ReportSchedulePruneLogError(CommandException):
|
class ReportSchedulePruneLogError(CommandException):
|
||||||
message = _("An error occurred while pruning logs ")
|
message = _("An error occurred while pruning logs ")
|
||||||
|
|
||||||
|
|
||||||
|
class ReportScheduleExecuteNowFailedError(CommandException):
|
||||||
|
message = _("Report Schedule execute now failed.")
|
||||||
|
|
||||||
|
|
||||||
|
class ReportScheduleCeleryNotConfiguredError(CommandException):
|
||||||
|
status = 503
|
||||||
|
message = _(
|
||||||
|
"Report Schedule execution requires a Celery backend to be configured. "
|
||||||
|
"Please configure a Celery broker (Redis or RabbitMQ) and worker processes."
|
||||||
|
)
|
||||||
|
|||||||
147
superset/commands/report/execute_now.py
Normal file
147
superset/commands/report/execute_now.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# 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 logging
|
||||||
|
from typing import Any, Optional
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from superset import security_manager
|
||||||
|
from superset.commands.base import BaseCommand
|
||||||
|
from superset.commands.exceptions import CommandException
|
||||||
|
from superset.commands.report.exceptions import (
|
||||||
|
ReportScheduleCeleryNotConfiguredError,
|
||||||
|
ReportScheduleExecuteNowFailedError,
|
||||||
|
ReportScheduleForbiddenError,
|
||||||
|
ReportScheduleNotFoundError,
|
||||||
|
)
|
||||||
|
from superset.daos.report import ReportScheduleDAO
|
||||||
|
from superset.exceptions import SupersetSecurityException
|
||||||
|
from superset.reports.models import ReportSchedule
|
||||||
|
from superset.utils.decorators import transaction
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecuteReportScheduleNowCommand(BaseCommand):
|
||||||
|
"""
|
||||||
|
Execute a report schedule immediately (manual trigger).
|
||||||
|
|
||||||
|
This command validates permissions and triggers immediate execution
|
||||||
|
of a report or alert via Celery task, similar to scheduled execution
|
||||||
|
but without waiting for the cron schedule.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, model_id: int) -> None:
|
||||||
|
self._model_id = model_id
|
||||||
|
self._model: Optional[ReportSchedule] = None
|
||||||
|
|
||||||
|
@transaction()
|
||||||
|
def run(self) -> str:
|
||||||
|
"""
|
||||||
|
Execute the command and return execution UUID for tracking.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Execution UUID that can be used to track the execution status
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ReportScheduleNotFoundError: Report schedule not found
|
||||||
|
ReportScheduleForbiddenError: User doesn't have permission to execute
|
||||||
|
ReportScheduleExecuteNowFailedError: Execution failed to start
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.validate()
|
||||||
|
if not self._model:
|
||||||
|
raise ReportScheduleExecuteNowFailedError()
|
||||||
|
|
||||||
|
# Generate execution UUID for tracking
|
||||||
|
execution_id = str(uuid4())
|
||||||
|
|
||||||
|
# Trigger immediate execution via Celery
|
||||||
|
logger.info(
|
||||||
|
"Manually executing report schedule %s (id: %d), execution_id: %s",
|
||||||
|
self._model.name,
|
||||||
|
self._model.id,
|
||||||
|
execution_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Import the existing execute task to avoid circular imports
|
||||||
|
from superset.tasks.scheduler import execute
|
||||||
|
|
||||||
|
# Set async options similar to scheduler but for immediate execution
|
||||||
|
async_options: dict[str, Any] = {"task_id": execution_id}
|
||||||
|
if self._model.working_timeout is not None and current_app.config.get(
|
||||||
|
"ALERT_REPORTS_WORKING_TIME_OUT_KILL", True
|
||||||
|
):
|
||||||
|
async_options["time_limit"] = (
|
||||||
|
self._model.working_timeout
|
||||||
|
+ current_app.config.get("ALERT_REPORTS_WORKING_TIME_OUT_LAG", 10)
|
||||||
|
)
|
||||||
|
async_options["soft_time_limit"] = (
|
||||||
|
self._model.working_timeout
|
||||||
|
+ current_app.config.get(
|
||||||
|
"ALERT_REPORTS_WORKING_SOFT_TIME_OUT_LAG", 5
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Execute the task
|
||||||
|
try:
|
||||||
|
execute.apply_async((self._model.id,), **async_options)
|
||||||
|
except Exception as celery_ex:
|
||||||
|
# Check for common Celery configuration issues
|
||||||
|
error_msg = str(celery_ex).lower()
|
||||||
|
if any(
|
||||||
|
keyword in error_msg
|
||||||
|
for keyword in [
|
||||||
|
"no broker",
|
||||||
|
"broker connection",
|
||||||
|
"kombu",
|
||||||
|
"redis",
|
||||||
|
"rabbitmq",
|
||||||
|
"celery",
|
||||||
|
"not registered",
|
||||||
|
"connection refused",
|
||||||
|
]
|
||||||
|
):
|
||||||
|
logger.error("Celery backend not configured: %s", str(celery_ex))
|
||||||
|
raise ReportScheduleCeleryNotConfiguredError() from celery_ex
|
||||||
|
else:
|
||||||
|
logger.error("Celery task execution failed: %s", str(celery_ex))
|
||||||
|
raise ReportScheduleExecuteNowFailedError() from celery_ex
|
||||||
|
|
||||||
|
return execution_id
|
||||||
|
|
||||||
|
except CommandException:
|
||||||
|
raise
|
||||||
|
except Exception as ex:
|
||||||
|
logger.exception(
|
||||||
|
"Unexpected error executing report schedule %d", self._model_id
|
||||||
|
)
|
||||||
|
raise ReportScheduleExecuteNowFailedError() from ex
|
||||||
|
|
||||||
|
def validate(self) -> None:
|
||||||
|
"""Validate the report schedule exists and user has permission to execute it."""
|
||||||
|
# Validate model exists
|
||||||
|
self._model = ReportScheduleDAO.find_by_id(self._model_id)
|
||||||
|
if not self._model:
|
||||||
|
raise ReportScheduleNotFoundError()
|
||||||
|
|
||||||
|
# Check ownership using the same pattern as delete command
|
||||||
|
try:
|
||||||
|
security_manager.raise_for_ownership(self._model)
|
||||||
|
except SupersetSecurityException as ex:
|
||||||
|
raise ReportScheduleForbiddenError() from ex
|
||||||
@@ -29,13 +29,16 @@ from superset.charts.filters import ChartFilter
|
|||||||
from superset.commands.report.create import CreateReportScheduleCommand
|
from superset.commands.report.create import CreateReportScheduleCommand
|
||||||
from superset.commands.report.delete import DeleteReportScheduleCommand
|
from superset.commands.report.delete import DeleteReportScheduleCommand
|
||||||
from superset.commands.report.exceptions import (
|
from superset.commands.report.exceptions import (
|
||||||
|
ReportScheduleCeleryNotConfiguredError,
|
||||||
ReportScheduleCreateFailedError,
|
ReportScheduleCreateFailedError,
|
||||||
ReportScheduleDeleteFailedError,
|
ReportScheduleDeleteFailedError,
|
||||||
|
ReportScheduleExecuteNowFailedError,
|
||||||
ReportScheduleForbiddenError,
|
ReportScheduleForbiddenError,
|
||||||
ReportScheduleInvalidError,
|
ReportScheduleInvalidError,
|
||||||
ReportScheduleNotFoundError,
|
ReportScheduleNotFoundError,
|
||||||
ReportScheduleUpdateFailedError,
|
ReportScheduleUpdateFailedError,
|
||||||
)
|
)
|
||||||
|
from superset.commands.report.execute_now import ExecuteReportScheduleNowCommand
|
||||||
from superset.commands.report.update import UpdateReportScheduleCommand
|
from superset.commands.report.update import UpdateReportScheduleCommand
|
||||||
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
|
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
|
||||||
from superset.dashboards.filters import DashboardAccessFilter
|
from superset.dashboards.filters import DashboardAccessFilter
|
||||||
@@ -48,6 +51,7 @@ from superset.reports.schemas import (
|
|||||||
get_delete_ids_schema,
|
get_delete_ids_schema,
|
||||||
get_slack_channels_schema,
|
get_slack_channels_schema,
|
||||||
openapi_spec_methods_override,
|
openapi_spec_methods_override,
|
||||||
|
ReportScheduleExecuteResponseSchema,
|
||||||
ReportSchedulePostSchema,
|
ReportSchedulePostSchema,
|
||||||
ReportSchedulePutSchema,
|
ReportSchedulePutSchema,
|
||||||
)
|
)
|
||||||
@@ -76,6 +80,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
|
|||||||
RouteMethod.RELATED,
|
RouteMethod.RELATED,
|
||||||
"bulk_delete",
|
"bulk_delete",
|
||||||
"slack_channels", # not using RouteMethod since locally defined
|
"slack_channels", # not using RouteMethod since locally defined
|
||||||
|
"execute", # not using RouteMethod since locally defined
|
||||||
}
|
}
|
||||||
class_permission_name = "ReportSchedule"
|
class_permission_name = "ReportSchedule"
|
||||||
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
|
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||||
@@ -588,3 +593,77 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
|
|||||||
except SupersetException as ex:
|
except SupersetException as ex:
|
||||||
logger.error("Error fetching slack channels %s", str(ex))
|
logger.error("Error fetching slack channels %s", str(ex))
|
||||||
return self.response_422(message=str(ex))
|
return self.response_422(message=str(ex))
|
||||||
|
|
||||||
|
@expose("/<int:pk>/execute", methods=("POST",))
|
||||||
|
@protect()
|
||||||
|
@safe
|
||||||
|
@permission_name("write")
|
||||||
|
@statsd_metrics
|
||||||
|
@event_logger.log_this_with_context(
|
||||||
|
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.execute",
|
||||||
|
log_to_statsd=False,
|
||||||
|
)
|
||||||
|
def execute(self, pk: int) -> Response:
|
||||||
|
"""Execute a report schedule immediately.
|
||||||
|
---
|
||||||
|
post:
|
||||||
|
summary: Execute a report schedule immediately
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
name: pk
|
||||||
|
description: The report schedule pk
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Report schedule execution started
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
execution_id:
|
||||||
|
type: string
|
||||||
|
description: UUID to track the execution status
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: Success message
|
||||||
|
403:
|
||||||
|
$ref: '#/components/responses/403'
|
||||||
|
404:
|
||||||
|
$ref: '#/components/responses/404'
|
||||||
|
422:
|
||||||
|
$ref: '#/components/responses/422'
|
||||||
|
500:
|
||||||
|
$ref: '#/components/responses/500'
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
execution_id = ExecuteReportScheduleNowCommand(pk).run()
|
||||||
|
response_schema = ReportScheduleExecuteResponseSchema()
|
||||||
|
return self.response(
|
||||||
|
200,
|
||||||
|
**response_schema.dump(
|
||||||
|
{
|
||||||
|
"execution_id": execution_id,
|
||||||
|
"message": "Report schedule execution started successfully",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except ReportScheduleNotFoundError:
|
||||||
|
return self.response_404()
|
||||||
|
except ReportScheduleForbiddenError:
|
||||||
|
return self.response_403()
|
||||||
|
except ReportScheduleCeleryNotConfiguredError as ex:
|
||||||
|
logger.error(
|
||||||
|
"Celery backend not configured for report schedule execution: %s",
|
||||||
|
str(ex),
|
||||||
|
)
|
||||||
|
return self.response(503, message=str(ex))
|
||||||
|
except ReportScheduleExecuteNowFailedError as ex:
|
||||||
|
logger.error(
|
||||||
|
"Error executing report schedule %s: %s",
|
||||||
|
self.__class__.__name__,
|
||||||
|
str(ex),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return self.response_422(message=str(ex))
|
||||||
|
|||||||
@@ -413,3 +413,15 @@ class SlackChannelSchema(Schema):
|
|||||||
name = fields.String()
|
name = fields.String()
|
||||||
is_member = fields.Boolean()
|
is_member = fields.Boolean()
|
||||||
is_private = fields.Boolean()
|
is_private = fields.Boolean()
|
||||||
|
|
||||||
|
|
||||||
|
class ReportScheduleExecuteResponseSchema(Schema):
|
||||||
|
"""
|
||||||
|
Schema for the response when executing a report schedule immediately.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unknown = EXCLUDE
|
||||||
|
|
||||||
|
execution_id = fields.String(description="UUID to track the execution status")
|
||||||
|
message = fields.String(description="Success message")
|
||||||
|
|||||||
@@ -2049,3 +2049,94 @@ class TestReportSchedulesApi(SupersetTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert json.loads(report_schedule.extra_json) == extra_json
|
assert json.loads(report_schedule.extra_json) == extra_json
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("create_report_schedules")
|
||||||
|
@patch("superset.tasks.scheduler.execute.apply_async")
|
||||||
|
def test_execute_report_schedule(self, mock_execute):
|
||||||
|
"""
|
||||||
|
ReportSchedule Api: Test execute report schedule
|
||||||
|
"""
|
||||||
|
report_schedule = (
|
||||||
|
db.session.query(ReportSchedule)
|
||||||
|
.filter(ReportSchedule.name == "name1")
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.login(ADMIN_USERNAME)
|
||||||
|
uri = f"api/v1/report/{report_schedule.id}/execute"
|
||||||
|
rv = self.client.post(uri)
|
||||||
|
assert rv.status_code == 200
|
||||||
|
data = json.loads(rv.data.decode("utf-8"))
|
||||||
|
assert "execution_id" in data
|
||||||
|
assert "message" in data
|
||||||
|
assert data["message"] == "Report schedule execution started successfully"
|
||||||
|
|
||||||
|
# Verify the task was called
|
||||||
|
mock_execute.assert_called_once()
|
||||||
|
# Verify that the task was called with the correct report_schedule_id and eta
|
||||||
|
call_args = mock_execute.call_args
|
||||||
|
assert call_args[0][0] == (report_schedule.id,)
|
||||||
|
# Check that eta was set for manual execution
|
||||||
|
assert "eta" in call_args[1]
|
||||||
|
assert call_args[1]["eta"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("create_report_schedules")
|
||||||
|
def test_execute_report_schedule_not_found(self):
|
||||||
|
"""
|
||||||
|
ReportSchedule Api: Test execute report schedule not found
|
||||||
|
"""
|
||||||
|
self.login(ADMIN_USERNAME)
|
||||||
|
uri = "api/v1/report/9999999/execute"
|
||||||
|
rv = self.client.post(uri)
|
||||||
|
assert rv.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("create_report_schedules")
|
||||||
|
def test_execute_report_schedule_not_owned(self):
|
||||||
|
"""
|
||||||
|
ReportSchedule Api: Test execute report schedule not owned
|
||||||
|
"""
|
||||||
|
report_schedule = (
|
||||||
|
db.session.query(ReportSchedule)
|
||||||
|
.filter(ReportSchedule.name == "name1")
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.login(GAMMA_USERNAME)
|
||||||
|
uri = f"api/v1/report/{report_schedule.id}/execute"
|
||||||
|
rv = self.client.post(uri)
|
||||||
|
assert rv.status_code == 403
|
||||||
|
|
||||||
|
def test_execute_report_schedule_disabled(self):
|
||||||
|
"""
|
||||||
|
ReportSchedule Api: Test execute report schedule 404s when feature is disabled
|
||||||
|
"""
|
||||||
|
self.login(ADMIN_USERNAME)
|
||||||
|
with patch("superset.is_feature_enabled", return_value=False):
|
||||||
|
uri = "api/v1/report/1/execute"
|
||||||
|
rv = self.client.post(uri)
|
||||||
|
assert rv.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("create_report_schedules")
|
||||||
|
@patch("superset.tasks.scheduler.execute.apply_async")
|
||||||
|
def test_execute_report_schedule_celery_error(self, mock_execute):
|
||||||
|
"""
|
||||||
|
ReportSchedule Api: Test execute report schedule with Celery backend error
|
||||||
|
"""
|
||||||
|
# Simulate Celery backend not configured
|
||||||
|
mock_execute.side_effect = Exception(
|
||||||
|
"kombu.exceptions.ConnectionError: broker connection"
|
||||||
|
)
|
||||||
|
|
||||||
|
report_schedule = (
|
||||||
|
db.session.query(ReportSchedule)
|
||||||
|
.filter(ReportSchedule.name == "name1")
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.login(ADMIN_USERNAME)
|
||||||
|
uri = f"api/v1/report/{report_schedule.id}/execute"
|
||||||
|
rv = self.client.post(uri)
|
||||||
|
assert rv.status_code == 503
|
||||||
|
data = json.loads(rv.data.decode("utf-8"))
|
||||||
|
assert "Celery backend" in data["message"]
|
||||||
|
assert "broker" in data["message"].lower()
|
||||||
|
|||||||
Reference in New Issue
Block a user