Compare commits

...

7 Commits

Author SHA1 Message Date
Maxime Beauchemin
58a2eb465a fix tests 2025-09-21 13:57:10 -07:00
Maxime Beauchemin
3c51194bb2 feat: Add 1600px default for screenshot width
Set sensible default of 1600px for custom_width field in AlertReportModal,
matching Superset's dashboard screenshot configuration.

Before: Empty field required manual input
After: Sensible 1600px default matching dashboard width settings

This improves user experience by providing a reasonable starting point
for custom screenshot dimensions instead of requiring manual input.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 11:30:42 -07:00
Maxime Beauchemin
19be7020c7 fix: Correct validation section for Database and SQL fields
Fix dynamic asterisk coloring by using the correct validation section:
- Database field: Use Sections.Alert instead of Sections.Content
- SQL field: Use Sections.Alert instead of Sections.Content
- Fix SQL condition: !currentAlert?.sql?.length (matches actual validation)

The validateAlertSection() function validates both Database and SQL fields,
so error props must check validationStatus[Sections.Alert]?.hasErrors.

Now asterisks should properly turn red when validation fails!

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 11:30:42 -07:00
Maxime Beauchemin
7c9794cc2f refactor: Migrate key AlertReportModal fields to ModalFormField pattern
Migrate critical form fields in AlertReportModal to use the modern ModalFormField pattern
for consistent styling and dynamic validation feedback:

- Database field (Sections.Content validation)
- SQL Query field (Sections.Content validation)
- Trigger Alert If... condition (Sections.Alert validation)
- Value threshold (Sections.Alert validation)
- Timezone (Sections.Schedule validation)
- Log retention (Sections.Schedule validation)
- Working timeout/Grace period (Sections.Schedule validation)

Benefits:
- Dynamic asterisk coloring: subtle by default, red on validation errors
- Consistent form field styling across all Superset modals
- Better accessibility and user experience
- Proper tooltip integration in ModalFormField
- Cleaner code by removing custom StyledInputContainer usage
- Removed unused imports and CSS for cleaner codebase

This continues the migration from custom form styling to the
standard ModalFormField pattern used by Chart/Dashboard properties modals.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 11:30:40 -07:00
Maxime Beauchemin
7a6b084ff7 fix: Consistent asterisk styling in AlertReportModal
Update custom .required CSS in AlertReportModal to use theme.colorIcon
for consistency with the shared ModalFormField component.

AlertReportModal was using its own .required CSS that overrode the
dynamic validation-based styling, causing inconsistent asterisk colors
across different modals.

Now all required field asterisks use the same subtle theme.colorIcon
color for a consistent, professional appearance.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 11:30:00 -07:00
Maxime Beauchemin
17eeeaccac feat: Dynamic asterisk coloring for form field validation
Improve form field UX by making required asterisks (*) context-aware:
- Default: subtle theme.colorIcon for clean appearance
- Error state: theme.colorError when validation fails or error prop set

This affects all modals using ModalFormField across Superset:
- Alert & Report Modal
- Chart Properties Modal
- Dashboard Properties Modal
- Database Connection Modal
- All other modal forms

Users now get visual feedback that clearly distinguishes between
required fields and fields with validation errors.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 11:30:00 -07:00
Maxime Beauchemin
4dd1e80f4c feat: Add "Trigger Now" functionality for Alerts & Reports
Add manual execution capability to Alerts & Reports CRUD interface with immediate trigger functionality.

- Add `/api/v1/report/{id}/execute` REST endpoint with proper RBAC
- Create `ExecuteReportScheduleNowCommand` following existing Command patterns
- Use `security_manager.raise_for_ownership()` for consistent permission checking
- Reuse existing `AsyncExecuteReportScheduleCommand` via Celery for execution
- Add `ReportScheduleExecuteResponseSchema` for structured API responses
- Add `ReportScheduleCeleryNotConfiguredError` for helpful Celery setup guidance

- Add "Trigger Now" () button to AlertReportList actions column
- Create `useExecuteReportSchedule` hook for API integration
- Implement per-button loading states with `executingIds` Set tracking
- Add success/error toast notifications with clear messaging
- Gate feature behind existing edit permissions (`allowEdit`)

- Add 5 comprehensive backend tests covering success, 404, 403, Celery errors, and feature disabled
- Detect Celery backend configuration issues with helpful error messages
- All tests passing with proper mocking and security validation

- One-click manual execution from CRUD list view
- Smart loading states preventing double-clicks
- Professional "Trigger Now" terminology throughout
- Proper error handling for missing Celery backend
- Maintains all existing security and permission patterns

