innercontext/backend/innercontext/api/health.py
Piotr Oleszczyk c09acc7c81 refactor: split table models into Base/Table/Public for proper FastAPI serialization
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>
2026-02-27 15:37:46 +01:00

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()