Skip to content

Manage participants from UI #6087

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Jul 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/dispatch/case/flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,38 @@ def case_remove_participant_flow(
db_session=db_session,
)

# we also try to remove the user from the Slack conversation
try:
slack_conversation_plugin = plugin_service.get_active_instance(
db_session=db_session, project_id=case.project.id, plugin_type="conversation"
)

if not slack_conversation_plugin:
log.warning(f"{user_email} not updated. No conversation plugin enabled.")
return

if not case.conversation:
log.warning("No conversation enabled for this case.")
return

slack_conversation_plugin.instance.remove_user(
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,
)

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}")


def update_conversation(case: Case, db_session: Session) -> None:
"""Updates external communication conversation."""
Expand Down
48 changes: 48 additions & 0 deletions src/dispatch/case/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
case_delete_flow,
case_escalated_create_flow,
case_new_create_flow,
case_remove_participant_flow,
case_stable_create_flow,
case_to_incident_endpoint_escalate_flow,
case_triage_create_flow,
Expand Down Expand Up @@ -435,6 +436,53 @@ def join_case(
)


@router.delete(
"/{case_id}/remove/{email}",
summary="Removes an individual from a case.",
dependencies=[Depends(PermissionsDependency([CaseEditPermission]))],
)
def remove_participant_from_case(
db_session: DbSession,
organization: OrganizationSlug,
case_id: PrimaryKey,
email: str,
current_case: CurrentCase,
current_user: CurrentUser,
background_tasks: BackgroundTasks,
):
"""Removes an individual from a case."""
background_tasks.add_task(
case_remove_participant_flow,
email,
case_id=current_case.id,
db_session=db_session,
)


@router.post(
"/{case_id}/add/{email}",
summary="Adds an individual to a case.",
dependencies=[Depends(PermissionsDependency([CaseEditPermission]))],
)
def add_participant_to_case(
db_session: DbSession,
organization: OrganizationSlug,
case_id: PrimaryKey,
email: str,
current_case: CurrentCase,
current_user: CurrentUser,
background_tasks: BackgroundTasks,
):
"""Adds an individual to a case."""
background_tasks.add_task(
case_add_or_reactivate_participant_flow,
email,
case_id=current_case.id,
organization_slug=organization,
db_session=db_session,
)


@router.post(
"/{case_id}/event",
summary="Creates a custom event.",
Expand Down
21 changes: 21 additions & 0 deletions src/dispatch/conversation/flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from dispatch.case.models import Case
from dispatch.conference.models import Conference
from dispatch.document.models import Document
from dispatch.enums import EventType
from dispatch.event import service as event_service
from dispatch.incident.models import Incident
from dispatch.messaging.strings import MessageType
Expand Down Expand Up @@ -490,8 +491,28 @@ def add_case_participants(
case.conversation.thread_id,
participant_emails,
)

# log event for adding participants
event_service.log_case_event(
db_session=db_session,
source=plugin.plugin.title,
description=f"{', '.join(participant_emails)} added to conversation (channel ID: {case.conversation.channel_id}, thread ID: {case.conversation.thread_id})",
case_id=case.id,
type=EventType.participant_updated,
)
log.info(f"{', '.join(participant_emails)} added to conversation (channel ID: {case.conversation.channel_id}, thread ID: {case.conversation.thread_id})")
elif case.has_channel:
plugin.instance.add(case.conversation.channel_id, participant_emails)

# log event for adding participants
event_service.log_case_event(
db_session=db_session,
source=plugin.plugin.title,
description=f"{', '.join(participant_emails)} added to conversation (channel ID: {case.conversation.channel_id})",
case_id=case.id,
type=EventType.participant_updated,
)
log.info(f"{', '.join(participant_emails)} added to conversation (channel ID: {case.conversation.channel_id})")
except Exception as e:
event_service.log_case_event(
db_session=db_session,
Expand Down
55 changes: 55 additions & 0 deletions src/dispatch/incident/flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -1078,6 +1078,29 @@ def incident_add_or_reactivate_participant_flow(
incident=incident, participant_emails=[user_email], db_session=db_session
)

# log event for adding the participant
try:
slack_conversation_plugin = plugin_service.get_active_instance(
db_session=db_session, project_id=incident.project.id, plugin_type="conversation"
)

if not slack_conversation_plugin:
log.warning(f"{user_email} not updated. No conversation plugin enabled.")
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,
)

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}")

# we announce the participant in the conversation
if send_announcement_message:
send_participant_announcement_message(
Expand Down Expand Up @@ -1153,3 +1176,35 @@ def incident_remove_participant_flow(
group_member=user_email,
db_session=db_session,
)

# we also try to remove the user from the Slack conversation
try:
slack_conversation_plugin = plugin_service.get_active_instance(
db_session=db_session, project_id=incident.project.id, plugin_type="conversation"
)

if not slack_conversation_plugin:
log.warning(f"{user_email} not updated. No conversation plugin enabled.")
return

if not incident.conversation:
log.warning("No conversation enabled for this incident.")
return

slack_conversation_plugin.instance.remove_user(
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,
)

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}")
47 changes: 47 additions & 0 deletions src/dispatch/incident/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
incident_create_resources_flow,
incident_create_stable_flow,
incident_delete_flow,
incident_remove_participant_flow,
incident_subscribe_participant_flow,
incident_update_flow,
)
Expand Down Expand Up @@ -292,6 +293,52 @@ def subscribe_to_incident(
)


