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
53 changes: 53 additions & 0 deletions src/dispatch/case/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from dispatch.enums import Visibility
from dispatch.event.models import EventRead
from dispatch.group.models import Group, GroupRead
from dispatch.individual.models import IndividualContactRead
from dispatch.messaging.strings import CASE_RESOLUTION_DEFAULT
from dispatch.models import (
DispatchBase,
Expand Down Expand Up @@ -184,6 +185,13 @@ class Case(Base, TimeStampMixin, ProjectMixin):
order_by="CaseCost.created_at",
)

case_notes = relationship(
"CaseNotes",
back_populates="case",
cascade="all, delete-orphan",
uselist=False,
)

@observes("participants")
def participant_observer(self, participants):
"""Update team and location fields based on the most common values among participants."""
Expand Down Expand Up @@ -232,6 +240,21 @@ def total_cost_new(self):
return total_cost


class CaseNotes(Base, TimeStampMixin):
"""SQLAlchemy model for case investigation notes."""

id = Column(Integer, primary_key=True)
content = Column(String)

# Foreign key to case
case_id = Column(Integer, ForeignKey("case.id", ondelete="CASCADE"))
case = relationship("Case", back_populates="case_notes")

# Foreign key to individual who last updated
last_updated_by_id = Column(Integer, ForeignKey("individual_contact.id"))
last_updated_by = relationship("IndividualContact", foreign_keys=[last_updated_by_id])


class SignalRead(DispatchBase):
"""Pydantic model for reading signal data."""

Expand Down Expand Up @@ -265,6 +288,34 @@ class ProjectRead(DispatchBase):
allow_self_join: bool | None = Field(True, nullable=True)


# CaseNotes Pydantic models
class CaseNotesBase(DispatchBase):
"""Base Pydantic model for case notes data."""

content: str | None = None


class CaseNotesCreate(CaseNotesBase):
"""Pydantic model for creating case notes."""

pass


class CaseNotesUpdate(CaseNotesBase):
"""Pydantic model for updating case notes."""

pass


class CaseNotesRead(CaseNotesBase):
"""Pydantic model for reading case notes data."""

id: PrimaryKey
created_at: datetime | None = None
updated_at: datetime | None = None
last_updated_by: IndividualContactRead | None = None


# Pydantic models...
class CaseBase(DispatchBase):
"""Base Pydantic model for case data."""
Expand Down Expand Up @@ -407,6 +458,7 @@ class CaseRead(CaseBase):
updated_at: datetime | None = None
workflow_instances: list[WorkflowInstanceRead] | None = []
event: bool | None = False
case_notes: CaseNotesRead | None = None


class CaseUpdate(CaseBase):
Expand All @@ -427,6 +479,7 @@ class CaseUpdate(CaseBase):
reported_at: datetime | None = None
tags: list[TagRead] | None = []
triage_at: datetime | None = None
case_notes: CaseNotesUpdate | None = None

