# What this PR does
Introduces a new class,
`apps.grafana_plugin.ui_url_builder.UIURLBuilder`, which is responsible
for... building UI URLs (😄). The class mainly does two things:
- it will decide if the URL should point to `grafana-oncall-app` or
`grafana-irm-app` based on the value of
`organization.is_grafana_irm_enabled` (**NOTE**: this value isn't yet
being set + defaults to `False`; logic for setting this value will be
done in a subsequent PR)
- Adds `enum`s, `OnCallPage` and `IncidentPage` to DRYify hardcoded UI
URLs (in case we decide to change these slightly in the near future)
## 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] Added the relevant release notes label (see labels prefixed w/
`release:`). These labels dictate how your PR will
show up in the autogenerated release notes.
1059 lines
38 KiB
Python
1059 lines
38 KiB
Python
import enum
|
|
import json
|
|
import logging
|
|
import typing
|
|
from uuid import uuid4
|
|
|
|
from django.conf import settings
|
|
from django.db.models import Model, QuerySet
|
|
from rest_framework.response import Response
|
|
|
|
from apps.alerts.models import AlertReceiveChannel
|
|
from apps.alerts.paging import DirectPagingUserTeamValidationError, UserNotifications, direct_paging, user_is_oncall
|
|
from apps.api.permissions import RBACPermission, user_is_authorized
|
|
from apps.grafana_plugin.ui_url_builder import UIURLBuilder
|
|
from apps.schedules.ical_utils import get_cached_oncall_users_for_multiple_schedules
|
|
from apps.slack.chatops_proxy_routing import make_private_metadata, make_value
|
|
from apps.slack.constants import DIVIDER, PRIVATE_METADATA_MAX_LENGTH
|
|
from apps.slack.errors import SlackAPIChannelNotFoundError, SlackAPIError
|
|
from apps.slack.scenarios import scenario_step
|
|
from apps.slack.slash_command import SlashCommand
|
|
from apps.slack.types import (
|
|
Block,
|
|
BlockActionType,
|
|
CompositionObjectOption,
|
|
CompositionObjectOptionGroup,
|
|
EventPayload,
|
|
ModalView,
|
|
PayloadType,
|
|
ScenarioRoute,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
if typing.TYPE_CHECKING:
|
|
from django.db.models.manager import RelatedManager
|
|
|
|
from apps.slack.models import SlackTeamIdentity, SlackUserIdentity
|
|
from apps.user_management.models import Organization, Team, User
|
|
|
|
|
|
DIRECT_PAGING_TEAM_SELECT_ID = "paging_team_select"
|
|
DIRECT_PAGING_ORG_SELECT_ID = "paging_org_select"
|
|
DIRECT_PAGING_USER_SELECT_ID = "paging_user_select"
|
|
DIRECT_PAGING_MESSAGE_INPUT_ID = "paging_message_input"
|
|
|
|
DEFAULT_TEAM_VALUE = "default_team"
|
|
|
|
|
|
class Policy(enum.StrEnum):
|
|
"""
|
|
selected user available actions
|
|
"""
|
|
|
|
DEFAULT = "default"
|
|
IMPORTANT = "important"
|
|
REMOVE_ACTION = "remove"
|
|
|
|
|
|
ITEM_ACTIONS = (
|
|
(Policy.DEFAULT, "Set default notification policy"),
|
|
(Policy.IMPORTANT, "Set important notification policy"),
|
|
(Policy.REMOVE_ACTION, "Remove from escalation"),
|
|
)
|
|
|
|
|
|
# helpers to manage current selected users state
|
|
|
|
|
|
class DataKey(enum.StrEnum):
|
|
USERS = "users"
|
|
|
|
|
|
# https://api.slack.com/reference/block-kit/block-elements#static_select
|
|
MAX_STATIC_SELECT_OPTIONS = 100
|
|
|
|
|
|
def add_or_update_item(payload: EventPayload, key: DataKey, item_pk: str, policy: Policy) -> EventPayload:
|
|
metadata = json.loads(payload["view"]["private_metadata"])
|
|
metadata[key][item_pk] = policy
|
|
updated_metadata = json.dumps(metadata)
|
|
if len(updated_metadata) > PRIVATE_METADATA_MAX_LENGTH:
|
|
raise ValueError("Cannot add entry, maximum exceeded")
|
|
payload["view"]["private_metadata"] = updated_metadata
|
|
return payload
|
|
|
|
|
|
def remove_item(payload: EventPayload, key: DataKey, item_pk: str) -> EventPayload:
|
|
metadata = json.loads(payload["view"]["private_metadata"])
|
|
if item_pk in metadata[key]:
|
|
del metadata[key][item_pk]
|
|
payload["view"]["private_metadata"] = json.dumps(metadata)
|
|
return payload
|
|
|
|
|
|
def reset_items(payload: EventPayload) -> EventPayload:
|
|
metadata = json.loads(payload["view"]["private_metadata"])
|
|
for key in (DataKey.USERS,):
|
|
metadata[key] = {}
|
|
payload["view"]["private_metadata"] = json.dumps(metadata)
|
|
return payload
|
|
|
|
|
|
T = typing.TypeVar("T", bound=Model)
|
|
|
|
|
|
def get_current_items(
|
|
payload: EventPayload, key: DataKey, qs: "RelatedManager['T']"
|
|
) -> typing.List[typing.Tuple[T, Policy]]:
|
|
metadata = json.loads(payload["view"]["private_metadata"])
|
|
items: typing.List[typing.Tuple[T, Policy]] = []
|
|
for u, p in metadata[key].items():
|
|
item = qs.filter(pk=u).first()
|
|
items.append((item, p))
|
|
return items
|
|
|
|
|
|
# Slack scenario steps
|
|
|
|
|
|
class StartDirectPaging(scenario_step.ScenarioStep):
|
|
"""Handle slash command invocation and show initial dialog."""
|
|
|
|
@staticmethod
|
|
def matcher(slash_command: SlashCommand) -> bool:
|
|
# Check if command is /escalate. It's a legacy command we keep for smooth transition.
|
|
is_legacy_command = slash_command.command == settings.SLACK_DIRECT_PAGING_SLASH_COMMAND
|
|
# Check if command is /grafana escalate. It's a new command from unified app.
|
|
is_unified_app_command = slash_command.is_root_command and slash_command.subcommand == "escalate"
|
|
return is_legacy_command or is_unified_app_command
|
|
|
|
def process_scenario(
|
|
self,
|
|
slack_user_identity: "SlackUserIdentity",
|
|
slack_team_identity: "SlackTeamIdentity",
|
|
payload: "EventPayload",
|
|
predefined_org: typing.Optional["Organization"] = None,
|
|
) -> None:
|
|
input_id_prefix = _generate_input_id_prefix()
|
|
|
|
try:
|
|
channel_id = payload["event"]["channel"]
|
|
except KeyError:
|
|
channel_id = payload["channel_id"]
|
|
|
|
if settings.UNIFIED_SLACK_APP_ENABLED:
|
|
if slack_team_identity.needs_reinstall:
|
|
organizations = _get_available_organizations(slack_team_identity, slack_user_identity)
|
|
if len(organizations) == 1:
|
|
# Provide a link to web if user has access only to one organization
|
|
link = UIURLBuilder(organizations[0]).settings("?tab=ChatOps&chatOpsTab=Slack")
|
|
else:
|
|
# Otherwise, provide a link to the documentation
|
|
link = (
|
|
"https://grafana.com/docs/grafana-cloud/alerting-and-irm/oncall/configure/integrations"
|
|
"/references/slack/#migrate-to-the-grafana-irm-slack-integration"
|
|
)
|
|
upgrade = f"<{link}|Upgrade>"
|
|
msg = (
|
|
f"The new Slack IRM integration is now available. f{upgrade} for a more powerful and flexible "
|
|
f"way to interact with Grafana IRM on Slack."
|
|
)
|
|
try:
|
|
self._slack_client.chat_postEphemeral(
|
|
channel=channel_id, user=slack_user_identity.slack_id, text=msg
|
|
)
|
|
except SlackAPIError:
|
|
# catch all exceptions to prevent the slash command from failing
|
|
logger.warning("StartDirectPaging: failed to send ephemeral message to user", exc_info=True)
|
|
else:
|
|
# hack, parsing command again to determine if it's a legacy command
|
|
cmd = SlashCommand.parse(payload)
|
|
is_legacy_command = cmd.command == settings.SLACK_DIRECT_PAGING_SLASH_COMMAND
|
|
if is_legacy_command:
|
|
self._slack_client.chat_postEphemeral(
|
|
channel=channel_id,
|
|
user=slack_user_identity.slack_id,
|
|
text=f"The new Slack IRM integration is now available. "
|
|
f"Please use `/{settings.SLACK_IRM_ROOT_COMMAND}` escalate to complete the action",
|
|
)
|
|
return
|
|
|
|
private_metadata = {
|
|
"channel_id": channel_id,
|
|
"input_id_prefix": input_id_prefix,
|
|
"submit_routing_uid": FinishDirectPaging.routing_uid(),
|
|
DataKey.USERS: {},
|
|
}
|
|
# We have access to predefined org only in StartDirectPaging, since it's a slash command.
|
|
# Chatops-Proxy adds a special header to slash commands payload to define the organization.
|
|
# Other Paging steps are triggered by buttons and actions,
|
|
# so we don't have access to predefined org and use private metadata instead.
|
|
private_metadata = _inject_predefined_org_to_private_metadata(predefined_org, private_metadata)
|
|
initial_payload = {"view": {"private_metadata": json.dumps(private_metadata)}}
|
|
view = render_dialog(slack_user_identity, slack_team_identity, initial_payload, initial=True)
|
|
self._slack_client.views_open(
|
|
trigger_id=payload["trigger_id"],
|
|
view=view,
|
|
)
|
|
|
|
|
|
class FinishDirectPaging(scenario_step.ScenarioStep):
|
|
"""Handle page command dialog submit."""
|
|
|
|
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_DIRECT_PAGING]
|
|
|
|
def process_scenario(
|
|
self,
|
|
slack_user_identity: "SlackUserIdentity",
|
|
slack_team_identity: "SlackTeamIdentity",
|
|
payload: "EventPayload",
|
|
predefined_org: typing.Optional["Organization"] = None,
|
|
) -> None:
|
|
message = _get_message_from_payload(payload)
|
|
private_metadata = json.loads(payload["view"]["private_metadata"])
|
|
predefined_org = _get_predefined_org_from_private_metadata(private_metadata, slack_team_identity)
|
|
channel_id = private_metadata["channel_id"]
|
|
input_id_prefix = private_metadata["input_id_prefix"]
|
|
selected_organization = predefined_org or _get_selected_org_from_payload(
|
|
payload, input_id_prefix, slack_team_identity, slack_user_identity
|
|
)
|
|
|
|
# get user in the context of the selected_organization
|
|
user = slack_user_identity.get_user(selected_organization)
|
|
if not user_is_authorized(user, self.REQUIRED_PERMISSIONS):
|
|
unauthorized_error = _get_unauthorized_warning(error=True)
|
|
return Response(
|
|
{
|
|
"response_action": "update",
|
|
"view": render_dialog(
|
|
slack_user_identity, slack_team_identity, payload, validation_errors=unauthorized_error
|
|
),
|
|
},
|
|
status=200,
|
|
)
|
|
|
|
_, selected_team = _get_selected_team_from_payload(payload, input_id_prefix)
|
|
user = slack_user_identity.get_user(selected_organization)
|
|
|
|
selected_users: UserNotifications = [
|
|
(u, p == Policy.IMPORTANT)
|
|
for u, p in get_current_items(payload, DataKey.USERS, selected_organization.users)
|
|
]
|
|
|
|
# trigger direct paging to selected team + users
|
|
try:
|
|
alert_group = direct_paging(
|
|
organization=selected_organization,
|
|
from_user=user,
|
|
message=message,
|
|
team=selected_team,
|
|
users=selected_users,
|
|
)
|
|
except DirectPagingUserTeamValidationError:
|
|
# show validation warning messages
|
|
validation_errors: Block.AnyBlocks = [
|
|
typing.cast(
|
|
Block.Section,
|
|
{
|
|
"type": "section",
|
|
"text": {
|
|
"type": "mrkdwn",
|
|
"text": ":warning: At least one team or one user must be selected to directly page",
|
|
},
|
|
},
|
|
)
|
|
]
|
|
|
|
return Response(
|
|
{
|
|
"response_action": "update",
|
|
"view": render_dialog(
|
|
slack_user_identity, slack_team_identity, payload, validation_errors=validation_errors
|
|
),
|
|
},
|
|
status=200,
|
|
)
|
|
|
|
text = f":white_check_mark: Escalation created: {alert_group.web_link}"
|
|
|
|
try:
|
|
self._slack_client.chat_postEphemeral(
|
|
channel=channel_id,
|
|
user=slack_user_identity.slack_id,
|
|
text=text,
|
|
)
|
|
except SlackAPIChannelNotFoundError:
|
|
self._slack_client.chat_postEphemeral(
|
|
channel=slack_user_identity.im_channel_id,
|
|
user=slack_user_identity.slack_id,
|
|
text=text,
|
|
)
|
|
|
|
|
|
# OnChange steps, responsible for rerendering form on changed values
|
|
|
|
|
|
class OnPagingOrgChange(scenario_step.ScenarioStep):
|
|
"""Reload form with updated organization."""
|
|
|
|
def process_scenario(
|
|
self,
|
|
slack_user_identity: "SlackUserIdentity",
|
|
slack_team_identity: "SlackTeamIdentity",
|
|
payload: "EventPayload",
|
|
predefined_org: typing.Optional["Organization"] = None,
|
|
) -> None:
|
|
updated_payload = reset_items(payload)
|
|
view = render_dialog(slack_user_identity, slack_team_identity, updated_payload)
|
|
self._slack_client.views_update(
|
|
trigger_id=updated_payload["trigger_id"],
|
|
view=view,
|
|
view_id=updated_payload["view"]["id"],
|
|
)
|
|
|
|
|
|
class OnPagingTeamChange(scenario_step.ScenarioStep):
|
|
"""Set team."""
|
|
|
|
def process_scenario(
|
|
self,
|
|
slack_user_identity: "SlackUserIdentity",
|
|
slack_team_identity: "SlackTeamIdentity",
|
|
payload: "EventPayload",
|
|
predefined_org: typing.Optional["Organization"] = None,
|
|
) -> None:
|
|
view = render_dialog(slack_user_identity, slack_team_identity, payload)
|
|
self._slack_client.views_update(
|
|
trigger_id=payload["trigger_id"],
|
|
view=view,
|
|
view_id=payload["view"]["id"],
|
|
)
|
|
|
|
|
|
class OnPagingUserChange(scenario_step.ScenarioStep):
|
|
"""Add selected to user to the list.
|
|
|
|
It will check to see if the user is on-call, pushing a new modal for additional confirmation if needed.
|
|
"""
|
|
|
|
def process_scenario(
|
|
self,
|
|
slack_user_identity: "SlackUserIdentity",
|
|
slack_team_identity: "SlackTeamIdentity",
|
|
payload: "EventPayload",
|
|
predefined_org: typing.Optional["Organization"] = None,
|
|
) -> None:
|
|
private_metadata = json.loads(payload["view"]["private_metadata"])
|
|
selected_user = _get_selected_user_from_payload(payload, private_metadata["input_id_prefix"])
|
|
if selected_user is None:
|
|
return
|
|
|
|
# check if user is on-call
|
|
if not user_is_oncall(selected_user):
|
|
# display additional confirmation modal
|
|
metadata = json.loads(payload["view"]["private_metadata"])
|
|
private_metadata = {
|
|
"state": payload["view"]["state"],
|
|
"input_id_prefix": metadata["input_id_prefix"],
|
|
"channel_id": metadata["channel_id"],
|
|
"submit_routing_uid": metadata["submit_routing_uid"],
|
|
DataKey.USERS: metadata[DataKey.USERS],
|
|
}
|
|
# keep predefined organization in private metadata
|
|
if "organization_id" in metadata:
|
|
private_metadata["organization_id"] = metadata["organization_id"]
|
|
|
|
view = _display_confirm_participant_invitation_view(
|
|
OnPagingConfirmUserChange.routing_uid(),
|
|
make_private_metadata(private_metadata, selected_user.organization),
|
|
)
|
|
self._slack_client.views_push(trigger_id=payload["trigger_id"], view=view)
|
|
else:
|
|
# user is currently on-call
|
|
error_msg = None
|
|
try:
|
|
updated_payload = add_or_update_item(payload, DataKey.USERS, selected_user.pk, Policy.DEFAULT)
|
|
except ValueError:
|
|
updated_payload = payload
|
|
error_msg = "Cannot add user, maximum responders exceeded"
|
|
view = render_dialog(slack_user_identity, slack_team_identity, updated_payload, error_msg=error_msg)
|
|
self._slack_client.views_update(
|
|
trigger_id=payload["trigger_id"],
|
|
view=view,
|
|
view_id=payload["view"]["id"],
|
|
)
|
|
|
|
|
|
class OnPagingItemActionChange(scenario_step.ScenarioStep):
|
|
"""Reload form with updated user details."""
|
|
|
|
def _parse_action(self, payload: EventPayload) -> typing.Tuple[Policy, DataKey, str]:
|
|
value = json.loads(payload["actions"][0]["selected_option"]["value"])
|
|
return value["action"], value["key"], value["id"]
|
|
|
|
def process_scenario(
|
|
self,
|
|
slack_user_identity: "SlackUserIdentity",
|
|
slack_team_identity: "SlackTeamIdentity",
|
|
payload: "EventPayload",
|
|
predefined_org: typing.Optional["Organization"] = None,
|
|
) -> None:
|
|
policy, key, user_pk = self._parse_action(payload)
|
|
|
|
error_msg = None
|
|
if policy == Policy.REMOVE_ACTION:
|
|
updated_payload = remove_item(payload, key, user_pk)
|
|
else:
|
|
try:
|
|
updated_payload = add_or_update_item(payload, key, user_pk, policy)
|
|
except ValueError:
|
|
updated_payload = payload
|
|
error_msg = "Cannot update policy, maximum responders exceeded"
|
|
|
|
view = render_dialog(slack_user_identity, slack_team_identity, updated_payload, error_msg=error_msg)
|
|
self._slack_client.views_update(trigger_id=payload["trigger_id"], view=view, view_id=payload["view"]["id"])
|
|
|
|
|
|
class OnPagingConfirmUserChange(scenario_step.ScenarioStep):
|
|
"""Confirm user selection despite not being on-call."""
|
|
|
|
def process_scenario(
|
|
self,
|
|
slack_user_identity: "SlackUserIdentity",
|
|
slack_team_identity: "SlackTeamIdentity",
|
|
payload: "EventPayload",
|
|
predefined_org: typing.Optional["Organization"] = None,
|
|
) -> None:
|
|
metadata = json.loads(payload["view"]["private_metadata"])
|
|
|
|
# recreate original view state and metadata
|
|
private_metadata = {
|
|
"channel_id": metadata["channel_id"],
|
|
"input_id_prefix": metadata["input_id_prefix"],
|
|
"submit_routing_uid": metadata["submit_routing_uid"],
|
|
DataKey.USERS: metadata[DataKey.USERS],
|
|
}
|
|
# keep predefined organization in private metadata
|
|
if "organization_id" in metadata:
|
|
private_metadata["organization_id"] = metadata["organization_id"]
|
|
|
|
previous_view_payload = {
|
|
"view": {
|
|
"state": metadata["state"],
|
|
"private_metadata": json.dumps(private_metadata),
|
|
},
|
|
}
|
|
# add selected user
|
|
selected_user = _get_selected_user_from_payload(previous_view_payload, private_metadata["input_id_prefix"])
|
|
|
|
error_msg = None
|
|
try:
|
|
updated_payload = add_or_update_item(previous_view_payload, DataKey.USERS, selected_user.pk, Policy.DEFAULT)
|
|
except ValueError:
|
|
updated_payload = payload
|
|
error_msg = "Cannot add user, maximum responders exceeded"
|
|
view = render_dialog(slack_user_identity, slack_team_identity, updated_payload, error_msg=error_msg)
|
|
self._slack_client.views_update(
|
|
trigger_id=payload["trigger_id"],
|
|
view=view,
|
|
view_id=payload["view"]["previous_view_id"],
|
|
)
|
|
|
|
|
|
# slack view/blocks rendering helpers
|
|
|
|
|
|
def render_dialog(
|
|
slack_user_identity: "SlackUserIdentity",
|
|
slack_team_identity: "SlackTeamIdentity",
|
|
payload: EventPayload,
|
|
initial=False,
|
|
error_msg=None,
|
|
validation_errors: typing.Optional[Block.AnyBlocks] = None,
|
|
) -> ModalView:
|
|
private_metadata = json.loads(payload["view"]["private_metadata"])
|
|
submit_routing_uid = private_metadata.get("submit_routing_uid")
|
|
|
|
predefined_org = _get_predefined_org_from_private_metadata(private_metadata, slack_team_identity)
|
|
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 = predefined_org if predefined_org else available_organizations.first()
|
|
is_team_selected, selected_team = False, None
|
|
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 = (
|
|
predefined_org
|
|
if predefined_org
|
|
else _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)
|
|
|
|
blocks: Block.AnyBlocks = []
|
|
|
|
if validation_errors:
|
|
blocks += validation_errors
|
|
|
|
# get user in the context of the selected_organization
|
|
user = slack_user_identity.get_user(selected_organization)
|
|
if not user_is_authorized(user, FinishDirectPaging.REQUIRED_PERMISSIONS):
|
|
blocks += _get_unauthorized_warning()
|
|
|
|
blocks.append(_get_message_input(payload))
|
|
|
|
# Add organization select if org is not defined on chatops-proxy (it's should happen only in OSS)
|
|
# and user has access to multiple orgs.
|
|
if not predefined_org and len(available_organizations) > 1:
|
|
organization_select = _get_organization_select(
|
|
available_organizations, selected_organization, new_input_id_prefix
|
|
)
|
|
blocks.append(organization_select)
|
|
|
|
# Add team select and additional responders blocks
|
|
blocks += _get_team_select_blocks(
|
|
slack_user_identity, selected_organization, is_team_selected, selected_team, new_input_id_prefix
|
|
)
|
|
blocks += _get_user_select_blocks(payload, selected_organization, new_input_id_prefix, error_msg)
|
|
|
|
blocks.append(
|
|
{
|
|
"type": "context",
|
|
"elements": [
|
|
{
|
|
"type": "mrkdwn",
|
|
"text": "*Note*: you *must* specify at least one team or one user to directly page.",
|
|
},
|
|
],
|
|
}
|
|
)
|
|
|
|
return _get_form_view(
|
|
submit_routing_uid, blocks, make_private_metadata(new_private_metadata, selected_organization)
|
|
)
|
|
|
|
|
|
def _get_unauthorized_warning(error=False):
|
|
icon = ":warning:" if not error else ":no_entry:"
|
|
text = f"{icon} You do not have permission to perform this action."
|
|
if not error:
|
|
text += "\nAsk an admin to upgrade your permissions."
|
|
return [
|
|
typing.cast(
|
|
Block.Section,
|
|
{"type": "section", "text": {"type": "mrkdwn", "text": text}},
|
|
)
|
|
]
|
|
|
|
|
|
def _get_form_view(routing_uid: str, blocks: Block.AnyBlocks, private_metadata: str) -> ModalView:
|
|
view: ModalView = {
|
|
"type": "modal",
|
|
"callback_id": routing_uid,
|
|
"title": {
|
|
"type": "plain_text",
|
|
"text": "Create Escalation",
|
|
},
|
|
"close": {
|
|
"type": "plain_text",
|
|
"text": "Cancel",
|
|
"emoji": True,
|
|
},
|
|
"submit": {
|
|
"type": "plain_text",
|
|
"text": "Create",
|
|
},
|
|
"blocks": blocks,
|
|
"private_metadata": private_metadata,
|
|
}
|
|
return view
|
|
|
|
|
|
def _get_organization_select(
|
|
organizations: QuerySet["Organization"], value: "Organization", input_id_prefix: str
|
|
) -> Block.Input:
|
|
organizations_options: typing.List[CompositionObjectOption] = []
|
|
initial_option_idx = 0
|
|
for idx, org in enumerate(organizations):
|
|
if org == value:
|
|
initial_option_idx = idx
|
|
organizations_options.append(
|
|
{
|
|
"text": {
|
|
"type": "plain_text",
|
|
"text": f"{org.org_title} ({org.stack_slug})",
|
|
"emoji": True,
|
|
},
|
|
"value": make_value({"id": org.pk}, org),
|
|
}
|
|
)
|
|
|
|
organization_select: Block.Input = {
|
|
"type": "input",
|
|
"block_id": input_id_prefix + DIRECT_PAGING_ORG_SELECT_ID,
|
|
"label": {
|
|
"type": "plain_text",
|
|
"text": "Organization",
|
|
},
|
|
"element": {
|
|
"type": "static_select",
|
|
"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: EventPayload, prefix_id: str, routing_uid: str, field_id: str) -> str | None:
|
|
try:
|
|
field = payload["view"]["state"]["values"][prefix_id + field_id][routing_uid]["selected_option"]
|
|
except KeyError:
|
|
return None
|
|
return json.loads(field["value"])["id"] if field else None
|
|
|
|
|
|
def _get_selected_org_from_payload(
|
|
payload: EventPayload,
|
|
input_id_prefix: str,
|
|
slack_team_identity: "SlackTeamIdentity",
|
|
slack_user_identity: "SlackUserIdentity",
|
|
) -> typing.Optional["Organization"]:
|
|
from apps.user_management.models import 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 None:
|
|
return _get_available_organizations(slack_team_identity, slack_user_identity).first()
|
|
return Organization.objects.filter(pk=selected_org_id).first()
|
|
|
|
|
|
def _inject_predefined_org_to_private_metadata(
|
|
predefined_org: typing.Optional["Organization"], private_metadata: dict
|
|
) -> dict:
|
|
"""
|
|
Injects predefined organization to private metadata.
|
|
Predefined org is org defined by chatops-proxy for slash commands.
|
|
"""
|
|
if predefined_org:
|
|
private_metadata["organization_id"] = predefined_org.pk
|
|
return private_metadata
|
|
|
|
|
|
def _get_predefined_org_from_private_metadata(
|
|
private_metadata: dict,
|
|
slack_team_identity: "SlackTeamIdentity",
|
|
) -> typing.Optional["Organization"]:
|
|
"""
|
|
Returns organization from private metadata.
|
|
"""
|
|
org_id = private_metadata.get("organization_id")
|
|
if not org_id:
|
|
return None
|
|
|
|
return slack_team_identity.organizations.filter(pk=org_id).first()
|
|
|
|
|
|
def _get_team_select_blocks(
|
|
slack_user_identity: "SlackUserIdentity",
|
|
organization: "Organization",
|
|
is_selected: bool,
|
|
value: typing.Optional["Team"],
|
|
input_id_prefix: str,
|
|
) -> Block.AnyBlocks:
|
|
blocks: Block.AnyBlocks = []
|
|
user = slack_user_identity.get_user(organization) # TODO: handle None
|
|
teams = (
|
|
user.organization.get_notifiable_direct_paging_integrations()
|
|
.filter(team__isnull=False)
|
|
.values_list("team__pk", "team__name")
|
|
)
|
|
|
|
direct_paging_info_msg = {
|
|
"type": "context",
|
|
"elements": [
|
|
{
|
|
"type": "mrkdwn",
|
|
"text": (
|
|
"*Note*: You can only page teams which have a Direct Paging integration that is configured. "
|
|
"<https://grafana.com/docs/oncall/latest/integrations/manual/#set-up-direct-paging-for-a-team|Learn more>"
|
|
),
|
|
},
|
|
],
|
|
}
|
|
|
|
if not teams:
|
|
direct_paging_info_msg["elements"][0][
|
|
"text"
|
|
] += ". There are currently no teams which have a Direct Paging integration that is configured."
|
|
blocks.append(direct_paging_info_msg)
|
|
return blocks
|
|
|
|
team_options: typing.List[CompositionObjectOption] = []
|
|
|
|
initial_option_idx = 0
|
|
for idx, team in enumerate(teams):
|
|
team_pk, team_name = team
|
|
|
|
if value and value.pk == team_pk:
|
|
initial_option_idx = idx
|
|
team_options.append(
|
|
{
|
|
"text": {
|
|
"type": "plain_text",
|
|
"text": team_name,
|
|
"emoji": True,
|
|
},
|
|
"value": make_value({"id": team_pk}, organization),
|
|
}
|
|
)
|
|
|
|
team_select: Block.Input = {
|
|
"type": "input",
|
|
"block_id": input_id_prefix + DIRECT_PAGING_TEAM_SELECT_ID,
|
|
"label": {
|
|
"type": "plain_text",
|
|
"text": "Team to notify",
|
|
},
|
|
"element": {
|
|
"type": "static_select",
|
|
"action_id": OnPagingTeamChange.routing_uid(),
|
|
"placeholder": {"type": "plain_text", "text": "Select team", "emoji": True},
|
|
"options": team_options,
|
|
},
|
|
"dispatch_action": True,
|
|
"optional": True,
|
|
}
|
|
|
|
blocks.append(team_select)
|
|
|
|
# No context block if no team selected
|
|
if not is_selected:
|
|
blocks.append(direct_paging_info_msg)
|
|
return blocks
|
|
|
|
team_select["element"]["initial_option"] = team_options[initial_option_idx]
|
|
|
|
alert_receive_channel = AlertReceiveChannel.objects.filter(
|
|
organization=organization,
|
|
team=value,
|
|
integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING,
|
|
).first()
|
|
|
|
blocks.append(
|
|
{
|
|
"type": "context",
|
|
"elements": [
|
|
{
|
|
"type": "mrkdwn",
|
|
"text": f"Integration <{alert_receive_channel.web_link}|{alert_receive_channel.verbal_name}> will be used for notification.",
|
|
},
|
|
],
|
|
}
|
|
)
|
|
|
|
return blocks
|
|
|
|
|
|
def _create_user_option_groups(
|
|
organization, users: "RelatedManager['User']", max_options_per_group: int, option_group_label_text_prefix: str
|
|
) -> typing.List[CompositionObjectOptionGroup]:
|
|
user_options: typing.List[CompositionObjectOption] = [
|
|
{
|
|
"text": {
|
|
"type": "plain_text",
|
|
"text": f"{user.name or user.username}",
|
|
"emoji": True,
|
|
},
|
|
"value": json.dumps({"id": user.pk}),
|
|
}
|
|
for user in users
|
|
]
|
|
|
|
chunks = [user_options[x : x + max_options_per_group] for x in range(0, len(user_options), max_options_per_group)]
|
|
has_more_than_one_chunk = len(chunks) > 1
|
|
|
|
option_groups: typing.List[CompositionObjectOptionGroup] = []
|
|
for idx, group in enumerate(chunks):
|
|
start = idx * max_options_per_group + 1
|
|
end = idx * max_options_per_group + max_options_per_group
|
|
|
|
if has_more_than_one_chunk:
|
|
label_text = f"{option_group_label_text_prefix} ({start}-{end})"
|
|
else:
|
|
label_text = option_group_label_text_prefix
|
|
|
|
option_groups.append(
|
|
{
|
|
"label": {"type": "plain_text", "text": label_text},
|
|
"options": group,
|
|
}
|
|
)
|
|
|
|
# Only inject chatops-proxy metadata into the first dropdown option to reduce payload size
|
|
# so the 250kb Slack limit is not exceeded for orgs with many users
|
|
if option_groups:
|
|
option_groups[0]["options"][0]["value"] = make_value(
|
|
json.loads(option_groups[0]["options"][0]["value"]), organization
|
|
)
|
|
|
|
return option_groups
|
|
|
|
|
|
def _get_user_select_blocks(
|
|
payload: EventPayload,
|
|
organization: "Organization",
|
|
input_id_prefix: str,
|
|
error_msg: str | None,
|
|
) -> Block.AnyBlocks:
|
|
blocks: Block.AnyBlocks = []
|
|
|
|
if error_msg:
|
|
blocks += [
|
|
typing.cast(
|
|
Block.Section,
|
|
{
|
|
"type": "section",
|
|
"block_id": "error_message",
|
|
"text": {
|
|
"type": "mrkdwn",
|
|
"text": f":warning: {error_msg}",
|
|
},
|
|
},
|
|
),
|
|
]
|
|
|
|
blocks.append(_get_users_select(organization, input_id_prefix, OnPagingUserChange.routing_uid()))
|
|
|
|
# selected items
|
|
if selected_users := get_current_items(payload, DataKey.USERS, organization.users):
|
|
blocks += [DIVIDER]
|
|
blocks += _get_selected_entries_list(organization, input_id_prefix, DataKey.USERS, selected_users)
|
|
blocks += [DIVIDER]
|
|
|
|
return blocks
|
|
|
|
|
|
def _get_users_select(
|
|
organization: "Organization", input_id_prefix: str, action_id: str, max_options_per_group=MAX_STATIC_SELECT_OPTIONS
|
|
) -> Block.Context | Block.Input:
|
|
schedules = get_cached_oncall_users_for_multiple_schedules(organization.oncall_schedules.all())
|
|
oncall_user_pks = {user.pk for _, users in schedules.items() for user in users}
|
|
|
|
oncall_user_option_groups = _create_user_option_groups(
|
|
organization, organization.users.filter(pk__in=oncall_user_pks), max_options_per_group, "On-call now"
|
|
)
|
|
not_oncall_user_option_groups = _create_user_option_groups(
|
|
organization, organization.users.exclude(pk__in=oncall_user_pks), max_options_per_group, "Not on-call"
|
|
)
|
|
|
|
if not oncall_user_option_groups and not not_oncall_user_option_groups:
|
|
return {"type": "context", "elements": [{"type": "mrkdwn", "text": "No users available"}]}
|
|
return {
|
|
"type": "input",
|
|
"block_id": input_id_prefix + DIRECT_PAGING_USER_SELECT_ID,
|
|
"label": {
|
|
"type": "plain_text",
|
|
"text": "User(s) to notify",
|
|
},
|
|
"element": {
|
|
"type": "static_select",
|
|
"action_id": action_id,
|
|
"placeholder": {"type": "plain_text", "text": "Select user", "emoji": True},
|
|
"option_groups": oncall_user_option_groups + not_oncall_user_option_groups,
|
|
},
|
|
"dispatch_action": True,
|
|
"optional": True,
|
|
}
|
|
|
|
|
|
def _get_selected_entries_list(
|
|
organization: "Organization", input_id_prefix: str, key: DataKey, entries: typing.List[typing.Tuple[Model, Policy]]
|
|
) -> typing.List[Block.Section]:
|
|
current_entries: typing.List[Block.Section] = []
|
|
for entry, policy in entries:
|
|
if key == DataKey.USERS:
|
|
icon = ":bust_in_silhouette:"
|
|
name = entry.name or entry.username
|
|
extra = entry.timezone
|
|
|
|
current_entries.append(
|
|
{
|
|
"type": "section",
|
|
"block_id": input_id_prefix + f"{key}_{entry.pk}",
|
|
"text": {
|
|
"type": "mrkdwn",
|
|
"text": f"{icon} *{name}* | {policy} notifications" + (f"\n_({extra})_" if extra else ""),
|
|
},
|
|
"accessory": {
|
|
"type": "overflow",
|
|
"options": [
|
|
{
|
|
"text": {"type": "plain_text", "text": f"{label}"},
|
|
"value": make_value({"action": action, "key": key, "id": str(entry.pk)}, organization),
|
|
}
|
|
for (action, label) in ITEM_ACTIONS
|
|
],
|
|
"action_id": OnPagingItemActionChange.routing_uid(),
|
|
},
|
|
}
|
|
)
|
|
return current_entries
|
|
|
|
|
|
def _display_confirm_participant_invitation_view(callback_id: str, private_metadata: str) -> ModalView:
|
|
return {
|
|
"type": "modal",
|
|
"callback_id": callback_id,
|
|
"title": {"type": "plain_text", "text": "Confirm user invitation"},
|
|
"submit": {"type": "plain_text", "text": "Confirm"},
|
|
"blocks": [
|
|
{
|
|
"type": "section",
|
|
"text": {
|
|
"type": "mrkdwn",
|
|
"text": "This user is not currently on-call. We don't recommend to page users outside on-call hours.",
|
|
},
|
|
}
|
|
],
|
|
"private_metadata": private_metadata,
|
|
}
|
|
|
|
|
|
def _get_selected_team_from_payload(
|
|
payload: EventPayload, input_id_prefix: str
|
|
) -> typing.Tuple[str | None, typing.Optional["Team"]]:
|
|
from apps.user_management.models import Team
|
|
|
|
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:
|
|
return None, None
|
|
return selected_team_id, Team.objects.filter(pk=selected_team_id).first()
|
|
|
|
|
|
def _get_selected_user_from_payload(payload: EventPayload, input_id_prefix: str) -> typing.Optional["User"]:
|
|
from apps.user_management.models import User
|
|
|
|
selected_user_id = _get_select_field_value(
|
|
payload, input_id_prefix, OnPagingUserChange.routing_uid(), DIRECT_PAGING_USER_SELECT_ID
|
|
)
|
|
if selected_user_id is not None:
|
|
user = User.objects.filter(pk=selected_user_id).first()
|
|
return user
|
|
return None
|
|
|
|
|
|
def _get_and_change_input_id_prefix_from_metadata(
|
|
metadata: typing.Dict[str, str]
|
|
) -> typing.Tuple[str, str, typing.Dict[str, str]]:
|
|
old_input_id_prefix = metadata["input_id_prefix"]
|
|
new_input_id_prefix = _generate_input_id_prefix()
|
|
metadata["input_id_prefix"] = new_input_id_prefix
|
|
return old_input_id_prefix, new_input_id_prefix, metadata
|
|
|
|
|
|
def _get_message_input(payload: EventPayload) -> Block.Input:
|
|
message_input_block: Block.Input = {
|
|
"type": "input",
|
|
"block_id": DIRECT_PAGING_MESSAGE_INPUT_ID,
|
|
"label": {
|
|
"type": "plain_text",
|
|
"text": "Message",
|
|
},
|
|
"element": {
|
|
"type": "plain_text_input",
|
|
"action_id": FinishDirectPaging.routing_uid(),
|
|
"multiline": True,
|
|
"placeholder": {
|
|
"type": "plain_text",
|
|
"text": " ",
|
|
},
|
|
},
|
|
"optional": False,
|
|
}
|
|
if payload.get("message", {}).get("text") is not None:
|
|
message_input_block["element"]["initial_value"] = payload["message"]["text"]
|
|
return message_input_block
|
|
|
|
|
|
def _get_message_from_payload(payload: EventPayload) -> str:
|
|
return (
|
|
payload["view"]["state"]["values"][DIRECT_PAGING_MESSAGE_INPUT_ID][FinishDirectPaging.routing_uid()]["value"]
|
|
or ""
|
|
)
|
|
|
|
|
|
def _get_available_organizations(
|
|
slack_team_identity: "SlackTeamIdentity", slack_user_identity: "SlackUserIdentity"
|
|
) -> QuerySet["Organization"]:
|
|
return (
|
|
slack_team_identity.organizations.filter(users__slack_user_identity=slack_user_identity)
|
|
.order_by("pk")
|
|
.distinct()
|
|
)
|
|
|
|
|
|
def _generate_input_id_prefix() -> str:
|
|
"""
|
|
returns unique string to not preserve input's values between view update
|
|
|
|
https://api.slack.com/methods/views.update#markdown
|
|
"""
|
|
return str(uuid4())
|
|
|
|
|
|
STEPS_ROUTING: ScenarioRoute.RoutingSteps = [
|
|
{
|
|
"payload_type": PayloadType.BLOCK_ACTIONS,
|
|
"block_action_type": BlockActionType.STATIC_SELECT,
|
|
"block_action_id": OnPagingOrgChange.routing_uid(),
|
|
"step": OnPagingOrgChange,
|
|
},
|
|
{
|
|
"payload_type": PayloadType.BLOCK_ACTIONS,
|
|
"block_action_type": BlockActionType.STATIC_SELECT,
|
|
"block_action_id": OnPagingTeamChange.routing_uid(),
|
|
"step": OnPagingTeamChange,
|
|
},
|
|
{
|
|
"payload_type": PayloadType.BLOCK_ACTIONS,
|
|
"block_action_type": BlockActionType.STATIC_SELECT,
|
|
"block_action_id": OnPagingUserChange.routing_uid(),
|
|
"step": OnPagingUserChange,
|
|
},
|
|
{
|
|
"payload_type": PayloadType.VIEW_SUBMISSION,
|
|
"view_callback_id": OnPagingConfirmUserChange.routing_uid(),
|
|
"step": OnPagingConfirmUserChange,
|
|
},
|
|
{
|
|
"payload_type": PayloadType.BLOCK_ACTIONS,
|
|
"block_action_type": BlockActionType.OVERFLOW,
|
|
"block_action_id": OnPagingItemActionChange.routing_uid(),
|
|
"step": OnPagingItemActionChange,
|
|
},
|
|
{
|
|
"payload_type": PayloadType.SLASH_COMMAND,
|
|
"step": StartDirectPaging,
|
|
"matcher": StartDirectPaging.matcher,
|
|
},
|
|
{
|
|
"payload_type": PayloadType.VIEW_SUBMISSION,
|
|
"view_callback_id": FinishDirectPaging.routing_uid(),
|
|
"step": FinishDirectPaging,
|
|
},
|
|
]
|