From 1e962ec73f8693d17bfcd311547daa1c3b59227f Mon Sep 17 00:00:00 2001 From: Shivank Kacker Date: Thu, 6 Jun 2024 15:52:38 +0530 Subject: [PATCH 1/4] Add Consent Model (#2209) * added migrations * updates * updates * v-comparision * fixing files * complete? * added tests * fixed test * made changes * updated migrations and used bulk updates * Update care/facility/api/serializers/patient_consultation.py * fixed * fixed permissions and router * fixes * cleanup queryset * rebase migrations * allow only home facility users to create or update consent * fixes * add is_migrated field * fix permission * remove types from migrations --------- Co-authored-by: Aakash Singh --- care/facility/admin.py | 8 + care/facility/api/serializers/file_upload.py | 12 +- .../api/serializers/patient_consultation.py | 113 +++++++++- care/facility/api/viewsets/file_upload.py | 16 +- .../api/viewsets/patient_consultation.py | 44 +++- ...ntconsultation_consent_records_and_more.py | 200 ++++++++++++++++++ care/facility/models/file_upload.py | 3 + care/facility/models/patient_consultation.py | 127 ++++++++++- care/facility/tests/test_file_upload.py | 26 +-- .../tests/test_patient_consents_api.py | 155 ++++++++++++++ care/utils/tests/test_utils.py | 20 ++ config/api_router.py | 9 +- 12 files changed, 693 insertions(+), 40 deletions(-) create mode 100644 care/facility/migrations/0443_remove_patientconsultation_consent_records_and_more.py create mode 100644 care/facility/tests/test_patient_consents_api.py diff --git a/care/facility/admin.py b/care/facility/admin.py index c25684e243..e48c3a6c00 100644 --- a/care/facility/admin.py +++ b/care/facility/admin.py @@ -6,6 +6,11 @@ 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.file_upload import FileUpload +from care.facility.models.patient_consultation import ( + PatientConsent, + PatientConsultation, +) from care.facility.models.patient_sample import PatientSample from .models import ( @@ -209,3 +214,6 @@ class FacilityUserAdmin(DjangoQLSearchMixin, admin.ModelAdmin, ExportCsvMixin): admin.site.register(AssetBed) admin.site.register(Asset) admin.site.register(Bed) +admin.site.register(PatientConsent) +admin.site.register(FileUpload) +admin.site.register(PatientConsultation) diff --git a/care/facility/api/serializers/file_upload.py b/care/facility/api/serializers/file_upload.py index e991cf045a..399d7862ce 100644 --- a/care/facility/api/serializers/file_upload.py +++ b/care/facility/api/serializers/file_upload.py @@ -7,7 +7,10 @@ from care.facility.models.facility import Facility from care.facility.models.file_upload import FileUpload from care.facility.models.patient import PatientRegistration -from care.facility.models.patient_consultation import PatientConsultation +from care.facility.models.patient_consultation import ( + PatientConsent, + PatientConsultation, +) from care.facility.models.patient_sample import PatientSample from care.users.api.serializers.user import UserBaseMinimumSerializer from care.users.models import User @@ -53,9 +56,9 @@ def check_permissions(file_type, associating_id, user, action="create"): raise Exception("No Permission") return consultation.id elif file_type == FileUpload.FileType.CONSENT_RECORD.value: - consultation = PatientConsultation.objects.get( - consent_records__contains=[{"id": associating_id}] - ) + consultation = PatientConsent.objects.get( + external_id=associating_id + ).consultation if consultation.discharge_date and not action == "read": raise serializers.ValidationError( { @@ -173,6 +176,7 @@ class Meta: fields = ( "id", "name", + "associating_id", "uploaded_by", "archived_by", "archived_datetime", diff --git a/care/facility/api/serializers/patient_consultation.py b/care/facility/api/serializers/patient_consultation.py index 109c21b8bb..6c0e132e16 100644 --- a/care/facility/api/serializers/patient_consultation.py +++ b/care/facility/api/serializers/patient_consultation.py @@ -3,6 +3,7 @@ from django.conf import settings from django.db import transaction +from django.utils import timezone from django.utils.timezone import localtime, make_aware, now from rest_framework import serializers from rest_framework.exceptions import ValidationError @@ -41,6 +42,7 @@ EncounterSymptom, Symptom, ) +from care.facility.models.file_upload import FileUpload from care.facility.models.icd11_diagnosis import ( ConditionVerificationStatus, ConsultationDiagnosis, @@ -51,7 +53,11 @@ RouteToFacility, SuggestionChoices, ) -from care.facility.models.patient_consultation import PatientConsultation +from care.facility.models.patient_consultation import ( + ConsentType, + PatientConsent, + PatientConsultation, +) from care.users.api.serializers.user import ( UserAssignedSerializer, UserBaseMinimumSerializer, @@ -848,3 +854,108 @@ def validate(self, attrs): class Meta: model = PatientConsultation fields = ("email",) + + +class PatientConsentSerializer(serializers.ModelSerializer): + id = serializers.CharField(source="external_id", read_only=True) + created_by = UserBaseMinimumSerializer(read_only=True) + archived_by = UserBaseMinimumSerializer(read_only=True) + + class Meta: + model = PatientConsent + + fields = ( + "id", + "type", + "patient_code_status", + "archived", + "archived_by", + "archived_date", + "created_by", + "created_date", + ) + + read_only_fields = ( + "id", + "created_by", + "created_date", + "archived", + "archived_by", + "archived_date", + ) + + def validate(self, attrs): + user = self.context["request"].user + if ( + user.user_type < User.TYPE_VALUE_MAP["DistrictAdmin"] + and self.context["consultation"].facility_id != user.home_facility_id + ): + raise ValidationError( + "Only Home Facility Staff can create consent for a Consultation" + ) + + if attrs.get("type") == ConsentType.PATIENT_CODE_STATUS and not attrs.get( + "patient_code_status" + ): + raise ValidationError( + { + "patient_code_status": [ + "This field is required for Patient Code Status Consent" + ] + } + ) + + if attrs.get("type") != ConsentType.PATIENT_CODE_STATUS and attrs.get( + "patient_code_status" + ): + raise ValidationError( + { + "patient_code_status": [ + "This field is not required for this type of Consent" + ] + } + ) + return attrs + + def clear_existing_records(self, consultation, type, user, self_id=None): + consents = PatientConsent.objects.filter( + consultation=consultation, type=type + ).exclude(id=self_id) + + archived_date = timezone.now() + consents.update( + archived=True, + archived_by=user, + archived_date=archived_date, + ) + FileUpload.objects.filter( + associating_id__in=list(consents.values_list("external_id", flat=True)), + file_type=FileUpload.FileType.CONSENT_RECORD, + is_archived=False, + ).update( + is_archived=True, + archived_datetime=archived_date, + archive_reason="Consent Archived", + archived_by=user, + ) + + def create(self, validated_data): + with transaction.atomic(): + self.clear_existing_records( + consultation=self.context["consultation"], + type=validated_data["type"], + user=self.context["request"].user, + ) + validated_data["consultation"] = self.context["consultation"] + validated_data["created_by"] = self.context["request"].user + return super().create(validated_data) + + def update(self, instance, validated_data): + with transaction.atomic(): + self.clear_existing_records( + consultation=instance.consultation, + type=instance.type, + user=self.context["request"].user, + self_id=instance.id, + ) + return super().update(instance, validated_data) diff --git a/care/facility/api/viewsets/file_upload.py b/care/facility/api/viewsets/file_upload.py index 666eac03c4..2f9ad882c5 100644 --- a/care/facility/api/viewsets/file_upload.py +++ b/care/facility/api/viewsets/file_upload.py @@ -83,13 +83,19 @@ def get_queryset(self): {"associating_id": "associating_id missing in request params"} ) file_type = self.request.GET["file_type"] - associating_id = self.request.GET["associating_id"] + associating_ids = self.request.GET["associating_id"].split(",") if file_type not in FileUpload.FileType.__members__: raise ValidationError({"file_type": "invalid file type"}) file_type = FileUpload.FileType[file_type].value - associating_internal_id = check_permissions( - file_type, associating_id, self.request.user, "read" - ) + + associating_internal_ids = [] + + for associating_id in associating_ids: + associating_internal_id = check_permissions( + file_type, associating_id, self.request.user, "read" + ) + associating_internal_ids.append(associating_internal_id) + return self.queryset.filter( - file_type=file_type, associating_id=associating_internal_id + file_type=file_type, associating_id__in=associating_internal_ids ) diff --git a/care/facility/api/viewsets/patient_consultation.py b/care/facility/api/viewsets/patient_consultation.py index 4a31f6354e..4fc1b857b2 100644 --- a/care/facility/api/viewsets/patient_consultation.py +++ b/care/facility/api/viewsets/patient_consultation.py @@ -1,6 +1,6 @@ from django.db.models import Prefetch from django.db.models.query_utils import Q -from django.shortcuts import render +from django.shortcuts import get_object_or_404, render from django_filters import rest_framework as filters from drf_spectacular.utils import extend_schema from dry_rest_permissions.generics import DRYPermissions @@ -14,6 +14,7 @@ from care.facility.api.serializers.file_upload import FileUploadRetrieveSerializer from care.facility.api.serializers.patient_consultation import ( EmailDischargeSummarySerializer, + PatientConsentSerializer, PatientConsultationDischargeSerializer, PatientConsultationIDSerializer, PatientConsultationSerializer, @@ -22,7 +23,10 @@ from care.facility.models.bed import AssetBed, ConsultationBed from care.facility.models.file_upload import FileUpload from care.facility.models.mixins.permissions.asset import IsAssetUser -from care.facility.models.patient_consultation import PatientConsultation +from care.facility.models.patient_consultation import ( + PatientConsent, + PatientConsultation, +) from care.facility.tasks.discharge_summary import ( email_discharge_summary_task, generate_discharge_summary_task, @@ -30,6 +34,7 @@ from care.facility.utils.reports import discharge_summary from care.users.models import Skill, User from care.utils.cache.cache_allowed_facilities import get_accessible_facilities +from care.utils.queryset.consultation import get_consultation_queryset class PatientConsultationFilter(filters.FilterSet): @@ -287,3 +292,38 @@ def dev_preview_discharge_summary(request, consultation_id): raise NotFound({"detail": "Consultation not found"}) data = discharge_summary.get_discharge_summary_data(consultation) return render(request, "reports/patient_discharge_summary_pdf.html", data) + + +class PatientConsentViewSet( + AssetUserAccessMixin, + mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + GenericViewSet, +): + lookup_field = "external_id" + serializer_class = PatientConsentSerializer + permission_classes = ( + IsAuthenticated, + DRYPermissions, + ) + queryset = PatientConsent.objects.all().select_related("consultation") + filter_backends = (filters.DjangoFilterBackend,) + + filterset_fields = ("archived",) + + def get_consultation_obj(self): + return get_object_or_404( + get_consultation_queryset(self.request.user).filter( + external_id=self.kwargs["consultation_external_id"] + ) + ) + + def get_queryset(self): + return self.queryset.filter(consultation=self.get_consultation_obj()) + + def get_serializer_context(self): + data = super().get_serializer_context() + data["consultation"] = self.get_consultation_obj() + return data diff --git a/care/facility/migrations/0443_remove_patientconsultation_consent_records_and_more.py b/care/facility/migrations/0443_remove_patientconsultation_consent_records_and_more.py new file mode 100644 index 0000000000..d6f72ca5ef --- /dev/null +++ b/care/facility/migrations/0443_remove_patientconsultation_consent_records_and_more.py @@ -0,0 +1,200 @@ +# Generated by Django 4.2.10 on 2024-05-30 16:35 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models +from django.utils import timezone + +import care.facility.models.mixins.permissions.patient + + +class Migration(migrations.Migration): + def migrate_consents(apps, schema_editor): + PatientConsultation = apps.get_model("facility", "PatientConsultation") + PatientConsent = apps.get_model("facility", "PatientConsent") + FileUpload = apps.get_model("facility", "FileUpload") + consultations = PatientConsultation.objects.filter( + consent_records__isnull=False + ) + for consultation in consultations: + for consent in consultation.consent_records: + new_consent = PatientConsent.objects.create( + consultation=consultation, + type=consent["type"], + patient_code_status=consent.get("patient_code_status", None), + created_by=consultation.created_by, + archived=consent.get("deleted", False), + is_migrated=True, + ) + + old_id = consent.get("id") + + files = FileUpload.objects.filter( + associating_id=old_id, + file_type=7, + ) + + kwargs = { + "associating_id": new_consent.external_id, + } + + if consent.get("deleted", False): + kwargs = { + **kwargs, + "is_archived": True, + "archived_datetime": timezone.now(), + "archive_reason": "Consent Record Archived", + "archived_by": consultation.created_by, + } + + files.update(**kwargs) + + def reverse_migrate(apps, schema_editor): + PatientConsent = apps.get_model("facility", "PatientConsent") + for consent in PatientConsent.objects.all(): + consultation = consent.consultation + consultation.consent_records.append( + { + "type": consent.type, + "deleted": consent.archived, + "id": str(consent.external_id), + "patient_code_status": consent.patient_code_status, + } + ) + consultation.save() + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ( + "facility", + "0442_remove_patientconsultation_unique_patient_no_within_facility", + ), + ] + + operations = [ + migrations.CreateModel( + name="PatientConsent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "external_id", + models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), + ), + ( + "created_date", + models.DateTimeField(auto_now_add=True, db_index=True, null=True), + ), + ( + "modified_date", + models.DateTimeField(auto_now=True, db_index=True, null=True), + ), + ("deleted", models.BooleanField(db_index=True, default=False)), + ( + "type", + models.IntegerField( + choices=[ + (1, "Consent for Admission"), + (2, "Patient Code Status"), + (3, "Consent for Procedure"), + (4, "High Risk Consent"), + (5, "Others"), + ] + ), + ), + ( + "patient_code_status", + models.IntegerField( + blank=True, + choices=[ + (1, "Do Not Hospitalize"), + (2, "Do Not Resuscitate"), + (3, "Comfort Care Only"), + (4, "Active Treatment"), + ], + null=True, + ), + ), + ("archived", models.BooleanField(default=False)), + ("archived_date", models.DateTimeField(blank=True, null=True)), + ( + "archived_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="archived_consents", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "consultation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="facility.patientconsultation", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="created_consents", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "is_migrated", + models.BooleanField( + default=False, + help_text="This field is to throw caution to data that was previously ported over", + ), + ), + ], + bases=( + models.Model, + care.facility.models.mixins.permissions.patient.ConsultationRelatedPermissionMixin, + ), + ), + migrations.AddConstraint( + model_name="patientconsent", + constraint=models.UniqueConstraint( + condition=models.Q(("archived", False)), + fields=("consultation", "type"), + name="unique_consultation_consent", + ), + ), + migrations.AddConstraint( + model_name="patientconsent", + constraint=models.CheckConstraint( + check=models.Q( + models.Q(("type", 2), _negated=True), + ("patient_code_status__isnull", False), + _connector="OR", + ), + name="patient_code_status_required", + ), + ), + migrations.AddConstraint( + model_name="patientconsent", + constraint=models.CheckConstraint( + check=models.Q( + ("type", 2), ("patient_code_status__isnull", True), _connector="OR" + ), + name="patient_code_status_not_required", + ), + ), + migrations.RunPython(migrate_consents, reverse_code=reverse_migrate), + migrations.RemoveField( + model_name="patientconsultation", + name="consent_records", + ), + ] diff --git a/care/facility/models/file_upload.py b/care/facility/models/file_upload.py index 51bce92d96..5ac205f82c 100644 --- a/care/facility/models/file_upload.py +++ b/care/facility/models/file_upload.py @@ -162,3 +162,6 @@ class FileType(models.IntegerChoices): # TODO: switch to Choices.choices FileTypeChoices = [(x.value, x.name) for x in FileType] FileCategoryChoices = [(x.value, x.name) for x in BaseFileUpload.FileCategory] + + def __str__(self): + return f"{self.FileTypeChoices[self.file_type][1]} - {self.name}{' (Archived)' if self.is_archived else ''}" diff --git a/care/facility/models/patient_consultation.py b/care/facility/models/patient_consultation.py index eaaa0f736c..4db0163f30 100644 --- a/care/facility/models/patient_consultation.py +++ b/care/facility/models/patient_consultation.py @@ -11,7 +11,7 @@ COVID_CATEGORY_CHOICES, PatientBaseModel, ) -from care.facility.models.json_schema.consultation import CONSENT_RECORDS +from care.facility.models.file_upload import FileUpload from care.facility.models.mixins.permissions.patient import ( ConsultationRelatedPermissionMixin, ) @@ -26,7 +26,7 @@ reverse_choices, ) from care.users.models import User -from care.utils.models.validators import JSONFieldSchemaValidator +from care.utils.models.base import BaseModel class PatientConsultation(PatientBaseModel, ConsultationRelatedPermissionMixin): @@ -248,10 +248,6 @@ class PatientConsultation(PatientBaseModel, ConsultationRelatedPermissionMixin): prn_prescription = JSONField(default=dict) discharge_advice = JSONField(default=dict) - consent_records = JSONField( - default=list, validators=[JSONFieldSchemaValidator(CONSENT_RECORDS)] - ) - def get_related_consultation(self): return self @@ -363,6 +359,21 @@ def has_object_generate_discharge_summary_permission(self, request): return self.has_object_read_permission(request) +class ConsentType(models.IntegerChoices): + CONSENT_FOR_ADMISSION = 1, "Consent for Admission" + PATIENT_CODE_STATUS = 2, "Patient Code Status" + CONSENT_FOR_PROCEDURE = 3, "Consent for Procedure" + HIGH_RISK_CONSENT = 4, "High Risk Consent" + OTHERS = 5, "Others" + + +class PatientCodeStatusType(models.IntegerChoices): + DNH = 1, "Do Not Hospitalize" + DNR = 2, "Do Not Resuscitate" + COMFORT_CARE = 3, "Comfort Care Only" + ACTIVE_TREATMENT = 4, "Active Treatment" + + class ConsultationClinician(models.Model): consultation = models.ForeignKey( PatientConsultation, @@ -372,3 +383,107 @@ class ConsultationClinician(models.Model): User, on_delete=models.PROTECT, ) + + +class PatientConsent(BaseModel, ConsultationRelatedPermissionMixin): + consultation = models.ForeignKey(PatientConsultation, on_delete=models.CASCADE) + type = models.IntegerField(choices=ConsentType.choices) + patient_code_status = models.IntegerField( + choices=PatientCodeStatusType.choices, null=True, blank=True + ) + archived = models.BooleanField(default=False) + archived_by = models.ForeignKey( + User, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="archived_consents", + ) + archived_date = models.DateTimeField(null=True, blank=True) + created_by = models.ForeignKey( + User, on_delete=models.PROTECT, related_name="created_consents" + ) + is_migrated = models.BooleanField( + default=False, + help_text="This field is to throw caution to data that was previously ported over", + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["consultation", "type"], + name="unique_consultation_consent", + condition=models.Q(archived=False), + ), + models.CheckConstraint( + name="patient_code_status_required", + check=~models.Q(type=ConsentType.PATIENT_CODE_STATUS) + | models.Q(patient_code_status__isnull=False), + ), + models.CheckConstraint( + name="patient_code_status_not_required", + check=models.Q(type=ConsentType.PATIENT_CODE_STATUS) + | models.Q(patient_code_status__isnull=True), + ), + ] + + def __str__(self) -> str: + return f"{self.consultation.patient.name} - {ConsentType(self.type).label}{' (Archived)' if self.archived else ''}" + + def save(self, *args, **kwargs): + if self.archived: + files = FileUpload.objects.filter( + associating_id=self.external_id, + file_type=FileUpload.FileType.CONSENT_RECORD, + is_archived=False, + ) + files.update( + is_archived=True, + archived_datetime=timezone.now(), + archive_reason="Consent Archived", + archived_by=self.archived_by, + ) + + super().save(*args, **kwargs) + + @staticmethod + def has_write_permission(request): + return request.user.is_superuser or ( + request.user.verified + and ConsultationRelatedPermissionMixin.has_write_permission(request) + ) + + def has_object_read_permission(self, request): + if not super().has_object_read_permission(request): + return False + return ( + request.user.is_superuser + or ( + self.consultation.patient.facility + and request.user in self.consultation.patient.facility.users.all() + ) + or ( + self.consultation.assigned_to == request.user + or request.user == self.consultation.patient.assigned_to + ) + or ( + request.user.user_type >= User.TYPE_VALUE_MAP["DistrictLabAdmin"] + and ( + self.consultation.patient.facility + and request.user.district + == self.consultation.patient.facility.district + ) + ) + or ( + request.user.user_type >= User.TYPE_VALUE_MAP["StateLabAdmin"] + and ( + self.consultation.patient.facility + and request.user.state == self.consultation.patient.facility.state + ) + ) + ) + + def has_object_update_permission(self, request): + return super().has_object_update_permission( + request + ) and self.has_object_read_permission(request) diff --git a/care/facility/tests/test_file_upload.py b/care/facility/tests/test_file_upload.py index 45a65e1f79..bfbe12c372 100644 --- a/care/facility/tests/test_file_upload.py +++ b/care/facility/tests/test_file_upload.py @@ -1,5 +1,3 @@ -import json - from rest_framework import status from rest_framework.test import APITestCase @@ -64,31 +62,16 @@ def setUpTestData(cls) -> None: cls.district, cls.facility, local_body=cls.local_body ) cls.consultation = cls.create_consultation(cls.patient, cls.facility) + cls.consent = cls.create_patient_consent(cls.consultation, created_by=cls.user) def test_consent_file_upload(self): - response = self.client.patch( - f"/api/v1/consultation/{self.consultation.external_id}/", - { - "consent_records": json.dumps( - [ - { - "id": "consent-12345", - "type": 2, - "patient_code_status": 1, - } - ] - ) - }, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - upload_response = self.client.post( "/api/v1/files/", { "original_name": "test.pdf", "file_type": "CONSENT_RECORD", "name": "Test File", - "associating_id": "consent-12345", + "associating_id": self.consent.external_id, "file_category": "UNSPECIFIED", "mime_type": "application/pdf", }, @@ -97,11 +80,12 @@ def test_consent_file_upload(self): self.assertEqual(upload_response.status_code, status.HTTP_201_CREATED) self.assertEqual( - FileUpload.objects.filter(associating_id="consent-12345").count(), 1 + FileUpload.objects.filter(associating_id=self.consent.external_id).count(), + 1, ) all_files = self.client.get( - "/api/v1/files/?associating_id=consent-12345&file_type=CONSENT_RECORD&is_archived=false" + f"/api/v1/files/?associating_id={self.consent.external_id}&file_type=CONSENT_RECORD&is_archived=false" ) self.assertEqual(all_files.status_code, status.HTTP_200_OK) diff --git a/care/facility/tests/test_patient_consents_api.py b/care/facility/tests/test_patient_consents_api.py new file mode 100644 index 0000000000..e59c958779 --- /dev/null +++ b/care/facility/tests/test_patient_consents_api.py @@ -0,0 +1,155 @@ +from rest_framework.test import APITestCase + +from care.facility.models.patient_consultation import ConsentType, PatientCodeStatusType +from care.utils.tests.test_utils import TestUtils + + +class TestPatientConsent(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.location = cls.create_asset_location(cls.facility) + cls.user = cls.create_user("staff1", cls.district, home_facility=cls.facility) + cls.doctor = cls.create_user( + "doctor", cls.district, home_facility=cls.facility, user_type=15 + ) + cls.patient1 = cls.create_patient(cls.district, cls.facility) + cls.consultation = cls.create_consultation( + cls.patient1, cls.facility, cls.doctor + ) + cls.patient2 = cls.create_patient(cls.district, cls.facility) + cls.consultation2 = cls.create_consultation( + cls.patient2, cls.facility, cls.doctor + ) + + def test_create_consent(self): + response = self.client.post( + f"/api/v1/consultation/{self.consultation.external_id}/consents/", + { + "type": ConsentType.CONSENT_FOR_ADMISSION, + }, + ) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["type"], ConsentType.CONSENT_FOR_ADMISSION) + + def test_list_consent(self): + response = self.client.get( + f"/api/v1/consultation/{self.consultation.external_id}/consents/" + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data.get("results")), 0) + + self.client.post( + f"/api/v1/consultation/{self.consultation.external_id}/consents/", + { + "type": ConsentType.CONSENT_FOR_ADMISSION, + }, + ) + response = self.client.get( + f"/api/v1/consultation/{self.consultation.external_id}/consents/" + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data.get("results")), 1) + + def test_retrieve_consent(self): + response = self.client.post( + f"/api/v1/consultation/{self.consultation.external_id}/consents/", + { + "type": ConsentType.CONSENT_FOR_ADMISSION, + }, + ) + self.assertEqual(response.status_code, 201) + response = self.client.get( + f"/api/v1/consultation/{self.consultation.external_id}/consents/{response.data['id']}/" + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["type"], ConsentType.CONSENT_FOR_ADMISSION) + + def test_update_consent(self): + response = self.client.post( + f"/api/v1/consultation/{self.consultation.external_id}/consents/", + { + "type": ConsentType.CONSENT_FOR_ADMISSION, + }, + ) + self.assertEqual(response.status_code, 201) + response = self.client.patch( + f"/api/v1/consultation/{self.consultation.external_id}/consents/{response.data['id']}/", + { + "type": ConsentType.CONSENT_FOR_PROCEDURE, + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["type"], ConsentType.CONSENT_FOR_PROCEDURE) + + def test_auto_archive_consents(self): + response_1 = self.client.post( + f"/api/v1/consultation/{self.consultation.external_id}/consents/", + { + "type": ConsentType.PATIENT_CODE_STATUS, + "patient_code_status": PatientCodeStatusType.ACTIVE_TREATMENT, + }, + ) + self.assertEqual(response_1.status_code, 201) + + upload_response = self.client.post( + "/api/v1/files/", + { + "original_name": "test.pdf", + "file_type": "CONSENT_RECORD", + "name": "Test File", + "associating_id": response_1.data["id"], + "file_category": "UNSPECIFIED", + "mime_type": "application/pdf", + }, + ) + + self.assertEqual(upload_response.status_code, 201) + + response_2 = self.client.post( + f"/api/v1/consultation/{self.consultation.external_id}/consents/", + { + "type": ConsentType.PATIENT_CODE_STATUS, + "patient_code_status": PatientCodeStatusType.COMFORT_CARE, + }, + ) + + self.assertEqual(response_2.status_code, 201) + + response = self.client.get( + f"/api/v1/consultation/{self.consultation.external_id}/consents/{response_1.data['id']}/" + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["archived"], True) + + files = self.client.get( + f"/api/v1/files/?associating_id={response_1.data['id']}&file_type=CONSENT_RECORD&is_archived=false" + ) + + self.assertEqual(files.status_code, 200) + self.assertEqual(files.data["count"], 0) + + def test_patient_code_status_constraint(self): + response = self.client.post( + f"/api/v1/consultation/{self.consultation.external_id}/consents/", + { + "type": ConsentType.PATIENT_CODE_STATUS, + }, + ) + + self.assertEqual(response.status_code, 400) + + response = self.client.post( + f"/api/v1/consultation/{self.consultation.external_id}/consents/", + { + "type": ConsentType.CONSENT_FOR_ADMISSION, + "patient_code_status": PatientCodeStatusType.ACTIVE_TREATMENT, + }, + ) + + self.assertEqual(response.status_code, 400) diff --git a/care/utils/tests/test_utils.py b/care/utils/tests/test_utils.py index 67faf483e0..39ed7a6c42 100644 --- a/care/utils/tests/test_utils.py +++ b/care/utils/tests/test_utils.py @@ -31,6 +31,11 @@ ICD11Diagnosis, ) from care.facility.models.patient import RationCardCategory +from care.facility.models.patient_consultation import ( + ConsentType, + PatientCodeStatusType, + PatientConsent, +) from care.users.models import District, State @@ -447,6 +452,21 @@ def create_consultation_diagnosis( data.update(kwargs) return ConsultationDiagnosis.objects.create(**data) + @classmethod + def create_patient_consent( + cls, + consultation: PatientConsultation, + **kwargs, + ): + data = { + "consultation": consultation, + "type": ConsentType.PATIENT_CODE_STATUS, + "patient_code_status": PatientCodeStatusType.COMFORT_CARE, + "created_by": consultation.created_by, + } + data.update(kwargs) + return PatientConsent.objects.create(**data) + @classmethod def clone_object(cls, obj, save=True): new_obj = obj._meta.model.objects.get(pk=obj.id) diff --git a/config/api_router.py b/config/api_router.py index 76f2ebc38f..78cb45736b 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -56,7 +56,10 @@ PatientSearchViewSet, PatientViewSet, ) -from care.facility.api.viewsets.patient_consultation import PatientConsultationViewSet +from care.facility.api.viewsets.patient_consultation import ( + PatientConsentViewSet, + PatientConsultationViewSet, +) from care.facility.api.viewsets.patient_external_test import PatientExternalTestViewSet from care.facility.api.viewsets.patient_investigation import ( InvestigationGroupViewset, @@ -289,6 +292,10 @@ r"events", PatientConsultationEventViewSet, basename="consultation-events" ) +consultation_nested_router.register( + r"consents", PatientConsentViewSet, basename="consultation-consents" +) + router.register("event_types", EventTypeViewSet, basename="event-types") router.register("medibase", MedibaseViewSet, basename="medibase") From 85a9cb1ac4280a87d637fb10937b6de273e0a2d4 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Thu, 6 Jun 2024 19:52:18 +0530 Subject: [PATCH 2/4] Fix consent records migration (#2250) --- ...ove_patientconsultation_consent_records_and_more.py | 10 ++++++++-- care/facility/models/patient_consultation.py | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/care/facility/migrations/0443_remove_patientconsultation_consent_records_and_more.py b/care/facility/migrations/0443_remove_patientconsultation_consent_records_and_more.py index d6f72ca5ef..b9e012db41 100644 --- a/care/facility/migrations/0443_remove_patientconsultation_consent_records_and_more.py +++ b/care/facility/migrations/0443_remove_patientconsultation_consent_records_and_more.py @@ -22,11 +22,16 @@ def migrate_consents(apps, schema_editor): for consent in consultation.consent_records: new_consent = PatientConsent.objects.create( consultation=consultation, - type=consent["type"], - patient_code_status=consent.get("patient_code_status", None), + type=consent.get("type", 5), + patient_code_status=( + consent.get("patient_code_status", 0) + if consent.get("type", 5) == 2 + else None + ), created_by=consultation.created_by, archived=consent.get("deleted", False), is_migrated=True, + created_date=consultation.modified_date, ) old_id = consent.get("id") @@ -116,6 +121,7 @@ def reverse_migrate(apps, schema_editor): models.IntegerField( blank=True, choices=[ + (0, "Not Specified"), (1, "Do Not Hospitalize"), (2, "Do Not Resuscitate"), (3, "Comfort Care Only"), diff --git a/care/facility/models/patient_consultation.py b/care/facility/models/patient_consultation.py index 4db0163f30..889f2a4067 100644 --- a/care/facility/models/patient_consultation.py +++ b/care/facility/models/patient_consultation.py @@ -368,6 +368,7 @@ class ConsentType(models.IntegerChoices): class PatientCodeStatusType(models.IntegerChoices): + NOT_SPECIFIED = 0, "Not Specified" DNH = 1, "Do Not Hospitalize" DNR = 2, "Do Not Resuscitate" COMFORT_CARE = 3, "Comfort Care Only" From a4d24b6555c7d011df981c42ee0ddb575673b067 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Fri, 7 Jun 2024 10:13:24 +0530 Subject: [PATCH 3/4] Dont allow users to set patient code status as not specified for consent (#2251) * dont allow users to set patient code status as not specified for consent * update serializer validation --------- Co-authored-by: Shivank Kacker --- .../api/serializers/patient_consultation.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/care/facility/api/serializers/patient_consultation.py b/care/facility/api/serializers/patient_consultation.py index 6c0e132e16..6c0796328a 100644 --- a/care/facility/api/serializers/patient_consultation.py +++ b/care/facility/api/serializers/patient_consultation.py @@ -55,6 +55,7 @@ ) from care.facility.models.patient_consultation import ( ConsentType, + PatientCodeStatusType, PatientConsent, PatientConsultation, ) @@ -884,6 +885,13 @@ class Meta: "archived_date", ) + def validate_patient_code_status(self, value): + if value == PatientCodeStatusType.NOT_SPECIFIED: + raise ValidationError( + "Specify a correct Patient Code Status for the Consent" + ) + return value + def validate(self, attrs): user = self.context["request"].user if ( @@ -894,8 +902,10 @@ def validate(self, attrs): "Only Home Facility Staff can create consent for a Consultation" ) - if attrs.get("type") == ConsentType.PATIENT_CODE_STATUS and not attrs.get( - "patient_code_status" + if ( + attrs.get("type", None) + and attrs.get("type") == ConsentType.PATIENT_CODE_STATUS + and not attrs.get("patient_code_status") ): raise ValidationError( { @@ -905,8 +915,10 @@ def validate(self, attrs): } ) - if attrs.get("type") != ConsentType.PATIENT_CODE_STATUS and attrs.get( - "patient_code_status" + if ( + attrs.get("type", None) + and attrs["type"] != ConsentType.PATIENT_CODE_STATUS + and attrs.get("patient_code_status") ): raise ValidationError( { From 37ab92604034fff6c9b710a79eec0225c0c99846 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Fri, 7 Jun 2024 12:39:02 +0530 Subject: [PATCH 4/4] Exclude ICD11 entries that does not have a `meta_chapter_short` from search instead for not loading in redis table (#2248) --- care/facility/api/viewsets/icd.py | 5 ++++- care/facility/static_data/icd11.py | 12 +++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/care/facility/api/viewsets/icd.py b/care/facility/api/viewsets/icd.py index 0b2bcc5a86..da28af1bdf 100644 --- a/care/facility/api/viewsets/icd.py +++ b/care/facility/api/viewsets/icd.py @@ -26,7 +26,10 @@ def list(self, request): except (ValueError, TypeError): limit = 20 - query = [ICD11.has_code == 1] + query = [ + ICD11.has_code == 1, + ICD11.chapter != "null", # noqa: E711 + ] if q := request.query_params.get("query"): query.append(ICD11.vec % query_builder(q)) diff --git a/care/facility/static_data/icd11.py b/care/facility/static_data/icd11.py index 440c6aa817..e591e99ae7 100644 --- a/care/facility/static_data/icd11.py +++ b/care/facility/static_data/icd11.py @@ -19,7 +19,7 @@ class ICD11Object(TypedDict): class ICD11(BaseRedisModel): id: int = Field(primary_key=True) label: str - chapter: str + chapter: str = Field(index=True) has_code: int = Field(index=True) vec: str = Field(index=True, full_text_search=True) @@ -28,17 +28,15 @@ def get_representation(self) -> ICD11Object: return { "id": self.id, "label": self.label, - "chapter": self.chapter, + "chapter": self.chapter if self.chapter != "null" else "", } def load_icd11_diagnosis(): print("Loading ICD11 Diagnosis into the redis cache...", end="", flush=True) - icd_objs = ( - ICD11Diagnosis.objects.filter(meta_chapter_short__isnull=False) - .order_by("id") - .values_list("id", "label", "meta_chapter_short") + icd_objs = ICD11Diagnosis.objects.order_by("id").values_list( + "id", "label", "meta_chapter_short" ) paginator = Paginator(icd_objs, 5000) for page_number in paginator.page_range: @@ -46,7 +44,7 @@ def load_icd11_diagnosis(): ICD11( id=diagnosis[0], label=diagnosis[1], - chapter=diagnosis[2], + chapter=diagnosis[2] or "null", has_code=1 if re.match(DISEASE_CODE_PATTERN, diagnosis[1]) else 0, vec=diagnosis[1].replace(".", "\\.", 1), ).save()