Compare commits

...

12 Commits

Author SHA1 Message Date
Elizabeth Thompson
89543ada1d add refresh button 2025-03-05 18:04:21 -08:00
Vitor Avila
3ef17d08b4 chore: Cache Slack channels 2025-03-05 19:12:20 -03:00
Vitor Avila
802c0aa399 I think now I fixed the test (again) 2025-03-05 18:39:29 -03:00
Vitor Avila
afdecd8292 I think now I fixed the test 2025-03-05 18:39:29 -03:00
Vitor Avila
cdeb989dad Hopefully fixing the test 2025-03-05 18:39:29 -03:00
Vitor Avila
23d2ce28fa Hopefully fixing tests 2025-03-05 18:39:28 -03:00
Vitor Avila
14fbb5424e Fixing test 2025-03-05 18:39:28 -03:00
Vitor Avila
df5a027dcf Error execution when migration fails + more tests 2025-03-05 18:39:28 -03:00
Vitor Avila
6b5b2425ad More fixes 2025-03-05 18:39:28 -03:00
Vitor Avila
a4b52c2437 Fixing tests 2025-03-05 18:39:28 -03:00
Vitor Avila
41cf0612b4 Improving the logic 2025-03-05 18:39:28 -03:00
Vitor Avila
5af66d85f3 fix(Slack): Fix Slack recipients migration to V2 2025-03-05 18:39:28 -03:00
14 changed files with 519 additions and 150 deletions

View File

@@ -52,6 +52,16 @@ const mockSetting: NotificationSetting = {
const mockEmailSubject = 'Test Subject';
const mockDefaultSubject = 'Default Subject';
const mockSettingSlackV2: NotificationSetting = {
method: NotificationMethodOption.SlackV2,
recipients: 'slack-channel',
options: [
NotificationMethodOption.Email,
NotificationMethodOption.Slack,
NotificationMethodOption.SlackV2,
],
};
describe('NotificationMethod', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -480,4 +490,71 @@ describe('NotificationMethod', () => {
screen.getByText('Recipients are separated by "," or ";"'),
).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();
});
});
});
});

View File

@@ -36,6 +36,7 @@ import {
} from '@superset-ui/core';
import { Select } from 'src/components';
import Icons from 'src/components/Icons';
import RefreshLabel from 'src/components/RefreshLabel';
import {
NotificationMethodOption,
NotificationSetting,
@@ -76,6 +77,9 @@ const StyledNotificationMethod = styled.div`
margin-left: ${theme.gridUnit * 2}px;
padding-top: ${theme.gridUnit}px;
}
.anticon {
margin-left: ${theme.gridUnit}px;
}
}
.ghost-button {
@@ -248,16 +252,68 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
searchString = '',
types = [],
exactMatch = false,
force = false,
}: {
searchString?: string | undefined;
types?: string[];
exactMatch?: boolean | undefined;
force?: boolean | undefined;
} = {}): 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}`;
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(() => {
const slackEnabled = options?.some(
option =>
@@ -265,44 +321,7 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
option === NotificationMethodOption.SlackV2,
);
if (slackEnabled && !slackOptions[0]?.options.length) {
fetchSlackChannels({ types: ['public_channel', 'private_channel'] })
.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);
});
updateSlackOptions();
}
}, []);
@@ -518,18 +537,24 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
</>
) : (
// for SlackV2
<Select
ariaLabel={t('Select channels')}
mode="multiple"
name="recipients"
value={slackRecipients}
options={slackOptions}
onChange={onSlackRecipientsChange}
allowClear
data-test="recipients"
allowSelectAll={false}
labelInValue
/>
<div className="input-container">
<Select
ariaLabel={t('Select channels')}
mode="multiple"
name="recipients"
value={slackRecipients}
options={slackOptions}
onChange={onSlackRecipientsChange}
allowClear
data-test="recipients"
allowSelectAll={false}
labelInValue
/>
<RefreshLabel
onClick={() => updateSlackOptions({ force: true })}
tooltipContent={t('Force refresh Slack channels list')}
/>
</div>
)}
</div>
</StyledInputContainer>

View File