@field_validator("tags")
@classmethod
Expand Down
29 changes: 27 additions & 2 deletions src/dispatch/case/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from dispatch.case_cost import service as case_cost_service
from dispatch.event import service as event_service
from dispatch.incident import service as incident_service
from dispatch.individual import service as individual_service
from dispatch.participant.models import Participant
from dispatch.participant import flows as participant_flows
from dispatch.participant_role.models import ParticipantRoleType
Expand All @@ -23,6 +24,7 @@
from .models import (
Case,
CaseCreate,
CaseNotes,
CaseRead,
CaseUpdate,
)
Expand Down Expand Up @@ -282,6 +284,7 @@ def update(*, db_session, case: Case, case_in: CaseUpdate, current_user: Dispatc
exclude={
"assignee",
"case_costs",
"case_notes",
"case_priority",
"case_severity",
"case_type",
Expand Down Expand Up @@ -392,8 +395,7 @@ def update(*, db_session, case: Case, case_in: CaseUpdate, current_user: Dispatc
db_session=db_session,
source="Dispatch Core App",
description=(
f"Case visibility changed to {case_in.visibility.lower()} "
f"by {current_user.email}"
f"Case visibility changed to {case_in.visibility.lower()} by {current_user.email}"
),
dispatch_user_id=current_user.id,
case_id=case.id,
Expand Down Expand Up @@ -426,6 +428,29 @@ def update(*, db_session, case: Case, case_in: CaseUpdate, current_user: Dispatc
incidents.append(incident_service.get(db_session=db_session, incident_id=i.id))
case.incidents = incidents

# Handle case notes update
if case_in.case_notes is not None:
# Get or create the individual contact
individual = individual_service.get_or_create(
db_session=db_session,
email=current_user.email,
project=case.project,
)

if case.case_notes:
# Update existing notes
case.case_notes.content = case_in.case_notes.content
case.case_notes.last_updated_by_id = individual.id
else:
# Create new notes
notes = CaseNotes(
content=case_in.case_notes.content,
last_updated_by_id=individual.id,
case_id=case.id,
)
db_session.add(notes)
case.case_notes = notes

db_session.commit()

return case
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Add case notes model

Revision ID: df10accae9a9
Revises: 6e66b6578810
Create Date: 2025-07-11 12:59:07.633861

"""

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "df10accae9a9"
down_revision = "6e66b6578810"
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
# Create case_notes table
op.create_table(
"case_notes",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("content", sa.String(), nullable=True),
sa.Column("case_id", sa.Integer(), nullable=True),
sa.Column("last_updated_by_id", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["case_id"], ["case.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["last_updated_by_id"], ["individual_contact.id"]),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
# Drop case_notes table
op.drop_table("case_notes")
# ### end Alembic commands ###
35 changes: 32 additions & 3 deletions src/dispatch/static/dispatch/src/case/CaseTabs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@
>
<span class="button-text">Timeline</span>
</v-btn>
<v-btn
class="text-subtitle-2 unselected-button"
height="24px"
value="notes"
variant="plain"
:ripple="false"
>
<span class="button-text">Notes</span>
</v-btn>
<v-btn
class="text-subtitle-2 unselected-button"
height="24px"
Expand Down Expand Up @@ -137,6 +146,9 @@
<v-window-item value="main" class="tab">
<case-timeline-tab v-model="events" />
</v-window-item>
<v-window-item value="notes" class="tab">
<notes-tab />
</v-window-item>
<v-window-item value="signals" class="tab">
<case-signal-instance-tab
:loading="loading"
Expand All @@ -158,6 +170,7 @@ import { useRoute, useRouter } from "vue-router"
import GraphTab from "@/case/GraphTab.vue"
import CaseSignalInstanceTab from "@/case/CaseSignalInstanceTab.vue"
import CaseTimelineTab from "@/case/TimelineTab.vue"
import NotesTab from "@/case/NotesTab.vue"

const props = defineProps({
modelValue: {
Expand Down Expand Up @@ -206,17 +219,19 @@ const router = useRouter()
watch(
() => tab.value,
(tabValue) => {
console.log("Emitting", tabValue)
emit("update:activeTab", tabValue)
// ...
}
)

watch(
() => tab.value,
(tabValue) => {
if (tabValue === "main") {
router.push({ name: "CasePage", params: { id: route.id } })
router.push({ name: "CasePage", params: route.params })
}

if (tabValue === "notes") {
router.push({ name: "CaseNotes", params: route.params })
}

if (tabValue === "signals") {
Expand All @@ -228,6 +243,20 @@ watch(
}
)

watch(
() => route.name,
(routeName) => {
if (routeName === "CaseNotes") {
tab.value = "notes"
} else if (routeName === "SignalDetails") {
tab.value = "signals"
} else if (routeName === "CasePage") {
tab.value = "main"
}
},
{ immediate: true }
)

watch(
() => route.params,
(newParams) => {
Expand Down
Loading
Loading