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 @@
+
+
+
+ Dispatch - Login
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Don't have a account?
+ Register
+
+
+ Login
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+ Dispatch - Register
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Have a account? Login
+
+
+
+ Register
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+ Users
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ Edit
+ User
+
+
+ save
+
+
+ close
+
+
+
+
+
+
+
+
+ Details
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
-
+
+ account_circle
@@ -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 @@
-
-
-
-
- Edit
- New
- Definition
-
-
-
-
-
+
+
+
+
+
+ Edit
+ New
+ Definition
+
+
+ save
+
+
+ close
+
+
+
+
@@ -35,20 +47,9 @@
-
-
- Cancel
- Save
-
-
-
+
+
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 @@
-
-
-
-
- {{ participant.individual.name }} ({{
- participant.participant_roles | commaString("role")
- }})
-
-
- {{ participant.team }} - {{ participant.location }}
-
-
-
-
- open_in_new
-
-
-
-
-
+
+
+
+
+
+ {{ participant.individual.name }} ({{
+ participant.participant_roles | commaString("role")
+ }})
+
+
+ {{ participant.team }} - {{ participant.location }}
+
+
+
+
+ open_in_new
+
+
+
+
+
+
+
+ No participant data available.
+
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 @@
-
+
Ticket
{{ ticket.description }}
@@ -12,7 +12,7 @@
-
+
Video Conference
{{ conference.description }}
@@ -24,7 +24,7 @@
-
+
Conversation
{{ conversation.description }}
@@ -36,7 +36,7 @@
-
+
Storage
{{ storage.description }}
diff --git a/src/dispatch/static/dispatch/src/incident/Status.vue b/src/dispatch/static/dispatch/src/incident/Status.vue
index aeb6fc963211..e710cd8ca8bd 100644
--- a/src/dispatch/static/dispatch/src/incident/Status.vue
+++ b/src/dispatch/static/dispatch/src/incident/Status.vue
@@ -31,6 +31,9 @@
open_in_new
+
+ Join Incident
+
@@ -80,6 +83,7 @@
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
-
-
-
- Filter Columns
-
-
-
-
- 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"
>
{{ item.cost | toUSD }}
@@ -89,6 +48,20 @@
{{
item.reported_at | formatDate
}}
+
+
+
+
+ mdi-dots-vertical
+
+
+
+
+ Edit
+
+
+
+
@@ -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 @@
-
-
+
+
{{ event.description }}
+
+
+
+ {{ key | capitalize }}
+ {{ value }}
+
+
+
{{ event.source }}
{{ event.started_at | formatDate }}
+
+ No timeline data available.
+
@@ -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 @@
-
-
-
-
- Edit
- New
- Individual
-
-
-
-
-
+
+
+
+
+
+ Edit
+ New
+ Individual
+
+
+ save
+
+
+ close
+
+
+
+
@@ -74,21 +86,9 @@
-
-
-
- Cancel
- Save
-
-
-
+
+
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 @@
+
+
+
+
+
+ Edit
+ Plugin
+
+
+ save
+
+
+ close
+
+
+
+
+
+
+
+
+ Details
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
-
-
-
-
- Edit
- New
- Service
-
-
-
-
-
+
+
+
+
+
+ Edit
+ New
+ Service
+
+
+ save
+
+
+ close
+
+
+
+
@@ -93,21 +105,9 @@ rules="required" immediate>
-
-
-
- Cancel
- Save
-
-
-
+
+
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 @@
-
-
-
-
- Edit
- New
- Tag
-
-
-
-
-
+
+
+
+
+
+ Edit
+ New
+ Tag
+
+
+ save
+
+
+ close
+
+
+
+
@@ -87,23 +99,19 @@
/>
+
+
+
-
-
- Cancel
- Save
-
-
-
+
+
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 @@
-
- Tasks
-
-
-
-
-
-
-
-
-
-
-
- fa fa-adjust
-
-
-
- {{ item.content.source }}
-
- Source
-
-
-
-
-
-
- {{ item.content.text }}
- Text
-
-
-
-
-
-
-
-
- {{ description.text }}
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ Tasks ({{ items.length }})
+
+
+
+
+
+
+ Description
+
+
+
+
+
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 @@
-
-
-
-
- Edit
- New
- Task
-
-
-
-
-
+
+
+
+
+
+ Edit
+ New
+ Task
+
+
+ save
+
+
+ close
+
+
+
+
@@ -47,8 +59,8 @@
>
-
-
+
+
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 @@
-
-
-
-
- Edit
- New
- Service
-
-
-
-
-
+
+
+
+
+
+ Edit
+ New
+ Service
+
+
+ save
+
+
+ close
+
+
+
+
@@ -74,21 +86,9 @@
-
-
-
- Cancel
- Save
-
-
-
+
+
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 @@
-
-
-
-
- Edit
- New
- Term
-
-
-
-
-
+
+
+
+
+
+ Edit
+ New
+ Term
+
+
+ save
+
+
+ close
+
+
+
+
@@ -35,20 +47,9 @@
-
-
- Cancel
- Save
-
-
-
+
+
| |