Resolves common user need for immediate alert/report execution without waiting for scheduled cron jobs.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 11:29:59 -07:00
11 changed files with 796 additions and 153 deletions

View File

@@ -184,8 +184,10 @@ services:
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
# configuring the dev-server to use the host.docker.internal to connect to the backend
superset: "http://superset:8088"
WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-0.0.0.0}"
WEBPACK_DEVSERVER_PORT: "${WEBPACK_DEVSERVER_PORT:-9000}"
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
command: ["/app/docker/docker-frontend.sh"]
env_file:

View File

@@ -33,8 +33,11 @@ interface ModalFormFieldProps {
hasFeedback?: boolean;
}
const StyledFieldContainer = styled.div<{ bottomSpacing: boolean }>`
${({ theme, bottomSpacing }) => css`
const StyledFieldContainer = styled.div<{
bottomSpacing: boolean;
hasError: boolean;
}>`
${({ theme, bottomSpacing, hasError }) => css`
flex: 1;
margin-top: 0px;
margin-bottom: ${bottomSpacing ? theme.sizeUnit * 4 : 0}px;
@@ -48,7 +51,7 @@ const StyledFieldContainer = styled.div<{ bottomSpacing: boolean }>`
.required {
margin-left: ${theme.sizeUnit / 2}px;
color: ${theme.colorError};
color: ${hasError ? theme.colorError : theme.colorIcon};
}
.helper {
@@ -128,8 +131,14 @@ export function ModalFormField({
validateStatus,
hasFeedback = false,
}: ModalFormFieldProps) {
const hasError = !!(error || validateStatus === 'error');
return (
<StyledFieldContainer bottomSpacing={bottomSpacing} data-test={testId}>
<StyledFieldContainer
bottomSpacing={bottomSpacing}
hasError={hasError}
data-test={testId}
>
<div className="control-label">
{label}
{tooltip && <InfoTooltip tooltip={tooltip} />}

View File

@@ -32,7 +32,6 @@ import {
FeatureFlag,
styled,
SupersetClient,
SupersetTheme,
t,
VizType,
useTheme,
@@ -41,7 +40,6 @@ import rison from 'rison';
import { useSingleViewResource } from 'src/views/CRUD/hooks';
import withToasts from 'src/components/MessageToasts/withToasts';
import Owner from 'src/types/Owner';
// import { Form as AntdForm } from 'src/components/Form';
import { propertyComparator } from '@superset-ui/core/components/Select/utils';
import {
AsyncSelect,
@@ -266,11 +264,6 @@ export const StyledInputContainer = styled.div`
text-align: left;
}
.required {
margin-left: ${theme.sizeUnit / 2}px;
color: ${theme.colorError};
}
.control-label {
margin-bottom: ${theme.sizeUnit * 2}px;
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';
interface NotificationMethodAddProps {
@@ -2042,6 +2031,15 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
<ModalFormField
label={isReport ? t('Report name') : t('Alert name')}
required
error={
validationStatus[Sections.General]?.hasErrors &&
!currentAlert?.name?.trim()
? t(
'%s name is required',
isReport ? t('Report') : t('Alert'),
)
: undefined
}
>
<Input
name="name"
@@ -2054,7 +2052,17 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
onChange={onInputChange}
/>
</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
ariaLabel={t('Owners')}
allowClear
@@ -2115,40 +2123,46 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
),
children: (
<div>
<StyledInputContainer>
<div className="control-label">
{t('Database')}
<span className="required">*</span>
</div>
<div className="input-container">
<AsyncSelect
ariaLabel={t('Database')}
name="source"
placeholder={t('Select database')}
value={
currentAlert?.database?.label &&
currentAlert?.database?.value
? {
value: currentAlert.database.value,
label: currentAlert.database.label,
}
: undefined
}
options={loadSourceOptions}
onChange={onSourceChange}
/>
</div>
</StyledInputContainer>
<StyledInputContainer>
<div className="control-label">
{t('SQL Query')}
<InfoTooltip
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>
<ModalFormField
label={t('Database')}
required
error={
validationStatus[Sections.Alert]?.hasErrors &&
!currentAlert?.database
? t('Database is required')
: undefined
}
>
<AsyncSelect
ariaLabel={t('Database')}
name="source"
placeholder={t('Select database')}
value={
currentAlert?.database?.label &&
currentAlert?.database?.value
? {
value: currentAlert.database.value,
label: currentAlert.database.label,
}
: undefined
}
options={loadSourceOptions}
onChange={onSourceChange}
/>
</ModalFormField>
<ModalFormField
label={t('SQL Query')}
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).',
)}
error={
validationStatus[Sections.Alert]?.hasErrors &&
!currentAlert?.sql?.length
? t('SQL Query is required')
: undefined
}
>
<TextAreaControl
name="sql"
language="sql"
@@ -2160,57 +2174,60 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
initialValue={resource?.sql}
key={currentAlert?.id}
/>
</StyledInputContainer>
</ModalFormField>
<div
className="inline-container wrap"
css={css`
gap: ${theme.sizeUnit}px;
`}
>
<StyledInputContainer css={noMarginBottom}>
<div className="control-label" css={inputSpacer}>
{t('Trigger Alert If...')}
<span className="required">*</span>
</div>
<div className="input-container">
<Select
ariaLabel={t('Condition')}
onChange={onConditionChange}
placeholder={t('Condition')}
value={
currentAlert?.validator_config_json?.op ||
undefined
}
options={CONDITIONS}
/>
</div>
</StyledInputContainer>
<StyledInputContainer css={noMarginBottom}>
<div className="control-label">
{t('Value')}{' '}
{!conditionNotNull && (
<span className="required">*</span>
)}
</div>
<div className="input-container">
<InputNumber
disabled={conditionNotNull}
type="number"
name="threshold"
value={
currentAlert?.validator_config_json
?.threshold !== undefined &&
!conditionNotNull
? currentAlert.validator_config_json
.threshold
: ''
}
min={0}
placeholder={t('Value')}
onChange={onThresholdChange}
/>
</div>
</StyledInputContainer>
<ModalFormField
label={t('Trigger Alert If...')}
required
error={
validationStatus[Sections.Alert]?.hasErrors &&
!currentAlert?.validator_config_json?.op
? t('Condition is required')
: undefined
}
>
<Select
ariaLabel={t('Condition')}
onChange={onConditionChange}
placeholder={t('Condition')}
value={
currentAlert?.validator_config_json?.op ||
undefined
}
options={CONDITIONS}
/>
</ModalFormField>
<ModalFormField
label={t('Value')}
required={!conditionNotNull}
error={
validationStatus[Sections.Alert]?.hasErrors &&
!conditionNotNull &&
!currentAlert?.validator_config_json?.threshold
? t('Value is required')
: undefined
}
>
<InputNumber
disabled={conditionNotNull}
type="number"
name="threshold"
value={
currentAlert?.validator_config_json
?.threshold !== undefined && !conditionNotNull
? currentAlert.validator_config_json.threshold
: ''
}
min={0}
placeholder={t('Value')}
onChange={onThresholdChange}
/>
</ModalFormField>
</div>
</div>
),
@@ -2468,7 +2485,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
<InputNumber
type="number"
name="custom_width"
value={currentAlert?.custom_width || undefined}
value={currentAlert?.custom_width || 1600}
min={600}
max={2400}
placeholder={t('Input custom width in pixels')}
@@ -2511,66 +2528,66 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
value={currentAlert?.crontab || ''}
onChange={newVal => updateAlertState('crontab', newVal)}
/>
<StyledInputContainer>
<div className="control-label">
{t('Timezone')} <span className="required">*</span>
</div>
<ModalFormField
label={t('Timezone')}
required
error={
validationStatus[Sections.Schedule]?.hasErrors &&
!currentAlert?.timezone
? t('Timezone is required')
: undefined
}
>
<TimezoneSelector
onTimezoneChange={onTimezoneChange}
timezone={currentAlert?.timezone}
minWidth="100%"
/>
</StyledInputContainer>
<StyledInputContainer>
<div className="control-label">
{t('Log retention')}
<span className="required">*</span>
</div>
<div className="input-container">
<Select
ariaLabel={t('Log retention')}
placeholder={t('Log retention')}
onChange={onLogRetentionChange}
value={currentAlert?.log_retention}
options={RETENTION_OPTIONS}
sortComparator={propertyComparator('value')}
/>
</div>
</StyledInputContainer>
<StyledInputContainer css={noMarginBottom}>
{isReport ? (
<>
<div className="control-label">
{t('Working timeout')}
<span className="required">*</span>
</div>
<div className="input-container">
<NumberInput
min={1}
name="working_timeout"
value={currentAlert?.working_timeout || ''}
placeholder={t('Time in seconds')}
onChange={onTimeoutVerifyChange}
timeUnit={t('seconds')}
/>
</div>
</>
) : (
<>
<div className="control-label">{t('Grace period')}</div>
<div className="input-container">
<NumberInput
min={1}
name="grace_period"
value={currentAlert?.grace_period || ''}
placeholder={t('Time in seconds')}
onChange={onTimeoutVerifyChange}
timeUnit={t('seconds')}
/>
</div>
</>
)}
</StyledInputContainer>
</ModalFormField>
<ModalFormField
label={t('Log retention')}
required
error={
validationStatus[Sections.Schedule]?.hasErrors &&
!currentAlert?.log_retention
? t('Log retention is required')
: undefined
}
>
<Select
ariaLabel={t('Log retention')}
placeholder={t('Log retention')}
onChange={onLogRetentionChange}
value={currentAlert?.log_retention}
options={RETENTION_OPTIONS}
sortComparator={propertyComparator('value')}
/>
</ModalFormField>
<ModalFormField
label={isReport ? t('Working timeout') : t('Grace period')}
required={isReport}
error={
validationStatus[Sections.Schedule]?.hasErrors &&
isReport &&
!currentAlert?.working_timeout
? t('Working timeout is required')
: undefined
}
bottomSpacing={false}
>
<NumberInput
min={1}
name={isReport ? 'working_timeout' : 'grace_period'}
value={
isReport
? currentAlert?.working_timeout || ''
: currentAlert?.grace_period || ''
}
placeholder={t('Time in seconds')}
onChange={onTimeoutVerifyChange}
timeUnit={t('seconds')}
/>
</ModalFormField>
</>
),
},

