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 @@ + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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" > - 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 @@ +