Direct paging improvements (#2537)

# What this PR does

- Deprecates `/oncall` Slack command in favour of `/esalate` (direct
paging) + fixes a regression bug in both commands
- Unifies direct paging UX across Slack & Web UI (or at least makes an
attempt to make things more similar). Kudos to @iskhakov for all the
great work on this recently!
- A bunch of minor changes that hopefully make direct paging more usable
- TODO: documentation updates will be added in a separate PR

## Screenshots

### No issues scenario

Slack:

<img width="522" alt="Screenshot 2023-07-14 at 23 53 11"
src="https://github.com/grafana/oncall/assets/20116910/ec15a18f-d817-4177-b1f2-6b89d79bb361">


Web UI: 

<img width="1172" alt="Screenshot 2023-07-14 at 23 52 25"
src="https://github.com/grafana/oncall/assets/20116910/813f967c-2fdd-4868-9287-487dbfa7cea6">


### Not configured scenario

Slack:

<img width="519" alt="Screenshot 2023-07-14 at 23 45 22"
src="https://github.com/grafana/oncall/assets/20116910/932fa05c-81ea-42ca-be80-41b05f767d3e">

Web UI:

<img width="1172" alt="Screenshot 2023-07-14 at 23 47 31"
src="https://github.com/grafana/oncall/assets/20116910/6bcb07e4-2e50-4120-9fac-be8b0277e181">

### `/oncall` deprecation warning

<img width="521" alt="Screenshot 2023-07-17 at 10 31 56"
src="https://github.com/grafana/oncall/assets/20116910/4ff28337-1693-4af0-81d9-9eda90099c1b">


## Which issue(s) this PR fixes

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

## 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-17 14:21:56 +01:00 committed by GitHub
parent 78df72bef9
commit 69bafb61f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 433 additions and 289 deletions

View file

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Added `PHONE_PROVIDER` configuration check by @sreway ([#2523](https://github.com/grafana/oncall/pull/2523))
- Deprecate `/oncall` Slack command, update direct paging functionality by @vadimkerr ([#2537](https://github.com/grafana/oncall/pull/2537))
## v1.3.13 (2023-07-17)

View file

@ -42,7 +42,7 @@ def _trigger_alert(
deleted_at=None,
defaults={
"author": from_user,
"verbal_name": "Direct paging",
"verbal_name": f"Direct paging ({team.name if team else 'No'} team)",
},
)
if alert_receive_channel.default_channel_filter is None:
@ -90,7 +90,7 @@ def _trigger_alert(
return alert.group
def check_user_availability(user: User, team: Team) -> list[dict[str, Any]]:
def check_user_availability(user: User) -> list[dict[str, Any]]:
"""Check user availability to be paged.
Return a warnings list indicating `error` and any additional related `data`.
@ -108,7 +108,6 @@ def check_user_availability(user: User, team: Team) -> list[dict[str, Any]]:
schedules = OnCallSchedule.objects.filter(
Q(cached_ical_file_primary__contains=user.username) | Q(cached_ical_file_primary__contains=user.email),
organization=user.organization,
team=team,
)
schedules_data = {}
for s in schedules:

View file

@ -70,7 +70,7 @@ def test_check_user_availability_no_policies(make_organization, make_user_for_or
organization = make_organization()
user = make_user_for_organization(organization)
warnings = check_user_availability(user, None)
warnings = check_user_availability(user)
assert warnings == [
{"data": {}, "error": USER_HAS_NO_NOTIFICATION_POLICY},
{"data": {"schedules": {}}, "error": USER_IS_NOT_ON_CALL},
@ -95,40 +95,12 @@ def test_check_user_availability_not_on_call(
make_schedule, make_on_call_shift, organization, None, other_user, extra_users=[user]
)
warnings = check_user_availability(user, None)
warnings = check_user_availability(user)
assert warnings == [
{"data": {"schedules": {schedule.name: {other_user.public_primary_key}}}, "error": USER_IS_NOT_ON_CALL},
]
@pytest.mark.django_db
def test_check_user_availability_on_call_different_team(
make_organization,
make_team,
make_user_for_organization,
make_user_notification_policy,
make_schedule,
make_on_call_shift,
):
organization = make_organization()
some_team = make_team(organization)
user = make_user_for_organization(organization)
make_user_notification_policy(
user=user,
step=UserNotificationPolicy.Step.NOTIFY,
notify_by=UserNotificationPolicy.NotificationChannel.SMS,
)
# setup on call schedule
# user is on call, but on a different team
setup_always_on_call_schedule(make_schedule, make_on_call_shift, organization, some_team, user)
warnings = check_user_availability(user, None)
assert warnings == [
{"data": {"schedules": {}}, "error": USER_IS_NOT_ON_CALL},
]
@pytest.mark.django_db
def test_check_user_availability_on_call(
make_organization,
@ -150,7 +122,7 @@ def test_check_user_availability_on_call(
# setup on call schedule
setup_always_on_call_schedule(make_schedule, make_on_call_shift, organization, some_team, user)
warnings = check_user_availability(user, some_team)
warnings = check_user_availability(user)
assert warnings == []

View file

@ -49,7 +49,7 @@ class DirectPagingSerializer(serializers.Serializer):
alert_group = serializers.HiddenField(default=None) # set in DirectPagingSerializer.validate
title = serializers.CharField(required=False, default=None)
message = serializers.CharField(required=False, default=None)
message = serializers.CharField(required=False, default=None, allow_null=True)
team = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault())

View file

@ -647,7 +647,7 @@ class UserView(
@action(detail=True, methods=["get"])
def check_availability(self, request, pk) -> Response:
user = self.get_object()
warnings = check_user_availability(user=user, team=request.user.current_team)
warnings = check_user_availability(user=user)
return Response(data={"warnings": warnings}, status=status.HTTP_200_OK)

View file

@ -20,6 +20,7 @@ DEFAULT_TEAM_VALUE = "default_team"
class StartCreateIncidentFromSlashCommand(scenario_step.ScenarioStep):
"""
StartCreateIncidentFromSlashCommand triggers creation of a manual incident from the slack message via slash command
THIS FEATURE IS DEPRECATED AND WILL BE REMOVED IN A FUTURE RELEASE
"""
command_name = [settings.SLACK_SLASH_COMMAND_NAME]
@ -232,6 +233,18 @@ class OnRouteChange(scenario_step.ScenarioStep):
def _get_manual_incident_form_view(routing_uid, blocks, private_metatada):
deprecation_blocks = [
{
"type": "header",
"text": {
"type": "plain_text",
"text": f":no_entry: This command is deprecated and will be removed soon. Please use {settings.SLACK_DIRECT_PAGING_SLASH_COMMAND} command instead :no_entry:",
"emoji": True,
},
},
{"type": "divider"},
]
view = {
"type": "modal",
"callback_id": routing_uid,
@ -248,7 +261,7 @@ def _get_manual_incident_form_view(routing_uid, blocks, private_metatada):
"type": "plain_text",
"text": "Submit",
},
"blocks": blocks,
"blocks": deprecation_blocks + blocks,
"private_metadata": private_metatada,
}

View file

@ -4,6 +4,7 @@ from uuid import uuid4
from django.apps import apps
from django.conf import settings
from apps.alerts.models import AlertReceiveChannel, EscalationChain
from apps.alerts.paging import (
USER_HAS_NO_NOTIFICATION_POLICY,
USER_IS_NOT_ON_CALL,
@ -11,19 +12,17 @@ from apps.alerts.paging import (
direct_paging,
)
from apps.slack.constants import PRIVATE_METADATA_MAX_LENGTH
from apps.slack.models import SlackChannel
from apps.slack.scenarios import scenario_step
from apps.slack.slack_client.exceptions import SlackAPIException
DIRECT_PAGING_TEAM_SELECT_ID = "paging_team_select"
DIRECT_PAGING_ORG_SELECT_ID = "paging_org_select"
DIRECT_PAGING_ESCALATION_SELECT_ID = "paging_escalation_select"
DIRECT_PAGING_USER_SELECT_ID = "paging_user_select"
DIRECT_PAGING_SCHEDULE_SELECT_ID = "paging_schedule_select"
DIRECT_PAGING_TITLE_INPUT_ID = "paging_title_input"
DIRECT_PAGING_MESSAGE_INPUT_ID = "paging_message_input"
DIRECT_PAGING_ADDITIONAL_RESPONDERS_INPUT_ID = "paging_additional_responders_input"
DEFAULT_NO_ESCALATION_VALUE = "default_no_escalation"
DEFAULT_TEAM_VALUE = "default_team"
@ -121,22 +120,27 @@ class FinishDirectPaging(scenario_step.ScenarioStep):
private_metadata = json.loads(payload["view"]["private_metadata"])
channel_id = private_metadata["channel_id"]
input_id_prefix = private_metadata["input_id_prefix"]
selected_organization = _get_selected_org_from_payload(payload, input_id_prefix)
selected_team = _get_selected_team_from_payload(payload, input_id_prefix)
selected_escalation = _get_selected_escalation_from_payload(payload, input_id_prefix)
selected_organization = _get_selected_org_from_payload(
payload, input_id_prefix, slack_team_identity, slack_user_identity
)
_, selected_team = _get_selected_team_from_payload(payload, input_id_prefix)
user = slack_user_identity.get_user(selected_organization)
selected_users = [
(u, p == IMPORTANT_POLICY)
for u, p in get_current_items(payload, USERS_DATA_KEY, selected_organization.users)
]
selected_schedules = [
(s, p == IMPORTANT_POLICY)
for s, p in get_current_items(payload, SCHEDULES_DATA_KEY, selected_organization.oncall_schedules)
]
# Only pass users/schedules if additional responders checkbox is checked
selected_users, selected_schedules = None, None
is_additional_responders_checked = _get_additional_responders_checked_from_payload(payload, input_id_prefix)
if is_additional_responders_checked:
selected_users = [
(u, p == IMPORTANT_POLICY)
for u, p in get_current_items(payload, USERS_DATA_KEY, selected_organization.users)
]
selected_schedules = [
(s, p == IMPORTANT_POLICY)
for s, p in get_current_items(payload, SCHEDULES_DATA_KEY, selected_organization.oncall_schedules)
]
# trigger direct paging to selected users/schedules/escalation
direct_paging(
# trigger direct paging to selected team + users/schedules
alert_group = direct_paging(
selected_organization,
selected_team,
user,
@ -144,15 +148,16 @@ class FinishDirectPaging(scenario_step.ScenarioStep):
message,
selected_users,
selected_schedules,
selected_escalation,
)
text = ":white_check_mark: Alert group *{}* created: {}".format(title, alert_group.web_link)
try:
self._slack_client.api_call(
"chat.postEphemeral",
channel=channel_id,
user=slack_user_identity.slack_id,
text=":white_check_mark: Alert *{}* successfully submitted".format(title),
text=text,
)
except SlackAPIException as e:
if e.response["error"] == "channel_not_found":
@ -160,7 +165,7 @@ class FinishDirectPaging(scenario_step.ScenarioStep):
"chat.postEphemeral",
channel=slack_user_identity.im_channel_id,
user=slack_user_identity.slack_id,
text=":white_check_mark: Alert *{}* successfully submitted".format(title),
text=text,
)
else:
raise e
@ -183,12 +188,21 @@ class OnPagingOrgChange(scenario_step.ScenarioStep):
)
class OnPagingTeamChange(OnPagingOrgChange):
"""Reload form with updated team."""
class OnPagingTeamChange(scenario_step.ScenarioStep):
"""Set team."""
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
view = render_dialog(slack_user_identity, slack_team_identity, payload)
self._slack_client.api_call(
"views.update",
trigger_id=payload["trigger_id"],
view=view,
view_id=payload["view"]["id"],
)
class OnPagingEscalationChange(scenario_step.ScenarioStep):
"""Set escalation chain."""
class OnPagingCheckAdditionalResponders(OnPagingOrgChange):
"""Check/uncheck additional responders checkbox."""
class OnPagingUserChange(scenario_step.ScenarioStep):
@ -199,14 +213,15 @@ class OnPagingUserChange(scenario_step.ScenarioStep):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
private_metadata = json.loads(payload["view"]["private_metadata"])
selected_organization = _get_selected_org_from_payload(payload, private_metadata["input_id_prefix"])
selected_team = _get_selected_team_from_payload(payload, private_metadata["input_id_prefix"])
selected_organization = _get_selected_org_from_payload(
payload, private_metadata["input_id_prefix"], slack_team_identity, slack_user_identity
)
selected_user = _get_selected_user_from_payload(payload, private_metadata["input_id_prefix"])
if selected_user is None:
return
# check availability
availability_warnings = check_user_availability(selected_user, selected_team)
availability_warnings = check_user_availability(selected_user)
if availability_warnings:
# display warnings and require additional confirmation
view = _display_availability_warnings(payload, availability_warnings, selected_organization, selected_user)
@ -335,93 +350,62 @@ DIVIDER_BLOCK = {"type": "divider"}
def render_dialog(slack_user_identity, slack_team_identity, payload, initial=False, error_msg=None):
private_metadata = json.loads(payload["view"]["private_metadata"])
submit_routing_uid = private_metadata.get("submit_routing_uid")
# Get organizations available to user
available_organizations = _get_available_organizations(slack_team_identity, slack_user_identity)
if initial:
# setup initial form
new_input_id_prefix = _generate_input_id_prefix()
new_private_metadata = private_metadata
new_private_metadata["input_id_prefix"] = new_input_id_prefix
selected_organization = (
slack_team_identity.organizations.filter(users__slack_user_identity=slack_user_identity)
.order_by("pk")
.distinct()
.first()
)
selected_team = None
selected_escalation = None
selected_organization = available_organizations.first()
is_team_selected, selected_team = False, None
is_additional_responders_checked = False
else:
# setup form using data/state
old_input_id_prefix, new_input_id_prefix, new_private_metadata = _get_and_change_input_id_prefix_from_metadata(
private_metadata
)
selected_organization = _get_selected_org_from_payload(payload, old_input_id_prefix)
selected_team = _get_selected_team_from_payload(payload, old_input_id_prefix)
selected_escalation = _get_selected_escalation_from_payload(payload, old_input_id_prefix)
selected_organization = _get_selected_org_from_payload(
payload, old_input_id_prefix, slack_team_identity, slack_user_identity
)
is_team_selected, selected_team = _get_selected_team_from_payload(payload, old_input_id_prefix)
is_additional_responders_checked = _get_additional_responders_checked_from_payload(payload, old_input_id_prefix)
# widgets
organization_select = _get_organization_select(
slack_team_identity, slack_user_identity, selected_organization, new_input_id_prefix
team_select_blocks = _get_team_select_blocks(
slack_user_identity, selected_organization, is_team_selected, selected_team, new_input_id_prefix
)
team_select = _get_team_select(slack_user_identity, selected_organization, selected_team, new_input_id_prefix)
escalation_select = _get_escalation_select(
selected_organization, selected_team, selected_escalation, new_input_id_prefix
additional_responders_blocks = _get_additional_responders_blocks(
payload, selected_organization, new_input_id_prefix, is_additional_responders_checked, error_msg
)
users_select = _get_users_select(selected_organization, selected_team, new_input_id_prefix)
schedules_select = _get_schedules_select(selected_organization, selected_team, new_input_id_prefix)
# blocks
blocks = [organization_select, team_select, escalation_select, users_select, schedules_select]
# Add title and message inputs
blocks = [_get_title_input(payload), _get_message_input(payload)]
if error_msg:
blocks += [
{
"type": "section",
"block_id": "error_message",
"text": {
"type": "mrkdwn",
"text": f":warning: {error_msg}",
},
}
]
# Add organization select if more than one organization available for user
if len(available_organizations) > 1:
organization_select = _get_organization_select(
available_organizations, selected_organization, new_input_id_prefix
)
blocks.append(organization_select)
# selected items
selected_users = get_current_items(payload, USERS_DATA_KEY, selected_organization.users)
selected_schedules = get_current_items(payload, SCHEDULES_DATA_KEY, selected_organization.oncall_schedules)
# Add team select and additional responders blocks
blocks += team_select_blocks
blocks += additional_responders_blocks
if selected_users or selected_schedules:
blocks += [DIVIDER_BLOCK]
blocks.extend(_get_selected_entries_list(new_input_id_prefix, USERS_DATA_KEY, selected_users))
blocks.extend(_get_selected_entries_list(new_input_id_prefix, SCHEDULES_DATA_KEY, selected_schedules))
blocks += [DIVIDER_BLOCK]
blocks.extend([_get_title_input(payload), _get_message_input(payload)])
view = _get_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata), selected_organization)
view = _get_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata))
return view
def _get_form_view(routing_uid, blocks, private_metatada, organization):
try:
channel = organization.slack_team_identity.get_cached_channels().get(
slack_id=organization.general_log_channel_id
)
additional_info = f":information_source: The alert group will be posted to the #{channel.name} Slack channel"
except SlackChannel.DoesNotExist:
additional_info = (
":information_source: The alert group will be posted to the default Slack channel if there is one setup"
)
blocks += [
{
"type": "context",
"elements": [{"type": "mrkdwn", "text": additional_info}],
}
]
def _get_form_view(routing_uid, blocks, private_metadata):
view = {
"type": "modal",
"callback_id": routing_uid,
"title": {
"type": "plain_text",
"text": "Create alert group",
"text": "Create Alert Group",
},
"close": {
"type": "plain_text",
@ -430,19 +414,16 @@ def _get_form_view(routing_uid, blocks, private_metatada, organization):
},
"submit": {
"type": "plain_text",
"text": "Submit",
"text": "Create",
},
"blocks": blocks,
"private_metadata": private_metatada,
"private_metadata": private_metadata,
}
return view
def _get_organization_select(slack_team_identity, slack_user_identity, value, input_id_prefix):
organizations = slack_team_identity.organizations.filter(
users__slack_user_identity=slack_user_identity,
).distinct()
def _get_organization_select(organizations, value, input_id_prefix):
organizations_options = []
initial_option_idx = 0
for idx, org in enumerate(organizations):
@ -452,7 +433,7 @@ def _get_organization_select(slack_team_identity, slack_user_identity, value, in
{
"text": {
"type": "plain_text",
"text": f"{org.stack_slug}",
"text": f"{org.org_title}",
"emoji": True,
},
"value": f"{org.pk}",
@ -460,41 +441,50 @@ def _get_organization_select(slack_team_identity, slack_user_identity, value, in
)
organization_select = {
"type": "section",
"text": {"type": "mrkdwn", "text": "Select an organization"},
"type": "input",
"block_id": input_id_prefix + DIRECT_PAGING_ORG_SELECT_ID,
"accessory": {
"label": {
"type": "plain_text",
"text": "Organization",
},
"element": {
"type": "static_select",
"placeholder": {"type": "plain_text", "text": "Select an organization", "emoji": True},
"placeholder": {"type": "plain_text", "text": "Organization", "emoji": True},
"options": organizations_options,
"action_id": OnPagingOrgChange.routing_uid(),
"initial_option": organizations_options[initial_option_idx],
},
"dispatch_action": True,
}
return organization_select
def _get_select_field_value(payload, prefix_id, routing_uid, field_id):
field = payload["view"]["state"]["values"][prefix_id + field_id][routing_uid]["selected_option"]
try:
field = payload["view"]["state"]["values"][prefix_id + field_id][routing_uid]["selected_option"]
except KeyError:
return None
if field:
return field["value"]
def _get_selected_org_from_payload(payload, input_id_prefix):
def _get_selected_org_from_payload(payload, input_id_prefix, slack_team_identity, slack_user_identity):
Organization = apps.get_model("user_management", "Organization")
selected_org_id = _get_select_field_value(
payload, input_id_prefix, OnPagingOrgChange.routing_uid(), DIRECT_PAGING_ORG_SELECT_ID
)
if selected_org_id is not None:
if selected_org_id is None:
return _get_available_organizations(slack_team_identity, slack_user_identity).first()
else:
org = Organization.objects.filter(pk=selected_org_id).first()
return org
def _get_team_select(slack_user_identity, organization, value, input_id_prefix):
teams = organization.teams.filter(
users__slack_user_identity=slack_user_identity,
).distinct()
def _get_team_select_blocks(slack_user_identity, organization, is_selected, value, input_id_prefix):
user = slack_user_identity.get_user(organization) # TODO: handle None
teams = user.available_teams
team_options = []
# Adding pseudo option for default team
@ -503,7 +493,7 @@ def _get_team_select(slack_user_identity, organization, value, input_id_prefix):
{
"text": {
"type": "plain_text",
"text": f"General",
"text": f"No team",
"emoji": True,
},
"value": DEFAULT_TEAM_VALUE,
@ -524,73 +514,133 @@ def _get_team_select(slack_user_identity, organization, value, input_id_prefix):
)
team_select = {
"type": "section",
"text": {"type": "mrkdwn", "text": "Select a team"},
"type": "input",
"block_id": input_id_prefix + DIRECT_PAGING_TEAM_SELECT_ID,
"accessory": {
"label": {
"type": "plain_text",
"text": "Team to notify",
},
"element": {
"type": "static_select",
"placeholder": {"type": "plain_text", "text": "Select a team", "emoji": True},
"options": team_options,
"action_id": OnPagingTeamChange.routing_uid(),
"initial_option": team_options[initial_option_idx],
"placeholder": {"type": "plain_text", "text": "Select team", "emoji": True},
"options": team_options,
},
"dispatch_action": True,
}
# No context block if no team selected
if not is_selected:
return [team_select]
team_select["element"]["initial_option"] = team_options[initial_option_idx]
return [team_select, _get_team_select_context(organization, value)]
def _get_team_select_context(organization, team):
team_name = team.name if team else "No team"
alert_receive_channel = AlertReceiveChannel.objects.filter(
organization=organization,
team=team,
integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING,
).first()
escalation_chains_exist = EscalationChain.objects.filter(
channel_filters__alert_receive_channel=alert_receive_channel
).exists()
if not alert_receive_channel:
context_text = (
":warning: *Direct paging integration missing*\n"
"The selected team doesn't have a direct paging integration configured and will not be notified. "
"If you proceed with the alert group, an empty direct paging integration will be created automatically for the team. "
"<https://grafana.com/docs/oncall/latest/integrations/manual/|Learn more.>"
)
elif not escalation_chains_exist:
context_text = (
":warning: *Direct paging integration not configured*\n"
"The direct paging integration for the selected team has no escalation chains configured. "
"If you proceed with the alert group, the team likely will not be notified. "
"<https://grafana.com/docs/oncall/latest/integrations/manual/|Learn more.>"
)
else:
context_text = f"Integration <{alert_receive_channel.web_link}|{alert_receive_channel.verbal_name} ({team_name})> will be used for notification."
context = {
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": context_text,
}
],
}
return context
def _get_additional_responders_blocks(
payload, organization, input_id_prefix, is_additional_responders_checked, error_msg
):
checkbox_option = {
"text": {
"type": "plain_text",
"text": "Notify additional responders",
},
}
return team_select
def _get_escalation_select(organization, team, value, input_id_prefix):
escalations = organization.escalation_chains.filter(team=team)
# adding a default no-escalation option
initial_option_idx = 0
options = [
blocks = [
{
"text": {
"type": "input",
"block_id": input_id_prefix + DIRECT_PAGING_ADDITIONAL_RESPONDERS_INPUT_ID,
"label": {
"type": "plain_text",
"text": f"None",
"emoji": True,
"text": "Additional responders",
},
"value": DEFAULT_NO_ESCALATION_VALUE,
"element": {
"type": "checkboxes",
"options": [checkbox_option],
"action_id": OnPagingCheckAdditionalResponders.routing_uid(),
},
"optional": True,
"dispatch_action": True,
}
]
for idx, escalation in enumerate(escalations, start=1):
if escalation == value:
initial_option_idx = idx
options.append(
if is_additional_responders_checked:
blocks[0]["element"]["initial_options"] = [checkbox_option]
if error_msg:
blocks += [
{
"type": "section",
"block_id": "error_message",
"text": {
"type": "plain_text",
"text": f"{escalation.name}",
"emoji": True,
"type": "mrkdwn",
"text": f":warning: {error_msg}",
},
"value": f"{escalation.pk}",
}
)
]
if not options:
escalations_select = {
"type": "context",
"elements": [{"type": "mrkdwn", "text": "No escalation chains available"}],
}
else:
escalations_select = {
"type": "section",
"text": {"type": "mrkdwn", "text": "Set escalation chain"},
"block_id": input_id_prefix + DIRECT_PAGING_ESCALATION_SELECT_ID,
"accessory": {
"type": "static_select",
"placeholder": {"type": "plain_text", "text": "Select an escalation", "emoji": True},
"options": options,
"action_id": OnPagingEscalationChange.routing_uid(),
"initial_option": options[initial_option_idx],
},
}
return escalations_select
if is_additional_responders_checked:
users_select = _get_users_select(organization, input_id_prefix)
schedules_select = _get_schedules_select(organization, input_id_prefix)
blocks += [users_select, schedules_select]
# selected items
selected_users = get_current_items(payload, USERS_DATA_KEY, organization.users)
selected_schedules = get_current_items(payload, SCHEDULES_DATA_KEY, organization.oncall_schedules)
if selected_users or selected_schedules:
blocks += [DIVIDER_BLOCK]
blocks += _get_selected_entries_list(input_id_prefix, USERS_DATA_KEY, selected_users)
blocks += _get_selected_entries_list(input_id_prefix, SCHEDULES_DATA_KEY, selected_schedules)
blocks += [DIVIDER_BLOCK]
return blocks
def _get_users_select(organization, team, input_id_prefix):
def _get_users_select(organization, input_id_prefix):
users = organization.users.all()
if team is not None:
users = users.filter(teams=team)
user_options = [
{
@ -609,7 +659,7 @@ def _get_users_select(organization, team, input_id_prefix):
user_select = {
"type": "section",
"text": {"type": "mrkdwn", "text": "Add responders"},
"text": {"type": "mrkdwn", "text": "Add users"},
"block_id": input_id_prefix + DIRECT_PAGING_USER_SELECT_ID,
"accessory": {
"type": "static_select",
@ -637,8 +687,8 @@ def _get_users_select(organization, team, input_id_prefix):
return user_select
def _get_schedules_select(organization, team, input_id_prefix):
schedules = organization.oncall_schedules.filter(team=team)
def _get_schedules_select(organization, input_id_prefix):
schedules = organization.oncall_schedules.all()
schedule_options = [
{
@ -753,21 +803,26 @@ def _get_selected_team_from_payload(payload, input_id_prefix):
selected_team_id = _get_select_field_value(
payload, input_id_prefix, OnPagingTeamChange.routing_uid(), DIRECT_PAGING_TEAM_SELECT_ID
)
if selected_team_id is None or selected_team_id == DEFAULT_TEAM_VALUE:
return None
if selected_team_id is None:
return None, None
if selected_team_id == DEFAULT_TEAM_VALUE:
return selected_team_id, None
team = Team.objects.filter(pk=selected_team_id).first()
return team
return selected_team_id, team
def _get_selected_escalation_from_payload(payload, input_id_prefix):
EscalationChain = apps.get_model("alerts", "EscalationChain")
selected_escalation_id = _get_select_field_value(
payload, input_id_prefix, OnPagingEscalationChange.routing_uid(), DIRECT_PAGING_ESCALATION_SELECT_ID
)
if selected_escalation_id is None or selected_escalation_id == DEFAULT_NO_ESCALATION_VALUE:
return None
escalation = EscalationChain.objects.filter(pk=selected_escalation_id).first()
return escalation
def _get_additional_responders_checked_from_payload(payload, input_id_prefix):
try:
selected_options = payload["view"]["state"]["values"][
input_id_prefix + DIRECT_PAGING_ADDITIONAL_RESPONDERS_INPUT_ID
][OnPagingCheckAdditionalResponders.routing_uid()]["selected_options"]
except KeyError:
return False
return len(selected_options) > 0
def _get_selected_user_from_payload(payload, input_id_prefix):
@ -803,7 +858,7 @@ def _get_title_input(payload):
"block_id": DIRECT_PAGING_TITLE_INPUT_ID,
"label": {
"type": "plain_text",
"text": "Title:",
"text": "Title",
},
"element": {
"type": "plain_text_input",
@ -830,7 +885,7 @@ def _get_message_input(payload):
"block_id": DIRECT_PAGING_MESSAGE_INPUT_ID,
"label": {
"type": "plain_text",
"text": "Message:",
"text": "Message",
},
"element": {
"type": "plain_text_input",
@ -856,6 +911,14 @@ def _get_message_from_payload(payload):
return message
def _get_available_organizations(slack_team_identity, slack_user_identity):
return (
slack_team_identity.organizations.filter(users__slack_user_identity=slack_user_identity)
.order_by("pk")
.distinct()
)
# _generate_input_id_prefix returns uniq str to not to preserve input's values between view update
# https://api.slack.com/methods/views.update#markdown
def _generate_input_id_prefix():
@ -877,9 +940,9 @@ STEPS_ROUTING = [
},
{
"payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS,
"block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT,
"block_action_id": OnPagingEscalationChange.routing_uid(),
"step": OnPagingEscalationChange,
"block_action_type": scenario_step.BLOCK_ACTION_TYPE_CHECKBOXES,
"block_action_id": OnPagingCheckAdditionalResponders.routing_uid(),
"step": OnPagingCheckAdditionalResponders,
},
{
"payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS,

View file

@ -46,6 +46,7 @@ BLOCK_ACTION_TYPE_CONVERSATIONS_SELECT = "conversations_select"
BLOCK_ACTION_TYPE_CHANNELS_SELECT = "channels_select"
BLOCK_ACTION_TYPE_OVERFLOW = "overflow"
BLOCK_ACTION_TYPE_DATEPICKER = "datepicker"
BLOCK_ACTION_TYPE_CHECKBOXES = "checkboxes"
PAYLOAD_TYPE_DIALOG_SUBMISSION = "dialog_submission"
PAYLOAD_TYPE_VIEW_SUBMISSION = "view_submission"

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.paging import OnPagingTeamChange
from apps.slack.scenarios.scenario_step import PAYLOAD_TYPE_BLOCK_ACTIONS
EVENT_TRIGGER_ID = "5333959822612.4122782784722.4734ff484b2ac4d36a185bb242ee9932"
@ -131,3 +132,35 @@ def test_organization_not_found_scenario_doesnt_break_slash_commands(
assert response.status_code == status.HTTP_200_OK
mock_open_warning_window_if_needed.assert_not_called()
@patch("apps.slack.views.SlackEventApiEndpointView.verify_signature", return_value=True)
@patch.object(OnPagingTeamChange, "process_scenario")
@pytest.mark.django_db
def test_organization_not_found_scenario_doesnt_break_direct_paging(
mock_on_paging_team_change,
_,
make_organization,
make_slack_user_identity,
make_user,
slack_team_identity,
):
"""
Check OnPagingTeamChange.process_scenario gets called when a user changes the team in direct paging 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": OnPagingTeamChange.routing_uid(), "type": "static_select"}],
"view": {"type": "modal"},
}
)
assert response.status_code == status.HTTP_200_OK
mock_on_paging_team_change.assert_called_once()

View file

@ -8,7 +8,7 @@ from apps.base.models import UserNotificationPolicy
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb
from apps.slack.scenarios.paging import (
DEFAULT_POLICY,
DIRECT_PAGING_ESCALATION_SELECT_ID,
DIRECT_PAGING_ADDITIONAL_RESPONDERS_INPUT_ID,
DIRECT_PAGING_MESSAGE_INPUT_ID,
DIRECT_PAGING_ORG_SELECT_ID,
DIRECT_PAGING_SCHEDULE_SELECT_ID,
@ -20,7 +20,7 @@ from apps.slack.scenarios.paging import (
SCHEDULES_DATA_KEY,
USERS_DATA_KEY,
FinishDirectPaging,
OnPagingEscalationChange,
OnPagingCheckAdditionalResponders,
OnPagingItemActionChange,
OnPagingOrgChange,
OnPagingScheduleChange,
@ -31,7 +31,14 @@ from apps.slack.scenarios.paging import (
def make_slack_payload(
organization, user=None, schedule=None, escalation=None, current_users=None, current_schedules=None, actions=None
organization,
team=None,
user=None,
schedule=None,
additional_responders=False,
current_users=None,
current_schedules=None,
actions=None,
):
payload = {
"channel_id": "123",
@ -52,10 +59,12 @@ def make_slack_payload(
DIRECT_PAGING_ORG_SELECT_ID: {
OnPagingOrgChange.routing_uid(): {"selected_option": {"value": organization.pk}}
},
DIRECT_PAGING_TEAM_SELECT_ID: {OnPagingTeamChange.routing_uid(): {"selected_option": {"value": 0}}},
DIRECT_PAGING_ESCALATION_SELECT_ID: {
OnPagingEscalationChange.routing_uid(): {
"selected_option": {"value": escalation.pk} if escalation else None
DIRECT_PAGING_TEAM_SELECT_ID: {
OnPagingTeamChange.routing_uid(): {"selected_option": {"value": team.pk if team else None}}
},
DIRECT_PAGING_ADDITIONAL_RESPONDERS_INPUT_ID: {
OnPagingCheckAdditionalResponders.routing_uid(): {
"selected_options": ["something"] if additional_responders else []
}
},
DIRECT_PAGING_USER_SELECT_ID: {
@ -263,13 +272,34 @@ def test_trigger_paging_no_responders(make_organization_and_user_with_slack_iden
@pytest.mark.django_db
def test_trigger_paging(make_organization_and_user_with_slack_identities, make_escalation_chain, make_schedule):
def test_trigger_paging(make_organization_and_user_with_slack_identities, make_team, make_schedule):
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb, team=None)
escalation = make_escalation_chain(organization)
team = make_team(organization)
payload = make_slack_payload(
organization=organization,
escalation=escalation,
team=team,
additional_responders=False,
)
step = FinishDirectPaging(slack_team_identity)
with patch("apps.slack.scenarios.paging.direct_paging") as mock_direct_paging:
with patch.object(step._slack_client, "api_call"):
step.process_scenario(slack_user_identity, slack_team_identity, payload)
assert mock_direct_paging.called_with(organization, team, user, "The Title", "The Message", [], [], None)
@pytest.mark.django_db
def test_trigger_paging_additional_responders(
make_organization_and_user_with_slack_identities, make_team, make_schedule
):
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
team = make_team(organization)
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb, team=None)
payload = make_slack_payload(
organization=organization,
team=team,
additional_responders=True,
current_users={str(user.pk): IMPORTANT_POLICY},
current_schedules={str(schedule.pk): DEFAULT_POLICY},
)
@ -280,7 +310,7 @@ def test_trigger_paging(make_organization_and_user_with_slack_identities, make_e
step.process_scenario(slack_user_identity, slack_team_identity, payload)
assert mock_direct_paging.called_with(
organization, None, user, "The Title", "The Message", [(user, True)], [(schedule, False)], escalation
organization, team, user, "The Title", "The Message", [(user, True)], [(schedule, False)], None
)

View file

@ -300,7 +300,8 @@ class SlackEventApiEndpointView(APIView):
# Open pop-up to inform user why OnCall bot doesn't work if any action was triggered
self._open_warning_window_if_needed(payload, slack_team_identity, warning_text)
return Response(status=200)
elif organization is None and payload_type_is_block_actions:
# direct paging / manual incident dialogs don't require organization to be set
elif organization is None and payload_type_is_block_actions and not payload.get("view"):
# see this GitHub issue for more context on how this situation can arise
# https://github.com/grafana/oncall-private/issues/1836
warning_text = (

View file

@ -0,0 +1,27 @@
import { test } from '../fixtures';
import { clickButton, fillInInput, selectDropdownValue } from '../utils/forms';
import { goToOnCallPage } from "../utils/navigation";
import { verifyAlertGroupTitleAndMessageContainText } from "../utils/alertGroup";
test('we can create an alert group for default team', async ({ adminRolePage }) => {
const { page } = adminRolePage;
await goToOnCallPage(page, 'alert-groups');
await clickButton({ page, buttonText: 'New alert group' });
await fillInInput(page, 'input[name="title"]', "Help me!");
await fillInInput(page, 'textarea[name="message"]', "Help me please!");
await selectDropdownValue({
page,
selectType: 'grafanaSelect',
placeholderText: "Select team",
value: "No team",
});
await clickButton({ page, buttonText: 'Create' });
// Check we are redirected to the alert group page
await page.waitForURL('**/alert-groups/I*'); // Alert group IDs always start with "I"
await verifyAlertGroupTitleAndMessageContainText(page, "Help me!", "Help me please!")
});

View file

@ -42,7 +42,7 @@ export const filterAlertGroupsTableByIntegrationAndGoToDetailPage = async (
throw new Error('we were not able to properly filter the alert groups table by integration');
}
await goToOnCallPage(page, 'incidents');
await goToOnCallPage(page, 'alert-groups');
// filter by integration
const selectElement = await selectDropdownValue({
@ -100,3 +100,13 @@ export const verifyThatAlertGroupIsTriggered = async (
expect(await incidentTimelineContainsStep(page, triggeredStepText)).toBe(true);
};
export const verifyAlertGroupTitleAndMessageContainText = async (
page: Page,
title: string,
message: string
): Promise<void> => {
await expect(page.getByTestId('incident-title')).toContainText(title);
await expect(page.getByTestId('incident-message')).toContainText(message);
};

View file

@ -2,7 +2,7 @@ import type { Page, Response } from '@playwright/test';
import { BASE_URL } from './constants';
type GrafanaPage = '/plugins/grafana-oncall-app';
type OnCallPage = 'incidents' | 'integrations' | 'escalations' | 'schedules' | 'users';
type OnCallPage = 'alert-groups' | 'integrations' | 'escalations' | 'schedules' | 'users';
const _goToPage = (page: Page, url = ''): Promise<Response> => page.goto(`${BASE_URL}${url}`);

View file

@ -12,8 +12,8 @@ export const manualAlertFormConfig: { name: string; fields: FormItem[] } = {
{
name: 'message',
type: FormItemType.TextArea,
label: 'Description',
validation: { required: true },
label: 'Message (optional)',
validation: { required: false },
},
],
};

View file

@ -113,7 +113,7 @@ const ManualAlertGroup: FC<ManualAlertGroupProps> = (props) => {
);
const DirectPagingIntegrationVariants = ({ selectedTeamId, selectedTeamDirectPaging, chatOpsAvailableChannels }) => {
const escalationChainsExist = selectedTeamDirectPaging?.connected_escalations_chains_count === 0;
const escalationChainsExist = selectedTeamDirectPaging?.connected_escalations_chains_count !== 0;
return (
<VerticalGroup>
@ -122,41 +122,32 @@ const ManualAlertGroup: FC<ManualAlertGroupProps> = (props) => {
<LoadingPlaceholder text="Loading..." />
) : selectedTeamDirectPaging ? (
<VerticalGroup>
<Label>Team will be notified according to the integration settings:</Label>
<Label>Integration to be used for notification</Label>
<ul className={cx('responders-list')}>
<li>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
{escalationChainsExist && (
<Tooltip content="Integration doesn't have connected escalation policies">
<Icon name="exclamation-triangle" style={{ color: 'var(--warning-text-color)' }} />
</Tooltip>
)}
<Text>{selectedTeamDirectPaging.verbal_name}</Text>
</HorizontalGroup>
<HorizontalGroup>
<Text type="secondary">Team:</Text>
<TeamName team={store.grafanaTeamStore.items[selectedTeamId]} />
</HorizontalGroup>
<HorizontalGroup>
{chatOpsAvailableChannels && (
<>
{chatOpsAvailableChannels.map(
(chatOpsChannel: { name: string; icon: IconName }, chatOpsIndex) => (
<div key={`${chatOpsChannel.name}-${chatOpsIndex}`}>
{chatOpsChannel.icon && <Icon name={chatOpsChannel.icon} />}
<Text type="primary">{chatOpsChannel.name || ''}</Text>
</div>
)
)}
{chatOpsAvailableChannels && (
<Tooltip content="Alert group will be posted to these chatops channels according to integration configuration">
<Icon name="info-circle" />
</Tooltip>
)}
</>
)}
</HorizontalGroup>
{chatOpsAvailableChannels.length && (
<HorizontalGroup>
{chatOpsAvailableChannels.map(
(chatOpsChannel: { name: string; icon: IconName }, chatOpsIndex) => (
<div key={`${chatOpsChannel.name}-${chatOpsIndex}`}>
{chatOpsChannel.icon && <Icon name={chatOpsChannel.icon} />}
<Text type="primary">{chatOpsChannel.name || ''}</Text>
</div>
)
)}
<Tooltip content="Alert group will be posted to these ChatOps channels">
<Icon name="info-circle" />
</Tooltip>
</HorizontalGroup>
)}
<HorizontalGroup>
<PluginLink target="_blank" query={{ page: 'integrations', id: selectedTeamDirectPaging.id }}>
<IconButton
@ -169,29 +160,41 @@ const ManualAlertGroup: FC<ManualAlertGroupProps> = (props) => {
</HorizontalGroup>
</li>
</ul>
{(escalationChainsExist || !chatOpsAvailableChannels) && (
<Alert severity="warning" title="Possible notification miss">
{!escalationChainsExist && (
<Alert severity="warning" title="Direct paging integration not configured">
<VerticalGroup>
{escalationChainsExist && (
<Text>
Integration doesn't have connected escalation policies. Consider adding responders manually by
user or by email
</Text>
)}
{!chatOpsAvailableChannels && (
<Text>Integration doesn't have connected ChatOps channels in messengers.</Text>
)}
<Text>
The direct paging integration for the selected team has no escalation chains configured.
<br />
If you proceed with the alert group, the team likely will not be notified. <br />
<a
href={'https://grafana.com/docs/oncall/latest/integrations/manual/'}
target="_blank"
rel="noreferrer"
className={cx('link')}
>
<Text type="link">Learn more.</Text>
</a>
</Text>
</VerticalGroup>
</Alert>
)}
</VerticalGroup>
) : (
<Alert severity="warning" title={"This team doesn't have the the Direct Paging integration yet"}>
<Alert severity="warning" title={'Direct paging integration missing'}>
<HorizontalGroup>
<Text>
Empty integration for this team will be created automatically. Consider selecting responders by
schedule or user below
The selected team doesn't have a direct paging integration configured and will not be notified. <br />
If you proceed with the alert group, an empty direct paging integration will be created automatically
for the team. <br />
<a
href={'https://grafana.com/docs/oncall/latest/integrations/manual/'}
target="_blank"
rel="noreferrer"
className={cx('link')}
>
<Text type="link">Learn more.</Text>
</a>
</Text>
</HorizontalGroup>
</Alert>
@ -200,22 +203,11 @@ const ManualAlertGroup: FC<ManualAlertGroupProps> = (props) => {
);
};
const submitButtonDisabled = !(
selectedTeamId &&
(selectedTeamDirectPaging || userResponders.length || scheduleResponders.length)
);
return (
<Drawer
scrollableContent
title="Create manual alert group (Direct Paging)"
onClose={onHide}
closeOnMaskClick={false}
width="70%"
>
<Drawer scrollableContent title="Create Alert Group" onClose={onHide} closeOnMaskClick={false} width="70%">
<VerticalGroup>
<GForm form={manualAlertFormConfig} data={data} onSubmit={handleFormSubmit} />
<Field label="Select team you want to notify">
<Field label="Team to notify">
<GrafanaTeamSelect withoutModal onSelect={onUpdateSelectedTeam} />
</Field>
<DirectPagingIntegrationVariants
@ -233,7 +225,7 @@ const ManualAlertGroup: FC<ManualAlertGroupProps> = (props) => {
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
<Button type="submit" form={manualAlertFormConfig.name} disabled={submitButtonDisabled}>
<Button type="submit" form={manualAlertFormConfig.name} disabled={!selectedTeamId}>
Create
</Button>
</HorizontalGroup>

View file

@ -105,7 +105,7 @@ const EscalationVariants = observer(
<div className={cx('body')}>
{!hideSelected && Boolean(value.userResponders.length || value.scheduleResponders.length) && (
<>
<Label>Additional Responders will be notified immediately:</Label>
<Label>Additional responders will be notified immediately:</Label>
<ul className={cx('responders-list')}>
{value.userResponders.map((responder, index) => (
<UserResponder
@ -127,7 +127,7 @@ const EscalationVariants = observer(
</>
)}
<div className={cx('assign-responders-button')}>
{withLabels && <Label>Assign additional responders from other teams (by user or by schedule)</Label>}
{withLabels && <Label>Additional responders (optional)</Label>}
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
<Button
icon="users-alt"
@ -137,7 +137,7 @@ const EscalationVariants = observer(
setShowEscalationVariants(true);
}}
>
Invite additional responders
Notify additional responders
</Button>
</WithPermissionControlTooltip>
</div>

View file

@ -52,10 +52,11 @@ const GrafanaTeamSelect = observer(({ onSelect, onHide, withoutModal, defaultVal
const select = (
<GSelect
showSearch
modelName="grafanaTeamStore"
displayField="name"
valueField="id"
placeholder="Select Team"
placeholder="Select team"
className={cx('select', 'control')}
value={selectedTeam}
onChange={handleTeamSelect}

View file

@ -260,7 +260,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
</PluginLink>
{/* @ts-ignore*/}
<HorizontalGroup align="baseline">
<Text.Title level={3}>
<Text.Title level={3} data-testid="incident-title">
#{incident.inside_organization_number} {incident.render_for_web.title}
</Text.Title>
{incident.root_alert_group && (
@ -647,6 +647,7 @@ function Incident({ incident }: { incident: Alert; datetimeReference: string })
dangerouslySetInnerHTML={{
__html: sanitize(incident.render_for_web.message),
}}
data-testid="incident-message"
/>
{incident.render_for_web.image_url && <img className={cx('image')} src={incident.render_for_web.image_url} />}
</div>

View file

@ -126,7 +126,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
<Text.Title level={3}>Alert Groups</Text.Title>
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
<Button icon="plus" onClick={this.handleOnClickEscalateTo}>
New manual alert group
New alert group
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>