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