mirror of
https://github.com/apache/superset.git
synced 2026-05-04 07:24:18 +00:00
Compare commits
12 Commits
docs/testi
...
chore/cach
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89543ada1d | ||
|
|
3ef17d08b4 | ||
|
|
802c0aa399 | ||
|
|
afdecd8292 | ||
|
|
cdeb989dad | ||
|
|
23d2ce28fa | ||
|
|
14fbb5424e | ||
|
|
df5a027dcf | ||
|
|
6b5b2425ad | ||
|
|
a4b52c2437 | ||
|
|
41cf0612b4 | ||
|
|
5af66d85f3 |
@@ -52,6 +52,16 @@ const mockSetting: NotificationSetting = {
|
|||||||
const mockEmailSubject = 'Test Subject';
|
const mockEmailSubject = 'Test Subject';
|
||||||
const mockDefaultSubject = 'Default Subject';
|
const mockDefaultSubject = 'Default Subject';
|
||||||
|
|
||||||
|
const mockSettingSlackV2: NotificationSetting = {
|
||||||
|
method: NotificationMethodOption.SlackV2,
|
||||||
|
recipients: 'slack-channel',
|
||||||
|
options: [
|
||||||
|
NotificationMethodOption.Email,
|
||||||
|
NotificationMethodOption.Slack,
|
||||||
|
NotificationMethodOption.SlackV2,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
describe('NotificationMethod', () => {
|
describe('NotificationMethod', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
@@ -480,4 +490,71 @@ describe('NotificationMethod', () => {
|
|||||||
screen.getByText('Recipients are separated by "," or ";"'),
|
screen.getByText('Recipients are separated by "," or ";"'),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the textarea when ff is true, slackChannels fail and slack is selected', async () => {
|
||||||
|
window.featureFlags = { [FeatureFlag.AlertReportSlackV2]: true };
|
||||||
|
jest.spyOn(SupersetClient, 'get').mockImplementation(() => {
|
||||||
|
throw new Error('Error fetching Slack channels');
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<NotificationMethod
|
||||||
|
setting={{
|
||||||
|
...mockSettingSlackV2,
|
||||||
|
method: NotificationMethodOption.Slack,
|
||||||
|
}}
|
||||||
|
index={0}
|
||||||
|
onUpdate={mockOnUpdate}
|
||||||
|
onRemove={mockOnRemove}
|
||||||
|
onInputChange={mockOnInputChange}
|
||||||
|
email_subject={mockEmailSubject}
|
||||||
|
defaultSubject={mockDefaultSubject}
|
||||||
|
setErrorSubject={mockSetErrorSubject}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText('Recipients are separated by "," or ";"'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RefreshLabel functionality', () => {
|
||||||
|
it('should call updateSlackOptions with force true when RefreshLabel is clicked', async () => {
|
||||||
|
// Set feature flag so that SlackV2 branch renders RefreshLabel
|
||||||
|
window.featureFlags = { [FeatureFlag.AlertReportSlackV2]: true };
|
||||||
|
// Spy on SupersetClient.get which is called by updateSlackOptions
|
||||||
|
const supersetClientSpy = jest
|
||||||
|
.spyOn(SupersetClient, 'get')
|
||||||
|
.mockImplementation(
|
||||||
|
() =>
|
||||||
|
Promise.resolve({ json: { result: [] } }) as unknown as Promise<
|
||||||
|
Response | JsonResponse | TextResponse
|
||||||
|
>,
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<NotificationMethod
|
||||||
|
setting={mockSettingSlackV2}
|
||||||
|
index={0}
|
||||||
|
onUpdate={mockOnUpdate}
|
||||||
|
onRemove={mockOnRemove}
|
||||||
|
onInputChange={mockOnInputChange}
|
||||||
|
email_subject={mockEmailSubject}
|
||||||
|
defaultSubject={mockDefaultSubject}
|
||||||
|
setErrorSubject={mockSetErrorSubject}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for RefreshLabel to be rendered (it may have a tooltip with the provided content)
|
||||||
|
const refreshLabel = await waitFor(() =>
|
||||||
|
screen.getByLabelText('refresh'),
|
||||||
|
);
|
||||||
|
// Simulate a click on the RefreshLabel
|
||||||
|
userEvent.click(refreshLabel);
|
||||||
|
// Verify that the SupersetClient.get was called indicating that updateSlackOptions executed
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(supersetClientSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import { Select } from 'src/components';
|
import { Select } from 'src/components';
|
||||||
import Icons from 'src/components/Icons';
|
import Icons from 'src/components/Icons';
|
||||||
|
import RefreshLabel from 'src/components/RefreshLabel';
|
||||||
import {
|
import {
|
||||||
NotificationMethodOption,
|
NotificationMethodOption,
|
||||||
NotificationSetting,
|
NotificationSetting,
|
||||||
@@ -76,6 +77,9 @@ const StyledNotificationMethod = styled.div`
|
|||||||
margin-left: ${theme.gridUnit * 2}px;
|
margin-left: ${theme.gridUnit * 2}px;
|
||||||
padding-top: ${theme.gridUnit}px;
|
padding-top: ${theme.gridUnit}px;
|
||||||
}
|
}
|
||||||
|
.anticon {
|
||||||
|
margin-left: ${theme.gridUnit}px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost-button {
|
.ghost-button {
|
||||||
@@ -248,16 +252,68 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
|
|||||||
searchString = '',
|
searchString = '',
|
||||||
types = [],
|
types = [],
|
||||||
exactMatch = false,
|
exactMatch = false,
|
||||||
|
force = false,
|
||||||
}: {
|
}: {
|
||||||
searchString?: string | undefined;
|
searchString?: string | undefined;
|
||||||
types?: string[];
|
types?: string[];
|
||||||
exactMatch?: boolean | undefined;
|
exactMatch?: boolean | undefined;
|
||||||
|
force?: boolean | undefined;
|
||||||
} = {}): Promise<JsonResponse> => {
|
} = {}): Promise<JsonResponse> => {
|
||||||
const queryString = rison.encode({ searchString, types, exactMatch });
|
const queryString = rison.encode({
|
||||||
|
searchString,
|
||||||
|
types,
|
||||||
|
exactMatch,
|
||||||
|
force,
|
||||||
|
});
|
||||||
const endpoint = `/api/v1/report/slack_channels/?q=${queryString}`;
|
const endpoint = `/api/v1/report/slack_channels/?q=${queryString}`;
|
||||||
return SupersetClient.get({ endpoint });
|
return SupersetClient.get({ endpoint });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateSlackOptions = async ({
|
||||||
|
force,
|
||||||
|
}: {
|
||||||
|
force?: boolean | undefined;
|
||||||
|
} = {}) => {
|
||||||
|
fetchSlackChannels({ types: ['public_channel', 'private_channel'], force })
|
||||||
|
.then(({ json }) => {
|
||||||
|
const { result } = json;
|
||||||
|
const options: SlackOptionsType = mapChannelsToOptions(result);
|
||||||
|
|
||||||
|
setSlackOptions(options);
|
||||||
|
|
||||||
|
if (isFeatureEnabled(FeatureFlag.AlertReportSlackV2)) {
|
||||||
|
// for edit mode, map existing ids to names for display if slack v2
|
||||||
|
// or names to ids if slack v1
|
||||||
|
const [publicOptions, privateOptions] = options;
|
||||||
|
if (
|
||||||
|
method &&
|
||||||
|
[
|
||||||
|
NotificationMethodOption.SlackV2,
|
||||||
|
NotificationMethodOption.Slack,
|
||||||
|
].includes(method)
|
||||||
|
) {
|
||||||
|
setSlackRecipients(
|
||||||
|
mapSlackValues({
|
||||||
|
method,
|
||||||
|
recipientValue,
|
||||||
|
slackOptions: [
|
||||||
|
...publicOptions.options,
|
||||||
|
...privateOptions.options,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
// Fallback to slack v1 if slack v2 is not compatible
|
||||||
|
setUseSlackV1(true);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setMethodOptionsLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const slackEnabled = options?.some(
|
const slackEnabled = options?.some(
|
||||||
option =>
|
option =>
|
||||||
@@ -265,44 +321,7 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
|
|||||||
option === NotificationMethodOption.SlackV2,
|
option === NotificationMethodOption.SlackV2,
|
||||||
);
|
);
|
||||||
if (slackEnabled && !slackOptions[0]?.options.length) {
|
if (slackEnabled && !slackOptions[0]?.options.length) {
|
||||||
fetchSlackChannels({ types: ['public_channel', 'private_channel'] })
|
updateSlackOptions();
|
||||||
.then(({ json }) => {
|
|
||||||
const { result } = json;
|
|
||||||
const options: SlackOptionsType = mapChannelsToOptions(result);
|
|
||||||
|
|
||||||
setSlackOptions(options);
|
|
||||||
|
|
||||||
if (isFeatureEnabled(FeatureFlag.AlertReportSlackV2)) {
|
|
||||||
// for edit mode, map existing ids to names for display if slack v2
|
|
||||||
// or names to ids if slack v1
|
|
||||||
const [publicOptions, privateOptions] = options;
|
|
||||||
if (
|
|
||||||
method &&
|
|
||||||
[
|
|
||||||
NotificationMethodOption.SlackV2,
|
|
||||||
NotificationMethodOption.Slack,
|
|
||||||
].includes(method)
|
|
||||||
) {
|
|
||||||
setSlackRecipients(
|
|
||||||
mapSlackValues({
|
|
||||||
method,
|
|
||||||
recipientValue,
|
|
||||||
slackOptions: [
|
|
||||||
...publicOptions.options,
|
|
||||||
...privateOptions.options,
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
// Fallback to slack v1 if slack v2 is not compatible
|
|
||||||
setUseSlackV1(true);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setMethodOptionsLoading(false);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -518,18 +537,24 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
// for SlackV2
|
// for SlackV2
|
||||||
<Select
|
<div className="input-container">
|
||||||
ariaLabel={t('Select channels')}
|
<Select
|
||||||
mode="multiple"
|
ariaLabel={t('Select channels')}
|
||||||
name="recipients"
|
mode="multiple"
|
||||||
value={slackRecipients}
|
name="recipients"
|
||||||
options={slackOptions}
|
value={slackRecipients}
|
||||||
onChange={onSlackRecipientsChange}
|
options={slackOptions}
|
||||||
allowClear
|
onChange={onSlackRecipientsChange}
|
||||||
data-test="recipients"
|
allowClear
|
||||||
allowSelectAll={false}
|
data-test="recipients"
|
||||||
labelInValue
|
allowSelectAll={false}
|
||||||
/>
|
labelInValue
|
||||||
|
/>
|
||||||
|
<RefreshLabel
|
||||||
|
onClick={() => updateSlackOptions({ force: true })}
|
||||||
|
tooltipContent={t('Force refresh Slack channels list')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</StyledInputContainer>
|
</StyledInputContainer>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ from superset.reports.notifications.exceptions import (
|
|||||||
)
|
)
|
||||||
from superset.tasks.utils import get_executor
|
from superset.tasks.utils import get_executor
|
||||||
from superset.utils import json
|
from superset.utils import json
|
||||||
from superset.utils.core import HeaderDataType, override_user
|
from superset.utils.core import get_recipients_list, HeaderDataType, override_user
|
||||||
from superset.utils.csv import get_chart_csv_data, get_chart_dataframe
|
from superset.utils.csv import get_chart_csv_data, get_chart_dataframe
|
||||||
from superset.utils.decorators import logs_context, transaction
|
from superset.utils.decorators import logs_context, transaction
|
||||||
from superset.utils.pdf import build_pdf_from_screenshots
|
from superset.utils.pdf import build_pdf_from_screenshots
|
||||||
@@ -139,22 +139,37 @@ class BaseReportState:
|
|||||||
slack_recipients = json.loads(recipient.recipient_config_json)
|
slack_recipients = json.loads(recipient.recipient_config_json)
|
||||||
# we need to ensure that existing reports can also fetch
|
# we need to ensure that existing reports can also fetch
|
||||||
# ids from private channels
|
# ids from private channels
|
||||||
|
channels_list = get_recipients_list(slack_recipients["target"])
|
||||||
|
channels_list = [channel.lstrip("#") for channel in channels_list]
|
||||||
|
channels = get_channels_with_search(
|
||||||
|
search_string=channels_list,
|
||||||
|
types=[
|
||||||
|
SlackChannelTypes.PRIVATE,
|
||||||
|
SlackChannelTypes.PUBLIC,
|
||||||
|
],
|
||||||
|
exact_match=True,
|
||||||
|
)
|
||||||
|
if len(channels_list) != len(channels):
|
||||||
|
missing_channels = set(channels_list) - {
|
||||||
|
channel["name"] for channel in channels
|
||||||
|
}
|
||||||
|
msg = (
|
||||||
|
"Could not find the following channels: "
|
||||||
|
f"{', '.join(missing_channels)}"
|
||||||
|
)
|
||||||
|
raise UpdateFailedError(msg)
|
||||||
|
channel_ids = ", ".join(channel["id"] for channel in channels)
|
||||||
recipient.recipient_config_json = json.dumps(
|
recipient.recipient_config_json = json.dumps(
|
||||||
{
|
{
|
||||||
"target": get_channels_with_search(
|
"target": channel_ids,
|
||||||
slack_recipients["target"],
|
|
||||||
types=[
|
|
||||||
SlackChannelTypes.PRIVATE,
|
|
||||||
SlackChannelTypes.PUBLIC,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(
|
# Revert to v1 to preserve configuration (requires manual fix)
|
||||||
"Failed to update slack recipients to v2: %s", str(ex), exc_info=True
|
recipient.type = ReportRecipientType.SLACK
|
||||||
)
|
msg = f"Failed to update slack recipients to v2: {str(ex)}"
|
||||||
raise UpdateFailedError from ex
|
logger.error(msg, exc_info=True)
|
||||||
|
raise UpdateFailedError(msg) from ex
|
||||||
|
|
||||||
def create_log(self, error_message: Optional[str] = None) -> None:
|
def create_log(self, error_message: Optional[str] = None) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -571,10 +586,13 @@ class BaseReportState:
|
|||||||
recipient.type = ReportRecipientType.SLACKV2
|
recipient.type = ReportRecipientType.SLACKV2
|
||||||
notification = create_notification(recipient, notification_content)
|
notification = create_notification(recipient, notification_content)
|
||||||
notification.send()
|
notification.send()
|
||||||
except (UpdateFailedError, NotificationParamException) as err:
|
except (UpdateFailedError, NotificationParamException) as mig_err:
|
||||||
# log the error but keep processing the report with SlackV1
|
notification_errors.append(
|
||||||
logger.warning(
|
SupersetError(
|
||||||
"Failed to update slack recipients to v2: %s", str(err)
|
message=mig_err.message,
|
||||||
|
error_type=SupersetErrorType.REPORT_NOTIFICATION_ERROR,
|
||||||
|
level=ErrorLevel.ERROR,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
except (NotificationError, SupersetException) as ex:
|
except (NotificationError, SupersetException) as ex:
|
||||||
# collect errors but keep processing them
|
# collect errors but keep processing them
|
||||||
|
|||||||
@@ -574,11 +574,15 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
params = kwargs.get("rison", {})
|
params = kwargs.get("rison", {})
|
||||||
search_string = params.get("search_string")
|
search_string = [params.get("search_string", "").strip()]
|
||||||
types = params.get("types", [])
|
types = params.get("types", [])
|
||||||
exact_match = params.get("exact_match", False)
|
exact_match = params.get("exact_match", False)
|
||||||
|
force = params.get("force", False)
|
||||||
channels = get_channels_with_search(
|
channels = get_channels_with_search(
|
||||||
search_string=search_string, types=types, exact_match=exact_match
|
search_string=search_string,
|
||||||
|
types=types,
|
||||||
|
exact_match=exact_match,
|
||||||
|
force=force,
|
||||||
)
|
)
|
||||||
return self.response(200, result=channels)
|
return self.response(200, result=channels)
|
||||||
except SupersetException as ex:
|
except SupersetException as ex:
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ from superset.reports.notifications.exceptions import (
|
|||||||
)
|
)
|
||||||
from superset.reports.notifications.slack_mixin import SlackMixin
|
from superset.reports.notifications.slack_mixin import SlackMixin
|
||||||
from superset.utils import json
|
from superset.utils import json
|
||||||
from superset.utils.core import get_email_address_list
|
from superset.utils.core import get_recipients_list
|
||||||
from superset.utils.decorators import statsd_gauge
|
from superset.utils.decorators import statsd_gauge
|
||||||
from superset.utils.slack import (
|
from superset.utils.slack import (
|
||||||
get_slack_client,
|
get_slack_client,
|
||||||
@@ -70,7 +70,7 @@ class SlackNotification(SlackMixin, BaseNotification): # pylint: disable=too-fe
|
|||||||
"""
|
"""
|
||||||
recipient_str = json.loads(self._recipient.recipient_config_json)["target"]
|
recipient_str = json.loads(self._recipient.recipient_config_json)["target"]
|
||||||
|
|
||||||
return ",".join(get_email_address_list(recipient_str))
|
return ",".join(get_recipients_list(recipient_str))
|
||||||
|
|
||||||
def _get_inline_files(
|
def _get_inline_files(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ from superset.reports.notifications.exceptions import (
|
|||||||
)
|
)
|
||||||
from superset.reports.notifications.slack_mixin import SlackMixin
|
from superset.reports.notifications.slack_mixin import SlackMixin
|
||||||
from superset.utils import json
|
from superset.utils import json
|
||||||
from superset.utils.core import get_email_address_list
|
from superset.utils.core import get_recipients_list
|
||||||
from superset.utils.decorators import statsd_gauge
|
from superset.utils.decorators import statsd_gauge
|
||||||
from superset.utils.slack import get_slack_client
|
from superset.utils.slack import get_slack_client
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ class SlackV2Notification(SlackMixin, BaseNotification): # pylint: disable=too-
|
|||||||
""" # noqa: E501
|
""" # noqa: E501
|
||||||
recipient_str = json.loads(self._recipient.recipient_config_json)["target"]
|
recipient_str = json.loads(self._recipient.recipient_config_json)["target"]
|
||||||
|
|
||||||
return get_email_address_list(recipient_str)
|
return get_recipients_list(recipient_str)
|
||||||
|
|
||||||
def _get_inline_files(
|
def _get_inline_files(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from typing import Any, Optional, Union
|
|||||||
from croniter import croniter
|
from croniter import croniter
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from marshmallow import fields, Schema, validate, validates, validates_schema
|
from marshmallow import EXCLUDE, fields, Schema, validate, validates, validates_schema
|
||||||
from marshmallow.validate import Length, Range, ValidationError
|
from marshmallow.validate import Length, Range, ValidationError
|
||||||
from pytz import all_timezones
|
from pytz import all_timezones
|
||||||
|
|
||||||
@@ -399,3 +399,17 @@ class ReportSchedulePutSchema(Schema):
|
|||||||
max=max_width,
|
max=max_width,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SlackChannelSchema(Schema):
|
||||||
|
"""
|
||||||
|
Schema to load Slack channels, set to ignore any fields not used by Superset.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unknown = EXCLUDE
|
||||||
|
|
||||||
|
id = fields.String()
|
||||||
|
name = fields.String()
|
||||||
|
is_member = fields.Boolean()
|
||||||
|
is_private = fields.Boolean()
|
||||||
|
|||||||
@@ -700,7 +700,7 @@ def send_email_smtp( # pylint: disable=invalid-name,too-many-arguments,too-many
|
|||||||
'test@example.com', 'foo', '<b>Foo</b> bar',['/dev/null'], dryrun=True)
|
'test@example.com', 'foo', '<b>Foo</b> bar',['/dev/null'], dryrun=True)
|
||||||
"""
|
"""
|
||||||
smtp_mail_from = config["SMTP_MAIL_FROM"]
|
smtp_mail_from = config["SMTP_MAIL_FROM"]
|
||||||
smtp_mail_to = get_email_address_list(to)
|
smtp_mail_to = get_recipients_list(to)
|
||||||
|
|
||||||
msg = MIMEMultipart(mime_subtype)
|
msg = MIMEMultipart(mime_subtype)
|
||||||
msg["Subject"] = subject
|
msg["Subject"] = subject
|
||||||
@@ -711,14 +711,14 @@ def send_email_smtp( # pylint: disable=invalid-name,too-many-arguments,too-many
|
|||||||
|
|
||||||
recipients = smtp_mail_to
|
recipients = smtp_mail_to
|
||||||
if cc:
|
if cc:
|
||||||
smtp_mail_cc = get_email_address_list(cc)
|
smtp_mail_cc = get_recipients_list(cc)
|
||||||
msg["Cc"] = ", ".join(smtp_mail_cc)
|
msg["Cc"] = ", ".join(smtp_mail_cc)
|
||||||
recipients = recipients + smtp_mail_cc
|
recipients = recipients + smtp_mail_cc
|
||||||
|
|
||||||
smtp_mail_bcc = []
|
smtp_mail_bcc = []
|
||||||
if bcc:
|
if bcc:
|
||||||
# don't add bcc in header
|
# don't add bcc in header
|
||||||
smtp_mail_bcc = get_email_address_list(bcc)
|
smtp_mail_bcc = get_recipients_list(bcc)
|
||||||
recipients = recipients + smtp_mail_bcc
|
recipients = recipients + smtp_mail_bcc
|
||||||
|
|
||||||
msg["Date"] = formatdate(localtime=True)
|
msg["Date"] = formatdate(localtime=True)
|
||||||
@@ -811,7 +811,13 @@ def send_mime_email(
|
|||||||
smtp.quit()
|
smtp.quit()
|
||||||
|
|
||||||
|
|
||||||
def get_email_address_list(address_string: str) -> list[str]:
|
def get_recipients_list(address_string: str | None) -> list[str]:
|
||||||
|
"""
|
||||||
|
Returns the list of target recipients for alerts and reports.
|
||||||
|
|
||||||
|
Strips values and converts a comma/semicolon separated
|
||||||
|
string into a list.
|
||||||
|
"""
|
||||||
address_string_list: list[str] = []
|
address_string_list: list[str] = []
|
||||||
if isinstance(address_string, str):
|
if isinstance(address_string, str):
|
||||||
address_string_list = re.split(r",|\s|;", address_string)
|
address_string_list = re.split(r",|\s|;", address_string)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from slack_sdk import WebClient
|
from slack_sdk import WebClient
|
||||||
@@ -25,6 +25,9 @@ from slack_sdk.errors import SlackApiError
|
|||||||
|
|
||||||
from superset import feature_flag_manager
|
from superset import feature_flag_manager
|
||||||
from superset.exceptions import SupersetException
|
from superset.exceptions import SupersetException
|
||||||
|
from superset.extensions import cache_manager
|
||||||
|
from superset.reports.schemas import SlackChannelSchema
|
||||||
|
from superset.utils import cache as cache_util
|
||||||
from superset.utils.backports import StrEnum
|
from superset.utils.backports import StrEnum
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -46,61 +49,82 @@ def get_slack_client() -> WebClient:
|
|||||||
return WebClient(token=token, proxy=current_app.config["SLACK_PROXY"])
|
return WebClient(token=token, proxy=current_app.config["SLACK_PROXY"])
|
||||||
|
|
||||||
|
|
||||||
|
@cache_util.memoized_func(
|
||||||
|
key="slack_conversations_list",
|
||||||
|
cache=cache_manager.cache,
|
||||||
|
)
|
||||||
|
def get_channels(limit: int, extra_params: dict[str, Any]) -> list[SlackChannelSchema]:
|
||||||
|
"""
|
||||||
|
Retrieves a list of all conversations accessible by the bot
|
||||||
|
from the Slack API, and caches results (to avoid rate limits).
|
||||||
|
|
||||||
|
The Slack API does not provide search so to apply a search use
|
||||||
|
get_channels_with_search instead.
|
||||||
|
"""
|
||||||
|
client = get_slack_client()
|
||||||
|
channel_schema = SlackChannelSchema()
|
||||||
|
channels: list[SlackChannelSchema] = []
|
||||||
|
cursor = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
response = client.conversations_list(
|
||||||
|
limit=limit, cursor=cursor, exclude_archived=True, **extra_params
|
||||||
|
)
|
||||||
|
channels.extend(
|
||||||
|
channel_schema.load(channel) for channel in response.data["channels"]
|
||||||
|
)
|
||||||
|
cursor = response.data.get("response_metadata", {}).get("next_cursor")
|
||||||
|
if not cursor:
|
||||||
|
break
|
||||||
|
|
||||||
|
return channels
|
||||||
|
|
||||||
|
|
||||||
def get_channels_with_search(
|
def get_channels_with_search(
|
||||||
search_string: str = "",
|
search_string: list[str] | None = None,
|
||||||
limit: int = 999,
|
limit: int = 999,
|
||||||
types: Optional[list[SlackChannelTypes]] = None,
|
types: Optional[list[SlackChannelTypes]] = None,
|
||||||
exact_match: bool = False,
|
exact_match: bool = False,
|
||||||
) -> list[str]:
|
force: bool = False,
|
||||||
|
) -> list[SlackChannelSchema]:
|
||||||
"""
|
"""
|
||||||
The slack api is paginated but does not include search, so we need to fetch
|
The slack api is paginated but does not include search, so we need to fetch
|
||||||
all channels and filter them ourselves
|
all channels and filter them ourselves
|
||||||
This will search by slack name or id
|
This will search by slack name or id
|
||||||
"""
|
"""
|
||||||
|
extra_params = {}
|
||||||
|
extra_params["types"] = ",".join(types) if types else None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = get_slack_client()
|
channels = get_channels(
|
||||||
channels = []
|
limit=limit,
|
||||||
cursor = None
|
extra_params=extra_params,
|
||||||
extra_params = {}
|
force=force,
|
||||||
extra_params["types"] = ",".join(types) if types else None
|
cache_timeout=86400,
|
||||||
|
)
|
||||||
while True:
|
|
||||||
response = client.conversations_list(
|
|
||||||
limit=limit, cursor=cursor, exclude_archived=True, **extra_params
|
|
||||||
)
|
|
||||||
channels.extend(response.data["channels"])
|
|
||||||
cursor = response.data.get("response_metadata", {}).get("next_cursor")
|
|
||||||
if not cursor:
|
|
||||||
break
|
|
||||||
|
|
||||||
# The search string can be multiple channels separated by commas
|
|
||||||
if search_string:
|
|
||||||
search_array = [
|
|
||||||
search.lower()
|
|
||||||
for search in (search_string.split(",") if search_string else [])
|
|
||||||
]
|
|
||||||
|
|
||||||
channels = [
|
|
||||||
channel
|
|
||||||
for channel in channels
|
|
||||||
if any(
|
|
||||||
(
|
|
||||||
search == channel["name"].lower()
|
|
||||||
or search == channel["id"].lower()
|
|
||||||
if exact_match
|
|
||||||
else (
|
|
||||||
search in channel["name"].lower()
|
|
||||||
or search in channel["id"].lower()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for search in search_array
|
|
||||||
)
|
|
||||||
]
|
|
||||||
return channels
|
|
||||||
except (SlackClientError, SlackApiError) as ex:
|
except (SlackClientError, SlackApiError) as ex:
|
||||||
raise SupersetException(f"Failed to list channels: {ex}") from ex
|
raise SupersetException(f"Failed to list channels: {ex}") from ex
|
||||||
|
|
||||||
|
# The search string can be multiple channels separated by commas
|
||||||
|
if search_string:
|
||||||
|
channels = [
|
||||||
|
channel
|
||||||
|
for channel in channels
|
||||||
|
if any(
|
||||||
|
(
|
||||||
|
search.lower() == channel["name"].lower()
|
||||||
|
or search.lower() == channel["id"].lower()
|
||||||
|
if exact_match
|
||||||
|
else (
|
||||||
|
search.lower() in channel["name"].lower()
|
||||||
|
or search.lower() in channel["id"].lower()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for search in search_string
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return channels
|
||||||
|
|
||||||
|
|
||||||
def should_use_v2_api() -> bool:
|
def should_use_v2_api() -> bool:
|
||||||
if not feature_flag_manager.is_feature_enabled("ALERT_REPORT_SLACK_V2"):
|
if not feature_flag_manager.is_feature_enabled("ALERT_REPORT_SLACK_V2"):
|
||||||
|
|||||||
@@ -308,7 +308,10 @@ def create_report_slack_chart():
|
|||||||
def create_report_slack_chartv2():
|
def create_report_slack_chartv2():
|
||||||
chart = db.session.query(Slice).first()
|
chart = db.session.query(Slice).first()
|
||||||
report_schedule = create_report_notification(
|
report_schedule = create_report_notification(
|
||||||
slack_channel="slack_channel_id", chart=chart, name="report_slack_chartv2"
|
slack_channel="slack_channel_id",
|
||||||
|
chart=chart,
|
||||||
|
name="report_slack_chartv2",
|
||||||
|
use_slack_v2=True,
|
||||||
)
|
)
|
||||||
yield report_schedule
|
yield report_schedule
|
||||||
|
|
||||||
@@ -1300,9 +1303,7 @@ def test_email_dashboard_report_schedule_force_screenshot(
|
|||||||
assert_log(ReportState.SUCCESS)
|
assert_log(ReportState.SUCCESS)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures(
|
@pytest.mark.usefixtures("create_report_slack_chart")
|
||||||
"load_birth_names_dashboard_with_slices", "create_report_slack_chart"
|
|
||||||
)
|
|
||||||
@patch("superset.commands.report.execute.get_channels_with_search")
|
@patch("superset.commands.report.execute.get_channels_with_search")
|
||||||
@patch("superset.reports.notifications.slack.should_use_v2_api", return_value=True)
|
@patch("superset.reports.notifications.slack.should_use_v2_api", return_value=True)
|
||||||
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
||||||
@@ -1316,13 +1317,19 @@ def test_slack_chart_report_schedule_converts_to_v2(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
ExecuteReport Command: Test chart slack report schedule
|
ExecuteReport Command: Test chart slack report schedule
|
||||||
|
while converting the recipients list to SlackV2.
|
||||||
"""
|
"""
|
||||||
# setup screenshot mock
|
# setup screenshot mock
|
||||||
screenshot_mock.return_value = SCREENSHOT_FILE
|
screenshot_mock.return_value = SCREENSHOT_FILE
|
||||||
|
|
||||||
channel_id = "slack_channel_id"
|
channel_id = "slack_channel_id"
|
||||||
|
get_channels_with_search_mock.return_value = [
|
||||||
get_channels_with_search_mock.return_value = channel_id
|
{
|
||||||
|
"id": channel_id,
|
||||||
|
"name": "slack_channel",
|
||||||
|
"is_member": True,
|
||||||
|
"is_private": False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
with freeze_time("2020-01-01T00:00:00Z"):
|
with freeze_time("2020-01-01T00:00:00Z"):
|
||||||
with patch.object(current_app.config["STATS_LOGGER"], "gauge") as statsd_mock:
|
with patch.object(current_app.config["STATS_LOGGER"], "gauge") as statsd_mock:
|
||||||
@@ -1357,33 +1364,40 @@ def test_slack_chart_report_schedule_converts_to_v2(
|
|||||||
assert statsd_mock.call_args_list[1] == call("reports.slack.send.ok", 1)
|
assert statsd_mock.call_args_list[1] == call("reports.slack.send.ok", 1)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures(
|
|
||||||
"load_birth_names_dashboard_with_slices", "create_report_slack_chartv2"
|
|
||||||
)
|
|
||||||
@patch("superset.commands.report.execute.get_channels_with_search")
|
@patch("superset.commands.report.execute.get_channels_with_search")
|
||||||
@patch("superset.reports.notifications.slack.should_use_v2_api", return_value=True)
|
@patch("superset.reports.notifications.slack.should_use_v2_api", return_value=True)
|
||||||
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
||||||
@patch("superset.utils.screenshots.ChartScreenshot.get_screenshot")
|
@patch("superset.utils.screenshots.ChartScreenshot.get_screenshot")
|
||||||
def test_slack_chart_report_schedule_v2(
|
def test_slack_chart_report_schedule_converts_to_v2_channel_with_hash(
|
||||||
screenshot_mock,
|
screenshot_mock,
|
||||||
slack_client_mock,
|
slack_client_mock,
|
||||||
slack_should_use_v2_api_mock,
|
slack_should_use_v2_api_mock,
|
||||||
get_channels_with_search_mock,
|
get_channels_with_search_mock,
|
||||||
create_report_slack_chart,
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
ExecuteReport Command: Test chart slack report schedule
|
ExecuteReport Command: Test converting a Slack report to v2 when
|
||||||
|
the channel name includes the leading hash (supported in v1).
|
||||||
"""
|
"""
|
||||||
# setup screenshot mock
|
# setup screenshot mock
|
||||||
screenshot_mock.return_value = SCREENSHOT_FILE
|
screenshot_mock.return_value = SCREENSHOT_FILE
|
||||||
channel_id = "slack_channel_id"
|
channel_id = "slack_channel_id"
|
||||||
|
chart = db.session.query(Slice).first()
|
||||||
get_channels_with_search_mock.return_value = channel_id
|
report_schedule = create_report_notification(
|
||||||
|
slack_channel="#slack_channel", chart=chart
|
||||||
|
)
|
||||||
|
get_channels_with_search_mock.return_value = [
|
||||||
|
{
|
||||||
|
"id": channel_id,
|
||||||
|
"name": "slack_channel",
|
||||||
|
"is_member": True,
|
||||||
|
"is_private": False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
with freeze_time("2020-01-01T00:00:00Z"):
|
with freeze_time("2020-01-01T00:00:00Z"):
|
||||||
with patch.object(current_app.config["STATS_LOGGER"], "gauge") as statsd_mock:
|
with patch.object(current_app.config["STATS_LOGGER"], "gauge") as statsd_mock:
|
||||||
AsyncExecuteReportScheduleCommand(
|
AsyncExecuteReportScheduleCommand(
|
||||||
TEST_ID, create_report_slack_chart.id, datetime.utcnow()
|
TEST_ID, report_schedule.id, datetime.utcnow()
|
||||||
).run()
|
).run()
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
@@ -1395,6 +1409,12 @@ def test_slack_chart_report_schedule_v2(
|
|||||||
== SCREENSHOT_FILE
|
== SCREENSHOT_FILE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Assert that the report recipients were updated
|
||||||
|
assert report_schedule.recipients[0].recipient_config_json == json.dumps(
|
||||||
|
{"target": channel_id}
|
||||||
|
)
|
||||||
|
assert report_schedule.recipients[0].type == ReportRecipientType.SLACKV2
|
||||||
|
|
||||||
# Assert logs are correct
|
# Assert logs are correct
|
||||||
assert_log(ReportState.SUCCESS)
|
assert_log(ReportState.SUCCESS)
|
||||||
# this will send a warning
|
# this will send a warning
|
||||||
@@ -1403,6 +1423,94 @@ def test_slack_chart_report_schedule_v2(
|
|||||||
)
|
)
|
||||||
assert statsd_mock.call_args_list[1] == call("reports.slack.send.ok", 1)
|
assert statsd_mock.call_args_list[1] == call("reports.slack.send.ok", 1)
|
||||||
|
|
||||||
|
cleanup_report_schedule(report_schedule)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("superset.commands.report.execute.get_channels_with_search")
|
||||||
|
@patch("superset.reports.notifications.slack.should_use_v2_api", return_value=True)
|
||||||
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
||||||
|
@patch("superset.utils.screenshots.ChartScreenshot.get_screenshot")
|
||||||
|
def test_slack_chart_report_schedule_fails_to_converts_to_v2(
|
||||||
|
screenshot_mock,
|
||||||
|
slack_client_mock,
|
||||||
|
slack_should_use_v2_api_mock,
|
||||||
|
get_channels_with_search_mock,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
ExecuteReport Command: Test converting a Slack report to v2 fails.
|
||||||
|
"""
|
||||||
|
# setup screenshot mock
|
||||||
|
screenshot_mock.return_value = SCREENSHOT_FILE
|
||||||
|
channel_id = "slack_channel_id"
|
||||||
|
chart = db.session.query(Slice).first()
|
||||||
|
report_schedule = create_report_notification(
|
||||||
|
slack_channel="#slack_channel,my_member_ID", chart=chart
|
||||||
|
)
|
||||||
|
get_channels_with_search_mock.return_value = [
|
||||||
|
{
|
||||||
|
"id": channel_id,
|
||||||
|
"name": "slack_channel",
|
||||||
|
"is_member": True,
|
||||||
|
"is_private": False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
with pytest.raises(ReportScheduleSystemErrorsException):
|
||||||
|
AsyncExecuteReportScheduleCommand(
|
||||||
|
TEST_ID, report_schedule.id, datetime.utcnow()
|
||||||
|
).run()
|
||||||
|
|
||||||
|
# Assert failuer with proper log
|
||||||
|
expected_message = (
|
||||||
|
"Failed to update slack recipients to v2: "
|
||||||
|
"Could not find the following channels: my_member_ID"
|
||||||
|
)
|
||||||
|
assert_log(ReportState.ERROR, error_message=expected_message)
|
||||||
|
|
||||||
|
# Assert that previous configuration was kept for manual correction
|
||||||
|
assert report_schedule.recipients[0].recipient_config_json == json.dumps(
|
||||||
|
{"target": "#slack_channel,my_member_ID"}
|
||||||
|
)
|
||||||
|
assert report_schedule.recipients[0].type == ReportRecipientType.SLACK
|
||||||
|
|
||||||
|
cleanup_report_schedule(report_schedule)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("create_report_slack_chartv2")
|
||||||
|
@patch("superset.reports.notifications.slack.should_use_v2_api", return_value=True)
|
||||||
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
||||||
|
@patch("superset.utils.screenshots.ChartScreenshot.get_screenshot")
|
||||||
|
def test_slack_chart_report_schedule_v2(
|
||||||
|
screenshot_mock,
|
||||||
|
slack_client_mock,
|
||||||
|
slack_should_use_v2_api_mock,
|
||||||
|
create_report_slack_chartv2,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
ExecuteReport Command: Test chart slack report schedule using Slack v2.
|
||||||
|
"""
|
||||||
|
# setup screenshot mock
|
||||||
|
screenshot_mock.return_value = SCREENSHOT_FILE
|
||||||
|
|
||||||
|
with freeze_time("2020-01-01T00:00:00Z"):
|
||||||
|
with patch.object(current_app.config["STATS_LOGGER"], "gauge") as statsd_mock:
|
||||||
|
AsyncExecuteReportScheduleCommand(
|
||||||
|
TEST_ID, create_report_slack_chartv2.id, datetime.utcnow()
|
||||||
|
).run()
|
||||||
|
|
||||||
|
assert (
|
||||||
|
slack_client_mock.return_value.files_upload_v2.call_args[1]["channel"]
|
||||||
|
== "slack_channel_id"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
slack_client_mock.return_value.files_upload_v2.call_args[1]["file"]
|
||||||
|
== SCREENSHOT_FILE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert logs are correct
|
||||||
|
assert_log(ReportState.SUCCESS)
|
||||||
|
assert statsd_mock.call_args_list[0] == call("reports.slack.send.ok", 1)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures(
|
@pytest.mark.usefixtures(
|
||||||
"load_birth_names_dashboard_with_slices", "create_report_slack_chart"
|
"load_birth_names_dashboard_with_slices", "create_report_slack_chart"
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ def create_report_notification(
|
|||||||
owners: Optional[list[User]] = None,
|
owners: Optional[list[User]] = None,
|
||||||
ccTarget: Optional[str] = None, # noqa: N803
|
ccTarget: Optional[str] = None, # noqa: N803
|
||||||
bccTarget: Optional[str] = None, # noqa: N803
|
bccTarget: Optional[str] = None, # noqa: N803
|
||||||
|
use_slack_v2: bool = False,
|
||||||
) -> ReportSchedule:
|
) -> ReportSchedule:
|
||||||
if not owners:
|
if not owners:
|
||||||
owners = [
|
owners = [
|
||||||
@@ -130,8 +131,11 @@ def create_report_notification(
|
|||||||
]
|
]
|
||||||
|
|
||||||
if slack_channel:
|
if slack_channel:
|
||||||
|
type = (
|
||||||
|
ReportRecipientType.SLACKV2 if use_slack_v2 else ReportRecipientType.SLACK
|
||||||
|
)
|
||||||
recipient = ReportRecipients(
|
recipient = ReportRecipients(
|
||||||
type=ReportRecipientType.SLACK,
|
type=type,
|
||||||
recipient_config_json=json.dumps(
|
recipient_config_json=json.dumps(
|
||||||
{
|
{
|
||||||
"target": slack_channel,
|
"target": slack_channel,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ from superset.utils.core import (
|
|||||||
GenericDataType,
|
GenericDataType,
|
||||||
get_form_data_token,
|
get_form_data_token,
|
||||||
as_list,
|
as_list,
|
||||||
get_email_address_list,
|
get_recipients_list,
|
||||||
get_stacktrace,
|
get_stacktrace,
|
||||||
merge_extra_filters,
|
merge_extra_filters,
|
||||||
merge_extra_form_data,
|
merge_extra_form_data,
|
||||||
@@ -809,12 +809,12 @@ class TestUtils(SupersetTestCase):
|
|||||||
assert expected_filename in path
|
assert expected_filename in path
|
||||||
assert os.path.exists(path)
|
assert os.path.exists(path)
|
||||||
|
|
||||||
def test_get_email_address_list(self):
|
def test_get_recipients_list(self):
|
||||||
assert get_email_address_list("a@a") == ["a@a"]
|
assert get_recipients_list("a@a") == ["a@a"]
|
||||||
assert get_email_address_list(" a@a ") == ["a@a"]
|
assert get_recipients_list(" a@a ") == ["a@a"]
|
||||||
assert get_email_address_list("a@a\n") == ["a@a"]
|
assert get_recipients_list("a@a\n") == ["a@a"]
|
||||||
assert get_email_address_list(",a@a;") == ["a@a"]
|
assert get_recipients_list(",a@a;") == ["a@a"]
|
||||||
assert get_email_address_list(",a@a; b@b c@c a-c@c; d@d, f@f") == [
|
assert get_recipients_list(",a@a; b@b c@c a-c@c; d@d, f@f") == [
|
||||||
"a@a",
|
"a@a",
|
||||||
"b@b",
|
"b@b",
|
||||||
"c@c",
|
"c@c",
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ import pytest
|
|||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from superset.app import SupersetApp
|
from superset.app import SupersetApp
|
||||||
|
from superset.commands.exceptions import UpdateFailedError
|
||||||
from superset.commands.report.execute import BaseReportState
|
from superset.commands.report.execute import BaseReportState
|
||||||
from superset.dashboards.permalink.types import DashboardPermalinkState
|
from superset.dashboards.permalink.types import DashboardPermalinkState
|
||||||
from superset.reports.models import (
|
from superset.reports.models import (
|
||||||
|
ReportRecipients,
|
||||||
ReportRecipientType,
|
ReportRecipientType,
|
||||||
ReportSchedule,
|
ReportSchedule,
|
||||||
ReportScheduleType,
|
ReportScheduleType,
|
||||||
@@ -480,3 +482,78 @@ def test_screenshot_width_calculation(
|
|||||||
f"Test {test_id}: Expected width {expected_width}, "
|
f"Test {test_id}: Expected width {expected_width}, "
|
||||||
f"but got {kwargs['window_size'][0]}"
|
f"but got {kwargs['window_size'][0]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_recipient_to_slack_v2(mocker: MockerFixture):
|
||||||
|
"""
|
||||||
|
Test converting a Slack recipient to Slack v2 format.
|
||||||
|
"""
|
||||||
|
mocker.patch(
|
||||||
|
"superset.commands.report.execute.get_channels_with_search",
|
||||||
|
return_value=[
|
||||||
|
{
|
||||||
|
"id": "abc124f",
|
||||||
|
"name": "channel-1",
|
||||||
|
"is_member": True,
|
||||||
|
"is_private": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "blah_!channel_2",
|
||||||
|
"name": "Channel_2",
|
||||||
|
"is_member": True,
|
||||||
|
"is_private": False,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
mock_report_schedule = ReportSchedule(
|
||||||
|
recipients=[
|
||||||
|
ReportRecipients(
|
||||||
|
type=ReportRecipientType.SLACK,
|
||||||
|
recipient_config_json=json.dumps({"target": "Channel-1, Channel_2"}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_cmmd: BaseReportState = BaseReportState(
|
||||||
|
mock_report_schedule, "January 1, 2021", "execution_id_example"
|
||||||
|
)
|
||||||
|
mock_cmmd.update_report_schedule_slack_v2()
|
||||||
|
|
||||||
|
assert (
|
||||||
|
mock_cmmd._report_schedule.recipients[0].recipient_config_json
|
||||||
|
== '{"target": "abc124f, blah_!channel_2"}'
|
||||||
|
)
|
||||||
|
assert mock_cmmd._report_schedule.recipients[0].type == ReportRecipientType.SLACKV2
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_recipient_to_slack_v2_missing_channels(mocker: MockerFixture):
|
||||||
|
"""
|
||||||
|
Test converting a Slack recipient to Slack v2 format raises an error
|
||||||
|
in case it can't find all channels.
|
||||||
|
"""
|
||||||
|
mocker.patch(
|
||||||
|
"superset.commands.report.execute.get_channels_with_search",
|
||||||
|
return_value=[
|
||||||
|
{
|
||||||
|
"id": "blah_!channel_2",
|
||||||
|
"name": "Channel 2",
|
||||||
|
"is_member": True,
|
||||||
|
"is_private": False,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
mock_report_schedule = ReportSchedule(
|
||||||
|
name="Test Report",
|
||||||
|
recipients=[
|
||||||
|
ReportRecipients(
|
||||||
|
type=ReportRecipientType.SLACK,
|
||||||
|
recipient_config_json=json.dumps({"target": "Channel 1, Channel 2"}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_cmmd: BaseReportState = BaseReportState(
|
||||||
|
mock_report_schedule, "January 1, 2021", "execution_id_example"
|
||||||
|
)
|
||||||
|
with pytest.raises(UpdateFailedError):
|
||||||
|
mock_cmmd.update_report_schedule_slack_v2()
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class TestGetChannelsWithSearch:
|
|||||||
mock_client.conversations_list.return_value = mock_response_instance
|
mock_client.conversations_list.return_value = mock_response_instance
|
||||||
mocker.patch("superset.utils.slack.get_slack_client", return_value=mock_client)
|
mocker.patch("superset.utils.slack.get_slack_client", return_value=mock_client)
|
||||||
|
|
||||||
result = get_channels_with_search(search_string="")
|
result = get_channels_with_search(search_string=[])
|
||||||
assert result == [{"name": "general", "id": "C12345"}]
|
assert result == [{"name": "general", "id": "C12345"}]
|
||||||
|
|
||||||
def test_handle_exact_match_search_string_single_channel(self, mocker):
|
def test_handle_exact_match_search_string_single_channel(self, mocker):
|
||||||
@@ -81,7 +81,7 @@ class TestGetChannelsWithSearch:
|
|||||||
mocker.patch("superset.utils.slack.get_slack_client", return_value=mock_client)
|
mocker.patch("superset.utils.slack.get_slack_client", return_value=mock_client)
|
||||||
|
|
||||||
# Call the function with a search string that matches a single channel
|
# Call the function with a search string that matches a single channel
|
||||||
result = get_channels_with_search(search_string="general", exact_match=True)
|
result = get_channels_with_search(search_string=["general"], exact_match=True)
|
||||||
|
|
||||||
# Assert that the result is a list with a single channel dictionary
|
# Assert that the result is a list with a single channel dictionary
|
||||||
assert result == [{"name": "general", "id": "C12345"}]
|
assert result == [{"name": "general", "id": "C12345"}]
|
||||||
@@ -102,7 +102,7 @@ class TestGetChannelsWithSearch:
|
|||||||
mocker.patch("superset.utils.slack.get_slack_client", return_value=mock_client)
|
mocker.patch("superset.utils.slack.get_slack_client", return_value=mock_client)
|
||||||
|
|
||||||
result = get_channels_with_search(
|
result = get_channels_with_search(
|
||||||
search_string="general,random", exact_match=True
|
search_string=["general", "random"], exact_match=True
|
||||||
)
|
)
|
||||||
assert result == [
|
assert result == [
|
||||||
{"name": "general", "id": "C12345"},
|
{"name": "general", "id": "C12345"},
|
||||||
@@ -124,7 +124,7 @@ class TestGetChannelsWithSearch:
|
|||||||
mock_client.conversations_list.return_value = mock_response_instance
|
mock_client.conversations_list.return_value = mock_response_instance
|
||||||
mocker.patch("superset.utils.slack.get_slack_client", return_value=mock_client)
|
mocker.patch("superset.utils.slack.get_slack_client", return_value=mock_client)
|
||||||
|
|
||||||
result = get_channels_with_search(search_string="general,random")
|
result = get_channels_with_search(search_string=["general", "random"])
|
||||||
assert result == [
|
assert result == [
|
||||||
{"name": "general", "id": "C12345"},
|
{"name": "general", "id": "C12345"},
|
||||||
{"name": "general2", "id": "C13454"},
|
{"name": "general2", "id": "C13454"},
|
||||||
@@ -153,7 +153,12 @@ The server responded with: missing scope: channels:read"""
|
|||||||
def test_filter_channels_by_specified_types(self, mocker):
|
def test_filter_channels_by_specified_types(self, mocker):
|
||||||
mock_data = {
|
mock_data = {
|
||||||
"channels": [
|
"channels": [
|
||||||
{"name": "general", "id": "C12345", "type": "public"},
|
{
|
||||||
|
"id": "C12345",
|
||||||
|
"name": "general",
|
||||||
|
"is_member": False,
|
||||||
|
"is_private": False,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"response_metadata": {"next_cursor": None},
|
"response_metadata": {"next_cursor": None},
|
||||||
}
|
}
|
||||||
@@ -164,7 +169,14 @@ The server responded with: missing scope: channels:read"""
|
|||||||
mocker.patch("superset.utils.slack.get_slack_client", return_value=mock_client)
|
mocker.patch("superset.utils.slack.get_slack_client", return_value=mock_client)
|
||||||
|
|
||||||
result = get_channels_with_search(types=["public"])
|
result = get_channels_with_search(types=["public"])
|
||||||
assert result == [{"name": "general", "id": "C12345", "type": "public"}]
|
assert result == [
|
||||||
|
{
|
||||||
|
"id": "C12345",
|
||||||
|
"name": "general",
|
||||||
|
"is_member": False,
|
||||||
|
"is_private": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
def test_handle_pagination_multiple_pages(self, mocker):
|
def test_handle_pagination_multiple_pages(self, mocker):
|
||||||
mock_data_page1 = {
|
mock_data_page1 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user