Add ProductBase, ProductPublic, ProductWithInventory and SkinConditionSnapshotBase, SkinConditionSnapshotPublic. Table models now inherit from their Base counterpart and override JSON fields with sa_column. All field_serializer hacks removed; FastAPI response models use the non-table Public classes so Pydantic coerces raw DB dicts → typed models cleanly. ProductCreate and SnapshotCreate now simply inherit their respective Base classes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
310 lines
9.5 KiB
Python
310 lines
9.5 KiB
Python
import re
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
from uuid import UUID, uuid4
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from pydantic import field_validator
|
|
from sqlmodel import Session, SQLModel, col, select
|
|
|
|
from db import get_session
|
|
from innercontext.api.utils import get_or_404
|
|
from innercontext.models import LabResult, MedicationEntry, MedicationUsage
|
|
from innercontext.models.enums import MedicationKind, ResultFlag
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class MedicationCreate(SQLModel):
|
|
kind: MedicationKind
|
|
product_name: str
|
|
active_substance: Optional[str] = None
|
|
formulation: Optional[str] = None
|
|
route: Optional[str] = None
|
|
source_file: Optional[str] = None
|
|
notes: Optional[str] = None
|
|
|
|
|
|
class MedicationUpdate(SQLModel):
|
|
kind: Optional[MedicationKind] = None
|
|
product_name: Optional[str] = None
|
|
active_substance: Optional[str] = None
|
|
formulation: Optional[str] = None
|
|
route: Optional[str] = None
|
|
source_file: Optional[str] = None
|
|
notes: Optional[str] = None
|
|
|
|
|
|
class UsageCreate(SQLModel):
|
|
dose_value: Optional[float] = None
|
|
dose_unit: Optional[str] = None
|
|
frequency: Optional[str] = None
|
|
schedule_text: Optional[str] = None
|
|
as_needed: bool = False
|
|
valid_from: datetime
|
|
valid_to: Optional[datetime] = None
|
|
source_file: Optional[str] = None
|
|
notes: Optional[str] = None
|
|
|
|
|
|
class UsageUpdate(SQLModel):
|
|
dose_value: Optional[float] = None
|
|
dose_unit: Optional[str] = None
|
|
frequency: Optional[str] = None
|
|
schedule_text: Optional[str] = None
|
|
as_needed: Optional[bool] = None
|
|
valid_from: Optional[datetime] = None
|
|
valid_to: Optional[datetime] = None
|
|
source_file: Optional[str] = None
|
|
notes: Optional[str] = None
|
|
|
|
|
|
class LabResultCreate(SQLModel):
|
|
collected_at: datetime
|
|
test_code: str
|
|
|
|
@field_validator("test_code")
|
|
@classmethod
|
|
def check_test_code_format(cls, v: str) -> str:
|
|
if not re.fullmatch(r"\d+-\d", v):
|
|
raise ValueError(
|
|
"test_code must match LOINC format: digits-digit (e.g. 718-7)"
|
|
)
|
|
return v
|
|
|
|
test_name_original: Optional[str] = None
|
|
test_name_loinc: Optional[str] = None
|
|
|
|
value_num: Optional[float] = None
|
|
value_text: Optional[str] = None
|
|
value_bool: Optional[bool] = None
|
|
|
|
unit_original: Optional[str] = None
|
|
unit_ucum: Optional[str] = None
|
|
|
|
ref_low: Optional[float] = None
|
|
ref_high: Optional[float] = None
|
|
ref_text: Optional[str] = None
|
|
flag: Optional[ResultFlag] = None
|
|
|
|
lab: Optional[str] = None
|
|
source_file: Optional[str] = None
|
|
notes: Optional[str] = None
|
|
|
|
|
|
class LabResultUpdate(SQLModel):
|
|
collected_at: Optional[datetime] = None
|
|
test_code: Optional[str] = None
|
|
test_name_original: Optional[str] = None
|
|
test_name_loinc: Optional[str] = None
|
|
|
|
value_num: Optional[float] = None
|
|
value_text: Optional[str] = None
|
|
value_bool: Optional[bool] = None
|
|
|
|
unit_original: Optional[str] = None
|
|
unit_ucum: Optional[str] = None
|
|
|
|
ref_low: Optional[float] = None
|
|
ref_high: Optional[float] = None
|
|
ref_text: Optional[str] = None
|
|
flag: Optional[ResultFlag] = None
|
|
|
|
lab: Optional[str] = None
|
|
source_file: Optional[str] = None
|
|
notes: Optional[str] = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Medication routes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/medications", response_model=list[MedicationEntry])
|
|
def list_medications(
|
|
kind: Optional[MedicationKind] = None,
|
|
product_name: Optional[str] = None,
|
|
session: Session = Depends(get_session),
|
|
):
|
|
stmt = select(MedicationEntry)
|
|
if kind is not None:
|
|
stmt = stmt.where(MedicationEntry.kind == kind)
|
|
if product_name is not None:
|
|
stmt = stmt.where(col(MedicationEntry.product_name).ilike(f"%{product_name}%"))
|
|
return session.exec(stmt).all()
|
|
|
|
|
|
@router.post("/medications", response_model=MedicationEntry, status_code=201)
|
|
def create_medication(data: MedicationCreate, session: Session = Depends(get_session)):
|
|
entry = MedicationEntry(record_id=uuid4(), **data.model_dump())
|
|
session.add(entry)
|
|
session.commit()
|
|
session.refresh(entry)
|
|
return entry
|
|
|
|
|
|
@router.get("/medications/{medication_id}", response_model=MedicationEntry)
|
|
def get_medication(medication_id: UUID, session: Session = Depends(get_session)):
|
|
return get_or_404(session, MedicationEntry, medication_id)
|
|
|
|
|
|
@router.patch("/medications/{medication_id}", response_model=MedicationEntry)
|
|
def update_medication(
|
|
medication_id: UUID,
|
|
data: MedicationUpdate,
|
|
session: Session = Depends(get_session),
|
|
):
|
|
entry = get_or_404(session, MedicationEntry, medication_id)
|
|
for key, value in data.model_dump(exclude_unset=True).items():
|
|
setattr(entry, key, value)
|
|
session.add(entry)
|
|
session.commit()
|
|
session.refresh(entry)
|
|
return entry
|
|
|
|
|
|
@router.delete("/medications/{medication_id}", status_code=204)
|
|
def delete_medication(medication_id: UUID, session: Session = Depends(get_session)):
|
|
entry = get_or_404(session, MedicationEntry, medication_id)
|
|
# Delete usages first (no cascade configured at DB level)
|
|
usages = session.exec(
|
|
select(MedicationUsage).where(
|
|
MedicationUsage.medication_record_id == medication_id
|
|
)
|
|
).all()
|
|
for u in usages:
|
|
session.delete(u)
|
|
session.delete(entry)
|
|
session.commit()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Usage sub-routes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/medications/{medication_id}/usages", response_model=list[MedicationUsage])
|
|
def list_usages(medication_id: UUID, session: Session = Depends(get_session)):
|
|
get_or_404(session, MedicationEntry, medication_id)
|
|
stmt = select(MedicationUsage).where(
|
|
MedicationUsage.medication_record_id == medication_id
|
|
)
|
|
return session.exec(stmt).all()
|
|
|
|
|
|
@router.post(
|
|
"/medications/{medication_id}/usages",
|
|
response_model=MedicationUsage,
|
|
status_code=201,
|
|
)
|
|
def create_usage(
|
|
medication_id: UUID,
|
|
data: UsageCreate,
|
|
session: Session = Depends(get_session),
|
|
):
|
|
get_or_404(session, MedicationEntry, medication_id)
|
|
usage = MedicationUsage(
|
|
record_id=uuid4(),
|
|
medication_record_id=medication_id,
|
|
**data.model_dump(),
|
|
)
|
|
session.add(usage)
|
|
session.commit()
|
|
session.refresh(usage)
|
|
return usage
|
|
|
|
|
|
@router.patch("/usages/{usage_id}", response_model=MedicationUsage)
|
|
def update_usage(
|
|
usage_id: UUID,
|
|
data: UsageUpdate,
|
|
session: Session = Depends(get_session),
|
|
):
|
|
usage = get_or_404(session, MedicationUsage, usage_id)
|
|
for key, value in data.model_dump(exclude_unset=True).items():
|
|
setattr(usage, key, value)
|
|
session.add(usage)
|
|
session.commit()
|
|
session.refresh(usage)
|
|
return usage
|
|
|
|
|
|
@router.delete("/usages/{usage_id}", status_code=204)
|
|
def delete_usage(usage_id: UUID, session: Session = Depends(get_session)):
|
|
usage = get_or_404(session, MedicationUsage, usage_id)
|
|
session.delete(usage)
|
|
session.commit()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lab result routes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/lab-results", response_model=list[LabResult])
|
|
def list_lab_results(
|
|
test_code: Optional[str] = None,
|
|
flag: Optional[ResultFlag] = None,
|
|
lab: Optional[str] = None,
|
|
from_date: Optional[datetime] = None,
|
|
to_date: Optional[datetime] = None,
|
|
session: Session = Depends(get_session),
|
|
):
|
|
stmt = select(LabResult)
|
|
if test_code is not None:
|
|
stmt = stmt.where(LabResult.test_code == test_code)
|
|
if flag is not None:
|
|
stmt = stmt.where(LabResult.flag == flag)
|
|
if lab is not None:
|
|
stmt = stmt.where(LabResult.lab == lab)
|
|
if from_date is not None:
|
|
stmt = stmt.where(LabResult.collected_at >= from_date)
|
|
if to_date is not None:
|
|
stmt = stmt.where(LabResult.collected_at <= to_date)
|
|
return session.exec(stmt).all()
|
|
|
|
|
|
@router.post("/lab-results", response_model=LabResult, status_code=201)
|
|
def create_lab_result(data: LabResultCreate, session: Session = Depends(get_session)):
|
|
result = LabResult(record_id=uuid4(), **data.model_dump())
|
|
session.add(result)
|
|
session.commit()
|
|
session.refresh(result)
|
|
return result
|
|
|
|
|
|
@router.get("/lab-results/{result_id}", response_model=LabResult)
|
|
def get_lab_result(result_id: UUID, session: Session = Depends(get_session)):
|
|
return get_or_404(session, LabResult, result_id)
|
|
|
|
|
|
@router.patch("/lab-results/{result_id}", response_model=LabResult)
|
|
def update_lab_result(
|
|
result_id: UUID,
|
|
data: LabResultUpdate,
|
|
session: Session = Depends(get_session),
|
|
):
|
|
result = get_or_404(session, LabResult, result_id)
|
|
for key, value in data.model_dump(exclude_unset=True).items():
|
|
setattr(result, key, value)
|
|
session.add(result)
|
|
session.commit()
|
|
session.refresh(result)
|
|
return result
|
|
|
|
|
|
@router.delete("/lab-results/{result_id}", status_code=204)
|
|
def delete_lab_result(result_id: UUID, session: Session = Depends(get_session)):
|
|
result = get_or_404(session, LabResult, result_id)
|
|
session.delete(result)
|
|
session.commit()
|