diff --git a/.editorconfig b/.editorconfig index 320e2172db18..f299d4828ef7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,6 +8,9 @@ indent_style=space indent_size=2 trim_trailing_whitespace=true +[*.py] +indent_size=4 + [{*.ng,*.sht,*.html,*.shtm,*.shtml,*.htm}] indent_style=space indent_size=2 diff --git a/.gitignore b/.gitignore index a37ce0080256..ed0421f7b1ac 100644 --- a/.gitignore +++ b/.gitignore @@ -137,6 +137,7 @@ venv/ ENV/ env.bak/ venv.bak/ +.vscode/ # Spyder project settings .spyderproject diff --git a/.nvmrc b/.nvmrc index 0510f298a0c1..d494f460a4db 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -12.7.0 \ No newline at end of file +12.16.2 diff --git a/.vscode/launch.json b/.vscode/launch.json index c6b501e6305f..d6a14421cabe 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,7 @@ "name": "Server Debug", "type": "python", "request": "launch", - "program": "${workspaceFolder}/src/dispatch/run.py" + "program": "${workspaceFolder}/bin/run.py" }, { "type": "chrome", diff --git a/.vscode/settings.json b/.vscode/settings.json index f88b329a91eb..acb1af551190 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,5 +27,5 @@ "editor.codeActionsOnSave": { "source.organizeImports": false } - } + }, } diff --git a/src/dispatch/run.py b/bin/run.py similarity index 100% rename from src/dispatch/run.py rename to bin/run.py diff --git a/docker/Dockerfile b/docker/Dockerfile index 9c3ccebef3d0..47037154bf18 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -11,8 +11,9 @@ LABEL org.opencontainers.image.authors="oss@netflix.com" RUN apt-get update && apt-get install -y --no-install-recommends \ # Needed for GPG dirmngr \ - gnupg \ + gnupg2 \ # Needed for fetching stuff + ca-certificates \ wget \ && rm -rf /var/lib/apt/lists/* @@ -35,16 +36,18 @@ RUN for key in \ A48C2BEE680E841632CD4E44F07496B3EB3C1762 \ B9E2F5981AA6E0CD28160D9FF13993A75599653C \ ; do \ - gpg --batch --keyserver hkps://mattrobenolt-keyserver.global.ssl.fastly.net:443 --recv-keys "$key"; \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "$key"; \ done # grab gosu for easy step-down from root -ENV GOSU_VERSION 1.11 +ENV GOSU_VERSION 1.12 RUN set -x \ - && wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$(dpkg --print-architecture)" \ - && wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$(dpkg --print-architecture).asc" \ + && dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')" + && wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch" \ + && wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc" \ + && export GNUPGHOME="$(mktemp -d)" \ && gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu \ - && rm -r /usr/local/bin/gosu.asc \ + && rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc \ && chmod +x /usr/local/bin/gosu # grab tini for signal processing and zombie killing @@ -53,7 +56,7 @@ RUN set -x \ && wget -O /usr/local/bin/tini "https://github.com/krallin/tini/releases/download/v$TINI_VERSION/tini" \ && wget -O /usr/local/bin/tini.asc "https://github.com/krallin/tini/releases/download/v$TINI_VERSION/tini.asc" \ && gpg --batch --verify /usr/local/bin/tini.asc /usr/local/bin/tini \ - && rm /usr/local/bin/tini.asc \ + && rm -f /usr/local/bin/tini.asc \ && chmod +x /usr/local/bin/tini # Get and set up Node for front-end asset building @@ -65,7 +68,7 @@ RUN cd /usr/src/dispatch \ && gpg --batch --verify SHASUMS256.txt.asc \ && grep " node-v$NODE_VERSION-linux-x64.tar.gz\$" SHASUMS256.txt.asc | sha256sum -c - \ && tar -xzf "node-v$NODE_VERSION-linux-x64.tar.gz" -C /usr/local --strip-components=1 \ - && rm -r "node-v$NODE_VERSION-linux-x64.tar.gz" SHASUMS256.txt.asc + && rm -f "node-v$NODE_VERSION-linux-x64.tar.gz" SHASUMS256.txt.asc ARG SOURCE_COMMIT ENV DISPATCH_BUILD=${SOURCE_COMMIT:-unknown} diff --git a/docs/configuration/app.md b/docs/configuration/app.md index 3b55a1b7da00..de376a9737d0 100644 --- a/docs/configuration/app.md +++ b/docs/configuration/app.md @@ -57,17 +57,35 @@ In general, do not include any quotation marks when adding configuration values. ### Authentication -#### `DISPATCH_AUTHENTICATION_PROVIDER` \['default': dispatch-auth-provider-pkce\] +#### `DISPATCH_AUTHENTICATION_PROVIDER_SLUG` \['default': dispatch-auth-provider-basic\] > Used by Dispatch to determine which authentication provider to use, by default Dispatch ships with a PKCE authentication provider. {% hint style="info" %} -If you wish to disabled authentication set `DISPATCH_AUTHENTICATION_PROVIDER=""` +If you wish to disable authentication set `DISPATCH_AUTHENTICATION_PROVIDER=""` {% endhint %} +#### Configuration for `dispatch-auth-provider-basic` + +{% hint style="warning" %} +Today, basic authentication allows self registration without approval. +{% endhint %} + +#### `DISPATCH_JWT_SECRET` + +> Uses by the basic auth provider to mint JWT tokens. + +#### `DISPATCH_JWT_ALG` ['default': 'HS256'] + +> Used by the basic auth provider to mint JWT tokens. + +#### `DISPATCH_JWT_EXP` ['default': 86400 ] + +> Used by the basic auth provider to mint JWT tokens and set their expiration. + #### `DISPATCH_AUTHENTICATION_DEFAULT_USER` \['default': dispatch@example.com\] -> Used when authentication is disable as the default anonymous user. +> Used as the default anonymous user when authentication is disabled. #### Configuration for `dispatch-auth-provider-pkce` @@ -77,11 +95,11 @@ If you wish to disabled authentication set `DISPATCH_AUTHENTICATION_PROVIDER=""` #### `VUE_APP_DISPATCH_AUTHENTICATION_PROVIDER_PKCE_OPEN_ID_CONNECT` -> Used by the Dispatch Web UI send the user via Proof Key Code Exchange \(PKCE\) to a correct open id connect endpoint. +> Used by the Dispatch Web UI send the user via Proof Key Code Exchange \(PKCE\) to a correct OpenID Connect endpoint. #### `VUE_APP_DISPATCH_AUTHENTICATOIN_PROVIDER_PKCE_CLIENT_ID` -> The client id to send to the open id connect endpoint. +> The client id to send to the OpenID Connect endpoint. ### Persistence @@ -105,13 +123,15 @@ If you wish to disabled authentication set `DISPATCH_AUTHENTICATION_PROVIDER=""` ### Incident Cost +Dispatch [calculates](https://github.com/Netflix/dispatch/blob/develop/src/dispatch/incident/service.py#L279) the cost of an incident by adding up the time participants have spent on each incident role (e.g. Incident Commander) and applying an [engagement multiplier](https://github.com/Netflix/dispatch/blob/develop/src/dispatch/incident/service.py#L266) that's based on the incident role. It also includes time spent on incident review related activities. Dispatch calculates and published the cost for all incidents [every 5 minutes](https://github.com/Netflix/dispatch/blob/develop/src/dispatch/incident/scheduled.py#L257). + #### `ANNUAL_COST_EMPLOYEE` \[default: '50000'\] -> Used for incident cost modeling, specifies the total `all-in` cost for an average employee working on incidents. +> Used for incident cost modeling, specifies the total `all-in` average cost for an employee working on incidents. #### `BUSINESS_HOURS_YEAR` \[default: '2080'\] -> Used for incident cost modeling, specifies the number of hours in an employee's work week. +> Used for incident cost modeling, specifies the number of hours in an employee's work year. ### Incident Plugin Configuration diff --git a/docs/configuration/plugins/configuring-slack.md b/docs/configuration/plugins/configuring-slack.md index d3d9f733b1aa..1f6c0f018cf6 100644 --- a/docs/configuration/plugins/configuring-slack.md +++ b/docs/configuration/plugins/configuring-slack.md @@ -72,6 +72,18 @@ The `Slack` plugin relies on the [Events API](https://api.slack.com/events-api) > List resources command as displayed in Slack. +#### `SLACK_PROFILE_DEPARTMENT_FIELD_ID` + +> Specifies the profile field ID where Department is mapped + +#### `SLACK_PROFILE_TEAM_FIELD_ID` + +> Specifies the profile field ID where Team is mapped + +#### `SLACK_PROFILE_WEBLINK_FIELD_ID` + +> Specifies the profile field ID where the weblink is mapped + ## Event Subscriptions To enable Dispatch to process Slack events, ensure your bot is subscribed to the following events: @@ -128,6 +140,7 @@ remote_files:read team:read users:read users:read.email +users.profile:read users:write ``` diff --git a/docs/contributing/plugins/interfaces.md b/docs/contributing/plugins/interfaces.md index 41adbd61731e..8fb05f124d59 100644 --- a/docs/contributing/plugins/interfaces.md +++ b/docs/contributing/plugins/interfaces.md @@ -93,6 +93,10 @@ def open_dialog(self, trigger_id: str, dialog: dict): """Opens a dialog with a user.""" return +def open_modal(self, trigger_id: str, modal: dict): + """Opens a modal with a user.""" + return + def archive(self, conversation_id: str): """Archives conversation.""" return diff --git a/docs/user-guide/incident-participant.md b/docs/user-guide/incident-participant.md index 3cf1c75ae406..206bca9cc4e8 100644 --- a/docs/user-guide/incident-participant.md +++ b/docs/user-guide/incident-participant.md @@ -26,7 +26,7 @@ Each new participant receives a welcome message \(Email + Slack\) providing them ![Incident welcome email](https://lh3.googleusercontent.com/9AhkQ-y5h-sQN0F6KLrBEE_6cGA-XN4Qu1cj4NAGNj1OOfA7p4c4z0G7BYxydz3oOYCVkqTkl_EYAeO4SOsCWkVXme5hUByCnYNDkFPQhQTkNYulc--rOQNQGD856s4uPZPYHEwvlk0) -![Incident welcome slack \(ephemeral\)](https://lh4.googleusercontent.com/EgiaPr7p7X-MsmhU7LCNn9BoM0qgqlj-yFBRsxHYGFY6GWSVmYkqNjDzFB-iTNpZBlaxjpVJ_R8HC5jO9gu12ehtIGfT3-7At7lQms-dppkxiFZTyOA8LUQyubCDqLAU23NYwcoQfrw) +![Incident welcome slack (ephemeral)](https://lh4.googleusercontent.com/EgiaPr7p7X-MsmhU7LCNn9BoM0qgqlj-yFBRsxHYGFY6GWSVmYkqNjDzFB-iTNpZBlaxjpVJ_R8HC5jO9gu12ehtIGfT3-7At7lQms-dppkxiFZTyOA8LUQyubCDqLAU23NYwcoQfrw) From there use the resources as you normally would to run your investigation, Dispatch will be there managing permissions, providing reminders and helping track the incident as it progresses to resolution. @@ -39,6 +39,5 @@ After an incident has been marked stable Dispatch is still there to help creatin In addition to Dispatch pulling in individuals that will be directly responsible for managing the incident, it provides notifications for general awareness throughout the organization. {% hint style="info" %} -The incident notification message includes a "Get Involved" button, this allows individuals to add themselves to the incident \(and it's resources\) without involvement from the incident commander. +The incident notification message includes a "Join Incident" button, this allows individuals to add themselves to the incident \(and it's resources\) without involvement from the incident commander. {% endhint %} - diff --git a/requirements-base.txt b/requirements-base.txt index 78c3552cf9de..066420eaa070 100644 --- a/requirements-base.txt +++ b/requirements-base.txt @@ -2,18 +2,22 @@ SQLAlchemy-Searchable aiofiles alembic arrow +bcrypt cachetools==3.1.1 # NOTE pinning for google-auth click email-validator emails fastapi +fastapi_permissions google-api-python-client google-auth-oauthlib httpx jinja2 jira joblib +numpy oauth2client +pandas psycopg2-binary pyparsing pypd # pagerduty plugin @@ -30,6 +34,7 @@ slackclient spacy sqlalchemy sqlalchemy-filters +statsmodels tabulate tenacity uvicorn diff --git a/requirements-metrics.txt b/requirements-metrics.txt deleted file mode 100644 index 97259fddc1e3..000000000000 --- a/requirements-metrics.txt +++ /dev/null @@ -1,3 +0,0 @@ -numpy -pystan -fbprophet diff --git a/setup.py b/setup.py index 83145522ae36..477705aff8a2 100644 --- a/setup.py +++ b/setup.py @@ -345,7 +345,6 @@ def get_requirements(env): install_requires = get_requirements("base") dev_requires = get_requirements("dev") -metrics_requires = get_requirements("metrics") class DispatchSDistCommand(SDistCommand): @@ -397,9 +396,9 @@ def run(self): packages=find_packages("src"), python_requires=">=3.7", install_requires=install_requires, - extras_require={"dev": dev_requires, "metrics": metrics_requires}, + extras_require={"dev": dev_requires}, cmdclass=cmdclass, - zip_save=False, + zip_safe=False, include_package_data=True, entry_points={ "console_scripts": ["dispatch = dispatch.cli:entrypoint"], @@ -407,6 +406,8 @@ def run(self): "dispatch_document_resolver = dispatch.plugins.dispatch_core.plugin:DispatchDocumentResolverPlugin", "dispatch_participant_resolver = dispatch.plugins.dispatch_core.plugin:DispatchParticipantResolverPlugin", "dispatch_pkce_auth = dispatch.plugins.dispatch_core.plugin:PKCEAuthProviderPlugin", + "dispatch_ticket = dispatch.plugins.dispatch_core.plugin:DispatchTicketPlugin", + "dispatch_basic_auth = dispatch.plugins.dispatch_core.plugin:BasicAuthProviderPlugin", "google_calendar_conference = dispatch.plugins.dispatch_google.calendar.plugin:GoogleCalendarConferencePlugin", "google_docs_document = dispatch.plugins.dispatch_google.docs.plugin:GoogleDocsDocumentPlugin", "google_drive_storage = dispatch.plugins.dispatch_google.drive.plugin:GoogleDriveStoragePlugin", diff --git a/src/dispatch/__init__.py b/src/dispatch/__init__.py index e6ccd5afedd6..b9eb6d4f4bce 100644 --- a/src/dispatch/__init__.py +++ b/src/dispatch/__init__.py @@ -7,6 +7,33 @@ except Exception: VERSION = "unknown" +from dispatch.conference.models import Conference # noqa lgtm[py/unused-import] +from dispatch.team.models import TeamContact # noqa lgtm[py/unused-import] +from dispatch.conversation.models import Conversation # noqa lgtm[py/unused-import] +from dispatch.definition.models import Definition # noqa lgtm[py/unused-import] +from dispatch.document.models import Document # noqa lgtm[py/unused-import] +from dispatch.event.models import Event # noqa lgtm[py/unused-import] +from dispatch.group.models import Group # noqa lgtm[py/unused-import] +from dispatch.incident.models import Incident # noqa lgtm[py/unused-import] +from dispatch.incident_priority.models import IncidentPriority # noqa lgtm[py/unused-import] +from dispatch.incident_type.models import IncidentType # noqa lgtm[py/unused-import] +from dispatch.individual.models import IndividualContact # noqa lgtm[py/unused-import] +from dispatch.participant.models import Participant # noqa lgtm[py/unused-import] +from dispatch.participant_role.models import ParticipantRole # noqa lgtm[py/unused-import] +from dispatch.policy.models import Policy # noqa lgtm[py/unused-import] +from dispatch.route.models import ( + Recommendation, # noqa lgtm[py/unused-import] + RecommendationAccuracy, # noqa lgtm[py/unused-import] +) +from dispatch.service.models import Service # noqa lgtm[py/unused-import] +from dispatch.status_report.models import StatusReport # noqa lgtm[py/unused-import] +from dispatch.storage.models import Storage # noqa lgtm[py/unused-import] +from dispatch.tag.models import Tag # noqa lgtm[py/unused-import] +from dispatch.task.models import Task # noqa lgtm[py/unused-import] +from dispatch.term.models import Term # noqa lgtm[py/unused-import] +from dispatch.ticket.models import Ticket # noqa lgtm[py/unused-import] +from dispatch.plugin.models import Plugin # noqa lgtm[py/unused-import] + def _get_git_revision(path): if not os.path.exists(os.path.join(path, ".git")): diff --git a/src/dispatch/alembic/env.py b/src/dispatch/alembic/env.py index 46ab8e2b5bf6..b7510818ec1d 100644 --- a/src/dispatch/alembic/env.py +++ b/src/dispatch/alembic/env.py @@ -4,8 +4,7 @@ from sqlalchemy import engine_from_config, pool from dispatch.config import SQLALCHEMY_DATABASE_URI - -from dispatch.models import * # noqa; noqa +from dispatch.database import Base # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/src/dispatch/alembic/versions/1221a4d60f03_.py b/src/dispatch/alembic/versions/1221a4d60f03_.py new file mode 100644 index 000000000000..37d8c1467f9c --- /dev/null +++ b/src/dispatch/alembic/versions/1221a4d60f03_.py @@ -0,0 +1,39 @@ +"""Adds discoverable column + +Revision ID: 1221a4d60f03 +Revises: 14d7a4703d7c +Create Date: 2020-04-17 15:25:36.075381 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.orm import Session +from dispatch.tag.models import Tag + +import sqlalchemy_utils + + +# revision identifiers, used by Alembic. +revision = "1221a4d60f03" +down_revision = "14d7a4703d7c" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("tag", sa.Column("discoverable", sa.Boolean())) + + conn = op.get_bind() + session = Session(bind=conn) + + for tag in session.query(Tag).all(): + tag.discoverable = True + session.commit() + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("tag", "discoverable") + # ### end Alembic commands ### diff --git a/src/dispatch/alembic/versions/2d3e511db6b4_.py b/src/dispatch/alembic/versions/2d3e511db6b4_.py new file mode 100644 index 000000000000..70004e8ef1a7 --- /dev/null +++ b/src/dispatch/alembic/versions/2d3e511db6b4_.py @@ -0,0 +1,30 @@ +"""Adds required or multiple fields to plugin data + +Revision ID: 2d3e511db6b4 +Revises: 62465f508f69 +Create Date: 2020-04-23 15:07:03.996871 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "2d3e511db6b4" +down_revision = "62465f508f69" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("plugin", sa.Column("multiple", sa.Boolean(), nullable=True)) + op.add_column("plugin", sa.Column("required", sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("plugin", "required") + op.drop_column("plugin", "multiple") + # ### end Alembic commands ### diff --git a/src/dispatch/alembic/versions/62465f508f69_.py b/src/dispatch/alembic/versions/62465f508f69_.py new file mode 100644 index 000000000000..ef40bf8b6dc2 --- /dev/null +++ b/src/dispatch/alembic/versions/62465f508f69_.py @@ -0,0 +1,35 @@ +"""Adding dispatch user table + +Revision ID: 62465f508f69 +Revises: 1221a4d60f03 +Create Date: 2020-04-21 15:24:55.512154 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "62465f508f69" +down_revision = "895ae7783033" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "dispatch_user", + sa.Column("email", sa.String(), nullable=False), + sa.Column("password", sa.Binary(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("email"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("dispatch_user") + # ### end Alembic commands ### diff --git a/src/dispatch/alembic/versions/6f04af3f261b_.py b/src/dispatch/alembic/versions/6f04af3f261b_.py new file mode 100644 index 000000000000..0d60e28e9410 --- /dev/null +++ b/src/dispatch/alembic/versions/6f04af3f261b_.py @@ -0,0 +1,31 @@ +"""Adds details column to event model + +Revision ID: 6f04af3f261b +Revises: 2d3e511db6b4 +Create Date: 2020-04-30 16:01:07.212266 + +""" +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils + + +# revision identifiers, used by Alembic. +revision = "6f04af3f261b" +down_revision = "2d3e511db6b4" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "event", sa.Column("details", sqlalchemy_utils.types.json.JSONType(), nullable=True) + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("event", "details") + # ### end Alembic commands ### diff --git a/src/dispatch/alembic/versions/895ae7783033_.py b/src/dispatch/alembic/versions/895ae7783033_.py new file mode 100644 index 000000000000..b19b79e320cf --- /dev/null +++ b/src/dispatch/alembic/versions/895ae7783033_.py @@ -0,0 +1,47 @@ +"""Adds plugin table + +Revision ID: 895ae7783033 +Revises: 057604415f6c +Create Date: 2020-04-14 16:52:08.628909 + +""" +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils + +# revision identifiers, used by Alembic. +revision = "895ae7783033" +down_revision = "1221a4d60f03" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "plugin", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("title", sa.String(), nullable=True), + sa.Column("slug", sa.String(), nullable=True), + sa.Column("description", sa.String(), nullable=True), + sa.Column("version", sa.String(), nullable=True), + sa.Column("author", sa.String(), nullable=True), + sa.Column("author_url", sa.String(), nullable=True), + sa.Column("type", sa.String(), nullable=True), + sa.Column("enabled", sa.Boolean(), nullable=True), + sa.Column("configuration", sqlalchemy_utils.types.json.JSONType(), nullable=True), + sa.Column("search_vector", sqlalchemy_utils.types.ts_vector.TSVectorType(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("slug"), + ) + op.create_index( + "ix_plugin_search_vector", "plugin", ["search_vector"], unique=False, postgresql_using="gin" + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("ix_plugin_search_vector", table_name="plugin") + op.drop_table("plugin") + # ### end Alembic commands ### diff --git a/src/dispatch/alembic/versions/9a3478cbe76c_.py b/src/dispatch/alembic/versions/9a3478cbe76c_.py new file mode 100644 index 000000000000..fad505dea7b8 --- /dev/null +++ b/src/dispatch/alembic/versions/9a3478cbe76c_.py @@ -0,0 +1,48 @@ +"""Adds primary key id to user table. + +Revision ID: 9a3478cbe76c +Revises: 6f04af3f261b +Create Date: 2020-04-29 12:21:04.458208 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import orm +from dispatch.auth.models import DispatchUser + + +# revision identifiers, used by Alembic. +revision = "9a3478cbe76c" +down_revision = "6f04af3f261b" +branch_labels = None +depends_on = None + + +def upgrade(): + # it's easier to drop the table and re-create instead of modifying + conn = op.get_bind() + session = orm.Session(bind=conn) + results = conn.execute("select * from dispatch_user").fetchall() + + op.drop_table("dispatch_user") + op.create_table( + "dispatch_user", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.Column("password", sa.Binary(), nullable=False), + sa.Column("role", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + + for r in results: + if len(r) == 5: + session.add(DispatchUser(email=r[0], password=r[1], role=r[4])) + else: + session.add(DispatchUser(email=r[0], password=r[1])) + session.commit() + + +def downgrade(): + pass diff --git a/src/dispatch/alembic/versions/a32bfbb4bcaa_.py b/src/dispatch/alembic/versions/a32bfbb4bcaa_.py new file mode 100644 index 000000000000..6c723df57d7f --- /dev/null +++ b/src/dispatch/alembic/versions/a32bfbb4bcaa_.py @@ -0,0 +1,30 @@ +"""Adds a unique constraint to email. + +Revision ID: a32bfbb4bcaa +Revises: d1e66a4ef671 +Create Date: 2020-05-18 16:04:52.217536 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "a32bfbb4bcaa" +down_revision = "d1e66a4ef671" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column("dispatch_user", "email", existing_type=sa.VARCHAR(), nullable=True) + op.create_unique_constraint(None, "dispatch_user", ["email"]) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "dispatch_user", type_="unique") + op.alter_column("dispatch_user", "email", existing_type=sa.VARCHAR(), nullable=False) + # ### end Alembic commands ### diff --git a/src/dispatch/alembic/versions/d1e66a4ef671_.py b/src/dispatch/alembic/versions/d1e66a4ef671_.py new file mode 100644 index 000000000000..46b7a3fb566c --- /dev/null +++ b/src/dispatch/alembic/versions/d1e66a4ef671_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: d1e66a4ef671 +Revises: 9a3478cbe76c +Create Date: 2020-05-03 20:41:43.402097 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd1e66a4ef671' +down_revision = '9a3478cbe76c' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('participant', sa.Column('after_hours_notification', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('participant', 'after_hours_notification') + # ### end Alembic commands ### diff --git a/src/dispatch/api.py b/src/dispatch/api.py index abd893a6c8c8..50a30537c6f8 100644 --- a/src/dispatch/api.py +++ b/src/dispatch/api.py @@ -19,14 +19,22 @@ from dispatch.term.views import router as team_router from dispatch.document.views import router as document_router from dispatch.task.views import router as task_router +from dispatch.plugin.views import router as plugin_router +from dispatch.auth.views import user_router, auth_router -from .common.utils.cli import install_plugins, install_plugin_events +from .config import DISPATCH_AUTHENTICATION_PROVIDER_SLUG -api_router = APIRouter() # WARNING: Don't use this unless you want unauthenticated routes +api_router = APIRouter( + default_response_class=JSONResponse +) # WARNING: Don't use this unless you want unauthenticated routes authenticated_api_router = APIRouter() +# NOTE we only advertise auth routes when basic auth is enabled +if DISPATCH_AUTHENTICATION_PROVIDER_SLUG == "dispatch-auth-provider-basic": + api_router.include_router(auth_router, prefix="/auth", tags=["auth"]) # NOTE: All api routes should be authenticated by default +authenticated_api_router.include_router(user_router, prefix="/user", tags=["user"]) authenticated_api_router.include_router(document_router, prefix="/documents", tags=["documents"]) authenticated_api_router.include_router(tag_router, prefix="/tags", tags=["Tags"]) authenticated_api_router.include_router(service_router, prefix="/services", tags=["services"]) @@ -49,29 +57,27 @@ authenticated_api_router.include_router( incident_priority_router, prefix="/incident_priorities", tags=["incident_priorities"] ) +authenticated_api_router.include_router(plugin_router, prefix="/plugins", tags=["plugins"]) doc_router = APIRouter() -@doc_router.get("/openapi.json") +@doc_router.get("/openapi.json", include_in_schema=False) async def get_open_api_endpoint(): return JSONResponse(get_openapi(title="Dispatch Docs", version=1, routes=api_router.routes)) -@doc_router.get("/") +@doc_router.get("/", include_in_schema=False) async def get_documentation(): return get_redoc_html(openapi_url="/api/v1/docs/openapi.json", title="Dispatch Docs") -authenticated_api_router.include_router(doc_router, prefix="/docs") +api_router.include_router(doc_router, prefix="/docs") -@api_router.get("/healthcheck") +@api_router.get("/healthcheck", include_in_schema=False) def healthcheck(): return {"status": "ok"} -install_plugins() -install_plugin_events(api_router) - api_router.include_router(authenticated_api_router, dependencies=[Depends(get_current_user)]) diff --git a/src/dispatch/auth/models.py b/src/dispatch/auth/models.py new file mode 100644 index 000000000000..8b8a3aae1678 --- /dev/null +++ b/src/dispatch/auth/models.py @@ -0,0 +1,121 @@ +import string +import secrets +from typing import List +from enum import Enum +from datetime import datetime, timedelta + +import bcrypt +from jose import jwt +from typing import Optional +from pydantic import validator +from sqlalchemy import Column, String, Binary, Integer + +from dispatch.database import Base +from dispatch.models import TimeStampMixin, DispatchBase + +from dispatch.config import ( + DISPATCH_JWT_SECRET, + DISPATCH_JWT_ALG, + DISPATCH_JWT_EXP, +) + + +def generate_password(): + """Generates a resonable password if none is provided.""" + alphanumeric = string.ascii_letters + string.digits + while True: + password = "".join(secrets.choice(alphanumeric) for i in range(10)) + if ( + any(c.islower() for c in password) + and any(c.isupper() for c in password) + and sum(c.isdigit() for c in password) >= 3 + ): + break + return password + + +def hash_password(password: str): + """Generates a hashed version of the provided password.""" + pw = bytes(password, "utf-8") + salt = bcrypt.gensalt() + return bcrypt.hashpw(pw, salt) + + +class UserRoles(str, Enum): + user = "User" + poweruser = "Poweruser" + admin = "Admin" + + +class DispatchUser(Base, TimeStampMixin): + id = Column(Integer, primary_key=True) + email = Column(String, unique=True) + password = Column(Binary, nullable=False) + role = Column(String, nullable=False, default=UserRoles.user) + + def check_password(self, password): + return bcrypt.checkpw(password.encode("utf-8"), self.password) + + @property + def token(self): + now = datetime.utcnow() + exp = (now + timedelta(seconds=DISPATCH_JWT_EXP)).timestamp() + data = {"exp": exp, "email": self.email, "role": self.role} + return jwt.encode(data, DISPATCH_JWT_SECRET, algorithm=DISPATCH_JWT_ALG) + + def principals(self): + return [f"user:{self.email}", f"role:{self.role}"] + + +class UserBase(DispatchBase): + email: str + + @validator("email") + def email_required(cls, v): + if not v: + raise ValueError("Must not be empty string and must be a email") + return v + + +class UserLogin(UserBase): + password: str + + @validator("password") + def password_required(cls, v): + if not v: + raise ValueError("Must not be empty string") + return v + + +class UserRegister(UserLogin): + password: Optional[str] + role: UserRoles = UserRoles.user + + @validator("password", pre=True, always=True) + def password_required(cls, v): + # we generate a password for those that don't have one + password = v or generate_password() + return hash_password(password) + + +class UserLoginResponse(DispatchBase): + token: Optional[str] + + +class UserRead(UserBase): + id: int + role: str + + +class UserUpdate(DispatchBase): + id: int + role: UserRoles + + +class UserRegisterResponse(DispatchBase): + email: str + + +class UserPagination(DispatchBase): + total: int + items: List[UserRead] = [] diff --git a/src/dispatch/auth/resources.py b/src/dispatch/auth/resources.py deleted file mode 100644 index a83510a16791..000000000000 --- a/src/dispatch/auth/resources.py +++ /dev/null @@ -1,20 +0,0 @@ -from fastapi_permissions import Allow, Deny, Authenticated, Everyone - - -class StaticAclResource: - __acl__ = [(Allow, Everyone, "view"), (Allow, "role:user", "share")] - - -class DynamicAclResource: - def __acl__(self): - return [ - (Allow, "role:user", "share"), - (Allow, "role:admin", "share"), - (Allow, f"user:{self.owner}", "edit"), - ] - - -# in contrast to pyramid, resources might be access conroll list themselves -# this can save some typing: - -AclResourceAsList = [(Allow, Everyone, "view"), (Deny, "role:troll", "edit")] diff --git a/src/dispatch/auth/service.py b/src/dispatch/auth/service.py index b275b6f91a50..e84c8ed150f5 100644 --- a/src/dispatch/auth/service.py +++ b/src/dispatch/auth/service.py @@ -5,24 +5,88 @@ :license: Apache, see LICENSE for more details. """ import logging - +from typing import List, Optional +from fastapi import HTTPException, Depends +from fastapi.encoders import jsonable_encoder from starlette.requests import Request +from starlette.status import HTTP_401_UNAUTHORIZED +from fastapi_permissions import Authenticated, configure_permissions + +from sqlalchemy.orm import Session +from dispatch.database import get_db + from dispatch.plugins.base import plugins from dispatch.config import ( DISPATCH_AUTHENTICATION_PROVIDER_SLUG, DISPATCH_AUTHENTICATION_DEFAULT_USER, ) +from .models import DispatchUser, UserRegister, UserUpdate log = logging.getLogger(__name__) +credentials_exception = HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail="Could not validate credentials" +) + + +def get(*, db_session, user_id: int) -> Optional[DispatchUser]: + """Returns an user based on the given user id.""" + return db_session.query(DispatchUser).filter(DispatchUser.id == user_id).one_or_none() + + +def get_by_email(*, db_session, email: str) -> Optional[DispatchUser]: + """Returns an user object based on user email.""" + return db_session.query(DispatchUser).filter(DispatchUser.email == email).one_or_none() + + +def create(*, db_session, user_in: UserRegister) -> DispatchUser: + """Creates a new dispatch user.""" + # pydantic forces a string password, but we really want bytes + password = bytes(user_in.password, "utf-8") + user = DispatchUser(**user_in.dict(exclude={"password"}), password=password) + db_session.add(user) + db_session.commit() + return user + -def get_current_user(*, request: Request): +def get_or_create(*, db_session, user_in: UserRegister) -> DispatchUser: + """Gets an existing user or creates a new one.""" + user = get_by_email(db_session=db_session, email=user_in.email) + if not user: + return create(db_session=db_session, user_in=user_in) + return user + + +def update(*, db_session, user: DispatchUser, user_in: UserUpdate) -> DispatchUser: + """Updates a user.""" + user_data = jsonable_encoder(user) + update_data = user_in.dict(skip_defaults=True) + for field in user_data: + if field in update_data: + setattr(user, field, update_data[field]) + + db_session.add(user) + db_session.commit() + return user + + +def get_current_user(*, db_session: Session = Depends(get_db), request: Request) -> DispatchUser: """Attempts to get the current user depending on the configured authentication provider.""" if DISPATCH_AUTHENTICATION_PROVIDER_SLUG: auth_plugin = plugins.get(DISPATCH_AUTHENTICATION_PROVIDER_SLUG) - return auth_plugin.get_current_user(request) + user_email = auth_plugin.get_current_user(request) else: - log.warning( - "No authentication provider has been provided. There is currently no user authentication." - ) - return DISPATCH_AUTHENTICATION_DEFAULT_USER + log.debug("No authentication provider. Default user will be used") + user_email = DISPATCH_AUTHENTICATION_DEFAULT_USER + + return get_or_create(db_session=db_session, user_in=UserRegister(email=user_email)) + + +def get_active_principals(user: DispatchUser = Depends(get_current_user)) -> List[str]: + """Fetches the current participants for a given user.""" + principals = [Authenticated] + principals.extend(getattr(user, "principals", [])) + return principals + + +Permission = configure_permissions(get_active_principals) diff --git a/src/dispatch/auth/views.py b/src/dispatch/auth/views.py new file mode 100644 index 000000000000..6e2db3fca234 --- /dev/null +++ b/src/dispatch/auth/views.py @@ -0,0 +1,108 @@ +from typing import List +from fastapi import APIRouter, Depends, Request, HTTPException, Query +from sqlalchemy.orm import Session +from dispatch.database import get_db, search_filter_sort_paginate + +from .models import ( + UserLogin, + UserRegister, + UserRead, + UserUpdate, + UserPagination, + UserLoginResponse, + UserRegisterResponse, +) +from .service import ( + get, + get_by_email, + update, + create, + get_current_user, +) + +auth_router = APIRouter() +user_router = APIRouter() + + +@user_router.get("/", response_model=UserPagination) +def get_users( + db_session: Session = Depends(get_db), + page: int = 1, + items_per_page: int = Query(5, alias="itemsPerPage"), + query_str: str = Query(None, alias="q"), + sort_by: List[str] = Query(None, alias="sortBy[]"), + descending: List[bool] = Query(None, alias="descending[]"), + fields: List[str] = Query(None, alias="field[]"), + ops: List[str] = Query(None, alias="op[]"), + values: List[str] = Query(None, alias="value[]"), +): + """ + Get all users. + """ + return search_filter_sort_paginate( + db_session=db_session, + model="DispatchUser", + query_str=query_str, + page=page, + items_per_page=items_per_page, + sort_by=sort_by, + descending=descending, + fields=fields, + values=values, + ops=ops, + ) + + +@user_router.get("/{user_id}", response_model=UserRead) +def get_user(*, db_session: Session = Depends(get_db), user_id: int): + """ + Get a user. + """ + user = get(db_session=db_session, user_id=user_id) + if not user: + raise HTTPException(status_code=404, detail="The user with this id does not exist.") + return user + + +@user_router.put("/{user_id}", response_model=UserUpdate) +def update_user(*, db_session: Session = Depends(get_db), user_id: int, user_in: UserUpdate): + """ + Update a user. + """ + user = get(db_session=db_session, user_id=user_id) + if not user: + raise HTTPException(status_code=404, detail="The user with this id does not exist.") + + user = update(db_session=db_session, user=user, user_in=user_in) + + return user + + +@auth_router.post("/login", response_model=UserLoginResponse) +def login_user( + user_in: UserLogin, db_session: Session = Depends(get_db), +): + user = get_by_email(db_session=db_session, email=user_in.email) + if user and user.check_password(user_in.password): + return {"token": user.token} + raise HTTPException(status_code=400, detail="Invalid username or password") + + +@auth_router.post("/register", response_model=UserRegisterResponse) +def register_user( + user_in: UserRegister, db_session: Session = Depends(get_db), +): + user = get_by_email(db_session=db_session, email=user_in.email) + if not user: + user = create(db_session=db_session, user_in=user_in) + else: + raise HTTPException(status_code=400, detail="User with that email address exists.") + + return user + + +@user_router.get("/me", response_model=UserRead) +def get_me( + req: Request, db_session: Session = Depends(get_db), +): + return get_current_user(request=req) diff --git a/src/dispatch/cli.py b/src/dispatch/cli.py index 2117d1eb67e7..561158b79239 100644 --- a/src/dispatch/cli.py +++ b/src/dispatch/cli.py @@ -10,15 +10,13 @@ from uvicorn import main as uvicorn_main from dispatch import __version__, config -from dispatch.tag.models import * # noqa -from dispatch.common.utils.cli import install_plugin_events, install_plugins +from .main import * # noqa from .database import Base, engine from .exceptions import DispatchException from .plugins.base import plugins from .scheduler import scheduler - -from dispatch.models import * # noqa; noqa +from .logging import configure_logging os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" @@ -30,21 +28,17 @@ def abort_if_false(ctx, param, value): ctx.abort() -def insert_newlines(string, every=64): - return "\n".join(string[i : i + every] for i in range(0, len(string), every)) - - @click.group() @click.version_option(version=__version__) def dispatch_cli(): """Command-line interface to Dispatch.""" - pass + configure_logging() @dispatch_cli.group("plugins") def plugins_group(): """All commands for plugin manipulation.""" - install_plugins() + pass @plugins_group.command("list") @@ -59,401 +53,6 @@ def list_plugins(): ) -@dispatch_cli.group("term") -def term_command_group(): - """All commands for term manipulation.""" - pass - - -@dispatch_cli.group("contact") -def contact_command_group(): - """All commands for contact manipulation.""" - pass - - -@contact_command_group.group("load") -def contact_load_group(): - """All contact load commands.""" - pass - - -@contact_load_group.command("csv") -@click.argument("input", type=click.File("r")) -@click.option("--first-row-is-header", is_flag=True, default=True) -def contact_load_csv_command(input, first_row_is_header): - """Load contacts via CSV.""" - import csv - from pydantic import ValidationError - from dispatch.individual import service as individual_service - from dispatch.team import service as team_service - from dispatch.database import SessionLocal - - db_session = SessionLocal() - - individual_contacts = [] - team_contacts = [] - if first_row_is_header: - reader = csv.DictReader(input) - for row in reader: - row = {k.lower(): v for k, v in row.items()} - if not row.get("email"): - continue - - individual_contacts.append(row) - - for i in individual_contacts: - i["is_external"] = True - try: - click.secho(f"Adding new individual contact. Email: {i['email']}", fg="blue") - individual_service.get_or_create(db_session=db_session, **i) - except ValidationError as e: - click.secho(f"Failed to add individual contact. {e} {row}", fg="red") - - for t in team_contacts: - i["is_external"] = True - try: - click.secho(f"Adding new team contact. Email: {t['email']}", fg="blue") - team_service.get_or_create(db_session=db_session, **t) - except ValidationError as e: - click.secho(f"Failed to add team contact. {e} {row}", fg="red") - - -@dispatch_cli.group("incident") -def incident_command_group(): - """All commands for incident manipulation.""" - pass - - -@incident_command_group.group("load") -def incident_load_group(): - """All incient load commands.""" - pass - - -@incident_load_group.command("csv") -@click.argument("input", type=click.File("r")) -@click.option("--first-row-is-header", is_flag=True, default=True) -def incident_load_csv_command(input, first_row_is_header): - """Load incidents via CSV.""" - import csv - from dispatch.database import SessionLocal - from datetime import datetime - from dispatch.incident import service as incident_service - - db_session = SessionLocal() - - if first_row_is_header: - reader = csv.DictReader(input) - for row in reader: - incident = incident_service.get_by_name( - db_session=db_session, incident_name=row["name"] - ) - if incident: - incident.created_at = datetime.fromisoformat(row["created"]) - else: - click.secho(f"No incident found. Name: {row['name']}", fg="red") - - -# This has been left as an example of how to import a jira issue -# @incident_load_group.command("jira") -# @click.argument("query") -# @click.option("--url", help="Jira instance url.", default=JIRA_URL) -# @click.option("--username", help="Jira username.", default=JIRA_USERNAME) -# @click.option("--password", help="Jira password.", default=JIRA_PASSWORD) -# def incident_load_jira(query, url, username, password): -# """Loads incident data from jira.""" -# install_plugins() -# import re -# from jira import JIRA -# from dispatch.incident.models import Incident -# from dispatch.database import SessionLocal -# from dispatch.incident_priority import service as incident_priority_service -# from dispatch.incident_type import service as incident_type_service -# from dispatch.individual import service as individual_service -# from dispatch.participant import service as participant_service -# from dispatch.participant_role import service as participant_role_service -# from dispatch.participant_role.models import ParticipantRoleType -# from dispatch.ticket import service as ticket_service -# from dispatch.conversation import service as conversation_service -# from dispatch.config import ( -# INCIDENT_RESOURCE_INVESTIGATION_DOCUMENT, -# INCIDENT_PLUGIN_CONVERSATION_SLUG, -# INCIDENT_PLUGIN_TICKET_SLUG, -# ) -# from dispatch.document import service as document_service -# from dispatch.document.models import DocumentCreate -# -# db_session = SessionLocal() -# -# client = JIRA(str(JIRA_URL), basic_auth=(JIRA_USERNAME, str(JIRA_PASSWORD))) -# -# block_size = 100 -# block_num = 0 -# -# -# while True: -# start_idx = block_num * block_size -# issues = client.search_issues(query, start_idx, block_size) -# -# click.secho(f"Collecting. PageSize: {block_size} PageNum: {block_num}", fg="blue") -# if not issues: -# # Retrieve issues until there are no more to come -# break -# -# block_num += 1 -# -# for issue in issues: -# try: -# participants = [] -# incident_name = issue.key -# created_at = issue.fields.created -# -# # older tickets don't have a component -# if not issue.fields.components: -# incident_type = "Other" -# else: -# incident_type = issue.fields.components[0].name -# -# title = issue.fields.summary -# -# if issue.fields.reporter: -# reporter_email = issue.fields.reporter.emailAddress -# else: -# reporter_email = "joe@example.com" -# -# status = issue.fields.status.name -# -# # older tickets don't have priority -# if not issue.fields.customfield_10551: -# incident_priority = "Low" -# else: -# incident_priority = issue.fields.customfield_10551.value -# -# incident_cost = issue.fields.customfield_20250 -# if incident_cost: -# incident_cost = incident_cost.replace("$", "") -# incident_cost = incident_cost.replace(",", "") -# incident_cost = float(incident_cost) -# -# if issue.fields.assignee: -# commander_email = issue.fields.assignee.emailAddress -# else: -# commander_email = "joe@example.com" -# -# resolved_at = issue.fields.resolutiondate -# -# description = issue.fields.description or "No Description" -# -# match = re.findall(r"\[(?P.*?)\|(?P.*?)\]", description) -# -# conversation_weblink = None -# incident_document_weblink = None -# for m_type, m_link in match: -# if "conversation" in m_type.lower(): -# conversation_weblink = m_link -# -# if "document" in m_type.lower(): -# incident_document_weblink = m_link -# -# ticket = { -# "resource_type": INCIDENT_PLUGIN_TICKET_SLUG, -# "weblink": f"{JIRA_URL}/projects/SEC/{incident_name}", -# } -# ticket_obj = ticket_service.create(db_session=db_session, **ticket) -# -# documents = [] -# if incident_document_weblink: -# document_in = DocumentCreate( -# name=f"{incident_name} - Investigation Document", -# resource_id=incident_document_weblink.split("/")[-2], -# resource_type=INCIDENT_RESOURCE_INVESTIGATION_DOCUMENT, -# weblink=incident_document_weblink, -# ) -# -# document_obj = document_service.create( -# db_session=db_session, document_in=document_in -# ) -# -# documents.append(document_obj) -# -# conversation_obj = None -# if conversation_weblink: -# conversation_obj = conversation_service.create( -# db_session=db_session, -# resource_id=incident_name.lower(), -# resource_type=INCIDENT_PLUGIN_CONVERSATION_SLUG, -# weblink=conversation_weblink, -# channel_id=incident_name.lower(), -# ) -# -# # TODO should some of this logic be in the incident_create_flow_instead? (kglisson) -# incident_priority = incident_priority_service.get_by_name( -# db_session=db_session, name=incident_priority -# ) -# -# incident_type = incident_type_service.get_by_name( -# db_session=db_session, name=incident_type -# ) -# -# try: -# commander_info = individual_service.resolve_user_by_email(commander_email) -# except KeyError: -# commander_info = {"email": commander_email, "fullname": "", "weblink": ""} -# -# incident_commander_role = participant_role_service.create( -# db_session=db_session, role=ParticipantRoleType.incident_commander -# ) -# -# commander_participant = participant_service.create( -# db_session=db_session, participant_role=[incident_commander_role] -# ) -# -# commander = individual_service.get_or_create( -# db_session=db_session, -# email=commander_info["email"], -# name=commander_info["fullname"], -# weblink=commander_info["weblink"], -# ) -# -# incident_reporter_role = participant_role_service.create( -# db_session=db_session, role=ParticipantRoleType.reporter -# ) -# -# if reporter_email == commander_email: -# commander_participant.participant_role.append(incident_reporter_role) -# else: -# reporter_participant = participant_service.create( -# db_session=db_session, participant_role=[incident_reporter_role] -# ) -# -# try: -# reporter_info = individual_service.resolve_user_by_email(reporter_email) -# except KeyError: -# reporter_info = {"email": reporter_email, "fullname": "", "weblink": ""} -# -# reporter = individual_service.get_or_create( -# db_session=db_session, -# email=reporter_info["email"], -# name=reporter_info["fullname"], -# weblink=commander_info["weblink"], -# ) -# reporter.participant.append(reporter_participant) -# db_session.add(reporter) -# participants.append(reporter_participant) -# -# participants.append(commander_participant) -# incident = Incident( -# title=title, -# description=description, -# status=status, -# name=incident_name, -# cost=incident_cost, -# created_at=created_at, -# closed_at=resolved_at, -# incident_priority=incident_priority, -# incident_type=incident_type, -# participants=participants, -# conversation=conversation_obj, -# documents=documents, -# ticket=ticket_obj, -# ) -# -# commander.participant.append(commander_participant) -# db_session.add(commander) -# db_session.add(incident) -# db_session.commit() -# click.secho( -# f"Imported Issue. Key: {issue.key} Reporter: {incident.reporter.email}, Commander: {incident.commander.email}", -# fg="blue", -# ) -# except Exception as e: -# click.secho(f"Error importing issue. Key: {issue.key} Reason: {e}", fg="red") -# - - -@incident_command_group.command("close") -@click.argument("username") -@click.argument("name", nargs=-1) -def close_incidents(name, username): - """This command will close a specific incident (running the close flow or all open incidents). Useful for development.""" - from dispatch.incident.flows import incident_closed_flow - from dispatch.incident.models import Incident - from dispatch.database import SessionLocal - - install_plugins() - - incidents = [] - db_session = SessionLocal() - - if not name: - incidents = db_session.query(Incident).all() - else: - incidents = [db_session.query(Incident).filter(Incident.name == x).first() for x in name] - - for i in incidents: - if i.conversation: - if i.status == "Active": - command = {"channel_id": i.conversation.channel_id, "user_id": username} - try: - incident_closed_flow(command=command, db_session=db_session, incident_id=i.id) - except Exception: - click.echo("Incident close failed.") - - -@incident_command_group.command("clean") -@click.argument("pattern", nargs=-1) -def clean_incident_artifacts(pattern): - """This command will clean up incident artifacts. Useful for development.""" - import re - from dispatch.plugins.dispatch_google.config import GOOGLE_DOMAIN - from dispatch.plugins.dispatch_google.common import get_service - from dispatch.plugins.dispatch_google.drive.drive import delete_team_drive, list_team_drives - - from dispatch.plugins.dispatch_slack.service import ( - slack, - list_conversations, - archive_conversation, - ) - from dispatch.plugins.dispatch_slack.config import SLACK_API_BOT_TOKEN - - from dispatch.plugins.dispatch_google.groups.plugin import delete_group, list_groups - - install_plugins() - - patterns = [re.compile(p) for p in pattern] - - click.secho("Deleting google groups...", fg="red") - - scopes = [ - "https://www.googleapis.com/auth/admin.directory.group", - "https://www.googleapis.com/auth/apps.groups.settings", - ] - client = get_service("admin", "directory_v1", scopes) - - for group in list_groups(client, query="email:sec-test*", domain=GOOGLE_DOMAIN)["groups"]: - for p in patterns: - if p.match(group["name"]): - click.secho(group["name"], fg="red") - delete_group(client, group_key=group["email"]) - - click.secho("Archiving slack channels...", fg="red") - client = slack.WebClient(token=SLACK_API_BOT_TOKEN) - for c in list_conversations(client): - for p in patterns: - if p.match(c["name"]): - archive_conversation(client, c["id"]) - - click.secho("Deleting google drives...", fg="red") - scopes = ["https://www.googleapis.com/auth/drive"] - client = get_service("drive", "v3", scopes) - - for drive in list_team_drives(client): - for p in patterns: - if p.match(drive["name"]): - click.secho(f"Deleting drive: {drive['name']}", fg="red") - delete_team_drive(client, drive["id"], empty=True) - - def sync_triggers(): from sqlalchemy_searchable import sync_trigger @@ -470,6 +69,7 @@ def sync_triggers(): sync_trigger(engine, "policy", "search_vector", ["name", "description"]) sync_trigger(engine, "service", "search_vector", ["name"]) sync_trigger(engine, "task", "search_vector", ["description"]) + sync_trigger(engine, "plugin", "search_vector", ["title"]) @dispatch_cli.group("database") @@ -579,19 +179,18 @@ def dump_database(): @dispatch_database.command("drop") -@click.option( - "--yes", - is_flag=True, - callback=abort_if_false, - expose_value=False, - prompt="Are you sure you want to drop the database?", -) -def drop_database(): +@click.option("--yes", is_flag=True, help="Silences all confirmation prompts.") +def drop_database(yes): """Drops all data in database.""" from sqlalchemy_utils import drop_database - drop_database(str(config.SQLALCHEMY_DATABASE_URI)) - click.secho("Success.", fg="green") + if yes: + drop_database(str(config.SQLALCHEMY_DATABASE_URI)) + click.secho("Success.", fg="green") + + if click.confirm(f"Are you sure you want to drop: '{config.DATABASE_HOSTNAME}:{config.DATABASE_NAME}'?"): + drop_database(str(config.SQLALCHEMY_DATABASE_URI)) + click.secho("Success.", fg="green") @dispatch_database.command("upgrade") @@ -608,6 +207,7 @@ def drop_database(): def upgrade_database(tag, sql, revision): """Upgrades database schema to newest version.""" from sqlalchemy_utils import database_exists, create_database + from alembic.migration import MigrationContext alembic_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "alembic.ini") alembic_cfg = AlembicConfig(alembic_path) @@ -616,11 +216,15 @@ def upgrade_database(tag, sql, revision): Base.metadata.create_all(engine) alembic_command.stamp(alembic_cfg, "head") else: - if not alembic_command.current(alembic_cfg): + conn = engine.connect() + context = MigrationContext.configure(conn) + current_rev = context.get_current_revision() + if not current_rev: Base.metadata.create_all(engine) alembic_command.stamp(alembic_cfg, "head") else: alembic_command.upgrade(alembic_cfg, revision, sql=sql, tag=tag) + sync_triggers() click.secho("Success.", fg="green") @@ -663,6 +267,24 @@ def downgrade_database(tag, sql, revision): click.secho("Success.", fg="green") +@dispatch_database.command("stamp") +@click.argument("revision", nargs=1, default="head") +@click.option( + "--tag", default=None, help="Arbitrary 'tag' name - can be used by custom env.py scripts." +) +@click.option( + "--sql", + is_flag=True, + default=False, + help="Don't emit SQL to database - dump to standard output instead.", +) +def stamp_database(revision, tag, sql): + """Forces the database to a given revision.""" + alembic_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "alembic.ini") + alembic_cfg = AlembicConfig(alembic_path) + alembic_command.stamp(alembic_cfg, revision, sql=sql, tag=tag) + + @dispatch_database.command("revision") @click.option( "-d", "--directory", default=None, help=('migration script directory (default is "migrations")') @@ -719,14 +341,12 @@ def revision_database( def dispatch_scheduler(): """Container for all dispatch scheduler commands.""" # we need scheduled tasks to be imported - from .incident.scheduled import daily_summary # noqa + from .incident.scheduled import daily_summary, auto_tagger # noqa from .task.scheduled import sync_tasks, create_task_reminders # noqa from .term.scheduled import sync_terms # noqa from .document.scheduled import sync_document_terms # noqa from .tag.scheduled import sync_tags # noqa - install_plugins() - @dispatch_scheduler.command("list") def list_tasks(): @@ -765,15 +385,13 @@ def start_tasks(tasks, eager): @dispatch_cli.group("server") def dispatch_server(): """Container for all dispatch server commands.""" - install_plugins() + pass @dispatch_server.command("routes") def show_routes(): """Prints all available routes.""" - from dispatch.api import api_router - - install_plugin_events(api_router) + from dispatch.main import api_router table = [] for r in api_router.routes: @@ -789,11 +407,16 @@ def show_routes(): @dispatch_server.command("config") def show_config(): """Prints the current config as dispatch sees it.""" - from dispatch.config import config + import sys + import inspect + from dispatch import config + + func_members = inspect.getmembers(sys.modules[config.__name__]) table = [] - for k, v in config.file_values.items(): - table.append([k, v]) + for key, value in func_members: + if key.isupper(): + table.append([key, value]) click.secho(tabulate(table, headers=["Key", "Value"]), fg="blue") diff --git a/src/dispatch/common/utils/cli.py b/src/dispatch/common/utils/cli.py index 10133b513e05..61a5ccc04c81 100644 --- a/src/dispatch/common/utils/cli.py +++ b/src/dispatch/common/utils/cli.py @@ -3,22 +3,17 @@ import traceback import logging import pkg_resources +from sqlalchemy.exc import SQLAlchemyError import click +from dispatch.plugins.base import plugins, register -from dispatch.plugins.base import plugins from .dynamic_click import params_factory logger = logging.getLogger(__name__) -def chunk(l, n): - """Chunk a list to sublists.""" - for i in range(0, len(l), n): - yield l[i : i + n] - - # Plugin endpoints should determine authentication # TODO allow them to specify (kglisson) def install_plugin_events(api): """Adds plugin endpoints to the event router.""" @@ -32,18 +27,24 @@ def install_plugins(): Installs plugins associated with dispatch :return: """ - from dispatch.plugins.base import register for ep in pkg_resources.iter_entry_points("dispatch.plugins"): - logger.debug(f"Loading plugin {ep.name}") + logger.debug(f"Attempting to load plugin: {ep.name}") try: plugin = ep.load() + register(plugin) + logger.debug(f"Successfully loaded plugin: {ep.name}") except KeyError as e: - logger.warning(f"Failed to load plugin {ep.name}. Reason: {e}") + logger.warning(f"Failed to load plugin: {ep.name} Reason: {e}") + except SQLAlchemyError: + logger.error( + "Something went wrong with creating plugin rows, is the database setup correctly?" + ) except Exception: logger.error(f"Failed to load plugin {ep.name}:{traceback.format_exc()}") else: - register(plugin) + if not plugin.enabled: + continue def with_plugins(plugin_type: str): diff --git a/src/dispatch/conference/service.py b/src/dispatch/conference/service.py index aa4d5e8e263a..74916f8e7414 100644 --- a/src/dispatch/conference/service.py +++ b/src/dispatch/conference/service.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, List from .models import Conference, ConferenceCreate @@ -11,10 +11,10 @@ def get_by_resource_id(*, db_session, resource_id: str) -> Optional[Conference]: return db_session.query(Conference).filter(Conference.resource_id == resource_id).one_or_none() -def get_by_resource_type(*, db_session, resource_type: str) -> Optional[Conference]: - return ( - db_session.query(Conference).filter(Conference.resource_type == resource_type).one_or_none() - ) +def get_by_resource_type(*, db_session, resource_type: str) -> List[Optional[Conference]]: + """Return a list of all conferences matching a given resource type. + May return an empty list if no conferences are available.""" + return db_session.query(Conference).filter(Conference.resource_type == resource_type).all() def get_by_conference_id(db_session, conference_id: str) -> Optional[Conference]: @@ -24,11 +24,7 @@ def get_by_conference_id(db_session, conference_id: str) -> Optional[Conference] def get_by_incident_id(*, db_session, incident_id: str) -> Optional[Conference]: - return ( - db_session.query(Conference) - .filter(Conference.incident_id == incident_id) - .one() - ) + return db_session.query(Conference).filter(Conference.incident_id == incident_id).one() def get_all(*, db_session): diff --git a/src/dispatch/config.py b/src/dispatch/config.py index 4244890d042f..b7f280af3ebf 100644 --- a/src/dispatch/config.py +++ b/src/dispatch/config.py @@ -5,6 +5,8 @@ from starlette.config import Config from starlette.datastructures import CommaSeparatedStrings +log = logging.getLogger(__name__) + # if we have metatron available to us, lets use it to decrypt our secrets in memory try: import metatron.decrypt @@ -72,10 +74,18 @@ def __str__(self) -> str: # authentication DISPATCH_AUTHENTICATION_PROVIDER_SLUG = config( - "DISPATCH_AUTHENTICATION_PROVIDER_SLUG", default="dispatch-auth-provider-pkce" + "DISPATCH_AUTHENTICATION_PROVIDER_SLUG", default="dispatch-auth-provider-basic" ) VUE_APP_DISPATCH_AUTHENTICATION_PROVIDER_SLUG = DISPATCH_AUTHENTICATION_PROVIDER_SLUG +DISPATCH_JWT_SECRET = config("DISPATCH_JWT_SECRET", default=None) +DISPATCH_JWT_ALG = config("DISPATCH_JWT_ALG", default="HS256") +DISPATCH_JWT_EXP = config("DISPATCH_JWT_EXP", default=86400) # Seconds + +if DISPATCH_AUTHENTICATION_PROVIDER_SLUG == "dispatch-auth-provider-basic": + if not DISPATCH_JWT_SECRET: + log.warn("No JWT secret specified, this is required if you are using basic authentication.") + DISPATCH_AUTHENTICATION_DEFAULT_USER = config( "DISPATCH_AUTHENTICATION_DEFAULT_USER", default="dispatch@example.com" ) @@ -83,6 +93,13 @@ def __str__(self) -> str: DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS = config( "DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS", default=None ) + +if DISPATCH_AUTHENTICATION_PROVIDER_SLUG == "dispatch-auth-provider-pkce": + if not DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS: + log.warn( + "No PKCE JWKS url provided, this is required if you are using PKCE authentication." + ) + VUE_APP_DISPATCH_AUTHENTICATION_PROVIDER_PKCE_OPEN_ID_CONNECT_URL = config( "VUE_APP_DISPATCH_AUTHENTICATION_PROVIDER_PKCE_OPEN_ID_CONNECT_URL", default=None ) @@ -137,6 +154,7 @@ def __str__(self) -> str: "INCIDENT_PLUGIN_CONFERENCE_SLUG", default="google-calendar-conference" ) INCIDENT_PLUGIN_TICKET_SLUG = config("INCIDENT_PLUGIN_TICKET_SLUG", default="jira-ticket") + INCIDENT_PLUGIN_TASK_SLUG = config("INCIDENT_PLUGIN_TASK_SLUG", default="google-drive-task") # incident resources @@ -183,8 +201,6 @@ def __str__(self) -> str: "INCIDENT_RESOURCE_INCIDENT_TASK", default="google-docs-incident-task" ) -INCIDENT_METRIC_FORECAST_REGRESSIONS = config("INCIDENT_METRIC_FORECAST_REGRESSIONS", default=None) - # Incident Cost Configuration ANNUAL_COST_EMPLOYEE = config("ANNUAL_COST_EMPLOYEE", cast=int, default="650000") BUSINESS_HOURS_YEAR = config("BUSINESS_HOURS_YEAR", cast=int, default="2080") diff --git a/src/dispatch/conversation/enums.py b/src/dispatch/conversation/enums.py index 61d7c360c08f..af793302d0fe 100644 --- a/src/dispatch/conversation/enums.py +++ b/src/dispatch/conversation/enums.py @@ -12,6 +12,7 @@ class ConversationCommands(str, Enum): edit_incident = "edit-incident" engage_oncall = "engage-oncall" list_resources = "list-resources" + report_incident = "report-incident" class ConversationButtonActions(str, Enum): diff --git a/src/dispatch/database.py b/src/dispatch/database.py index b6497c92f2ef..f814ece3cb6a 100644 --- a/src/dispatch/database.py +++ b/src/dispatch/database.py @@ -1,4 +1,5 @@ import re +import logging from typing import Any, List from itertools import groupby @@ -14,6 +15,8 @@ from .config import SQLALCHEMY_DATABASE_URI +log = logging.getLogger(__file__) + engine = create_engine(str(SQLALCHEMY_DATABASE_URI)) SessionLocal = sessionmaker(bind=engine) @@ -98,7 +101,19 @@ def create_filter_spec(model, fields, ops, values): # group by field (or for same fields and for different fields) data = sorted(filters, key=lambda x: x["model"]) for k, g in groupby(data, key=lambda x: x["model"]): - filter_spec.append({"or": list(g)}) + # force 'and' for operations other than equality + filters = list(g) + force_and = False + for f in filters: + if ">" in f["op"] or "<" in f["op"]: + force_and = True + + if force_and: + filter_spec.append({"and": filters}) + else: + filter_spec.append({"or": filters}) + + log.debug(f"Filter Spec: {filter_spec}") if filter_spec: return {"and": filter_spec} @@ -134,6 +149,22 @@ def get_all(*, db_session, model): return db_session.query(get_class_by_tablename(model)) +def join_required_attrs(query, model, join_attrs, fields): + """Determines which attrs (if any) require a join.""" + if not fields: + return query + + if not join_attrs: + return query + + for field, attr in join_attrs: + for f in fields: + if field in f: + query = query.join(getattr(model, attr)) + + return query + + def search_filter_sort_paginate( db_session, model, @@ -145,12 +176,16 @@ def search_filter_sort_paginate( fields: List[str] = None, ops: List[str] = None, values: List[str] = None, + join_attrs: List[str] = None, ): """Common functionality for searching, filtering and sorting""" + model_cls = get_class_by_tablename(model) if query_str: query = search(db_session=db_session, query_str=query_str, model=model) else: - query = get_all(db_session=db_session, model=model) + query = db_session.query(model_cls) + + query = join_required_attrs(query, model_cls, join_attrs, fields) filter_spec = create_filter_spec(model, fields, ops, values) query = apply_filters(query, filter_spec) diff --git a/src/dispatch/decorators.py b/src/dispatch/decorators.py index b65106152aec..e19819dc0b48 100644 --- a/src/dispatch/decorators.py +++ b/src/dispatch/decorators.py @@ -30,12 +30,12 @@ def wrapper(*args, **kwargs): db_session = SessionLocal() kwargs["db_session"] = db_session try: - metrics_provider.counter(f"function.call.counter", tags={"function": fullname(func)}) + metrics_provider.counter("function.call.counter", tags={"function": fullname(func)}) start = time.perf_counter() result = func(*args, **kwargs) elapsed_time = time.perf_counter() - start metrics_provider.timer( - f"function.elapsed.time", value=elapsed_time, tags={"function": fullname(func)} + "function.elapsed.time", value=elapsed_time, tags={"function": fullname(func)} ) return result except Exception as e: @@ -59,7 +59,7 @@ def wrapper(*args, **kwargs): result = func(*args, **kwargs) elapsed_time = time.perf_counter() - start metrics_provider.timer( - f"function.elapsed.time", value=elapsed_time, tags={"function": fullname(func)} + "function.elapsed.time", value=elapsed_time, tags={"function": fullname(func)} ) return result @@ -71,7 +71,7 @@ def counter(func: Any): @wraps(func) def wrapper(*args, **kwargs): - metrics_provider.counter(f"function.call.counter", tags={"function": fullname(func)}) + metrics_provider.counter("function.call.counter", tags={"function": fullname(func)}) return func(*args, **kwargs) return wrapper diff --git a/src/dispatch/definition/models.py b/src/dispatch/definition/models.py index 62f5e4c893a5..fdae6bba7e87 100644 --- a/src/dispatch/definition/models.py +++ b/src/dispatch/definition/models.py @@ -1,6 +1,6 @@ from typing import List, Optional -from sqlalchemy import Column, Integer, String +from sqlalchemy import Table, Column, Integer, String, ForeignKey, PrimaryKeyConstraint from sqlalchemy.orm import relationship from sqlalchemy_utils import TSVectorType @@ -9,8 +9,23 @@ DispatchBase, TermNested, TermReadNested, - definition_teams, - definition_terms, +) + +# Association tables +definition_teams = Table( + "definition_teams", + Base.metadata, + Column("definition_id", Integer, ForeignKey("definition.id")), + Column("team_contact_id", Integer, ForeignKey("team_contact.id")), + PrimaryKeyConstraint("definition_id", "team_contact_id"), +) + +definition_terms = Table( + "definition_terms", + Base.metadata, + Column("definition_id", Integer, ForeignKey("definition.id")), + Column("term_id", Integer, ForeignKey("term.id")), + PrimaryKeyConstraint("definition_id", "term_id"), ) diff --git a/src/dispatch/document/scheduled.py b/src/dispatch/document/scheduled.py index 891367a1971b..e14abfecc45d 100644 --- a/src/dispatch/document/scheduled.py +++ b/src/dispatch/document/scheduled.py @@ -35,7 +35,9 @@ def sync_document_terms(db_session=None): mime_type = "text/plain" doc_text = p.get(doc.resource_id, mime_type) - extracted_terms = route_service.get_terms(db_session=db_session, text=doc_text) + extracted_terms = route_service.get_terms( + db_session=db_session, model=Term, text=doc_text + ) matched_terms = ( db_session.query(Term) diff --git a/src/dispatch/enums.py b/src/dispatch/enums.py index d1e705f01f34..30f4bbc2b361 100644 --- a/src/dispatch/enums.py +++ b/src/dispatch/enums.py @@ -13,3 +13,10 @@ class SearchTypes(str, Enum): team_contact = "Team" service = "Service" policy = "Policy" + tag = "Tag" + task = "Task" + document = "Document" + plugin = "Plugin" + incident_priority = "IncidentPriority" + incident_type = "IncidentType" + incident = "Incident" diff --git a/src/dispatch/event/models.py b/src/dispatch/event/models.py index ade3f32a183f..fed33ee97f59 100644 --- a/src/dispatch/event/models.py +++ b/src/dispatch/event/models.py @@ -1,26 +1,14 @@ from datetime import datetime -from enum import Enum from uuid import UUID from typing import Optional -from sqlalchemy import ( - Column, - DateTime, - ForeignKey, - Integer, - PrimaryKeyConstraint, - String, - Table, - event, -) +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String from sqlalchemy.dialects.postgresql import UUID as SQLAlchemyUUID -from sqlalchemy.orm import relationship -from sqlalchemy_utils import TSVectorType +from sqlalchemy_utils import TSVectorType, JSONType from dispatch.database import Base from dispatch.models import DispatchBase, TimeStampMixin -from dispatch.plugins.base import plugins # SQLAlchemy Model @@ -32,6 +20,7 @@ class Event(Base, TimeStampMixin): ended_at = Column(DateTime, nullable=False) source = Column(String, nullable=False) description = Column(String, nullable=False) + details = Column(JSONType, nullable=True) # relationships individual_id = Column(Integer, ForeignKey("individual_contact.id")) @@ -50,6 +39,7 @@ class EventBase(DispatchBase): ended_at: datetime source: str description: str + details: Optional[dict] class EventCreate(EventBase): diff --git a/src/dispatch/event/service.py b/src/dispatch/event/service.py index 0f75369d96d4..7e753e40d62b 100644 --- a/src/dispatch/event/service.py +++ b/src/dispatch/event/service.py @@ -115,6 +115,7 @@ def log( individual_id: int = None, started_at: datetime = None, ended_at: datetime = None, + details: dict = None, ) -> Event: """ Logs an event @@ -128,7 +129,12 @@ def log( ended_at = started_at event_in = EventCreate( - uuid=uuid, started_at=started_at, ended_at=ended_at, source=source, description=description + uuid=uuid, + started_at=started_at, + ended_at=ended_at, + source=source, + description=description, + details=details, ) event = create(db_session=db_session, event_in=event_in) diff --git a/src/dispatch/extensions.py b/src/dispatch/extensions.py index 0b424587706c..8d6638736c26 100644 --- a/src/dispatch/extensions.py +++ b/src/dispatch/extensions.py @@ -5,6 +5,7 @@ from .config import SENTRY_DSN, ENV + log = logging.getLogger(__file__) sentry_logging = LoggingIntegration( diff --git a/src/dispatch/incident/enums.py b/src/dispatch/incident/enums.py index 47f5a50f82a3..191e93353cbb 100644 --- a/src/dispatch/incident/enums.py +++ b/src/dispatch/incident/enums.py @@ -5,3 +5,14 @@ class IncidentStatus(str, Enum): active = "Active" stable = "Stable" closed = "Closed" + + +class IncidentSlackViewBlockId(str, Enum): + title = 'title_field' + description = 'description_field' + type = 'incident_type_field' + priority = 'incident_priority_field' + + +class NewIncidentSubmission(str, Enum): + form_slack_view = "submit-new-incident-form-from-slack" diff --git a/src/dispatch/incident/flows.py b/src/dispatch/incident/flows.py index a3db65ce2224..d7ce77f6ec9f 100644 --- a/src/dispatch/incident/flows.py +++ b/src/dispatch/incident/flows.py @@ -8,6 +8,7 @@ .. moduleauthor:: Marc Vilanova """ import logging + from datetime import datetime from typing import Any, List, Optional @@ -23,7 +24,6 @@ INCIDENT_PLUGIN_GROUP_SLUG, INCIDENT_PLUGIN_PARTICIPANT_RESOLVER_SLUG, INCIDENT_PLUGIN_STORAGE_SLUG, - INCIDENT_PLUGIN_TICKET_SLUG, INCIDENT_RESOURCE_CONVERSATION_COMMANDS_REFERENCE_DOCUMENT, INCIDENT_RESOURCE_FAQ_DOCUMENT, INCIDENT_RESOURCE_INCIDENT_REVIEW_DOCUMENT, @@ -53,11 +53,13 @@ from dispatch.incident.models import IncidentRead from dispatch.incident_priority.models import IncidentPriorityRead from dispatch.incident_type.models import IncidentTypeRead +from dispatch.individual import service as individual_service from dispatch.participant import flows as participant_flows from dispatch.participant import service as participant_service from dispatch.participant_role import flows as participant_role_flows from dispatch.participant_role.models import ParticipantRoleType from dispatch.plugins.base import plugins +from dispatch.plugin import service as plugin_service from dispatch.service import service as service_service from dispatch.storage import service as storage_service from dispatch.ticket import service as ticket_service @@ -112,24 +114,25 @@ def get_incident_documents( def create_incident_ticket(incident: Incident, db_session: SessionLocal): """Create an external ticket for tracking.""" - p = plugins.get(INCIDENT_PLUGIN_TICKET_SLUG) + plugin = plugin_service.get_active(db_session=db_session, plugin_type="ticket") title = incident.title if incident.visibility == Visibility.restricted: title = incident.incident_type.name - ticket = p.create( + ticket = plugin.instance.create( + incident.id, title, incident.incident_type.name, incident.incident_priority.name, incident.commander.email, incident.reporter.email, ) - ticket.update({"resource_type": INCIDENT_PLUGIN_TICKET_SLUG}) + ticket.update({"resource_type": plugin.slug}) event_service.log( db_session=db_session, - source=p.title, + source=plugin.title, description="External ticket created", incident_id=incident.id, ) @@ -138,6 +141,7 @@ def create_incident_ticket(incident: Incident, db_session: SessionLocal): def update_incident_ticket( + db_session: SessionLocal, ticket_id: str, title: str = None, description: str = None, @@ -155,12 +159,12 @@ def update_incident_ticket( visibility: str = None, ): """Update external incident ticket.""" - p = plugins.get(INCIDENT_PLUGIN_TICKET_SLUG) + plugin = plugin_service.get_active(db_session=db_session, plugin_type="ticket") if visibility == Visibility.restricted: title = description = incident_type - p.update( + plugin.instance.update( ticket_id, title=title, description=description, @@ -647,6 +651,7 @@ def incident_create_flow(*, incident_id: int, checkpoint: str = None, db_session # we update the incident ticket update_incident_ticket( + db_session, incident.ticket.resource_id, title=incident.title, description=incident.description, @@ -719,18 +724,12 @@ def incident_active_flow(incident_id: int, command: Optional[dict] = None, db_se # we update the status of the external ticket update_incident_ticket( + db_session, incident.ticket.resource_id, incident_type=incident.incident_type.name, status=IncidentStatus.active.lower(), ) - event_service.log( - db_session=db_session, - source="Dispatch Core App", - description=f"Incident marked as {incident.status}", - incident_id=incident.id, - ) - @background_task def incident_stable_flow(incident_id: int, command: Optional[dict] = None, db_session=None): @@ -749,7 +748,10 @@ def incident_stable_flow(incident_id: int, command: Optional[dict] = None, db_se # we update the external ticket update_incident_ticket( - incident.ticket.resource_id, status=IncidentStatus.stable.lower(), cost=incident_cost + db_session, + incident.ticket.resource_id, + status=IncidentStatus.stable.lower(), + cost=incident_cost, ) incident_review_document = get_document( @@ -835,13 +837,6 @@ def incident_stable_flow(incident_id: int, command: Optional[dict] = None, db_se db_session.add(incident) db_session.commit() - event_service.log( - db_session=db_session, - source="Dispatch Core App", - description=f"Incident marked as {incident.status}", - incident_id=incident.id, - ) - @background_task def incident_closed_flow(incident_id: int, command: Optional[dict] = None, db_session=None): @@ -861,7 +856,10 @@ def incident_closed_flow(incident_id: int, command: Optional[dict] = None, db_se # we update the external ticket update_incident_ticket( - incident.ticket.resource_id, status=IncidentStatus.closed.lower(), cost=incident_cost + db_session, + incident.ticket.resource_id, + status=IncidentStatus.closed.lower(), + cost=incident_cost, ) if incident.visibility == Visibility.open: @@ -877,13 +875,6 @@ def incident_closed_flow(incident_id: int, command: Optional[dict] = None, db_se db_session.add(incident) db_session.commit() - event_service.log( - db_session=db_session, - source="Dispatch Core App", - description=f"Incident marked as {incident.status}", - incident_id=incident.id, - ) - @background_task def incident_update_flow( @@ -895,15 +886,61 @@ def incident_update_flow( # we load the incident instance incident = incident_service.get(db_session=db_session, incident_id=incident_id) + # we load the individual + individual = individual_service.get_by_email(db_session=db_session, email=user_email) + + if previous_incident.title != incident.title: + event_service.log( + db_session=db_session, + source="Incident Participant", + description=f'{individual.name} changed the incident title to "{incident.title}"', + incident_id=incident.id, + individual_id=individual.id, + ) + + if previous_incident.description != incident.description: + event_service.log( + db_session=db_session, + source="Incident Participant", + description=f"{individual.name} changed the incident description", + details={"description": incident.description}, + incident_id=incident.id, + individual_id=individual.id, + ) + if previous_incident.incident_type.name != incident.incident_type.name: conversation_topic_change = True + event_service.log( + db_session=db_session, + source="Incident Participant", + description=f"{individual.name} changed the incident type to {incident.incident_type.name}", + incident_id=incident.id, + individual_id=individual.id, + ) + if previous_incident.incident_priority.name != incident.incident_priority.name: conversation_topic_change = True + event_service.log( + db_session=db_session, + source="Incident Participant", + description=f"{individual.name} changed the incident priority to {incident.incident_priority.name}", + incident_id=incident.id, + individual_id=individual.id, + ) + if previous_incident.status.value != incident.status: conversation_topic_change = True + event_service.log( + db_session=db_session, + source="Incident Participant", + description=f"{individual.name} marked the incident as {incident.status}", + incident_id=incident.id, + individual_id=individual.id, + ) + if conversation_topic_change: # we update the conversation topic set_conversation_topic(incident) @@ -920,6 +957,7 @@ def incident_update_flow( # we update the external ticket update_incident_ticket( + db_session, incident.ticket.resource_id, title=incident.title, description=incident.description, @@ -1033,6 +1071,7 @@ def incident_assign_role_flow( # we update the external ticket update_incident_ticket( + db_session, incident.ticket.resource_id, description=incident.description, incident_type=incident.incident_type.name, diff --git a/src/dispatch/incident/messaging.py b/src/dispatch/incident/messaging.py index 2856484f942a..a660b92ddb0a 100644 --- a/src/dispatch/incident/messaging.py +++ b/src/dispatch/incident/messaging.py @@ -192,7 +192,7 @@ def send_incident_status_notifications(incident: Incident, db_session: SessionLo """Sends incident status notifications to conversations and distribution lists.""" notification_text = "Incident Notification" notification_type = MessageType.incident_notification - message_template = INCIDENT_NOTIFICATION + message_template = INCIDENT_NOTIFICATION.copy() # we get the incident documents incident_document = get_document( @@ -258,7 +258,7 @@ def send_incident_status_notifications(incident: Incident, db_session: SessionLo incident_id=incident.id, ) - log.debug(f"Incident status notifications sent.") + log.debug("Incident status notifications sent.") def send_incident_notifications(incident: Incident, db_session: SessionLocal): @@ -266,7 +266,7 @@ def send_incident_notifications(incident: Incident, db_session: SessionLocal): # we send the incident status notifications send_incident_status_notifications(incident, db_session) - log.debug(f"Incident notifications sent.") + log.debug("Incident notifications sent.") def send_incident_update_notifications(incident: Incident, previous_incident: IncidentRead): @@ -290,7 +290,7 @@ def send_incident_update_notifications(incident: Incident, previous_incident: In if not change: # we don't need to notify - log.debug(f"Incident change notifications not sent.") + log.debug("Incident change notifications not sent.") return notification_template.append(INCIDENT_COMMANDER) @@ -319,18 +319,18 @@ def send_incident_update_notifications(incident: Incident, previous_incident: In ) if incident.visibility == Visibility.open: - notification_coversation_notification_template = notification_template.copy() + notification_conversation_notification_template = notification_template.copy() if incident.status != IncidentStatus.closed: - notification_coversation_notification_template.insert(0, INCIDENT_NAME_WITH_ENGAGEMENT) + notification_conversation_notification_template.insert(0, INCIDENT_NAME_WITH_ENGAGEMENT) else: - notification_coversation_notification_template.insert(0, INCIDENT_NAME) + notification_conversation_notification_template.insert(0, INCIDENT_NAME) # we send an update to the incident notification conversations for conversation in INCIDENT_NOTIFICATION_CONVERSATIONS: convo_plugin.send( conversation, notification_text, - notification_coversation_notification_template, + notification_conversation_notification_template, notification_type, name=incident.name, ticket_weblink=incident.ticket.weblink, @@ -373,7 +373,7 @@ def send_incident_update_notifications(incident: Incident, previous_incident: In incident_status_new=incident.status, ) - log.debug(f"Incident update notifications sent.") + log.debug("Incident update notifications sent.") def send_incident_participant_announcement_message( @@ -439,7 +439,7 @@ def send_incident_participant_announcement_message( blocks=blocks, ) - log.debug(f"Incident participant announcement message sent.") + log.debug("Incident participant announcement message sent.") def send_incident_commander_readded_notification(incident_id: int, db_session: SessionLocal): @@ -459,7 +459,7 @@ def send_incident_commander_readded_notification(incident_id: int, db_session: S commander_fullname=incident.commander.name, ) - log.debug(f"Incident commander readded notification sent.") + log.debug("Incident commander readded notification sent.") def send_incident_participant_has_role_ephemeral_message( @@ -484,7 +484,7 @@ def send_incident_participant_has_role_ephemeral_message( ], ) - log.debug(f"Incident participant has role message sent.") + log.debug("Incident participant has role message sent.") def send_incident_participant_role_not_assigned_ephemeral_message( @@ -509,7 +509,7 @@ def send_incident_participant_role_not_assigned_ephemeral_message( ], ) - log.debug(f"Incident participant role not assigned message sent.") + log.debug("Incident participant role not assigned message sent.") def send_incident_new_role_assigned_notification( @@ -532,7 +532,7 @@ def send_incident_new_role_assigned_notification( assignee_role=assignee_role, ) - log.debug(f"Incident new role assigned message sent.") + log.debug("Incident new role assigned message sent.") def send_incident_review_document_notification( @@ -551,7 +551,7 @@ def send_incident_review_document_notification( incident_review_document_weblink=incident_review_document_weblink, ) - log.debug(f"Incident review document notification sent.") + log.debug("Incident review document notification sent.") def send_incident_resources_ephemeral_message_to_participant( diff --git a/src/dispatch/incident/metrics.py b/src/dispatch/incident/metrics.py index ffa0018f8d46..02a601d976b1 100644 --- a/src/dispatch/incident/metrics.py +++ b/src/dispatch/incident/metrics.py @@ -1,4 +1,3 @@ -import json import math import logging from itertools import groupby @@ -8,19 +7,14 @@ from calendar import monthrange -from dispatch.config import INCIDENT_METRIC_FORECAST_REGRESSIONS +import pandas as pd +from statsmodels.tsa.api import ExponentialSmoothing + from dispatch.incident_type.models import IncidentType from .models import Incident - log = logging.getLogger(__name__) -try: - from fbprophet import Prophet - import pandas as pd -except ImportError: - log.warning("Unable to import fbprophet, some metrics will not be usable.") - def month_grouper(item): """Determines the last day of a given month.""" @@ -55,49 +49,44 @@ def make_forecast( incidents = query.all() incidents_sorted = sorted(incidents, key=grouper) - # TODO ensure there are no missing periods (e.g. periods with no incidents) dataframe_dict = {"ds": [], "y": []} - regression_keys = [] - if INCIDENT_METRIC_FORECAST_REGRESSIONS: - # assumes json file with key as column and list of values - regression_data = json.loads(INCIDENT_METRIC_FORECAST_REGRESSIONS) - regression_keys = regression_data.keys() - dataframe_dict.update(regression_data) - for (last_day, items) in groupby(incidents_sorted, grouper): dataframe_dict["ds"].append(str(last_day)) dataframe_dict["y"].append(len(list(items))) dataframe = pd.DataFrame.from_dict(dataframe_dict) - forecaster = Prophet() + if dataframe.empty: + return { + "categories": [], + "series": [{"name": "Predicted", "data": []}], + } - for r in regression_keys: - forecaster.add_regressor(r) + # reset index to by month and drop month column + dataframe.index = dataframe.ds + dataframe.index.freq = "M" + dataframe.drop("ds", inplace=True, axis=1) - forecaster.fit(dataframe, algorithm="LBFGS") + # fill periods without incidents with 0 + idx = pd.date_range(dataframe.index[0], dataframe.index[-1], freq="M") + dataframe = dataframe.reindex(idx, fill_value=0) - # https://facebook.github.io/prophet/docs/quick_start.html#python-api - future = forecaster.make_future_dataframe(periods=periods, freq="M") - forecast = forecaster.predict(future) + forecaster = ExponentialSmoothing( + dataframe, seasonal_periods=12, trend="add", seasonal="add" + ).fit(use_boxcox=True) - forecast_data = forecast.to_dict("series") + forecast = forecaster.forecast(12) + forecast_df = pd.DataFrame({"ds": forecast.index.astype("str"), "yhat": forecast.values}) + + forecast_data = forecast_df.to_dict("series") return { "categories": list(forecast_data["ds"]), "series": [ - { - "name": "Upper", - "data": [max(math.ceil(x), 0) for x in list(forecast_data["yhat_upper"])], - }, { "name": "Predicted", "data": [max(math.ceil(x), 0) for x in list(forecast_data["yhat"])], - }, - { - "name": "Lower", - "data": [max(math.ceil(x), 0) for x in list(forecast_data["yhat_lower"])], - }, + } ], } diff --git a/src/dispatch/incident/models.py b/src/dispatch/incident/models.py index 0c75f31deff3..f19c94962cc8 100644 --- a/src/dispatch/incident/models.py +++ b/src/dispatch/incident/models.py @@ -11,13 +11,17 @@ PrimaryKeyConstraint, String, Table, + select, ) from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import relationship from sqlalchemy_utils import TSVectorType +from fastapi_permissions import Allow + from dispatch.config import INCIDENT_RESOURCE_FAQ_DOCUMENT, INCIDENT_RESOURCE_INVESTIGATION_DOCUMENT +from dispatch.auth.models import UserRoles from dispatch.conference.models import ConferenceRead from dispatch.conversation.models import ConversationRead from dispatch.database import Base @@ -32,11 +36,10 @@ from dispatch.incident_type.models import IncidentTypeCreate, IncidentTypeRead, IncidentTypeBase from dispatch.models import DispatchBase, IndividualReadNested, TimeStampMixin from dispatch.participant.models import ParticipantRead -from dispatch.participant_role.models import ParticipantRoleType +from dispatch.participant_role.models import ParticipantRole, ParticipantRoleType from dispatch.storage.models import StorageRead from dispatch.ticket.models import TicketRead - from .enums import IncidentStatus assoc_incident_terms = Table( @@ -76,9 +79,6 @@ class Incident(Base, TimeStampMixin): ) ) - # NOTE these only work in python, if want them to be executed via sql we need to - # write the coresponding expressions. See: - # https://docs.sqlalchemy.org/en/13/orm/extensions/hybrid.html @hybrid_property def commander(self): if self.participants: @@ -91,6 +91,15 @@ def commander(self): ): return p.individual + @commander.expression + def commander(cls): + return ( + select(ParticipantRole.individual) + .where(ParticipantRole.incident_id == cls.id) + .where(ParticipantRole.role == ParticipantRoleType.incident_commander) + .where(ParticipantRole.renounce_at == None) # noqa + ) + @hybrid_property def reporter(self): if self.participants: @@ -99,6 +108,15 @@ def reporter(self): if role.role == ParticipantRoleType.reporter: return p.individual + @reporter.expression + def reporter(cls): + return ( + select(ParticipantRole.individual) + .where(ParticipantRole.incident_id == cls.id) + .where(ParticipantRole.role == ParticipantRoleType.reporter) + .where(ParticipantRole.renounce_at == None) # noqa + ) + @hybrid_property def incident_document(self): if self.documents: @@ -197,6 +215,17 @@ class IncidentRead(IncidentBase): stable_at: Optional[datetime] = None closed_at: Optional[datetime] = None + def __acl__(self): + if self.visibility == Visibility.restricted: + return [ + (Allow, f"role:{UserRoles.admin}", "view"), + (Allow, f"role:{UserRoles.admin}", "edit"), + ] + return [ + (Allow, f"role:{UserRoles.user}", "view"), + (Allow, f"role:{UserRoles.user}", "edit"), + ] + class IncidentPagination(DispatchBase): total: int diff --git a/src/dispatch/incident/scheduled.py b/src/dispatch/incident/scheduled.py index d8907d2a0bd6..71d6bb2183ea 100644 --- a/src/dispatch/incident/scheduled.py +++ b/src/dispatch/incident/scheduled.py @@ -1,5 +1,7 @@ import logging from datetime import datetime +from sqlalchemy import func + from schedule import every from dispatch.config import ( @@ -7,6 +9,7 @@ INCIDENT_DAILY_SUMMARY_ONCALL_SERVICE_ID, INCIDENT_NOTIFICATION_CONVERSATIONS, INCIDENT_PLUGIN_TICKET_SLUG, + INCIDENT_PLUGIN_STORAGE_SLUG, ) from dispatch.decorators import background_task from dispatch.enums import Visibility @@ -19,9 +22,13 @@ INCIDENT_DAILY_SUMMARY_NO_STABLE_CLOSED_INCIDENTS_DESCRIPTION, INCIDENT_DAILY_SUMMARY_STABLE_CLOSED_INCIDENTS_DESCRIPTION, ) + +from dispatch.nlp import build_phrase_matcher, build_term_vocab, extract_terms_from_text from dispatch.plugins.base import plugins from dispatch.scheduler import scheduler from dispatch.service import service as service_service +from dispatch.tag import service as tag_service +from dispatch.tag.models import Tag from dispatch.conversation.enums import ConversationButtonActions from .enums import IncidentStatus @@ -32,6 +39,49 @@ log = logging.getLogger(__name__) +@scheduler.add(every(1).hours, name="incident-tagger") +@background_task +def auto_tagger(db_session): + """Attempts to take existing tags and associate them with incidents.""" + tags = tag_service.get_all(db_session=db_session).all() + log.debug(f"Fetched {len(tags)} tags from database.") + + tag_strings = [t.name.lower() for t in tags if t.discoverable] + phrases = build_term_vocab(tag_strings) + matcher = build_phrase_matcher("dispatch-tag", phrases) + + p = plugins.get( + INCIDENT_PLUGIN_STORAGE_SLUG + ) # this may need to be refactored if we support multiple document types + + for incident in get_all(db_session=db_session).all(): + log.debug(f"Processing incident. Name: {incident.name}") + + doc = incident.incident_document + try: + mime_type = "text/plain" + text = p.get(doc.resource_id, mime_type) + except Exception as e: + log.debug(f"Failed to get document. Reason: {e}") + sentry_sdk.capture_exception(e) + continue + + extracted_tags = list(set(extract_terms_from_text(text, matcher))) + + matched_tags = ( + db_session.query(Tag) + .filter(func.upper(Tag.name).in_([func.upper(t) for t in extracted_tags])) + .all() + ) + + incident.tags.extend(matched_tags) + db_session.commit() + + log.debug( + f"Associating tags with incident. Incident: {incident.name}, Tags: {extracted_tags}" + ) + + @scheduler.add(every(1).hours, name="incident-status-report-reminder") @background_task def status_report_reminder(db_session=None): @@ -96,6 +146,7 @@ def daily_summary(db_session=None): "text": ( f"*<{incident.ticket.weblink}|{incident.name}>*\n" f"*Title*: {incident.title}\n" + f"*Type*: {incident.incident_type.name}\n" f"*Priority*: {incident.incident_priority.name}\n" f"*Incident Commander*: <{incident.commander.weblink}|{incident.commander.name}>" ), @@ -103,7 +154,7 @@ def daily_summary(db_session=None): "block_id": f"{ConversationButtonActions.invite_user}-{idx}", "accessory": { "type": "button", - "text": {"type": "plain_text", "text": "Get Involved"}, + "text": {"type": "plain_text", "text": "Join Incident"}, "value": f"{incident.id}", }, } @@ -133,13 +184,14 @@ def daily_summary(db_session=None): ) hours = 24 - stable_closed_incidents = get_all_last_x_hours_by_status( + stable_incidents = get_all_last_x_hours_by_status( db_session=db_session, status=IncidentStatus.stable, hours=hours - ) + get_all_last_x_hours_by_status( + ) + closed_incidents = get_all_last_x_hours_by_status( db_session=db_session, status=IncidentStatus.closed, hours=hours ) - if stable_closed_incidents: - for incident in stable_closed_incidents: + if stable_incidents or closed_incidents: + for idx, incident in enumerate(stable_incidents): if incident.visibility == Visibility.open: try: blocks.append( @@ -150,9 +202,38 @@ def daily_summary(db_session=None): "text": ( f"*<{incident.ticket.weblink}|{incident.name}>*\n" f"*Title*: {incident.title}\n" - f"*Status*: {incident.status}\n" + f"*Type*: {incident.incident_type.name}\n" f"*Priority*: {incident.incident_priority.name}\n" - f"*Incident Commander*: <{incident.commander.weblink}|{incident.commander.name}>" + f"*Incident Commander*: <{incident.commander.weblink}|{incident.commander.name}>\n" + f"*Status*: {incident.status}" + ), + }, + "block_id": f"{ConversationButtonActions.invite_user}-{idx}", + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Join Incident"}, + "value": f"{incident.id}", + }, + } + ) + except Exception as e: + sentry_sdk.capture_exception(e) + + for incident in closed_incidents: + if incident.visibility == Visibility.open: + try: + blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + f"*<{incident.ticket.weblink}|{incident.name}>*\n" + f"*Title*: {incident.title}\n" + f"*Type*: {incident.incident_type.name}\n" + f"*Priority*: {incident.incident_priority.name}\n" + f"*Incident Commander*: <{incident.commander.weblink}|{incident.commander.name}>\n" + f"*Status*: {incident.status}" ), }, } diff --git a/src/dispatch/incident/views.py b/src/dispatch/incident/views.py index 797e69325ba9..872cd9794397 100644 --- a/src/dispatch/incident/views.py +++ b/src/dispatch/incident/views.py @@ -3,19 +3,27 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query from sqlalchemy.orm import Session +from dispatch.enums import Visibility +from dispatch.auth.models import DispatchUser from dispatch.auth.service import get_current_user from dispatch.database import get_db, search_filter_sort_paginate from dispatch.participant_role.models import ParticipantRoleType -from .flows import incident_create_flow, incident_update_flow, incident_assign_role_flow +from dispatch.auth.models import UserRoles +from .flows import ( + incident_create_flow, + incident_update_flow, + incident_assign_role_flow, + incident_add_or_reactivate_participant_flow, +) from .models import IncidentCreate, IncidentPagination, IncidentRead, IncidentUpdate from .service import create, delete, get, update from .metrics import make_forecast router = APIRouter() -# TODO add additional routes to get incident by e.g. deeplink + @router.get("/", response_model=IncidentPagination, summary="Retrieve a list of all incidents.") def get_incidents( db_session: Session = Depends(get_db), @@ -24,13 +32,23 @@ def get_incidents( query_str: str = Query(None, alias="q"), sort_by: List[str] = Query(None, alias="sortBy[]"), descending: List[bool] = Query(None, alias="descending[]"), - fields: List[str] = Query(None, alias="fields[]"), - ops: List[str] = Query(None, alias="ops[]"), - values: List[str] = Query(None, alias="values[]"), + fields: List[str] = Query([], alias="fields[]"), + ops: List[str] = Query([], alias="ops[]"), + values: List[str] = Query([], alias="values[]"), + current_user: DispatchUser = Depends(get_current_user), ): """ Retrieve a list of all incidents. """ + # we want to provide additional protections around restricted incidents + # Because we want to proactively filter (instead of when the item is returned + # we don't use fastapi_permissions acls. + if current_user.role != UserRoles.admin: + # add a filter for restricted incidents + fields.append("visibility") + values.append(Visibility.restricted) + ops.append("!=") + return search_filter_sort_paginate( db_session=db_session, model="Incident", @@ -42,17 +60,32 @@ def get_incidents( fields=fields, values=values, ops=ops, + join_attrs=[("tag", "tags")], ) @router.get("/{incident_id}", response_model=IncidentRead, summary="Retrieve a single incident.") -def get_incident(*, db_session: Session = Depends(get_db), incident_id: str): +def get_incident( + *, + db_session: Session = Depends(get_db), + incident_id: str, + current_user: DispatchUser = Depends(get_current_user), +): """ Retrieve details about a specific incident. """ incident = get(db_session=db_session, incident_id=incident_id) if not incident: raise HTTPException(status_code=404, detail="The requested incident does not exist.") + + # we want to provide additional protections around restricted incidents + if incident.visibility == Visibility.restricted: + if not incident.reporter == current_user: + if not current_user.role != UserRoles.admin: + raise HTTPException( + status_code=401, detail="You do no have permission to view this incident." + ) + return incident @@ -61,14 +94,14 @@ def create_incident( *, db_session: Session = Depends(get_db), incident_in: IncidentCreate, - current_user_email: str = Depends(get_current_user), + current_user: DispatchUser = Depends(get_current_user), background_tasks: BackgroundTasks, ): """ Create a new incident. """ incident = create( - db_session=db_session, reporter_email=current_user_email, **incident_in.dict() + db_session=db_session, reporter_email=current_user.email, **incident_in.dict() ) background_tasks.add_task(incident_create_flow, incident_id=incident.id) @@ -82,7 +115,7 @@ def update_incident( db_session: Session = Depends(get_db), incident_id: str, incident_in: IncidentUpdate, - current_user_email: str = Depends(get_current_user), + current_user: DispatchUser = Depends(get_current_user), background_tasks: BackgroundTasks, ): """ @@ -99,7 +132,7 @@ def update_incident( background_tasks.add_task( incident_update_flow, - user_email=current_user_email, + user_email=current_user.email, incident_id=incident.id, previous_incident=previous_incident, ) @@ -107,7 +140,7 @@ def update_incident( # assign commander background_tasks.add_task( incident_assign_role_flow, - current_user_email, + current_user.email, incident_id=incident.id, assignee_email=incident_in.commander.email, assignee_role=ParticipantRoleType.incident_commander, @@ -116,7 +149,7 @@ def update_incident( # assign reporter background_tasks.add_task( incident_assign_role_flow, - current_user_email, + current_user.email, incident_id=incident.id, assignee_email=incident_in.reporter.email, assignee_role=ParticipantRoleType.reporter, @@ -125,6 +158,26 @@ def update_incident( return incident +@router.post("/{incident_id}/join", summary="Join an incident.") +def join_incident( + *, + db_session: Session = Depends(get_db), + incident_id: str, + current_user: DispatchUser = Depends(get_current_user), + background_tasks: BackgroundTasks, +): + """ + Join an individual incident. + """ + incident = get(db_session=db_session, incident_id=incident_id) + if not incident: + raise HTTPException(status_code=404, detail="The requested incident does not exist.") + + background_tasks.add_task( + incident_add_or_reactivate_participant_flow, current_user.email, incident_id=incident.id + ) + + @router.delete("/{incident_id}", response_model=IncidentRead, summary="Delete an incident.") def delete_incident(*, db_session: Session = Depends(get_db), incident_id: str): """ diff --git a/src/dispatch/incident_type/models.py b/src/dispatch/incident_type/models.py index 7aaef844dc8e..86bbe648beda 100644 --- a/src/dispatch/incident_type/models.py +++ b/src/dispatch/incident_type/models.py @@ -4,8 +4,9 @@ from sqlalchemy.orm import relationship from sqlalchemy_utils import TSVectorType +from dispatch.database import Base from dispatch.enums import Visibility -from dispatch.models import Base, DispatchBase +from dispatch.models import DispatchBase class IncidentType(Base): diff --git a/src/dispatch/main.py b/src/dispatch/main.py index 01affb3626ef..593b5d69b2c6 100644 --- a/src/dispatch/main.py +++ b/src/dispatch/main.py @@ -14,26 +14,30 @@ import httpx from .api import api_router +from .common.utils.cli import install_plugins, install_plugin_events from .config import STATIC_DIR from .database import SessionLocal -from .metrics import provider as metric_provider -from .logging import configure_logging from .extensions import configure_extensions +from .logging import configure_logging +from .metrics import provider as metric_provider + log = logging.getLogger(__name__) -app = Starlette() -frontend = Starlette() +# we configure the logging level and format +configure_logging() -api = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) +# we configure the extensions such as Sentry +configure_extensions() -api.include_router(api_router, prefix="/v1") +# we create the ASGI for the app +app = Starlette() -if STATIC_DIR: - frontend.mount("/", StaticFiles(directory=STATIC_DIR), name="app") +# we create the ASGI for the frontend +frontend = Starlette() -app.mount("/api", app=api) -app.mount("/", app=frontend) +# we create the Web API framework +api = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) def get_path_template(request: Request) -> str: @@ -106,13 +110,29 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) - return response +# we add a middleware class for logging exceptions to Sentry app.add_middleware(SentryMiddleware) + +# we add a middleware class for capturing metrics using Dispatch's metrics provider app.add_middleware(MetricsMiddleware) -# app.add_middleware(GZipMiddleware) -configure_logging() -configure_extensions() +# we install all the plugins +install_plugins() + +# we add all the plugin event API routes to the API router +install_plugin_events(api_router) + +# we add all API routes to the Web API framework +api.include_router(api_router, prefix="/v1") + +# we mount the frontend and app +if STATIC_DIR: + frontend.mount("/", StaticFiles(directory=STATIC_DIR), name="app") + +app.mount("/api", app=api) +app.mount("/", app=frontend) +# we print all the registered API routes to the console table = [] for r in api_router.routes: auth = False diff --git a/src/dispatch/messaging.py b/src/dispatch/messaging.py index 55033a37a8b1..a8c2a5f5c793 100644 --- a/src/dispatch/messaging.py +++ b/src/dispatch/messaging.py @@ -37,8 +37,8 @@ class MessageType(str, Enum): INCIDENT_TASK_REMINDER_DESCRIPTION = """ You are assigned to the following incident tasks. -This is a reminder that these tasks have *passed* their due date. -Please review and update as appropriate.""".replace( +This is a reminder that these tasks have passed their due date. +Please review and update them as appropriate. Resolving them will stop the reminders.""".replace( "\n", " " ).strip() @@ -166,11 +166,6 @@ class MessageType(str, Enum): "\n", " " ).strip() -INCIDENT_GET_INVOLVED_BUTTON_DESCRIPTION = """ -Click the button to be added to the incident conversation.""".replace( - "\n", " " -).strip() - INCIDENT_CAN_REPORT_REMINDER = """ It's time to send a new CAN report. Go to the Demisto UI and run the CAN Report playbook from the Playground Work Plan.""".replace( @@ -321,14 +316,6 @@ class MessageType(str, Enum): "text": INCIDENT_PARTICIPANT_WELCOME_DESCRIPTION, } -INCIDENT_GET_INVOLVED_BUTTON = { - "title": "Get Involved", - "text": INCIDENT_GET_INVOLVED_BUTTON_DESCRIPTION, - "button_text": "Get Involved", - "button_value": "{{incident_id}}", - "button_action": ConversationButtonActions.invite_user, -} - INCIDENT_PARTICIPANT_WELCOME_MESSAGE = [ INCIDENT_PARTICIPANT_WELCOME, INCIDENT_TITLE, diff --git a/src/dispatch/models.py b/src/dispatch/models.py index 3d91f99eea38..2d54eed71441 100644 --- a/src/dispatch/models.py +++ b/src/dispatch/models.py @@ -2,38 +2,9 @@ from typing import List, Optional from pydantic import BaseModel -from sqlalchemy import ( - Boolean, - Column, - DateTime, - ForeignKey, - Integer, - PrimaryKeyConstraint, - String, - Table, - event, -) +from sqlalchemy import Boolean, Column, DateTime, Integer, String, event, ForeignKey from sqlalchemy.ext.declarative import declared_attr -from .database import Base - -# Association tables -definition_teams = Table( - "definition_teams", - Base.metadata, - Column("definition_id", Integer, ForeignKey("definition.id")), - Column("team_contact_id", Integer, ForeignKey("team_contact.id")), - PrimaryKeyConstraint("definition_id", "team_contact_id"), -) - -definition_terms = Table( - "definition_terms", - Base.metadata, - Column("definition_id", Integer, ForeignKey("definition.id")), - Column("term_id", Integer, ForeignKey("term.id")), - PrimaryKeyConstraint("definition_id", "term_id"), -) - # SQLAlchemy models... class TimeStampMixin(object): @@ -154,27 +125,3 @@ class TeamReadNested(ContactBase): class PolicyReadNested(DispatchBase): pass - - -from dispatch.conference.models import * # noqa -from dispatch.conversation.models import * # noqa -from dispatch.definition.models import * # noqa -from dispatch.document.models import Document # noqa -from dispatch.event.models import * # noqa -from dispatch.group.models import * # noqa -from dispatch.incident.models import * # noqa -from dispatch.incident_priority.models import * # noqa -from dispatch.incident_type.models import * # noqa -from dispatch.individual.models import * # noqa -from dispatch.participant.models import * # noqa -from dispatch.participant_role.models import * # noqa -from dispatch.policy.models import * # noqa -from dispatch.route.models import * # noqa -from dispatch.service.models import * # noqa -from dispatch.status_report.models import * # noqa -from dispatch.storage.models import * # noqa -from dispatch.tag.models import * # noqa -from dispatch.task.models import * # noqa -from dispatch.team.models import * # noqa -from dispatch.term.models import * # noqa -from dispatch.ticket.models import * # noqa diff --git a/src/dispatch/nlp.py b/src/dispatch/nlp.py new file mode 100644 index 000000000000..5be06113bec1 --- /dev/null +++ b/src/dispatch/nlp.py @@ -0,0 +1,52 @@ +import logging +from typing import List + +import spacy +from spacy.matcher import PhraseMatcher + +log = logging.getLogger(__name__) + +nlp = spacy.blank("en") +nlp.vocab.lex_attr_getters = {} + + +def build_term_vocab(terms: List[str]): + """Builds nlp vocabulary.""" + for v in terms: + texts = [v, v.lower(), v.upper(), v.title()] + for t in texts: + if t: # guard against `None` + phrase = nlp.tokenizer(t) + for w in phrase: + _ = nlp.tokenizer.vocab[w.text] + yield phrase + + +def build_phrase_matcher(name: str, phrases: List[str]) -> PhraseMatcher: + """Builds a PhraseMatcher object.""" + matcher = PhraseMatcher(nlp.tokenizer.vocab) + matcher.add(name, phrases) + return matcher + + +def extract_terms_from_text(text: str, matcher: PhraseMatcher) -> List[str]: + """Extracts key terms out of test.""" + terms = [] + doc = nlp.tokenizer(text) + for w in doc: + _ = doc.vocab[ + w.text.lower() + ] # We normalize our docs so that vocab doesn't take so long to build. + + matches = matcher(doc) + for _, start, end in matches: + token = doc[start:end].merge() + + # We try to filter out common stop words unless + # we have surrounding context that would suggest they are not stop words. + if token.is_stop: + continue + + terms.append(token.text.lower()) + + return terms diff --git a/src/dispatch/participant/models.py b/src/dispatch/participant/models.py index 1328701a9659..a50d8c05017f 100644 --- a/src/dispatch/participant/models.py +++ b/src/dispatch/participant/models.py @@ -22,6 +22,7 @@ class Participant(Base): location = Column(String) team = Column(String) department = Column(String) + after_hours_notification = Column(Boolean, default=False) participant_roles = relationship("ParticipantRole", backref="participant") status_reports = relationship("StatusReport", backref="participant") diff --git a/src/dispatch/plugin/__init__.py b/src/dispatch/plugin/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/dispatch/plugin/models.py b/src/dispatch/plugin/models.py new file mode 100644 index 000000000000..b0fb72ffc6fe --- /dev/null +++ b/src/dispatch/plugin/models.py @@ -0,0 +1,81 @@ +from typing import List, Optional + +from sqlalchemy import Column, Integer, String, Boolean +from sqlalchemy_utils import TSVectorType, JSONType + +from dispatch.database import Base +from dispatch.models import DispatchBase +from dispatch.plugins.base import plugins + + +class Plugin(Base): + id = Column(Integer, primary_key=True) + title = Column(String) + slug = Column(String, unique=True) + description = Column(String) + version = Column(String) + author = Column(String) + author_url = Column(String) + type = Column(String) + enabled = Column(Boolean) + required = Column(Boolean) + multiple = Column(Boolean) + configuration = Column(JSONType) + + @property + def instance(self): + """Fetches a plugin instance that matches this record.""" + return plugins.get(self.slug) + + search_vector = Column(TSVectorType("title")) + + +# Pydantic models... +class PluginBase(DispatchBase): + pass + + +class PluginCreate(PluginBase): + title: str + slug: str + author: str + author_url: str + type: str + enabled: Optional[bool] + required: Optional[bool] = True + multiple: Optional[bool] = False + description: Optional[str] + configuration: Optional[dict] + + +class PluginUpdate(PluginBase): + id: int + title: str + slug: str + author: str + author_url: str + type: str + enabled: Optional[bool] + required: Optional[bool] = True + multiple: Optional[bool] = False + description: Optional[str] + configuration: Optional[dict] + + +class PluginRead(PluginBase): + id: int + title: str + slug: str + author: str + author_url: str + type: str + enabled: bool + required: bool + multiple: bool + description: Optional[str] + configuration: Optional[dict] + + +class PluginPagination(DispatchBase): + total: int + items: List[PluginRead] = [] diff --git a/src/dispatch/plugin/service.py b/src/dispatch/plugin/service.py new file mode 100644 index 000000000000..abdc7994bf7b --- /dev/null +++ b/src/dispatch/plugin/service.py @@ -0,0 +1,93 @@ +import logging +from typing import List, Optional +from fastapi.encoders import jsonable_encoder + +from dispatch.exceptions import InvalidConfiguration +from .models import Plugin, PluginCreate, PluginUpdate + + +log = logging.getLogger(__name__) + + +def get(*, db_session, plugin_id: int) -> Optional[Plugin]: + """Returns a plugin based on the given plugin id.""" + return db_session.query(Plugin).filter(Plugin.id == plugin_id).one_or_none() + + +def get_active(*, db_session, plugin_type: str) -> Optional[Plugin]: + """Fetches the current active plugin for the given type.""" + return ( + db_session.query(Plugin) + .filter(Plugin.type == plugin_type) + .filter(Plugin.enabled == True) # noqa + .one_or_none() + ) + + +def get_by_type(*, db_session, plugin_type: str) -> List[Optional[Plugin]]: + """Fetches all plugins for a given type.""" + return db_session.query(Plugin).filter(Plugin.type == plugin_type).all() + + +def get_enabled_by_type(*, db_session, plugin_type: str) -> List[Optional[Plugin]]: + """Fetches all enabled plugins for a given type.""" + return ( + db_session.query(Plugin) + .filter(Plugin.type == plugin_type) + .filter(Plugin.enabled == True) # noqa + .all() + ) + + +def get_all(*, db_session) -> List[Optional[Plugin]]: + """Returns all plugins.""" + return db_session.query(Plugin) + + +def get_by_slug(*, db_session, slug: str) -> Plugin: + """Fetches a given plugin or creates a new one.""" + return db_session.query(Plugin).filter(Plugin.slug == slug).one_or_none() + + +def create(*, db_session, plugin_in: PluginCreate) -> Plugin: + """Creates a new plugin.""" + plugin = Plugin(**plugin_in.dict()) + db_session.add(plugin) + db_session.commit() + return plugin + + +def update(*, db_session, plugin: Plugin, plugin_in: PluginUpdate) -> Plugin: + """Updates a plugin.""" + plugin_data = jsonable_encoder(plugin) + update_data = plugin_in.dict(skip_defaults=True) + + if plugin_in.enabled: # user wants to enable the plugin + if not plugin.multiple: + # we can't have multiple plugins of this type disable the currently enabled one + enabled_plugins = get_enabled_by_type(db_session=db_session, plugin_type=plugin.type) + if enabled_plugins: + enabled_plugins[0].enabled = False + db_session.add(enabled_plugins[0]) + + if not plugin_in.enabled: # user wants to disable the plugin + if plugin.required: + enabled_plugins = get_enabled_by_type(db_session=db_session, plugin_type=plugin.type) + if len(enabled_plugins) == 1: + raise InvalidConfiguration( + f"Cannot disable plugin: {plugin.title}. It is required and no other plugins of type {plugin.type} are enabled." + ) + + for field in plugin_data: + if field in update_data: + setattr(plugin, field, update_data[field]) + + db_session.add(plugin) + db_session.commit() + return plugin + + +def delete(*, db_session, plugin_id: int): + """Deletes a plugin.""" + db_session.query(Plugin).filter(Plugin.id == plugin_id).delete() + db_session.commit() diff --git a/src/dispatch/plugin/views.py b/src/dispatch/plugin/views.py new file mode 100644 index 000000000000..2c87d1468f44 --- /dev/null +++ b/src/dispatch/plugin/views.py @@ -0,0 +1,71 @@ +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from dispatch.exceptions import InvalidConfiguration +from dispatch.database import get_db, search_filter_sort_paginate + +from .models import PluginCreate, PluginPagination, PluginRead, PluginUpdate +from .service import get, update + +router = APIRouter() + + +@router.get("/", response_model=PluginPagination) +def get_plugins( + db_session: Session = Depends(get_db), + page: int = 1, + items_per_page: int = Query(5, alias="itemsPerPage"), + query_str: str = Query(None, alias="q"), + sort_by: List[str] = Query(None, alias="sortBy[]"), + descending: List[bool] = Query(None, alias="descending[]"), + fields: List[str] = Query(None, alias="field[]"), + ops: List[str] = Query(None, alias="op[]"), + values: List[str] = Query(None, alias="value[]"), +): + """ + Get all plugins. + """ + return search_filter_sort_paginate( + db_session=db_session, + model="Plugin", + query_str=query_str, + page=page, + items_per_page=items_per_page, + sort_by=sort_by, + descending=descending, + fields=fields, + values=values, + ops=ops, + ) + + +@router.get("/{plugin_id}", response_model=PluginRead) +def get_plugin(*, db_session: Session = Depends(get_db), plugin_id: int): + """ + Get a plugin. + """ + plugin = get(db_session=db_session, plugin_id=plugin_id) + if not plugin: + raise HTTPException(status_code=404, detail="The plugin with this id does not exist.") + return plugin + + +@router.put("/{plugin_id}", response_model=PluginCreate) +def update_plugin( + *, db_session: Session = Depends(get_db), plugin_id: int, plugin_in: PluginUpdate +): + """ + Update a plugin. + """ + plugin = get(db_session=db_session, plugin_id=plugin_id) + if not plugin: + raise HTTPException(status_code=404, detail="The plugin with this id does not exist.") + + try: + plugin = update(db_session=db_session, plugin=plugin, plugin_in=plugin_in) + except InvalidConfiguration as e: + raise HTTPException(status_code=400, detail=str(e)) + + return plugin diff --git a/src/dispatch/plugins/base/manager.py b/src/dispatch/plugins/base/manager.py index 8821d0eccea9..7028ba25fa6c 100644 --- a/src/dispatch/plugins/base/manager.py +++ b/src/dispatch/plugins/base/manager.py @@ -59,6 +59,36 @@ def first(self, func_name, *args, **kwargs): return result def register(self, cls): + from dispatch.database import SessionLocal + from dispatch.plugin import service as plugin_service + from dispatch.plugin.models import Plugin + + db_session = SessionLocal() + record = plugin_service.get_by_slug(db_session=db_session, slug=cls.slug) + if not record: + plugin = Plugin( + title=cls.title, + slug=cls.slug, + type=cls.type, + version=cls.version, + author=cls.author, + author_url=cls.author_url, + required=cls.required, + multiple=cls.multiple, + description=cls.description, + enabled=cls.enabled + ) + db_session.add(plugin) + else: + # we only update values that should change + record.tile = cls.title + record.version = cls.version + record.author = cls.author + record.author_url = cls.author_url + record.description = cls.description + db_session.add(record) + + db_session.commit() self.add(f"{cls.__module__}.{cls.__name__}") return cls diff --git a/src/dispatch/plugins/base/v1.py b/src/dispatch/plugins/base/v1.py index 8d1f5ab7b011..1885ff3b1e76 100644 --- a/src/dispatch/plugins/base/v1.py +++ b/src/dispatch/plugins/base/v1.py @@ -25,6 +25,7 @@ def __new__(cls, name, bases, attrs): new_cls.title = new_cls.__name__ if not new_cls.slug: new_cls.slug = new_cls.title.replace(" ", "-").lower() + return new_cls @@ -60,6 +61,8 @@ class IPlugin(local): # Global enabled state enabled: bool = True can_disable: bool = True + multiple: bool = False + required: bool = True def validate_options(self, options: dict) -> Any: """ @@ -76,14 +79,12 @@ def schema(self): def is_enabled(self) -> bool: """ Returns a boolean representing if this plugin is enabled. - If ``project`` is passed, it will limit the scope to that project. >>> plugin.is_enabled() """ if not self.enabled: return False if not self.can_disable: return True - return True def get_title(self) -> Optional[str]: diff --git a/src/dispatch/plugins/dispatch_core/plugin.py b/src/dispatch/plugins/dispatch_core/plugin.py index e3f0f95c445e..1d32497d058f 100644 --- a/src/dispatch/plugins/dispatch_core/plugin.py +++ b/src/dispatch/plugins/dispatch_core/plugin.py @@ -8,12 +8,14 @@ import requests from fastapi import HTTPException +from typing import List from fastapi.security.utils import get_authorization_scheme_param from jose import JWTError, jwt from starlette.status import HTTP_401_UNAUTHORIZED from starlette.requests import Request +from dispatch.config import DISPATCH_UI_URL from dispatch.individual import service as individual_service from dispatch.plugins import dispatch_core as dispatch_plugin from dispatch.plugins.base import plugins @@ -21,23 +23,48 @@ ParticipantPlugin, DocumentResolverPlugin, AuthenticationProviderPlugin, + TicketPlugin, ) from dispatch.route import service as route_service from dispatch.route.models import RouteRequest -from dispatch.config import DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS +from dispatch.config import DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS, DISPATCH_JWT_SECRET log = logging.getLogger(__name__) +class BasicAuthProviderPlugin(AuthenticationProviderPlugin): + title = "Dispatch Plugin - Basic Authentication Provider" + slug = "dispatch-auth-provider-basic" + description = "Generic basic authentication provider." + version = dispatch_plugin.__version__ + + author = "Netflix" + author_url = "https://github.com/netflix/dispatch.git" + + def get_current_user(self, request: Request, **kwargs): + authorization: str = request.headers.get("Authorization") + scheme, param = get_authorization_scheme_param(authorization) + if not authorization or scheme.lower() != "bearer": + return + + token = authorization.split()[1] + + try: + data = jwt.decode(token, DISPATCH_JWT_SECRET) + except JWTError as e: + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=str(e)) + return data["email"] + + class PKCEAuthProviderPlugin(AuthenticationProviderPlugin): title = "Dispatch Plugin - PKCE Authentication Provider" slug = "dispatch-auth-provider-pkce" description = "Generic PCKE authentication provider." version = dispatch_plugin.__version__ - author = "Kevin Glisson" + author = "Netflix" author_url = "https://github.com/netflix/dispatch.git" def get_current_user(self, request: Request, **kwargs): @@ -61,13 +88,60 @@ def get_current_user(self, request: Request, **kwargs): return data["email"] +class DispatchTicketPlugin(TicketPlugin): + title = "Dispatch Plugin - Ticket Management" + slug = "dispatch-ticket" + description = "Uses dispatch itself to create a ticket." + version = dispatch_plugin.__version__ + + author = "Netflix" + author_url = "https://github.com/netflix/dispatch.git" + + def create( + self, + incident_id: int, + title: str, + incident_type: str, + incident_priority: str, + commander: str, + reporter: str, + ): + """Creates a dispatch ticket.""" + resource_id = f"dispatch-{incident_id}" + return { + "resource_id": resource_id, + "weblink": f"{DISPATCH_UI_URL}/incidents/{resource_id}", + "resource_type": "dispatch-internal-ticket", + } + + def update( + self, + ticket_id: str, + title: str = None, + description: str = None, + incident_type: str = None, + priority: str = None, + status: str = None, + commander_email: str = None, + reporter_email: str = None, + conversation_weblink: str = None, + conference_weblink: str = None, + document_weblink: str = None, + storage_weblink: str = None, + labels: List[str] = None, + cost: str = None, + ): + """Updates the incident.""" + return + + class DispatchDocumentResolverPlugin(DocumentResolverPlugin): title = "Dispatch Plugin - Document Resolver" slug = "dispatch-document-resolver" description = "Uses dispatch itself to resolve incident documents." version = dispatch_plugin.__version__ - author = "Kevin Glisson" + author = "Netflix" author_url = "https://github.com/netflix/dispatch.git" def get( @@ -94,7 +168,7 @@ class DispatchParticipantResolverPlugin(ParticipantPlugin): description = "Uses dispatch itself to resolve incident participants." version = dispatch_plugin.__version__ - author = "Kevin Glisson" + author = "Netflix" author_url = "https://github.com/netflix/dispatch.git" def get( diff --git a/src/dispatch/plugins/dispatch_google/calendar/__init__.py b/src/dispatch/plugins/dispatch_google/calendar/__init__.py index e69de29bb2d1..ad5cc752c07b 100644 --- a/src/dispatch/plugins/dispatch_google/calendar/__init__.py +++ b/src/dispatch/plugins/dispatch_google/calendar/__init__.py @@ -0,0 +1 @@ +from ._version import __version__ # noqa diff --git a/src/dispatch/plugins/dispatch_google/calendar/plugin.py b/src/dispatch/plugins/dispatch_google/calendar/plugin.py index 50edec6ab465..a0152a0e1c28 100644 --- a/src/dispatch/plugins/dispatch_google/calendar/plugin.py +++ b/src/dispatch/plugins/dispatch_google/calendar/plugin.py @@ -17,8 +17,10 @@ from dispatch.decorators import apply, counter, timer from dispatch.plugins.bases import ConferencePlugin +from dispatch.plugins.dispatch_google import calendar as google_calendar_plugin from dispatch.plugins.dispatch_google.common import get_service + log = logging.getLogger(__name__) @@ -119,8 +121,9 @@ class GoogleCalendarConferencePlugin(ConferencePlugin): title = "Google Calendar Plugin - Conference Management" slug = "google-calendar-conference" description = "Uses Google calendar to manage conference rooms/meets." + version = google_calendar_plugin.__version__ - author = "Kevin Glisson" + author = "Netflix" author_url = "https://github.com/netflix/dispatch.git" def __init__(self): diff --git a/src/dispatch/plugins/dispatch_google/docs/__init__.py b/src/dispatch/plugins/dispatch_google/docs/__init__.py index 2d0b548e6531..ad5cc752c07b 100644 --- a/src/dispatch/plugins/dispatch_google/docs/__init__.py +++ b/src/dispatch/plugins/dispatch_google/docs/__init__.py @@ -1 +1 @@ -from dispatch import __version__ # noqa +from ._version import __version__ # noqa diff --git a/src/dispatch/plugins/dispatch_google/docs/plugin.py b/src/dispatch/plugins/dispatch_google/docs/plugin.py index 88ae8f809aff..c995d60dd62c 100644 --- a/src/dispatch/plugins/dispatch_google/docs/plugin.py +++ b/src/dispatch/plugins/dispatch_google/docs/plugin.py @@ -10,8 +10,8 @@ from dispatch.decorators import apply, counter, timer from dispatch.plugins.bases import DocumentPlugin +from dispatch.plugins.dispatch_google import docs as google_docs_plugin from dispatch.plugins.dispatch_google.common import get_service -from ._version import __version__ def remove_control_characters(s): @@ -93,9 +93,9 @@ class GoogleDocsDocumentPlugin(DocumentPlugin): title = "Google Docs Plugin - Document Management" slug = "google-docs-document" description = "Uses Google docs to manage document contents." - version = __version__ + version = google_docs_plugin.__version__ - author = "Kevin Glisson" + author = "Netflix" author_url = "https://github.com/netflix/dispatch.git" def __init__(self): diff --git a/src/dispatch/plugins/dispatch_google/drive/plugin.py b/src/dispatch/plugins/dispatch_google/drive/plugin.py index 7ed25b2f7ca8..ab4361195dc9 100644 --- a/src/dispatch/plugins/dispatch_google/drive/plugin.py +++ b/src/dispatch/plugins/dispatch_google/drive/plugin.py @@ -39,7 +39,7 @@ class GoogleDriveStoragePlugin(StoragePlugin): description = "Uses Google Drive to help manage incident storage." version = google_drive_plugin.__version__ - author = "Kevin Glisson" + author = "Netflix" author_url = "https://github.com/netflix/dispatch.git" _schema = None @@ -139,7 +139,7 @@ class GoogleDriveTaskPlugin(TaskPlugin): description = "Uses Google Drive to help manage incident tasks." version = google_drive_plugin.__version__ - author = "Marc Vilanova" + author = "Netflix" author_url = "https://github.com/netflix/dispatch.git" _schema = None diff --git a/src/dispatch/plugins/dispatch_google/gmail/plugin.py b/src/dispatch/plugins/dispatch_google/gmail/plugin.py index 9ffd7a0770a0..8636be761eec 100644 --- a/src/dispatch/plugins/dispatch_google/gmail/plugin.py +++ b/src/dispatch/plugins/dispatch_google/gmail/plugin.py @@ -87,7 +87,14 @@ def create_multi_message_body( data.update({"name": item.incident.name, "title": item.incident.title}) master_map.append(render_message_template(message_template, **data)) - kwargs.update({"items": master_map, "description": description}) + kwargs.update( + { + "items": master_map, + "description": description, + "dispatch_help_email": DISPATCH_HELP_EMAIL, + "dispatch_help_slack_channel": DISPATCH_HELP_SLACK_CHANNEL, + } + ) return template.render(**kwargs) @@ -127,7 +134,7 @@ class GoogleGmailConversationPlugin(ConversationPlugin): description = "Uses gmail to facilitate conversations." version = google_gmail_plugin.__version__ - author = "Kevin Glisson" + author = "Netflix" author_url = "https://github.com/netflix/dispatch.git" def __init__(self): diff --git a/src/dispatch/plugins/dispatch_google/gmail/templates/base.html b/src/dispatch/plugins/dispatch_google/gmail/templates/base.html index 0acdcbd1521f..fefaaa277bf8 100644 --- a/src/dispatch/plugins/dispatch_google/gmail/templates/base.html +++ b/src/dispatch/plugins/dispatch_google/gmail/templates/base.html @@ -115,10 +115,12 @@

