From 71032b77376c71f8904a11985b83b12951418d7f Mon Sep 17 00:00:00 2001 From: Will Sheldon <114631109+wssheldon@users.noreply.github.com> Date: Thu, 28 Mar 2024 18:23:09 -0700 Subject: [PATCH] Latest, adds slash commands, better escalate flow, bookmarks, and more --- src/dispatch/case/flows.py | 291 +++++++++--------- src/dispatch/case/models.py | 12 + src/dispatch/case/service.py | 1 + src/dispatch/conversation/flows.py | 64 ++-- src/dispatch/conversation/service.py | 12 +- .../versions/2024-03-28_3a33bc153e7e.py | 28 ++ src/dispatch/enums.py | 6 + src/dispatch/incident/flows.py | 42 ++- src/dispatch/incident/messaging.py | 68 ++-- src/dispatch/plugins/dispatch_slack/ack.py | 14 + src/dispatch/plugins/dispatch_slack/bolt.py | 4 +- .../dispatch_slack/case/interactive.py | 176 ++++++++++- src/dispatch/plugins/dispatch_slack/config.py | 10 + .../dispatch_slack/incident/interactive.py | 13 +- .../plugins/dispatch_slack/middleware.py | 12 +- .../plugins/dispatch_slack/service.py | 18 +- .../src/case/ReportSubmissionCard.vue | 9 + .../static/dispatch/src/case/store.js | 1 + src/dispatch/types.py | 6 + 19 files changed, 569 insertions(+), 218 deletions(-) create mode 100644 src/dispatch/database/revisions/tenant/versions/2024-03-28_3a33bc153e7e.py create mode 100644 src/dispatch/plugins/dispatch_slack/ack.py create mode 100644 src/dispatch/types.py diff --git a/src/dispatch/case/flows.py b/src/dispatch/case/flows.py index 4ae2baa90887..4e1475a5fbb7 100644 --- a/src/dispatch/case/flows.py +++ b/src/dispatch/case/flows.py @@ -17,7 +17,8 @@ from dispatch.incident import flows as incident_flows from dispatch.incident import service as incident_service from dispatch.incident.enums import IncidentStatus -from dispatch.incident.models import IncidentCreate +from dispatch.incident.messaging import send_participant_announcement_message +from dispatch.incident.models import IncidentCreate, Incident from dispatch.individual.models import IndividualContactRead from dispatch.models import OrganizationSlug, PrimaryKey from dispatch.participant import flows as participant_flows @@ -317,7 +318,7 @@ def case_update_flow( db_session=db_session, ) - if case.conversation: + if case.conversation and case.has_thread: # we send the case updated notification update_conversation(case, db_session) @@ -364,7 +365,9 @@ def case_escalated_status_flow(case: Case, organization_slug: OrganizationSlug, db_session.commit() case_to_incident_escalate_flow( - case=case, organization_slug=organization_slug, db_session=db_session + case=case, + organization_slug=organization_slug, + db_session=db_session, ) @@ -381,110 +384,122 @@ def case_status_transition_flow_dispatcher( current_status: CaseStatus, previous_status: CaseStatus, organization_slug: OrganizationSlug, - db_session: SessionLocal, + db_session: Session, ): """Runs the correct flows based on the current and previous status of the case.""" - # we changed the status of the case to new - if current_status == CaseStatus.new: - if previous_status == CaseStatus.triage: - # Triage -> New - pass - elif previous_status == CaseStatus.escalated: - # Escalated -> New - pass - elif previous_status == CaseStatus.closed: - # Closed -> New + match (previous_status, current_status): + case (_, CaseStatus.new): + # Any -> New pass - # we changed the status of the case to triage - elif current_status == CaseStatus.triage: - if previous_status == CaseStatus.new: + case (CaseStatus.new, CaseStatus.triage): # New -> Triage case_triage_status_flow(case=case, db_session=db_session) - elif previous_status == CaseStatus.escalated: - # Escalated -> Triage - pass - elif previous_status == CaseStatus.closed: - # Closed -> Triage + + case (_, CaseStatus.triage): + # Any -> Triage pass - # we changed the status of the case to escalated - elif current_status == CaseStatus.escalated: - if previous_status == CaseStatus.new: + case (CaseStatus.new, CaseStatus.escalated): # New -> Escalated case_triage_status_flow(case=case, db_session=db_session) case_escalated_status_flow( - case=case, organization_slug=organization_slug, db_session=db_session + case=case, + organization_slug=organization_slug, + db_session=db_session, ) - elif previous_status == CaseStatus.triage: + + case (CaseStatus.triage, CaseStatus.escalated): # Triage -> Escalated case_escalated_status_flow( - case=case, organization_slug=organization_slug, db_session=db_session + case=case, + organization_slug=organization_slug, + db_session=db_session, ) - elif previous_status == CaseStatus.closed: - # Closed -> Escalated + + case (_, CaseStatus.escalated): + # Any -> Escalated pass - # we changed the status of the case to closed - elif current_status == CaseStatus.closed: - if previous_status == CaseStatus.new: + case (CaseStatus.new, CaseStatus.closed): # New -> Closed case_triage_status_flow(case=case, db_session=db_session) case_closed_status_flow(case=case, db_session=db_session) - elif previous_status == CaseStatus.triage: + + case (CaseStatus.triage, CaseStatus.closed): # Triage -> Closed case_closed_status_flow(case=case, db_session=db_session) - elif previous_status == CaseStatus.escalated: + + case (CaseStatus.escalated, CaseStatus.closed): # Escalated -> Closed case_closed_status_flow(case=case, db_session=db_session) + case (_, _): + pass -def case_to_incident_escalate_flow( - case: Case, organization_slug: OrganizationSlug, db_session=None + +def send_escalation_messages_for_channel_case( + case: Case, + db_session: Session, + incident: Incident, ): - """Escalates a case to an incident if the case's type is mapped to an incident type.""" - if case.incidents: - # we don't escalate the case if the case is already linked to incidents - return + from dispatch.plugins.dispatch_slack.incident import messages - if not case.case_type.incident_type: - # we don't escalate the case if its type is not mapped to an incident type + plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=case.project.id, plugin_type="conversation" + ) + if plugin is None: + log.warning("Case close reminder message not sent. No conversation plugin enabled.") return - # we make the assignee of the case the reporter of the incident - reporter = ParticipantUpdate( - individual=IndividualContactRead(email=case.assignee.individual.email) + plugin.instance.send_message( + conversation_id=incident.conversation.channel_id, + blocks=messages.create_incident_channel_escalate_message(), ) - # we add information about the case in the incident's description - description = ( - f"{case.description}\n\n" - f"This incident was the result of escalating case {case.name} " - f"in the {case.project.name} project. Check out the case in the Dispatch Web UI for additional context." + plugin.instance.rename( + conversation_id=incident.conversation.channel_id, + name=incident.name, ) - # we create the incident - incident_in = IncidentCreate( - title=case.title, - description=description, - status=IncidentStatus.active, - incident_type=case.case_type.incident_type, - incident_priority=case.case_priority, - project=case.case_type.incident_type.project, - reporter=reporter, + +def common_escalate_flow( + case: Case, + incident: Incident, + organization_slug: OrganizationSlug, + db_session: Session, +): + # This is a channel based Case, so we reuse the case conversation for the incident + if case.has_channel: + incident.conversation = case.conversation + db_session.add(incident) + db_session.commit() + + # we run the incident create flow + incident = incident_flows.incident_create_flow( + incident_id=incident.id, organization_slug=organization_slug, db_session=db_session ) - incident = incident_service.create(db_session=db_session, incident_in=incident_in) - # we map the case to the newly created incident + # we add the case participants to the incident + for participant in case.participants: + conversation_flows.add_incident_participants( + db_session=db_session, + incident=incident, + participant_emails=[participant.individual.email], + ) + + if case.has_channel: + # depends on `incident_create_flow()` (we need incident.name), so we invoke after we call it + send_escalation_messages_for_channel_case( + case=case, + db_session=db_session, + incident=incident, + ) + case.incidents.append(incident) db_session.add(case) db_session.commit() - # we run the incident creation flow - incident_flows.incident_create_flow( - incident_id=incident.id, organization_slug=organization_slug, db_session=db_session - ) - event_service.log_case_event( db_session=db_session, source="Dispatch Core App", @@ -493,8 +508,6 @@ def case_to_incident_escalate_flow( ) if case.storage and incident.tactical_group: - # we add the incident's tactical group to the case's storage folder - # to allow incident participants to access the case's artifacts in the folder storage_members = [incident.tactical_group.email] storage_flows.update_storage( subject=case, @@ -511,86 +524,64 @@ def case_to_incident_escalate_flow( ) +def case_to_incident_escalate_flow( + case: Case, + organization_slug: OrganizationSlug, + db_session: Session = None, +): + if case.incidents or not case.case_type.incident_type: + return + + reporter = ParticipantUpdate( + individual=IndividualContactRead(email=case.assignee.individual.email) + ) + + description = ( + f"{case.description}\n\n" + f"This incident was the result of escalating case {case.name} " + f"in the {case.project.name} project. Check out the case in the Dispatch Web UI for additional context." + ) + + incident_in = IncidentCreate( + title=case.title, + description=description, + status=IncidentStatus.active, + incident_type=case.case_type.incident_type, + incident_priority=case.case_priority, + project=case.case_type.incident_type.project, + reporter=reporter, + ) + incident = incident_service.create(db_session=db_session, incident_in=incident_in) + + common_escalate_flow( + case=case, + incident=incident, + organization_slug=organization_slug, + db_session=db_session, + ) + + @background_task def case_to_incident_endpoint_escalate_flow( case_id: PrimaryKey, incident_id: PrimaryKey, organization_slug: OrganizationSlug, - db_session=None, + db_session: Session = None, ): - """Allows for a case to be escalated to an incident while modifying its properties.""" - from dispatch.plugins.dispatch_slack.incident import messages - - # we get the case case = get(case_id=case_id, db_session=db_session) - # we set the triage at time case_triage_status_flow(case=case, db_session=db_session) - - # we set the escalated at time and change the status to escalated case.escalated_at = datetime.utcnow() case.status = CaseStatus.escalated - - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - - channel_case = True if case.conversation.thread_id else False - # This is a channel based Case, so we reuse the case conversation for the incident - if not channel_case: - incident.conversation = case.conversation - db_session.add(incident) - db_session.commit() - - # we run the incident create flow - incident = incident_flows.incident_create_flow( - incident_id=incident_id, organization_slug=organization_slug, db_session=db_session - ) - - if not channel_case: - plugin = plugin_service.get_active_instance( - db_session=db_session, project_id=case.project.id, plugin_type="conversation" - ) - if plugin is None: - log.warning("Case close reminder message not sent. No conversation plugin enabled.") - return - - plugin.instance.send_message( - conversation_id=incident.conversation.channel_id, - blocks=messages.create_incident_channel_escalate_message(), - ) - - plugin.instance.rename( - conversation_id=incident.conversation.channel_id, - name=incident.name, - ) - - case.incidents.append(incident) - - db_session.add(case) db_session.commit() - event_service.log_case_event( - db_session=db_session, - source="Dispatch Core App", - description=f"The case has been linked to incident {incident.name} in the {incident.project.name} project", - case_id=case.id, - ) - - if case.storage and incident.tactical_group: - # we add the incident's tactical group to the case's storage folder - # to allow incident participants to access the case's artifacts in the folder - storage_members = [incident.tactical_group.email] - storage_flows.update_storage( - subject=case, - storage_action=StorageAction.add_members, - storage_members=storage_members, - db_session=db_session, - ) + incident = incident_service.get(db_session=db_session, incident_id=incident_id) - event_service.log_case_event( + common_escalate_flow( + case=case, + incident=incident, + organization_slug=organization_slug, db_session=db_session, - source="Dispatch Core App", - description=f"The members of the incident's tactical group {incident.tactical_group.email} have been given permission to access the case's storage folder", - case_id=case.id, ) @@ -693,13 +684,24 @@ def case_create_resources_flow( case_id=case.id, add_to_conversation=False, ) - # explicitly add the assignee to the conversation - all_participants = individual_participants + [case.assignee.individual.email] + # explicitly add the assignee and reporter to the conversation + all_participants = individual_participants + [ + case.assignee.individual.email, + case.reporter.individual.email, + ] # # we add the participant to the conversation conversation_flows.add_case_participants( - case=case, participant_emails=all_participants, db_session=db_session + case=case, + participant_emails=all_participants, + db_session=db_session, ) + for user_email in set(all_participants): + send_participant_announcement_message( + db_session=db_session, + participant_email=user_email, + subject=case, + ) event_service.log_case_event( db_session=db_session, @@ -716,5 +718,20 @@ def case_create_resources_flow( ) log.exception(e) + if case.has_channel: + bookmarks = [ + # resource, title + (case.case_document, None), + (case.ticket, "Case Ticket"), + (case.storage, "Case Storage"), + ] + for resource, title in bookmarks: + conversation_flows.add_conversation_bookmark( + subject=case, + resource=resource, + db_session=db_session, + title=title, + ) + # we update the ticket ticket_flows.update_case_ticket(case=case, db_session=db_session) diff --git a/src/dispatch/case/models.py b/src/dispatch/case/models.py index d7aa9852b2a2..0c2b73c224b2 100644 --- a/src/dispatch/case/models.py +++ b/src/dispatch/case/models.py @@ -4,6 +4,7 @@ from pydantic import validator from sqlalchemy import ( + Boolean, Column, DateTime, ForeignKey, @@ -88,6 +89,8 @@ class Case(Base, TimeStampMixin, ProjectMixin): escalated_at = Column(DateTime) closed_at = Column(DateTime) + dedicated_channel = Column(Boolean, default=False) + search_vector = Column( TSVectorType( "name", "title", "description", weights={"name": "A", "title": "B", "description": "C"} @@ -169,6 +172,14 @@ def participant_observer(self, participants): self.participants_team = Counter(p.team for p in participants).most_common(1)[0][0] self.participants_location = Counter(p.location for p in participants).most_common(1)[0][0] + @property + def has_channel(self) -> bool: + return True if not self.conversation.thread_id else False + + @property + def has_thread(self) -> bool: + return True if self.conversation.thread_id else False + class SignalRead(DispatchBase): id: PrimaryKey @@ -222,6 +233,7 @@ class CaseCreate(CaseBase): case_priority: Optional[CasePriorityCreate] case_severity: Optional[CaseSeverityCreate] case_type: Optional[CaseTypeCreate] + dedicated_channel: Optional[bool] project: Optional[ProjectRead] reporter: Optional[ParticipantUpdate] tags: Optional[List[TagRead]] = [] diff --git a/src/dispatch/case/service.py b/src/dispatch/case/service.py index 0116a8028569..efc0d1910bcf 100644 --- a/src/dispatch/case/service.py +++ b/src/dispatch/case/service.py @@ -142,6 +142,7 @@ def create(*, db_session, case_in: CaseCreate, current_user: DispatchUser = None description=case_in.description, project=project, status=case_in.status, + dedicated_channel=case_in.dedicated_channel, tags=tag_objs, ) diff --git a/src/dispatch/conversation/flows.py b/src/dispatch/conversation/flows.py index dfcdeccf3a2c..c6fbd0769238 100644 --- a/src/dispatch/conversation/flows.py +++ b/src/dispatch/conversation/flows.py @@ -1,6 +1,6 @@ import logging -from typing import TypeVar, List +from sqlalchemy.orm import Session from dispatch.case.models import Case from dispatch.conference.models import Conference @@ -13,6 +13,7 @@ from dispatch.ticket.models import Ticket from dispatch.utils import deslug_and_capitalize_resource_type from dispatch.config import DISPATCH_UI_URL +from dispatch.types import Subject from .models import Conversation, ConversationCreate from .service import create @@ -20,11 +21,13 @@ log = logging.getLogger(__name__) -Resource = TypeVar("Resource", Document, Conference, Storage, Ticket) +Resource = Document | Conference | Storage | Ticket def create_case_conversation( - case: Case, conversation_target: str, db_session: SessionLocal, use_channel: bool = False + case: Case, + conversation_target: str, + db_session: Session, ): """Create external communication conversation.""" @@ -40,18 +43,26 @@ def create_case_conversation( conversation = None - use_channel = True + # This case is a thread version, we send a new messaged (threaded) to the conversation target + # for the configured case type if conversation_target: try: - if not use_channel: + if not case.dedicated_channel: conversation = plugin.instance.create_threaded( case=case, conversation_id=conversation_target, db_session=db_session, ) - else: - conversation = plugin.instance.create(name=case.name) + except Exception as e: + # TODO: consistency across exceptions + log.exception(e) + # otherwise, it must be a channel based case. + if case.dedicated_channel: + try: + conversation = plugin.instance.create( + name=f"case-{case.name}", + ) except Exception as e: # TODO: consistency across exceptions log.exception(e) @@ -219,16 +230,23 @@ def set_conversation_topic(incident: Incident, db_session: SessionLocal): log.exception(e) -def add_conversation_bookmark(incident: Incident, resource: Resource, db_session: SessionLocal): +def add_conversation_bookmark( + db_session: Session, + subject: Subject, + resource: Resource, + title: str | None = None, +): """Adds a conversation bookmark.""" - if not incident.conversation: + if not subject.conversation: log.warning( - f"Conversation bookmark {resource.name.lower()} not added. No conversation available for this incident." + f"Conversation bookmark {resource.name.lower()} not added. No conversation available." ) return plugin = plugin_service.get_active_instance( - db_session=db_session, project_id=incident.project.id, plugin_type="conversation" + db_session=db_session, + project_id=subject.project.id, + plugin_type="conversation", ) if not plugin: log.warning( @@ -237,16 +255,17 @@ def add_conversation_bookmark(incident: Incident, resource: Resource, db_session return try: - title = deslug_and_capitalize_resource_type(resource.resource_type) + if not title: + title = deslug_and_capitalize_resource_type(resource.resource_type) ( plugin.instance.add_bookmark( - incident.conversation.channel_id, + subject.conversation.channel_id, resource.weblink, title=title, ) if resource else log.warning( - f"{resource.name} bookmark not added. No {resource.name.lower()} available for this incident." + f"{resource.name} bookmark not added. No {resource.name.lower()} available for subject.." ) ) except Exception as e: @@ -254,12 +273,15 @@ def add_conversation_bookmark(incident: Incident, resource: Resource, db_session db_session=db_session, source="Dispatch Core App", description=f"Adding the {resource.name.lower()} bookmark failed. Reason: {e}", - incident_id=incident.id, + incident_id=subject.id, ) log.exception(e) -def add_conversation_bookmarks(incident: Incident, db_session: SessionLocal): +def add_conversation_bookmarks( + incident: Incident, + db_session: Session, +): """Adds the conversation bookmarks.""" if not incident.conversation: log.warning( @@ -339,7 +361,11 @@ def add_conversation_bookmarks(incident: Incident, db_session: SessionLocal): log.exception(e) -def add_case_participants(case: Case, participant_emails: List[str], db_session: SessionLocal): +def add_case_participants( + case: Case, + participant_emails: list[str], + db_session: Session, +): """Adds one or more participants to the case conversation.""" if not case.conversation: log.warning( @@ -373,7 +399,9 @@ def add_case_participants(case: Case, participant_emails: List[str], db_session: def add_incident_participants( - incident: Incident, participant_emails: List[str], db_session: SessionLocal + incident: Incident, + participant_emails: list[str], + db_session: Session, ): """Adds one or more participants to the incident conversation.""" if not incident.conversation: diff --git a/src/dispatch/conversation/service.py b/src/dispatch/conversation/service.py index daa441e738ea..9ff7d691fccf 100644 --- a/src/dispatch/conversation/service.py +++ b/src/dispatch/conversation/service.py @@ -15,24 +15,20 @@ def get_by_channel_id_ignoring_channel_type( Gets a conversation by its id ignoring the channel type, and updates the channel id in the database if the channel type has changed. """ - channel_id_without_type = channel_id[1:] - conversation = None - query = db_session.query(Conversation).filter( - Conversation.channel_id.contains(channel_id_without_type) - ) + conversations = db_session.query(Conversation).filter(Conversation.channel_id == channel_id) # The code below disambiguates between incident threads, case threads, and incident messages if not thread_id: # assume incident message - conversation = query.first() + conversation = conversations.first() if not conversation: - conversation = query.filter(Conversation.thread_id == thread_id).one_or_none() + conversation = conversations.filter(Conversation.thread_id == thread_id).one_or_none() if not conversation: - conversation = query.one_or_none() + conversation = conversations.one_or_none() if conversation: if channel_id[0] != conversation.channel_id[0]: diff --git a/src/dispatch/database/revisions/tenant/versions/2024-03-28_3a33bc153e7e.py b/src/dispatch/database/revisions/tenant/versions/2024-03-28_3a33bc153e7e.py new file mode 100644 index 000000000000..ab90b7acf6cf --- /dev/null +++ b/src/dispatch/database/revisions/tenant/versions/2024-03-28_3a33bc153e7e.py @@ -0,0 +1,28 @@ +"""Creates a column on the Case + +Revision ID: 3a33bc153e7e +Revises: 91bd05855ad1 +Create Date: 2024-03-28 16:28:06.148971 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "3a33bc153e7e" +down_revision = "91bd05855ad1" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("case", sa.Column("dedicated_channel", sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("case", "dedicated_channel") + # ### end Alembic commands ### diff --git a/src/dispatch/enums.py b/src/dispatch/enums.py index b85a532b9627..057555f48077 100644 --- a/src/dispatch/enums.py +++ b/src/dispatch/enums.py @@ -65,3 +65,9 @@ class EventType(DispatchEnum): participant_updated = "Participant updated" # for added/removed users and role changes imported_message = "Imported message" # for stopwatch-reacted messages from Slack custom_event = "Custom event" # for user-added events (new feature) + + +class SubjectNames(DispatchEnum): + CASE = "Case" + INCIDENT = "Incident" + SIGNAL = "Signal" diff --git a/src/dispatch/incident/flows.py b/src/dispatch/incident/flows.py index 84c8a01e5c0f..5b3e14b9e576 100644 --- a/src/dispatch/incident/flows.py +++ b/src/dispatch/incident/flows.py @@ -41,7 +41,7 @@ send_incident_management_help_tips_message, send_incident_new_role_assigned_notification, send_incident_open_tasks_ephemeral_message, - send_incident_participant_announcement_message, + send_participant_announcement_message, send_incident_rating_feedback_message, send_incident_review_document_notification, # send_incident_suggested_reading_messages, @@ -229,7 +229,24 @@ def incident_create_resources(*, incident: Incident, db_session=None) -> Inciden conversation_flows.set_conversation_topic(incident, db_session) # we set the conversation bookmarks - conversation_flows.add_conversation_bookmarks(incident, db_session) + # conversation_flows.add_conversation_bookmarks(incident, db_session) + bookmarks = [ + # resource, title + (incident.incident_document, None), # generated by resource name + (incident.ticket, "Incident Ticket"), + (incident.conference, "Incident Bridge"), + (incident.storage, "Incident Storage"), + ] + for resource, title in bookmarks: + if not resource: + continue + + conversation_flows.add_conversation_bookmark( + subject=incident, + resource=resource, + db_session=db_session, + title=title, + ) # we defer this setup for all resolved incident roles until after resources have been created roles = ["reporter", "commander", "liaison", "scribe"] @@ -261,11 +278,22 @@ def incident_create_resources(*, incident: Incident, db_session=None) -> Inciden # we add the participant to the conversation conversation_flows.add_incident_participants( - incident=incident, participant_emails=[user_email], db_session=db_session + incident=incident, + participant_emails=[user_email], + db_session=db_session, ) # we announce the participant in the conversation - send_incident_participant_announcement_message(user_email, incident, db_session) + try: + send_participant_announcement_message( + participant_email=user_email, + subject=incident, + db_session=db_session, + ) + except Exception as e: + log.warning( + f"Could not send participant announcement message to {user_email} in incident {incident.name}: {e}" + ) # we send the welcome messages to the participant send_incident_welcome_participant_messages(user_email, incident, db_session) @@ -952,7 +980,11 @@ def incident_add_or_reactivate_participant_flow( ) # we announce the participant in the conversation - send_incident_participant_announcement_message(user_email, incident, db_session) + send_participant_announcement_message( + participant_email=user_email, + subject=incident, + db_session=db_session, + ) # we send the welcome messages to the participant send_incident_welcome_participant_messages(user_email, incident, db_session) diff --git a/src/dispatch/incident/messaging.py b/src/dispatch/incident/messaging.py index 6c862712abc3..49a7621b2b82 100644 --- a/src/dispatch/incident/messaging.py +++ b/src/dispatch/incident/messaging.py @@ -9,6 +9,8 @@ from typing import Optional +from sqlalchemy.orm import Session + from dispatch.decorators import timer from dispatch.config import DISPATCH_UI_URL from dispatch.conversation.enums import ConversationCommands @@ -17,6 +19,7 @@ from dispatch.email_templates.models import EmailTemplates from dispatch.email_templates import service as email_template_service from dispatch.email_templates.enums import EmailTemplateTypes +from dispatch.enums import SubjectNames from dispatch.event import service as event_service from dispatch.incident.enums import IncidentStatus from dispatch.incident.models import Incident, IncidentRead @@ -48,6 +51,7 @@ from dispatch.participant import service as participant_service from dispatch.participant_role import service as participant_role_service from dispatch.plugin import service as plugin_service +from dispatch.types import Subject log = logging.getLogger(__name__) @@ -561,18 +565,22 @@ def send_incident_update_notifications( @timer -def send_incident_participant_announcement_message( - participant_email: str, incident: Incident, db_session: SessionLocal +def send_participant_announcement_message( + participant_email: str, + subject: Subject, + db_session: Session, ): """Announces a participant in the conversation.""" - if not incident.conversation: + subject_type = type(subject).__name__ + + if not subject.conversation: log.warning( - "Incident participant announcement message not sent. No conversation available for this incident." + f"{subject_type} participant announcement message not sent. No conversation available for this {subject_type.lower()}." ) return plugin = plugin_service.get_active_instance( - db_session=db_session, project_id=incident.project.id, plugin_type="conversation" + db_session=db_session, project_id=subject.project.id, plugin_type="conversation" ) if not plugin: log.warning( @@ -580,17 +588,31 @@ def send_incident_participant_announcement_message( ) return - notification_text = "New Incident Participant" + notification_text = f"New {subject_type} Participant" notification_type = MessageType.incident_notification notification_template = [] - participant = participant_service.get_by_incident_id_and_email( - db_session=db_session, incident_id=incident.id, email=participant_email - ) + match subject_type: + case SubjectNames.CASE: + participant = participant_service.get_by_case_id_and_email( + db_session=db_session, + case_id=subject.id, + email=participant_email, + ) + case SubjectNames.INCIDENT: + participant = participant_service.get_by_incident_id_and_email( + db_session=db_session, + incident_id=subject.id, + email=participant_email, + ) + case _: + raise Exception( + "Unknown subject was passed to send_participant_announcement_message", + ) participant_info = {} contact_plugin = plugin_service.get_active_instance( - db_session=db_session, project_id=incident.project.id, plugin_type="contact" + db_session=db_session, project_id=subject.project.id, plugin_type="contact" ) if contact_plugin: participant_info = contact_plugin.instance.get(participant_email, db_session=db_session) @@ -635,15 +657,25 @@ def send_incident_participant_announcement_message( }, ] - plugin.instance.send( - incident.conversation.channel_id, - notification_text, - notification_template, - notification_type, - blocks=blocks, - ) + if subject_type == SubjectNames.CASE and subject.has_thread: + plugin.instance.send( + subject.conversation.channel_id, + notification_text, + notification_template, + notification_type, + blocks=blocks, + ts=subject.thread_id, + ) + else: + plugin.instance.send( + subject.conversation.channel_id, + notification_text, + notification_template, + notification_type, + blocks=blocks, + ) - log.debug("Incident participant announcement message sent.") + log.debug(f"{subject_type} participant announcement message sent.") def send_incident_commander_readded_notification(incident: Incident, db_session: SessionLocal): diff --git a/src/dispatch/plugins/dispatch_slack/ack.py b/src/dispatch/plugins/dispatch_slack/ack.py new file mode 100644 index 000000000000..d6b17c2cc88e --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/ack.py @@ -0,0 +1,14 @@ +from blockkit import Modal, Section +from slack_bolt import Ack + + +def ack_submission_event(ack: Ack, title: str, close: str, text: str) -> None: + """Handles event acknowledgment.""" + ack( + response_action="update", + view=Modal( + title=title, + close=close, + blocks=[Section(text=text)], + ).build(), + ) diff --git a/src/dispatch/plugins/dispatch_slack/bolt.py b/src/dispatch/plugins/dispatch_slack/bolt.py index 6279312e9aa8..12e1dfca313d 100644 --- a/src/dispatch/plugins/dispatch_slack/bolt.py +++ b/src/dispatch/plugins/dispatch_slack/bolt.py @@ -1,7 +1,7 @@ import logging import uuid from http import HTTPStatus -from typing import Any, Union +from typing import Any from blockkit import Context, MarkdownText, Modal from slack_bolt.app import App @@ -146,7 +146,7 @@ def build_and_log_error( def handle_message_events( ack: Ack, body: dict, - client: Union[WebClient, WebClient], + client: WebClient, context: BoltContext, db_session: Session, payload: dict, diff --git a/src/dispatch/plugins/dispatch_slack/case/interactive.py b/src/dispatch/plugins/dispatch_slack/case/interactive.py index f826613b8b72..0b1bc0fa2362 100644 --- a/src/dispatch/plugins/dispatch_slack/case/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/case/interactive.py @@ -76,10 +76,13 @@ from dispatch.plugins.dispatch_slack.middleware import ( action_context_middleware, button_context_middleware, + command_context_middleware, db_middleware, engagement_button_context_middleware, modal_submit_middleware, shortcut_context_middleware, + subject_middleware, + configuration_middleware, user_middleware, ) from dispatch.plugins.dispatch_slack.modals.common import send_success_modal @@ -111,10 +114,154 @@ def configure(config: SlackConversationConfiguration): handle_list_signals_command ) + middleware = [ + subject_middleware, + configuration_middleware, + command_context_middleware, + ] + + app.command(config.slack_command_escalate_case, middleware=middleware)( + handle_escalate_case_command + ) + + # non-sensitive commands + middleware = [ + subject_middleware, + configuration_middleware, + command_context_middleware, + user_middleware, + ] + + app.command(config.slack_command_update_case, middleware=middleware)(handle_update_case_command) + # Commands +def handle_escalate_case_command( + ack: Ack, + body: dict, + client: WebClient, + context: BoltContext, + db_session: Session, +) -> None: + """Handles list participants command.""" + ack() + case = case_service.get(db_session=db_session, case_id=context["subject"].id) + already_escalated = True if case.escalated_at else False + if already_escalated: + modal = Modal( + title="Already Escalated", + blocks=[Section(text="This case has already been escalated to an incident.")], + close="Close", + ).build() + + return client.views_open( + trigger_id=body["trigger_id"], + view=modal, + ) + + default_title = case.name + default_description = case.description + default_project = {"text": case.project.name, "value": case.project.id} + + blocks = [ + Context(elements=[MarkdownText(text="Accept the defaults or adjust as needed.")]), + title_input(initial_value=default_title), + description_input(initial_value=default_description), + assignee_select(), + project_select( + db_session=db_session, + initial_option=default_project, + action_id=CaseEscalateActions.project_select, + dispatch_action=True, + ), + incident_type_select( + db_session=db_session, + initial_option=None, + project_id=case.project.id, + block_id=None, + ), + incident_priority_select( + db_session=db_session, + project_id=case.project.id, + initial_option=None, + optional=True, + block_id=None, # ensures state is reset + ), + ] + + modal = Modal( + title="Escalate Case", + submit="Escalate", + blocks=blocks, + close="Close", + callback_id=CaseEscalateActions.submit, + private_metadata=context["subject"].json(), + ).build() + + client.views_open( + trigger_id=body["trigger_id"], + view=modal, + ) + + +def handle_update_case_command( + ack: Ack, + body: dict, + client: WebClient, + context: BoltContext, + db_session: Session, +) -> None: + ack() + + case = case_service.get(db_session=db_session, case_id=context["subject"].id) + + # assignee_initial_user = client.users_lookupByEmail(email=case.assignee.individual.email)[ + # "user" + # ]["id"] + + blocks = [ + title_input(initial_value=case.title), + description_input(initial_value=case.description), + case_resolution_reason_select(optional=True), + resolution_input(initial_value=case.resolution), + assignee_select(), + case_status_select(initial_option={"text": case.status, "value": case.status}), + case_type_select( + db_session=db_session, + initial_option={"text": case.case_type.name, "value": case.case_type.id}, + project_id=case.project.id, + ), + case_priority_select( + db_session=db_session, + initial_option={"text": case.case_priority.name, "value": case.case_priority.id}, + project_id=case.project.id, + optional=True, + ), + ] + + modal = Modal( + title="Edit Case", + blocks=blocks, + submit="Update", + close="Close", + callback_id=CaseEditActions.submit, + private_metadata=context["subject"].json(), + ).build() + client.views_open(trigger_id=body["trigger_id"], view=modal) + + +def ack_engage_oncall_submission_event(ack: Ack) -> None: + """Handles engage oncall acknowledgment.""" + modal = Modal( + title="Escalate Case", + close="Close", + blocks=[Section(text="Escalating case to an incident...")], + ).build() + ack(response_action="update", view=modal) + + def handle_list_signals_command( ack: Ack, body: dict, @@ -616,8 +763,7 @@ def _create_snooze_filter( # Create the new filter from the form data if form_data.get(DefaultBlockIds.entity_select): entities = [ - {"id": int(entity.value)} - for entity in form_data[DefaultBlockIds.entity_select] + {"id": int(entity.value)} for entity in form_data[DefaultBlockIds.entity_select] ] else: entities = [] @@ -1026,9 +1172,13 @@ def handle_project_select_action( def ack_handle_escalation_submission_event(ack: Ack) -> None: """Handles the escalation submission event.""" modal = Modal( - title="Escalate Case", + title="Escalating Case", close="Close", - blocks=[Section(text="Escalating case as incident...")], + blocks=[ + Section( + text="The case has been esclated to an incident. This channel will be reused for the incident." + ) + ], ).build() ack(response_action="update", view=modal) @@ -1053,20 +1203,28 @@ def handle_escalation_submission_event( db_session.commit() blocks = create_case_message(case=case, channel_id=context["subject"].channel_id) - client.chat_update( - blocks=blocks, ts=case.conversation.thread_id, channel=case.conversation.channel_id - ) + if case.has_thread: + client.chat_update( + blocks=blocks, + ts=case.conversation.thread_id, + channel=case.conversation.channel_id, + ) + client.chat_postMessage( text="This case has been escalated to an incident. All further triage work will take place in the incident channel.", channel=case.conversation.channel_id, - thread_ts=case.conversation.thread_id, + thread_ts=case.conversation.thread_id if case.has_thread else None, ) case_flows.case_escalated_status_flow( - case=case, organization_slug=context["subject"].organization_slug, db_session=db_session + case=case, + organization_slug=context["subject"].organization_slug, + db_session=db_session, ) incident = case.incidents[0] + # TODO: (wshel) Note, user who escalated case is added as participant, not all users in previous case + # TODO: (wshel) Likely remove this and move to common_esclate_flow() conversation_flows.add_incident_participants( incident=incident, participant_emails=[user.email], db_session=db_session ) diff --git a/src/dispatch/plugins/dispatch_slack/config.py b/src/dispatch/plugins/dispatch_slack/config.py index 0a78cc1b8b51..2512a441438e 100644 --- a/src/dispatch/plugins/dispatch_slack/config.py +++ b/src/dispatch/plugins/dispatch_slack/config.py @@ -98,6 +98,16 @@ class SlackConversationConfiguration(SlackConfiguration): title="Engage Oncall Command String", description="Defines the string used to engage an oncall. Must match what is defined in Slack.", ) + slack_command_update_case: str = Field( + "/dispatch-update-case", + title="Update Case Command String", + description="Defines the string used to update a case. Must match what is defined in Slack.", + ) + slack_command_escalate_case: str = Field( + "/dispatch-escalate-case", + title="Escalates a case to an incident", + description="Only works from within a channel based Case.", + ) slack_command_report_incident: str = Field( "/dispatch-report-incident", title="Report Incident Command String", diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index c211b13a99d2..f11989c0680a 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -113,7 +113,12 @@ shortcut_context_middleware, ) from dispatch.plugins.dispatch_slack.modals.common import send_success_modal -from dispatch.plugins.dispatch_slack.models import MonitorMetadata, TaskMetadata, IncidentSubjects, CaseSubjects +from dispatch.plugins.dispatch_slack.models import ( + MonitorMetadata, + TaskMetadata, + IncidentSubjects, + CaseSubjects, +) from dispatch.plugins.dispatch_slack.service import ( get_user_email, get_user_profile_by_email, @@ -807,7 +812,7 @@ def handle_after_hours_message( except SlackApiError as e: if e.response["error"] == SlackAPIErrorCode.USERS_NOT_FOUND: e.add_note( - "This error usually indiciates that the incident commanders Slack account is deactivated." + "This error usually indicates that the incident commanders Slack account is deactivated." ) log.warning(f"Failed to fetch timezone from Slack API: {e}") @@ -2243,7 +2248,7 @@ def handle_incident_notification_join_button_click( if not incident: message = "Sorry, we can't invite you to this incident. The incident does not exist." elif incident.visibility == Visibility.restricted: - message = "Sorry, we can't invite you to this incident. The incident's visbility is restricted. Please, reach out to the incident commander if you have any questions." + message = "Sorry, we can't invite you to this incident. The incident's visibility is restricted. Please, reach out to the incident commander if you have any questions." elif incident.status == IncidentStatus.closed: message = "Sorry, you can't join this incident. The incident has already been marked as closed. Please, reach out to the incident commander if you have any questions." else: @@ -2276,7 +2281,7 @@ def handle_incident_notification_subscribe_button_click( if not incident: message = "Sorry, we can't invite you to this incident. The incident does not exist." elif incident.visibility == Visibility.restricted: - message = "Sorry, we can't invite you to this incident. The incident's visbility is restricted. Please, reach out to the incident commander if you have any questions." + message = "Sorry, we can't invite you to this incident. The incident's visibility is restricted. Please, reach out to the incident commander if you have any questions." elif incident.status == IncidentStatus.closed: message = "Sorry, you can't subscribe to this incident. The incident has already been marked as closed. Please, reach out to the incident commander if you have any questions." else: diff --git a/src/dispatch/plugins/dispatch_slack/middleware.py b/src/dispatch/plugins/dispatch_slack/middleware.py index 756eafe49ebd..49afb4f4f9f8 100644 --- a/src/dispatch/plugins/dispatch_slack/middleware.py +++ b/src/dispatch/plugins/dispatch_slack/middleware.py @@ -18,7 +18,13 @@ from dispatch.project import service as project_service from .exceptions import ContextError, RoleError -from .models import EngagementMetadata, SubjectMetadata, FormMetadata, IncidentSubjects, CaseSubjects +from .models import ( + EngagementMetadata, + SubjectMetadata, + FormMetadata, + IncidentSubjects, + CaseSubjects, +) log = logging.getLogger(__file__) @@ -142,7 +148,7 @@ def action_context_middleware(body: dict, context: BoltContext, next: Callable) def message_context_middleware( request: BoltRequest, payload: dict, context: BoltContext, next: Callable ) -> None: - """Attemps to determine the current context of the event.""" + """Attempts to determine the current context of the event.""" if is_bot(request): return context.ack() @@ -158,7 +164,7 @@ def message_context_middleware( # TODO should we support reactions for cases? def reaction_context_middleware(context: BoltContext, next: Callable) -> None: - """Attemps to determine the current context of a reaction event.""" + """Attempts to determine the current context of a reaction event.""" if subject := resolve_context_from_conversation(channel_id=context.channel_id): context.update(subject._asdict()) else: diff --git a/src/dispatch/plugins/dispatch_slack/service.py b/src/dispatch/plugins/dispatch_slack/service.py index 1f1877b9561f..82d6da4b5417 100644 --- a/src/dispatch/plugins/dispatch_slack/service.py +++ b/src/dispatch/plugins/dispatch_slack/service.py @@ -1,4 +1,3 @@ -from blockkit import Message, Section from datetime import datetime import functools import heapq @@ -293,23 +292,14 @@ def add_users_to_conversation_thread( client: WebClient, conversation_id: str, thread_id, user_ids: List[str] ) -> NoReturn: """Adds user to a threaded conversation.""" + users = [f"<@{user_id}>" for user_id in user_ids] if users: # @'ing them isn't enough if they aren't already in the channel add_users_to_conversation(client=client, conversation_id=conversation_id, user_ids=user_ids) - blocks = Message( - blocks=[ - Section( - text="Adding the following individuals to help resolve this case:", fields=users - ) - ] - ).build()["blocks"] - send_message(client=client, conversation_id=conversation_id, blocks=blocks, ts=thread_id) - - -def add_users_to_conversation( - client: WebClient, conversation_id: str, user_ids: List[str] -) -> NoReturn: + + +def add_users_to_conversation(client: WebClient, conversation_id: str, user_ids: List[str]) -> None: """Add users to conversation.""" # NOTE this will trigger a member_joined_channel event, which we will capture and run # the incident.incident_add_or_reactivate_participant_flow() as a result diff --git a/src/dispatch/static/dispatch/src/case/ReportSubmissionCard.vue b/src/dispatch/static/dispatch/src/case/ReportSubmissionCard.vue index 41ebd77056de..f126fb367f88 100644 --- a/src/dispatch/static/dispatch/src/case/ReportSubmissionCard.vue +++ b/src/dispatch/static/dispatch/src/case/ReportSubmissionCard.vue @@ -68,6 +68,14 @@ + + + + { case_priority: null, case_severity: null, case_type: null, + dedicated_channel: false, closed_at: null, description: null, documents: [], diff --git a/src/dispatch/types.py b/src/dispatch/types.py new file mode 100644 index 000000000000..1fac21346718 --- /dev/null +++ b/src/dispatch/types.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from dispatch.case.models import Case +from dispatch.incident.models import Incident + +Subject = Case | Incident