@@ -72,7 +72,7 @@ from superset.reports.notifications.exceptions import (
)
from superset.tasks.utils import get_executor
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.decorators import logs_context, transaction
from superset.utils.pdf import build_pdf_from_screenshots
@@ -139,22 +139,37 @@ class BaseReportState:
slack_recipients = json.loads(recipient.recipient_config_json)
# we need to ensure that existing reports can also fetch
# 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(
{
"target": get_channels_with_search(
slack_recipients["target"],
types=[
SlackChannelTypes.PRIVATE,
SlackChannelTypes.PUBLIC,
],
)
"target": channel_ids,
}
)
except Exception as ex:
logger.warning(
"Failed to update slack recipients to v2: %s", str(ex), exc_info=True
)
raise UpdateFailedError from ex
# Revert to v1 to preserve configuration (requires manual fix)
recipient.type = ReportRecipientType.SLACK
msg = f"Failed to update slack recipients to v2: {str(ex)}"
logger.error(msg, exc_info=True)
raise UpdateFailedError(msg) from ex
def create_log(self, error_message: Optional[str] = None) -> None:
"""
@@ -571,10 +586,13 @@ class BaseReportState:
recipient.type = ReportRecipientType.SLACKV2
notification = create_notification(recipient, notification_content)
notification.send()
except (UpdateFailedError, NotificationParamException) as err:
# log the error but keep processing the report with SlackV1
logger.warning(
"Failed to update slack recipients to v2: %s", str(err)
except (UpdateFailedError, NotificationParamException) as mig_err:
notification_errors.append(
SupersetError(
message=mig_err.message,
error_type=SupersetErrorType.REPORT_NOTIFICATION_ERROR,
level=ErrorLevel.ERROR,
)
)
except (NotificationError, SupersetException) as ex:
# collect errors but keep processing them

View File

@@ -574,11 +574,15 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
"""
try:
params = kwargs.get("rison", {})
search_string = params.get("search_string")
search_string = [params.get("search_string", "").strip()]
types = params.get("types", [])
exact_match = params.get("exact_match", False)
force = params.get("force", False)
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)
except SupersetException as ex:

View File

