539 lines
16 KiB
Python
539 lines
16 KiB
Python
import re
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
from uuid import UUID, uuid4
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, 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
|
|
from innercontext.api.auth_deps import get_current_user
|
|
from innercontext.api.utils import get_owned_or_404
|
|
from innercontext.auth import CurrentUser
|
|
from innercontext.models import LabResult, MedicationEntry, MedicationUsage
|
|
from innercontext.models.enums import MedicationKind, ResultFlag, Role
|
|
|
|
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
|
|
|
|
|
|
class LabResultListResponse(SQLModel):
|
|
items: list[LabResult]
|
|
total: int
|
|
limit: int
|
|
offset: int
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _resolve_target_user_id(
|
|
current_user: CurrentUser,
|
|
user_id: UUID | None,
|
|
) -> UUID:
|
|
if user_id is None:
|
|
return current_user.user_id
|
|
if current_user.role is not Role.ADMIN:
|
|
raise HTTPException(status_code=403, detail="Admin role required")
|
|
return user_id
|
|
|
|
|
|
def _get_owned_or_admin_override(
|
|
session: Session,
|
|
model: type[MedicationEntry] | type[MedicationUsage] | type[LabResult],
|
|
record_id: UUID,
|
|
current_user: CurrentUser,
|
|
user_id: UUID | None,
|
|
):
|
|
if user_id is None:
|
|
return get_owned_or_404(session, model, record_id, current_user)
|
|
record = session.get(model, record_id)
|
|
if record is None or record.user_id != _resolve_target_user_id(
|
|
current_user, user_id
|
|
):
|
|
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
|
return record
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Medication routes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/medications", response_model=list[MedicationEntry])
|
|
def list_medications(
|
|
kind: Optional[MedicationKind] = None,
|
|
product_name: Optional[str] = None,
|
|
user_id: UUID | None = Query(default=None),
|
|
session: Session = Depends(get_session),
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
target_user_id = _resolve_target_user_id(current_user, user_id)
|
|
stmt = select(MedicationEntry).where(MedicationEntry.user_id == target_user_id)
|
|
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,
|
|
user_id: UUID | None = Query(default=None),
|
|
session: Session = Depends(get_session),
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
target_user_id = _resolve_target_user_id(current_user, user_id)
|
|
entry = MedicationEntry(
|
|
record_id=uuid4(),
|
|
user_id=target_user_id,
|
|
**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,
|
|
user_id: UUID | None = Query(default=None),
|
|
session: Session = Depends(get_session),
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return _get_owned_or_admin_override(
|
|
session,
|
|
MedicationEntry,
|
|
medication_id,
|
|
current_user,
|
|
user_id,
|
|
)
|
|
|
|
|
|
@router.patch("/medications/{medication_id}", response_model=MedicationEntry)
|
|
def update_medication(
|
|
medication_id: UUID,
|
|
data: MedicationUpdate,
|
|
user_id: UUID | None = Query(default=None),
|
|
session: Session = Depends(get_session),
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
entry = _get_owned_or_admin_override(
|
|
session,
|
|
MedicationEntry,
|
|
medication_id,
|
|
current_user,
|
|
user_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,
|
|
user_id: UUID | None = Query(default=None),
|
|
session: Session = Depends(get_session),
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
target_user_id = _resolve_target_user_id(current_user, user_id)
|
|
entry = _get_owned_or_admin_override(
|
|
session,
|
|
MedicationEntry,
|
|
medication_id,
|
|
current_user,
|
|
user_id,
|
|
)
|
|
# Delete usages first (no cascade configured at DB level)
|
|
usages = session.exec(
|
|
select(MedicationUsage)
|
|
.where(MedicationUsage.medication_record_id == medication_id)
|
|
.where(MedicationUsage.user_id == target_user_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,
|
|
user_id: UUID | None = Query(default=None),
|
|
session: Session = Depends(get_session),
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
target_user_id = _resolve_target_user_id(current_user, user_id)
|
|
_ = _get_owned_or_admin_override(
|
|
session,
|
|
MedicationEntry,
|
|
medication_id,
|
|
current_user,
|
|
user_id,
|
|
)
|
|
stmt = (
|
|
select(MedicationUsage)
|
|
.where(MedicationUsage.medication_record_id == medication_id)
|
|
.where(MedicationUsage.user_id == target_user_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,
|
|
user_id: UUID | None = Query(default=None),
|
|
session: Session = Depends(get_session),
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
target_user_id = _resolve_target_user_id(current_user, user_id)
|
|
_ = _get_owned_or_admin_override(
|
|
session,
|
|
MedicationEntry,
|
|
medication_id,
|
|
current_user,
|
|
user_id,
|
|
)
|
|
usage = MedicationUsage(
|
|
record_id=uuid4(),
|
|
user_id=target_user_id,
|
|
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,
|
|
user_id: UUID | None = Query(default=None),
|
|
session: Session = Depends(get_session),
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
usage = _get_owned_or_admin_override(
|
|
session,
|
|
MedicationUsage,
|
|
usage_id,
|
|
current_user,
|
|
user_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,
|
|
user_id: UUID | None = Query(default=None),
|
|
session: Session = Depends(get_session),
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
usage = _get_owned_or_admin_override(
|
|
session,
|
|
MedicationUsage,
|
|
usage_id,
|
|
current_user,
|
|
user_id,
|
|
)
|
|
session.delete(usage)
|
|
session.commit()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lab result routes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/lab-results", response_model=LabResultListResponse)
|
|
def list_lab_results(
|
|
q: Optional[str] = None,
|
|
test_code: Optional[str] = None,
|
|
flag: Optional[ResultFlag] = None,
|
|
flags: list[ResultFlag] = Query(default_factory=list),
|
|
without_flag: bool = False,
|
|
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),
|
|
user_id: UUID | None = Query(default=None),
|
|
session: Session = Depends(get_session),
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
target_user_id = _resolve_target_user_id(current_user, user_id)
|
|
|
|
def _apply_filters(statement):
|
|
statement = statement.where(col(LabResult.user_id) == target_user_id)
|
|
if q is not None and q.strip():
|
|
query = f"%{q.strip()}%"
|
|
statement = statement.where(
|
|
or_(
|
|
col(LabResult.test_code).ilike(query),
|
|
col(LabResult.test_name_original).ilike(query),
|
|
)
|
|
)
|
|
if test_code is not None:
|
|
statement = statement.where(col(LabResult.test_code) == test_code)
|
|
if flag is not None:
|
|
statement = statement.where(col(LabResult.flag) == flag)
|
|
if flags:
|
|
statement = statement.where(col(LabResult.flag).in_(flags))
|
|
if without_flag:
|
|
statement = statement.where(col(LabResult.flag).is_(None))
|
|
if from_date is not None:
|
|
statement = statement.where(col(LabResult.collected_at) >= from_date)
|
|
if to_date is not None:
|
|
statement = statement.where(col(LabResult.collected_at) <= to_date)
|
|
return statement
|
|
|
|
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"),
|
|
)
|
|
ranked_stmt = _apply_filters(ranked_stmt)
|
|
|
|
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 = _apply_filters(select(LabResult))
|
|
count_stmt = _apply_filters(select(func.count()).select_from(LabResult))
|
|
|
|
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)
|
|
def create_lab_result(
|
|
data: LabResultCreate,
|
|
user_id: UUID | None = Query(default=None),
|
|
session: Session = Depends(get_session),
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
target_user_id = _resolve_target_user_id(current_user, user_id)
|
|
result = LabResult(
|
|
record_id=uuid4(),
|
|
user_id=target_user_id,
|
|
**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,
|
|
user_id: UUID | None = Query(default=None),
|
|
session: Session = Depends(get_session),
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return _get_owned_or_admin_override(
|
|
session,
|
|
LabResult,
|
|
result_id,
|
|
current_user,
|
|
user_id,
|
|
)
|
|
|
|
|
|
@router.patch("/lab-results/{result_id}", response_model=LabResult)
|
|
def update_lab_result(
|
|
result_id: UUID,
|
|
data: LabResultUpdate,
|
|
user_id: UUID | None = Query(default=None),
|
|
session: Session = Depends(get_session),
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
result = _get_owned_or_admin_override(
|
|
session,
|
|
LabResult,
|
|
result_id,
|
|
current_user,
|
|
user_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,
|
|
user_id: UUID | None = Query(default=None),
|
|
session: Session = Depends(get_session),
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
result = _get_owned_or_admin_override(
|
|
session,
|
|
LabResult,
|
|
result_id,
|
|
current_user,
|
|
user_id,
|
|
)
|
|
session.delete(result)
|
|
session.commit()
|