From 85ea260050bf33f3fd3f4540ee0df3f10aa2db1f Mon Sep 17 00:00:00 2001 From: Avery Date: Mon, 1 Jul 2024 14:05:59 -0700 Subject: [PATCH] Create case costs, case cost types, and case cost models (#4899) * Add cost models to case types * Adds case cost type * Create case costs * Make db changes * add cases to participant activity * Makes case field in participant activity optional * Fixes lint errors * Fixes sqlalchemy upgrade/downgrade. * Fixes lint errors. * Remove wrongly added file. * Restore previous case type service --- src/dispatch/case/models.py | 25 ++++++ src/dispatch/case/type/models.py | 8 ++ src/dispatch/case_cost/models.py | 46 +++++++++++ src/dispatch/case_cost_type/models.py | 66 +++++++++++++++ .../versions/2024-06-28_15a8d3228123.py | 80 +++++++++++++++++++ src/dispatch/participant_activity/models.py | 12 ++- tests/conftest.py | 17 ++++ tests/factories.py | 46 +++++++++++ 8 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 src/dispatch/case_cost/models.py create mode 100644 src/dispatch/case_cost_type/models.py create mode 100644 src/dispatch/database/revisions/tenant/versions/2024-06-28_15a8d3228123.py diff --git a/src/dispatch/case/models.py b/src/dispatch/case/models.py index 18dea4e731a3..e45ec42a6477 100644 --- a/src/dispatch/case/models.py +++ b/src/dispatch/case/models.py @@ -14,9 +14,14 @@ Table, UniqueConstraint, ) +from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import relationship from sqlalchemy_utils import TSVectorType, observes +from dispatch.case_cost.models import ( + CaseCostRead, + CaseCostUpdate, +) from dispatch.case.priority.models import CasePriorityBase, CasePriorityCreate, CasePriorityRead from dispatch.case.severity.models import CaseSeverityBase, CaseSeverityCreate, CaseSeverityRead from dispatch.case.type.models import CaseTypeBase, CaseTypeCreate, CaseTypeRead @@ -167,6 +172,15 @@ class Case(Base, TimeStampMixin, ProjectMixin): ticket = relationship("Ticket", uselist=False, backref="case", cascade="all, delete-orphan") + # resources + case_costs = relationship( + "CaseCost", + backref="case", + cascade="all, delete-orphan", + lazy="subquery", + order_by="CaseCost.created_at", + ) + @observes("participants") def participant_observer(self, participants): self.participants_team = Counter(p.team for p in participants).most_common(1)[0][0] @@ -184,6 +198,14 @@ def has_thread(self) -> bool: return False return True if self.conversation.thread_id else False + @hybrid_property + def total_cost(self): + total_cost = 0 + if self.case_costs: + for cost in self.case_costs: + total_cost += cost.amount + return total_cost + class SignalRead(DispatchBase): id: PrimaryKey @@ -249,6 +271,7 @@ class CaseCreate(CaseBase): class CaseReadMinimal(CaseBase): id: PrimaryKey assignee: Optional[ParticipantReadMinimal] + case_costs: Optional[List[CaseCostRead]] = [] case_priority: CasePriorityRead case_severity: CaseSeverityRead case_type: CaseTypeRead @@ -271,6 +294,7 @@ class CaseReadMinimal(CaseBase): class CaseRead(CaseBase): id: PrimaryKey assignee: Optional[ParticipantRead] + case_costs: Optional[List[CaseCostRead]] = [] case_priority: CasePriorityRead case_severity: CaseSeverityRead case_type: CaseTypeRead @@ -300,6 +324,7 @@ class CaseRead(CaseBase): class CaseUpdate(CaseBase): assignee: Optional[ParticipantUpdate] + case_costs: Optional[List[CaseCostUpdate]] = [] case_priority: Optional[CasePriorityBase] case_severity: Optional[CaseSeverityBase] case_type: Optional[CaseTypeBase] diff --git a/src/dispatch/case/type/models.py b/src/dispatch/case/type/models.py index 6fbf64ec4450..416bccb121df 100644 --- a/src/dispatch/case/type/models.py +++ b/src/dispatch/case/type/models.py @@ -8,6 +8,7 @@ from sqlalchemy.sql.schema import UniqueConstraint from sqlalchemy_utils import TSVectorType +from dispatch.cost_model.models import CostModelRead from dispatch.database.core import Base, ensure_unique_default_per_project from dispatch.enums import Visibility from dispatch.models import DispatchBase, NameStr, Pagination, PrimaryKey, ProjectMixin @@ -40,6 +41,12 @@ class CaseType(ProjectMixin, Base): incident_type_id = Column(Integer, ForeignKey("incident_type.id")) incident_type = relationship("IncidentType", foreign_keys=[incident_type_id]) + cost_model_id = Column(Integer, ForeignKey("cost_model.id"), nullable=True, default=None) + cost_model = relationship( + "CostModel", + foreign_keys=[cost_model_id], + ) + @hybrid_method def get_meta(self, slug): if not self.plugin_metadata: @@ -92,6 +99,7 @@ class CaseTypeBase(DispatchBase): plugin_metadata: List[PluginMetadata] = [] project: Optional[ProjectRead] visibility: Optional[str] = Field(None, nullable=True) + cost_model: Optional[CostModelRead] = None @validator("plugin_metadata", pre=True) def replace_none_with_empty_list(cls, value): diff --git a/src/dispatch/case_cost/models.py b/src/dispatch/case_cost/models.py new file mode 100644 index 000000000000..ca37c529ebe4 --- /dev/null +++ b/src/dispatch/case_cost/models.py @@ -0,0 +1,46 @@ +from sqlalchemy import Column, ForeignKey, Integer, Numeric +from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.orm import relationship +from typing import List, Optional + +from dispatch.database.core import Base +from dispatch.case_cost_type.models import CaseCostTypeRead +from dispatch.models import DispatchBase, Pagination, PrimaryKey, ProjectMixin, TimeStampMixin +from dispatch.project.models import ProjectRead + + +# SQLAlchemy Model +class CaseCost(Base, TimeStampMixin, ProjectMixin): + # columns + id = Column(Integer, primary_key=True) + amount = Column(Numeric(precision=10, scale=2), nullable=True) + + # relationships + case_cost_type = relationship("CaseCostType", backref="case_cost") + case_cost_type_id = Column(Integer, ForeignKey("case_cost_type.id")) + case_id = Column(Integer, ForeignKey("case.id", ondelete="CASCADE")) + search_vector = association_proxy("case_cost_type", "search_vector") + + +# Pydantic Models +class CaseCostBase(DispatchBase): + amount: float = 0 + + +class CaseCostCreate(CaseCostBase): + case_cost_type: CaseCostTypeRead + project: ProjectRead + + +class CaseCostUpdate(CaseCostBase): + id: Optional[PrimaryKey] = None + case_cost_type: CaseCostTypeRead + + +class CaseCostRead(CaseCostBase): + id: PrimaryKey + case_cost_type: CaseCostTypeRead + + +class CaseCostPagination(Pagination): + items: List[CaseCostRead] = [] diff --git a/src/dispatch/case_cost_type/models.py b/src/dispatch/case_cost_type/models.py new file mode 100644 index 000000000000..13a382f99556 --- /dev/null +++ b/src/dispatch/case_cost_type/models.py @@ -0,0 +1,66 @@ +from datetime import datetime +from typing import List, Optional +from pydantic import Field + +from sqlalchemy import Column, Integer, String, Boolean +from sqlalchemy.event import listen + +from sqlalchemy_utils import TSVectorType, JSONType + +from dispatch.database.core import Base, ensure_unique_default_per_project +from dispatch.models import ( + DispatchBase, + NameStr, + ProjectMixin, + TimeStampMixin, + Pagination, + PrimaryKey, +) +from dispatch.project.models import ProjectRead + + +# SQLAlchemy Model +class CaseCostType(Base, TimeStampMixin, ProjectMixin): + # columns + id = Column(Integer, primary_key=True) + name = Column(String) + description = Column(String) + category = Column(String) + details = Column(JSONType, nullable=True) + default = Column(Boolean, default=False) + editable = Column(Boolean, default=True) + + # full text search capabilities + search_vector = Column( + TSVectorType("name", "description", weights={"name": "A", "description": "B"}) + ) + + +listen(CaseCostType.default, "set", ensure_unique_default_per_project) + + +# Pydantic Models +class CaseCostTypeBase(DispatchBase): + name: NameStr + description: Optional[str] = Field(None, nullable=True) + category: Optional[str] = Field(None, nullable=True) + details: Optional[dict] = {} + created_at: Optional[datetime] + default: Optional[bool] + editable: Optional[bool] + + +class CaseCostTypeCreate(CaseCostTypeBase): + project: ProjectRead + + +class CaseCostTypeUpdate(CaseCostTypeBase): + id: PrimaryKey = None + + +class CaseCostTypeRead(CaseCostTypeBase): + id: PrimaryKey + + +class CaseCostTypePagination(Pagination): + items: List[CaseCostTypeRead] = [] diff --git a/src/dispatch/database/revisions/tenant/versions/2024-06-28_15a8d3228123.py b/src/dispatch/database/revisions/tenant/versions/2024-06-28_15a8d3228123.py new file mode 100644 index 000000000000..eda4af05ba1f --- /dev/null +++ b/src/dispatch/database/revisions/tenant/versions/2024-06-28_15a8d3228123.py @@ -0,0 +1,80 @@ +"""empty message + +Revision ID: 15a8d3228123 +Revises: 4286dcce0a2d +Create Date: 2024-06-28 11:19:27.227089 + +""" + +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils + +# revision identifiers, used by Alembic. +revision = "15a8d3228123" +down_revision = "4286dcce0a2d" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "case_cost_type", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=True), + sa.Column("description", sa.String(), nullable=True), + sa.Column("category", sa.String(), nullable=True), + sa.Column("details", sqlalchemy_utils.types.json.JSONType(), nullable=True), + sa.Column("default", sa.Boolean(), nullable=True), + sa.Column("editable", 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"), + ) + op.create_index( + "case_cost_type_search_vector_idx", + "case_cost_type", + ["search_vector"], + unique=False, + postgresql_using="gin", + ) + op.create_table( + "case_cost", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("amount", sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column("case_cost_type_id", sa.Integer(), nullable=True), + sa.Column("case_id", sa.Integer(), 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( + ["case_cost_type_id"], + ["case_cost_type.id"], + ), + sa.ForeignKeyConstraint(["case_id"], ["case.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.add_column("case_type", sa.Column("cost_model_id", sa.Integer(), nullable=True)) + op.create_foreign_key(None, "case_type", "cost_model", ["cost_model_id"], ["id"]) + op.add_column("participant_activity", sa.Column("case_id", sa.Integer(), nullable=True)) + op.create_foreign_key(None, "participant_activity", "case", ["case_id"], ["id"]) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "participant_activity", type_="foreignkey") + op.drop_column("participant_activity", "case_id") + op.drop_constraint(None, "case_type", type_="foreignkey") + op.drop_column("case_type", "cost_model_id") + op.drop_table("case_cost") + op.drop_index( + "case_cost_type_search_vector_idx", table_name="case_cost_type", postgresql_using="gin" + ) + op.drop_table("case_cost_type") + # ### end Alembic commands ### diff --git a/src/dispatch/participant_activity/models.py b/src/dispatch/participant_activity/models.py index 442fc232561e..8681820a19dc 100644 --- a/src/dispatch/participant_activity/models.py +++ b/src/dispatch/participant_activity/models.py @@ -1,8 +1,8 @@ from datetime import datetime from sqlalchemy import Column, Integer, ForeignKey, DateTime from sqlalchemy.orm import relationship -from typing import Optional +from dispatch.case.models import CaseRead from dispatch.database.core import Base from dispatch.incident.models import IncidentRead from dispatch.models import DispatchBase, PrimaryKey @@ -26,14 +26,18 @@ class ParticipantActivity(Base): incident_id = Column(Integer, ForeignKey("incident.id")) incident = relationship("Incident", foreign_keys=[incident_id]) + case_id = Column(Integer, ForeignKey("case.id")) + case = relationship("Case", foreign_keys=[case_id]) + # Pydantic Models class ParticipantActivityBase(DispatchBase): plugin_event: PluginEventRead - started_at: Optional[datetime] = None - ended_at: Optional[datetime] = None + started_at: datetime | None + ended_at: datetime | None participant: ParticipantRead - incident: IncidentRead + incident: IncidentRead | None + case: CaseRead | None class ParticipantActivityRead(ParticipantActivityBase): diff --git a/tests/conftest.py b/tests/conftest.py index 2818ede5f8f4..ee1d37fad193 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,6 +23,8 @@ from .database import Session from .factories import ( CaseFactory, + CaseCostFactory, + CaseCostTypeFactory, CasePriorityFactory, CaseSeverityFactory, CaseTypeFactory, @@ -548,6 +550,21 @@ def feedbacks(session): return [FeedbackFactory(), FeedbackFactory()] +@pytest.fixture +def case_cost(session): + return CaseCostFactory() + + +@pytest.fixture +def case_costs(session): + return [CaseCostFactory(), CaseCostFactory()] + + +@pytest.fixture +def case_cost_type(session): + return CaseCostTypeFactory() + + @pytest.fixture def incident_cost(session): return IncidentCostFactory() diff --git a/tests/factories.py b/tests/factories.py index 0bc919f655dc..07f2b86e9eb7 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -20,6 +20,8 @@ from dispatch.case.priority.models import CasePriority from dispatch.case.severity.models import CaseSeverity from dispatch.case.type.models import CaseType +from dispatch.case_cost.models import CaseCost +from dispatch.case_cost_type.models import CaseCostType from dispatch.conference.models import Conference from dispatch.conversation.models import Conversation from dispatch.definition.models import Definition @@ -710,6 +712,7 @@ class CaseTypeFactory(BaseFactory): description = FuzzyText() conversation_target = FuzzyText() project = SubFactory(ProjectFactory) + cost_model = SubFactory(CostModelFactory) class Meta: """Factory Configuration.""" @@ -1127,6 +1130,49 @@ def participant(self, create, extracted, **kwargs): self.participant_id = extracted.id +class CaseCostFactory(BaseFactory): + """Case Cost Factory.""" + + amount = FuzzyInteger(low=0, high=10000) + + class Meta: + """Factory Configuration.""" + + model = CaseCost + + @post_generation + def case(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + self.case_id = extracted.id + + @post_generation + def case_cost_type(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + self.case_cost_type_id = extracted.id + + +class CaseCostTypeFactory(BaseFactory): + """Case Cost Type Factory.""" + + name = FuzzyText() + description = FuzzyText() + category = FuzzyText() + details = {} + default = Faker().pybool() + editable = Faker().pybool() + + class Meta: + """Factory Configuration.""" + + model = CaseCostType + + class IncidentCostFactory(BaseFactory): """Incident Cost Factory."""