From 5760562a654042b230d302c9e8de144c70f4b759 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 Sep 2023 11:06:02 -0700 Subject: [PATCH 01/11] bump fastapi from 0.103.1 to 0.103.2 (#3821) --- updated-dependencies: - dependency-name: fastapi 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> --- requirements-base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-base.txt b/requirements-base.txt index b174b8920ee9..267228cd1451 100644 --- a/requirements-base.txt +++ b/requirements-base.txt @@ -114,7 +114,7 @@ email-validator==2.0.0.post2 # via -r requirements-base.in emails==0.6 # via -r requirements-base.in -fastapi==0.103.1 +fastapi==0.103.2 # via -r requirements-base.in frozenlist==1.4.0 # via From 0f0970501f2e2580434c86c44a0ffb4f7c8c9513 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 Sep 2023 11:06:10 -0700 Subject: [PATCH 02/11] Bump msal from 1.24.0 to 1.24.1 (#3820) Bumps [msal](https://github.com/AzureAD/microsoft-authentication-library-for-python) from 1.24.0 to 1.24.1. - [Release notes](https://github.com/AzureAD/microsoft-authentication-library-for-python/releases) - [Commits](https://github.com/AzureAD/microsoft-authentication-library-for-python/compare/1.24.0...1.24.1) --- updated-dependencies: - dependency-name: msal 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> --- requirements-base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-base.txt b/requirements-base.txt index 267228cd1451..b29277cd7662 100644 --- a/requirements-base.txt +++ b/requirements-base.txt @@ -207,7 +207,7 @@ markupsafe==2.1.3 # jinja2 # mako # werkzeug -msal==1.24.0 +msal==1.24.1 # via -r requirements-base.in multidict==6.0.4 # via From 6b00b0f22939e05e744d52ff3f53c37dbcafca46 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 Sep 2023 11:06:19 -0700 Subject: [PATCH 03/11] Bump ipython from 8.15.0 to 8.16.0 (#3819) Bumps [ipython](https://github.com/ipython/ipython) from 8.15.0 to 8.16.0. - [Release notes](https://github.com/ipython/ipython/releases) - [Commits](https://github.com/ipython/ipython/compare/8.15.0...8.16.0) --- updated-dependencies: - dependency-name: ipython 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> --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index c31768d3beee..c8d639b2ce32 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -44,7 +44,7 @@ identify==2.5.27 # via pre-commit iniconfig==2.0.0 # via pytest -ipython==8.15.0 +ipython==8.16.0 # via -r requirements-dev.in jedi==0.19.0 # via ipython From 0a59fa49c4d0bbf1c8d8a2776e4079617470c91f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 Sep 2023 11:06:27 -0700 Subject: [PATCH 04/11] Bump psycopg2-binary from 2.9.7 to 2.9.8 (#3816) Bumps [psycopg2-binary](https://github.com/psycopg/psycopg2) from 2.9.7 to 2.9.8. - [Changelog](https://github.com/psycopg/psycopg2/blob/master/NEWS) - [Commits](https://github.com/psycopg/psycopg2/compare/2.9.7...2.9.8) --- updated-dependencies: - dependency-name: psycopg2-binary 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> --- requirements-base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-base.txt b/requirements-base.txt index b29277cd7662..f3e2dc86c687 100644 --- a/requirements-base.txt +++ b/requirements-base.txt @@ -272,7 +272,7 @@ protobuf==4.24.3 # -r requirements-base.in # google-api-core # googleapis-common-protos -psycopg2-binary==2.9.7 +psycopg2-binary==2.9.8 # via -r requirements-base.in pyasn1==0.5.0 # via From 4bc48633b453026286895792080632e0ff312958 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 Sep 2023 11:06:34 -0700 Subject: [PATCH 05/11] Bump pydantic from 1.10.12 to 1.10.13 (#3815) Bumps [pydantic](https://github.com/pydantic/pydantic) from 1.10.12 to 1.10.13. - [Release notes](https://github.com/pydantic/pydantic/releases) - [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md) - [Commits](https://github.com/pydantic/pydantic/compare/v1.10.12...v1.10.13) --- updated-dependencies: - dependency-name: pydantic 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> --- requirements-base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-base.txt b/requirements-base.txt index f3e2dc86c687..0775b5d9bc6e 100644 --- a/requirements-base.txt +++ b/requirements-base.txt @@ -286,7 +286,7 @@ pyasn1-modules==0.3.0 # oauth2client pycparser==2.21 # via cffi -pydantic==1.10.12 +pydantic==1.10.13 # via # -r requirements-base.in # blockkit From f08a22b3972f6db1f5e718f1d3a2e96a5e78f6c2 Mon Sep 17 00:00:00 2001 From: Avery Date: Fri, 29 Sep 2023 12:02:32 -0700 Subject: [PATCH 06/11] 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 = { From 2e3f8e0efe9176ef1d8eacd176c87bd4caaa728f Mon Sep 17 00:00:00 2001 From: David Whittaker <84562015+whitdog47@users.noreply.github.com> Date: Fri, 29 Sep 2023 15:06:58 -0700 Subject: [PATCH 07/11] Fixes errors in oncall end-of-shift feedback reminders plus opens api to fetch feedback (#3817) --- src/dispatch/api.py | 4 ++++ src/dispatch/feedback/service/models.py | 12 +++++++----- .../feedback/service/reminder/service.py | 10 +++++----- src/dispatch/feedback/service/scheduled.py | 16 +++++++++++----- src/dispatch/feedback/service/views.py | 14 ++++++++++++++ .../plugins/dispatch_pagerduty/plugin.py | 3 ++- .../plugins/dispatch_pagerduty/service.py | 5 ++++- .../dispatch_slack/feedback/interactive.py | 7 ++++--- .../static/dispatch/src/service_feedback/api.js | 9 +++++++++ 9 files changed, 60 insertions(+), 20 deletions(-) create mode 100644 src/dispatch/feedback/service/views.py create mode 100644 src/dispatch/static/dispatch/src/service_feedback/api.js diff --git a/src/dispatch/api.py b/src/dispatch/api.py index 882a08d99b4d..21c84e50de7d 100644 --- a/src/dispatch/api.py +++ b/src/dispatch/api.py @@ -24,6 +24,7 @@ from dispatch.entity.views import router as entity_router from dispatch.entity_type.views import router as entity_type_router from dispatch.feedback.incident.views import router as feedback_router +from dispatch.feedback.service.views import router as service_feedback_router from dispatch.incident.priority.views import router as incident_priority_router from dispatch.incident.severity.views import router as incident_severity_router from dispatch.incident.type.views import router as incident_type_router @@ -203,6 +204,9 @@ def get_organization_path(organization: OrganizationSlug): authenticated_organization_api_router.include_router( feedback_router, prefix="/feedback", tags=["feedback"] ) +authenticated_organization_api_router.include_router( + service_feedback_router, prefix="/service_feedback", tags=["service_feedback"] +) authenticated_organization_api_router.include_router( notification_router, prefix="/notifications", tags=["notifications"] ) diff --git a/src/dispatch/feedback/service/models.py b/src/dispatch/feedback/service/models.py index e1d09e9f51ca..e2773f347bac 100644 --- a/src/dispatch/feedback/service/models.py +++ b/src/dispatch/feedback/service/models.py @@ -5,10 +5,11 @@ from sqlalchemy import Column, Integer, ForeignKey, DateTime, String from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy_utils import TSVectorType +from sqlalchemy.orm import relationship from dispatch.database.core import Base -from dispatch.individual.models import IndividualContactRead -from dispatch.models import DispatchBase, TimeStampMixin, FeedbackMixin, PrimaryKey +from dispatch.individual.models import IndividualContactReadMinimal +from dispatch.models import DispatchBase, TimeStampMixin, FeedbackMixin, PrimaryKey, Pagination from dispatch.project.models import ProjectRead from .enums import ServiceFeedbackRating @@ -26,6 +27,7 @@ class ServiceFeedback(TimeStampMixin, FeedbackMixin, Base): # Relationships individual_contact_id = Column(Integer, ForeignKey("individual_contact.id")) + individual = relationship("IndividualContact") search_vector = Column( TSVectorType( @@ -37,14 +39,14 @@ class ServiceFeedback(TimeStampMixin, FeedbackMixin, Base): @hybrid_property def project(self): - return self.service.project + return self.individual.project # Pydantic models class ServiceFeedbackBase(DispatchBase): feedback: Optional[str] = Field(None, nullable=True) hours: Optional[int] - individual: Optional[IndividualContactRead] + individual: Optional[IndividualContactReadMinimal] rating: ServiceFeedbackRating = ServiceFeedbackRating.little_effort schedule: Optional[str] shift_end_at: Optional[datetime] @@ -64,6 +66,6 @@ class ServiceFeedbackRead(ServiceFeedbackBase): project: Optional[ProjectRead] -class ServiceFeedbackPagination(DispatchBase): +class ServiceFeedbackPagination(Pagination): items: List[ServiceFeedbackRead] total: int diff --git a/src/dispatch/feedback/service/reminder/service.py b/src/dispatch/feedback/service/reminder/service.py index ae264e1d403d..248f729e0f73 100644 --- a/src/dispatch/feedback/service/reminder/service.py +++ b/src/dispatch/feedback/service/reminder/service.py @@ -3,7 +3,6 @@ from .models import ( ServiceFeedbackReminder, - ServiceFeedbackReminderCreate, ServiceFeedbackReminderUpdate, ) from dispatch.individual.models import IndividualContact @@ -24,11 +23,11 @@ def get_all_expired_reminders_by_project_id( ) -def create(*, db_session, reminder_in: ServiceFeedbackReminderCreate) -> ServiceFeedbackReminder: +def create(*, db_session, reminder_in: ServiceFeedbackReminder) -> ServiceFeedbackReminder: """Creates a new service feedback reminder.""" reminder = ServiceFeedbackReminder(**reminder_in.dict()) - db_session.add(reminder_in) + db_session.add(reminder) db_session.commit() return reminder @@ -55,5 +54,6 @@ def delete(*, db_session, reminder_id: int): .filter(ServiceFeedbackReminder.id == reminder_id) .one_or_none() ) - db_session.delete(reminder) - db_session.commit() + if reminder: + db_session.delete(reminder) + db_session.commit() diff --git a/src/dispatch/feedback/service/scheduled.py b/src/dispatch/feedback/service/scheduled.py index 6f5968659559..3c7a3c48f079 100644 --- a/src/dispatch/feedback/service/scheduled.py +++ b/src/dispatch/feedback/service/scheduled.py @@ -28,7 +28,7 @@ @timer @scheduled_project_task def oncall_shift_feedback_ucan(db_session: SessionLocal, project: Project): - oncall_shift_feedback(db_session=db_session, project=project) + oncall_shift_feedback(db_session=db_session, project=project, hour=16) find_expired_reminders_and_send(db_session=db_session, project=project) @@ -36,7 +36,7 @@ def oncall_shift_feedback_ucan(db_session: SessionLocal, project: Project): @timer @scheduled_project_task def oncall_shift_feedback_emea(db_session: SessionLocal, project: Project): - oncall_shift_feedback(db_session=db_session, project=project) + oncall_shift_feedback(db_session=db_session, project=project, hour=6) find_expired_reminders_and_send(db_session=db_session, project=project) @@ -60,13 +60,18 @@ def find_expired_reminders_and_send(*, db_session: SessionLocal, project: Projec def find_schedule_and_send( - *, db_session: SessionLocal, project: Project, oncall_plugin: PluginInstance, schedule_id: str + *, + db_session: SessionLocal, + project: Project, + oncall_plugin: PluginInstance, + schedule_id: str, + hour: int, ): """ Given PagerDuty schedule_id, determine if the shift ended for the previous oncall person and send the health metrics feedback request """ - current_oncall = oncall_plugin.instance.did_oncall_just_go_off_shift(schedule_id) + current_oncall = oncall_plugin.instance.did_oncall_just_go_off_shift(schedule_id, hour) individual = individual_service.get_by_email_and_project( db_session=db_session, email=current_oncall["email"], project_id=project.id @@ -82,7 +87,7 @@ def find_schedule_and_send( ) -def oncall_shift_feedback(db_session: SessionLocal, project: Project): +def oncall_shift_feedback(db_session: SessionLocal, project: Project, hour: int): """ Experimental: collects feedback from individuals participating in an oncall service that has health metrics enabled when their oncall shift ends. For now, only for one project and schedule. @@ -115,4 +120,5 @@ def oncall_shift_feedback(db_session: SessionLocal, project: Project): project=project, oncall_plugin=oncall_plugin, schedule_id=schedule_id, + hour=hour, ) diff --git a/src/dispatch/feedback/service/views.py b/src/dispatch/feedback/service/views.py new file mode 100644 index 000000000000..280ec390ac5e --- /dev/null +++ b/src/dispatch/feedback/service/views.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter + +from dispatch.database.service import search_filter_sort_paginate, CommonParameters + +from .models import ServiceFeedbackPagination + + +router = APIRouter() + + +@router.get("", response_model=ServiceFeedbackPagination) +def get_feedback_entries(commons: CommonParameters): + """Get all feedback entries, or only those matching a given search term.""" + return search_filter_sort_paginate(model="ServiceFeedback", **commons) diff --git a/src/dispatch/plugins/dispatch_pagerduty/plugin.py b/src/dispatch/plugins/dispatch_pagerduty/plugin.py index 1e7303d8bb32..b30b0456bdd2 100644 --- a/src/dispatch/plugins/dispatch_pagerduty/plugin.py +++ b/src/dispatch/plugins/dispatch_pagerduty/plugin.py @@ -83,12 +83,13 @@ def page( incident_description=incident_description, ) - def did_oncall_just_go_off_shift(self, schedule_id: str) -> Optional[dict]: + def did_oncall_just_go_off_shift(self, schedule_id: str, hour: int) -> Optional[dict]: client = APISession(self.configuration.api_key.get_secret_value()) client.url = self.configuration.pagerduty_api_url return oncall_shift_check( client=client, schedule_id=schedule_id, + hour=hour, ) def get_schedule_id_from_service_id(self, service_id: str) -> Optional[str]: diff --git a/src/dispatch/plugins/dispatch_pagerduty/service.py b/src/dispatch/plugins/dispatch_pagerduty/service.py index 130401e841e2..89079192ff40 100644 --- a/src/dispatch/plugins/dispatch_pagerduty/service.py +++ b/src/dispatch/plugins/dispatch_pagerduty/service.py @@ -177,9 +177,12 @@ def get_oncall_at_time(client: APISession, schedule_id: str, utctime: str) -> Op raise e -def oncall_shift_check(client: APISession, schedule_id: str) -> Optional[dict]: +def oncall_shift_check(client: APISession, schedule_id: str, hour: int) -> Optional[dict]: """Determines whether the oncall person just went off shift and returns their email.""" now = datetime.utcnow() + # in case scheduler is late, replace hour with exact one for shift comparison + now = now.replace(hour=hour, minute=0, second=0, microsecond=0) + # compare oncall person scheduled 18 hours ago vs 2 hours from now previous_shift = (now - timedelta(hours=18)).isoformat(timespec="minutes") + "Z" next_shift = (now + timedelta(hours=2)).isoformat(timespec="minutes") + "Z" diff --git a/src/dispatch/plugins/dispatch_slack/feedback/interactive.py b/src/dispatch/plugins/dispatch_slack/feedback/interactive.py index f0ba81afdb54..ea61d7665323 100644 --- a/src/dispatch/plugins/dispatch_slack/feedback/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/feedback/interactive.py @@ -186,7 +186,7 @@ def handle_incident_feedback_submission_event( ack_incident_feedback_submission_event(ack=ack) incident = incident_service.get(db_session=db_session, incident_id=context["subject"].id) - feedback = form_data.get(IncidentFeedbackNotificationBlockIds.feedback_input) + feedback = form_data.get(IncidentFeedbackNotificationBlockIds.feedback_input, "") rating = form_data.get(IncidentFeedbackNotificationBlockIds.rating_select, {}).get("value") feedback_in = FeedbackCreate( @@ -276,6 +276,7 @@ def oncall_shift_feedback_input( multiline=True, placeholder="How would you describe your experience?", ), + optional=True, label=label, **kwargs, ) @@ -381,7 +382,7 @@ def handle_oncall_shift_feedback_submission_event( ack_oncall_shift_feedback_submission_event(ack=ack) - feedback = form_data.get(ServiceFeedbackNotificationBlockIds.feedback_input) + feedback = form_data.get(ServiceFeedbackNotificationBlockIds.feedback_input, "") rating = form_data.get(ServiceFeedbackNotificationBlockIds.rating_select, {}).get("value") # metadata is organization_slug|project_id|schedule_id|shift_end_at|reminder_id @@ -447,7 +448,7 @@ def handle_oncall_shift_feedback_submission_event( ] try: plugin.instance.send_direct( - individual.email, + user.email, notification_text, notification_template, MessageType.service_feedback, diff --git a/src/dispatch/static/dispatch/src/service_feedback/api.js b/src/dispatch/static/dispatch/src/service_feedback/api.js new file mode 100644 index 000000000000..95b15f247874 --- /dev/null +++ b/src/dispatch/static/dispatch/src/service_feedback/api.js @@ -0,0 +1,9 @@ +import API from "@/api" + +const resource = "/service_feedback" + +export default { + getAll(options) { + return API.get(`${resource}`, { params: { ...options } }) + }, +} From a4a322ee8e7bc2af4e765cf232e9470742ec5a36 Mon Sep 17 00:00:00 2001 From: David Whittaker <84562015+whitdog47@users.noreply.github.com> Date: Fri, 29 Sep 2023 15:12:36 -0700 Subject: [PATCH 08/11] Allowing filtering in incident dashboard by participant or commander (#3802) --- src/dispatch/database/service.py | 6 ++- .../incident/IncidentDialogFilter.vue | 36 ++++++++++++++ .../static/dispatch/src/router/utils.js | 48 ++++++++++++++++--- .../static/dispatch/src/search/utils.js | 9 +++- 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/dispatch/database/service.py b/src/dispatch/database/service.py index 03a3c8c39d7a..a7f6d297d640 100644 --- a/src/dispatch/database/service.py +++ b/src/dispatch/database/service.py @@ -504,12 +504,14 @@ def search_filter_sort_paginate( sort = False if sort_by else True query = search(query_str=query_str, query=query, model=model, sort=sort) - query = apply_model_specific_filters(model_cls, query, current_user, role) - if filter_spec: query = apply_filter_specific_joins(model_cls, filter_spec, query) query = apply_filters(query, filter_spec, model_cls) + if model == "Incident": + query_restricted = apply_model_specific_filters(model_cls, query, current_user, role) + query = query.intersect(query_restricted) + if sort_by: sort_spec = create_sort_spec(model, sort_by, descending) query = apply_sort(query, sort_spec) diff --git a/src/dispatch/static/dispatch/src/dashboard/incident/IncidentDialogFilter.vue b/src/dispatch/static/dispatch/src/dashboard/incident/IncidentDialogFilter.vue index 4c9bc4394fd8..28532489bf0a 100644 --- a/src/dispatch/static/dispatch/src/dashboard/incident/IncidentDialogFilter.vue +++ b/src/dispatch/static/dispatch/src/dashboard/incident/IncidentDialogFilter.vue @@ -45,6 +45,28 @@ + + + + Incident Participant + Show only incidents with this participant + + + + + @@ -70,6 +92,7 @@ import ProjectCombobox from "@/project/ProjectCombobox.vue" import RouterUtils from "@/router/utils" import SearchUtils from "@/search/utils" import TagFilterAutoComplete from "@/tag/TagFilterAutoComplete.vue" +import ParticipantSelect from "@/incident/ParticipantSelect.vue" let today = function () { let now = new Date() @@ -86,6 +109,7 @@ export default { IncidentTypeCombobox, ProjectCombobox, TagFilterAutoComplete, + ParticipantSelect, }, props: { @@ -118,6 +142,8 @@ export default { end: null, }, }, + local_participant_is_commander: false, + local_participant: null, } }, @@ -130,6 +156,7 @@ export default { this.filters.project.length, this.filters.status.length, this.filters.tag.length, + this.local_participant == null ? 0 : 1, 1, ]) }, @@ -138,6 +165,15 @@ export default { methods: { applyFilters() { + if (this.local_participant) { + if (this.local_participant_is_commander) { + this.filters.commander = this.local_participant + this.filters.participant = null + } else { + this.filters.commander = null + this.filters.participant = this.local_participant + } + } RouterUtils.updateURLFilters(this.filters) this.fetchData() // we close the dialog diff --git a/src/dispatch/static/dispatch/src/router/utils.js b/src/dispatch/static/dispatch/src/router/utils.js index 081d8b8f64e1..ccd5c72c20ef 100644 --- a/src/dispatch/static/dispatch/src/router/utils.js +++ b/src/dispatch/static/dispatch/src/router/utils.js @@ -14,17 +14,33 @@ export default { return } each(value, function (item) { - if (has(flatFilters, key)) { - if (typeof item === "string" || item instanceof String) { - flatFilters[key].push(item) + if (["commander", "participant"].includes(key)) { + if (has(flatFilters, key)) { + if (typeof item === "string" || item instanceof String) { + flatFilters[key].push(item) + } else { + flatFilters[key].push(item.email) + } } else { - flatFilters[key].push(item.name) + if (typeof item === "string" || item instanceof String) { + flatFilters[key] = [item] + } else { + flatFilters[key] = [item.email] + } } } else { - if (typeof item === "string" || item instanceof String) { - flatFilters[key] = [item] + if (has(flatFilters, key)) { + if (typeof item === "string" || item instanceof String) { + flatFilters[key].push(item) + } else { + flatFilters[key].push(item.name) + } } else { - flatFilters[key] = [item.name] + if (typeof item === "string" || item instanceof String) { + flatFilters[key] = [item] + } else { + flatFilters[key] = [item.name] + } } } }) @@ -69,6 +85,24 @@ export default { } return } + if (["commander", "participant"].includes(key)) { + if (typeof value === "string" || value instanceof String) { + if (has(filters, key)) { + filters[key].push({ email: value }) + } else { + filters[key] = [{ email: value }] + } + } else { + each(value, function (item) { + if (has(filters, key)) { + filters[key].push({ email: item }) + } else { + filters[key] = [{ email: item }] + } + }) + } + return + } if (typeof value === "string" || value instanceof String) { if (has(filters, key)) { filters[key].push({ name: value }) diff --git a/src/dispatch/static/dispatch/src/search/utils.js b/src/dispatch/static/dispatch/src/search/utils.js index 3d1be804615f..20438a2c5183 100644 --- a/src/dispatch/static/dispatch/src/search/utils.js +++ b/src/dispatch/static/dispatch/src/search/utils.js @@ -118,7 +118,14 @@ export default { if (!value) { return } - if (has(value, "id")) { + if (["commander", "participant"].includes(key) && has(value, "email")) { + subFilter.push({ + model: toPascalCase(key), + field: "email", + op: "==", + value: value.email, + }) + } else if (has(value, "id")) { subFilter.push({ model: toPascalCase(key), field: "id", From 8dfae58663d9ff9828c0bf9bfa7de8468f467a26 Mon Sep 17 00:00:00 2001 From: David Whittaker <84562015+whitdog47@users.noreply.github.com> Date: Fri, 29 Sep 2023 15:28:27 -0700 Subject: [PATCH 09/11] Fixing restricted query statement (#3822) --- src/dispatch/database/service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/dispatch/database/service.py b/src/dispatch/database/service.py index a7f6d297d640..e404b96a7d7b 100644 --- a/src/dispatch/database/service.py +++ b/src/dispatch/database/service.py @@ -504,12 +504,13 @@ def search_filter_sort_paginate( sort = False if sort_by else True query = search(query_str=query_str, query=query, model=model, sort=sort) + query_restricted = apply_model_specific_filters(model_cls, query, current_user, role) + if filter_spec: query = apply_filter_specific_joins(model_cls, filter_spec, query) query = apply_filters(query, filter_spec, model_cls) if model == "Incident": - query_restricted = apply_model_specific_filters(model_cls, query, current_user, role) query = query.intersect(query_restricted) if sort_by: From d63554e40a436a5dcb439fdab9f39ffdbc7bedbb Mon Sep 17 00:00:00 2001 From: Will Sheldon <114631109+wssheldon@users.noreply.github.com> Date: Fri, 29 Sep 2023 15:42:14 -0700 Subject: [PATCH 10/11] Adds `Experimental Features` toggle for built-in feature flags (#3818) * Add db revision * Adds experimental toggle to User model, and front-end toggle, for feature flag * Update color, margin --- src/dispatch/auth/models.py | 3 ++ src/dispatch/auth/service.py | 3 ++ .../core/versions/2023-09-27_5c60513d6e5e.py | 28 +++++++++++++++++++ .../static/dispatch/src/auth/store.js | 13 +++++++++ .../dispatch/src/components/AppToolbar.vue | 25 ++++++++++++++++- 5 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/dispatch/database/revisions/core/versions/2023-09-27_5c60513d6e5e.py diff --git a/src/dispatch/auth/models.py b/src/dispatch/auth/models.py index d6cd1828d2d8..56827f9ca588 100644 --- a/src/dispatch/auth/models.py +++ b/src/dispatch/auth/models.py @@ -54,6 +54,7 @@ class DispatchUser(Base, TimeStampMixin): email = Column(String, unique=True) password = Column(LargeBinary, nullable=False) last_mfa_time = Column(DateTime, nullable=True) + experimental_features = Column(Boolean, default=False) # relationships events = relationship("Event", backref="dispatch_user") @@ -157,6 +158,7 @@ class UserLoginResponse(DispatchBase): class UserRead(UserBase): id: PrimaryKey role: Optional[str] = Field(None, nullable=True) + experimental_features: Optional[bool] class UserUpdate(DispatchBase): @@ -164,6 +166,7 @@ class UserUpdate(DispatchBase): password: Optional[str] = Field(None, nullable=True) projects: Optional[List[UserProject]] organizations: Optional[List[UserOrganization]] + experimental_features: Optional[bool] role: Optional[str] = Field(None, nullable=True) @validator("password", pre=True) diff --git a/src/dispatch/auth/service.py b/src/dispatch/auth/service.py index 29f8ca5be8ee..8d2b57ef1385 100644 --- a/src/dispatch/auth/service.py +++ b/src/dispatch/auth/service.py @@ -245,6 +245,9 @@ def update(*, db_session, user: DispatchUser, user_in: UserUpdate) -> DispatchUs ) ) + if experimental_features := user_in.experimental_features: + user.experimental_features = experimental_features + db_session.commit() return user diff --git a/src/dispatch/database/revisions/core/versions/2023-09-27_5c60513d6e5e.py b/src/dispatch/database/revisions/core/versions/2023-09-27_5c60513d6e5e.py new file mode 100644 index 000000000000..c124e41afa3c --- /dev/null +++ b/src/dispatch/database/revisions/core/versions/2023-09-27_5c60513d6e5e.py @@ -0,0 +1,28 @@ +"""Adds last_mfa_time to DispatchUser + +Revision ID: 5c60513d6e5e +Revises: 3dd4d12844dc +Create Date: 2023-09-27 15:17:00.450716 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "5c60513d6e5e" +down_revision = "3dd4d12844dc" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("dispatch_user", sa.Column("experimental_features", sa.Boolean(), default=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("dispatch_user", "experimental_features") + # ### end Alembic commands ### diff --git a/src/dispatch/static/dispatch/src/auth/store.js b/src/dispatch/static/dispatch/src/auth/store.js index 1bf94015d960..b8cafad588ee 100644 --- a/src/dispatch/static/dispatch/src/auth/store.js +++ b/src/dispatch/static/dispatch/src/auth/store.js @@ -25,6 +25,7 @@ const state = { email: "", projects: [], role: null, + experimental_features: false, }, selected: { ...getDefaultSelectedState(), @@ -148,6 +149,15 @@ const actions = { commit("SET_USER_LOGOUT") router.go() }, + getExperimentalFeatures({ commit }) { + UserApi.getUserInfo() + .then((response) => { + commit("SET_EXPERIMENTAL_FEATURES", response.data.experimental_features) + }) + .catch((error) => { + console.error("Error occurred while updating experimental features: ", error) + }) + }, createExpirationCheck({ state, commit }) { // expiration time minus 10 min let expire_at = subMinutes(fromUnixTime(state.currentUser.exp), 10) @@ -195,6 +205,9 @@ const mutations = { } localStorage.setItem("token", token) }, + SET_EXPERIMENTAL_FEATURES(state, value) { + state.currentUser.experimental_features = value + }, SET_USER_LOGOUT(state) { state.currentUser = { loggedIn: false } }, diff --git a/src/dispatch/static/dispatch/src/components/AppToolbar.vue b/src/dispatch/static/dispatch/src/components/AppToolbar.vue index f98c0e80d440..78aaad9c1a61 100644 --- a/src/dispatch/static/dispatch/src/components/AppToolbar.vue +++ b/src/dispatch/static/dispatch/src/components/AppToolbar.vue @@ -112,6 +112,16 @@ + Experimental Features + + Organizations @@ -159,6 +169,7 @@ import { mapActions, mapGetters, mapMutations, mapState } from "vuex" import Util from "@/util" import OrganizationApi from "@/organization/api" import OrganizationCreateEditDialog from "@/organization/CreateEditDialog.vue" +import UserApi from "@/auth/api" export default { name: "AppToolbar", @@ -181,6 +192,16 @@ export default { }, }, methods: { + updateExperimentalFeatures() { + UserApi.getUserInfo() + .then((response) => { + let userId = response.data.id + UserApi.update(userId, { id: userId, experimental_features: this.experimental_features }) + }) + .catch((error) => { + console.error("Error occurred while updating experimental features: ", error) + }) + }, handleDrawerToggle() { this.$store.dispatch("app/toggleDrawer") }, @@ -203,7 +224,7 @@ export default { }, ...mapState("auth", ["currentUser"]), ...mapState("app", ["currentVersion"]), - ...mapActions("auth", ["logout"]), + ...mapActions("auth", ["logout", "getExperimentalFeatures"]), ...mapActions("search", ["setQuery"]), ...mapActions("organization", ["showCreateEditDialog"]), ...mapActions("app", ["showCommitMessage"]), @@ -233,6 +254,8 @@ export default { this.organizations = response.data.items this.loading = false }) + + this.getExperimentalFeatures() }, } From e2d6ec7ce1c7d37d1da8f2c2282a87a1796593de Mon Sep 17 00:00:00 2001 From: kevgliss Date: Sat, 30 Sep 2023 21:04:02 -0700 Subject: [PATCH 11/11] Enhancement/case reminders (#3796) * Adds case status reminders * Adds scheduling * Update src/dispatch/case/messaging.py Co-authored-by: Marc Vilanova <39573146+mvilanova@users.noreply.github.com> * Update src/dispatch/messaging/strings.py Co-authored-by: Marc Vilanova <39573146+mvilanova@users.noreply.github.com> * Update src/dispatch/cli.py Co-authored-by: Marc Vilanova <39573146+mvilanova@users.noreply.github.com> * Update src/dispatch/messaging/strings.py Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> --------- Co-authored-by: Marc Vilanova <39573146+mvilanova@users.noreply.github.com> Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> --- src/dispatch/case/messaging.py | 87 +++++++++++++++++++++++++++++++ src/dispatch/case/scheduled.py | 47 +++++++++++++++++ src/dispatch/cli.py | 1 + src/dispatch/messaging/strings.py | 49 +++++++++++++++++ 4 files changed, 184 insertions(+) create mode 100644 src/dispatch/case/messaging.py create mode 100644 src/dispatch/case/scheduled.py diff --git a/src/dispatch/case/messaging.py b/src/dispatch/case/messaging.py new file mode 100644 index 000000000000..9cb7780f6e1d --- /dev/null +++ b/src/dispatch/case/messaging.py @@ -0,0 +1,87 @@ +""" +.. module: dispatch.case.messaging + :platform: Unix + :copyright: (c) 2019 by Netflix Inc., see AUTHORS for more + :license: Apache, see LICENSE for more details. +""" +import logging + +from dispatch.database.core import SessionLocal +from dispatch.case.models import Case +from dispatch.messaging.strings import ( + CASE_CLOSE_REMINDER, + CASE_TRIAGE_REMINDER, + MessageType, +) +from dispatch.plugin import service as plugin_service + + +log = logging.getLogger(__name__) + + +def send_case_close_reminder(case: Case, db_session: SessionLocal): + """ + Sends a direct message to the assignee reminding them to close the case if possible. + """ + message_text = "Case Close Reminder" + message_template = CASE_CLOSE_REMINDER + + plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=case.project.id, plugin_type="conversation" + ) + if not plugin: + log.warning("Case close reminder message not sent. No conversation plugin enabled.") + return + + items = [ + { + "name": case.name, + "ticket_weblink": case.ticket.weblink, + "title": case.title, + "status": case.status, + } + ] + + plugin.instance.send_direct( + case.assignee.individual.email, + message_text, + message_template, + MessageType.case_status_reminder, + items=items, + ) + + log.debug(f"Case close reminder sent to {case.assignee.individual.email}.") + + +def send_case_triage_reminder(case: Case, db_session: SessionLocal): + """ + Sends a direct message to the assignee reminding them to triage the case if possible. + """ + message_text = "Case Triage Reminder" + message_template = CASE_TRIAGE_REMINDER + + plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=case.project.id, plugin_type="conversation" + ) + if not plugin: + log.warning("Case triage reminder message not sent. No conversation plugin enabled.") + return + + items = [ + { + "name": case.name, + "ticket_weblink": case.ticket.weblink, + "title": case.title, + "status": case.status, + } + ] + + plugin.instance.send_direct( + case.assignee.individual.email, + message_text, + message_template, + MessageType.case_status_reminder, + items=items, + ) + + log.debug(f"Case triage reminder sent to {case.assignee.individual.email}.") diff --git a/src/dispatch/case/scheduled.py b/src/dispatch/case/scheduled.py new file mode 100644 index 000000000000..fa386bcf085e --- /dev/null +++ b/src/dispatch/case/scheduled.py @@ -0,0 +1,47 @@ +from datetime import datetime, date +from schedule import every + +from dispatch.database.core import SessionLocal +from dispatch.decorators import scheduled_project_task, timer +from dispatch.project.models import Project +from dispatch.scheduler import scheduler + +from .enums import CaseStatus +from .messaging import send_case_close_reminder, send_case_triage_reminder +from .service import ( + get_all_by_status, +) + + +@scheduler.add(every(1).day.at("18:00"), name="case-close-reminder") +@timer +@scheduled_project_task +def case_close_reminder(db_session: SessionLocal, project: Project): + """Sends a reminder to the case assignee to close out their case.""" + cases = get_all_by_status( + db_session=db_session, project_id=project.id, status=CaseStatus.triage + ) + + for case in cases: + span = datetime.utcnow() - case.triage_at + q, r = divmod(span.days, 7) + if q >= 1 and date.today().isoweekday() == 1: + # we only send the reminder for cases that have been triaging + # longer than a week and only on Mondays + send_case_close_reminder(case, db_session) + + +@scheduler.add(every(1).day.at("18:00"), name="case-triage-reminder") +@timer +@scheduled_project_task +def case_triage_reminder(db_session: SessionLocal, project: Project): + """Sends a reminder to the case assignee to triage their case.""" + cases = get_all_by_status(db_session=db_session, project_id=project.id, status=CaseStatus.new) + + # if we want more specific SLA reminders, we would need to add additional data model + for case in cases: + span = datetime.utcnow() - case.created + q, r = divmod(span.days, 1) + if q >= 1: + # we only send one reminder per case per day + send_case_triage_reminder(case, db_session) diff --git a/src/dispatch/cli.py b/src/dispatch/cli.py index 95ae7d5a3f96..689e3ec9c66b 100644 --- a/src/dispatch/cli.py +++ b/src/dispatch/cli.py @@ -640,6 +640,7 @@ def dispatch_scheduler(): ) from .term.scheduled import sync_terms # noqa from .workflow.scheduled import sync_workflows # noqa + from .case.scheduled import case_triage_reminder, case_close_reminder # noqa @dispatch_scheduler.command("list") diff --git a/src/dispatch/messaging/strings.py b/src/dispatch/messaging/strings.py index 0facbef81ce4..877dac1ce69b 100644 --- a/src/dispatch/messaging/strings.py +++ b/src/dispatch/messaging/strings.py @@ -5,6 +5,7 @@ from dispatch.messaging.email.filters import env from dispatch.conversation.enums import ConversationButtonActions from dispatch.incident.enums import IncidentStatus +from dispatch.case.enums import CaseStatus from dispatch.enums import Visibility from dispatch import config @@ -34,6 +35,7 @@ class MessageType(DispatchEnum): incident_tactical_report = "incident-tactical-report" incident_task_list = "incident-task-list" incident_task_reminder = "incident-task-reminder" + case_status_reminder = "case-status-reminder" service_feedback = "service-feedback" @@ -43,6 +45,13 @@ class MessageType(DispatchEnum): IncidentStatus.closed: "This no longer requires additional involvement, long term incident action items have been assigned to their respective owners.", } +CASE_STATUS_DESCRIPTIONS = { + CaseStatus.new: "This case is new and needs triaging.", + CaseStatus.triage: "This case is being triaged.", + CaseStatus.escalated: "This case has been escalated.", + CaseStatus.closed: "This case has been closed.", +} + INCIDENT_VISIBILITY_DESCRIPTIONS = { Visibility.open: "We ask that you use your best judgment while sharing details about this incident outside of the dedicated channels of communication. Please reach out to the Incident Commander if you have any questions.", Visibility.restricted: "This incident is restricted to immediate participants of this incident. We ask that you exercise extra caution and discretion while talking about this incident outside of the dedicated channels of communication. Only invite new participants that are strictly necessary. Please reach out to the Incident Commander if you have any questions.", @@ -236,6 +245,16 @@ class MessageType(DispatchEnum): "\n", " " ).strip() +CASE_TRIAGE_REMINDER_DESCRIPTION = """The status of this case hasn't been updated recently. +Please ensure you triage the case based on its priority.""".replace( + "\n", " " +).strip() + +CASE_CLOSE_REMINDER_DESCRIPTION = """The status of this case hasn't been updated recently. +You can use the case 'Resolve' button if it has been resolved and can be closed.""".replace( + "\n", " " +).strip() + INCIDENT_TASK_NEW_DESCRIPTION = """ The following incident task has been created and assigned to you by {{task_creator}}: {{task_description}}""" @@ -390,6 +409,14 @@ class MessageType(DispatchEnum): INCIDENT_TITLE = {"title": "Title", "text": "{{title}}"} +CASE_TITLE = {"title": "Title", "text": "{{title}}"} + +CASE_STATUS = { + "title": "Status - {{status}}", + "status_mapping": CASE_STATUS_DESCRIPTIONS, +} + + if config.DISPATCH_MARKDOWN_IN_INCIDENT_DESC: INCIDENT_DESCRIPTION = {"title": "Description", "text": "{{description | markdown}}"} else: @@ -596,6 +623,28 @@ class MessageType(DispatchEnum): INCIDENT_STATUS, ] + +CASE_CLOSE_REMINDER = [ + { + "title": "{{name}} Case - Close Reminder", + "title_link": "{{ticket_weblink}}", + "text": CASE_CLOSE_REMINDER_DESCRIPTION, + }, + CASE_TITLE, + CASE_STATUS, +] + +CASE_TRIAGE_REMINDER = [ + { + "title": "{{name}} Case - Triage Reminder", + "title_link": "{{ticket_weblink}}", + "text": CASE_TRIAGE_REMINDER_DESCRIPTION, + }, + CASE_TITLE, + CASE_STATUS, +] + + INCIDENT_TASK_REMINDER = [ {"title": "Incident - {{ name }}", "text": "{{ title }}"}, {"title": "Creator", "text": "{{ creator }}"},