From c682670850a2708c367f6fc4d1e84eecdeb87c94 Mon Sep 17 00:00:00 2001 From: Avery Date: Thu, 18 Jan 2024 14:37:35 -0800 Subject: [PATCH] Updated Feature: Activity-based Incident Cost Model (#4172) * Bump uvloop from 0.18.0 to 0.19.0 (#3896) Bumps [uvloop](https://github.com/MagicStack/uvloop) from 0.18.0 to 0.19.0. - [Release notes](https://github.com/MagicStack/uvloop/releases) - [Commits](https://github.com/MagicStack/uvloop/compare/v0.18.0...v0.19.0) --- updated-dependencies: - dependency-name: uvloop 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 email-validator from 2.0.0.post2 to 2.1.0.post1 (#3897) Bumps [email-validator](https://github.com/JoshData/python-email-validator) from 2.0.0.post2 to 2.1.0.post1. - [Release notes](https://github.com/JoshData/python-email-validator/releases) - [Changelog](https://github.com/JoshData/python-email-validator/blob/main/CHANGELOG.md) - [Commits](https://github.com/JoshData/python-email-validator/commits) --- updated-dependencies: - dependency-name: email-validator 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 vue from 2.7.14 to 2.7.15 in /src/dispatch/static/dispatch (#3895) Bumps [vue](https://github.com/vuejs/core) from 2.7.14 to 2.7.15. - [Release notes](https://github.com/vuejs/core/releases) - [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md) - [Commits](https://github.com/vuejs/core/commits) --- updated-dependencies: - dependency-name: vue 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 eslint from 8.51.0 to 8.52.0 in /src/dispatch/static/dispatch (#3894) Bumps [eslint](https://github.com/eslint/eslint) from 8.51.0 to 8.52.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.51.0...v8.52.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> * Bump vue-template-compiler in /src/dispatch/static/dispatch (#3893) Bumps [vue-template-compiler](https://github.com/vuejs/vue) from 2.7.14 to 2.7.15. - [Release notes](https://github.com/vuejs/vue/releases) - [Changelog](https://github.com/vuejs/vue/blob/main/CHANGELOG.md) - [Commits](https://github.com/vuejs/vue/compare/v2.7.14...v2.7.15) --- updated-dependencies: - dependency-name: vue-template-compiler 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 @vue/compiler-sfc in /src/dispatch/static/dispatch (#3892) Bumps [@vue/compiler-sfc](https://github.com/vuejs/core/tree/HEAD/packages/compiler-sfc) from 3.3.4 to 3.3.6. - [Release notes](https://github.com/vuejs/core/releases) - [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md) - [Commits](https://github.com/vuejs/core/commits/v3.3.6/packages/compiler-sfc) --- updated-dependencies: - dependency-name: "@vue/compiler-sfc" 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 ruff from 0.1.0 to 0.1.1 (#3891) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.0 to 0.1.1. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.1.0...v0.1.1) --- 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 schemathesis from 3.19.7 to 3.20.1 (#3890) Bumps [schemathesis](https://github.com/schemathesis/schemathesis) from 3.19.7 to 3.20.1. - [Release notes](https://github.com/schemathesis/schemathesis/releases) - [Changelog](https://github.com/schemathesis/schemathesis/blob/master/docs/changelog.rst) - [Commits](https://github.com/schemathesis/schemathesis/compare/v3.19.7...v3.20.1) --- updated-dependencies: - dependency-name: schemathesis 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 faker from 19.10.0 to 19.11.0 (#3887) Bumps [faker](https://github.com/joke2k/faker) from 19.10.0 to 19.11.0. - [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.10.0...v19.11.0) --- updated-dependencies: - dependency-name: faker 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> * Enhances Signals table (#3898) * Enhances Signals table * removes commented code * Call ('input') in remove method (#3871) * Reverts package-lock.json file to version in PR 3892 (#3899) * Fixing an issue where we aren't paging for critical (#3902) * Fixing an issue where we aren't paging for critical * Adding back in search vector * Removing vector based on signal * Update to vue 3 (#3857) * update packages * auto fix * update root navigation * remove vuex-router-sync mapped fields * update dashboards * set default icon button variant to text * replace vee-validate with v-form * auto fix * manual fix * replace vue filters with function calls * remove deprecated grid components * remove deprecated slot syntax * disable explicit emits * update event hyphenation * move v-for key to template node * remove deprecated $listeners * remove badge overlap prop * remove deprecated app prop * remove deprecated clipped prop * clean up * update search results summary tables * update chip size prop * update activator slots * fix menu positioning * swap field append-inner/outer * update combobox selection slots * remove .native modifier * show active indicator on all list items * update table headers and slots * update drawer action buttons * remove deprecated list sub-components * replace material icons with mdi * replace value/input with modelValue * replace v-app-bar in drawers with v-toolbar * fix breadcrumbs * add default color to selection controls * update vuetify * update steppers * replace fixed-width subtitles with title attribute * update select item slots * set custom link color * update help icon * update InfoWidget * set default itemTitle to "text" * add missing sortyBy array * update tabs * update monaco * fix non-existent property access error * replace VueClipboard with navigator.clipboard * replace vue-markdown with vue3-markdown-it * update incident report form * misc fixes * replace vuedraggable with vueuse sortable * replace date-picker and time-picker with native inputs * remove unused file * fix BaseCombobox usage * Adding FormKit dependency and fixing incident drawer * fix chart card props * update vuetify * $vuetify.breakpoint -> $vuetify.display * v-data-table -> v-data-table-server * fix timeline layout * Minor ui fixes * Migrating btn * More UI fixes --------- Co-authored-by: David Whittaker Co-authored-by: kevgliss Co-authored-by: Marc Vilanova <39573146+mvilanova@users.noreply.github.com> * Adds default org for api calls (#3912) * Do not print out raw role policies in the incident roles UI section (#3913) * Rollback session in case or exceptions (#3917) * Bugfix/table font size (#3921) * Reducing font size * Removing table density * Adding padding for spacing on participants and resources tabs (#3920) * Fixing export preview missing scroll for incidents, cases, and tasks (#3919) * Fixing styling for incident priority settings, search filter settings, and plugins table (#3918) * Fixing validation for stable priority * Ignore v-stepper and other deprecated component linter errors (#3923) * Fixes issues with Signals table after migration to Vue3 (#3922) * handle possible null actions in interactive.py (#3914) * handle possible null actions interactive.py It is possible that there are tasks, but there is no previous actions value so actions is None. This addresses that issue by setting actions to an empty string if it is None. * Bump faker from 19.11.0 to 19.13.0 (#3924) Bumps [faker](https://github.com/joke2k/faker) from 19.11.0 to 19.13.0. - [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.11.0...v19.13.0) --- updated-dependencies: - dependency-name: faker 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.104.0 to 2.106.0 (#3916) Bumps [google-api-python-client](https://github.com/googleapis/google-api-python-client) from 2.104.0 to 2.106.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.104.0...v2.106.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 ruff from 0.1.1 to 0.1.3 (#3910) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.1 to 0.1.3. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.1.1...v0.1.3) --- 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 werkzeug from 2.3.7 to 3.0.1 (#3908) Bumps [werkzeug](https://github.com/pallets/werkzeug) from 2.3.7 to 3.0.1. - [Release notes](https://github.com/pallets/werkzeug/releases) - [Changelog](https://github.com/pallets/werkzeug/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/werkzeug/compare/2.3.7...3.0.1) --- updated-dependencies: - dependency-name: werkzeug dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump cachetools from 5.3.1 to 5.3.2 (#3906) Bumps [cachetools](https://github.com/tkem/cachetools) from 5.3.1 to 5.3.2. - [Changelog](https://github.com/tkem/cachetools/blob/master/CHANGELOG.rst) - [Commits](https://github.com/tkem/cachetools/compare/v5.3.1...v5.3.2) --- updated-dependencies: - dependency-name: cachetools 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 black from 23.10.0 to 23.10.1 (#3901) Bumps [black](https://github.com/psf/black) from 23.10.0 to 23.10.1. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/23.10.0...23.10.1) --- updated-dependencies: - dependency-name: black 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 ipython from 8.16.1 to 8.17.2 (#3931) Bumps [ipython](https://github.com/ipython/ipython) from 8.16.1 to 8.17.2. - [Release notes](https://github.com/ipython/ipython/releases) - [Commits](https://github.com/ipython/ipython/commits/8.17.2) --- 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> * Bump markdown from 3.5 to 3.5.1 (#3930) Bumps [markdown](https://github.com/Python-Markdown/markdown) from 3.5 to 3.5.1. - [Release notes](https://github.com/Python-Markdown/markdown/releases) - [Changelog](https://github.com/Python-Markdown/markdown/blob/master/docs/changelog.md) - [Commits](https://github.com/Python-Markdown/markdown/compare/3.5...3.5.1) --- updated-dependencies: - dependency-name: markdown 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 protobuf from 4.24.4 to 4.25.0 (#3929) Bumps [protobuf](https://github.com/protocolbuffers/protobuf) from 4.24.4 to 4.25.0. - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Changelog](https://github.com/protocolbuffers/protobuf/blob/main/protobuf_release.bzl) - [Commits](https://github.com/protocolbuffers/protobuf/compare/v4.24.4...v4.25.0) --- updated-dependencies: - dependency-name: protobuf 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 duo-client from 5.1.0 to 5.2.0 (#3928) Bumps [duo-client](https://github.com/duosecurity/duo_client_python) from 5.1.0 to 5.2.0. - [Release notes](https://github.com/duosecurity/duo_client_python/releases) - [Commits](https://github.com/duosecurity/duo_client_python/compare/5.1.0...5.2.0) --- updated-dependencies: - dependency-name: duo-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 sentry-sdk from 1.32.0 to 1.34.0 (#3927) Bumps [sentry-sdk](https://github.com/getsentry/sentry-python) from 1.32.0 to 1.34.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.32.0...1.34.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> * Fixing participant select and centralizes component (#3925) * Fixing participant select and centralizes component * Add component * Removing spans * Fix linting * Fixes * Switch to auto-complete * Fixes autocomplete * Restyling templates settings page (#3926) * Bugfix/format timeline fixes (#3932) * Mirror the incident bottom sheet to prevent focus from being stolen (#3933) * return object when an item is selected in case/incident table (#3934) * return object for tasks data table (#3935) * Bump eslint from 8.52.0 to 8.53.0 in /src/dispatch/static/dispatch (#3943) Bumps [eslint](https://github.com/eslint/eslint) from 8.52.0 to 8.53.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.52.0...v8.53.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> * Bump msal from 1.24.1 to 1.25.0 (#3942) Bumps [msal](https://github.com/AzureAD/microsoft-authentication-library-for-python) from 1.24.1 to 1.25.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.24.1...1.25.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 vue from 3.3.7 to 3.3.8 in /src/dispatch/static/dispatch (#3941) Bumps [vue](https://github.com/vuejs/core) from 3.3.7 to 3.3.8. - [Release notes](https://github.com/vuejs/core/releases) - [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md) - [Commits](https://github.com/vuejs/core/compare/v3.3.7...v3.3.8) --- updated-dependencies: - dependency-name: vue 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.1.3 to 0.1.4 (#3940) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.3 to 0.1.4. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.1.3...v0.1.4) --- 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 uvicorn from 0.23.2 to 0.24.0.post1 (#3939) Bumps [uvicorn](https://github.com/encode/uvicorn) from 0.23.2 to 0.24.0.post1. - [Release notes](https://github.com/encode/uvicorn/releases) - [Changelog](https://github.com/encode/uvicorn/blob/master/CHANGELOG.md) - [Commits](https://github.com/encode/uvicorn/compare/0.23.2...0.24.0.post1) --- updated-dependencies: - dependency-name: uvicorn 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 schemathesis from 3.20.1 to 3.20.2 (#3938) Bumps [schemathesis](https://github.com/schemathesis/schemathesis) from 3.20.1 to 3.20.2. - [Release notes](https://github.com/schemathesis/schemathesis/releases) - [Changelog](https://github.com/schemathesis/schemathesis/blob/master/docs/changelog.rst) - [Commits](https://github.com/schemathesis/schemathesis/compare/v3.20.1...v3.20.2) --- updated-dependencies: - dependency-name: schemathesis 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 httpx from 0.24.1 to 0.25.1 (#3937) Bumps [httpx](https://github.com/encode/httpx) from 0.24.1 to 0.25.1. - [Release notes](https://github.com/encode/httpx/releases) - [Changelog](https://github.com/encode/httpx/blob/master/CHANGELOG.md) - [Commits](https://github.com/encode/httpx/compare/0.24.1...0.25.1) --- updated-dependencies: - dependency-name: httpx 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> * Fixing issue with new template dialog always appearing (#3936) * Removing password element from plugin form (#3945) * Fixing time not saving correctly in edit event dialog (#3946) * Project autocomplete (#3944) * Project autocomplete * Fixing linting --------- Co-authored-by: Will Sheldon <114631109+wssheldon@users.noreply.github.com> * Use real JSONPathError exception (#3951) * Bump openai from 0.28.1 to 1.2.0 (#3954) Bumps [openai](https://github.com/openai/openai-python) from 0.28.1 to 1.2.0. - [Release notes](https://github.com/openai/openai-python/releases) - [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/openai/openai-python/compare/v0.28.1...v1.2.0) --- updated-dependencies: - dependency-name: openai dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump @vitejs/plugin-vue in /src/dispatch/static/dispatch (#3953) Bumps [@vitejs/plugin-vue](https://github.com/vitejs/vite-plugin-vue/tree/HEAD/packages/plugin-vue) from 4.4.0 to 4.4.1. - [Release notes](https://github.com/vitejs/vite-plugin-vue/releases) - [Changelog](https://github.com/vitejs/vite-plugin-vue/blob/main/packages/plugin-vue/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite-plugin-vue/commits/plugin-vue@4.4.1/packages/plugin-vue) --- updated-dependencies: - dependency-name: "@vitejs/plugin-vue" 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 google-api-python-client from 2.106.0 to 2.107.0 (#3952) Bumps [google-api-python-client](https://github.com/googleapis/google-api-python-client) from 2.106.0 to 2.107.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.106.0...v2.107.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 pytest from 7.4.2 to 7.4.3 (#3949) Bumps [pytest](https://github.com/pytest-dev/pytest) from 7.4.2 to 7.4.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/7.4.2...7.4.3) --- updated-dependencies: - dependency-name: pytest 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 pandas from 2.1.1 to 2.1.2 (#3948) Bumps [pandas](https://github.com/pandas-dev/pandas) from 2.1.1 to 2.1.2. - [Release notes](https://github.com/pandas-dev/pandas/releases) - [Commits](https://github.com/pandas-dev/pandas/compare/v2.1.1...v2.1.2) --- 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 alembic from 1.12.0 to 1.12.1 (#3947) Bumps [alembic](https://github.com/sqlalchemy/alembic) from 1.12.0 to 1.12.1. - [Release notes](https://github.com/sqlalchemy/alembic/releases) - [Changelog](https://github.com/sqlalchemy/alembic/blob/main/CHANGES) - [Commits](https://github.com/sqlalchemy/alembic/commits) --- updated-dependencies: - dependency-name: alembic 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> * Only display raw data in signal viewer (#3955) * Only display raw data in signal viewer * Fixes warning and error in EditEventDialog.vue * Revert "Bump alembic from 1.12.0 to 1.12.1 (#3947)" (#3968) This reverts commit f983f790e81a2ca1f515d4d06251c59cdfd161df. * Fixing playwright tests (#3969) * Continue to work on fixing playwright tests (#3970) * Have playwright.yml only run on chromium * Replacing deprecated props (#3971) * Making search more reliable (#3967) Co-authored-by: Will Sheldon <114631109+wssheldon@users.noreply.github.com> Co-authored-by: David Whittaker Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> * Bugfix/vuetify upgrade (#3973) * Upgrade and remove labs * Upgrade lock * Bump faker from 19.13.0 to 20.0.0 (#3972) Bumps [faker](https://github.com/joke2k/faker) from 19.13.0 to 20.0.0. - [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.13.0...v20.0.0) --- updated-dependencies: - dependency-name: faker dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump schemathesis from 3.20.2 to 3.21.0 (#3966) Bumps [schemathesis](https://github.com/schemathesis/schemathesis) from 3.20.2 to 3.21.0. - [Release notes](https://github.com/schemathesis/schemathesis/releases) - [Changelog](https://github.com/schemathesis/schemathesis/blob/master/docs/changelog.rst) - [Commits](https://github.com/schemathesis/schemathesis/compare/v3.20.2...v3.21.0) --- updated-dependencies: - dependency-name: schemathesis 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.1.4 to 0.1.5 (#3965) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.4 to 0.1.5. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.1.4...v0.1.5) --- 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> * Bump black from 23.10.1 to 23.11.0 (#3964) Bumps [black](https://github.com/psf/black) from 23.10.1 to 23.11.0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/23.10.1...23.11.0) --- updated-dependencies: - dependency-name: black 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> Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> * Bump openai from 1.2.0 to 1.2.2 (#3963) Bumps [openai](https://github.com/openai/openai-python) from 1.2.0 to 1.2.2. - [Release notes](https://github.com/openai/openai-python/releases) - [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/openai/openai-python/compare/v1.2.0...v1.2.2) --- 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> Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> * Bump @formkit/vue from 1.2.2 to 1.3.0 in /src/dispatch/static/dispatch (#3962) Bumps [@formkit/vue](https://github.com/formkit/formkit/tree/HEAD/packages/rules) from 1.2.2 to 1.3.0. - [Release notes](https://github.com/formkit/formkit/releases) - [Commits](https://github.com/formkit/formkit/commits/HEAD/packages/rules) --- updated-dependencies: - dependency-name: "@formkit/vue" 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 @vueuse/integrations in /src/dispatch/static/dispatch (#3961) Bumps [@vueuse/integrations](https://github.com/vueuse/vueuse/tree/HEAD/packages/integrations) from 10.5.0 to 10.6.0. - [Release notes](https://github.com/vueuse/vueuse/releases) - [Commits](https://github.com/vueuse/vueuse/commits/v10.6.0/packages/integrations) --- updated-dependencies: - dependency-name: "@vueuse/integrations" 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 @formkit/themes in /src/dispatch/static/dispatch (#3960) Bumps [@formkit/themes](https://github.com/formkit/formkit/tree/HEAD/packages/themes) from 1.2.2 to 1.3.0. - [Release notes](https://github.com/formkit/formkit/releases) - [Commits](https://github.com/formkit/formkit/commits/HEAD/packages/themes) --- updated-dependencies: - dependency-name: "@formkit/themes" 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 eslint-plugin-vuetify in /src/dispatch/static/dispatch (#3959) Bumps [eslint-plugin-vuetify](https://github.com/vuetifyjs/eslint-plugin-vuetify) from 2.0.5 to 2.1.0. - [Release notes](https://github.com/vuetifyjs/eslint-plugin-vuetify/releases) - [Commits](https://github.com/vuetifyjs/eslint-plugin-vuetify/compare/v2.0.5...v2.1.0) --- updated-dependencies: - dependency-name: eslint-plugin-vuetify 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> Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> * Updates OpenAI plugin (#3975) * Bump openai from 1.2.2 to 1.2.4 (#3976) Bumps [openai](https://github.com/openai/openai-python) from 1.2.2 to 1.2.4. - [Release notes](https://github.com/openai/openai-python/releases) - [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/openai/openai-python/compare/v1.2.2...v1.2.4) --- 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> * Pre commit/eslint ruff versions (#3957) * Add eslint pre-commit and ruff, typos * Add husky * Bump vuetify from 3.3.22 to 3.4.0 in /src/dispatch/static/dispatch (#3982) Bumps [vuetify](https://github.com/vuetifyjs/vuetify/tree/HEAD/packages/vuetify) from 3.3.22 to 3.4.0. - [Release notes](https://github.com/vuetifyjs/vuetify/releases) - [Commits](https://github.com/vuetifyjs/vuetify/commits/v3.4.0/packages/vuetify) --- updated-dependencies: - dependency-name: vuetify 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> * Removes canary column in signal instance table (#3985) Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> * Only show variant in case notification if it is set (#3984) * Only show variant in case notification if it exists * walrus operator --------- Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> * Sets resolution reason when case is resolved through signal engagement (#3983) Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> * Bump slack-sdk from 3.23.0 to 3.23.1 (#3988) Bumps [slack-sdk](https://github.com/slackapi/python-slack-sdk) from 3.23.0 to 3.23.1. - [Release notes](https://github.com/slackapi/python-slack-sdk/releases) - [Changelog](https://github.com/slackapi/python-slack-sdk/blob/main/docs-v2/changelog.html) - [Commits](https://github.com/slackapi/python-slack-sdk/compare/v3.23.0...v3.23.1) --- updated-dependencies: - dependency-name: slack-sdk 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 google-api-python-client from 2.107.0 to 2.108.0 (#3987) Bumps [google-api-python-client](https://github.com/googleapis/google-api-python-client) from 2.107.0 to 2.108.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.107.0...v2.108.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 @vueuse/integrations in /src/dispatch/static/dispatch (#3981) Bumps [@vueuse/integrations](https://github.com/vueuse/vueuse/tree/HEAD/packages/integrations) from 10.6.0 to 10.6.1. - [Release notes](https://github.com/vueuse/vueuse/releases) - [Commits](https://github.com/vueuse/vueuse/commits/v10.6.1/packages/integrations) --- updated-dependencies: - dependency-name: "@vueuse/integrations" 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 numpy from 1.26.1 to 1.26.2 (#3979) Bumps [numpy](https://github.com/numpy/numpy) from 1.26.1 to 1.26.2. - [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.26.1...v1.26.2) --- updated-dependencies: - dependency-name: numpy 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> Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> * Bump sentry-sdk from 1.34.0 to 1.35.0 (#3978) Bumps [sentry-sdk](https://github.com/getsentry/sentry-python) from 1.34.0 to 1.35.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.34.0...1.35.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> Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> * Bump pandas from 2.1.2 to 2.1.3 (#3977) Bumps [pandas](https://github.com/pandas-dev/pandas) from 2.1.2 to 2.1.3. - [Release notes](https://github.com/pandas-dev/pandas/releases) - [Commits](https://github.com/pandas-dev/pandas/compare/v2.1.2...v2.1.3) --- 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> Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> * Declutters Slack case threads (#3994) * Declutters Slack case threads * Update src/dispatch/plugins/dispatch_slack/case/messages.py Co-authored-by: Will Sheldon <114631109+wssheldon@users.noreply.github.com> * Update src/dispatch/plugins/dispatch_slack/case/messages.py Co-authored-by: Will Sheldon <114631109+wssheldon@users.noreply.github.com> --------- Co-authored-by: Will Sheldon <114631109+wssheldon@users.noreply.github.com> * Fixes DSN. (#3986) * Uses entity type name in Slack snooze modal (#3995) * Show entity value in entity filter combobox (#3996) * Aligns form styles and reverts project autocomplete (#3989) * Aligns form styles and reverts project autocomplete * Adding cases * Update src/dispatch/static/dispatch/src/auth/Register.vue Co-authored-by: Marc Vilanova <39573146+mvilanova@users.noreply.github.com> --------- Co-authored-by: Marc Vilanova <39573146+mvilanova@users.noreply.github.com> * Fixing settings bread crumbs (#3992) * Fixing table cards to remove card outlines (#3990) Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> * Increase min page sizes (#3993) * Bump slack-sdk from 3.23.1 to 3.24.0 (#4001) Bumps [slack-sdk](https://github.com/slackapi/python-slack-sdk) from 3.23.1 to 3.24.0. - [Release notes](https://github.com/slackapi/python-slack-sdk/releases) - [Changelog](https://github.com/slackapi/python-slack-sdk/blob/main/docs-v2/changelog.html) - [Commits](https://github.com/slackapi/python-slack-sdk/compare/v3.23.1...v3.24.0) --- updated-dependencies: - dependency-name: slack-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> * Bump faker from 20.0.0 to 20.0.3 (#4000) Bumps [faker](https://github.com/joke2k/faker) from 20.0.0 to 20.0.3. - [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/v20.0.0...v20.0.3) --- 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 pdpyras from 5.1.2 to 5.1.3 (#3999) Bumps [pdpyras](https://github.com/PagerDuty/pdpyras) from 5.1.2 to 5.1.3. - [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.2...v5.1.3) --- 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 openai from 1.2.4 to 1.3.0 (#3998) Bumps [openai](https://github.com/openai/openai-python) from 1.2.4 to 1.3.0. - [Release notes](https://github.com/openai/openai-python/releases) - [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/openai/openai-python/compare/v1.2.4...v1.3.0) --- updated-dependencies: - dependency-name: openai 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 protobuf from 4.25.0 to 4.25.1 (#3997) Bumps [protobuf](https://github.com/protocolbuffers/protobuf) from 4.25.0 to 4.25.1. - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Changelog](https://github.com/protocolbuffers/protobuf/blob/main/protobuf_release.bzl) - [Commits](https://github.com/protocolbuffers/protobuf/compare/v4.25.0...v4.25.1) --- updated-dependencies: - dependency-name: protobuf 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> * Correctly render object title in SignalEngagementCombobox (#4002) * Handle invalid JSONPath with KeyError, Exceptions, and add test cases (#4007) * Handle invalid JSONPath with KeyError, general Exceptions, and add test cases * Change comment to correct value * Update log line to be a warning instead of an exception * Bump ruff from 0.1.5 to 0.1.6 (#4016) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.5 to 0.1.6. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.1.5...v0.1.6) --- 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> * Fixing project and case type select (#4010) * Bugfix/cant add assignee (#4017) * Bump aiohttp from 3.8.6 to 3.9.0 (#4015) Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.8.6 to 3.9.0. - [Release notes](https://github.com/aio-libs/aiohttp/releases) - [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst) - [Commits](https://github.com/aio-libs/aiohttp/compare/v3.8.6...v3.9.0) --- updated-dependencies: - dependency-name: aiohttp 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 1.3.0 to 1.3.3 (#4014) Bumps [openai](https://github.com/openai/openai-python) from 1.3.0 to 1.3.3. - [Release notes](https://github.com/openai/openai-python/releases) - [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/openai/openai-python/compare/v1.3.0...v1.3.3) --- 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 eslint from 8.53.0 to 8.54.0 in /src/dispatch/static/dispatch (#4013) Bumps [eslint](https://github.com/eslint/eslint) from 8.53.0 to 8.54.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.53.0...v8.54.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> * Bump schemathesis from 3.21.0 to 3.21.1 (#4006) Bumps [schemathesis](https://github.com/schemathesis/schemathesis) from 3.21.0 to 3.21.1. - [Release notes](https://github.com/schemathesis/schemathesis/releases) - [Changelog](https://github.com/schemathesis/schemathesis/blob/master/docs/changelog.rst) - [Commits](https://github.com/schemathesis/schemathesis/compare/v3.21.0...v3.21.1) --- updated-dependencies: - dependency-name: schemathesis 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 @playwright/test in /src/dispatch/static/dispatch (#4004) Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.39.0 to 1.40.0. - [Release notes](https://github.com/microsoft/playwright/releases) - [Commits](https://github.com/microsoft/playwright/compare/v1.39.0...v1.40.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 @vitejs/plugin-vue in /src/dispatch/static/dispatch (#4003) Bumps [@vitejs/plugin-vue](https://github.com/vitejs/vite-plugin-vue/tree/HEAD/packages/plugin-vue) from 4.4.1 to 4.5.0. - [Release notes](https://github.com/vitejs/vite-plugin-vue/releases) - [Changelog](https://github.com/vitejs/vite-plugin-vue/blob/main/packages/plugin-vue/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite-plugin-vue/commits/plugin-vue@4.5.0/packages/plugin-vue) --- updated-dependencies: - dependency-name: "@vitejs/plugin-vue" 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> * Do not send case traige reminders if there is no assignee. (#4008) * Do not send case traige reminders if there is no assignee. * Case assignee check fails faster. * Make assignee combobox behave like participant select (#4018) * Bump sentry-sdk from 1.35.0 to 1.37.0 (#4027) Bumps [sentry-sdk](https://github.com/getsentry/sentry-python) from 1.35.0 to 1.37.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.35.0...1.37.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> * Bump ipython from 8.17.2 to 8.18.0 (#4026) Bumps [ipython](https://github.com/ipython/ipython) from 8.17.2 to 8.18.0. - [Release notes](https://github.com/ipython/ipython/releases) - [Commits](https://github.com/ipython/ipython/compare/8.17.2...8.18.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> * Bump slack-sdk from 3.24.0 to 3.26.0 (#4025) Bumps [slack-sdk](https://github.com/slackapi/python-slack-sdk) from 3.24.0 to 3.26.0. - [Release notes](https://github.com/slackapi/python-slack-sdk/releases) - [Changelog](https://github.com/slackapi/python-slack-sdk/blob/main/docs-v2/changelog.html) - [Commits](https://github.com/slackapi/python-slack-sdk/compare/v3.24.0...v3.26.0) --- updated-dependencies: - dependency-name: slack-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> * Bump openai from 1.3.3 to 1.3.5 (#4024) Bumps [openai](https://github.com/openai/openai-python) from 1.3.3 to 1.3.5. - [Release notes](https://github.com/openai/openai-python/releases) - [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/openai/openai-python/compare/v1.3.3...v1.3.5) --- 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 faker from 20.0.3 to 20.1.0 (#4021) Bumps [faker](https://github.com/joke2k/faker) from 20.0.3 to 20.1.0. - [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/v20.0.3...v20.1.0) --- updated-dependencies: - dependency-name: faker 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> * Dark mode not persisting through reloads (#4033) * Bump slack-bolt from 1.18.0 to 1.18.1 (#4032) Bumps [slack-bolt](https://github.com/slackapi/bolt-python) from 1.18.0 to 1.18.1. - [Release notes](https://github.com/slackapi/bolt-python/releases) - [Commits](https://github.com/slackapi/bolt-python/compare/v1.18.0...v1.18.1) --- updated-dependencies: - dependency-name: slack-bolt 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 aiohttp from 3.9.0 to 3.9.1 (#4031) Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.9.0 to 3.9.1. - [Release notes](https://github.com/aio-libs/aiohttp/releases) - [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst) - [Commits](https://github.com/aio-libs/aiohttp/compare/v3.9.0...v3.9.1) --- updated-dependencies: - dependency-name: aiohttp 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 sentry-sdk from 1.37.0 to 1.37.1 (#4030) Bumps [sentry-sdk](https://github.com/getsentry/sentry-python) from 1.37.0 to 1.37.1. - [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.37.0...1.37.1) --- updated-dependencies: - dependency-name: sentry-sdk 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 eslint-plugin-local-rules in /src/dispatch/static/dispatch (#4029) Bumps [eslint-plugin-local-rules](https://github.com/cletusw/eslint-plugin-local-rules) from 2.0.0 to 2.0.1. - [Release notes](https://github.com/cletusw/eslint-plugin-local-rules/releases) - [Commits](https://github.com/cletusw/eslint-plugin-local-rules/compare/v2.0.0...v2.0.1) --- updated-dependencies: - dependency-name: eslint-plugin-local-rules 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 vue from 3.3.8 to 3.3.9 in /src/dispatch/static/dispatch (#4028) Bumps [vue](https://github.com/vuejs/core) from 3.3.8 to 3.3.9. - [Release notes](https://github.com/vuejs/core/releases) - [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md) - [Commits](https://github.com/vuejs/core/compare/v3.3.8...v3.3.9) --- updated-dependencies: - dependency-name: vue 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> * Fixes signal instance deduplication check. (#4012) * Deduplicating signal instances should only occur when those instances are associated with a case. * Checks for existing cases before signal deduplication. --------- Co-authored-by: kevgliss * Swallow send exceptions (#4034) * All backend. Creates new models and adds new API calls for improving the cost model. Refactors Plugin Events. Creates PluginEvents views. Adds routes for incident cost model deletion. Adds incident cost model to the scheduler. * Front end * Refactors incident cost calculations * Fixed front end. * Updates to database schema. * Removes unused code/comments. * Restores main branch's package-lock.json. * Fixes Pylint errors. * Fixes JavaScript lint errors. * Fixes cost model documentation. * Documents which plugin events are currently supported for the incident cost model. * Adds image of edit sheet to the incident cost model documentation. * Increases test code readability. * Cleans up import statements. * Update src/dispatch/incident_cost_model/service.py Co-authored-by: Marc Vilanova <39573146+mvilanova@users.noreply.github.com> * Update src/dispatch/incident_cost_model/service.py Co-authored-by: Marc Vilanova <39573146+mvilanova@users.noreply.github.com> * Database migration cleanup. * Fixes error messages and adds doc strings to functions. * Update src/dispatch/incident_cost_model/service.py Co-authored-by: Marc Vilanova <39573146+mvilanova@users.noreply.github.com> * Update src/dispatch/incident_cost/service.py Co-authored-by: Marc Vilanova <39573146+mvilanova@users.noreply.github.com> * Code cleanup. * Code cleanup. * Code cleanup. * Renames IncidentCostModel* to CostModel* * Fixes lint errors * Fixes markdown link * Adds additional validation. * Adds additional validation. * Adds cost model selection in Slack interface. * Updates cost model documentation with concrete cost calculation examples. * Cost Model Activity Dialog only displays Plugins with PluginEvents in the PluginInstanceCombobox. * Fixes database revision change --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Marc Vilanova <39573146+mvilanova@users.noreply.github.com> Co-authored-by: Will Sheldon <114631109+wssheldon@users.noreply.github.com> Co-authored-by: kevgliss Co-authored-by: Kael Co-authored-by: David Whittaker Co-authored-by: David Whittaker <84562015+whitdog47@users.noreply.github.com> Co-authored-by: Jason Schroth --- docs/docs/user-guide/cost_model.mdx | 105 +++++++ docs/static/img/admin-ui-cost-model.png | Bin 0 -> 88402 bytes docs/static/img/admin-ui-edit-cost-model.png | Bin 0 -> 28821 bytes src/dispatch/__init__.py | 13 +- src/dispatch/api.py | 11 +- src/dispatch/cli.py | 20 +- src/dispatch/conversation/enums.py | 5 + src/dispatch/cost_model/__init__.py | 0 src/dispatch/cost_model/models.py | 111 +++++++ src/dispatch/cost_model/service.py | 198 ++++++++++++ src/dispatch/cost_model/views.py | 76 +++++ src/dispatch/database/core.py | 5 +- .../core/versions/2023-12-27_ed0b0388fa3f.py | 57 ++++ .../versions/2023-12-27_065c59f15267.py | 105 +++++++ src/dispatch/feedback/service/views.py | 12 +- src/dispatch/incident/flows.py | 2 +- src/dispatch/incident/models.py | 20 +- src/dispatch/incident/service.py | 22 ++ src/dispatch/incident/views.py | 15 +- src/dispatch/incident_cost/models.py | 3 +- src/dispatch/incident_cost/scheduled.py | 1 - src/dispatch/incident_cost/service.py | 177 +++++++++-- src/dispatch/incident_cost_type/service.py | 5 +- src/dispatch/messaging/strings.py | 72 ++--- src/dispatch/participant/service.py | 14 +- src/dispatch/participant_activity/__init__.py | 0 src/dispatch/participant_activity/models.py | 48 +++ src/dispatch/participant_activity/service.py | 164 ++++++++++ src/dispatch/plugin/models.py | 51 +++- src/dispatch/plugin/service.py | 40 ++- src/dispatch/plugin/views.py | 7 + src/dispatch/plugins/base/v1.py | 14 + .../dispatch_slack/case/interactive.py | 2 + src/dispatch/plugins/dispatch_slack/events.py | 64 ++++ src/dispatch/plugins/dispatch_slack/fields.py | 32 ++ .../dispatch_slack/incident/interactive.py | 28 ++ src/dispatch/plugins/dispatch_slack/plugin.py | 43 ++- .../plugins/dispatch_slack/service.py | 87 +++++- .../plugins/dispatch_test/conversation.py | 24 ++ .../cost_model/CostModelActivityDialog.vue | 177 +++++++++++ .../src/cost_model/CostModelActivityInput.vue | 123 ++++++++ .../src/cost_model/CostModelCombobox.vue | 130 ++++++++ .../dispatch/src/cost_model/DeleteDialog.vue | 34 +++ .../dispatch/src/cost_model/NewEditSheet.vue | 110 +++++++ .../static/dispatch/src/cost_model/Table.vue | 149 +++++++++ .../static/dispatch/src/cost_model/api.js | 21 ++ .../static/dispatch/src/cost_model/store.js | 177 +++++++++++ .../dispatch/src/incident/DetailsTab.vue | 12 + .../src/incident/ReportSubmissionCard.vue | 13 + .../src/incident/ReportSubmissionForm.vue | 13 + .../static/dispatch/src/incident/store.js | 1 + .../src/plugin/PluginEventCombobox.vue | 126 ++++++++ .../src/plugin/PluginInstanceCombobox.vue | 23 +- .../static/dispatch/src/plugin/api.js | 6 + .../static/dispatch/src/plugin/store.js | 6 +- .../static/dispatch/src/router/config.js | 6 + src/dispatch/static/dispatch/src/store.js | 2 + tests/conftest.py | 24 ++ tests/cost_model/test_cost_model_service.py | 282 ++++++++++++++++++ tests/factories.py | 79 ++++- .../test_incident_cost_service.py | 69 +++++ .../test_incident_cost_type_service.py | 2 +- .../test_participant_activity_service.py | 212 +++++++++++++ tests/plugin/test_plugin_service.py | 55 ++++ 64 files changed, 3364 insertions(+), 141 deletions(-) create mode 100644 docs/docs/user-guide/cost_model.mdx create mode 100644 docs/static/img/admin-ui-cost-model.png create mode 100644 docs/static/img/admin-ui-edit-cost-model.png create mode 100644 src/dispatch/cost_model/__init__.py create mode 100644 src/dispatch/cost_model/models.py create mode 100644 src/dispatch/cost_model/service.py create mode 100644 src/dispatch/cost_model/views.py create mode 100644 src/dispatch/database/revisions/core/versions/2023-12-27_ed0b0388fa3f.py create mode 100644 src/dispatch/database/revisions/tenant/versions/2023-12-27_065c59f15267.py create mode 100644 src/dispatch/participant_activity/__init__.py create mode 100644 src/dispatch/participant_activity/models.py create mode 100644 src/dispatch/participant_activity/service.py create mode 100644 src/dispatch/plugins/dispatch_slack/events.py create mode 100644 src/dispatch/static/dispatch/src/cost_model/CostModelActivityDialog.vue create mode 100644 src/dispatch/static/dispatch/src/cost_model/CostModelActivityInput.vue create mode 100644 src/dispatch/static/dispatch/src/cost_model/CostModelCombobox.vue create mode 100644 src/dispatch/static/dispatch/src/cost_model/DeleteDialog.vue create mode 100644 src/dispatch/static/dispatch/src/cost_model/NewEditSheet.vue create mode 100644 src/dispatch/static/dispatch/src/cost_model/Table.vue create mode 100644 src/dispatch/static/dispatch/src/cost_model/api.js create mode 100644 src/dispatch/static/dispatch/src/cost_model/store.js create mode 100644 src/dispatch/static/dispatch/src/plugin/PluginEventCombobox.vue create mode 100644 tests/cost_model/test_cost_model_service.py create mode 100644 tests/participant_activity/test_participant_activity_service.py diff --git a/docs/docs/user-guide/cost_model.mdx b/docs/docs/user-guide/cost_model.mdx new file mode 100644 index 000000000000..7f77386fed52 --- /dev/null +++ b/docs/docs/user-guide/cost_model.mdx @@ -0,0 +1,105 @@ +# Cost Model + +Our Cost Model is a feature that enables teams to estimate response cost for each incident. Users can opt in to create and use personalized cost calculations for each incident based on participant activity. + +If no cost model is assigned to an incident, the default classic cost model will be used. See [Incident Cost Type](../administration/settings/incident/incident-cost-type.mdx###calculating-incident-cost). + +
+ +![](/img/admin-ui-cost-model.png) + +
+ +## Key Features + +### Customizable Cost Models +Users have the flexibility to define their unique cost models based on their organization's workflow and tools. This customization can be tailored to each incident, providing a versitile approach to cost calculation. The cost model for an incident can be changed at any time during its lifespan. All participant activity costs moving forward will be calculated using the new cost model. + +### Plugin-Based Tracking +Users can track costs from their existing tools by using our plugin-based tracking system. Users have the flexibility to select which plugins and specific plugin events they want to track, offering a targeted approach to cost calculation. + +### Effort Assignment +For each tracked activity, users can assign a quantifiable measure of effort, represented in seconds of work time. This feature provides a more accurate representation of the cost of an incident. + +### Incident Cost Calculation +Incident cost calculation is based on the cost model and effort assignment for each tracked participant activity. This helps in understanding resource utilization and cost of an incident. + + +## Currently Supported Plugin Events + +### Slack: Channel Activity +This event tracks activity within a specific Slack channel. By periodically polling channel messages, this gathers insights into the activity and engagement levels of each participant. + +### Slack: Thread Activity +This event tracks activity within a specific Slack thread. By periodically polling thread replies, this gathers insights into the activity and engagement levels of each participant. + + +
+ +![](/img/admin-ui-edit-cost-model.png) + +
+ +## Cost Calculation Examples + +Below, we illustrate the use of the cost model through two examples. These are based on the following values: + +Cost Model 1 + +| Plugin Event | Response Time (seconds) +| ------------ | ------------- +| Slack Channel Activity | 300 + +The employee hourly rate can be adjusted by modifying the `Annual Employee Cost` and `Business Year Hours` fields in the [project settings](../administration/settings/project.mdx). In these examples, we will use the following value: +``` +hourly_rate = 100 +``` + +#### Example 1 + +Consider the following Slack channel activity for `Incident 1`: + +| Slack Channel Activity Timestamp | Participant +| ------------ | ------------- +| 100 | Cookie Doe +| 200 | Nate Flex + +The resulting recorded participant activity will be: + +| Participant | started_at | ended_at | Plugin Event | Incident +| ------------ | ------------- | ------------- | ------------- | ------------- +| Cookie Doe | 100 | 400 | Slack Channel Activity | Incident 1 +| Nate Flex | 200 | 500 | Slack Channel Activity | Incident 1 + + +The incident cost is then calculated as: + +``` +( (400 - 100) + (500-200) ) / SECONDS_IN_HOUR * hourly_rate = $16.67 +``` + +#### Example 2 + +Consider the following Slack channel activity for `Incident 2`: + +| Slack Channel Activity Timestamp | Participant +| ------------ | ------------- +| 100 | Cookie Doe +| 150 | Cookie Doe +| 200 | Nate Flex +| 500 | Cookie Doe + +The resulting recorded participant activity will be: + +| Participant | started_at | ended_at | Plugin Event | Incident +| ------------ | ------------- | ------------- | ------------- | ------------- +| Cookie Doe | 100 | 450 | Slack Channel Activity | Incident 2 +| Nate Flex | 200 | 500 | Slack Channel Activity | Incident 2 +| Cookie Doe | 500 | 800 | Slack Channel Activity | Incident 2 + + +The incident cost is then calculated as: + +``` +( (450 - 100) + (500 - 200) + (800 - 500) ) / SECONDS_IN_HOUR * hourly_rate = $26.39 +``` diff --git a/docs/static/img/admin-ui-cost-model.png b/docs/static/img/admin-ui-cost-model.png new file mode 100644 index 0000000000000000000000000000000000000000..c0148fc5bf3594832d0b8f528dbb0b2918802099 GIT binary patch literal 88402 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 literal 0 HcmV?d00001 diff --git a/docs/static/img/admin-ui-edit-cost-model.png b/docs/static/img/admin-ui-edit-cost-model.png new file mode 100644 index 0000000000000000000000000000000000000000..aefb98d84acd1bd72517fb0c22b3eb1274ebf094 GIT binary patch literal 28821 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 bool: + seen = set() + for activity in cost_model_in.activities: + if activity.plugin_event.id in seen: + log.warning( + f"Duplicate plugin event id detected. Please ensure all plugin events are unique for each cost model. Duplicate id: {activity.plugin_event.id}" + ) + return False + seen.add(activity.plugin_event.id) + return True + + +def get_all(*, db_session, project_id: int) -> List[CostModel]: + """Returns all cost models.""" + if project_id: + return db_session.query(CostModel).filter(CostModel.project_id == project_id) + return db_session.query(CostModel) + + +def get_cost_model_activity_by_id(*, db_session, cost_model_activity_id: int) -> CostModelActivity: + """Returns a cost model activity based on the given cost model activity id.""" + return ( + db_session.query(CostModelActivity) + .filter(CostModelActivity.id == cost_model_activity_id) + .one() + ) + + +def delete_cost_model_activity(*, db_session, cost_model_activity_id: int): + """Deletes a cost model activity.""" + cost_model_activity = get_cost_model_activity_by_id( + db_session=db_session, cost_model_activity_id=cost_model_activity_id + ) + db_session.delete(cost_model_activity) + db_session.commit() + + +def update_cost_model_activity(*, db_session, cost_model_activity_in: CostModelActivityUpdate): + """Updates a cost model activity.""" + cost_model_activity = get_cost_model_activity_by_id( + db_session=db_session, cost_model_activity_id=cost_model_activity_in.id + ) + + cost_model_activity.response_time_seconds = cost_model_activity_in.response_time_seconds + cost_model_activity.enabled = cost_model_activity_in.enabled + cost_model_activity.plugin_event_id = cost_model_activity_in.plugin_event.id + + db_session.commit() + return cost_model_activity + + +def create_cost_model_activity( + *, db_session, cost_model_activity_in: CostModelActivityCreate +) -> CostModelActivity: + cost_model_activity = CostModelActivity( + response_time_seconds=cost_model_activity_in.response_time_seconds, + enabled=cost_model_activity_in.enabled, + plugin_event_id=cost_model_activity_in.plugin_event.id, + ) + + db_session.add(cost_model_activity) + db_session.commit() + return cost_model_activity + + +def delete(*, db_session, cost_model_id: int): + """Deletes a cost model.""" + cost_model = get_cost_model_by_id(db_session=db_session, cost_model_id=cost_model_id) + if not cost_model: + raise ValueError( + f"Unable to delete cost model. No cost model found with id {cost_model_id}." + ) + + db_session.delete(cost_model) + db_session.commit() + + +def update(*, db_session, cost_model_in: CostModelUpdate) -> CostModel: + """Updates a cost model.""" + if not has_unique_plugin_event(cost_model_in): + raise KeyError("Unable to update cost model. Duplicate plugin event ids detected.") + + cost_model = get_cost_model_by_id(db_session=db_session, cost_model_id=cost_model_in.id) + if not cost_model: + raise ValueError("Unable to update cost model. No cost model found with that id.") + + cost_model.name = cost_model_in.name + cost_model.description = cost_model_in.description + cost_model.enabled = cost_model_in.enabled + cost_model.created_at = cost_model_in.created_at + cost_model.updated_at = ( + cost_model_in.updated_at if cost_model_in.updated_at else datetime.utcnow() + ) + + # Update all recognized activities. Delete all removed activites. + update_activities = [] + delete_activities = [] + + for activity in cost_model.activities: + updated = False + for idx_in, activity_in in enumerate(cost_model_in.activities): + if activity.plugin_event.id == activity_in.plugin_event.id: + update_activities.append((activity, activity_in)) + cost_model_in.activities.pop(idx_in) + updated = True + break + if updated: + continue + + # Delete activities that have been removed from the cost model. + delete_activities.append(activity) + + for activity, activity_in in update_activities: + activity.response_time_seconds = activity_in.response_time_seconds + activity.enabled = activity_in.enabled + activity.plugin_event = plugin_service.get_plugin_event_by_id( + db_session=db_session, plugin_event_id=activity_in.plugin_event.id + ) + + for activity in delete_activities: + cost_model_service.delete_cost_model_activity( + db_session=db_session, cost_model_activity_id=activity.id + ) + + # Create new activities. + for activity_in in cost_model_in.activities: + activity_out = cost_model_service.create_cost_model_activity( + db_session=db_session, cost_model_activity_in=activity_in + ) + + if not activity_out: + log.error("Failed to create cost model activity. Continuing.") + continue + + cost_model.activities.append(activity_out) + + db_session.commit() + return cost_model + + +def create(*, db_session, cost_model_in: CostModelCreate) -> CostModel: + """Creates a new cost model.""" + if not has_unique_plugin_event(cost_model_in): + raise KeyError("Unable to update cost model. Duplicate plugin event ids detected.") + + project = project_service.get_by_name_or_raise( + db_session=db_session, project_in=cost_model_in.project + ) + + cost_model = CostModel( + **cost_model_in.dict(exclude={"activities", "project"}), + activities=[], + project=project, + ) + + db_session.add(cost_model) + db_session.commit() + + # Create activities after the cost model is created. + # We need the cost model id to map to the activity. + if cost_model and cost_model_in.activities: + for activity_in in cost_model_in.activities: + activity_out = cost_model_service.create_cost_model_activity( + db_session=db_session, cost_model_activity_in=activity_in + ) + if not activity_out: + log.error("Failed to create cost model activity. Continuing.") + continue + + cost_model.activities.append(activity_out) + + db_session.commit() + return cost_model + + +def get_cost_model_by_id(*, db_session, cost_model_id: int) -> CostModel: + """Returns a cost model based on the given cost model id.""" + return db_session.query(CostModel).filter(CostModel.id == cost_model_id).one() diff --git a/src/dispatch/cost_model/views.py b/src/dispatch/cost_model/views.py new file mode 100644 index 000000000000..8d41b8e310a7 --- /dev/null +++ b/src/dispatch/cost_model/views.py @@ -0,0 +1,76 @@ +from fastapi import APIRouter, Depends, HTTPException, status +import logging +from sqlalchemy.exc import IntegrityError + +from dispatch.auth.permissions import SensitiveProjectActionPermission, PermissionsDependency +from dispatch.database.core import DbSession +from dispatch.database.service import CommonParameters, search_filter_sort_paginate +from dispatch.models import PrimaryKey + +from .models import ( + CostModelCreate, + CostModelPagination, + CostModelRead, + CostModelUpdate, +) +from .service import create, update, delete + +log = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("", response_model=CostModelPagination) +def get_cost_models(common: CommonParameters): + """Get all cost models, or only those matching a given search term.""" + return search_filter_sort_paginate(model="CostModel", **common) + + +@router.post( + "", + summary="Creates a new cost model.", + response_model=CostModelRead, + dependencies=[Depends(PermissionsDependency([SensitiveProjectActionPermission]))], +) +def create_cost_model( + db_session: DbSession, + cost_model_in: CostModelCreate, +): + """Create a cost model.""" + return create(db_session=db_session, cost_model_in=cost_model_in) + + +@router.put( + "/{cost_model_id}", + summary="Modifies an existing cost model.", + response_model=CostModelRead, + dependencies=[Depends(PermissionsDependency([SensitiveProjectActionPermission]))], +) +def update_cost_model( + cost_model_id: PrimaryKey, + db_session: DbSession, + cost_model_in: CostModelUpdate, +): + """Modifies an existing cost model.""" + return update(db_session=db_session, cost_model_in=cost_model_in) + + +@router.delete( + "/{cost_model_id}", + response_model=None, + summary="Deletes a cost model and its activities.", + dependencies=[Depends(PermissionsDependency([SensitiveProjectActionPermission]))], +) +def delete_cost_model( + cost_model_id: PrimaryKey, + db_session: DbSession, +): + """Deletes a cost model and its external resources.""" + try: + delete(cost_model_id=cost_model_id, db_session=db_session) + except IntegrityError as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=[{"msg": (f"Cost Model with id {cost_model_id} could not be deleted. ")}], + ) from None diff --git a/src/dispatch/database/core.py b/src/dispatch/database/core.py index 4410aa2ed789..fab5c47d3186 100644 --- a/src/dispatch/database/core.py +++ b/src/dispatch/database/core.py @@ -92,8 +92,9 @@ def _repr_attrs_str(self): for key in self.__repr_attrs__: if not hasattr(self, key): raise KeyError( - "{} has incorrect attribute '{}' in " - "__repr__attrs__".format(self.__class__, key) + "{} has incorrect attribute '{}' in " "__repr__attrs__".format( + self.__class__, key + ) ) value = getattr(self, key) wrap_in_quote = isinstance(value, str) diff --git a/src/dispatch/database/revisions/core/versions/2023-12-27_ed0b0388fa3f.py b/src/dispatch/database/revisions/core/versions/2023-12-27_ed0b0388fa3f.py new file mode 100644 index 000000000000..ee060a1c7ee5 --- /dev/null +++ b/src/dispatch/database/revisions/core/versions/2023-12-27_ed0b0388fa3f.py @@ -0,0 +1,57 @@ +"""Adds the plugin_event table. + +Revision ID: ed0b0388fa3f +Revises: 5c60513d6e5e +Create Date: 2023-12-27 13:44:17.960851 + +""" +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils + +# revision identifiers, used by Alembic. +revision = "ed0b0388fa3f" +down_revision = "5c60513d6e5e" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "plugin_event", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=True), + sa.Column("slug", sa.String(), nullable=True), + sa.Column("description", sa.String(), nullable=True), + sa.Column("plugin_id", sa.Integer(), nullable=True), + sa.Column("search_vector", sqlalchemy_utils.types.ts_vector.TSVectorType(), nullable=True), + sa.ForeignKeyConstraint( + ["plugin_id"], + ["dispatch_core.plugin.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("slug"), + schema="dispatch_core", + ) + op.create_index( + "plugin_event_search_vector_idx", + "plugin_event", + ["search_vector"], + unique=False, + schema="dispatch_core", + postgresql_using="gin", + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + "plugin_event_search_vector_idx", + table_name="plugin_event", + schema="dispatch_core", + postgresql_using="gin", + ) + op.drop_table("plugin_event", schema="dispatch_core") + # ### end Alembic commands ### diff --git a/src/dispatch/database/revisions/tenant/versions/2023-12-27_065c59f15267.py b/src/dispatch/database/revisions/tenant/versions/2023-12-27_065c59f15267.py new file mode 100644 index 000000000000..e17c726a2384 --- /dev/null +++ b/src/dispatch/database/revisions/tenant/versions/2023-12-27_065c59f15267.py @@ -0,0 +1,105 @@ +"""Adds cost model tables: cost_model, cost_model_activity, participant_activity + +Revision ID: 065c59f15267 +Revises: 6c1a250b1e4b +Create Date: 2023-12-27 13:44:18.845443 + +""" +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils + +# revision identifiers, used by Alembic. +revision = "065c59f15267" +down_revision = "6c1a250b1e4b" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "cost_model", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=True), + sa.Column("description", sa.String(), nullable=True), + sa.Column("enabled", sa.Boolean(), nullable=True), + sa.Column("search_vector", sqlalchemy_utils.types.ts_vector.TSVectorType(), nullable=True), + sa.Column("project_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name", "project_id"), + ) + op.create_index( + "cost_model_search_vector_idx", + "cost_model", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + op.create_table( + "cost_model_activity", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("plugin_event_id", sa.Integer(), nullable=True), + sa.Column("response_time_seconds", sa.Integer(), nullable=True), + sa.Column("enabled", sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint( + ["plugin_event_id"], ["dispatch_core.plugin_event.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "assoc_cost_model_activities", + sa.Column("cost_model_id", sa.Integer(), nullable=False), + sa.Column("cost_model_activity_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["cost_model_activity_id"], + ["cost_model_activity.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint(["cost_model_id"], ["cost_model.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("cost_model_id", "cost_model_activity_id"), + ) + op.create_table( + "participant_activity", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("plugin_event_id", sa.Integer(), nullable=True), + sa.Column("started_at", sa.DateTime(), nullable=True), + sa.Column("ended_at", sa.DateTime(), nullable=True), + sa.Column("participant_id", sa.Integer(), nullable=True), + sa.Column("incident_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["incident_id"], + ["incident.id"], + ), + sa.ForeignKeyConstraint( + ["participant_id"], + ["participant.id"], + ), + sa.ForeignKeyConstraint( + ["plugin_event_id"], + ["dispatch_core.plugin_event.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.add_column("incident", sa.Column("cost_model_id", sa.Integer(), nullable=True)) + op.create_foreign_key(None, "incident", "cost_model", ["cost_model_id"], ["id"]) + # # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "incident", type_="foreignkey") + op.drop_column("incident", "cost_model_id") + op.drop_table("participant_activity") + op.drop_table("assoc_cost_model_activities") + op.drop_table("cost_model_activity") + op.drop_index( + "cost_model_search_vector_idx", + table_name="cost_model", + postgresql_using="gin", + ) + op.drop_table("cost_model") + # ### end Alembic commands ### diff --git a/src/dispatch/feedback/service/views.py b/src/dispatch/feedback/service/views.py index f094ca8207f0..2359f0c0228c 100644 --- a/src/dispatch/feedback/service/views.py +++ b/src/dispatch/feedback/service/views.py @@ -17,9 +17,9 @@ @router.get( - "", - response_model=ServiceFeedbackPagination, - dependencies=[Depends(PermissionsDependency([SensitiveProjectActionPermission]))], + "", + response_model=ServiceFeedbackPagination, + dependencies=[Depends(PermissionsDependency([SensitiveProjectActionPermission]))], ) def get_feedback_entries(commons: CommonParameters): """Get all feedback entries, or only those matching a given search term.""" @@ -27,9 +27,9 @@ def get_feedback_entries(commons: CommonParameters): @router.get( - "/{service_feedback_id}", - response_model=ServiceFeedbackRead, - dependencies=[Depends(PermissionsDependency([SensitiveProjectActionPermission]))], + "/{service_feedback_id}", + response_model=ServiceFeedbackRead, + dependencies=[Depends(PermissionsDependency([SensitiveProjectActionPermission]))], ) def get_feedback(db_session: DbSession, service_feedback_id: PrimaryKey): """Get a feedback entry by its id.""" diff --git a/src/dispatch/incident/flows.py b/src/dispatch/incident/flows.py index b46b82fe4e93..c3d4a9394085 100644 --- a/src/dispatch/incident/flows.py +++ b/src/dispatch/incident/flows.py @@ -687,7 +687,7 @@ def incident_update_flow( group_plugin = plugin_service.get_active_instance( db_session=db_session, project_id=incident.project.id, plugin_type="participant-group" ) - if group_plugin: + if group_plugin and incident.notifications_group: team_participant_emails = [x.email for x in team_participants] group_plugin.instance.add(incident.notifications_group.email, team_participant_emails) diff --git a/src/dispatch/incident/models.py b/src/dispatch/incident/models.py index 75b5dbb77036..0cb1a980cbcf 100644 --- a/src/dispatch/incident/models.py +++ b/src/dispatch/incident/models.py @@ -11,6 +11,7 @@ from dispatch.conference.models import ConferenceRead from dispatch.conversation.models import ConversationRead +from dispatch.cost_model.models import CostModelRead from dispatch.database.core import Base from dispatch.document.models import Document, DocumentRead from dispatch.enums import Visibility @@ -34,7 +35,10 @@ IncidentTypeRead, IncidentTypeReadMinimal, ) -from dispatch.incident_cost.models import IncidentCostRead, IncidentCostUpdate +from dispatch.incident_cost.models import ( + IncidentCostRead, + IncidentCostUpdate, +) from dispatch.messaging.strings import INCIDENT_RESOLUTION_DEFAULT from dispatch.models import ( DispatchBase, @@ -221,13 +225,19 @@ def last_executive_report(self): notifications_group_id = Column(Integer, ForeignKey("group.id")) notifications_group = relationship("Group", foreign_keys=[notifications_group_id]) + cost_model_id = Column(Integer, ForeignKey("cost_model.id"), nullable=True, default=None) + cost_model = relationship( + "CostModel", + foreign_keys=[cost_model_id], + ) + @hybrid_property def total_cost(self): + total_cost = 0 if self.incident_costs: - total_cost = 0 for cost in self.incident_costs: total_cost += cost.amount - return total_cost + return total_cost @observes("participants") def participant_observer(self, participants): @@ -286,6 +296,7 @@ def description_required(cls, v): class IncidentCreate(IncidentBase): commander: Optional[ParticipantUpdate] commander_email: Optional[str] + cost_model: Optional[CostModelRead] = None incident_priority: Optional[IncidentPriorityCreate] incident_severity: Optional[IncidentSeverityCreate] incident_type: Optional[IncidentTypeCreate] @@ -302,6 +313,7 @@ class IncidentReadMinimal(IncidentBase): closed_at: Optional[datetime] = None commander: Optional[ParticipantReadMinimal] commanders_location: Optional[str] + cost_model: Optional[CostModelRead] = None created_at: Optional[datetime] = None duplicates: Optional[List[IncidentReadMinimal]] = [] incident_costs: Optional[List[IncidentCostRead]] = [] @@ -327,6 +339,7 @@ class IncidentReadMinimal(IncidentBase): class IncidentUpdate(IncidentBase): cases: Optional[List[CaseRead]] = [] commander: Optional[ParticipantUpdate] + cost_model: Optional[CostModelRead] = None delay_executive_report_reminder: Optional[datetime] = None delay_tactical_report_reminder: Optional[datetime] = None duplicates: Optional[List[IncidentReadMinimal]] = [] @@ -364,6 +377,7 @@ class IncidentRead(IncidentBase): commanders_location: Optional[str] conference: Optional[ConferenceRead] = None conversation: Optional[ConversationRead] = None + cost_model: Optional[CostModelRead] = None created_at: Optional[datetime] = None delay_executive_report_reminder: Optional[datetime] = None delay_tactical_report_reminder: Optional[datetime] = None diff --git a/src/dispatch/incident/service.py b/src/dispatch/incident/service.py index 1048dc0b5e3d..3d5649784c2b 100644 --- a/src/dispatch/incident/service.py +++ b/src/dispatch/incident/service.py @@ -12,6 +12,7 @@ from dispatch.decorators import timer from dispatch.case import service as case_service +from dispatch.cost_model import service as cost_model_service from dispatch.database.core import SessionLocal from dispatch.event import service as event_service from dispatch.exceptions import NotFoundError @@ -169,6 +170,13 @@ def create(*, db_session, incident_in: IncidentCreate) -> Incident: project_id=project.id, ) + cost_model = None + if incident_in.cost_model: + cost_model = cost_model_service.get_cost_model_by_id( + db_session=db_session, + cost_model_id=incident_in.cost_model.id, + ) + visibility = incident_type.visibility if incident_in.visibility: visibility = incident_in.visibility @@ -188,6 +196,7 @@ def create(*, db_session, incident_in: IncidentCreate) -> Incident: tags=tag_objs, title=incident_in.title, visibility=visibility, + cost_model=cost_model, ) db_session.add(incident) @@ -328,6 +337,13 @@ def update(*, db_session, incident: Incident, incident_in: IncidentUpdate) -> In incident_priority_in=incident_in.incident_priority, ) + cost_model = None + if incident_in.cost_model and incident_in.cost_model.id != incident.cost_model_id: + cost_model = cost_model_service.get_cost_model_by_id( + db_session=db_session, + cost_model_id=incident_in.cost_model.id, + ) + cases = [] for c in incident_in.cases: cases.append(case_service.get(db_session=db_session, case_id=c.id)) @@ -358,6 +374,7 @@ def update(*, db_session, incident: Incident, incident_in: IncidentUpdate) -> In "cases", "commander", "duplicates", + "cost_model", "incident_costs", "incident_priority", "incident_severity", @@ -375,6 +392,7 @@ def update(*, db_session, incident: Incident, incident_in: IncidentUpdate) -> In setattr(incident, field, update_data[field]) incident.cases = cases + incident.cost_model = cost_model incident.duplicates = duplicates incident.incident_costs = incident_costs incident.incident_priority = incident_priority @@ -387,6 +405,10 @@ def update(*, db_session, incident: Incident, incident_in: IncidentUpdate) -> In db_session.commit() + # Update total incident reponse cost. + incident_cost_service.update_incident_response_cost( + incident_id=incident.id, db_session=db_session + ) return incident diff --git a/src/dispatch/incident/views.py b/src/dispatch/incident/views.py index 9924ecabb924..ddc90c6e51fc 100644 --- a/src/dispatch/incident/views.py +++ b/src/dispatch/incident/views.py @@ -1,14 +1,11 @@ import calendar -import json -import logging from datetime import date, datetime -from typing import Annotated, List - from dateutil.relativedelta import relativedelta - from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status +import json +import logging +from typing import Annotated, List from starlette.requests import Request - from sqlalchemy.exc import IntegrityError from dispatch.auth.permissions import ( @@ -22,14 +19,14 @@ from dispatch.common.utils.views import create_pydantic_include from dispatch.database.core import DbSession from dispatch.database.service import CommonParameters, search_filter_sort_paginate +from dispatch.event import flows as event_flows +from dispatch.event.models import EventUpdate, EventCreateMinimal from dispatch.incident.enums import IncidentStatus from dispatch.individual.models import IndividualContactRead from dispatch.models import OrganizationSlug, PrimaryKey from dispatch.participant.models import ParticipantUpdate from dispatch.report import flows as report_flows -from dispatch.event import flows as event_flows from dispatch.report.models import ExecutiveReportCreate, TacticalReportCreate -from dispatch.event.models import EventUpdate, EventCreateMinimal from .flows import ( incident_add_or_reactivate_participant_flow, @@ -63,7 +60,7 @@ def get_current_incident(db_session: DbSession, request: Request) -> Incident: if not incident: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=[{"msg": "An incident with this id does not existt."}], + detail=[{"msg": "An incident with this id does not exist."}], ) return incident diff --git a/src/dispatch/incident_cost/models.py b/src/dispatch/incident_cost/models.py index 03582a955d13..858565cb16e5 100644 --- a/src/dispatch/incident_cost/models.py +++ b/src/dispatch/incident_cost/models.py @@ -1,8 +1,7 @@ -from typing import List, Optional - from sqlalchemy import Column, ForeignKey, Integer, Numeric from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.orm import relationship +from typing import List, Optional from dispatch.database.core import Base from dispatch.incident_cost_type.models import IncidentCostTypeRead diff --git a/src/dispatch/incident_cost/scheduled.py b/src/dispatch/incident_cost/scheduled.py index 072ac8bba1c5..1558d12a63bb 100644 --- a/src/dispatch/incident_cost/scheduled.py +++ b/src/dispatch/incident_cost/scheduled.py @@ -67,7 +67,6 @@ def calculate_incidents_response_cost(db_session: SessionLocal, project: Project # we calculate the response cost amount amount = calculate_incident_response_cost(incident.id, db_session) - # we don't need to update the cost amount if it hasn't changed if incident_response_cost.amount == amount: continue diff --git a/src/dispatch/incident_cost/service.py b/src/dispatch/incident_cost/service.py index 4cb074ec98d9..186739c1e19f 100644 --- a/src/dispatch/incident_cost/service.py +++ b/src/dispatch/incident_cost/service.py @@ -1,19 +1,27 @@ +from datetime import datetime, timedelta, timezone +import logging import math -from datetime import datetime - from typing import List, Optional from dispatch.database.core import SessionLocal from dispatch.incident import service as incident_service from dispatch.incident.enums import IncidentStatus +from dispatch.incident.models import Incident from dispatch.incident_cost_type import service as incident_cost_type_service +from dispatch.incident_cost_type.models import IncidentCostTypeRead +from dispatch.participant import service as participant_service +from dispatch.participant.models import ParticipantRead +from dispatch.participant_activity import service as participant_activity_service +from dispatch.participant_activity.models import ParticipantActivityCreate from dispatch.participant_role.models import ParticipantRoleType +from dispatch.plugin import service as plugin_service from .models import IncidentCost, IncidentCostCreate, IncidentCostUpdate HOURS_IN_DAY = 24 SECONDS_IN_HOUR = 3600 +log = logging.getLogger(__name__) def get(*, db_session, incident_cost_id: int) -> Optional[IncidentCost]: @@ -104,14 +112,95 @@ def get_engagement_multiplier(participant_role: str): return engagement_mappings.get(participant_role) -def calculate_incident_response_cost( - incident_id: int, db_session: SessionLocal, incident_review=True -): - """Calculates the response cost of a given incident.""" - incident = incident_service.get(db_session=db_session, incident_id=incident_id) +def get_incident_review_hours(incident: Incident) -> int: + """Calculate the time spent in incident review related activities.""" + num_participants = len(incident.participants) + incident_review_prep = ( + 1 # we make the assumption that it takes an hour to prepare the incident review + ) + incident_review_meeting = ( + num_participants * 0.5 * 1 + ) # we make the assumption that only half of the incident participants will attend the 1-hour, incident review session + return incident_review_prep + incident_review_meeting + + +def calculate_incident_response_cost_with_cost_model( + incident: Incident, db_session: SessionLocal +) -> int: + """Calculates the cost of an incident using the incident's cost model.""" participants_total_response_time_seconds = 0 + # Get the cost model. Iterate through all the listed activities we want to record. + for activity in incident.cost_model.activities: + plugin_instance = plugin_service.get_active_instance_by_slug( + db_session=db_session, + slug=activity.plugin_event.plugin.slug, + project_id=incident.project.id, + ) + if not plugin_instance: + log.warning( + f"Cannot fetch cost model activity. Its associated plugin {activity.plugin_event.plugin.title} is not enabled." + ) + continue + + oldest = "0" + response_cost_type = incident_cost_type_service.get_default( + db_session=db_session, project_id=incident.project.id + ) + incident_response_cost = get_by_incident_id_and_incident_cost_type_id( + db_session=db_session, + incident_id=incident.id, + incident_cost_type_id=response_cost_type.id, + ) + if incident_response_cost: + oldest = incident_response_cost.updated_at.replace(tzinfo=timezone.utc).timestamp() + + # Array of sorted (timestamp, user_id) tuples. + incident_events = plugin_instance.instance.fetch_incident_events( + db_session=db_session, + subject=incident, + plugin_event_id=activity.plugin_event.id, + oldest=oldest, + ) + + for ts, user_id in incident_events: + participant = participant_service.get_by_incident_id_and_conversation_id( + db_session=db_session, + incident_id=incident.id, + user_conversation_id=user_id, + ) + if not participant: + log.warning("Cannot resolve participant.") + continue + + activity_in = ParticipantActivityCreate( + plugin_event=activity.plugin_event, + started_at=ts, + ended_at=ts + timedelta(seconds=activity.response_time_seconds), + participant=ParticipantRead(id=participant.id), + incident=incident, + ) + + if participant_response_time := participant_activity_service.create_or_update( + db_session=db_session, activity_in=activity_in + ): + participants_total_response_time_seconds += ( + participant_response_time.total_seconds() + ) + + # Calculate and round up the hourly rate. + hourly_rate = math.ceil( + incident.project.annual_employee_cost / incident.project.business_year_hours + ) + additional_incident_cost = math.ceil( + (participants_total_response_time_seconds / SECONDS_IN_HOUR) * hourly_rate + ) + return incident.total_cost + additional_incident_cost + + +def calculate_incident_response_cost_with_classic_model(incident: Incident, incident_review=True): + participants_total_response_time_seconds = 0 for participant in incident.participants: participant_total_roles_time_seconds = 0 @@ -173,28 +262,74 @@ def calculate_incident_response_cost( participant_total_roles_time_seconds += participant_role_time_seconds participants_total_response_time_seconds += participant_total_roles_time_seconds - - # we calculate the time spent in incident review related activities - incident_review_hours = 0 if incident_review: - num_participants = len(incident.participants) - incident_review_prep = ( - 1 # we make the assumption that it takes an hour to prepare the incident review - ) - incident_review_meeting = ( - num_participants * 0.5 * 1 - ) # we make the assumption that only half of the incident participants will attend the 1-hour, incident review session - incident_review_hours = incident_review_prep + incident_review_meeting - + incident_review_hours = get_incident_review_hours(incident) # we calculate and round up the hourly rate hourly_rate = math.ceil( incident.project.annual_employee_cost / incident.project.business_year_hours ) # we calculate and round up the incident cost - incident_cost = math.ceil( + return math.ceil( ((participants_total_response_time_seconds / SECONDS_IN_HOUR) + incident_review_hours) * hourly_rate ) - return incident_cost + +def calculate_incident_response_cost( + incident_id: int, db_session: SessionLocal, incident_review=True +) -> int: + """Calculates the response cost of a given incident.""" + incident = incident_service.get(db_session=db_session, incident_id=incident_id) + if not incident: + log.warning(f"Incident with id {incident_id} not found.") + return 0 + if incident.cost_model and incident.cost_model.enabled: + log.info(f"Calculating {incident.name} incident cost with model {incident.cost_model}.") + return calculate_incident_response_cost_with_cost_model( + incident=incident, db_session=db_session + ) + else: + log.info("No incident cost model found. Defaulting to classic incident cost model.") + return calculate_incident_response_cost_with_classic_model( + incident=incident, incident_review=incident_review + ) + + +def update_incident_response_cost(incident_id: int, db_session: SessionLocal) -> int: + """Updates the response cost of a given incident.""" + incident = incident_service.get(db_session=db_session, incident_id=incident_id) + response_cost_type = incident_cost_type_service.get_default( + db_session=db_session, project_id=incident.project.id + ) + + if response_cost_type is None: + log.warning( + f"A default cost type for response cost doesn't exist in the {incident.project.name} project and organization {incident.project.organization.name}. Response costs for incident {incident.name} won't be calculated." + ) + return 0 + + incident_response_cost = get_by_incident_id_and_incident_cost_type_id( + db_session=db_session, + incident_id=incident.id, + incident_cost_type_id=response_cost_type.id, + ) + if incident_response_cost is None: + # we create the response cost if it doesn't exist + incident_cost_type = IncidentCostTypeRead.from_orm(response_cost_type) + incident_cost_in = IncidentCostCreate( + incident_cost_type=incident_cost_type, project=incident.project + ) + incident_response_cost = create(db_session=db_session, incident_cost_in=incident_cost_in) + amount = calculate_incident_response_cost(incident_id=incident.id, db_session=db_session) + # we don't need to update the cost amount if it hasn't changed + if incident_response_cost.amount == amount: + return incident_response_cost.amount + + # we save the new incident cost amount + incident_response_cost.amount = amount + incident.incident_costs.append(incident_response_cost) + db_session.add(incident) + db_session.commit() + + return incident_response_cost.amount diff --git a/src/dispatch/incident_cost_type/service.py b/src/dispatch/incident_cost_type/service.py index 334fd2dc763f..7850546505a5 100644 --- a/src/dispatch/incident_cost_type/service.py +++ b/src/dispatch/incident_cost_type/service.py @@ -1,6 +1,5 @@ -from typing import List, Optional - from sqlalchemy.sql.expression import true +from typing import List, Optional from dispatch.project import service as project_service @@ -44,7 +43,7 @@ def get_by_name( def get_all(*, db_session) -> List[Optional[IncidentCostType]]: """Gets all incident cost types.""" - return db_session.query(IncidentCostType) + return db_session.query(IncidentCostType).all() def create(*, db_session, incident_cost_type_in: IncidentCostTypeCreate) -> IncidentCostType: diff --git a/src/dispatch/messaging/strings.py b/src/dispatch/messaging/strings.py index 5849375b5aed..1599dc369f59 100644 --- a/src/dispatch/messaging/strings.py +++ b/src/dispatch/messaging/strings.py @@ -64,14 +64,10 @@ class MessageType(DispatchEnum): ).strip() INCIDENT_FEEDBACK_DAILY_REPORT_DESCRIPTION = """ -This is a daily report of feedback about incidents handled by you.""".replace( - "\n", " " -).strip() +This is a daily report of feedback about incidents handled by you.""".replace("\n", " ").strip() INCIDENT_DAILY_REPORT_TITLE = """ -Incidents Daily Report""".replace( - "\n", " " -).strip() +Incidents Daily Report""".replace("\n", " ").strip() INCIDENT_DAILY_REPORT_DESCRIPTION = """ This is a daily report of incidents that are currently active and incidents that have been marked as stable or closed in the last 24 hours.""".replace( @@ -91,9 +87,7 @@ class MessageType(DispatchEnum): INCIDENT_COMMANDER_DESCRIPTION = """ The Incident Commander (IC) is responsible for knowing the full context of the incident. -Contact them about any questions or concerns.""".replace( - "\n", " " -).strip() +Contact them about any questions or concerns.""".replace("\n", " ").strip() INCIDENT_COMMANDER_READDED_DESCRIPTION = """ {{ commander_fullname }} (Incident Commander) has been re-added to the conversation. @@ -118,56 +112,40 @@ class MessageType(DispatchEnum): INCIDENT_CONVERSATION_DESCRIPTION = """ Private conversation for real-time discussion. All incident participants get added to it. -""".replace( - "\n", " " -).strip() +""".replace("\n", " ").strip() INCIDENT_CONVERSATION_REFERENCE_DOCUMENT_DESCRIPTION = """ Document containing the list of slash commands available to the Incident Commander (IC) -and participants in the incident conversation.""".replace( - "\n", " " -).strip() +and participants in the incident conversation.""".replace("\n", " ").strip() INCIDENT_CONFERENCE_DESCRIPTION = """ Video conference and phone bridge to be used throughout the incident. Password: {{conference_challenge if conference_challenge else 'N/A'}} -""".replace( - "\n", "" -).strip() +""".replace("\n", "").strip() STORAGE_DESCRIPTION = """ Common storage for all artifacts and documents. Add logs, screen captures, or any other data collected during the -investigation to this folder. It is shared with all participants.""".replace( - "\n", " " -).strip() +investigation to this folder. It is shared with all participants.""".replace("\n", " ").strip() INCIDENT_INVESTIGATION_DOCUMENT_DESCRIPTION = """ This is a document for all incident facts and context. All incident participants are expected to contribute to this document. -It is shared with all incident participants.""".replace( - "\n", " " -).strip() +It is shared with all incident participants.""".replace("\n", " ").strip() CASE_INVESTIGATION_DOCUMENT_DESCRIPTION = """ This is a document for all investigation facts and context. All case participants are expected to contribute to this document. -It is shared with all participants.""".replace( - "\n", " " -).strip() +It is shared with all participants.""".replace("\n", " ").strip() INCIDENT_INVESTIGATION_SHEET_DESCRIPTION = """ This is a sheet for tracking impacted assets. All incident participants are expected to contribute to this sheet. -It is shared with all incident participants.""".replace( - "\n", " " -).strip() +It is shared with all incident participants.""".replace("\n", " ").strip() INCIDENT_FAQ_DOCUMENT_DESCRIPTION = """ First time responding to an incident? This document answers common questions encountered when -helping us respond to an incident.""".replace( - "\n", " " -).strip() +helping us respond to an incident.""".replace("\n", " ").strip() INCIDENT_REVIEW_DOCUMENT_DESCRIPTION = """ This document will capture all lessons learned, questions, and action items raised during the incident.""".replace( @@ -191,33 +169,23 @@ class MessageType(DispatchEnum): INCIDENT_RESOLUTION_DEFAULT = """ Description of the actions taken to resolve the incident. -""".replace( - "\n", " " -).strip() +""".replace("\n", " ").strip() CASE_RESOLUTION_DEFAULT = """ Description of the actions taken to resolve the case. -""".replace( - "\n", " " -).strip() +""".replace("\n", " ").strip() INCIDENT_PARTICIPANT_WELCOME_DESCRIPTION = """ You\'ve been added to this incident, because we think you may be able to help resolve it. Please review the incident details below and -reach out to the incident commander if you have any questions.""".replace( - "\n", " " -).strip() +reach out to the incident commander if you have any questions.""".replace("\n", " ").strip() INCIDENT_PARTICIPANT_SUGGESTED_READING_DESCRIPTION = """ Dispatch thinks the following documents might be -relevant to this incident.""".replace( - "\n", " " -).strip() +relevant to this incident.""".replace("\n", " ").strip() INCIDENT_NOTIFICATION_PURPOSES_FYI = """ -This message is for notification purposes only.""".replace( - "\n", " " -).strip() +This message is for notification purposes only.""".replace("\n", " ").strip() INCIDENT_TACTICAL_REPORT_DESCRIPTION = """ The following conditions, actions, and needs summarize the current status of the incident.""".replace( @@ -246,9 +214,7 @@ class MessageType(DispatchEnum): ).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() +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( @@ -273,9 +239,7 @@ class MessageType(DispatchEnum): INCIDENT_OPEN_TASKS_DESCRIPTION = """ Please resolve or transfer ownership of all the open incident tasks assigned to you in the incident documents or using the <{{dispatch_ui_url}}|Dispatch Web UI>, then wait about 30 seconds for Dispatch to update the tasks before leaving the incident conversation. -""".replace( - "\n", " " -).strip() +""".replace("\n", " ").strip() INCIDENT_MONITOR_CREATED_DESCRIPTION = """ A new monitor instance has been created. diff --git a/src/dispatch/participant/service.py b/src/dispatch/participant/service.py index e6d05367a034..0ea0d73965c1 100644 --- a/src/dispatch/participant/service.py +++ b/src/dispatch/participant/service.py @@ -1,5 +1,5 @@ from typing import List, Optional - +from dispatch.database.core import SessionLocal from dispatch.decorators import timer from dispatch.case import service as case_service from dispatch.incident import service as incident_service @@ -18,6 +18,14 @@ def get(*, db_session, participant_id: int) -> Optional[Participant]: return db_session.query(Participant).filter(Participant.id == participant_id).first() +def get_by_individual_contact_id(db_session: SessionLocal, individual_id: int) -> List[Participant]: + return ( + db_session.query(Participant) + .filter(Participant.individual_contact_id == individual_id) + .all() + ) + + def get_by_incident_id_and_role( *, db_session, incident_id: int, role: str ) -> Optional[Participant]: @@ -120,12 +128,12 @@ def get_by_case_id_and_conversation_id( def get_all(*, db_session) -> List[Optional[Participant]]: """Get all participants.""" - return db_session.query(Participant) + return db_session.query(Participant).all() def get_all_by_incident_id(*, db_session, incident_id: int) -> List[Optional[Participant]]: """Get all participants by incident id.""" - return db_session.query(Participant).filter(Participant.incident_id == incident_id) + return db_session.query(Participant).filter(Participant.incident_id == incident_id).all() def get_or_create( diff --git a/src/dispatch/participant_activity/__init__.py b/src/dispatch/participant_activity/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/dispatch/participant_activity/models.py b/src/dispatch/participant_activity/models.py new file mode 100644 index 000000000000..442fc232561e --- /dev/null +++ b/src/dispatch/participant_activity/models.py @@ -0,0 +1,48 @@ +from datetime import datetime +from sqlalchemy import Column, Integer, ForeignKey, DateTime +from sqlalchemy.orm import relationship +from typing import Optional + +from dispatch.database.core import Base +from dispatch.incident.models import IncidentRead +from dispatch.models import DispatchBase, PrimaryKey +from dispatch.participant.models import ParticipantRead +from dispatch.plugin.models import PluginEvent, PluginEventRead + + +# SQLAlchemy Models +class ParticipantActivity(Base): + id = Column(Integer, primary_key=True) + + plugin_event_id = Column(Integer, ForeignKey(PluginEvent.id)) + plugin_event = relationship(PluginEvent, foreign_keys=[plugin_event_id]) + + started_at = Column(DateTime, default=datetime.utcnow) + ended_at = Column(DateTime, default=datetime.utcnow) + + participant_id = Column(Integer, ForeignKey("participant.id")) + participant = relationship("Participant", foreign_keys=[participant_id]) + + incident_id = Column(Integer, ForeignKey("incident.id")) + incident = relationship("Incident", foreign_keys=[incident_id]) + + +# Pydantic Models +class ParticipantActivityBase(DispatchBase): + plugin_event: PluginEventRead + started_at: Optional[datetime] = None + ended_at: Optional[datetime] = None + participant: ParticipantRead + incident: IncidentRead + + +class ParticipantActivityRead(ParticipantActivityBase): + id: PrimaryKey + + +class ParticipantActivityCreate(ParticipantActivityBase): + pass + + +class ParticipantActivityUpdate(ParticipantActivityBase): + id: PrimaryKey diff --git a/src/dispatch/participant_activity/service.py b/src/dispatch/participant_activity/service.py new file mode 100644 index 000000000000..7dc702762d99 --- /dev/null +++ b/src/dispatch/participant_activity/service.py @@ -0,0 +1,164 @@ +from datetime import datetime, timedelta + +from dispatch.database.core import SessionLocal +from dispatch.participant import service as participant_service +from dispatch.plugin import service as plugin_service + +from .models import ( + ParticipantActivity, + ParticipantActivityRead, + ParticipantActivityCreate, + ParticipantActivityUpdate, +) + + +def get_all_incident_participant_activities_from_last_update( + db_session: SessionLocal, + incident_id: int, +) -> list[ParticipantActivityRead]: + """Fetches the most recent recorded participant incident activities for each participant for a given incident.""" + return ( + db_session.query(ParticipantActivity) + .distinct(ParticipantActivity.participant_id) + .filter(ParticipantActivity.incident_id == incident_id) + .order_by(ParticipantActivity.participant_id, ParticipantActivity.ended_at.desc()) + .all() + ) + + +def create(*, db_session: SessionLocal, activity_in: ParticipantActivityCreate): + """Creates a new record for a participant's activity.""" + activity = ParticipantActivity( + plugin_event_id=activity_in.plugin_event.id, + started_at=activity_in.started_at, + ended_at=activity_in.ended_at, + participant_id=activity_in.participant.id, + incident_id=activity_in.incident.id, + ) + + db_session.add(activity) + db_session.commit() + + return activity + + +def update( + *, + db_session: SessionLocal, + activity: ParticipantActivity, + activity_in: ParticipantActivityUpdate, +) -> ParticipantActivity: + """Updates an existing record for a participant's activity.""" + activity.ended_at = activity_in.ended_at + db_session.commit() + return activity + + +def get_last_participant_activity( + db_session: SessionLocal, incident_id: int +) -> ParticipantActivity: + """Returns the last recorded participant incident activity for a given incident.""" + return ( + db_session.query(ParticipantActivity) + .filter(ParticipantActivity.incident_id == incident_id) + .order_by(ParticipantActivity.ended_at.desc()) + .first() + ) + + +def get_all_incident_participant_activities_for_incident( + db_session: SessionLocal, + incident_id: int, +) -> list[ParticipantActivityRead]: + """Fetches all recorded participant incident activities for a given incident.""" + return ( + db_session.query(ParticipantActivity) + .filter(ParticipantActivity.incident_id == incident_id) + .all() + ) + + +def get_participant_activity_from_last_update( + db_session: SessionLocal, incident_id: int, participant_id: int +) -> ParticipantActivity: + """Fetches the most recent recorded participant incident activity for a given incident and participant.""" + return ( + db_session.query(ParticipantActivity) + .filter(ParticipantActivity.incident_id == incident_id) + .filter(ParticipantActivity.participant_id == participant_id) + .order_by(ParticipantActivity.ended_at.desc()) + .first() + ) + + +def create_or_update(db_session: SessionLocal, activity_in: ParticipantActivityCreate) -> timedelta: + """Creates or updates a participant activity. Returns the change of the participant's total incident response time.""" + delta = timedelta(seconds=0) + + prev_activity = get_participant_activity_from_last_update( + db_session=db_session, + incident_id=activity_in.incident.id, + participant_id=activity_in.participant.id, + ) + + # There's continuous participant activity. + if prev_activity and activity_in.started_at < prev_activity.ended_at: + # Continuation of current plugin event. + if activity_in.plugin_event.id == prev_activity.plugin_event.id: + delta = activity_in.ended_at - prev_activity.ended_at + prev_activity.ended_at = activity_in.ended_at + db_session.commit() + return delta + + # New activity is associated with a different plugin event. + delta += activity_in.started_at - prev_activity.ended_at + prev_activity.ended_at = activity_in.started_at + + create(db_session=db_session, activity_in=activity_in) + delta += activity_in.ended_at - activity_in.started_at + return delta + + +def get_participant_incident_activities_by_individual_contact( + db_session: SessionLocal, individual_contact_id: int +) -> list[ParticipantActivity]: + """Fetches all recorded participant incident activities across all incidents for a given individual.""" + participants = participant_service.get_by_individual_contact_id( + db_session=db_session, individual_id=individual_contact_id + ) + + return ( + db_session.query(ParticipantActivity) + .filter( + ParticipantActivity.participant_id.in_([participant.id for participant in participants]) + ) + .all() + ) + + +def get_all_recorded_incident_partcipant_activities_for_plugin( + db_session: SessionLocal, + incident_id: int, + plugin_id: int, + started_at: datetime = datetime.min, + ended_at: datetime = datetime.utcnow(), +) -> list[ParticipantActivityRead]: + """Fetches all recorded participant incident activities for a given plugin.""" + + plugin_events = plugin_service.get_all_events_for_plugin( + db_session=db_session, plugin_id=plugin_id + ) + participant_activities_for_plugin = [] + + for plugin_event in plugin_events: + event_activities = ( + db_session.query(ParticipantActivity) + .filter(ParticipantActivity.incident_id == incident_id) + .filter(ParticipantActivity.plugin_event_id == plugin_event.id) + .filter(ParticipantActivity.started_at >= started_at) + .filter(ParticipantActivity.ended_at <= ended_at) + .all() + ) + participant_activities_for_plugin.extend(event_activities) + + return participant_activities_for_plugin diff --git a/src/dispatch/plugin/models.py b/src/dispatch/plugin/models.py index ef0af2ec3020..205c49af113e 100644 --- a/src/dispatch/plugin/models.py +++ b/src/dispatch/plugin/models.py @@ -1,21 +1,19 @@ import logging -from typing import Any, List, Optional from pydantic import Field, SecretStr from pydantic.json import pydantic_encoder - -from sqlalchemy.orm import relationship -from sqlalchemy.ext.associationproxy import association_proxy +from typing import Any, List, Optional from sqlalchemy import Column, Integer, String, Boolean, ForeignKey +from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import relationship from sqlalchemy_utils import TSVectorType, StringEncryptedType from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine - -from dispatch.database.core import Base from dispatch.config import DISPATCH_ENCRYPTION_KEY -from dispatch.models import DispatchBase, ProjectMixin, Pagination, PrimaryKey +from dispatch.database.core import Base +from dispatch.models import DispatchBase, ProjectMixin, Pagination, PrimaryKey, NameStr from dispatch.plugins.base import plugins from dispatch.project.models import ProjectRead @@ -64,6 +62,26 @@ def configuration_schema(self): return None +# SQLAlchemy Model +class PluginEvent(Base): + __table_args__ = {"schema": "dispatch_core"} + id = Column(Integer, primary_key=True) + name = Column(String) + slug = Column(String, unique=True) + description = Column(String) + plugin_id = Column(Integer, ForeignKey(Plugin.id)) + plugin = relationship(Plugin, foreign_keys=[plugin_id]) + + search_vector = Column( + TSVectorType( + "name", + "slug", + "description", + weights={"name": "A", "slug": "B", "description": "C"}, + ) + ) + + class PluginInstance(Base, ProjectMixin): id = Column(Integer, primary_key=True) enabled = Column(Boolean) @@ -148,6 +166,25 @@ class PluginRead(PluginBase): description: Optional[str] = Field(None, nullable=True) +class PluginEventBase(DispatchBase): + name: NameStr + slug: str + plugin: PluginRead + description: Optional[str] = Field(None, nullable=True) + + +class PluginEventRead(PluginEventBase): + id: PrimaryKey + + +class PluginEventCreate(PluginEventBase): + pass + + +class PluginEventPagination(Pagination): + items: List[PluginEventRead] = [] + + class PluginInstanceRead(PluginBase): id: PrimaryKey enabled: Optional[bool] diff --git a/src/dispatch/plugin/service.py b/src/dispatch/plugin/service.py index 818e88f79e2b..9334b12167cb 100644 --- a/src/dispatch/plugin/service.py +++ b/src/dispatch/plugin/service.py @@ -1,15 +1,20 @@ import logging - -from typing import List, Optional - from pydantic.error_wrappers import ErrorWrapper, ValidationError +from typing import List, Optional from dispatch.exceptions import InvalidConfigurationError from dispatch.plugins.bases import OncallPlugin from dispatch.project import service as project_service from dispatch.service import service as service_service -from .models import Plugin, PluginInstance, PluginInstanceCreate, PluginInstanceUpdate +from .models import ( + Plugin, + PluginInstance, + PluginInstanceCreate, + PluginInstanceUpdate, + PluginEvent, + PluginEventCreate, +) log = logging.getLogger(__name__) @@ -27,7 +32,7 @@ def get_by_slug(*, db_session, slug: str) -> Plugin: def get_all(*, db_session) -> List[Optional[Plugin]]: """Returns all plugins.""" - return db_session.query(Plugin) + return db_session.query(Plugin).all() def get_by_type(*, db_session, plugin_type: str) -> List[Optional[Plugin]]: @@ -168,3 +173,28 @@ def delete_instance(*, db_session, plugin_instance_id: int): """Deletes a plugin instance.""" db_session.query(PluginInstance).filter(PluginInstance.id == plugin_instance_id).delete() db_session.commit() + + +def get_plugin_event_by_id(*, db_session, plugin_event_id: int) -> Optional[PluginEvent]: + """Returns a plugin event based on the plugin event id.""" + return db_session.query(PluginEvent).filter(PluginEvent.id == plugin_event_id).one_or_none() + + +def get_plugin_event_by_slug(*, db_session, slug: str) -> Optional[PluginEvent]: + """Returns a project based on the plugin event slug.""" + return db_session.query(PluginEvent).filter(PluginEvent.slug == slug).one_or_none() + + +def get_all_events_for_plugin(*, db_session, plugin_id: int) -> List[Optional[PluginEvent]]: + """Returns all plugin events for a given plugin.""" + return db_session.query(PluginEvent).filter(PluginEvent.plugin_id == plugin_id).all() + + +def create_plugin_event(*, db_session, plugin_event_in: PluginEventCreate) -> PluginEvent: + """Creates a new plugin event.""" + plugin_event = PluginEvent(**plugin_event_in.dict(exclude={"plugin"})) + plugin_event.plugin = get(db_session=db_session, plugin_id=plugin_event_in.plugin.id) + db_session.add(plugin_event) + db_session.commit() + + return plugin_event diff --git a/src/dispatch/plugin/views.py b/src/dispatch/plugin/views.py index 40035e5875a7..e676396fb662 100644 --- a/src/dispatch/plugin/views.py +++ b/src/dispatch/plugin/views.py @@ -6,6 +6,7 @@ from dispatch.models import PrimaryKey from .models import ( + PluginEventPagination, PluginInstanceRead, PluginInstanceCreate, PluginInstanceUpdate, @@ -103,3 +104,9 @@ def delete_plugin_instances( detail=[{"msg": "A plugin instance with this id does not exist."}], ) delete_instance(db_session=db_session, plugin_instance_id=plugin_instance_id) + + +@router.get("/plugin_events", response_model=PluginEventPagination) +def get_plugin_events(common: CommonParameters): + """Get all plugins.""" + return search_filter_sort_paginate(model="PluginEvent", **common) diff --git a/src/dispatch/plugins/base/v1.py b/src/dispatch/plugins/base/v1.py index 8c021aea8696..0df441dc9684 100644 --- a/src/dispatch/plugins/base/v1.py +++ b/src/dispatch/plugins/base/v1.py @@ -19,6 +19,11 @@ class PluginConfiguration(BaseModel): pass +class IPluginEvent: + name: Optional[str] = None + description: Optional[str] = None + + # stolen from https://github.com/getsentry/sentry/ class PluginMount(type): def __new__(cls, name, bases, attrs): @@ -63,6 +68,7 @@ class IPlugin(local): commands: List[Any] = [] events: Any = None + plugin_events: Optional[List[IPluginEvent]] = [] # Global enabled state enabled: bool = False @@ -107,6 +113,14 @@ def get_resource_links(self) -> List[Any]: """ return self.resource_links + def get_event(self, event) -> Optional[IPluginEvent]: + for plugin_event in self.plugin_events: + if plugin_event.slug == event.slug: + return plugin_event + + def fetch_incident_events(self, **kwargs): + raise NotImplementedError + class Plugin(IPlugin): """ diff --git a/src/dispatch/plugins/dispatch_slack/case/interactive.py b/src/dispatch/plugins/dispatch_slack/case/interactive.py index eac76a770601..ec52e92fc3f9 100644 --- a/src/dispatch/plugins/dispatch_slack/case/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/case/interactive.py @@ -63,6 +63,7 @@ case_resolution_reason_select, case_status_select, case_type_select, + cost_model_select, description_input, entity_select, incident_priority_select, @@ -929,6 +930,7 @@ def escalate_button_click( project_id=case.project.id, ), incident_priority_select(db_session=db_session, project_id=case.project.id, optional=True), + cost_model_select(db_session=db_session, project_id=case.project.id, optional=True), ] modal = Modal( diff --git a/src/dispatch/plugins/dispatch_slack/events.py b/src/dispatch/plugins/dispatch_slack/events.py new file mode 100644 index 000000000000..81c4d63e8cf8 --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/events.py @@ -0,0 +1,64 @@ +import logging +from slack_sdk import WebClient +from typing import List + +from dispatch.plugins.base import IPluginEvent + +from .service import ( + get_channel_activity, + get_thread_activity, +) + +log = logging.getLogger(__name__) + + +class SlackPluginEvent(IPluginEvent): + def fetch_activity(self): + raise NotImplementedError + + +class ChannelActivityEvent(SlackPluginEvent): + name = "Slack Channel Activity" + slug = "slack-channel-activity" + description = "Analyzes incident/case activity within a specific Slack channel.\n \ + By periodically polling channel messages, this gathers insights into the \ + activity and engagement levels of each participant." + + def fetch_activity(client: WebClient, subject: None, oldest: str = "0") -> List: + if not subject: + log.warning("No subject provided. Cannot fetch channel activity.") + elif not subject.conversation: + log.warning("No conversation provided. Cannot fetch channel activity.") + elif not subject.conversation.channel_id: + log.warning("No channel id provided. Cannot fetch channel activity.") + else: + return get_channel_activity( + client, conversation_id=subject.conversation.channel_id, oldest=oldest + ) + return [] + + +class ThreadActivityEvent(SlackPluginEvent): + name = "Slack Thread Activity" + slug = "slack-thread-activity" + description = "Analyzes incident/case activity within a specific Slack thread.\n \ + By periodically polling thread replies, this gathers insights \ + into the activity and engagement levels of each participant." + + def fetch_activity(client: WebClient, subject: None, oldest: str = "0") -> List: + if not subject: + log.warning("No subject provided. Cannot fetch thread activity.") + elif not subject.conversation: + log.warning("No conversation provided. Cannot fetch thread activity.") + elif not subject.conversation.channel_id: + log.warning("No channel id provided. Cannot fetch thread activity.") + elif not subject.conversation.thread_id: + log.warning("No thread id provided. Cannot fetch thread activity.") + else: + return get_thread_activity( + client, + conversation_id=subject.conversation.channel_id, + ts=subject.conversation.thread_id, + oldest=oldest, + ) + return [] diff --git a/src/dispatch/plugins/dispatch_slack/fields.py b/src/dispatch/plugins/dispatch_slack/fields.py index 061fbad1c176..fc14725fd609 100644 --- a/src/dispatch/plugins/dispatch_slack/fields.py +++ b/src/dispatch/plugins/dispatch_slack/fields.py @@ -19,6 +19,7 @@ from dispatch.case.type import service as case_type_service from dispatch.case.priority import service as case_priority_service from dispatch.case.severity import service as case_severity_service +from dispatch.cost_model import service as cost_model_service from dispatch.entity import service as entity_service from dispatch.incident.enums import IncidentStatus from dispatch.incident.type import service as incident_type_service @@ -64,6 +65,9 @@ class DefaultBlockIds(DispatchEnum): # tags tags_multi_select = "tag-multi-select" + # cost models + cost_model_select = "cost-model-select" + class DefaultActionIds(DispatchEnum): date_picker_input = "date-picker-input" @@ -101,6 +105,9 @@ class DefaultActionIds(DispatchEnum): # tags tags_multi_select = "tag-multi-select" + # cost models + cost_model_select = "cost-model-select" + class TimezoneOptions(DispatchEnum): local = "Local Time (based on your Slack profile)" @@ -609,6 +616,31 @@ def case_type_select( ) +def cost_model_select( + db_session: SessionLocal, + action_id: str = DefaultActionIds.cost_model_select, + block_id: str = DefaultBlockIds.cost_model_select, + label: str = "Cost Model", + initial_option: dict = None, + project_id: int = None, + **kwargs, +): + cost_model_options = [ + {"text": cost_model.name, "value": cost_model.id} + for cost_model in cost_model_service.get_all(db_session=db_session, project_id=project_id) + ] + + return static_select_block( + placeholder="Select Cost Model", + options=cost_model_options, + initial_option=initial_option, + action_id=action_id, + block_id=block_id, + label=label, + **kwargs, + ) + + def entity_select( signal_id: int, db_session: SessionLocal, diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index aec662a4c029..5a990a23c382 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -28,6 +28,7 @@ from dispatch.auth.models import DispatchUser from dispatch.config import DISPATCH_UI_URL +from dispatch.cost_model import service as cost_model_service from dispatch.database.service import search_filter_sort_paginate from dispatch.enums import Visibility, EventType from dispatch.event import service as event_service @@ -56,6 +57,7 @@ DefaultActionIds, DefaultBlockIds, TimezoneOptions, + cost_model_select, datetime_picker_block, description_input, incident_priority_select, @@ -317,6 +319,12 @@ def handle_update_incident_project_select_action( optional=True, initial_options=[t.name for t in incident.tags], ), + cost_model_select( + db_session=db_session, + initial_option={"text": incident.cost_model.name, "value": incident.cost_model.id}, + project_id=incident.project.id, + optional=True, + ), ] modal = Modal( @@ -1811,6 +1819,12 @@ def handle_update_incident_command( optional=True, initial_options=[{"text": t.name, "value": t.id} for t in incident.tags], ), + cost_model_select( + db_session=db_session, + initial_option={"text": incident.cost_model.name, "value": incident.cost_model.id}, + project_id=incident.project.id, + optional=True, + ), ] modal = Modal( @@ -1858,6 +1872,10 @@ def handle_update_incident_submission_event( tag = tag_service.get(db_session=db_session, tag_id=int(t["value"])) tags.append(tag) + cost_model = cost_model_service.get_cost_model_by_id( + db_session=db_session, + cost_model_id=int(form_data[DefaultBlockIds.cost_model_select]["value"]), + ) incident_in = IncidentUpdate( title=form_data[DefaultBlockIds.title_input], description=form_data[DefaultBlockIds.description_input], @@ -1867,6 +1885,7 @@ def handle_update_incident_submission_event( incident_priority={"name": form_data[DefaultBlockIds.incident_priority_select]["name"]}, status=form_data[DefaultBlockIds.incident_status_select]["name"], tags=tags, + cost_model=cost_model, ) previous_incident = IncidentRead.from_orm(incident) @@ -2035,6 +2054,13 @@ def handle_report_incident_submission_event( if form_data.get(DefaultBlockIds.incident_severity_select): incident_severity = {"name": form_data[DefaultBlockIds.incident_severity_select]["name"]} + cost_model = None + if form_data.get(DefaultBlockIds.cost_model_select): + cost_model = cost_model_service.get_cost_model_by_id( + db_session=db_session, + cost_model_id=int(form_data[DefaultBlockIds.cost_model_select]["value"]), + ) + incident_in = IncidentCreate( title=form_data[DefaultBlockIds.title_input], description=form_data[DefaultBlockIds.description_input], @@ -2044,6 +2070,7 @@ def handle_report_incident_submission_event( project=project, reporter=ParticipantUpdate(individual=IndividualContactRead(email=user.email)), tags=tags, + cost_model=cost_model, ) blocks = [ @@ -2125,6 +2152,7 @@ def handle_report_incident_project_select_action( incident_severity_select(db_session=db_session, project_id=project.id, optional=True), incident_priority_select(db_session=db_session, project_id=project.id, optional=True), tag_multi_select(optional=True), + cost_model_select(db_session=db_session, project_id=project.id, optional=True), ] modal = Modal( diff --git a/src/dispatch/plugins/dispatch_slack/plugin.py b/src/dispatch/plugins/dispatch_slack/plugin.py index 913cf2cfeaf6..aa0c96feffd2 100644 --- a/src/dispatch/plugins/dispatch_slack/plugin.py +++ b/src/dispatch/plugins/dispatch_slack/plugin.py @@ -5,16 +5,17 @@ :license: Apache, see LICENSE for more details. .. moduleauthor:: Kevin Glisson """ -import logging -from typing import List, Optional - from blockkit import Message +import logging +from typing import List, Optional, Any +from slack_sdk.errors import SlackApiError from sqlalchemy.orm import Session from dispatch.auth.models import DispatchUser from dispatch.case.models import Case from dispatch.conversation.enums import ConversationCommands from dispatch.decorators import apply, counter, timer +from dispatch.plugin import service as plugin_service from dispatch.plugins import dispatch_slack as slack_plugin from dispatch.plugins.bases import ContactPlugin, ConversationPlugin from dispatch.plugins.dispatch_slack.config import ( @@ -24,11 +25,16 @@ from dispatch.signal.enums import SignalEngagementStatus from dispatch.signal.models import SignalEngagement, SignalInstance -from .case.messages import create_case_message, create_signal_messages +from .case.messages import ( + create_case_message, + create_signal_messages, + create_signal_engagement_message, +) from .endpoints import router as slack_event_router +from .enums import SlackAPIErrorCode +from .events import ChannelActivityEvent, ThreadActivityEvent from .messaging import create_message_blocks -from .case.messages import create_signal_engagement_message from .service import ( add_conversation_bookmark, add_users_to_conversation, @@ -49,8 +55,7 @@ unarchive_conversation, update_message, ) -from slack_sdk.errors import SlackApiError -from .enums import SlackAPIErrorCode + logger = logging.getLogger(__name__) @@ -63,6 +68,7 @@ class SlackConversationPlugin(ConversationPlugin): description = "Uses Slack to facilitate conversations." version = slack_plugin.__version__ events = slack_event_router + plugin_events = [ChannelActivityEvent, ThreadActivityEvent] author = "Netflix" author_url = "https://github.com/netflix/dispatch.git" @@ -305,6 +311,29 @@ def get_command_name(self, command: str): } return command_mappings.get(command, []) + def fetch_incident_events( + self, db_session: Session, subject: Any, plugin_event_id: int, oldest: str = "0", **kwargs + ): + """Fetches incident events from the Slack plugin. + + Args: + subject: An Incident or Case object. + plugin_event_id: The plugin event id. + oldest: The oldest timestamp to fetch events from. + + Returns: + A sorted list of tuples (utc_dt, user_id). + """ + try: + client = create_slack_client(self.configuration) + plugin_event = plugin_service.get_plugin_event_by_id( + db_session=db_session, plugin_event_id=plugin_event_id + ) + return self.get_event(plugin_event).fetch_activity(client, subject, oldest=oldest) + except Exception as e: + logger.exception(e) + raise e + @apply(counter, exclude=["__init__"]) @apply(timer, exclude=["__init__"]) diff --git a/src/dispatch/plugins/dispatch_slack/service.py b/src/dispatch/plugins/dispatch_slack/service.py index 475b153d9400..80ff827c3317 100644 --- a/src/dispatch/plugins/dispatch_slack/service.py +++ b/src/dispatch/plugins/dispatch_slack/service.py @@ -1,15 +1,16 @@ +from blockkit import Message, Section +from datetime import datetime import functools +import heapq import logging -import time -from typing import Dict, List, Optional, NoReturn - -from tenacity import TryAgain, retry, retry_if_exception_type, stop_after_attempt - -from blockkit import Message, Section from slack_sdk.errors import SlackApiError from slack_sdk.web.client import WebClient from slack_sdk.web.slack_response import SlackResponse +import time +from tenacity import TryAgain, retry, retry_if_exception_type, stop_after_attempt +from typing import Dict, List, Optional, NoReturn + from .config import SlackConversationConfiguration from .enums import SlackAPIErrorCode, SlackAPIGetEndpoints, SlackAPIPostEndpoints @@ -376,3 +377,77 @@ def add_pin(client: WebClient, conversation_id: str, timestamp: str) -> SlackRes def is_user(config: SlackConversationConfiguration, user_id: str) -> bool: """Returns true if it's a regular user, false if Dispatch or Slackbot bot.""" return user_id != config.app_user_slug and user_id != "USLACKBOT" + + +def get_thread_activity( + client: WebClient, conversation_id: str, ts: str, oldest: str = "0" +) -> List: + """Gets all messages for a given Slack thread. + + Returns: + A sorted list of tuples (utc_dt, user_id) of each thread reply. + """ + result = [] + cursor = None + while True: + response = make_call( + client, + SlackAPIGetEndpoints.conversations_replies, + channel=conversation_id, + ts=ts, + cursor=cursor, + oldest=oldest, + ) + if not response["ok"] or "messages" not in response: + break + + for message in response["messages"]: + if "bot_id" in message: + continue + + # Resolves users for messages. + if "user" in message: + user_id = resolve_user(client, message["user"])["id"] + heapq.heappush(result, (datetime.utcfromtimestamp(float(message["ts"])), user_id)) + + if not response["has_more"]: + break + cursor = response["response_metadata"]["next_cursor"] + + return heapq.nsmallest(len(result), result) + + +def get_channel_activity(client: WebClient, conversation_id: str, oldest: str = "0") -> List: + """Gets all top-level messages for a given Slack channel. + + Returns: + A sorted list of tuples (utc_dt, user_id) of each message in the channel. + """ + result = [] + cursor = None + while True: + response = make_call( + client, + SlackAPIGetEndpoints.conversations_history, + channel=conversation_id, + cursor=cursor, + oldest=oldest, + ) + + if not response["ok"] or "messages" not in response: + break + + for message in response["messages"]: + if "bot_id" in message: + continue + + # Resolves users for messages. + if "user" in message: + user_id = resolve_user(client, message["user"])["id"] + heapq.heappush(result, (datetime.utcfromtimestamp(float(message["ts"])), user_id)) + + if not response["has_more"]: + break + cursor = response["response_metadata"]["next_cursor"] + + return heapq.nsmallest(len(result), result) diff --git a/src/dispatch/plugins/dispatch_test/conversation.py b/src/dispatch/plugins/dispatch_test/conversation.py index 8b2b49c66af0..0e10990a1d9d 100644 --- a/src/dispatch/plugins/dispatch_test/conversation.py +++ b/src/dispatch/plugins/dispatch_test/conversation.py @@ -1,9 +1,23 @@ +from datetime import datetime +from slack_sdk import WebClient +from typing import Any + from dispatch.plugins.bases import ConversationPlugin +from dispatch.plugins.dispatch_slack.events import ChannelActivityEvent + + +class TestWebClient(WebClient): + def api_call(self, *args, **kwargs): + return {"ok": True, "messages": [], "has_more": False} class TestConversationPlugin(ConversationPlugin): + id = 123 title = "Dispatch Test Plugin - Conversation" slug = "test-conversation" + configuration = {"api_bot_token": "123"} + type = "conversation" + plugin_events = [ChannelActivityEvent] def create(self, items, **kwargs): return @@ -13,3 +27,13 @@ def add(self, items, **kwargs): def send(self, items, **kwargs): return + + def fetch_incident_events(self, subject: Any, **kwargs): + client = TestWebClient() + for plugin_event in self.plugin_events: + plugin_event.fetch_activity(client=client, subject=subject) + return [ + (datetime.utcfromtimestamp(1512085950.000216), "0XDECAFBAD"), + (datetime.utcfromtimestamp(1512104434.000490), "0XDECAFBAD"), + (datetime.utcfromtimestamp(1512104534.000490), "0X8BADF00D"), + ] diff --git a/src/dispatch/static/dispatch/src/cost_model/CostModelActivityDialog.vue b/src/dispatch/static/dispatch/src/cost_model/CostModelActivityDialog.vue new file mode 100644 index 000000000000..f65d4f11f8b4 --- /dev/null +++ b/src/dispatch/static/dispatch/src/cost_model/CostModelActivityDialog.vue @@ -0,0 +1,177 @@ + + + diff --git a/src/dispatch/static/dispatch/src/cost_model/CostModelActivityInput.vue b/src/dispatch/static/dispatch/src/cost_model/CostModelActivityInput.vue new file mode 100644 index 000000000000..de1ec2266469 --- /dev/null +++ b/src/dispatch/static/dispatch/src/cost_model/CostModelActivityInput.vue @@ -0,0 +1,123 @@ + + + diff --git a/src/dispatch/static/dispatch/src/cost_model/CostModelCombobox.vue b/src/dispatch/static/dispatch/src/cost_model/CostModelCombobox.vue new file mode 100644 index 000000000000..b81268a51a70 --- /dev/null +++ b/src/dispatch/static/dispatch/src/cost_model/CostModelCombobox.vue @@ -0,0 +1,130 @@ + + + diff --git a/src/dispatch/static/dispatch/src/cost_model/DeleteDialog.vue b/src/dispatch/static/dispatch/src/cost_model/DeleteDialog.vue new file mode 100644 index 000000000000..5b1798ef9558 --- /dev/null +++ b/src/dispatch/static/dispatch/src/cost_model/DeleteDialog.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/dispatch/static/dispatch/src/cost_model/NewEditSheet.vue b/src/dispatch/static/dispatch/src/cost_model/NewEditSheet.vue new file mode 100644 index 000000000000..ab76da57185b --- /dev/null +++ b/src/dispatch/static/dispatch/src/cost_model/NewEditSheet.vue @@ -0,0 +1,110 @@ + + + diff --git a/src/dispatch/static/dispatch/src/cost_model/Table.vue b/src/dispatch/static/dispatch/src/cost_model/Table.vue new file mode 100644 index 000000000000..3d2be4f502d6 --- /dev/null +++ b/src/dispatch/static/dispatch/src/cost_model/Table.vue @@ -0,0 +1,149 @@ + + + diff --git a/src/dispatch/static/dispatch/src/cost_model/api.js b/src/dispatch/static/dispatch/src/cost_model/api.js new file mode 100644 index 000000000000..c5f02204a58d --- /dev/null +++ b/src/dispatch/static/dispatch/src/cost_model/api.js @@ -0,0 +1,21 @@ +import API from "@/api" + +const resource = "/cost_models" + +export default { + getAll(options) { + return API.get(`/${resource}`, { + params: { ...options }, + }) + }, + + create(payload) { + return API.post(`/${resource}`, payload) + }, + update(costModelId, payload) { + return API.put(`/${resource}/${costModelId}`, payload) + }, + delete(costModelId) { + return API.delete(`/${resource}/${costModelId}`) + }, +} diff --git a/src/dispatch/static/dispatch/src/cost_model/store.js b/src/dispatch/static/dispatch/src/cost_model/store.js new file mode 100644 index 000000000000..ad99907a5385 --- /dev/null +++ b/src/dispatch/static/dispatch/src/cost_model/store.js @@ -0,0 +1,177 @@ +import { getField, updateField } from "vuex-map-fields" +import { debounce } from "lodash" + +import SearchUtils from "@/search/utils" +import CostModelApi from "@/cost_model/api" + +const getDefaultSelectedState = () => { + return { + id: null, + name: null, + enabled: null, + description: null, + created_at: null, + updated_at: null, + project: null, + loading: false, + activities: [], + } +} + +const state = { + selected: { + ...getDefaultSelectedState(), + }, + dialogs: { + showCreateEdit: false, + showRemove: false, + showActivity: false, + }, + table: { + rows: { + items: [], + total: null, + }, + options: { + q: "", + page: 1, + itemsPerPage: 25, + sortBy: ["CostModel.created_at"], + descending: [true], + filters: { + project: [], + }, + }, + loading: false, + }, +} + +const getters = { + getField, +} + +const actions = { + getAll: debounce(({ commit, state }) => { + commit("SET_TABLE_LOADING", "primary") + let params = SearchUtils.createParametersFromTableOptions( + { ...state.table.options }, + "CostModel" + ) + return CostModelApi.getAll(params) + .then((response) => { + commit("SET_TABLE_LOADING", false) + commit("SET_TABLE_ROWS", response.data) + }) + .catch(() => { + commit("SET_TABLE_LOADING", false) + }) + }, 500), + createEditShow({ commit }, incidentCostModel) { + commit("SET_DIALOG_EDIT", true) + if (incidentCostModel) { + commit("SET_SELECTED", incidentCostModel) + } + }, + closeCreateEdit({ commit }) { + commit("SET_DIALOG_EDIT", false) + commit("RESET_SELECTED") + }, + createActivityShow({ commit }) { + commit("SET_DIALOG_ACTIVITY", true) + }, + closeActivity({ commit }) { + commit("SET_DIALOG_ACTIVITY", false) + }, + removeShow({ commit }, incidentCostModel) { + commit("SET_DIALOG_DELETE", true) + commit("SET_SELECTED", incidentCostModel) + }, + closeRemove({ commit }) { + commit("SET_DIALOG_DELETE", false) + commit("RESET_SELECTED") + }, + save({ commit, state, dispatch }) { + commit("SET_SELECTED_LOADING", true) + if (!state.selected.id) { + return CostModelApi.create(state.selected) + .then(() => { + commit("SET_SELECTED_LOADING", false) + dispatch("closeCreateEdit") + dispatch("getAll") + commit( + "notification_backend/addBeNotification", + { text: "Cost model created successfully.", type: "success" }, + { root: true } + ) + }) + .catch(() => { + commit("SET_SELECTED_LOADING", false) + }) + } else { + return CostModelApi.update(state.selected.id, state.selected) + .then(() => { + commit("SET_SELECTED_LOADING", false) + dispatch("closeCreateEdit") + dispatch("getAll") + commit( + "notification_backend/addBeNotification", + { text: "Cost model updated successfully.", type: "success" }, + { root: true } + ) + }) + .catch(() => { + commit("SET_SELECTED_LOADING", false) + }) + } + }, + remove({ commit, state, dispatch }) { + return CostModelApi.delete(state.selected.id).then(function () { + dispatch("closeRemove") + dispatch("getAll") + commit( + "notification_backend/addBeNotification", + { text: "Cost model deleted successfully.", type: "success" }, + { root: true } + ) + }) + }, +} + +const mutations = { + updateField, + SET_SELECTED(state, value) { + state.selected = Object.assign(state.selected, value) + }, + SET_SELECTED_LOADING(state, value) { + state.selected.loading = value + }, + SET_TABLE_LOADING(state, value) { + state.table.loading = value + }, + SET_TABLE_ROWS(state, value) { + state.table.rows = value + }, + SET_DIALOG_EDIT(state, value) { + state.dialogs.showCreateEdit = value + }, + SET_DIALOG_DELETE(state, value) { + state.dialogs.showRemove = value + }, + SET_DIALOG_ACTIVITY(state, value) { + state.dialogs.showActivity = value + }, + RESET_SELECTED(state) { + // do not reset project + let project = state.selected.project + state.selected = { ...getDefaultSelectedState() } + state.selected.project = project + }, +} + +export default { + namespaced: true, + state, + getters, + actions, + mutations, +} diff --git a/src/dispatch/static/dispatch/src/incident/DetailsTab.vue b/src/dispatch/static/dispatch/src/incident/DetailsTab.vue index 2fca289d1730..4aeb75cfb99d 100644 --- a/src/dispatch/static/dispatch/src/incident/DetailsTab.vue +++ b/src/dispatch/static/dispatch/src/incident/DetailsTab.vue @@ -102,6 +102,15 @@ :model-id="id" /> + + + @@ -120,6 +129,7 @@ import { required } from "@/util/form" import { mapFields } from "vuex-map-fields" import CaseFilterCombobox from "@/case/CaseFilterCombobox.vue" +import CostModelCombobox from "@/cost_model/CostModelCombobox.vue" import DateTimePickerMenu from "@/components/DateTimePickerMenu.vue" import IncidentFilterCombobox from "@/incident/IncidentFilterCombobox.vue" import IncidentPrioritySelect from "@/incident/priority/IncidentPrioritySelect.vue" @@ -140,6 +150,7 @@ export default { components: { CaseFilterCombobox, DateTimePickerMenu, + CostModelCombobox, IncidentFilterCombobox, IncidentPrioritySelect, IncidentSeveritySelect, @@ -169,6 +180,7 @@ export default { ...mapFields("incident", [ "selected.cases", "selected.commander", + "selected.cost_model", "selected.created_at", "selected.description", "selected.duplicates", diff --git a/src/dispatch/static/dispatch/src/incident/ReportSubmissionCard.vue b/src/dispatch/static/dispatch/src/incident/ReportSubmissionCard.vue index 048006077a2d..b9aa16daf408 100644 --- a/src/dispatch/static/dispatch/src/incident/ReportSubmissionCard.vue +++ b/src/dispatch/static/dispatch/src/incident/ReportSubmissionCard.vue @@ -84,6 +84,16 @@ :rules="[only_one]" /> + + + @@ -116,6 +126,7 @@ import { isNavigationFailure, NavigationFailureType } from "vue-router" import router from "@/router" +import CostModelCombobox from "@/cost_model/CostModelCombobox.vue" import DocumentApi from "@/document/api" import IncidentPrioritySelect from "@/incident/priority/IncidentPrioritySelect.vue" import IncidentTypeSelect from "@/incident/type/IncidentTypeSelect.vue" @@ -132,6 +143,7 @@ export default { name: "ReportSubmissionCard", components: { + CostModelCombobox, IncidentTypeSelect, IncidentPrioritySelect, ProjectSelect, @@ -158,6 +170,7 @@ export default { "selected.incident_priority", "selected.incident_type", "selected.commander_email", + "selected.cost_model", "selected.title", "selected.tags", "selected.description", diff --git a/src/dispatch/static/dispatch/src/incident/ReportSubmissionForm.vue b/src/dispatch/static/dispatch/src/incident/ReportSubmissionForm.vue index bcc63590bb39..f6d7342c4f09 100644 --- a/src/dispatch/static/dispatch/src/incident/ReportSubmissionForm.vue +++ b/src/dispatch/static/dispatch/src/incident/ReportSubmissionForm.vue @@ -49,6 +49,16 @@ model="incident" /> + + + @@ -58,6 +68,7 @@ import { mapFields } from "vuex-map-fields" import { required } from "@/util/form" +import CostModelCombobox from "@/cost_model/CostModelCombobox.vue" import IncidentPrioritySelect from "@/incident/priority/IncidentPrioritySelect.vue" import IncidentTypeSelect from "@/incident/type/IncidentTypeSelect.vue" import ProjectSelect from "@/project/ProjectSelect.vue" @@ -72,6 +83,7 @@ export default { name: "ReportSubmissionForm", components: { + CostModelCombobox, IncidentPrioritySelect, IncidentTypeSelect, ProjectSelect, @@ -98,6 +110,7 @@ export default { "selected.commander", "selected.conference", "selected.conversation", + "selected.cost_model", "selected.description", "selected.documents", "selected.id", diff --git a/src/dispatch/static/dispatch/src/incident/store.js b/src/dispatch/static/dispatch/src/incident/store.js index 691af12a3ee7..1ec8f4d0242a 100644 --- a/src/dispatch/static/dispatch/src/incident/store.js +++ b/src/dispatch/static/dispatch/src/incident/store.js @@ -13,6 +13,7 @@ const getDefaultSelectedState = () => { commander: null, conference: null, conversation: null, + cost_model: null, created_at: null, description: null, documents: null, diff --git a/src/dispatch/static/dispatch/src/plugin/PluginEventCombobox.vue b/src/dispatch/static/dispatch/src/plugin/PluginEventCombobox.vue new file mode 100644 index 000000000000..e320f188bffe --- /dev/null +++ b/src/dispatch/static/dispatch/src/plugin/PluginEventCombobox.vue @@ -0,0 +1,126 @@ + + + diff --git a/src/dispatch/static/dispatch/src/plugin/PluginInstanceCombobox.vue b/src/dispatch/static/dispatch/src/plugin/PluginInstanceCombobox.vue index 570a4026ca40..58183576bd61 100644 --- a/src/dispatch/static/dispatch/src/plugin/PluginInstanceCombobox.vue +++ b/src/dispatch/static/dispatch/src/plugin/PluginInstanceCombobox.vue @@ -57,6 +57,10 @@ export default { type: String, default: "Plugins", }, + requiresPluginEvents: { + type: Boolean, + default: false, + }, project: { type: [Object], default: null, @@ -81,8 +85,9 @@ export default { methods: { loadMore() { this.numItems = this.numItems + 5 + this.fetchData() }, - fetchData() { + async fetchData() { this.error = null this.loading = "error" let filter = { @@ -111,11 +116,25 @@ export default { }) } + // Only display plugins that have PluginEvents. + if (this.requiresPluginEvents) { + await PluginApi.getAllPluginEvents().then((response) => { + let plugin_events = response.data.items + + filter["and"].push({ + model: "Plugin", + field: "slug", + op: "in", + value: plugin_events.map((p) => p.plugin.slug), + }) + }) + } + let filterOptions = { q: this.search, sortBy: ["slug"], itemsPerPage: this.numItems, - filter: JSON.stringify(this.filter), + filter: JSON.stringify(filter), } PluginApi.getAllInstances(filterOptions).then((response) => { diff --git a/src/dispatch/static/dispatch/src/plugin/api.js b/src/dispatch/static/dispatch/src/plugin/api.js index 19d5462ac153..cfec542f3958 100644 --- a/src/dispatch/static/dispatch/src/plugin/api.js +++ b/src/dispatch/static/dispatch/src/plugin/api.js @@ -30,4 +30,10 @@ export default { deleteInstance(instanceId) { return API.delete(`/${resource}/instances/${instanceId}`) }, + + getAllPluginEvents(options) { + return API.get(`/${resource}/plugin_events`, { + params: { ...options }, + }) + }, } diff --git a/src/dispatch/static/dispatch/src/plugin/store.js b/src/dispatch/static/dispatch/src/plugin/store.js index 72ce57ec3875..ccf54148c07c 100644 --- a/src/dispatch/static/dispatch/src/plugin/store.js +++ b/src/dispatch/static/dispatch/src/plugin/store.js @@ -192,7 +192,11 @@ function convertToFormkit(json_schema) { const mutations = { updateField, SET_SELECTED(state, value) { - state.selected = Object.assign(state.selected, value) + Object.keys(value).forEach(function (key) { + if (value[key]) { + state.selected[key] = value[key] + } + }) state.selected.formkit_configuration_schema = convertToFormkit(value.configuration_schema) }, SET_SELECTED_LOADING(state, value) { diff --git a/src/dispatch/static/dispatch/src/router/config.js b/src/dispatch/static/dispatch/src/router/config.js index deedb66e7121..e87f1f474d51 100644 --- a/src/dispatch/static/dispatch/src/router/config.js +++ b/src/dispatch/static/dispatch/src/router/config.js @@ -407,6 +407,12 @@ export const protectedRoute = [ meta: { title: "Workflows", subMenu: "project", group: "general" }, component: () => import("@/workflow/Table.vue"), }, + { + path: "costModels", + name: "CostModelTable", + meta: { title: "Cost Models", subMenu: "project", group: "general" }, + component: () => import("@/cost_model/Table.vue"), + }, { path: "incidentTypes", name: "IncidentTypeTable", diff --git a/src/dispatch/static/dispatch/src/store.js b/src/dispatch/static/dispatch/src/store.js index aeb22e8886af..1aebfaf51ce1 100644 --- a/src/dispatch/static/dispatch/src/store.js +++ b/src/dispatch/static/dispatch/src/store.js @@ -6,6 +6,7 @@ import case_management from "@/case/store" import case_priority from "@/case/priority/store" import case_severity from "@/case/severity/store" import case_type from "@/case/type/store" +import cost_model from "@/cost_model/store" import definition from "@/definition/store" import document from "@/document/store" import entity from "@/entity/store" @@ -66,6 +67,7 @@ export default createStore({ forms_type, incident_feedback, incident, + cost_model, incident_cost_type, incident_priority, incident_severity, diff --git a/tests/conftest.py b/tests/conftest.py index adaabba106a1..2818ede5f8f4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,6 +37,8 @@ FeedbackFactory, GroupFactory, IncidentCostFactory, + CostModelFactory, + CostModelActivityFactory, IncidentCostTypeFactory, IncidentFactory, IncidentPriorityFactory, @@ -45,9 +47,11 @@ IndividualContactFactory, NotificationFactory, OrganizationFactory, + ParticipantActivityFactory, ParticipantFactory, ParticipantRoleFactory, PluginFactory, + PluginEventFactory, PluginInstanceFactory, ProjectFactory, RecommendationFactory, @@ -519,6 +523,11 @@ def incident(session): return IncidentFactory() +@pytest.fixture +def participant_activity(session): + return ParticipantActivityFactory() + + @pytest.fixture def event(session): return EventFactory() @@ -612,3 +621,18 @@ def workflow(session, workflow_plugin_instance): @pytest.fixture def workflow_instance(session): return WorkflowInstanceFactory() + + +@pytest.fixture +def plugin_event(session): + return PluginEventFactory() + + +@pytest.fixture +def cost_model(session): + return CostModelFactory() + + +@pytest.fixture +def cost_model_activity(session): + return CostModelActivityFactory() diff --git a/tests/cost_model/test_cost_model_service.py b/tests/cost_model/test_cost_model_service.py new file mode 100644 index 000000000000..2f19a5792390 --- /dev/null +++ b/tests/cost_model/test_cost_model_service.py @@ -0,0 +1,282 @@ +# Cost Model Activity Tests + + +def test_create_cost_model_activity(session, plugin_event): + from dispatch.cost_model.service import create_cost_model_activity + from dispatch.cost_model.models import CostModelActivityCreate + + cost_model_activity_in = CostModelActivityCreate( + plugin_event=plugin_event, + response_time_seconds=5, + enabled=True, + ) + + activity = create_cost_model_activity( + db_session=session, cost_model_activity_in=cost_model_activity_in + ) + + assert activity + + +def test_update_cost_model_activity(session, cost_model_activity): + from dispatch.cost_model.service import update_cost_model_activity + from dispatch.cost_model.models import CostModelActivityUpdate + + cost_model_activity_in = CostModelActivityUpdate( + id=cost_model_activity.id, + plugin_event=cost_model_activity.plugin_event, + response_time_seconds=cost_model_activity.response_time_seconds + 2, + enabled=cost_model_activity.enabled, + ) + + activity = update_cost_model_activity( + db_session=session, cost_model_activity_in=cost_model_activity_in + ) + + assert activity + assert activity.response_time_seconds == cost_model_activity_in.response_time_seconds + + +def test_delete_cost_model_activity(session, cost_model_activity): + from dispatch.cost_model.service import ( + delete_cost_model_activity, + get_cost_model_activity_by_id, + ) + from sqlalchemy.orm.exc import NoResultFound + + delete_cost_model_activity(db_session=session, cost_model_activity_id=cost_model_activity.id) + deleted = False + + try: + get_cost_model_activity_by_id( + db_session=session, cost_model_activity_id=cost_model_activity.id + ) + except NoResultFound: + deleted = True + + assert deleted + + +# Cost Model Tests + + +def test_create_cost_model(session, cost_model_activity, project): + """Tests that a cost model can be created.""" + from dispatch.cost_model.models import CostModelCreate + from datetime import datetime + from dispatch.cost_model.service import create, get_cost_model_by_id + + name = "model_name" + description = "model_description" + activities = [cost_model_activity] + enabled = False + + cost_model_in = CostModelCreate( + name=name, + description=description, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + activities=activities, + enabled=enabled, + project=project, + ) + cost_model = create(db_session=session, cost_model_in=cost_model_in) + + # Validate cost model creation + assert cost_model + assert cost_model.name == cost_model_in.name + assert cost_model.description == cost_model_in.description + assert cost_model.enabled == cost_model_in.enabled + assert len(cost_model.activities) == len(cost_model_in.activities) + + activity_out = cost_model.activities[0] + assert activity_out.response_time_seconds == cost_model_activity.response_time_seconds + assert activity_out.enabled == cost_model_activity.enabled + assert activity_out.plugin_event.id == cost_model_activity.plugin_event.id + + # Validate cost model retrieval + cost_model = get_cost_model_by_id(db_session=session, cost_model_id=cost_model.id) + assert cost_model.created_at == cost_model.created_at + assert cost_model.updated_at == cost_model.updated_at + assert cost_model.name == cost_model.name + assert cost_model.description == cost_model.description + assert cost_model.enabled == cost_model.enabled + assert len(cost_model.activities) == len(cost_model.activities) + + +def test_fail_create_cost_model(session, plugin_event, project): + """Tests that a cost model cannot be created with duplicate plugin events.""" + from dispatch.cost_model.models import CostModelCreate + from dispatch.cost_model.models import CostModelActivityCreate + from datetime import datetime + from dispatch.cost_model.service import create + + cost_model_activity_in = CostModelActivityCreate( + plugin_event=plugin_event, + response_time_seconds=5, + enabled=True, + ) + + name = "model_name" + description = "model_description" + activities = [cost_model_activity_in, cost_model_activity_in] + enabled = False + + cost_model_in = CostModelCreate( + name=name, + description=description, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + activities=activities, + enabled=enabled, + project=project, + ) + try: + create(db_session=session, cost_model_in=cost_model_in) + except KeyError as e: + assert "Duplicate plugin event ids" in str(e) + + +def test_update_cost_model(session, cost_model): + """Tests that a cost model and all its activities are updated. + + The update test cases are: + - Adding a new cost model activity to the existing cost model + - Modifying an existing cost model activity + - Deleting a cost model activity from the existing cost model + """ + import copy + from tests.factories import PluginEventFactory + + from dispatch.cost_model.service import update + from dispatch.cost_model.models import ( + CostModelActivityCreate, + CostModelActivityUpdate, + CostModelUpdate, + ) + + plugin_event_0 = PluginEventFactory() + plugin_event_1 = PluginEventFactory() + + # Update: adding new cost model activities + add_cost_model_activity_0 = CostModelActivityCreate( + plugin_event=plugin_event_0, response_time_seconds=1, enabled=True + ) + add_cost_model_activity_1 = CostModelActivityCreate( + plugin_event=plugin_event_1, + response_time_seconds=2, + enabled=True, + ) + add_update_cost_model_in = CostModelUpdate( + id=cost_model.id, + name="new name", + description="new description", + enabled=True, + project=cost_model.project, + activities=[add_cost_model_activity_0, add_cost_model_activity_1], + ) + + add_update_cost_model = update(db_session=session, cost_model_in=add_update_cost_model_in) + + assert add_update_cost_model.description == add_update_cost_model_in.description + assert add_update_cost_model.name == add_update_cost_model_in.name + assert add_update_cost_model.enabled == add_update_cost_model_in.enabled + assert len(add_update_cost_model.activities) == len(add_update_cost_model_in.activities) + for ( + actual, + expected, + ) in zip( + add_update_cost_model.activities, + add_update_cost_model_in.activities, + strict=True, + ): + assert actual.response_time_seconds == expected.response_time_seconds + assert actual.enabled == expected.enabled + assert actual.plugin_event.id == expected.plugin_event.id + + id_0 = add_update_cost_model.activities[0].id + id_1 = add_update_cost_model.activities[1].id + + # Update: modifying existing cost model activities + modify_cost_model_activity_0 = CostModelActivityUpdate( + plugin_event=plugin_event_0, response_time_seconds=3, enabled=True, id=id_0 + ) + modify_cost_model_activity_1 = CostModelActivityUpdate( + plugin_event=plugin_event_1, + response_time_seconds=4, + enabled=True, + id=id_1, + ) + modify_update_cost_model_in = CostModelUpdate( + id=cost_model.id, + name="new name", + description="new description", + enabled=True, + project=cost_model.project, + activities=[modify_cost_model_activity_0, modify_cost_model_activity_1], + ) + modify_update_cost_model = update( + db_session=session, + cost_model_in=copy.deepcopy(modify_update_cost_model_in), + ) + + assert modify_update_cost_model.description == modify_update_cost_model_in.description + assert modify_update_cost_model.name == modify_update_cost_model_in.name + assert modify_update_cost_model.enabled == modify_update_cost_model_in.enabled + assert len(modify_update_cost_model.activities) == len(modify_update_cost_model_in.activities) + for ( + actual, + expected, + ) in zip( + modify_update_cost_model.activities, + modify_update_cost_model_in.activities, + strict=True, + ): + assert actual.response_time_seconds == expected.response_time_seconds + assert actual.enabled == expected.enabled + assert actual.plugin_event.id == expected.plugin_event.id + + # Update: deleting existing cost model activities + retained_cost_model_activity = modify_cost_model_activity_1 + delete_update_cost_model_in = CostModelUpdate( + id=cost_model.id, + name=cost_model.name, + description=cost_model.description, + enabled=cost_model.enabled, + project=cost_model.project, + activities=[retained_cost_model_activity], + ) + delete_update_cost_model = update(db_session=session, cost_model_in=delete_update_cost_model_in) + assert len(delete_update_cost_model.activities) == 1 + assert ( + delete_update_cost_model.activities[0].plugin_event.id + == retained_cost_model_activity.plugin_event.id + ) + + +def test_delete_cost_model(session, cost_model, cost_model_activity): + """Tests that a cost model and all its activities are deleted.""" + from dispatch.cost_model.service import delete, get_cost_model_by_id + from dispatch.cost_model import ( + service as cost_model_service, + ) + from sqlalchemy.orm.exc import NoResultFound + + cost_model.activities.append(cost_model_activity) + delete(db_session=session, cost_model_id=cost_model.id) + deleted = False + + try: + get_cost_model_by_id(db_session=session, cost_model_id=cost_model.id) + except NoResultFound: + deleted = True + + try: + cost_model_service.get_cost_model_activity_by_id( + db_session=session, cost_model_activity_id=cost_model_activity.id + ) + except NoResultFound: + deleted = deleted and True + + # Fails if the cost model and all its activities are not deleted. + assert deleted diff --git a/tests/factories.py b/tests/factories.py index 7b8291e5699a..47b91f451365 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -34,6 +34,8 @@ from dispatch.incident.severity.models import IncidentSeverity from dispatch.incident.type.models import IncidentType from dispatch.incident_cost.models import IncidentCost +from dispatch.cost_model.models import CostModel, CostModelActivity +from dispatch.participant_activity.models import ParticipantActivity from dispatch.incident_cost_type.models import IncidentCostType from dispatch.incident_role.models import IncidentRole from dispatch.individual.models import IndividualContact @@ -41,7 +43,7 @@ from dispatch.organization.models import Organization from dispatch.participant.models import Participant from dispatch.participant_role.models import ParticipantRole -from dispatch.plugin.models import Plugin, PluginInstance +from dispatch.plugin.models import Plugin, PluginInstance, PluginEvent from dispatch.project.models import Project from dispatch.report.models import Report from dispatch.route.models import Recommendation, RecommendationMatch @@ -140,6 +142,32 @@ def organization(self, create, extracted, **kwargs): self.organization_id = extracted.id +class CostModelFactory(BaseFactory): + """Cost Model Factory.""" + + id = Sequence(lambda n: f"1{n}") + name = FuzzyText() + description = FuzzyText() + created_at = FuzzyDateTime(datetime(2020, 1, 1, tzinfo=UTC)) + updated_at = FuzzyDateTime(datetime(2020, 1, 1, tzinfo=UTC)) + enabled = Faker().pybool() + project = SubFactory(ProjectFactory) + + class Meta: + """Factory Configuration.""" + + model = CostModel + + @post_generation + def activities(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + for activity in extracted: + self.activities.append(activity) + + class ResourceBaseFactory(TimeStampBaseFactory): """Resource Base Factory.""" @@ -489,6 +517,7 @@ class ParticipantFactory(BaseFactory): location = Sequence(lambda n: f"location{n}") added_reason = Sequence(lambda n: f"added_reason{n}") after_hours_notification = Faker().pybool() + user_conversation_id = FuzzyText() class Meta: """Factory Configuration.""" @@ -888,6 +917,8 @@ class IncidentFactory(BaseFactory): incident_priority = SubFactory(IncidentPriorityFactory) incident_severity = SubFactory(IncidentSeverityFactory) project = SubFactory(ProjectFactory) + cost_model = SubFactory(CostModelFactory) + conversation = SubFactory(ConversationFactory) class Meta: """Factory Configuration.""" @@ -1212,6 +1243,7 @@ class Meta: class PluginInstanceFactory(BaseFactory): """PluginInstance Factory.""" + # id = Sequence(lambda n: f"1{n}") enabled = True project = SubFactory(ProjectFactory) plugin = SubFactory(PluginFactory) @@ -1222,6 +1254,51 @@ class Meta: model = PluginInstance +class PluginEventFactory(BaseFactory): + """Plugin Event Factory.""" + + id = Sequence(lambda n: f"1{n}") + name = FuzzyText() + slug = Sequence(lambda n: f"1{n}") # Ensures unique slug + plugin = SubFactory(PluginFactory) + + class Meta: + """Factory Configuration.""" + + model = PluginEvent + + +class CostModelActivityFactory(BaseFactory): + """Cost Model Activity Factory.""" + + response_time_seconds = FuzzyInteger(low=1, high=10000) + enabled = Faker().pybool() + plugin_event = SubFactory(PluginEventFactory) + + class Meta: + """Factory Configuration.""" + + model = CostModelActivity + + +class ParticipantActivityFactory(BaseFactory): + """Participant Activity Factory.""" + + id = Sequence(lambda n: f"1{n}") + plugin_event = SubFactory(PluginEventFactory) + started_at = FuzzyDateTime( + start_dt=datetime(2020, 1, 1, tzinfo=UTC), end_dt=datetime(2020, 2, 1, tzinfo=UTC) + ) + ended_at = FuzzyDateTime(start_dt=datetime(2020, 2, 2, tzinfo=UTC)) + participant = SubFactory(ParticipantFactory) + incident = SubFactory(IncidentFactory) + + class Meta: + """Factory Configuration.""" + + model = ParticipantActivity + + class WorkflowFactory(BaseFactory): """Workflow Factory.""" diff --git a/tests/incident_cost/test_incident_cost_service.py b/tests/incident_cost/test_incident_cost_service.py index 0ae4ca4b2ae5..be44103c0954 100644 --- a/tests/incident_cost/test_incident_cost_service.py +++ b/tests/incident_cost/test_incident_cost_service.py @@ -45,3 +45,72 @@ def test_delete(session, incident_cost): delete(db_session=session, incident_cost_id=incident_cost.id) assert not get(db_session=session, incident_cost_id=incident_cost.id) + + +def test_calculate_incident_response_cost_with_cost_model( + session, + incident, + incident_cost_type, + cost_model_activity, + conversation_plugin_instance, + conversation, + participant, +): + """Tests that the incident cost is calculated correctly when a cost model is enabled.""" + from datetime import timedelta + import math + from dispatch.incident.service import get + from dispatch.incident_cost.service import ( + update_incident_response_cost, + ) + from dispatch.incident_cost_type import service as incident_cost_type_service + from dispatch.participant_activity.service import ( + get_all_incident_participant_activities_for_incident, + ) + from dispatch.plugins.dispatch_slack.events import ChannelActivityEvent + + SECONDS_IN_HOUR = 3600 + orig_total_incident_cost = incident.total_cost + + # Set incoming plugin events. + conversation_plugin_instance.project_id = incident.project.id + cost_model_activity.plugin_event.plugin = conversation_plugin_instance.plugin + participant.user_conversation_id = "0XDECAFBAD" + participant.incident = incident + + # Set up a default incident costs type. + for cost_type in incident_cost_type_service.get_all(db_session=session): + cost_type.default = False + incident_cost_type.default = True + incident_cost_type.project = incident.project + + # Set up incident. + incident = get(db_session=session, incident_id=incident.id) + cost_model_activity.plugin_event.slug = ChannelActivityEvent.slug + incident.cost_model.enabled = True + incident.cost_model.activities = [cost_model_activity] + incident.conversation = conversation + + # Calculates and updates the incident cost. + cost = update_incident_response_cost(incident_id=incident.id, db_session=session) + activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=incident.id + ) + assert activities + + # Evaluate expected incident cost. + participants_total_response_time_seconds = timedelta(seconds=0) + for activity in activities: + participants_total_response_time_seconds += activity.ended_at - activity.started_at + hourly_rate = math.ceil( + incident.project.annual_employee_cost / incident.project.business_year_hours + ) + expected_incident_cost = ( + math.ceil( + (participants_total_response_time_seconds.seconds / SECONDS_IN_HOUR) * hourly_rate + ) + + orig_total_incident_cost + ) + + assert cost + assert cost == expected_incident_cost == incident.total_cost diff --git a/tests/incident_cost_type/test_incident_cost_type_service.py b/tests/incident_cost_type/test_incident_cost_type_service.py index ca38f57788dc..a7c3bc9a70c0 100644 --- a/tests/incident_cost_type/test_incident_cost_type_service.py +++ b/tests/incident_cost_type/test_incident_cost_type_service.py @@ -8,7 +8,7 @@ def test_get(session, incident_cost_type): def test_get_all(session, incident_cost_types): from dispatch.incident_cost_type.service import get_all - t_incident_cost_types = get_all(db_session=session).all() + t_incident_cost_types = get_all(db_session=session) assert t_incident_cost_types diff --git a/tests/participant_activity/test_participant_activity_service.py b/tests/participant_activity/test_participant_activity_service.py new file mode 100644 index 000000000000..186c969df044 --- /dev/null +++ b/tests/participant_activity/test_participant_activity_service.py @@ -0,0 +1,212 @@ +def test_create_participant_activity(session, plugin_event, participant, incident): + from dispatch.participant_activity.service import ( + create, + get_all_incident_participant_activities_for_incident, + ) + from dispatch.participant_activity.models import ParticipantActivityCreate + + activity_in = ParticipantActivityCreate( + plugin_event=plugin_event, + participant=participant, + incident=incident, + ) + + activity_out = create(db_session=session, activity_in=activity_in) + assert activity_out + + activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=incident.id + ) + assert activities + assert activity_out in activities + + +def test_get_participant_incident_activities_by_individual_contact( + session, participant_activity, participant +): + """Tests that we can get all incident participant activities for an individual across all incidents.""" + from dispatch.participant_activity.service import ( + get_participant_incident_activities_by_individual_contact, + ) + + participant_activity.participant = participant + activities = get_participant_incident_activities_by_individual_contact( + db_session=session, + individual_contact_id=participant_activity.participant.individual_contact_id, + ) + + assert activities + assert participant_activity.id in [activity.id for activity in activities] + + +def test_create_or_update_participant_activity__same_plugin_no_overlap( + session, participant_activity +): + """Tests that a new participant activity is created when there is no time overlap with previously recorded activities.""" + from datetime import timedelta + from dispatch.participant_activity.models import ParticipantActivityCreate + from dispatch.participant_activity.service import ( + create_or_update, + get_all_incident_participant_activities_for_incident, + ) + + orig_activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=participant_activity.incident.id + ) + + started_at = participant_activity.ended_at + timedelta(seconds=1) + ended_at = participant_activity.ended_at + timedelta(seconds=10) + + activity_in = ParticipantActivityCreate( + plugin_event=participant_activity.plugin_event, + started_at=started_at, + ended_at=ended_at, + participant=participant_activity.participant, + incident=participant_activity.incident, + ) + + assert ended_at - started_at == create_or_update(db_session=session, activity_in=activity_in) + activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=participant_activity.incident.id + ) + assert activities + assert len(activities) == len(orig_activities) + 1 + assert participant_activity.id in [activity.id for activity in activities] + + +def test_create_or_update_participant_activity__new_plugin_no_overlap( + session, participant_activity, plugin_event +): + """Tests that a new participant activity is created when there is no time overlap with previously recorded activities.""" + from datetime import timedelta + from dispatch.participant_activity.models import ParticipantActivityCreate + from dispatch.participant_activity.service import ( + create_or_update, + get_all_incident_participant_activities_for_incident, + ) + + assert participant_activity.plugin_event.id != plugin_event.id + orig_activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=participant_activity.incident.id + ) + + started_at = participant_activity.ended_at + timedelta(seconds=1) + ended_at = started_at + timedelta(seconds=10) + + activity_in = ParticipantActivityCreate( + plugin_event=plugin_event, + started_at=started_at, + ended_at=ended_at, + participant=participant_activity.participant, + incident=participant_activity.incident, + ) + assert ended_at - started_at == create_or_update(db_session=session, activity_in=activity_in) + + activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=participant_activity.incident.id + ) + assert activities + assert len(activities) == len(orig_activities) + 1 + assert participant_activity.id in [activity.id for activity in activities] + + +def test_create_or_update_participant_activity__same_plugin_with_overlap( + session, participant_activity +): + """Tests only updating an existing participant activity. + + Tests that the previously recorded participant activity is updated when there is continuous activity with the same plugin event. + """ + from datetime import timedelta + from dispatch.participant_activity.models import ParticipantActivityCreate + from dispatch.participant_activity.service import ( + create_or_update, + get_all_incident_participant_activities_for_incident, + ) + + orig_activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=participant_activity.incident.id + ) + + # Start new incident activity in the middle of the existing recorded incident activity. + started_at = ( + participant_activity.started_at + + (participant_activity.ended_at - participant_activity.started_at) / 2 + ) + ended_at = participant_activity.ended_at + timedelta(seconds=10) + + activity_in = ParticipantActivityCreate( + plugin_event=participant_activity.plugin_event, + started_at=started_at, + ended_at=ended_at, + participant=participant_activity.participant, + incident=participant_activity.incident, + ) + + assert timedelta(seconds=10) == create_or_update(db_session=session, activity_in=activity_in) + activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=participant_activity.incident.id + ) + assert activities + assert len(activities) == len(orig_activities) + assert participant_activity.id in [activity.id for activity in activities] + + +def test_create_or_update_participant_activity__new_plugin_with_overlap( + session, participant_activity, plugin_event +): + """Tests updating an existing participant activity and creating a new participant activity. + + Tests that the previously recorded participant activity is updated and a new incident participant + activity is created when there is continuous participant activity coming from a different plugin event. + """ + from datetime import timedelta + from dispatch.participant_activity.models import ParticipantActivityCreate + from dispatch.participant_activity.service import ( + create_or_update, + get_all_incident_participant_activities_for_incident, + ) + + assert participant_activity.plugin_event.id != plugin_event.id + orig_activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=participant_activity.incident.id + ) + + # Start new incident activity in the middle of the existing recorded incident activity. + started_at = ( + participant_activity.started_at + + (participant_activity.ended_at - participant_activity.started_at) / 2 + ) + ended_at = participant_activity.ended_at + timedelta(seconds=10) + + activity_in = ParticipantActivityCreate( + plugin_event=plugin_event, + started_at=started_at, + ended_at=ended_at, + participant=participant_activity.participant, + incident=participant_activity.incident, + ) + + assert timedelta(seconds=10) == create_or_update(db_session=session, activity_in=activity_in) + activities = get_all_incident_participant_activities_for_incident( + db_session=session, incident_id=participant_activity.incident.id + ) + assert activities + assert len(activities) == len(orig_activities) + 1 + assert participant_activity.id in [activity.id for activity in activities] + + +def test_get_incidents_by_plugin(session, participant_activity): + """Tests retrieval of all of an incident's recorded participant activity from a specific plugin.""" + from dispatch.participant_activity.service import ( + get_all_recorded_incident_partcipant_activities_for_plugin, + ) + + activities = get_all_recorded_incident_partcipant_activities_for_plugin( + db_session=session, + incident_id=participant_activity.incident.id, + plugin_id=participant_activity.plugin_event.plugin.id, + ) + + assert activities + assert participant_activity.id in [activity.id for activity in activities] diff --git a/tests/plugin/test_plugin_service.py b/tests/plugin/test_plugin_service.py index 97ea3b343e09..099618fa1ce0 100644 --- a/tests/plugin/test_plugin_service.py +++ b/tests/plugin/test_plugin_service.py @@ -55,3 +55,58 @@ def test_delete_instance(session, plugin_instance): delete_instance(db_session=session, plugin_instance_id=plugin_instance.id) assert not get_instance(db_session=session, plugin_instance_id=plugin_instance.id) + + +def test_get_plugin_event_by_id(*, session, plugin_event): + """Returns a project based on the given project name.""" + from dispatch.plugin.service import get_plugin_event_by_id + + plugin_event_out = get_plugin_event_by_id(db_session=session, plugin_event_id=plugin_event.id) + assert plugin_event_out + assert plugin_event_out.id == plugin_event.id + + +def test_get_plugin_event_by_slug(*, session, plugin_event): + """Returns a project based on the given project name.""" + from dispatch.plugin.service import get_plugin_event_by_slug + + plugin_event_out = get_plugin_event_by_slug(db_session=session, slug=plugin_event.slug) + assert plugin_event_out + assert plugin_event_out.id == plugin_event.id + + +def test_register_plugin_event(session, plugin): + from dispatch.plugin.service import create_plugin_event, get_plugin_event_by_id + from dispatch.plugin.models import PluginEventCreate + + plugin_event = create_plugin_event( + db_session=session, + plugin_event_in=PluginEventCreate(name="foo", slug="bar", plugin=plugin), + ) + assert plugin_event + + plugin_event_out = get_plugin_event_by_id(db_session=session, plugin_event_id=plugin_event.id) + assert plugin_event_out + assert plugin_event_out.id == plugin_event.id + + +def test_get_all_events_for_plugin(*, session, plugin_event): + from dispatch.plugin.service import get_all_events_for_plugin + + plugin_events_out = get_all_events_for_plugin( + db_session=session, plugin_id=plugin_event.plugin.id + ) + + # Assert membership of plugin_event. + is_member = False + for plugin_event_out in plugin_events_out: + if ( + plugin_event_out.id == plugin_event.id + and plugin_event_out.name == plugin_event.name + and plugin_event_out.plugin.id == plugin_event.plugin.id + and plugin_event_out.description == plugin_event.description + and plugin_event_out.slug == plugin_event.slug + ): + is_member = True + break + assert is_member