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:
parent
8e3ceb4704
commit
56743857ee
12 changed files with 586 additions and 54 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
265
engine/apps/slack/scenarios/manage_responders.py
Normal file
265
engine/apps/slack/scenarios/manage_responders.py
Normal 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,
|
||||
},
|
||||
]
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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"]) == {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue