diff --git a/care/facility/api/serializers/patient.py b/care/facility/api/serializers/patient.py index 32e378dc9a..b3a88b21e3 100644 --- a/care/facility/api/serializers/patient.py +++ b/care/facility/api/serializers/patient.py @@ -500,6 +500,21 @@ class Meta: exclude = ("patient_note",) +class ReplyToPatientNoteSerializer(serializers.ModelSerializer): + id = serializers.CharField(source="external_id", read_only=True) + created_by_object = UserBaseMinimumSerializer(source="created_by", read_only=True) + + class Meta: + model = PatientNotes + fields = ( + "id", + "created_by_object", + "created_date", + "user_type", + "note", + ) + + class PatientNotesSerializer(serializers.ModelSerializer): id = serializers.CharField(source="external_id", read_only=True) facility = FacilityBasicInfoSerializer(read_only=True) @@ -515,6 +530,13 @@ class PatientNotesSerializer(serializers.ModelSerializer): thread = serializers.ChoiceField( choices=PatientNoteThreadChoices, required=False, allow_null=False ) + reply_to = ExternalIdSerializerField( + queryset=PatientNotes.objects.all(), + write_only=True, + required=False, + allow_null=True, + ) + reply_to_object = ReplyToPatientNoteSerializer(source="reply_to", read_only=True) def validate_empty_values(self, data): if not data.get("note", "").strip(): @@ -524,6 +546,10 @@ def validate_empty_values(self, data): def create(self, validated_data): if "thread" not in validated_data: raise serializers.ValidationError({"thread": "This field is required"}) + if "consultation" not in validated_data: + raise serializers.ValidationError( + {"consultation": "This field is required"} + ) user_type = User.REVERSE_TYPE_MAP[validated_data["created_by"].user_type] # If the user is a doctor and the note is being created in the home facility # then the user type is doctor else it is a remote specialist @@ -536,6 +562,17 @@ def create(self, validated_data): # If the user is not a doctor then the user type is the same as the user type validated_data["user_type"] = user_type + if validated_data.get("reply_to"): + reply_to_note = validated_data["reply_to"] + if reply_to_note.thread != validated_data["thread"]: + raise serializers.ValidationError( + "Reply to note should be in the same thread" + ) + if reply_to_note.consultation != validated_data.get("consultation"): + raise serializers.ValidationError( + "Reply to note should be in the same consultation" + ) + user = self.context["request"].user note = validated_data.get("note") with transaction.atomic(): @@ -584,6 +621,8 @@ class Meta: "modified_date", "last_edited_by", "last_edited_date", + "reply_to", + "reply_to_object", ) read_only_fields = ( "id", diff --git a/care/facility/api/viewsets/patient.py b/care/facility/api/viewsets/patient.py index a820331a92..4953ef54af 100644 --- a/care/facility/api/viewsets/patient.py +++ b/care/facility/api/viewsets/patient.py @@ -941,7 +941,9 @@ class PatientNotesViewSet( ): queryset = ( PatientNotes.objects.all() - .select_related("facility", "patient", "created_by") + .select_related( + "facility", "patient", "created_by", "reply_to", "reply_to__created_by" + ) .order_by("-created_date") ) lookup_field = "external_id" diff --git a/care/facility/migrations/0463_patientnotes_reply_to.py b/care/facility/migrations/0463_patientnotes_reply_to.py new file mode 100644 index 0000000000..7b44acd71a --- /dev/null +++ b/care/facility/migrations/0463_patientnotes_reply_to.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.1 on 2024-09-22 17:36 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("facility", "0462_facilityhubspoke"), + ] + + operations = [ + migrations.AddField( + model_name="patientnotes", + name="reply_to", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="replies", + to="facility.patientnotes", + ), + ), + ] diff --git a/care/facility/models/patient.py b/care/facility/models/patient.py index 6d328414ff..f635a773d4 100644 --- a/care/facility/models/patient.py +++ b/care/facility/models/patient.py @@ -799,6 +799,13 @@ class PatientNotes(FacilityBaseModel, ConsultationRelatedPermissionMixin): db_index=True, default=PatientNoteThreadChoices.DOCTORS, ) + reply_to = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="replies", + ) note = models.TextField(default="", blank=True) def get_related_consultation(self): diff --git a/care/facility/tests/test_patient_api.py b/care/facility/tests/test_patient_api.py index d4ea975c2f..c243c7a10b 100644 --- a/care/facility/tests/test_patient_api.py +++ b/care/facility/tests/test_patient_api.py @@ -27,6 +27,7 @@ class ExpectedPatientNoteKeys(Enum): LAST_EDITED_DATE = "last_edited_date" THREAD = "thread" USER_TYPE = "user_type" + REPLY_TO_OBJECT = "reply_to_object" class ExpectedFacilityKeys(Enum): @@ -234,6 +235,48 @@ def test_patient_notes(self): [item.value for item in ExpectedCreatedByObjectKeys], ) + def test_patient_note_with_reply(self): + patient = self.patient + note = "How is the patient" + created_by = self.user + + data = { + "facility": patient.facility or self.facility, + "note": note, + "thread": PatientNoteThreadChoices.DOCTORS, + } + self.client.force_authenticate(user=created_by) + response = self.client.post( + f"/api/v1/patient/{patient.external_id}/notes/", data=data + ) + reply_data = { + "facility": patient.facility or self.facility, + "note": "Patient is doing fine", + "thread": PatientNoteThreadChoices.DOCTORS, + "reply_to": response.json()["id"], + } + reply_response = self.client.post( + f"/api/v1/patient/{patient.external_id}/notes/", data=reply_data + ) + + # Ensure the reply is created successfully + self.assertEqual(reply_response.status_code, status.HTTP_201_CREATED) + + # Ensure the reply is posted on same thread + self.assertEqual(reply_response.json()["thread"], response.json()["thread"]) + + # Posting reply in other thread should fail + reply_response = self.client.post( + f"/api/v1/patient/{patient.external_id}/notes/", + { + "facility": patient.facility or self.facility, + "note": "Patient is doing fine", + "thread": PatientNoteThreadChoices.NURSES, + "reply_to": response.json()["id"], + }, + ) + self.assertEqual(reply_response.status_code, status.HTTP_400_BAD_REQUEST) + def test_patient_note_edit(self): patientId = self.patient.external_id notes_list_response = self.client.get(