Skip to content

Commit

Permalink
add slackv2 notification
Browse files Browse the repository at this point in the history
  • Loading branch information
eschutho committed Jun 17, 2024
1 parent fc9bc17 commit d1bdaa5
Show file tree
Hide file tree
Showing 19 changed files with 673 additions and 141 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export enum FeatureFlag {
AlertsAttachReports = 'ALERTS_ATTACH_REPORTS',
AlertReports = 'ALERT_REPORTS',
AlertReportTabs = 'ALERT_REPORT_TABS',
AlertReportSlackV2 = 'ALERT_REPORT_SLACK_V2',
AllowFullCsvExport = 'ALLOW_FULL_CSV_EXPORT',
AvoidColorsCollision = 'AVOID_COLORS_COLLISION',
ChartPluginsExperimental = 'CHART_PLUGINS_EXPERIMENTAL',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jest.mock('@superset-ui/core', () => ({

jest.mock('src/features/databases/state.ts', () => ({
useCommonConf: () => ({
ALERT_REPORTS_NOTIFICATION_METHODS: ['Email', 'Slack'],
ALERT_REPORTS_NOTIFICATION_METHODS: ['Email', 'Slack', 'SlackV2'],
}),
}));

Expand Down
163 changes: 140 additions & 23 deletions superset-frontend/src/features/alerts/components/NotificationMethod.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,27 @@
* specific language governing permissions and limitations
* under the License.
*/
import { FunctionComponent, useState, ChangeEvent } from 'react';
import {
FunctionComponent,
useState,
ChangeEvent,
useEffect,
useMemo,
} from 'react';

import { styled, t, useTheme } from '@superset-ui/core';
import { Select } from 'src/components';
import {
FeatureFlag,
SupersetClient,
isFeatureEnabled,
styled,
t,
useTheme,
} from '@superset-ui/core';
import rison from 'rison';
import { AsyncSelect, Select } from 'src/components';
import Icons from 'src/components/Icons';
import { set } from 'lodash';
import { option } from 'yargs';
import { NotificationMethodOption, NotificationSetting } from '../types';
import { StyledInputContainer } from '../AlertReportModal';

Expand Down Expand Up @@ -87,20 +103,92 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
const [recipientValue, setRecipientValue] = useState<string>(
recipients || '',
);
const [slackRecipients, setSlackRecipients] = useState<
{ label: string; value: string }[]
>([]);
const [error, setError] = useState(false);
const theme = useTheme();

const [useSlackV1, setUseSlackV1] = useState<boolean>(false);

const mapChannelsToOptions = (result: { name: any; id: any }[]) =>
result.map((result: { name: any; id: any }) => ({
label: result.name,
value: result.id,
}));

const loadChannels = async (
search_string: string | undefined = '',
): Promise<{
data?: { label: any; value: any }[];
totalCount?: number;
}> => {
const query = rison.encode({ search_string });
const endpoint = `/api/v1/report/slack_channels?q=${query}`;
return SupersetClient.get({ endpoint })
.then(({ json }) => {
const { result, count } = json;

const options: { label: any; value: any }[] =
mapChannelsToOptions(result);

return {
data: options,
totalCount: (count ?? options.length) as number,
};
})
.catch(() => {
// Fallback to slack v1 if slack v2 is not compatible
setUseSlackV1(true);
return {};
});
};

useEffect(() => {
// fetch slack channel names from
// ids on first load
if (method && ['Slack', 'SlackV2'].includes(method)) {
loadChannels(recipients).then(response => {
setSlackRecipients(response.data || []);
onMethodChange({ label: 'Slack', value: 'SlackV2' });
});
}
}, []);

const formattedOptions = useMemo(
() =>
(options || [])
.filter(
method =>
(isFeatureEnabled(FeatureFlag.AlertReportSlackV2) &&
!useSlackV1 &&
method === 'SlackV2') ||
((!isFeatureEnabled(FeatureFlag.AlertReportSlackV2) ||
useSlackV1) &&
method === 'Slack') ||
method === 'Email',
)
.map(method => ({
label: method === 'SlackV2' ? 'Slack' : method,
value: method,
})),
[options],
);

if (!setting) {
return null;
}

const onMethodChange = (method: NotificationMethodOption) => {
const onMethodChange = (selected: {
label: string;
value: NotificationMethodOption;
}) => {
// Since we're swapping the method, reset the recipients
setRecipientValue('');
if (onUpdate) {
const updatedSetting = {
...setting,
method,
method: selected.value,
recipients: '',
};

Expand All @@ -123,6 +211,21 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
}
};

const onSlackRecipientsChange = (
recipients: { label: string; value: string }[],
) => {
setSlackRecipients(recipients);

if (onUpdate) {
const updatedSetting = {
...setting,
recipients: recipients?.map(obj => obj.value).join(','),
};

onUpdate(index, updatedSetting);
}
};

const onSubjectChange = (
event: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>,
) => {
Expand Down Expand Up @@ -153,15 +256,12 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
<Select
ariaLabel={t('Delivery method')}
data-test="select-delivery-method"
labelInValue
onChange={onMethodChange}
placeholder={t('Select Delivery Method')}
options={(options || []).map(
(method: NotificationMethodOption) => ({
label: method,
value: method,
}),
)}
value={method}
options={formattedOptions}
showSearch
value={formattedOptions.find(option => option.value === method)}
/>
{index !== 0 && !!onRemove ? (
<span
Expand Down Expand Up @@ -211,19 +311,36 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
<div className="inline-container">
<StyledInputContainer>
<div className="control-label">
{t('%s recipients', method)}
{t('%s recipients', method === 'SlackV2' ? 'Slack' : method)}
<span className="required">*</span>
</div>
<div className="input-container">
<textarea
name="recipients"
data-test="recipients"
value={recipientValue}
onChange={onRecipientsChange}
/>
</div>
<div className="helper">
{t('Recipients are separated by "," or ";"')}
<div>
{['Email', 'Slack'].includes(method) ? (
<>
<div className="input-container">
<textarea
name="recipients"
data-test="recipients"
value={recipientValue}
onChange={onRecipientsChange}
/>
</div>
<div className="helper">
{t('Recipients are separated by "," or ";"')}
</div>
</>
) : (
// for SlackV2
<AsyncSelect
ariaLabel={t('Select owners')}
mode="multiple"
name="owners"
value={slackRecipients}
options={loadChannels}
onChange={onSlackRecipientsChange}
allowClear
/>
)}
</div>
</StyledInputContainer>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export default function RecipientIcon({ type }: { type: string }) {
recipientIconConfig.icon = <Icons.Slack css={StyledIcon} />;
recipientIconConfig.label = RecipientIconName.Slack;
break;
case RecipientIconName.SlackV2:
recipientIconConfig.icon = <Icons.Slack css={StyledIcon} />;
recipientIconConfig.label = RecipientIconName.Slack;
break;
default:
recipientIconConfig.icon = null;
recipientIconConfig.label = '';
Expand Down
3 changes: 2 additions & 1 deletion superset-frontend/src/features/alerts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export type DatabaseObject = {
id: number;
};

export type NotificationMethodOption = 'Email' | 'Slack';
export type NotificationMethodOption = 'Email' | 'Slack' | 'SlackV2';

export type NotificationSetting = {
method?: NotificationMethodOption;
Expand Down Expand Up @@ -124,6 +124,7 @@ export enum AlertState {
export enum RecipientIconName {
Email = 'Email',
Slack = 'Slack',
SlackV2 = 'SlackV2',
}
export interface AlertsReportsConfig {
ALERT_REPORTS_DEFAULT_WORKING_TIMEOUT: number;
Expand Down
68 changes: 64 additions & 4 deletions superset/commands/report/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
import logging
from copy import deepcopy
from datetime import datetime, timedelta
from typing import Any, Optional, Union
from uuid import UUID
Expand All @@ -25,7 +26,7 @@
from superset import app, db, security_manager
from superset.commands.base import BaseCommand
from superset.commands.dashboard.permalink.create import CreateDashboardPermalinkCommand
from superset.commands.exceptions import CommandException
from superset.commands.exceptions import CommandException, UpdateFailedError
from superset.commands.report.alert import AlertCommand
from superset.commands.report.exceptions import (
ReportScheduleAlertGracePeriodError,
Expand Down Expand Up @@ -64,14 +65,18 @@
)
from superset.reports.notifications import create_notification
from superset.reports.notifications.base import NotificationContent
from superset.reports.notifications.exceptions import NotificationError
from superset.reports.notifications.exceptions import (
NotificationError,
SlackV1NotificationError,
)
from superset.tasks.utils import get_executor
from superset.utils import json
from superset.utils.core import HeaderDataType, override_user
from superset.utils.csv import get_chart_csv_data, get_chart_dataframe
from superset.utils.decorators import logs_context
from superset.utils.pdf import build_pdf_from_screenshots
from superset.utils.screenshots import ChartScreenshot, DashboardScreenshot
from superset.utils.slack import get_paginated_channels_with_search
from superset.utils.urls import get_url_path

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -122,6 +127,36 @@ def update_report_schedule(self, state: ReportState) -> None:
self._report_schedule.last_eval_dttm = datetime.utcnow()
db.session.commit()

def update_report_schedule_slack_v2(self) -> None:
"""
Update the report schedule type and channels for all slack recipients to v2.
V2 uses ids instead of names for channels.
"""
try:
updated_recipients = []
for recipient in self._report_schedule.recipients:
recipient_copy = deepcopy(recipient)
if recipient_copy.type == ReportRecipientType.SLACK:
recipient_copy.type = ReportRecipientType.SLACKV2
slack_recipients = json.loads(recipient_copy.recipient_config_json)
recipient_copy.recipient_config_json = json.dumps(
{
"target": get_paginated_channels_with_search(
slack_recipients["target"]
)
}
)

updated_recipients.append(recipient_copy)
logger.warning("recipient_copy is: %s", recipient_copy)
db.session.commit()
logger.warning("recipients updated to v2: %s", updated_recipients)
except Exception as ex:
logger.warning(
"Failed to update slack recipients to v2: %s", str(ex), exc_info=1
)
raise UpdateFailedError from ex

def create_log(self, error_message: Optional[str] = None) -> None:
"""
Creates a Report execution log, uses the current computed last_value for Alerts
Expand Down Expand Up @@ -440,6 +475,28 @@ def _send(
)
else:
notification.send()
except SlackV1NotificationError as ex:
# The slack api was tested and it failed
# The slack notification should be sent with the v2 api
logger.warning(
"Attempting to upgrade the report to Slackv2: %s", str(ex)
)
try:
self.update_report_schedule_slack_v2()
recipient.type = ReportRecipientType.SLACKV2
notification = create_notification(recipient, notification_content)
notification.send()
except UpdateFailedError as ex:
# log the error but keep processing
# it will keep trying as it runs
# and then during a majore version we will run a migration and update it again
# and remove the old slack version code
logger.warning(
"Failed to update slack recipients to v2: %s", str(ex)
)
except (NotificationError, SupersetException) as ex:
# if the send command errors, catch it in the next block
raise ex
except (NotificationError, SupersetException) as ex:
# collect errors but keep processing them
notification_errors.append(
Expand Down Expand Up @@ -478,9 +535,10 @@ def send_error(self, name: str, message: str) -> None:
"""
header_data = self._get_log_data()
logger.info(
"header_data in notifications for alerts and reports %s, taskid, %s",
"header_data in notifications for alerts and reports %s, taskid, %s, recipients %s",
header_data,
self._execution_id,
json.loads(self._report_schedule.recipients.recipient_config_json),
)
notification_content = NotificationContent(
name=name, text=message, header_data=header_data
Expand Down Expand Up @@ -750,7 +808,9 @@ def validate(self) -> None:
self._execution_id,
)
self._model = (
db.session.query(ReportSchedule).filter_by(id=self._model_id).one_or_none()
db.session.query(ReportSchedule)
.filter_by(id=self._model_id[0])
.one_or_none()
)
if not self._model:
raise ReportScheduleNotFoundError()
1 change: 1 addition & 0 deletions superset/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ class D3Format(TypedDict, total=False):
# Enables Alerts and reports new implementation
"ALERT_REPORTS": False,
"ALERT_REPORT_TABS": False,
"ALERT_REPORTS_SLACK_V2": False,
"DASHBOARD_RBAC": False,
"ENABLE_ADVANCED_DATA_TYPES": False,
# Enabling ALERTS_ATTACH_REPORTS, the system sends email and slack message
Expand Down
Loading

0 comments on commit d1bdaa5

Please sign in to comment.