Update Slack "invite" feature to use direct paging (#2562)

# What this PR does

Refactors the "invite" functionality in Slack to use direct paging and
be more consistent with the web UI and `/escalate` Slack command.

## Screenshots

### Alert group buttons

Before:

<img width="609" alt="Screenshot 2023-07-17 at 22 40 47"
src="https://github.com/grafana/oncall/assets/20116910/68fad5a4-5011-4d74-b1c7-362bdb4f8cf0">


After (replace "Invite..." dropdown with "Responders" button, swap it
with the silence button):
<img width="587" alt="Screenshot 2023-07-17 at 22 37 19"
src="https://github.com/grafana/oncall/assets/20116910/50b42057-f46b-4558-ab1c-56c34a15af5e">


### What happens when clicking on "Responders"

The following modal opens up with a list of currently paged users and
inputs to page more users/schedules:

<img width="514" alt="Screenshot 2023-07-17 at 22 37 52"
src="https://github.com/grafana/oncall/assets/20116910/70bd2853-d459-4343-8b25-8519ac0098f7">

This is supposed to be the Slack equivalent of this part of the web UI:

<img width="601" alt="Screenshot 2023-07-17 at 22 47 17"
src="https://github.com/grafana/oncall/assets/20116910/101e1229-a5c4-404f-8388-eceee3e4820f">


## Which issue(s) this PR fixes

https://github.com/grafana/oncall/issues/2336

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
This commit is contained in:
Vadim Stepanov 2023-07-18 09:36:11 +01:00 committed by GitHub
parent 8e3ceb4704
commit 56743857ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 586 additions and 54 deletions

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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,

View file

@ -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()]

View file

@ -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):

View file

@ -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,
},
]

View file

@ -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,
}

View file

@ -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()

View file

@ -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"

View file

@ -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"]) == {

View file

@ -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)