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