diff --git a/CHANGELOG.md b/CHANGELOG.md index f7dd4d57..a52a1139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- Update Slack "invite" feature to use direct paging by @vadimkerr ([#2562](https://github.com/grafana/oncall/pull/2562)) + ## v1.3.14 (2023-07-17) ### Changed diff --git a/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py b/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py index 5f32166d..9417a250 100644 --- a/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py +++ b/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py @@ -213,12 +213,6 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): }, ) - if self.alert_group.invitations.filter(is_active=True).count() < 5: - action_id = ScenarioStep.get_step("distribute_alerts", "InviteOtherPersonToIncident").routing_uid() - text = "Invite..." - invitation_element = self._get_select_user_element(action_id, text=text) - buttons.append(invitation_element) - if not self.alert_group.silenced: silence_options = [ { @@ -245,6 +239,15 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer): }, ) + buttons.append( + { + "text": {"type": "plain_text", "text": "Responders", "emoji": True}, + "type": "button", + "value": self._alert_group_action_value(), + "action_id": ScenarioStep.get_step("manage_responders", "StartManageResponders").routing_uid(), + }, + ) + attach_button = { "text": {"type": "plain_text", "text": "Attach to ...", "emoji": True}, "type": "button", diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index e44f2527..118626e3 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -498,6 +498,23 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. def happened_while_maintenance(self): return self.root_alert_group is not None and self.root_alert_group.maintenance_uuid is not None + def get_paged_users(self) -> QuerySet[User]: + from apps.alerts.models import AlertGroupLogRecord + + users_ids = set() + for log_record in self.log_records.filter( + type__in=(AlertGroupLogRecord.TYPE_DIRECT_PAGING, AlertGroupLogRecord.TYPE_UNPAGE_USER) + ): + # filter paging events, track still active escalations + info = log_record.get_step_specific_info() + user_id = info.get("user") if info else None + if user_id is not None: + users_ids.add( + user_id + ) if log_record.type == AlertGroupLogRecord.TYPE_DIRECT_PAGING else users_ids.discard(user_id) + + return User.objects.filter(public_primary_key__in=users_ids) + def _get_response_time(self): """Return response_time based on current alert group status.""" response_time = None diff --git a/engine/apps/alerts/paging.py b/engine/apps/alerts/paging.py index 710ac486..1be4348f 100644 --- a/engine/apps/alerts/paging.py +++ b/engine/apps/alerts/paging.py @@ -28,7 +28,7 @@ ScheduleNotifications = list[tuple[OnCallSchedule, bool]] def _trigger_alert( organization: Organization, - team: Team, + team: Team | None, title: str, message: str, from_user: User, @@ -133,7 +133,7 @@ def check_user_availability(user: User) -> list[dict[str, Any]]: def direct_paging( organization: Organization, - team: Team, + team: Team | None, from_user: User, title: str = None, message: str = None, diff --git a/engine/apps/api/serializers/alert_group.py b/engine/apps/api/serializers/alert_group.py index aab66f71..77ccc536 100644 --- a/engine/apps/api/serializers/alert_group.py +++ b/engine/apps/api/serializers/alert_group.py @@ -6,8 +6,7 @@ from rest_framework import serializers from apps.alerts.incident_appearance.renderers.classic_markdown_renderer import AlertGroupClassicMarkdownRenderer from apps.alerts.incident_appearance.renderers.web_renderer import AlertGroupWebRenderer -from apps.alerts.models import AlertGroup, AlertGroupLogRecord -from apps.user_management.models import User +from apps.alerts.models import AlertGroup from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField from common.api_helpers.mixins import EagerLoadingMixin @@ -216,17 +215,4 @@ class AlertGroupSerializer(AlertGroupListSerializer): return AlertSerializer(alerts, many=True).data def get_paged_users(self, obj): - users_ids = set() - for log_record in obj.log_records.filter( - type__in=(AlertGroupLogRecord.TYPE_DIRECT_PAGING, AlertGroupLogRecord.TYPE_UNPAGE_USER) - ): - # filter paging events, track still active escalations - info = log_record.get_step_specific_info() - user_id = info.get("user") if info else None - if user_id is not None: - users_ids.add( - user_id - ) if log_record.type == AlertGroupLogRecord.TYPE_DIRECT_PAGING else users_ids.discard(user_id) - - users = [u.short() for u in User.objects.filter(public_primary_key__in=users_ids)] - return users + return [u.short() for u in obj.get_paged_users()] diff --git a/engine/apps/slack/scenarios/distribute_alerts.py b/engine/apps/slack/scenarios/distribute_alerts.py index 19f41a18..980b4472 100644 --- a/engine/apps/slack/scenarios/distribute_alerts.py +++ b/engine/apps/slack/scenarios/distribute_alerts.py @@ -212,6 +212,11 @@ class AlertShootingStep(scenario_step.ScenarioStep): class InviteOtherPersonToIncident(AlertGroupActionsMixin, scenario_step.ScenarioStep): + """ + THIS SCENARIO STEP IS DEPRECATED AND WILL BE REMOVED IN THE FUTURE. + Check out apps/slack/scenarios/manage_responders.py for the new version that uses direct paging. + """ + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] def process_scenario(self, slack_user_identity, slack_team_identity, payload): @@ -490,6 +495,11 @@ class UnAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): class StopInvitationProcess(AlertGroupActionsMixin, scenario_step.ScenarioStep): + """ + THIS SCENARIO STEP IS DEPRECATED AND WILL BE REMOVED IN THE FUTURE. + Check out apps/slack/scenarios/manage_responders.py for the new version that uses direct paging. + """ + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] def process_scenario(self, slack_user_identity, slack_team_identity, payload): diff --git a/engine/apps/slack/scenarios/manage_responders.py b/engine/apps/slack/scenarios/manage_responders.py new file mode 100644 index 00000000..96d499ff --- /dev/null +++ b/engine/apps/slack/scenarios/manage_responders.py @@ -0,0 +1,265 @@ +import json + +from django.apps import apps + +from apps.alerts.paging import check_user_availability, direct_paging, unpage_user +from apps.slack.scenarios import scenario_step +from apps.slack.scenarios.paging import ( + DIRECT_PAGING_SCHEDULE_SELECT_ID, + DIRECT_PAGING_USER_SELECT_ID, + DIVIDER_BLOCK, + _generate_input_id_prefix, + _get_availability_warnings_view, + _get_schedules_select, + _get_select_field_value, + _get_users_select, +) +from apps.slack.scenarios.step_mixins import AlertGroupActionsMixin + +MANAGE_RESPONDERS_USER_SELECT_ID = "responders_user_select" +MANAGE_RESPONDERS_SCHEDULE_SELECT_ID = "responders_schedule_select" + +USER_DATA_KEY = "user" +ALERT_GROUP_DATA_KEY = "alert_group_pk" + +# Slack scenario steps + + +class StartManageResponders(AlertGroupActionsMixin, scenario_step.ScenarioStep): + """Handle "Responders" button click.""" + + def process_scenario(self, slack_user_identity, slack_team_identity, payload): + alert_group = self.get_alert_group(slack_team_identity, payload) + if not self.is_authorized(alert_group): + self.open_unauthorized_warning(payload) + return + + view = render_dialog(alert_group) + self._slack_client.api_call( + "views.open", + trigger_id=payload["trigger_id"], + view=view, + ) + + +class ManageRespondersUserChange(scenario_step.ScenarioStep): + """Handle user selection in responders modal.""" + + def process_scenario(self, slack_user_identity, slack_team_identity, payload): + alert_group = _get_alert_group_from_payload(payload) + selected_user = _get_selected_user_from_payload(payload) + organization = alert_group.channel.organization + + # check availability + availability_warnings = check_user_availability(selected_user) + if availability_warnings: + # display warnings and require additional confirmation + view = _get_availability_warnings_view( + availability_warnings, + organization, + selected_user, + ManageRespondersConfirmUserChange.routing_uid(), + json.dumps({USER_DATA_KEY: selected_user.id, ALERT_GROUP_DATA_KEY: alert_group.pk}), + ) + self._slack_client.api_call( + "views.push", + trigger_id=payload["trigger_id"], + view=view, + ) + else: + # no warnings, proceed with paging + direct_paging( + organization=organization, + team=alert_group.channel.team, + from_user=slack_user_identity.get_user(organization), + users=[(selected_user, False)], + alert_group=alert_group, + ) + view = render_dialog(alert_group) + self._slack_client.api_call( + "views.update", + trigger_id=payload["trigger_id"], + view=view, + view_id=payload["view"]["id"], + ) + + +class ManageRespondersConfirmUserChange(scenario_step.ScenarioStep): + """Handle user confirmation on availability warnings modal.""" + + def process_scenario(self, slack_user_identity, slack_team_identity, payload): + alert_group = _get_alert_group_from_payload(payload) + selected_user = _get_selected_user_from_payload(payload) + organization = alert_group.channel.organization + + direct_paging( + organization=organization, + team=alert_group.channel.team, + from_user=slack_user_identity.get_user(organization), + users=[(selected_user, False)], + alert_group=alert_group, + ) + view = render_dialog(alert_group) + self._slack_client.api_call( + "views.update", + trigger_id=payload["trigger_id"], + view=view, + view_id=payload["view"]["previous_view_id"], + ) + + +class ManageRespondersScheduleChange(scenario_step.ScenarioStep): + """Handle schedule selection in responders modal.""" + + def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): + alert_group = _get_alert_group_from_payload(payload) + selected_schedule = _get_selected_schedule_from_payload(payload) + organization = alert_group.channel.organization + + direct_paging( + organization=organization, + team=alert_group.channel.team, + from_user=slack_user_identity.get_user(organization), + schedules=[(selected_schedule, False)], + alert_group=alert_group, + ) + self._slack_client.api_call( + "views.update", + trigger_id=payload["trigger_id"], + view=render_dialog(alert_group), + view_id=payload["view"]["id"], + ) + + +class ManageRespondersRemoveUser(scenario_step.ScenarioStep): + """Handle user removal in responders modal.""" + + def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None): + alert_group = _get_alert_group_from_payload(payload) + selected_user = _get_selected_user_from_payload(payload) + from_user = slack_user_identity.get_user(alert_group.channel.organization) + + unpage_user(alert_group, selected_user, from_user) + view = render_dialog(alert_group) + self._slack_client.api_call( + "views.update", + trigger_id=payload["trigger_id"], + view=view, + view_id=payload["view"]["id"], + ) + + +# slack view/blocks rendering helpers + + +def render_dialog(alert_group): + blocks = [] + + # Show list of users that are currently paged + paged_users = alert_group.get_paged_users() + for user in alert_group.get_paged_users(): + blocks += [ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f":bust_in_silhouette: *{user.name or user.username}*"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Remove", "emoji": True}, + "action_id": ManageRespondersRemoveUser.routing_uid(), + "value": str(user.pk), + }, + } + ] + if paged_users: + blocks += [DIVIDER_BLOCK] + + # Show user and schedule dropdowns + input_id_prefix = _generate_input_id_prefix() + blocks += [ + _get_users_select(alert_group.channel.organization, input_id_prefix, ManageRespondersUserChange.routing_uid()) + ] + blocks += [ + _get_schedules_select( + alert_group.channel.organization, input_id_prefix, ManageRespondersScheduleChange.routing_uid() + ) + ] + + view = { + "type": "modal", + "title": { + "type": "plain_text", + "text": "Additional responders", + }, + "blocks": blocks, + "private_metadata": json.dumps({ALERT_GROUP_DATA_KEY: alert_group.pk, "input_id_prefix": input_id_prefix}), + } + return view + + +def _get_selected_user_from_payload(payload): + User = apps.get_model("user_management", "User") + + try: + selected_user_id = payload["actions"][0]["value"] # "remove" button + except KeyError: + try: + # "confirm" button on availability warnings modal + selected_user_id = json.loads(payload["view"]["private_metadata"])[USER_DATA_KEY] + except KeyError: + # user select dropdown + input_id_prefix = json.loads(payload["view"]["private_metadata"])["input_id_prefix"] + selected_user_id = _get_select_field_value( + payload, input_id_prefix, ManageRespondersUserChange.routing_uid(), DIRECT_PAGING_USER_SELECT_ID + ) + + return User.objects.get(pk=selected_user_id) + + +def _get_selected_schedule_from_payload(payload): + OnCallSchedule = apps.get_model("schedules", "OnCallSchedule") + + input_id_prefix = json.loads(payload["view"]["private_metadata"])["input_id_prefix"] + selected_schedule_id = _get_select_field_value( + payload, input_id_prefix, ManageRespondersScheduleChange.routing_uid(), DIRECT_PAGING_SCHEDULE_SELECT_ID + ) + + return OnCallSchedule.objects.get(pk=selected_schedule_id) + + +def _get_alert_group_from_payload(payload): + AlertGroup = apps.get_model("alerts", "AlertGroup") + alert_group_pk = json.loads(payload["view"]["private_metadata"])[ALERT_GROUP_DATA_KEY] + return AlertGroup.all_objects.get(pk=alert_group_pk) + + +STEPS_ROUTING = [ + { + "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, + "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "block_action_id": ManageRespondersUserChange.routing_uid(), + "step": ManageRespondersUserChange, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION, + "view_callback_id": ManageRespondersConfirmUserChange.routing_uid(), + "step": ManageRespondersConfirmUserChange, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, + "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, + "block_action_id": ManageRespondersScheduleChange.routing_uid(), + "step": ManageRespondersScheduleChange, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, + "block_action_type": scenario_step.BLOCK_ACTION_TYPE_BUTTON, + "block_action_id": ManageRespondersRemoveUser.routing_uid(), + "step": ManageRespondersRemoveUser, + }, + { + "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, + "block_action_type": scenario_step.BLOCK_ACTION_TYPE_BUTTON, + "block_action_id": StartManageResponders.routing_uid(), + "step": StartManageResponders, + }, +] diff --git a/engine/apps/slack/scenarios/paging.py b/engine/apps/slack/scenarios/paging.py index ba495db6..09aec8ed 100644 --- a/engine/apps/slack/scenarios/paging.py +++ b/engine/apps/slack/scenarios/paging.py @@ -622,8 +622,8 @@ def _get_additional_responders_blocks( ] if is_additional_responders_checked: - users_select = _get_users_select(organization, input_id_prefix) - schedules_select = _get_schedules_select(organization, input_id_prefix) + users_select = _get_users_select(organization, input_id_prefix, OnPagingUserChange.routing_uid()) + schedules_select = _get_schedules_select(organization, input_id_prefix, OnPagingScheduleChange.routing_uid()) blocks += [users_select, schedules_select] # selected items @@ -639,7 +639,7 @@ def _get_additional_responders_blocks( return blocks -def _get_users_select(organization, input_id_prefix): +def _get_users_select(organization, input_id_prefix, action_id): users = organization.users.all() user_options = [ @@ -659,12 +659,12 @@ def _get_users_select(organization, input_id_prefix): user_select = { "type": "section", - "text": {"type": "mrkdwn", "text": "Add users"}, + "text": {"type": "mrkdwn", "text": "Notify user"}, "block_id": input_id_prefix + DIRECT_PAGING_USER_SELECT_ID, "accessory": { "type": "static_select", - "placeholder": {"type": "plain_text", "text": "Select a user", "emoji": True}, - "action_id": OnPagingUserChange.routing_uid(), + "placeholder": {"type": "plain_text", "text": "Select user", "emoji": True}, + "action_id": action_id, }, } MAX_STATIC_SELECT_OPTIONS = 100 @@ -687,7 +687,7 @@ def _get_users_select(organization, input_id_prefix): return user_select -def _get_schedules_select(organization, input_id_prefix): +def _get_schedules_select(organization, input_id_prefix, action_id): schedules = organization.oncall_schedules.all() schedule_options = [ @@ -706,13 +706,13 @@ def _get_schedules_select(organization, input_id_prefix): else: schedule_select = { "type": "section", - "text": {"type": "mrkdwn", "text": "Add schedules"}, + "text": {"type": "mrkdwn", "text": "Notify schedule"}, "block_id": input_id_prefix + DIRECT_PAGING_SCHEDULE_SELECT_ID, "accessory": { "type": "static_select", - "placeholder": {"type": "plain_text", "text": "Select a schedule", "emoji": True}, + "placeholder": {"type": "plain_text", "text": "Select schedule", "emoji": True}, "options": schedule_options, - "action_id": OnPagingScheduleChange.routing_uid(), + "action_id": action_id, }, } return schedule_select @@ -753,7 +753,25 @@ def _get_selected_entries_list(input_id_prefix, key, entries): def _display_availability_warnings(payload, warnings, organization, user): metadata = json.loads(payload["view"]["private_metadata"]) + return _get_availability_warnings_view( + warnings, + organization, + user, + OnPagingConfirmUserChange.routing_uid(), + 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"], + USERS_DATA_KEY: metadata[USERS_DATA_KEY], + SCHEDULES_DATA_KEY: metadata[SCHEDULES_DATA_KEY], + } + ), + ) + +def _get_availability_warnings_view(warnings, organization, user, callback_id, private_metadata): messages = [] for w in warnings: if w["error"] == USER_IS_NOT_ON_CALL: @@ -772,7 +790,7 @@ def _display_availability_warnings(payload, warnings, organization, user): return { "type": "modal", - "callback_id": OnPagingConfirmUserChange.routing_uid(), + "callback_id": callback_id, "title": {"type": "plain_text", "text": "Are you sure?"}, "submit": {"type": "plain_text", "text": "Confirm"}, "blocks": [ @@ -785,16 +803,7 @@ def _display_availability_warnings(payload, warnings, organization, user): } 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"], - USERS_DATA_KEY: metadata[USERS_DATA_KEY], - SCHEDULES_DATA_KEY: metadata[SCHEDULES_DATA_KEY], - } - ), + "private_metadata": private_metadata, } diff --git a/engine/apps/slack/tests/test_interactive_api_endpoint.py b/engine/apps/slack/tests/test_interactive_api_endpoint.py index 23a4437b..3d18dd85 100644 --- a/engine/apps/slack/tests/test_interactive_api_endpoint.py +++ b/engine/apps/slack/tests/test_interactive_api_endpoint.py @@ -6,6 +6,7 @@ from django.conf import settings from rest_framework import status from rest_framework.test import APIClient +from apps.slack.scenarios.manage_responders import ManageRespondersUserChange from apps.slack.scenarios.paging import OnPagingTeamChange from apps.slack.scenarios.scenario_step import PAYLOAD_TYPE_BLOCK_ACTIONS @@ -164,3 +165,35 @@ def test_organization_not_found_scenario_doesnt_break_direct_paging( assert response.status_code == status.HTTP_200_OK mock_on_paging_team_change.assert_called_once() + + +@patch("apps.slack.views.SlackEventApiEndpointView.verify_signature", return_value=True) +@patch.object(ManageRespondersUserChange, "process_scenario") +@pytest.mark.django_db +def test_organization_not_found_scenario_doesnt_break_manage_responders( + mock_process_scenario, + _, + make_organization, + make_slack_user_identity, + make_user, + slack_team_identity, +): + """ + Check ManageRespondersUserChange.process_scenario is called when user is notified in manage responders dialog. + """ + organization = make_organization(slack_team_identity=slack_team_identity) + slack_user_identity = make_slack_user_identity(slack_team_identity=slack_team_identity, slack_id=SLACK_USER_ID) + make_user(organization=organization, slack_user_identity=slack_user_identity) + + response = _make_request( + { + "team_id": SLACK_TEAM_ID, + "user_id": SLACK_USER_ID, + "type": "block_actions", + "actions": [{"action_id": ManageRespondersUserChange.routing_uid(), "type": "static_select"}], + "view": {"type": "modal"}, + } + ) + + assert response.status_code == status.HTTP_200_OK + mock_process_scenario.assert_called_once() diff --git a/engine/apps/slack/tests/test_scenario_steps/test_manage_responders.py b/engine/apps/slack/tests/test_scenario_steps/test_manage_responders.py new file mode 100644 index 00000000..81ba6e58 --- /dev/null +++ b/engine/apps/slack/tests/test_scenario_steps/test_manage_responders.py @@ -0,0 +1,207 @@ +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.manage_responders import ( + ALERT_GROUP_DATA_KEY, + DIRECT_PAGING_SCHEDULE_SELECT_ID, + DIRECT_PAGING_USER_SELECT_ID, + USER_DATA_KEY, + ManageRespondersRemoveUser, + ManageRespondersScheduleChange, + ManageRespondersUserChange, + StartManageResponders, +) + +ORGANIZATION_ID = 12 +ALERT_GROUP_ID = 42 +TRIGGER_ID = "111" +CHANNEL_ID = "123" +MESSAGE_TS = "67" + + +def make_slack_payload( + user=None, + schedule=None, + actions=None, +): + payload = { + "trigger_id": TRIGGER_ID, + "view": { + "id": "view-id", + "private_metadata": json.dumps({"input_id_prefix": "", ALERT_GROUP_DATA_KEY: ALERT_GROUP_ID}), + "state": { + "values": { + DIRECT_PAGING_USER_SELECT_ID: { + ManageRespondersUserChange.routing_uid(): { + "selected_option": {"value": user.pk} if user else None + } + }, + DIRECT_PAGING_SCHEDULE_SELECT_ID: { + ManageRespondersScheduleChange.routing_uid(): { + "selected_option": {"value": schedule.pk} if schedule else None + } + }, + } + }, + }, + } + if actions is not None: + payload["actions"] = actions + return payload + + +@pytest.fixture +def manage_responders_setup( + make_organization_and_user_with_slack_identities, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_slack_channel, + make_slack_message, +): + organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, pk=ALERT_GROUP_ID) + make_alert(alert_group, raw_request_data={}) + + slack_channel = make_slack_channel(slack_team_identity, slack_id=CHANNEL_ID) + slack_message = make_slack_message(alert_group=alert_group, channel_id=slack_channel.slack_id, slack_id=MESSAGE_TS) + slack_message.get_alert_group() # fix FKs + + return organization, user, slack_team_identity, slack_user_identity + + +@pytest.mark.django_db +def test_initial_state(manage_responders_setup): + payload = { + "trigger_id": TRIGGER_ID, + "actions": [ + { + "type": "button", + "value": json.dumps({"organization_id": ORGANIZATION_ID, "alert_group_pk": ALERT_GROUP_ID}), + } + ], + } + + organization, user, slack_team_identity, slack_user_identity = manage_responders_setup + + step = StartManageResponders(slack_team_identity, organization, user) + 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[ALERT_GROUP_DATA_KEY] == ALERT_GROUP_ID + + +@pytest.mark.django_db +def test_add_user_no_warning(manage_responders_setup, make_schedule, make_on_call_shift, make_user_notification_policy): + organization, user, slack_team_identity, slack_user_identity = manage_responders_setup + + # 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(user=user) + + step = ManageRespondersUserChange(slack_team_identity, organization, user) + 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",) + + # check there's a delete button for the user + assert mock_slack_api_call.call_args.kwargs["view"]["blocks"][0]["accessory"]["value"] == str(user.pk) + + +@pytest.mark.django_db +def test_add_user_raise_warning(manage_responders_setup): + organization, user, slack_team_identity, slack_user_identity = manage_responders_setup + # user is not on call + payload = make_slack_payload(user=user) + + step = ManageRespondersUserChange(slack_team_identity, organization, user) + 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"] == "ManageRespondersConfirmUserChange" + 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[USER_DATA_KEY] == user.pk + + +@pytest.mark.django_db +def test_add_schedule(manage_responders_setup, make_schedule, make_on_call_shift): + organization, user, slack_team_identity, slack_user_identity = manage_responders_setup + 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() + payload = make_slack_payload(schedule=schedule) + + step = ManageRespondersScheduleChange(slack_team_identity, organization, user) + 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",) + assert mock_slack_api_call.call_args.kwargs["view"]["blocks"][0]["accessory"]["value"] == str(user.pk) + + +@pytest.mark.django_db +def test_remove_user(manage_responders_setup): + organization, user, slack_team_identity, slack_user_identity = manage_responders_setup + + payload = make_slack_payload(actions=[{"value": user.pk}]) + step = ManageRespondersRemoveUser(slack_team_identity, organization, user) + 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",) + # check there's no list of users in the view + assert mock_slack_api_call.call_args.kwargs["view"]["blocks"][0]["accessory"]["type"] != "button" diff --git a/engine/apps/slack/tests/test_slack_renderer.py b/engine/apps/slack/tests/test_slack_renderer.py index 9c2f15ef..08531c1e 100644 --- a/engine/apps/slack/tests/test_slack_renderer.py +++ b/engine/apps/slack/tests/test_slack_renderer.py @@ -65,22 +65,18 @@ def test_slack_renderer_unresolve_button(make_organization, make_alert_receive_c @pytest.mark.django_db -def test_slack_renderer_invite_action( +def test_slack_renderer_responders_button( make_organization, make_user, make_alert_receive_channel, make_alert_group, make_alert ): organization = make_organization() - user = make_user(organization=organization) alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) make_alert(alert_group=alert_group, raw_request_data={}) elements = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[0]["blocks"][0]["elements"] - ack_button = elements[2] - assert ack_button["placeholder"]["text"] == "Invite..." - - # Check only user_id is passed. Otherwise, if there are a lot of users, the payload could be unnecessarily large. - assert json.loads(ack_button["options"][0]["value"]) == {"user_id": user.pk} + button = elements[3] + assert button["text"]["text"] == "Responders" @pytest.mark.django_db @@ -113,7 +109,7 @@ def test_slack_renderer_silence_button(make_organization, make_alert_receive_cha elements = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[0]["blocks"][0]["elements"] - button = elements[3] + button = elements[2] assert button["placeholder"]["text"] == "Silence" values = [json.loads(option["value"]) for option in button["options"]] @@ -131,7 +127,7 @@ def test_slack_renderer_unsilence_button(make_organization, make_alert_receive_c make_alert(alert_group=alert_group, raw_request_data={}) elements = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[0]["blocks"][0]["elements"] - button = elements[3] + button = elements[2] assert button["text"]["text"] == "Unsilence" assert json.loads(button["value"]) == { diff --git a/engine/apps/slack/views.py b/engine/apps/slack/views.py index f90efea7..9f94ca4f 100644 --- a/engine/apps/slack/views.py +++ b/engine/apps/slack/views.py @@ -22,6 +22,7 @@ from apps.slack.scenarios.alertgroup_appearance import STEPS_ROUTING as ALERTGRO from apps.slack.scenarios.declare_incident import STEPS_ROUTING as DECLARE_INCIDENT_ROUTING from apps.slack.scenarios.distribute_alerts import STEPS_ROUTING as DISTRIBUTION_STEPS_ROUTING from apps.slack.scenarios.invited_to_channel import STEPS_ROUTING as INVITED_TO_CHANNEL_ROUTING +from apps.slack.scenarios.manage_responders import STEPS_ROUTING as MANAGE_RESPONDERS_ROUTING from apps.slack.scenarios.manual_incident import STEPS_ROUTING as MANUAL_INCIDENT_ROUTING from apps.slack.scenarios.notified_user_not_in_channel import STEPS_ROUTING as NOTIFIED_USER_NOT_IN_CHANNEL_ROUTING from apps.slack.scenarios.onboarding import STEPS_ROUTING as ONBOARDING_STEPS_ROUTING @@ -75,6 +76,7 @@ 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(MANAGE_RESPONDERS_ROUTING) SCENARIOS_ROUTES.extend(DECLARE_INCIDENT_ROUTING) SCENARIOS_ROUTES.extend(NOTIFIED_USER_NOT_IN_CHANNEL_ROUTING)