Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Patient Details Endpoints #174

Merged
merged 15 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading