diff --git a/src/dispatch/auth/permissions.py b/src/dispatch/auth/permissions.py index 9b3f5e1d0d23..b06ad6af5f1b 100644 --- a/src/dispatch/auth/permissions.py +++ b/src/dispatch/auth/permissions.py @@ -15,6 +15,7 @@ from dispatch.models import PrimaryKeyModel from dispatch.organization import service as organization_service from dispatch.organization.models import OrganizationRead +from dispatch.participant_role.enums import ParticipantRoleType log = logging.getLogger(__name__) @@ -315,6 +316,8 @@ def has_required_permissions( if current_incident.reporter.individual.email == current_user.email: return True + return False + class IncidentCommanderPermission(BasePermission): def has_required_permissions( @@ -331,8 +334,10 @@ def has_required_permissions( if current_incident.commander.individual.email == current_user.email: return True + return False + -class IncidentCommanderOrReporterPermission(BasePermission): +class IncidentCommanderOrScribePermission(BasePermission): def has_required_permissions( self, request: Request, @@ -343,13 +348,18 @@ def has_required_permissions( if not current_incident: return False - if current_incident.commander: - if current_incident.commander.individual.email == current_user.email: - return True + if current_incident.commander and current_incident.commander.individual.email == current_user.email: + return True - if current_incident.reporter: - if current_incident.reporter.individual.email == current_user.email: - return True + scribes = [ + participant.individual.email + for participant in current_incident.participants + if ParticipantRoleType.scribe in [role.role for role in participant.participant_roles] + ] + if current_user.email in scribes: + return True + + return False class IncidentParticipantPermission(BasePermission): diff --git a/src/dispatch/event/flows.py b/src/dispatch/event/flows.py new file mode 100644 index 000000000000..f6123404c0f8 --- /dev/null +++ b/src/dispatch/event/flows.py @@ -0,0 +1,54 @@ +import logging + +from dispatch.decorators import background_task +from dispatch.event import service as event_service +from dispatch.incident import service as incident_service +from dispatch.individual import service as individual_service +from dispatch.event.models import EventUpdate, EventCreateMinimal + +log = logging.getLogger(__name__) + + +@background_task +def log_incident_event( + user_email: str, + incident_id: int, + event_in: EventCreateMinimal, + db_session=None, + organization_slug: str = None, +): + incident = incident_service.get(db_session=db_session, incident_id=incident_id) + individual = individual_service.get_by_email_and_project( + db_session=db_session, email=user_email, project_id=incident.project.id + ) + event_in.source = f"Custom event created by {individual.name}" + + event_service.log_incident_event( + db_session=db_session, + incident_id=incident_id, + **event_in.__dict__, + ) + + +@background_task +def update_incident_event( + event_in: EventUpdate, + db_session=None, + organization_slug: str = None, +): + event_service.update_incident_event( + db_session=db_session, + event_in=event_in, + ) + + +@background_task +def delete_incident_event( + event_uuid: str, + db_session=None, + organization_slug: str = None, +): + event_service.delete_incident_event( + db_session=db_session, + uuid=event_uuid, + ) diff --git a/src/dispatch/event/models.py b/src/dispatch/event/models.py index da61aeb201f3..f8d3c1b38c5a 100644 --- a/src/dispatch/event/models.py +++ b/src/dispatch/event/models.py @@ -60,3 +60,11 @@ class EventUpdate(EventBase): class EventRead(EventBase): pass + + +class EventCreateMinimal(DispatchBase): + started_at: datetime + source: str + description: str + details: dict + type: Optional[str] diff --git a/src/dispatch/event/service.py b/src/dispatch/event/service.py index 5d729b1e9078..d80a1152add4 100644 --- a/src/dispatch/event/service.py +++ b/src/dispatch/event/service.py @@ -20,22 +20,22 @@ def get(*, db_session, event_id: int) -> Optional[Event]: return db_session.query(Event).filter(Event.id == event_id).one_or_none() -def get_by_case_id(*, db_session, case_id: int) -> List[Optional[Event]]: +def get_by_case_id(*, db_session, case_id: int) -> list[Event | None]: """Get events by case id.""" return db_session.query(Event).filter(Event.case_id == case_id) -def get_by_incident_id(*, db_session, incident_id: int) -> List[Optional[Event]]: +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) -def get_by_uuid(*, db_session, uuid: str) -> List[Optional[Event]]: +def get_by_uuid(*, db_session, uuid: str) -> list[Event | None]: """Get events by uuid.""" return db_session.query(Event).filter(Event.uuid == uuid).one_or_none() -def get_all(*, db_session) -> List[Optional[Event]]: +def get_all(*, db_session) -> list[Event | None]: """Get all events.""" return db_session.query(Event) @@ -161,29 +161,10 @@ def log_case_event( def update_incident_event( db_session, - uuid: str, - source: str, - description: str, - started_at: datetime = None, - ended_at: datetime = None, - details: dict = None, - type: str = EventType.other, + event_in: EventUpdate, ) -> Event: """Updates an event in the incident timeline.""" - event = get_by_uuid(db_session=db_session, uuid=uuid) - if not ended_at: - ended_at = started_at - - event_in = EventUpdate( - uuid=uuid, - started_at=started_at, - ended_at=ended_at, - source=source, - details=details, - description=description, - type=type, - ) - + 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 diff --git a/src/dispatch/incident/views.py b/src/dispatch/incident/views.py index 37ff3434497a..caa9acb430f6 100644 --- a/src/dispatch/incident/views.py +++ b/src/dispatch/incident/views.py @@ -13,7 +13,7 @@ from dispatch.auth.permissions import ( IncidentEditPermission, - IncidentCommanderOrReporterPermission, + IncidentCommanderOrScribePermission, IncidentJoinOrSubscribePermission, IncidentViewPermission, PermissionsDependency, @@ -27,7 +27,9 @@ from dispatch.models import OrganizationSlug, PrimaryKey from dispatch.participant.models import ParticipantUpdate from dispatch.report import flows as report_flows +from dispatch.event import flows as event_flows from dispatch.report.models import ExecutiveReportCreate, TacticalReportCreate +from dispatch.event.models import EventUpdate, EventCreateMinimal from .flows import ( incident_add_or_reactivate_participant_flow, @@ -311,16 +313,16 @@ def create_custom_event( organization: OrganizationSlug, incident_id: PrimaryKey, current_incident: CurrentIncident, - event_in: dict, + event_in: EventCreateMinimal, current_user: CurrentUser, background_tasks: BackgroundTasks, ): - event_in.update( - {"details": {"created_by": current_user.email, "added_on": str(datetime.utcnow())}} + event_in.details.update( + {"created_by": current_user.email, "added_on": str(datetime.utcnow())} ) """Creates a custom event.""" background_tasks.add_task( - report_flows.log_incident_event, + event_flows.log_incident_event, user_email=current_user.email, incident_id=current_incident.id, event_in=event_in, @@ -331,34 +333,32 @@ def create_custom_event( @router.patch( "/{incident_id}/event", summary="Updates a custom event.", - dependencies=[Depends(PermissionsDependency([IncidentCommanderOrReporterPermission]))], + dependencies=[Depends(PermissionsDependency([IncidentCommanderOrScribePermission]))], ) def update_custom_event( db_session: DbSession, organization: OrganizationSlug, incident_id: PrimaryKey, current_incident: CurrentIncident, - event_in: dict, + event_in: EventUpdate, current_user: CurrentUser, background_tasks: BackgroundTasks, ): - if event_in.get("details"): - event_in.update( + if event_in.details: + event_in.details.update( { - "details": { - **event_in["details"], - "updated_by": current_user.email, - "updated_on": str(datetime.utcnow()), - } + **event_in.details, + "updated_by": current_user.email, + "updated_on": str(datetime.utcnow()), } ) else: - event_in.update( - {"details": {"updated_by": current_user.email, "updated_on": str(datetime.utcnow())}} + event_in.details.update( + {"updated_by": current_user.email, "updated_on": str(datetime.utcnow())} ) """Updates a custom event.""" background_tasks.add_task( - report_flows.update_incident_event, + event_flows.update_incident_event, event_in=event_in, organization_slug=organization, ) @@ -367,7 +367,7 @@ def update_custom_event( @router.delete( "/{incident_id}/event/{event_uuid}", summary="Deletes a custom event.", - dependencies=[Depends(PermissionsDependency([IncidentCommanderOrReporterPermission]))], + dependencies=[Depends(PermissionsDependency([IncidentCommanderOrScribePermission]))], ) def delete_custom_event( db_session: DbSession, @@ -380,7 +380,7 @@ def delete_custom_event( ): """Deletes a custom event.""" background_tasks.add_task( - report_flows.delete_incident_event, + event_flows.delete_incident_event, event_uuid=event_uuid, organization_slug=organization, ) diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index c47af6a68f54..b9e0c26d383d 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -691,7 +691,7 @@ def handle_timeline_added_event( incident_id=context["subject"].id, individual_id=individual.id, started_at=message_ts_utc, - type=EventType.custom_event, + type=EventType.imported_message, ) @@ -1048,7 +1048,7 @@ def handle_add_timeline_submission_event( description=f'"{event_description}," said {participant.individual.name}', incident_id=context["subject"].id, individual_id=participant.individual.id, - type=EventType.custom_event, + type=EventType.imported_message, ) send_success_modal( diff --git a/src/dispatch/report/flows.py b/src/dispatch/report/flows.py index b724fba50eca..8fca3c5bfc5a 100644 --- a/src/dispatch/report/flows.py +++ b/src/dispatch/report/flows.py @@ -12,7 +12,6 @@ from dispatch.exceptions import InvalidConfigurationError from dispatch.incident import service as incident_service from dispatch.participant import service as participant_service -from dispatch.individual import service as individual_service from dispatch.plugin import service as plugin_service from .enums import ReportTypes @@ -80,60 +79,6 @@ def create_tactical_report( return tactical_report -@background_task -def log_incident_event( - user_email: str, - incident_id: int, - event_in: dict, - organization_slug: str = None, - db_session=None, -): - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - individual = individual_service.get_by_email_and_project( - db_session=db_session, email=user_email, project_id=incident.project.id - ) - event_in["source"] = f"Custom event created by {individual.name}" - - event_service.log_incident_event( - db_session=db_session, - source=event_in["source"], - description=event_in["description"], - incident_id=incident_id, - started_at=event_in["started_at"], - details=event_in["details"], - type=event_in["type"], - ) - - -@background_task -def update_incident_event( - event_in: dict, - organization_slug: str = None, - db_session=None, -): - event_service.update_incident_event( - db_session=db_session, - uuid=event_in["uuid"], - source=event_in["source"], - description=event_in["description"], - started_at=event_in["started_at"], - details=event_in["details"], - type=event_in["type"], - ) - - -@background_task -def delete_incident_event( - event_uuid: str, - organization_slug: str = None, - db_session=None, -): - event_service.delete_incident_event( - db_session=db_session, - uuid=event_uuid, - ) - - @background_task def create_executive_report( user_email: str, diff --git a/src/dispatch/static/dispatch/src/incident/TimelineTab.vue b/src/dispatch/static/dispatch/src/incident/TimelineTab.vue index 667db8e6f0dc..0158c6fea5e2 100644 --- a/src/dispatch/static/dispatch/src/incident/TimelineTab.vue +++ b/src/dispatch/static/dispatch/src/incident/TimelineTab.vue @@ -41,7 +41,7 @@ :icon="iconItem(event)" :key="event.id" class="mb-4" - :class="event.type == 'Custom event' ? 'custom-event' : null" + :class="isEditable(event) ? 'custom-event' : null" color="blue" > @@ -61,15 +61,28 @@ {{ event.source }} - - - - {{ event.started_at | formatToTimeZones }} - + +
+ + mdi-pencil + +
+ + mdi-trash-can + +
+
+ + + + + {{ event.started_at | formatToTimeZones }} + +
@@ -88,12 +101,6 @@ -
- mdi-pencil - - mdi-trash-can - -
@@ -121,6 +128,7 @@ const eventTypeToIcon = { "Assessment updated": "mdi-priority-high", "Participant updated": "mdi-account-outline", "Custom event": "mdi-text-account", + "Imported message": "mdi-page-next-outline", } const eventTypeToFilter = { @@ -129,6 +137,7 @@ const eventTypeToFilter = { "Assessment updated": "assessment_updates", "Participant updated": "participant_updates", "Custom event": "user_curated_events", + "Imported message": "user_curated_events", } export default { @@ -183,6 +192,9 @@ export default { }) ) }, + isEditable(event) { + return event.type == "Custom event" || event.type == "Imported message" + }, }, } diff --git a/src/dispatch/static/dispatch/src/incident/store.js b/src/dispatch/static/dispatch/src/incident/store.js index f14b07a21d3f..ef4980f4e734 100644 --- a/src/dispatch/static/dispatch/src/incident/store.js +++ b/src/dispatch/static/dispatch/src/incident/store.js @@ -276,6 +276,7 @@ const actions = { description: state.selected.currentEvent.description, started_at: state.selected.currentEvent.started_at, type: "Custom event", + details: {}, }).then(() => { IncidentApi.get(state.selected.id).then((response) => { commit("SET_SELECTED", response.data) diff --git a/src/dispatch/static/dispatch/src/styles/timeline.css b/src/dispatch/static/dispatch/src/styles/timeline.css index bae69f4831a1..02e662847b3c 100644 --- a/src/dispatch/static/dispatch/src/styles/timeline.css +++ b/src/dispatch/static/dispatch/src/styles/timeline.css @@ -58,27 +58,17 @@ display: block; } -.custom-event:hover { - background: linear-gradient( - to top, - transparent, - transparent 1.5rem, - var(--v-borderline-base) 1rem, - var(--v-borderline-base) 100% - ); -} .custom-event-edit { display: none; - - } .custom-event:hover .custom-event-edit { display: block; + z-index: 100; position: absolute; - top: 50%; - left: 42%; - -ms-transform: translateY(-50%); - transform: translateY(-50%); - z-index: 99; + margin-top: -12px; +} + +.not-a-custom-event { + height: 20px; }