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
17,595 changes: 8,798 additions & 8,797 deletions docs/scripts/openapi.yaml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/dispatch/case/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class CaseStatus(DispatchEnum):
new = "New"
triage = "Triage"
escalated = "Escalated"
stable = "Stable"
closed = "Closed"


Expand Down
93 changes: 88 additions & 5 deletions src/dispatch/case/flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,11 +336,28 @@ def case_triage_create_flow(*, case_id: int, organization_slug: OrganizationSlug
case_triage_status_flow(case=case, db_session=db_session)


@background_task
def case_stable_create_flow(*, case_id: int, organization_slug: OrganizationSlug, db_session=None):
"""Runs the case stable create flow."""
# we run the case new creation flow
case_new_create_flow(
case_id=case_id, organization_slug=organization_slug, db_session=db_session
)

# we get the case
case = get(db_session=db_session, case_id=case_id)

# we transition the case to the triage state
case_triage_status_flow(case=case, db_session=db_session)

case_stable_status_flow(case=case, db_session=db_session)


@background_task
def case_escalated_create_flow(
*, case_id: int, organization_slug: OrganizationSlug, db_session=None
):
"""Runs the case escalated creation flow."""
"""Runs the case escalated create flow."""
# we run the case new creation flow
case_new_create_flow(
case_id=case_id, organization_slug=organization_slug, db_session=db_session
Expand All @@ -352,9 +369,13 @@ def case_escalated_create_flow(
# we transition the case to the triage state
case_triage_status_flow(case=case, db_session=db_session)

# we transition the case to the escalated state
# then to the stable state
case_stable_status_flow(case=case, db_session=db_session)

case_escalated_status_flow(
case=case, organization_slug=organization_slug, db_session=db_session
case=case,
organization_slug=organization_slug,
db_session=db_session,
)


Expand Down Expand Up @@ -516,12 +537,19 @@ def case_escalated_status_flow(
incident_type: IncidentType | None,
incident_description: str | None,
):
"""Runs the case escalated transition flow."""
# we set the escalated_at time
"""Runs the case escalated status flow."""
# we set the escalated at time
case.escalated_at = datetime.utcnow()
db_session.add(case)
db_session.commit()

event_service.log_case_event(
db_session=db_session,
source="Dispatch Core App",
description="Case escalated",
case_id=case.id,
)

case_to_incident_escalate_flow(
case=case,
organization_slug=organization_slug,
Expand All @@ -533,6 +561,21 @@ def case_escalated_status_flow(
)


def case_stable_status_flow(case: Case, db_session=None):
"""Runs the case stable status flow."""
# we set the stable at time
case.stable_at = datetime.utcnow()
db_session.add(case)
db_session.commit()

event_service.log_case_event(
db_session=db_session,
source="Dispatch Core App",
description="Case marked as stable",
case_id=case.id,
)


def case_closed_status_flow(case: Case, db_session=None):
"""Runs the case closed transition flow."""
# we set the closed_at time
Expand Down Expand Up @@ -723,6 +766,46 @@ def case_status_transition_flow_dispatcher(
db_session=db_session,
)

case (CaseStatus.escalated, CaseStatus.stable):
# Escalated -> Stable
case_stable_status_flow(
case=case,
db_session=db_session,
)

case (CaseStatus.triage, CaseStatus.stable):
# Triage -> Stable
case_stable_status_flow(
case=case,
db_session=db_session,
)

case (CaseStatus.new, CaseStatus.stable):
# New -> Stable
case_triage_status_flow(
case=case,
db_session=db_session,
)
case_stable_status_flow(
case=case,
db_session=db_session,
)

case (CaseStatus.stable, CaseStatus.closed):
# Stable -> Closed
case_closed_status_flow(
case=case,
db_session=db_session,
)

case (CaseStatus.closed, CaseStatus.stable):
# Closed -> Stable
case_active_status_flow(case, db_session)
case_stable_status_flow(
case=case,
db_session=db_session,
)

case (_, _):
pass

Expand Down
8 changes: 8 additions & 0 deletions src/dispatch/case/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ class Case(Base, TimeStampMixin, ProjectMixin):
dedicated_channel = Column(Boolean, default=False)
genai_analysis = Column(JSONB, default={}, nullable=False, server_default="{}")
event = Column(Boolean, default=False)
stable_at = Column(DateTime)

search_vector = Column(
TSVectorType(
Expand Down Expand Up @@ -328,6 +329,7 @@ class CaseReadMinimal(CaseBase):
status: CaseStatus | None = None # Used in table and for action disabling
closed_at: datetime | None = None
reported_at: datetime | None = None
stable_at: datetime | None = None
dedicated_channel: bool | None = None # Used by CaseStatus component
case_type: CaseTypeRead
case_severity: CaseSeverityRead
Expand All @@ -336,6 +338,7 @@ class CaseReadMinimal(CaseBase):
assignee: ParticipantReadMinimal | None = None
case_costs: list[CaseCostReadMinimal] = []


class CaseReadMinimalWithExtras(CaseBase):
"""Pydantic model for reading minimal case data."""

Expand All @@ -349,6 +352,7 @@ class CaseReadMinimalWithExtras(CaseBase):
status: CaseStatus | None = None # Used in table and for action disabling
reported_at: datetime | None = None
triage_at: datetime | None = None
stable_at: datetime | None = None
escalated_at: datetime | None = None
closed_at: datetime | None = None
dedicated_channel: bool | None = None # Used by CaseStatus component
Expand Down Expand Up @@ -379,6 +383,7 @@ class CaseRead(CaseBase):
closed_at: datetime | None = None
conversation: ConversationRead | None = None
created_at: datetime | None = None
stable_at: datetime | None = None
documents: list[DocumentRead] | None = []
duplicates: list[CaseReadBasic] | None = []
escalated_at: datetime | None = None
Expand Down Expand Up @@ -413,6 +418,7 @@ class CaseUpdate(CaseBase):
case_severity: CaseSeverityBase | None = None
case_type: CaseTypeBase | None = None
closed_at: datetime | None = None
stable_at: datetime | None = None
duplicates: list[CaseReadBasic] | None = []
related: list[CaseRead] | None = []
reporter: ParticipantUpdate | None = None
Expand Down Expand Up @@ -451,11 +457,13 @@ class CasePagination(Pagination):

items: list[CaseReadMinimal] = []


class CasePaginationMinimalWithExtras(Pagination):
"""Pydantic model for paginated minimal case results."""

items: list[CaseReadMinimalWithExtras] = []


class CaseExpandedPagination(Pagination):
"""Pydantic model for paginated expanded case results."""

Expand Down
22 changes: 22 additions & 0 deletions src/dispatch/case/scheduled.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,25 @@ def case_triage_reminder(db_session: Session, project: Project):
if q >= 1:
# we only send one reminder per case per day
send_case_triage_reminder(case, db_session)


@scheduler.add(every(1).day.at("18:00"), name="case-stable-reminder")
@timer
@scheduled_project_task
def case_stable_reminder(db_session: Session, project: Project):
"""Sends a reminder to the case assignee to close their stable case."""
cases = get_all_by_status(
db_session=db_session, project_id=project.id, statuses=[CaseStatus.stable]
)

for case in cases:
try:
span = datetime.utcnow() - case.stable_at
q, r = divmod(span.days, 7)
if q >= 1 and date.today().isoweekday() == 1:
# we only send the reminder for cases that have been stable
# longer than a week and only on Mondays
send_case_close_reminder(case, db_session)
except Exception as e:
# if one fails we don't want all to fail
log.exception(e)
24 changes: 20 additions & 4 deletions src/dispatch/case/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,10 @@ def get_all_by_status(
Case.created_at,
Case.updated_at,
Case.triage_at,
Case.escalated_at,
Case.stable_at,
Case.closed_at,
)
),
)
.filter(Case.project_id == project_id)
.filter(Case.status.in_(statuses))
Expand Down Expand Up @@ -141,6 +143,15 @@ def get_all_last_x_hours_by_status(
.all()
)

if status == CaseStatus.stable:
return (
db_session.query(Case)
.filter(Case.project_id == project_id)
.filter(Case.status == CaseStatus.stable)
.filter(Case.stable_at >= now - timedelta(hours=hours))
.all()
)

if status == CaseStatus.closed:
return (
db_session.query(Case)
Expand All @@ -152,13 +163,16 @@ def get_all_last_x_hours_by_status(


def create(*, db_session, case_in: CaseCreate, current_user: DispatchUser = None) -> Case:
"""Creates a new case.
"""
Creates a new case.

Returns:
The created case.

Raises:
ValidationError: If the case type does not have a conversation target and the case is not being created with a dedicated channel, the case will not be created.
ValidationError: If the case type does not have a conversation target and
the case is not being created with a dedicated channel, the case will not
be created.
"""
project = project_service.get_by_name_or_default(
db_session=db_session, project_in=case_in.project
Expand All @@ -177,7 +191,9 @@ def create(*, db_session, case_in: CaseCreate, current_user: DispatchUser = None
if not case_in.dedicated_channel:
if not case_type or not case_type.conversation_target:
raise ValueError(
f"Cases without dedicated channels require a conversation target. Case type with name {case_in.case_type.name} does not have a conversation target. The case will not be created."
f"Cases without dedicated channels require a conversation target. "
f"Case type with name {case_in.case_type.name} does not have a "
f"conversation target. The case will not be created."
)

case = Case(
Expand Down
7 changes: 7 additions & 0 deletions src/dispatch/case/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
case_delete_flow,
case_escalated_create_flow,
case_new_create_flow,
case_stable_create_flow,
case_to_incident_endpoint_escalate_flow,
case_triage_create_flow,
case_update_flow,
Expand Down Expand Up @@ -214,6 +215,12 @@ def create_case(
case_id=case.id,
organization_slug=organization,
)
elif case.status == CaseStatus.stable:
background_tasks.add_task(
case_stable_create_flow,
case_id=case.id,
organization_slug=organization,
)
else:
background_tasks.add_task(
case_new_create_flow,
Expand Down
16 changes: 13 additions & 3 deletions src/dispatch/case_cost/scheduled.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
def calculate_cases_response_cost(db_session: Session, project: Project):
"""Calculates and saves the response cost for all cases."""
cases = case_service.get_all_by_status(
db_session=db_session, project_id=project.id, statuses=[CaseStatus.new, CaseStatus.triage]
db_session=db_session,
project_id=project.id,
statuses=[CaseStatus.new, CaseStatus.triage, CaseStatus.stable],
)

for case in cases:
Expand All @@ -38,16 +40,24 @@ def calculate_cases_response_cost(db_session: Session, project: Project):
case=case, db_session=db_session, model_type=CostModelType.new
)

# we don't need to update the cost of closed cases if they already have a response cost and this was updated after the case was closed
# we don't need to update the cost of closed cases if they already have a response
# cost and this was updated after the case was closed
if case.status == CaseStatus.closed:
if case_response_cost_classic:
if case_response_cost_classic.updated_at > case.closed_at:
continue
# we don't need to update the cost of escalated cases if they already have a response cost and this was updated after the case was escalated
# we don't need to update the cost of escalated cases if they already have a response
# cost and this was updated after the case was escalated
if case.status == CaseStatus.escalated:
if case_response_cost_classic:
if case_response_cost_classic.updated_at > case.escalated_at:
continue
# we don't need to update the cost of stable cases if they already have a response
# cost and this was updated after the case was marked as stable
if case.status == CaseStatus.stable:
if case_response_cost_classic:
if case.stable_at and case_response_cost_classic.updated_at > case.stable_at:
continue

# we calculate the response cost amount
results = update_case_response_cost(case, db_session)
Expand Down
Loading
Loading