diff --git a/src/dispatch/auth/permissions.py b/src/dispatch/auth/permissions.py index 51342ef91ab3..b10642f0ce50 100644 --- a/src/dispatch/auth/permissions.py +++ b/src/dispatch/auth/permissions.py @@ -473,6 +473,20 @@ def has_required_permissions( return True +class CaseEventPermission(BasePermission): + def has_required_permissions( + self, + request: Request, + ) -> bool: + return any_permission( + permissions=[ + OrganizationAdminPermission, + CaseParticipantPermission, + ], + request=request, + ) + + class FeedbackDeletePermission(BasePermission): def has_required_permissions( self, diff --git a/src/dispatch/case/service.py b/src/dispatch/case/service.py index e2ce5ca40ed9..4e5742bb335a 100644 --- a/src/dispatch/case/service.py +++ b/src/dispatch/case/service.py @@ -222,6 +222,7 @@ def create(*, db_session, case_in: CaseCreate, current_user: DispatchUser = None "visibility": case.visibility, }, case_id=case.id, + pinned=True, ) assignee_email = None diff --git a/src/dispatch/case/views.py b/src/dispatch/case/views.py index 7b57456de8cb..8ac2205a7273 100644 --- a/src/dispatch/case/views.py +++ b/src/dispatch/case/views.py @@ -1,5 +1,6 @@ import json import logging +from datetime import datetime from typing import Annotated from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status @@ -11,6 +12,7 @@ CaseEditPermission, CaseJoinPermission, CaseViewPermission, + CaseEventPermission, PermissionsDependency, ) from dispatch.auth.service import CurrentUser @@ -18,6 +20,8 @@ from dispatch.common.utils.views import create_pydantic_include from dispatch.database.core import DbSession from dispatch.database.service import CommonParameters, search_filter_sort_paginate +from dispatch.event import flows as event_flows +from dispatch.event.models import EventCreateMinimal, EventUpdate from dispatch.incident import service as incident_service from dispatch.incident.models import IncidentCreate, IncidentRead from dispatch.individual.models import IndividualContactRead @@ -39,7 +43,15 @@ case_update_flow, get_case_participants_flow, ) -from .models import Case, CaseCreate, CaseExpandedPagination, CasePagination, CasePaginationMinimalWithExtras, CaseRead, CaseUpdate +from .models import ( + Case, + CaseCreate, + CaseExpandedPagination, + CasePagination, + CasePaginationMinimalWithExtras, + CaseRead, + CaseUpdate, +) from .service import create, delete, get, get_participants, update log = logging.getLogger(__name__) @@ -144,6 +156,7 @@ def get_cases_minimal( return json.loads(CasePaginationMinimalWithExtras(**pagination).json()) + @router.post("", response_model=CaseRead, summary="Creates a new case.") def create_case( db_session: DbSession, @@ -413,3 +426,113 @@ def join_case( case_id=current_case.id, organization_slug=organization, ) + + +@router.post( + "/{case_id}/event", + summary="Creates a custom event.", + dependencies=[Depends(PermissionsDependency([CaseEventPermission]))], +) +def create_custom_event( + db_session: DbSession, + organization: OrganizationSlug, + case_id: PrimaryKey, + current_case: CurrentCase, + event_in: EventCreateMinimal, + current_user: CurrentUser, + background_tasks: BackgroundTasks, +): + if event_in.details is None: + event_in.details = {} + event_in.details.update({"created_by": current_user.email, "added_on": str(datetime.utcnow())}) + """Creates a custom event.""" + background_tasks.add_task( + event_flows.log_case_event, + user_email=current_user.email, + case_id=current_case.id, + event_in=event_in, + organization_slug=organization, + ) + + +@router.patch( + "/{case_id}/event", + summary="Updates a custom event.", + dependencies=[Depends(PermissionsDependency([CaseEventPermission]))], +) +def update_custom_event( + db_session: DbSession, + organization: OrganizationSlug, + case_id: PrimaryKey, + current_case: CurrentCase, + event_in: EventUpdate, + current_user: CurrentUser, + background_tasks: BackgroundTasks, +): + if event_in.details: + event_in.details.update( + { + **event_in.details, + "updated_by": current_user.email, + "updated_on": str(datetime.utcnow()), + } + ) + else: + event_in.details = {"updated_by": current_user.email, "updated_on": str(datetime.utcnow())} + """Updates a custom event.""" + background_tasks.add_task( + event_flows.update_case_event, + event_in=event_in, + organization_slug=organization, + ) + + +@router.post( + "/{case_id}/exportTimeline", + summary="Exports timeline events.", + dependencies=[Depends(PermissionsDependency([CaseEventPermission]))], +) +def export_timeline_event( + db_session: DbSession, + organization: OrganizationSlug, + case_id: PrimaryKey, + current_case: CurrentCase, + timeline_filters: dict, + current_user: CurrentUser, + background_tasks: BackgroundTasks, +): + try: + event_flows.export_case_timeline( + timeline_filters=timeline_filters, + case_id=case_id, + organization_slug=organization, + db_session=db_session, + ) + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=[{"msg": (f"{str(e)}.",)}], + ) from e + + +@router.delete( + "/{case_id}/event/{event_uuid}", + summary="Deletes a custom event.", + dependencies=[Depends(PermissionsDependency([CaseEventPermission]))], +) +def delete_custom_event( + db_session: DbSession, + organization: OrganizationSlug, + case_id: PrimaryKey, + current_case: CurrentCase, + event_uuid: str, + current_user: CurrentUser, + background_tasks: BackgroundTasks, +): + """Deletes a custom event.""" + background_tasks.add_task( + event_flows.delete_case_event, + event_uuid=event_uuid, + organization_slug=organization, + ) diff --git a/src/dispatch/document/service.py b/src/dispatch/document/service.py index 21deb5866fe5..df95137d65d0 100644 --- a/src/dispatch/document/service.py +++ b/src/dispatch/document/service.py @@ -27,6 +27,19 @@ def get_by_incident_id_and_resource_type( ) +def get_by_case_id_and_resource_type( + *, db_session, case_id: int, project_id: int, resource_type: str +) -> Document | None: + """Returns a document based on the given case and id and document resource type.""" + return ( + db_session.query(Document) + .filter(Document.case_id == case_id) + .filter(Document.project_id == project_id) + .filter(Document.resource_type == resource_type) + .one_or_none() + ) + + def get_project_forms_export_template(*, db_session, project_id: int) -> Document | None: """Fetches the project forms export template.""" resource_type = DocumentResourceTemplateTypes.forms @@ -89,12 +102,14 @@ def create(*, db_session, document_in: DocumentCreate) -> Document: .one_or_none() ) if faq_doc: - raise ValidationError([ - { - "msg": "FAQ document already defined for this project.", - "loc": "document", - } - ]) + raise ValidationError( + [ + { + "msg": "FAQ document already defined for this project.", + "loc": "document", + } + ] + ) if document_in.resource_type == DocumentResourceTemplateTypes.forms: forms_doc = ( @@ -104,12 +119,14 @@ def create(*, db_session, document_in: DocumentCreate) -> Document: .one_or_none() ) if forms_doc: - raise ValidationError([ - { - "msg": "Forms export template document already defined for this project.", - "loc": "document", - } - ]) + raise ValidationError( + [ + { + "msg": "Forms export template document already defined for this project.", + "loc": "document", + } + ] + ) filters = [ search_filter_service.get(db_session=db_session, search_filter_id=f.id) diff --git a/src/dispatch/event/flows.py b/src/dispatch/event/flows.py index 83caf1b94fd8..2dd167949d47 100644 --- a/src/dispatch/event/flows.py +++ b/src/dispatch/event/flows.py @@ -3,8 +3,10 @@ from dispatch.decorators import background_task from dispatch.event import service as event_service from dispatch.incident import service as incident_service +from dispatch.case import service as case_service from dispatch.individual import service as individual_service from dispatch.event.models import EventUpdate, EventCreateMinimal +from dispatch.auth import service as auth_service log = logging.getLogger(__name__) @@ -71,3 +73,71 @@ def export_timeline( except Exception: raise + + +@background_task +def log_case_event( + user_email: str, + case_id: int, + event_in: EventCreateMinimal, + db_session=None, + organization_slug: str = None, +): + case = case_service.get(db_session=db_session, case_id=case_id) + individual = individual_service.get_by_email_and_project( + db_session=db_session, email=user_email, project_id=case.project.id + ) + event_in.source = f"Custom event created by {individual.name}" + event_in.owner = individual.name + + # Get dispatch user by email + dispatch_user = auth_service.get_by_email(db_session=db_session, email=user_email) + dispatch_user_id = dispatch_user.id if dispatch_user else None + + event_service.log_case_event( + db_session=db_session, + case_id=case_id, + dispatch_user_id=dispatch_user_id, + **event_in.__dict__, + ) + + +@background_task +def update_case_event( + event_in: EventUpdate, + db_session=None, + organization_slug: str = None, +): + event_service.update_case_event( + db_session=db_session, + event_in=event_in, + ) + + +@background_task +def delete_case_event( + event_uuid: str, + db_session=None, + organization_slug: str = None, +): + event_service.delete_case_event( + db_session=db_session, + uuid=event_uuid, + ) + + +def export_case_timeline( + timeline_filters: dict, + case_id: int, + db_session=None, + organization_slug: str = None, +): + try: + event_service.export_case_timeline( + db_session=db_session, + timeline_filters=timeline_filters, + case_id=case_id, + ) + + except Exception: + raise diff --git a/src/dispatch/event/models.py b/src/dispatch/event/models.py index 33c5bf6f44fa..5f7293435197 100644 --- a/src/dispatch/event/models.py +++ b/src/dispatch/event/models.py @@ -70,7 +70,7 @@ class EventCreateMinimal(DispatchBase): started_at: datetime source: str description: str - details: dict + details: dict | None = None type: str | None = None owner: str | None = None pinned: bool | None = False diff --git a/src/dispatch/event/service.py b/src/dispatch/event/service.py index aa1d7852843a..88dd5125d610 100644 --- a/src/dispatch/event/service.py +++ b/src/dispatch/event/service.py @@ -262,6 +262,27 @@ def delete_incident_event( delete(db_session=db_session, event_id=event.id) +def update_case_event( + db_session, + event_in: EventUpdate, +) -> Event: + """Updates an event in the case timeline.""" + event = get_by_uuid(db_session=db_session, uuid=event_in.uuid) + event = update(db_session=db_session, event=event, event_in=event_in) + + return event + + +def delete_case_event( + db_session, + uuid: str, +): + """Deletes a case event.""" + event = get_by_uuid(db_session=db_session, uuid=uuid) + + delete(db_session=db_session, event_id=event.id) + + def export_timeline( db_session, timeline_filters: str, @@ -463,9 +484,7 @@ def export_timeline( str_len = 0 row_idx = 0 insert_data_request = [] - print("cell indices") - print(len(cell_indices)) - print(len(data_to_insert)) + for index, text in zip(cell_indices, data_to_insert, strict=True): # Adjusting index based on string length new_idx = index + str_len @@ -545,3 +564,275 @@ def export_timeline( # raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=[{"msg": "No timeline data to export"}]) from None return True + + +def export_case_timeline( + db_session, + timeline_filters: str, + case_id: int, +): + case = case_service.get(db_session=db_session, case_id=case_id) + plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=case.project_id, plugin_type="document" + ) + if not plugin: + log.error("Document not created. No storage plugin enabled.") + return False + + """gets timeline events for case""" + event = get_by_case_id(db_session=db_session, case_id=case_id) + table_data = [] + dates = set() + data_inserted = False + + """Filters events based on user filter""" + for e in event: + time_header = "Time (UTC)" + event_timestamp = e.started_at.strftime("%Y-%m-%d %H:%M:%S") + if not e.owner: + e.owner = "Dispatch" + if timeline_filters.get("timezone").strip() == "America/Los_Angeles": + time_header = "Time (PST/PDT)" + event_timestamp = ( + pytz.utc.localize(e.started_at) + .astimezone(pytz.timezone(timeline_filters.get("timezone").strip())) + .replace(tzinfo=None) + .strftime("%Y-%m-%d %H:%M:%S") + ) + date, time = str(event_timestamp).split(" ") + if e.pinned or timeline_filters.get(e.type): + if date in dates: + if timeline_filters.get("exportOwner"): + table_data.append( + {time_header: time, "Description": e.description, "Owner": e.owner} + ) + else: + table_data.append({time_header: time, "Description": e.description}) + + else: + dates.add(date) + if timeline_filters.get("exportOwner"): + table_data.append({time_header: date, "Description": "\t", "Owner": "\t"}) + table_data.append( + {time_header: time, "Description": e.description, "Owner": e.owner} + ) + else: + table_data.append({time_header: date, "Description": "\t"}) + table_data.append({time_header: time, "Description": e.description}) + + if table_data: + table_data = json.loads(json.dumps(table_data)) + num_columns = len(table_data[0].keys() if table_data else []) + column_headers = table_data[0].keys() + + documents_list = [] + if timeline_filters.get("caseDocument"): + documents = document_service.get_by_case_id_and_resource_type( + db_session=db_session, + case_id=case_id, + project_id=case.project.id, + resource_type="dispatch-case-document", + ) + if documents: + documents_list.append((documents.resource_id, "Case")) + + for doc_id, doc_name in documents_list: + # Checks for existing table in the document + table_exists, curr_table_start, curr_table_end, _ = plugin.instance.get_table_details( + document_id=doc_id, header="Timeline", doc_name=doc_name + ) + + # Deletes existing table + if table_exists: + delete_table_request = [ + { + "deleteContentRange": { + "range": { + "segmentId": "", + "startIndex": curr_table_start, + "endIndex": curr_table_end, + } + } + } + ] + if plugin.instance.delete_table(document_id=doc_id, request=delete_table_request): + log.debug("Existing table in the doc has been deleted") + + else: + log.debug("Table doesn't exist under header, creating new table") + curr_table_start += 1 + + # Insert new table with required rows & columns + insert_table_request = [ + { + "insertTable": { + "rows": len(table_data) + 1, + "columns": num_columns, + "location": {"index": curr_table_start - 1}, + } + } + ] + if plugin.instance.insert(document_id=doc_id, request=insert_table_request): + log.debug("Table skeleton inserted successfully") + + else: + log.error( + f"Unable to insert table skeleton in the {doc_name} document with id {doc_id}" + ) + raise Exception( + f"Unable to insert table skeleton for timeline export in the {doc_name} document" + ) + + # Formatting & inserting empty table + insert_data_request = [ + { + "updateTableCellStyle": { + "tableCellStyle": { + "backgroundColor": { + "color": {"rgbColor": {"green": 0.4, "red": 0.4, "blue": 0.4}} + } + }, + "fields": "backgroundColor", + "tableRange": { + "columnSpan": num_columns, + "rowSpan": 1, + "tableCellLocation": { + "columnIndex": 0, + "rowIndex": 0, + "tableStartLocation": {"index": curr_table_start}, + }, + }, + } + }, + { + "updateTableColumnProperties": { + "tableStartLocation": { + "index": curr_table_start, + }, + "columnIndices": [0], + "tableColumnProperties": { + "width": {"magnitude": 90, "unit": "PT"}, + "widthType": "FIXED_WIDTH", + }, + "fields": "width,widthType", + } + }, + ] + + if timeline_filters.get("exportOwner"): + insert_data_request.append( + { + "updateTableColumnProperties": { + "tableStartLocation": { + "index": curr_table_start, + }, + "columnIndices": [2], + "tableColumnProperties": { + "width": {"magnitude": 105, "unit": "PT"}, + "widthType": "FIXED_WIDTH", + }, + "fields": "width,widthType", + } + } + ) + + if plugin.instance.insert(document_id=doc_id, request=insert_data_request): + log.debug("Table Formatted successfully") + + else: + log.error( + f"Unable to format table for timeline export in {doc_name} document with id {doc_id}" + ) + raise Exception( + f"Unable to format table for timeline export in the {doc_name} document" + ) + + # Calculating table cell indices + _, _, _, cell_indices = plugin.instance.get_table_details( + document_id=doc_id, header="Timeline", doc_name=doc_name + ) + data_to_insert = list(column_headers) + [ + item for row in table_data for item in row.values() + ] + str_len = 0 + row_idx = 0 + insert_data_request = [] + + for index, text in zip(cell_indices, data_to_insert, strict=True): + # Adjusting index based on string length + new_idx = index + str_len + + insert_data_request.append( + {"insertText": {"location": {"index": new_idx}, "text": text}} + ) + + # Header field formatting + if text in column_headers: + insert_data_request.append( + { + "updateTextStyle": { + "range": {"startIndex": new_idx, "endIndex": new_idx + len(text)}, + "textStyle": { + "bold": True, + "foregroundColor": { + "color": {"rgbColor": {"red": 1, "green": 1, "blue": 1}} + }, + "fontSize": {"magnitude": 10, "unit": "PT"}, + }, + "fields": "bold,foregroundColor", + } + } + ) + + # Formating for date rows + if text == "\t": + insert_data_request.append( + { + "updateTableCellStyle": { + "tableCellStyle": { + "backgroundColor": { + "color": { + "rgbColor": {"green": 0.8, "red": 0.8, "blue": 0.8} + } + } + }, + "fields": "backgroundColor", + "tableRange": { + "columnSpan": num_columns, + "rowSpan": 1, + "tableCellLocation": { + "tableStartLocation": {"index": curr_table_start}, + "columnIndex": 0, + "rowIndex": row_idx // len(column_headers), + }, + }, + } + } + ) + + # Formating for time column + if row_idx % num_columns == 0: + insert_data_request.append( + { + "updateTextStyle": { + "range": {"startIndex": new_idx, "endIndex": new_idx + len(text)}, + "textStyle": { + "bold": True, + }, + "fields": "bold", + } + } + ) + + row_idx += 1 + str_len += len(text) if text else 0 + + data_inserted = plugin.instance.insert(document_id=doc_id, request=insert_data_request) + if not data_inserted: + raise Exception(f"Encountered error while inserting data into the {doc_name} document") + + else: + log.error("No data to export") + raise Exception("No data to export, please check filter selection") + + return True diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index b27ad1c5b57f..8bed0b649b65 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -141,6 +141,7 @@ from dispatch.ticket import flows as ticket_flows from dispatch.messaging.strings import reminder_select_values from dispatch.plugins.dispatch_slack.messaging import build_unexpected_error_message +from dispatch.auth import service as auth_service log = logging.getLogger(__file__) @@ -782,30 +783,28 @@ def replace_slack_users_in_message(client: Any, message: str) -> str: def handle_timeline_added_event( ack: Ack, client: Any, context: BoltContext, payload: Any, db_session: Session ) -> None: - """Handles an event where the configured timeline reaction is added to a message.""" + """Handles an event where the configured timeline reaction is added to a message for incidents or cases.""" ack() conversation_id = context["channel_id"] message_ts = payload["item"]["ts"] message_ts_utc = datetime.utcfromtimestamp(float(message_ts)) - # we fetch the message information response = dispatch_slack_service.list_conversation_messages( client, conversation_id, latest=message_ts, limit=1, inclusive=1 ) message_text = replace_slack_users_in_message(client, response["messages"][0]["text"]) message_sender_id = response["messages"][0]["user"] - # TODO: (wshel) handle case reactions - if context["subject"].type == IncidentSubjects.incident: - individual = None - # we fetch the incident + subject_type = context["subject"].type + individual = None + source = "Slack message" + event_id = None + + if subject_type == IncidentSubjects.incident: incident = incident_service.get( db_session=db_session, incident_id=int(context["subject"].id) ) - - # we fetch the individual who sent the message - # if user is not found, we default to "Unknown" try: message_sender_email = get_user_email(client=client, user_id=message_sender_id) if message_sender_email: @@ -814,24 +813,20 @@ def handle_timeline_added_event( email=message_sender_email, project_id=incident.project.id, ) - except Exception: + except Exception as e: + log.error(f"Error getting user email: {e}") individual = None - - source = "Slack message" - # if the individual is not found, see if it is a bot if not individual: - if bot_user_id := context["bot_user_id"]: + if bot_user_id := context.get("bot_user_id"): try: bot = dispatch_slack_service.get_user_info_by_id(client, bot_user_id) bot_name = bot["profile"]["real_name"] source = f"Slack message from {bot_name}" - except Exception: - pass + except Exception as e: + log.error(f"Error getting bot info: {e}") else: source = f"Slack message from {individual.name}" - - # we log the event - event_service.log_incident_event( + event = event_service.log_incident_event( db_session=db_session, source=source, description=message_text, @@ -841,6 +836,53 @@ def handle_timeline_added_event( type=EventType.imported_message, owner=individual.name if individual else None, ) + db_session.commit() + event_id = event.id + log.info(f"Logged incident event with ID: {event_id}") + elif subject_type == CaseSubjects.case: + case = case_service.get(db_session=db_session, case_id=int(context["subject"].id)) + try: + message_sender_email = dispatch_slack_service.get_user_email( + client=client, user_id=message_sender_id + ) + if message_sender_email: + individual = individual_service.get_by_email_and_project( + db_session=db_session, + email=message_sender_email, + project_id=case.project.id, + ) + except Exception as e: + log.error(f"Error getting user email: {e}") + individual = None + if not individual: + if bot_user_id := context.get("bot_user_id"): + try: + bot = dispatch_slack_service.get_user_info_by_id(client, bot_user_id) + bot_name = bot["profile"]["real_name"] + source = f"Slack message from {bot_name}" + except Exception as e: + log.error(f"Error getting bot info: {e}") + else: + source = f"Slack message from {individual.name}" + dispatch_user_id = None + if individual: + dispatch_user = auth_service.get_by_email(db_session=db_session, email=individual.email) + dispatch_user_id = dispatch_user.id if dispatch_user else None + event = event_service.log_case_event( + db_session=db_session, + source=source, + description=message_text, + case_id=int(context["subject"].id), + dispatch_user_id=dispatch_user_id, + started_at=message_ts_utc, + type=EventType.imported_message, + owner=individual.name if individual else "Unknown", + ) + db_session.commit() + event_id = event.id + log.info(f"Logged case event with ID: {event_id}") + else: + log.info(f"TIMELINE HANDLER: Unknown subject type: {subject_type}") @message_dispatcher.add( diff --git a/src/dispatch/static/dispatch/src/case/CaseTimelineTabV1.vue b/src/dispatch/static/dispatch/src/case/CaseTimelineTabV1.vue index b93af58b28c0..0cda2d0ea61e 100644 --- a/src/dispatch/static/dispatch/src/case/CaseTimelineTabV1.vue +++ b/src/dispatch/static/dispatch/src/case/CaseTimelineTabV1.vue @@ -1,31 +1,50 @@