@router.delete(
"/{incident_id}/remove/{email}",
summary="Removes an individual from an incident.",
dependencies=[Depends(PermissionsDependency([IncidentEditPermission]))],
)
def remove_participant_from_incident(
db_session: DbSession,
organization: OrganizationSlug,
incident_id: PrimaryKey,
email: str,
current_incident: CurrentIncident,
current_user: CurrentUser,
background_tasks: BackgroundTasks,
):
"""Removes an individual from an incident."""
background_tasks.add_task(
incident_remove_participant_flow,
email,
incident_id=current_incident.id,
organization_slug=organization,
)


@router.post(
"/{incident_id}/add/{email}",
summary="Adds an individual to an incident.",
dependencies=[Depends(PermissionsDependency([IncidentEditPermission]))],
)
def add_participant_to_incident(
db_session: DbSession,
organization: OrganizationSlug,
incident_id: PrimaryKey,
email: str,
current_incident: CurrentIncident,
current_user: CurrentUser,
background_tasks: BackgroundTasks,
):
"""Adds an individual to an incident."""
background_tasks.add_task(
incident_add_or_reactivate_participant_flow,
email,
incident_id=current_incident.id,
organization_slug=organization,
)


@router.post(
"/{incident_id}/report/tactical",
summary="Creates a tactical report.",
Expand Down
23 changes: 15 additions & 8 deletions src/dispatch/participant/flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,18 +188,25 @@ def inactivate_participant(user_email: str, subject: Subject, db_session: Sessio


def reactivate_participant(
user_email: str, incident: Incident, db_session: Session, service_id: int = None
user_email: str, subject: Subject, db_session: Session, service_id: int = None
):
"""Reactivates a participant."""
participant = participant_service.get_by_incident_id_and_email(
db_session=db_session, incident_id=incident.id, email=user_email
)
subject_type = get_table_name_by_class_instance(subject)

if subject_type == "case":
participant = participant_service.get_by_case_id_and_email(
db_session=db_session, case_id=subject.id, email=user_email
)
else:
participant = participant_service.get_by_incident_id_and_email(
db_session=db_session, incident_id=subject.id, email=user_email
)

if not participant:
log.debug(f"{user_email} is not an inactive participant of {incident.name} incident.")
log.debug(f"{user_email} is not an inactive participant of {subject.name} {subject_type}.")
return False

log.debug(f"Reactivating {participant.individual.name} on {incident.name} incident...")
log.debug(f"Reactivating {participant.individual.name} on {subject.name} {subject_type}...")

# we get the last active role
participant_role = participant_role_service.get_last_active_role(
Expand All @@ -219,11 +226,11 @@ def reactivate_participant(
db_session.add(participant)
db_session.commit()

event_service.log_incident_event(
event_service.log_subject_event(
subject=subject,
db_session=db_session,
source="Dispatch Core App",
description=f"{participant.individual.name} has been reactivated",
incident_id=incident.id,
type=EventType.participant_updated,
)

Expand Down
1 change: 1 addition & 0 deletions src/dispatch/plugins/dispatch_slack/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class SlackAPIErrorCode(DispatchEnum):
FATAL_ERROR = "fatal_error"
IS_ARCHIVED = "is_archived" # Channel is archived
MISSING_SCOPE = "missing_scope"
NOT_IN_CHANNEL = "not_in_channel"
ORG_USER_NOT_IN_TEAM = "org_user_not_in_team"
USERS_NOT_FOUND = "users_not_found"
USER_IN_CHANNEL = "user_in_channel"
Expand Down
45 changes: 45 additions & 0 deletions src/dispatch/plugins/dispatch_slack/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,13 @@ def add_conversation_bookmark(

def remove_member_from_channel(client: WebClient, conversation_id: str, user_id: str) -> None:
"""Removes a user from a channel."""
log.info(f"Attempting to remove user {user_id} from channel {conversation_id}")

# Check if user is actually in the channel before attempting removal
if not is_member_in_channel(client, conversation_id, user_id):
log.info(f"User {user_id} is not in channel {conversation_id}, skipping removal")
return

return make_call(
client, SlackAPIPostEndpoints.conversations_kick, channel=conversation_id, user=user_id
)
Expand Down Expand Up @@ -734,3 +741,41 @@ def create_genai_message_metadata_blocks(
)
blocks.append(Divider())
return Message(blocks=blocks).build()["blocks"]


def is_member_in_channel(client: WebClient, conversation_id: str, user_id: str) -> bool:
"""
Check if a user is a member of a specific Slack channel.

Args:
client (WebClient): A Slack WebClient object used to interact with the Slack API.
conversation_id (str): The ID of the Slack channel/conversation to check.
user_id (str): The ID of the user to check for membership.

Returns:
bool: True if the user is a member of the channel, False otherwise.

Raises:
SlackApiError: If there's an error from the Slack API (e.g., channel not found).
"""
try:
response = make_call(
client,
SlackAPIGetEndpoints.conversations_members,
channel=conversation_id,
)

# Check if the user_id is in the list of members
return user_id in response.get("members", [])

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}")
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}")
return False
else:
log.exception(f"Error checking channel membership for user {user_id} in channel {conversation_id}: {e}")
raise
Loading
Loading