Skip to content

Commit 7224f68

Browse files
authored
Merge pull request #594 from abhishek-8081/issue-544-models
2 parents 752d0d2 + 7e909a0 commit 7224f68

8 files changed

Lines changed: 891 additions & 5 deletions

File tree

alembic/env.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from sqlmodel import SQLModel
77

88
from app.core.config import DATABASE_URL
9-
from app.models import FormSubmission, Job, Template
9+
from app.models import Extraction, Form, FormSubmission, Incident, Input, Job, Report, Template # noqa: F401
1010

1111
config = context.config
1212

alembic/versions/002_v1_models.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""v1 models — Input, Extraction, Incident, Form, Report tables.
2+
3+
Revision ID: 002
4+
Revises: 001
5+
Create Date: 2026-06-26
6+
7+
JSON columns use sa.JSON (Postgres json type, not jsonb) for consistency with
8+
migration 001 and SQLite test-harness compatibility. A follow-up can switch
9+
high-query blobs (especially incident_contract) to jsonb via with_variant() if
10+
containment operators or GIN indexing are needed.
11+
12+
Form.job_id and Report.job_id are plain UUID columns with no FK constraint.
13+
The contract Job model (UUID-PK, enum-typed) is a separate decision; the FK
14+
will be added when that model is resolved.
15+
"""
16+
17+
from collections.abc import Sequence
18+
19+
from alembic import op
20+
import sqlalchemy as sa
21+
import sqlmodel
22+
23+
revision: str = "002"
24+
down_revision: str | None = "001"
25+
branch_labels: str | Sequence[str] | None = None
26+
depends_on: str | Sequence[str] | None = None
27+
28+
29+
def upgrade() -> None:
30+
op.create_table(
31+
"inputs",
32+
sa.Column("input_id", sa.Uuid(), primary_key=True),
33+
sa.Column("input_type", sqlmodel.sql.sqltypes.AutoString, nullable=False),
34+
sa.Column("status", sqlmodel.sql.sqltypes.AutoString, nullable=False),
35+
sa.Column("transcript", sqlmodel.sql.sqltypes.AutoString, nullable=True),
36+
sa.Column("original_filename", sqlmodel.sql.sqltypes.AutoString, nullable=True),
37+
sa.Column("audio_duration_seconds", sa.Float, nullable=True),
38+
sa.Column("character_count", sa.Integer, nullable=True),
39+
sa.Column("word_count", sa.Integer, nullable=True),
40+
sa.Column("station_id", sqlmodel.sql.sqltypes.AutoString, nullable=True),
41+
sa.Column("responder_badge", sqlmodel.sql.sqltypes.AutoString, nullable=True),
42+
sa.Column("incident_date_hint", sa.Date, nullable=True),
43+
sa.Column("error_detail", sqlmodel.sql.sqltypes.AutoString, nullable=True),
44+
sa.Column("created_at", sa.DateTime, nullable=False),
45+
sa.Column("updated_at", sa.DateTime, nullable=False),
46+
)
47+
48+
op.create_table(
49+
"extractions",
50+
sa.Column("extract_id", sa.Uuid(), primary_key=True),
51+
sa.Column(
52+
"input_id",
53+
sa.Uuid(),
54+
sa.ForeignKey("inputs.input_id"),
55+
nullable=False,
56+
),
57+
sa.Column("status", sqlmodel.sql.sqltypes.AutoString, nullable=False),
58+
sa.Column("started_at", sa.DateTime, nullable=True),
59+
sa.Column("completed_at", sa.DateTime, nullable=True),
60+
sa.Column("model_used", sqlmodel.sql.sqltypes.AutoString, nullable=True),
61+
sa.Column("processing_time_seconds", sa.Float, nullable=True),
62+
sa.Column("incident_contract", sa.JSON, nullable=True),
63+
sa.Column("corrections", sa.JSON, nullable=True),
64+
sa.Column("error_type", sqlmodel.sql.sqltypes.AutoString, nullable=True),
65+
sa.Column("error_detail", sqlmodel.sql.sqltypes.AutoString, nullable=True),
66+
sa.Column("created_at", sa.DateTime, nullable=False),
67+
sa.Column("updated_at", sa.DateTime, nullable=False),
68+
)
69+
70+
op.create_table(
71+
"incidents",
72+
sa.Column("incident_id", sa.Uuid(), primary_key=True),
73+
sa.Column(
74+
"extract_id",
75+
sa.Uuid(),
76+
sa.ForeignKey("extractions.extract_id"),
77+
nullable=False,
78+
),
79+
sa.Column("incident_number", sqlmodel.sql.sqltypes.AutoString, nullable=True),
80+
sa.Column("status", sqlmodel.sql.sqltypes.AutoString, nullable=False),
81+
sa.Column("incident_name", sqlmodel.sql.sqltypes.AutoString, nullable=True),
82+
sa.Column("incident_type", sqlmodel.sql.sqltypes.AutoString, nullable=True),
83+
sa.Column("incident_date", sa.Date, nullable=True),
84+
sa.Column("tags", sa.JSON, nullable=True),
85+
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString, nullable=True),
86+
sa.Column("created_at", sa.DateTime, nullable=False),
87+
sa.Column("updated_at", sa.DateTime, nullable=False),
88+
sa.Column("deleted_at", sa.DateTime, nullable=True),
89+
)
90+
91+
op.create_table(
92+
"forms",
93+
sa.Column("form_id", sa.Uuid(), primary_key=True),
94+
sa.Column("form_type", sqlmodel.sql.sqltypes.AutoString, nullable=False),
95+
sa.Column("status", sqlmodel.sql.sqltypes.AutoString, nullable=False),
96+
sa.Column(
97+
"extract_id",
98+
sa.Uuid(),
99+
sa.ForeignKey("extractions.extract_id"),
100+
nullable=False,
101+
),
102+
sa.Column(
103+
"incident_id",
104+
sa.Uuid(),
105+
sa.ForeignKey("incidents.incident_id"),
106+
nullable=True,
107+
),
108+
sa.Column("job_id", sa.Uuid(), nullable=True), # no FK — see module docstring
109+
sa.Column("completed_at", sa.DateTime, nullable=True),
110+
sa.Column("pdf_ready", sa.Boolean, nullable=False),
111+
sa.Column("json_ready", sa.Boolean, nullable=False),
112+
sa.Column("field_mapping_summary", sa.JSON, nullable=True),
113+
sa.Column("pdf_path", sqlmodel.sql.sqltypes.AutoString, nullable=True),
114+
sa.Column("json_data", sa.JSON, nullable=True),
115+
sa.Column("created_at", sa.DateTime, nullable=False),
116+
sa.Column("updated_at", sa.DateTime, nullable=False),
117+
)
118+
119+
op.create_table(
120+
"reports",
121+
sa.Column("report_id", sa.Uuid(), primary_key=True),
122+
sa.Column("period_type", sqlmodel.sql.sqltypes.AutoString, nullable=False),
123+
sa.Column("period_label", sqlmodel.sql.sqltypes.AutoString, nullable=True),
124+
sa.Column("year", sa.Integer, nullable=False),
125+
sa.Column("month", sa.Integer, nullable=True),
126+
sa.Column("quarter", sa.Integer, nullable=True),
127+
sa.Column("output_format", sqlmodel.sql.sqltypes.AutoString, nullable=False),
128+
sa.Column("status", sqlmodel.sql.sqltypes.AutoString, nullable=False),
129+
sa.Column("generated_at", sa.DateTime, nullable=True),
130+
sa.Column("summary", sa.JSON, nullable=True),
131+
sa.Column("job_id", sa.Uuid(), nullable=True), # no FK — see module docstring
132+
sa.Column("pdf_path", sqlmodel.sql.sqltypes.AutoString, nullable=True),
133+
sa.Column("json_data", sa.JSON, nullable=True),
134+
sa.Column("created_at", sa.DateTime, nullable=False),
135+
sa.Column("updated_at", sa.DateTime, nullable=False),
136+
)
137+
138+
139+
def downgrade() -> None:
140+
op.drop_table("reports")
141+
op.drop_table("forms")
142+
op.drop_table("incidents")
143+
op.drop_table("extractions")
144+
op.drop_table("inputs")

app/api/schemas/enums.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
from enum import Enum
22

33

4+
class InputType(str, Enum):
5+
voice = "voice"
6+
text = "text"
7+
8+
49
class InputStatus(str, Enum):
510
queued = "queued"
611
transcribing = "transcribing"

app/models/__init__.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
"""ORM models. Import from here: `from app.models import Template`."""
22

3-
from app.models.models import FormSubmission, Job, Template
3+
from app.models.models import (
4+
Extraction,
5+
Form,
6+
FormSubmission,
7+
Incident,
8+
Input,
9+
Job,
10+
Report,
11+
Template,
12+
)
413

5-
__all__ = ["Template", "FormSubmission", "Job"]
14+
__all__ = [
15+
"Template",
16+
"FormSubmission",
17+
"Job",
18+
"Input",
19+
"Extraction",
20+
"Incident",
21+
"Form",
22+
"Report",
23+
]

app/models/models.py

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,22 @@
11
import uuid as uuid_mod
2+
from uuid import UUID, uuid4
3+
from datetime import date, datetime, timezone
24

35
from sqlalchemy import Column, JSON
46
from sqlmodel import SQLModel, Field
5-
from datetime import datetime, timezone
7+
from sqlmodel.sql.sqltypes import AutoString
8+
9+
from app.api.schemas.enums import (
10+
ExtractionStatus,
11+
FormStatus,
12+
FormType,
13+
InputStatus,
14+
InputType,
15+
JobStatus,
16+
OutputFormat,
17+
PeriodType,
18+
ReportStatus,
19+
)
620

721

822
class Template(SQLModel, table=True):
@@ -34,4 +48,120 @@ class Job(SQLModel, table=True):
3448
error: dict | None = Field(default=None, sa_column=Column(JSON))
3549
model: str | None = None
3650
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
51+
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
52+
53+
54+
# ---------------------------------------------------------------------------
55+
# v1 contract models
56+
# ---------------------------------------------------------------------------
57+
58+
class Input(SQLModel, table=True):
59+
__tablename__ = "inputs"
60+
61+
input_id: UUID = Field(default_factory=uuid4, primary_key=True)
62+
# sa_column required on all str-Enum fields: without it SQLModel emits
63+
# sa.Enum(native_enum=True) which creates a Postgres ENUM type — hard to
64+
# migrate and inconsistent with the VARCHAR approach used in migration 001.
65+
input_type: InputType = Field(sa_column=Column(AutoString, nullable=False))
66+
status: InputStatus = Field(
67+
default=InputStatus.queued, sa_column=Column(AutoString, nullable=False)
68+
)
69+
transcript: str | None = None
70+
original_filename: str | None = None
71+
audio_duration_seconds: float | None = None
72+
character_count: int | None = None
73+
word_count: int | None = None
74+
station_id: str | None = None
75+
responder_badge: str | None = None
76+
incident_date_hint: date | None = None
77+
error_detail: str | None = None
78+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
79+
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
80+
81+
82+
class Extraction(SQLModel, table=True):
83+
__tablename__ = "extractions"
84+
85+
extract_id: UUID = Field(default_factory=uuid4, primary_key=True)
86+
input_id: UUID = Field(foreign_key="inputs.input_id")
87+
status: ExtractionStatus = Field(
88+
default=ExtractionStatus.processing, sa_column=Column(AutoString, nullable=False)
89+
)
90+
started_at: datetime | None = None
91+
completed_at: datetime | None = None
92+
model_used: str | None = None
93+
processing_time_seconds: float | None = None
94+
# Full IncidentContract superset blob; stores partial result while processing,
95+
# final canonical JSON when status=completed.
96+
incident_contract: dict | None = Field(default=None, sa_column=Column(JSON))
97+
# Audit trail of manual corrections applied via PATCH /extract/{id}.
98+
corrections: list | None = Field(default=None, sa_column=Column(JSON))
99+
error_type: str | None = None
100+
error_detail: str | None = None
101+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
102+
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
103+
104+
105+
class Incident(SQLModel, table=True):
106+
__tablename__ = "incidents"
107+
108+
incident_id: UUID = Field(default_factory=uuid4, primary_key=True)
109+
extract_id: UUID = Field(foreign_key="extractions.extract_id")
110+
incident_number: str | None = None
111+
status: ReportStatus = Field(
112+
default=ReportStatus.draft, sa_column=Column(AutoString, nullable=False)
113+
)
114+
incident_name: str | None = None
115+
incident_type: str | None = None
116+
incident_date: date | None = None
117+
tags: list | None = Field(default=None, sa_column=Column(JSON))
118+
notes: str | None = None
119+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
120+
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
121+
deleted_at: datetime | None = None
122+
123+
124+
class Form(SQLModel, table=True):
125+
__tablename__ = "forms"
126+
127+
form_id: UUID = Field(default_factory=uuid4, primary_key=True)
128+
form_type: FormType = Field(sa_column=Column(AutoString, nullable=False))
129+
status: FormStatus = Field(
130+
default=FormStatus.queued, sa_column=Column(AutoString, nullable=False)
131+
)
132+
extract_id: UUID = Field(foreign_key="extractions.extract_id")
133+
incident_id: UUID | None = Field(default=None, foreign_key="incidents.incident_id")
134+
# Plain UUID, no FK constraint — pending contract Job model resolution (#544 decision A).
135+
job_id: UUID | None = None
136+
completed_at: datetime | None = None
137+
pdf_ready: bool = Field(default=False)
138+
json_ready: bool = Field(default=False)
139+
field_mapping_summary: dict | None = Field(default=None, sa_column=Column(JSON))
140+
pdf_path: str | None = None
141+
json_data: dict | None = Field(default=None, sa_column=Column(JSON))
142+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
143+
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
144+
145+
146+
class Report(SQLModel, table=True):
147+
__tablename__ = "reports"
148+
149+
report_id: UUID = Field(default_factory=uuid4, primary_key=True)
150+
period_type: PeriodType = Field(sa_column=Column(AutoString, nullable=False))
151+
period_label: str | None = None
152+
year: int
153+
month: int | None = None
154+
quarter: int | None = None
155+
# Named output_format (not format) to avoid shadowing the Python builtin.
156+
output_format: OutputFormat = Field(sa_column=Column(AutoString, nullable=False))
157+
status: JobStatus = Field(
158+
default=JobStatus.queued, sa_column=Column(AutoString, nullable=False)
159+
)
160+
generated_at: datetime | None = None
161+
summary: dict | None = Field(default=None, sa_column=Column(JSON))
162+
# Plain UUID, no FK constraint — pending contract Job model resolution (#544 decision A).
163+
job_id: UUID | None = None
164+
pdf_path: str | None = None
165+
json_data: dict | None = Field(default=None, sa_column=Column(JSON))
166+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
37167
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from app.main import app
1616
from app.api.deps import get_db
1717
from app.core.config import API_PREFIX # single source of truth for all tests
18-
from app.models import Template, FormSubmission, Job # noqa: F401 — registers tables
18+
from app.models import Template, FormSubmission, Job, Input, Extraction, Incident, Form, Report # noqa: F401 — registers tables
1919

2020
# ---------------------------------------------------------------------------
2121
# In-memory database

0 commit comments

Comments
 (0)