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:
parent
78df72bef9
commit
69bafb61f1
20 changed files with 433 additions and 289 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
27
grafana-plugin/integration-tests/alerts/directPaging.test.ts
Normal file
27
grafana-plugin/integration-tests/alerts/directPaging.test.ts
Normal 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!")
|
||||
});
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue