From 733ef3135accee5dbdbe3a37c31f58e8477b3a9a Mon Sep 17 00:00:00 2001 From: Sebastian Jingoi Date: Sat, 15 Nov 2025 21:48:49 -0500 Subject: [PATCH 01/16] added empty webhook option to frontend for alerts and reports --- .../src/features/alerts/AlertReportModal.tsx | 1 + .../alerts/components/NotificationMethod.tsx | 118 +++++++++--------- .../src/features/alerts/types.ts | 1 + .../src/features/reports/types.ts | 2 +- superset/reports/models.py | 1 + superset/views/base.py | 2 + 6 files changed, 66 insertions(+), 59 deletions(-) diff --git a/superset-frontend/src/features/alerts/AlertReportModal.tsx b/superset-frontend/src/features/alerts/AlertReportModal.tsx index 69ab65796d95..b5caf9c0bad1 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.tsx @@ -122,6 +122,7 @@ const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; const DEFAULT_NOTIFICATION_METHODS: NotificationMethodOption[] = [ NotificationMethodOption.Email, + NotificationMethodOption.Webhook, ]; const DEFAULT_NOTIFICATION_FORMAT = 'PNG'; const DEFAULT_EXTRA_DASHBOARD_OPTIONS: Extra = { diff --git a/superset-frontend/src/features/alerts/components/NotificationMethod.tsx b/superset-frontend/src/features/alerts/components/NotificationMethod.tsx index 228130b32e58..e451c3715dea 100644 --- a/superset-frontend/src/features/alerts/components/NotificationMethod.tsx +++ b/superset-frontend/src/features/alerts/components/NotificationMethod.tsx @@ -338,8 +338,8 @@ export const NotificationMethod: FunctionComponent = ({ ((!isFeatureEnabled(FeatureFlag.AlertReportSlackV2) || useSlackV1) && method === NotificationMethodOption.Slack) || - method === NotificationMethodOption.Email, - ) + method === NotificationMethodOption.Email || + method === NotificationMethodOption.Webhook) .map(method => ({ label: method === NotificationMethodOption.SlackV2 @@ -349,7 +349,7 @@ export const NotificationMethod: FunctionComponent = ({ })), [options, useSlackV1], ); - + if (!setting) { return null; } @@ -477,8 +477,8 @@ export const NotificationMethod: FunctionComponent = ({ {method !== undefined ? ( <>
- - {method === NotificationMethodOption.Email ? ( + {method === NotificationMethodOption.Email ? ( + <>
{TRANSLATIONS.EMAIL_SUBJECT_NAME} @@ -503,65 +503,67 @@ export const NotificationMethod: FunctionComponent = ({
)} - ) : null} -
+
+ ) : null}
- -
- {t( - '%s recipients', - method === NotificationMethodOption.SlackV2 - ? NotificationMethodOption.Slack - : method, - )} - * -
-
- {[ - NotificationMethodOption.Email, - NotificationMethodOption.Slack, - ].includes(method) ? ( - <> + {method !== NotificationMethodOption.Webhook ? + +
+ {t( + '%s recipients', + method === NotificationMethodOption.SlackV2 + ? NotificationMethodOption.Slack + : method, + )} + * +
+
+ {[ + NotificationMethodOption.Email, + NotificationMethodOption.Slack, + ].includes(method) ? ( + <> +
+ +
+
+
+ {t('Recipients are separated by "," or ";"')} +
+
+ + ) : ( + // for SlackV2
- + updateSlackOptions({ force: true })} + tooltipContent={t('Force refresh Slack channels list')} + disabled={isSlackChannelsLoading} />
-
-
- {t('Recipients are separated by "," or ";"')} -
-
- - ) : ( - // for SlackV2 -
- +
+
+
+
+ )} {method === NotificationMethodOption.Email && ( {/* Render "CC" input field if ccVisible is true */} From d51af8651094e37cb38f990c841e92de855d82d0 Mon Sep 17 00:00:00 2001 From: Sebastian Jingoi Date: Sat, 15 Nov 2025 23:10:43 -0500 Subject: [PATCH 03/16] added starter webhook class --- superset/reports/notifications/__init__.py | 1 + superset/reports/notifications/webhook.py | 49 ++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 superset/reports/notifications/webhook.py diff --git a/superset/reports/notifications/__init__.py b/superset/reports/notifications/__init__.py index 938e3934475c..8cf9ea5668e1 100644 --- a/superset/reports/notifications/__init__.py +++ b/superset/reports/notifications/__init__.py @@ -19,6 +19,7 @@ from superset.reports.notifications.email import EmailNotification # noqa: F401 from superset.reports.notifications.slack import SlackNotification # noqa: F401 from superset.reports.notifications.slackv2 import SlackV2Notification # noqa: F401 +from superset.reports.notifications.webhook import WebhookNotification # noqa: F401 def create_notification( diff --git a/superset/reports/notifications/webhook.py b/superset/reports/notifications/webhook.py new file mode 100644 index 000000000000..49320af5caad --- /dev/null +++ b/superset/reports/notifications/webhook.py @@ -0,0 +1,49 @@ +# 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 +import textwrap +from dataclasses import dataclass +from datetime import datetime +from email.utils import make_msgid, parseaddr +from typing import Any, Optional + +import nh3 +from flask import current_app +from flask_babel import gettext as __ +from pytz import timezone + +from superset import is_feature_enabled +from superset.exceptions import SupersetErrorsException +from superset.reports.models import ReportRecipientType +from superset.reports.notifications.base import BaseNotification +from superset.reports.notifications.exceptions import NotificationError +from superset.utils import json +from superset.utils.core import HeaderDataType, send_email_smtp +from superset.utils.decorators import statsd_gauge + +class WebhookNotification(BaseNotification): + """ + Sends a post request to a webhook url + """ + + type = ReportRecipientType.WEBHOOK + + @statsd_gauge("reports.webhook.send") + def send(self) -> None: + print("WEBHOOK SEND CALLED!!!!!", self._recipient) + pass \ No newline at end of file From 1494a81296ddd997123b16d2f2bc941ac0e81a02 Mon Sep 17 00:00:00 2001 From: Sebastian Jingoi Date: Mon, 17 Nov 2025 02:01:35 -0500 Subject: [PATCH 04/16] basic non-file info sent to webhook url --- superset/reports/notifications/webhook.py | 30 ++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/superset/reports/notifications/webhook.py b/superset/reports/notifications/webhook.py index 49320af5caad..3ea64dcad3ae 100644 --- a/superset/reports/notifications/webhook.py +++ b/superset/reports/notifications/webhook.py @@ -26,6 +26,7 @@ from flask import current_app from flask_babel import gettext as __ from pytz import timezone +import requests from superset import is_feature_enabled from superset.exceptions import SupersetErrorsException @@ -43,7 +44,34 @@ class WebhookNotification(BaseNotification): type = ReportRecipientType.WEBHOOK + def _get_webhook_url(self): + return json.loads(self._recipient.recipient_config_json)["target"] + + def _get_req_payload(self): + print("Header data: ", self._content.header_data) + header_content = { + "notification_format": self._content.header_data.get("nofitication_format"), + "notification_type": self._content.header_data.get("notification_type"), + "notification_source": self._content.header_data.get("notification_source"), + "chart_id": self._content.header_data.get("chart_id"), + "dashboard_id": self._content.header_data.get("dashboard_id"), + } + content = { + "name": self._content.name, + "header": header_content, + "text": self._content.text, + "description:": self._content.description, + "url": self._content.url, + } + return content + @statsd_gauge("reports.webhook.send") def send(self) -> None: - print("WEBHOOK SEND CALLED!!!!!", self._recipient) + wh_url = self._get_webhook_url() + payload = self._get_req_payload() + + requests.post( + url=wh_url, + json=payload + ) pass \ No newline at end of file From c8064a8e842761c8cf9f2be77a22c27b375b4f98 Mon Sep 17 00:00:00 2001 From: Hriday Algh Date: Thu, 20 Nov 2025 21:03:29 -0500 Subject: [PATCH 05/16] feat: Add icon for Webhook notification method --- .../superset-ui-core/src/components/Icons/AntdEnhanced.tsx | 2 ++ .../src/features/alerts/components/RecipientIcon.tsx | 6 ++++++ superset-frontend/src/features/alerts/types.ts | 1 + 3 files changed, 9 insertions(+) diff --git a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx index a079b54c16d3..6f9222470c92 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx @@ -149,6 +149,7 @@ import { CompressOutlined, HistoryOutlined, SlackOutlined, + ApiOutlined, } from '@ant-design/icons'; import { FC } from 'react'; import { IconType } from './types'; @@ -287,6 +288,7 @@ const AntdIcons = { CompressOutlined, HistoryOutlined, SlackOutlined, + ApiOutlined, } as const; type AntdIconNames = keyof typeof AntdIcons; diff --git a/superset-frontend/src/features/alerts/components/RecipientIcon.tsx b/superset-frontend/src/features/alerts/components/RecipientIcon.tsx index 9bb6ba2ba0ec..698363a6cfa7 100644 --- a/superset-frontend/src/features/alerts/components/RecipientIcon.tsx +++ b/superset-frontend/src/features/alerts/components/RecipientIcon.tsx @@ -52,6 +52,12 @@ export default function RecipientIcon({ type }: { type: string }) { ); recipientIconConfig.label = NotificationMethodOption.Slack; break; + case NotificationMethodOption.Webhook: + recipientIconConfig.icon = ( + + ); + recipientIconConfig.label = NotificationMethodOption.Webhook; + break; default: recipientIconConfig.icon = null; recipientIconConfig.label = ''; diff --git a/superset-frontend/src/features/alerts/types.ts b/superset-frontend/src/features/alerts/types.ts index 1dbf44f77ef2..8fa6ba688f00 100644 --- a/superset-frontend/src/features/alerts/types.ts +++ b/superset-frontend/src/features/alerts/types.ts @@ -174,6 +174,7 @@ export enum RecipientIconName { Email = 'Email', Slack = 'Slack', SlackV2 = 'SlackV2', + Webhook = 'Webhook', } export interface AlertsReportsConfig { ALERT_REPORTS_DEFAULT_WORKING_TIMEOUT: number; From f9e9f6e1f64b74d99a9eaa1209fb5c33f537cba2 Mon Sep 17 00:00:00 2001 From: Hriday Algh Date: Thu, 20 Nov 2025 21:29:34 -0500 Subject: [PATCH 06/16] feat: Add sending of data to webhook notification --- superset/reports/notifications/webhook.py | 78 ++++++++++++++++------- 1 file changed, 55 insertions(+), 23 deletions(-) diff --git a/superset/reports/notifications/webhook.py b/superset/reports/notifications/webhook.py index 3ea64dcad3ae..24099ffa2b74 100644 --- a/superset/reports/notifications/webhook.py +++ b/superset/reports/notifications/webhook.py @@ -16,27 +16,21 @@ # under the License. import logging -import textwrap -from dataclasses import dataclass -from datetime import datetime -from email.utils import make_msgid, parseaddr -from typing import Any, Optional +from typing import Any -import nh3 -from flask import current_app -from flask_babel import gettext as __ -from pytz import timezone +import backoff import requests - -from superset import is_feature_enabled -from superset.exceptions import SupersetErrorsException from superset.reports.models import ReportRecipientType from superset.reports.notifications.base import BaseNotification -from superset.reports.notifications.exceptions import NotificationError +from superset.reports.notifications.exceptions import ( + NotificationParamException, + NotificationUnprocessableException, +) from superset.utils import json -from superset.utils.core import HeaderDataType, send_email_smtp from superset.utils.decorators import statsd_gauge +logger = logging.getLogger(__name__) + class WebhookNotification(BaseNotification): """ Sends a post request to a webhook url @@ -48,9 +42,8 @@ def _get_webhook_url(self): return json.loads(self._recipient.recipient_config_json)["target"] def _get_req_payload(self): - print("Header data: ", self._content.header_data) header_content = { - "notification_format": self._content.header_data.get("nofitication_format"), + "notification_format": self._content.header_data.get("notification_format"), "notification_type": self._content.header_data.get("notification_type"), "notification_source": self._content.header_data.get("notification_source"), "chart_id": self._content.header_data.get("chart_id"), @@ -60,18 +53,57 @@ def _get_req_payload(self): "name": self._content.name, "header": header_content, "text": self._content.text, - "description:": self._content.description, + "description": self._content.description, "url": self._content.url, } return content + def _get_files(self) -> list[tuple[str, tuple[str, bytes, str]]]: + files = [] + if self._content.csv: + files.append(("files", ("report.csv", self._content.csv, "text/csv"))) + if self._content.pdf: + files.append(("files", ("report.pdf", self._content.pdf, "application/pdf"))) + if self._content.screenshots: + for i, screenshot in enumerate(self._content.screenshots): + files.append( + ( + "files", + (f"screenshot_{i}.png", screenshot, "image/png"), + ) + ) + return files + + @backoff.on_exception(backoff.expo, NotificationUnprocessableException, factor=10, base=2, max_tries=5) @statsd_gauge("reports.webhook.send") def send(self) -> None: wh_url = self._get_webhook_url() payload = self._get_req_payload() - - requests.post( - url=wh_url, - json=payload - ) - pass \ No newline at end of file + files = self._get_files() + + try: + if files: + data = {} + for key, value in payload.items(): + if isinstance(value, (dict, list)): + data[key] = json.dumps(value) + else: + data[key] = value + + response = requests.post(wh_url, data=data, files=files, timeout=60) + else: + response = requests.post(wh_url, json=payload, timeout=60) + + logger.info("Webhook sent to %s, status code: %s", wh_url, response.status_code) + + if response.status_code >= 500 or response.status_code == 429: + raise NotificationUnprocessableException( + f"Webhook failed with status code {response.status_code}: {response.text}" + ) + if response.status_code >= 400: + raise NotificationParamException( + f"Webhook failed with status code {response.status_code}: {response.text}" + ) + + except requests.exceptions.RequestException as ex: + raise NotificationUnprocessableException(str(ex)) from ex \ No newline at end of file From 8b13aded4aa774c7212a5d12ff446bcfd5343e38 Mon Sep 17 00:00:00 2001 From: Sebastian Jingoi Date: Sun, 23 Nov 2025 12:52:13 -0500 Subject: [PATCH 07/16] added webhook feature flag --- .../packages/superset-ui-core/src/utils/featureFlags.ts | 1 + .../features/alerts/components/NotificationMethod.tsx | 9 +++++---- superset/config.py | 1 + superset/reports/notifications/webhook.py | 6 ++++++ 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts index fb3ec30d8c53..f5e38905ad57 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts @@ -26,6 +26,7 @@ export enum FeatureFlag { AlertReports = 'ALERT_REPORTS', AlertReportTabs = 'ALERT_REPORT_TABS', AlertReportSlackV2 = 'ALERT_REPORT_SLACK_V2', + AlertReportWebhook = 'ALERT_REPORT_WEBHOOK', AlertReportsFilter = 'ALERT_REPORTS_FILTER', AllowFullCsvExport = 'ALLOW_FULL_CSV_EXPORT', AvoidColorsCollision = 'AVOID_COLORS_COLLISION', diff --git a/superset-frontend/src/features/alerts/components/NotificationMethod.tsx b/superset-frontend/src/features/alerts/components/NotificationMethod.tsx index 39d33990e5bd..2ae77b55a1b8 100644 --- a/superset-frontend/src/features/alerts/components/NotificationMethod.tsx +++ b/superset-frontend/src/features/alerts/components/NotificationMethod.tsx @@ -326,7 +326,7 @@ export const NotificationMethod: FunctionComponent = ({ updateSlackOptions(); } }, []); - + const methodOptions = useMemo( () => (options || []) @@ -338,9 +338,10 @@ export const NotificationMethod: FunctionComponent = ({ ((!isFeatureEnabled(FeatureFlag.AlertReportSlackV2) || useSlackV1) && method === NotificationMethodOption.Slack) || - method === NotificationMethodOption.Email || - method === NotificationMethodOption.Webhook) - .map(method => ({ + (isFeatureEnabled(FeatureFlag.AlertReportWebhook) && + method === NotificationMethodOption.Webhook) || + method === NotificationMethodOption.Email + ).map(method => ({ label: method === NotificationMethodOption.SlackV2 ? NotificationMethodOption.Slack diff --git a/superset/config.py b/superset/config.py index 4912fc4d954e..df8405e484f4 100644 --- a/superset/config.py +++ b/superset/config.py @@ -538,6 +538,7 @@ class D3TimeFormat(TypedDict, total=False): "ALERT_REPORT_TABS": False, "ALERT_REPORTS_FILTER": False, "ALERT_REPORT_SLACK_V2": False, + "ALERT_REPORT_WEBHOOK": False, "DASHBOARD_RBAC": False, "ENABLE_ADVANCED_DATA_TYPES": False, # Enabling ALERTS_ATTACH_REPORTS, the system sends email and slack message diff --git a/superset/reports/notifications/webhook.py b/superset/reports/notifications/webhook.py index 24099ffa2b74..decc5e06d5e7 100644 --- a/superset/reports/notifications/webhook.py +++ b/superset/reports/notifications/webhook.py @@ -20,6 +20,8 @@ import backoff import requests + +from superset import feature_flag_manager from superset.reports.models import ReportRecipientType from superset.reports.notifications.base import BaseNotification from superset.reports.notifications.exceptions import ( @@ -77,6 +79,10 @@ def _get_files(self) -> list[tuple[str, tuple[str, bytes, str]]]: @backoff.on_exception(backoff.expo, NotificationUnprocessableException, factor=10, base=2, max_tries=5) @statsd_gauge("reports.webhook.send") def send(self) -> None: + if not feature_flag_manager.is_feature_enabled("ALERT_REPORT_WEBHOOK"): + raise NotificationUnprocessableException( + "Attempted to send a Webhook notification but Webhook feature flag is not enabled." + ) wh_url = self._get_webhook_url() payload = self._get_req_payload() files = self._get_files() From 80880d8bedb7ba19e24f654f2fda1b697d881d84 Mon Sep 17 00:00:00 2001 From: Hriday Algh Date: Sun, 23 Nov 2025 16:04:01 -0500 Subject: [PATCH 08/16] Webhook: Add HTTPS enforcement via config --- superset/config.py | 2 ++ superset/reports/notifications/webhook.py | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/superset/config.py b/superset/config.py index df8405e484f4..2d02ae3ccd0f 100644 --- a/superset/config.py +++ b/superset/config.py @@ -1733,6 +1733,8 @@ def EMAIL_HEADER_MUTATOR( # pylint: disable=invalid-name,unused-argument # noq # You can also assign a function to the config that returns the expected integer ALERT_MINIMUM_INTERVAL = int(timedelta(minutes=0).total_seconds()) REPORT_MINIMUM_INTERVAL = int(timedelta(minutes=0).total_seconds()) +# Enforce HTTPS for webhook alerts/reports +ALERT_REPORTS_WEBHOOK_HTTPS_ONLY = False # A custom prefix to use on all Alerts & Reports emails EMAIL_REPORTS_SUBJECT_PREFIX = "[Report] " diff --git a/superset/reports/notifications/webhook.py b/superset/reports/notifications/webhook.py index decc5e06d5e7..9c427291c9c5 100644 --- a/superset/reports/notifications/webhook.py +++ b/superset/reports/notifications/webhook.py @@ -17,8 +17,10 @@ import logging from typing import Any +from urllib.parse import urlparse import backoff +from flask import current_app import requests from superset import feature_flag_manager @@ -84,6 +86,11 @@ def send(self) -> None: "Attempted to send a Webhook notification but Webhook feature flag is not enabled." ) wh_url = self._get_webhook_url() + if current_app.config["ALERT_REPORTS_WEBHOOK_HTTPS_ONLY"]: + if urlparse(wh_url).scheme.lower() != "https": + raise NotificationParamException( + "Webhook failed: HTTPS is required by config for webhook URLs." + ) payload = self._get_req_payload() files = self._get_files() From a191ec89785a7410b5a3757040417daa0d4daca2 Mon Sep 17 00:00:00 2001 From: Sebastian Jingoi Date: Tue, 25 Nov 2025 12:55:47 -0500 Subject: [PATCH 09/16] changed default for https enforcement --- superset/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset/config.py b/superset/config.py index 2d02ae3ccd0f..ae1f3e55f2dc 100644 --- a/superset/config.py +++ b/superset/config.py @@ -1734,7 +1734,7 @@ def EMAIL_HEADER_MUTATOR( # pylint: disable=invalid-name,unused-argument # noq ALERT_MINIMUM_INTERVAL = int(timedelta(minutes=0).total_seconds()) REPORT_MINIMUM_INTERVAL = int(timedelta(minutes=0).total_seconds()) # Enforce HTTPS for webhook alerts/reports -ALERT_REPORTS_WEBHOOK_HTTPS_ONLY = False +ALERT_REPORTS_WEBHOOK_HTTPS_ONLY = True # A custom prefix to use on all Alerts & Reports emails EMAIL_REPORTS_SUBJECT_PREFIX = "[Report] " From 7cbc1e3cfdc901537f719f86f0ae4284ccae0f47 Mon Sep 17 00:00:00 2001 From: Hriday Algh Date: Tue, 25 Nov 2025 19:39:03 -0500 Subject: [PATCH 10/16] Add type definitions to WebhookNotification methods --- superset/reports/notifications/webhook.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/superset/reports/notifications/webhook.py b/superset/reports/notifications/webhook.py index 9c427291c9c5..affc3df0a51a 100644 --- a/superset/reports/notifications/webhook.py +++ b/superset/reports/notifications/webhook.py @@ -42,10 +42,10 @@ class WebhookNotification(BaseNotification): type = ReportRecipientType.WEBHOOK - def _get_webhook_url(self): + def _get_webhook_url(self) -> str: return json.loads(self._recipient.recipient_config_json)["target"] - def _get_req_payload(self): + def _get_req_payload(self) -> dict[str, Any]: header_content = { "notification_format": self._content.header_data.get("notification_format"), "notification_type": self._content.header_data.get("notification_type"), From aab2ee4252386fbc9078ee04622735a9d4b62c71 Mon Sep 17 00:00:00 2001 From: Hriday Algh Date: Tue, 25 Nov 2025 19:42:45 -0500 Subject: [PATCH 11/16] Formatting --- superset/reports/notifications/webhook.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/superset/reports/notifications/webhook.py b/superset/reports/notifications/webhook.py index affc3df0a51a..01f9182795a8 100644 --- a/superset/reports/notifications/webhook.py +++ b/superset/reports/notifications/webhook.py @@ -35,6 +35,7 @@ logger = logging.getLogger(__name__) + class WebhookNotification(BaseNotification): """ Sends a post request to a webhook url @@ -67,7 +68,9 @@ def _get_files(self) -> list[tuple[str, tuple[str, bytes, str]]]: if self._content.csv: files.append(("files", ("report.csv", self._content.csv, "text/csv"))) if self._content.pdf: - files.append(("files", ("report.pdf", self._content.pdf, "application/pdf"))) + files.append( + ("files", ("report.pdf", self._content.pdf, "application/pdf")) + ) if self._content.screenshots: for i, screenshot in enumerate(self._content.screenshots): files.append( @@ -78,7 +81,9 @@ def _get_files(self) -> list[tuple[str, tuple[str, bytes, str]]]: ) return files - @backoff.on_exception(backoff.expo, NotificationUnprocessableException, factor=10, base=2, max_tries=5) + @backoff.on_exception( + backoff.expo, NotificationUnprocessableException, factor=10, base=2, max_tries=5 + ) @statsd_gauge("reports.webhook.send") def send(self) -> None: if not feature_flag_manager.is_feature_enabled("ALERT_REPORT_WEBHOOK"): @@ -102,12 +107,14 @@ def send(self) -> None: data[key] = json.dumps(value) else: data[key] = value - + response = requests.post(wh_url, data=data, files=files, timeout=60) else: response = requests.post(wh_url, json=payload, timeout=60) - - logger.info("Webhook sent to %s, status code: %s", wh_url, response.status_code) + + logger.info( + "Webhook sent to %s, status code: %s", wh_url, response.status_code + ) if response.status_code >= 500 or response.status_code == 429: raise NotificationUnprocessableException( @@ -119,4 +126,4 @@ def send(self) -> None: ) except requests.exceptions.RequestException as ex: - raise NotificationUnprocessableException(str(ex)) from ex \ No newline at end of file + raise NotificationUnprocessableException(str(ex)) from ex From d5cb3d35652fe71f3e2c56a0f1f792c6cc29e04d Mon Sep 17 00:00:00 2001 From: Hriday Algh Date: Tue, 25 Nov 2025 19:47:25 -0500 Subject: [PATCH 12/16] trailing whitespace --- .../alerts/components/NotificationMethod.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/superset-frontend/src/features/alerts/components/NotificationMethod.tsx b/superset-frontend/src/features/alerts/components/NotificationMethod.tsx index 2ae77b55a1b8..f9c3c75f65d4 100644 --- a/superset-frontend/src/features/alerts/components/NotificationMethod.tsx +++ b/superset-frontend/src/features/alerts/components/NotificationMethod.tsx @@ -326,7 +326,7 @@ export const NotificationMethod: FunctionComponent = ({ updateSlackOptions(); } }, []); - + const methodOptions = useMemo( () => (options || []) @@ -338,10 +338,11 @@ export const NotificationMethod: FunctionComponent = ({ ((!isFeatureEnabled(FeatureFlag.AlertReportSlackV2) || useSlackV1) && method === NotificationMethodOption.Slack) || - (isFeatureEnabled(FeatureFlag.AlertReportWebhook) && + (isFeatureEnabled(FeatureFlag.AlertReportWebhook) && method === NotificationMethodOption.Webhook) || - method === NotificationMethodOption.Email - ).map(method => ({ + method === NotificationMethodOption.Email, + ) + .map(method => ({ label: method === NotificationMethodOption.SlackV2 ? NotificationMethodOption.Slack @@ -350,13 +351,13 @@ export const NotificationMethod: FunctionComponent = ({ })), [options, useSlackV1], ); - + if (!setting) { return null; } const onRecipientsChange = ( - event: ChangeEvent + event: ChangeEvent, ) => { const { target } = event; From 1d40911fd7c509d416cf97a14c69f311264d7d9f Mon Sep 17 00:00:00 2001 From: Sebastian Jingoi Date: Sun, 30 Nov 2025 15:16:01 -0500 Subject: [PATCH 13/16] added webhook url tests --- superset/reports/notifications/webhook.py | 21 ++++- .../reports/notifications/webhook_tests.py | 91 +++++++++++++++++++ 2 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 tests/unit_tests/reports/notifications/webhook_tests.py diff --git a/superset/reports/notifications/webhook.py b/superset/reports/notifications/webhook.py index 01f9182795a8..4a9746dc5159 100644 --- a/superset/reports/notifications/webhook.py +++ b/superset/reports/notifications/webhook.py @@ -20,8 +20,8 @@ from urllib.parse import urlparse import backoff -from flask import current_app import requests +from flask import current_app from superset import feature_flag_manager from superset.reports.models import ReportRecipientType @@ -44,7 +44,15 @@ class WebhookNotification(BaseNotification): type = ReportRecipientType.WEBHOOK def _get_webhook_url(self) -> str: - return json.loads(self._recipient.recipient_config_json)["target"] + """ + Get the webhook URL from the recipient configuration + :returns: The webhook URL + :raises NotificationParamException: If the webhook URL is not provided in the recipient configuration + """ # noqa: E501 + try: + return json.loads(self._recipient.recipient_config_json)["target"] + except (json.JSONDecodeError, KeyError) as ex: + raise NotificationParamException("Webhook URL is required") from ex def _get_req_payload(self) -> dict[str, Any]: header_content = { @@ -88,7 +96,8 @@ def _get_files(self) -> list[tuple[str, tuple[str, bytes, str]]]: def send(self) -> None: if not feature_flag_manager.is_feature_enabled("ALERT_REPORT_WEBHOOK"): raise NotificationUnprocessableException( - "Attempted to send a Webhook notification but Webhook feature flag is not enabled." + "Attempted to send a Webhook notification but Webhook feature flag \ + is not enabled." ) wh_url = self._get_webhook_url() if current_app.config["ALERT_REPORTS_WEBHOOK_HTTPS_ONLY"]: @@ -118,11 +127,13 @@ def send(self) -> None: if response.status_code >= 500 or response.status_code == 429: raise NotificationUnprocessableException( - f"Webhook failed with status code {response.status_code}: {response.text}" + f"Webhook failed with status code {response.status_code}: \ + {response.text}" ) if response.status_code >= 400: raise NotificationParamException( - f"Webhook failed with status code {response.status_code}: {response.text}" + f"Webhook failed with status code {response.status_code}: \ + {response.text}" ) except requests.exceptions.RequestException as ex: diff --git a/tests/unit_tests/reports/notifications/webhook_tests.py b/tests/unit_tests/reports/notifications/webhook_tests.py new file mode 100644 index 000000000000..6f9abed29788 --- /dev/null +++ b/tests/unit_tests/reports/notifications/webhook_tests.py @@ -0,0 +1,91 @@ +# 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 pandas as pd +import pytest + +from superset.reports.notifications.exceptions import ( + NotificationParamException, +) +from superset.reports.notifications.webhook import WebhookNotification +from superset.utils.core import HeaderDataType + + +@pytest.fixture +def mock_header_data() -> HeaderDataType: + return { + "notification_format": "PNG", + "notification_type": "Alert", + "owners": [1], + "notification_source": None, + "chart_id": None, + "dashboard_id": None, + "slack_channels": None, + "execution_id": "test-execution-id", + } + + +def test_get_webhook_url(mock_header_data) -> None: + """ + Test the _get_webhook_url function to ensure it correctly extracts + the webhook URL from recipient configuration + """ + from superset.reports.models import ReportRecipients, ReportRecipientType + from superset.reports.notifications.base import NotificationContent + + content = NotificationContent( + name="test alert", + header_data=mock_header_data, + embedded_data=pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}), + description="Test description", + ) + webhook_notification = WebhookNotification( + recipient=ReportRecipients( + type=ReportRecipientType.WEBHOOK, + recipient_config_json='{"target": "https://example.com/webhook"}', + ), + content=content, + ) + + result = webhook_notification._get_webhook_url() + + assert result == "https://example.com/webhook" + + +def test_get_webhook_url_missing_url(mock_header_data) -> None: + """ + Test that _get_webhook_url raises an exception when URL is missing + """ + from superset.reports.models import ReportRecipients, ReportRecipientType + from superset.reports.notifications.base import NotificationContent + + content = NotificationContent( + name="test alert", + header_data=mock_header_data, + description="Test description", + ) + webhook_notification = WebhookNotification( + recipient=ReportRecipients( + type=ReportRecipientType.WEBHOOK, + recipient_config_json="{}", + ), + content=content, + ) + + with pytest.raises(NotificationParamException, match="Webhook URL is required"): + webhook_notification._get_webhook_url() From 7c14cbbfdb6ce56059fe02c50af9d83841867ea7 Mon Sep 17 00:00:00 2001 From: Sebastian Jingoi Date: Sun, 30 Nov 2025 15:27:45 -0500 Subject: [PATCH 14/16] more webhook notification tests --- .../reports/notifications/webhook_tests.py | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/tests/unit_tests/reports/notifications/webhook_tests.py b/tests/unit_tests/reports/notifications/webhook_tests.py index 6f9abed29788..271ce2856781 100644 --- a/tests/unit_tests/reports/notifications/webhook_tests.py +++ b/tests/unit_tests/reports/notifications/webhook_tests.py @@ -89,3 +89,137 @@ def test_get_webhook_url_missing_url(mock_header_data) -> None: with pytest.raises(NotificationParamException, match="Webhook URL is required"): webhook_notification._get_webhook_url() + + +def test_get_req_payload_basic(mock_header_data) -> None: + """ + Test that _get_req_payload returns correct payload structure + """ + from superset.reports.models import ReportRecipients, ReportRecipientType + from superset.reports.notifications.base import NotificationContent + + content = NotificationContent( + name="Payload Name", + header_data=mock_header_data, + embedded_data=None, + description="Payload Description", + url="http://example.com/report", + text="Report Text", + ) + webhook_notification = WebhookNotification( + recipient=ReportRecipients( + type=ReportRecipientType.WEBHOOK, + recipient_config_json='{"target": "https://webhook.com"}', + ), + content=content, + ) + + payload = webhook_notification._get_req_payload() + + assert payload["name"] == "Payload Name" + assert payload["description"] == "Payload Description" + assert payload["url"] == "http://example.com/report" + assert payload["text"] == "Report Text" + assert isinstance(payload["header"], dict) + # Optional fields from header_data + assert payload["header"]["notification_format"] == "PNG" + assert payload["header"]["notification_type"] == "Alert" + + +def test_get_files_includes_all_content_types(mock_header_data) -> None: + """ + Test that _get_files correctly includes csv, pdf, and multiple screenshot attachments + """ # noqa: E501 + + from superset.reports.models import ReportRecipients, ReportRecipientType + from superset.reports.notifications.base import NotificationContent + + csv_bytes = b"col1,col2\n1,2" + pdf_bytes = b"%PDF-1.4" + screenshots = [b"fakeimg1", b"fakeimg2"] + + content = NotificationContent( + name="file test", + header_data=mock_header_data, + csv=csv_bytes, + pdf=pdf_bytes, + screenshots=screenshots, + description="files test", + ) + webhook_notification = WebhookNotification( + recipient=ReportRecipients( + type=ReportRecipientType.WEBHOOK, + recipient_config_json='{"target": "https://webhook.com"}', + ), + content=content, + ) + files = webhook_notification._get_files() + # There should be 1 csv, 1 pdf, and 2 screenshots = 4 files total + assert len(files) == 4 + + file_names = [file_info[1][0] for file_info in files] + assert "report.csv" in file_names + assert "report.pdf" in file_names + assert "screenshot_0.png" in file_names + assert "screenshot_1.png" in file_names + + mime_types = [file_info[1][2] for file_info in files] + assert "text/csv" in mime_types + assert "application/pdf" in mime_types + assert mime_types.count("image/png") == 2 + + +def test_get_files_empty_when_no_content(mock_header_data) -> None: + """ + Test that _get_files returns empty list when no files present + """ + from superset.reports.models import ReportRecipients, ReportRecipientType + from superset.reports.notifications.base import NotificationContent + + content = NotificationContent( + name="no files", + header_data=mock_header_data, + description="no files test", + ) + webhook_notification = WebhookNotification( + recipient=ReportRecipients( + type=ReportRecipientType.WEBHOOK, + recipient_config_json='{"target": "https://webhook.com"}', + ), + content=content, + ) + files = webhook_notification._get_files() + assert files == [] + + +def test_send_http_only_https_check(monkeypatch, mock_header_data) -> None: + """ + Test send raises when URL is not HTTPS and config enforces HTTPS only + """ + from superset.reports.models import ReportRecipients, ReportRecipientType + from superset.reports.notifications.base import NotificationContent + + content = NotificationContent( + name="test alert", header_data=mock_header_data, description="Test description" + ) + webhook_notification = WebhookNotification( + recipient=ReportRecipients( + type=ReportRecipientType.WEBHOOK, + recipient_config_json='{"target": "http://notsecure.com/webhook"}', + ), + content=content, + ) + + class MockCurrentApp: + config = {"ALERT_REPORTS_WEBHOOK_HTTPS_ONLY": True} + + monkeypatch.setattr( + "superset.reports.notifications.webhook.current_app", MockCurrentApp + ) + monkeypatch.setattr( + "superset.reports.notifications.webhook.feature_flag_manager.is_feature_enabled", + lambda flag: True, + ) + + with pytest.raises(NotificationParamException, match="HTTPS is required by config"): + webhook_notification.send() From 26cede2f4f112523e8d6ad3a20ffb0c49ecc4920 Mon Sep 17 00:00:00 2001 From: Hriday Algh Date: Wed, 17 Dec 2025 19:04:01 -0500 Subject: [PATCH 15/16] Formatting --- superset-frontend/src/features/alerts/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/src/features/alerts/types.ts b/superset-frontend/src/features/alerts/types.ts index 8fa6ba688f00..0ea83f8d2347 100644 --- a/superset-frontend/src/features/alerts/types.ts +++ b/superset-frontend/src/features/alerts/types.ts @@ -45,7 +45,7 @@ export enum NotificationMethodOption { Email = 'Email', Slack = 'Slack', SlackV2 = 'SlackV2', - Webhook = 'Webhook' + Webhook = 'Webhook', } export type SelectValue = { From 363194eed06251164cd5c1240c78c8928e76b1f3 Mon Sep 17 00:00:00 2001 From: Sebastian Jingoi Date: Wed, 17 Dec 2025 19:45:33 -0500 Subject: [PATCH 16/16] Update superset/reports/notifications/webhook.py Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com> --- superset/reports/notifications/webhook.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/superset/reports/notifications/webhook.py b/superset/reports/notifications/webhook.py index 4a9746dc5159..b549fca27b7e 100644 --- a/superset/reports/notifications/webhook.py +++ b/superset/reports/notifications/webhook.py @@ -50,8 +50,12 @@ def _get_webhook_url(self) -> str: :raises NotificationParamException: If the webhook URL is not provided in the recipient configuration """ # noqa: E501 try: - return json.loads(self._recipient.recipient_config_json)["target"] - except (json.JSONDecodeError, KeyError) as ex: + cfg = json.loads(self._recipient.recipient_config_json) + target = cfg.get("target") if isinstance(cfg, dict) else None + if not target: + raise NotificationParamException("Webhook URL is required") + return target + except (json.JSONDecodeError, KeyError, TypeError) as ex: raise NotificationParamException("Webhook URL is required") from ex def _get_req_payload(self) -> dict[str, Any]: