diff --git a/care/facility/api/serializers/bed.py b/care/facility/api/serializers/bed.py index 6624c3440f..4dd183e1c1 100644 --- a/care/facility/api/serializers/bed.py +++ b/care/facility/api/serializers/bed.py @@ -5,6 +5,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.serializers import ( BooleanField, + DateTimeField, IntegerField, ListField, ModelSerializer, @@ -166,6 +167,7 @@ class ConsultationBedSerializer(ModelSerializer): assets = ListField(child=UUIDField(), required=False, write_only=True) assets_objects = AssetSerializer(source="assets", many=True, read_only=True) + start_date = DateTimeField(required=True) class Meta: model = ConsultationBed @@ -173,92 +175,125 @@ class Meta: read_only_fields = TIMESTAMP_FIELDS def validate(self, attrs): - user = self.context["request"].user - if "consultation" in attrs and "bed" in attrs and "start_date" in attrs: - bed = attrs["bed"] - facilities = get_facility_queryset(user) - if not facilities.filter(id=bed.facility_id).exists(): - raise PermissionError() - - permitted_consultations = get_consultation_queryset(user) - consultation: PatientConsultation = get_object_or_404( - permitted_consultations.filter(id=attrs["consultation"].id) - ) - if not consultation.patient.is_active: - raise ValidationError( - {"patient:": ["Patient is already discharged from CARE"]} - ) - - if consultation.facility_id != bed.facility_id: - raise ValidationError( - {"consultation": "Should be in the same facility as the bed"} - ) + if "consultation" not in attrs: + raise ValidationError({"consultation": "This field is required."}) + if "bed" not in attrs: + raise ValidationError({"bed": "This field is required."}) + if "start_date" not in attrs: + raise ValidationError({"start_date": "This field is required."}) - previous_consultation_bed = consultation.current_bed - if ( - previous_consultation_bed - and previous_consultation_bed.bed == bed - and set( - previous_consultation_bed.assets.order_by( - "external_id" - ).values_list("external_id", flat=True) - ) - == set(attrs.get("assets", [])) - ): - raise ValidationError( - {"consultation": "These set of bed and assets are already assigned"} + user = self.context["request"].user + bed = attrs["bed"] + + facilities = get_facility_queryset(user) + if not facilities.filter(id=bed.facility_id).exists(): + raise ValidationError("You do not have access to this facility") + + permitted_consultations = get_consultation_queryset(user).select_related( + "patient" + ) + consultation: PatientConsultation = get_object_or_404( + permitted_consultations.filter(id=attrs["consultation"].id) + ) + if ( + not consultation.patient.is_active + or consultation.discharge_date + or consultation.death_datetime + ): + raise ValidationError("Patient not active") + + # bed validations + if consultation.facility_id != bed.facility_id: + raise ValidationError("Consultation and bed are not in the same facility") + if ( + ConsultationBed.objects.filter(bed=bed, end_date__isnull=True) + .exclude(consultation=consultation) + .exists() + ): + raise ValidationError("Bed is already in use") + + # check whether the same set of bed and assets are already assigned + current_consultation_bed = consultation.current_bed + if ( + not self.instance + and current_consultation_bed + and current_consultation_bed.bed == bed + and set( + current_consultation_bed.assets.order_by("external_id").values_list( + "external_id", flat=True ) - - start_date = attrs["start_date"] - end_date = attrs.get("end_date", None) - - qs = ConsultationBed.objects.filter(consultation=consultation) - # Validations based of the latest entry - if qs.exists(): - latest_qs = qs.latest("id") - if start_date < latest_qs.start_date: - raise ValidationError( - { - "start_date": "Start date cannot be before the latest start date" - } - ) - if end_date and end_date < latest_qs.start_date: - raise ValidationError( - {"end_date": "End date cannot be before the latest start date"} - ) - - # Conflict checking logic - existing_qs = ConsultationBed.objects.filter(bed=bed).exclude( - consultation=consultation ) - if existing_qs.filter(start_date__gt=start_date).exists(): - raise ValidationError({"start_date": "Cannot create conflicting entry"}) - if end_date: - if existing_qs.filter( - start_date__gt=end_date, end_date__lt=end_date - ).exists(): - raise ValidationError( - {"end_date": "Cannot create conflicting entry"} - ) + == set(attrs.get("assets", [])) + ): + raise ValidationError("These set of bed and assets are already assigned") + + # date validations + # note: end_date is for setting end date on current instance + current_start_date = attrs["start_date"] + current_end_date = attrs.get("end_date", None) + if current_end_date and current_end_date < current_start_date: + raise ValidationError( + {"end_date": "End date cannot be before the start date"} + ) + if ( + consultation.admission_date + and consultation.admission_date > current_start_date + ): + raise ValidationError( + {"start_date": "Start date cannot be before the admission date"} + ) - else: + # validations based on patients previous consultation bed + last_consultation_bed = ( + ConsultationBed.objects.filter(consultation=consultation) + .exclude(id=self.instance.id if self.instance else None) + .order_by("id") + .last() + ) + if ( + last_consultation_bed + and current_start_date < last_consultation_bed.start_date + ): raise ValidationError( - { - "consultation": "Field is Required", - "bed": "Field is Required", - "start_date": "Field is Required", - } + {"start_date": "Start date cannot be before previous bed start date"} ) + + # check bed occupancy conflicts + if ( + not self.instance + and current_consultation_bed + and ConsultationBed.objects.filter( + Q(bed=current_consultation_bed.bed), + Q(start_date__lte=current_start_date) + | Q(end_date__gte=current_start_date), + ).exists() + ): + # validation for setting end date on current bed based on new bed start date + raise ValidationError({"start_date": "Cannot create conflicting entry"}) + + conflicting_beds = ConsultationBed.objects.filter(bed=bed) + if conflicting_beds.filter( + start_date__lte=current_start_date, end_date__gte=current_start_date + ).exists(): + raise ValidationError({"start_date": "Cannot create conflicting entry"}) + if ( + current_end_date + and conflicting_beds.filter( + start_date__lte=current_end_date, end_date__gte=current_end_date + ).exists() + ): + raise ValidationError({"end_date": "Cannot create conflicting entry"}) return super().validate(attrs) - def create(self, validated_data): + def create(self, validated_data) -> ConsultationBed: consultation = validated_data["consultation"] - with transaction.atomic(): ConsultationBed.objects.filter( end_date__isnull=True, consultation=consultation ).update(end_date=validated_data["start_date"]) if assets_ids := validated_data.pop("assets", None): + # we check assets in use here as they might have been in use in + # the previous bed assets = ( Asset.objects.annotate( is_in_use=Exists( @@ -282,10 +317,11 @@ def create(self, validated_data): ) .values_list("external_id", flat=True) ) - not_found_assets = list(set(assets_ids) - set(assets)) + not_found_assets = set(assets_ids) - set(assets) if not_found_assets: raise ValidationError( - f"Some assets are not available - {' ,'.join(map(str, not_found_assets))}" + "Some assets are not available - " + f"{' ,'.join([str(x) for x in not_found_assets])}" ) obj: ConsultationBed = super().create(validated_data) if assets_ids: diff --git a/care/facility/tests/test_consultation_bed_api.py b/care/facility/tests/test_consultation_bed_api.py new file mode 100644 index 0000000000..99962277db --- /dev/null +++ b/care/facility/tests/test_consultation_bed_api.py @@ -0,0 +1,429 @@ +from datetime import datetime, timedelta + +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APITestCase + +from care.facility.models import Asset, Bed, ConsultationBedAsset +from care.facility.models.bed import ConsultationBed +from care.utils.tests.test_utils import TestUtils + + +class ConsultationBedApiTestCase(TestUtils, APITestCase): + @classmethod + def setUpTestData(cls) -> None: + 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.asset_location = cls.create_asset_location(cls.facility) + cls.asset = cls.create_asset(cls.asset_location) + cls.user = cls.create_user("staff", cls.district, home_facility=cls.facility) + cls.state_admin = cls.create_user( + "state_admin", cls.district, state=cls.state, user_type=40 + ) + cls.patient = cls.create_patient( + cls.district, cls.facility, local_body=cls.local_body + ) + + def setUp(self) -> None: + super().setUp() + self.bed1 = Bed.objects.create( + name="bed1", location=self.asset_location, facility=self.facility + ) + self.bed2 = Bed.objects.create( + name="bed2", location=self.asset_location, facility=self.facility + ) + self.asset1 = Asset.objects.create( + name="asset1", current_location=self.asset_location + ) + self.asset2 = Asset.objects.create( + name="asset2", current_location=self.asset_location + ) + self.asset3 = Asset.objects.create( + name="asset3", current_location=self.asset_location + ) + self.consultation = self.create_consultation(self.patient, self.facility) + + def test_missing_fields(self): + consultation_bed = ConsultationBed.objects.create( + consultation=self.consultation, + bed=self.bed1, + start_date=timezone.now(), + ) + response = self.client.patch( + f"/api/v1/consultationbed/{consultation_bed.external_id}/", + { + "consultation": self.consultation.external_id, + "bed": self.bed1.external_id, + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"start_date": ["This field is required."]}, + ) + + response = self.client.patch( + f"/api/v1/consultationbed/{consultation_bed.external_id}/", + { + "consultation": self.consultation.external_id, + "start_date": timezone.now(), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"bed": ["This field is required."]}, + ) + + response = self.client.patch( + f"/api/v1/consultationbed/{consultation_bed.external_id}/", + { + "bed": self.bed1.external_id, + "start_date": timezone.now(), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"consultation": ["This field is required."]}, + ) + + def test_no_access_to_facility(self): + user2 = self.create_user(username="user2", district=self.district) + self.client.force_authenticate(user2) + response = self.client.post( + "/api/v1/consultationbed/", + { + "consultation": self.consultation.external_id, + "bed": self.bed1.external_id, + "start_date": timezone.now(), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"non_field_errors": ["You do not have access to this facility"]}, + ) + + def test_patient_not_active(self): + err = {"non_field_errors": ["Patient not active"]} + patient = self.create_patient(self.district, self.facility) + self.consultation.discharge_date = timezone.now() + self.consultation.save() + response = self.client.post( + "/api/v1/consultationbed/", + { + "consultation": self.consultation.external_id, + "bed": self.bed1.external_id, + "start_date": timezone.now(), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.json(), err) + + consultation2 = self.create_consultation( + patient, self.facility, death_datetime=timezone.now() + ) + response = self.client.post( + "/api/v1/consultationbed/", + { + "consultation": consultation2.external_id, + "bed": self.bed1.external_id, + "start_date": timezone.now(), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.json(), err) + + patient.is_active = False + patient.save() + consultation3 = self.create_consultation(patient, self.facility) + response = self.client.post( + "/api/v1/consultationbed/", + { + "consultation": consultation3.external_id, + "bed": self.bed1.external_id, + "start_date": timezone.now(), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.json(), err) + + def test_bed_in_different_facility(self): + facility2 = self.create_facility( + self.super_user, self.district, self.local_body + ) + asset_location2 = self.create_asset_location(facility2) + bed2 = Bed.objects.create( + name="bed1", location=asset_location2, facility=facility2 + ) + self.client.force_authenticate(self.state_admin) + response = self.client.post( + "/api/v1/consultationbed/", + { + "consultation": self.consultation.external_id, + "bed": bed2.external_id, + "start_date": timezone.now(), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"non_field_errors": ["Consultation and bed are not in the same facility"]}, + ) + + def test_bed_already_in_use(self): + self.client.post( + "/api/v1/consultationbed/", + { + "consultation": self.consultation.external_id, + "bed": self.bed1.external_id, + "start_date": timezone.now(), + }, + ) + + patient2 = self.create_patient(self.district, self.facility) + consultation2 = self.create_consultation(patient2, self.facility) + response = self.client.post( + "/api/v1/consultationbed/", + { + "consultation": consultation2.external_id, + "bed": self.bed1.external_id, + "start_date": timezone.now(), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"non_field_errors": ["Bed is already in use"]}, + ) + + def test_same_set_of_bed_and_assets_assigned(self): + self.client.post( + "/api/v1/consultationbed/", + { + "consultation": self.consultation.external_id, + "bed": self.bed1.external_id, + "start_date": timezone.now(), + "assets": [self.asset1.external_id, self.asset2.external_id], + }, + ) + response = self.client.post( + "/api/v1/consultationbed/", + { + "consultation": self.consultation.external_id, + "bed": self.bed1.external_id, + "start_date": timezone.now(), + "assets": [self.asset1.external_id, self.asset2.external_id], + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"non_field_errors": ["These set of bed and assets are already assigned"]}, + ) + + def test_start_date_before_end_date(self): + response = self.client.post( + "/api/v1/consultationbed/", + { + "consultation": self.consultation.external_id, + "bed": self.bed1.external_id, + "start_date": timezone.now(), + "end_date": timezone.now() - timedelta(days=1), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"end_date": ["End date cannot be before the start date"]}, + ) + + def test_start_date_before_consultation_admission_date(self): + self.consultation.admission_date = timezone.now() + self.consultation.save() + response = self.client.post( + "/api/v1/consultationbed/", + { + "consultation": self.consultation.external_id, + "bed": self.bed1.external_id, + "start_date": timezone.now() - timedelta(days=1), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"start_date": ["Start date cannot be before the admission date"]}, + ) + + def test_start_date_before_previous_bed_start_date(self): + self.client.post( + "/api/v1/consultationbed/", + { + "consultation": self.consultation.external_id, + "bed": self.bed1.external_id, + "start_date": timezone.now(), + }, + ) + response = self.client.post( + "/api/v1/consultationbed/", + { + "consultation": self.consultation.external_id, + "bed": self.bed2.external_id, + "start_date": timezone.now() - timedelta(days=1), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"start_date": ["Start date cannot be before previous bed start date"]}, + ) + + def test_overlap_caused_by_setting_current_bed_end_date_from_current_start_date( + self, + ): + self.client.post( + "/api/v1/consultationbed/", + { + "consultation": self.consultation.external_id, + "bed": self.bed1.external_id, + "start_date": timezone.now(), + "end_date": timezone.now() + timedelta(days=1), + }, + ) + + consultation2 = self.create_consultation(self.patient, self.facility) + self.client.post( + "/api/v1/consultationbed/", + { + "consultation": consultation2.external_id, + "bed": self.bed1.external_id, + "start_date": timezone.now() - timedelta(days=2), + }, + ) + response = self.client.post( + "/api/v1/consultationbed/", + { + "consultation": consultation2.external_id, + "bed": self.bed2.external_id, + "start_date": timezone.now(), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"start_date": ["Cannot create conflicting entry"]}, + ) + + def test_consultation_bed_conflicting_shift(self): + ConsultationBed.objects.create( + consultation=self.consultation, + bed=self.bed1, + start_date=timezone.now(), + end_date=timezone.now() + timedelta(days=1), + ) + + patient2 = self.create_patient(self.district, self.facility) + consultation2 = self.create_consultation(patient2, self.facility) + response = self.client.post( + "/api/v1/consultationbed/", + { + "consultation": consultation2.external_id, + "bed": self.bed1.external_id, + "start_date": timezone.now(), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"start_date": ["Cannot create conflicting entry"]}, + ) + + def test_consultation_bed_conflicting_shift_with_end_date(self): + ConsultationBed.objects.create( + consultation=self.consultation, + bed=self.bed1, + start_date=timezone.now(), + end_date=timezone.now() + timedelta(days=1), + ) + + patient2 = self.create_patient(self.district, self.facility) + consultation2 = self.create_consultation(patient2, self.facility) + response = self.client.post( + "/api/v1/consultationbed/", + { + "consultation": consultation2.external_id, + "bed": self.bed1.external_id, + "start_date": timezone.now() - timedelta(days=1), + "end_date": timezone.now(), + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"end_date": ["Cannot create conflicting entry"]}, + ) + + def test_update_end_date(self): + start_time = timezone.now() + end_time = timezone.now() + timedelta(seconds=10) + response = self.client.post( + "/api/v1/consultationbed/", + { + "consultation": self.consultation.external_id, + "bed": self.bed1.external_id, + "start_date": start_time, + }, + ) + response = self.client.patch( + f"/api/v1/consultationbed/{response.json()['id']}/", + { + "consultation": self.consultation.external_id, + "bed": self.bed1.external_id, + "start_date": start_time, + "end_date": end_time, + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + datetime.strptime(response.json()["end_date"], "%Y-%m-%dT%H:%M:%S.%f%z"), + end_time, + ) + + def test_link_asset_to_consultation_bed(self): + response = self.client.post( + "/api/v1/consultationbed/", + { + "consultation": self.consultation.external_id, + "bed": self.bed1.external_id, + "start_date": timezone.now(), + "assets": [self.asset1.external_id, self.asset2.external_id], + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(ConsultationBedAsset.objects.count(), 2) + + def test_link_asset_to_consultation_bed_with_asset_already_in_use(self): + self.client.post( + "/api/v1/consultationbed/", + { + "consultation": self.consultation.external_id, + "bed": self.bed1.external_id, + "start_date": timezone.now(), + "assets": [self.asset1.external_id, self.asset2.external_id], + }, + ) + consultation2 = self.create_consultation(self.patient, self.facility) + response = self.client.post( + "/api/v1/consultationbed/", + { + "consultation": consultation2.external_id, + "bed": self.bed2.external_id, + "start_date": timezone.now(), + "assets": [self.asset1.external_id, self.asset3.external_id], + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/care/facility/tests/test_consultation_bed_asset_api.py b/care/facility/tests/test_consultation_bed_asset_api.py deleted file mode 100644 index 7539ff0f16..0000000000 --- a/care/facility/tests/test_consultation_bed_asset_api.py +++ /dev/null @@ -1,78 +0,0 @@ -from datetime import datetime - -from rest_framework import status -from rest_framework.test import APITestCase - -from care.facility.models import Asset, Bed, ConsultationBedAsset -from care.utils.tests.test_utils import TestUtils - - -class ConsultationBedAssetApiTestCase(TestUtils, APITestCase): - @classmethod - def setUpTestData(cls) -> None: - 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.asset_location = cls.create_asset_location(cls.facility) - cls.asset = cls.create_asset(cls.asset_location) - cls.user = cls.create_user("staff", cls.district, home_facility=cls.facility) - cls.patient = cls.create_patient( - cls.district, cls.facility, local_body=cls.local_body - ) - - def setUp(self) -> None: - super().setUp() - self.bed1 = Bed.objects.create( - name="bed1", location=self.asset_location, facility=self.facility - ) - self.bed2 = Bed.objects.create( - name="bed2", location=self.asset_location, facility=self.facility - ) - self.asset1 = Asset.objects.create( - name="asset1", current_location=self.asset_location - ) - self.asset2 = Asset.objects.create( - name="asset2", current_location=self.asset_location - ) - self.asset3 = Asset.objects.create( - name="asset3", current_location=self.asset_location - ) - - def test_link_asset_to_consultation_bed(self): - consultation = self.create_consultation(self.patient, self.facility) - response = self.client.post( - "/api/v1/consultationbed/", - { - "consultation": consultation.external_id, - "bed": self.bed1.external_id, - "start_date": datetime.now().isoformat(), - "assets": [self.asset1.external_id, self.asset2.external_id], - }, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(ConsultationBedAsset.objects.count(), 2) - - def test_link_asset_to_consultation_bed_with_asset_already_in_use(self): - consultation = self.create_consultation(self.patient, self.facility) - self.client.post( - "/api/v1/consultationbed/", - { - "consultation": consultation.external_id, - "bed": self.bed1.external_id, - "start_date": datetime.now().isoformat(), - "assets": [self.asset1.external_id, self.asset2.external_id], - }, - ) - consultation2 = self.create_consultation(self.patient, self.facility) - response = self.client.post( - "/api/v1/consultationbed/", - { - "consultation": consultation2.external_id, - "bed": self.bed2.external_id, - "start_date": datetime.now().isoformat(), - "assets": [self.asset1.external_id, self.asset3.external_id], - }, - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)