From 693b5a41c45b1b63d029b62c1255358caaaeb308 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Fri, 20 Jan 2023 09:06:27 -0300 Subject: [PATCH] Add slack command to trigger direct paging (#1154) Slash command needs to be added to slack app manifest: ``` slash_commands: - command: /escalate url: https:///slack/interactive_api_endpoint/ description: Create a new alert group escalation should_escape: false ``` --- CHANGELOG.md | 6 + engine/apps/slack/scenarios/paging.py | 663 ++++++++++++++++++ .../tests/test_scenario_steps/test_paging.py | 183 +++++ engine/apps/slack/views.py | 2 + engine/settings/base.py | 1 + 5 files changed, 855 insertions(+) create mode 100644 engine/apps/slack/scenarios/paging.py create mode 100644 engine/apps/slack/tests/test_scenario_steps/test_paging.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a7d08a1b..f9d4779d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Add Slack slash command allowing to trigger a direct page via a manually created alert group + ## v1.1.18 (2023-01-18) ### Added diff --git a/engine/apps/slack/scenarios/paging.py b/engine/apps/slack/scenarios/paging.py new file mode 100644 index 00000000..34d8eec4 --- /dev/null +++ b/engine/apps/slack/scenarios/paging.py @@ -0,0 +1,663 @@ +import json +from uuid import uuid4 + +from django.apps import apps +from django.conf import settings + +from apps.alerts.paging import ( + USER_HAS_NO_NOTIFICATION_POLICY, + USER_IS_NOT_ON_CALL, + check_user_availability, + direct_paging, +) +from apps.slack.scenarios import scenario_step +from apps.slack.slack_client.exceptions import SlackAPIException + +DIRECT_PAGING_TEAM_SELECT_ID = "paging_team_select" +DIRECT_PAGING_ORG_SELECT_ID = "paging_org_select" +DIRECT_PAGING_ROUTE_SELECT_ID = "paging_route_select" +DIRECT_PAGING_USER_SELECT_ID = "paging_user_select" +DIRECT_PAGING_TITLE_INPUT_ID = "paging_title_input" +DIRECT_PAGING_MESSAGE_INPUT_ID = "paging_message_input" + +DEFAULT_TEAM_VALUE = "default_team" + + +# selected user available actions +DEFAULT_POLICY = "default" +IMPORTANT_POLICY = "important" +REMOVE_ACTION = "remove" + +USER_ACTIONS = ( + (DEFAULT_POLICY, "Set default notification policy"), + (IMPORTANT_POLICY, "Set important notification policy"), + (REMOVE_ACTION, "Remove from escalation"), +) + + +# helpers to manage current selected users state + + +def add_or_update_user(payload, user_pk, policy): + metadata = json.loads(payload["view"]["private_metadata"]) + metadata["current_users"][user_pk] = policy + payload["view"]["private_metadata"] = json.dumps(metadata) + return payload + + +def remove_user(payload, user_pk): + metadata = json.loads(payload["view"]["private_metadata"]) + if user_pk in metadata["current_users"]: + del metadata["current_users"][user_pk] + payload["view"]["private_metadata"] = json.dumps(metadata) + return payload + + +def reset_users(payload): + metadata = json.loads(payload["view"]["private_metadata"]) + metadata["current_users"] = {} + payload["view"]["private_metadata"] = json.dumps(metadata) + return payload + + +def get_current_users(payload, organization): + metadata = json.loads(payload["view"]["private_metadata"]) + current_users = [] + for u, p in metadata["current_users"].items(): + user = organization.users.filter(pk=u).first() + current_users.append((user, p)) + return current_users + + +# Slack scenario steps + + +class StartDirectPaging(scenario_step.ScenarioStep): + """Handle slash command invocation and show initial dialog.""" + + command_name = [settings.SLACK_DIRECT_PAGING_SLASH_COMMAND] + + def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): + input_id_prefix = _generate_input_id_prefix() + + try: + channel_id = payload["event"]["channel"] + except KeyError: + channel_id = payload["channel_id"] + + private_metadata = { + "channel_id": channel_id, + "input_id_prefix": input_id_prefix, + "submit_routing_uid": FinishDirectPaging.routing_uid(), + "current_users": {}, + } + + blocks = _get_initial_form_fields(slack_team_identity, slack_user_identity, input_id_prefix, payload) + view = _get_form_view(FinishDirectPaging.routing_uid(), blocks, json.dumps(private_metadata)) + self._slack_client.api_call( + "views.open", + trigger_id=payload["trigger_id"], + view=view, + ) + + +class FinishDirectPaging(scenario_step.ScenarioStep): + """Handle page command dialog submit.""" + + def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): + title = _get_title_from_payload(payload) + message = _get_message_from_payload(payload) + private_metadata = json.loads(payload["view"]["private_metadata"]) + channel_id = private_metadata["channel_id"] + input_id_prefix = private_metadata["input_id_prefix"] + selected_organization = _get_selected_org_from_payload(payload, input_id_prefix) + selected_team = _get_selected_team_from_payload(payload, input_id_prefix) + user = slack_user_identity.get_user(selected_organization) + selected_users = [(u, p == IMPORTANT_POLICY) for u, p in get_current_users(payload, selected_organization)] + + # trigger direct paging to selected users + direct_paging(selected_organization, selected_team, user, title, message, selected_users) + + try: + self._slack_client.api_call( + "chat.postEphemeral", + channel=channel_id, + user=slack_user_identity.slack_id, + text=":white_check_mark: Alert *{}* successfully submitted".format(title), + ) + except SlackAPIException as e: + if e.response["error"] == "channel_not_found": + self._slack_client.api_call( + "chat.postEphemeral", + channel=slack_user_identity.im_channel_id, + user=slack_user_identity.slack_id, + text=":white_check_mark: Alert *{}* successfully submitted".format(title), + ) + else: + raise e + + +# OnChange steps, responsible for rerendering form on changed values + + +class OnOrgChange(scenario_step.ScenarioStep): + """Reload form with updated organization.""" + + def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): + updated_payload = reset_users(payload) + view = render_dialog(slack_user_identity, slack_team_identity, updated_payload) + self._slack_client.api_call( + "views.update", + trigger_id=payload["trigger_id"], + view=view, + view_id=payload["view"]["id"], + ) + + +class OnTeamChange(OnOrgChange): + """Reload form with updated team.""" + + +class OnUserChange(scenario_step.ScenarioStep): + """Add selected to user to the list. + + It will perform a user availability check, pushing a new modal for additional confirmation if needed. + """ + + def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): + private_metadata = json.loads(payload["view"]["private_metadata"]) + selected_organization = _get_selected_org_from_payload(payload, private_metadata["input_id_prefix"]) + selected_team = _get_selected_team_from_payload(payload, private_metadata["input_id_prefix"]) + selected_user = _get_selected_user_from_payload(payload, private_metadata["input_id_prefix"]) + if selected_user is None: + return + + # check availability + availability_warnings = check_user_availability(selected_user, selected_team) + if availability_warnings: + # display warnings and require additional confirmation + view = _display_availability_warnings(payload, availability_warnings, selected_organization, selected_user) + self._slack_client.api_call( + "views.push", + trigger_id=payload["trigger_id"], + view=view, + ) + else: + # user is available to be paged + updated_payload = add_or_update_user(payload, selected_user.pk, DEFAULT_POLICY) + view = render_dialog(slack_user_identity, slack_team_identity, updated_payload) + self._slack_client.api_call( + "views.update", + trigger_id=payload["trigger_id"], + view=view, + view_id=payload["view"]["id"], + ) + + +class OnUserActionChange(scenario_step.ScenarioStep): + """Reload form with updated user details.""" + + def _parse_action(self, payload): + value = payload["actions"][0]["selected_option"]["value"] + return value.split("|") + + def process_scenario(self, slack_user_identity, slack_team_identity, payload, policy=None): + policy, user_pk = self._parse_action(payload) + + if policy == REMOVE_ACTION: + updated_payload = remove_user(payload, user_pk) + else: + updated_payload = add_or_update_user(payload, user_pk, policy) + + view = render_dialog(slack_user_identity, slack_team_identity, updated_payload) + self._slack_client.api_call( + "views.update", + trigger_id=payload["trigger_id"], + view=view, + view_id=payload["view"]["id"], + ) + + +class OnConfirmUserChange(scenario_step.ScenarioStep): + """Confirm user selection despite not being available.""" + + def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): + metadata = json.loads(payload["view"]["private_metadata"]) + + # recreate original view state and metadata + private_metadata = { + "channel_id": metadata["channel_id"], + "input_id_prefix": metadata["input_id_prefix"], + "submit_routing_uid": metadata["submit_routing_uid"], + "current_users": metadata["current_users"], + } + previous_view_payload = { + "view": { + "state": metadata["state"], + "private_metadata": json.dumps(private_metadata), + }, + } + # add selected user + selected_user = _get_selected_user_from_payload(previous_view_payload, private_metadata["input_id_prefix"]) + updated_payload = add_or_update_user(previous_view_payload, selected_user.pk, DEFAULT_POLICY) + view = render_dialog(slack_user_identity, slack_team_identity, updated_payload) + self._slack_client.api_call( + "views.update", + trigger_id=payload["trigger_id"], + view=view, + view_id=payload["view"]["previous_view_id"], + ) + + +# slack view/blocks rendering helpers + + +def render_dialog(slack_user_identity, slack_team_identity, payload): + # data/state + private_metadata = json.loads(payload["view"]["private_metadata"]) + submit_routing_uid = private_metadata.get("submit_routing_uid") + old_input_id_prefix, new_input_id_prefix, new_private_metadata = _get_and_change_input_id_prefix_from_metadata( + private_metadata + ) + selected_organization = _get_selected_org_from_payload(payload, old_input_id_prefix) + selected_team = _get_selected_team_from_payload(payload, old_input_id_prefix) + selected_user = _get_selected_user_from_payload(payload, old_input_id_prefix) + + # widgets + organization_select = _get_organization_select( + slack_team_identity, slack_user_identity, selected_organization, new_input_id_prefix + ) + team_select = _get_team_select(slack_user_identity, selected_organization, selected_team, new_input_id_prefix) + users_select = _get_users_select( + slack_user_identity, selected_organization, selected_team, selected_user, new_input_id_prefix + ) + + # blocks + blocks = [organization_select, team_select, users_select] + selected_users = get_current_users(payload, selected_organization) + blocks.extend(_get_selected_users_list(new_input_id_prefix, selected_users)) + blocks.extend([_get_title_input(payload), _get_message_input(payload)]) + + view = _get_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata)) + return view + + +def _get_form_view(routing_uid, blocks, private_metatada): + view = { + "type": "modal", + "callback_id": routing_uid, + "title": { + "type": "plain_text", + "text": "Create alert group", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + "emoji": True, + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "blocks": blocks, + "private_metadata": private_metatada, + } + + return view + + +def _get_initial_form_fields(slack_team_identity, slack_user_identity, input_id_prefix, payload): + initial_organization = ( + slack_team_identity.organizations.filter(users__slack_user_identity=slack_user_identity) + .order_by("pk") + .distinct() + .first() + ) + + organization_select = _get_organization_select( + slack_team_identity, slack_user_identity, initial_organization, input_id_prefix + ) + + initial_team = None # means default team + initial_user = None # no user + team_select = _get_team_select(slack_user_identity, initial_organization, initial_team, input_id_prefix) + users_select = _get_users_select( + slack_user_identity, initial_organization, initial_team, initial_user, input_id_prefix + ) + + blocks = [organization_select, team_select, users_select] + title_input = _get_title_input(payload) + message_input = _get_message_input(payload) + blocks.append(title_input) + blocks.append(message_input) + return blocks + + +def _get_organization_select(slack_team_identity, slack_user_identity, value, input_id_prefix): + organizations = slack_team_identity.organizations.filter( + users__slack_user_identity=slack_user_identity, + ).distinct() + organizations_options = [] + initial_option_idx = 0 + for idx, org in enumerate(organizations): + if org == value: + initial_option_idx = idx + organizations_options.append( + { + "text": { + "type": "plain_text", + "text": f"{org.stack_slug}", + "emoji": True, + }, + "value": f"{org.pk}", + } + ) + + organization_select = { + "type": "section", + "text": {"type": "mrkdwn", "text": "Select an organization"}, + "block_id": input_id_prefix + DIRECT_PAGING_ORG_SELECT_ID, + "accessory": { + "type": "static_select", + "placeholder": {"type": "plain_text", "text": "Select an organization", "emoji": True}, + "options": organizations_options, + "action_id": OnOrgChange.routing_uid(), + "initial_option": organizations_options[initial_option_idx], + }, + } + + return organization_select + + +def _get_selected_org_from_payload(payload, input_id_prefix): + Organization = apps.get_model("user_management", "Organization") + selected_org_id = payload["view"]["state"]["values"][input_id_prefix + DIRECT_PAGING_ORG_SELECT_ID][ + OnOrgChange.routing_uid() + ]["selected_option"]["value"] + org = Organization.objects.filter(pk=selected_org_id).first() + return org + + +def _get_team_select(slack_user_identity, organization, value, input_id_prefix): + teams = organization.teams.filter( + users__slack_user_identity=slack_user_identity, + ).distinct() + + team_options = [] + # Adding pseudo option for default team + initial_option_idx = 0 + team_options.append( + { + "text": { + "type": "plain_text", + "text": f"General", + "emoji": True, + }, + "value": DEFAULT_TEAM_VALUE, + } + ) + for idx, team in enumerate(teams, start=1): + if team == value: + initial_option_idx = idx + team_options.append( + { + "text": { + "type": "plain_text", + "text": f"{team.name}", + "emoji": True, + }, + "value": f"{team.pk}", + } + ) + + team_select = { + "type": "section", + "text": {"type": "mrkdwn", "text": "Select a team"}, + "block_id": input_id_prefix + DIRECT_PAGING_TEAM_SELECT_ID, + "accessory": { + "type": "static_select", + "placeholder": {"type": "plain_text", "text": "Select a team", "emoji": True}, + "options": team_options, + "action_id": OnTeamChange.routing_uid(), + "initial_option": team_options[initial_option_idx], + }, + } + return team_select + + +def _get_users_select(slack_user_identity, organization, team, value, input_id_prefix): + users = organization.users.all() + if team is not None: + users = users.filter(teams=team) + + user_options = [ + { + "text": { + "type": "plain_text", + "text": f"{user.name or user.username}", + "emoji": True, + }, + "value": f"{user.pk}", + } + for user in users + ] + + user_select = { + "type": "section", + "text": {"type": "mrkdwn", "text": "Add responders"}, + "block_id": input_id_prefix + DIRECT_PAGING_USER_SELECT_ID, + "accessory": { + "type": "static_select", + "placeholder": {"type": "plain_text", "text": "Select a user", "emoji": True}, + "options": user_options, + "action_id": OnUserChange.routing_uid(), + }, + } + return user_select + + +def _get_selected_users_list(input_id_prefix, users): + user_entries = ( + [{"type": "divider"}] + + [ + { + "type": "section", + "block_id": input_id_prefix + f"user_{u.pk}", + "text": {"type": "mrkdwn", "text": f"*{u.name or u.username}* | {p} notifications\n_({u.timezone})_"}, + "accessory": { + "type": "overflow", + "options": [ + {"text": {"type": "plain_text", "text": f"{label}"}, "value": f"{action}|{u.pk}"} + for (action, label) in USER_ACTIONS + ], + "action_id": OnUserActionChange.routing_uid(), + }, + } + for u, p in users + ] + + [{"type": "divider"}] + ) + return user_entries + + +def _display_availability_warnings(payload, warnings, organization, user): + metadata = json.loads(payload["view"]["private_metadata"]) + + messages = [] + for w in warnings: + if w["error"] == USER_IS_NOT_ON_CALL: + messages.append( + f":warning: User *{user.name or user.username}* is not on-call.\nWe recommend you to select on-call users first." + ) + schedules_available = w["data"].get("schedules", {}) + if schedules_available: + messages.append(":information_source: Currently on-call from schedules:") + for schedule, users in schedules_available.items(): + oncall_users = organization.users.filter(public_primary_key__in=users) + usernames = ", ".join(f"*{u.name or u.username}*" for u in oncall_users) + messages.append(f":spiral_calendar_pad: {schedule}: {usernames}") + elif w["error"] == USER_HAS_NO_NOTIFICATION_POLICY: + messages.append(f":warning: User *{user.name or user.username}* has no notification policy setup.") + + return { + "type": "modal", + "callback_id": OnConfirmUserChange.routing_uid(), + "title": {"type": "plain_text", "text": "Are you sure?"}, + "submit": {"type": "plain_text", "text": "Confirm"}, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": message, + }, + } + for message in messages + ], + "private_metadata": json.dumps( + { + "state": payload["view"]["state"], + "input_id_prefix": metadata["input_id_prefix"], + "channel_id": metadata["channel_id"], + "submit_routing_uid": metadata["submit_routing_uid"], + "current_users": metadata["current_users"], + } + ), + } + + +def _get_selected_team_from_payload(payload, input_id_prefix): + Team = apps.get_model("user_management", "Team") + selected_team_id = payload["view"]["state"]["values"][input_id_prefix + DIRECT_PAGING_TEAM_SELECT_ID][ + OnTeamChange.routing_uid() + ]["selected_option"]["value"] + if selected_team_id == DEFAULT_TEAM_VALUE: + return None + team = Team.objects.filter(pk=selected_team_id).first() + return team + + +def _get_selected_user_from_payload(payload, input_id_prefix): + User = apps.get_model("user_management", "User") + selected_option = payload["view"]["state"]["values"][input_id_prefix + DIRECT_PAGING_USER_SELECT_ID][ + OnUserChange.routing_uid() + ]["selected_option"] + if selected_option is not None: + selected_user_id = selected_option["value"] + user = User.objects.filter(pk=selected_user_id).first() + return user + + +def _get_and_change_input_id_prefix_from_metadata(metadata): + old_input_id_prefix = metadata["input_id_prefix"] + new_input_id_prefix = _generate_input_id_prefix() + metadata["input_id_prefix"] = new_input_id_prefix + return old_input_id_prefix, new_input_id_prefix, metadata + + +def _get_title_input(payload): + title_input_block = { + "type": "input", + "block_id": DIRECT_PAGING_TITLE_INPUT_ID, + "label": { + "type": "plain_text", + "text": "Title:", + }, + "element": { + "type": "plain_text_input", + "action_id": FinishDirectPaging.routing_uid(), + "placeholder": { + "type": "plain_text", + "text": " ", + }, + }, + } + if payload.get("text", None) is not None: + title_input_block["element"]["initial_value"] = payload["text"] + return title_input_block + + +def _get_title_from_payload(payload): + title = payload["view"]["state"]["values"][DIRECT_PAGING_TITLE_INPUT_ID][FinishDirectPaging.routing_uid()]["value"] + return title + + +def _get_message_input(payload): + message_input_block = { + "type": "input", + "block_id": DIRECT_PAGING_MESSAGE_INPUT_ID, + "label": { + "type": "plain_text", + "text": "Message:", + }, + "element": { + "type": "plain_text_input", + "action_id": FinishDirectPaging.routing_uid(), + "multiline": True, + "placeholder": { + "type": "plain_text", + "text": " ", + }, + }, + "optional": True, + } + if payload.get("message", {}).get("text") is not None: + message_input_block["element"]["initial_value"] = payload["message"]["text"] + return message_input_block + + +def _get_message_from_payload(payload): + message = ( + payload["view"]["state"]["values"][DIRECT_PAGING_MESSAGE_INPUT_ID][FinishDirectPaging.routing_uid()]["value"] + or "" + ) + return message + + +# _generate_input_id_prefix returns uniq str to not to preserve input's values between view update +# https://api.slack.com/methods/views.update#markdown +def _generate_input_id_prefix(): + return str(uuid4()) + + +STEPS_ROUTING = [ + { + "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, + "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "block_action_id": OnOrgChange.routing_uid(), + "step": OnOrgChange, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, + "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "block_action_id": OnTeamChange.routing_uid(), + "step": OnTeamChange, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, + "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "block_action_id": OnUserChange.routing_uid(), + "step": OnUserChange, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION, + "view_callback_id": OnConfirmUserChange.routing_uid(), + "step": OnConfirmUserChange, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, + "block_action_type": scenario_step.BLOCK_ACTION_TYPE_OVERFLOW, + "block_action_id": OnUserActionChange.routing_uid(), + "step": OnUserActionChange, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_SLASH_COMMAND, + "command_name": StartDirectPaging.command_name, + "step": StartDirectPaging, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION, + "view_callback_id": FinishDirectPaging.routing_uid(), + "step": FinishDirectPaging, + }, +] diff --git a/engine/apps/slack/tests/test_scenario_steps/test_paging.py b/engine/apps/slack/tests/test_scenario_steps/test_paging.py new file mode 100644 index 00000000..9dcdb2a3 --- /dev/null +++ b/engine/apps/slack/tests/test_scenario_steps/test_paging.py @@ -0,0 +1,183 @@ +import json +from unittest.mock import patch + +import pytest +from django.utils import timezone + +from apps.base.models import UserNotificationPolicy +from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb +from apps.slack.scenarios.paging import ( + DEFAULT_POLICY, + DIRECT_PAGING_MESSAGE_INPUT_ID, + DIRECT_PAGING_ORG_SELECT_ID, + DIRECT_PAGING_TEAM_SELECT_ID, + DIRECT_PAGING_TITLE_INPUT_ID, + DIRECT_PAGING_USER_SELECT_ID, + IMPORTANT_POLICY, + REMOVE_ACTION, + FinishDirectPaging, + OnOrgChange, + OnTeamChange, + OnUserActionChange, + OnUserChange, + StartDirectPaging, +) + + +def make_slack_payload(organization, user=None, current_users=None, actions=None): + payload = { + "channel_id": "123", + "trigger_id": "111", + "view": { + "id": "view-id", + "private_metadata": json.dumps( + { + "input_id_prefix": "", + "channel_id": "123", + "submit_routing_uid": "FinishStepUID", + "current_users": current_users or {}, + } + ), + "state": { + "values": { + DIRECT_PAGING_ORG_SELECT_ID: { + OnOrgChange.routing_uid(): {"selected_option": {"value": organization.pk}} + }, + DIRECT_PAGING_TEAM_SELECT_ID: {OnTeamChange.routing_uid(): {"selected_option": {"value": 0}}}, + DIRECT_PAGING_USER_SELECT_ID: { + OnUserChange.routing_uid(): {"selected_option": {"value": user.pk} if user else None} + }, + DIRECT_PAGING_TITLE_INPUT_ID: {FinishDirectPaging.routing_uid(): {"value": "The Title"}}, + DIRECT_PAGING_MESSAGE_INPUT_ID: {FinishDirectPaging.routing_uid(): {"value": "The Message"}}, + } + }, + }, + } + if actions is not None: + payload["actions"] = actions + return payload + + +@pytest.mark.django_db +def test_initial_users( + make_organization_and_user_with_slack_identities, +): + organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() + payload = {"channel_id": "123", "trigger_id": "111"} + + step = StartDirectPaging(slack_team_identity) + with patch.object(step._slack_client, "api_call") as mock_slack_api_call: + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + assert mock_slack_api_call.call_args.args == ("views.open",) + metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"]) + assert metadata["current_users"] == {} + + +@pytest.mark.django_db +def test_add_user_no_warning( + make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift, make_user_notification_policy +): + organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() + # set up schedule: user is on call + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + team=None, + ) + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_date = now - timezone.timedelta(days=7) + data = { + "start": start_date, + "rotation_start": start_date, + "duration": timezone.timedelta(hours=23, minutes=59, seconds=59), + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + schedule.refresh_ical_file() + # setup notification policy + make_user_notification_policy( + user=user, + step=UserNotificationPolicy.Step.NOTIFY, + notify_by=UserNotificationPolicy.NotificationChannel.SMS, + ) + + payload = make_slack_payload(organization=organization, user=user) + + step = OnUserChange(slack_team_identity) + with patch.object(step._slack_client, "api_call") as mock_slack_api_call: + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + assert mock_slack_api_call.call_args.args == ("views.update",) + metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"]) + assert metadata["current_users"] == {str(user.pk): DEFAULT_POLICY} + + +@pytest.mark.django_db +def test_add_user_raise_warning(make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift): + organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() + # user is not on call + payload = make_slack_payload(organization=organization, user=user) + + step = OnUserChange(slack_team_identity) + with patch.object(step._slack_client, "api_call") as mock_slack_api_call: + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + assert mock_slack_api_call.call_args.args == ("views.push",) + assert mock_slack_api_call.call_args.kwargs["view"]["callback_id"] == "OnConfirmUserChange" + text_from_blocks = "".join( + b["text"]["text"] for b in mock_slack_api_call.call_args.kwargs["view"]["blocks"] if b["type"] == "section" + ) + assert f"*{user.username}* is not on-call" in text_from_blocks + metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"]) + assert metadata["current_users"] == {} + + +@pytest.mark.django_db +def test_change_user_policy(make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift): + organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() + payload = make_slack_payload( + organization=organization, actions=[{"selected_option": {"value": f"{IMPORTANT_POLICY}|{user.pk}"}}] + ) + + step = OnUserActionChange(slack_team_identity) + with patch.object(step._slack_client, "api_call") as mock_slack_api_call: + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + assert mock_slack_api_call.call_args.args == ("views.update",) + metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"]) + assert metadata["current_users"] == {str(user.pk): IMPORTANT_POLICY} + + +@pytest.mark.django_db +def test_remove_user(make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift): + organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() + payload = make_slack_payload( + organization=organization, actions=[{"selected_option": {"value": f"{REMOVE_ACTION}|{user.pk}"}}] + ) + + step = OnUserActionChange(slack_team_identity) + with patch.object(step._slack_client, "api_call") as mock_slack_api_call: + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + assert mock_slack_api_call.call_args.args == ("views.update",) + metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"]) + assert metadata["current_users"] == {} + + +@pytest.mark.django_db +def test_trigger_paging(make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift): + organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() + payload = make_slack_payload(organization=organization, current_users={str(user.pk): IMPORTANT_POLICY}) + + step = FinishDirectPaging(slack_team_identity) + with patch("apps.slack.scenarios.paging.direct_paging") as mock_direct_paging: + with patch.object(step._slack_client, "api_call"): + step.process_scenario(slack_user_identity, slack_team_identity, payload) + + assert mock_direct_paging.called_with(organization, None, user, "The Title", "The Message", [(user, True)]) diff --git a/engine/apps/slack/views.py b/engine/apps/slack/views.py index 4aa8c730..458caadf 100644 --- a/engine/apps/slack/views.py +++ b/engine/apps/slack/views.py @@ -22,6 +22,7 @@ from apps.slack.scenarios.distribute_alerts import STEPS_ROUTING as DISTRIBUTION from apps.slack.scenarios.invited_to_channel import STEPS_ROUTING as INVITED_TO_CHANNEL_ROUTING from apps.slack.scenarios.manual_incident import STEPS_ROUTING as MANUAL_INCIDENT_ROUTING from apps.slack.scenarios.onboarding import STEPS_ROUTING as ONBOARDING_STEPS_ROUTING +from apps.slack.scenarios.paging import STEPS_ROUTING as DIRECT_PAGE_ROUTING from apps.slack.scenarios.profile_update import STEPS_ROUTING as PROFILE_UPDATE_ROUTING from apps.slack.scenarios.resolution_note import STEPS_ROUTING as RESOLUTION_NOTE_ROUTING from apps.slack.scenarios.scenario_step import ( @@ -69,6 +70,7 @@ SCENARIOS_ROUTES.extend(SLACK_USERGROUP_UPDATE_ROUTING) SCENARIOS_ROUTES.extend(CHANNEL_ROUTING) SCENARIOS_ROUTES.extend(PROFILE_UPDATE_ROUTING) SCENARIOS_ROUTES.extend(MANUAL_INCIDENT_ROUTING) +SCENARIOS_ROUTES.extend(DIRECT_PAGE_ROUTING) SCENARIOS_ROUTES.extend(DECLARE_INCIDENT_ROUTING) logger = logging.getLogger(__name__) diff --git a/engine/settings/base.py b/engine/settings/base.py index fb60c6cb..101159aa 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -501,6 +501,7 @@ SLACK_CLIENT_OAUTH_ID = os.environ.get("SLACK_CLIENT_OAUTH_ID") SLACK_CLIENT_OAUTH_SECRET = os.environ.get("SLACK_CLIENT_OAUTH_SECRET") SLACK_SLASH_COMMAND_NAME = os.environ.get("SLACK_SLASH_COMMAND_NAME", "/oncall") +SLACK_DIRECT_PAGING_SLASH_COMMAND = os.environ.get("SLACK_DIRECT_PAGING_SLASH_COMMAND", "/escalate") SOCIAL_AUTH_SLACK_LOGIN_KEY = SLACK_CLIENT_OAUTH_ID SOCIAL_AUTH_SLACK_LOGIN_SECRET = SLACK_CLIENT_OAUTH_SECRET