diff --git a/care/facility/api/serializers/patient.py b/care/facility/api/serializers/patient.py index ac70f90052..c4059c2577 100644 --- a/care/facility/api/serializers/patient.py +++ b/care/facility/api/serializers/patient.py @@ -29,6 +29,7 @@ ) from care.facility.models.bed import ConsultationBed from care.facility.models.notification import Notification +from care.facility.models.patient import PatientNotesEdit from care.facility.models.patient_base import ( BLOOD_GROUP_CHOICES, DISEASE_STATUS_CHOICES, @@ -475,9 +476,20 @@ def update(self, instance, validated_data): return instance +class PatientNotesEditSerializer(serializers.ModelSerializer): + edited_by = UserBaseMinimumSerializer(read_only=True) + + class Meta: + model = PatientNotesEdit + exclude = ("patient_note",) + + class PatientNotesSerializer(serializers.ModelSerializer): + id = serializers.CharField(source="external_id", read_only=True) facility = FacilityBasicInfoSerializer(read_only=True) created_by_object = UserBaseMinimumSerializer(source="created_by", read_only=True) + last_edited_by = serializers.CharField(read_only=True) + last_edited_date = serializers.DateTimeField(read_only=True) consultation = ExternalIdSerializerField( queryset=PatientConsultation.objects.all(), required=False, @@ -503,16 +515,56 @@ 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 - return super().create(validated_data) + user = self.context["request"].user + note = validated_data.get("note") + with transaction.atomic(): + instance = super().create(validated_data) + initial_edit = PatientNotesEdit( + patient_note=instance, + edited_date=instance.modified_date, + edited_by=user, + note=note, + ) + initial_edit.save() + + return instance + + def update(self, instance, validated_data): + user = self.context["request"].user + note = validated_data.get("note") + + if note == instance.note: + return instance + + with transaction.atomic(): + instance = super().update(instance, validated_data) + edit = PatientNotesEdit( + patient_note=instance, + edited_date=instance.modified_date, + edited_by=user, + note=note, + ) + edit.save() + return instance class Meta: model = PatientNotes fields = ( + "id", "note", "facility", "consultation", "created_by_object", "user_type", "created_date", + "modified_date", + "last_edited_by", + "last_edited_date", + ) + read_only_fields = ( + "id", + "created_date", + "modified_date", + "last_edited_by", + "last_edited_date", ) - read_only_fields = ("created_date",) diff --git a/care/facility/api/viewsets/patient.py b/care/facility/api/viewsets/patient.py index bacb81e8c9..9ab6b18848 100644 --- a/care/facility/api/viewsets/patient.py +++ b/care/facility/api/viewsets/patient.py @@ -5,8 +5,7 @@ from django.conf import settings from django.contrib.postgres.search import TrigramSimilarity from django.db import models -from django.db.models import Case, When -from django.db.models.query_utils import Q +from django.db.models import Case, OuterRef, Q, Subquery, When from django_filters import rest_framework as filters from djqscsv import render_to_csv_response from drf_spectacular.utils import extend_schema, extend_schema_view @@ -17,7 +16,12 @@ from rest_framework.exceptions import ValidationError from rest_framework.filters import BaseFilterBackend from rest_framework.generics import get_object_or_404 -from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin +from rest_framework.mixins import ( + CreateModelMixin, + ListModelMixin, + RetrieveModelMixin, + UpdateModelMixin, +) from rest_framework.pagination import PageNumberPagination from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -27,6 +31,7 @@ FacilityPatientStatsHistorySerializer, PatientDetailSerializer, PatientListSerializer, + PatientNotesEditSerializer, PatientNotesSerializer, PatientSearchSerializer, PatientTransferSerializer, @@ -53,6 +58,7 @@ ConditionVerificationStatus, ) from care.facility.models.notification import Notification +from care.facility.models.patient import PatientNotesEdit from care.facility.models.patient_base import ( DISEASE_STATUS_DICT, NewDischargeReasonEnum, @@ -668,14 +674,57 @@ class PatientNotesFilterSet(filters.FilterSet): consultation = filters.CharFilter(field_name="consultation__external_id") +class PatientNotesEditViewSet( + ListModelMixin, + RetrieveModelMixin, + GenericViewSet, +): + queryset = PatientNotesEdit.objects.all().order_by("-edited_date") + lookup_field = "external_id" + serializer_class = PatientNotesEditSerializer + permission_classes = (IsAuthenticated,) + + def get_queryset(self): + user = self.request.user + + queryset = self.queryset.filter( + patient_note__external_id=self.kwargs.get("notes_external_id") + ) + + if user.is_superuser: + return queryset + if user.user_type >= User.TYPE_VALUE_MAP["StateLabAdmin"]: + queryset = queryset.filter( + patient_note__patient__facility__state=user.state + ) + elif user.user_type >= User.TYPE_VALUE_MAP["DistrictLabAdmin"]: + queryset = queryset.filter( + patient_note__patient__facility__district=user.district + ) + else: + allowed_facilities = get_accessible_facilities(user) + q_filters = Q(patient_note__patient__facility__id__in=allowed_facilities) + q_filters |= Q(patient_note__patient__last_consultation__assigned_to=user) + q_filters |= Q(patient_note__patient__assigned_to=user) + q_filters |= Q(patient_note__created_by=user) + queryset = queryset.filter(q_filters) + + return queryset + + class PatientNotesViewSet( - ListModelMixin, RetrieveModelMixin, CreateModelMixin, GenericViewSet + ListModelMixin, + RetrieveModelMixin, + CreateModelMixin, + UpdateModelMixin, + GenericViewSet, ): queryset = ( PatientNotes.objects.all() .select_related("facility", "patient", "created_by") .order_by("-created_date") ) + lookup_field = "external_id" serializer_class = PatientNotesSerializer permission_classes = (IsAuthenticated, DRYPermissions) filter_backends = (filters.DjangoFilterBackend,) @@ -683,10 +732,21 @@ class PatientNotesViewSet( def get_queryset(self): user = self.request.user + + last_edit_subquery = PatientNotesEdit.objects.filter( + patient_note=OuterRef("pk") + ).order_by("-edited_date") + queryset = self.queryset.filter( patient__external_id=self.kwargs.get("patient_external_id") + ).annotate( + last_edited_by=Subquery( + last_edit_subquery.values("edited_by__username")[:1] + ), + last_edited_date=Subquery(last_edit_subquery.values("edited_date")[:1]), ) - if not user.is_superuser: + + if user.is_superuser: return queryset if user.user_type >= User.TYPE_VALUE_MAP["StateLabAdmin"]: queryset = queryset.filter(patient__facility__state=user.state) @@ -697,6 +757,7 @@ def get_queryset(self): q_filters = Q(patient__facility__id__in=allowed_facilities) q_filters |= Q(patient__last_consultation__assigned_to=user) q_filters |= Q(patient__assigned_to=user) + q_filters |= Q(created_by=user) queryset = queryset.filter(q_filters) return queryset @@ -709,7 +770,7 @@ def perform_create(self, serializer): ) if not patient.is_active: raise ValidationError( - {"patient": "Only active patients data can be updated"} + {"patient": "Updating patient data is only allowed for active patients"} ) instance = serializer.save( @@ -743,3 +804,23 @@ def perform_create(self, serializer): ).generate() return instance + + def perform_update(self, serializer): + user = self.request.user + patient = get_object_or_404( + get_patient_notes_queryset(self.request.user).filter( + external_id=self.kwargs.get("patient_external_id") + ) + ) + + if not patient.is_active: + raise ValidationError( + {"patient": "Updating patient data is only allowed for active patients"} + ) + + if serializer.instance.created_by != user: + raise ValidationError( + {"Note": "Only the user who created the note can edit it"} + ) + + return super().perform_update(serializer) diff --git a/care/facility/migrations/0408_patientnotesedit.py b/care/facility/migrations/0408_patientnotesedit.py new file mode 100644 index 0000000000..b3743adb55 --- /dev/null +++ b/care/facility/migrations/0408_patientnotesedit.py @@ -0,0 +1,76 @@ +# Generated by Django 4.2.2 on 2023-08-28 14:09 + +import django.db.models.deletion +from django.conf import settings +from django.core.paginator import Paginator +from django.db import migrations, models + + +def create_initial_patient_notes_edit_record(apps, schema_editor): + PatientNotes = apps.get_model("facility", "PatientNotes") + PatientNotesEdit = apps.get_model("facility", "PatientNotesEdit") + + notes_without_edits = PatientNotes.objects.all() + + paginator = Paginator(notes_without_edits, 1000) + for page_number in paginator.page_range: + edit_records = [] + for patient_note in paginator.page(page_number).object_list: + edit_record = PatientNotesEdit( + patient_note=patient_note, + edited_date=patient_note.created_date, + edited_by=patient_note.created_by, + note=patient_note.note, + ) + + edit_records.append(edit_record) + + PatientNotesEdit.objects.bulk_create(edit_records) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("facility", "0407_alter_dailyround_additional_symptoms_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="PatientNotesEdit", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("edited_date", models.DateTimeField(auto_now_add=True)), + ("note", models.TextField()), + ( + "edited_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "patient_note", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="edits", + to="facility.patientnotes", + ), + ), + ], + options={ + "ordering": ["-edited_date"], + }, + ), + migrations.RunPython( + code=create_initial_patient_notes_edit_record, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/care/facility/models/patient.py b/care/facility/models/patient.py index 9078392e28..b967a6e956 100644 --- a/care/facility/models/patient.py +++ b/care/facility/models/patient.py @@ -740,3 +740,22 @@ def get_related_consultation(self): # and hence the permission mixin will fail if edit/object_read permissions are checked (although not used as of now) # Remove once patient notes is made consultation specific. return self + + +class PatientNotesEdit(models.Model): + patient_note = models.ForeignKey( + PatientNotes, + on_delete=models.CASCADE, + null=False, + blank=False, + related_name="edits", + ) + edited_date = models.DateTimeField(auto_now_add=True) + edited_by = models.ForeignKey( + User, on_delete=models.PROTECT, null=False, blank=False + ) + + note = models.TextField() + + class Meta: + ordering = ["-edited_date"] diff --git a/care/facility/tests/test_patient_api.py b/care/facility/tests/test_patient_api.py index 633f5ede75..c3621e66eb 100644 --- a/care/facility/tests/test_patient_api.py +++ b/care/facility/tests/test_patient_api.py @@ -13,11 +13,15 @@ class ExpectedPatientNoteKeys(Enum): + ID = "id" NOTE = "note" FACILITY = "facility" CONSULTATION = "consultation" CREATED_BY_OBJECT = "created_by_object" CREATED_DATE = "created_date" + MODIFIED_DATE = "modified_date" + LAST_EDITED_BY = "last_edited_by" + LAST_EDITED_DATE = "last_edited_date" USER_TYPE = "user_type" @@ -122,7 +126,7 @@ def setUp(self): ) def create_patient_note( - self, patient=None, note="Patient is doing find", created_by=None, **kwargs + self, patient=None, note="Patient is doing fine", created_by=None, **kwargs ): data = { "facility": patient.facility or self.facility, @@ -133,6 +137,7 @@ def create_patient_note( self.client.post(f"/api/v1/patient/{patient.external_id}/notes/", data=data) def test_patient_notes(self): + self.client.force_authenticate(user=self.state_admin) patientId = self.patient.external_id response = self.client.get( f"/api/v1/patient/{patientId}/notes/?consultation={self.consultation.external_id}" @@ -220,6 +225,55 @@ def test_patient_notes(self): [item.value for item in ExpectedCreatedByObjectKeys], ) + def test_patient_note_edit(self): + patientId = self.patient.external_id + notes_list_response = self.client.get( + f"/api/v1/patient/{patientId}/notes/?consultation={self.consultation.external_id}" + ) + note_data = notes_list_response.json()["results"][0] + response = self.client.get( + f"/api/v1/patient/{patientId}/notes/{note_data['id']}/edits/" + ) + + data = response.json()["results"] + self.assertEqual(len(data), 1) + + note_content = note_data["note"] + new_note_content = note_content + " edited" + + # Test with a different user editing the note than the one who created it + self.client.force_authenticate(user=self.state_admin) + response = self.client.put( + f"/api/v1/patient/{patientId}/notes/{note_data['id']}/", + {"note": new_note_content}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json()["Note"], "Only the user who created the note can edit it" + ) + + # Test with the same user editing the note + self.client.force_authenticate(user=self.user2) + response = self.client.put( + f"/api/v1/patient/{patientId}/notes/{note_data['id']}/", + {"note": new_note_content}, + ) + + updated_data = response.json() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(updated_data["note"], new_note_content) + + # Ensure the original note is still present in the edits + response = self.client.get( + f"/api/v1/patient/{patientId}/notes/{note_data['id']}/edits/" + ) + + data = response.json()["results"] + self.assertEqual(len(data), 2) + self.assertEqual(data[0]["note"], new_note_content) + self.assertEqual(data[1]["note"], note_content) + class PatientFilterTestCase(TestUtils, APITestCase): @classmethod diff --git a/config/api_router.py b/config/api_router.py index c2a77e8d87..163a8ebd73 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -44,6 +44,7 @@ from care.facility.api.viewsets.notification import NotificationViewSet from care.facility.api.viewsets.patient import ( FacilityPatientStatsHistoryViewSet, + PatientNotesEditViewSet, PatientNotesViewSet, PatientSearchViewSet, PatientViewSet, @@ -201,6 +202,10 @@ patient_nested_router.register(r"test_sample", PatientSampleViewSet) patient_nested_router.register(r"investigation", PatientInvestigationSummaryViewSet) patient_nested_router.register(r"notes", PatientNotesViewSet) +patient_notes_nested_router = NestedSimpleRouter( + patient_nested_router, r"notes", lookup="notes" +) +patient_notes_nested_router.register(r"edits", PatientNotesEditViewSet) patient_nested_router.register(r"abha", AbhaViewSet) consultation_nested_router = NestedSimpleRouter( @@ -240,6 +245,7 @@ path("", include(facility_location_nested_router.urls)), path("", include(asset_nested_router.urls)), path("", include(patient_nested_router.urls)), + path("", include(patient_notes_nested_router.urls)), path("", include(consultation_nested_router.urls)), path("", include(resource_nested_router.urls)), path("", include(shifting_nested_router.urls)),