This is an automated email from the ASF dual-hosted git repository. maximebeauchemin pushed a commit to branch fire-alert in repository https://gitbox.apache.org/repos/asf/superset.git
commit dcbfbed68452e9dedb0d0b5ce9ed038750360c22 Author: Maxime Beauchemin <[email protected]> AuthorDate: Wed Sep 10 00:02:55 2025 -0700 feat: Add "Trigger Now" functionality for Alerts & Reports Add manual execution capability to Alerts & Reports CRUD interface with immediate trigger functionality. ## Backend Implementation - 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 ## Frontend Implementation - 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`) ## Testing & Error Handling - 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 ## Key Features - 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 <[email protected]> --- superset-frontend/package-lock.json | 5 +- .../src/components/Modal/ModalFormField.tsx | 2 +- .../alerts/hooks/useExecuteReportSchedule.test.ts | 121 +++++++++++++++++ .../alerts/hooks/useExecuteReportSchedule.ts | 86 ++++++++++++ .../src/pages/AlertReportList/index.tsx | 69 +++++++++- superset/commands/report/exceptions.py | 12 ++ superset/commands/report/execute_now.py | 147 +++++++++++++++++++++ superset/reports/api.py | 79 +++++++++++ superset/reports/schemas.py | 12 ++ tests/integration_tests/reports/api_tests.py | 85 ++++++++++++ 10 files changed, 614 insertions(+), 4 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index d2f9ca5b31..d4c0646fbb 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -60642,7 +60642,7 @@ }, "packages/superset-core": { "name": "@apache-superset/core", - "version": "0.0.1-rc2", + "version": "0.0.1-rc3", "license": "ISC", "devDependencies": { "@babel/cli": "^7.26.4", @@ -60652,7 +60652,8 @@ "@babel/preset-typescript": "^7.26.0", "@types/react": "^17.0.83", "install": "^0.13.0", - "npm": "^11.1.0" + "npm": "^11.1.0", + "typescript": "^5.0.0" }, "peerDependencies": { "antd": "^5.24.6", diff --git a/superset-frontend/src/components/Modal/ModalFormField.tsx b/superset-frontend/src/components/Modal/ModalFormField.tsx index db2293a4e5..95f8f87dcd 100644 --- a/superset-frontend/src/components/Modal/ModalFormField.tsx +++ b/superset-frontend/src/components/Modal/ModalFormField.tsx @@ -48,7 +48,7 @@ const StyledFieldContainer = styled.div<{ bottomSpacing: boolean }>` .required { margin-left: ${theme.sizeUnit / 2}px; - color: ${theme.colorError}; + color: ${theme.colorIcon}; } .helper { diff --git a/superset-frontend/src/features/alerts/hooks/useExecuteReportSchedule.test.ts b/superset-frontend/src/features/alerts/hooks/useExecuteReportSchedule.test.ts new file mode 100644 index 0000000000..33b61e8711 --- /dev/null +++ b/superset-frontend/src/features/alerts/hooks/useExecuteReportSchedule.test.ts @@ -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); +}); diff --git a/superset-frontend/src/features/alerts/hooks/useExecuteReportSchedule.ts b/superset-frontend/src/features/alerts/hooks/useExecuteReportSchedule.ts new file mode 100644 index 0000000000..e980442af4 --- /dev/null +++ b/superset-frontend/src/features/alerts/hooks/useExecuteReportSchedule.ts @@ -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, + }; +} diff --git a/superset-frontend/src/pages/AlertReportList/index.tsx b/superset-frontend/src/pages/AlertReportList/index.tsx index c77433e1f5..ffc2a71125 100644 --- a/superset-frontend/src/pages/AlertReportList/index.tsx +++ b/superset-frontend/src/pages/AlertReportList/index.tsx @@ -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'] = []; diff --git a/superset/commands/report/exceptions.py b/superset/commands/report/exceptions.py index 8688627810..4fa575ee88 100644 --- a/superset/commands/report/exceptions.py +++ b/superset/commands/report/exceptions.py @@ -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." + ) diff --git a/superset/commands/report/execute_now.py b/superset/commands/report/execute_now.py new file mode 100644 index 0000000000..9491e96f48 --- /dev/null +++ b/superset/commands/report/execute_now.py @@ -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 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 = {"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 diff --git a/superset/reports/api.py b/superset/reports/api.py index a0ebabb202..f88368a2fb 100644 --- a/superset/reports/api.py +++ b/superset/reports/api.py @@ -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)) diff --git a/superset/reports/schemas.py b/superset/reports/schemas.py index cfccc579bc..bc03fc543f 100644 --- a/superset/reports/schemas.py +++ b/superset/reports/schemas.py @@ -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") diff --git a/tests/integration_tests/reports/api_tests.py b/tests/integration_tests/reports/api_tests.py index 58bde34ac7..2b33762857 100644 --- a/tests/integration_tests/reports/api_tests.py +++ b/tests/integration_tests/reports/api_tests.py @@ -2049,3 +2049,88 @@ 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() + + @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()