@@ -43,7 +43,7 @@ from superset.reports.notifications.exceptions import (
)
from superset.reports.notifications.slack_mixin import SlackMixin
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.slack import (
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"]
return ",".join(get_email_address_list(recipient_str))
return ",".join(get_recipients_list(recipient_str))
def _get_inline_files(
self,

View File

@@ -42,7 +42,7 @@ from superset.reports.notifications.exceptions import (
)
from superset.reports.notifications.slack_mixin import SlackMixin
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.slack import get_slack_client
@@ -64,7 +64,7 @@ class SlackV2Notification(SlackMixin, BaseNotification): # pylint: disable=too-
""" # noqa: E501
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(
self,

View File

@@ -19,7 +19,7 @@ from typing import Any, Optional, Union
from croniter import croniter
from flask import current_app
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 pytz import all_timezones
@@ -399,3 +399,17 @@ class ReportSchedulePutSchema(Schema):
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()

View File

@@ -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)
"""
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["Subject"] = subject
@@ -711,14 +711,14 @@ def send_email_smtp( # pylint: disable=invalid-name,too-many-arguments,too-many
recipients = smtp_mail_to
if cc:
smtp_mail_cc = get_email_address_list(cc)
smtp_mail_cc = get_recipients_list(cc)
msg["Cc"] = ", ".join(smtp_mail_cc)
recipients = recipients + smtp_mail_cc
smtp_mail_bcc = []
if bcc:
# 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
msg["Date"] = formatdate(localtime=True)
@@ -811,7 +811,13 @@ def send_mime_email(
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] = []
if isinstance(address_string, str):
address_string_list = re.split(r",|\s|;", address_string)

View File

@@ -17,7 +17,7 @@
import logging
from typing import Optional
from typing import Any, Optional
from flask import current_app
from slack_sdk import WebClient
@@ -25,6 +25,9 @@ from slack_sdk.errors import SlackApiError
from superset import feature_flag_manager
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
logger = logging.getLogger(__name__)
@@ -46,61 +49,82 @@ def get_slack_client() -> WebClient:
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(
search_string: str = "",
search_string: list[str] | None = None,
limit: int = 999,
types: Optional[list[SlackChannelTypes]] = None,
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
all channels and filter them ourselves
This will search by slack name or id
"""
extra_params = {}
extra_params["types"] = ",".join(types) if types else None
try:
client = get_slack_client()
channels = []
cursor = None
extra_params = {}
extra_params["types"] = ",".join(types) if types else None
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
channels = get_channels(
limit=limit,
extra_params=extra_params,
force=force,
cache_timeout=86400,
)
except (SlackClientError, SlackApiError) as 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:
if not feature_flag_manager.is_feature_enabled("ALERT_REPORT_SLACK_V2"):

View File

@@ -308,7 +308,10 @@ def create_report_slack_chart():
def create_report_slack_chartv2():
chart = db.session.query(Slice).first()
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
@@ -1300,9 +1303,7 @@ def test_email_dashboard_report_schedule_force_screenshot(
assert_log(ReportState.SUCCESS)
@pytest.mark.usefixtures(
"load_birth_names_dashboard_with_slices", "create_report_slack_chart"
)
@pytest.mark.usefixtures("create_report_slack_chart")
@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")
@@ -1316,13 +1317,19 @@ def test_slack_chart_report_schedule_converts_to_v2(
):
"""
ExecuteReport Command: Test chart slack report schedule
while converting the recipients list to SlackV2.
"""
# setup screenshot mock
screenshot_mock.return_value = SCREENSHOT_FILE
channel_id = "slack_channel_id"
get_channels_with_search_mock.return_value = channel_id
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 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)
@pytest.mark.usefixtures(
"load_birth_names_dashboard_with_slices", "create_report_slack_chartv2"
)
@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_v2(
def test_slack_chart_report_schedule_converts_to_v2_channel_with_hash(
screenshot_mock,
slack_client_mock,
slack_should_use_v2_api_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
screenshot_mock.return_value = SCREENSHOT_FILE
channel_id = "slack_channel_id"
get_channels_with_search_mock.return_value = channel_id
chart = db.session.query(Slice).first()
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 patch.object(current_app.config["STATS_LOGGER"], "gauge") as statsd_mock:
AsyncExecuteReportScheduleCommand(
TEST_ID, create_report_slack_chart.id, datetime.utcnow()
TEST_ID, report_schedule.id, datetime.utcnow()
).run()
assert (
@@ -1395,6 +1409,12 @@ def test_slack_chart_report_schedule_v2(
== 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_log(ReportState.SUCCESS)
# 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)
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(
"load_birth_names_dashboard_with_slices", "create_report_slack_chart"

View File

@@ -119,6 +119,7 @@ def create_report_notification(
owners: Optional[list[User]] = None,
ccTarget: Optional[str] = None, # noqa: N803
bccTarget: Optional[str] = None, # noqa: N803
use_slack_v2: bool = False,
) -> ReportSchedule:
if not owners:
owners = [
@@ -130,8 +131,11 @@ def create_report_notification(
]
if slack_channel:
type = (
ReportRecipientType.SLACKV2 if use_slack_v2 else ReportRecipientType.SLACK
)
recipient = ReportRecipients(
type=ReportRecipientType.SLACK,
type=type,
recipient_config_json=json.dumps(
{
"target": slack_channel,

View File

@@ -52,7 +52,7 @@ from superset.utils.core import (
GenericDataType,
get_form_data_token,
as_list,
get_email_address_list,
get_recipients_list,
get_stacktrace,
merge_extra_filters,
merge_extra_form_data,
@@ -809,12 +809,12 @@ class TestUtils(SupersetTestCase):
assert expected_filename in path
assert os.path.exists(path)
def test_get_email_address_list(self):
assert get_email_address_list("a@a") == ["a@a"]
assert get_email_address_list(" a@a ") == ["a@a"]
assert get_email_address_list("a@a\n") == ["a@a"]
assert get_email_address_list(",a@a;") == ["a@a"]
assert get_email_address_list(",a@a; b@b c@c a-c@c; d@d, f@f") == [
def test_get_recipients_list(self):
assert get_recipients_list("a@a") == ["a@a"]
assert get_recipients_list(" a@a ") == ["a@a"]
assert get_recipients_list("a@a\n") == ["a@a"]
assert get_recipients_list(",a@a;") == ["a@a"]
assert get_recipients_list(",a@a; b@b c@c a-c@c; d@d, f@f") == [
"a@a",
"b@b",
"c@c",

View File

@@ -24,9 +24,11 @@ import pytest
from pytest_mock import MockerFixture
from superset.app import SupersetApp
from superset.commands.exceptions import UpdateFailedError
from superset.commands.report.execute import BaseReportState
from superset.dashboards.permalink.types import DashboardPermalinkState
from superset.reports.models import (
ReportRecipients,
ReportRecipientType,
ReportSchedule,
ReportScheduleType,
@@ -480,3 +482,78 @@ def test_screenshot_width_calculation(
f"Test {test_id}: Expected width {expected_width}, "
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()

View File

@@ -60,7 +60,7 @@ class TestGetChannelsWithSearch:
mock_client.conversations_list.return_value = mock_response_instance
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"}]
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)
# 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 result == [{"name": "general", "id": "C12345"}]
@@ -102,7 +102,7 @@ class TestGetChannelsWithSearch:
mocker.patch("superset.utils.slack.get_slack_client", return_value=mock_client)
result = get_channels_with_search(
search_string="general,random", exact_match=True
search_string=["general", "random"], exact_match=True
)
assert result == [
{"name": "general", "id": "C12345"},
@@ -124,7 +124,7 @@ class TestGetChannelsWithSearch:
mock_client.conversations_list.return_value = mock_response_instance
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 == [
{"name": "general", "id": "C12345"},
{"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):
mock_data = {
"channels": [
{"name": "general", "id": "C12345", "type": "public"},
{
"id": "C12345",
"name": "general",
"is_member": False,
"is_private": False,
},
],
"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)
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):
mock_data_page1 = {