From 5428d66032a13d072ec950668bb4f25a44730a1b Mon Sep 17 00:00:00 2001 From: Pranshu Aggarwal <70687348+Pranshu1902@users.noreply.github.com> Date: Tue, 31 Oct 2023 00:42:30 +0530 Subject: [PATCH 01/10] Filter out those facilities that are already linked to user (#1588) * template methos * filter unlinked facilities * rename params * add tests * fix import * use facilityfilter * refactor * Update care/facility/api/viewsets/facility.py Co-authored-by: Aakash Singh * Update care/facility/api/viewsets/facility.py Co-authored-by: Aakash Singh * fix errors * fix tests * fix lint ci * fix test cases * fix filter --------- Co-authored-by: Aakash Singh --- care/facility/api/viewsets/facility.py | 6 +++++ care/facility/tests/test_facilityuser_api.py | 28 +++++++++++++++++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/care/facility/api/viewsets/facility.py b/care/facility/api/viewsets/facility.py index 8e4f4bd3f7..c24aa1578a 100644 --- a/care/facility/api/viewsets/facility.py +++ b/care/facility/api/viewsets/facility.py @@ -39,6 +39,12 @@ class FacilityFilter(filters.FilterSet): state = filters.NumberFilter(field_name="state__id") state_name = filters.CharFilter(field_name="state__name", lookup_expr="icontains") kasp_empanelled = filters.BooleanFilter(field_name="kasp_empanelled") + exclude_user = filters.CharFilter(method="filter_exclude_user") + + def filter_exclude_user(self, queryset, name, value): + if value: + queryset = queryset.exclude(facilityuser__user__username=value) + return queryset class FacilityQSPermissions(DRYPermissionFiltersBase): diff --git a/care/facility/tests/test_facilityuser_api.py b/care/facility/tests/test_facilityuser_api.py index 0f57868a08..8b7f0d8c2d 100644 --- a/care/facility/tests/test_facilityuser_api.py +++ b/care/facility/tests/test_facilityuser_api.py @@ -1,7 +1,6 @@ from rest_framework import status from rest_framework.test import APITestCase -from care.users.models import Skill from care.utils.tests.test_utils import TestUtils @@ -14,9 +13,16 @@ def setUpTestData(cls) -> None: 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("staff", cls.district, home_facility=cls.facility) - cls.skill1 = Skill.objects.create(name="Skill 1") - cls.skill2 = Skill.objects.create(name="Skill 2") - cls.user.skills.add(cls.skill1, cls.skill2) + + cls.facility1 = cls.create_facility( + cls.super_user, cls.district, cls.local_body + ) + cls.facility2 = cls.create_facility( + cls.super_user, cls.district, cls.local_body + ) + + def setUp(self) -> None: + self.client.force_authenticate(self.super_user) def test_get_queryset_with_prefetching(self): response = self.client.get( @@ -25,3 +31,17 @@ def test_get_queryset_with_prefetching(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertNumQueries(2) + + def test_link_new_facility(self): + response = self.client.get("/api/v1/facility/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), 3) + + def test_link_existing_facility(self): + response = self.client.get( + f"/api/v1/facility/?exclude_user={self.user.username}" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), 2) From 92b99fdf27ab164b7adc6640a64cea9f9fcabf10 Mon Sep 17 00:00:00 2001 From: Ashesh <3626859+Ashesh3@users.noreply.github.com> Date: Tue, 31 Oct 2023 00:44:48 +0530 Subject: [PATCH 02/10] Implement Location Filtering in Patient Consultations (#1679) * Add location filtering to patient consultations * fix location filter --- care/facility/api/viewsets/patient.py | 3 +++ care/facility/tests/test_patient_api.py | 18 +++++++++++++++- care/utils/tests/test_utils.py | 28 +++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/care/facility/api/viewsets/patient.py b/care/facility/api/viewsets/patient.py index 2fe2e08954..6caaafaa42 100644 --- a/care/facility/api/viewsets/patient.py +++ b/care/facility/api/viewsets/patient.py @@ -152,6 +152,9 @@ def filter_by_category(self, queryset, name, value): last_consultation_medico_legal_case = filters.BooleanFilter( field_name="last_consultation__medico_legal_case" ) + last_consultation_current_bed__location = filters.UUIDFilter( + field_name="last_consultation__current_bed__bed__location__external_id" + ) def filter_by_bed_type(self, queryset, name, value): if not value: diff --git a/care/facility/tests/test_patient_api.py b/care/facility/tests/test_patient_api.py index b4885b9fe8..793308dcb4 100644 --- a/care/facility/tests/test_patient_api.py +++ b/care/facility/tests/test_patient_api.py @@ -205,6 +205,7 @@ def setUpTestData(cls): 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( "doctor1", cls.district, home_facility=cls.facility, user_type=15 ) @@ -217,13 +218,28 @@ def setUpTestData(cls): suggestion="A", admission_date=now(), ) + cls.bed = cls.create_bed(cls.facility, cls.location) + cls.consultation_bed = cls.create_consultation_bed(cls.consultation, cls.bed) + cls.consultation.current_bed = cls.consultation_bed + cls.consultation.save() cls.patient.last_consultation = cls.consultation cls.patient.save() def test_filter_by_patient_no(self): self.client.force_authenticate(user=self.user) response = self.client.get("/api/v1/patient/?patient_no=IP5678") - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + self.assertEqual( + response.data["results"][0]["id"], str(self.patient.external_id) + ) + + def test_filter_by_location(self): + self.client.force_authenticate(user=self.user) + response = self.client.get( + f"/api/v1/patient/?facility={self.facility.external_id}&location={self.location.external_id}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["count"], 1) self.assertEqual( response.data["results"][0]["id"], str(self.patient.external_id) diff --git a/care/utils/tests/test_utils.py b/care/utils/tests/test_utils.py index d2b19705d1..cd95e02299 100644 --- a/care/utils/tests/test_utils.py +++ b/care/utils/tests/test_utils.py @@ -23,6 +23,7 @@ User, ) from care.facility.models.asset import Asset, AssetLocation +from care.facility.models.bed import Bed, ConsultationBed from care.facility.models.facility import FacilityUser from care.users.models import District, State @@ -352,6 +353,33 @@ def create_asset(cls, location: AssetLocation, **kwargs) -> Asset: data.update(kwargs) return Asset.objects.create(**data) + @classmethod + def create_bed(cls, facility: Facility, location: AssetLocation, **kwargs): + data = { + "bed_type": 1, + "description": "Sample bed", + "facility": facility, + "location": location, + "name": "Test Bed", + } + data.update(kwargs) + return Bed.objects.create(**data) + + @classmethod + def create_consultation_bed( + cls, + consultation: PatientConsultation, + bed: Bed, + **kwargs, + ): + data = { + "bed": bed, + "consultation": consultation, + "start_date": make_aware(datetime(2020, 4, 1, 15, 30)), + } + data.update(kwargs) + return ConsultationBed.objects.create(**data) + @classmethod def clone_object(cls, obj, save=True): new_obj = obj._meta.model.objects.get(pk=obj.id) From 1a232d75b17429d2646eb911c33f526e3fe19bcd Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Fri, 3 Nov 2023 21:43:28 +0530 Subject: [PATCH 03/10] add auto deployment for staging gcp deployment (#1691) --- .github/workflows/deployment.yaml | 46 +++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/.github/workflows/deployment.yaml b/.github/workflows/deployment.yaml index f50eafbbee..da086dfe81 100644 --- a/.github/workflows/deployment.yaml +++ b/.github/workflows/deployment.yaml @@ -252,6 +252,52 @@ jobs: cluster: ${{ env.ECS_CLUSTER }} wait-for-service-stability: true + deploy-staging-gcp: + needs: build-production + name: Deploy to staging GCP cluster + runs-on: ubuntu-latest + environment: + name: Staging-GCP + url: https://care-staging-api.ohc.network/ + steps: + - name: Checkout Kube Config + uses: actions/checkout@v3 + with: + repository: coronasafe/care-staging-gcp + token: ${{ secrets.GIT_ACCESS_TOKEN }} + path: kube + ref: main + + # Setup gcloud CLI + - uses: google-github-actions/setup-gcloud@94337306dda8180d967a56932ceb4ddcf01edae7 + with: + service_account_key: ${{ secrets.GKE_SA_KEY }} + project_id: ${{ secrets.GKE_PROJECT }} + + # Get the GKE credentials so we can deploy to the cluster + - uses: google-github-actions/get-gke-credentials@fb08709ba27618c31c09e014e1d8364b02e5042e + with: + cluster_name: ${{ secrets.GKE_CLUSTER }} + location: ${{ secrets.GKE_ZONE }} + credentials: ${{ secrets.GKE_SA_KEY }} + + - name: install kubectl + uses: azure/setup-kubectl@v3.0 + with: + version: "v1.23.6" + id: install + + - name: Deploy Care Production Manipur + run: | + mkdir -p $HOME/.kube/ + cd kube/deployments/ + sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-backend.yaml + sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-celery-beat.yaml + sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-celery-worker.yaml + kubectl apply -f care-backend.yaml + kubectl apply -f care-celery-beat.yaml + kubectl apply -f care-celery-worker.yaml + deploy-production-manipur: needs: build-production name: Deploy to GKE Manipur From 942cfe517dc3bd2ae2a01455b152c7f7a6054b3e Mon Sep 17 00:00:00 2001 From: Rithvik Nishad Date: Thu, 9 Nov 2023 00:00:18 +0530 Subject: [PATCH 04/10] Exclude `external_id` from resources serializers (#1701) * fixes #1687; exclude `external_id` from resources serializers * fix typo --- care/facility/api/serializers/resources.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/care/facility/api/serializers/resources.py b/care/facility/api/serializers/resources.py index 3b5957ed6f..e94cc8f05f 100644 --- a/care/facility/api/serializers/resources.py +++ b/care/facility/api/serializers/resources.py @@ -174,8 +174,8 @@ def create(self, validated_data): class Meta: model = ResourceRequest - exclude = ("deleted",) - read_only_fields = TIMESTAMP_FIELDS + ("external_id",) + exclude = ("deleted", "external_id") + read_only_fields = TIMESTAMP_FIELDS class ResourceRequestCommentSerializer(serializers.ModelSerializer): @@ -195,9 +195,5 @@ def create(self, validated_data): class Meta: model = ResourceRequestComment - exclude = ("deleted", "request") - read_only_fields = TIMESTAMP_FIELDS + ( - "created_by", - "external_id", - "id", - ) + exclude = ("deleted", "request", "external_id") + read_only_fields = TIMESTAMP_FIELDS + ("created_by",) From 6f4bedb6f6cf3a5e568ebc09c88ea06c44dba865 Mon Sep 17 00:00:00 2001 From: Rithvik Nishad Date: Thu, 9 Nov 2023 00:01:55 +0530 Subject: [PATCH 05/10] Fixes N+1 queries in Daily Rounds (#1700) Fixes #1699: N+1 queries in Daily Rounds --- care/facility/api/viewsets/daily_round.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/care/facility/api/viewsets/daily_round.py b/care/facility/api/viewsets/daily_round.py index 387c5489a3..06bbcb6a5c 100644 --- a/care/facility/api/viewsets/daily_round.py +++ b/care/facility/api/viewsets/daily_round.py @@ -45,7 +45,11 @@ class DailyRoundsViewSet( IsAuthenticated, DRYPermissions, ) - queryset = DailyRound.objects.all().order_by("-id") + queryset = ( + DailyRound.objects.all() + .order_by("-id") + .select_related("created_by", "last_edited_by") + ) lookup_field = "external_id" filterset_class = DailyRoundFilterSet From cadfebf4669cd192b15c8cb63eaf72a423391b46 Mon Sep 17 00:00:00 2001 From: Rithvik Nishad Date: Thu, 9 Nov 2023 00:55:47 +0530 Subject: [PATCH 06/10] Adds M2M Consultation Diagnosis model (#1690) * Add ConsultationDiagnosis M2M model and migrations * ABDM: Update FHIR utils with ConsultationDiagnoses M2M relation * Update HCX: Update diagnoses in make_claim with M2M relation * API: Adds serializers, viewsets and api route * Unlock `unconfirmed` and `differential` verification status * uncomplicate things.... * minor fixes * more bug fixes.... :/ * gracefully handle invalid icd11 objects * fix `load_icd11_diagnoses_data` re-run issue * Update Discharge Summary * Annotate CSV export by diagoses * fix create_disgnoses not working * minor fix * fix existing tests * add tests * squash migrations * improve coverage by a tiny factor * Add authz, optimizations to model, proper validation message * fix validation * fix authz * fix typo * fix patient notes * Add chapter information to ICD serialization (#1696) * HCX: update diagnoses in make claim * avoid redundant permission checks * fix errors * make code pretty * Apply suggestions from code review Co-authored-by: Aakash Singh --------- Co-authored-by: Aakash Singh --- care/abdm/utils/fhir.py | 30 ++- .../api/serializers/consultation_diagnosis.py | 127 ++++++++++++ .../api/serializers/patient_consultation.py | 156 +++++++------- .../api/viewsets/consultation_diagnosis.py | 55 +++++ care/facility/api/viewsets/icd.py | 4 +- care/facility/api/viewsets/patient.py | 6 +- .../commands/load_icd11_diagnoses_data.py | 6 +- .../migrations/0001_initial_squashed.py | 4 +- ...sultation_deprecated_diagnosis_and_more.py | 191 ++++++++++++++++++ .../migrations_old/0353_auto_20230429_2026.py | 2 +- care/facility/models/icd11_diagnosis.py | 78 +++++++ .../models/mixins/permissions/patient.py | 64 +----- care/facility/models/patient.py | 63 +++++- care/facility/models/patient_consultation.py | 91 ++++++++- care/facility/static_data/icd11.py | 13 +- .../tests/test_patient_consultation_api.py | 159 ++++++++++++++- .../utils/reports/discharge_summary.py | 62 ++++-- care/hcx/api/viewsets/gateway.py | 48 ++--- .../patient_discharge_summary_pdf.html | 82 +++++++- config/api_router.py | 4 + 20 files changed, 1006 insertions(+), 239 deletions(-) create mode 100644 care/facility/api/serializers/consultation_diagnosis.py create mode 100644 care/facility/api/viewsets/consultation_diagnosis.py create mode 100644 care/facility/migrations/0393_rename_diagnosis_patientconsultation_deprecated_diagnosis_and_more.py diff --git a/care/abdm/utils/fhir.py b/care/abdm/utils/fhir.py index 214257bc02..52e9c5fea5 100644 --- a/care/abdm/utils/fhir.py +++ b/care/abdm/utils/fhir.py @@ -32,8 +32,9 @@ from fhir.resources.reference import Reference from care.facility.models.file_upload import FileUpload +from care.facility.models.icd11_diagnosis import REVERSE_CONDITION_VERIFICATION_STATUSES from care.facility.models.patient_investigation import InvestigationValue -from care.facility.static_data.icd11 import ICDDiseases +from care.facility.static_data.icd11 import get_icd11_diagnosis_object_by_id class Fhir: @@ -136,8 +137,8 @@ def _organization(self): return self._organization_profile - def _condition(self, diagnosis_id, provisional=False): - diagnosis = ICDDiseases.by.id[diagnosis_id] + def _condition(self, diagnosis_id, verification_status): + diagnosis = get_icd11_diagnosis_object_by_id(diagnosis_id) [code, label] = diagnosis.label.split(" ", 1) condition_profile = Condition( id=diagnosis_id, @@ -158,8 +159,10 @@ def _condition(self, diagnosis_id, provisional=False): coding=[ Coding( system="http://terminology.hl7.org/CodeSystem/condition-ver-status", - code="provisional" if provisional else "confirmed", - display="Provisional" if provisional else "Confirmed", + code=verification_status, + display=REVERSE_CONDITION_VERIFICATION_STATUSES[ + verification_status + ], ) ] ), @@ -368,20 +371,15 @@ def _encounter(self, include_diagnosis=False): "period": Period(start=period_start, end=period_end), "diagnosis": list( map( - lambda diagnosis: EncounterDiagnosis( + lambda consultation_diagnosis: EncounterDiagnosis( condition=self._reference( - self._condition(diagnosis), + self._condition( + consultation_diagnosis.diagnosis_id, + consultation_diagnosis.verification_status, + ), ) ), - self.consultation.icd11_diagnoses, - ) - ) - + list( - map( - lambda diagnosis: EncounterDiagnosis( - condition=self._reference(self._condition(diagnosis)) - ), - self.consultation.icd11_provisional_diagnoses, + self.consultation.diagnoses.all(), ) ) if include_diagnosis diff --git a/care/facility/api/serializers/consultation_diagnosis.py b/care/facility/api/serializers/consultation_diagnosis.py new file mode 100644 index 0000000000..62f5d80a6a --- /dev/null +++ b/care/facility/api/serializers/consultation_diagnosis.py @@ -0,0 +1,127 @@ +from typing import Any + +from rest_framework import serializers + +from care.facility.models import ( + INACTIVE_CONDITION_VERIFICATION_STATUSES, + ConsultationDiagnosis, +) +from care.facility.models.icd11_diagnosis import ICD11Diagnosis +from care.facility.static_data.icd11 import get_icd11_diagnosis_object_by_id +from care.users.api.serializers.user import UserBaseMinimumSerializer + + +class ConsultationCreateDiagnosisSerializer(serializers.ModelSerializer): + def validate_verification_status(self, value): + if value in INACTIVE_CONDITION_VERIFICATION_STATUSES: + raise serializers.ValidationError("Verification status not allowed") + return value + + class Meta: + model = ConsultationDiagnosis + fields = ("diagnosis", "verification_status", "is_principal") + + +class ConsultationDiagnosisSerializer(serializers.ModelSerializer): + id = serializers.UUIDField(source="external_id", read_only=True) + diagnosis = serializers.PrimaryKeyRelatedField( + queryset=ICD11Diagnosis.objects.all(), required=True, allow_null=False + ) + diagnosis_object = serializers.SerializerMethodField() + created_by = UserBaseMinimumSerializer(read_only=True) + + def get_diagnosis_object(self, obj): + return get_icd11_diagnosis_object_by_id(obj.diagnosis_id, as_dict=True) + + class Meta: + model = ConsultationDiagnosis + exclude = ( + "consultation", + "external_id", + "deleted", + ) + read_only_fields = ( + "created_by", + "created_date", + "modified_date", + "is_migrated", + ) + + def get_consultation_external_id(self): + return self.context["request"].parser_context["kwargs"][ + "consultation_external_id" + ] + + def validate_diagnosis(self, value): + if self.instance and value != self.instance.diagnosis: + raise serializers.ValidationError("Diagnosis cannot be changed") + + if ( + not self.instance + and ConsultationDiagnosis.objects.filter( + consultation__external_id=self.get_consultation_external_id(), + diagnosis=value, + ).exists() + ): + raise serializers.ValidationError( + "Diagnosis already exists for consultation" + ) + + return value + + def validate_verification_status(self, value): + if not self.instance and value in INACTIVE_CONDITION_VERIFICATION_STATUSES: + raise serializers.ValidationError("Verification status not allowed") + return value + + def validate_is_principal(self, value): + if not value: + return value + + qs = ConsultationDiagnosis.objects.filter( + consultation__external_id=self.get_consultation_external_id(), + is_principal=True, + ) + + if self.instance: + qs = qs.exclude(id=self.instance.id) + + if qs.exists(): + raise serializers.ValidationError( + "Consultation already has a principal diagnosis. Unset the existing principal diagnosis first." + ) + + return value + + def update(self, instance, validated_data): + if ( + "verification_status" in validated_data + and validated_data["verification_status"] + in INACTIVE_CONDITION_VERIFICATION_STATUSES + ): + instance.is_principal = False + return super().update(instance, validated_data) + + def validate(self, attrs: Any) -> Any: + validated = super().validate(attrs) + + if ( + "verification_status" in validated + and validated["verification_status"] + in INACTIVE_CONDITION_VERIFICATION_STATUSES + ): + validated["is_principal"] = False + + if "is_principal" in validated and validated["is_principal"]: + verification_status = validated.get( + "verification_status", + self.instance.verification_status if self.instance else None, + ) + if verification_status in INACTIVE_CONDITION_VERIFICATION_STATUSES: + raise serializers.ValidationError( + { + "is_principal": "Refuted/Entered in error diagnoses cannot be marked as Principal Diagnosis" + } + ) + + return validated diff --git a/care/facility/api/serializers/patient_consultation.py b/care/facility/api/serializers/patient_consultation.py index 726facf027..7c58788329 100644 --- a/care/facility/api/serializers/patient_consultation.py +++ b/care/facility/api/serializers/patient_consultation.py @@ -8,6 +8,10 @@ from care.abdm.utils.api_call import AbdmGateway from care.facility.api.serializers import TIMESTAMP_FIELDS from care.facility.api.serializers.bed import ConsultationBedSerializer +from care.facility.api.serializers.consultation_diagnosis import ( + ConsultationCreateDiagnosisSerializer, + ConsultationDiagnosisSerializer, +) from care.facility.api.serializers.daily_round import DailyRoundSerializer from care.facility.api.serializers.facility import FacilityBasicInfoSerializer from care.facility.models import ( @@ -19,6 +23,10 @@ PrescriptionType, ) from care.facility.models.bed import Bed, ConsultationBed +from care.facility.models.icd11_diagnosis import ( + ConditionVerificationStatus, + ConsultationDiagnosis, +) from care.facility.models.notification import Notification from care.facility.models.patient_base import ( DISCHARGE_REASON_CHOICES, @@ -26,7 +34,6 @@ SuggestionChoices, ) from care.facility.models.patient_consultation import PatientConsultation -from care.facility.static_data.icd11 import get_icd11_diagnoses_objects_by_ids from care.users.api.serializers.user import ( UserAssignedSerializer, UserBaseMinimumSerializer, @@ -100,11 +107,13 @@ class PatientConsultationSerializer(serializers.ModelSerializer): bed = ExternalIdSerializerField(queryset=Bed.objects.all(), required=False) - icd11_diagnoses_object = serializers.SerializerMethodField(read_only=True) - - icd11_provisional_diagnoses_object = serializers.SerializerMethodField( - read_only=True + create_diagnoses = ConsultationCreateDiagnosisSerializer( + many=True, + write_only=True, + required=False, + help_text="Bulk create diagnoses for the consultation upon creation", ) + diagnoses = ConsultationDiagnosisSerializer(many=True, read_only=True) medico_legal_case = serializers.BooleanField(default=False, required=False) @@ -122,14 +131,6 @@ def get_discharge_prn_prescription(self, consultation): is_prn=True, ).values() - def get_icd11_diagnoses_object(self, consultation): - return get_icd11_diagnoses_objects_by_ids(consultation.icd11_diagnoses) - - def get_icd11_provisional_diagnoses_object(self, consultation): - return get_icd11_diagnoses_objects_by_ids( - consultation.icd11_provisional_diagnoses - ) - class Meta: model = PatientConsultation read_only_fields = TIMESTAMP_FIELDS + ( @@ -139,9 +140,16 @@ class Meta: "created_by", "kasp_enabled_date", "is_readmission", + "deprecated_diagnosis", "deprecated_verified_by", ) - exclude = ("deleted", "external_id") + exclude = ( + "deleted", + "external_id", + "deprecated_icd11_provisional_diagnoses", + "deprecated_icd11_diagnoses", + "deprecated_icd11_principal_diagnosis", + ) def validate_bed_number(self, bed_number): try: @@ -222,6 +230,7 @@ def update(self, instance, validated_data): return consultation def create(self, validated_data): + create_diagnosis = validated_data.pop("create_diagnoses") action = -1 review_interval = -1 if "action" in validated_data: @@ -283,6 +292,19 @@ def create(self, validated_data): consultation.is_readmission = True consultation.save() + ConsultationDiagnosis.objects.bulk_create( + [ + ConsultationDiagnosis( + consultation=consultation, + diagnosis_id=obj["diagnosis"].id, + is_principal=obj["is_principal"], + verification_status=obj["verification_status"], + created_by=self.context["request"].user, + ) + for obj in create_diagnosis + ] + ) + if bed and consultation.suggestion == SuggestionChoices.A: consultation_bed = ConsultationBed( bed=bed, @@ -332,6 +354,45 @@ def create(self, validated_data): return consultation + def validate_create_diagnoses(self, value): + # Reject if create_diagnoses is present for edits + if self.instance and value: + raise ValidationError("Bulk create diagnoses is not allowed on update") + + # Reject if no diagnoses are provided + if len(value) == 0: + raise ValidationError("Atleast one diagnosis is required") + + # Reject if duplicate diagnoses are provided + if len(value) != len(set([obj["diagnosis"].id for obj in value])): + raise ValidationError("Duplicate diagnoses are not allowed") + + principal_diagnosis, confirmed_diagnoses = None, [] + for obj in value: + if obj["verification_status"] == ConditionVerificationStatus.CONFIRMED: + confirmed_diagnoses.append(obj) + + # Reject if there are more than one principal diagnosis + if obj["is_principal"]: + if principal_diagnosis: + raise ValidationError( + "Only one diagnosis can be set as principal diagnosis" + ) + principal_diagnosis = obj + + # Reject if principal diagnosis is not one of confirmed diagnosis (if it is present) + if ( + principal_diagnosis + and len(confirmed_diagnoses) + and principal_diagnosis["verification_status"] + != ConditionVerificationStatus.CONFIRMED + ): + raise ValidationError( + "Only confirmed diagnosis can be set as principal diagnosis if it is present" + ) + + return value + def validate(self, attrs): validated = super().validate(attrs) # TODO Add Bed Authorisation Validation @@ -410,72 +471,9 @@ def validate(self, attrs): ] } ) - from care.facility.static_data.icd11 import ICDDiseases - final_diagnosis = [] - provisional_diagnosis = [] - - if "icd11_diagnoses" in validated: - for diagnosis in validated["icd11_diagnoses"]: - try: - ICDDiseases.by.id[diagnosis] - final_diagnosis.append(diagnosis) - except BaseException: - raise ValidationError( - { - "icd11_diagnoses": [ - f"{diagnosis} is not a valid ICD 11 Diagnosis ID" - ] - } - ) - - if "icd11_provisional_diagnoses" in validated: - for diagnosis in validated["icd11_provisional_diagnoses"]: - try: - ICDDiseases.by.id[diagnosis] - provisional_diagnosis.append(diagnosis) - except BaseException: - raise ValidationError( - { - "icd11_provisional_diagnoses": [ - f"{diagnosis} is not a valid ICD 11 Diagnosis ID" - ] - } - ) - - if ( - "icd11_principal_diagnosis" in validated - and validated.get("suggestion") != SuggestionChoices.DD - ): - if len(final_diagnosis): - if validated["icd11_principal_diagnosis"] not in final_diagnosis: - raise ValidationError( - { - "icd11_principal_diagnosis": [ - "Principal Diagnosis must be one of the Final Diagnosis" - ] - } - ) - elif len(provisional_diagnosis): - if validated["icd11_principal_diagnosis"] not in provisional_diagnosis: - raise ValidationError( - { - "icd11_principal_diagnosis": [ - "Principal Diagnosis must be one of the Provisional Diagnosis" - ] - } - ) - else: - raise ValidationError( - { - "icd11_diagnoses": [ - "Atleast one diagnosis is required for final diagnosis" - ], - "icd11_provisional_diagnoses": [ - "Atleast one diagnosis is required for provisional diagnosis" - ], - } - ) + if not self.instance and "create_diagnoses" not in validated: + raise ValidationError({"create_diagnoses": ["This field is required."]}) return validated diff --git a/care/facility/api/viewsets/consultation_diagnosis.py b/care/facility/api/viewsets/consultation_diagnosis.py new file mode 100644 index 0000000000..73d218bc3e --- /dev/null +++ b/care/facility/api/viewsets/consultation_diagnosis.py @@ -0,0 +1,55 @@ +from django.shortcuts import get_object_or_404 +from django_filters import rest_framework as filters +from dry_rest_permissions.generics import DRYPermissions +from rest_framework import mixins +from rest_framework.permissions import IsAuthenticated +from rest_framework.viewsets import GenericViewSet + +from care.facility.api.serializers.consultation_diagnosis import ( + ConsultationDiagnosisSerializer, +) +from care.facility.models import ( + ConditionVerificationStatus, + ConsultationDiagnosis, + generate_choices, +) +from care.utils.filters.choicefilter import CareChoiceFilter +from care.utils.queryset.consultation import get_consultation_queryset + + +class ConsultationDiagnosisFilter(filters.FilterSet): + verification_status = CareChoiceFilter( + choices=generate_choices(ConditionVerificationStatus) + ) + + +class ConsultationDiagnosisViewSet( + mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + GenericViewSet, +): + serializer_class = ConsultationDiagnosisSerializer + permission_classes = (IsAuthenticated, DRYPermissions) + queryset = ( + ConsultationDiagnosis.objects.all() + .select_related("created_by") + .order_by("-created_date") + ) + lookup_field = "external_id" + + 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): + consultation = self.get_consultation_obj() + return self.queryset.filter(consultation_id=consultation.id) + + def perform_create(self, serializer): + consultation = self.get_consultation_obj() + serializer.save(consultation=consultation, created_by=self.request.user) diff --git a/care/facility/api/viewsets/icd.py b/care/facility/api/viewsets/icd.py index 56561b9b70..d4de44d9f4 100644 --- a/care/facility/api/viewsets/icd.py +++ b/care/facility/api/viewsets/icd.py @@ -10,7 +10,9 @@ def serailize_data(icd11_object): for object in icd11_object: if type(object) == tuple: object = object[0] - result.append({"id": object.id, "label": object.label}) + result.append( + {"id": object.id, "label": object.label, "chapter": object.chapter} + ) return result diff --git a/care/facility/api/viewsets/patient.py b/care/facility/api/viewsets/patient.py index 6caaafaa42..a84979a9f5 100644 --- a/care/facility/api/viewsets/patient.py +++ b/care/facility/api/viewsets/patient.py @@ -409,8 +409,10 @@ def list(self, request, *args, **kwargs): } ) # End Date Limiting Validation - queryset = self.filter_queryset(self.get_queryset()).values( - *PatientRegistration.CSV_MAPPING.keys() + queryset = ( + self.filter_queryset(self.get_queryset()) + .annotate(**PatientRegistration.CSV_ANNOTATE_FIELDS) + .values(*PatientRegistration.CSV_MAPPING.keys()) ) return render_to_csv_response( queryset, diff --git a/care/facility/management/commands/load_icd11_diagnoses_data.py b/care/facility/management/commands/load_icd11_diagnoses_data.py index 4ad383bba7..faa8ba6df9 100644 --- a/care/facility/management/commands/load_icd11_diagnoses_data.py +++ b/care/facility/management/commands/load_icd11_diagnoses_data.py @@ -37,7 +37,7 @@ class Command(BaseCommand): """ Management command to load ICD11 diagnoses to database. Not for production use. - Usage: python manage.py load_icd11_diagnoses + Usage: python manage.py load_icd11_diagnoses_data """ help = "Loads ICD11 diagnoses data to database" @@ -143,7 +143,6 @@ def roots(item): result["meta_hidden"] = mapped is None return result - ICD11Diagnosis.objects.all().delete() ICD11Diagnosis.objects.bulk_create( [ ICD11Diagnosis( @@ -159,7 +158,8 @@ def roots(item): **roots(obj), ) for obj in self.data - ] + ], + ignore_conflicts=True, # Voluntarily set to skip duplicates, so that we can run this command multiple times + existing relations are not affected ) except Exception as e: raise CommandError(e) diff --git a/care/facility/migrations/0001_initial_squashed.py b/care/facility/migrations/0001_initial_squashed.py index 0917e3e324..7ec400916a 100644 --- a/care/facility/migrations/0001_initial_squashed.py +++ b/care/facility/migrations/0001_initial_squashed.py @@ -2664,7 +2664,7 @@ class Migration(migrations.Migration): ], bases=( models.Model, - care.facility.models.mixins.permissions.patient.PatientRelatedPermissionMixin, + care.facility.models.mixins.permissions.patient.ConsultationRelatedPermissionMixin, ), ), migrations.CreateModel( @@ -4550,7 +4550,7 @@ class Migration(migrations.Migration): }, bases=( models.Model, - care.facility.models.mixins.permissions.patient.PatientRelatedPermissionMixin, + care.facility.models.mixins.permissions.patient.ConsultationRelatedPermissionMixin, ), ), migrations.CreateModel( diff --git a/care/facility/migrations/0393_rename_diagnosis_patientconsultation_deprecated_diagnosis_and_more.py b/care/facility/migrations/0393_rename_diagnosis_patientconsultation_deprecated_diagnosis_and_more.py new file mode 100644 index 0000000000..d5f67b6f30 --- /dev/null +++ b/care/facility/migrations/0393_rename_diagnosis_patientconsultation_deprecated_diagnosis_and_more.py @@ -0,0 +1,191 @@ +# Generated by Django 4.2.5 on 2023-11-03 07:49 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import care.facility.models.mixins.permissions.patient + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("facility", "0392_alter_dailyround_consciousness_level"), + ] + + def populate_consultation_diagnosis(apps, schema_editor): + PatientConsultation = apps.get_model("facility", "PatientConsultation") + ConsultationDiagnosis = apps.get_model("facility", "ConsultationDiagnosis") + + objects = [] + + for consultation in PatientConsultation.objects.all(): + processed_diagnosis_ids = [] # to skip duplicates if any + principal_diagnosis_id = consultation.deprecated_icd11_principal_diagnosis + + # confirmed diagnoses + for diagnosis_id in consultation.deprecated_icd11_diagnoses: + if diagnosis_id in processed_diagnosis_ids: + continue + processed_diagnosis_ids.append(diagnosis_id) + objects.append( + ConsultationDiagnosis( + is_migrated=True, + consultation=consultation, + diagnosis_id=diagnosis_id, + verification_status="confirmed", + is_principal=diagnosis_id == principal_diagnosis_id, + ) + ) + + # provisional diagnoses + for diagnosis_id in consultation.deprecated_icd11_provisional_diagnoses: + if diagnosis_id in processed_diagnosis_ids: + continue + processed_diagnosis_ids.append(diagnosis_id) + objects.append( + ConsultationDiagnosis( + is_migrated=True, + consultation=consultation, + diagnosis_id=diagnosis_id, + verification_status="provisional", + is_principal=diagnosis_id == principal_diagnosis_id, + ) + ) + + ConsultationDiagnosis.objects.bulk_create(objects, batch_size=2000) + + operations = [ + migrations.RenameField( + model_name="patientconsultation", + old_name="diagnosis", + new_name="deprecated_diagnosis", + ), + migrations.RenameField( + model_name="patientconsultation", + old_name="icd11_diagnoses", + new_name="deprecated_icd11_diagnoses", + ), + migrations.RenameField( + model_name="patientconsultation", + old_name="icd11_principal_diagnosis", + new_name="deprecated_icd11_principal_diagnosis", + ), + migrations.RenameField( + model_name="patientconsultation", + old_name="icd11_provisional_diagnoses", + new_name="deprecated_icd11_provisional_diagnoses", + ), + migrations.CreateModel( + name="ConsultationDiagnosis", + 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)), + ( + "verification_status", + models.CharField( + choices=[ + ("unconfirmed", "Unconfirmed"), + ("provisional", "Provisional"), + ("differential", "Differential"), + ("confirmed", "Confirmed"), + ("refuted", "Refuted"), + ("entered-in-error", "Entered in Error"), + ], + max_length=20, + ), + ), + ("is_principal", models.BooleanField(default=False)), + ( + "is_migrated", + models.BooleanField( + default=False, + help_text="This field is to throw caution to data that was previously ported over", + ), + ), + ( + "consultation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="diagnoses", + to="facility.patientconsultation", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "diagnosis", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="facility.icd11diagnosis", + ), + ), + ], + bases=( + models.Model, + care.facility.models.mixins.permissions.patient.ConsultationRelatedPermissionMixin, + ), + ), + migrations.AddConstraint( + model_name="consultationdiagnosis", + constraint=models.UniqueConstraint( + fields=("consultation", "diagnosis"), + name="unique_diagnosis_per_consultation", + ), + ), + migrations.AddConstraint( + model_name="consultationdiagnosis", + constraint=models.UniqueConstraint( + condition=models.Q(("is_principal", True)), + fields=("consultation", "is_principal"), + name="unique_principal_diagnosis", + ), + ), + migrations.AddConstraint( + model_name="consultationdiagnosis", + constraint=models.CheckConstraint( + check=models.Q( + ("is_principal", False), + models.Q( + ("verification_status__in", ["refuted", "entered-in-error"]), + _negated=True, + ), + _connector="OR", + ), + name="refuted_or_entered_in_error_diagnosis_cannot_be_principal", + ), + ), + migrations.RunPython( + populate_consultation_diagnosis, reverse_code=migrations.RunPython.noop + ), + ] diff --git a/care/facility/migrations_old/0353_auto_20230429_2026.py b/care/facility/migrations_old/0353_auto_20230429_2026.py index 101e9a7d4f..010f93e357 100644 --- a/care/facility/migrations_old/0353_auto_20230429_2026.py +++ b/care/facility/migrations_old/0353_auto_20230429_2026.py @@ -127,7 +127,7 @@ class Migration(migrations.Migration): }, bases=( models.Model, - care.facility.models.mixins.permissions.patient.PatientRelatedPermissionMixin, + care.facility.models.mixins.permissions.patient.ConsultationRelatedPermissionMixin, ), ), migrations.CreateModel( diff --git a/care/facility/models/icd11_diagnosis.py b/care/facility/models/icd11_diagnosis.py index 959340fe12..35ba3a0a4a 100644 --- a/care/facility/models/icd11_diagnosis.py +++ b/care/facility/models/icd11_diagnosis.py @@ -1,4 +1,11 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ + +from care.facility.models.mixins.permissions.patient import ( + ConsultationRelatedPermissionMixin, +) +from care.facility.models.patient_base import reverse_choices +from care.utils.models.base import BaseModel class ICD11ClassKind(models.TextChoices): @@ -31,3 +38,74 @@ class ICD11Diagnosis(models.Model): def __str__(self) -> str: return self.label + + +class ConditionVerificationStatus(models.TextChoices): + """ + See: https://www.hl7.org/fhir/valueset-condition-ver-status.html + """ + + UNCONFIRMED = "unconfirmed", _("Unconfirmed") + PROVISIONAL = "provisional", _("Provisional") + DIFFERENTIAL = "differential", _("Differential") + CONFIRMED = "confirmed", _("Confirmed") + REFUTED = "refuted", _("Refuted") + ENTERED_IN_ERROR = "entered-in-error", _("Entered in Error") + + +INACTIVE_CONDITION_VERIFICATION_STATUSES = [ + ConditionVerificationStatus.REFUTED, + ConditionVerificationStatus.ENTERED_IN_ERROR, +] # Theses statuses are not allowed to be selected during create or can't be a principal diagnosis + +ACTIVE_CONDITION_VERIFICATION_STATUSES = [ + status + for status in ConditionVerificationStatus + if status not in INACTIVE_CONDITION_VERIFICATION_STATUSES +] # These statuses are allowed to be selected during create and these diagnosis can only be a principal diagnosis + +REVERSE_CONDITION_VERIFICATION_STATUSES = reverse_choices(ConditionVerificationStatus) + + +class ConsultationDiagnosis(BaseModel, ConsultationRelatedPermissionMixin): + consultation = models.ForeignKey( + "PatientConsultation", on_delete=models.CASCADE, related_name="diagnoses" + ) + diagnosis = models.ForeignKey(ICD11Diagnosis, on_delete=models.PROTECT) + verification_status = models.CharField( + max_length=20, choices=ConditionVerificationStatus.choices + ) + is_principal = models.BooleanField(default=False) + + created_by = models.ForeignKey( + "users.User", null=True, blank=True, on_delete=models.PROTECT, related_name="+" + ) + 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", "diagnosis"], + name="unique_diagnosis_per_consultation", + ), + # A consultation can have only one principal diagnosis + # in other words: unique together (consultation, is_principal) where is_principal is true + models.UniqueConstraint( + fields=["consultation", "is_principal"], + condition=models.Q(is_principal=True), + name="unique_principal_diagnosis", + ), + # Diagnosis cannot be principal if verification status is one of refuted/entered-in-error. + models.CheckConstraint( + check=( + models.Q(is_principal=False) + | ~models.Q( + verification_status__in=INACTIVE_CONDITION_VERIFICATION_STATUSES + ) + ), + name="refuted_or_entered_in_error_diagnosis_cannot_be_principal", + ), + ] diff --git a/care/facility/models/mixins/permissions/patient.py b/care/facility/models/mixins/permissions/patient.py index f014b4f2a4..c81685392b 100644 --- a/care/facility/models/mixins/permissions/patient.py +++ b/care/facility/models/mixins/permissions/patient.py @@ -150,7 +150,10 @@ def has_object_transfer_permission(self, request): ) -class PatientRelatedPermissionMixin(BasePermissionMixin): +class ConsultationRelatedPermissionMixin(BasePermissionMixin): + def get_related_consultation(self): + return self.consultation + @staticmethod def has_write_permission(request): if ( @@ -159,38 +162,11 @@ def has_write_permission(request): or request.user.user_type == User.TYPE_VALUE_MAP["StaffReadOnly"] ): return False - return ( - request.user.is_superuser - or request.user.verified - and request.user.user_type >= User.TYPE_VALUE_MAP["Staff"] - ) + return True def has_object_read_permission(self, request): - return ( - request.user.is_superuser - or ( - self.patient.facility - and request.user in self.patient.facility.users.all() - ) - or ( - self.assigned_to == request.user - or request.user == self.patient.assigned_to - ) - or ( - request.user.user_type >= User.TYPE_VALUE_MAP["DistrictLabAdmin"] - and ( - self.patient.facility - and request.user.district == self.patient.facility.district - ) - ) - or ( - request.user.user_type >= User.TYPE_VALUE_MAP["StateLabAdmin"] - and ( - self.patient.facility - and request.user.state == self.patient.facility.state - ) - ) - ) + # This is because, `get_queryset` for related models already filters by consultation. + return True def has_object_update_permission(self, request): if ( @@ -199,28 +175,4 @@ def has_object_update_permission(self, request): or request.user.user_type == User.TYPE_VALUE_MAP["StaffReadOnly"] ): return False - return ( - request.user.is_superuser - or ( - self.patient.facility - and self.patient.facility == request.user.home_facility - ) - or ( - self.assigned_to == request.user - or request.user == self.patient.assigned_to - ) - or ( - request.user.user_type >= User.TYPE_VALUE_MAP["DistrictLabAdmin"] - and ( - self.patient.facility - and request.user.district == self.patient.facility.district - ) - ) - or ( - request.user.user_type >= User.TYPE_VALUE_MAP["StateLabAdmin"] - and ( - self.patient.facility - and request.user.state == self.patient.facility.state - ) - ) - ) + return True diff --git a/care/facility/models/patient.py b/care/facility/models/patient.py index cd14e5fcfa..6778153505 100644 --- a/care/facility/models/patient.py +++ b/care/facility/models/patient.py @@ -1,6 +1,7 @@ import datetime import enum +from django.contrib.postgres.aggregates import ArrayAgg from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import JSONField @@ -18,12 +19,13 @@ State, Ward, ) +from care.facility.models.icd11_diagnosis import ConditionVerificationStatus from care.facility.models.mixins.permissions.facility import ( FacilityRelatedPermissionMixin, ) from care.facility.models.mixins.permissions.patient import ( + ConsultationRelatedPermissionMixin, PatientPermissionMixin, - PatientRelatedPermissionMixin, ) from care.facility.models.patient_base import ( BLOOD_GROUP_CHOICES, @@ -33,7 +35,7 @@ REVERSE_DISCHARGE_REASON_CHOICES, ) from care.facility.models.patient_consultation import PatientConsultation -from care.facility.static_data.icd11 import ICDDiseases +from care.facility.static_data.icd11 import get_icd11_diagnoses_objects_by_ids from care.users.models import GENDER_CHOICES, REVERSE_GENDER_CHOICES, User from care.utils.models.base import BaseManager, BaseModel from care.utils.models.validators import mobile_or_landline_number_validator @@ -479,6 +481,31 @@ def save(self, *args, **kwargs) -> None: self._alias_recovery_to_recovered() super().save(*args, **kwargs) + def annotate_diagnosis_ids(*args, **kwargs): + return ArrayAgg( + "last_consultation__diagnoses__diagnosis_id", + filter=models.Q(*args, **kwargs), + ) + + CSV_ANNOTATE_FIELDS = { + # Principal Diagnoses + "principal_diagnoses": annotate_diagnosis_ids( + last_consultation__diagnoses__is_principal=True + ), + "unconfirmed_diagnoses": annotate_diagnosis_ids( + last_consultation__diagnoses__verification_status=ConditionVerificationStatus.UNCONFIRMED + ), + "provisional_diagnoses": annotate_diagnosis_ids( + last_consultation__diagnoses__verification_status=ConditionVerificationStatus.PROVISIONAL + ), + "differential_diagnoses": annotate_diagnosis_ids( + last_consultation__diagnoses__verification_status=ConditionVerificationStatus.DIFFERENTIAL + ), + "confirmed_diagnoses": annotate_diagnosis_ids( + last_consultation__diagnoses__verification_status=ConditionVerificationStatus.CONFIRMED + ), + } + CSV_MAPPING = { # Patient Details "external_id": "Patient ID", @@ -491,8 +518,13 @@ def save(self, *args, **kwargs) -> None: "last_consultation__consultation_status": "Status during consultation", "last_consultation__created_date": "Date of first consultation", "last_consultation__created_date__time": "Time of first consultation", - "last_consultation__icd11_diagnoses": "Diagnoses", - "last_consultation__icd11_provisional_diagnoses": "Provisional Diagnoses", + # Diagnosis Details + "principal_diagnoses": "Principal Diagnosis", + "unconfirmed_diagnoses": "Unconfirmed Diagnoses", + "provisional_diagnoses": "Provisional Diagnoses", + "differential_diagnoses": "Differential Diagnoses", + "confirmed_diagnoses": "Confirmed Diagnoses", + # Last Consultation Details "last_consultation__suggestion": "Decision after consultation", "last_consultation__category": "Category", "last_consultation__discharge_date": "Date of discharge", @@ -505,6 +537,10 @@ def format_as_date(date): def format_as_time(time): return time.strftime("%H:%M") + def format_diagnoses(diagnosis_ids): + diagnoses = get_icd11_diagnoses_objects_by_ids(diagnosis_ids) + return ", ".join([diagnosis["label"] for diagnosis in diagnoses]) + CSV_MAKE_PRETTY = { "gender": (lambda x: REVERSE_GENDER_CHOICES[x]), "created_date": format_as_date, @@ -514,12 +550,11 @@ def format_as_time(time): "last_consultation__suggestion": ( lambda x: PatientConsultation.REVERSE_SUGGESTION_CHOICES.get(x, "-") ), - "last_consultation__icd11_diagnoses": ( - lambda x: ", ".join([ICDDiseases.by.id[id].label.strip() for id in x]) - ), - "last_consultation__icd11_provisional_diagnoses": ( - lambda x: ", ".join([ICDDiseases.by.id[id].label.strip() for id in x]) - ), + "principal_diagnoses": format_diagnoses, + "unconfirmed_diagnoses": format_diagnoses, + "provisional_diagnoses": format_diagnoses, + "differential_diagnoses": format_diagnoses, + "confirmed_diagnoses": format_diagnoses, "last_consultation__consultation_status": ( lambda x: REVERSE_CONSULTATION_STATUS_CHOICES.get(x, "-").replace("_", " ") ), @@ -675,7 +710,7 @@ class PatientMobileOTP(BaseModel): otp = models.CharField(max_length=10) -class PatientNotes(FacilityBaseModel, PatientRelatedPermissionMixin): +class PatientNotes(FacilityBaseModel, ConsultationRelatedPermissionMixin): patient = models.ForeignKey( PatientRegistration, on_delete=models.PROTECT, null=False, blank=False ) @@ -689,3 +724,9 @@ class PatientNotes(FacilityBaseModel, PatientRelatedPermissionMixin): null=True, ) note = models.TextField(default="", blank=True) + + def get_related_consultation(self): + # This is a temporary hack! this model does not have `assigned_to` field + # 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 diff --git a/care/facility/models/patient_consultation.py b/care/facility/models/patient_consultation.py index 6707e967a5..0b920757c2 100644 --- a/care/facility/models/patient_consultation.py +++ b/care/facility/models/patient_consultation.py @@ -11,7 +11,7 @@ PatientBaseModel, ) from care.facility.models.mixins.permissions.patient import ( - PatientRelatedPermissionMixin, + ConsultationRelatedPermissionMixin, ) from care.facility.models.patient_base import ( DISCHARGE_REASON_CHOICES, @@ -26,7 +26,7 @@ from care.users.models import User -class PatientConsultation(PatientBaseModel, PatientRelatedPermissionMixin): +class PatientConsultation(PatientBaseModel, ConsultationRelatedPermissionMixin): SUGGESTION_CHOICES = [ (SuggestionChoices.HI, "HOME ISOLATION"), (SuggestionChoices.A, "ADMISSION"), @@ -57,16 +57,18 @@ class PatientConsultation(PatientBaseModel, PatientRelatedPermissionMixin): facility = models.ForeignKey( "Facility", on_delete=models.CASCADE, related_name="consultations" ) - diagnosis = models.TextField(default="", null=True, blank=True) # Deprecated - icd11_provisional_diagnoses = ArrayField( + deprecated_diagnosis = models.TextField( + default="", null=True, blank=True + ) # Deprecated + deprecated_icd11_provisional_diagnoses = ArrayField( models.CharField(max_length=100), default=list, blank=True, null=True - ) - icd11_diagnoses = ArrayField( + ) # Deprecated in favour of ConsultationDiagnosis M2M model + deprecated_icd11_diagnoses = ArrayField( models.CharField(max_length=100), default=list, blank=True, null=True - ) - icd11_principal_diagnosis = models.CharField( + ) # Deprecated in favour of ConsultationDiagnosis M2M model + deprecated_icd11_principal_diagnosis = models.CharField( max_length=100, default="", blank=True, null=True - ) + ) # Deprecated in favour of ConsultationDiagnosis M2M model symptoms = MultiSelectField( choices=SYMPTOM_CHOICES, default=1, @@ -205,6 +207,9 @@ class PatientConsultation(PatientBaseModel, PatientRelatedPermissionMixin): prn_prescription = JSONField(default=dict) discharge_advice = JSONField(default=dict) + def get_related_consultation(self): + return self + CSV_MAPPING = { "consultation_created_date": "Date of Consultation", "admission_date": "Date of Admission", @@ -263,6 +268,74 @@ class Meta: ), ] + @staticmethod + def has_write_permission(request): + if not ConsultationRelatedPermissionMixin.has_write_permission(request): + return False + return ( + request.user.is_superuser + or request.user.verified + and request.user.user_type >= User.TYPE_VALUE_MAP["Staff"] + ) + + def has_object_read_permission(self, request): + if not super().has_object_read_permission(request): + return False + return ( + request.user.is_superuser + or ( + self.patient.facility + and request.user in self.patient.facility.users.all() + ) + or ( + self.assigned_to == request.user + or request.user == self.patient.assigned_to + ) + or ( + request.user.user_type >= User.TYPE_VALUE_MAP["DistrictLabAdmin"] + and ( + self.patient.facility + and request.user.district == self.patient.facility.district + ) + ) + or ( + request.user.user_type >= User.TYPE_VALUE_MAP["StateLabAdmin"] + and ( + self.patient.facility + and request.user.state == self.patient.facility.state + ) + ) + ) + + def has_object_update_permission(self, request): + if not super().has_object_update_permission(request): + return False + return ( + request.user.is_superuser + or ( + self.patient.facility + and request.user in self.patient.facility.users.all() + ) + or ( + self.assigned_to == request.user + or request.user == self.patient.assigned_to + ) + or ( + request.user.user_type >= User.TYPE_VALUE_MAP["DistrictLabAdmin"] + and ( + self.patient.facility + and request.user.district == self.patient.facility.district + ) + ) + or ( + request.user.user_type >= User.TYPE_VALUE_MAP["StateLabAdmin"] + and ( + self.patient.facility + and request.user.state == self.patient.facility.state + ) + ) + ) + def has_object_discharge_patient_permission(self, request): return self.has_object_update_permission(request) diff --git a/care/facility/static_data/icd11.py b/care/facility/static_data/icd11.py index ceb4aa48de..dbdcb176a7 100644 --- a/care/facility/static_data/icd11.py +++ b/care/facility/static_data/icd11.py @@ -15,9 +15,10 @@ def fetch_from_db(): "id": str(diagnosis["id"]), "label": diagnosis["label"], "is_leaf": diagnosis["is_leaf"], + "chapter": diagnosis["meta_chapter_short"], } for diagnosis in ICD11Diagnosis.objects.filter().values( - "id", "label", "is_leaf" + "id", "label", "is_leaf", "meta_chapter_short" ) ] return [] @@ -29,10 +30,16 @@ def fetch_from_db(): ICDDiseases.create_index("id", unique=True) +def get_icd11_diagnosis_object_by_id(diagnosis_id, as_dict=False): + obj = None + with contextlib.suppress(BaseException): + obj = ICDDiseases.by.id[str(diagnosis_id)] + return obj and (obj.__dict__ if as_dict else obj) + + def get_icd11_diagnoses_objects_by_ids(diagnoses_ids): diagnosis_objects = [] for diagnosis in diagnoses_ids: with contextlib.suppress(BaseException): - diagnosis_object = ICDDiseases.by.id[diagnosis].__dict__ - diagnosis_objects.append(diagnosis_object) + diagnosis_objects.append(ICDDiseases.by.id[str(diagnosis)].__dict__) return diagnosis_objects diff --git a/care/facility/tests/test_patient_consultation_api.py b/care/facility/tests/test_patient_consultation_api.py index 2ca154bfe5..fb33cfaeb8 100644 --- a/care/facility/tests/test_patient_consultation_api.py +++ b/care/facility/tests/test_patient_consultation_api.py @@ -4,6 +4,10 @@ from rest_framework import status from rest_framework.test import APITestCase +from care.facility.models.icd11_diagnosis import ( + ConditionVerificationStatus, + ICD11Diagnosis, +) from care.facility.models.patient_consultation import ( CATEGORY_CHOICES, PatientConsultation, @@ -47,15 +51,32 @@ def create_admission_consultation(self, patient=None, **kwargs): { "patient": patient.external_id, "facility": self.facility.external_id, + "create_diagnoses": [ + { + "diagnosis": ICD11Diagnosis.objects.first().id, + "is_principal": False, + "verification_status": ConditionVerificationStatus.CONFIRMED, + } + ], } ) data.update(kwargs) - res = self.client.post(self.get_url(), data) + res = self.client.post(self.get_url(), data, format="json") return PatientConsultation.objects.get(external_id=res.data["id"]) def update_consultation(self, consultation, **kwargs): return self.client.patch(self.get_url(consultation), kwargs, "json") + def add_diagnosis(self, consultation, **kwargs): + return self.client.post( + f"{self.get_url(consultation)}diagnoses/", kwargs, "json" + ) + + def edit_diagnosis(self, consultation, id, **kwargs): + return self.client.patch( + f"{self.get_url(consultation)}diagnoses/{id}/", kwargs, "json" + ) + def discharge(self, consultation, **kwargs): return self.client.post( f"{self.get_url(consultation)}discharge_patient/", kwargs, "json" @@ -307,3 +328,139 @@ def test_update_consultation_after_discharge(self): consultation, symptoms=[1, 2], category="MILD", suggestion="A" ) self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_add_diagnoses_and_duplicate_diagnoses(self): + consultation = self.create_admission_consultation( + suggestion="A", + admission_date=make_aware(datetime.datetime(2020, 4, 1, 15, 30, 00)), + ) + diagnosis = ICD11Diagnosis.objects.all()[0].id + res = self.add_diagnosis( + consultation, + diagnosis=diagnosis, + is_principal=True, + verification_status=ConditionVerificationStatus.CONFIRMED, + ) + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + res = self.add_diagnosis( + consultation, + diagnosis=diagnosis, + is_principal=True, + verification_status=ConditionVerificationStatus.PROVISIONAL, + ) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_add_diagnosis_inactive(self): + consultation = self.create_admission_consultation( + suggestion="A", + admission_date=make_aware(datetime.datetime(2020, 4, 1, 15, 30, 00)), + ) + diagnosis = ICD11Diagnosis.objects.first().id + res = self.add_diagnosis( + consultation, + diagnosis=diagnosis, + is_principal=False, + verification_status=ConditionVerificationStatus.ENTERED_IN_ERROR, + ) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + res = self.add_diagnosis( + consultation, + diagnosis=diagnosis, + is_principal=True, + verification_status=ConditionVerificationStatus.REFUTED, + ) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def mark_inactive_diagnosis_as_principal(self): + consultation = self.create_admission_consultation( + suggestion="A", + admission_date=make_aware(datetime.datetime(2020, 4, 1, 15, 30, 00)), + ) + diagnosis = ICD11Diagnosis.objects.first().id + res = self.add_diagnosis( + consultation, + diagnosis=diagnosis, + is_principal=False, + verification_status=ConditionVerificationStatus.CONFIRMED, + ) + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + res = self.edit_diagnosis( + consultation, + res.data["id"], + verification_status=ConditionVerificationStatus.REFUTED, + ) + self.assertEqual(res.status_code, status.HTTP_200_OK) + res = self.edit_diagnosis( + consultation, + res.data["id"], + is_principal=True, + ) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_change_diagnosis(self): + consultation = self.create_admission_consultation( + suggestion="A", + admission_date=make_aware(datetime.datetime(2020, 4, 1, 15, 30, 00)), + ) + res = self.add_diagnosis( + consultation, + diagnosis=ICD11Diagnosis.objects.all()[0].id, + is_principal=False, + verification_status=ConditionVerificationStatus.CONFIRMED, + ) + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + res = self.edit_diagnosis( + consultation, + res.data["id"], + diagnosis=ICD11Diagnosis.objects.all()[1].id, + is_principal=True, + verification_status=ConditionVerificationStatus.PROVISIONAL, + ) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_add_multiple_principal_diagnosis(self): + consultation = self.create_admission_consultation( + suggestion="A", + admission_date=make_aware(datetime.datetime(2020, 4, 1, 15, 30, 00)), + ) + res = self.add_diagnosis( + consultation, + diagnosis=ICD11Diagnosis.objects.all()[0].id, + is_principal=True, + verification_status=ConditionVerificationStatus.CONFIRMED, + ) + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + res = self.add_diagnosis( + consultation, + diagnosis=ICD11Diagnosis.objects.all()[1].id, + is_principal=True, + verification_status=ConditionVerificationStatus.PROVISIONAL, + ) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_add_principal_edit_as_inactive_add_principal(self): + consultation = self.create_admission_consultation( + suggestion="A", + admission_date=make_aware(datetime.datetime(2020, 4, 1, 15, 30, 00)), + ) + res = self.add_diagnosis( + consultation, + diagnosis=ICD11Diagnosis.objects.all()[0].id, + is_principal=True, + verification_status=ConditionVerificationStatus.CONFIRMED, + ) + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + res = self.edit_diagnosis( + consultation, + res.data["id"], + verification_status=ConditionVerificationStatus.ENTERED_IN_ERROR, + ) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertFalse(res.data["is_principal"]) + res = self.add_diagnosis( + consultation, + diagnosis=ICD11Diagnosis.objects.all()[1].id, + is_principal=True, + verification_status=ConditionVerificationStatus.PROVISIONAL, + ) + self.assertEqual(res.status_code, status.HTTP_201_CREATED) diff --git a/care/facility/utils/reports/discharge_summary.py b/care/facility/utils/reports/discharge_summary.py index 072ca43266..48af9e6c66 100644 --- a/care/facility/utils/reports/discharge_summary.py +++ b/care/facility/utils/reports/discharge_summary.py @@ -21,6 +21,10 @@ PrescriptionType, ) from care.facility.models.file_upload import FileUpload +from care.facility.models.icd11_diagnosis import ( + ACTIVE_CONDITION_VERIFICATION_STATUSES, + ConditionVerificationStatus, +) from care.facility.static_data.icd11 import get_icd11_diagnoses_objects_by_ids from care.hcx.models.policy import Policy @@ -45,6 +49,46 @@ def clear_lock(consultation_ext_id: str): cache.delete(lock_key(consultation_ext_id)) +def get_diagnoses_data(consultation: PatientConsultation): + entries = ( + consultation.diagnoses.filter( + verification_status__in=ACTIVE_CONDITION_VERIFICATION_STATUSES + ) + .order_by("-created_date") + .values_list( + "diagnosis_id", + "verification_status", + "is_principal", + ) + ) + + # retrieve diagnosis objects from in-memory table + diagnoses = get_icd11_diagnoses_objects_by_ids([entry[0] for entry in entries]) + + principal, unconfirmed, provisional, differential, confirmed = [], [], [], [], [] + + for diagnosis, record in zip(diagnoses, entries): + _, verification_status, is_principal = record + if is_principal: + principal.append(diagnosis) + if verification_status == ConditionVerificationStatus.UNCONFIRMED: + unconfirmed.append(diagnosis) + if verification_status == ConditionVerificationStatus.PROVISIONAL: + provisional.append(diagnosis) + if verification_status == ConditionVerificationStatus.DIFFERENTIAL: + differential.append(diagnosis) + if verification_status == ConditionVerificationStatus.CONFIRMED: + confirmed.append(diagnosis) + + return { + "principal": principal, + "unconfirmed": unconfirmed, + "provisional": provisional, + "differential": differential, + "confirmed": confirmed, + } + + def get_discharge_summary_data(consultation: PatientConsultation): logger.info(f"fetching discharge summary data for {consultation.external_id}") samples = PatientSample.objects.filter( @@ -52,15 +96,7 @@ def get_discharge_summary_data(consultation: PatientConsultation): ) hcx = Policy.objects.filter(patient=consultation.patient) daily_rounds = DailyRound.objects.filter(consultation=consultation) - diagnosis = get_icd11_diagnoses_objects_by_ids(consultation.icd11_diagnoses) - provisional_diagnosis = get_icd11_diagnoses_objects_by_ids( - consultation.icd11_provisional_diagnoses - ) - principal_diagnosis = get_icd11_diagnoses_objects_by_ids( - [consultation.icd11_principal_diagnosis] - if consultation.icd11_principal_diagnosis - else [] - ) + diagnoses = get_diagnoses_data(consultation) investigations = InvestigationValue.objects.filter( Q(consultation=consultation.id) & (Q(value__isnull=False) | Q(notes__isnull=False)) @@ -97,9 +133,11 @@ def get_discharge_summary_data(consultation: PatientConsultation): "patient": consultation.patient, "samples": samples, "hcx": hcx, - "diagnosis": diagnosis, - "provisional_diagnosis": provisional_diagnosis, - "principal_diagnosis": principal_diagnosis, + "principal_diagnoses": diagnoses["principal"], + "unconfirmed_diagnoses": diagnoses["unconfirmed"], + "provisional_diagnoses": diagnoses["provisional"], + "differential_diagnoses": diagnoses["differential"], + "confirmed_diagnoses": diagnoses["confirmed"], "consultation": consultation, "prescriptions": prescriptions, "prn_prescriptions": prn_prescriptions, diff --git a/care/hcx/api/viewsets/gateway.py b/care/hcx/api/viewsets/gateway.py index e584cad958..e04f92ebe2 100644 --- a/care/hcx/api/viewsets/gateway.py +++ b/care/hcx/api/viewsets/gateway.py @@ -12,8 +12,9 @@ from rest_framework.viewsets import GenericViewSet from care.facility.models.file_upload import FileUpload +from care.facility.models.icd11_diagnosis import ConditionVerificationStatus from care.facility.models.patient_consultation import PatientConsultation -from care.facility.static_data.icd11 import ICDDiseases +from care.facility.static_data.icd11 import get_icd11_diagnosis_object_by_id from care.facility.utils.reports.discharge_summary import ( generate_discharge_report_signed_url, ) @@ -139,40 +140,17 @@ def make_claim(self, request): ) diagnoses = [] - if len(consultation.icd11_diagnoses): - diagnoses = list( - map( - lambda diagnosis: { - "id": str(uuid()), - "label": diagnosis.label.split(" ", 1)[1], - "code": diagnosis.label.split(" ", 1)[0] or "00", - "type": "clinical", - }, - list( - map( - lambda icd_id: ICDDiseases.by.id[icd_id], - consultation.icd11_diagnoses, - ) - ), - ) - ) - - if len(consultation.icd11_provisional_diagnoses): - diagnoses = list( - map( - lambda diagnosis: { - "id": str(uuid()), - "label": diagnosis.label.split(" ", 1)[1], - "code": diagnosis.label.split(" ", 1)[0] or "00", - "type": "admitting", - }, - list( - map( - lambda icd_id: ICDDiseases.by.id[icd_id], - consultation.icd11_provisional_diagnoses, - ) - ), - ) + for diagnosis_id, is_principal in consultation.diagnoses.filter( + verification_status=ConditionVerificationStatus.CONFIRMED + ).values_list("diagnosis_id", "is_principal"): + diagnosis = get_icd11_diagnosis_object_by_id(diagnosis_id) + diagnoses.append( + { + "id": str(uuid()), + "label": diagnosis.label.split(" ", 1)[1], + "code": diagnosis.label.split(" ", 1)[0] or "00", + "type": "principal" if is_principal else "clinical", + } ) previous_claim = ( diff --git a/care/templates/reports/patient_discharge_summary_pdf.html b/care/templates/reports/patient_discharge_summary_pdf.html index 2b1ed15156..238d142a20 100644 --- a/care/templates/reports/patient_discharge_summary_pdf.html +++ b/care/templates/reports/patient_discharge_summary_pdf.html @@ -181,7 +181,73 @@

{% endif %} - {% if provisional_diagnosis %} + {% if principal_diagnosis %} +

+ Principal Diagnosis (as per ICD-11 recommended by WHO): +

+
+ + + + + + + + + {% for disease in principal_diagnosis %} + + + + + {% endfor %} + +
+ ID + + Name +
+ {{disease.id}} + + {{disease.label}} +
+
+ {% endif %} + + {% if unconfirmed_diagnoses %} +

+ Unconfirmed Diagnoses (as per ICD-11 recommended by WHO): +

+
+ + + + + + + + + {% for disease in unconfirmed_diagnoses %} + + + + + {% endfor %} + +
+ ID + + Name +
+ {{disease.id}} + + {{disease.label}} +
+
+ {% endif %} + + {% if provisional_diagnoses %}

Provisional Diagnosis (as per ICD-11 recommended by WHO):

@@ -199,7 +265,7 @@

- {% for disease in provisional_diagnosis %} + {% for disease in provisional_diagnoses %} {{disease.id}} @@ -214,9 +280,9 @@

{% endif %} - {% if diagnosis %} + {% if differential_diagnoses %}

- Diagnosis (as per ICD-11 recommended by WHO): + Differential Diagnoses (as per ICD-11 recommended by WHO):

@@ -232,7 +298,7 @@

- {% for disease in diagnosis %} + {% for disease in differential_diagnoses %}
{{disease.id}} @@ -247,9 +313,9 @@

{% endif %} - {% if principal_diagnosis %} + {% if confirmed_diagnoses %}

- Principal Diagnosis (as per ICD-11 recommended by WHO): + Confirmed Diagnoses (as per ICD-11 recommended by WHO):

@@ -265,7 +331,7 @@

- {% for disease in principal_diagnosis %} + {% for disease in confirmed_diagnoses %}
{{disease.id}} diff --git a/config/api_router.py b/config/api_router.py index 289df33b2a..cb1b98dbed 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -25,6 +25,9 @@ ConsultationBedViewSet, PatientAssetBedViewSet, ) +from care.facility.api.viewsets.consultation_diagnosis import ( + ConsultationDiagnosisViewSet, +) from care.facility.api.viewsets.daily_round import DailyRoundsViewSet from care.facility.api.viewsets.facility import AllFacilityViewSet, FacilityViewSet from care.facility.api.viewsets.facility_capacity import FacilityCapacityViewSet @@ -205,6 +208,7 @@ router, r"consultation", lookup="consultation" ) consultation_nested_router.register(r"daily_rounds", DailyRoundsViewSet) +consultation_nested_router.register(r"diagnoses", ConsultationDiagnosisViewSet) consultation_nested_router.register(r"investigation", InvestigationValueViewSet) consultation_nested_router.register(r"prescriptions", ConsultationPrescriptionViewSet) consultation_nested_router.register( From ee9ba815e7db34703f150e5b55a9cec378d1d797 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Thu, 9 Nov 2023 13:58:17 +0530 Subject: [PATCH 07/10] remove deprecated diagnosis fields --- data/dummy/facility.json | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/data/dummy/facility.json b/data/dummy/facility.json index d0e247d803..ec7d1065ca 100644 --- a/data/dummy/facility.json +++ b/data/dummy/facility.json @@ -1268,10 +1268,6 @@ "patient": 1, "patient_no": "88.99.44.66", "facility": 1, - "diagnosis": "", - "icd11_provisional_diagnoses": "[]", - "icd11_diagnoses": "[]", - "icd11_principal_diagnosis": "", "symptoms": "3", "other_symptoms": "", "symptoms_onset_date": "2022-09-27T07:19:53.380Z", @@ -2145,7 +2141,6 @@ "has_sari": false, "has_ari": false, "doctor_name": "NO DOCTOR SPECIFIED", - "diagnosis": "", "diff_diagnosis": "", "etiology_identified": "", "is_atypical_presentation": false, @@ -2178,7 +2173,6 @@ "has_sari": false, "has_ari": false, "doctor_name": "NO DOCTOR SPECIFIED", - "diagnosis": "", "diff_diagnosis": "", "etiology_identified": "", "is_atypical_presentation": false, @@ -2211,7 +2205,6 @@ "has_sari": false, "has_ari": false, "doctor_name": "NO DOCTOR SPECIFIED", - "diagnosis": "", "diff_diagnosis": "", "etiology_identified": "", "is_atypical_presentation": false, @@ -2244,7 +2237,6 @@ "has_sari": false, "has_ari": false, "doctor_name": "NO DOCTOR SPECIFIED", - "diagnosis": "", "diff_diagnosis": "", "etiology_identified": "", "is_atypical_presentation": false, @@ -2277,7 +2269,6 @@ "has_sari": false, "has_ari": false, "doctor_name": "NO DOCTOR SPECIFIED", - "diagnosis": "", "diff_diagnosis": "", "etiology_identified": "", "is_atypical_presentation": false, @@ -2310,7 +2301,6 @@ "has_sari": false, "has_ari": false, "doctor_name": "NO DOCTOR SPECIFIED", - "diagnosis": "", "diff_diagnosis": "", "etiology_identified": "", "is_atypical_presentation": false, @@ -2343,7 +2333,6 @@ "has_sari": false, "has_ari": false, "doctor_name": "NO DOCTOR SPECIFIED", - "diagnosis": "", "diff_diagnosis": "", "etiology_identified": "", "is_atypical_presentation": false, @@ -2376,7 +2365,6 @@ "has_sari": false, "has_ari": false, "doctor_name": "NO DOCTOR SPECIFIED", - "diagnosis": "", "diff_diagnosis": "", "etiology_identified": "", "is_atypical_presentation": false, @@ -2409,7 +2397,6 @@ "has_sari": false, "has_ari": false, "doctor_name": "NO DOCTOR SPECIFIED", - "diagnosis": "", "diff_diagnosis": "", "etiology_identified": "", "is_atypical_presentation": false, @@ -2442,7 +2429,6 @@ "has_sari": false, "has_ari": false, "doctor_name": "NO DOCTOR SPECIFIED", - "diagnosis": "", "diff_diagnosis": "", "etiology_identified": "", "is_atypical_presentation": false, @@ -2475,7 +2461,6 @@ "has_sari": false, "has_ari": false, "doctor_name": "NO DOCTOR SPECIFIED", - "diagnosis": "", "diff_diagnosis": "", "etiology_identified": "", "is_atypical_presentation": false, @@ -2508,7 +2493,6 @@ "has_sari": false, "has_ari": false, "doctor_name": "NO DOCTOR SPECIFIED", - "diagnosis": "", "diff_diagnosis": "", "etiology_identified": "", "is_atypical_presentation": false, @@ -2541,7 +2525,6 @@ "has_sari": false, "has_ari": false, "doctor_name": "NO DOCTOR SPECIFIED", - "diagnosis": "", "diff_diagnosis": "", "etiology_identified": "", "is_atypical_presentation": false, @@ -2574,7 +2557,6 @@ "has_sari": false, "has_ari": false, "doctor_name": "NO DOCTOR SPECIFIED", - "diagnosis": "", "diff_diagnosis": "", "etiology_identified": "", "is_atypical_presentation": false, @@ -2607,7 +2589,6 @@ "has_sari": false, "has_ari": false, "doctor_name": "NO DOCTOR SPECIFIED", - "diagnosis": "", "diff_diagnosis": "", "etiology_identified": "", "is_atypical_presentation": false, @@ -2640,7 +2621,6 @@ "has_sari": false, "has_ari": false, "doctor_name": "NO DOCTOR SPECIFIED", - "diagnosis": "", "diff_diagnosis": "", "etiology_identified": "", "is_atypical_presentation": false, @@ -2673,7 +2653,6 @@ "has_sari": false, "has_ari": false, "doctor_name": "NO DOCTOR SPECIFIED", - "diagnosis": "", "diff_diagnosis": "", "etiology_identified": "", "is_atypical_presentation": false, From 1604ed84a1ccb85c3787909a87f1a3b17cae4ab3 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Mon, 6 Nov 2023 14:15:24 +0530 Subject: [PATCH 08/10] deploy to staging gcp instance from staging image --- .github/workflows/deployment.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deployment.yaml b/.github/workflows/deployment.yaml index da086dfe81..9172c4c2fd 100644 --- a/.github/workflows/deployment.yaml +++ b/.github/workflows/deployment.yaml @@ -253,7 +253,7 @@ jobs: wait-for-service-stability: true deploy-staging-gcp: - needs: build-production + needs: build-staging name: Deploy to staging GCP cluster runs-on: ubuntu-latest environment: From 727329888fbd13ace3a837918585fe0f28013384 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Mon, 13 Nov 2023 18:23:00 +0530 Subject: [PATCH 09/10] add job to deploy to GKE Meghalaya --- .github/workflows/deployment.yaml | 46 +++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/.github/workflows/deployment.yaml b/.github/workflows/deployment.yaml index 9172c4c2fd..762f965284 100644 --- a/.github/workflows/deployment.yaml +++ b/.github/workflows/deployment.yaml @@ -527,3 +527,49 @@ jobs: kubectl apply -f care-backend.yaml kubectl apply -f care-celery-beat.yaml kubectl apply -f care-celery-worker.yaml + + deploy-production-meghalaya: + needs: build-production + name: Deploy to GKE Meghalaya + runs-on: ubuntu-latest + environment: + name: Production-Meghalaya + url: https://careapi.meghealth.gov.in + steps: + - name: Checkout Kube Config + uses: actions/checkout@v3 + with: + repository: coronasafe/ml-care-infra + token: ${{ secrets.GIT_ACCESS_TOKEN }} + path: kube + ref: main + + # Setup gcloud CLI + - uses: google-github-actions/setup-gcloud@94337306dda8180d967a56932ceb4ddcf01edae7 + with: + service_account_key: ${{ secrets.GKE_SA_KEY }} + project_id: ${{ secrets.GKE_PROJECT }} + + # Get the GKE credentials, so we can deploy to the cluster + - uses: google-github-actions/get-gke-credentials@fb08709ba27618c31c09e014e1d8364b02e5042e + with: + cluster_name: ${{ secrets.GKE_CLUSTER }} + location: ${{ secrets.GKE_ZONE }} + credentials: ${{ secrets.GKE_SA_KEY }} + + - name: install kubectl + uses: azure/setup-kubectl@v3.0 + with: + version: "v1.23.6" + id: install + + - name: Deploy Care Production Nagaland + run: | + mkdir -p $HOME/.kube/ + cd kube/deployments/ + sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-backend.yaml + sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-celery-beat.yaml + sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-celery-worker.yaml + kubectl apply -f care-backend.yaml + kubectl apply -f care-celery-beat.yaml + kubectl apply -f care-celery-worker.yaml From fa5ba427282d8fa22e2026fdf7d22c93fb070011 Mon Sep 17 00:00:00 2001 From: Ashesh <3626859+Ashesh3@users.noreply.github.com> Date: Thu, 16 Nov 2023 10:31:47 +0530 Subject: [PATCH 10/10] Fix for Incorrect Discharge Reasons in Patient Transfers and Readmissions (#1712) * Add discharge reason to PatientTransferSerializer and handle patient transfer validation * remove bed on transfer * Migration for beds attached to discharged patients * Apply suggestions from code review Co-authored-by: Aakash Singh * Update patient.py * lint * atomic transaction * add tests --------- Co-authored-by: Aakash Singh --- care/facility/api/serializers/patient.py | 23 ++++- care/facility/api/viewsets/patient.py | 13 ++- .../migrations/0394_auto_20231114_2219.py | 28 ++++++ care/facility/tests/test_patient_api.py | 95 +++++++++++++++++++ care/utils/tests/test_utils.py | 2 - 5 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 care/facility/migrations/0394_auto_20231114_2219.py diff --git a/care/facility/api/serializers/patient.py b/care/facility/api/serializers/patient.py index d3b47f24c6..eef65c7893 100644 --- a/care/facility/api/serializers/patient.py +++ b/care/facility/api/serializers/patient.py @@ -3,7 +3,7 @@ from django.conf import settings from django.db import transaction from django.db.models import Q -from django.utils.timezone import localtime, make_aware, now +from django.utils.timezone import make_aware, now from rest_framework import serializers from care.abdm.api.serializers.abhanumber import AbhaNumberSerializer @@ -27,6 +27,7 @@ PatientNotes, PatientRegistration, ) +from care.facility.models.bed import ConsultationBed from care.facility.models.notification import Notification from care.facility.models.patient_base import ( BLOOD_GROUP_CHOICES, @@ -453,10 +454,22 @@ def create(self, validated_data): def save(self, **kwargs): self.instance.facility = self.validated_data["facility"] - PatientConsultation.objects.filter( - patient=self.instance, discharge_date__isnull=True - ).update(discharge_date=localtime(now())) - self.instance.save() + + with transaction.atomic(): + consultation = PatientConsultation.objects.filter( + patient=self.instance, discharge_date__isnull=True + ).first() + + if consultation: + consultation.discharge_date = now() + consultation.discharge_reason = "REF" + consultation.current_bed = None + consultation.save() + + ConsultationBed.objects.filter( + consultation=consultation, end_date__isnull=True + ).update(end_date=now()) + self.instance.save() class PatientNotesSerializer(serializers.ModelSerializer): diff --git a/care/facility/api/viewsets/patient.py b/care/facility/api/viewsets/patient.py index a84979a9f5..afa23f9904 100644 --- a/care/facility/api/viewsets/patient.py +++ b/care/facility/api/viewsets/patient.py @@ -426,10 +426,21 @@ def list(self, request, *args, **kwargs): @action(detail=True, methods=["POST"]) def transfer(self, request, *args, **kwargs): patient = PatientRegistration.objects.get(external_id=kwargs["external_id"]) + facility = Facility.objects.get(external_id=request.data["facility"]) + + if patient.is_active and facility == patient.facility: + return Response( + { + "Patient": "Patient transfer cannot be completed because the patient has an active consultation in the same facility" + }, + status=status.HTTP_406_NOT_ACCEPTABLE, + ) if patient.allow_transfer is False: return Response( - {"Patient": "Cannot Transfer Patient , Source Facility Does Not Allow"}, + { + "Patient": "Patient transfer cannot be completed because the source facility does not permit it" + }, status=status.HTTP_406_NOT_ACCEPTABLE, ) patient.allow_transfer = False diff --git a/care/facility/migrations/0394_auto_20231114_2219.py b/care/facility/migrations/0394_auto_20231114_2219.py new file mode 100644 index 0000000000..11d7d53307 --- /dev/null +++ b/care/facility/migrations/0394_auto_20231114_2219.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.6 on 2023-11-14 16:49 + +from django.db import migrations + + +def free_discharged_beds(apps, schema_editor): + PatientConsultation = apps.get_model("facility", "PatientConsultation") + ConsultationBed = apps.get_model("facility", "ConsultationBed") + + for consultation in PatientConsultation.objects.filter( + discharge_date__isnull=False + ): + ConsultationBed.objects.filter( + consultation=consultation, end_date__isnull=True + ).update(end_date=consultation.discharge_date) + + +class Migration(migrations.Migration): + dependencies = [ + ( + "facility", + "0393_rename_diagnosis_patientconsultation_deprecated_diagnosis_and_more", + ), + ] + + operations = [ + migrations.RunPython(free_discharged_beds, migrations.RunPython.noop), + ] diff --git a/care/facility/tests/test_patient_api.py b/care/facility/tests/test_patient_api.py index 793308dcb4..493a43f24b 100644 --- a/care/facility/tests/test_patient_api.py +++ b/care/facility/tests/test_patient_api.py @@ -244,3 +244,98 @@ def test_filter_by_location(self): self.assertEqual( response.data["results"][0]["id"], str(self.patient.external_id) ) + + +class PatientTransferTestCase(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.destination_facility = cls.create_facility( + cls.super_user, cls.district, cls.local_body, name="Facility 2" + ) + cls.location = cls.create_asset_location(cls.facility) + cls.user = cls.create_user( + "doctor1", cls.district, home_facility=cls.facility, user_type=15 + ) + cls.patient = cls.create_patient(cls.district, cls.facility) + cls.consultation = cls.create_consultation( + patient_no="IP5678", + patient=cls.patient, + facility=cls.facility, + created_by=cls.user, + suggestion="A", + admission_date=now(), + discharge_date=None, # Patient is currently admitted + discharge_reason=None, + ) + cls.bed = cls.create_bed(cls.facility, cls.location) + cls.consultation_bed = cls.create_consultation_bed(cls.consultation, cls.bed) + cls.consultation.current_bed = cls.consultation_bed + cls.consultation.save() + cls.patient.last_consultation = cls.consultation + cls.patient.save() + + def test_patient_transfer(self): + self.client.force_authenticate(user=self.super_user) + response = self.client.post( + f"/api/v1/patient/{self.patient.external_id}/transfer/", + { + "date_of_birth": "1992-04-01", + "facility": self.destination_facility.external_id, + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Refresh patient data + self.patient.refresh_from_db() + self.consultation.refresh_from_db() + + # Assert the patient's facility has been updated + self.assertEqual(self.patient.facility, self.destination_facility) + + # Assert the consultation discharge reason and date are set correctly + self.assertEqual(self.consultation.discharge_reason, "REF") + self.assertIsNotNone(self.consultation.discharge_date) + + def test_transfer_with_active_consultation_same_facility(self): + # Set the patient's facility to allow transfers + self.patient.allow_transfer = True + self.patient.save() + + # Ensure transfer fails if the patient has an active consultation + self.client.force_authenticate(user=self.super_user) + response = self.client.post( + f"/api/v1/patient/{self.patient.external_id}/transfer/", + { + "date_of_birth": "1992-04-01", + "facility": self.facility.external_id, + }, + ) + self.assertEqual(response.status_code, status.HTTP_406_NOT_ACCEPTABLE) + self.assertEqual( + response.data["Patient"], + "Patient transfer cannot be completed because the patient has an active consultation in the same facility", + ) + + def test_transfer_disallowed_by_facility(self): + # Set the patient's facility to disallow transfers + self.patient.allow_transfer = False + self.patient.save() + + self.client.force_authenticate(user=self.super_user) + response = self.client.post( + f"/api/v1/patient/{self.patient.external_id}/transfer/", + { + "date_of_birth": "1992-04-01", + "facility": self.destination_facility.external_id, + }, + ) + self.assertEqual(response.status_code, status.HTTP_406_NOT_ACCEPTABLE) + self.assertEqual( + response.data["Patient"], + "Patient transfer cannot be completed because the source facility does not permit it", + ) diff --git a/care/utils/tests/test_utils.py b/care/utils/tests/test_utils.py index cd95e02299..1f6ceff22e 100644 --- a/care/utils/tests/test_utils.py +++ b/care/utils/tests/test_utils.py @@ -78,8 +78,6 @@ class TestUtils: Base class for tests, handles most of the test setup and tools for setting up data """ - maxDiff = None - def setUp(self) -> None: self.client.force_login(self.user)