Skip to content
This repository was archived by the owner on Sep 3, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/docs/administration/settings/cost_model.mdx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Cost Model

Our Cost Model is a feature that enables teams to estimate response cost for each incident. Users can opt in to create and use personalized cost calculations for each incident based on participant activity.
Our Cost Model is a feature that enables teams to estimate response cost for each incident based on the incident type. Users can opt in to create and use personalized cost calculations for each type of incident based on participant activity. The cost models are automatically applied to each incident based on the incident type.

If no cost model is assigned to an incident, the default classic cost model will be used. See [Incident Cost Type](./incident/incident-cost-type.mdx###calculating-incident-cost).
If an incident type does not have a specific cost model assigned, the default classic cost model will be used when calculating the incident costs. See [Incident Cost Type](./incident/incident-cost-type.mdx###calculating-incident-cost).

<div style={{textAlign: 'center'}}>

Expand All @@ -13,7 +13,7 @@ If no cost model is assigned to an incident, the default classic cost model will
## Key Features

### Customizable Cost Models
Users have the flexibility to define their unique cost models based on their organization's workflow and tools. This customization can be tailored to each incident, providing a versitile approach to cost calculation. The cost model for an incident can be changed at any time during its lifespan. All participant activity costs moving forward will be calculated using the new cost model.
Users have the flexibility to define their unique cost models based on their organization's workflow and tools. This customization can be tailored to each incident type, providing a versitile approach to cost calculation. The cost model for an incident type can be changed at any time. When cost model changes are made to the incident type or an incident changes its type, all participant activity costs moving forward will be calculated using the new cost model.

### Plugin-Based Tracking
Users can track costs from their existing tools by using our plugin-based tracking system. Users have the flexibility to select which plugins and specific plugin events they want to track, offering a targeted approach to cost calculation.
Expand Down
2 changes: 2 additions & 0 deletions docs/docs/administration/settings/incident/incident-type.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,5 @@ Dispatch allows you to categorize your incidents by defining incidents types and
**Enabled:** Whether the incident type is enabled or not.

**Plugin Metadata:** Allows you to define and pass metadata key-value pairs to plugins. For example, create issues in different Jira projects based on the incident type.

**Cost Model:** Allows you to define how to calculate incident response costs. If an incident type does not have a cost model assigned, the default classic cost model will be used when calculating the incident costs. See [Cost Model](../cost_model.mdx).
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Removes the cost_model field from Incident. Adds the cost_model field to IncidentType.
Revision ID: 50e99c66e72f
Revises: 3a33bc153e7e
Create Date: 2024-04-22 14:19:18.388675

"""

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision = "50e99c66e72f"
down_revision = "3a33bc153e7e"
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint("incident_cost_model_id_fkey", "incident", type_="foreignkey")
op.drop_column("incident", "cost_model_id")
op.add_column("incident_type", sa.Column("cost_model_id", sa.Integer(), nullable=True))
op.create_foreign_key(None, "incident_type", "cost_model", ["cost_model_id"], ["id"])
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, "incident_type", type_="foreignkey")
op.drop_column("incident_type", "cost_model_id")
op.add_column(
"incident", sa.Column("cost_model_id", sa.INTEGER(), autoincrement=False, nullable=True)
)
op.create_foreign_key(
"incident_cost_model_id_fkey", "incident", "cost_model", ["cost_model_id"], ["id"]
)
# ### end Alembic commands ###
11 changes: 0 additions & 11 deletions src/dispatch/incident/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

from dispatch.conference.models import ConferenceRead
from dispatch.conversation.models import ConversationRead
from dispatch.cost_model.models import CostModelRead
from dispatch.database.core import Base
from dispatch.document.models import Document, DocumentRead
from dispatch.enums import Visibility
Expand Down Expand Up @@ -225,12 +224,6 @@ def last_executive_report(self):
notifications_group_id = Column(Integer, ForeignKey("group.id"))
notifications_group = relationship("Group", foreign_keys=[notifications_group_id])

cost_model_id = Column(Integer, ForeignKey("cost_model.id"), nullable=True, default=None)
cost_model = relationship(
"CostModel",
foreign_keys=[cost_model_id],
)

@hybrid_property
def total_cost(self):
total_cost = 0
Expand Down Expand Up @@ -296,7 +289,6 @@ def description_required(cls, v):
class IncidentCreate(IncidentBase):
commander: Optional[ParticipantUpdate]
commander_email: Optional[str]
cost_model: Optional[CostModelRead] = None
incident_priority: Optional[IncidentPriorityCreate]
incident_severity: Optional[IncidentSeverityCreate]
incident_type: Optional[IncidentTypeCreate]
Expand All @@ -313,7 +305,6 @@ class IncidentReadMinimal(IncidentBase):
closed_at: Optional[datetime] = None
commander: Optional[ParticipantReadMinimal]
commanders_location: Optional[str]
cost_model: Optional[CostModelRead] = None
created_at: Optional[datetime] = None
duplicates: Optional[List[IncidentReadMinimal]] = []
incident_costs: Optional[List[IncidentCostRead]] = []
Expand Down Expand Up @@ -342,7 +333,6 @@ class IncidentReadMinimal(IncidentBase):
class IncidentUpdate(IncidentBase):
cases: Optional[List[CaseRead]] = []
commander: Optional[ParticipantUpdate]
cost_model: Optional[CostModelRead] = None
delay_executive_report_reminder: Optional[datetime] = None
delay_tactical_report_reminder: Optional[datetime] = None
duplicates: Optional[List[IncidentReadMinimal]] = []
Expand Down Expand Up @@ -380,7 +370,6 @@ class IncidentRead(IncidentBase):
commanders_location: Optional[str]
conference: Optional[ConferenceRead] = None
conversation: Optional[ConversationRead] = None
cost_model: Optional[CostModelRead] = None
created_at: Optional[datetime] = None
delay_executive_report_reminder: Optional[datetime] = None
delay_tactical_report_reminder: Optional[datetime] = None
Expand Down
40 changes: 16 additions & 24 deletions src/dispatch/incident/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

from dispatch.decorators import timer
from dispatch.case import service as case_service
from dispatch.cost_model import service as cost_model_service
from dispatch.database.core import SessionLocal
from dispatch.event import service as event_service
from dispatch.exceptions import NotFoundError
Expand Down Expand Up @@ -81,6 +80,16 @@ def get_by_name(*, db_session, project_id: int, name: str) -> Optional[Incident]
)


def get_all_open_by_incident_type(*, db_session, incident_type_id: int) -> List[Optional[Incident]]:
"""Returns all non-closed incidents based on the given incident type."""
return (
db_session.query(Incident)
.filter(Incident.status != IncidentStatus.closed)
.filter(Incident.incident_type_id == incident_type_id)
.all()
)


def get_by_name_or_raise(*, db_session, project_id: int, incident_in: IncidentRead) -> Incident:
"""Returns an incident based on a given name or raises ValidationError"""
incident = get_by_name(db_session=db_session, project_id=project_id, name=incident_in.name)
Expand Down Expand Up @@ -172,13 +181,6 @@ def create(*, db_session, incident_in: IncidentCreate) -> Incident:
incident_severity_in=incident_in.incident_severity,
)

cost_model = None
if incident_in.cost_model:
cost_model = cost_model_service.get_cost_model_by_id(
db_session=db_session,
cost_model_id=incident_in.cost_model.id,
)

visibility = incident_type.visibility
if incident_in.visibility:
visibility = incident_in.visibility
Expand All @@ -198,7 +200,6 @@ def create(*, db_session, incident_in: IncidentCreate) -> Incident:
tags=tag_objs,
title=incident_in.title,
visibility=visibility,
cost_model=cost_model,
)

db_session.add(incident)
Expand Down Expand Up @@ -340,15 +341,6 @@ def update(*, db_session, incident: Incident, incident_in: IncidentUpdate) -> In
incident_priority_in=incident_in.incident_priority,
)

cost_model = (
cost_model_service.get_cost_model_by_id(
db_session=db_session,
cost_model_id=incident_in.cost_model.id,
)
if incident_in.cost_model
else None
)

cases = []
for c in incident_in.cases:
cases.append(case_service.get(db_session=db_session, case_id=c.id))
Expand All @@ -373,13 +365,18 @@ def update(*, db_session, incident: Incident, incident_in: IncidentUpdate) -> In
)
)

# Update total incident reponse cost if incident type has changed.
if incident_in.incident_type.id != incident.incident_type.id:
incident_cost_service.update_incident_response_cost(
incident_id=incident.id, db_session=db_session
)

update_data = incident_in.dict(
skip_defaults=True,
exclude={
"cases",
"commander",
"duplicates",
"cost_model",
"incident_costs",
"incident_priority",
"incident_severity",
Expand All @@ -397,7 +394,6 @@ def update(*, db_session, incident: Incident, incident_in: IncidentUpdate) -> In
setattr(incident, field, update_data[field])

incident.cases = cases
incident.cost_model = cost_model
incident.duplicates = duplicates
incident.incident_costs = incident_costs
incident.incident_priority = incident_priority
Expand All @@ -410,10 +406,6 @@ def update(*, db_session, incident: Incident, incident_in: IncidentUpdate) -> In

db_session.commit()

# Update total incident response cost.
incident_cost_service.update_incident_response_cost(
incident_id=incident.id, db_session=db_session
)
return incident


Expand Down
8 changes: 8 additions & 0 deletions src/dispatch/incident/type/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

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, ProjectMixin, Pagination
Expand Down Expand Up @@ -57,6 +58,12 @@ class IncidentType(ProjectMixin, Base):
# the catalog here is simple to help matching "named entities"
search_vector = Column(TSVectorType("name", regconfig="pg_catalog.simple"))

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:
Expand Down Expand Up @@ -93,6 +100,7 @@ class IncidentTypeBase(DispatchBase):
default: Optional[bool] = False
project: Optional[ProjectRead]
plugin_metadata: List[PluginMetadata] = []
cost_model: Optional[CostModelRead] = None

@validator("plugin_metadata", pre=True)
def replace_none_with_empty_list(cls, value):
Expand Down
39 changes: 38 additions & 1 deletion src/dispatch/incident/type/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

from sqlalchemy.sql.expression import true

from dispatch.incident_cost import service as incident_cost_service
from dispatch.incident import service as incident_service
from dispatch.cost_model import service as cost_model_service
from dispatch.document import service as document_service
from dispatch.exceptions import NotFoundError
from dispatch.project import service as project_service
Expand Down Expand Up @@ -133,12 +136,19 @@ def create(*, db_session, incident_type_in: IncidentTypeCreate) -> IncidentType:
"executive_template_document",
"tracking_template_document",
"review_template_document",
"cost_model",
"project",
}
),
project=project,
)

if incident_type_in.cost_model:
cost_model = cost_model_service.get_cost_model_by_id(
db_session=db_session, cost_model_id=incident_type_in.cost_model.id
)
incident_type.cost_model = cost_model

if incident_type_in.incident_template_document:
incident_template_document = document_service.get(
db_session=db_session, document_id=incident_type_in.incident_template_document.id
Expand Down Expand Up @@ -171,7 +181,27 @@ def create(*, db_session, incident_type_in: IncidentTypeCreate) -> IncidentType:
def update(
*, db_session, incident_type: IncidentType, incident_type_in: IncidentTypeUpdate
) -> IncidentType:
"""Updates an incident type."""
"""Updates an incident type.

If the cost model is updated, we need to update the costs of all incidents associated with this incident type.
"""
cost_model = None
if incident_type_in.cost_model:
cost_model = cost_model_service.get_cost_model_by_id(
db_session=db_session, cost_model_id=incident_type_in.cost_model.id
)
should_update_incident_cost = incident_type.cost_model != cost_model
incident_type.cost_model = cost_model

# Calculate the cost of all non-closed incidents associated with this incident type
incidents = incident_service.get_all_open_by_incident_type(
db_session=db_session, incident_type_id=incident_type.id
)
for incident in incidents:
incident_cost_service.calculate_incident_response_cost(
incident_id=incident.id, db_session=db_session, incident_review=False
)

if incident_type_in.incident_template_document:
incident_template_document = document_service.get(
db_session=db_session, document_id=incident_type_in.incident_template_document.id
Expand Down Expand Up @@ -205,6 +235,7 @@ def update(
"executive_template_document",
"tracking_template_document",
"review_template_document",
"cost_model",
},
)

Expand All @@ -213,6 +244,12 @@ def update(
setattr(incident_type, field, update_data[field])

db_session.commit()

if should_update_incident_cost:
incident_cost_service.update_incident_response_cost_for_incident_type(
db_session=db_session, incident_type=incident_type
)

return incident_type


Expand Down
25 changes: 22 additions & 3 deletions src/dispatch/incident_cost/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from dispatch.incident import service as incident_service
from dispatch.incident.enums import IncidentStatus
from dispatch.incident.models import Incident
from dispatch.incident.type.models import IncidentType
from dispatch.incident_cost_type import service as incident_cost_type_service
from dispatch.incident_cost_type.models import IncidentCostTypeRead
from dispatch.participant import service as participant_service
Expand Down Expand Up @@ -132,6 +133,17 @@ def get_hourly_rate(project) -> int:
return math.ceil(project.annual_employee_cost / project.business_year_hours)


def update_incident_response_cost_for_incident_type(
db_session, incident_type: IncidentType
) -> None:
"""Calculate the response cost of all non-closed incidents associated with this incident type."""
incidents = incident_service.get_all_open_by_incident_type(
db_session=db_session, incident_type_id=incident_type.id
)
for incident in incidents:
update_incident_response_cost(incident_id=incident.id, db_session=db_session)


def calculate_response_cost(
hourly_rate, total_response_time_seconds, incident_review_hours=0
) -> int:
Expand Down Expand Up @@ -255,7 +267,7 @@ def calculate_incident_response_cost_with_cost_model(
oldest = incident_response_cost.updated_at.replace(tzinfo=timezone.utc).timestamp()

# Get the cost model. Iterate through all the listed activities we want to record.
for activity in incident.cost_model.activities:
for activity in incident.incident_type.cost_model.activities:

# Array of sorted (timestamp, user_id) tuples.
incident_events = fetch_incident_events(
Expand Down Expand Up @@ -457,8 +469,15 @@ def calculate_incident_response_cost(
log.warning(f"Incident with id {incident_id} not found.")
return 0

if incident.cost_model and incident.cost_model.enabled:
log.debug(f"Calculating {incident.name} incident cost with model {incident.cost_model}.")
incident_type = incident.incident_type
if not incident_type:
log.warning(f"Incident type for incident {incident.name} not found.")
return 0

if incident_type.cost_model and incident_type.cost_model.enabled:
log.debug(
f"Calculating {incident.name} incident cost with model {incident_type.cost_model}."
)
return calculate_incident_response_cost_with_cost_model(
incident=incident, db_session=db_session
)
Expand Down
Loading