From b015458cf1d8a2d4914891861280a416eab7e4e3 Mon Sep 17 00:00:00 2001
From: Will Sheldon <114631109+wssheldon@users.noreply.github.com>
Date: Fri, 10 Feb 2023 09:54:55 -0800
Subject: [PATCH 01/12] add base concenpt of entities to signals
---
src/dispatch/api.py | 4 +
.../versions/2023-02-09_8746b4e292d2.py | 69 +++++++
src/dispatch/entity/__init__.py | 0
src/dispatch/entity/models.py | 56 ++++++
src/dispatch/entity/service.py | 92 ++++++++++
src/dispatch/entity/views.py | 84 +++++++++
src/dispatch/signal/flows.py | 6 +
src/dispatch/signal/models.py | 28 +++
src/dispatch/signal/service.py | 47 +++++
.../src/entity/EntityFilterCombobox.vue | 171 ++++++++++++++++++
.../dispatch/src/entity/EntitySelect.vue | 133 ++++++++++++++
.../dispatch/src/entity/NewEditSheet.vue | 151 ++++++++++++++++
.../static/dispatch/src/entity/Table.vue | 127 +++++++++++++
.../static/dispatch/src/entity/api.js | 25 +++
.../static/dispatch/src/entity/store.js | 160 ++++++++++++++++
.../static/dispatch/src/router/config.js | 6 +
.../static/dispatch/src/signal/EntityRule.vue | 71 ++++++++
.../dispatch/src/signal/NewEditSheet.vue | 6 +
src/dispatch/static/dispatch/src/store.js | 2 +
19 files changed, 1238 insertions(+)
create mode 100644 src/dispatch/database/revisions/tenant/versions/2023-02-09_8746b4e292d2.py
create mode 100644 src/dispatch/entity/__init__.py
create mode 100644 src/dispatch/entity/models.py
create mode 100644 src/dispatch/entity/service.py
create mode 100644 src/dispatch/entity/views.py
create mode 100644 src/dispatch/static/dispatch/src/entity/EntityFilterCombobox.vue
create mode 100644 src/dispatch/static/dispatch/src/entity/EntitySelect.vue
create mode 100644 src/dispatch/static/dispatch/src/entity/NewEditSheet.vue
create mode 100644 src/dispatch/static/dispatch/src/entity/Table.vue
create mode 100644 src/dispatch/static/dispatch/src/entity/api.js
create mode 100644 src/dispatch/static/dispatch/src/entity/store.js
create mode 100644 src/dispatch/static/dispatch/src/signal/EntityRule.vue
diff --git a/src/dispatch/api.py b/src/dispatch/api.py
index 74ddc8d6c2ea..b4434ec68846 100644
--- a/src/dispatch/api.py
+++ b/src/dispatch/api.py
@@ -21,6 +21,7 @@
from dispatch.data.source.views import router as source_router
from dispatch.definition.views import router as definition_router
from dispatch.document.views import router as document_router
+from dispatch.entity.views import router as entity_router
from dispatch.feedback.views import router as feedback_router
from dispatch.incident.priority.views import router as incident_priority_router
from dispatch.incident.severity.views import router as incident_severity_router
@@ -131,6 +132,9 @@ def get_organization_path(organization: OrganizationSlug):
authenticated_organization_api_router.include_router(
document_router, prefix="/documents", tags=["documents"]
)
+authenticated_organization_api_router.include_router(
+ entity_router, prefix="/entity", tags=["entities"]
+)
authenticated_organization_api_router.include_router(tag_router, prefix="/tags", tags=["tags"])
authenticated_organization_api_router.include_router(
tag_type_router, prefix="/tag_types", tags=["tag_types"]
diff --git a/src/dispatch/database/revisions/tenant/versions/2023-02-09_8746b4e292d2.py b/src/dispatch/database/revisions/tenant/versions/2023-02-09_8746b4e292d2.py
new file mode 100644
index 000000000000..531526182b1a
--- /dev/null
+++ b/src/dispatch/database/revisions/tenant/versions/2023-02-09_8746b4e292d2.py
@@ -0,0 +1,69 @@
+"""empty message
+
+Revision ID: 8746b4e292d2
+Revises: 941efd922446
+Create Date: 2023-02-09 23:18:11.326027
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+import sqlalchemy_utils
+
+# revision identifiers, used by Alembic.
+revision = "8746b4e292d2"
+down_revision = "941efd922446"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table(
+ "entity",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("name", sa.String(), nullable=True),
+ sa.Column("description", sa.String(), nullable=True),
+ sa.Column("regular_expression", sa.String(), nullable=True),
+ sa.Column("global_find", sa.Boolean(), nullable=True),
+ sa.Column("search_vector", sqlalchemy_utils.types.ts_vector.TSVectorType(), nullable=True),
+ sa.Column("project_id", sa.Integer(), nullable=True),
+ sa.Column("updated_at", sa.DateTime(), nullable=True),
+ sa.Column("created_at", sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("name", "project_id"),
+ )
+ op.create_table(
+ "assoc_signal_entities",
+ sa.Column("signal_id", sa.Integer(), nullable=False),
+ sa.Column("entity_id", sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(["entity_id"], ["entity.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["signal_id"], ["signal.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("signal_id", "entity_id"),
+ )
+ op.create_table(
+ "assoc_signal_instance_entities",
+ sa.Column("signal_instance_id", postgresql.UUID(), nullable=False),
+ sa.Column("entity_id", sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(["signal_instance_id"], ["signal_instance.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["entity_id"], ["entity.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("signal_instance_id", "entity_id"),
+ )
+ op.create_index(
+ "entity_search_vector_idx",
+ "entity",
+ ["search_vector"],
+ unique=False,
+ postgresql_using="gin",
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table("entity")
+ op.drop_index("entity_search_vector_idx", table_name="entity", postgresql_using="gin")
+ op.drop_table("assoc_signal_entities")
+ op.drop_table("assoc_signal_instance_entities")
+ # ### end Alembic commands ###
diff --git a/src/dispatch/entity/__init__.py b/src/dispatch/entity/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/src/dispatch/entity/models.py b/src/dispatch/entity/models.py
new file mode 100644
index 000000000000..1d9d35302b67
--- /dev/null
+++ b/src/dispatch/entity/models.py
@@ -0,0 +1,56 @@
+from typing import List, Optional
+from pydantic import StrictBool, Field
+
+from sqlalchemy import Column, Integer, String
+from sqlalchemy.sql.schema import UniqueConstraint
+from sqlalchemy.sql.sqltypes import Boolean
+from sqlalchemy_utils import TSVectorType
+
+from dispatch.database.core import Base
+from dispatch.models import DispatchBase, NameStr, TimeStampMixin, ProjectMixin, PrimaryKey
+from dispatch.project.models import ProjectRead
+
+
+class Entity(Base, TimeStampMixin, ProjectMixin):
+ __table_args__ = (UniqueConstraint("name", "project_id"),)
+ id = Column(Integer, primary_key=True)
+ name = Column(String)
+ description = Column(String)
+ regular_expression = Column(String)
+ global_find = Column(Boolean, default=False)
+ search_vector = Column(TSVectorType("name", regconfig="pg_catalog.simple"))
+
+
+# Pydantic models
+class EntityBase(DispatchBase):
+ name: NameStr
+ description: Optional[str] = Field(None, nullable=True)
+ global_find: Optional[StrictBool]
+ regular_expression: Optional[str] = Field(None, nullable=True)
+
+
+class EntityCreate(EntityBase):
+ project: ProjectRead
+
+
+class EntityUpdate(EntityBase):
+ id: PrimaryKey = None
+
+
+class EntityRead(EntityBase):
+ id: PrimaryKey
+ global_find: Optional[StrictBool]
+ project: ProjectRead
+
+
+class EntityReadMinimal(DispatchBase):
+ id: PrimaryKey
+ name: NameStr
+ description: Optional[str] = Field(None, nullable=True)
+ global_find: Optional[StrictBool]
+ regular_expression: Optional[str] = Field(None, nullable=True)
+
+
+class EntityPagination(DispatchBase):
+ items: List[EntityRead]
+ total: int
diff --git a/src/dispatch/entity/service.py b/src/dispatch/entity/service.py
new file mode 100644
index 000000000000..d9675d21264f
--- /dev/null
+++ b/src/dispatch/entity/service.py
@@ -0,0 +1,92 @@
+from typing import Optional
+
+from pydantic.error_wrappers import ErrorWrapper, ValidationError
+from sqlalchemy.orm import Query, Session
+
+from dispatch.exceptions import NotFoundError
+from dispatch.project import service as project_service
+from .models import Entity, EntityCreate, EntityRead, EntityUpdate
+
+
+def get(*, db_session, entity_id: int) -> Optional[Entity]:
+ """Gets a entity by its id."""
+ return db_session.query(Entity).filter(Entity.id == entity_id).one_or_none()
+
+
+def get_by_name(*, db_session: Session, project_id: int, name: str) -> Optional[Entity]:
+ """Gets a entity by its name."""
+ return (
+ db_session.query(Entity)
+ .filter(Entity.name == name)
+ .filter(Entity.project_id == project_id)
+ .one_or_none()
+ )
+
+
+def get_by_name_or_raise(*, db_session: Session, project_id: int, entity_in=EntityRead) -> Entity:
+ """Returns the entity specified or raises ValidationError."""
+ entity = get_by_name(db_session=db_session, project_id=project_id, name=entity_in.name)
+
+ if not entity:
+ raise ValidationError(
+ [
+ ErrorWrapper(
+ NotFoundError(msg="Entity not found.", entity=entity_in.name),
+ loc="entity",
+ )
+ ],
+ model=EntityRead,
+ )
+
+ return entity
+
+
+def get_all(*, db_session: Session) -> Query:
+ """Gets all entitys."""
+ return db_session.query(Entity)
+
+
+def create(*, db_session: Session, entity_in: EntityCreate) -> Entity:
+ """Creates a new entity."""
+ project = project_service.get_by_name_or_raise(
+ db_session=db_session, project_in=entity_in.project
+ )
+ entity = Entity(**entity_in.dict(exclude={"project"}), project=project)
+ db_session.add(entity)
+ db_session.commit()
+ return entity
+
+
+def get_or_create(*, db_session: Session, entity_in: EntityCreate) -> Entity:
+ """Gets or creates a new entity."""
+ q = (
+ db_session.query(Entity)
+ .filter(Entity.name == entity_in.name)
+ .filter(Entity.project_id == entity_in.project.id)
+ )
+
+ instance = q.first()
+ if instance:
+ return instance
+
+ return create(db_session=db_session, entity_in=entity_in)
+
+
+def update(*, db_session: Session, entity: Entity, entity_in: EntityUpdate) -> Entity:
+ """Updates an entity."""
+ entity_data = entity.dict()
+ update_data = entity_in.dict(skip_defaults=True)
+
+ for field in entity_data:
+ if field in update_data:
+ setattr(entity, field, update_data[field])
+
+ db_session.commit()
+ return entity
+
+
+def delete(*, db_session: Session, entity_id: int) -> None:
+ """Deletes an entity."""
+ entity = db_session.query(Entity).filter(Entity.id == entity_id).one_or_none()
+ db_session.delete(entity)
+ db_session.commit()
diff --git a/src/dispatch/entity/views.py b/src/dispatch/entity/views.py
new file mode 100644
index 000000000000..c966d68554be
--- /dev/null
+++ b/src/dispatch/entity/views.py
@@ -0,0 +1,84 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from pydantic.error_wrappers import ErrorWrapper, ValidationError
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session
+
+from dispatch.database.core import get_db
+from dispatch.exceptions import ExistsError
+from dispatch.database.service import common_parameters, search_filter_sort_paginate
+from dispatch.models import PrimaryKey
+
+from .models import (
+ EntityCreate,
+ EntityPagination,
+ EntityRead,
+ EntityUpdate,
+)
+from .service import create, delete, get, update
+
+router = APIRouter()
+
+
+@router.get("", response_model=EntityPagination)
+def get_entities(*, common: dict = Depends(common_parameters)):
+ """Get all entities, or only those matching a given search term."""
+ return search_filter_sort_paginate(model="Entity", **common)
+
+
+@router.get("/{entity_id}", response_model=EntityRead)
+def get_entity(*, db_session: Session = Depends(get_db), entity_id: PrimaryKey):
+ """Get a entity by its id."""
+ entity = get(db_session=db_session, entity_id=entity_id)
+ if not entity:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=[{"msg": "A entity with this id does not exist."}],
+ )
+ return entity
+
+
+@router.post("", response_model=EntityRead)
+def create_entity(*, db_session: Session = Depends(get_db), entity_in: EntityCreate):
+ """Create a new entity."""
+ try:
+ entity = create(db_session=db_session, entity_in=entity_in)
+ except IntegrityError:
+ raise ValidationError(
+ [ErrorWrapper(ExistsError(msg="A entity with this name already exists."), loc="name")],
+ model=EntityCreate,
+ )
+ return entity
+
+
+@router.put("/{entity_id}", response_model=EntityRead)
+def update_entity(
+ *, db_session: Session = Depends(get_db), entity_id: PrimaryKey, entity_in: EntityUpdate
+):
+ """Update an entity."""
+ entity = get(db_session=db_session, entity_id=entity_id)
+ if not entity:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=[{"msg": "A entity with this id does not exist."}],
+ )
+
+ try:
+ entity = update(db_session=db_session, entity=entity, entity_in=entity_in)
+ except IntegrityError:
+ raise ValidationError(
+ [ErrorWrapper(ExistsError(msg="A entity with this name already exists."), loc="name")],
+ model=EntityUpdate,
+ )
+ return entity
+
+
+@router.delete("/{entity_id}", response_model=None)
+def delete_entity(*, db_session: Session = Depends(get_db), entity_id: PrimaryKey):
+ """Delete an entity."""
+ entity = get(db_session=db_session, entity_id=entity_id)
+ if not entity:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=[{"msg": "A entity with this id does not exist."}],
+ )
+ delete(db_session=db_session, entity_id=entity_id)
diff --git a/src/dispatch/signal/flows.py b/src/dispatch/signal/flows.py
index ba7d80b18429..2c21dc623e72 100644
--- a/src/dispatch/signal/flows.py
+++ b/src/dispatch/signal/flows.py
@@ -50,6 +50,12 @@ def create_signal_instance(
if duplicate:
return
+ # entities = signal_service.find_entities(
+ # db_session=db_session,
+ # signal_instance=signal_instance,
+ # duplication_rule=signal.entity_rule,
+ # )
+
# create a case if not duplicate or supressed
case_in = CaseCreate(
title=signal.name,
diff --git a/src/dispatch/signal/models.py b/src/dispatch/signal/models.py
index 5d5e69c54e68..6988a7488d95 100644
--- a/src/dispatch/signal/models.py
+++ b/src/dispatch/signal/models.py
@@ -25,6 +25,7 @@
from dispatch.case.models import CaseRead
from dispatch.case.type.models import CaseTypeRead, CaseType
from dispatch.case.priority.models import CasePriority, CasePriorityRead
+from dispatch.entity.models import EntityRead
from dispatch.tag.models import TagRead
from dispatch.project.models import ProjectRead
from dispatch.data.source.models import SourceBase
@@ -53,6 +54,22 @@ class RuleMode(DispatchEnum):
PrimaryKeyConstraint("signal_id", "tag_id"),
)
+assoc_signal_instance_entities = Table(
+ "assoc_signal_instance_entities",
+ Base.metadata,
+ Column("signal_instance_id", Integer, ForeignKey("signal_instance.id", ondelete="CASCADE")),
+ Column("entity_id", Integer, ForeignKey("entity.id", ondelete="CASCADE")),
+ PrimaryKeyConstraint("signal_instance_id", "entity_id"),
+)
+
+assoc_signal_entities = Table(
+ "assoc_signal_entities",
+ Base.metadata,
+ Column("signal_id", Integer, ForeignKey("signal.id", ondelete="CASCADE")),
+ Column("entity_id", Integer, ForeignKey("entity.id", ondelete="CASCADE")),
+ PrimaryKeyConstraint("signal_id", "entity_id"),
+)
+
assoc_duplication_tag_types = Table(
"assoc_duplication_rule_tag_types",
Base.metadata,
@@ -109,6 +126,11 @@ class Signal(Base, TimeStampMixin, ProjectMixin):
case_priority = relationship("CasePriority", backref="signals")
duplication_rule_id = Column(Integer, ForeignKey(DuplicationRule.id))
duplication_rule = relationship("DuplicationRule", backref="signal")
+ entities = relationship(
+ "Entity",
+ secondary=assoc_signal_entities,
+ backref="signals",
+ )
suppression_rule_id = Column(Integer, ForeignKey(SuppressionRule.id))
suppression_rule = relationship("SuppressionRule", backref="signal")
tags = relationship(
@@ -125,6 +147,11 @@ class SignalInstance(Base, TimeStampMixin, ProjectMixin):
case_id = Column(Integer, ForeignKey("case.id", ondelete="CASCADE"))
duplication_rule = relationship("DuplicationRule", backref="signal_instances")
duplication_rule_id = Column(Integer, ForeignKey(DuplicationRule.id))
+ entities = relationship(
+ "Entity",
+ secondary=assoc_signal_instance_entities,
+ backref="signal_instances",
+ )
fingerprint = Column(String)
raw = Column(JSONB)
signal = relationship("Signal", backref="instances")
@@ -236,6 +263,7 @@ class RawSignal(DispatchBase):
class SignalInstanceBase(DispatchBase):
project: ProjectRead
case: Optional[CaseRead]
+ entities: Optional[List[EntityRead]] = []
tags: Optional[List[TagRead]] = []
raw: RawSignal
suppression_rule: Optional[SuppressionRuleBase]
diff --git a/src/dispatch/signal/service.py b/src/dispatch/signal/service.py
index 4001f7d21e13..75c8907a69fb 100644
--- a/src/dispatch/signal/service.py
+++ b/src/dispatch/signal/service.py
@@ -1,5 +1,6 @@
import json
import hashlib
+import re
from typing import Optional
from datetime import datetime, timedelta, timezone
from dispatch.enums import RuleMode
@@ -326,3 +327,49 @@ def supress(
db_session.commit()
return supressed
+
+
+def find_entities(d, signal_instance: SignalInstance):
+ """
+ Recursively search a dictionary for values that match a list of entities.
+
+ Args:
+ - d (dict): The dictionary to search
+ - entity_list (list): The list of entities to search for
+
+ Returns:
+ - result (list): The values in the dictionary that match the entities
+
+ """
+ result = []
+ entity_regexes = [
+ re.compile(entity) for entity in signal_instance.entities if isinstance(entity, str)
+ ]
+ cache = {}
+
+ def _search(val):
+ """Helper function to search a value for entities"""
+ if id(val) in cache:
+ # If val has been searched before, return cached result
+ result.extend(cache[id(val)])
+ return
+
+ if isinstance(val, dict):
+ # If val is a dictionary, recursively search it
+ entities_found = find_entities(val, signal_instance.entities)
+ cache[id(val)] = entities_found
+ result.extend(entities_found)
+ elif isinstance(val, list):
+ # If val is a list, search each item in the list
+ for item in val:
+ _search(item)
+ elif isinstance(val, str):
+ for entity_regex in entity_regexes:
+ # If val is a string and matches any entity regex, add it to result
+ if entity_regex.match(val):
+ result.append(val)
+
+ for key, value in d.items():
+ _search(value)
+
+ return result
diff --git a/src/dispatch/static/dispatch/src/entity/EntityFilterCombobox.vue b/src/dispatch/static/dispatch/src/entity/EntityFilterCombobox.vue
new file mode 100644
index 000000000000..93c7696827c8
--- /dev/null
+++ b/src/dispatch/static/dispatch/src/entity/EntityFilterCombobox.vue
@@ -0,0 +1,171 @@
+
+
+
+
+
+
+ No entities matching "
+ {{ search }}"
+
+
+
+
+
+
+ {{ item.name }}
+
+
+
+
+ {{ data.item.name }}
+
+ {{ data.item.regular_expression }}
+
+
+
+
+
+
+ Load More
+
+
+
+
+
+
+
diff --git a/src/dispatch/static/dispatch/src/entity/EntitySelect.vue b/src/dispatch/static/dispatch/src/entity/EntitySelect.vue
new file mode 100644
index 000000000000..814ffb8f5208
--- /dev/null
+++ b/src/dispatch/static/dispatch/src/entity/EntitySelect.vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Load More
+
+
+
+
+
+
+
diff --git a/src/dispatch/static/dispatch/src/entity/NewEditSheet.vue b/src/dispatch/static/dispatch/src/entity/NewEditSheet.vue
new file mode 100644
index 000000000000..66400dbf8f6f
--- /dev/null
+++ b/src/dispatch/static/dispatch/src/entity/NewEditSheet.vue
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+ Edit
+ New
+ Entity Type
+
+
+ save
+
+
+ close
+
+
+
+
+
+
+
+
+ Details
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mdi-information
+
+ Checking this box collect the entity from all signals.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/dispatch/static/dispatch/src/entity/Table.vue b/src/dispatch/static/dispatch/src/entity/Table.vue
new file mode 100644
index 000000000000..cee17d294c48
--- /dev/null
+++ b/src/dispatch/static/dispatch/src/entity/Table.vue
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+
+ New
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mdi-dots-vertical
+
+
+
+
+ View / Edit
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/dispatch/static/dispatch/src/entity/api.js b/src/dispatch/static/dispatch/src/entity/api.js
new file mode 100644
index 000000000000..e851ef4dbff9
--- /dev/null
+++ b/src/dispatch/static/dispatch/src/entity/api.js
@@ -0,0 +1,25 @@
+import API from "@/api"
+
+const resource = "/entity"
+
+export default {
+ getAll(options) {
+ return API.get(`${resource}`, { params: { ...options } })
+ },
+
+ get(entityId) {
+ return API.get(`${resource}/${entityId}`)
+ },
+
+ create(payload) {
+ return API.post(`${resource}`, payload)
+ },
+
+ update(entityId, payload) {
+ return API.put(`${resource}/${entityId}`, payload)
+ },
+
+ delete(entityId) {
+ return API.delete(`${resource}/${entityId}`)
+ },
+}
diff --git a/src/dispatch/static/dispatch/src/entity/store.js b/src/dispatch/static/dispatch/src/entity/store.js
new file mode 100644
index 000000000000..d4184b326abb
--- /dev/null
+++ b/src/dispatch/static/dispatch/src/entity/store.js
@@ -0,0 +1,160 @@
+import { getField, updateField } from "vuex-map-fields"
+import { debounce } from "lodash"
+
+import SearchUtils from "@/search/utils"
+import EntityApi from "@/entity/api"
+
+const getDefaultSelectedState = () => {
+ return {
+ id: null,
+ name: null,
+ regular_expression: null,
+ project: null,
+ default: false,
+ }
+}
+
+const state = {
+ selected: {
+ ...getDefaultSelectedState(),
+ },
+ dialogs: {
+ showCreateEdit: false,
+ showRemove: false,
+ },
+ table: {
+ rows: {
+ items: [],
+ total: null,
+ },
+ options: {
+ q: "",
+ page: 1,
+ itemsPerPage: 10,
+ sortBy: ["name"],
+ descending: [true],
+ filters: {
+ project: [],
+ },
+ },
+ loading: false,
+ },
+}
+
+const getters = {
+ getField,
+}
+
+const actions = {
+ getAll: debounce(({ commit, state }) => {
+ commit("SET_TABLE_LOADING", "primary")
+ let params = SearchUtils.createParametersFromTableOptions({ ...state.table.options }, "Entity")
+ return EntityApi.getAll(params)
+ .then((response) => {
+ commit("SET_TABLE_LOADING", false)
+ commit("SET_TABLE_ROWS", response.data)
+ })
+ .catch(() => {
+ commit("SET_TABLE_LOADING", false)
+ })
+ }, 500),
+ createEditShow({ commit }, entity) {
+ commit("SET_DIALOG_CREATE_EDIT", true)
+ if (entity) {
+ commit("SET_SELECTED", entity)
+ }
+ },
+ removeShow({ commit }, entity) {
+ commit("SET_DIALOG_DELETE", true)
+ commit("SET_SELECTED", entity)
+ },
+ closeCreateEdit({ commit }) {
+ commit("SET_DIALOG_CREATE_EDIT", false)
+ commit("RESET_SELECTED")
+ },
+ closeRemove({ commit }) {
+ commit("SET_DIALOG_DELETE", false)
+ commit("RESET_SELECTED")
+ },
+ save({ commit, state, dispatch }) {
+ commit("SET_SELECTED_LOADING", true)
+ if (!state.selected.id) {
+ return EntityApi.create(state.selected)
+ .then(() => {
+ commit("SET_SELECTED_LOADING", false)
+ dispatch("closeCreateEdit")
+ dispatch("getAll")
+ commit(
+ "notification_backend/addBeNotification",
+ { text: "Entity created successfully.", type: "success" },
+ { root: true }
+ )
+ })
+ .catch(() => {
+ commit("SET_SELECTED_LOADING", false)
+ })
+ } else {
+ return EntityApi.update(state.selected.id, state.selected)
+ .then(() => {
+ commit("SET_SELECTED_LOADING", false)
+ dispatch("closeCreateEdit")
+ dispatch("getAll")
+ commit(
+ "notification_backend/addBeNotification",
+ { text: "Entity updated successfully.", type: "success" },
+ { root: true }
+ )
+ })
+ .catch(() => {
+ commit("SET_SELECTED_LOADING", false)
+ })
+ }
+ },
+ remove({ commit, dispatch, state }) {
+ return EntityApi.delete(state.selected.id).then(() => {
+ dispatch("closeRemove")
+ dispatch("getAll")
+ commit(
+ "notification_backend/addBeNotification",
+ { text: "Entity deleted successfully.", type: "success" },
+ { root: true }
+ )
+ })
+ },
+}
+
+const mutations = {
+ updateField,
+ SET_SELECTED(state, value) {
+ state.selected = Object.assign(state.selected, value)
+ },
+ SET_SELECTED_LOADING(state, value) {
+ state.selected.loading = value
+ },
+ SET_TABLE_LOADING(state, value) {
+ state.table.loading = value
+ },
+ SET_TABLE_ROWS(state, value) {
+ state.table.rows = value
+ },
+ SET_DIALOG_CREATE_EDIT(state, value) {
+ state.dialogs.showCreateEdit = value
+ },
+ SET_DIALOG_DELETE(state, value) {
+ state.dialogs.showRemove = value
+ },
+ RESET_SELECTED(state) {
+ // do not reset project
+ let project = state.selected.project
+ state.selected = { ...getDefaultSelectedState() }
+ state.selected.project = project
+ },
+}
+
+export default {
+ namespaced: true,
+ state,
+ getters,
+ actions,
+ mutations,
+}
diff --git a/src/dispatch/static/dispatch/src/router/config.js b/src/dispatch/static/dispatch/src/router/config.js
index 8568b0a2cd8d..f7ea10d24f64 100644
--- a/src/dispatch/static/dispatch/src/router/config.js
+++ b/src/dispatch/static/dispatch/src/router/config.js
@@ -464,6 +464,12 @@ export const protectedRoute = [
meta: { title: "Teams", subMenu: "project", group: "contact" },
component: () => import("@/team/Table.vue"),
},
+ {
+ path: "entity",
+ name: "EntityTable",
+ meta: { title: "Entities", subMenu: "project", group: "knowledge" },
+ component: () => import("@/entity/Table.vue"),
+ },
{
path: "tagTypes",
name: "TagTypeTable",
diff --git a/src/dispatch/static/dispatch/src/signal/EntityRule.vue b/src/dispatch/static/dispatch/src/signal/EntityRule.vue
new file mode 100644
index 000000000000..53838b131293
--- /dev/null
+++ b/src/dispatch/static/dispatch/src/signal/EntityRule.vue
@@ -0,0 +1,71 @@
+
+
+
+ Entity Configuration
+
+
+
+ help_outline
+
+ Dispatch will attempt to locate entities that match the given criteria. Global entities cannot be selected, since they are applied to all signals.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/dispatch/static/dispatch/src/signal/NewEditSheet.vue b/src/dispatch/static/dispatch/src/signal/NewEditSheet.vue
index f23aac55a841..96420070f265 100644
--- a/src/dispatch/static/dispatch/src/signal/NewEditSheet.vue
+++ b/src/dispatch/static/dispatch/src/signal/NewEditSheet.vue
@@ -142,6 +142,9 @@
+
+
+
@@ -163,6 +166,7 @@ import CaseTypeSelect from "@/case/type/CaseTypeSelect.vue"
import CasePrioritySelect from "@/case/priority/CasePrioritySelect.vue"
import DuplicationRuleCard from "@/signal/DuplicationRule.vue"
+import EntityRuleCard from "@/signal/EntityRule.vue"
import SuppressionRule from "./SuppressionRule.vue"
extend("required", {
@@ -178,6 +182,7 @@ export default {
CaseTypeSelect,
CasePrioritySelect,
DuplicationRuleCard,
+ EntityRuleCard,
SuppressionRule,
},
@@ -205,6 +210,7 @@ export default {
"selected.external_url",
"selected.case_type",
"selected.case_priority",
+ "selected.entities",
"selected.duplication_rule",
"selected.suppression_rule",
"selected.source",
diff --git a/src/dispatch/static/dispatch/src/store.js b/src/dispatch/static/dispatch/src/store.js
index eb2fddf830dd..54f48383f3d4 100644
--- a/src/dispatch/static/dispatch/src/store.js
+++ b/src/dispatch/static/dispatch/src/store.js
@@ -10,6 +10,7 @@ import case_severity from "@/case/severity/store"
import case_type from "@/case/type/store"
import definition from "@/definition/store"
import document from "@/document/store"
+import entity from "@/entity/store"
import feedback from "@/feedback/store"
import incident from "@/incident/store"
import incident_cost_type from "@/incident_cost_type/store"
@@ -54,6 +55,7 @@ export default new Vuex.Store({
case_type,
definition,
document,
+ entity,
feedback,
incident,
incident_cost_type,
From 26091af8a6d92748e62440608d2d3a0a19dff55e Mon Sep 17 00:00:00 2001
From: Will Sheldon <114631109+wssheldon@users.noreply.github.com>
Date: Mon, 13 Feb 2023 09:12:59 -0800
Subject: [PATCH 02/12] Add entity model to store type results and add entity
case tab
---
src/dispatch/api.py | 4 +
src/dispatch/case/models.py | 2 +
.../versions/2023-02-09_8746b4e292d2.py | 75 +++++--
src/dispatch/entity/models.py | 47 +++--
src/dispatch/entity/service.py | 113 ++++++++--
src/dispatch/entity/views.py | 50 ++---
src/dispatch/entity_type/__init__.py | 0
src/dispatch/entity_type/models.py | 59 ++++++
src/dispatch/entity_type/service.py | 98 +++++++++
src/dispatch/entity_type/views.py | 124 +++++++++++
src/dispatch/signal/flows.py | 16 +-
src/dispatch/signal/models.py | 19 +-
src/dispatch/signal/service.py | 143 ++++++++++---
src/dispatch/static/dispatch/components.d.ts | 197 +++++++++---------
.../static/dispatch/src/case/EditSheet.vue | 6 +
.../dispatch/src/entity/EntitiesTab.vue | 60 ++++++
.../static/dispatch/src/entity/EntityCard.vue | 125 +++++++++++
.../static/dispatch/src/entity/api.js | 20 +-
.../EntityTypeFilterCombobox.vue} | 39 ++--
.../EntityTypeSelect.vue} | 6 +-
.../{entity => entity_type}/NewEditSheet.vue | 51 ++++-
.../src/{entity => entity_type}/Table.vue | 14 +-
.../static/dispatch/src/entity_type/api.js | 29 +++
.../static/dispatch/src/entity_type/store.js | 163 +++++++++++++++
.../static/dispatch/src/router/config.js | 8 +-
.../static/dispatch/src/signal/EntityRule.vue | 108 +++++-----
.../dispatch/src/signal/NewEditSheet.vue | 4 +-
.../dispatch/src/signal/SignalInstanceTab.vue | 14 +-
.../static/dispatch/src/signal/Table.vue | 10 -
.../static/dispatch/src/signal/store.js | 1 +
src/dispatch/static/dispatch/src/store.js | 4 +-
31 files changed, 1256 insertions(+), 353 deletions(-)
create mode 100644 src/dispatch/entity_type/__init__.py
create mode 100644 src/dispatch/entity_type/models.py
create mode 100644 src/dispatch/entity_type/service.py
create mode 100644 src/dispatch/entity_type/views.py
create mode 100644 src/dispatch/static/dispatch/src/entity/EntitiesTab.vue
create mode 100644 src/dispatch/static/dispatch/src/entity/EntityCard.vue
rename src/dispatch/static/dispatch/src/{entity/EntityFilterCombobox.vue => entity_type/EntityTypeFilterCombobox.vue} (78%)
rename src/dispatch/static/dispatch/src/{entity/EntitySelect.vue => entity_type/EntityTypeSelect.vue} (95%)
rename src/dispatch/static/dispatch/src/{entity => entity_type}/NewEditSheet.vue (70%)
rename src/dispatch/static/dispatch/src/{entity => entity_type}/Table.vue (87%)
create mode 100644 src/dispatch/static/dispatch/src/entity_type/api.js
create mode 100644 src/dispatch/static/dispatch/src/entity_type/store.js
diff --git a/src/dispatch/api.py b/src/dispatch/api.py
index b4434ec68846..c5b8dffff525 100644
--- a/src/dispatch/api.py
+++ b/src/dispatch/api.py
@@ -22,6 +22,7 @@
from dispatch.definition.views import router as definition_router
from dispatch.document.views import router as document_router
from dispatch.entity.views import router as entity_router
+from dispatch.entity_type.views import router as entity_type_router
from dispatch.feedback.views import router as feedback_router
from dispatch.incident.priority.views import router as incident_priority_router
from dispatch.incident.severity.views import router as incident_severity_router
@@ -135,6 +136,9 @@ def get_organization_path(organization: OrganizationSlug):
authenticated_organization_api_router.include_router(
entity_router, prefix="/entity", tags=["entities"]
)
+authenticated_organization_api_router.include_router(
+ entity_type_router, prefix="/entity_type", tags=["entity_types"]
+)
authenticated_organization_api_router.include_router(tag_router, prefix="/tags", tags=["tags"])
authenticated_organization_api_router.include_router(
tag_type_router, prefix="/tag_types", tags=["tag_types"]
diff --git a/src/dispatch/case/models.py b/src/dispatch/case/models.py
index 97e5673552fc..550dc84923a5 100644
--- a/src/dispatch/case/models.py
+++ b/src/dispatch/case/models.py
@@ -25,6 +25,7 @@
from dispatch.database.core import Base
from dispatch.document.models import Document, DocumentRead
from dispatch.enums import Visibility
+from dispatch.entity.models import EntityRead
from dispatch.event.models import EventRead
from dispatch.group.models import Group, GroupRead
from dispatch.incident.models import IncidentReadMinimal
@@ -165,6 +166,7 @@ class SignalRead(DispatchBase):
class SignalInstanceRead(DispatchBase):
signal: SignalRead
+ entities: Optional[List[EntityRead]] = []
tags: Optional[List[TagRead]] = []
raw: Any
fingerprint: str
diff --git a/src/dispatch/database/revisions/tenant/versions/2023-02-09_8746b4e292d2.py b/src/dispatch/database/revisions/tenant/versions/2023-02-09_8746b4e292d2.py
index 531526182b1a..4943f60b3ae1 100644
--- a/src/dispatch/database/revisions/tenant/versions/2023-02-09_8746b4e292d2.py
+++ b/src/dispatch/database/revisions/tenant/versions/2023-02-09_8746b4e292d2.py
@@ -1,4 +1,4 @@
-"""empty message
+"""Adds entity and entity type tables and associations
Revision ID: 8746b4e292d2
Revises: 941efd922446
@@ -7,8 +7,7 @@
"""
from alembic import op
import sqlalchemy as sa
-from sqlalchemy.dialects import postgresql
-import sqlalchemy_utils
+from sqlalchemy.dialects.postgresql import TSVECTOR, UUID
# revision identifiers, used by Alembic.
revision = "8746b4e292d2"
@@ -18,15 +17,19 @@
def upgrade():
- # ### commands auto generated by Alembic - please adjust! ###
+ # ### commands auto generated by Alembic - please adjust!
+
+ # EntityType
op.create_table(
- "entity",
+ "entity_type",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=True),
sa.Column("description", sa.String(), nullable=True),
+ sa.Column("field", sa.String(), nullable=True),
sa.Column("regular_expression", sa.String(), nullable=True),
sa.Column("global_find", sa.Boolean(), nullable=True),
- sa.Column("search_vector", sqlalchemy_utils.types.ts_vector.TSVectorType(), nullable=True),
+ sa.Column("enabled", sa.Boolean(), nullable=True),
+ sa.Column("search_vector", TSVECTOR, nullable=True),
sa.Column("project_id", sa.Integer(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
@@ -35,35 +38,65 @@ def upgrade():
sa.UniqueConstraint("name", "project_id"),
)
op.create_table(
- "assoc_signal_entities",
+ "assoc_signal_entity_types",
sa.Column("signal_id", sa.Integer(), nullable=False),
- sa.Column("entity_id", sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(["entity_id"], ["entity.id"], ondelete="CASCADE"),
+ sa.Column("entity_type_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["signal_id"], ["signal.id"], ondelete="CASCADE"),
- sa.PrimaryKeyConstraint("signal_id", "entity_id"),
+ sa.ForeignKeyConstraint(["entity_type_id"], ["entity_type.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("signal_id", "entity_type_id"),
)
+ op.create_index(
+ "entity_type_search_vector_idx",
+ "entity_type",
+ ["search_vector"],
+ unique=False,
+ postgresql_using="gin",
+ )
+
+ # Entity
op.create_table(
- "assoc_signal_instance_entities",
- sa.Column("signal_instance_id", postgresql.UUID(), nullable=False),
- sa.Column("entity_id", sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(["signal_instance_id"], ["signal_instance.id"], ondelete="CASCADE"),
- sa.ForeignKeyConstraint(["entity_id"], ["entity.id"], ondelete="CASCADE"),
- sa.PrimaryKeyConstraint("signal_instance_id", "entity_id"),
+ "entity",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("name", sa.String(), nullable=True),
+ sa.Column("description", sa.String(), nullable=True),
+ sa.Column("value", sa.String(), nullable=True),
+ sa.Column("source", sa.Boolean(), nullable=True),
+ sa.Column("entity_type_id", sa.Integer(), nullable=False),
+ sa.Column("search_vector", TSVECTOR, nullable=True),
+ sa.Column("project_id", sa.Integer(), nullable=True),
+ sa.Column("updated_at", sa.DateTime(), nullable=True),
+ sa.Column("created_at", sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(
+ ["entity_type_id"],
+ ["entity_type.id"],
+ ),
+ sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("name", "project_id"),
)
op.create_index(
- "entity_search_vector_idx",
+ "ix_entity_search_vector",
"entity",
["search_vector"],
unique=False,
postgresql_using="gin",
)
- # ### end Alembic commands ###
+ op.create_table(
+ "assoc_signal_instance_entities",
+ sa.Column("signal_instance_id", UUID(), nullable=False),
+ sa.Column("entity_id", sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(["signal_instance_id"], ["signal_instance.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["entity_id"], ["entity.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("signal_instance_id", "entity_id"),
+ )
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index("ix_entity_search_vector", table_name="entity")
op.drop_table("entity")
- op.drop_index("entity_search_vector_idx", table_name="entity", postgresql_using="gin")
- op.drop_table("assoc_signal_entities")
- op.drop_table("assoc_signal_instance_entities")
+ op.drop_table("entity_type")
+ op.drop_index("entity_type_search_vector_idx", table_name="entity", postgresql_using="gin")
+ op.drop_table("assoc_signal_entity_types")
+ op.drop_table("assoc_signal_instance_entity_types")
# ### end Alembic commands ###
diff --git a/src/dispatch/entity/models.py b/src/dispatch/entity/models.py
index 1d9d35302b67..f35596f1c7f0 100644
--- a/src/dispatch/entity/models.py
+++ b/src/dispatch/entity/models.py
@@ -1,54 +1,71 @@
-from typing import List, Optional
-from pydantic import StrictBool, Field
+from typing import Optional, List
+from pydantic import Field
-from sqlalchemy import Column, Integer, String
+from sqlalchemy import Column, Integer, String, ForeignKey
+from sqlalchemy.orm import relationship
from sqlalchemy.sql.schema import UniqueConstraint
-from sqlalchemy.sql.sqltypes import Boolean
from sqlalchemy_utils import TSVectorType
from dispatch.database.core import Base
-from dispatch.models import DispatchBase, NameStr, TimeStampMixin, ProjectMixin, PrimaryKey
+from dispatch.models import DispatchBase, TimeStampMixin, ProjectMixin, PrimaryKey
from dispatch.project.models import ProjectRead
+from dispatch.entity_type.models import (
+ EntityTypeRead,
+ EntityTypeCreate,
+ EntityTypeUpdate,
+)
class Entity(Base, TimeStampMixin, ProjectMixin):
__table_args__ = (UniqueConstraint("name", "project_id"),)
+
+ # Columns
id = Column(Integer, primary_key=True)
name = Column(String)
description = Column(String)
- regular_expression = Column(String)
- global_find = Column(Boolean, default=False)
+ value = Column(String)
+ source = Column(String)
+
+ # Relationships
+ entity_type_id = Column(Integer, ForeignKey("entity_type.id"), nullable=False)
+ entity_type = relationship("EntityType", backref="entity")
+
+ # the catalog here is simple to help matching "named entities"
search_vector = Column(TSVectorType("name", regconfig="pg_catalog.simple"))
# Pydantic models
class EntityBase(DispatchBase):
- name: NameStr
+ name: Optional[str] = Field(None, nullable=True)
+ source: Optional[str] = Field(None, nullable=True)
+ value: Optional[str] = Field(None, nullable=True)
description: Optional[str] = Field(None, nullable=True)
- global_find: Optional[StrictBool]
- regular_expression: Optional[str] = Field(None, nullable=True)
class EntityCreate(EntityBase):
+ id: Optional[PrimaryKey]
+ entity_type: EntityTypeCreate
project: ProjectRead
class EntityUpdate(EntityBase):
- id: PrimaryKey = None
+ id: Optional[PrimaryKey]
+ entity_type: Optional[EntityTypeUpdate]
class EntityRead(EntityBase):
id: PrimaryKey
- global_find: Optional[StrictBool]
+ entity_type: Optional[EntityTypeRead]
project: ProjectRead
class EntityReadMinimal(DispatchBase):
id: PrimaryKey
- name: NameStr
+ name: Optional[str] = Field(None, nullable=True)
+ source: Optional[str] = Field(None, nullable=True)
+ value: Optional[str] = Field(None, nullable=True)
description: Optional[str] = Field(None, nullable=True)
- global_find: Optional[StrictBool]
- regular_expression: Optional[str] = Field(None, nullable=True)
+ entity_type: Optional[EntityTypeRead]
class EntityPagination(DispatchBase):
diff --git a/src/dispatch/entity/service.py b/src/dispatch/entity/service.py
index d9675d21264f..ddfe384b5167 100644
--- a/src/dispatch/entity/service.py
+++ b/src/dispatch/entity/service.py
@@ -1,11 +1,16 @@
+from datetime import datetime, timedelta
from typing import Optional
-
from pydantic.error_wrappers import ErrorWrapper, ValidationError
-from sqlalchemy.orm import Query, Session
+from sqlalchemy.orm import Session
from dispatch.exceptions import NotFoundError
from dispatch.project import service as project_service
-from .models import Entity, EntityCreate, EntityRead, EntityUpdate
+from dispatch.case.models import Case
+from dispatch.entity.models import Entity
+from dispatch.entity_type import service as entity_type_service
+from dispatch.signal.models import SignalInstance
+
+from .models import Entity, EntityCreate, EntityUpdate, EntityRead
def get(*, db_session, entity_id: int) -> Optional[Entity]:
@@ -13,8 +18,8 @@ def get(*, db_session, entity_id: int) -> Optional[Entity]:
return db_session.query(Entity).filter(Entity.id == entity_id).one_or_none()
-def get_by_name(*, db_session: Session, project_id: int, name: str) -> Optional[Entity]:
- """Gets a entity by its name."""
+def get_by_name(*, db_session, project_id: int, name: str) -> Optional[Entity]:
+ """Gets a entity by its project and name."""
return (
db_session.query(Entity)
.filter(Entity.name == name)
@@ -23,7 +28,7 @@ def get_by_name(*, db_session: Session, project_id: int, name: str) -> Optional[
)
-def get_by_name_or_raise(*, db_session: Session, project_id: int, entity_in=EntityRead) -> Entity:
+def get_by_name_or_raise(*, db_session, project_id: int, entity_in=EntityRead) -> EntityRead:
"""Returns the entity specified or raises ValidationError."""
entity = get_by_name(db_session=db_session, project_id=project_id, name=entity_in.name)
@@ -31,7 +36,10 @@ def get_by_name_or_raise(*, db_session: Session, project_id: int, entity_in=Enti
raise ValidationError(
[
ErrorWrapper(
- NotFoundError(msg="Entity not found.", entity=entity_in.name),
+ NotFoundError(
+ msg="Entity not found.",
+ entity=entity_in.name,
+ ),
loc="entity",
)
],
@@ -41,29 +49,48 @@ def get_by_name_or_raise(*, db_session: Session, project_id: int, entity_in=Enti
return entity
-def get_all(*, db_session: Session) -> Query:
- """Gets all entitys."""
- return db_session.query(Entity)
+def get_by_value(*, db_session, project_id: int, value: str) -> Optional[Entity]:
+ """Gets a entity by its value."""
+ return (
+ db_session.query(Entity)
+ .filter(Entity.value == value)
+ .filter(Entity.project_id == project_id)
+ .one_or_none()
+ )
+
+
+def get_all(*, db_session, project_id: int):
+ """Gets all entities by their project."""
+ return db_session.query(Entity).filter(Entity.project_id == project_id)
-def create(*, db_session: Session, entity_in: EntityCreate) -> Entity:
+def create(*, db_session, entity_in: EntityCreate) -> Entity:
"""Creates a new entity."""
project = project_service.get_by_name_or_raise(
db_session=db_session, project_in=entity_in.project
)
- entity = Entity(**entity_in.dict(exclude={"project"}), project=project)
+ entity_type = entity_type_service.get_or_create(
+ db_session=db_session, entity_type_in=entity_in.entity_type
+ )
+ entity = Entity(
+ **entity_in.dict(exclude={"entity_type", "project"}),
+ project=project,
+ entity_type=entity_type,
+ )
+ entity.entity_type = entity_type
+ entity.project = project
db_session.add(entity)
db_session.commit()
return entity
-def get_or_create(*, db_session: Session, entity_in: EntityCreate) -> Entity:
+def get_by_value_or_create(*, db_session, entity_in: EntityCreate) -> Entity:
"""Gets or creates a new entity."""
- q = (
- db_session.query(Entity)
- .filter(Entity.name == entity_in.name)
- .filter(Entity.project_id == entity_in.project.id)
- )
+ # prefer the entity id if available
+ if entity_in.id:
+ q = db_session.query(Entity).filter(Entity.id == entity_in.id)
+ else:
+ q = db_session.query(Entity).filter_by(value=entity_in.value)
instance = q.first()
if instance:
@@ -72,21 +99,61 @@ def get_or_create(*, db_session: Session, entity_in: EntityCreate) -> Entity:
return create(db_session=db_session, entity_in=entity_in)
-def update(*, db_session: Session, entity: Entity, entity_in: EntityUpdate) -> Entity:
- """Updates an entity."""
+def update(*, db_session, entity: Entity, entity_in: EntityUpdate) -> Entity:
+ """Updates an existing entity."""
entity_data = entity.dict()
- update_data = entity_in.dict(skip_defaults=True)
+ update_data = entity_in.dict(skip_defaults=True, exclude={"entity_type"})
for field in entity_data:
if field in update_data:
setattr(entity, field, update_data[field])
+ if entity_in.entity_type is not None:
+ entity_type = entity_type_service.get_by_name_or_raise(
+ db_session=db_session,
+ project_id=entity.project.id,
+ entity_type_in=entity_in.entity_type,
+ )
+ entity.entity_type = entity_type
+
db_session.commit()
return entity
-def delete(*, db_session: Session, entity_id: int) -> None:
- """Deletes an entity."""
+def delete(*, db_session, entity_id: int):
+ """Deletes an existing entity."""
entity = db_session.query(Entity).filter(Entity.id == entity_id).one_or_none()
db_session.delete(entity)
db_session.commit()
+
+
+def get_cases_with_entity(db: Session, entity_id: int, days_back: int) -> int:
+ start_date = datetime.utcnow() - timedelta(days=days_back)
+ cases = (
+ db.query(Case)
+ .join(Case.signal_instances)
+ .join(SignalInstance.entities)
+ .filter(Entity.id == entity_id, SignalInstance.created_at >= start_date)
+ .all()
+ )
+ return cases
+
+
+def get_signal_instances_with_entity(
+ db: Session, entity_id: int, days_back: int
+) -> list[SignalInstance]:
+ """
+ Searches for signal instances with the same entity within a given timeframe.
+ """
+ # Calculate the datetime for the start of the search window
+ start_date = datetime.utcnow() - timedelta(days=days_back)
+
+ # Query for signal instances containing the entity within the search window
+ signal_instances = (
+ db.query(SignalInstance)
+ .join(SignalInstance.entities)
+ .filter(SignalInstance.created_at >= start_date, Entity.id == entity_id)
+ .all()
+ )
+
+ return signal_instances
diff --git a/src/dispatch/entity/views.py b/src/dispatch/entity/views.py
index c966d68554be..2cd8258e8517 100644
--- a/src/dispatch/entity/views.py
+++ b/src/dispatch/entity/views.py
@@ -1,11 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException, status
-from pydantic.error_wrappers import ErrorWrapper, ValidationError
-from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
-from dispatch.database.core import get_db
-from dispatch.exceptions import ExistsError
from dispatch.database.service import common_parameters, search_filter_sort_paginate
+from dispatch.entity.service import get_cases_with_entity, get_signal_instances_with_entity
from dispatch.models import PrimaryKey
from .models import (
@@ -20,61 +17,46 @@
@router.get("", response_model=EntityPagination)
-def get_entities(*, common: dict = Depends(common_parameters)):
- """Get all entities, or only those matching a given search term."""
+def get_entitys(*, common: dict = Depends(common_parameters)):
+ """Get all entitys, or only those matching a given search term."""
return search_filter_sort_paginate(model="Entity", **common)
@router.get("/{entity_id}", response_model=EntityRead)
def get_entity(*, db_session: Session = Depends(get_db), entity_id: PrimaryKey):
- """Get a entity by its id."""
+ """Given its unique id, retrieve details about a single entity."""
entity = get(db_session=db_session, entity_id=entity_id)
if not entity:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
- detail=[{"msg": "A entity with this id does not exist."}],
+ detail=[{"msg": "The requested entity does not exist."}],
)
return entity
@router.post("", response_model=EntityRead)
def create_entity(*, db_session: Session = Depends(get_db), entity_in: EntityCreate):
- """Create a new entity."""
- try:
- entity = create(db_session=db_session, entity_in=entity_in)
- except IntegrityError:
- raise ValidationError(
- [ErrorWrapper(ExistsError(msg="A entity with this name already exists."), loc="name")],
- model=EntityCreate,
- )
- return entity
+ """Creates a new entity."""
+ return create(db_session=db_session, entity_in=entity_in)
@router.put("/{entity_id}", response_model=EntityRead)
def update_entity(
*, db_session: Session = Depends(get_db), entity_id: PrimaryKey, entity_in: EntityUpdate
):
- """Update an entity."""
+ """Updates an exisiting entity."""
entity = get(db_session=db_session, entity_id=entity_id)
if not entity:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=[{"msg": "A entity with this id does not exist."}],
)
-
- try:
- entity = update(db_session=db_session, entity=entity, entity_in=entity_in)
- except IntegrityError:
- raise ValidationError(
- [ErrorWrapper(ExistsError(msg="A entity with this name already exists."), loc="name")],
- model=EntityUpdate,
- )
- return entity
+ return update(db_session=db_session, entity=entity, entity_in=entity_in)
@router.delete("/{entity_id}", response_model=None)
def delete_entity(*, db_session: Session = Depends(get_db), entity_id: PrimaryKey):
- """Delete an entity."""
+ """Deletes a entity, returning only an HTTP 200 OK if successful."""
entity = get(db_session=db_session, entity_id=entity_id)
if not entity:
raise HTTPException(
@@ -82,3 +64,15 @@ def delete_entity(*, db_session: Session = Depends(get_db), entity_id: PrimaryKe
detail=[{"msg": "A entity with this id does not exist."}],
)
delete(db_session=db_session, entity_id=entity_id)
+
+
+@router.get("/{entity_id}/cases", response_model=None)
+def count_cases_with_entity(*, db_session: Session = Depends(get_db), entity_id: PrimaryKey):
+ cases = get_cases_with_entity(db=db_session, entity_id=entity_id, days_back=30)
+ return {"cases": cases}
+
+
+@router.get("/{entity_id}/signal_instances", response_model=None)
+def get_signal_instances_by_entity(entity_id: int, db: Session = Depends(get_db)):
+ instances = get_signal_instances_with_entity(db, entity_id, days_back=30)
+ return {"instances": instances}
diff --git a/src/dispatch/entity_type/__init__.py b/src/dispatch/entity_type/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/src/dispatch/entity_type/models.py b/src/dispatch/entity_type/models.py
new file mode 100644
index 000000000000..caf6718d587b
--- /dev/null
+++ b/src/dispatch/entity_type/models.py
@@ -0,0 +1,59 @@
+from typing import List, Optional
+from pydantic import StrictBool, Field
+
+from sqlalchemy import Column, Integer, String
+from sqlalchemy.sql.schema import UniqueConstraint
+from sqlalchemy.sql.sqltypes import Boolean
+from sqlalchemy_utils import TSVectorType
+
+from dispatch.database.core import Base
+from dispatch.models import DispatchBase, NameStr, TimeStampMixin, ProjectMixin, PrimaryKey
+from dispatch.project.models import ProjectRead
+
+
+class EntityType(Base, TimeStampMixin, ProjectMixin):
+ __table_args__ = (UniqueConstraint("name", "project_id"),)
+ id = Column(Integer, primary_key=True)
+ name = Column(String)
+ description = Column(String)
+ field = Column(String)
+ regular_expression = Column(String)
+ global_find = Column(Boolean, default=False)
+ enabled = Column(Boolean, default=False)
+ search_vector = Column(TSVectorType("name", regconfig="pg_catalog.simple"))
+
+
+# Pydantic models
+class EntityTypeBase(DispatchBase):
+ name: Optional[NameStr]
+ description: Optional[str] = Field(None, nullable=True)
+ field: Optional[str] = Field(None, nullable=True)
+ global_find: Optional[StrictBool]
+ enabled: Optional[bool]
+ regular_expression: Optional[str] = Field(None, nullable=True)
+
+
+class EntityTypeCreate(EntityTypeBase):
+ project: ProjectRead
+
+
+class EntityTypeUpdate(EntityTypeBase):
+ id: PrimaryKey = None
+
+
+class EntityTypeRead(EntityTypeBase):
+ id: PrimaryKey = None
+
+
+class EntityTypeReadMinimal(DispatchBase):
+ id: PrimaryKey
+ name: NameStr
+ description: Optional[str] = Field(None, nullable=True)
+ global_find: Optional[StrictBool]
+ enabled: Optional[bool]
+ regular_expression: Optional[str] = Field(None, nullable=True)
+
+
+class EntityTypePagination(DispatchBase):
+ items: List[EntityTypeRead]
+ total: int
diff --git a/src/dispatch/entity_type/service.py b/src/dispatch/entity_type/service.py
new file mode 100644
index 000000000000..c245c21af361
--- /dev/null
+++ b/src/dispatch/entity_type/service.py
@@ -0,0 +1,98 @@
+from typing import Optional
+
+from pydantic.error_wrappers import ErrorWrapper, ValidationError
+from sqlalchemy.orm import Query, Session
+
+from dispatch.exceptions import NotFoundError
+from dispatch.project import service as project_service
+from .models import EntityType, EntityTypeCreate, EntityTypeRead, EntityTypeUpdate
+
+
+def get(*, db_session, entity_type_id: int) -> Optional[EntityType]:
+ """Gets a entity type by its id."""
+ return db_session.query(EntityType).filter(EntityType.id == entity_type_id).one_or_none()
+
+
+def get_by_name(*, db_session: Session, project_id: int, name: str) -> Optional[EntityType]:
+ """Gets a entity type by its name."""
+ return (
+ db_session.query(EntityType)
+ .filter(EntityType.name == name)
+ .filter(EntityType.project_id == project_id)
+ .one_or_none()
+ )
+
+
+def get_by_name_or_raise(
+ *, db_session: Session, project_id: int, entity_type_in=EntityTypeRead
+) -> EntityType:
+ """Returns the entity type specified or raises ValidationError."""
+ entity_type = get_by_name(
+ db_session=db_session, project_id=project_id, name=entity_type_in.name
+ )
+
+ if not entity_type:
+ raise ValidationError(
+ [
+ ErrorWrapper(
+ NotFoundError(msg="Entity not found.", entity_type=entity_type_in.name),
+ loc="entity",
+ )
+ ],
+ model=EntityTypeRead,
+ )
+
+ return entity_type
+
+
+def get_all(*, db_session: Session) -> Query:
+ """Gets all entity types."""
+ return db_session.query(EntityType)
+
+
+def create(*, db_session: Session, entity_type_in: EntityTypeCreate) -> EntityType:
+ """Creates a new entity type."""
+ project = project_service.get_by_name_or_raise(
+ db_session=db_session, project_in=entity_type_in.project
+ )
+ entity_type = EntityType(**entity_type_in.dict(exclude={"project"}), project=project)
+ db_session.add(entity_type)
+ db_session.commit()
+ return entity_type
+
+
+def get_or_create(*, db_session: Session, entity_type_in: EntityTypeCreate) -> EntityType:
+ """Gets or creates a new entity type."""
+ q = (
+ db_session.query(EntityType)
+ .filter(EntityType.name == entity_type_in.name)
+ .filter(EntityType.project_id == entity_type_in.project.id)
+ )
+
+ instance = q.first()
+ if instance:
+ return instance
+
+ return create(db_session=db_session, entity_type_in=entity_type_in)
+
+
+def update(
+ *, db_session: Session, entity_type: EntityType, entity_type_in: EntityTypeUpdate
+) -> EntityType:
+ """Updates an entity type."""
+ entity_type_data = entity_type.dict()
+ update_data = entity_type_in.dict(skip_defaults=True)
+
+ for field in entity_type_data:
+ if field in update_data:
+ setattr(entity_type, field, update_data[field])
+
+ db_session.commit()
+ return entity_type
+
+
+def delete(*, db_session: Session, entity_type_id: int) -> None:
+ """Deletes an entity type."""
+ entity_type = db_session.query(EntityType).filter(EntityType.id == entity_type_id)
+ db_session.delete(entity_type.one_or_none)
+ db_session.commit()
diff --git a/src/dispatch/entity_type/views.py b/src/dispatch/entity_type/views.py
new file mode 100644
index 000000000000..1270813db7e5
--- /dev/null
+++ b/src/dispatch/entity_type/views.py
@@ -0,0 +1,124 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from pydantic.error_wrappers import ErrorWrapper, ValidationError
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session
+
+from dispatch.database.core import get_db
+from dispatch.exceptions import ExistsError
+from dispatch.database.service import common_parameters, search_filter_sort_paginate
+from dispatch.models import PrimaryKey
+
+from .models import (
+ EntityTypeCreate,
+ EntityTypePagination,
+ EntityTypeRead,
+ EntityTypeUpdate,
+)
+from .service import create, delete, get, update
+
+router = APIRouter()
+
+
+@router.get("", response_model=EntityTypePagination)
+def get_entity_types(*, common: dict = Depends(common_parameters)):
+ """Get all entities, or only those matching a given search term."""
+ return search_filter_sort_paginate(model="EntityType", **common)
+
+
+@router.get("/{entity_type_id}", response_model=EntityTypeRead)
+def get_entity_type(*, db_session: Session = Depends(get_db), entity_type_id: PrimaryKey):
+ """Get a entity by its id."""
+ entity_type = get(db_session=db_session, entity_type_id=entity_type_id)
+ if not entity_type:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=[{"msg": "A entity_type with this id does not exist."}],
+ )
+ return entity_type
+
+
+@router.post("", response_model=EntityTypeRead)
+def create_entity_type(*, db_session: Session = Depends(get_db), entity_type_in: EntityTypeCreate):
+ """Create a new entity."""
+ try:
+ entity = create(db_session=db_session, entity_type_in=entity_type_in)
+ except IntegrityError:
+ raise ValidationError(
+ [ErrorWrapper(ExistsError(msg="A entity with this name already exists."), loc="name")],
+ model=EntityTypeCreate,
+ )
+ return entity
+
+
+@router.put("/{entity_type_id}", response_model=EntityTypeRead)
+def update_entity_type(
+ *,
+ db_session: Session = Depends(get_db),
+ entity_type_id: PrimaryKey,
+ entity_type_in: EntityTypeUpdate,
+):
+ """Update an entity."""
+ entity_type = get(db_session=db_session, entity_type_id=entity_type_id)
+ if not entity_type:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=[{"msg": "A entity with this id does not exist."}],
+ )
+
+ try:
+ entity_type = update(
+ db_session=db_session, entity_type=entity_type, entity_type_in=entity_type_in
+ )
+ except IntegrityError:
+ raise ValidationError(
+ [
+ ErrorWrapper(
+ ExistsError(msg="A entity type with this name already exists."), loc="name"
+ )
+ ],
+ model=EntityTypeUpdate,
+ )
+ return entity_type
+
+
+@router.put("/{entity_type_id}/process", response_model=EntityTypeRead)
+def process_entity_type(
+ *,
+ db_session: Session = Depends(get_db),
+ entity_type_id: PrimaryKey,
+ entity_type_in: EntityTypeUpdate,
+):
+ """Process an entity type."""
+ entity_type = get(db_session=db_session, entity_type_id=entity_type_id)
+ if not entity_type:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=[{"msg": "A entity with this id does not exist."}],
+ )
+
+ try:
+ entity_type = update(
+ db_session=db_session, entity_type=entity_type, entity_type_in=entity_type_in
+ )
+ except IntegrityError:
+ raise ValidationError(
+ [
+ ErrorWrapper(
+ ExistsError(msg="A entity type with this name already exists."), loc="name"
+ )
+ ],
+ model=EntityTypeUpdate,
+ )
+ return entity_type
+
+
+@router.delete("/{entity_type_id}", response_model=None)
+def delete_entity_type(*, db_session: Session = Depends(get_db), entity_type_id: PrimaryKey):
+ """Delete an entity."""
+ entity_type = get(db_session=db_session, entity_type_id=entity_type_id)
+ if not entity_type:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=[{"msg": "A entity type with this id does not exist."}],
+ )
+ delete(db_session=db_session, entity_id=entity_type_id)
diff --git a/src/dispatch/signal/flows.py b/src/dispatch/signal/flows.py
index 2c21dc623e72..54f93092279f 100644
--- a/src/dispatch/signal/flows.py
+++ b/src/dispatch/signal/flows.py
@@ -6,6 +6,7 @@
from dispatch.signal import service as signal_service
from dispatch.tag import service as tag_service
from dispatch.signal.models import SignalInstanceCreate, RawSignal
+from dispatch.entity.correlator import search_cases_with_entity
def create_signal_instance(
@@ -34,6 +35,13 @@ def create_signal_instance(
signal_instance.signal = signal
db_session.commit()
+ entities = signal_service.find_entities(
+ db_session=db_session,
+ signal_instance=signal_instance,
+ entity_types=signal.entity_types,
+ )
+ signal_instance.entities = entities
+
suppressed = signal_service.supress(
db_session=db_session,
signal_instance=signal_instance,
@@ -50,13 +58,7 @@ def create_signal_instance(
if duplicate:
return
- # entities = signal_service.find_entities(
- # db_session=db_session,
- # signal_instance=signal_instance,
- # duplication_rule=signal.entity_rule,
- # )
-
- # create a case if not duplicate or supressed
+ # # create a case if not duplicate or supressed
case_in = CaseCreate(
title=signal.name,
description=signal.description,
diff --git a/src/dispatch/signal/models.py b/src/dispatch/signal/models.py
index 6988a7488d95..99689272edad 100644
--- a/src/dispatch/signal/models.py
+++ b/src/dispatch/signal/models.py
@@ -26,6 +26,7 @@
from dispatch.case.type.models import CaseTypeRead, CaseType
from dispatch.case.priority.models import CasePriority, CasePriorityRead
from dispatch.entity.models import EntityRead
+from dispatch.entity_type.models import EntityTypeRead, EntityTypeUpdate, EntityTypeCreate
from dispatch.tag.models import TagRead
from dispatch.project.models import ProjectRead
from dispatch.data.source.models import SourceBase
@@ -62,12 +63,12 @@ class RuleMode(DispatchEnum):
PrimaryKeyConstraint("signal_instance_id", "entity_id"),
)
-assoc_signal_entities = Table(
- "assoc_signal_entities",
+assoc_signal_entity_types = Table(
+ "assoc_signal_entity_types",
Base.metadata,
Column("signal_id", Integer, ForeignKey("signal.id", ondelete="CASCADE")),
- Column("entity_id", Integer, ForeignKey("entity.id", ondelete="CASCADE")),
- PrimaryKeyConstraint("signal_id", "entity_id"),
+ Column("entity_type_id", Integer, ForeignKey("entity_type.id", ondelete="CASCADE")),
+ PrimaryKeyConstraint("signal_id", "entity_type_id"),
)
assoc_duplication_tag_types = Table(
@@ -126,9 +127,9 @@ class Signal(Base, TimeStampMixin, ProjectMixin):
case_priority = relationship("CasePriority", backref="signals")
duplication_rule_id = Column(Integer, ForeignKey(DuplicationRule.id))
duplication_rule = relationship("DuplicationRule", backref="signal")
- entities = relationship(
- "Entity",
- secondary=assoc_signal_entities,
+ entity_types = relationship(
+ "EntityType",
+ secondary=assoc_signal_entity_types,
backref="signals",
)
suppression_rule_id = Column(Integer, ForeignKey(SuppressionRule.id))
@@ -215,24 +216,28 @@ class SignalBase(DispatchBase):
external_url: Optional[str]
source: Optional[SourceBase]
created_at: Optional[datetime] = None
+ entity_types: Optional[List[EntityTypeRead]]
suppression_rule: Optional[SuppressionRuleRead]
duplication_rule: Optional[DuplicationRuleBase]
project: ProjectRead
class SignalCreate(SignalBase):
+ entity_types: Optional[EntityTypeCreate]
suppression_rule: Optional[SuppressionRuleCreate]
duplication_rule: Optional[DuplicationRuleCreate]
class SignalUpdate(SignalBase):
id: PrimaryKey
+ entity_types: Optional[List[EntityTypeUpdate]] = []
suppression_rule: Optional[SuppressionRuleUpdate]
duplication_rule: Optional[DuplicationRuleUpdate]
class SignalRead(SignalBase):
id: PrimaryKey
+ entity_types: Optional[List[EntityTypeRead]]
suppression_rule: Optional[SuppressionRuleRead]
duplication_rule: Optional[DuplicationRuleRead]
diff --git a/src/dispatch/signal/service.py b/src/dispatch/signal/service.py
index 75c8907a69fb..37b3e80be5e3 100644
--- a/src/dispatch/signal/service.py
+++ b/src/dispatch/signal/service.py
@@ -1,7 +1,10 @@
import json
import hashlib
import re
-from typing import Optional
+from typing import Optional, Sequence
+
+from sqlalchemy.orm import Session
+
from datetime import datetime, timedelta, timezone
from dispatch.enums import RuleMode
from dispatch.project import service as project_service
@@ -9,6 +12,10 @@
from dispatch.tag_type import service as tag_type_service
from dispatch.case.type import service as case_type_service
from dispatch.case.priority import service as case_priority_service
+from dispatch.entity_type import service as entity_type_service
+from dispatch.entity import service as entity_service
+from dispatch.entity.models import Entity, EntityCreate
+from dispatch.entity_type.models import EntityType
from .models import (
Signal,
@@ -174,6 +181,14 @@ def update(*, db_session, signal: Signal, signal_in: SignalUpdate) -> Signal:
if field in update_data:
setattr(signal, field, update_data[field])
+ if entity_types := signal_in.entity_types:
+ for entity_type in entity_types:
+ if entity_type.id:
+ entity_type = entity_type_service.get(
+ db_session=db_session, entity_type_id=entity_type.id
+ )
+ signal.entity_types.append(entity_type)
+
if signal_in.duplication_rule:
if signal_in.duplication_rule.id:
update_duplication_rule(
@@ -329,47 +344,113 @@ def supress(
return supressed
-def find_entities(d, signal_instance: SignalInstance):
- """
- Recursively search a dictionary for values that match a list of entities.
+def find_entities(
+ db_session: Session, signal_instance: SignalInstance, entity_types: Sequence[EntityType]
+) -> list[Entity]:
+ """Find entities of the given types in the raw data of a signal instance.
Args:
- - d (dict): The dictionary to search
- - entity_list (list): The list of entities to search for
+ db_session (Session): SQLAlchemy database session.
+ signal_instance (SignalInstance): SignalInstance to search for entities in.
+ entity_types (list[EntityType]): List of EntityType objects to search for.
Returns:
- - result (list): The values in the dictionary that match the entities
-
+ list[Entity]: List of Entity objects found.
+
+ Example:
+ >>> signal_instance = SignalInstance(
+ ... raw={
+ ... "name": "John Smith",
+ ... "email": "john.smith@example.com",
+ ... "phone": "555-555-1212",
+ ... "address": {
+ ... "street": "123 Main St",
+ ... "city": "Anytown",
+ ... "state": "CA",
+ ... "zip": "12345"
+ ... },
+ ... "notes": "Customer is interested in buying a product."
+ ... }
+ ... )
+ >>> entity_types = [
+ ... EntityType(name="Name", field="name", regular_expression=r"\b[A-Z][a-z]+ [A-Z][a-z]+\b"),
+ ... EntityType(name="Phone", field=None, regular_expression=r"\b\\d{3}[-.]?\\d{3}[-.]?\\d{4}\b"),
+ ... ]
+ >>> entities = find_entities(db_session, signal_instance, entity_types)
+
+ Notes:
+ This function uses depth-first search to traverse the raw data of the signal instance. It searches for
+ the regular expressions specified in the EntityType objects in the values of the dictionary, list, and
+ string objects encountered during the traversal. The search can be limited to a specific key in the
+ dictionary objects by specifying a value for the 'field' attribute of the EntityType object.
"""
- result = []
- entity_regexes = [
- re.compile(entity) for entity in signal_instance.entities if isinstance(entity, str)
- ]
- cache = {}
- def _search(val):
- """Helper function to search a value for entities"""
+ def search(key, val, entity_type_pairs):
+ # Create a list to hold any entities that are found in this value
+ entities = []
+
+ # If this value has been searched before, return the cached entities
if id(val) in cache:
- # If val has been searched before, return cached result
- result.extend(cache[id(val)])
- return
+ return cache[id(val)]
+ # If the value is a dictionary, search its key-value pairs recursively
if isinstance(val, dict):
- # If val is a dictionary, recursively search it
- entities_found = find_entities(val, signal_instance.entities)
- cache[id(val)] = entities_found
- result.extend(entities_found)
+ for subkey, subval in val.items():
+ entities.extend(search(subkey, subval, entity_type_pairs))
+
+ # If the value is a list, search its items recursively
elif isinstance(val, list):
- # If val is a list, search each item in the list
for item in val:
- _search(item)
+ entities.extend(search(None, item, entity_type_pairs))
+
+ # If the value is a string, search it for entity matches
elif isinstance(val, str):
- for entity_regex in entity_regexes:
- # If val is a string and matches any entity regex, add it to result
- if entity_regex.match(val):
- result.append(val)
+ for entity_type, entity_regex, field in entity_type_pairs:
+ import time
+
+ time.sleep(3)
+ # If a field was specified for this entity type, only search that field
+ if not field or key == field:
+ print(f"Checking {val} for {entity_type.name} using {entity_regex} in {field=}")
+ # Search the string for matches to the entity type's regular expression
+ if match := entity_regex.search(val):
+ print(f"Found match {match.group(0)} for {entity_type.name} in {field=}")
+ # If a match was found, create a new Entity object for it
+ entity = EntityCreate(
+ value=match.group(0),
+ entity_type=entity_type,
+ project=signal_instance.project,
+ )
+ # Add the entity to the list of entities found in this value
+ entities.append(entity)
+
+ # Cache the entities found for this value
+ cache[id(val)] = entities
+
+ return entities
+
+ # Create a list of (entity type, regular expression, field) tuples
+ entity_type_pairs = [
+ (type, re.compile(type.regular_expression), type.field)
+ for type in entity_types
+ if isinstance(type.regular_expression, str)
+ ]
+
+ # Initialize a cache of previously searched values
+ cache = {}
+
+ # Traverse the signal data using depth-first search
+ entities = [
+ entity
+ for key, val in signal_instance.raw.items()
+ for entity in search(key, val, entity_type_pairs)
+ ]
- for key, value in d.items():
- _search(value)
+ # Create the entities in the database and add them to the signal instance
+ entities_out = [
+ entity_service.get_or_create(db_session=db_session, entity_in=entity_in)
+ for entity_in in entities
+ ]
- return result
+ # Return the list of entities found
+ return entities_out
diff --git a/src/dispatch/static/dispatch/components.d.ts b/src/dispatch/static/dispatch/components.d.ts
index a5f27fd42bc7..437a8a801335 100644
--- a/src/dispatch/static/dispatch/components.d.ts
+++ b/src/dispatch/static/dispatch/components.d.ts
@@ -2,104 +2,105 @@
// We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/core/pull/3399
export {}
-
-declare module 'vue' {
+
+declare module "vue" {
export interface GlobalComponents {
- AdminLayout: typeof import('./src/components/layouts/AdminLayout.vue')['default']
- AppDrawer: typeof import('./src/components/AppDrawer.vue')['default']
- AppToolbar: typeof import('./src/components/AppToolbar.vue')['default']
- BasicLayout: typeof import('./src/components/layouts/BasicLayout.vue')['default']
- ColorPickerInput: typeof import('./src/components/ColorPickerInput.vue')['default']
- DashboardLayout: typeof import('./src/components/layouts/DashboardLayout.vue')['default']
- DateTimePickerMenu: typeof import('./src/components/DateTimePickerMenu.vue')['default']
- DateWindowInput: typeof import('./src/components/DateWindowInput.vue')['default']
- DefaultLayout: typeof import('./src/components/layouts/DefaultLayout.vue')['default']
- InfoWidget: typeof import('./src/components/InfoWidget.vue')['default']
- Loading: typeof import('./src/components/Loading.vue')['default']
- NotificationSnackbarsWrapper: typeof import('./src/components/NotificationSnackbarsWrapper.vue')['default']
- PageHeader: typeof import('./src/components/PageHeader.vue')['default']
- Refresh: typeof import('./src/components/Refresh.vue')['default']
- RouterLink: typeof import('vue-router')['RouterLink']
- RouterView: typeof import('vue-router')['RouterView']
- SettingsBreadcrumbs: typeof import('./src/components/SettingsBreadcrumbs.vue')['default']
- StatWidget: typeof import('./src/components/StatWidget.vue')['default']
- VAlert: typeof import('vuetify/lib')['VAlert']
- VApp: typeof import('vuetify/lib')['VApp']
- VAppBar: typeof import('vuetify/lib')['VAppBar']
- VAutocomplete: typeof import('vuetify/lib')['VAutocomplete']
- VAvatar: typeof import('vuetify/lib')['VAvatar']
- VBadge: typeof import('vuetify/lib')['VBadge']
- VBottomSheet: typeof import('vuetify/lib')['VBottomSheet']
- VBreadcrumbs: typeof import('vuetify/lib')['VBreadcrumbs']
- VBreadcrumbsItem: typeof import('vuetify/lib')['VBreadcrumbsItem']
- VBtn: typeof import('vuetify/lib')['VBtn']
- VCard: typeof import('vuetify/lib')['VCard']
- VCardActions: typeof import('vuetify/lib')['VCardActions']
- VCardSubtitle: typeof import('vuetify/lib')['VCardSubtitle']
- VCardText: typeof import('vuetify/lib')['VCardText']
- VCardTitle: typeof import('vuetify/lib')['VCardTitle']
- VCheckbox: typeof import('vuetify/lib')['VCheckbox']
- VChip: typeof import('vuetify/lib')['VChip']
- VChipGroup: typeof import('vuetify/lib')['VChipGroup']
- VCol: typeof import('vuetify/lib')['VCol']
- VColorPicker: typeof import('vuetify/lib')['VColorPicker']
- VCombobox: typeof import('vuetify/lib')['VCombobox']
- VContainer: typeof import('vuetify/lib')['VContainer']
- VDataTable: typeof import('vuetify/lib')['VDataTable']
- VDatePicker: typeof import('vuetify/lib')['VDatePicker']
- VDialog: typeof import('vuetify/lib')['VDialog']
- VDivider: typeof import('vuetify/lib')['VDivider']
- VExpansionPanel: typeof import('vuetify/lib')['VExpansionPanel']
- VExpansionPanelContent: typeof import('vuetify/lib')['VExpansionPanelContent']
- VExpansionPanelHeader: typeof import('vuetify/lib')['VExpansionPanelHeader']
- VExpansionPanels: typeof import('vuetify/lib')['VExpansionPanels']
- VFlex: typeof import('vuetify/lib')['VFlex']
- VForm: typeof import('vuetify/lib')['VForm']
- VIcon: typeof import('vuetify/lib')['VIcon']
- VItem: typeof import('vuetify/lib')['VItem']
- VLayout: typeof import('vuetify/lib')['VLayout']
- VList: typeof import('vuetify/lib')['VList']
- VListGroup: typeof import('vuetify/lib')['VListGroup']
- VListItem: typeof import('vuetify/lib')['VListItem']
- VListItemAction: typeof import('vuetify/lib')['VListItemAction']
- VListItemAvatar: typeof import('vuetify/lib')['VListItemAvatar']
- VListItemContent: typeof import('vuetify/lib')['VListItemContent']
- VListItemGroup: typeof import('vuetify/lib')['VListItemGroup']
- VListItemIcon: typeof import('vuetify/lib')['VListItemIcon']
- VListItemSubtitle: typeof import('vuetify/lib')['VListItemSubtitle']
- VListItemTitle: typeof import('vuetify/lib')['VListItemTitle']
- VMain: typeof import('vuetify/lib')['VMain']
- VMenu: typeof import('vuetify/lib')['VMenu']
- VNavigationDrawer: typeof import('vuetify/lib')['VNavigationDrawer']
- VProgressLinear: typeof import('vuetify/lib')['VProgressLinear']
- VRadio: typeof import('vuetify/lib')['VRadio']
- VRadioGroup: typeof import('vuetify/lib')['VRadioGroup']
- VRow: typeof import('vuetify/lib')['VRow']
- VSelect: typeof import('vuetify/lib')['VSelect']
- VSimpleCheckbox: typeof import('vuetify/lib')['VSimpleCheckbox']
- VSnackbar: typeof import('vuetify/lib')['VSnackbar']
- VSpacer: typeof import('vuetify/lib')['VSpacer']
- VStepper: typeof import('vuetify/lib')['VStepper']
- VStepperContent: typeof import('vuetify/lib')['VStepperContent']
- VStepperHeader: typeof import('vuetify/lib')['VStepperHeader']
- VStepperItems: typeof import('vuetify/lib')['VStepperItems']
- VStepperStep: typeof import('vuetify/lib')['VStepperStep']
- VSubheader: typeof import('vuetify/lib')['VSubheader']
- VSwitch: typeof import('vuetify/lib')['VSwitch']
- VSystemBar: typeof import('vuetify/lib')['VSystemBar']
- VTab: typeof import('vuetify/lib')['VTab']
- VTabItem: typeof import('vuetify/lib')['VTabItem']
- VTabs: typeof import('vuetify/lib')['VTabs']
- VTabsItems: typeof import('vuetify/lib')['VTabsItems']
- VTabsSlider: typeof import('vuetify/lib')['VTabsSlider']
- VTextarea: typeof import('vuetify/lib')['VTextarea']
- VTextField: typeof import('vuetify/lib')['VTextField']
- VTimeline: typeof import('vuetify/lib')['VTimeline']
- VTimelineItem: typeof import('vuetify/lib')['VTimelineItem']
- VTimePicker: typeof import('vuetify/lib')['VTimePicker']
- VToolbar: typeof import('vuetify/lib')['VToolbar']
- VToolbarItems: typeof import('vuetify/lib')['VToolbarItems']
- VToolbarTitle: typeof import('vuetify/lib')['VToolbarTitle']
- VTooltip: typeof import('vuetify/lib')['VTooltip']
+ AdminLayout: typeof import("./src/components/layouts/AdminLayout.vue")["default"]
+ AppDrawer: typeof import("./src/components/AppDrawer.vue")["default"]
+ AppToolbar: typeof import("./src/components/AppToolbar.vue")["default"]
+ BasicLayout: typeof import("./src/components/layouts/BasicLayout.vue")["default"]
+ ColorPickerInput: typeof import("./src/components/ColorPickerInput.vue")["default"]
+ DashboardLayout: typeof import("./src/components/layouts/DashboardLayout.vue")["default"]
+ DateTimePickerMenu: typeof import("./src/components/DateTimePickerMenu.vue")["default"]
+ DateWindowInput: typeof import("./src/components/DateWindowInput.vue")["default"]
+ DefaultLayout: typeof import("./src/components/layouts/DefaultLayout.vue")["default"]
+ InfoWidget: typeof import("./src/components/InfoWidget.vue")["default"]
+ Loading: typeof import("./src/components/Loading.vue")["default"]
+ NotificationSnackbarsWrapper: typeof import("./src/components/NotificationSnackbarsWrapper.vue")["default"]
+ PageHeader: typeof import("./src/components/PageHeader.vue")["default"]
+ Refresh: typeof import("./src/components/Refresh.vue")["default"]
+ RouterLink: typeof import("vue-router")["RouterLink"]
+ RouterView: typeof import("vue-router")["RouterView"]
+ SettingsBreadcrumbs: typeof import("./src/components/SettingsBreadcrumbs.vue")["default"]
+ StatWidget: typeof import("./src/components/StatWidget.vue")["default"]
+ VAlert: typeof import("vuetify/lib")["VAlert"]
+ VApp: typeof import("vuetify/lib")["VApp"]
+ VAppBar: typeof import("vuetify/lib")["VAppBar"]
+ VAutocomplete: typeof import("vuetify/lib")["VAutocomplete"]
+ VAvatar: typeof import("vuetify/lib")["VAvatar"]
+ VBadge: typeof import("vuetify/lib")["VBadge"]
+ VBottomSheet: typeof import("vuetify/lib")["VBottomSheet"]
+ VBreadcrumbs: typeof import("vuetify/lib")["VBreadcrumbs"]
+ VBreadcrumbsItem: typeof import("vuetify/lib")["VBreadcrumbsItem"]
+ VBtn: typeof import("vuetify/lib")["VBtn"]
+ VCard: typeof import("vuetify/lib")["VCard"]
+ VCardActions: typeof import("vuetify/lib")["VCardActions"]
+ VCardSubtitle: typeof import("vuetify/lib")["VCardSubtitle"]
+ VCardText: typeof import("vuetify/lib")["VCardText"]
+ VCardTitle: typeof import("vuetify/lib")["VCardTitle"]
+ VCheckbox: typeof import("vuetify/lib")["VCheckbox"]
+ VChip: typeof import("vuetify/lib")["VChip"]
+ VChipGroup: typeof import("vuetify/lib")["VChipGroup"]
+ VCol: typeof import("vuetify/lib")["VCol"]
+ VColorPicker: typeof import("vuetify/lib")["VColorPicker"]
+ VCombobox: typeof import("vuetify/lib")["VCombobox"]
+ VContainer: typeof import("vuetify/lib")["VContainer"]
+ VDataTable: typeof import("vuetify/lib")["VDataTable"]
+ VDatePicker: typeof import("vuetify/lib")["VDatePicker"]
+ VDialog: typeof import("vuetify/lib")["VDialog"]
+ VDivider: typeof import("vuetify/lib")["VDivider"]
+ VExpansionPanel: typeof import("vuetify/lib")["VExpansionPanel"]
+ VExpansionPanelContent: typeof import("vuetify/lib")["VExpansionPanelContent"]
+ VExpansionPanelHeader: typeof import("vuetify/lib")["VExpansionPanelHeader"]
+ VExpansionPanels: typeof import("vuetify/lib")["VExpansionPanels"]
+ VFlex: typeof import("vuetify/lib")["VFlex"]
+ VForm: typeof import("vuetify/lib")["VForm"]
+ VHover: typeof import("vuetify/lib")["VHover"]
+ VIcon: typeof import("vuetify/lib")["VIcon"]
+ VItem: typeof import("vuetify/lib")["VItem"]
+ VLayout: typeof import("vuetify/lib")["VLayout"]
+ VList: typeof import("vuetify/lib")["VList"]
+ VListGroup: typeof import("vuetify/lib")["VListGroup"]
+ VListItem: typeof import("vuetify/lib")["VListItem"]
+ VListItemAction: typeof import("vuetify/lib")["VListItemAction"]
+ VListItemAvatar: typeof import("vuetify/lib")["VListItemAvatar"]
+ VListItemContent: typeof import("vuetify/lib")["VListItemContent"]
+ VListItemGroup: typeof import("vuetify/lib")["VListItemGroup"]
+ VListItemIcon: typeof import("vuetify/lib")["VListItemIcon"]
+ VListItemSubtitle: typeof import("vuetify/lib")["VListItemSubtitle"]
+ VListItemTitle: typeof import("vuetify/lib")["VListItemTitle"]
+ VMain: typeof import("vuetify/lib")["VMain"]
+ VMenu: typeof import("vuetify/lib")["VMenu"]
+ VNavigationDrawer: typeof import("vuetify/lib")["VNavigationDrawer"]
+ VProgressLinear: typeof import("vuetify/lib")["VProgressLinear"]
+ VRadio: typeof import("vuetify/lib")["VRadio"]
+ VRadioGroup: typeof import("vuetify/lib")["VRadioGroup"]
+ VRow: typeof import("vuetify/lib")["VRow"]
+ VSelect: typeof import("vuetify/lib")["VSelect"]
+ VSimpleCheckbox: typeof import("vuetify/lib")["VSimpleCheckbox"]
+ VSnackbar: typeof import("vuetify/lib")["VSnackbar"]
+ VSpacer: typeof import("vuetify/lib")["VSpacer"]
+ VStepper: typeof import("vuetify/lib")["VStepper"]
+ VStepperContent: typeof import("vuetify/lib")["VStepperContent"]
+ VStepperHeader: typeof import("vuetify/lib")["VStepperHeader"]
+ VStepperItems: typeof import("vuetify/lib")["VStepperItems"]
+ VStepperStep: typeof import("vuetify/lib")["VStepperStep"]
+ VSubheader: typeof import("vuetify/lib")["VSubheader"]
+ VSwitch: typeof import("vuetify/lib")["VSwitch"]
+ VSystemBar: typeof import("vuetify/lib")["VSystemBar"]
+ VTab: typeof import("vuetify/lib")["VTab"]
+ VTabItem: typeof import("vuetify/lib")["VTabItem"]
+ VTabs: typeof import("vuetify/lib")["VTabs"]
+ VTabsItems: typeof import("vuetify/lib")["VTabsItems"]
+ VTabsSlider: typeof import("vuetify/lib")["VTabsSlider"]
+ VTextarea: typeof import("vuetify/lib")["VTextarea"]
+ VTextField: typeof import("vuetify/lib")["VTextField"]
+ VTimeline: typeof import("vuetify/lib")["VTimeline"]
+ VTimelineItem: typeof import("vuetify/lib")["VTimelineItem"]
+ VTimePicker: typeof import("vuetify/lib")["VTimePicker"]
+ VToolbar: typeof import("vuetify/lib")["VToolbar"]
+ VToolbarItems: typeof import("vuetify/lib")["VToolbarItems"]
+ VToolbarTitle: typeof import("vuetify/lib")["VToolbarTitle"]
+ VTooltip: typeof import("vuetify/lib")["VTooltip"]
}
}
diff --git a/src/dispatch/static/dispatch/src/case/EditSheet.vue b/src/dispatch/static/dispatch/src/case/EditSheet.vue
index 677d9d4da020..ba85fe2c48e0 100644
--- a/src/dispatch/static/dispatch/src/case/EditSheet.vue
+++ b/src/dispatch/static/dispatch/src/case/EditSheet.vue
@@ -40,6 +40,7 @@
Participants
Timeline
Workflows
+ Entities
Signals
@@ -58,6 +59,9 @@
+
+
+
@@ -77,6 +81,7 @@ import CaseResourcesTab from "@/case/ResourcesTab.vue"
import CaseTimelineTab from "@/case/TimelineTab.vue"
import WorkflowInstanceTab from "@/workflow/WorkflowInstanceTab.vue"
import SignalInstanceTab from "@/signal/SignalInstanceTab.vue"
+import EntitiesTab from "@/entity/EntitiesTab.vue"
export default {
name: "CaseEditSheet",
@@ -88,6 +93,7 @@ export default {
CaseTimelineTab,
WorkflowInstanceTab,
SignalInstanceTab,
+ EntitiesTab,
ValidationObserver,
},
diff --git a/src/dispatch/static/dispatch/src/entity/EntitiesTab.vue b/src/dispatch/static/dispatch/src/entity/EntitiesTab.vue
new file mode 100644
index 000000000000..98d193956c64
--- /dev/null
+++ b/src/dispatch/static/dispatch/src/entity/EntitiesTab.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/dispatch/static/dispatch/src/entity/EntityCard.vue b/src/dispatch/static/dispatch/src/entity/EntityCard.vue
new file mode 100644
index 000000000000..437720b5b1d0
--- /dev/null
+++ b/src/dispatch/static/dispatch/src/entity/EntityCard.vue
@@ -0,0 +1,125 @@
+
+
+
+
+ mdi-open-in-new
+
+
+ {{ entity.entity_type.name }}
+ {{ entity.value }}
+
+
+
+
+
+
+
+
+ Seen in {{ signalInstanceCount }} other signals
+
+
+ First time this entity has been seen in a signal
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Seen in {{ caseCount }} other cases
+
+
+ First time this entity has been seen in a case
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/dispatch/static/dispatch/src/entity/api.js b/src/dispatch/static/dispatch/src/entity/api.js
index e851ef4dbff9..20fa820bc49b 100644
--- a/src/dispatch/static/dispatch/src/entity/api.js
+++ b/src/dispatch/static/dispatch/src/entity/api.js
@@ -7,19 +7,27 @@ export default {
return API.get(`${resource}`, { params: { ...options } })
},
- get(entityId) {
- return API.get(`${resource}/${entityId}`)
+ get(EntityId) {
+ return API.get(`${resource}/${EntityId}`)
},
create(payload) {
return API.post(`${resource}`, payload)
},
- update(entityId, payload) {
- return API.put(`${resource}/${entityId}`, payload)
+ update(EntityId, payload) {
+ return API.put(`${resource}/${EntityId}`, payload)
},
- delete(entityId) {
- return API.delete(`${resource}/${entityId}`)
+ delete(EntityId) {
+ return API.delete(`${resource}/${EntityId}`)
+ },
+
+ async getCasesCount(EntityId) {
+ return await API.get(`${resource}/${EntityId}/cases`)
+ },
+
+ async getSignalInstances(EntityId) {
+ return await API.get(`${resource}/${EntityId}/signal_instances`)
},
}
diff --git a/src/dispatch/static/dispatch/src/entity/EntityFilterCombobox.vue b/src/dispatch/static/dispatch/src/entity_type/EntityTypeFilterCombobox.vue
similarity index 78%
rename from src/dispatch/static/dispatch/src/entity/EntityFilterCombobox.vue
rename to src/dispatch/static/dispatch/src/entity_type/EntityTypeFilterCombobox.vue
index 93c7696827c8..96d887cd04d5 100644
--- a/src/dispatch/static/dispatch/src/entity/EntityFilterCombobox.vue
+++ b/src/dispatch/static/dispatch/src/entity_type/EntityTypeFilterCombobox.vue
@@ -12,13 +12,13 @@
item-value="id"
multiple
no-filter
- v-model="entities"
+ v-model="entity_types"
>
- No entities matching "
+ No entity types matching "
{{ search }}"
@@ -26,8 +26,8 @@
-
- {{ item.name }}
+
+ {{ item.name }} {{ item.name }}
@@ -52,21 +52,19 @@
import { cloneDeep, debounce } from "lodash"
import SearchUtils from "@/search/utils"
-import EntitiesApi from "@/entities/api"
+import EntityTypeApi from "@/entity_type/api"
export default {
- name: "EntityCombobox",
+ name: "EntityTypeCombobox",
props: {
value: {
type: Array,
- default: function () {
- return []
- },
+ default: () => [],
},
label: {
type: String,
- default: "Add Entities",
+ default: "Add Entity Types",
},
model: {
type: String,
@@ -93,19 +91,12 @@ export default {
},
computed: {
- entities: {
+ entity_types: {
get() {
- return cloneDeep(this.value)
+ return this.value;
},
set(value) {
- this.search = null
- this._entities = value.filter((v) => {
- if (typeof v === "string") {
- return false
- }
- return true
- })
- this.$emit("input", this._entities)
+ this.$emit("input", value);
},
},
},
@@ -144,7 +135,7 @@ export default {
filterOptions = SearchUtils.createParametersFromTableOptions({ ...filterOptions })
}
- EntitiesApi.getAll(filterOptions).then((response) => {
+ EntityTypeApi.getAll(filterOptions).then((response) => {
this.items = response.data.items
this.total = response.data.total
@@ -163,8 +154,10 @@ export default {
},
filters: {
- filterGlobal: function(entities) {
- return entities.filter(entity => entity.global_find === false);
+ filterGlobal: function(entity_types) {
+ return entity_types.filter(
+ entity_type => entity_type.global_find === false
+ );
}
}
}
diff --git a/src/dispatch/static/dispatch/src/entity/EntitySelect.vue b/src/dispatch/static/dispatch/src/entity_type/EntityTypeSelect.vue
similarity index 95%
rename from src/dispatch/static/dispatch/src/entity/EntitySelect.vue
rename to src/dispatch/static/dispatch/src/entity_type/EntityTypeSelect.vue
index 814ffb8f5208..e66302b1b0aa 100644
--- a/src/dispatch/static/dispatch/src/entity/EntitySelect.vue
+++ b/src/dispatch/static/dispatch/src/entity_type/EntityTypeSelect.vue
@@ -34,10 +34,10 @@
import { cloneDeep } from "lodash"
import SearchUtils from "@/search/utils"
-import EntityApi from "@/entity/api"
+import EntityTypeApi from "@/entity_type/api"
export default {
- name: "EntitySelect",
+ name: "EntityTypeSelect",
props: {
value: {
@@ -97,7 +97,7 @@ export default {
filterOptions = SearchUtils.createParametersFromTableOptions({ ...filterOptions })
}
- EntityApi.getAll(filterOptions).then((response) => {
+ EntityTypeApi.getAll(filterOptions).then((response) => {
this.items = response.data.items
if (this.entity) {
diff --git a/src/dispatch/static/dispatch/src/entity/NewEditSheet.vue b/src/dispatch/static/dispatch/src/entity_type/NewEditSheet.vue
similarity index 70%
rename from src/dispatch/static/dispatch/src/entity/NewEditSheet.vue
rename to src/dispatch/static/dispatch/src/entity_type/NewEditSheet.vue
index 66400dbf8f6f..5a9b351291f1 100644
--- a/src/dispatch/static/dispatch/src/entity/NewEditSheet.vue
+++ b/src/dispatch/static/dispatch/src/entity_type/NewEditSheet.vue
@@ -37,12 +37,25 @@
:error-messages="errors"
:success="valid"
label="Name"
- hint="A name for your entity."
+ hint="A name for your entity type."
clearable
required
/>
+
+
+
+
+
-
+
+
+
+
+
+
mdi-information
- Checking this box collect the entity from all signals.
+ Checking this box collects the entity from all signal instances.
+
+
+
+
@@ -102,7 +135,7 @@ extend('regexp', {
});
export default {
- name: "EntityNewEditSheet",
+ name: "EntityTypeNewEditSheet",
components: {
ValidationObserver,
@@ -110,24 +143,26 @@ export default {
},
computed: {
- ...mapFields("entity", [
+ ...mapFields("entity_type", [
"dialogs.showCreateEdit",
"selected.id",
"selected.name",
"selected.description",
+ "selected.field",
"selected.project",
"selected.regular_expression",
"selected.global_find",
+ "selected.enabled",
"selected.loading",
]),
- ...mapFields("entity", {
+ ...mapFields("entity_type", {
default_entity: "selected.default",
}),
...mapFields("route", ["query"]),
},
methods: {
- ...mapActions("entity", ["save", "closeCreateEdit"]),
+ ...mapActions("entity_type", ["save", "closeCreateEdit"]),
},
created() {
diff --git a/src/dispatch/static/dispatch/src/entity/Table.vue b/src/dispatch/static/dispatch/src/entity_type/Table.vue
similarity index 87%
rename from src/dispatch/static/dispatch/src/entity/Table.vue
rename to src/dispatch/static/dispatch/src/entity_type/Table.vue
index cee17d294c48..8a16d05403cd 100644
--- a/src/dispatch/static/dispatch/src/entity/Table.vue
+++ b/src/dispatch/static/dispatch/src/entity_type/Table.vue
@@ -36,6 +36,9 @@
+
+
+
@@ -62,10 +65,10 @@ import { mapFields } from "vuex-map-fields"
import { mapActions } from "vuex"
import SettingsBreadcrumbs from "@/components/SettingsBreadcrumbs.vue"
-import NewEditSheet from "@/entity/NewEditSheet.vue"
+import NewEditSheet from "@/entity_type/NewEditSheet.vue"
export default {
- name: "EntityTable",
+ name: "EntityTypeTable",
components: {
NewEditSheet,
@@ -75,16 +78,17 @@ export default {
return {
headers: [
{ text: "Name", value: "name", sortable: true },
- { text: "Description", value: "description", sortable: false },
+ { text: "Field", value: "field", sortable: false },
{ text: "Regular Expression", value: "regular_expression", sortable: false },
{ text: "Global", value: "global_find", sortable: true },
+ { text: "Enabled", value: "enabled", sortable: true },
{ text: "", value: "data-table-actions", sortable: false, align: "end" },
],
}
},
computed: {
- ...mapFields("entity", [
+ ...mapFields("entity_type", [
"table.options.q",
"table.options.page",
"table.options.itemsPerPage",
@@ -121,7 +125,7 @@ export default {
},
methods: {
- ...mapActions("entity", ["getAll", "createEditShow", "removeShow"]),
+ ...mapActions("entity_type", ["getAll", "createEditShow", "removeShow"]),
},
}
diff --git a/src/dispatch/static/dispatch/src/entity_type/api.js b/src/dispatch/static/dispatch/src/entity_type/api.js
new file mode 100644
index 000000000000..f426cf5834dd
--- /dev/null
+++ b/src/dispatch/static/dispatch/src/entity_type/api.js
@@ -0,0 +1,29 @@
+import API from "@/api"
+
+const resource = "/entity_type"
+
+export default {
+ getAll(options) {
+ return API.get(`${resource}`, { params: { ...options } })
+ },
+
+ get(EntityTypeId) {
+ return API.get(`${resource}/${EntityTypeId}`)
+ },
+
+ create(payload) {
+ return API.post(`${resource}`, payload)
+ },
+
+ update(EntityTypeId, payload) {
+ return API.put(`${resource}/${EntityTypeId}`, payload)
+ },
+
+ process(EntityTypeId, payload) {
+ return API.put(`${resource}/${EntityTypeId}/process`, payload)
+ },
+
+ delete(EntityTypeId) {
+ return API.delete(`${resource}/${EntityTypeId}`)
+ },
+}
diff --git a/src/dispatch/static/dispatch/src/entity_type/store.js b/src/dispatch/static/dispatch/src/entity_type/store.js
new file mode 100644
index 000000000000..8c0491a53874
--- /dev/null
+++ b/src/dispatch/static/dispatch/src/entity_type/store.js
@@ -0,0 +1,163 @@
+import { getField, updateField } from "vuex-map-fields"
+import { debounce } from "lodash"
+
+import SearchUtils from "@/search/utils"
+import EntityTypeApi from "@/entity_type/api"
+
+const getDefaultSelectedState = () => {
+ return {
+ id: null,
+ name: null,
+ description: null,
+ regular_expression: null,
+ enabled: false,
+ global_find: false,
+ project: null,
+ default: false,
+ }
+}
+
+const state = {
+ selected: {
+ ...getDefaultSelectedState(),
+ },
+ dialogs: {
+ showCreateEdit: false,
+ showRemove: false,
+ },
+ table: {
+ rows: {
+ items: [],
+ total: null,
+ },
+ options: {
+ q: "",
+ page: 1,
+ itemsPerPage: 10,
+ sortBy: ["name"],
+ descending: [true],
+ filters: {
+ project: [],
+ },
+ },
+ loading: false,
+ },
+}
+
+const getters = {
+ getField,
+}
+
+const actions = {
+ getAll: debounce(({ commit, state }) => {
+ commit("SET_TABLE_LOADING", "primary")
+ let params = SearchUtils.createParametersFromTableOptions({ ...state.table.options }, "Entity")
+ return EntityTypeApi.getAll(params)
+ .then((response) => {
+ commit("SET_TABLE_LOADING", false)
+ commit("SET_TABLE_ROWS", response.data)
+ })
+ .catch(() => {
+ commit("SET_TABLE_LOADING", false)
+ })
+ }, 500),
+ createEditShow({ commit }, entity_type) {
+ commit("SET_DIALOG_CREATE_EDIT", true)
+ if (entity_type) {
+ commit("SET_SELECTED", entity_type)
+ }
+ },
+ removeShow({ commit }, entity_type) {
+ commit("SET_DIALOG_DELETE", true)
+ commit("SET_SELECTED", entity_type)
+ },
+ closeCreateEdit({ commit }) {
+ commit("SET_DIALOG_CREATE_EDIT", false)
+ commit("RESET_SELECTED")
+ },
+ closeRemove({ commit }) {
+ commit("SET_DIALOG_DELETE", false)
+ commit("RESET_SELECTED")
+ },
+ save({ commit, state, dispatch }) {
+ commit("SET_SELECTED_LOADING", true)
+ if (!state.selected.id) {
+ return EntityTypeApi.create(state.selected)
+ .then(() => {
+ commit("SET_SELECTED_LOADING", false)
+ dispatch("closeCreateEdit")
+ dispatch("getAll")
+ commit(
+ "notification_backend/addBeNotification",
+ { text: "Entity type created successfully.", type: "success" },
+ { root: true }
+ )
+ })
+ .catch(() => {
+ commit("SET_SELECTED_LOADING", false)
+ })
+ } else {
+ return EntityTypeApi.update(state.selected.id, state.selected)
+ .then(() => {
+ commit("SET_SELECTED_LOADING", false)
+ dispatch("closeCreateEdit")
+ dispatch("getAll")
+ commit(
+ "notification_backend/addBeNotification",
+ { text: "Entity type updated successfully.", type: "success" },
+ { root: true }
+ )
+ })
+ .catch(() => {
+ commit("SET_SELECTED_LOADING", false)
+ })
+ }
+ },
+ remove({ commit, dispatch, state }) {
+ return EntityTypeApi.delete(state.selected.id).then(() => {
+ dispatch("closeRemove")
+ dispatch("getAll")
+ commit(
+ "notification_backend/addBeNotification",
+ { text: "Entity type deleted successfully.", type: "success" },
+ { root: true }
+ )
+ })
+ },
+}
+
+const mutations = {
+ updateField,
+ SET_SELECTED(state, value) {
+ state.selected = Object.assign(state.selected, value)
+ },
+ SET_SELECTED_LOADING(state, value) {
+ state.selected.loading = value
+ },
+ SET_TABLE_LOADING(state, value) {
+ state.table.loading = value
+ },
+ SET_TABLE_ROWS(state, value) {
+ state.table.rows = value
+ },
+ SET_DIALOG_CREATE_EDIT(state, value) {
+ state.dialogs.showCreateEdit = value
+ },
+ SET_DIALOG_DELETE(state, value) {
+ state.dialogs.showRemove = value
+ },
+ RESET_SELECTED(state) {
+ // do not reset project
+ let project = state.selected.project
+ state.selected = { ...getDefaultSelectedState() }
+ state.selected.project = project
+ },
+}
+
+export default {
+ namespaced: true,
+ state,
+ getters,
+ actions,
+ mutations,
+}
diff --git a/src/dispatch/static/dispatch/src/router/config.js b/src/dispatch/static/dispatch/src/router/config.js
index f7ea10d24f64..d9b230487ff9 100644
--- a/src/dispatch/static/dispatch/src/router/config.js
+++ b/src/dispatch/static/dispatch/src/router/config.js
@@ -465,10 +465,10 @@ export const protectedRoute = [
component: () => import("@/team/Table.vue"),
},
{
- path: "entity",
- name: "EntityTable",
- meta: { title: "Entities", subMenu: "project", group: "knowledge" },
- component: () => import("@/entity/Table.vue"),
+ path: "entityTpes",
+ name: "EntityTypeTable",
+ meta: { title: "Entity Types", subMenu: "project", group: "knowledge" },
+ component: () => import("@/entity_type/Table.vue"),
},
{
path: "tagTypes",
diff --git a/src/dispatch/static/dispatch/src/signal/EntityRule.vue b/src/dispatch/static/dispatch/src/signal/EntityRule.vue
index 53838b131293..2a25ba49ed0d 100644
--- a/src/dispatch/static/dispatch/src/signal/EntityRule.vue
+++ b/src/dispatch/static/dispatch/src/signal/EntityRule.vue
@@ -1,71 +1,61 @@
-
-
- Entity Configuration
-
-
-
- help_outline
-
- Dispatch will attempt to locate entities that match the given criteria. Global entities cannot be selected, since they are applied to all signals.
-
-
-
-
-
-
-
-
-
-
-
+
+
+ Entity Type Configuration
+
+
+
+ help_outline
+
+ Dispatch will attempt to locate entities that match the given criteria. Global entities cannot be selected, since they are applied to all signals.
+
+
+
+
+
+
+
+
+
+
+
-
+ },
+}
+
diff --git a/src/dispatch/static/dispatch/src/signal/NewEditSheet.vue b/src/dispatch/static/dispatch/src/signal/NewEditSheet.vue
index 96420070f265..973d0fc0f4f8 100644
--- a/src/dispatch/static/dispatch/src/signal/NewEditSheet.vue
+++ b/src/dispatch/static/dispatch/src/signal/NewEditSheet.vue
@@ -143,7 +143,7 @@
-
+
@@ -210,7 +210,7 @@ export default {
"selected.external_url",
"selected.case_type",
"selected.case_priority",
- "selected.entities",
+ "selected.entity_types",
"selected.duplication_rule",
"selected.suppression_rule",
"selected.source",
diff --git a/src/dispatch/static/dispatch/src/signal/SignalInstanceTab.vue b/src/dispatch/static/dispatch/src/signal/SignalInstanceTab.vue
index b92c0d002e24..fe3e7c74225b 100644
--- a/src/dispatch/static/dispatch/src/signal/SignalInstanceTab.vue
+++ b/src/dispatch/static/dispatch/src/signal/SignalInstanceTab.vue
@@ -1,7 +1,7 @@
[],
+ },
+ },
data() {
return {
menu: false,
@@ -62,6 +68,12 @@ export default {
},
computed: {
...mapFields("case_management", ["selected.signal_instances"]),
+ signalInstances() {
+ if (this.inputSignalInstances.length) {
+ return this.inputSignalInstances
+ }
+ return this.signal_instances;
+ },
},
}
diff --git a/src/dispatch/static/dispatch/src/signal/Table.vue b/src/dispatch/static/dispatch/src/signal/Table.vue
index eef82d6d7f7b..ceac756e2c7b 100644
--- a/src/dispatch/static/dispatch/src/signal/Table.vue
+++ b/src/dispatch/static/dispatch/src/signal/Table.vue
@@ -97,23 +97,18 @@
diff --git a/src/dispatch/static/dispatch/src/entity_type/NewEditSheet.vue b/src/dispatch/static/dispatch/src/entity_type/NewEditSheet.vue
index 5a9b351291f1..9f7a076e3fe6 100644
--- a/src/dispatch/static/dispatch/src/entity_type/NewEditSheet.vue
+++ b/src/dispatch/static/dispatch/src/entity_type/NewEditSheet.vue
@@ -64,7 +64,7 @@
label="Regular Expression"
:error-messages="errors"
:success="valid"
- hint="A regular expression pattern for your entity type."
+ hint="A regular expression pattern for your entity type. Multiple capture groups are not supported, the first group will be used."
clearable
required
/>
@@ -83,19 +83,6 @@
/>
-
-
-
-
-
- mdi-information
-
- Checking this box collects the entity from all signal instances.
-
-
-
-
-
@@ -80,7 +77,6 @@ export default {
{ text: "Name", value: "name", sortable: true },
{ text: "Field", value: "field", sortable: false },
{ text: "Regular Expression", value: "regular_expression", sortable: false },
- { text: "Global", value: "global_find", sortable: true },
{ text: "Enabled", value: "enabled", sortable: true },
{ text: "", value: "data-table-actions", sortable: false, align: "end" },
],
From c875a8af72a6375cf7fa82605acf4e64e8823ea2 Mon Sep 17 00:00:00 2001
From: Will Sheldon <114631109+wssheldon@users.noreply.github.com>
Date: Mon, 13 Feb 2023 13:19:19 -0800
Subject: [PATCH 11/12] Address @kglisson comments
---
src/dispatch/entity/models.py | 14 ++++++++++---
src/dispatch/entity/service.py | 8 ++++----
src/dispatch/entity/views.py | 2 +-
src/dispatch/entity_type/models.py | 9 ++++++++-
.../static/dispatch/src/entity/EntityCard.vue | 2 +-
.../static/dispatch/src/entity/api.js | 20 +++++++++----------
.../static/dispatch/src/entity_type/api.js | 16 +++++++--------
7 files changed, 43 insertions(+), 28 deletions(-)
diff --git a/src/dispatch/entity/models.py b/src/dispatch/entity/models.py
index f35596f1c7f0..cfc6c581e98f 100644
--- a/src/dispatch/entity/models.py
+++ b/src/dispatch/entity/models.py
@@ -10,8 +10,9 @@
from dispatch.models import DispatchBase, TimeStampMixin, ProjectMixin, PrimaryKey
from dispatch.project.models import ProjectRead
from dispatch.entity_type.models import (
- EntityTypeRead,
EntityTypeCreate,
+ EntityTypeRead,
+ EntityTypeReadMinimal,
EntityTypeUpdate,
)
@@ -31,7 +32,14 @@ class Entity(Base, TimeStampMixin, ProjectMixin):
entity_type = relationship("EntityType", backref="entity")
# the catalog here is simple to help matching "named entities"
- search_vector = Column(TSVectorType("name", regconfig="pg_catalog.simple"))
+ search_vector = Column(
+ TSVectorType(
+ "name",
+ "description",
+ weights={"name": "A", "description": "B"},
+ regconfig="pg_catalog.simple",
+ )
+ )
# Pydantic models
@@ -65,7 +73,7 @@ class EntityReadMinimal(DispatchBase):
source: Optional[str] = Field(None, nullable=True)
value: Optional[str] = Field(None, nullable=True)
description: Optional[str] = Field(None, nullable=True)
- entity_type: Optional[EntityTypeRead]
+ entity_type: Optional[EntityTypeReadMinimal]
class EntityPagination(DispatchBase):
diff --git a/src/dispatch/entity/service.py b/src/dispatch/entity/service.py
index b82094a5c381..4d3f1cd8e4b5 100644
--- a/src/dispatch/entity/service.py
+++ b/src/dispatch/entity/service.py
@@ -204,7 +204,7 @@ def find_entities(
dictionary objects by specifying a value for the 'field' attribute of the EntityType object.
"""
- def search(key, val, entity_type_pairs):
+ def _search(key, val, entity_type_pairs):
# Create a list to hold any entities that are found in this value
entities = []
@@ -215,12 +215,12 @@ def search(key, val, entity_type_pairs):
# If the value is a dictionary, search its key-value pairs recursively
if isinstance(val, dict):
for subkey, subval in val.items():
- entities.extend(search(subkey, subval, entity_type_pairs))
+ entities.extend(_search(subkey, subval, entity_type_pairs))
# If the value is a list, search its items recursively
elif isinstance(val, list):
for item in val:
- entities.extend(search(None, item, entity_type_pairs))
+ entities.extend(_search(None, item, entity_type_pairs))
# If the value is a string, search it for entity matches
elif isinstance(val, str):
@@ -257,7 +257,7 @@ def search(key, val, entity_type_pairs):
entities = [
entity
for key, val in signal_instance.raw.items()
- for entity in search(key, val, entity_type_pairs)
+ for entity in _search(key, val, entity_type_pairs)
]
# Create the entities in the database and add them to the signal instance
diff --git a/src/dispatch/entity/views.py b/src/dispatch/entity/views.py
index 5136eb10741b..a7c29e1696b7 100644
--- a/src/dispatch/entity/views.py
+++ b/src/dispatch/entity/views.py
@@ -18,7 +18,7 @@
@router.get("", response_model=EntityPagination)
-def get_entitys(*, common: dict = Depends(common_parameters)):
+def get_entities(*, common: dict = Depends(common_parameters)):
"""Get all entitys, or only those matching a given search term."""
return search_filter_sort_paginate(model="Entity", **common)
diff --git a/src/dispatch/entity_type/models.py b/src/dispatch/entity_type/models.py
index 132c0f020f2b..8fd53bb82e65 100644
--- a/src/dispatch/entity_type/models.py
+++ b/src/dispatch/entity_type/models.py
@@ -20,7 +20,14 @@ class EntityType(Base, TimeStampMixin, ProjectMixin):
regular_expression = Column(String)
global_find = Column(Boolean, default=False)
enabled = Column(Boolean, default=False)
- search_vector = Column(TSVectorType("name", regconfig="pg_catalog.simple"))
+ search_vector = Column(
+ TSVectorType(
+ "name",
+ "description",
+ weights={"name": "A", "description": "B"},
+ regconfig="pg_catalog.simple",
+ )
+ )
# Pydantic models
diff --git a/src/dispatch/static/dispatch/src/entity/EntityCard.vue b/src/dispatch/static/dispatch/src/entity/EntityCard.vue
index 5c7b56c24661..2fe4c7a3271e 100644
--- a/src/dispatch/static/dispatch/src/entity/EntityCard.vue
+++ b/src/dispatch/static/dispatch/src/entity/EntityCard.vue
@@ -92,7 +92,7 @@
};
},
async mounted() {
- const casePromise = EntityApi.getCasesCount(this.entity.id).then((response) => response.data);
+ const casePromise = EntityApi.getCasesCount(this.entity.id).then((response) => response.data);
const signalPromise = EntityApi.getSignalInstances(this.entity.id).then((response) => response.data);
const [casesResponse, signalResponse] = await Promise.all([
diff --git a/src/dispatch/static/dispatch/src/entity/api.js b/src/dispatch/static/dispatch/src/entity/api.js
index 20fa820bc49b..265daf02e0f5 100644
--- a/src/dispatch/static/dispatch/src/entity/api.js
+++ b/src/dispatch/static/dispatch/src/entity/api.js
@@ -7,27 +7,27 @@ export default {
return API.get(`${resource}`, { params: { ...options } })
},
- get(EntityId) {
- return API.get(`${resource}/${EntityId}`)
+ get(entityId) {
+ return API.get(`${resource}/${entityId}`)
},
create(payload) {
return API.post(`${resource}`, payload)
},
- update(EntityId, payload) {
- return API.put(`${resource}/${EntityId}`, payload)
+ update(entityId, payload) {
+ return API.put(`${resource}/${entityId}`, payload)
},
- delete(EntityId) {
- return API.delete(`${resource}/${EntityId}`)
+ delete(entityId) {
+ return API.delete(`${resource}/${entityId}`)
},
- async getCasesCount(EntityId) {
- return await API.get(`${resource}/${EntityId}/cases`)
+ async getCasesCount(entityId) {
+ return await API.get(`${resource}/${entityId}/cases`)
},
- async getSignalInstances(EntityId) {
- return await API.get(`${resource}/${EntityId}/signal_instances`)
+ async getSignalInstances(entityId) {
+ return await API.get(`${resource}/${entityId}/signal_instances`)
},
}
diff --git a/src/dispatch/static/dispatch/src/entity_type/api.js b/src/dispatch/static/dispatch/src/entity_type/api.js
index f426cf5834dd..9c1e25771d36 100644
--- a/src/dispatch/static/dispatch/src/entity_type/api.js
+++ b/src/dispatch/static/dispatch/src/entity_type/api.js
@@ -7,23 +7,23 @@ export default {
return API.get(`${resource}`, { params: { ...options } })
},
- get(EntityTypeId) {
- return API.get(`${resource}/${EntityTypeId}`)
+ get(entityTypeId) {
+ return API.get(`${resource}/${entityTypeId}`)
},
create(payload) {
return API.post(`${resource}`, payload)
},
- update(EntityTypeId, payload) {
- return API.put(`${resource}/${EntityTypeId}`, payload)
+ update(entityTypeId, payload) {
+ return API.put(`${resource}/${entityTypeId}`, payload)
},
- process(EntityTypeId, payload) {
- return API.put(`${resource}/${EntityTypeId}/process`, payload)
+ process(entityTypeId, payload) {
+ return API.put(`${resource}/${entityTypeId}/process`, payload)
},
- delete(EntityTypeId) {
- return API.delete(`${resource}/${EntityTypeId}`)
+ delete(entityTypeId) {
+ return API.delete(`${resource}/${entityTypeId}`)
},
}
From 342be5f184a86bbcf3af5812d66a474d1f175da4 Mon Sep 17 00:00:00 2001
From: Will Sheldon <114631109+wssheldon@users.noreply.github.com>
Date: Mon, 13 Feb 2023 13:25:51 -0800
Subject: [PATCH 12/12] Make case edit sheet wider to give tabs some room to
breathe
---
src/dispatch/static/dispatch/src/case/EditSheet.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/dispatch/static/dispatch/src/case/EditSheet.vue b/src/dispatch/static/dispatch/src/case/EditSheet.vue
index ba85fe2c48e0..a0b7b0915e59 100644
--- a/src/dispatch/static/dispatch/src/case/EditSheet.vue
+++ b/src/dispatch/static/dispatch/src/case/EditSheet.vue
@@ -1,6 +1,6 @@
-
+