diff --git a/src/dispatch/event/flows.py b/src/dispatch/event/flows.py index 5da3ae803736..692aec126afd 100644 --- a/src/dispatch/event/flows.py +++ b/src/dispatch/event/flows.py @@ -27,6 +27,8 @@ def log_incident_event( event_service.log_incident_event( db_session=db_session, incident_id=incident_id, + individual_id=individual.id, + owner=individual.name, **event_in.__dict__, ) @@ -53,3 +55,18 @@ def delete_incident_event( db_session=db_session, uuid=event_uuid, ) + + +@background_task +def export_timeline( + timeline_filters: dict, + incident_id: int, + db_session=None, + organization_slug: str = None, +): + status = event_service.export_timeline( + db_session=db_session, + timeline_filters=timeline_filters, + incident_id=incident_id, + ) + return status diff --git a/src/dispatch/event/service.py b/src/dispatch/event/service.py index 9c56ee3eaff6..0c08e678ac58 100644 --- a/src/dispatch/event/service.py +++ b/src/dispatch/event/service.py @@ -2,6 +2,8 @@ from uuid import uuid4 import datetime import logging +import json +import pytz from dispatch.auth import service as auth_service from dispatch.case import service as case_service @@ -10,14 +12,20 @@ from dispatch.enums import EventType from .models import Event, EventCreate, EventUpdate - +from dispatch.document import service as document_service +from dispatch.plugin import service as plugin_service log = logging.getLogger(__name__) def get(*, db_session, event_id: int) -> Optional[Event]: """Get an event by id.""" - return db_session.query(Event).filter(Event.id == event_id).one_or_none() + return ( + db_session.query(Event) + .filter(Event.id == event_id) + .order_by(Event.started_at) + .one_or_none() + ) def get_by_case_id(*, db_session, case_id: int) -> list[Event | None]: @@ -27,6 +35,7 @@ def get_by_case_id(*, db_session, case_id: int) -> list[Event | None]: def get_by_incident_id(*, db_session, incident_id: int) -> list[Event | None]: """Get events by incident id.""" + return db_session.query(Event).filter(Event.incident_id == incident_id) @@ -182,3 +191,233 @@ def delete_incident_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, + incident_id: int, +): + incident = incident_service.get(db_session=db_session, incident_id=incident_id) + plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=incident.project_id, plugin_type="document" + ) + if not plugin: + log.error("Document not created. No storage plugin enabled.") + return False + + """gets timeline events for incident""" + event = get_by_incident_id(db_session=db_session, incident_id=incident_id) + table_data = [] + dates = set() + data_inserted = False + + """Filters events based on user filter""" + for e in event.all(): + 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) + ) + date, time = str(event_timestamp).split(" ") + if e.pinned or timeline_filters.get(e.type): + if date in dates: + table_data.append( + {time_header: time, "Description": e.description, "Owner": e.owner} + ) + else: + dates.add(date) + table_data.append({time_header: date, "Description": "\t", "owner": "\t"}) + table_data.append( + {time_header: time, "Description": e.description, "Owner": e.owner} + ) + + 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("incidentDocument"): + documents = document_service.get_by_incident_id_and_resource_type( + db_session=db_session, + incident_id=incident_id, + project_id=incident.project.id, + resource_type="dispatch-incident-document", + ) + if documents: + documents_list.append(documents.resource_id) + + if timeline_filters.get("reviewDocument"): + documents = document_service.get_by_incident_id_and_resource_type( + db_session=db_session, + incident_id=incident_id, + project_id=incident.project.id, + resource_type="dispatch-incident-review-document", + ) + if documents: + documents_list.append(documents.resource_id) + + for doc_id 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" + ) + + # 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: + 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: + return False + + # 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": 3, + "rowSpan": 1, + "tableCellLocation": { + "columnIndex": 0, + "rowIndex": 0, + "tableStartLocation": {"index": curr_table_start}, + }, + }, + } + } + ] + + if plugin.instance.insert(document_id=doc_id, request=insert_data_request): + log.debug("Table Formatted successfully") + + else: + return False + + # Calculating table cell indices + _, _, _, cell_indices = plugin.instance.get_table_details( + document_id=doc_id, header="Timeline" + ) + + 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": 3, + "rowSpan": 1, + "tableCellLocation": { + "tableStartLocation": {"index": curr_table_start}, + "columnIndex": 0, + "rowIndex": row_idx // 3, + }, + }, + } + } + ) + + # 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: + return False + else: + log.error("No timeline data to export") + return False + return True diff --git a/src/dispatch/incident/flows.py b/src/dispatch/incident/flows.py index 8976eec03aec..5b21408b977d 100644 --- a/src/dispatch/incident/flows.py +++ b/src/dispatch/incident/flows.py @@ -612,15 +612,6 @@ def status_flow_dispatcher( elif previous_status == IncidentStatus.stable: incident_closed_status_flow(incident=incident, db_session=db_session) - if previous_status != current_status: - event_service.log_incident_event( - db_session=db_session, - source="Dispatch Core App", - description=f"The incident status has been changed from {previous_status.lower()} to {current_status.lower()}", # noqa - incident_id=incident.id, - type=EventType.assessment_updated, - ) - @background_task def incident_update_flow( diff --git a/src/dispatch/incident/service.py b/src/dispatch/incident/service.py index 0f6c09612c7c..1048dc0b5e3d 100644 --- a/src/dispatch/incident/service.py +++ b/src/dispatch/incident/service.py @@ -208,6 +208,7 @@ def create(*, db_session, incident_in: IncidentCreate) -> Incident: "status": incident.status, "visibility": incident.visibility, }, + individual_id=incident_in.reporter.individual.id, incident_id=incident.id, owner=reporter_name, pinned=True, @@ -279,7 +280,9 @@ def create(*, db_session, incident_in: IncidentCreate) -> Incident: ) # add observer (if engage_next_oncall is enabled) - incident_role = resolve_role(db_session=db_session, role=ParticipantRoleType.incident_commander, incident=incident) + incident_role = resolve_role( + db_session=db_session, role=ParticipantRoleType.incident_commander, incident=incident + ) if incident_role and incident_role.engage_next_oncall: oncall_plugin = plugin_service.get_active_instance( db_session=db_session, project_id=incident.project.id, plugin_type="oncall" @@ -287,7 +290,9 @@ def create(*, db_session, incident_in: IncidentCreate) -> Incident: if not oncall_plugin: log.debug("Resolved observer role not available since oncall plugin is not active.") else: - oncall_email = oncall_plugin.instance.get_next_oncall(service_id=incident_role.service.external_id) + oncall_email = oncall_plugin.instance.get_next_oncall( + service_id=incident_role.service.external_id + ) if oncall_email: participant_flows.add_participant( oncall_email, diff --git a/src/dispatch/incident/views.py b/src/dispatch/incident/views.py index d20d3bcdbb4f..9924ecabb924 100644 --- a/src/dispatch/incident/views.py +++ b/src/dispatch/incident/views.py @@ -380,6 +380,29 @@ def update_custom_event( ) +@router.post( + "/{incident_id}/exportTimeline", + summary="Exports timeline events.", + dependencies=[Depends(PermissionsDependency([IncidentCommanderOrScribePermission]))], +) +def export_timeline_event( + db_session: DbSession, + organization: OrganizationSlug, + incident_id: PrimaryKey, + current_incident: CurrentIncident, + timeline_filters: dict, + current_user: CurrentUser, + background_tasks: BackgroundTasks, +): + result = background_tasks.add_task( + event_flows.export_timeline, + timeline_filters=timeline_filters, + incident_id=incident_id, + organization_slug=organization, + ) + return result + + @router.delete( "/{incident_id}/event/{event_uuid}", summary="Deletes a custom event.", diff --git a/src/dispatch/plugins/dispatch_google/docs/plugin.py b/src/dispatch/plugins/dispatch_google/docs/plugin.py index a8c7f02ce56f..ea6897b96aee 100644 --- a/src/dispatch/plugins/dispatch_google/docs/plugin.py +++ b/src/dispatch/plugins/dispatch_google/docs/plugin.py @@ -6,6 +6,7 @@ .. moduleauthor:: Kevin Glisson """ import unicodedata +import logging from typing import Any, List from dispatch.decorators import apply, counter, timer @@ -13,6 +14,9 @@ from dispatch.plugins.dispatch_google import docs as google_docs_plugin from dispatch.plugins.dispatch_google.common import get_service from dispatch.plugins.dispatch_google.config import GoogleConfiguration +from googleapiclient.errors import HttpError + +log = logging.getLogger(__name__) def remove_control_characters(s): @@ -26,7 +30,6 @@ def replace_text(client: Any, document_id: str, replacements: List[str]): requests.append( {"replaceAllText": {"containsText": {"text": k, "matchCase": "true"}, "replaceText": v}} ) - body = {"requests": requests} return client.batchUpdate(documentId=document_id, body=body).execute() @@ -55,3 +58,91 @@ def update(self, document_id: str, **kwargs): kwargs = {"{{" + k + "}}": v for k, v in kwargs.items()} client = get_service(self.configuration, "docs", "v1", self.scopes).documents() return replace_text(client, document_id, kwargs) + + def insert(self, document_id: str, request): + client = get_service(self.configuration, "docs", "v1", self.scopes).documents() + body = {"requests": request} + try: + response = client.batchUpdate(documentId=document_id, body=body).execute() + if "replies" in response and response["replies"]: + return True + + except HttpError as error: + log.exception(error) + return False + except Exception as e: + log.exception(e) + return False + + def get_table_details(self, document_id: str, header: str): + client = get_service(self.configuration, "docs", "v1", self.scopes).documents() + try: + document_content = ( + client.get(documentId=document_id).execute().get("body").get("content") + ) + start_index = 0 + end_index = 0 + header_index = 0 + past_header = False + header_section = False + table_exists = False + table_indices = [] + headingId = "" + for element in document_content: + if "paragraph" in element and "elements" in element["paragraph"]: + for item in element["paragraph"]["elements"]: + if "textRun" in item: + if item["textRun"]["content"].strip() == header: + header_index = element["endIndex"] + header_section = True + headingId = element["paragraph"].get("paragraphStyle")["headingId"] + + elif header_section: + # Gets the end index of any text below the header + if header_section and item["textRun"]["content"].strip(): + header_index = item["endIndex"] + # checking if we are past header in question + if ( + any( + "headingId" in style + for style in element["paragraph"]["paragraphStyle"] + for style in element["paragraph"].get("paragraphStyle", {}) + ) + and element["paragraph"].get("paragraphStyle")["headingId"] + != headingId + ): + past_header = True + header_section = False + break + # Checking for table under the header + elif header_section and "table" in element and not past_header: + table_exists = True + start_index = element["startIndex"] + end_index = element["endIndex"] + table = element["table"] + for row in table["tableRows"]: + for cell in row["tableCells"]: + table_indices.append(cell["content"][0]["startIndex"]) + return table_exists, start_index, end_index, table_indices + except HttpError as error: + log.exception(error) + return table_exists, header_index, -1, table_indices + except Exception as e: + log.exception(e) + return table_exists, header_index, -1, table_indices + return table_exists, header_index, -1, table_indices + + def delete_table(self, document_id: str, request): + try: + client = get_service(self.configuration, "docs", "v1", self.scopes).documents() + body = {"requests": request} + response = client.batchUpdate(documentId=document_id, body=body).execute() + if "replies" in response and response["replies"]: + return True + + except HttpError as error: + log.exception(error) + return False + except Exception as e: + log.exception(e) + return False diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index 27250703b5f2..aec662a4c029 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -679,21 +679,25 @@ def handle_timeline_added_event( incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) # we fetch the individual who sent the message - message_sender_email = get_user_email(client=client, user_id=message_sender_id) - individual = individual_service.get_by_email_and_project( - db_session=db_session, email=message_sender_email, project_id=incident.project.id - ) + # if user is not found, we default to "Unknown" + try: + message_sender_email = get_user_email(client=client, user_id=message_sender_id) + individual = individual_service.get_by_email_and_project( + db_session=db_session, email=message_sender_email, project_id=incident.project.id + ) + except Exception: + individual = None # we log the event event_service.log_incident_event( db_session=db_session, source=f"Slack message from {individual.name}", - description=f'"{message_text}," said {individual.name}', + description=message_text, incident_id=context["subject"].id, - individual_id=individual.id, + individual_id=individual.id if individual else None, started_at=message_ts_utc, type=EventType.imported_message, - owner=individual.name, + owner=individual.name if individual else None, ) @@ -1072,7 +1076,7 @@ def handle_add_timeline_submission_event( db_session=db_session, source=f"Slack message from {participant.individual.name}", started_at=event_dt_utc, - description=f'"{event_description}," said {participant.individual.name}', + description=event_description, incident_id=context["subject"].id, individual_id=participant.individual.id, type=EventType.imported_message, diff --git a/src/dispatch/report/flows.py b/src/dispatch/report/flows.py index 8fca3c5bfc5a..5844012c3474 100644 --- a/src/dispatch/report/flows.py +++ b/src/dispatch/report/flows.py @@ -68,6 +68,7 @@ def create_tactical_report( details={"conditions": conditions, "actions": actions, "needs": needs}, incident_id=incident_id, individual_id=participant.individual.id, + owner=participant.individual.name, ) # we send the tactical report to the conversation @@ -147,6 +148,7 @@ def create_executive_report( details={"current_status": current_status, "overview": overview, "next_steps": next_steps}, incident_id=incident_id, individual_id=participant.individual.id, + owner=participant.individual.name, ) # we create a new document for the executive report diff --git a/src/dispatch/static/dispatch/src/case/CaseStatusSelectGroup.vue b/src/dispatch/static/dispatch/src/case/CaseStatusSelectGroup.vue index 5859c3643bf6..f1b67632975c 100644 --- a/src/dispatch/static/dispatch/src/case/CaseStatusSelectGroup.vue +++ b/src/dispatch/static/dispatch/src/case/CaseStatusSelectGroup.vue @@ -5,13 +5,13 @@ Update Case Status Are you sure you want to change the case status from - {{ - modelValue.status - }} + + {{ modelValue.status }} + to - {{ - selectedStatus - }} + + {{ selectedStatus }} + ? @@ -27,9 +27,9 @@ Status Not Changed This case was moved to the status - {{ - selectedStatus - }} + + {{ selectedStatus }} + on