+ {{dispatch_help_email}} - or Slack channel - # {{dispatch_help_slack_channel}}. + + or Slack channel + #{{dispatch_help_slack_channel}}.

diff --git a/src/dispatch/plugins/dispatch_google/groups/plugin.py b/src/dispatch/plugins/dispatch_google/groups/plugin.py index e8bae2351f48..c6427933533d 100644 --- a/src/dispatch/plugins/dispatch_google/groups/plugin.py +++ b/src/dispatch/plugins/dispatch_google/groups/plugin.py @@ -117,7 +117,7 @@ class GoogleGroupParticipantGroupPlugin(ParticipantGroupPlugin): description = "Uses Google Groups to help manage participant membership." version = google_group_plugin.__version__ - author = "Kevin Glisson" + author = "Netflix" author_url = "https://github.com/netflix/dispatch.git" _schema = None diff --git a/src/dispatch/plugins/dispatch_jira/plugin.py b/src/dispatch/plugins/dispatch_jira/plugin.py index 2201232c1136..20a46d00482e 100644 --- a/src/dispatch/plugins/dispatch_jira/plugin.py +++ b/src/dispatch/plugins/dispatch_jira/plugin.py @@ -184,13 +184,19 @@ class JiraTicketPlugin(TicketPlugin): description = "Uses Jira to hepl manage external tickets." version = jira_plugin.__version__ - author = "Kevin Glisson" + author = "Netflix" author_url = "https://github.com/netflix/dispatch.git" _schema = None def create( - self, title: str, incident_type: str, incident_priority: str, commander: str, reporter: str + self, + incident_id: int, + title: str, + incident_type: str, + incident_priority: str, + commander: str, + reporter: str, ): """Creates a Jira ticket.""" client = JIRA(str(JIRA_API_URL), basic_auth=(JIRA_USERNAME, str(JIRA_PASSWORD))) diff --git a/src/dispatch/plugins/dispatch_pagerduty/plugin.py b/src/dispatch/plugins/dispatch_pagerduty/plugin.py index 7fb35e56434e..83058b6a8a30 100644 --- a/src/dispatch/plugins/dispatch_pagerduty/plugin.py +++ b/src/dispatch/plugins/dispatch_pagerduty/plugin.py @@ -21,6 +21,8 @@ class PagerDutyOncallPlugin(OncallPlugin): title = "PagerDuty Plugin - Oncall Management" slug = "pagerduty-oncall" + author = "Netflix" + author_url = "https://github.com/Netflix/dispatch" description = "Uses PagerDuty to resolve and page oncall teams." version = pagerduty_oncall_plugin.__version__ diff --git a/src/dispatch/plugins/dispatch_pagerduty/service.py b/src/dispatch/plugins/dispatch_pagerduty/service.py index babc64f775be..c1a30f01de30 100644 --- a/src/dispatch/plugins/dispatch_pagerduty/service.py +++ b/src/dispatch/plugins/dispatch_pagerduty/service.py @@ -46,7 +46,7 @@ def get_oncall(service_id: str = None, service_name: str = None): return get_oncall_email(service[0]) - raise DispatchPluginException(f"Cannot fetch oncall. Must specify service_id or service_name.") + raise DispatchPluginException("Cannot fetch oncall. Must specify service_id or service_name.") def page_oncall( diff --git a/src/dispatch/plugins/dispatch_slack/__init__.py b/src/dispatch/plugins/dispatch_slack/__init__.py index 2d0b548e6531..ad5cc752c07b 100644 --- a/src/dispatch/plugins/dispatch_slack/__init__.py +++ b/src/dispatch/plugins/dispatch_slack/__init__.py @@ -1 +1 @@ -from dispatch import __version__ # noqa +from ._version import __version__ # noqa diff --git a/src/dispatch/plugins/dispatch_slack/_version.py b/src/dispatch/plugins/dispatch_slack/_version.py new file mode 100644 index 000000000000..3dc1f76bc69e --- /dev/null +++ b/src/dispatch/plugins/dispatch_slack/_version.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/src/dispatch/plugins/dispatch_slack/config.py b/src/dispatch/plugins/dispatch_slack/config.py index 70d87b59d0b3..f0a4a4767f2c 100644 --- a/src/dispatch/plugins/dispatch_slack/config.py +++ b/src/dispatch/plugins/dispatch_slack/config.py @@ -7,6 +7,9 @@ SLACK_TIMELINE_EVENT_REACTION = config("SLACK_TIMELINE_EVENT_REACTION", default="stopwatch") SLACK_USER_ID_OVERRIDE = config("SLACK_USER_ID_OVERRIDE", default=None) SLACK_WORKSPACE_NAME = config("SLACK_WORKSPACE_NAME") +SLACK_PROFILE_DEPARTMENT_FIELD_ID = config("SLACK_PROFILE_DEPARTMENT_FIELD_ID", default="") +SLACK_PROFILE_TEAM_FIELD_ID = config("SLACK_PROFILE_TEAM_FIELD_ID", default="") +SLACK_PROFILE_WEBLINK_FIELD_ID = config("SLACK_PROFILE_WEBLINK_FIELD_ID", default="") # Slash commands SLACK_COMMAND_MARK_ACTIVE_SLUG = config( @@ -39,3 +42,6 @@ SLACK_COMMAND_LIST_RESOURCES_SLUG = config( "SLACK_COMMAND_LIST_RESOURCES_SLUG", default="/dispatch-list-resources" ) +SLACK_COMMAND_REPORT_INCIDENT_SLUG = config( + "SLACK_COMMAND_REPORT_INCIDENT_SLUG", default="/dispatch-report-incident" +) diff --git a/src/dispatch/plugins/dispatch_slack/messaging.py b/src/dispatch/plugins/dispatch_slack/messaging.py index 0c8a9601cad0..96da25214529 100644 --- a/src/dispatch/plugins/dispatch_slack/messaging.py +++ b/src/dispatch/plugins/dispatch_slack/messaging.py @@ -8,6 +8,7 @@ from typing import List, Optional from jinja2 import Template +from dispatch.incident.enums import IncidentSlackViewBlockId from dispatch.messaging import ( INCIDENT_TASK_LIST_DESCRIPTION, INCIDENT_TASK_REMINDER_DESCRIPTION, @@ -26,6 +27,7 @@ SLACK_COMMAND_MARK_CLOSED_SLUG, SLACK_COMMAND_MARK_STABLE_SLUG, SLACK_COMMAND_STATUS_REPORT_SLUG, + SLACK_COMMAND_REPORT_INCIDENT_SLUG, ) @@ -77,6 +79,10 @@ "response_type": "ephemeral", "text": "Listing all incident resources...", }, + SLACK_COMMAND_REPORT_INCIDENT_SLUG: { + "response_type": "ephemeral", + "text": "Opening a dialog to report an incident...", + }, } INCIDENT_CONVERSATION_NON_INCIDENT_CONVERSATION_COMMAND_ERROR = """ @@ -185,3 +191,124 @@ def slack_preview(message, block=None): print(f"https://api.slack.com/tools/block-kit-builder?blocks={message}") else: print(f"https://api.slack.com/docs/messages/builder?msg={message}") + + +def create_block_option_from_template(text: str, value: str): + """Helper function which generates the option block for modals / views""" + return {"text": {"type": "plain_text", "text": str(text), "emoji": True}, "value": str(value)} + + +def create_modal_content( + channel_id: str = None, incident_types: list = None, incident_priorities: list = None +): + """Helper function which generates the slack modal / view message for (Create / start a new Incident) call""" + incident_type_options = [] + incident_priority_options = [] + + # below fields for incident type and priority are the same + # (label and value) are set from the caller function create_incident_open_modal + # if the value needs to be changed in the future to ID (from name to id) then modify them in the caller function + + for incident_type in incident_types: + incident_type_options.append( + create_block_option_from_template( + text=incident_type.get("label"), value=incident_type.get("value") + ) + ) + + for incident_priority in incident_priorities: + incident_priority_options.append( + create_block_option_from_template( + text=incident_priority.get("label"), value=incident_priority.get("value") + ) + ) + + modal_view_template = { + "type": "modal", + "title": {"type": "plain_text", "text": "Security Incident Report"}, + "blocks": [ + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "If you suspect a security incident and require help from security, " + "please fill out the following to the best of your abilities.", + } + ], + }, + { + "block_id": IncidentSlackViewBlockId.title, + "type": "input", + "label": {"type": "plain_text", "text": "Title"}, + "element": { + "type": "plain_text_input", + "placeholder": { + "type": "plain_text", + "text": "A brief explanatory title. You can change this later.", + }, + }, + }, + { + "block_id": IncidentSlackViewBlockId.description, + "type": "input", + "label": {"type": "plain_text", "text": "Description"}, + "element": { + "type": "plain_text_input", + "placeholder": { + "type": "plain_text", + "text": "A summary of what you know so far. It's all right if this is incomplete.", + }, + "multiline": True, + }, + }, + { + "block_id": IncidentSlackViewBlockId.type, + "type": "input", + "label": {"type": "plain_text", "text": "Type"}, + "element": { + "type": "static_select", + "placeholder": {"type": "plain_text", "text": "Select Incident Type"}, + "options": incident_type_options, + }, + }, + { + "block_id": IncidentSlackViewBlockId.priority, + "type": "input", + "label": {"type": "plain_text", "text": "Priority", "emoji": True}, + "element": { + "type": "static_select", + "placeholder": {"type": "plain_text", "text": "Select Incident Priority"}, + "options": incident_priority_options, + }, + }, + ], + "close": {"type": "plain_text", "text": "Cancel"}, + "submit": {"type": "plain_text", "text": "Submit"}, + "private_metadata": channel_id, + } + + return modal_view_template + + +def create_incident_reported_confirmation_msg( + title: str, incident_type: str, incident_priority: str +): + return [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "This is a confirmation that you have reported a security incident with the following information. You'll get invited to a Slack conversation soon.", + }, + }, + {"type": "section", "text": {"type": "mrkdwn", "text": f"*Incident Title*: {title}"}}, + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"*Incident Type*: {incident_type}"}, + }, + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"*Incident Priority*: {incident_priority}"}, + }, + ] diff --git a/src/dispatch/plugins/dispatch_slack/plugin.py b/src/dispatch/plugins/dispatch_slack/plugin.py index 52fa092a2188..8aec923859e8 100644 --- a/src/dispatch/plugins/dispatch_slack/plugin.py +++ b/src/dispatch/plugins/dispatch_slack/plugin.py @@ -30,6 +30,9 @@ SLACK_COMMAND_MARK_CLOSED_SLUG, SLACK_COMMAND_MARK_STABLE_SLUG, SLACK_COMMAND_STATUS_REPORT_SLUG, + SLACK_PROFILE_DEPARTMENT_FIELD_ID, + SLACK_PROFILE_TEAM_FIELD_ID, + SLACK_PROFILE_WEBLINK_FIELD_ID, ) from .views import router as slack_event_router from .messaging import create_message_blocks @@ -41,7 +44,7 @@ get_user_avatar_url, get_user_email, get_user_info_by_id, - get_user_info_by_email, + get_user_profile_by_email, get_user_username, list_conversation_messages, list_conversations, @@ -51,6 +54,7 @@ send_ephemeral_message, send_message, set_conversation_topic, + open_modal_with_user, ) @@ -79,7 +83,7 @@ class SlackConversationPlugin(ConversationPlugin): version = slack_plugin.__version__ events = slack_event_router - author = "Kevin Glisson" + author = "Netflix" author_url = "https://github.com/netflix/dispatch.git" def __init__(self): @@ -177,6 +181,10 @@ def get_command_name(self, command: str): """Gets the command name.""" return command_mappings.get(command, []) + def open_modal(self, trigger_id: str, modal: dict): + """Opens a modal with a user.""" + return open_modal_with_user(client=self.client, trigger_id=trigger_id, modal=modal) + @apply(counter, exclude=["__init__"]) @apply(timer, exclude=["__init__"]) @@ -186,7 +194,7 @@ class SlackContactPlugin(ContactPlugin): description = "Uses Slack to resolve contact information details." version = slack_plugin.__version__ - author = "Kevin Glisson" + author = "Netflix" author_url = "https://github.com/netflix/dispatch.git" def __init__(self): @@ -194,17 +202,19 @@ def __init__(self): def get(self, email: str): """Fetch user info by email.""" - info = get_user_info_by_email(self.client, email) - profile = info["profile"] + profile = get_user_profile_by_email(self.client, email) return { "fullname": profile["real_name"], "email": profile["email"], - "title": "", - "team": "", - "department": "", - "location": info["tz"], - "weblink": "", + "title": profile["title"], + "team": profile.get("fields", {}).get( + SLACK_PROFILE_TEAM_FIELD_ID, {}).get("value", ""), + "department": profile.get("fields", {}).get( + SLACK_PROFILE_DEPARTMENT_FIELD_ID, {}).get("value", ""), + "location": profile["tz"], + "weblink": profile.get("fields", {}).get( + SLACK_PROFILE_WEBLINK_FIELD_ID, {}).get("value", ""), "thumbnail": profile["image_512"], } @@ -215,7 +225,7 @@ class SlackDocumentPlugin(DocumentPlugin): description = "Uses Slack as a document source." version = slack_plugin.__version__ - author = "Kevin Glisson" + author = "Netflix" author_url = "https://github.com/netflix/dispatch.git" def __init__(self): diff --git a/src/dispatch/plugins/dispatch_slack/service.py b/src/dispatch/plugins/dispatch_slack/service.py index 4fb4d94a8914..b0e645311a35 100644 --- a/src/dispatch/plugins/dispatch_slack/service.py +++ b/src/dispatch/plugins/dispatch_slack/service.py @@ -45,10 +45,10 @@ def resolve_user(client: Any, user_id: str): return {"id": user_id} -def chunks(l, n): +def chunks(ids, n): """Yield successive n-sized chunks from l.""" - for i in range(0, len(l), n): - yield l[i : i + n] + for i in range(0, len(ids), n): + yield ids[i : i + n] def paginated(data_key): @@ -99,7 +99,7 @@ def decorated_function(*args, **kwargs): # NOTE I don't like this but slack client is annoying (kglisson) -SLACK_GET_ENDPOINTS = ["users.lookupByEmail", "users.info", "conversations.history"] +SLACK_GET_ENDPOINTS = ["users.lookupByEmail", "users.info", "conversations.history", "users.profile.get"] @retry(stop=stop_after_attempt(5), retry=retry_if_exception_type(TryAgain)) @@ -184,6 +184,15 @@ def get_user_info_by_email(client: Any, email: str): return make_call(client, "users.lookupByEmail", email=email)["user"] +@functools.lru_cache() +def get_user_profile_by_email(client: Any, email: str): + """Gets extended profile information about a user by email.""" + user = make_call(client, "users.lookupByEmail", email=email)["user"] + profile = make_call(client, "users.profile.get", user=user["id"])["profile"] + profile["tz"] = user["tz"] + return profile + + def get_user_email(client: Any, user_id: str): """Gets the user's email.""" return get_user_info_by_id(client, user_id)["profile"]["email"] @@ -373,3 +382,9 @@ def is_user(slack_user: str): def open_dialog_with_user(client: Any, trigger_id: str, dialog: dict): """Opens a dialog with a user.""" return make_call(client, "dialog.open", trigger_id=trigger_id, dialog=dialog) + + +def open_modal_with_user(client: Any, trigger_id: str, modal: dict): + """Opens a modal with a user.""" + # the argument should be view in the make call, since slack api expects view + return make_call(client, "views.open", trigger_id=trigger_id, view=modal) diff --git a/src/dispatch/plugins/dispatch_slack/views.py b/src/dispatch/plugins/dispatch_slack/views.py index 8e203611af5a..17db75d20f40 100644 --- a/src/dispatch/plugins/dispatch_slack/views.py +++ b/src/dispatch/plugins/dispatch_slack/views.py @@ -7,7 +7,6 @@ import platform import sys -from cachetools import TTLCache from time import time from typing import List @@ -29,7 +28,8 @@ from dispatch.event import service as event_service from dispatch.incident import flows as incident_flows from dispatch.incident import service as incident_service -from dispatch.incident.models import IncidentUpdate, IncidentRead, IncidentStatus +from dispatch.incident.enums import IncidentStatus, NewIncidentSubmission, IncidentSlackViewBlockId +from dispatch.incident.models import IncidentUpdate, IncidentRead from dispatch.incident_priority import service as incident_priority_service from dispatch.incident_type import service as incident_type_service from dispatch.individual import service as individual_service @@ -46,7 +46,6 @@ from . import __version__ from .config import ( - SLACK_API_BOT_TOKEN, SLACK_COMMAND_ASSIGN_ROLE_SLUG, SLACK_COMMAND_ENGAGE_ONCALL_SLUG, SLACK_COMMAND_LIST_PARTICIPANTS_SLUG, @@ -57,18 +56,19 @@ SLACK_COMMAND_MARK_STABLE_SLUG, SLACK_COMMAND_STATUS_REPORT_SLUG, SLACK_COMMAND_UPDATE_INCIDENT_SLUG, + SLACK_COMMAND_REPORT_INCIDENT_SLUG, SLACK_SIGNING_SECRET, SLACK_TIMELINE_EVENT_REACTION, ) from .messaging import ( INCIDENT_CONVERSATION_COMMAND_MESSAGE, + create_incident_reported_confirmation_msg, + create_modal_content, render_non_incident_conversation_command_error_message, ) from .service import get_user_email -once_a_day_cache = TTLCache(maxsize=1000, ttl=60 * 60 * 24) - router = APIRouter() slack_client = dispatch_slack_service.create_slack_client() @@ -155,7 +155,7 @@ def handle_reaction_added_event( event_service.log( db_session=db_session, source=convo_plugin.title, - description=message_text, + description=f'"{message_text}," said {individual.name}', incident_id=incident_id, individual_id=individual.id, started_at=message_ts_utc, @@ -179,12 +179,7 @@ def add_evidence_to_storage(user_email: str, incident_id: int, event: dict = Non def is_business_hours(commander_tz: str): """Determines if it's currently office hours where the incident commander is located.""" now = arrow.utcnow().to(commander_tz) - return now.weekday() not in [5, 6] and now.hour >= 9 and now.hour < 17 - - -def create_cache_key(user_id: str, channel_id: str): - """Uses information in the evenvelope to construct a caching key.""" - return f"{channel_id}-{user_id}" + return now.weekday() not in [5, 6] and 9 <= now.hour < 17 @background_task @@ -192,16 +187,6 @@ def after_hours(user_email: str, incident_id: int, event: dict = None, db_sessio """Notifies the user that this incident is current in after hours mode.""" incident = incident_service.get(db_session=db_session, incident_id=incident_id) - user_id = dispatch_slack_service.resolve_user(slack_client, user_email)["id"] - - # NOTE Limitations: Does not sync across instances. Does not survive webserver restart - cache_key = create_cache_key(user_id, incident.conversation.channel_id) - try: - once_a_day_cache[cache_key] - return - except Exception: - pass # we don't care if there is nothing here - # get their timezone from slack commander_info = dispatch_slack_service.get_user_info_by_email( slack_client, email=incident.commander.email @@ -225,10 +210,18 @@ def after_hours(user_email: str, incident_id: int, event: dict = None, db_sessio }, } ] - dispatch_slack_service.send_ephemeral_message( - slack_client, incident.conversation.channel_id, user_id, "", blocks=blocks + + participant = participant_service.get_by_incident_id_and_email( + incident_id=incident_id, email=user_email ) - once_a_day_cache[cache_key] = True + if not participant.after_hours_notification: + user_id = dispatch_slack_service.resolve_user(slack_client, user_email)["id"] + dispatch_slack_service.send_ephemeral_message( + slack_client, incident.conversation.channel_id, user_id, "", blocks=blocks + ) + participant.after_hours_notification = True + db_session.add(participant) + db_session.commit() @background_task @@ -276,7 +269,7 @@ def list_participants(incident_id: int, command: dict = None, db_session=None): """Returns the list of incident participants to the user as an ephemeral message.""" blocks = [] blocks.append( - {"type": "section", "text": {"type": "mrkdwn", "text": f"*Incident Participants*"}} + {"type": "section", "text": {"type": "mrkdwn", "text": "*Incident Participants*"}} ) participants = participant_service.get_all_by_incident_id( @@ -446,7 +439,7 @@ def create_engage_oncall_dialog(incident_id: int, command: dict = None, db_sessi "type": "section", "text": { "type": "mrkdwn", - "text": f"No oncall services have been defined. You can define them in the Dispatch UI at /services", + "text": "No oncall services have been defined. You can define them in the Dispatch UI at /services", }, } ] @@ -544,7 +537,7 @@ def event_functions(event: EventEnvelope): "file_shared": [add_evidence_to_storage], "link_shared": [], "member_joined_channel": [incident_flows.incident_add_or_reactivate_participant_flow], - "message": [after_hours], + "message": [], "member_left_channel": [incident_flows.incident_remove_participant_flow], "message.groups": [], "message.im": [], @@ -610,6 +603,7 @@ def action_functions(action: str): SLACK_COMMAND_UPDATE_INCIDENT_SLUG: [handle_update_incident_action], SLACK_COMMAND_ENGAGE_ONCALL_SLUG: [incident_flows.incident_engage_oncall_flow], ConversationButtonActions.invite_user: [add_user_to_conversation], + NewIncidentSubmission.form_slack_view: [report_incident_from_submitted_form], } # this allows for unique action blocks e.g. invite-user or invite-user-1, etc @@ -628,6 +622,10 @@ def get_action_name_by_action_type(action: dict): if action["type"] == "block_actions": action_name = action["actions"][0]["block_id"] + # TODO: maybe use callback info in the future to differentiate action types + if action["type"] == "view_submission": + action_name = NewIncidentSubmission.form_slack_view + return action_name @@ -692,6 +690,105 @@ def get_channel_id(event_body: dict): return channel_id +@background_task +def create_report_incident_modal(command: dict = None, db_session=None): + """ + Prepare the Modal / View x + Ask slack to open a modal with the prepared Modal / View content + """ + channel_id = command.get("channel_id") + trigger_id = command.get("trigger_id") + + type_options = [] + for t in incident_type_service.get_all(db_session=db_session): + type_options.append({"label": t.name, "value": t.name}) + + priority_options = [] + for priority in incident_priority_service.get_all(db_session=db_session): + priority_options.append({"label": priority.name, "value": priority.name}) + + modal_view_template = create_modal_content( + channel_id=channel_id, incident_types=type_options, incident_priorities=priority_options + ) + + dispatch_slack_service.open_modal_with_user( + client=slack_client, trigger_id=trigger_id, modal=modal_view_template + ) + + +def parse_submitted_form(view_data: dict): + """Parse the submitted data and return important / required fields for Dispatch to create an incident.""" + parsed_data = {} + state_elem = view_data.get("state") + state_values = state_elem.get("values") + for state in state_values: + state_key_value_pair = state_values[state] + + for elem_key in state_key_value_pair: + elem_key_value_pair = state_values[state][elem_key] + + if elem_key_value_pair.get("selected_option") and elem_key_value_pair.get( + "selected_option" + ).get("value"): + parsed_data[state] = { + "name": elem_key_value_pair.get("selected_option").get("text").get("text"), + "value": elem_key_value_pair.get("selected_option").get("value"), + } + else: + parsed_data[state] = elem_key_value_pair.get("value") + + return parsed_data + + +@background_task +def report_incident_from_submitted_form( + user_id: str, + user_email: str, + incident_id: int, + action: dict, + db_session: Session = Depends(get_db), +): + submitted_form = action.get("view") + + # Fetch channel id from private metadata field + channel_id = submitted_form.get("private_metadata") + + parsed_form_data = parse_submitted_form(submitted_form) + + requested_form_title = parsed_form_data.get(IncidentSlackViewBlockId.title) + requested_form_description = parsed_form_data.get(IncidentSlackViewBlockId.description) + requested_form_incident_type = parsed_form_data.get(IncidentSlackViewBlockId.type) + requested_form_incident_priority = parsed_form_data.get(IncidentSlackViewBlockId.priority) + + # send an incident report confirmation to the user + msg_template = create_incident_reported_confirmation_msg( + title=requested_form_title, + incident_type=requested_form_incident_type.get("value"), + incident_priority=requested_form_incident_priority.get("value"), + ) + + dispatch_slack_service.send_ephemeral_message( + client=slack_client, + conversation_id=channel_id, + user_id=user_id, + text="", + blocks=msg_template, + ) + + # create the incident + incident = incident_service.create( + db_session=db_session, + title=requested_form_title, + status=IncidentStatus.active, + description=requested_form_description, + incident_type=requested_form_incident_type, + incident_priority=requested_form_incident_priority, + reporter_email=user_email, + ) + + incident_flows.incident_create_flow(incident_id=incident.id) + + @router.post("/slack/event") async def handle_event( event: EventEnvelope, @@ -767,20 +864,30 @@ async def handle_command( # We add the user-agent string to the response headers response.headers["X-Slack-Powered-By"] = create_ua_string() - # Fetch conversation by channel id - channel_id = command.get("channel_id") - conversation = get_by_channel_id(db_session=db_session, channel_id=channel_id) - - # Dispatch command functions to be executed in the background - if conversation: - for f in command_functions(command.get("command")): - background_tasks.add_task(f, conversation.incident_id, command=command) + # If the incoming slash command is equal to reporting new incident slug + if command.get("command") == SLACK_COMMAND_REPORT_INCIDENT_SLUG: + background_tasks.add_task( + func=create_report_incident_modal, db_session=db_session, command=command + ) return INCIDENT_CONVERSATION_COMMAND_MESSAGE.get( command.get("command"), f"Unable to find message. Command: {command.get('command')}" ) else: - return render_non_incident_conversation_command_error_message(command.get("command")) + # Fetch conversation by channel id + channel_id = command.get("channel_id") + conversation = get_by_channel_id(db_session=db_session, channel_id=channel_id) + + # Dispatch command functions to be executed in the background + if conversation: + for f in command_functions(command.get("command")): + background_tasks.add_task(f, conversation.incident_id, command=command) + + return INCIDENT_CONVERSATION_COMMAND_MESSAGE.get( + command.get("command"), f"Unable to find message. Command: {command.get('command')}" + ) + else: + return render_non_incident_conversation_command_error_message(command.get("command")) @router.post("/slack/action") @@ -793,7 +900,6 @@ async def handle_action( db_session: Session = Depends(get_db), ): """Handle all incomming Slack actions.""" - raw_request_body = bytes.decode(await request.body()) request_body_form = await request.form() action = json.loads(request_body_form.get("payload")) @@ -814,8 +920,12 @@ async def handle_action( # We resolve the action name based on the type action_name = get_action_name_by_action_type(action) - # we resolve the incident id based on the action type - incident_id = get_incident_id_by_action_type(action, db_session) + # if the request was made as a form submission from slack then we skip getting the incident_id + # the incident will be created in in the next step + incident_id = 0 + if action_name != NewIncidentSubmission.form_slack_view: + # we resolve the incident id based on the action type + incident_id = get_incident_id_by_action_type(action, db_session) # Dispatch action functions to be executed in the background for f in action_functions(action_name): @@ -825,5 +935,10 @@ async def handle_action( response.headers["X-Slack-Powered-By"] = create_ua_string() # When there are no exceptions within the dialog submission, your app must respond with 200 OK with an empty body. - # This will complete the dialog. (https://api.slack.com/dialogs#validation) - return {} + response_body = {} + if action_name == NewIncidentSubmission.form_slack_view: + # For modals we set "response_action" to "clear" to close all views in the modal. + # An empty body is currently not working. + response_body = {"response_action": "clear"} + + return response_body diff --git a/src/dispatch/plugins/dispatch_zoom/plugin.py b/src/dispatch/plugins/dispatch_zoom/plugin.py index 2957d3532599..c47d2c7c19e2 100644 --- a/src/dispatch/plugins/dispatch_zoom/plugin.py +++ b/src/dispatch/plugins/dispatch_zoom/plugin.py @@ -10,6 +10,7 @@ from typing import List from dispatch.decorators import apply, counter, timer +from dispatch.plugins import dispatch_zoom as zoom_plugin from dispatch.plugins.bases import ConferencePlugin from .config import ZOOM_API_USER_ID, ZOOM_API_KEY, ZOOM_API_SECRET @@ -55,6 +56,10 @@ class ZoomConferencePlugin(ConferencePlugin): title = "Zoom Plugin - Conference Management" slug = "zoom-conference" description = "Uses Zoom to manage conference meetings." + version = zoom_plugin.__version__ + + author = "HashiCorp" + author_url = "https://github.com/netflix/dispatch.git" def create( self, name: str, description: str = None, title: str = None, participants: List[str] = [] diff --git a/src/dispatch/route/flows.py b/src/dispatch/route/flows.py deleted file mode 100644 index b136b18c36af..000000000000 --- a/src/dispatch/route/flows.py +++ /dev/null @@ -1,48 +0,0 @@ -""" -.. module: dispatch.task.flows - :platform: Unix - :copyright: (c) 2019 by Netflix Inc., see AUTHORS for more - :license: Apache, see LICENSE for more details. - -.. moduleauthor:: Kevin Glisson -""" -import logging -from collections import defaultdict -from datetime import datetime - -from dispatch.config import INCIDENT_PLUGIN_EMAIL_SLUG -from dispatch.messaging import INCIDENT_TASK_REMINDER -from dispatch.plugins.base import plugins - -log = logging.getLogger(__name__) - - -def group_tasks_by_assignee(tasks): - """Groups tasks by assignee.""" - grouped = defaultdict(lambda: []) - for task in tasks: - grouped[task.assignees].append(task) - return grouped - - -def create_reminder(db_session, assignee, tasks): - """Contains the logic for incident task reminders.""" - # send email - email_plugin = plugins.get(INCIDENT_PLUGIN_EMAIL_SLUG) - message_template = INCIDENT_TASK_REMINDER - - notification_type = "incident-task-reminder" - email_plugin.send( - assignee, message_template, notification_type, name="Task Reminder", items=tasks - ) - - # We currently think DM's might be too agressive - # send slack - # convo_plugin = plugins.get(INCIDENT_PLUGIN_CONVERSATION_SLUG) - # convo_plugin.send_direct( - # assignee, notification_text, message_template, notification_type, items=tasks - # ) - - for task in tasks: - task.last_reminder_at = datetime.utcnow() - db_session.commit() diff --git a/src/dispatch/route/models.py b/src/dispatch/route/models.py index 7342cf1063a6..61c97fc158ea 100644 --- a/src/dispatch/route/models.py +++ b/src/dispatch/route/models.py @@ -12,8 +12,8 @@ IndividualReadNested, ServiceReadNested, TeamReadNested, - DocumentRead, ) +from dispatch.document.models import DocumentRead from dispatch.term.models import TermRead recommendation_documents = Table( diff --git a/src/dispatch/route/service.py b/src/dispatch/route/service.py index 0c2eb82dff69..c74ecce2ef4c 100644 --- a/src/dispatch/route/service.py +++ b/src/dispatch/route/service.py @@ -1,10 +1,9 @@ import logging from typing import Any, Dict, List -import spacy -from spacy.matcher import PhraseMatcher from sqlalchemy import func +from dispatch.nlp import build_phrase_matcher, build_term_vocab, extract_terms_from_text from dispatch.incident_priority import service as incident_priority_service from dispatch.incident_priority.models import IncidentPriority from dispatch.incident_type import service as incident_type_service @@ -15,74 +14,13 @@ log = logging.getLogger(__name__) -nlp = spacy.blank("en") -nlp.vocab.lex_attr_getters = {} - - -# NOTE -# This is kinda slow so we might cheat and just build this -# periodically or cache it -def build_term_vocab(terms: List[Term]): - """Builds nlp vocabulary.""" - # We need to build four sets of vocabulary - # such that we can more accurately match - # - # - No change - # - Lower - # - Upper - # - Title - # - # We may also normalize the document itself at some point - # but it unclear how this will affect the things like - # Parts-of-speech (POS) analysis. - for v in terms: - texts = [v.text, v.text.lower(), v.text.upper(), v.text.title()] - for t in texts: - if t: # guard against `None` - phrase = nlp.tokenizer(t) - for w in phrase: - _ = nlp.tokenizer.vocab[w.text] - yield phrase - - -def build_phrase_matcher(phrases: List[str]) -> PhraseMatcher: - """Builds a PhraseMatcher object.""" - matcher = PhraseMatcher(nlp.tokenizer.vocab) - matcher.add("NFLX", None, *phrases) # TODO customize - return matcher - - -def extract_terms_from_document( - document: str, phrases: List[str], matcher: PhraseMatcher -) -> List[str]: - """Extracts key terms out of documents.""" - terms = [] - doc = nlp.tokenizer(document) - for w in doc: - _ = doc.vocab[ - w.text.lower() - ] # We normalize our docs so that vocab doesn't take so long to build. - - matches = matcher(doc) - for _, start, end in matches: - token = doc[start:end].merge() - - # We try to filter out common stop words unless - # we have surrounding context that would suggest they are not stop words. - if token.is_stop: - continue - - terms.append(token.text) - - return terms - def get_terms(db_session, text: str) -> List[str]: """Get terms from request.""" all_terms = db_session.query(Term).all() - phrases = build_term_vocab(all_terms) - matcher = build_phrase_matcher(phrases) - extracted_terms = extract_terms_from_document(text, phrases, matcher) + phrases = build_term_vocab([t.text for t in all_terms]) + matcher = build_phrase_matcher("dispatch-terms", phrases) + extracted_terms = extract_terms_from_text(text, matcher) return extracted_terms @@ -202,7 +140,7 @@ def get_resources_from_terms(db_session, terms: List[str]): return matched_terms, resources -# TODO ontacts could be List[Union(...)] +# TODO contacts could be List[Union(...)] def create_recommendation( *, db_session, text=str, context: ContextBase, matched_terms: List[Term], resources: List[Any] ): diff --git a/src/dispatch/static/dispatch/package-lock.json b/src/dispatch/static/dispatch/package-lock.json index ef6d7963fe1f..092a2dfb81d0 100644 --- a/src/dispatch/static/dispatch/package-lock.json +++ b/src/dispatch/static/dispatch/package-lock.json @@ -1046,9 +1046,9 @@ } }, "@mdi/font": { - "version": "5.0.45", - "resolved": "https://registry.npmjs.org/@mdi/font/-/font-5.0.45.tgz", - "integrity": "sha512-3sOO/UMyQqrmizW5zjvhKJP4FZFO7LblRw4G0alDxK1ekUwNxNXI7hYv8ACAv8H44riBuqlVJ7u39XDqv/Xwuw==", + "version": "5.1.45", + "resolved": "https://registry.npmjs.org/@mdi/font/-/font-5.1.45.tgz", + "integrity": "sha512-7H1UMwUpEp8mthdPlpAi7bhEyvTbvtK1TlA89scc0cXMpQy0UFygdkaf+6fveIxpBcRNgw0gnGSEonlsfYocXg==", "dev": true }, "@mrmlnc/readdir-enhanced": { @@ -1081,69 +1081,69 @@ } }, "@sentry/browser": { - "version": "5.15.4", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-5.15.4.tgz", - "integrity": "sha512-l/auT1HtZM3KxjCGQHYO/K51ygnlcuOrM+7Ga8gUUbU9ZXDYw6jRi0+Af9aqXKmdDw1naNxr7OCSy6NBrLWVZw==", + "version": "5.15.5", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-5.15.5.tgz", + "integrity": "sha512-rqDvjk/EvogfdbZ4TiEpxM/lwpPKmq23z9YKEO4q81+1SwJNua53H60dOk9HpRU8nOJ1g84TMKT2Ov8H7sqDWA==", "requires": { - "@sentry/core": "5.15.4", - "@sentry/types": "5.15.4", - "@sentry/utils": "5.15.4", + "@sentry/core": "5.15.5", + "@sentry/types": "5.15.5", + "@sentry/utils": "5.15.5", "tslib": "^1.9.3" } }, "@sentry/core": { - "version": "5.15.4", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-5.15.4.tgz", - "integrity": "sha512-9KP4NM4SqfV5NixpvAymC7Nvp36Zj4dU2fowmxiq7OIbzTxGXDhwuN/t0Uh8xiqlkpkQqSECZ1OjSFXrBldetQ==", - "requires": { - "@sentry/hub": "5.15.4", - "@sentry/minimal": "5.15.4", - "@sentry/types": "5.15.4", - "@sentry/utils": "5.15.4", + "version": "5.15.5", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-5.15.5.tgz", + "integrity": "sha512-enxBLv5eibBMqcWyr+vApqeix8uqkfn0iGsD3piKvoMXCgKsrfMwlb/qo9Ox0lKr71qIlZVt+9/A2vZohdgnlg==", + "requires": { + "@sentry/hub": "5.15.5", + "@sentry/minimal": "5.15.5", + "@sentry/types": "5.15.5", + "@sentry/utils": "5.15.5", "tslib": "^1.9.3" } }, "@sentry/hub": { - "version": "5.15.4", - "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-5.15.4.tgz", - "integrity": "sha512-1XJ1SVqadkbUT4zLS0TVIVl99si7oHizLmghR8LMFl5wOkGEgehHSoOydQkIAX2C7sJmaF5TZ47ORBHgkqclUg==", + "version": "5.15.5", + "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-5.15.5.tgz", + "integrity": "sha512-zX9o49PcNIVMA4BZHe//GkbQ4Jx+nVofqU/Il32/IbwKhcpPlhGX3c1sOVQo4uag3cqd/JuQsk+DML9TKkN0Lw==", "requires": { - "@sentry/types": "5.15.4", - "@sentry/utils": "5.15.4", + "@sentry/types": "5.15.5", + "@sentry/utils": "5.15.5", "tslib": "^1.9.3" } }, "@sentry/integrations": { - "version": "5.15.4", - "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-5.15.4.tgz", - "integrity": "sha512-GaEVQf4R+WBJvTOGptOHIFSylnH1JAvBQZ7c45jGIDBp+upqzeI67KD+HoM4sSNT2Y2i8DLTJCWibe34knz5Kw==", + "version": "5.15.5", + "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-5.15.5.tgz", + "integrity": "sha512-s9N9altnGkDH+vNNUZu1dKuMVLAgJNYtgs6DMJTrZRswFl8gzZytYTZCdpzjBgTsqkLaGbRDIjQeE/yP3gnrqw==", "requires": { - "@sentry/types": "5.15.4", - "@sentry/utils": "5.15.4", + "@sentry/types": "5.15.5", + "@sentry/utils": "5.15.5", "tslib": "^1.9.3" } }, "@sentry/minimal": { - "version": "5.15.4", - "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-5.15.4.tgz", - "integrity": "sha512-GL4GZ3drS9ge+wmxkHBAMEwulaE7DMvAEfKQPDAjg2p3MfcCMhAYfuY4jJByAC9rg9OwBGGehz7UmhWMFjE0tw==", + "version": "5.15.5", + "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-5.15.5.tgz", + "integrity": "sha512-zQkkJ1l9AjmU/Us5IrOTzu7bic4sTPKCatptXvLSTfyKW7N6K9MPIIFeSpZf9o1yM2sRYdK7GV08wS2eCT3JYw==", "requires": { - "@sentry/hub": "5.15.4", - "@sentry/types": "5.15.4", + "@sentry/hub": "5.15.5", + "@sentry/types": "5.15.5", "tslib": "^1.9.3" } }, "@sentry/types": { - "version": "5.15.4", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-5.15.4.tgz", - "integrity": "sha512-quPHPpeAuwID48HLPmqBiyXE3xEiZLZ5D3CEbU3c3YuvvAg8qmfOOTI6z4Z3Eedi7flvYpnx3n7N3dXIEz30Eg==" + "version": "5.15.5", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-5.15.5.tgz", + "integrity": "sha512-F9A5W7ucgQLJUG4LXw1ZIy4iLevrYZzbeZ7GJ09aMlmXH9PqGThm1t5LSZlVpZvUfQ2rYA8NU6BdKJSt7B5LPw==" }, "@sentry/utils": { - "version": "5.15.4", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-5.15.4.tgz", - "integrity": "sha512-lO8SLBjrUDGADl0LOkd55R5oL510d/1SaI08/IBHZCxCUwI4TiYo5EPECq8mrj3XGfgCyq9osw33bymRlIDuSQ==", + "version": "5.15.5", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-5.15.5.tgz", + "integrity": "sha512-Nl9gl/MGnzSkuKeo3QaefoD/OJrFLB8HmwQ7HUbTXb6E7yyEzNKAQMHXGkwNAjbdYyYbd42iABP6Y5F/h39NtA==", "requires": { - "@sentry/types": "5.15.4", + "@sentry/types": "5.15.5", "tslib": "^1.9.3" } }, @@ -2184,9 +2184,9 @@ } }, "apexcharts": { - "version": "3.18.1", - "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.18.1.tgz", - "integrity": "sha512-xBhuEegV8RK1q3UVC/jezdN/bwTvCAcmjuOu+UutO+pFdM9qy6RifB4jKU/8Ek7ZPucmnDRDI2YJ0iXTKbzzYg==", + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.19.0.tgz", + "integrity": "sha512-fzupCGVDvOoU6kEzguLAfgRgrlHynHM5fnkkyCL85tYf9U8bw1hCijs4A+kWXurC/SNytJrArBc21kA/2wuHYg==", "requires": { "svg.draggable.js": "^2.2.2", "svg.easing.js": "^2.0.0", @@ -4041,9 +4041,9 @@ } }, "date-fns": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.11.1.tgz", - "integrity": "sha512-3RdUoinZ43URd2MJcquzBbDQo+J87cSzB8NkXdZiN5ia1UNyep0oCyitfiL88+R7clGTeq/RniXAc16gWyAu1w==" + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.12.0.tgz", + "integrity": "sha512-qJgn99xxKnFgB1qL4jpxU7Q2t0LOn1p8KMIveef3UZD7kqjT3tpFNNdXJelEHhE+rUgffriXriw/sOSU+cS1Hw==" }, "de-indent": { "version": "1.0.2", @@ -4813,9 +4813,9 @@ } }, "eslint-config-prettier": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.10.1.tgz", - "integrity": "sha512-svTy6zh1ecQojvpbJSgH3aei/Rt7C6i090l5f2WQ4aB05lYHeZIR1qL4wZyyILTbtmnbHP5Yn8MrsOJMGa8RkQ==", + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.11.0.tgz", + "integrity": "sha512-oB8cpLWSAjOVFEJhhyMZh6NOEOtBVziaqdDQ86+qhDHFbZXoRTM7pNSvFRfW/W/L/LrQ38C99J5CGuRBBzBsdA==", "dev": true, "requires": { "get-stdin": "^6.0.0" @@ -4835,9 +4835,9 @@ } }, "eslint-plugin-prettier": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.2.tgz", - "integrity": "sha512-GlolCC9y3XZfv3RQfwGew7NnuFDKsfI4lbvRK+PIIo23SFH+LemGs4cKwzAaRa+Mdb+lQO/STaIayno8T5sJJA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.3.tgz", + "integrity": "sha512-+HG5jmu/dN3ZV3T6eCD7a4BlAySdN7mLIbJYo0z1cFQuI+r2DiTJEFeF68ots93PsnrMxbzIZ2S/ieX+mkrBeQ==", "dev": true, "requires": { "prettier-linter-helpers": "^1.0.0" @@ -7275,9 +7275,9 @@ } }, "jquery": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.4.1.tgz", - "integrity": "sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.0.tgz", + "integrity": "sha512-Xb7SVYMvygPxbFMpTFQiHh1J7HClEaThguL15N/Gg37Lri/qKyhRGZYzHRyLH8Stq3Aow0LsHO2O2ci86fCrNQ==", "dev": true }, "js-message": { @@ -8322,11 +8322,6 @@ "path-key": "^2.0.0" } }, - "nprogress": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", - "integrity": "sha1-y480xTIT2JVyP8urkH6UIq28r7E=" - }, "nth-check": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", @@ -10529,9 +10524,9 @@ "dev": true }, "sass": { - "version": "1.26.3", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.26.3.tgz", - "integrity": "sha512-5NMHI1+YFYw4sN3yfKjpLuV9B5l7MqQ6FlkTcC4FT+oHbBRUZoSjHrrt/mE0nFXJyY2kQtU9ou9HxvFVjLFuuw==", + "version": "1.26.5", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.26.5.tgz", + "integrity": "sha512-FG2swzaZUiX53YzZSjSakzvGtlds0lcbF+URuU9mxOv7WBh7NhXEVDa4kPKN4hN6fC2TkOTOKqiqp6d53N9X5Q==", "dev": true, "requires": { "chokidar": ">=2.0.0 <4.0.0" @@ -11935,8 +11930,7 @@ "typescript": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.8.3.tgz", - "integrity": "sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w==", - "dev": true + "integrity": "sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w==" }, "uglify-js": { "version": "3.4.10", @@ -12238,9 +12232,9 @@ "dev": true }, "vee-validate": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/vee-validate/-/vee-validate-3.2.5.tgz", - "integrity": "sha512-qUgx4fcD077aNYuaRmK5qZ6G/qRHI0igC5tvGP1IRtvkScOyhCHuZwCcto4VPy5Cip0yAOqrbFudD9JOevwZhw==" + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/vee-validate/-/vee-validate-3.3.0.tgz", + "integrity": "sha512-+QQZgA0I9ZTDsYNOSFlUqOvGIqW4yxjloxQCC6TD0rPn407G9hifn6RnId8kzl6+zHfl3/dE+bko49mYzgNNGg==" }, "vendors": { "version": "1.0.4", @@ -12414,9 +12408,9 @@ } }, "vuex": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/vuex/-/vuex-3.1.3.tgz", - "integrity": "sha512-k8vZqNMSNMgKelVZAPYw5MNb2xWSmVgCKtYKAptvm9YtZiOXnRXFWu//Y9zQNORTrm3dNj1n/WaZZI26tIX6Mw==" + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vuex/-/vuex-3.2.0.tgz", + "integrity": "sha512-qBZGJJ1gUNWZbfZlH7ylIPwENg3R0Ckpq+qPexF065kOMOt1Ixt8WDJmtssVv7OhepWD0+Qie7pOS8f0oQy1JA==" }, "vuex-map-fields": { "version": "1.4.0", diff --git a/src/dispatch/static/dispatch/package.json b/src/dispatch/static/dispatch/package.json index 0afa9c8a2a17..bd0339c25d1b 100644 --- a/src/dispatch/static/dispatch/package.json +++ b/src/dispatch/static/dispatch/package.json @@ -9,31 +9,32 @@ }, "dependencies": { "@openid/appauth": "^1.2.7", - "@sentry/browser": "^5.15.4", - "@sentry/integrations": "^5.15.4", - "apexcharts": "^3.18.1", + "@sentry/browser": "^5.15.5", + "@sentry/integrations": "^5.15.5", + "apexcharts": "^3.19.0", "axios": "^0.19.2", - "date-fns": "^2.11.1", + "date-fns": "^2.12.0", "font-awesome": "^4.7.0", "good-env": "^5.1.2", "lodash.truncate": "^4.4.2", "moment": "^2.24.0", - "nprogress": "^0.2.0", "quill": "^1.3.7", "register-service-worker": "^1.7.1", "roboto-fontface": "*", - "vee-validate": "^3.2.5", + "typescript": "^3.8.3", + "vee-validate": "^3.3.0", "vue": "^2.6.11", "vue-apexcharts": "^1.5.3", "vue-perfect-scrollbar": "^0.2.1", "vue-router": "^3.1.6", "vuetify": "2.2.20", - "vuex": "^3.1.3", + "vuex": "^3.2.0", "vuex-map-fields": "^1.4.0", "vuex-persist": "^2.2.0" }, "devDependencies": { - "@mdi/font": "^5.0.45", + "@mdi/font": "^5.1.45", + "@typescript-eslint/parser": "^1.13.0", "@vue/cli-plugin-babel": "^4.3.1", "@vue/cli-plugin-eslint": "^4.3.1", "@vue/cli-plugin-pwa": "^4.3.1", @@ -41,14 +42,14 @@ "@vue/eslint-config-prettier": "^6.0.0", "babel-eslint": "^10.1.0", "eslint": "^6.8.0", - "eslint-config-prettier": "^6.10.1", - "eslint-plugin-prettier": "^3.1.2", + "eslint-config-prettier": "^6.11.0", + "eslint-plugin-prettier": "^3.1.3", "eslint-plugin-vue": "^6.2.2", "eslint-plugin-vuetify": "^1.0.0-beta.6", - "jquery": "^3.4.1", + "jquery": "^3.5.0", "jwt-decode": "^2.2.0", "prettier-eslint": "^9.0.1", - "sass": "^1.26.3", + "sass": "^1.26.5", "sass-loader": "^8.0.2", "stylus": "^0.54.7", "stylus-loader": "^3.0.2", diff --git a/src/dispatch/static/dispatch/src/api.js b/src/dispatch/static/dispatch/src/api.js index 477a5549c25f..039ba2eed34a 100644 --- a/src/dispatch/static/dispatch/src/api.js +++ b/src/dispatch/static/dispatch/src/api.js @@ -1,14 +1,16 @@ import axios from "axios" - import store from "@/store" +import router from "./router" const instance = axios.create({ baseURL: "/api/v1" }) +const authProviderSlug = process.env.VUE_APP_DISPATCH_AUTHENTICATION_PROVIDER_SLUG + instance.interceptors.request.use( config => { - let token = store.state.account.accessToken + let token = store.state.auth.accessToken if (token) { config.headers["Authorization"] = `Bearer ${token}` } @@ -20,4 +22,31 @@ instance.interceptors.request.use( } ) +instance.interceptors.response.use( + function(res) { + return res + }, + function(err) { + // TODO account for other auth providers + + if (err.response.status == 401) { + if (authProviderSlug === "dispatch-auth-provider-basic") { + router.push({ path: "/login" }) + } + } + if (err.response.status == 500) { + store.commit( + "app/SET_SNACKBAR", + { + text: + "Something has gone very wrong, please retry or let your admin know that you recieved this error.", + color: "red" + }, + { root: true } + ) + } + return Promise.reject(err) + } +) + export default instance diff --git a/src/dispatch/static/dispatch/src/api/menu.js b/src/dispatch/static/dispatch/src/api/menu.js index e0770a1541b7..bb6ce3c76ff0 100644 --- a/src/dispatch/static/dispatch/src/api/menu.js +++ b/src/dispatch/static/dispatch/src/api/menu.js @@ -101,6 +101,20 @@ const Menu = [ name: "Incident Priorities", icon: "report", href: "/incidents/priorities" + }, + { + title: "Plugins", + group: "Configuration", + name: "Plugins", + icon: "power", + href: "/plugins" + }, + { + title: "Users", + group: "Configuration", + name: "Users", + icon: "account_box", + href: "/users" } ] // reorder menu diff --git a/src/dispatch/static/dispatch/src/auth/Login.vue b/src/dispatch/static/dispatch/src/auth/Login.vue new file mode 100644 index 000000000000..4fa2090e85aa --- /dev/null +++ b/src/dispatch/static/dispatch/src/auth/Login.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/src/dispatch/static/dispatch/src/auth/Register.vue b/src/dispatch/static/dispatch/src/auth/Register.vue new file mode 100644 index 000000000000..b8a8451cd416 --- /dev/null +++ b/src/dispatch/static/dispatch/src/auth/Register.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/src/dispatch/static/dispatch/src/auth/Table.vue b/src/dispatch/static/dispatch/src/auth/Table.vue new file mode 100644 index 000000000000..0b962a86e3ef --- /dev/null +++ b/src/dispatch/static/dispatch/src/auth/Table.vue @@ -0,0 +1,86 @@ + + + diff --git a/src/dispatch/static/dispatch/src/auth/api.js b/src/dispatch/static/dispatch/src/auth/api.js index ec5c75b12e2c..7616b113707b 100644 --- a/src/dispatch/static/dispatch/src/auth/api.js +++ b/src/dispatch/static/dispatch/src/auth/api.js @@ -1,9 +1,24 @@ import API from "@/api" -const resource = "/auth" +const resource = "/user" export default { + getAll(options) { + return API.get(`${resource}/`, { params: { ...options } }) + }, + get(userId) { + return API.get(`${resource}/${userId}`) + }, + update(userId, payload) { + return API.put(`${resource}/${userId}`, payload) + }, getUserInfo() { - return API.get(`${resource}/userinfo`) + return API.get(`${resource}/me`) + }, + login(email, password) { + return API.post(`/auth/login`, { email: email, password: password }) + }, + register(email, password) { + return API.post(`/auth/register`, { email: email, password: password }) } } diff --git a/src/dispatch/static/dispatch/src/auth/editSheet.vue b/src/dispatch/static/dispatch/src/auth/editSheet.vue new file mode 100644 index 000000000000..0e927c9953bb --- /dev/null +++ b/src/dispatch/static/dispatch/src/auth/editSheet.vue @@ -0,0 +1,69 @@ + + + diff --git a/src/dispatch/static/dispatch/src/auth/store.js b/src/dispatch/static/dispatch/src/auth/store.js index 8668bf4fb330..9001b0fbbd56 100644 --- a/src/dispatch/static/dispatch/src/auth/store.js +++ b/src/dispatch/static/dispatch/src/auth/store.js @@ -1,19 +1,142 @@ import jwt_decode from "jwt-decode" import router from "@/router/index" import { differenceInMilliseconds, fromUnixTime, subMinutes } from "date-fns" +import { getField, updateField } from "vuex-map-fields" +import { debounce } from "lodash" +import UserApi from "./api" + +const getDefaultSelectedState = () => { + return { + id: null, + email: null, + role: null, + loading: false + } +} const state = { status: { loggedIn: false }, userInfo: { email: "" }, - accessToken: null + accessToken: null, + selected: { + ...getDefaultSelectedState() + }, + dialogs: { + showEdit: false + }, + table: { + rows: { + items: [], + total: null + }, + options: { + q: "", + page: 1, + itemsPerPage: 10, + sortBy: ["email"], + descending: [true] + }, + loading: false + } } const actions = { + getAll: debounce(({ commit, state }) => { + commit("SET_TABLE_LOADING", true) + return UserApi.getAll(state.table.options).then(response => { + commit("SET_TABLE_LOADING", false) + commit("SET_TABLE_ROWS", response.data) + }) + }, 200), + editShow({ commit }, plugin) { + commit("SET_DIALOG_EDIT", true) + if (plugin) { + commit("SET_SELECTED", plugin) + } + }, + closeEdit({ commit }) { + commit("SET_DIALOG_EDIT", false) + commit("RESET_SELECTED") + }, + save({ commit, dispatch }) { + if (!state.selected.id) { + return UserApi.create(state.selected) + .then(() => { + dispatch("closeEdit") + dispatch("getAll") + commit("app/SET_SNACKBAR", { text: "User created successfully." }, { root: true }) + }) + .catch(err => { + commit( + "app/SET_SNACKBAR", + { + text: "User not created. Reason: " + err.response.data.detail, + color: "red" + }, + { root: true } + ) + }) + } else { + return UserApi.update(state.selected.id, state.selected) + .then(() => { + dispatch("closeEdit") + dispatch("getAll") + commit("app/SET_SNACKBAR", { text: "User updated successfully." }, { root: true }) + }) + .catch(err => { + commit( + "app/SET_SNACKBAR", + { + text: "User not updated. Reason: " + err.response.data.detail, + color: "red" + }, + { root: true } + ) + }) + } + }, + remove({ commit, dispatch }) { + return UserApi.delete(state.selected.id) + .then(function() { + dispatch("closeRemove") + dispatch("getAll") + commit("app/SET_SNACKBAR", { text: "User deleted successfully." }, { root: true }) + }) + .catch(err => { + commit( + "app/SET_SNACKBAR", + { + text: "User not deleted. Reason: " + err.response.data.detail, + color: "red" + }, + { root: true } + ) + }) + }, loginRedirect({ state }, redirectUri) { let redirectUrl = new URL(redirectUri) void state router.push({ path: redirectUrl.pathname }) }, + basicLogin({ commit }, payload) { + UserApi.login(payload.email, payload.password) + .then(function(res) { + commit("SET_USER_LOGIN", res.data.token) + router.push({ path: "/dashboard" }) + }) + .catch(err => { + commit("app/SET_SNACKBAR", { text: err.response.data.detail, color: "red" }, { root: true }) + }) + }, + register({ dispatch, commit }, payload) { + UserApi.register(payload.email, payload.password) + .then(function() { + dispatch("basicLogin", payload) + }) + .catch(err => { + commit("app/SET_SNACKBAR", { text: err.response.data.detail, color: "red" }, { root: true }) + }) + }, login({ dispatch, commit }, payload) { commit("SET_USER_LOGIN", payload.token) dispatch("loginRedirect", payload.redirectUri).then(() => { @@ -35,23 +158,50 @@ const actions = { { root: true } ) }, differenceInMilliseconds(expire_at, now)) + }, + getUserInfo({ commit }) { + UserApi.getUserInfo().then(function(res) { + commit("SET_USER_INFO", res.data) + }) } } const mutations = { + updateField, + SET_SELECTED(state, value) { + state.selected = Object.assign(state.selected, 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.showEdit = value + }, + RESET_SELECTED(state) { + state.selected = Object.assign(state.selected, getDefaultSelectedState()) + }, + SET_USER_INFO(state, info) { + state.userInfo = info + }, SET_USER_LOGIN(state, accessToken) { state.accessToken = accessToken state.status = { loggedIn: true } state.userInfo = jwt_decode(accessToken) + localStorage.setItem("token", accessToken) }, SET_USER_LOGOUT(state) { state.status = { loggedIn: false } state.userInfo = null state.accessToken = null + localStorage.removeItem("token") } } const getters = { + getField, accessToken: () => state.accessToken, email: () => state.userInfo.email, exp: () => state.userInfo.exp diff --git a/src/dispatch/static/dispatch/src/components/AppToolbar.vue b/src/dispatch/static/dispatch/src/components/AppToolbar.vue index 785c8c97fe45..31b7ee77ed40 100644 --- a/src/dispatch/static/dispatch/src/components/AppToolbar.vue +++ b/src/dispatch/static/dispatch/src/components/AppToolbar.vue @@ -27,7 +27,12 @@ @@ -78,7 +83,7 @@ export default { this.$store.dispatch("search/getResults", this.$store.state.query) this.$router.push("/search") }, - ...mapState("account", ["userInfo"]), + ...mapState("auth", ["userInfo"]), ...mapActions("search", ["setQuery"]), ...mapMutations("search", ["SET_QUERY"]) } diff --git a/src/dispatch/static/dispatch/src/components/layouts/AuthLayout.vue b/src/dispatch/static/dispatch/src/components/layouts/AuthLayout.vue index 5f1e8a8b7b40..3b8826960d82 100644 --- a/src/dispatch/static/dispatch/src/components/layouts/AuthLayout.vue +++ b/src/dispatch/static/dispatch/src/components/layouts/AuthLayout.vue @@ -1,19 +1,28 @@ diff --git a/src/dispatch/static/dispatch/src/definition/NewEditSheet.vue b/src/dispatch/static/dispatch/src/definition/NewEditSheet.vue index af5fe60fef4b..2271f274e889 100644 --- a/src/dispatch/static/dispatch/src/definition/NewEditSheet.vue +++ b/src/dispatch/static/dispatch/src/definition/NewEditSheet.vue @@ -1,16 +1,28 @@ diff --git a/src/dispatch/static/dispatch/src/incident/IncidentPriorityBarChartCard.vue b/src/dispatch/static/dispatch/src/incident/IncidentPriorityBarChartCard.vue index 613695a0ce34..c3018d25df86 100644 --- a/src/dispatch/static/dispatch/src/incident/IncidentPriorityBarChartCard.vue +++ b/src/dispatch/static/dispatch/src/incident/IncidentPriorityBarChartCard.vue @@ -6,7 +6,7 @@ diff --git a/src/dispatch/static/dispatch/src/incident/ParticipantsTab.vue b/src/dispatch/static/dispatch/src/incident/ParticipantsTab.vue index d9cba0cbde0d..5b4185240291 100644 --- a/src/dispatch/static/dispatch/src/incident/ParticipantsTab.vue +++ b/src/dispatch/static/dispatch/src/incident/ParticipantsTab.vue @@ -1,25 +1,30 @@ diff --git a/src/dispatch/static/dispatch/src/incident/ResourcesTab.vue b/src/dispatch/static/dispatch/src/incident/ResourcesTab.vue index a70c44c40093..099fa4164b6b 100644 --- a/src/dispatch/static/dispatch/src/incident/ResourcesTab.vue +++ b/src/dispatch/static/dispatch/src/incident/ResourcesTab.vue @@ -1,6 +1,6 @@ + diff --git a/src/dispatch/static/dispatch/src/incident/Table.vue b/src/dispatch/static/dispatch/src/incident/Table.vue index ed9168ccf1c6..8c889438d5f3 100644 --- a/src/dispatch/static/dispatch/src/incident/Table.vue +++ b/src/dispatch/static/dispatch/src/incident/Table.vue @@ -5,47 +5,7 @@
Incidents
- - - - - Column Filters - - - - - - - - - - - - - - - - - - - - - + New @@ -70,7 +30,6 @@ :sort-by.sync="sortBy" :sort-desc.sync="descending" :loading="loading" - @click:row="showEditSheet" loading-text="Loading... Please wait" > @@ -89,6 +48,20 @@ + @@ -98,32 +71,25 @@ diff --git a/src/dispatch/static/dispatch/src/incident/TimelineTab.vue b/src/dispatch/static/dispatch/src/incident/TimelineTab.vue index b37b19861b34..9d5137ea12a6 100644 --- a/src/dispatch/static/dispatch/src/incident/TimelineTab.vue +++ b/src/dispatch/static/dispatch/src/incident/TimelineTab.vue @@ -1,16 +1,33 @@ @@ -19,6 +36,13 @@ import { mapFields } from "vuex-map-fields" export default { name: "IncidentTimelineTab", + + data() { + return { + showDetails: false + } + }, + computed: { ...mapFields("incident", ["selected.events"]) } diff --git a/src/dispatch/static/dispatch/src/incident/api.js b/src/dispatch/static/dispatch/src/incident/api.js index e2601b56d65a..295feca40fbd 100644 --- a/src/dispatch/static/dispatch/src/incident/api.js +++ b/src/dispatch/static/dispatch/src/incident/api.js @@ -23,6 +23,10 @@ export default { return API.put(`${resource}/${incidentId}`, payload) }, + join(incidentId, payload) { + return API.post(`${resource}/${incidentId}/join`, payload) + }, + // TODO: Still not clear to me we'll actually use delete() here, and like // this, for incidents. delete(incidentId) { diff --git a/src/dispatch/static/dispatch/src/incident/store.js b/src/dispatch/static/dispatch/src/incident/store.js index 662fc65ae4cf..24d7a2400cf9 100644 --- a/src/dispatch/static/dispatch/src/incident/store.js +++ b/src/dispatch/static/dispatch/src/incident/store.js @@ -1,8 +1,7 @@ import IncidentApi from "@/incident/api" import { getField, updateField } from "vuex-map-fields" -import { debounce } from "lodash" -import _ from "lodash" +import { debounce, forEach, each, has } from "lodash" const getDefaultSelectedState = () => { return { @@ -52,7 +51,8 @@ const state = { commander: [], incident_type: [], incident_priority: [], - status: [] + status: [], + tag: [] }, q: "", page: 1, @@ -83,9 +83,9 @@ const actions = { tableOptions.ops = [] tableOptions.values = [] - _.forEach(state.table.options.filters, function(value, key) { - _.each(value, function(value) { - if (_.has(value, "id")) { + forEach(state.table.options.filters, function(value, key) { + each(value, function(value) { + if (has(value, "id")) { tableOptions.fields.push(key + ".id") tableOptions.values.push(value.id) } else { @@ -95,10 +95,14 @@ const actions = { tableOptions.ops.push("==") }) }) - return IncidentApi.getAll(tableOptions).then(response => { - commit("SET_TABLE_LOADING", false) - commit("SET_TABLE_ROWS", response.data) - }) + return IncidentApi.getAll(tableOptions) + .then(response => { + commit("SET_TABLE_LOADING", false) + commit("SET_TABLE_ROWS", response.data) + }) + .catch(() => { + commit("SET_TABLE_LOADING", false) + }) }, 200), get({ commit, state }) { return IncidentApi.get(state.selected.id).then(response => { @@ -191,6 +195,15 @@ const actions = { }, resetSelected({ commit }) { commit("RESET_SELECTED") + }, + joinIncident({ commit }, incidentId) { + IncidentApi.join(incidentId, {}).then(() => { + commit( + "app/SET_SNACKBAR", + { text: "You have successfully joined the incident." }, + { root: true } + ) + }) } } diff --git a/src/dispatch/static/dispatch/src/incident_priority/IncidentPriorityCombobox.vue b/src/dispatch/static/dispatch/src/incident_priority/IncidentPriorityCombobox.vue index 357ec90bcb8c..2a0e98e31c4a 100644 --- a/src/dispatch/static/dispatch/src/incident_priority/IncidentPriorityCombobox.vue +++ b/src/dispatch/static/dispatch/src/incident_priority/IncidentPriorityCombobox.vue @@ -29,7 +29,7 @@ diff --git a/src/dispatch/static/dispatch/src/individual/NewEditSheet.vue b/src/dispatch/static/dispatch/src/individual/NewEditSheet.vue index a876fd3e4f27..7b635eb51b6d 100644 --- a/src/dispatch/static/dispatch/src/individual/NewEditSheet.vue +++ b/src/dispatch/static/dispatch/src/individual/NewEditSheet.vue @@ -1,16 +1,28 @@ diff --git a/src/dispatch/static/dispatch/src/plugin/api.js b/src/dispatch/static/dispatch/src/plugin/api.js new file mode 100644 index 000000000000..ce6270e1055b --- /dev/null +++ b/src/dispatch/static/dispatch/src/plugin/api.js @@ -0,0 +1,17 @@ +import API from "@/api" + +const resource = "/plugins" + +export default { + getAll(options) { + return API.get(`${resource}/`, { params: { ...options } }) + }, + + get(pluginId) { + return API.get(`${resource}/${pluginId}`) + }, + + update(pluginId, payload) { + return API.put(`${resource}/${pluginId}`, payload) + } +} diff --git a/src/dispatch/static/dispatch/src/plugin/editSheet.vue b/src/dispatch/static/dispatch/src/plugin/editSheet.vue new file mode 100644 index 000000000000..746003bd1f96 --- /dev/null +++ b/src/dispatch/static/dispatch/src/plugin/editSheet.vue @@ -0,0 +1,81 @@ + + + diff --git a/src/dispatch/static/dispatch/src/plugin/store.js b/src/dispatch/static/dispatch/src/plugin/store.js new file mode 100644 index 000000000000..131b1d43ffbf --- /dev/null +++ b/src/dispatch/static/dispatch/src/plugin/store.js @@ -0,0 +1,155 @@ +import PluginApi from "@/plugin/api" + +import { getField, updateField } from "vuex-map-fields" +import { debounce } from "lodash" + +const getDefaultSelectedState = () => { + return { + id: null, + title: null, + slug: null, + description: null, + version: null, + author: null, + author_url: null, + enabled: null, + type: null, + required: null, + multiple: null, + configuration: null, + loading: false + } +} + +const state = { + selected: { + ...getDefaultSelectedState() + }, + dialogs: { + showEdit: false + }, + table: { + rows: { + items: [], + total: null + }, + options: { + q: "", + page: 1, + itemsPerPage: 10, + sortBy: ["slug"], + descending: [true] + }, + loading: false + } +} + +const getters = { + getField +} + +const actions = { + getAll: debounce(({ commit, state }) => { + commit("SET_TABLE_LOADING", true) + return PluginApi.getAll(state.table.options) + .then(response => { + commit("SET_TABLE_LOADING", false) + commit("SET_TABLE_ROWS", response.data) + }) + .catch(() => { + commit("SET_TABLE_LOADING", false) + }) + }, 200), + editShow({ commit }, plugin) { + commit("SET_DIALOG_EDIT", true) + if (plugin) { + commit("SET_SELECTED", plugin) + } + }, + closeEdit({ commit }) { + commit("SET_DIALOG_EDIT", false) + commit("RESET_SELECTED") + }, + save({ commit, dispatch }) { + if (!state.selected.id) { + return PluginApi.create(state.selected) + .then(() => { + dispatch("closeEdit") + dispatch("getAll") + commit("app/SET_SNACKBAR", { text: "Plugin created successfully." }, { root: true }) + }) + .catch(err => { + commit( + "app/SET_SNACKBAR", + { + text: "Plugin not created. Reason: " + err.response.data.detail, + color: "red" + }, + { root: true } + ) + }) + } else { + return PluginApi.update(state.selected.id, state.selected) + .then(() => { + dispatch("closeEdit") + dispatch("getAll") + commit("app/SET_SNACKBAR", { text: "Plugin updated successfully." }, { root: true }) + }) + .catch(err => { + commit( + "app/SET_SNACKBAR", + { + text: "Plugin not updated. Reason: " + err.response.data.detail, + color: "red" + }, + { root: true } + ) + }) + } + }, + remove({ commit, dispatch }) { + return PluginApi.delete(state.selected.id) + .then(function() { + dispatch("closeRemove") + dispatch("getAll") + commit("app/SET_SNACKBAR", { text: "Plugin deleted successfully." }, { root: true }) + }) + .catch(err => { + commit( + "app/SET_SNACKBAR", + { + text: "Plugin not deleted. Reason: " + err.response.data.detail, + color: "red" + }, + { root: true } + ) + }) + } +} + +const mutations = { + updateField, + SET_SELECTED(state, value) { + state.selected = Object.assign(state.selected, 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.showEdit = value + }, + RESET_SELECTED(state) { + state.selected = Object.assign(state.selected, getDefaultSelectedState()) + } +} + +export default { + namespaced: true, + state, + getters, + actions, + mutations +} diff --git a/src/dispatch/static/dispatch/src/policy/Table.vue b/src/dispatch/static/dispatch/src/policy/Table.vue index 86eabe0815e6..bbd24fd1d6ff 100644 --- a/src/dispatch/static/dispatch/src/policy/Table.vue +++ b/src/dispatch/static/dispatch/src/policy/Table.vue @@ -48,7 +48,7 @@ diff --git a/src/dispatch/static/dispatch/src/service/NewEditSheet.vue b/src/dispatch/static/dispatch/src/service/NewEditSheet.vue index 4dbbb10d7e33..15fb3d3b3b64 100644 --- a/src/dispatch/static/dispatch/src/service/NewEditSheet.vue +++ b/src/dispatch/static/dispatch/src/service/NewEditSheet.vue @@ -1,16 +1,28 @@ diff --git a/src/dispatch/static/dispatch/src/tag/NewEditSheet.vue b/src/dispatch/static/dispatch/src/tag/NewEditSheet.vue index 841084975b2d..57779f52e2ac 100644 --- a/src/dispatch/static/dispatch/src/tag/NewEditSheet.vue +++ b/src/dispatch/static/dispatch/src/tag/NewEditSheet.vue @@ -1,16 +1,28 @@ diff --git a/src/dispatch/static/dispatch/src/tag/store.js b/src/dispatch/static/dispatch/src/tag/store.js index 28c0d364b0f3..29df2bdb6cdf 100644 --- a/src/dispatch/static/dispatch/src/tag/store.js +++ b/src/dispatch/static/dispatch/src/tag/store.js @@ -12,6 +12,7 @@ const getDefaultSelectedState = () => { id: null, description: null, created_at: null, + discoverable: null, updated_at: null, loading: false } @@ -48,10 +49,14 @@ const getters = { const actions = { getAll: debounce(({ commit, state }) => { commit("SET_TABLE_LOADING", true) - return TagApi.getAll(state.table.options).then(response => { - commit("SET_TABLE_LOADING", false) - commit("SET_TABLE_ROWS", response.data) - }) + return TagApi.getAll(state.table.options) + .then(response => { + commit("SET_TABLE_LOADING", false) + commit("SET_TABLE_ROWS", response.data) + }) + .catch(() => { + commit("SET_TABLE_LOADING", false) + }) }, 200), createEditShow({ commit }, Tag) { commit("SET_DIALOG_CREATE_EDIT", true) diff --git a/src/dispatch/static/dispatch/src/task/List.vue b/src/dispatch/static/dispatch/src/task/List.vue index 8eceafa8f63d..8a0a3f46a49a 100644 --- a/src/dispatch/static/dispatch/src/task/List.vue +++ b/src/dispatch/static/dispatch/src/task/List.vue @@ -1,59 +1,50 @@ diff --git a/src/dispatch/static/dispatch/src/task/NewEditSheet.vue b/src/dispatch/static/dispatch/src/task/NewEditSheet.vue index 006807513491..78570e9e2fd8 100644 --- a/src/dispatch/static/dispatch/src/task/NewEditSheet.vue +++ b/src/dispatch/static/dispatch/src/task/NewEditSheet.vue @@ -1,16 +1,28 @@ diff --git a/src/dispatch/static/dispatch/src/team/NewEditSheet.vue b/src/dispatch/static/dispatch/src/team/NewEditSheet.vue index a07ca6dc78ba..ce64012a7cd1 100644 --- a/src/dispatch/static/dispatch/src/team/NewEditSheet.vue +++ b/src/dispatch/static/dispatch/src/team/NewEditSheet.vue @@ -1,16 +1,28 @@ diff --git a/src/dispatch/static/dispatch/src/term/NewEditSheet.vue b/src/dispatch/static/dispatch/src/term/NewEditSheet.vue index ff9c15c61fe1..4d19e74e5b7c 100644 --- a/src/dispatch/static/dispatch/src/term/NewEditSheet.vue +++ b/src/dispatch/static/dispatch/src/term/NewEditSheet.vue @@ -1,16 +1,28 @@