From 07b5396e687b59cc9b45e8a2b6bf46e988557662 Mon Sep 17 00:00:00 2001 From: Will Sheldon <114631109+wssheldon@users.noreply.github.com> Date: Mon, 16 Sep 2024 16:49:59 -0700 Subject: [PATCH] ux(slack): improve case report modal type to assignee and oncall resolution (#5197) * ux(slack): improve case report modal type to assignee and oncall resolution * ux(slack): improve case report modal type to assignee and oncall resolution * fix: assigne_email can never be None --- .../plugins/dispatch_pagerduty/plugin.py | 15 ++ .../plugins/dispatch_slack/case/enums.py | 2 + .../dispatch_slack/case/interactive.py | 184 +++++++++++++++++- src/dispatch/plugins/dispatch_slack/fields.py | 2 +- 4 files changed, 199 insertions(+), 4 deletions(-) diff --git a/src/dispatch/plugins/dispatch_pagerduty/plugin.py b/src/dispatch/plugins/dispatch_pagerduty/plugin.py index d504bb800b01..f37e7ea0f2df 100644 --- a/src/dispatch/plugins/dispatch_pagerduty/plugin.py +++ b/src/dispatch/plugins/dispatch_pagerduty/plugin.py @@ -4,6 +4,7 @@ :copyright: (c) 2019 by Netflix Inc., see AUTHORS for more :license: Apache, see LICENSE for more details. """ + from pdpyras import APISession from pydantic import Field, SecretStr, EmailStr from typing import Optional @@ -116,6 +117,20 @@ def get_schedule_id_from_service_id(self, service_id: str) -> Optional[str]: log.error("Error trying to retrieve schedule_id from service_id") log.exception(e) + def get_service_url(self, service_id: str) -> Optional[str]: + if not service_id: + return None + + client = APISession(self.configuration.api_key.get_secret_value()) + client.url = self.configuration.pagerduty_api_url + try: + service = get_service(client, service_id) + return service.get("html_url") + except Exception as e: + log.error(f"Error retrieving service URL for service_id {service_id}") + log.exception(e) + return None + def get_next_oncall(self, service_id: str) -> Optional[str]: schedule_id = self.get_schedule_id_from_service_id(service_id) diff --git a/src/dispatch/plugins/dispatch_slack/case/enums.py b/src/dispatch/plugins/dispatch_slack/case/enums.py index bea59c5f6480..e1375aff5004 100644 --- a/src/dispatch/plugins/dispatch_slack/case/enums.py +++ b/src/dispatch/plugins/dispatch_slack/case/enums.py @@ -33,6 +33,8 @@ class CaseEscalateActions(DispatchEnum): class CaseReportActions(DispatchEnum): submit = "case-report-submit" project_select = "case-report-project-select" + case_type_select = "ccase-report-case-type-select" + assignee_select = "case-report-assignee-select" class CaseShortcutCallbacks(DispatchEnum): diff --git a/src/dispatch/plugins/dispatch_slack/case/interactive.py b/src/dispatch/plugins/dispatch_slack/case/interactive.py index adb6edbf3c6a..be6a0c5e55ea 100644 --- a/src/dispatch/plugins/dispatch_slack/case/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/case/interactive.py @@ -30,6 +30,7 @@ from dispatch.case import service as case_service from dispatch.case.enums import CaseStatus, CaseResolutionReason from dispatch.case.models import Case, CaseCreate, CaseRead, CaseUpdate +from dispatch.case.type import service as case_type_service from dispatch.conversation import flows as conversation_flows from dispatch.entity import service as entity_service from dispatch.participant_role import service as participant_role_service @@ -100,6 +101,7 @@ ) from dispatch.project import service as project_service from dispatch.search.utils import create_filter_expression +from dispatch.service import flows as service_flows from dispatch.signal import service as signal_service from dispatch.signal.enums import SignalEngagementStatus from dispatch.signal.models import ( @@ -1651,8 +1653,12 @@ def report_issue( @app.action(CaseReportActions.project_select, middleware=[db_middleware, action_context_middleware]) def handle_report_project_select_action( - ack: Ack, body: dict, db_session: Session, context: BoltContext, client: WebClient -): + ack: Ack, + body: dict, + db_session: Session, + context: BoltContext, + client: WebClient, +) -> None: ack() values = body["view"]["state"]["values"] @@ -1681,7 +1687,20 @@ def handle_report_project_select_action( action_id=CaseReportActions.project_select, dispatch_action=True, ), - case_type_select(db_session=db_session, initial_option=None, project_id=project.id), + case_type_select( + db_session=db_session, + initial_option=None, + project_id=project.id, + action_id=CaseReportActions.case_type_select, + dispatch_action=True, + ), + Context( + elements=[ + MarkdownText( + text="💡 Case Types determine the initial assignee based on their configured on-call schedule." + ) + ] + ), case_priority_select( db_session=db_session, project_id=project.id, @@ -1707,6 +1726,160 @@ def handle_report_project_select_action( ) +@app.action( + CaseReportActions.case_type_select, middleware=[db_middleware, action_context_middleware] +) +def handle_report_case_type_select_action( + ack: Ack, + body: dict, + db_session: Session, + context: BoltContext, + client: WebClient, +) -> None: + ack() + values = body["view"]["state"]["values"] + + project_id = values[DefaultBlockIds.project_select][CaseReportActions.project_select][ + "selected_option" + ]["value"] + + case_type_id = values[DefaultBlockIds.case_type_select][CaseReportActions.case_type_select][ + "selected_option" + ]["value"] + + project = project_service.get( + db_session=db_session, + project_id=project_id, + ) + + case_type = case_type_service.get( + db_session=db_session, + case_type_id=case_type_id, + ) + + assignee_email = None + assignee_slack_id = None + oncall_service_name = None + service_url = None + + # Resolve the assignee based on the case type + if case_type.oncall_service: + assignee_email = service_flows.resolve_oncall( + service=case_type.oncall_service, db_session=db_session + ) + oncall_service_name = case_type.oncall_service.name + + oncall_plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=project.id, plugin_type="oncall" + ) + if not oncall_plugin: + log.debug("Unable to send email since oncall plugin is not active.") + else: + service_url = oncall_plugin.instance.get_service_url( + case_type.oncall_service.external_id + ) + + if assignee_email: + # Get the Slack user ID for the assignee + try: + assignee_slack_id = client.users_lookupByEmail(email=assignee_email)["user"]["id"] + except SlackApiError: + assignee_slack_id = None + + blocks = [ + Context( + elements=[ + MarkdownText( + text="Cases are meant to triage events that do not raise to the level of incidents, but can be escalated to incidents if necessary. If you suspect a security issue and need help, please fill out this form to the best of your abilities." + ) + ] + ), + title_input(), + description_input(), + project_select( + db_session=db_session, + initial_option={"text": project.name, "value": project.id}, + action_id=CaseReportActions.project_select, + dispatch_action=True, + ), + case_type_select( + db_session=db_session, + initial_option={"text": case_type.name, "value": case_type.id}, + project_id=project.id, + action_id=CaseReportActions.case_type_select, + dispatch_action=True, + ), + Context( + elements=[ + MarkdownText( + text="💡 Case Types determine the initial assignee based on their configured on-call schedule." + ) + ] + ), + case_priority_select( + db_session=db_session, + project_id=project.id, + initial_option=None, + optional=True, + block_id=None, # ensures state is reset + ), + assignee_select( + initial_user=assignee_slack_id if assignee_slack_id else None, + action_id=CaseReportActions.assignee_select, + ), + ] + + # Conditionally add context blocks + if oncall_service_name and assignee_email: + if service_url: + oncall_text = ( + f"👩‍🚒 {assignee_email} is on-call for <{service_url}|{oncall_service_name}>" + ) + else: + oncall_text = f"👩‍🚒 {assignee_email} is on-call for {oncall_service_name}" + + blocks.extend( + [ + Context(elements=[MarkdownText(text=oncall_text)]), + Divider(), + Context( + elements=[ + MarkdownText( + text="Not who you're looking for? You can override the assignee for this case." + ) + ] + ), + ] + ) + else: + blocks.extend( + [ + Context( + elements=[ + MarkdownText( + text="There is no on-call service associated with this case type." + ) + ] + ), + Context(elements=[MarkdownText(text="Please select an assignee for this case.")]), + ] + ) + + modal = Modal( + title="Open a Case", + blocks=blocks, + submit="Report", + close="Close", + callback_id=CaseReportActions.submit, + private_metadata=context["subject"].json(), + ).build() + + client.views_update( + view_id=body["view"]["id"], + view=modal, + ) + + def ack_report_case_submission_event(ack: Ack) -> None: """Handles the report case submission event acknowledgment.""" modal = Modal( @@ -1740,6 +1913,10 @@ def handle_report_submission_event( if form_data.get(DefaultBlockIds.case_type_select): case_type = {"name": form_data[DefaultBlockIds.case_type_select]["name"]} + assignee_email = client.users_info( + user=form_data[DefaultBlockIds.case_assignee_select]["value"] + )["user"]["profile"]["email"] + case_in = CaseCreate( title=form_data[DefaultBlockIds.title_input], description=form_data[DefaultBlockIds.description_input], @@ -1748,6 +1925,7 @@ def handle_report_submission_event( case_type=case_type, dedicated_channel=True, reporter=ParticipantUpdate(individual=IndividualContactRead(email=user.email)), + assignee=ParticipantUpdate(individual=IndividualContactRead(email=assignee_email)), ) case = case_service.create(db_session=db_session, case_in=case_in, current_user=user) diff --git a/src/dispatch/plugins/dispatch_slack/fields.py b/src/dispatch/plugins/dispatch_slack/fields.py index 9e624444974d..58de4eeadd6c 100644 --- a/src/dispatch/plugins/dispatch_slack/fields.py +++ b/src/dispatch/plugins/dispatch_slack/fields.py @@ -598,7 +598,7 @@ def case_type_select( action_id: str = DefaultActionIds.case_type_select, block_id: str = DefaultBlockIds.case_type_select, label: str = "Case Type", - initial_option: dict = None, + initial_option: dict | None = None, project_id: int = None, **kwargs, ):