View File

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

View File

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

View File

@@ -59,6 +59,7 @@ import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
import Owner from 'src/types/Owner';
import AlertReportModal from 'src/features/alerts/AlertReportModal';
import { AlertObject, AlertState } from 'src/features/alerts/types';
import { useExecuteReportSchedule } from 'src/features/alerts/hooks/useExecuteReportSchedule';
import { QueryObjectColumns } from 'src/views/CRUD/types';
import { Icons } from '@superset-ui/core/components/Icons';
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
@@ -157,12 +158,16 @@ function AlertList({
addDangerToast,
);
// Execute hook for Fire Now functionality
const { executeReport } = useExecuteReportSchedule();
const [alertModalOpen, setAlertModalOpen] = useState<boolean>(false);
const [currentAlert, setCurrentAlert] = useState<Partial<AlertObject> | null>(
null,
);
const [currentAlertDeleting, setCurrentAlertDeleting] =
useState<AlertObject | null>(null);
const [executingIds, setExecutingIds] = useState<Set<number>>(new Set());
// Actions
function handleAlertEdit(alert: AlertObject | null) {
@@ -246,6 +251,51 @@ function AlertList({
[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(
() => [
{
@@ -397,6 +447,16 @@ function AlertList({
onClick: handleEdit,
}
: 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
? {
label: 'delete-action',
@@ -424,7 +484,14 @@ function AlertList({
id: QueryObjectColumns.ChangedBy,
},
],
[canDelete, canEdit, isReportEnabled, toggleActive],
[
canDelete,
canEdit,
isReportEnabled,
toggleActive,
executingIds,
handleExecuteReport,
],
);
const subMenuButtons: SubMenuProps['buttons'] = [];

View File

@@ -309,3 +309,15 @@ class ReportScheduleForbiddenError(ForbiddenError):
class ReportSchedulePruneLogError(CommandException):
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."
)

View 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

View File

@@ -29,13 +29,16 @@ from superset.charts.filters import ChartFilter
from superset.commands.report.create import CreateReportScheduleCommand
from superset.commands.report.delete import DeleteReportScheduleCommand
from superset.commands.report.exceptions import (
ReportScheduleCeleryNotConfiguredError,
ReportScheduleCreateFailedError,
ReportScheduleDeleteFailedError,
ReportScheduleExecuteNowFailedError,
ReportScheduleForbiddenError,
ReportScheduleInvalidError,
ReportScheduleNotFoundError,
ReportScheduleUpdateFailedError,
)
from superset.commands.report.execute_now import ExecuteReportScheduleNowCommand
from superset.commands.report.update import UpdateReportScheduleCommand
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
from superset.dashboards.filters import DashboardAccessFilter
@@ -48,6 +51,7 @@ from superset.reports.schemas import (
get_delete_ids_schema,
get_slack_channels_schema,
openapi_spec_methods_override,
ReportScheduleExecuteResponseSchema,
ReportSchedulePostSchema,
ReportSchedulePutSchema,
)
@@ -76,6 +80,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
RouteMethod.RELATED,
"bulk_delete",
"slack_channels", # not using RouteMethod since locally defined
"execute", # not using RouteMethod since locally defined
}
class_permission_name = "ReportSchedule"
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
@@ -588,3 +593,77 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
except SupersetException as ex:
logger.error("Error fetching slack channels %s", 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))

View File

@@ -413,3 +413,15 @@ class SlackChannelSchema(Schema):
name = fields.String()
is_member = 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")

View File

@@ -2049,3 +2049,94 @@ class TestReportSchedulesApi(SupersetTestCase):
)
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()