From e465bd16b75e2c08b3042c0f0547307db8d2e098 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 22 Aug 2026 12:32:45 +0530 Subject: [PATCH 1/6] fix(contracts): rename GET /incidents filter to incident_category --- contracts/path/incidents.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/contracts/path/incidents.yaml b/contracts/path/incidents.yaml index 08d97de9..a050d8d8 100644 --- a/contracts/path/incidents.yaml +++ b/contracts/path/incidents.yaml @@ -95,9 +95,9 @@ incidents: schema: type: string format: date - - name: incident_type + - name: incident_category in: query - description: Filter by incident category + description: Filter by the incident's primary category schema: $ref: "../schemas/enums.yaml#/IncidentCategory" - name: status @@ -142,7 +142,8 @@ incidents: incident_number: "CA-SQF-2024-0421" status: "draft" incident_name: "Bear Creek Wildfire" - incident_type: "fire" + incident_type: "wildland_fire" + incident_category: "fire" incident_datetime: "2024-07-10T13:52:00-07:00" forms_count: 3 created_at: "2024-07-15T14:35:00Z" From 07a9f91715fd94018935a3c47ca5e014a9c82c3b Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 22 Aug 2026 12:34:49 +0530 Subject: [PATCH 2/6] fix(incidents): promote incident_name and incident_type from the contract --- app/services/extraction/worker.py | 2 -- app/services/incidents.py | 25 ++++++++++++++++++------- tests/test_repositories_promote.py | 13 +++++++++++-- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/app/services/extraction/worker.py b/app/services/extraction/worker.py index 1ad877e7..814c7519 100644 --- a/app/services/extraction/worker.py +++ b/app/services/extraction/worker.py @@ -112,8 +112,6 @@ def _write_incident(session: Session, extraction, contract: dict[str, Any]): promoted = promote(contract) for column in PROMOTED_COLUMNS: setattr(incident, column, promoted[column]) - incident_section = contract.get("incident") or {} - incident.incident_name = incident_section.get("name") incident.updated_at = _now() return update_incident(session, incident) diff --git a/app/services/incidents.py b/app/services/incidents.py index e3827051..73081052 100644 --- a/app/services/incidents.py +++ b/app/services/incidents.py @@ -13,6 +13,8 @@ # Keys of the value dict returned by ``promote``. Kept explicit so callers can # apply the result to an Incident row without guessing field names. PROMOTED_COLUMNS = ( + "incident_name", + "incident_type", "incident_category", "incident_datetime", "city", @@ -66,16 +68,22 @@ def _seconds_between(start: Any, end: Any) -> int | None: return int(delta) -def _primary_category(incident: dict) -> str | None: - """Category of the incident type flagged primary, else the first type's.""" +def _primary_type(incident: dict) -> dict: + """The incident type flagged primary, else the first one, else empty. + + Both `incident_category` and `incident_type` come off this single entry, so + they can never describe two different types. + """ types = incident.get("types") - if not isinstance(types, list) or not types: - return None + if not isinstance(types, list): + return {} entries = [t for t in types if isinstance(t, dict)] + if not entries: + return {} for entry in entries: if entry.get("primary"): - return entry.get("category") - return entries[0].get("category") if entries else None + return entry + return entries[0] def _incident_datetime(incident: dict, dispatch: dict) -> datetime | None: @@ -158,9 +166,12 @@ def promote(contract: dict | None) -> dict[str, Any]: call_received = dispatch.get("call_received_datetime") or incident.get("alarm_datetime") first_arrival = incident.get("first_arrival_datetime") first_unit = _first_unit(contract) + primary_type = _primary_type(incident) return { - "incident_category": _primary_category(incident), + "incident_name": incident.get("name"), + "incident_type": primary_type.get("subcategory"), + "incident_category": primary_type.get("category"), "incident_datetime": _incident_datetime(incident, dispatch), "city": location.get("city"), "state": location.get("state"), diff --git a/tests/test_repositories_promote.py b/tests/test_repositories_promote.py index 150c82ca..57381bd3 100644 --- a/tests/test_repositories_promote.py +++ b/tests/test_repositories_promote.py @@ -117,9 +117,10 @@ def test_partial_contract_only_fills_present_fields(self): FULL_CONTRACT = { "incident": { + "name": "Bear Creek Wildfire", "types": [ - {"primary": False, "category": "ems"}, - {"primary": True, "category": "fire"}, + {"primary": False, "category": "ems", "subcategory": "medical_assist"}, + {"primary": True, "category": "fire", "subcategory": "wildland_fire"}, ], "alarm_datetime": "2024-07-10T13:50:00-07:00", "start_datetime": "2024-07-10T13:40:00-07:00", @@ -167,6 +168,14 @@ def setup_method(self): def test_category_from_primary_type(self): assert self.result["incident_category"] == "fire" + def test_name_promoted(self): + assert self.result["incident_name"] == "Bear Creek Wildfire" + + def test_type_is_the_primary_subcategory(self): + # Both come off the same entry, so they can never describe two + # different types: the non-primary ems/medical_assist pair is ignored. + assert self.result["incident_type"] == "wildland_fire" + def test_incident_datetime_prefers_alarm(self): # Alarm wins over start and dispatch call-received. assert self.result["incident_datetime"].isoformat() == "2024-07-10T13:50:00-07:00" From 9edf44dd5c42a3f559df3c836862a103a216438b Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 22 Aug 2026 12:35:36 +0530 Subject: [PATCH 3/6] feat(incidents): index incidents for listing, drop unused incident_date --- .../versions/006_incidents_crud_indexes.py | 35 +++++++++++++ app/models/models.py | 20 +++++++- tests/test_migrations.py | 49 +++++++++++++++++-- tests/test_v1_models.py | 6 --- 4 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 alembic/versions/006_incidents_crud_indexes.py diff --git a/alembic/versions/006_incidents_crud_indexes.py b/alembic/versions/006_incidents_crud_indexes.py new file mode 100644 index 00000000..53b58fe6 --- /dev/null +++ b/alembic/versions/006_incidents_crud_indexes.py @@ -0,0 +1,35 @@ +"""incidents crud indexes + +Revision ID: 006 +Revises: 005 +Create Date: 2026-08-22 06:49:34.422095 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '006' +down_revision: Union[str, None] = '005' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_index('ix_incidents_live_datetime', 'incidents', ['deleted_at', 'incident_datetime'], unique=False) + op.create_index('ix_incidents_number_live', 'incidents', ['incident_number'], unique=True, postgresql_where=sa.text('incident_number IS NOT NULL AND deleted_at IS NULL'), sqlite_where=sa.text('incident_number IS NOT NULL AND deleted_at IS NULL')) + op.drop_column('incidents', 'incident_date') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('incidents', sa.Column('incident_date', sa.DATE(), autoincrement=False, nullable=True)) + op.drop_index('ix_incidents_number_live', table_name='incidents', postgresql_where=sa.text('incident_number IS NOT NULL AND deleted_at IS NULL'), sqlite_where=sa.text('incident_number IS NOT NULL AND deleted_at IS NULL')) + op.drop_index('ix_incidents_live_datetime', table_name='incidents') + # ### end Alembic commands ### diff --git a/app/models/models.py b/app/models/models.py index 29e2f9de..49954db2 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -2,7 +2,7 @@ from uuid import UUID, uuid4 from datetime import date, datetime, timezone -from sqlalchemy import Column, JSON +from sqlalchemy import Column, Index, JSON, text from sqlmodel import SQLModel, Field from sqlmodel.sql.sqltypes import AutoString @@ -108,6 +108,23 @@ class Extraction(SQLModel, table=True): class Incident(SQLModel, table=True): __tablename__ = "incidents" + __table_args__ = ( + # A department's incident number identifies one live incident. Partial + # so a soft-deleted row does not keep its number reserved forever, and + # so the many rows still awaiting a number do not collide on NULL. + Index( + "ix_incidents_number_live", + "incident_number", + unique=True, + postgresql_where=text("incident_number IS NOT NULL AND deleted_at IS NULL"), + sqlite_where=text("incident_number IS NOT NULL AND deleted_at IS NULL"), + ), + # Covers GET /incidents: every query excludes soft-deleted rows and + # then sorts on incident_datetime. The status and incident_category + # filters are left unindexed on purpose, they are low cardinality and + # a department-sized table does not need them. + Index("ix_incidents_live_datetime", "deleted_at", "incident_datetime"), + ) incident_id: UUID = Field(default_factory=uuid4, primary_key=True) extract_id: UUID = Field(foreign_key="extractions.extract_id") @@ -117,7 +134,6 @@ class Incident(SQLModel, table=True): ) incident_name: str | None = None incident_type: str | None = None - incident_date: date | None = None tags: list | None = Field(default=None, sa_column=Column(JSON)) notes: str | None = None # The single store of the incident contract. Created as a draft when diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 2e747ed5..9b6e203f 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -205,7 +205,6 @@ def test_incidents_columns(alembic_cfg, alembic_engine): "status", "incident_name", "incident_type", - "incident_date", "tags", "notes", "incident_contract", @@ -462,9 +461,13 @@ def test_forms_extract_id_removed(alembic_cfg, alembic_engine): def test_downgrade_005(alembic_cfg, alembic_engine): - """Downgrade by one step from head restores 004's forms shape.""" + """Downgrade to 004 restores 004's forms shape. + + Targets "004" explicitly rather than "-1": 006 now sits on top of head, so + a relative one-step downgrade only undoes 006, not 005. + """ command.upgrade(alembic_cfg, "head") - command.downgrade(alembic_cfg, "-1") + command.downgrade(alembic_cfg, "004") inspector = inspect(alembic_engine) columns = {c["name"]: c for c in inspector.get_columns("forms")} @@ -483,7 +486,7 @@ def test_downgrade_005(alembic_cfg, alembic_engine): def test_round_trip_005(alembic_cfg, alembic_engine): command.upgrade(alembic_cfg, "head") - command.downgrade(alembic_cfg, "-1") + command.downgrade(alembic_cfg, "004") command.upgrade(alembic_cfg, "head") inspector = inspect(alembic_engine) @@ -491,3 +494,41 @@ def test_round_trip_005(alembic_cfg, alembic_engine): assert "template_id" in columns assert "batch_id" in columns assert "extract_id" not in columns + + +def test_incidents_indexes(alembic_cfg, alembic_engine): + """006 adds the two indexes GET /incidents and the number check rely on.""" + command.upgrade(alembic_cfg, "head") + + inspector = inspect(alembic_engine) + indexes = {ix["name"]: ix for ix in inspector.get_indexes("incidents")} + + assert indexes["ix_incidents_live_datetime"]["column_names"] == [ + "deleted_at", + "incident_datetime", + ] + assert indexes["ix_incidents_number_live"]["unique"] + + +def test_downgrade_006(alembic_cfg, alembic_engine): + """Downgrade to 005 drops the indexes and puts incident_date back.""" + command.upgrade(alembic_cfg, "head") + command.downgrade(alembic_cfg, "005") + + inspector = inspect(alembic_engine) + assert "incident_date" in {c["name"] for c in inspector.get_columns("incidents")} + assert "ix_incidents_live_datetime" not in { + ix["name"] for ix in inspector.get_indexes("incidents") + } + + +def test_round_trip_006(alembic_cfg, alembic_engine): + command.upgrade(alembic_cfg, "head") + command.downgrade(alembic_cfg, "005") + command.upgrade(alembic_cfg, "head") + + inspector = inspect(alembic_engine) + assert "incident_date" not in {c["name"] for c in inspector.get_columns("incidents")} + assert "ix_incidents_number_live" in { + ix["name"] for ix in inspector.get_indexes("incidents") + } diff --git a/tests/test_v1_models.py b/tests/test_v1_models.py index 00472924..5b732a56 100644 --- a/tests/test_v1_models.py +++ b/tests/test_v1_models.py @@ -245,17 +245,11 @@ def test_soft_delete(self, db): fetched = db.get(Incident, row.incident_id) assert fetched.deleted_at is not None - def test_incident_date_field(self, db): - row = self._make(db, incident_date=date(2026, 5, 15)) - fetched = db.get(Incident, row.incident_id) - assert fetched.incident_date == date(2026, 5, 15) - def test_all_nullable_fields_default_none(self, db): row = self._make(db) assert row.incident_number is None assert row.incident_name is None assert row.incident_type is None - assert row.incident_date is None assert row.notes is None def test_incident_contract_json_roundtrip(self, db): From 0c8290e2cc1c017d638cd1b781956657de2e5c09 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 22 Aug 2026 12:36:07 +0530 Subject: [PATCH 4/6] feat(incidents): add list, number lookup and form count queries --- app/db/repositories.py | 98 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/app/db/repositories.py b/app/db/repositories.py index ae418414..d03df596 100644 --- a/app/db/repositories.py +++ b/app/db/repositories.py @@ -1,5 +1,7 @@ +from datetime import date, datetime, time, timedelta from uuid import UUID +from sqlalchemy import func, nullslast from sqlmodel import Session, select from app.models import ( @@ -13,7 +15,7 @@ Incident, TemplateUpload, ) -from app.api.schemas.enums import ReportStatus +from app.api.schemas.enums import IncidentCategory, ReportStatus # Templates (legacy fill pipeline - read-only lookup, consumed by forms/jobs/tasks) def get_template(session: Session, template_id: int) -> Template | None: @@ -185,6 +187,16 @@ def update_extraction(session: Session, extraction: Extraction) -> Extraction: # Incidents +def _day_start(value: date) -> datetime: + """Midnight on the given day, naive. + + incident_datetime is a naive DateTime column and the offset on the value + promoted from the contract is dropped on write, so what is stored is local + wall-clock time. Date bounds are built the same way to match. + """ + return datetime.combine(value, time.min) + + def create_incident(session: Session, incident: Incident) -> Incident: session.add(incident) session.commit() @@ -217,3 +229,87 @@ def create_draft_incident(session: Session, extract_id: UUID) -> Incident: incident = Incident(extract_id=extract_id, status=ReportStatus.draft) return create_incident(session, incident) + +def get_incident_by_number(session: Session, incident_number: str) -> Incident | None: + """Look up a live incident by its department-assigned number. + + Soft-deleted rows are skipped so a deleted incident does not block its + number from being reused. + """ + statement = select(Incident).where( + Incident.incident_number == incident_number, + Incident.deleted_at.is_(None), + ) + return session.exec(statement).first() + + +def list_incidents( + session: Session, + date_from: date | None = None, + date_to: date | None = None, + incident_category: IncidentCategory | None = None, + status: ReportStatus | None = None, + page: int = 1, + per_page: int = 20, + sort: str = "date_desc", +) -> tuple[list[Incident], int]: + """One page of live incidents plus the total matching the filters. + + Date filters are inclusive and apply to incident_datetime, which is + nullable, so rows without one are excluded whenever a date bound is given + and sort last otherwise. created_at breaks ties, keeping paging stable + across rows that share an incident_datetime. + """ + conditions = [Incident.deleted_at.is_(None)] + if date_from is not None: + conditions.append(Incident.incident_datetime >= _day_start(date_from)) + if date_to is not None: + conditions.append(Incident.incident_datetime < _day_start(date_to) + timedelta(days=1)) + if incident_category is not None: + conditions.append(Incident.incident_category == incident_category) + if status is not None: + conditions.append(Incident.status == status) + + total = session.exec( + select(func.count()).select_from(Incident).where(*conditions) + ).one() + + ascending = sort == "date_asc" + ordering = ( + nullslast(Incident.incident_datetime.asc()) if ascending + else nullslast(Incident.incident_datetime.desc()) + ) + tiebreak = Incident.created_at.asc() if ascending else Incident.created_at.desc() + + statement = ( + select(Incident) + .where(*conditions) + .order_by(ordering, tiebreak, Incident.incident_id) + .offset((page - 1) * per_page) + .limit(per_page) + ) + return list(session.exec(statement)), total + + +def list_forms_by_incident(session: Session, incident_id: UUID) -> list[Form]: + statement = select(Form).where(Form.incident_id == incident_id).order_by( + Form.created_at, Form.form_id + ) + return list(session.exec(statement)) + + +def count_forms_by_incident(session: Session, incident_ids: list[UUID]) -> dict[UUID, int]: + """Form counts for a page of incidents, as one grouped query. + + Counting per row would issue a query per incident on every list request. + Incidents with no forms are absent from the result; callers default to 0. + """ + if not incident_ids: + return {} + statement = ( + select(Form.incident_id, func.count()) + .where(Form.incident_id.in_(incident_ids)) + .group_by(Form.incident_id) + ) + return {incident_id: count for incident_id, count in session.exec(statement)} + From bbb20b9c9face36c6c1553ddcea95b753ccf5ab3 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 22 Aug 2026 12:36:33 +0530 Subject: [PATCH 5/6] feat(incidents): add the five incident CRUD endpoints --- app/api/router.py | 4 +- app/api/routes/incidents.py | 165 ++++++++++++++++++++++++++++++++ app/api/schemas/incidents.py | 166 ++++++++++++++++++++++++++++++++ app/services/incident_crud.py | 173 ++++++++++++++++++++++++++++++++++ 4 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 app/api/routes/incidents.py create mode 100644 app/api/schemas/incidents.py create mode 100644 app/services/incident_crud.py diff --git a/app/api/router.py b/app/api/router.py index 4521b125..d19767f9 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -5,6 +5,7 @@ form_generation, forms, form_templates, + incidents, input, jobs, system, @@ -28,4 +29,5 @@ api_router.include_router(weather.router, prefix=API_PREFIX) api_router.include_router(zipcode.router, prefix=API_PREFIX) api_router.include_router(input.router, prefix=API_PREFIX) -api_router.include_router(extraction.router, prefix=API_PREFIX) \ No newline at end of file +api_router.include_router(extraction.router, prefix=API_PREFIX) +api_router.include_router(incidents.router, prefix=API_PREFIX) \ No newline at end of file diff --git a/app/api/routes/incidents.py b/app/api/routes/incidents.py new file mode 100644 index 00000000..15403080 --- /dev/null +++ b/app/api/routes/incidents.py @@ -0,0 +1,165 @@ +"""Contract Layer 4 incident endpoints (contracts/path/incidents.yaml). + +Handlers are thin; the logic lives in app/services/incident_crud.py. The one +job kept here is assembling the response shapes, since the DB stores the +promoted analytics as flat columns while the contract nests them under +`analytics`. +""" + +from datetime import date +from math import ceil +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from sqlmodel import Session + +from app.api.deps import get_db +from app.api.schemas.common import Pagination +from app.api.schemas.enums import IncidentCategory, ReportStatus +from app.api.schemas.form_generation import FormRecord +from app.api.schemas.incidents import ( + CreateIncidentRequest, + DeleteIncidentResponse, + GeneratedForm, + IncidentAnalytics, + IncidentListItem, + IncidentListResponse, + IncidentRecord, + IncidentRecordFull, + SubmissionLogEntry, + UpdateIncidentRequest, +) +from app.api.schemas.incident_contract import IncidentContract +from app.models import Form, Incident +from app.services.incident_crud import IncidentService + +router = APIRouter(prefix="/incidents", tags=["incidents"]) + + +def _form_summary(form: Form) -> GeneratedForm: + return GeneratedForm(form_id=form.form_id, form_type=form.form_type, status=form.status) + + +def _record(incident: Incident, forms: list[Form]) -> IncidentRecord: + """The incident row as the contract's IncidentRecord.""" + return IncidentRecord( + incident_id=incident.incident_id, + extract_id=incident.extract_id, + incident_number=incident.incident_number, + status=incident.status, + incident_name=incident.incident_name, + incident_type=incident.incident_type, + incident_category=incident.incident_category, + incident_datetime=incident.incident_datetime, + analytics=IncidentAnalytics.model_validate(incident), + forms_generated=[_form_summary(f) for f in forms], + tags=incident.tags or [], + notes=incident.notes, + created_at=incident.created_at, + updated_at=incident.updated_at, + deleted_at=incident.deleted_at, + ) + + +def _submission_log(contract: dict | None) -> list[SubmissionLogEntry]: + """Submissions read out of the contract document. + + Empty until the submission layer exists; nothing writes it today. + """ + entries = (contract or {}).get("submission_log") + if not isinstance(entries, list): + return [] + return [SubmissionLogEntry.model_validate(e) for e in entries if isinstance(e, dict)] + + +@router.post("", response_model=IncidentRecord, status_code=201) +def create_incident(body: CreateIncidentRequest, db: Session = Depends(get_db)): + service = IncidentService() + incident = service.finalize(db, body) + return _record(incident, service.forms(db, incident.incident_id)) + + +@router.get("", response_model=IncidentListResponse) +def list_incidents( + db: Session = Depends(get_db), + date_from: date | None = Query(default=None), + date_to: date | None = Query(default=None), + incident_category: IncidentCategory | None = Query(default=None), + status: ReportStatus | None = Query(default=None), + page: int = Query(default=1, ge=1), + per_page: int = Query(default=20, ge=1, le=100), + sort: str = Query(default="date_desc", pattern="^(date_asc|date_desc)$"), +): + rows, counts, total = IncidentService().list_page( + db, + date_from=date_from, + date_to=date_to, + incident_category=incident_category, + status=status, + page=page, + per_page=per_page, + sort=sort, + ) + total_pages = ceil(total / per_page) if total else 0 + return IncidentListResponse( + data=[ + IncidentListItem( + incident_id=row.incident_id, + incident_number=row.incident_number, + status=row.status, + incident_name=row.incident_name, + incident_type=row.incident_type, + incident_category=row.incident_category, + incident_datetime=row.incident_datetime, + city=row.city, + country=row.country, + forms_count=counts.get(row.incident_id, 0), + created_at=row.created_at, + ) + for row in rows + ], + pagination=Pagination( + total=total, + page=page, + per_page=per_page, + total_pages=total_pages, + has_next=page < total_pages, + has_prev=page > 1, + ), + ) + + +@router.get("/{incident_id}", response_model=IncidentRecordFull) +def get_incident(incident_id: UUID, db: Session = Depends(get_db)): + service = IncidentService() + incident = service.get(db, incident_id) + forms = service.forms(db, incident_id) + base = _record(incident, forms) + return IncidentRecordFull( + **base.model_dump(), + incident_contract=( + IncidentContract.model_validate(incident.incident_contract) + if incident.incident_contract + else None + ), + forms=[FormRecord.model_validate(f, from_attributes=True) for f in forms], + submission_log=_submission_log(incident.incident_contract), + ) + + +@router.patch("/{incident_id}", response_model=IncidentRecord) +def update_incident( + incident_id: UUID, body: UpdateIncidentRequest, db: Session = Depends(get_db) +): + service = IncidentService() + incident = service.update(db, incident_id, body) + return _record(incident, service.forms(db, incident_id)) + + +@router.delete("/{incident_id}", response_model=DeleteIncidentResponse) +def delete_incident(incident_id: UUID, db: Session = Depends(get_db)): + incident = IncidentService().soft_delete(db, incident_id) + return DeleteIncidentResponse( + incident_id=incident.incident_id, + deleted_at=incident.deleted_at, + ) diff --git a/app/api/schemas/incidents.py b/app/api/schemas/incidents.py new file mode 100644 index 00000000..4319f867 --- /dev/null +++ b/app/api/schemas/incidents.py @@ -0,0 +1,166 @@ +"""Contract Layer 4 incident schemas (contracts/schemas/incident-record.yaml). + +The DB stores the promoted analytics as flat columns on the incident row, but +the contract nests them under an `analytics` object. `IncidentAnalytics` owns +that reshaping so routes and services never assemble the block by hand. +""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.api.schemas.common import Pagination +from app.api.schemas.enums import FormStatus, IncidentCategory, ReportStatus +from app.api.schemas.form_generation import FormRecord +from app.api.schemas.incident_contract import IncidentContract + + +# --------------------------------------------------------------------------- +# Requests +# --------------------------------------------------------------------------- + +class CreateIncidentRequest(BaseModel): + """POST /incidents body. + + Finalizes the draft incident that was created when the extraction + completed. `extract_id` resolves to that existing row; a second row is + never created for the same extraction. + """ + + extract_id: UUID + incident_number: str | None = None + tags: list[str] | None = None + + +class UpdateIncidentRequest(BaseModel): + """PATCH /incidents/{id} body. + + A partial update: only the fields actually present in the request body are + applied, so omitting a field leaves it untouched rather than nulling it. + Callers read that distinction off `model_fields_set`, which is why no + field here carries a meaningful default. + """ + + status: ReportStatus | None = None + tags: list[str] | None = None + incident_number: str | None = None + notes: str | None = None + + +# --------------------------------------------------------------------------- +# Responses +# --------------------------------------------------------------------------- + +class IncidentAnalytics(BaseModel): + """Read-only stats promoted out of the incident contract. + + Recomputed server-side by `app.services.incidents.promote` on every change + to the document. Clients never write these. + """ + + model_config = ConfigDict(from_attributes=True) + + city: str | None = None + state: str | None = None + country: str | None = None + civilian_injuries: int | None = None + civilian_fatalities: int | None = None + responder_injuries: int | None = None + responder_fatalities: int | None = None + people_rescued: int | None = None + people_evacuated: int | None = None + structures_destroyed: int | None = None + area_burned_ha: float | None = None + total_loss_amount: float | None = None + total_loss_currency: str | None = None + call_to_arrival_seconds: int | None = None + turnout_seconds_first_unit: int | None = None + travel_seconds_first_unit: int | None = None + on_scene_duration_seconds: int | None = None + + +class GeneratedForm(BaseModel): + """One entry in `forms_generated`, the at-a-glance form summary.""" + + form_id: UUID + # Open string rather than the FormType enum, matching FormRecord: a + # registry can hold form types the closed enum does not know about yet. + form_type: str + status: FormStatus + + +class IncidentRecord(BaseModel): + """The incident record without its contract document or full form rows.""" + + incident_id: UUID + extract_id: UUID + incident_number: str | None = None + status: ReportStatus + incident_name: str | None = None + incident_type: str | None = None + incident_category: IncidentCategory | None = None + incident_datetime: datetime | None = None + analytics: IncidentAnalytics | None = None + forms_generated: list[GeneratedForm] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) + notes: str | None = None + created_at: datetime + updated_at: datetime + deleted_at: datetime | None = None + + +class SubmissionLogEntry(BaseModel): + """One agency submission, read out of the contract document. + + Stays empty until the submission layer exists; nothing writes it today. + """ + + form_type: str | None = None + submitted_at: datetime | None = None + submitted_to: str | None = None + status: str | None = None + + +class IncidentRecordFull(IncidentRecord): + """GET /incidents/{id}: the record plus everything hanging off it.""" + + incident_contract: IncidentContract | None = None + forms: list[FormRecord] = Field(default_factory=list) + submission_log: list[SubmissionLogEntry] = Field(default_factory=list) + + +class IncidentListItem(BaseModel): + """One row of GET /incidents. + + Deliberately flatter than IncidentRecord: a list view needs the location + columns but not the whole analytics block, and a form count rather than + the forms themselves. + """ + + incident_id: UUID + incident_number: str | None = None + status: ReportStatus + incident_name: str | None = None + incident_type: str | None = None + incident_category: IncidentCategory | None = None + incident_datetime: datetime | None = None + city: str | None = None + country: str | None = None + forms_count: int = 0 + created_at: datetime + + +class IncidentListResponse(BaseModel): + data: list[IncidentListItem] = Field(default_factory=list) + pagination: Pagination + + +class DeleteIncidentResponse(BaseModel): + """200 body for DELETE /incidents/{id}. The row is never removed.""" + + incident_id: UUID + deleted_at: datetime + recoverable: bool = True diff --git a/app/services/incident_crud.py b/app/services/incident_crud.py new file mode 100644 index 00000000..ba854c1f --- /dev/null +++ b/app/services/incident_crud.py @@ -0,0 +1,173 @@ +"""Contract Layer 4 incident CRUD (contracts/path/incidents.yaml). + +Read/write operations on the incident row itself. The promoted analytics +columns are not touched here: they are derived from the contract document by +`app.services.incidents.promote`, which runs on the extraction path and on +PATCH /extract. This module only moves the metadata a user owns, so the two +can never fight over the same column. +""" + +from datetime import date, datetime, timezone +from uuid import UUID + +from sqlmodel import Session + +from app.api.schemas.enums import IncidentCategory, ReportStatus +from app.api.schemas.incidents import CreateIncidentRequest, UpdateIncidentRequest +from app.core.errors.base import AppError +from app.db.repositories import ( + count_forms_by_incident, + get_extraction, + get_incident, + get_incident_by_extract, + get_incident_by_number, + list_forms_by_incident, + list_incidents, + update_incident, +) +from app.models import Form, Incident + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class IncidentService: + """Business logic for the five incident endpoints.""" + + def finalize(self, session: Session, body: CreateIncidentRequest) -> Incident: + """POST /incidents: finalize the draft created when extraction completed. + + Never creates a second row. Calling it again for the same extraction + just reapplies the number and tags, so a client that retries after a + dropped response gets the same incident back. + """ + incident = get_incident_by_extract(session, body.extract_id) + if incident is None: + # Distinguish "no such extraction" from "extraction exists but has + # not produced its draft yet", because only the second is worth + # retrying. + if get_extraction(session, body.extract_id) is None: + raise AppError( + f"Extract {body.extract_id} not found", + status_code=404, + error_code="EXTRACT_NOT_FOUND", + ) + raise AppError( + "Extraction has not completed yet, so it has no incident to finalize", + status_code=409, + error_code="EXTRACTION_NOT_COMPLETED", + detail={"extract_id": str(body.extract_id)}, + ) + + if body.incident_number is not None: + self._require_number_free(session, body.incident_number, incident.incident_id) + incident.incident_number = body.incident_number + if body.tags is not None: + incident.tags = body.tags + + incident.updated_at = _now() + return update_incident(session, incident) + + def get(self, session: Session, incident_id: UUID) -> Incident: + """A single incident, soft-deleted ones included. + + Reads stay open on a deleted incident: the DELETE response promises the + row is recoverable, which is meaningless if it cannot be read back. + """ + incident = get_incident(session, incident_id) + if incident is None: + raise AppError( + f"Incident {incident_id} not found", + status_code=404, + error_code="INCIDENT_NOT_FOUND", + ) + return incident + + # Not named `list`: that would shadow the builtin for the rest of the class + # body, breaking every later `list[...]` annotation. + def list_page( + self, + session: Session, + date_from: date | None = None, + date_to: date | None = None, + incident_category: IncidentCategory | None = None, + status: ReportStatus | None = None, + page: int = 1, + per_page: int = 20, + sort: str = "date_desc", + ) -> tuple[list[Incident], dict[UUID, int], int]: + """One page of live incidents, their form counts, and the total.""" + if date_from is not None and date_to is not None and date_from > date_to: + raise AppError( + "date_from must not be later than date_to", + status_code=422, + error_code="VALIDATION_ERROR", + detail={"date_from": date_from.isoformat(), "date_to": date_to.isoformat()}, + ) + + rows, total = list_incidents( + session, + date_from=date_from, + date_to=date_to, + incident_category=incident_category, + status=status, + page=page, + per_page=per_page, + sort=sort, + ) + counts = count_forms_by_incident(session, [row.incident_id for row in rows]) + return rows, counts, total + + def forms(self, session: Session, incident_id: UUID) -> list[Form]: + return list_forms_by_incident(session, incident_id) + + def update( + self, session: Session, incident_id: UUID, body: UpdateIncidentRequest + ) -> Incident: + """PATCH /incidents/{id}: update the metadata a user owns. + + Only fields present in the request body are applied, so omitting one + leaves it alone rather than clearing it. The contract document and the + columns promoted from it are untouched; correcting those is PATCH + /extract. + """ + incident = self.get(session, incident_id) + changes = body.model_dump(exclude_unset=True) + + if "incident_number" in changes and changes["incident_number"] is not None: + self._require_number_free(session, changes["incident_number"], incident_id) + + for field, value in changes.items(): + setattr(incident, field, value) + + incident.updated_at = _now() + return update_incident(session, incident) + + def soft_delete(self, session: Session, incident_id: UUID) -> Incident: + """DELETE /incidents/{id}: stamp deleted_at. Data is never removed.""" + incident = self.get(session, incident_id) + if incident.deleted_at is not None: + raise AppError( + "Incident has already been deleted", + status_code=409, + error_code="ALREADY_DELETED", + detail={"deleted_at": incident.deleted_at.isoformat()}, + ) + + incident.deleted_at = _now() + incident.updated_at = incident.deleted_at + return update_incident(session, incident) + + def _require_number_free( + self, session: Session, incident_number: str, incident_id: UUID + ) -> None: + """Reject a number already held by a different live incident.""" + existing = get_incident_by_number(session, incident_number) + if existing is not None and existing.incident_id != incident_id: + raise AppError( + f"Incident number {incident_number} already exists", + status_code=409, + error_code="DUPLICATE_INCIDENT_NUMBER", + detail={"existing_incident_id": str(existing.incident_id)}, + ) From 8786f9264f9ce1d6d48e2b5c20b172d2b0db5f46 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Sat, 22 Aug 2026 12:37:19 +0530 Subject: [PATCH 6/6] test(incidents): cover the five CRUD endpoints --- tests/test_v1_incidents.py | 510 +++++++++++++++++++++++++++++++++++++ 1 file changed, 510 insertions(+) create mode 100644 tests/test_v1_incidents.py diff --git a/tests/test_v1_incidents.py b/tests/test_v1_incidents.py new file mode 100644 index 00000000..73c7207f --- /dev/null +++ b/tests/test_v1_incidents.py @@ -0,0 +1,510 @@ +"""Tests for the five incident endpoints (contracts/path/incidents.yaml). + +Rows are seeded directly rather than driven through extraction, so these cover +the CRUD surface itself: finalizing the draft, list filtering/paging/sorting, +the full record shape, metadata updates, and soft delete. + +The submitted-status lock is deliberately not built yet, so the tests here +assert the current behaviour: status moves freely and a submitted incident is +still editable. +""" + +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + +from app.api.schemas.enums import ( + ExtractionStatus, + FormStatus, + IncidentCategory, + InputStatus, + InputType, + ReportStatus, +) +from app.core.config import API_PREFIX +from app.db.repositories import ( + create_extraction, + create_form_template, + create_generated_form, + create_incident, + create_input, +) +from app.models import Extraction, Form, FormTemplate, Incident, Input + +INCIDENTS_URL = f"{API_PREFIX}/incidents" + +_CONTRACT = { + "schema_version": "1.1.0", + "schema_name": "fireform_incident_contract", + "incident": { + "name": "Bear Creek Wildfire", + "alarm_datetime": "2024-07-10T13:52:00-07:00", + "types": [{"primary": True, "category": "fire", "subcategory": "wildland_fire"}], + }, + "location": {"city": "Reno", "state": "NV", "country": "US"}, + "casualties": {"total_civilian_injuries": 2}, +} + + +# --------------------------------------------------------------------------- +# Seed helpers +# --------------------------------------------------------------------------- + +def _extraction(db, status=ExtractionStatus.completed) -> Extraction: + now = datetime.now(timezone.utc) + inp = create_input( + db, + Input( + input_type=InputType.text, + status=InputStatus.ready, + transcript="Wildfire off Bear Creek, two injured.", + created_at=now, + updated_at=now, + ), + ) + return create_extraction( + db, + Extraction( + input_id=inp.input_id, + status=status, + started_at=now, + completed_at=now if status == ExtractionStatus.completed else None, + ), + ) + + +def _incident(db, extraction=None, **kwargs) -> Incident: + """A draft incident with the promoted columns already filled, as the + extraction worker would leave it.""" + extraction = extraction or _extraction(db) + defaults = dict( + extract_id=extraction.extract_id, + status=ReportStatus.draft, + incident_contract=_CONTRACT, + incident_name="Bear Creek Wildfire", + incident_type="wildland_fire", + incident_category=IncidentCategory.fire, + incident_datetime=datetime(2024, 7, 10, 13, 52), + city="Reno", + state="NV", + country="US", + civilian_injuries=2, + ) + defaults.update(kwargs) + return create_incident(db, Incident(**defaults)) + + +def _form(db, incident, form_type="neris") -> Form: + template = create_form_template( + db, + FormTemplate( + form_type=f"{form_type}-{uuid4().hex[:6]}", + display_name=form_type.upper(), + fields=[], + ), + ) + return create_generated_form( + db, + Form( + form_type=form_type, + status=FormStatus.completed, + template_id=template.template_id, + incident_id=incident.incident_id, + ), + ) + + +# --------------------------------------------------------------------------- +# POST /incidents +# --------------------------------------------------------------------------- + +class TestCreateIncident: + def test_finalizes_the_existing_draft(self, client, db): + incident = _incident(db) + body = { + "extract_id": str(incident.extract_id), + "incident_number": "CA-SQF-2024-0421", + "tags": ["wildland", "mutual_aid"], + } + response = client.post(INCIDENTS_URL, json=body) + + assert response.status_code == 201 + payload = response.json() + # The same row, not a second one. + assert payload["incident_id"] == str(incident.incident_id) + assert payload["incident_number"] == "CA-SQF-2024-0421" + assert payload["tags"] == ["wildland", "mutual_aid"] + assert payload["status"] == "draft" + + def test_promoted_fields_are_returned(self, client, db): + incident = _incident(db) + payload = client.post( + INCIDENTS_URL, json={"extract_id": str(incident.extract_id)} + ).json() + + assert payload["incident_name"] == "Bear Creek Wildfire" + assert payload["incident_type"] == "wildland_fire" + assert payload["incident_category"] == "fire" + assert payload["analytics"]["city"] == "Reno" + assert payload["analytics"]["civilian_injuries"] == 2 + + def test_is_idempotent(self, client, db): + incident = _incident(db) + body = {"extract_id": str(incident.extract_id), "incident_number": "CA-1"} + + first = client.post(INCIDENTS_URL, json=body) + second = client.post(INCIDENTS_URL, json=body) + + assert first.status_code == 201 + assert second.status_code == 201 + assert first.json()["incident_id"] == second.json()["incident_id"] + + def test_omitted_fields_leave_the_draft_alone(self, client, db): + incident = _incident(db, incident_number="CA-1", tags=["existing"]) + payload = client.post( + INCIDENTS_URL, json={"extract_id": str(incident.extract_id)} + ).json() + + assert payload["incident_number"] == "CA-1" + assert payload["tags"] == ["existing"] + + def test_unknown_extract_id_is_404(self, client, db): + response = client.post(INCIDENTS_URL, json={"extract_id": str(uuid4())}) + + assert response.status_code == 404 + assert response.json()["error_code"] == "EXTRACT_NOT_FOUND" + + def test_extraction_without_a_draft_is_409(self, client, db): + extraction = _extraction(db, status=ExtractionStatus.processing) + response = client.post( + INCIDENTS_URL, json={"extract_id": str(extraction.extract_id)} + ) + + assert response.status_code == 409 + assert response.json()["error_code"] == "EXTRACTION_NOT_COMPLETED" + + def test_duplicate_incident_number_is_409(self, client, db): + _incident(db, incident_number="CA-SQF-2024-0421") + other = _incident(db) + + response = client.post( + INCIDENTS_URL, + json={ + "extract_id": str(other.extract_id), + "incident_number": "CA-SQF-2024-0421", + }, + ) + + assert response.status_code == 409 + assert response.json()["error_code"] == "DUPLICATE_INCIDENT_NUMBER" + + def test_a_deleted_incident_frees_its_number(self, client, db): + _incident( + db, + incident_number="CA-SQF-2024-0421", + deleted_at=datetime.now(timezone.utc), + ) + other = _incident(db) + + response = client.post( + INCIDENTS_URL, + json={ + "extract_id": str(other.extract_id), + "incident_number": "CA-SQF-2024-0421", + }, + ) + + assert response.status_code == 201 + + +# --------------------------------------------------------------------------- +# GET /incidents +# --------------------------------------------------------------------------- + +class TestListIncidents: + def test_returns_rows_and_pagination(self, client, db): + incident = _incident(db) + _form(db, incident) + _form(db, incident) + + payload = client.get(INCIDENTS_URL).json() + + assert payload["pagination"] == { + "total": 1, + "page": 1, + "per_page": 20, + "total_pages": 1, + "has_next": False, + "has_prev": False, + } + row = payload["data"][0] + assert row["incident_name"] == "Bear Creek Wildfire" + assert row["incident_type"] == "wildland_fire" + assert row["incident_category"] == "fire" + assert row["city"] == "Reno" + assert row["forms_count"] == 2 + + def test_forms_count_is_zero_without_forms(self, client, db): + _incident(db) + assert client.get(INCIDENTS_URL).json()["data"][0]["forms_count"] == 0 + + def test_excludes_soft_deleted(self, client, db): + _incident(db) + _incident(db, deleted_at=datetime.now(timezone.utc)) + + payload = client.get(INCIDENTS_URL).json() + + assert payload["pagination"]["total"] == 1 + assert len(payload["data"]) == 1 + + def test_filters_by_status_and_category(self, client, db): + _incident(db, status=ReportStatus.approved) + _incident(db, status=ReportStatus.draft) + _incident(db, incident_category=IncidentCategory.ems) + + approved = client.get(INCIDENTS_URL, params={"status": "approved"}).json() + ems = client.get(INCIDENTS_URL, params={"incident_category": "ems"}).json() + + assert approved["pagination"]["total"] == 1 + assert ems["pagination"]["total"] == 1 + + def test_date_bounds_are_inclusive(self, client, db): + _incident(db, incident_datetime=datetime(2024, 7, 9, 23, 0)) + _incident(db, incident_datetime=datetime(2024, 7, 10, 13, 52)) + _incident(db, incident_datetime=datetime(2024, 7, 11, 0, 30)) + + payload = client.get( + INCIDENTS_URL, params={"date_from": "2024-07-10", "date_to": "2024-07-10"} + ).json() + + assert payload["pagination"]["total"] == 1 + assert payload["data"][0]["incident_datetime"].startswith("2024-07-10T13:52") + + def test_rows_without_a_datetime_are_dropped_by_a_date_filter(self, client, db): + _incident(db, incident_datetime=None) + + payload = client.get(INCIDENTS_URL, params={"date_from": "2024-07-10"}).json() + + assert payload["pagination"]["total"] == 0 + + def test_sort_order(self, client, db): + early = datetime(2024, 7, 1, 8, 0) + late = datetime(2024, 7, 20, 8, 0) + _incident(db, incident_datetime=early) + _incident(db, incident_datetime=late) + + desc = client.get(INCIDENTS_URL).json()["data"] + asc = client.get(INCIDENTS_URL, params={"sort": "date_asc"}).json()["data"] + + assert desc[0]["incident_datetime"].startswith("2024-07-20") + assert asc[0]["incident_datetime"].startswith("2024-07-01") + + def test_rows_without_a_datetime_sort_last(self, client, db): + _incident(db, incident_datetime=None) + _incident(db, incident_datetime=datetime(2024, 7, 1, 8, 0)) + + rows = client.get(INCIDENTS_URL).json()["data"] + + assert rows[0]["incident_datetime"] is not None + assert rows[-1]["incident_datetime"] is None + + def test_paging(self, client, db): + base = datetime(2024, 7, 1, 8, 0) + for offset in range(3): + _incident(db, incident_datetime=base + timedelta(days=offset)) + + page_one = client.get(INCIDENTS_URL, params={"per_page": 2}).json() + page_two = client.get(INCIDENTS_URL, params={"per_page": 2, "page": 2}).json() + + assert page_one["pagination"] == { + "total": 3, + "page": 1, + "per_page": 2, + "total_pages": 2, + "has_next": True, + "has_prev": False, + } + assert len(page_one["data"]) == 2 + assert len(page_two["data"]) == 1 + assert page_two["pagination"]["has_next"] is False + assert page_two["pagination"]["has_prev"] is True + + def test_empty_list(self, client, db): + payload = client.get(INCIDENTS_URL).json() + + assert payload["data"] == [] + assert payload["pagination"]["total"] == 0 + assert payload["pagination"]["total_pages"] == 0 + + def test_reversed_date_range_is_422(self, client, db): + response = client.get( + INCIDENTS_URL, params={"date_from": "2024-07-20", "date_to": "2024-07-10"} + ) + + assert response.status_code == 422 + + def test_bad_date_format_is_422(self, client, db): + assert client.get(INCIDENTS_URL, params={"date_from": "15/07/2024"}).status_code == 422 + + def test_per_page_over_the_cap_is_422(self, client, db): + assert client.get(INCIDENTS_URL, params={"per_page": 101}).status_code == 422 + + def test_unknown_sort_is_422(self, client, db): + assert client.get(INCIDENTS_URL, params={"sort": "name_asc"}).status_code == 422 + + +# --------------------------------------------------------------------------- +# GET /incidents/{incident_id} +# --------------------------------------------------------------------------- + +class TestGetIncident: + def test_returns_contract_and_forms(self, client, db): + incident = _incident(db) + form = _form(db, incident) + + payload = client.get(f"{INCIDENTS_URL}/{incident.incident_id}").json() + + assert payload["incident_contract"]["incident"]["name"] == "Bear Creek Wildfire" + assert [f["form_id"] for f in payload["forms"]] == [str(form.form_id)] + assert [f["form_id"] for f in payload["forms_generated"]] == [str(form.form_id)] + + def test_submission_log_is_empty_by_default(self, client, db): + incident = _incident(db) + payload = client.get(f"{INCIDENTS_URL}/{incident.incident_id}").json() + + assert payload["submission_log"] == [] + + def test_submission_log_is_read_from_the_contract(self, client, db): + contract = dict(_CONTRACT) + contract["submission_log"] = [ + {"form_type": "neris", "submitted_to": "State FMO", "status": "accepted"} + ] + incident = _incident(db, incident_contract=contract) + + payload = client.get(f"{INCIDENTS_URL}/{incident.incident_id}").json() + + assert payload["submission_log"][0]["submitted_to"] == "State FMO" + + def test_soft_deleted_is_still_readable(self, client, db): + incident = _incident(db, deleted_at=datetime.now(timezone.utc)) + + response = client.get(f"{INCIDENTS_URL}/{incident.incident_id}") + + assert response.status_code == 200 + assert response.json()["deleted_at"] is not None + + def test_unknown_id_is_404(self, client, db): + response = client.get(f"{INCIDENTS_URL}/{uuid4()}") + + assert response.status_code == 404 + assert response.json()["error_code"] == "INCIDENT_NOT_FOUND" + + +# --------------------------------------------------------------------------- +# PATCH /incidents/{incident_id} +# --------------------------------------------------------------------------- + +class TestUpdateIncident: + def test_updates_metadata(self, client, db): + incident = _incident(db) + + payload = client.patch( + f"{INCIDENTS_URL}/{incident.incident_id}", + json={"status": "approved", "tags": ["reviewed"], "notes": "Ready to go."}, + ).json() + + assert payload["status"] == "approved" + assert payload["tags"] == ["reviewed"] + assert payload["notes"] == "Ready to go." + + def test_omitted_fields_are_untouched(self, client, db): + incident = _incident(db, notes="Original note", tags=["wildland"]) + + payload = client.patch( + f"{INCIDENTS_URL}/{incident.incident_id}", json={"status": "under_review"} + ).json() + + assert payload["notes"] == "Original note" + assert payload["tags"] == ["wildland"] + + def test_does_not_touch_the_contract(self, client, db): + incident = _incident(db) + + client.patch( + f"{INCIDENTS_URL}/{incident.incident_id}", json={"notes": "Checked."} + ) + + db.refresh(incident) + assert incident.incident_contract == _CONTRACT + assert incident.incident_name == "Bear Creek Wildfire" + + def test_duplicate_number_is_409(self, client, db): + _incident(db, incident_number="CA-1") + target = _incident(db) + + response = client.patch( + f"{INCIDENTS_URL}/{target.incident_id}", json={"incident_number": "CA-1"} + ) + + assert response.status_code == 409 + assert response.json()["error_code"] == "DUPLICATE_INCIDENT_NUMBER" + + def test_keeping_its_own_number_is_allowed(self, client, db): + incident = _incident(db, incident_number="CA-1") + + response = client.patch( + f"{INCIDENTS_URL}/{incident.incident_id}", + json={"incident_number": "CA-1", "notes": "Same number."}, + ) + + assert response.status_code == 200 + + def test_submitted_is_still_editable_for_now(self, client, db): + """The submitted lock is deferred, so this documents current behaviour.""" + incident = _incident(db, status=ReportStatus.submitted) + + response = client.patch( + f"{INCIDENTS_URL}/{incident.incident_id}", json={"notes": "Late edit."} + ) + + assert response.status_code == 200 + + def test_unknown_id_is_404(self, client, db): + response = client.patch(f"{INCIDENTS_URL}/{uuid4()}", json={"notes": "x"}) + + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# DELETE /incidents/{incident_id} +# --------------------------------------------------------------------------- + +class TestDeleteIncident: + def test_soft_deletes(self, client, db): + incident = _incident(db) + + payload = client.delete(f"{INCIDENTS_URL}/{incident.incident_id}").json() + + assert payload["incident_id"] == str(incident.incident_id) + assert payload["recoverable"] is True + assert payload["deleted_at"] is not None + + def test_the_row_survives(self, client, db): + incident = _incident(db) + + client.delete(f"{INCIDENTS_URL}/{incident.incident_id}") + + db.refresh(incident) + assert incident.deleted_at is not None + assert incident.incident_contract == _CONTRACT + + def test_deleting_twice_is_409(self, client, db): + incident = _incident(db) + client.delete(f"{INCIDENTS_URL}/{incident.incident_id}") + + response = client.delete(f"{INCIDENTS_URL}/{incident.incident_id}") + + assert response.status_code == 409 + assert response.json()["error_code"] == "ALREADY_DELETED" + + def test_unknown_id_is_404(self, client, db): + assert client.delete(f"{INCIDENTS_URL}/{uuid4()}").status_code == 404