From 867679f32427d83a78c353b34024ac6f4e482aee Mon Sep 17 00:00:00 2001 From: Will Sheldon <114631109+wssheldon@users.noreply.github.com> Date: Mon, 13 Feb 2023 13:35:07 -0800 Subject: [PATCH] add base concept of entities to signals (#2977) * add base concenpt of entities to signals * Add entity model to store type results and add entity case tab * Add missing import and remove unused import * Remove debug statements and sleep from find_entities function * Fix entity_type update service and fix signal instance popover in table * fix service tests, signal_instance id should be uuid not integer * Remove debug console.log statement * move find_entities function to entity service * Update comment * Remove global find from front-end, add regex help message about groups * Address @kglisson comments * Make case edit sheet wider to give tabs some room to breathe --- src/dispatch/api.py | 8 + src/dispatch/case/models.py | 2 + src/dispatch/case/service.py | 13 +- .../versions/2023-02-09_8746b4e292d2.py | 102 +++++++ src/dispatch/entity/__init__.py | 0 src/dispatch/entity/models.py | 81 ++++++ src/dispatch/entity/service.py | 269 ++++++++++++++++++ src/dispatch/entity/views.py | 79 +++++ src/dispatch/entity_type/__init__.py | 0 src/dispatch/entity_type/models.py | 67 +++++ src/dispatch/entity_type/service.py | 98 +++++++ src/dispatch/entity_type/views.py | 124 ++++++++ src/dispatch/signal/flows.py | 10 +- src/dispatch/signal/models.py | 35 ++- src/dispatch/signal/service.py | 11 +- src/dispatch/static/dispatch/components.d.ts | 197 ++++++------- .../static/dispatch/src/case/EditSheet.vue | 8 +- .../dispatch/src/entity/EntitiesTab.vue | 55 ++++ .../static/dispatch/src/entity/EntityCard.vue | 119 ++++++++ .../static/dispatch/src/entity/api.js | 33 +++ .../static/dispatch/src/entity/store.js | 160 +++++++++++ .../entity_type/EntityTypeFilterCombobox.vue | 157 ++++++++++ .../src/entity_type/EntityTypeSelect.vue | 133 +++++++++ .../dispatch/src/entity_type/NewEditSheet.vue | 172 +++++++++++ .../static/dispatch/src/entity_type/Table.vue | 127 +++++++++ .../static/dispatch/src/entity_type/api.js | 29 ++ .../static/dispatch/src/entity_type/store.js | 163 +++++++++++ .../static/dispatch/src/router/config.js | 6 + .../static/dispatch/src/signal/EntityRule.vue | 61 ++++ .../dispatch/src/signal/NewEditSheet.vue | 6 + .../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 | 2 + 34 files changed, 2233 insertions(+), 119 deletions(-) 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/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 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/entity_type/EntityTypeFilterCombobox.vue create mode 100644 src/dispatch/static/dispatch/src/entity_type/EntityTypeSelect.vue create mode 100644 src/dispatch/static/dispatch/src/entity_type/NewEditSheet.vue create mode 100644 src/dispatch/static/dispatch/src/entity_type/Table.vue create mode 100644 src/dispatch/static/dispatch/src/entity_type/api.js create mode 100644 src/dispatch/static/dispatch/src/entity_type/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..c5b8dffff525 100644 --- a/src/dispatch/api.py +++ b/src/dispatch/api.py @@ -21,6 +21,8 @@ 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.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 @@ -131,6 +133,12 @@ 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( + 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/case/service.py b/src/dispatch/case/service.py index afcddedb1fa7..c6900d42a1b2 100644 --- a/src/dispatch/case/service.py +++ b/src/dispatch/case/service.py @@ -195,12 +195,13 @@ def create(*, db_session, case_in: CaseCreate, current_user: DispatchUser = None ) # add reporter - participant_flows.add_participant( - case_in.reporter.individual.email, - case, - db_session, - role=ParticipantRoleType.reporter, - ) + if case_in.reporter: + participant_flows.add_participant( + case_in.reporter.individual.email, + case, + db_session, + role=ParticipantRoleType.reporter, + ) return case 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..4943f60b3ae1 --- /dev/null +++ b/src/dispatch/database/revisions/tenant/versions/2023-02-09_8746b4e292d2.py @@ -0,0 +1,102 @@ +"""Adds entity and entity type tables and associations + +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.postgresql import TSVECTOR, UUID + +# 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! + + # EntityType + op.create_table( + "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("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), + sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name", "project_id"), + ) + op.create_table( + "assoc_signal_entity_types", + sa.Column("signal_id", sa.Integer(), nullable=False), + sa.Column("entity_type_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["signal_id"], ["signal.id"], ondelete="CASCADE"), + 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( + "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( + "ix_entity_search_vector", + "entity", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + 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_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/__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..cfc6c581e98f --- /dev/null +++ b/src/dispatch/entity/models.py @@ -0,0 +1,81 @@ +from typing import Optional, List +from pydantic import Field + +from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy.orm import relationship +from sqlalchemy.sql.schema import UniqueConstraint +from sqlalchemy_utils import TSVectorType + +from dispatch.database.core import Base +from dispatch.models import DispatchBase, TimeStampMixin, ProjectMixin, PrimaryKey +from dispatch.project.models import ProjectRead +from dispatch.entity_type.models import ( + EntityTypeCreate, + EntityTypeRead, + EntityTypeReadMinimal, + 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) + 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", + "description", + weights={"name": "A", "description": "B"}, + regconfig="pg_catalog.simple", + ) + ) + + +# Pydantic models +class EntityBase(DispatchBase): + 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) + + +class EntityCreate(EntityBase): + id: Optional[PrimaryKey] + entity_type: EntityTypeCreate + project: ProjectRead + + +class EntityUpdate(EntityBase): + id: Optional[PrimaryKey] + entity_type: Optional[EntityTypeUpdate] + + +class EntityRead(EntityBase): + id: PrimaryKey + entity_type: Optional[EntityTypeRead] + project: ProjectRead + + +class EntityReadMinimal(DispatchBase): + id: PrimaryKey + 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) + entity_type: Optional[EntityTypeReadMinimal] + + +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..4d3f1cd8e4b5 --- /dev/null +++ b/src/dispatch/entity/service.py @@ -0,0 +1,269 @@ +from datetime import datetime, timedelta +from typing import Optional, Sequence +import re + +from pydantic.error_wrappers import ErrorWrapper, ValidationError +from sqlalchemy.orm import Session, joinedload + +from dispatch.exceptions import NotFoundError +from dispatch.project import service as project_service +from dispatch.case.models import Case +from dispatch.entity.models import Entity, EntityCreate, EntityUpdate, EntityRead +from dispatch.entity_type import service as entity_type_service +from dispatch.entity_type.models import EntityType +from dispatch.signal.models import SignalInstance + + +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, project_id: int, name: str) -> Optional[Entity]: + """Gets a entity by its project and 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, 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) + + if not entity: + raise ValidationError( + [ + ErrorWrapper( + NotFoundError( + msg="Entity not found.", + entity=entity_in.name, + ), + loc="entity", + ) + ], + model=EntityRead, + ) + + return 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, 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_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_by_value_or_create(*, db_session, entity_in: EntityCreate) -> Entity: + """Gets or creates a new entity.""" + # 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: + return instance + + return create(db_session=db_session, entity_in=entity_in) + + +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, 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, 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) -> list[Case]: + """Searches for cases 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 + 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) + .options(joinedload(SignalInstance.signal)) + .join(SignalInstance.entities) + .filter(SignalInstance.created_at >= start_date, Entity.id == entity_id) + .all() + ) + + return signal_instances + + +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: + 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: + 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. + """ + + 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: + return cache[id(val)] + + # 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)) + + # 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)) + + # If the value is a string, search it for entity matches + elif isinstance(val, str): + for entity_type, entity_regex, field in entity_type_pairs: + # If a field was specified for this entity type, only search that field + if not field or key == field: + # Search the string for matches to the entity type's regular expression + if match := entity_regex.search(val): + # 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) + ] + + # Create the entities in the database and add them to the signal instance + entities_out = [ + get_by_value_or_create(db_session=db_session, entity_in=entity_in) for entity_in in entities + ] + + # Return the list of entities found + return entities_out diff --git a/src/dispatch/entity/views.py b/src/dispatch/entity/views.py new file mode 100644 index 000000000000..a7c29e1696b7 --- /dev/null +++ b/src/dispatch/entity/views.py @@ -0,0 +1,79 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from dispatch.database.core import get_db +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 ( + 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 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): + """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": "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): + """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 +): + """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."}], + ) + 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): + """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( + 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) + + +@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..8fd53bb82e65 --- /dev/null +++ b/src/dispatch/entity_type/models.py @@ -0,0 +1,67 @@ +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", + "description", + weights={"name": "A", "description": "B"}, + 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 + project: ProjectRead + + +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 ba7d80b18429..fe0c13762454 100644 --- a/src/dispatch/signal/flows.py +++ b/src/dispatch/signal/flows.py @@ -3,6 +3,7 @@ from dispatch.project.models import Project from dispatch.case import service as case_service from dispatch.case import flows as case_flows +from dispatch.entity import service as entity_service from dispatch.signal import service as signal_service from dispatch.tag import service as tag_service from dispatch.signal.models import SignalInstanceCreate, RawSignal @@ -34,6 +35,13 @@ def create_signal_instance( signal_instance.signal = signal db_session.commit() + entities = entity_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,7 +58,7 @@ def create_signal_instance( if duplicate: return - # create a case if not duplicate or supressed + # create a case if not duplicate or suppressed case_in = CaseCreate( title=signal.name, description=signal.description, diff --git a/src/dispatch/signal/models.py b/src/dispatch/signal/models.py index 5d5e69c54e68..9f4b3f10cdcb 100644 --- a/src/dispatch/signal/models.py +++ b/src/dispatch/signal/models.py @@ -25,6 +25,8 @@ 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.entity_type.models import EntityTypeRead, EntityTypeCreate from dispatch.tag.models import TagRead from dispatch.project.models import ProjectRead from dispatch.data.source.models import SourceBase @@ -53,6 +55,22 @@ class RuleMode(DispatchEnum): PrimaryKeyConstraint("signal_id", "tag_id"), ) +assoc_signal_instance_entities = Table( + "assoc_signal_instance_entities", + Base.metadata, + Column("signal_instance_id", UUID, ForeignKey("signal_instance.id", ondelete="CASCADE")), + Column("entity_id", Integer, ForeignKey("entity.id", ondelete="CASCADE")), + PrimaryKeyConstraint("signal_instance_id", "entity_id"), +) + +assoc_signal_entity_types = Table( + "assoc_signal_entity_types", + Base.metadata, + Column("signal_id", Integer, ForeignKey("signal.id", ondelete="CASCADE")), + Column("entity_type_id", Integer, ForeignKey("entity_type.id", ondelete="CASCADE")), + PrimaryKeyConstraint("signal_id", "entity_type_id"), +) + assoc_duplication_tag_types = Table( "assoc_duplication_rule_tag_types", Base.metadata, @@ -109,6 +127,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") + entity_types = relationship( + "EntityType", + secondary=assoc_signal_entity_types, + backref="signals", + ) suppression_rule_id = Column(Integer, ForeignKey(SuppressionRule.id)) suppression_rule = relationship("SuppressionRule", backref="signal") tags = relationship( @@ -125,6 +148,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") @@ -188,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[EntityTypeRead]] = [] 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] @@ -236,6 +268,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] @@ -249,7 +282,7 @@ class SignalInstanceCreate(SignalInstanceBase): class SignalInstanceRead(SignalInstanceBase): id: uuid.UUID - fingerprint: str + fingerprint: str = None signal: SignalRead diff --git a/src/dispatch/signal/service.py b/src/dispatch/signal/service.py index 4001f7d21e13..935d84a67bba 100644 --- a/src/dispatch/signal/service.py +++ b/src/dispatch/signal/service.py @@ -1,6 +1,7 @@ import json import hashlib from typing import Optional + from datetime import datetime, timedelta, timezone from dispatch.enums import RuleMode from dispatch.project import service as project_service @@ -8,6 +9,7 @@ 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 .models import ( Signal, @@ -43,7 +45,7 @@ def create_duplication_rule( def update_duplication_rule( *, db_session, duplication_rule_in: DuplicationRuleUpdate ) -> DuplicationRule: - """Updates an 1existing duplication rule.""" + """Updates an existing duplication rule.""" rule = ( db_session.query(DuplicationRule).filter(DuplicationRule.id == duplication_rule_in.id).one() ) @@ -173,6 +175,13 @@ def update(*, db_session, signal: Signal, signal_in: SignalUpdate) -> Signal: if field in update_data: setattr(signal, field, update_data[field]) + entity_types = [] + for entity_type in signal_in.entity_types: + entity_types.append( + entity_type_service.get_or_create(db_session=db_session, entity_type_in=entity_type) + ) + signal.entity_types = entity_types + if signal_in.duplication_rule: if signal_in.duplication_rule.id: update_duplication_rule( 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..a0b7b0915e59 100644 --- a/src/dispatch/static/dispatch/src/case/EditSheet.vue +++ b/src/dispatch/static/dispatch/src/case/EditSheet.vue @@ -1,6 +1,6 @@