diff --git a/src/dispatch/__init__.py b/src/dispatch/__init__.py index aa1e6f24eb2c..aef1a7b3550d 100644 --- a/src/dispatch/__init__.py +++ b/src/dispatch/__init__.py @@ -79,6 +79,7 @@ from dispatch.forms.type.models import FormsType # noqa lgtm[py/unused-import] from dispatch.forms.models import Forms # noqa lgtm[py/unused-import] from dispatch.email_templates.models import EmailTemplates # noqa lgtm[py/unused-import] + from dispatch.canvas.models import Canvas # noqa lgtm[py/unused-import] except Exception: diff --git a/src/dispatch/canvas/__init__.py b/src/dispatch/canvas/__init__.py new file mode 100644 index 000000000000..4fbaf14b3c76 --- /dev/null +++ b/src/dispatch/canvas/__init__.py @@ -0,0 +1,39 @@ +"""Canvas management module for Dispatch.""" + +from .enums import CanvasType +from .models import Canvas, CanvasBase, CanvasCreate, CanvasRead, CanvasUpdate +from .service import ( + create, + delete, + delete_by_slack_canvas_id, + get, + get_by_canvas_id, + get_by_case, + get_by_incident, + get_by_project, + get_by_type, + get_or_create_by_case, + get_or_create_by_incident, + update, +) + +__all__ = [ + "Canvas", + "CanvasBase", + "CanvasCreate", + "CanvasRead", + "CanvasType", + "CanvasUpdate", + "create", + "delete", + "delete_by_slack_canvas_id", + "get", + "get_by_canvas_id", + "get_by_case", + "get_by_incident", + "get_by_project", + "get_by_type", + "get_or_create_by_case", + "get_or_create_by_incident", + "update", +] diff --git a/src/dispatch/canvas/enums.py b/src/dispatch/canvas/enums.py new file mode 100644 index 000000000000..0489e17ca47e --- /dev/null +++ b/src/dispatch/canvas/enums.py @@ -0,0 +1,10 @@ +from dispatch.enums import DispatchEnum + + +class CanvasType(DispatchEnum): + """Types of canvases that can be created.""" + + summary = "summary" + tactical_reports = "tactical_reports" + participants = "participants" + tasks = "tasks" diff --git a/src/dispatch/canvas/flows.py b/src/dispatch/canvas/flows.py new file mode 100644 index 000000000000..4717ba5e38de --- /dev/null +++ b/src/dispatch/canvas/flows.py @@ -0,0 +1,528 @@ +"""Canvas flows for managing incident and case-related canvases.""" + +import logging +from typing import Optional + +from sqlalchemy.orm import Session + +from .models import Canvas, CanvasCreate +from .enums import CanvasType +from .service import create +from dispatch.incident.models import Incident +from dispatch.case.models import Case +from dispatch.participant.models import Participant +from dispatch.plugin import service as plugin_service + + +log = logging.getLogger(__name__) + + +def create_participants_canvas( + incident: Incident = None, case: Case = None, db_session: Session = None +) -> Optional[str]: + """ + Creates a new participants canvas in the incident's or case's Slack channel. + + Args: + incident: The incident to create the canvas for (mutually exclusive with case) + case: The case to create the canvas for (mutually exclusive with incident) + db_session: Database session + + Returns: + The canvas ID if successful, None if failed + """ + if incident and case: + raise ValueError("Cannot specify both incident and case") + if not incident and not case: + raise ValueError("Must specify either incident or case") + + if incident: + return _create_incident_participants_canvas(incident, db_session) + else: + return _create_case_participants_canvas(case, db_session) + + +def _create_incident_participants_canvas(incident: Incident, db_session: Session) -> Optional[str]: + """ + Creates a new participants canvas in the incident's Slack channel. + + Args: + incident: The incident to create the canvas for + db_session: Database session + + Returns: + The canvas ID if successful, None if failed + """ + # Check if incident has a conversation + if not incident.conversation: + log.debug(f"Skipping canvas creation for incident {incident.id} - no conversation") + return None + + # Check if conversation has a channel_id + if not incident.conversation.channel_id: + log.debug(f"Skipping canvas creation for incident {incident.id} - no channel_id") + return None + + try: + # Get the Slack plugin instance + slack_plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=incident.project.id, plugin_type="conversation" + ) + + # Create the canvas in Slack + canvas_id = slack_plugin.instance.create_canvas( + conversation_id=incident.conversation.channel_id, + title="Participants", + user_emails=( + [incident.commander.individual.email] if incident.commander else [] + ), # Give commander edit permissions + content=_build_participants_table(incident, db_session), + ) + + if canvas_id: + # Store the canvas record in the database + create( + db_session=db_session, + canvas_in=CanvasCreate( + canvas_id=canvas_id, + incident_id=incident.id, + case_id=None, + type=CanvasType.participants, + project_id=incident.project_id, + ), + ) + return canvas_id + else: + log.error(f"Failed to create participants canvas for incident {incident.id}") + return None + + except Exception as e: + log.exception(f"Error creating participants canvas for incident {incident.id}: {e}") + return None + + +def _create_case_participants_canvas(case: Case, db_session: Session) -> Optional[str]: + """ + Creates a new participants canvas in the case's Slack channel. + + Args: + case: The case to create the canvas for + db_session: Database session + + Returns: + The canvas ID if successful, None if failed + """ + # Only create canvas for cases with dedicated channels + if not case.dedicated_channel: + log.debug(f"Skipping canvas creation for case {case.id} - no dedicated channel") + return None + + # Check if case has a conversation + if not case.conversation: + log.debug(f"Skipping canvas creation for case {case.id} - no conversation") + return None + + # Check if conversation has a channel_id + if not case.conversation.channel_id: + log.debug(f"Skipping canvas creation for case {case.id} - no channel_id") + return None + + try: + # Get the Slack plugin instance + slack_plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=case.project.id, plugin_type="conversation" + ) + + if not slack_plugin: + log.error(f"No conversation plugin found for case {case.id}") + return None + + # Build the participants table content + table_content = _build_case_participants_table(case, db_session) + log.debug(f"Built participants table for case {case.id}: {table_content[:100]}...") + + # Create the canvas in Slack + canvas_id = slack_plugin.instance.create_canvas( + conversation_id=case.conversation.channel_id, + title="Participants", + user_emails=( + [case.assignee.individual.email] if case.assignee else [] + ), # Give assignee edit permissions + content=table_content, + ) + + if canvas_id: + # Store the canvas record in the database + create( + db_session=db_session, + canvas_in=CanvasCreate( + canvas_id=canvas_id, + incident_id=None, + case_id=case.id, + type=CanvasType.participants, + project_id=case.project_id, + ), + ) + log.info(f"Successfully created participants canvas {canvas_id} for case {case.id}") + return canvas_id + else: + log.error(f"Failed to create participants canvas for case {case.id}") + return None + + except Exception as e: + log.exception(f"Error creating participants canvas for case {case.id}: {e}") + return None + + +def update_participants_canvas( + incident: Incident = None, case: Case = None, db_session: Session = None +) -> bool: + """ + Updates the participants canvas with current participant information. + + Args: + incident: The incident to update the canvas for (mutually exclusive with case) + case: The case to update the canvas for (mutually exclusive with incident) + db_session: Database session + + Returns: + True if successful, False if failed + """ + if incident and case: + raise ValueError("Cannot specify both incident and case") + if not incident and not case: + raise ValueError("Must specify either incident or case") + + if incident: + return _update_incident_participants_canvas(incident, db_session) + else: + return _update_case_participants_canvas(case, db_session) + + +def _update_incident_participants_canvas(incident: Incident, db_session: Session) -> bool: + """ + Updates the participants canvas with current participant information. + + Args: + incident: The incident to update the canvas for + db_session: Database session + + Returns: + True if successful, False if failed + """ + # Check if incident has a conversation + if not incident.conversation: + log.debug(f"Skipping canvas update for incident {incident.id} - no conversation") + return False + + # Check if conversation has a channel_id + if not incident.conversation.channel_id: + log.debug(f"Skipping canvas update for incident {incident.id} - no channel_id") + return False + + try: + # Get the existing canvas record by incident and type + canvas = ( + db_session.query(Canvas) + .filter(Canvas.incident_id == incident.id, Canvas.type == CanvasType.participants) + .first() + ) + + if not canvas: + log.warning( + f"No participants canvas found for incident {incident.id}, creating new one" + ) + return False + + # Get the Slack plugin instance + slack_plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=incident.project.id, plugin_type="conversation" + ) + + # Build the updated table content + table_content = _build_participants_table(incident, db_session) + + # Update the canvas + success = slack_plugin.instance.edit_canvas( + canvas_id=canvas.canvas_id, content=table_content + ) + + if success: + log.info(f"Updated participants canvas {canvas.canvas_id} for incident {incident.id}") + else: + log.error( + f"Failed to update participants canvas {canvas.canvas_id} for incident {incident.id}" + ) + + return success + + except Exception as e: + log.exception(f"Error updating participants canvas for incident {incident.id}: {e}") + return False + + +def _build_participants_table(incident: Incident, db_session: Session) -> str: + """ + Builds markdown tables of participants for the canvas. + Splits into multiple tables if there are more than 60 participants to avoid Slack's 300 cell limit. + + Args: + incident: The incident to build the table for + db_session: Database session + + Returns: + Markdown table string + """ + # Get all participants for the incident + participants = ( + db_session.query(Participant).filter(Participant.incident_id == incident.id).all() + ) + + if not participants: + return "# Participants\n\nNo participants have been added to this incident yet." + + # Define role priority for sorting (lower number = higher priority) + role_priority = { + "Incident Commander": 1, + "Scribe": 2, + "Reporter": 3, + "Participant": 4, + "Observer": 5, + } + + # Filter out inactive participants and sort by role priority + active_participants = [] + for participant in participants: + if participant.active_roles: + # Get the highest priority role for this participant + highest_priority = float("inf") + primary_role = "Other" + + for role in participant.active_roles: + # role.role is already a string (role name), not an object + role_name = role.role if role.role else "Other" + priority = role_priority.get(role_name, 999) # Default to low priority + if priority < highest_priority: + highest_priority = priority + primary_role = role_name + + active_participants.append((participant, highest_priority, primary_role)) + + # Sort by priority, then by name + active_participants.sort( + key=lambda x: (x[1], x[0].individual.name if x[0].individual else "Unknown") + ) + + # Extract just the participants in sorted order + sorted_participants = [p[0] for p in active_participants] + + if not sorted_participants: + return "# Participants\n\nNo active participants found for this incident." + + # Build the content + content = f"# Participants ({len(participants)} total)\n\n" + + # Group participants by their primary role + participants_by_role = {} + for participant in sorted_participants: + # Get the highest priority role for this participant + highest_priority = float("inf") + primary_role = "Other" + + for role in participant.active_roles: + # role.role is already a string (role name), not an object + role_name = role.role if role.role else "Other" + priority = role_priority.get(role_name, 999) # Default to low priority + if priority < highest_priority: + highest_priority = priority + primary_role = role_name + + if primary_role not in participants_by_role: + participants_by_role[primary_role] = [] + participants_by_role[primary_role].append(participant) + + # Add participants grouped by role + for role_name in [ + "Incident Commander", + "Scribe", + "Reporter", + "Participant", + "Observer", + "Other", + ]: + if role_name in participants_by_role: + participants_count = len(participants_by_role[role_name]) + # Add "s" only if there are multiple participants in this role + heading = f"## {role_name}{'s' if participants_count > 1 else ''}\n\n" + content += heading + for participant in participants_by_role[role_name]: + name = participant.individual.name if participant.individual else "Unknown" + team = participant.team or "Unknown" + location = participant.location or "Unknown" + content += f"* **{name}** - {team} - {location}\n" + content += "\n" + + return content + + +def _update_case_participants_canvas(case: Case, db_session: Session) -> bool: + """ + Updates the participants canvas with current participant information. + + Args: + case: The case to update the canvas for + db_session: Database session + + Returns: + True if successful, False if failed + """ + # Only update canvas for cases with dedicated channels + if not case.dedicated_channel: + log.debug(f"Skipping canvas update for case {case.id} - no dedicated channel") + return False + + # Check if case has a conversation + if not case.conversation: + log.debug(f"Skipping canvas update for case {case.id} - no conversation") + return False + + # Check if conversation has a channel_id + if not case.conversation.channel_id: + log.debug(f"Skipping canvas update for case {case.id} - no channel_id") + return False + + try: + # Get the existing canvas record by case and type + canvas = ( + db_session.query(Canvas) + .filter(Canvas.case_id == case.id, Canvas.type == CanvasType.participants) + .first() + ) + + if not canvas: + log.warning(f"No participants canvas found for case {case.id}, creating new one") + return False + + # Get the Slack plugin instance + slack_plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=case.project.id, plugin_type="conversation" + ) + + # Build the updated table content + table_content = _build_case_participants_table(case, db_session) + + # Update the canvas + success = slack_plugin.instance.edit_canvas( + canvas_id=canvas.canvas_id, content=table_content + ) + + if success: + log.info(f"Updated participants canvas {canvas.canvas_id} for case {case.id}") + else: + log.error(f"Failed to update participants canvas {canvas.canvas_id} for case {case.id}") + + return success + + except Exception as e: + log.exception(f"Error updating participants canvas for case {case.id}: {e}") + return False + + +def _build_case_participants_table(case: Case, db_session: Session) -> str: + """ + Builds markdown tables of participants for the canvas. + Splits into multiple tables if there are more than 60 participants to avoid Slack's 300 cell limit. + + Args: + case: The case to build the table for + db_session: Database session + + Returns: + Markdown table string + """ + # Get all participants for the case + participants = db_session.query(Participant).filter(Participant.case_id == case.id).all() + + if not participants: + return "# Participants\n\nNo participants have been added to this case yet." + + # Define role priority for sorting (lower number = higher priority) + role_priority = { + "Assignee": 1, + "Reporter": 2, + "Participant": 3, + "Observer": 4, + } + + # Filter out inactive participants and sort by role priority + active_participants = [] + for participant in participants: + if participant.active_roles: + # Get the highest priority role for this participant + highest_priority = float("inf") + primary_role = "Other" + + for role in participant.active_roles: + # role.role is already a string (role name), not an object + role_name = role.role if role.role else "Other" + priority = role_priority.get(role_name, 999) # Default to low priority + if priority < highest_priority: + highest_priority = priority + primary_role = role_name + + active_participants.append((participant, highest_priority, primary_role)) + + # Sort by priority, then by name + active_participants.sort( + key=lambda x: (x[1], x[0].individual.name if x[0].individual else "Unknown") + ) + + # Extract just the participants in sorted order + sorted_participants = [p[0] for p in active_participants] + + if not sorted_participants: + return "# Participants\n\nNo active participants found for this case." + + # Build the content + content = f"# Participants ({len(participants)} total)\n\n" + + # Group participants by their primary role + participants_by_role = {} + for participant in sorted_participants: + # Get the highest priority role for this participant + highest_priority = float("inf") + primary_role = "Other" + + for role in participant.active_roles: + # role.role is already a string (role name), not an object + role_name = role.role if role.role else "Other" + priority = role_priority.get(role_name, 999) # Default to low priority + if priority < highest_priority: + highest_priority = priority + primary_role = role_name + + if primary_role not in participants_by_role: + participants_by_role[primary_role] = [] + participants_by_role[primary_role].append(participant) + + # Add participants grouped by role + for role_name in [ + "Assignee", + "Reporter", + "Participant", + "Observer", + "Other", + ]: + if role_name in participants_by_role: + participants_count = len(participants_by_role[role_name]) + # Add "s" only if there are multiple participants in this role + heading = f"## {role_name}{'s' if participants_count > 1 else ''}\n\n" + content += heading + for participant in participants_by_role[role_name]: + name = participant.individual.name if participant.individual else "Unknown" + team = participant.team or "Unknown" + location = participant.location or "Unknown" + content += f"* **{name}** - {team} - {location}\n" + content += "\n" + + return content diff --git a/src/dispatch/canvas/models.py b/src/dispatch/canvas/models.py new file mode 100644 index 000000000000..e1bfcd126410 --- /dev/null +++ b/src/dispatch/canvas/models.py @@ -0,0 +1,55 @@ +"""Models and schemas for the Dispatch canvas management system.""" + +from datetime import datetime +from typing import Optional +from pydantic import Field +from sqlalchemy import Column, ForeignKey, Integer, String +from sqlalchemy.orm import relationship + +from dispatch.database.core import Base +from dispatch.models import DispatchBase, PrimaryKey, ProjectMixin, TimeStampMixin + + +class Canvas(Base, TimeStampMixin, ProjectMixin): + """SQLAlchemy model for a Canvas, representing a Slack canvas in the system.""" + + id = Column(Integer, primary_key=True) + canvas_id = Column(String, nullable=False) # Slack canvas ID + incident_id = Column(Integer, ForeignKey("incident.id", ondelete="CASCADE"), nullable=True) + case_id = Column(Integer, ForeignKey("case.id", ondelete="CASCADE"), nullable=True) + type = Column(String, nullable=False) # CanvasType enum value + + # Relationships + incident = relationship("Incident", back_populates="canvases") + case = relationship("Case", back_populates="canvases") + + +# Pydantic models... +class CanvasBase(DispatchBase): + """Base Pydantic model for canvas-related fields.""" + + canvas_id: str = Field(..., description="The Slack canvas ID") + incident_id: Optional[int] = Field(None, description="The associated incident ID") + case_id: Optional[int] = Field(None, description="The associated case ID") + type: str = Field(..., description="The type of canvas") + + +class CanvasCreate(CanvasBase): + """Pydantic model for creating a new canvas.""" + + project_id: int = Field(..., description="The project ID") + + +class CanvasUpdate(CanvasBase): + """Pydantic model for updating an existing canvas.""" + + pass + + +class CanvasRead(CanvasBase): + """Pydantic model for reading canvas data.""" + + id: PrimaryKey + created_at: datetime + updated_at: datetime + project_id: int diff --git a/src/dispatch/canvas/service.py b/src/dispatch/canvas/service.py new file mode 100644 index 000000000000..b93b120be638 --- /dev/null +++ b/src/dispatch/canvas/service.py @@ -0,0 +1,149 @@ +"""Service functions for canvas management.""" + +import logging +from typing import Optional +from sqlalchemy.orm import Session + +from dispatch.case.models import Case +from dispatch.incident.models import Incident + +from .models import Canvas, CanvasCreate, CanvasUpdate + +log = logging.getLogger(__name__) + + +def get(*, db_session: Session, canvas_id: int) -> Optional[Canvas]: + """Returns a canvas based on the given id.""" + return db_session.query(Canvas).filter(Canvas.id == canvas_id).first() + + +def get_by_canvas_id(*, db_session: Session, slack_canvas_id: str) -> Optional[Canvas]: + """Returns a canvas based on the Slack canvas ID.""" + return db_session.query(Canvas).filter(Canvas.canvas_id == slack_canvas_id).first() + + +def get_by_incident(*, db_session: Session, incident_id: int) -> list[Canvas]: + """Returns all canvases associated with an incident.""" + return db_session.query(Canvas).filter(Canvas.incident_id == incident_id).all() + + +def get_by_case(*, db_session: Session, case_id: int) -> list[Canvas]: + """Returns all canvases associated with a case.""" + return db_session.query(Canvas).filter(Canvas.case_id == case_id).all() + + +def get_by_project(*, db_session: Session, project_id: int) -> list[Canvas]: + """Returns all canvases for a project.""" + return db_session.query(Canvas).filter(Canvas.project_id == project_id).all() + + +def get_by_type(*, db_session: Session, project_id: int, canvas_type: str) -> list[Canvas]: + """Returns all canvases of a specific type for a project.""" + return ( + db_session.query(Canvas) + .filter(Canvas.project_id == project_id) + .filter(Canvas.type == canvas_type) + .all() + ) + + +def create(*, db_session: Session, canvas_in: CanvasCreate) -> Canvas: + """Creates a new canvas.""" + canvas = Canvas( + canvas_id=canvas_in.canvas_id, + incident_id=canvas_in.incident_id, + case_id=canvas_in.case_id, + type=canvas_in.type, + project_id=canvas_in.project_id, + ) + db_session.add(canvas) + db_session.commit() + return canvas + + +def update(*, db_session: Session, canvas_id: int, canvas_in: CanvasUpdate) -> Canvas | None: + """Updates an existing canvas.""" + canvas = get(db_session=db_session, canvas_id=canvas_id) + if not canvas: + log.error(f"Canvas with id {canvas_id} not found") + return None + + update_data = canvas_in.model_dump(exclude_unset=True) + + for field, value in update_data.items(): + setattr(canvas, field, value) + + db_session.add(canvas) + db_session.commit() + db_session.refresh(canvas) + return canvas + + +def delete(*, db_session: Session, canvas_id: int) -> bool: + """Deletes a canvas.""" + canvas = db_session.query(Canvas).filter(Canvas.id == canvas_id).first() + if not canvas: + return False + + db_session.delete(canvas) + db_session.commit() + return True + + +def delete_by_slack_canvas_id(*, db_session: Session, slack_canvas_id: str) -> bool: + """Deletes a canvas by its Slack canvas ID.""" + canvas = get_by_canvas_id(db_session=db_session, slack_canvas_id=slack_canvas_id) + if not canvas: + return False + + db_session.delete(canvas) + db_session.commit() + return True + + +def get_or_create_by_incident( + *, db_session: Session, incident: Incident, canvas_type: str, slack_canvas_id: str +) -> Canvas: + """Gets an existing canvas for an incident and type, or creates a new one.""" + canvas = ( + db_session.query(Canvas) + .filter(Canvas.incident_id == incident.id) + .filter(Canvas.type == canvas_type) + .first() + ) + + if not canvas: + canvas_in = CanvasCreate( + canvas_id=slack_canvas_id, + incident_id=incident.id, + case_id=None, + type=canvas_type, + project_id=incident.project_id, + ) + canvas = create(db_session=db_session, canvas_in=canvas_in) + + return canvas + + +def get_or_create_by_case( + *, db_session: Session, case: Case, canvas_type: str, slack_canvas_id: str +) -> Canvas: + """Gets an existing canvas for a case and type, or creates a new one.""" + canvas = ( + db_session.query(Canvas) + .filter(Canvas.case_id == case.id) + .filter(Canvas.type == canvas_type) + .first() + ) + + if not canvas: + canvas_in = CanvasCreate( + canvas_id=slack_canvas_id, + incident_id=None, + case_id=case.id, + type=canvas_type, + project_id=case.project_id, + ) + canvas = create(db_session=db_session, canvas_in=canvas_in) + + return canvas diff --git a/src/dispatch/case/flows.py b/src/dispatch/case/flows.py index b794e8ce4182..56b5bbe0fd2a 100644 --- a/src/dispatch/case/flows.py +++ b/src/dispatch/case/flows.py @@ -35,6 +35,7 @@ from dispatch.storage import flows as storage_flows from dispatch.storage.enums import StorageAction from dispatch.ticket import flows as ticket_flows +from dispatch.canvas import flows as canvas_flows from .enums import CaseResolutionReason, CaseStatus from .messaging import ( @@ -149,6 +150,13 @@ def case_add_or_reactivate_participant_flow( welcome_template=welcome_template, ) + # Update the participants canvas since a new participant was added + try: + canvas_flows.update_participants_canvas(case=case, db_session=db_session) + log.info(f"Updated participants canvas for case {case.id} after adding {user_email}") + except Exception as e: + log.exception(f"Failed to update participants canvas for case {case.id}: {e}") + return participant @@ -183,6 +191,13 @@ def case_remove_participant_flow( db_session=db_session, ) + # Update the participants canvas since a participant was removed + try: + canvas_flows.update_participants_canvas(case=case, db_session=db_session) + log.info(f"Updated participants canvas for case {case.id} after removing {user_email}") + except Exception as e: + log.exception(f"Failed to update participants canvas for case {case.id}: {e}") + # we also try to remove the user from the Slack conversation slack_conversation_plugin = plugin_service.get_active_instance( db_session=db_session, project_id=case.project.id, plugin_type="conversation" @@ -198,19 +213,20 @@ def case_remove_participant_flow( try: slack_conversation_plugin.instance.remove_user( - conversation_id=case.conversation.channel_id, - user_email=user_email + conversation_id=case.conversation.channel_id, user_email=user_email ) event_service.log_case_event( - db_session=db_session, - source=slack_conversation_plugin.plugin.title, - description=f"{user_email} removed from conversation (channel ID: {case.conversation.channel_id})", - case_id=case.id, - type=EventType.participant_updated, + db_session=db_session, + source=slack_conversation_plugin.plugin.title, + description=f"{user_email} removed from conversation (channel ID: {case.conversation.channel_id})", + case_id=case.id, + type=EventType.participant_updated, ) - log.info(f"Removed {user_email} from conversation in channel {case.conversation.channel_id}") + log.info( + f"Removed {user_email} from conversation in channel {case.conversation.channel_id}" + ) except Exception as e: log.exception(f"Failed to remove user from Slack conversation: {e}") @@ -534,7 +550,11 @@ def case_update_flow( # we send the case updated notification update_conversation(case, db_session) - if case.has_channel and not case.has_thread and case.status not in [CaseStatus.escalated, CaseStatus.closed]: + if ( + case.has_channel + and not case.has_thread + and case.status not in [CaseStatus.escalated, CaseStatus.closed] + ): # determine if case channel topic needs to be updated if case_details_changed(case, previous_case): conversation_flows.set_conversation_topic(case, db_session) @@ -1144,6 +1164,15 @@ def case_assign_role_flow( # update the conversation topic conversation_flows.set_conversation_topic(case, db_session) + # Update the participants canvas since a role was assigned + try: + canvas_flows.update_participants_canvas(case=case, db_session=db_session) + log.info( + f"Updated participants canvas for case {case.id} after assigning {participant_role} to {participant_email}" + ) + except Exception as e: + log.exception(f"Failed to update participants canvas for case {case.id}: {e}") + def case_create_conversation_flow( db_session: Session, @@ -1165,20 +1194,46 @@ def case_create_conversation_flow( for email in participant_emails: # we don't rely on on this flow to add folks to the conversation because in this case # we want to do it in bulk - case_add_or_reactivate_participant_flow( + try: + case_add_or_reactivate_participant_flow( + db_session=db_session, + user_email=email, + case_id=case.id, + add_to_conversation=False, + ) + except Exception as e: + log.warning( + f"Failed to add participant {email} to case {case.id}: {e}. " + f"Continuing with other participants..." + ) + # Log the event but don't fail the case creation + event_service.log_case_event( + db_session=db_session, + source="Dispatch Core App", + description=f"Failed to add participant {email}: {e}", + case_id=case.id, + ) + + # we add the participant to the conversation + try: + conversation_flows.add_case_participants( + case=case, + participant_emails=participant_emails, + db_session=db_session, + ) + except Exception as e: + log.warning( + f"Failed to add participants to conversation for case {case.id}: {e}. " + f"Continuing with case creation..." + ) + # Log the event but don't fail the case creation + event_service.log_case_event( db_session=db_session, - user_email=email, + source="Dispatch Core App", + description=f"Failed to add participants to conversation: {e}", case_id=case.id, - add_to_conversation=False, ) - # we add the participant to the conversation - conversation_flows.add_case_participants( - case=case, - participant_emails=participant_emails, - db_session=db_session, - ) - def case_create_resources_flow( db_session: Session, @@ -1279,6 +1334,7 @@ def case_create_resources_flow( description="Case participants added to conversation.", case_id=case.id, ) + except Exception as e: event_service.log_case_event( db_session=db_session, @@ -1289,6 +1345,11 @@ def case_create_resources_flow( log.exception(e) if case.has_channel: + try: + canvas_flows.create_participants_canvas(case=case, db_session=db_session) + except Exception as e: + log.exception(f"Failed to create participants canvas for case {case.id}: {e}") + bookmarks = [ # resource, title (case.case_document, None), diff --git a/src/dispatch/case/models.py b/src/dispatch/case/models.py index 44e0d92aaf09..fccd9e71d2f4 100644 --- a/src/dispatch/case/models.py +++ b/src/dispatch/case/models.py @@ -156,6 +156,7 @@ class Case(Base, TimeStampMixin, ProjectMixin): workflow_instances = relationship( "WorkflowInstance", backref="case", cascade="all, delete-orphan" ) + canvases = relationship("Canvas", back_populates="case", cascade="all, delete-orphan") conversation = relationship( "Conversation", uselist=False, backref="case", cascade="all, delete-orphan" diff --git a/src/dispatch/database/revisions/tenant/versions/2025-08-28_ff08d822ef2c.py b/src/dispatch/database/revisions/tenant/versions/2025-08-28_ff08d822ef2c.py new file mode 100644 index 000000000000..9e1aa5e06a64 --- /dev/null +++ b/src/dispatch/database/revisions/tenant/versions/2025-08-28_ff08d822ef2c.py @@ -0,0 +1,42 @@ +"""Create canvas table. + +Revision ID: ff08d822ef2c +Revises: f2bce475e71b +Create Date: 2025-08-28 15:33:37.139043 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "ff08d822ef2c" +down_revision = "f2bce475e71b" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "canvas", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("canvas_id", sa.String(), nullable=False), + sa.Column("incident_id", sa.Integer(), nullable=True), + sa.Column("case_id", sa.Integer(), nullable=True), + sa.Column("type", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.Column("project_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["case_id"], ["case.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["incident_id"], ["incident.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("canvas") + # ### end Alembic commands ### diff --git a/src/dispatch/incident/flows.py b/src/dispatch/incident/flows.py index 76c41e14569c..8c00ea13fc51 100644 --- a/src/dispatch/incident/flows.py +++ b/src/dispatch/incident/flows.py @@ -39,6 +39,7 @@ from dispatch.task.enums import TaskStatus from dispatch.team.models import TeamContact from dispatch.ticket import flows as ticket_flows +from dispatch.canvas import flows as canvas_flows from .messaging import ( bulk_participant_announcement_message, @@ -59,7 +60,9 @@ log = logging.getLogger(__name__) -def filter_participants_for_bridge(participant_emails: list[str], project_id: int, db_session: Session) -> list[str]: +def filter_participants_for_bridge( + participant_emails: list[str], project_id: int, db_session: Session +) -> list[str]: """Filter participant emails to only include those who have opted into bridge participation.""" filtered_emails = [] for email in participant_emails: @@ -352,6 +355,13 @@ def incident_create_resources( send_announcement_message=False, ) + # Create the participants canvas after all participants have been resolved + try: + canvas_flows.create_participants_canvas(incident=incident, db_session=db_session) + log.info(f"Created participants canvas for incident {incident.id}") + except Exception as e: + log.exception(f"Failed to create participants canvas for incident {incident.id}: {e}") + event_service.log_incident_event( db_session=db_session, source="Dispatch Core App", @@ -921,6 +931,15 @@ def incident_assign_role_flow( # we send a message to the incident commander with tips on how to manage the incident send_incident_management_help_tips_message(incident, db_session) + # Update the participants canvas since a role was assigned + try: + canvas_flows.update_participants_canvas(incident=incident, db_session=db_session) + log.info( + f"Updated participants canvas for incident {incident.id} after assigning {assignee_role} to {assignee_email}" + ) + except Exception as e: + log.exception(f"Failed to update participants canvas for incident {incident.id}: {e}") + @background_task def incident_engage_oncall_flow( @@ -1089,14 +1108,16 @@ def incident_add_or_reactivate_participant_flow( return event_service.log_incident_event( - db_session=db_session, - source=slack_conversation_plugin.plugin.title, - description=f"{user_email} added to conversation (channel ID: {incident.conversation.channel_id})", - incident_id=incident.id, - type=EventType.participant_updated, + db_session=db_session, + source=slack_conversation_plugin.plugin.title, + description=f"{user_email} added to conversation (channel ID: {incident.conversation.channel_id})", + incident_id=incident.id, + type=EventType.participant_updated, ) - log.info(f"Added {user_email} to conversation in (channel ID: {incident.conversation.channel_id})") + log.info( + f"Added {user_email} to conversation in (channel ID: {incident.conversation.channel_id})" + ) except Exception as e: log.exception(f"Failed to add user to Slack conversation: {e}") @@ -1112,6 +1133,15 @@ def incident_add_or_reactivate_participant_flow( # we send the welcome messages to the participant send_incident_welcome_participant_messages(user_email, incident, db_session) + # Update the participants canvas since a new participant was added + try: + canvas_flows.update_participants_canvas(incident=incident, db_session=db_session) + log.info( + f"Updated participants canvas for incident {incident.id} after adding {user_email}" + ) + except Exception as e: + log.exception(f"Failed to update participants canvas for incident {incident.id}: {e}") + return participant @@ -1177,6 +1207,15 @@ def incident_remove_participant_flow( db_session=db_session, ) + # Update the participants canvas since a participant was removed + try: + canvas_flows.update_participants_canvas(incident=incident, db_session=db_session) + log.info( + f"Updated participants canvas for incident {incident.id} after removing {user_email}" + ) + except Exception as e: + log.exception(f"Failed to update participants canvas for incident {incident.id}: {e}") + # we also try to remove the user from the Slack conversation slack_conversation_plugin = plugin_service.get_active_instance( db_session=db_session, project_id=incident.project.id, plugin_type="conversation" @@ -1192,19 +1231,20 @@ def incident_remove_participant_flow( try: slack_conversation_plugin.instance.remove_user( - conversation_id=incident.conversation.channel_id, - user_email=user_email + conversation_id=incident.conversation.channel_id, user_email=user_email ) event_service.log_incident_event( - db_session=db_session, - source=slack_conversation_plugin.plugin.title, - description=f"{user_email} removed from conversation (channel ID: {incident.conversation.channel_id})", - incident_id=incident.id, - type=EventType.participant_updated, + db_session=db_session, + source=slack_conversation_plugin.plugin.title, + description=f"{user_email} removed from conversation (channel ID: {incident.conversation.channel_id})", + incident_id=incident.id, + type=EventType.participant_updated, ) - log.info(f"Removed {user_email} from conversation in channel {incident.conversation.channel_id}") + log.info( + f"Removed {user_email} from conversation in channel {incident.conversation.channel_id}" + ) except Exception as e: log.exception(f"Failed to remove user from Slack conversation: {e}") diff --git a/src/dispatch/incident/models.py b/src/dispatch/incident/models.py index d84db0b4e973..6301e50bcd84 100644 --- a/src/dispatch/incident/models.py +++ b/src/dispatch/incident/models.py @@ -191,6 +191,7 @@ def last_executive_report(self): workflow_instances = relationship( "WorkflowInstance", backref="incident", cascade="all, delete-orphan" ) + canvases = relationship("Canvas", back_populates="incident", cascade="all, delete-orphan") duplicate_id = Column(Integer, ForeignKey("incident.id")) duplicates = relationship( diff --git a/src/dispatch/plugins/dispatch_slack/enums.py b/src/dispatch/plugins/dispatch_slack/enums.py index 99ae6414d51f..6ca5a17d4851 100644 --- a/src/dispatch/plugins/dispatch_slack/enums.py +++ b/src/dispatch/plugins/dispatch_slack/enums.py @@ -16,6 +16,10 @@ class SlackAPIGetEndpoints(DispatchEnum): class SlackAPIPostEndpoints(DispatchEnum): bookmarks_add = "bookmarks.add" + canvas_access_set = "canvases.access.set" + canvas_create = "canvases.create" + canvas_delete = "canvases.delete" + canvas_update = "canvases.edit" chat_post_message = "chat.postMessage" chat_post_ephemeral = "chat.postEphemeral" chat_update = "chat.update" diff --git a/src/dispatch/plugins/dispatch_slack/plugin.py b/src/dispatch/plugins/dispatch_slack/plugin.py index 8436456b8304..44f8eff6511a 100644 --- a/src/dispatch/plugins/dispatch_slack/plugin.py +++ b/src/dispatch/plugins/dispatch_slack/plugin.py @@ -65,6 +65,9 @@ set_conversation_topic, unarchive_conversation, update_message, + create_canvas, + update_canvas, + delete_canvas, ) logger = logging.getLogger(__name__) @@ -287,7 +290,11 @@ def send( error = exception.response["error"] if error == SlackAPIErrorCode.IS_ARCHIVED: # swallow send errors if the channel is archived - message = f"SlackAPIError trying to send: {exception.response}. Message: {text}. Type: {notification_type}. Template: {message_template}" + message = ( + f"SlackAPIError trying to send: {exception.response}. " + f"Message: {text}. Type: {notification_type}. " + f"Template: {message_template}" + ) logger.error(message) else: raise exception @@ -416,7 +423,8 @@ def remove_user(self, conversation_id: str, user_email: str): logger.warning( "Cannot remove user %s from conversation %s: " "User ID not found in resolve_user response", - user_email, conversation_id + user_email, + conversation_id, ) return None @@ -426,7 +434,8 @@ def remove_user(self, conversation_id: str, user_email: str): "User %s not found in Slack workspace. " "Cannot remove from conversation %s. " "User may have been deactivated or never had Slack access.", - user_email, conversation_id + user_email, + conversation_id, ) return None else: @@ -485,7 +494,11 @@ def fetch_events( raise def get_conversation( - self, conversation_id: str, oldest: str = "0", include_user_details = False, important_reaction: str | None = None + self, + conversation_id: str, + oldest: str = "0", + include_user_details=False, + important_reaction: str | None = None, ) -> list: """ Fetches the top-level posts from a Slack conversation. @@ -555,6 +568,66 @@ def get_all_member_emails(self, conversation_id: str) -> list[str]: return member_emails + def create_canvas( + self, conversation_id: str, title: str, user_emails: list[str] = None, content: str = None + ) -> str: + """ + Creates a new Slack canvas in the specified conversation. + + Args: + conversation_id (str): The ID of the Slack conversation where the canvas will be created. + title (str): The title of the canvas. + user_emails (list[str], optional): List of email addresses to grant editing permissions to. + content (str, optional): The markdown content of the canvas. Defaults to None. + + Returns: + str | None: The ID of the created canvas, or None if creation failed. + """ + if user_emails is None: + user_emails = [] + + client = create_slack_client(self.configuration) + + user_ids = emails_to_user_ids(client, user_emails) + + result = create_canvas( + client=client, + conversation_id=conversation_id, + title=title, + user_ids=user_ids, + content=content, + ) + if result is None: + logger.exception(f"Failed to create canvas in conversation {conversation_id}") + return result + + def edit_canvas(self, canvas_id: str, content: str) -> bool: + """ + Edits an existing Slack canvas. + + Args: + canvas_id (str): The ID of the canvas to edit. + content (str): The new markdown content for the canvas. + + Returns: + bool: True if the canvas was successfully edited, False otherwise. + """ + client = create_slack_client(self.configuration) + return update_canvas(client=client, canvas_id=canvas_id, content=content) + + def delete_canvas(self, canvas_id: str) -> bool: + """ + Deletes a Slack canvas. + + Args: + canvas_id (str): The ID of the canvas to delete. + + Returns: + bool: True if the canvas was successfully deleted, False otherwise. + """ + client = create_slack_client(self.configuration) + return delete_canvas(client=client, canvas_id=canvas_id) + @apply(counter, exclude=["__init__"]) @apply(timer, exclude=["__init__"]) diff --git a/src/dispatch/plugins/dispatch_slack/service.py b/src/dispatch/plugins/dispatch_slack/service.py index e7eff1a43139..31f990625645 100644 --- a/src/dispatch/plugins/dispatch_slack/service.py +++ b/src/dispatch/plugins/dispatch_slack/service.py @@ -645,7 +645,7 @@ def mention_resolver(user_match): user_id = user_match.group(1) try: user_info = get_user_info_by_id(client, user_id) - return user_info.get('real_name', f"{user_id} (name not found)") + return user_info.get("real_name", f"{user_id} (name not found)") except SlackApiError as e: log.warning(f"Error resolving mentioned Slack user: {e}") # fall back on id @@ -680,16 +680,18 @@ def mention_resolver(user_match): message_text = f"IMPORTANT!: {message_text}" if include_user_details: # attempt to resolve mentioned users - message_text = re.sub(r'<@(\w+)>', mention_resolver, message_text) + message_text = re.sub(r"<@(\w+)>", mention_resolver, message_text) message_result.append(message_text) if include_user_details: user_details = get_user_info_by_id(client, user_id) - user_name = user_details.get('real_name', "Name not found") - user_profile = user_details.get('profile', {}) - user_display_name = user_profile.get('display_name_normalized', "DisplayName not found") - user_email = user_profile.get('email', "Email not found") + user_name = user_details.get("real_name", "Name not found") + user_profile = user_details.get("profile", {}) + user_display_name = user_profile.get( + "display_name_normalized", "DisplayName not found" + ) + user_email = user_profile.get("email", "Email not found") message_result.extend([user_name, user_display_name, user_email]) heapq.heappush(result, tuple(message_result)) @@ -770,12 +772,164 @@ def is_member_in_channel(client: WebClient, conversation_id: str, user_id: str) except SlackApiError as e: if e.response["error"] == SlackAPIErrorCode.CHANNEL_NOT_FOUND: - log.warning(f"Channel {conversation_id} not found when checking membership for user {user_id}") + log.warning( + f"Channel {conversation_id} not found when checking membership for user {user_id}" + ) return False elif e.response["error"] == SlackAPIErrorCode.USER_NOT_IN_CHANNEL: # The bot itself is not in the channel, so it can't check membership - log.warning(f"Bot not in channel {conversation_id}, cannot check membership for user {user_id}") + log.warning( + f"Bot not in channel {conversation_id}, cannot check membership for user {user_id}" + ) return False else: - log.exception(f"Error checking channel membership for user {user_id} in channel {conversation_id}: {e}") + log.exception( + f"Error checking channel membership for user {user_id} in channel {conversation_id}: {e}" + ) raise + + +def canvas_set_access( + client: WebClient, conversation_id: str, canvas_id: str, user_ids: list[str] = None +) -> bool: + """ + Locks the canvas to read-only by the channel but allows the Dispatch bot to edit the canvas. + + Args: + client (WebClient): A Slack WebClient object used to interact with the Slack API. + conversation_id (str): The ID of the Slack conversation where the canvas will be created. + canvas_id (str): The ID of the canvas to update. + user_ids (list[str]): The IDs of the users to allow to edit the canvas. + + Returns: + bool: True if the canvas was successfully updated, False otherwise. + """ + if user_ids is None: + user_ids = [] + + try: + make_call( + client, + SlackAPIPostEndpoints.canvas_access_set, + access_level="read", + canvas_id=canvas_id, + channel_ids=[conversation_id], + ) + if user_ids: + make_call( + client, + SlackAPIPostEndpoints.canvas_access_set, + access_level="write", + canvas_id=canvas_id, + user_ids=user_ids, + ) + + return True + except SlackApiError as e: + log.exception(f"Error setting canvas access for canvas {canvas_id}: {e}") + return False + + +def create_canvas( + client: WebClient, + conversation_id: str, + title: str, + user_ids: list[str] = None, + content: str = None, +) -> str: + """ + Creates a new Slack canvas in the specified conversation. + + Args: + client (WebClient): A Slack WebClient object used to interact with the Slack API. + conversation_id (str): The ID of the Slack conversation where the canvas will be created. + title (str): The title of the canvas. + user_ids (list[str]): The IDs of the user(s) who will have write access to the canvas. + content (str, optional): The markdown content of the canvas. Defaults to None. + + Returns: + str | None: The ID of the created canvas, or None if creation failed. + """ + if user_ids is None: + user_ids = [] + + try: + kwargs = { + "channel_id": conversation_id, + "title": title, + } + if content is not None: + kwargs["document_content"] = {"type": "markdown", "markdown": content} + + response = make_call( + client, + SlackAPIPostEndpoints.canvas_create, + **kwargs, + ) + canvas_id = response.get("canvas_id") + canvas_set_access( + client=client, conversation_id=conversation_id, canvas_id=canvas_id, user_ids=user_ids + ) + return canvas_id + except SlackApiError as e: + log.exception(f"Error creating canvas in conversation {conversation_id}: {e}") + return None + + +def update_canvas( + client: WebClient, + canvas_id: str, + content: str, +) -> bool: + """ + Updates an existing Slack canvas. + + Args: + client (WebClient): A Slack WebClient object used to interact with the Slack API. + canvas_id (str): The ID of the canvas to update. + content (str): The new markdown content for the canvas. + + Returns: + bool: True if the canvas was successfully updated, False otherwise. + """ + try: + changes = [ + { + "operation": "replace", + "document_content": {"type": "markdown", "markdown": content}, + } + ] + + make_call( + client, + SlackAPIPostEndpoints.canvas_update, + canvas_id=canvas_id, + changes=changes, + ) + return True + except SlackApiError as e: + log.exception(f"Error updating canvas {canvas_id}: {e}") + return False + + +def delete_canvas(client: WebClient, canvas_id: str) -> bool: + """ + Deletes a Slack canvas. + + Args: + client (WebClient): A Slack WebClient object used to interact with the Slack API. + canvas_id (str): The ID of the canvas to delete. + + Returns: + bool: True if the canvas was successfully deleted, False otherwise. + """ + try: + make_call( + client, + SlackAPIPostEndpoints.canvas_delete, + canvas_id=canvas_id, + ) + return True + except SlackApiError as e: + log.exception(f"Error deleting canvas {canvas_id}: {e}") + return False