feat(repo): expand lab results workflows across backend and frontend
This commit is contained in:
parent
f1b104909d
commit
0a4ccefe28
19 changed files with 1330 additions and 170 deletions
|
|
@ -3,8 +3,9 @@ from datetime import datetime
|
|||
from typing import Optional
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import field_validator
|
||||
from sqlalchemy import Integer, cast, func, or_
|
||||
from sqlmodel import Session, SQLModel, col, select
|
||||
|
||||
from db import get_session
|
||||
|
|
@ -120,6 +121,13 @@ class LabResultUpdate(SQLModel):
|
|||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class LabResultListResponse(SQLModel):
|
||||
items: list[LabResult]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -251,27 +259,86 @@ def delete_usage(usage_id: UUID, session: Session = Depends(get_session)):
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/lab-results", response_model=list[LabResult])
|
||||
@router.get("/lab-results", response_model=LabResultListResponse)
|
||||
def list_lab_results(
|
||||
q: Optional[str] = None,
|
||||
test_code: Optional[str] = None,
|
||||
flag: Optional[ResultFlag] = None,
|
||||
lab: Optional[str] = None,
|
||||
flags: list[ResultFlag] = Query(default_factory=list),
|
||||
from_date: Optional[datetime] = None,
|
||||
to_date: Optional[datetime] = None,
|
||||
latest_only: bool = False,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
stmt = select(LabResult)
|
||||
filters = []
|
||||
if q is not None and q.strip():
|
||||
query = f"%{q.strip()}%"
|
||||
filters.append(
|
||||
or_(
|
||||
col(LabResult.test_code).ilike(query),
|
||||
col(LabResult.test_name_original).ilike(query),
|
||||
)
|
||||
)
|
||||
if test_code is not None:
|
||||
stmt = stmt.where(LabResult.test_code == test_code)
|
||||
filters.append(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)
|
||||
filters.append(LabResult.flag == flag)
|
||||
if flags:
|
||||
filters.append(col(LabResult.flag).in_(flags))
|
||||
if from_date is not None:
|
||||
stmt = stmt.where(LabResult.collected_at >= from_date)
|
||||
filters.append(LabResult.collected_at >= from_date)
|
||||
if to_date is not None:
|
||||
stmt = stmt.where(LabResult.collected_at <= to_date)
|
||||
return session.exec(stmt).all()
|
||||
filters.append(LabResult.collected_at <= to_date)
|
||||
|
||||
if latest_only:
|
||||
ranked_stmt = select(
|
||||
col(LabResult.record_id).label("record_id"),
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=LabResult.test_code,
|
||||
order_by=(
|
||||
col(LabResult.collected_at).desc(),
|
||||
col(LabResult.created_at).desc(),
|
||||
col(LabResult.record_id).desc(),
|
||||
),
|
||||
)
|
||||
.label("rank"),
|
||||
)
|
||||
if filters:
|
||||
ranked_stmt = ranked_stmt.where(*filters)
|
||||
|
||||
ranked_subquery = ranked_stmt.subquery()
|
||||
latest_ids = select(ranked_subquery.c.record_id).where(
|
||||
ranked_subquery.c.rank == 1
|
||||
)
|
||||
stmt = select(LabResult).where(col(LabResult.record_id).in_(latest_ids))
|
||||
count_stmt = select(func.count()).select_from(
|
||||
select(LabResult.record_id)
|
||||
.where(col(LabResult.record_id).in_(latest_ids))
|
||||
.subquery()
|
||||
)
|
||||
else:
|
||||
stmt = select(LabResult)
|
||||
count_stmt = select(func.count()).select_from(LabResult)
|
||||
if filters:
|
||||
stmt = stmt.where(*filters)
|
||||
count_stmt = count_stmt.where(*filters)
|
||||
|
||||
test_code_numeric = cast(
|
||||
func.replace(col(LabResult.test_code), "-", ""),
|
||||
Integer,
|
||||
)
|
||||
stmt = stmt.order_by(
|
||||
col(LabResult.collected_at).desc(),
|
||||
test_code_numeric.asc(),
|
||||
col(LabResult.record_id).asc(),
|
||||
)
|
||||
|
||||
total = session.exec(count_stmt).one()
|
||||
items = list(session.exec(stmt.offset(offset).limit(limit)).all())
|
||||
return LabResultListResponse(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.post("/lab-results", response_model=LabResult, status_code=201)
|
||||
|
|
|
|||
|
|
@ -224,7 +224,11 @@ def test_create_lab_result_invalid_flag(client):
|
|||
def test_list_lab_results_empty(client):
|
||||
r = client.get("/health/lab-results")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == []
|
||||
data = r.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
assert data["limit"] == 50
|
||||
assert data["offset"] == 0
|
||||
|
||||
|
||||
def test_list_filter_test_code(client):
|
||||
|
|
@ -232,9 +236,9 @@ def test_list_filter_test_code(client):
|
|||
client.post("/health/lab-results", json={**LAB_RESULT_DATA, "test_code": "2951-2"})
|
||||
r = client.get("/health/lab-results?test_code=718-7")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["test_code"] == "718-7"
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["test_code"] == "718-7"
|
||||
|
||||
|
||||
def test_list_filter_flag(client):
|
||||
|
|
@ -242,9 +246,9 @@ def test_list_filter_flag(client):
|
|||
client.post("/health/lab-results", json={**LAB_RESULT_DATA, "flag": "H"})
|
||||
r = client.get("/health/lab-results?flag=H")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["flag"] == "H"
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["flag"] == "H"
|
||||
|
||||
|
||||
def test_list_filter_date_range(client):
|
||||
|
|
@ -258,8 +262,139 @@ def test_list_filter_date_range(client):
|
|||
)
|
||||
r = client.get("/health/lab-results?from_date=2026-05-01T00:00:00")
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()["items"]) == 1
|
||||
|
||||
|
||||
def test_list_lab_results_search_and_pagination(client):
|
||||
client.post(
|
||||
"/health/lab-results",
|
||||
json={
|
||||
**LAB_RESULT_DATA,
|
||||
"test_code": "718-7",
|
||||
"test_name_original": "Hemoglobin",
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
"/health/lab-results",
|
||||
json={
|
||||
**LAB_RESULT_DATA,
|
||||
"test_code": "4548-4",
|
||||
"test_name_original": "Hemoglobin A1c",
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
"/health/lab-results",
|
||||
json={**LAB_RESULT_DATA, "test_code": "2951-2", "test_name_original": "Sodium"},
|
||||
)
|
||||
|
||||
r = client.get("/health/lab-results?q=hemo&limit=1&offset=1")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data) == 1
|
||||
assert data["total"] == 2
|
||||
assert data["limit"] == 1
|
||||
assert data["offset"] == 1
|
||||
assert len(data["items"]) == 1
|
||||
assert "Hemoglobin" in data["items"][0]["test_name_original"]
|
||||
|
||||
|
||||
def test_list_lab_results_sorted_newest_first(client):
|
||||
client.post(
|
||||
"/health/lab-results",
|
||||
json={
|
||||
**LAB_RESULT_DATA,
|
||||
"collected_at": "2026-01-01T00:00:00",
|
||||
"test_code": "1111-1",
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
"/health/lab-results",
|
||||
json={
|
||||
**LAB_RESULT_DATA,
|
||||
"collected_at": "2026-06-01T00:00:00",
|
||||
"test_code": "2222-2",
|
||||
},
|
||||
)
|
||||
r = client.get("/health/lab-results")
|
||||
assert r.status_code == 200
|
||||
items = r.json()["items"]
|
||||
assert items[0]["collected_at"] == "2026-06-01T00:00:00"
|
||||
|
||||
|
||||
def test_list_lab_results_test_code_sorted_numerically_for_same_date(client):
|
||||
client.post(
|
||||
"/health/lab-results",
|
||||
json={
|
||||
**LAB_RESULT_DATA,
|
||||
"collected_at": "2026-06-01T00:00:00",
|
||||
"test_code": "1111-1",
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
"/health/lab-results",
|
||||
json={
|
||||
**LAB_RESULT_DATA,
|
||||
"collected_at": "2026-06-01T00:00:00",
|
||||
"test_code": "99-9",
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
"/health/lab-results",
|
||||
json={
|
||||
**LAB_RESULT_DATA,
|
||||
"collected_at": "2026-06-01T00:00:00",
|
||||
"test_code": "718-7",
|
||||
},
|
||||
)
|
||||
|
||||
r = client.get("/health/lab-results")
|
||||
assert r.status_code == 200
|
||||
items = r.json()["items"]
|
||||
assert [items[0]["test_code"], items[1]["test_code"], items[2]["test_code"]] == [
|
||||
"99-9",
|
||||
"718-7",
|
||||
"1111-1",
|
||||
]
|
||||
|
||||
|
||||
def test_list_lab_results_latest_only_returns_one_per_test_code(client):
|
||||
client.post(
|
||||
"/health/lab-results",
|
||||
json={
|
||||
**LAB_RESULT_DATA,
|
||||
"test_code": "1742-6",
|
||||
"test_name_original": "ALT",
|
||||
"collected_at": "2026-01-01T00:00:00",
|
||||
"value_num": 30,
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
"/health/lab-results",
|
||||
json={
|
||||
**LAB_RESULT_DATA,
|
||||
"test_code": "1742-6",
|
||||
"test_name_original": "ALT",
|
||||
"collected_at": "2026-02-01T00:00:00",
|
||||
"value_num": 60,
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
"/health/lab-results",
|
||||
json={
|
||||
**LAB_RESULT_DATA,
|
||||
"test_code": "2093-3",
|
||||
"test_name_original": "Cholesterol total",
|
||||
"collected_at": "2026-01-10T00:00:00",
|
||||
"value_num": 200,
|
||||
},
|
||||
)
|
||||
|
||||
r = client.get("/health/lab-results?latest_only=true")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["total"] == 2
|
||||
assert len(data["items"]) == 2
|
||||
alt = next(item for item in data["items"] if item["test_code"] == "1742-6")
|
||||
assert alt["collected_at"] == "2026-02-01T00:00:00"
|
||||
|
||||
|
||||
def test_get_lab_result(client):
|
||||
|
|
@ -285,6 +420,34 @@ def test_update_lab_result(client):
|
|||
assert r2.json()["notes"] == "Recheck in 3 months"
|
||||
|
||||
|
||||
def test_update_lab_result_can_clear_and_switch_value_type(client):
|
||||
r = client.post(
|
||||
"/health/lab-results",
|
||||
json={
|
||||
**LAB_RESULT_DATA,
|
||||
"value_num": 12.5,
|
||||
"unit_original": "mg/dl",
|
||||
"flag": "H",
|
||||
},
|
||||
)
|
||||
rid = r.json()["record_id"]
|
||||
r2 = client.patch(
|
||||
f"/health/lab-results/{rid}",
|
||||
json={
|
||||
"value_num": None,
|
||||
"value_text": "not detected",
|
||||
"flag": None,
|
||||
"unit_original": None,
|
||||
},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
data = r2.json()
|
||||
assert data["value_num"] is None
|
||||
assert data["value_text"] == "not detected"
|
||||
assert data["flag"] is None
|
||||
assert data["unit_original"] is None
|
||||
|
||||
|
||||
def test_delete_lab_result(client):
|
||||
r = client.post("/health/lab-results", json=LAB_RESULT_DATA)
|
||||
rid = r.json()["record_id"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue