Skip to content

Commit

Permalink
Patient Details Endpoints (#174)
Browse files Browse the repository at this point in the history
## Description
Add two new API endpoints that allow users to directly add/update
patient record details in the MPI.

## Related Issues
closes #159 

## Additional Notes
`POST /patient -d '{"record": "...", "person": "person_reference_id"}'`
Add the above endpoint to allow users to directly add a patient record
to the MPI, skipping the record linkage algorithm.

`PATCH /patient/<patient-reference-id> -d '{"record": "...", "person":
"person_reference_id"}'`
Add the above endpoint to allow users to update an existing patient
record in the MPI.
NOTE: For this operation, the `record` and `person` attributes are
optional. If not specified, the existing value will stay unchanged.
However, at least 1 must be specified.

---------

Co-authored-by: Eric Buckley <[email protected]>
Co-authored-by: m-goggins <[email protected]>
  • Loading branch information
3 people authored Feb 4, 2025
1 parent e837f63 commit 5b5fe41
Show file tree
Hide file tree
Showing 6 changed files with 333 additions and 3 deletions.
57 changes: 57 additions & 0 deletions src/recordlinker/database/mpi_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,46 @@ def bulk_insert_patients(
return patients


def update_patient(
session: orm.Session,
patient: models.Patient,
record: typing.Optional[schemas.PIIRecord] = None,
person: typing.Optional[models.Person] = None,
external_patient_id: typing.Optional[str] = None,
commit: bool = True,
) -> models.Patient:
"""
Updates an existing patient record in the database.
:param session: The database session
:param patient: The Patient to update
:param record: Optional PIIRecord to update
:param person: Optional Person to associate with the Patient
:param external_patient_id: Optional external patient ID
:param commit: Whether to commit the transaction
:returns: The updated Patient record
"""
if patient.id is None:
raise ValueError("Patient has not yet been inserted into the database")

if record:
patient.record = record
delete_blocking_values_for_patient(session, patient, commit=False)
insert_blocking_values(session, [patient], commit=False)

if person:
patient.person = person

if external_patient_id is not None:
patient.external_patient_id = external_patient_id

session.flush()
if commit:
session.commit()
return patient


def insert_blocking_values(
session: orm.Session,
patients: typing.Sequence[models.Patient],
Expand Down Expand Up @@ -190,6 +230,23 @@ def insert_blocking_values(
session.commit()


def delete_blocking_values_for_patient(
session: orm.Session, patient: models.Patient, commit: bool = True
) -> None:
"""
Delete all BlockingValues for a given Patient.
:param session: The database session
:param patient: The Patient to delete BlockingValues for
:param commit: Whether to commit the transaction
:returns: None
"""
session.query(models.BlockingValue).filter(models.BlockingValue.patient_id == patient.id).delete()
if commit:
session.commit()


def get_patient_by_reference_id(
session: orm.Session, reference_id: uuid.UUID
) -> models.Patient | None:
Expand Down
90 changes: 88 additions & 2 deletions src/recordlinker/routes/patient_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
the patient API endpoints.
"""

import typing
import uuid

import fastapi
Expand Down Expand Up @@ -65,6 +66,91 @@ def update_person(
patient_reference_id=patient.reference_id, person_reference_id=person.reference_id
)


@router.post(
"/",
summary="Create a patient record and link to an existing person",
status_code=fastapi.status.HTTP_201_CREATED,
)
def create_patient(
payload: typing.Annotated[schemas.PatientCreatePayload, fastapi.Body],
session: orm.Session = fastapi.Depends(get_session),
) -> schemas.PatientRef:
"""
Create a new patient record in the MPI and link to an existing person.
"""
person = service.get_person_by_reference_id(session, payload.person_reference_id)

if person is None:
raise fastapi.HTTPException(
status_code=fastapi.status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=[
{
"loc": ["body", "person_reference_id"],
"msg": "Person not found",
"type": "value_error",
}
],
)

patient = service.insert_patient(
session,
payload.record,
person=person,
external_patient_id=payload.record.external_id,
commit=False,
)
return schemas.PatientRef(
patient_reference_id=patient.reference_id, external_patient_id=patient.external_patient_id
)


@router.patch(
"/{patient_reference_id}",
summary="Update a patient record",
status_code=fastapi.status.HTTP_200_OK,
)
def update_patient(
patient_reference_id: uuid.UUID,
payload: typing.Annotated[schemas.PatientUpdatePayload, fastapi.Body],
session: orm.Session = fastapi.Depends(get_session),
) -> schemas.PatientRef:
"""
Update an existing patient record in the MPI
"""
patient = service.get_patient_by_reference_id(session, patient_reference_id)
if patient is None:
raise fastapi.HTTPException(status_code=fastapi.status.HTTP_404_NOT_FOUND)

person = None
if payload.person_reference_id:
person = service.get_person_by_reference_id(session, payload.person_reference_id)
if person is None:
raise fastapi.HTTPException(
status_code=fastapi.status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=[
{
"loc": ["body", "person_reference_id"],
"msg": "Person not found",
"type": "value_error",
}
],
)

external_patient_id = getattr(payload.record, "external_id", None)
patient = service.update_patient(
session,
patient,
person=person,
record=payload.record,
external_patient_id=external_patient_id,
commit=False,
)
return schemas.PatientRef(
patient_reference_id=patient.reference_id, external_patient_id=patient.external_patient_id
)


@router.delete(
"/{patient_reference_id}",
summary="Delete a Patient",
Expand All @@ -80,5 +166,5 @@ def delete_patient(

if patient is None:
raise fastapi.HTTPException(status_code=fastapi.status.HTTP_404_NOT_FOUND)
return service.delete_patient(session, patient)

return service.delete_patient(session, patient)
4 changes: 4 additions & 0 deletions src/recordlinker/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
from .link import MatchFhirResponse
from .link import MatchResponse
from .link import Prediction
from .mpi import PatientCreatePayload
from .mpi import PatientPersonRef
from .mpi import PatientRef
from .mpi import PatientUpdatePayload
from .mpi import PersonRef
from .pii import Feature
from .pii import FeatureAttribute
Expand Down Expand Up @@ -38,6 +40,8 @@
"PersonRef",
"PatientRef",
"PatientPersonRef",
"PatientCreatePayload",
"PatientUpdatePayload",
"Cluster",
"ClusterGroup",
"PersonCluster",
Expand Down
22 changes: 22 additions & 0 deletions src/recordlinker/schemas/mpi.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import typing
import uuid

import pydantic

from .pii import PIIRecord


class PersonRef(pydantic.BaseModel):
person_reference_id: uuid.UUID
Expand All @@ -16,3 +19,22 @@ class PatientRef(pydantic.BaseModel):
class PatientPersonRef(pydantic.BaseModel):
patient_reference_id: uuid.UUID
person_reference_id: uuid.UUID


class PatientCreatePayload(pydantic.BaseModel):
person_reference_id: uuid.UUID
record: PIIRecord


class PatientUpdatePayload(pydantic.BaseModel):
person_reference_id: uuid.UUID | None = None
record: PIIRecord | None = None

@pydantic.model_validator(mode="after")
def validate_both_not_empty(self) -> typing.Self:
"""
Ensure that either person_reference_id or record is not None.
"""
if self.person_reference_id is None and self.record is None:
raise ValueError("at least one of person_reference_id or record must be provided")
return self
59 changes: 59 additions & 0 deletions tests/unit/database/test_mpi_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,65 @@ def test_error(self, session):
assert mpi_service.bulk_insert_patients(session, [])


class TestUpdatePatient:
def test_no_patient(self, session):
with pytest.raises(ValueError):
mpi_service.update_patient(session, models.Patient(), schemas.PIIRecord())

def test_update_record(self, session):
patient = models.Patient(person=models.Person(), data={"sex": "M"})
session.add(patient)
session.flush()
session.add(models.BlockingValue(patient_id=patient.id, blockingkey=models.BlockingKey.SEX.id, value="M"))
record = schemas.PIIRecord(**{"name": [{"given": ["John"], "family": "Doe"}], "birthdate": "1980-01-01"})
patient = mpi_service.update_patient(session, patient, record=record)
assert patient.data == {"name": [{"given": ["John"], "family": "Doe"}], "birth_date": "1980-01-01"}
assert len(patient.blocking_values) == 3

def test_update_person(self, session):
person = models.Person()
session.add(person)
patient = models.Patient()
session.add(patient)
session.flush()
patient = mpi_service.update_patient(session, patient, person=person)
assert patient.person_id == person.id

def test_update_external_patient_id(self, session):
patient = models.Patient()
session.add(patient)
session.flush()

patient = mpi_service.update_patient(session, patient, external_patient_id="123")
assert patient.external_patient_id == "123"


class TestDeleteBlockingValuesForPatient:
def test_no_values(self, session):
other_patient = models.Patient()
session.add(other_patient)
session.flush()
session.add(models.BlockingValue(patient_id=other_patient.id, blockingkey=models.BlockingKey.FIRST_NAME.id, value="John"))
session.flush()
patient = models.Patient()
session.add(patient)
session.flush()
assert len(patient.blocking_values) == 0
mpi_service.delete_blocking_values_for_patient(session, patient)
assert len(patient.blocking_values) == 0

def test_with_values(self, session):
patient = models.Patient()
session.add(patient)
session.flush()
session.add(models.BlockingValue(patient_id=patient.id, blockingkey=models.BlockingKey.FIRST_NAME.id, value="John"))
session.add(models.BlockingValue(patient_id=patient.id, blockingkey=models.BlockingKey.LAST_NAME.id, value="Smith"))
session.flush()
assert len(patient.blocking_values) == 2
mpi_service.delete_blocking_values_for_patient(session, patient)
assert len(patient.blocking_values) == 0


class TestGetBlockData:
@pytest.fixture
def prime_index(self, session):
Expand Down
Loading

0 comments on commit 5b5fe41

Please sign in to comment.