innercontext/backend/innercontext/api/health.py

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