diff --git a/care/facility/admin.py b/care/facility/admin.py index 11a210d0ed..55a9bdff65 100644 --- a/care/facility/admin.py +++ b/care/facility/admin.py @@ -6,7 +6,7 @@ from care.facility.models.ambulance import Ambulance, AmbulanceDriver from care.facility.models.asset import Asset -from care.facility.models.bed import AssetBed, Bed +from care.facility.models.bed import AssetBed, Bed, ConsultationBed from care.facility.models.facility import FacilityHubSpoke from care.facility.models.file_upload import FileUpload from care.facility.models.patient_consultation import ( @@ -237,6 +237,7 @@ class Meta: admin.site.register(AssetBed) admin.site.register(Asset) admin.site.register(Bed) +admin.site.register(ConsultationBed) admin.site.register(PatientConsent) admin.site.register(FileUpload) admin.site.register(PatientConsultation) diff --git a/care/facility/api/viewsets/patient.py b/care/facility/api/viewsets/patient.py index 4953ef54af..f7cb1a05e0 100644 --- a/care/facility/api/viewsets/patient.py +++ b/care/facility/api/viewsets/patient.py @@ -67,7 +67,7 @@ ShiftingRequest, ) from care.facility.models.base import covert_choice_dict -from care.facility.models.bed import AssetBed +from care.facility.models.bed import AssetBed, ConsultationBed from care.facility.models.icd11_diagnosis import ( INACTIVE_CONDITION_VERIFICATION_STATUSES, ConditionVerificationStatus, @@ -97,6 +97,9 @@ class PatientFilterSet(filters.FilterSet): + + last_consultation_field = "last_consultation" + source = filters.ChoiceFilter(choices=PatientRegistration.SourceChoices) disease_status = CareChoiceFilter(choice_dict=DISEASE_STATUS_DICT) facility = filters.UUIDFilter(field_name="facility__external_id") @@ -109,14 +112,14 @@ class PatientFilterSet(filters.FilterSet): allow_transfer = filters.BooleanFilter(field_name="allow_transfer") name = filters.CharFilter(field_name="name", lookup_expr="icontains") patient_no = filters.CharFilter( - field_name="last_consultation__patient_no", lookup_expr="iexact" + field_name=f"{last_consultation_field}__patient_no", lookup_expr="iexact" ) gender = filters.NumberFilter(field_name="gender") age = filters.NumberFilter(field_name="age") age_min = filters.NumberFilter(field_name="age", lookup_expr="gte") age_max = filters.NumberFilter(field_name="age", lookup_expr="lte") deprecated_covid_category = filters.ChoiceFilter( - field_name="last_consultation__deprecated_covid_category", + field_name=f"{last_consultation_field}__deprecated_covid_category", choices=COVID_CATEGORY_CHOICES, ) category = filters.ChoiceFilter( @@ -168,24 +171,24 @@ def filter_by_category(self, queryset, name, value): state = filters.NumberFilter(field_name="state__id") state_name = filters.CharFilter(field_name="state__name", lookup_expr="icontains") # Consultation Fields - is_kasp = filters.BooleanFilter(field_name="last_consultation__is_kasp") + is_kasp = filters.BooleanFilter(field_name=f"{last_consultation_field}__is_kasp") last_consultation_kasp_enabled_date = filters.DateFromToRangeFilter( - field_name="last_consultation__kasp_enabled_date" + field_name=f"{last_consultation_field}__kasp_enabled_date" ) last_consultation_encounter_date = filters.DateFromToRangeFilter( - field_name="last_consultation__encounter_date" + field_name=f"{last_consultation_field}__encounter_date" ) last_consultation_discharge_date = filters.DateFromToRangeFilter( - field_name="last_consultation__discharge_date" + field_name=f"{last_consultation_field}__discharge_date" ) last_consultation_admitted_bed_type_list = MultiSelectFilter( method="filter_by_bed_type", ) last_consultation_medico_legal_case = filters.BooleanFilter( - field_name="last_consultation__medico_legal_case" + field_name=f"{last_consultation_field}__medico_legal_case" ) last_consultation_current_bed__location = filters.UUIDFilter( - field_name="last_consultation__current_bed__bed__location__external_id" + field_name=f"{last_consultation_field}__current_bed__bed__location__external_id" ) def filter_by_bed_type(self, queryset, name, value): @@ -204,21 +207,21 @@ def filter_by_bed_type(self, queryset, name, value): return queryset.filter(filter_q) last_consultation_admitted_bed_type = CareChoiceFilter( - field_name="last_consultation__current_bed__bed__bed_type", + field_name=f"{last_consultation_field}__current_bed__bed__bed_type", choice_dict=REVERSE_BED_TYPES, ) last_consultation__new_discharge_reason = filters.ChoiceFilter( - field_name="last_consultation__new_discharge_reason", + field_name=f"{last_consultation_field}__new_discharge_reason", choices=NewDischargeReasonEnum.choices, ) last_consultation_assigned_to = filters.NumberFilter( - field_name="last_consultation__assigned_to" + field_name=f"{last_consultation_field}__assigned_to" ) last_consultation_is_telemedicine = filters.BooleanFilter( - field_name="last_consultation__is_telemedicine" + field_name=f"{last_consultation_field}__is_telemedicine" ) ventilator_interface = CareChoiceFilter( - field_name="last_consultation__last_daily_round__ventilator_interface", + field_name=f"{last_consultation_field}__last_daily_round__ventilator_interface", choice_dict={ label: value for value, label in DailyRound.VentilatorInterfaceType.choices }, @@ -633,19 +636,74 @@ def transfer(self, request, *args, **kwargs): return Response(data=response_serializer.data, status=status.HTTP_200_OK) +class DischargePatientFilterSet(PatientFilterSet): + last_consultation_field = "last_discharge_consultation" + + # Filters patients by the type of bed they have been assigned to. + def filter_by_bed_type(self, queryset, name, value): + if not value: + return queryset + + values = value.split(",") + filter_q = Q() + + # Get the latest consultation bed records for each patient by ordering by patient_id + # and the end_date of the consultation, then selecting distinct patient entries. + last_consultation_bed_ids = ( + ConsultationBed.objects.filter(end_date__isnull=False) + .order_by("consultation__patient_id", "-end_date") + .distinct("consultation__patient_id") + ) + + # patients whose last consultation did not include a bed + if "None" in values: + filter_q |= ~Q( + last_discharge_consultation__id__in=Subquery( + last_consultation_bed_ids.values_list("consultation_id", flat=True) + ) + ) + values.remove("None") + + # If the values list contains valid bed types, apply the filtering for those bed types. + if isinstance(values, list) and len(values) > 0: + filter_q |= Q( + last_discharge_consultation__id__in=Subquery( + ConsultationBed.objects.filter( + id__in=Subquery( + last_consultation_bed_ids.values_list("id", flat=True) + ), # Filter by consultation beds that are part of the latest records for each patient. + bed__bed_type__in=values, # Match the bed types from the provided values list. + ).values_list("consultation_id", flat=True) + ) + ) + + return queryset.filter(filter_q) + + @extend_schema_view(tags=["patient"]) class FacilityDischargedPatientViewSet(GenericViewSet, mixins.ListModelMixin): permission_classes = (IsAuthenticated, DRYPermissions) lookup_field = "external_id" serializer_class = PatientListSerializer filter_backends = ( + PatientDRYFilter, filters.DjangoFilterBackend, rest_framework_filters.OrderingFilter, PatientCustomOrderingFilter, ) - filterset_class = PatientFilterSet + filterset_class = DischargePatientFilterSet queryset = ( - PatientRegistration.objects.select_related( + PatientRegistration.objects.annotate( + last_discharge_consultation__id=Subquery( + PatientConsultation.objects.filter( + patient_id=OuterRef("id"), + discharge_date__isnull=False, + ) + .order_by("-discharge_date") + .values("id")[:1] + ) + ) + .select_related( "local_body", "district", "state", @@ -656,8 +714,6 @@ class FacilityDischargedPatientViewSet(GenericViewSet, mixins.ListModelMixin): "facility__local_body", "facility__district", "facility__state", - "last_consultation", - "last_consultation__assigned_to", "last_edited", "created_by", ) @@ -702,9 +758,9 @@ class FacilityDischargedPatientViewSet(GenericViewSet, mixins.ListModelMixin): "date_declared_positive", "date_of_result", "last_vaccinated_date", - "last_consultation_encounter_date", - "last_consultation_discharge_date", - "last_consultation_symptoms_onset_date", + "last_discharge_consultation_encounter_date", + "last_discharge_consultation_discharge_date", + "last_discharge_consultation_symptoms_onset_date", ] ordering_fields = [ @@ -713,7 +769,7 @@ class FacilityDischargedPatientViewSet(GenericViewSet, mixins.ListModelMixin): "created_date", "modified_date", "review_time", - "last_consultation__current_bed__bed__name", + "last_discharge_consultation__current_bed__bed__name", "date_declared_positive", ] diff --git a/care/facility/tests/test_patient_api.py b/care/facility/tests/test_patient_api.py index c243c7a10b..c40d57639b 100644 --- a/care/facility/tests/test_patient_api.py +++ b/care/facility/tests/test_patient_api.py @@ -1,6 +1,6 @@ from enum import Enum -from django.utils.timezone import now +from django.utils.timezone import now, timedelta from rest_framework import status from rest_framework.test import APITestCase @@ -728,6 +728,156 @@ def test_filter_by_has_consents(self): self.assertEqual(res.json()["count"], 3) +class DischargePatientFilterTestCase(TestUtils, APITestCase): + @classmethod + def setUpTestData(cls): + cls.state = cls.create_state() + cls.district = cls.create_district(cls.state) + cls.local_body = cls.create_local_body(cls.district) + cls.super_user = cls.create_super_user("su", cls.district) + cls.facility = cls.create_facility(cls.super_user, cls.district, cls.local_body) + cls.user = cls.create_user( + "user", cls.district, user_type=15, home_facility=cls.facility + ) + cls.location = cls.create_asset_location(cls.facility) + + cls.iso_bed = cls.create_bed(cls.facility, cls.location, bed_type=1, name="ISO") + cls.icu_bed = cls.create_bed(cls.facility, cls.location, bed_type=2, name="ICU") + cls.oxy_bed = cls.create_bed(cls.facility, cls.location, bed_type=6, name="OXY") + cls.nor_bed = cls.create_bed(cls.facility, cls.location, bed_type=7, name="NOR") + + cls.patient_iso = cls.create_patient(cls.district, cls.facility) + cls.patient_icu = cls.create_patient(cls.district, cls.facility) + cls.patient_oxy = cls.create_patient(cls.district, cls.facility) + cls.patient_nor = cls.create_patient(cls.district, cls.facility) + cls.patient_nb = cls.create_patient(cls.district, cls.facility) + + cls.consultation_iso = cls.create_consultation( + patient=cls.patient_iso, + facility=cls.facility, + discharge_date=now(), + ) + cls.consultation_icu = cls.create_consultation( + patient=cls.patient_icu, + facility=cls.facility, + discharge_date=now(), + ) + cls.consultation_oxy = cls.create_consultation( + patient=cls.patient_oxy, + facility=cls.facility, + discharge_date=now(), + ) + cls.consultation_nor = cls.create_consultation( + patient=cls.patient_nor, + facility=cls.facility, + discharge_date=now(), + ) + + cls.consultation_nb = cls.create_consultation( + patient=cls.patient_nb, + facility=cls.facility, + discharge_date=now(), + ) + + cls.consultation_bed_iso = cls.create_consultation_bed( + cls.consultation_iso, + cls.iso_bed, + end_date=now(), + ) + cls.consultation_bed_icu = cls.create_consultation_bed( + cls.consultation_icu, + cls.icu_bed, + end_date=now(), + ) + cls.consultation_bed_oxy = cls.create_consultation_bed( + cls.consultation_oxy, + cls.oxy_bed, + end_date=now(), + ) + cls.consultation_bed_nor = cls.create_consultation_bed( + cls.consultation_nor, + cls.nor_bed, + end_date=now(), + ) + + def get_base_url(self) -> str: + return ( + "/api/v1/facility/" + + str(self.facility.external_id) + + "/discharged_patients/" + ) + + def test_filter_by_admitted_to_bed(self): + self.client.force_authenticate(user=self.user) + choices = ["1", "2", "6", "7", "None"] + + res = self.client.get( + self.get_base_url(), + {"last_consultation_admitted_bed_type_list": ",".join([choices[0]])}, + ) + + self.assertContains(res, self.patient_iso.external_id) + self.assertNotContains(res, self.patient_icu.external_id) + self.assertNotContains(res, self.patient_oxy.external_id) + self.assertNotContains(res, self.patient_nor.external_id) + self.assertNotContains(res, self.patient_nb.external_id) + + res = self.client.get( + self.get_base_url(), + {"last_consultation_admitted_bed_type_list": ",".join(choices[1:3])}, + ) + + self.assertNotContains(res, self.patient_iso.external_id) + self.assertContains(res, self.patient_icu.external_id) + self.assertContains(res, self.patient_oxy.external_id) + self.assertNotContains(res, self.patient_nor.external_id) + self.assertNotContains(res, self.patient_nb.external_id) + + res = self.client.get( + self.get_base_url(), + {"last_consultation_admitted_bed_type_list": ",".join(choices)}, + ) + + self.assertContains(res, self.patient_iso.external_id) + self.assertContains(res, self.patient_icu.external_id) + self.assertContains(res, self.patient_oxy.external_id) + self.assertContains(res, self.patient_nor.external_id) + self.assertContains(res, self.patient_nb.external_id) + + res = self.client.get( + self.get_base_url(), + {"last_consultation_admitted_bed_type_list": ",".join(choices[3:])}, + ) + + self.assertNotContains(res, self.patient_iso.external_id) + self.assertNotContains(res, self.patient_icu.external_id) + self.assertNotContains(res, self.patient_oxy.external_id) + self.assertContains(res, self.patient_nor.external_id) + self.assertContains(res, self.patient_nb.external_id) + + # if patient is readmitted to another bed type, only the latest admission should be considered + + def test_admitted_to_bed_after_readmission(self): + self.client.force_authenticate(user=self.user) + self.create_consultation_bed( + self.consultation_icu, self.iso_bed, end_date=now() + timedelta(days=1) + ) + + res = self.client.get( + self.get_base_url(), + {"last_consultation_admitted_bed_type_list": "1"}, + ) + + self.assertContains(res, self.patient_icu.external_id) + + res = self.client.get( + self.get_base_url(), + {"last_consultation_admitted_bed_type_list": "2"}, + ) + + self.assertNotContains(res, self.patient_icu.external_id) + + class PatientTransferTestCase(TestUtils, APITestCase): @classmethod def setUpTestData(cls):