From f08a22b3972f6db1f5e718f1d3a2e96a5e78f6c2 Mon Sep 17 00:00:00 2001 From: Avery Date: Fri, 29 Sep 2023 12:02:32 -0700 Subject: [PATCH] Adds functionality to manually create missing incident and case resources. (#3791) * Refactors resource creation to a separate function outside of the background task. Adds checks for resource existence. * Adds a button to the resources tab to initiate a retry for creating any missing or unsuccessfully created incident resource(s) * Adds a button to the resources tab to initiate a retry for creating any missing or unsuccessfully created case management resource(s) * Fixes lint errors. * Update src/dispatch/static/dispatch/src/case/ResourcesTab.vue Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> * Plugin retrieval returns list of all enabled plugins instead of by individual queries. Adds additional indicators that resource creation is active * Fixes lint errors. * Changes in client-side incident updates. * Removes success toast after resource creation. * Sets resource creation as a background task. * Bump jsonpath-ng from 1.5.3 to 1.6.0 (#3787) Bumps [jsonpath-ng](https://github.com/h2non/jsonpath-ng) from 1.5.3 to 1.6.0. - [Release notes](https://github.com/h2non/jsonpath-ng/releases) - [Changelog](https://github.com/h2non/jsonpath-ng/blob/master/History.md) - [Commits](https://github.com/h2non/jsonpath-ng/compare/v1.5.3...v1.6.0) --- updated-dependencies: - dependency-name: jsonpath-ng dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump @playwright/test in /src/dispatch/static/dispatch (#3786) Bumps [@playwright/test](https://github.com/Microsoft/playwright) from 1.37.1 to 1.38.0. - [Release notes](https://github.com/Microsoft/playwright/releases) - [Commits](https://github.com/Microsoft/playwright/compare/v1.37.1...v1.38.0) --- updated-dependencies: - dependency-name: "@playwright/test" dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump google-api-python-client from 2.98.0 to 2.99.0 (#3784) Bumps [google-api-python-client](https://github.com/googleapis/google-api-python-client) from 2.98.0 to 2.99.0. - [Release notes](https://github.com/googleapis/google-api-python-client/releases) - [Changelog](https://github.com/googleapis/google-api-python-client/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/google-api-python-client/compare/v2.98.0...v2.99.0) --- updated-dependencies: - dependency-name: google-api-python-client dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump pdpyras from 5.1.1 to 5.1.2 (#3783) Bumps [pdpyras](https://github.com/PagerDuty/pdpyras) from 5.1.1 to 5.1.2. - [Release notes](https://github.com/PagerDuty/pdpyras/releases) - [Changelog](https://github.com/PagerDuty/pdpyras/blob/main/docs/changelog.html) - [Commits](https://github.com/PagerDuty/pdpyras/compare/v5.1.1...v5.1.2) --- updated-dependencies: - dependency-name: pdpyras dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump msal from 1.23.0 to 1.24.0 (#3782) Bumps [msal](https://github.com/AzureAD/microsoft-authentication-library-for-python) from 1.23.0 to 1.24.0. - [Release notes](https://github.com/AzureAD/microsoft-authentication-library-for-python/releases) - [Commits](https://github.com/AzureAD/microsoft-authentication-library-for-python/compare/1.23.0...1.24.0) --- updated-dependencies: - dependency-name: msal dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump sentry-sdk from 1.30.0 to 1.31.0 (#3781) Bumps [sentry-sdk](https://github.com/getsentry/sentry-python) from 1.30.0 to 1.31.0. - [Release notes](https://github.com/getsentry/sentry-python/releases) - [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/sentry-python/compare/1.30.0...1.31.0) --- updated-dependencies: - dependency-name: sentry-sdk dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Do not run playwright with safar (#3797) * Appends outstanding incident tasks in tactical reports (#3798) * Appends outstanding incident tasks to the end of user input in tactical reports. * Update src/dispatch/plugins/dispatch_slack/incident/interactive.py Co-authored-by: Marc Vilanova <39573146+mvilanova@users.noreply.github.com> * Update src/dispatch/static/dispatch/src/incident/store.js Co-authored-by: Marc Vilanova <39573146+mvilanova@users.noreply.github.com> --------- Co-authored-by: Marc Vilanova <39573146+mvilanova@users.noreply.github.com> * Rework the way slack api errors are propagated (to the caller) (#3789) Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> * Only use stable priority if set (#3803) Co-authored-by: David Whittaker * Bump pandas from 2.1.0 to 2.1.1 (#3801) Bumps [pandas](https://github.com/pandas-dev/pandas) from 2.1.0 to 2.1.1. - [Release notes](https://github.com/pandas-dev/pandas/releases) - [Commits](https://github.com/pandas-dev/pandas/compare/v2.1.0...v2.1.1) --- updated-dependencies: - dependency-name: pandas dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump faker from 19.6.1 to 19.6.2 (#3800) Bumps [faker](https://github.com/joke2k/faker) from 19.6.1 to 19.6.2. - [Release notes](https://github.com/joke2k/faker/releases) - [Changelog](https://github.com/joke2k/faker/blob/master/CHANGELOG.md) - [Commits](https://github.com/joke2k/faker/compare/v19.6.1...v19.6.2) --- updated-dependencies: - dependency-name: faker dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump numpy from 1.25.2 to 1.26.0 (#3794) Bumps [numpy](https://github.com/numpy/numpy) from 1.25.2 to 1.26.0. - [Release notes](https://github.com/numpy/numpy/releases) - [Changelog](https://github.com/numpy/numpy/blob/main/doc/RELEASE_WALKTHROUGH.rst) - [Commits](https://github.com/numpy/numpy/compare/v1.25.2...v1.26.0) --- updated-dependencies: - dependency-name: numpy dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> * Bump google-api-python-client from 2.99.0 to 2.100.0 (#3793) Bumps [google-api-python-client](https://github.com/googleapis/google-api-python-client) from 2.99.0 to 2.100.0. - [Release notes](https://github.com/googleapis/google-api-python-client/releases) - [Changelog](https://github.com/googleapis/google-api-python-client/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/google-api-python-client/compare/v2.99.0...v2.100.0) --- updated-dependencies: - dependency-name: google-api-python-client dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> * Bump ruff from 0.0.289 to 0.0.290 (#3792) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.0.289 to 0.0.290. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/BREAKING_CHANGES.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.0.289...v0.0.290) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> * Making experience textbox from oncall end-of-shift feedback optional (#3799) * Handles unexpected numbers of enabled plugins. * Adds front end polling for resource creation updates. Adds success notification. * Minor refactors for case and incident resource creation. * Bump google-api-python-client from 2.98.0 to 2.99.0 (#3784) Bumps [google-api-python-client](https://github.com/googleapis/google-api-python-client) from 2.98.0 to 2.99.0. - [Release notes](https://github.com/googleapis/google-api-python-client/releases) - [Changelog](https://github.com/googleapis/google-api-python-client/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/google-api-python-client/compare/v2.98.0...v2.99.0) --- updated-dependencies: - dependency-name: google-api-python-client dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump google-api-python-client from 2.100.0 to 2.101.0 (#3810) Bumps [google-api-python-client](https://github.com/googleapis/google-api-python-client) from 2.100.0 to 2.101.0. - [Release notes](https://github.com/googleapis/google-api-python-client/releases) - [Changelog](https://github.com/googleapis/google-api-python-client/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/google-api-python-client/compare/v2.100.0...v2.101.0) --- updated-dependencies: - dependency-name: google-api-python-client dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump openai from 0.28.0 to 0.28.1 (#3809) Bumps [openai](https://github.com/openai/openai-python) from 0.28.0 to 0.28.1. - [Release notes](https://github.com/openai/openai-python/releases) - [Commits](https://github.com/openai/openai-python/compare/v0.28.0...v0.28.1) --- updated-dependencies: - dependency-name: openai dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump ruff from 0.0.290 to 0.0.291 (#3808) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.0.290 to 0.0.291. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/BREAKING_CHANGES.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.0.290...v0.0.291) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump eslint from 8.49.0 to 8.50.0 in /src/dispatch/static/dispatch (#3807) Bumps [eslint](https://github.com/eslint/eslint) from 8.49.0 to 8.50.0. - [Release notes](https://github.com/eslint/eslint/releases) - [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md) - [Commits](https://github.com/eslint/eslint/compare/v8.49.0...v8.50.0) --- updated-dependencies: - dependency-name: eslint dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Revert "Making experience textbox from oncall end-of-shift feedback optional (#3799)" (#3814) This reverts commit db544b0251d21d5ad1f7d16f7068b5a50202ba4f. * Bump @playwright/test in /src/dispatch/static/dispatch (#3786) Bumps [@playwright/test](https://github.com/Microsoft/playwright) from 1.37.1 to 1.38.0. - [Release notes](https://github.com/Microsoft/playwright/releases) - [Commits](https://github.com/Microsoft/playwright/compare/v1.37.1...v1.38.0) --- updated-dependencies: - dependency-name: "@playwright/test" dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update src/dispatch/case/views.py Co-authored-by: Marc Vilanova <39573146+mvilanova@users.noreply.github.com> * Fixes lint error. * Bump @playwright/test in /src/dispatch/static/dispatch (#3786) Bumps [@playwright/test](https://github.com/Microsoft/playwright) from 1.37.1 to 1.38.0. - [Release notes](https://github.com/Microsoft/playwright/releases) - [Commits](https://github.com/Microsoft/playwright/compare/v1.37.1...v1.38.0) --- updated-dependencies: - dependency-name: "@playwright/test" dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Restores original package-lock file. * Removes irrelevant files. * Removes irrelevant files. * Refactors converstion creation flow. * Fixes linting errors. --------- Signed-off-by: dependabot[bot] Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Will Sheldon <114631109+wssheldon@users.noreply.github.com> Co-authored-by: Marc Vilanova <39573146+mvilanova@users.noreply.github.com> Co-authored-by: kevgliss Co-authored-by: David Whittaker --- src/dispatch/case/flows.py | 206 ++++++++---------- src/dispatch/case/views.py | 28 +++ src/dispatch/conversation/flows.py | 97 ++++++++- src/dispatch/incident/flows.py | 133 ++++++----- src/dispatch/incident/messaging.py | 8 + src/dispatch/incident/views.py | 20 ++ .../dispatch_slack/case/interactive.py | 4 +- .../static/dispatch/src/case/ResourcesTab.vue | 62 +++++- src/dispatch/static/dispatch/src/case/api.js | 4 + .../static/dispatch/src/case/store.js | 66 ++++++ .../dispatch/src/incident/ResourcesTab.vue | 66 +++++- .../static/dispatch/src/incident/api.js | 4 + .../static/dispatch/src/incident/store.js | 66 ++++++ 13 files changed, 581 insertions(+), 183 deletions(-) diff --git a/src/dispatch/case/flows.py b/src/dispatch/case/flows.py index 4f64d6ee717c..6c06cb88113d 100644 --- a/src/dispatch/case/flows.py +++ b/src/dispatch/case/flows.py @@ -6,8 +6,7 @@ from dispatch.case import service as case_service from dispatch.case.models import CaseRead -from dispatch.conversation import service as conversation_service -from dispatch.conversation.models import ConversationCreate +from dispatch.conversation import flows as conversation_flows from dispatch.database.core import SessionLocal from dispatch.decorators import background_task from dispatch.document import flows as document_flows @@ -154,26 +153,6 @@ def case_add_or_reactivate_participant_flow( return participant -def create_conversation(case: Case, conversation_target: str, db_session: SessionLocal): - """Create external communication conversation.""" - plugin = plugin_service.get_active_instance( - db_session=db_session, project_id=case.project.id, plugin_type="conversation" - ) - conversation = plugin.instance.create_threaded( - case=case, conversation_id=conversation_target, db_session=db_session - ) - conversation.update({"resource_type": plugin.plugin.slug, "resource_id": conversation["id"]}) - - event_service.log_case_event( - db_session=db_session, - source=plugin.plugin.title, - description="Case conversation created", - case_id=case.id, - ) - - return conversation - - def update_conversation(case: Case, db_session: SessionLocal): """Updates external communication conversation.""" plugin = plugin_service.get_active_instance( @@ -217,6 +196,7 @@ def case_new_create_flow( case_id=case.id, individual_participants=individual_participants, team_participants=team_participants, + conversation_target=conversation_target, ) if case.case_priority.page_assignee: @@ -241,67 +221,6 @@ def case_new_create_flow( else: log.warning("Case assignee not paged. No plugin of type oncall enabled.") - conversation_plugin = plugin_service.get_active_instance( - db_session=db_session, project_id=case.project.id, plugin_type="conversation" - ) - if conversation_plugin: - if not conversation_target: - conversation_target = case.case_type.conversation_target - if conversation_target: - try: - # TODO: Refactor conversation creation using conversation_flows module - conversation = create_conversation(case, conversation_target, db_session) - conversation_in = ConversationCreate( - resource_id=conversation["resource_id"], - resource_type=conversation["resource_type"], - weblink=conversation["weblink"], - thread_id=conversation["timestamp"], - channel_id=conversation["id"], - ) - case.conversation = conversation_service.create( - db_session=db_session, conversation_in=conversation_in - ) - - event_service.log_case_event( - db_session=db_session, - source="Dispatch Core App", - description="Conversation added to case", - case_id=case.id, - ) - # wait until all resources are created before adding suggested participants - individual_participants = [x.email for x, _ in individual_participants] - - for email in individual_participants: - # 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( - db_session=db_session, - user_email=email, - case_id=case.id, - add_to_conversation=False, - ) - # explicitly add the assignee to the conversation - all_participants = individual_participants + [case.assignee.individual.email] - conversation_plugin.instance.add_to_thread( - case.conversation.channel_id, - case.conversation.thread_id, - all_participants, - ) - event_service.log_case_event( - db_session=db_session, - source="Dispatch Core App", - description="Case participants added to conversation.", - case_id=case.id, - ) - except Exception as e: - event_service.log_case_event( - db_session=db_session, - source="Dispatch Core App", - description=f"Creation of case conversation failed. Reason: {e}", - case_id=case.id, - ) - log.exception(e) - db_session.add(case) db_session.commit() @@ -684,7 +603,12 @@ def case_assign_role_flow( def case_create_resources_flow( - db_session: Session, case_id: int, individual_participants: list, team_participants: list + db_session: Session, + case_id: int, + individual_participants: List[str], + team_participants: List[str], + conversation_target: str = None, + create_resources: bool = True, ) -> None: """Runs the case resource creation flow.""" case = get(db_session=db_session, case_id=case_id) @@ -692,43 +616,93 @@ def case_create_resources_flow( if case.assignee: individual_participants.append((case.assignee.individual, None)) - # we create the tactical group - direct_participant_emails = [i.email for i, _ in individual_participants] + if create_resources: + # we create the tactical group + direct_participant_emails = [i.email for i, _ in individual_participants] - indirect_participant_emails = [t.email for t in team_participants] + indirect_participant_emails = [t.email for t in team_participants] - group = group_flows.create_group( - subject=case, - group_type=GroupType.tactical, - group_participants=list(set(direct_participant_emails + indirect_participant_emails)), - db_session=db_session, - ) + if not case.groups: + group_flows.create_group( + subject=case, + group_type=GroupType.tactical, + group_participants=list( + set(direct_participant_emails + indirect_participant_emails) + ), + db_session=db_session, + ) - # we create the storage folder - storage_members = [] - if group: - storage_members = [group.email] + # we create the storage folder + storage_members = [] + if case.tactical_group: + storage_members = [case.tactical_group.email] + # direct add members if not group exists + else: + storage_members = direct_participant_emails - # direct add members if not group exists - else: - storage_members = direct_participant_emails + if not case.storage: + storage_flows.create_storage( + subject=case, storage_members=storage_members, db_session=db_session + ) - case.storage = storage_flows.create_storage( - subject=case, storage_members=storage_members, db_session=db_session - ) + # we create the investigation document + if not case.case_document: + document_flows.create_document( + subject=case, + document_type=DocumentResourceTypes.case, + document_template=case.case_type.case_template_document, + db_session=db_session, + ) - # we create the investigation document - document = document_flows.create_document( - subject=case, - document_type=DocumentResourceTypes.case, - document_template=case.case_type.case_template_document, - db_session=db_session, - ) + # we update the ticket + ticket_flows.update_case_ticket(case=case, db_session=db_session) - # we update the ticket - ticket_flows.update_case_ticket(case=case, db_session=db_session) + # we update the case document + document_flows.update_document( + document=case.case_document, project_id=case.project.id, db_session=db_session + ) - # we update the case document - document_flows.update_document( - document=document, project_id=case.project.id, db_session=db_session - ) + try: + # we create the conversation and add participants to the thread + conversation_flows.create_case_conversation(case, conversation_target, db_session) + + event_service.log_case_event( + db_session=db_session, + source="Dispatch Core App", + description="Conversation added to case", + case_id=case.id, + ) + # wait until all resources are created before adding suggested participants + individual_participants = [x.email for x, _ in individual_participants] + + for email in individual_participants: + # 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( + db_session=db_session, + user_email=email, + case_id=case.id, + add_to_conversation=False, + ) + # explicitly add the assignee to the conversation + all_participants = individual_participants + [case.assignee.individual.email] + + # # we add the participant to the conversation + conversation_flows.add_case_participants( + case=case, participant_emails=all_participants, db_session=db_session + ) + + event_service.log_case_event( + db_session=db_session, + source="Dispatch Core App", + description="Case participants added to conversation.", + case_id=case.id, + ) + except Exception as e: + event_service.log_case_event( + db_session=db_session, + source="Dispatch Core App", + description=f"Creation of case conversation failed. Reason: {e}", + case_id=case.id, + ) + log.exception(e) diff --git a/src/dispatch/case/views.py b/src/dispatch/case/views.py index 1d9e68f36e94..add6f8a5037a 100644 --- a/src/dispatch/case/views.py +++ b/src/dispatch/case/views.py @@ -34,6 +34,8 @@ case_new_create_flow, case_triage_create_flow, case_update_flow, + case_create_resources_flow, + get_case_participants, ) from .models import Case, CaseCreate, CasePagination, CaseRead, CaseUpdate, CaseExpandedPagination from .service import create, delete, get, update @@ -145,6 +147,32 @@ def create_case( return case +@router.post( + "/{case_id}/resources", + response_model=CaseRead, + summary="Creates resources for an existing case.", +) +def create_case_resources( + db_session: DbSession, + case_id: PrimaryKey, + current_case: CurrentCase, + background_tasks: BackgroundTasks, +): + """Creates resources for an existing case.""" + individual_participants, team_participants = get_case_participants( + case=current_case, db_session=db_session + ) + background_tasks.add_task( + case_create_resources_flow, + db_session=db_session, + case_id=case_id, + individual_participants=individual_participants, + team_participants=team_participants, + ) + + return current_case + + @router.put( "/{case_id}", response_model=CaseRead, diff --git a/src/dispatch/conversation/flows.py b/src/dispatch/conversation/flows.py index ab14fbf4b4a3..dadba50ff470 100644 --- a/src/dispatch/conversation/flows.py +++ b/src/dispatch/conversation/flows.py @@ -2,6 +2,7 @@ from typing import TypeVar, List +from dispatch.case.models import Case from dispatch.conference.models import Conference from dispatch.database.core import SessionLocal, resolve_attr from dispatch.document.models import Document @@ -22,7 +23,57 @@ Resource = TypeVar("Resource", Document, Conference, Storage, Ticket) -def create_conversation(incident: Incident, db_session: SessionLocal): +def create_case_conversation(case: Case, conversation_target: str, db_session: SessionLocal): + """Create external communication conversation.""" + + plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=case.project.id, plugin_type="conversation" + ) + if not plugin: + log.warning("Conversation not created. No conversation plugin enabled.") + return + + if not conversation_target: + conversation_target = case.case_type.conversation_target + + if conversation_target: + try: + conversation = plugin.instance.create_threaded( + case=case, conversation_id=conversation_target, db_session=db_session + ) + except Exception as e: + # TODO: consistency across exceptions + log.exception(e) + + if not conversation: + log.error(f"Conversation not created. Plugin {plugin.plugin.slug} encountered an error.") + return + + conversation.update({"resource_type": plugin.plugin.slug, "resource_id": conversation["id"]}) + + conversation_in = ConversationCreate( + resource_id=conversation["resource_id"], + resource_type=conversation["resource_type"], + weblink=conversation["weblink"], + thread_id=conversation["timestamp"], + channel_id=conversation["id"], + ) + case.conversation = create(db_session=db_session, conversation_in=conversation_in) + + event_service.log_case_event( + db_session=db_session, + source=plugin.plugin.title, + description="Case conversation created", + case_id=case.id, + ) + + db_session.add(case) + db_session.commit() + + return case.conversation + + +def create_incident_conversation(incident: Incident, db_session: SessionLocal): """Creates a conversation.""" plugin = plugin_service.get_active_instance( db_session=db_session, project_id=incident.project.id, plugin_type="conversation" @@ -59,8 +110,7 @@ def create_conversation(incident: Incident, db_session: SessionLocal): weblink=external_conversation["weblink"], channel_id=external_conversation["id"], ) - conversation = create(conversation_in=conversation_in, db_session=db_session) - incident.conversation = conversation + incident.conversation = create(conversation_in=conversation_in, db_session=db_session) db_session.add(incident) db_session.commit() @@ -72,7 +122,7 @@ def create_conversation(incident: Incident, db_session: SessionLocal): incident_id=incident.id, ) - return conversation + return incident.conversation def archive_conversation(incident: Incident, db_session: SessionLocal): @@ -262,8 +312,43 @@ def add_conversation_bookmarks(incident: Incident, db_session: SessionLocal): log.exception(e) -def add_participants(incident: Incident, participant_emails: List[str], db_session: SessionLocal): - """Adds one or more participants to the conversation.""" +def add_case_participants(case: Case, participant_emails: List[str], db_session: SessionLocal): + """Adds one or more participants to the case conversation.""" + if not case.conversation: + log.warning( + "Case participant(s) not added to conversation. No conversation available for this case." + ) + return + + plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=case.project.id, plugin_type="conversation" + ) + if not plugin: + log.warning( + "Case participant(s) not added to conversation. No conversation plugin enabled." + ) + return + + try: + plugin.instance.add_to_thread( + case.conversation.channel_id, + case.conversation.thread_id, + participant_emails, + ) + except Exception as e: + event_service.log_case_event( + db_session=db_session, + source="Dispatch Core App", + description=f"Adding participant(s) to case conversation failed. Reason: {e}", + case_id=case.id, + ) + log.exception(e) + + +def add_incident_participants( + incident: Incident, participant_emails: List[str], db_session: SessionLocal +): + """Adds one or more participants to the incident conversation.""" if not incident.conversation: log.warning( "Incident participant(s) not added to conversation. No conversation available for this incident." diff --git a/src/dispatch/incident/flows.py b/src/dispatch/incident/flows.py index 84f553c20456..4585f5f5d4d6 100644 --- a/src/dispatch/incident/flows.py +++ b/src/dispatch/incident/flows.py @@ -140,68 +140,74 @@ def inactivate_incident_participants(incident: Incident, db_session: Session): ) -@background_task -def incident_create_flow(*, organization_slug: str, incident_id: int, db_session=None) -> Incident: - """Creates all resources required for new incidents.""" - # we get the incident - incident = incident_service.get(db_session=db_session, incident_id=incident_id) - +def incident_create_resources(*, incident: Incident, db_session=None) -> Incident: + """Creates all resources required for incidents.""" # we create the incident ticket - ticket_flows.create_incident_ticket(incident=incident, db_session=db_session) + if not incident.ticket: + ticket_flows.create_incident_ticket(incident=incident, db_session=db_session) # we resolve individual and team participants individual_participants, team_participants = get_incident_participants(incident, db_session) + tactical_participant_emails = [i.email for i, _ in individual_participants] # we create the tactical group - tactical_participant_emails = [i.email for i, _ in individual_participants] - tactical_group = group_flows.create_group( - subject=incident, - group_type=GroupType.tactical, - group_participants=tactical_participant_emails, - db_session=db_session, - ) + if not incident.tactical_group: + group_flows.create_group( + subject=incident, + group_type=GroupType.tactical, + group_participants=tactical_participant_emails, + db_session=db_session, + ) # we create the notifications group - notification_participant_emails = [t.email for t in team_participants] - notifications_group = group_flows.create_group( - subject=incident, - group_type=GroupType.notifications, - group_participants=notification_participant_emails, - db_session=db_session, - ) + if not incident.notifications_group: + notification_participant_emails = [t.email for t in team_participants] + group_flows.create_group( + subject=incident, + group_type=GroupType.notifications, + group_participants=notification_participant_emails, + db_session=db_session, + ) # we create the storage folder - storage_members = [] - if tactical_group and notifications_group: - storage_members = [tactical_group.email, notifications_group.email] - else: - storage_members = tactical_participant_emails + if not incident.storage: + storage_members = [] + if incident.tactical_group and incident.notifications_group: + storage_members = [incident.tactical_group.email, incident.notifications_group.email] + else: + storage_members = tactical_participant_emails - storage_flows.create_storage( - subject=incident, storage_members=storage_members, db_session=db_session - ) + storage_flows.create_storage( + subject=incident, storage_members=storage_members, db_session=db_session + ) # we create the incident document - document_flows.create_document( - subject=incident, - document_type=DocumentResourceTypes.incident, - document_template=incident.incident_type.incident_template_document, - db_session=db_session, - ) + if not incident.incident_document: + document_flows.create_document( + subject=incident, + document_type=DocumentResourceTypes.incident, + document_template=incident.incident_type.incident_template_document, + db_session=db_session, + ) # we create the conference room - conference_participants = [] - if tactical_group and notifications_group: - conference_participants = [tactical_group.email, notifications_group.email] - else: - conference_participants = tactical_participant_emails + if not incident.conference: + conference_participants = [] + if incident.tactical_group and incident.notifications_group: + conference_participants = [ + incident.tactical_group.email, + incident.notifications_group.email, + ] + else: + conference_participants = tactical_participant_emails - conference_flows.create_conference( - incident=incident, participants=conference_participants, db_session=db_session - ) + conference_flows.create_conference( + incident=incident, participants=conference_participants, db_session=db_session + ) # we create the conversation - conversation_flows.create_conversation(incident=incident, db_session=db_session) + if not incident.conversation: + conversation_flows.create_incident_conversation(incident=incident, db_session=db_session) # we update the incident ticket ticket_flows.update_incident_ticket(incident_id=incident.id, db_session=db_session) @@ -238,7 +244,7 @@ def incident_create_flow(*, organization_slug: str, incident_id: int, db_session ) # we add the participant to the conversation - conversation_flows.add_participants( + conversation_flows.add_incident_participants( incident=incident, participant_emails=[user_email], db_session=db_session ) @@ -272,14 +278,31 @@ def incident_create_flow(*, organization_slug: str, incident_id: int, db_session incident_id=incident.id, ) - send_incident_created_notifications(incident, db_session) + return incident - event_service.log_incident_event( - db_session=db_session, - source="Dispatch Core App", - description="Incident notifications sent", - incident_id=incident.id, - ) + +@background_task +def incident_create_resources_flow( + *, organization_slug: str, incident_id: int, db_session=None +) -> Incident: + """Creates all resources required for an existing incident.""" + # we get the incident + incident = incident_service.get(db_session=db_session, incident_id=incident_id) + + # we create the incident resources + return incident_create_resources(incident=incident, db_session=db_session) + + +@background_task +def incident_create_flow(*, organization_slug: str, incident_id: int, db_session=None) -> Incident: + """Creates all resources required for new incidents and initiates incident response workflow.""" + # we get the incident + incident = incident_service.get(db_session=db_session, incident_id=incident_id) + + # we create the incident resources + incident_create_resources(incident=incident, db_session=db_session) + + send_incident_created_notifications(incident, db_session) # we page the incident commander based on incident priority if incident.incident_priority.page_commander: @@ -902,7 +925,7 @@ def incident_add_or_reactivate_participant_flow( if incident.status != IncidentStatus.closed: # we add the participant to the conversation - conversation_flows.add_participants( + conversation_flows.add_incident_participants( incident=incident, participant_emails=[user_email], db_session=db_session ) @@ -942,7 +965,7 @@ def incident_remove_participant_flow( for assignee in task.assignees: if assignee == participant: # we add the participant to the conversation - conversation_flows.add_participants( + conversation_flows.add_incident_participants( incident=incident, participant_emails=[user_email], db_session=db_session ) @@ -954,7 +977,7 @@ def incident_remove_participant_flow( if user_email == incident.commander.individual.email: # we add the participant to the conversation - conversation_flows.add_participants( + conversation_flows.add_incident_participants( incident=incident, participant_emails=[user_email], db_session=db_session ) diff --git a/src/dispatch/incident/messaging.py b/src/dispatch/incident/messaging.py index 1c6f1d4b2575..994ae45d4564 100644 --- a/src/dispatch/incident/messaging.py +++ b/src/dispatch/incident/messaging.py @@ -11,6 +11,7 @@ from dispatch.conversation.enums import ConversationCommands from dispatch.database.core import SessionLocal, resolve_attr from dispatch.document import service as document_service +from dispatch.event import service as event_service from dispatch.incident.enums import IncidentStatus from dispatch.incident.models import Incident, IncidentRead from dispatch.notification import service as notification_service @@ -347,6 +348,13 @@ def send_incident_created_notifications(incident: Incident, db_session: SessionL notification_params=notification_params, ) + event_service.log_incident_event( + db_session=db_session, + source="Dispatch Core App", + description="Incident notifications sent", + incident_id=incident.id, + ) + log.debug("Incident created notifications sent.") diff --git a/src/dispatch/incident/views.py b/src/dispatch/incident/views.py index 031909017f47..f8c740a70230 100644 --- a/src/dispatch/incident/views.py +++ b/src/dispatch/incident/views.py @@ -36,6 +36,7 @@ incident_delete_flow, incident_subscribe_participant_flow, incident_update_flow, + incident_create_resources_flow, ) from .metrics import create_incident_metric_query, make_forecast from .models import ( @@ -139,6 +140,25 @@ def create_incident( return incident +@router.post( + "/{incident_id}/resources", + response_model=IncidentRead, + summary="Creates resources for an existing incident.", +) +def create_incident_resources( + organization: OrganizationSlug, + incident_id: PrimaryKey, + current_incident: CurrentIncident, + background_tasks: BackgroundTasks, +): + """Creates resources for an existing incident.""" + background_tasks.add_task( + incident_create_resources_flow, organization_slug=organization, incident_id=incident_id + ) + + return current_incident + + @router.put( "/{incident_id}", response_model=IncidentRead, diff --git a/src/dispatch/plugins/dispatch_slack/case/interactive.py b/src/dispatch/plugins/dispatch_slack/case/interactive.py index e4797c36d368..a49ad567497b 100644 --- a/src/dispatch/plugins/dispatch_slack/case/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/case/interactive.py @@ -1032,7 +1032,7 @@ def handle_escalation_submission_event( case=case, organization_slug=context["subject"].organization_slug, db_session=db_session ) - conversation_flows.add_participants( + conversation_flows.add_incident_participants( incident=incident, participant_emails=[user.email], db_session=db_session ) @@ -1082,7 +1082,7 @@ def join_incident_button_click( case = case_service.get(db_session=db_session, case_id=context["subject"].id) # we add the user to the incident conversation - conversation_flows.add_participants( + conversation_flows.add_incident_participants( # TODO: handle case where there are multiple related incidents incident=case.incidents[0], participant_emails=[user.email], diff --git a/src/dispatch/static/dispatch/src/case/ResourcesTab.vue b/src/dispatch/static/dispatch/src/case/ResourcesTab.vue index ad7403449c82..a9eb2b5d5df4 100644 --- a/src/dispatch/static/dispatch/src/case/ResourcesTab.vue +++ b/src/dispatch/static/dispatch/src/case/ResourcesTab.vue @@ -65,23 +65,83 @@ + + + + Recreate Missing Resources + Initiate a retry for creating any missing or unsuccesfully created + resource(s). + + + + refresh + + + + + + Creating resources... + Initiate a retry for creating any missing or unsuccesfully created + resource(s). + + + diff --git a/src/dispatch/static/dispatch/src/case/api.js b/src/dispatch/static/dispatch/src/case/api.js index 8a3b0d2abb61..2fc1a01f27e3 100644 --- a/src/dispatch/static/dispatch/src/case/api.js +++ b/src/dispatch/static/dispatch/src/case/api.js @@ -44,4 +44,8 @@ export default { delete(caseId) { return API.delete(`/${resource}/${caseId}`) }, + + createAllResources(caseId, payload) { + return API.post(`/${resource}/${caseId}/resources`, payload) + }, } diff --git a/src/dispatch/static/dispatch/src/case/store.js b/src/dispatch/static/dispatch/src/case/store.js index bbe3ec901dac..6eda8cc9b416 100644 --- a/src/dispatch/static/dispatch/src/case/store.js +++ b/src/dispatch/static/dispatch/src/case/store.js @@ -3,6 +3,7 @@ import { debounce } from "lodash" import SearchUtils from "@/search/utils" import CaseApi from "@/case/api" +import PluginApi from "@/plugin/api" import router from "@/router" const getDefaultSelectedState = () => { @@ -253,6 +254,40 @@ const actions = { commit("SET_SELECTED_LOADING", false) }) }, + createAllResources({ commit, dispatch }) { + commit("SET_SELECTED_LOADING", true) + return CaseApi.createAllResources(state.selected.id) + .then(() => { + CaseApi.get(state.selected.id).then((response) => { + commit("SET_SELECTED", response.data) + dispatch("getEnabledPlugins").then((enabledPlugins) => { + // Poll the server for resource creation updates. + var interval = setInterval(function () { + if ( + state.selected.conversation ^ enabledPlugins.includes("conversation") || + state.selected.documents ^ enabledPlugins.includes("document") || + state.selected.storage ^ enabledPlugins.includes("storage") || + state.selected.groups ^ enabledPlugins.includes("participant-group") || + state.selected.ticket ^ enabledPlugins.includes("ticket") + ) { + dispatch("get").then(() => { + clearInterval(interval) + commit("SET_SELECTED_LOADING", false) + commit( + "notification_backend/addBeNotification", + { text: "Resources(s) created successfully.", type: "success" }, + { root: true } + ) + }) + } + }, 5000) + }) + }) + }) + .catch(() => { + commit("SET_SELECTED_LOADING", false) + }) + }, save({ commit, dispatch }) { commit("SET_SELECTED_LOADING", true) if (!state.selected.id) { @@ -330,6 +365,37 @@ const actions = { ) }) }, + getEnabledPlugins() { + if (!state.selected.project) { + return false + } + return PluginApi.getAllInstances({ + filter: JSON.stringify({ + and: [ + { + model: "PluginInstance", + field: "enabled", + op: "==", + value: "true", + }, + { + model: "Project", + field: "name", + op: "==", + value: state.selected.project.name, + }, + ], + }), + itemsPerPage: 50, + }).then((response) => { + return response.data.items.reduce((result, item) => { + if (item.plugin) { + result.push(item.plugin.type) + } + return result + }, []) + }) + }, } const mutations = { diff --git a/src/dispatch/static/dispatch/src/incident/ResourcesTab.vue b/src/dispatch/static/dispatch/src/incident/ResourcesTab.vue index 9c1a3d2c9f15..b478fb9ecb6e 100644 --- a/src/dispatch/static/dispatch/src/incident/ResourcesTab.vue +++ b/src/dispatch/static/dispatch/src/incident/ResourcesTab.vue @@ -62,23 +62,83 @@ + + + + Recreate Missing Resources + Initiate a retry for creating any missing or unsuccesfully created + resource(s). + + + + refresh + + + + + + Creating resources... + Initiate a retry for creating any missing or unsuccesfully created + resource(s). + + + diff --git a/src/dispatch/static/dispatch/src/incident/api.js b/src/dispatch/static/dispatch/src/incident/api.js index 0516aaa84cdf..1f77a9053460 100644 --- a/src/dispatch/static/dispatch/src/incident/api.js +++ b/src/dispatch/static/dispatch/src/incident/api.js @@ -58,4 +58,8 @@ export default { createReport(incidentId, type, payload) { return API.post(`/${resource}/${incidentId}/report/${type}`, payload) }, + + createAllResources(incidentId, payload) { + return API.post(`/${resource}/${incidentId}/resources`, payload) + }, } diff --git a/src/dispatch/static/dispatch/src/incident/store.js b/src/dispatch/static/dispatch/src/incident/store.js index bf57d004f858..f43befd9717d 100644 --- a/src/dispatch/static/dispatch/src/incident/store.js +++ b/src/dispatch/static/dispatch/src/incident/store.js @@ -3,6 +3,7 @@ import { debounce } from "lodash" import SearchUtils from "@/search/utils" import IncidentApi from "@/incident/api" +import PluginApi from "@/plugin/api" import router from "@/router" const getDefaultSelectedState = () => { @@ -347,6 +348,40 @@ const actions = { ) }) }, + createAllResources({ commit, dispatch }) { + commit("SET_SELECTED_LOADING", true) + return IncidentApi.createAllResources(state.selected.id) + .then(() => { + IncidentApi.get(state.selected.id).then((response) => { + commit("SET_SELECTED", response.data) + dispatch("getEnabledPlugins").then((enabledPlugins) => { + // Poll the server for resource creation updates. + var interval = setInterval(function () { + if ( + state.selected.conversation ^ enabledPlugins.includes("conversation") || + state.selected.documents ^ enabledPlugins.includes("document") || + state.selected.storage ^ enabledPlugins.includes("storage") || + state.selected.conference ^ enabledPlugins.includes("conference") || + state.selected.ticket ^ enabledPlugins.includes("ticket") + ) { + dispatch("get").then(() => { + clearInterval(interval) + commit("SET_SELECTED_LOADING", false) + commit( + "notification_backend/addBeNotification", + { text: "Resources(s) created successfully.", type: "success" }, + { root: true } + ) + }) + } + }, 5000) + }) + }) + }) + .catch(() => { + commit("SET_SELECTED_LOADING", false) + }) + }, resetSelected({ commit }) { commit("RESET_SELECTED") }, @@ -371,6 +406,37 @@ const actions = { ) }) }, + getEnabledPlugins() { + if (!state.selected.project) { + return false + } + return PluginApi.getAllInstances({ + filter: JSON.stringify({ + and: [ + { + model: "PluginInstance", + field: "enabled", + op: "==", + value: "true", + }, + { + model: "Project", + field: "name", + op: "==", + value: state.selected.project.name, + }, + ], + }), + itemsPerPage: 50, + }).then((response) => { + return response.data.items.reduce((result, item) => { + if (item.plugin) { + result.push(item.plugin.type) + } + return result + }, []) + }) + }, } const mutations = {