From f051cabeadf880f02ca79491d3260afef8a6fb04 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Sat, 25 May 2024 19:24:36 +0530 Subject: [PATCH 1/8] bump docker healthcheck retries (#2192) --- docker/dev.Dockerfile | 2 +- docker/prod.Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/dev.Dockerfile b/docker/dev.Dockerfile index b4eb2f8c92..6cc595fa0a 100644 --- a/docker/dev.Dockerfile +++ b/docker/dev.Dockerfile @@ -27,7 +27,7 @@ HEALTHCHECK \ --interval=10s \ --timeout=5s \ --start-period=10s \ - --retries=24 \ + --retries=48 \ CMD ["/app/scripts/healthcheck.sh"] WORKDIR /app diff --git a/docker/prod.Dockerfile b/docker/prod.Dockerfile index ab6d548a73..0e89d39c17 100644 --- a/docker/prod.Dockerfile +++ b/docker/prod.Dockerfile @@ -57,7 +57,7 @@ HEALTHCHECK \ --interval=30s \ --timeout=5s \ --start-period=10s \ - --retries=6 \ + --retries=12 \ CMD ["/app/healthcheck.sh"] COPY . ${APP_HOME} From de22042e23741c8725dead138cd611405c3520a9 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Tue, 28 May 2024 16:08:40 +0530 Subject: [PATCH 2/8] Symptoms table (#2186) * consultation depth backend Co-authored-by: Rithvik Nishad Co-authored-by: Aakash Singh * refactor * fix migrations * fix test and dummy data * add is_migrated field * add created by to symptoms bulk create * fix discharge summary * make onset date non nullable * fixes unknown field excluded * fix tests * fix validations * update bulk migration to exclude symptom if already created earlier for a consultation * add clinical_impression_status to indicate symptom status * update migrations * review suggestions * add trigger for marked as errors * fix validation * fix updates * rename consultation symptom to encounter symptom * fix unable to mark as entered in error * update discharge summary pdf * add test cases and minor fixes * allow create symptoms to be empty * update migration to ignore asymptomatic symptom * rebase migrations --------- Co-authored-by: Hritesh Shanty Co-authored-by: Rithvik Nishad Co-authored-by: rithviknishad --- care/facility/api/serializers/daily_round.py | 5 +- .../api/serializers/encounter_symptom.py | 126 +++++ .../api/serializers/patient_consultation.py | 77 +++- care/facility/api/serializers/patient_icmr.py | 4 +- .../api/viewsets/encounter_symptom.py | 57 +++ care/facility/api/viewsets/patient.py | 4 - .../management/commands/load_event_types.py | 25 +- .../migrations/0439_encounter_symptoms.py | 263 +++++++++++ care/facility/models/__init__.py | 1 + care/facility/models/daily_round.py | 6 +- care/facility/models/encounter_symptom.py | 94 ++++ care/facility/models/patient_consultation.py | 18 +- care/facility/models/patient_icmr.py | 21 +- .../tests/test_encounter_symptom_api.py | 431 ++++++++++++++++++ .../tests/test_patient_consultation_api.py | 19 +- .../utils/reports/discharge_summary.py | 6 + .../patient_discharge_summary_pdf.html | 63 ++- care/utils/tests/test_utils.py | 14 +- config/api_router.py | 2 + data/dummy/facility.json | 78 ---- 20 files changed, 1154 insertions(+), 160 deletions(-) create mode 100644 care/facility/api/serializers/encounter_symptom.py create mode 100644 care/facility/api/viewsets/encounter_symptom.py create mode 100644 care/facility/migrations/0439_encounter_symptoms.py create mode 100644 care/facility/models/encounter_symptom.py create mode 100644 care/facility/tests/test_encounter_symptom_api.py diff --git a/care/facility/api/serializers/daily_round.py b/care/facility/api/serializers/daily_round.py index 0ec6bda9af..eea3e21d61 100644 --- a/care/facility/api/serializers/daily_round.py +++ b/care/facility/api/serializers/daily_round.py @@ -14,7 +14,7 @@ from care.facility.models.bed import Bed from care.facility.models.daily_round import DailyRound from care.facility.models.notification import Notification -from care.facility.models.patient_base import SYMPTOM_CHOICES, SuggestionChoices +from care.facility.models.patient_base import SuggestionChoices from care.facility.models.patient_consultation import PatientConsultation from care.users.api.serializers.user import UserBaseMinimumSerializer from care.utils.notification_handler import NotificationGenerator @@ -24,9 +24,6 @@ class DailyRoundSerializer(serializers.ModelSerializer): id = serializers.CharField(source="external_id", read_only=True) - additional_symptoms = serializers.MultipleChoiceField( - choices=SYMPTOM_CHOICES, required=False - ) deprecated_covid_category = ChoiceField( choices=COVID_CATEGORY_CHOICES, required=False ) # Deprecated diff --git a/care/facility/api/serializers/encounter_symptom.py b/care/facility/api/serializers/encounter_symptom.py new file mode 100644 index 0000000000..858ab7f9c8 --- /dev/null +++ b/care/facility/api/serializers/encounter_symptom.py @@ -0,0 +1,126 @@ +from copy import copy + +from django.db import transaction +from django.utils.timezone import now +from rest_framework import serializers + +from care.facility.events.handler import create_consultation_events +from care.facility.models.encounter_symptom import ( + ClinicalImpressionStatus, + EncounterSymptom, + Symptom, +) +from care.users.api.serializers.user import UserBaseMinimumSerializer + + +class EncounterSymptomSerializer(serializers.ModelSerializer): + id = serializers.UUIDField(source="external_id", read_only=True) + created_by = UserBaseMinimumSerializer(read_only=True) + updated_by = UserBaseMinimumSerializer(read_only=True) + + class Meta: + model = EncounterSymptom + exclude = ( + "consultation", + "external_id", + "deleted", + ) + read_only_fields = ( + "created_date", + "modified_date", + "is_migrated", + ) + + def validate_onset_date(self, value): + if value and value > now(): + raise serializers.ValidationError("Onset date cannot be in the future") + return value + + def validate(self, attrs): + validated_data = super().validate(attrs) + consultation = ( + self.instance.consultation + if self.instance + else self.context["consultation"] + ) + + onset_date = ( + self.instance.onset_date + if self.instance + else validated_data.get("onset_date") + ) + if cure_date := validated_data.get("cure_date"): + if cure_date < onset_date: + raise serializers.ValidationError( + {"cure_date": "Cure date should be after onset date"} + ) + + if validated_data.get("symptom") != Symptom.OTHERS and validated_data.get( + "other_symptom" + ): + raise serializers.ValidationError( + { + "other_symptom": "Other symptom should be empty when symptom type is not OTHERS" + } + ) + + if validated_data.get("symptom") == Symptom.OTHERS and not validated_data.get( + "other_symptom" + ): + raise serializers.ValidationError( + { + "other_symptom": "Other symptom should not be empty when symptom type is OTHERS" + } + ) + + if EncounterSymptom.objects.filter( + consultation=consultation, + symptom=validated_data.get("symptom"), + other_symptom=validated_data.get("other_symptom") or "", + cure_date__isnull=True, + clinical_impression_status=ClinicalImpressionStatus.IN_PROGRESS, + ).exists(): + raise serializers.ValidationError( + {"symptom": "An active symptom with the same details already exists"} + ) + + return validated_data + + def create(self, validated_data): + validated_data["consultation"] = self.context["consultation"] + validated_data["created_by"] = self.context["request"].user + + with transaction.atomic(): + instance: EncounterSymptom = super().create(validated_data) + + create_consultation_events( + instance.consultation_id, + instance, + instance.created_by_id, + instance.created_date, + ) + + return instance + + def update(self, instance, validated_data): + validated_data["updated_by"] = self.context["request"].user + + with transaction.atomic(): + old_instance = copy(instance) + instance = super().update(instance, validated_data) + + create_consultation_events( + instance.consultation_id, + instance, + instance.updated_by_id, + instance.modified_date, + old=old_instance, + ) + + return instance + + +class EncounterCreateSymptomSerializer(serializers.ModelSerializer): + class Meta: + model = EncounterSymptom + fields = ("symptom", "other_symptom", "onset_date", "cure_date") diff --git a/care/facility/api/serializers/patient_consultation.py b/care/facility/api/serializers/patient_consultation.py index ad0cd8ece3..e08a80ea78 100644 --- a/care/facility/api/serializers/patient_consultation.py +++ b/care/facility/api/serializers/patient_consultation.py @@ -19,6 +19,10 @@ ConsultationDiagnosisSerializer, ) from care.facility.api.serializers.daily_round import DailyRoundSerializer +from care.facility.api.serializers.encounter_symptom import ( + EncounterCreateSymptomSerializer, + EncounterSymptomSerializer, +) from care.facility.api.serializers.facility import FacilityBasicInfoSerializer from care.facility.events.handler import create_consultation_events from care.facility.models import ( @@ -32,13 +36,17 @@ ) from care.facility.models.asset import AssetLocation from care.facility.models.bed import Bed, ConsultationBed +from care.facility.models.encounter_symptom import ( + ClinicalImpressionStatus, + EncounterSymptom, + Symptom, +) from care.facility.models.icd11_diagnosis import ( ConditionVerificationStatus, ConsultationDiagnosis, ) from care.facility.models.notification import Notification from care.facility.models.patient_base import ( - SYMPTOM_CHOICES, NewDischargeReasonEnum, RouteToFacility, SuggestionChoices, @@ -66,7 +74,6 @@ class PatientConsultationSerializer(serializers.ModelSerializer): source="suggestion", ) - symptoms = serializers.MultipleChoiceField(choices=SYMPTOM_CHOICES) deprecated_covid_category = ChoiceField( choices=COVID_CATEGORY_CHOICES, required=False ) @@ -151,7 +158,13 @@ class PatientConsultationSerializer(serializers.ModelSerializer): help_text="Bulk create diagnoses for the consultation upon creation", ) diagnoses = ConsultationDiagnosisSerializer(many=True, read_only=True) - + create_symptoms = EncounterCreateSymptomSerializer( + many=True, + write_only=True, + required=False, + help_text="Bulk create symptoms for the consultation upon creation", + ) + symptoms = EncounterSymptomSerializer(many=True, read_only=True) medico_legal_case = serializers.BooleanField(default=False, required=False) def get_discharge_prescription(self, consultation): @@ -332,6 +345,7 @@ def create(self, validated_data): raise ValidationError({"route_to_facility": "This field is required"}) create_diagnosis = validated_data.pop("create_diagnoses") + create_symptoms = validated_data.pop("create_symptoms") action = -1 review_interval = -1 if "action" in validated_data: @@ -407,6 +421,19 @@ def create(self, validated_data): ] ) + symptoms = EncounterSymptom.objects.bulk_create( + EncounterSymptom( + consultation=consultation, + symptom=obj.get("symptom"), + onset_date=obj.get("onset_date"), + cure_date=obj.get("cure_date"), + clinical_impression_status=obj.get("clinical_impression_status"), + other_symptom=obj.get("other_symptom") or "", + created_by=self.context["request"].user, + ) + for obj in create_symptoms + ) + if bed and consultation.suggestion == SuggestionChoices.A: consultation_bed = ConsultationBed( bed=bed, @@ -444,7 +471,7 @@ def create(self, validated_data): create_consultation_events( consultation.id, - (consultation, *diagnosis), + (consultation, *diagnosis, *symptoms), consultation.created_by.id, consultation.created_date, ) @@ -502,6 +529,45 @@ def validate_create_diagnoses(self, value): return value + def validate_create_symptoms(self, value): + if self.instance: + raise ValidationError("Bulk create symptoms is not allowed on update") + + counter: set[int | str] = set() + for obj in value: + item: int | str = obj["symptom"] + if obj["symptom"] == Symptom.OTHERS: + other_symptom = obj.get("other_symptom") + if not other_symptom: + raise ValidationError( + { + "other_symptom": "Other symptom should not be empty when symptom type is OTHERS" + } + ) + item: str = other_symptom.strip().lower() + if item in counter: + # Reject if duplicate symptoms are provided + raise ValidationError("Duplicate symptoms are not allowed") + counter.add(item) + + current_time = now() + for obj in value: + if obj["onset_date"] > current_time: + raise ValidationError( + {"onset_date": "Onset date cannot be in the future"} + ) + + if cure_date := obj.get("cure_date"): + if cure_date < obj["onset_date"]: + raise ValidationError( + {"cure_date": "Cure date should be after onset date"} + ) + obj["clinical_impression_status"] = ClinicalImpressionStatus.COMPLETED + else: + obj["clinical_impression_status"] = ClinicalImpressionStatus.IN_PROGRESS + + return value + def validate_encounter_date(self, value): if value < MIN_ENCOUNTER_DATE: raise ValidationError( @@ -623,6 +689,9 @@ def validate(self, attrs): if not self.instance and "create_diagnoses" not in validated: raise ValidationError({"create_diagnoses": ["This field is required."]}) + if not self.instance and "create_symptoms" not in validated: + raise ValidationError({"create_symptoms": ["This field is required."]}) + return validated diff --git a/care/facility/api/serializers/patient_icmr.py b/care/facility/api/serializers/patient_icmr.py index e252ea4631..b90b5645dc 100644 --- a/care/facility/api/serializers/patient_icmr.py +++ b/care/facility/api/serializers/patient_icmr.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from care.facility.models import DISEASE_CHOICES, SAMPLE_TYPE_CHOICES, SYMPTOM_CHOICES +from care.facility.models import DISEASE_CHOICES, SAMPLE_TYPE_CHOICES from care.facility.models.patient_icmr import ( PatientConsultationICMR, PatientIcmr, @@ -124,7 +124,7 @@ class Meta: class ICMRMedicalConditionSerializer(serializers.ModelSerializer): date_of_onset_of_symptoms = serializers.DateField() - symptoms = serializers.ListSerializer(child=ChoiceField(choices=SYMPTOM_CHOICES)) + symptoms = serializers.ListSerializer(child=serializers.CharField()) hospitalization_date = serializers.DateField() hospital_phone_number = serializers.CharField( source="consultation.facility.phone_number" diff --git a/care/facility/api/viewsets/encounter_symptom.py b/care/facility/api/viewsets/encounter_symptom.py new file mode 100644 index 0000000000..3f49cef2de --- /dev/null +++ b/care/facility/api/viewsets/encounter_symptom.py @@ -0,0 +1,57 @@ +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.permissions import IsAuthenticated +from rest_framework.viewsets import ModelViewSet + +from care.facility.api.serializers.encounter_symptom import EncounterSymptomSerializer +from care.facility.models.encounter_symptom import ( + ClinicalImpressionStatus, + EncounterSymptom, +) +from care.utils.queryset.consultation import get_consultation_queryset + + +class EncounterSymptomFilter(filters.FilterSet): + is_cured = filters.BooleanFilter(method="filter_is_cured") + + def filter_is_cured(self, queryset, name, value): + if value: + return queryset.filter(cure_date__isnull=False) + return queryset.filter(cure_date__isnull=True) + + +class EncounterSymptomViewSet(ModelViewSet): + serializer_class = EncounterSymptomSerializer + permission_classes = (IsAuthenticated, DRYPermissions) + queryset = EncounterSymptom.objects.all() + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = EncounterSymptomFilter + 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 get_serializer_context(self): + context = super().get_serializer_context() + context["consultation"] = self.get_consultation_obj() + return context + + def perform_destroy(self, instance): + serializer = self.get_serializer( + instance, + data={ + "clinical_impression_status": ClinicalImpressionStatus.ENTERED_IN_ERROR + }, + partial=True, + ) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) diff --git a/care/facility/api/viewsets/patient.py b/care/facility/api/viewsets/patient.py index a79360d76c..36748513ec 100644 --- a/care/facility/api/viewsets/patient.py +++ b/care/facility/api/viewsets/patient.py @@ -178,9 +178,6 @@ def filter_by_category(self, queryset, name, value): last_consultation_discharge_date = filters.DateFromToRangeFilter( field_name="last_consultation__discharge_date" ) - last_consultation_symptoms_onset_date = filters.DateFromToRangeFilter( - field_name="last_consultation__symptoms_onset_date" - ) last_consultation_admitted_bed_type_list = MultiSelectFilter( method="filter_by_bed_type", ) @@ -449,7 +446,6 @@ class PatientViewSet( "last_vaccinated_date", "last_consultation_encounter_date", "last_consultation_discharge_date", - "last_consultation_symptoms_onset_date", ] CSV_EXPORT_LIMIT = 7 diff --git a/care/facility/management/commands/load_event_types.py b/care/facility/management/commands/load_event_types.py index f0cc50d186..7e5d689f48 100644 --- a/care/facility/management/commands/load_event_types.py +++ b/care/facility/management/commands/load_event_types.py @@ -33,14 +33,6 @@ class Command(BaseCommand): { "name": "CLINICAL", "children": ( - { - "name": "SYMPTOMS", - "fields": ( - "symptoms", - "other_symptoms", - "symptoms_onset_date", - ), - }, { "name": "DEATH", "fields": ("death_datetime", "death_confirmed_doctor"), @@ -108,10 +100,6 @@ class Command(BaseCommand): "review_after", ), "children": ( - { - "name": "ROUND_SYMPTOMS", # todo resolve clash with consultation symptoms - "fields": ("additional_symptoms",), - }, { "name": "PHYSICAL_EXAMINATION", "fields": ("physical_examination_info",), @@ -239,12 +227,25 @@ class Command(BaseCommand): "model": "ConsultationDiagnosis", "fields": ("diagnosis", "verification_status", "is_principal"), }, + { + "name": "SYMPTOMS", + "model": "EncounterSymptom", + "fields": ( + "symptom", + "other_symptom", + "onset_date", + "cure_date", + "clinical_impression_status", + ), + }, ) inactive_event_types: Tuple[str, ...] = ( "RESPIRATORY", "INTAKE_OUTPUT", "VENTILATOR_MODES", + "SYMPTOMS", + "ROUND_SYMPTOMS", "TREATING_PHYSICIAN", ) diff --git a/care/facility/migrations/0439_encounter_symptoms.py b/care/facility/migrations/0439_encounter_symptoms.py new file mode 100644 index 0000000000..67f9b17f45 --- /dev/null +++ b/care/facility/migrations/0439_encounter_symptoms.py @@ -0,0 +1,263 @@ +# Generated by Django 4.2.10 on 2024-05-17 10:52 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.core.paginator import Paginator +from django.db import migrations, models + +import care.facility.models.mixins.permissions.patient + + +def backfill_symptoms_table(apps, schema_editor): + EncounterSymptom = apps.get_model("facility", "EncounterSymptom") + PatientConsultation = apps.get_model("facility", "PatientConsultation") + DailyRound = apps.get_model("facility", "DailyRound") + + paginator = Paginator(PatientConsultation.objects.all().order_by("id"), 100) + for page_number in paginator.page_range: + bulk = [] + for consultation in paginator.page(page_number).object_list: + consultation_symptoms_set = set() + for symptom in consultation.deprecated_symptoms: + try: + symptom_id = int(symptom) + if symptom_id == 1: + # Asymptomatic + continue + if symptom_id == 9: + # Other symptom + if not consultation.deprecated_other_symptoms: + # invalid other symptom + continue + consultation_symptoms_set.add( + consultation.deprecated_other_symptoms.lower() + ) + else: + consultation_symptoms_set.add(symptom_id) + bulk.append( + EncounterSymptom( + symptom=symptom_id, + other_symptom=consultation.deprecated_other_symptoms + if symptom_id == 9 # Other symptom + else "", + onset_date=consultation.deprecated_symptoms_onset_date + or consultation.encounter_date, + created_date=consultation.created_date, + created_by=consultation.created_by, + consultation=consultation, + is_migrated=True, + ) + ) + except ValueError: + print( + f"Invalid Symptom {symptom} for Consultation {consultation.id}" + ) + + for daily_round in DailyRound.objects.filter(consultation=consultation): + for symptom in daily_round.deprecated_additional_symptoms: + try: + symptom_id = int(symptom) + if symptom_id == 1: + # Asymptomatic + continue + if symptom_id == 9: + # Other symptom + if not daily_round.deprecated_other_symptoms: + # invalid other symptom + continue + if ( + daily_round.deprecated_other_symptoms.lower() + in consultation_symptoms_set + ): + # Skip if symptom already exists + continue + consultation_symptoms_set.add( + daily_round.deprecated_other_symptoms.lower() + ) + elif symptom_id in consultation_symptoms_set: + # Skip if symptom already exists + continue + else: + consultation_symptoms_set.add(symptom_id) + + bulk.append( + EncounterSymptom( + symptom=symptom_id, + other_symptom=daily_round.deprecated_other_symptoms + if symptom_id == 9 # Other symptom + else "", + onset_date=daily_round.created_date, + created_date=daily_round.created_date, + created_by=daily_round.created_by, + consultation=daily_round.consultation, + is_migrated=True, + ) + ) + except ValueError: + print( + f"Invalid Symptom {symptom} for DailyRound {daily_round.id}" + ) + EncounterSymptom.objects.bulk_create(bulk) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("facility", "0438_alter_dailyround_patient_category_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="dailyround", + old_name="additional_symptoms", + new_name="deprecated_additional_symptoms", + ), + migrations.RenameField( + model_name="dailyround", + old_name="other_symptoms", + new_name="deprecated_other_symptoms", + ), + migrations.RenameField( + model_name="patientconsultation", + old_name="other_symptoms", + new_name="deprecated_other_symptoms", + ), + migrations.RenameField( + model_name="patientconsultation", + old_name="symptoms", + new_name="deprecated_symptoms", + ), + migrations.RenameField( + model_name="patientconsultation", + old_name="symptoms_onset_date", + new_name="deprecated_symptoms_onset_date", + ), + migrations.CreateModel( + name="EncounterSymptom", + 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)), + ( + "symptom", + models.SmallIntegerField( + choices=[ + (9, "Others"), + (2, "Fever"), + (3, "Sore Throat"), + (4, "Cough"), + (5, "Breathlessness"), + (6, "Myalgia"), + (7, "Abdominal Discomfort"), + (8, "Vomiting"), + (11, "Sputum"), + (12, "Nausea"), + (13, "Chest Pain"), + (14, "Hemoptysis"), + (15, "Nasal Discharge"), + (16, "Body Ache"), + (17, "Diarrhoea"), + (18, "Pain"), + (19, "Pedal Edema"), + (20, "Wound"), + (21, "Constipation"), + (22, "Headache"), + (23, "Bleeding"), + (24, "Dizziness"), + (25, "Chills"), + (26, "General Weakness"), + (27, "Irritability"), + (28, "Confusion"), + (29, "Abdominal Pain"), + (30, "Joint Pain"), + (31, "Redness Of Eyes"), + (32, "Anorexia"), + (33, "New Loss Of Taste"), + (34, "New Loss Of Smell"), + ] + ), + ), + ("other_symptom", models.CharField(blank=True, default="")), + ("onset_date", models.DateTimeField(null=False, blank=False)), + ("cure_date", models.DateTimeField(blank=True, null=True)), + ( + "clinical_impression_status", + models.CharField( + choices=[ + ("in-progress", "In Progress"), + ("completed", "Completed"), + ("entered-in-error", "Entered in Error"), + ], + default="in-progress", + max_length=255, + ), + ), + ( + "consultation", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="symptoms", + 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, + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + 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", + ), + ), + ], + options={ + "abstract": False, + }, + bases=( + models.Model, + care.facility.models.mixins.permissions.patient.ConsultationRelatedPermissionMixin, + ), + ), + migrations.RunPython(backfill_symptoms_table, migrations.RunPython.noop), + ] diff --git a/care/facility/models/__init__.py b/care/facility/models/__init__.py index de348c5fad..f5f21da821 100644 --- a/care/facility/models/__init__.py +++ b/care/facility/models/__init__.py @@ -5,6 +5,7 @@ from .asset import * # noqa from .bed import * # noqa from .daily_round import * # noqa +from .encounter_symptom import * # noqa from .events import * # noqa from .facility import * # noqa from .icd11_diagnosis import * # noqa diff --git a/care/facility/models/daily_round.py b/care/facility/models/daily_round.py index afb05147fb..4b2457e50c 100644 --- a/care/facility/models/daily_round.py +++ b/care/facility/models/daily_round.py @@ -141,14 +141,14 @@ class InsulinIntakeFrequencyType(enum.Enum): max_digits=4, decimal_places=2, blank=True, null=True, default=None ) physical_examination_info = models.TextField(null=True, blank=True) - additional_symptoms = MultiSelectField( + deprecated_additional_symptoms = MultiSelectField( choices=SYMPTOM_CHOICES, default=1, null=True, blank=True, max_length=get_max_length(SYMPTOM_CHOICES, None), - ) - other_symptoms = models.TextField(default="", blank=True) + ) # Deprecated + deprecated_other_symptoms = models.TextField(default="", blank=True) # Deprecated deprecated_covid_category = models.CharField( choices=COVID_CATEGORY_CHOICES, max_length=8, diff --git a/care/facility/models/encounter_symptom.py b/care/facility/models/encounter_symptom.py new file mode 100644 index 0000000000..1322decbf1 --- /dev/null +++ b/care/facility/models/encounter_symptom.py @@ -0,0 +1,94 @@ +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_consultation import PatientConsultation +from care.utils.models.base import BaseModel + + +class ClinicalImpressionStatus(models.TextChoices): + """ + See: https://fhir-ru.github.io/valueset-clinicalimpression-status.html + """ + + IN_PROGRESS = "in-progress", _("In Progress") + COMPLETED = "completed", _("Completed") + ENTERED_IN_ERROR = "entered-in-error", _("Entered in Error") + + +class Symptom(models.IntegerChoices): + OTHERS = 9 + FEVER = 2 + SORE_THROAT = 3 + COUGH = 4 + BREATHLESSNESS = 5 + MYALGIA = 6 + ABDOMINAL_DISCOMFORT = 7 + VOMITING = 8 + SPUTUM = 11 + NAUSEA = 12 + CHEST_PAIN = 13 + HEMOPTYSIS = 14 + NASAL_DISCHARGE = 15 + BODY_ACHE = 16 + DIARRHOEA = 17 + PAIN = 18 + PEDAL_EDEMA = 19 + WOUND = 20 + CONSTIPATION = 21 + HEADACHE = 22 + BLEEDING = 23 + DIZZINESS = 24 + CHILLS = 25 + GENERAL_WEAKNESS = 26 + IRRITABILITY = 27 + CONFUSION = 28 + ABDOMINAL_PAIN = 29 + JOINT_PAIN = 30 + REDNESS_OF_EYES = 31 + ANOREXIA = 32 + NEW_LOSS_OF_TASTE = 33 + NEW_LOSS_OF_SMELL = 34 + + +class EncounterSymptom(BaseModel, ConsultationRelatedPermissionMixin): + symptom = models.SmallIntegerField(choices=Symptom.choices, null=False, blank=False) + other_symptom = models.CharField(default="", blank=True, null=False) + onset_date = models.DateTimeField(null=False, blank=False) + cure_date = models.DateTimeField(null=True, blank=True) + clinical_impression_status = models.CharField( + max_length=255, + choices=ClinicalImpressionStatus.choices, + default=ClinicalImpressionStatus.IN_PROGRESS, + ) + consultation = models.ForeignKey( + PatientConsultation, + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="symptoms", + ) + created_by = models.ForeignKey( + "users.User", null=True, blank=True, on_delete=models.PROTECT, related_name="+" + ) + updated_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", + ) + + def save(self, *args, **kwargs): + if self.other_symptom and self.symptom != Symptom.OTHERS: + raise ValueError("Other Symptom should be empty when Symptom is not OTHERS") + + if self.clinical_impression_status != ClinicalImpressionStatus.ENTERED_IN_ERROR: + if self.onset_date and self.cure_date: + self.clinical_impression_status = ClinicalImpressionStatus.COMPLETED + elif self.onset_date and not self.cure_date: + self.clinical_impression_status = ClinicalImpressionStatus.IN_PROGRESS + + super().save(*args, **kwargs) diff --git a/care/facility/models/patient_consultation.py b/care/facility/models/patient_consultation.py index 91dad91de4..1e787d7885 100644 --- a/care/facility/models/patient_consultation.py +++ b/care/facility/models/patient_consultation.py @@ -72,15 +72,17 @@ class PatientConsultation(PatientBaseModel, ConsultationRelatedPermissionMixin): deprecated_icd11_principal_diagnosis = models.CharField( max_length=100, default="", blank=True, null=True ) # Deprecated in favour of ConsultationDiagnosis M2M model - symptoms = MultiSelectField( + deprecated_symptoms = MultiSelectField( choices=SYMPTOM_CHOICES, default=1, null=True, blank=True, max_length=get_max_length(SYMPTOM_CHOICES, None), - ) - other_symptoms = models.TextField(default="", blank=True) - symptoms_onset_date = models.DateTimeField(null=True, blank=True) + ) # Deprecated + deprecated_other_symptoms = models.TextField(default="", blank=True) # Deprecated + deprecated_symptoms_onset_date = models.DateTimeField( + null=True, blank=True + ) # Deprecated deprecated_covid_category = models.CharField( choices=COVID_CATEGORY_CHOICES, max_length=8, @@ -256,8 +258,8 @@ def get_related_consultation(self): CSV_MAPPING = { "consultation_created_date": "Date of Consultation", "encounter_date": "Date of Admission", - "symptoms_onset_date": "Date of Onset of Symptoms", - "symptoms": "Symptoms at time of consultation", + "deprecated_symptoms_onset_date": "Date of Onset of Symptoms", + "deprecated_symptoms": "Symptoms at time of consultation", "deprecated_covid_category": "Covid Category", "category": "Category", "examination_details": "Examination Details", @@ -276,8 +278,8 @@ def get_related_consultation(self): # CSV_DATATYPE_DEFAULT_MAPPING = { # "encounter_date": (None, models.DateTimeField(),), - # "symptoms_onset_date": (None, models.DateTimeField(),), - # "symptoms": ("-", models.CharField(),), + # "deprecated_symptoms_onset_date": (None, models.DateTimeField(),), + # "deprecated_symptoms": ("-", models.CharField(),), # "category": ("-", models.CharField(),), # "examination_details": ("-", models.CharField(),), # "suggestion": ("-", models.CharField(),), diff --git a/care/facility/models/patient_icmr.py b/care/facility/models/patient_icmr.py index 677b278322..e6f06da451 100644 --- a/care/facility/models/patient_icmr.py +++ b/care/facility/models/patient_icmr.py @@ -10,6 +10,7 @@ PatientContactDetails, PatientRegistration, PatientSample, + Symptom, ) @@ -187,19 +188,19 @@ def medical_conditions_list(self): @property def symptoms(self): - return [ - symptom - for symptom in self.consultation.symptoms - # if SYMPTOM_CHOICES[0][0] not in self.consultation.symptoms.choices.keys() - ] + symptoms = [] + for symptom in self.consultation.symptoms: + if symptom == Symptom.OTHERS: + symptoms.append(self.consultation.other_symptoms) + else: + symptoms.append(symptom) + + return symptoms @property def date_of_onset_of_symptoms(self): - return ( - self.consultation.symptoms_onset_date.date() - if self.consultation and self.consultation.symptoms_onset_date - else None - ) + if symptom := self.consultation.symptoms.first(): + return symptom.onset_date.date() class PatientConsultationICMR(PatientConsultation): diff --git a/care/facility/tests/test_encounter_symptom_api.py b/care/facility/tests/test_encounter_symptom_api.py new file mode 100644 index 0000000000..efff7fb6d2 --- /dev/null +++ b/care/facility/tests/test_encounter_symptom_api.py @@ -0,0 +1,431 @@ +from datetime import timedelta + +from django.utils.timezone import now +from rest_framework import status +from rest_framework.test import APITestCase + +from care.facility.models.encounter_symptom import ( + ClinicalImpressionStatus, + EncounterSymptom, + Symptom, +) +from care.facility.models.icd11_diagnosis import ( + ConditionVerificationStatus, + ICD11Diagnosis, +) +from care.utils.tests.test_utils import TestUtils + + +class TestEncounterSymptomInConsultation(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.patient = cls.create_patient(cls.district, cls.facility) + cls.consultation_data = cls.get_consultation_data() + cls.consultation_data.update( + { + "patient": cls.patient.external_id, + "consultation": cls.facility.external_id, + "treating_physician": cls.doctor.id, + "create_diagnoses": [ + { + "diagnosis": ICD11Diagnosis.objects.first().id, + "is_principal": False, + "verification_status": ConditionVerificationStatus.CONFIRMED, + } + ], + "create_symptoms": [ + { + "symptom": Symptom.COUGH, + "onset_date": now(), + }, + { + "symptom": Symptom.FEVER, + "onset_date": now(), + }, + ], + } + ) + + def test_create_consultation(self): + data = self.consultation_data.copy() + + response = self.client.post( + "/api/v1/consultation/", + data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(EncounterSymptom.objects.count(), 2) + + def test_create_consultation_with_duplicate_symptoms(self): + data = self.consultation_data.copy() + data["create_symptoms"] = [ + { + "symptom": Symptom.FEVER, + "onset_date": now(), + }, + { + "symptom": Symptom.FEVER, + "onset_date": now(), + }, + ] + response = self.client.post( + "/api/v1/consultation/", + data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"create_symptoms": ["Duplicate symptoms are not allowed"]}, + ) + + data["create_symptoms"] = [ + { + "symptom": Symptom.OTHERS, + "other_symptom": "Other Symptom", + "onset_date": now(), + }, + { + "symptom": Symptom.OTHERS, + "other_symptom": "Other Symptom", + "onset_date": now(), + }, + ] + + response = self.client.post( + "/api/v1/consultation/", + data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"create_symptoms": ["Duplicate symptoms are not allowed"]}, + ) + + def test_create_consultation_with_no_symptom(self): + data = self.consultation_data.copy() + + data["create_symptoms"] = [] + response = self.client.post( + "/api/v1/consultation/", + data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(EncounterSymptom.objects.count(), 0) + + def test_create_consultation_with_invalid_symptom(self): + data = self.consultation_data.copy() + data["create_symptoms"] = [ + { + "symptom": 100, + "onset_date": now(), + }, + ] + response = self.client.post( + "/api/v1/consultation/", + data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"create_symptoms": [{"symptom": ['"100" is not a valid choice.']}]}, + ) + + data["create_symptoms"] = [ + { + "symptom": Symptom.OTHERS, + "onset_date": now(), + }, + ] + response = self.client.post( + "/api/v1/consultation/", + data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + { + "create_symptoms": { + "other_symptom": "Other symptom should not be empty when symptom type is OTHERS" + } + }, + ) + + def test_create_consultation_with_no_symptom_onset_date(self): + data = self.consultation_data.copy() + data["create_symptoms"] = [ + { + "symptom": Symptom.FEVER, + }, + ] + response = self.client.post( + "/api/v1/consultation/", + data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"create_symptoms": [{"onset_date": ["This field is required."]}]}, + ) + + def test_create_consultation_with_symptom_onset_date_in_future(self): + data = self.consultation_data.copy() + data["create_symptoms"] = [ + { + "symptom": Symptom.FEVER, + "onset_date": now() + timedelta(days=1), + }, + ] + response = self.client.post( + "/api/v1/consultation/", + data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"create_symptoms": {"onset_date": "Onset date cannot be in the future"}}, + ) + + def test_create_consultation_with_cure_date_before_onset_date(self): + data = self.consultation_data.copy() + data["create_symptoms"] = [ + { + "symptom": Symptom.FEVER, + "onset_date": now(), + "cure_date": now() - timedelta(days=1), + }, + ] + response = self.client.post( + "/api/v1/consultation/", + data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"create_symptoms": {"cure_date": "Cure date should be after onset date"}}, + ) + + def test_create_consultation_with_correct_cure_date(self): + data = self.consultation_data.copy() + data["create_symptoms"] = [ + { + "symptom": Symptom.FEVER, + "onset_date": now() - timedelta(days=1), + "cure_date": now(), + }, + ] + response = self.client.post( + "/api/v1/consultation/", + data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(EncounterSymptom.objects.count(), 1) + self.assertEqual( + EncounterSymptom.objects.first().clinical_impression_status, + ClinicalImpressionStatus.COMPLETED, + ) + + +class TestEncounterSymptomApi(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.patient = cls.create_patient(cls.district, cls.facility) + cls.consultation = cls.create_consultation( + cls.patient, cls.facility, cls.doctor + ) + + def get_url(self, symptom=None): + if symptom: + return f"/api/v1/consultation/{self.consultation.external_id}/symptoms/{symptom.external_id}/" + return f"/api/v1/consultation/{self.consultation.external_id}/symptoms/" + + def test_create_new_symptom(self): + data = { + "symptom": Symptom.FEVER, + "onset_date": now(), + } + response = self.client.post( + self.get_url(), + data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(EncounterSymptom.objects.count(), 1) + + def test_create_static_symptom_with_other_symptom(self): + data = { + "symptom": Symptom.FEVER, + "other_symptom": "Other Symptom", + "onset_date": now(), + } + response = self.client.post( + self.get_url(), + data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + { + "other_symptom": [ + "Other symptom should be empty when symptom type is not OTHERS" + ] + }, + ) + + def test_create_others_symptom(self): + data = { + "symptom": Symptom.OTHERS, + "other_symptom": "Other Symptom", + "onset_date": now(), + } + response = self.client.post( + self.get_url(), + data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(EncounterSymptom.objects.count(), 1) + + def test_create_other_symptom_without_other_symptom(self): + data = { + "symptom": Symptom.OTHERS, + "onset_date": now(), + } + response = self.client.post( + self.get_url(), + data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + { + "other_symptom": [ + "Other symptom should not be empty when symptom type is OTHERS" + ] + }, + ) + + def test_create_duplicate_symptoms(self): + EncounterSymptom.objects.create( + consultation=self.consultation, + symptom=Symptom.FEVER, + onset_date=now(), + created_by=self.user, + ) + data = { + "symptom": Symptom.FEVER, + "onset_date": now(), + } + response = self.client.post( + self.get_url(), + data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"symptom": ["An active symptom with the same details already exists"]}, + ) + + def test_update_symptom(self): + symptom = EncounterSymptom.objects.create( + consultation=self.consultation, + symptom=Symptom.FEVER, + onset_date=now(), + created_by=self.user, + ) + data = { + "cure_date": now(), + } + response = self.client.patch( + self.get_url(symptom), + data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(EncounterSymptom.objects.count(), 1) + + def test_create_onset_date_in_future(self): + data = { + "symptom": Symptom.FEVER, + "onset_date": now() + timedelta(days=1), + } + response = self.client.post( + self.get_url(), + data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"onset_date": ["Onset date cannot be in the future"]}, + ) + + def test_cure_date_before_onset_date(self): + symptom = EncounterSymptom.objects.create( + consultation=self.consultation, + symptom=Symptom.FEVER, + onset_date=now(), + created_by=self.user, + ) + data = { + "cure_date": now() - timedelta(days=1), + } + response = self.client.patch( + self.get_url(symptom), + data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"cure_date": ["Cure date should be after onset date"]}, + ) + + def test_mark_symptom_as_error(self): + symptom = EncounterSymptom.objects.create( + consultation=self.consultation, + symptom=Symptom.FEVER, + onset_date=now(), + created_by=self.user, + ) + response = self.client.delete( + self.get_url(symptom), + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual( + EncounterSymptom.objects.get(id=symptom.id).clinical_impression_status, + ClinicalImpressionStatus.ENTERED_IN_ERROR, + ) diff --git a/care/facility/tests/test_patient_consultation_api.py b/care/facility/tests/test_patient_consultation_api.py index 5a4104b1ec..270cff9157 100644 --- a/care/facility/tests/test_patient_consultation_api.py +++ b/care/facility/tests/test_patient_consultation_api.py @@ -1,11 +1,12 @@ import datetime from unittest.mock import patch -from django.utils.timezone import make_aware +from django.utils.timezone import make_aware, now from rest_framework import status from rest_framework.test import APITestCase from care.facility.api.serializers.patient_consultation import MIN_ENCOUNTER_DATE +from care.facility.models.encounter_symptom import Symptom from care.facility.models.file_upload import FileUpload from care.facility.models.icd11_diagnosis import ( ConditionVerificationStatus, @@ -37,7 +38,6 @@ def setUpTestData(cls) -> None: def get_default_data(self): return { "route_to_facility": 10, - "symptoms": [1], "category": CATEGORY_CHOICES[0][0], "examination_details": "examination_details", "history_of_present_illness": "history_of_present_illness", @@ -51,6 +51,17 @@ def get_default_data(self): "verification_status": ConditionVerificationStatus.CONFIRMED, } ], + "create_symptoms": [ + { + "symptom": Symptom.FEVER, + "onset_date": now(), + }, + { + "symptom": Symptom.OTHERS, + "other_symptom": "Other Symptom", + "onset_date": now(), + }, + ], "patient_no": datetime.datetime.now().timestamp(), } @@ -419,9 +430,7 @@ def test_update_consultation_after_discharge(self): ) self.assertEqual(res.status_code, status.HTTP_200_OK) - res = self.update_consultation( - consultation, symptoms=[1, 2], category="MILD", suggestion="A" - ) + res = self.update_consultation(consultation, category="MILD", suggestion="A") self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) def test_add_diagnoses_and_duplicate_diagnoses(self): diff --git a/care/facility/utils/reports/discharge_summary.py b/care/facility/utils/reports/discharge_summary.py index ac3afd1665..acec25ea20 100644 --- a/care/facility/utils/reports/discharge_summary.py +++ b/care/facility/utils/reports/discharge_summary.py @@ -14,6 +14,7 @@ from care.facility.models import ( DailyRound, Disease, + EncounterSymptom, InvestigationValue, PatientConsultation, PatientSample, @@ -21,6 +22,7 @@ PrescriptionDosageType, PrescriptionType, ) +from care.facility.models.encounter_symptom import ClinicalImpressionStatus from care.facility.models.file_upload import FileUpload from care.facility.models.icd11_diagnosis import ( ACTIVE_CONDITION_VERIFICATION_STATUSES, @@ -97,6 +99,9 @@ def get_discharge_summary_data(consultation: PatientConsultation): ) hcx = Policy.objects.filter(patient=consultation.patient) daily_rounds = DailyRound.objects.filter(consultation=consultation) + symptoms = EncounterSymptom.objects.filter(consultation=consultation).exclude( + clinical_impression_status=ClinicalImpressionStatus.ENTERED_IN_ERROR + ) diagnoses = get_diagnoses_data(consultation) investigations = InvestigationValue.objects.filter( Q(consultation=consultation.id) @@ -133,6 +138,7 @@ def get_discharge_summary_data(consultation: PatientConsultation): "patient": consultation.patient, "samples": samples, "hcx": hcx, + "symptoms": symptoms, "principal_diagnoses": diagnoses["principal"], "unconfirmed_diagnoses": diagnoses["unconfirmed"], "provisional_diagnoses": diagnoses["provisional"], diff --git a/care/templates/reports/patient_discharge_summary_pdf.html b/care/templates/reports/patient_discharge_summary_pdf.html index 48c05155b1..06c7969b10 100644 --- a/care/templates/reports/patient_discharge_summary_pdf.html +++ b/care/templates/reports/patient_discharge_summary_pdf.html @@ -133,18 +133,6 @@

{{consultation.height}} cm

- {% if consultation.route_to_facility %} -
-

- Symptoms at admission: - {{consultation.get_symptoms_display|title}} -

-

- From: - {{consultation.symptoms_onset_date.date}} -

-
- {% endif %} {% if hcx %} @@ -193,6 +181,49 @@

{% endif %} + {% if symptoms %} +

+ Symptoms: +

+
+ + + + + + + + + + {% for symptom in symptoms %} + + + + + + {% endfor %} + +
+ Name + + Onset Date + + Cure Date +
+ {% if symptom.symptom == 9 %} + {{symptom.other_symptom}} + {% else %} + {{symptom.get_symptom_display}} + {% endif %} + + {{symptom.onset_date.date}} + + {{symptom.cure_date.date}} +
+
+ {% endif %} + {% if principal_diagnosis %}

Principal Diagnosis (as per ICD-11 recommended by WHO): @@ -765,14 +796,6 @@

{{daily_round.other_details}} -
-
- Symptoms -
-
- {{daily_round.additional_symptoms}} -
-
diff --git a/care/utils/tests/test_utils.py b/care/utils/tests/test_utils.py index acb286f043..465fa74c87 100644 --- a/care/utils/tests/test_utils.py +++ b/care/utils/tests/test_utils.py @@ -12,7 +12,6 @@ from care.facility.models import ( CATEGORY_CHOICES, DISEASE_CHOICES_MAP, - SYMPTOM_CHOICES, Ambulance, Disease, DiseaseStatusEnum, @@ -307,13 +306,8 @@ def create_patient(cls, district: District, facility: Facility, **kwargs): return patient @classmethod - def get_consultation_data(cls): + def get_consultation_data(cls) -> dict: return { - "patient": cls.patient, - "facility": cls.facility, - "symptoms": [SYMPTOM_CHOICES[0][0], SYMPTOM_CHOICES[1][0]], - "other_symptoms": "No other symptoms", - "symptoms_onset_date": make_aware(datetime(2020, 4, 7, 15, 30)), "category": CATEGORY_CHOICES[0][0], "examination_details": "examination_details", "history_of_present_illness": "history_of_present_illness", @@ -321,14 +315,12 @@ def get_consultation_data(cls): "suggestion": PatientConsultation.SUGGESTION_CHOICES[0][ 0 ], # HOME ISOLATION - "referred_to": None, "encounter_date": make_aware(datetime(2020, 4, 7, 15, 30)), "discharge_date": None, "consultation_notes": "", "course_in_facility": "", - "created_date": mock_equal, - "modified_date": mock_equal, "patient_no": int(datetime.now().timestamp() * 1000), + "route_to_facility": 10, } @classmethod @@ -336,6 +328,7 @@ def create_consultation( cls, patient: PatientRegistration, facility: Facility, + doctor: User | None = None, referred_to=None, **kwargs, ) -> PatientConsultation: @@ -345,6 +338,7 @@ def create_consultation( "patient": patient, "facility": facility, "referred_to": referred_to, + "treating_physician": doctor, } ) data.update(kwargs) diff --git a/config/api_router.py b/config/api_router.py index 70512b97c0..ab8787fcc2 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -33,6 +33,7 @@ ConsultationDiagnosisViewSet, ) from care.facility.api.viewsets.daily_round import DailyRoundsViewSet +from care.facility.api.viewsets.encounter_symptom import EncounterSymptomViewSet from care.facility.api.viewsets.events import ( EventTypeViewSet, PatientConsultationEventViewSet, @@ -227,6 +228,7 @@ ) consultation_nested_router.register(r"daily_rounds", DailyRoundsViewSet) consultation_nested_router.register(r"diagnoses", ConsultationDiagnosisViewSet) +consultation_nested_router.register(r"symptoms", EncounterSymptomViewSet) consultation_nested_router.register(r"investigation", InvestigationValueViewSet) consultation_nested_router.register(r"prescriptions", ConsultationPrescriptionViewSet) consultation_nested_router.register( diff --git a/data/dummy/facility.json b/data/dummy/facility.json index 9976ca3646..7b7485df8c 100644 --- a/data/dummy/facility.json +++ b/data/dummy/facility.json @@ -1952,9 +1952,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "3", - "other_symptoms": "", - "symptoms_onset_date": "2022-09-27T07:19:53.380Z", "deprecated_covid_category": null, "category": "Moderate", "examination_details": "", @@ -2023,9 +2020,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -2094,9 +2088,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -2165,9 +2156,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -2236,9 +2224,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -2307,9 +2292,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -2378,9 +2360,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -2449,9 +2428,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -2520,9 +2496,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -2591,9 +2564,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -2662,9 +2632,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -2733,9 +2700,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -2804,9 +2768,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -2875,9 +2836,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -2946,9 +2904,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -3017,9 +2972,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -3088,9 +3040,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -3159,9 +3108,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -3230,9 +3176,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -3301,9 +3244,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -3372,9 +3312,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -3443,9 +3380,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -3514,9 +3448,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -3585,9 +3516,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -3656,9 +3584,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", @@ -3727,9 +3652,6 @@ "deprecated_icd11_provisional_diagnoses": "[]", "deprecated_icd11_diagnoses": "[]", "deprecated_icd11_principal_diagnosis": "", - "symptoms": "1", - "other_symptoms": "", - "symptoms_onset_date": null, "deprecated_covid_category": null, "category": "Stable", "examination_details": "Examination details and Clinical conditions", From 9edfed086129f06b1e668e8e25d3d7d44b38be48 Mon Sep 17 00:00:00 2001 From: Rithvik Nishad Date: Tue, 28 May 2024 16:10:39 +0530 Subject: [PATCH 3/8] Adds support to specify and filter ration card category of a patient (#2201) * Adds support to specify and filter ration card category of a patient * Show ration card category in discharge summary --------- Co-authored-by: Vignesh Hari --- care/facility/api/viewsets/patient.py | 3 +- ...istration_ration_card_category_and_more.py | 38 +++++++++++++++++++ care/facility/models/patient.py | 11 +++++- .../patient_discharge_summary_pdf.html | 3 ++ care/utils/tests/test_utils.py | 2 + 5 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 care/facility/migrations/0439_historicalpatientregistration_ration_card_category_and_more.py diff --git a/care/facility/api/viewsets/patient.py b/care/facility/api/viewsets/patient.py index 36748513ec..f61b7ffb0e 100644 --- a/care/facility/api/viewsets/patient.py +++ b/care/facility/api/viewsets/patient.py @@ -73,7 +73,7 @@ ConditionVerificationStatus, ) from care.facility.models.notification import Notification -from care.facility.models.patient import PatientNotesEdit +from care.facility.models.patient import PatientNotesEdit, RationCardCategory from care.facility.models.patient_base import ( DISEASE_STATUS_DICT, NewDischargeReasonEnum, @@ -124,6 +124,7 @@ class PatientFilterSet(filters.FilterSet): method="filter_by_category", choices=CATEGORY_CHOICES, ) + ration_card_category = filters.ChoiceFilter(choices=RationCardCategory.choices) def filter_by_category(self, queryset, name, value): if value: diff --git a/care/facility/migrations/0439_historicalpatientregistration_ration_card_category_and_more.py b/care/facility/migrations/0439_historicalpatientregistration_ration_card_category_and_more.py new file mode 100644 index 0000000000..e40f20b2cc --- /dev/null +++ b/care/facility/migrations/0439_historicalpatientregistration_ration_card_category_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.8 on 2024-05-28 05:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0438_alter_dailyround_patient_category_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="historicalpatientregistration", + name="ration_card_category", + field=models.CharField( + choices=[ + ("NO_CARD", "Non-card holder"), + ("BPL", "BPL"), + ("APL", "APL"), + ], + max_length=8, + null=True, + ), + ), + migrations.AddField( + model_name="patientregistration", + name="ration_card_category", + field=models.CharField( + choices=[ + ("NO_CARD", "Non-card holder"), + ("BPL", "BPL"), + ("APL", "APL"), + ], + max_length=8, + null=True, + ), + ), + ] diff --git a/care/facility/models/patient.py b/care/facility/models/patient.py index 94a06f0f5f..9c30e76914 100644 --- a/care/facility/models/patient.py +++ b/care/facility/models/patient.py @@ -7,6 +7,7 @@ from django.db.models import Case, F, Func, JSONField, Value, When from django.db.models.functions import Coalesce, Now from django.utils import timezone +from django.utils.translation import gettext_lazy as _ from simple_history.models import HistoricalRecords from care.abdm.models import AbhaNumber @@ -43,6 +44,12 @@ from care.utils.models.validators import mobile_or_landline_number_validator +class RationCardCategory(models.TextChoices): + NON_CARD_HOLDER = "NO_CARD", _("Non-card holder") + BPL = "BPL", _("BPL") + APL = "APL", _("APL") + + class PatientRegistration(PatientBaseModel, PatientPermissionMixin): # fields in the PatientSearch model PATIENT_SEARCH_KEYS = [ @@ -140,7 +147,9 @@ class TestTypeEnum(enum.Enum): default="", verbose_name="Passport Number of Foreign Patients", ) - # aadhar_no = models.CharField(max_length=255, default="", verbose_name="Aadhar Number of Patient") + ration_card_category = models.CharField( + choices=RationCardCategory.choices, null=True, max_length=8 + ) is_medical_worker = models.BooleanField( default=False, verbose_name="Is the Patient a Medical Worker" diff --git a/care/templates/reports/patient_discharge_summary_pdf.html b/care/templates/reports/patient_discharge_summary_pdf.html index 06c7969b10..b5b1594b64 100644 --- a/care/templates/reports/patient_discharge_summary_pdf.html +++ b/care/templates/reports/patient_discharge_summary_pdf.html @@ -66,6 +66,9 @@

Address: {{patient.address}}

+

+ Ration Card Category: {{patient.get_ration_card_category_display|field_name_to_label}} +

diff --git a/care/utils/tests/test_utils.py b/care/utils/tests/test_utils.py index 465fa74c87..20fbfee7d2 100644 --- a/care/utils/tests/test_utils.py +++ b/care/utils/tests/test_utils.py @@ -31,6 +31,7 @@ ConsultationDiagnosis, ICD11Diagnosis, ) +from care.facility.models.patient import RationCardCategory from care.users.models import District, State @@ -275,6 +276,7 @@ def get_patient_data(cls, district, state) -> dict: "date_of_receipt_of_information": make_aware( datetime(2020, 4, 1, 15, 30, 00) ), + "ration_card_category": RationCardCategory.NON_CARD_HOLDER, } @classmethod From 0e947b8142978860a3d350c2549e360aa297cb36 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Tue, 28 May 2024 16:11:37 +0530 Subject: [PATCH 4/8] fix redis tokenization for icd11 ids (#2193) * fix redis tokenization for icd11 ids * remove index on the default label field * rename search vector field to be consistent with other models * add test case --------- Co-authored-by: Vignesh Hari --- Pipfile | 2 +- Pipfile.lock | 110 +++++++++++++------------- care/facility/api/viewsets/icd.py | 2 +- care/facility/static_data/icd11.py | 5 +- care/facility/tests/test_icd11_api.py | 3 + care/utils/static_data/helpers.py | 4 +- 6 files changed, 66 insertions(+), 60 deletions(-) diff --git a/Pipfile b/Pipfile index 2d91f60a26..df4012ccbe 100644 --- a/Pipfile +++ b/Pipfile @@ -41,10 +41,10 @@ pyjwt = "==2.8.0" python-slugify = "==8.0.1" pywebpush = "==1.14.0" redis = {extras = ["hiredis"], version = "<5.0.0"} # constraint for redis-om +redis-om = "==0.3.1" requests = "==2.31.0" sentry-sdk = "==1.30.0" whitenoise = "==6.6.0" -redis-om = "==0.2.1" [dev-packages] black = "==24.4.2" diff --git a/Pipfile.lock b/Pipfile.lock index 1007041312..5efbc2e6ac 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "3225dfd3c9b038bca4b17ef7ea788a08cbaf926bf1e12d78c4d6add45a675816" + "sha256": "bdb4245bd4ae7a35663200ca87b7da1815e7d62b411e04c021df1a02361244c8" }, "pipfile-spec": 6, "requires": { @@ -104,11 +104,11 @@ }, "botocore": { "hashes": [ - "sha256:0330d139f18f78d38127e65361859e24ebd6a8bcba184f903c01bb999a3fa431", - "sha256:5f07e2c7302c0a9f469dcd08b4ddac152e9f5888b12220242c20056255010939" + "sha256:449912ba3c4ded64f21d09d428146dd9c05337b2a112e15511bf2c4888faae79", + "sha256:8ca87776450ef41dd25c327eb6e504294230a5756940d68bcfdedc4a7cdeca97" ], "markers": "python_version >= '3.8'", - "version": "==1.34.103" + "version": "==1.34.113" }, "celery": { "hashes": [ @@ -733,11 +733,11 @@ }, "more-itertools": { "hashes": [ - "sha256:cabaa341ad0389ea83c17a94566a53ae4c9d07349861ecb14dc6d0345cf9ac5d", - "sha256:d2bc7f02446e86a68911e58ded76d6561eea00cddfb2a91e7019bbb586c799f3" + "sha256:686b06abe565edfab151cb8fd385a05651e1fdf8f0a14191e4439283421f8684", + "sha256:8fccb480c43d3e99a00087634c06dd02b0d50fbf088b380de5a41a015ec239e1" ], - "markers": "python_version >= '3.7'", - "version": "==9.1.0" + "markers": "python_version >= '3.8'", + "version": "==10.2.0" }, "newrelic": { "hashes": [ @@ -982,7 +982,7 @@ "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.9.0.post0" }, "python-fsutil": { @@ -1093,12 +1093,12 @@ }, "redis-om": { "hashes": [ - "sha256:150c9cb5238d6003f35e9b6394aab30a0df35b00e955eb7dc508f4345e0a0ccc", - "sha256:31313a3027a014608b3a4d44ecd1d3000c7d0fe3a25060db19b42225e636cd53" + "sha256:1a1eea45a507da3541a6afa982c7aecae2d58920c756525198917afc433504ee", + "sha256:c521b4e60d7bbdf537642f5b94d004330a095dcc1e4daf6efec8e46b0a2f2799" ], "index": "pypi", - "markers": "python_version >= '3.7' and python_version < '4.0'", - "version": "==0.2.1" + "markers": "python_version >= '3.8' and python_version < '4.0'", + "version": "==0.3.1" }, "referencing": { "hashes": [ @@ -1243,7 +1243,7 @@ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.16.0" }, "sqlparse": { @@ -1287,19 +1287,19 @@ }, "types-setuptools": { "hashes": [ - "sha256:a4381e041510755a6c9210e26ad55b1629bc10237aeb9cb8b6bd24996b73db48", - "sha256:a7ba908f1746c4337d13f027fa0f4a5bcad6d1d92048219ba792b3295c58586d" + "sha256:8f5379b9948682d72a9ab531fbe52932e84c4f38deda570255f9bae3edd766bc", + "sha256:e31fee7b9d15ef53980526579ac6089b3ae51a005a281acf97178e90ac71aff6" ], "markers": "python_version >= '3.8'", - "version": "==69.5.0.20240423" + "version": "==70.0.0.20240524" }, "typing-extensions": { "hashes": [ - "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", - "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" + "sha256:8cbcdc8606ebcb0d95453ad7dc5065e6237b6aa230a31e81d0f440c30fed5fd8", + "sha256:b349c66bea9016ac22978d800cfff206d5f9816951f12a7d0ec5578b0a819594" ], "markers": "python_version >= '3.8'", - "version": "==4.11.0" + "version": "==4.12.0" }, "tzdata": { "hashes": [ @@ -1328,7 +1328,7 @@ "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" ], - "markers": "python_version >= '3.8'", + "markers": "python_version >= '3.6'", "version": "==2.2.1" }, "vine": { @@ -1374,11 +1374,11 @@ }, "autopep8": { "hashes": [ - "sha256:1fa8964e4618929488f4ec36795c7ff12924a68b8bf01366c094fc52f770b6e7", - "sha256:2bb76888c5edbcafe6aabab3c47ba534f5a2c2d245c2eddced4a30c4b4946357" + "sha256:57c1026ee3ee40f57c5b93073b705f8e30aa52411fca33306d730274d2882bba", + "sha256:bc9b267f14d358a9af574b95e95a661681c60a275ffce419ba5fb4eae9920bcc" ], "markers": "python_version >= '3.8'", - "version": "==2.1.0" + "version": "==2.1.1" }, "black": { "hashes": [ @@ -1432,11 +1432,11 @@ }, "botocore": { "hashes": [ - "sha256:0330d139f18f78d38127e65361859e24ebd6a8bcba184f903c01bb999a3fa431", - "sha256:5f07e2c7302c0a9f469dcd08b4ddac152e9f5888b12220242c20056255010939" + "sha256:449912ba3c4ded64f21d09d428146dd9c05337b2a112e15511bf2c4888faae79", + "sha256:8ca87776450ef41dd25c327eb6e504294230a5756940d68bcfdedc4a7cdeca97" ], "markers": "python_version >= '3.8'", - "version": "==1.34.103" + "version": "==1.34.113" }, "botocore-stubs": { "hashes": [ @@ -1758,11 +1758,11 @@ }, "faker": { "hashes": [ - "sha256:2107618cf306bb188dcfea3e5cfd94aa92d65c7293a2437c1e96a99c83274755", - "sha256:24e28dce0b89683bb9e017e042b971c8c4909cff551b6d46f1e207674c7c2526" + "sha256:45b84f47ff1ef86e3d1a8d11583ca871ecf6730fad0660edadc02576583a2423", + "sha256:cfe97c4857c4c36ee32ea4aaabef884895992e209bae4cbd26807cf3e05c6918" ], "markers": "python_version >= '3.8'", - "version": "==25.1.0" + "version": "==25.2.0" }, "filelock": { "hashes": [ @@ -1966,10 +1966,10 @@ }, "mypy-boto3-s3": { "hashes": [ - "sha256:0d37161fd0cd7ebf194cf9ccadb9101bf5c9b2426c2d00677b7e644d6f2298e4", - "sha256:70c8bad00db70704fb7ac0ee1440c7eb0587578ae9a2b00997f29f17f60f45e7" + "sha256:95fbc6bcba2bb03c20a97cc5cf60ff66c6842c8c4fc4183c49bfa35905d5a1ee", + "sha256:a137bca9bbe86c0fe35bbf36a2d44ab62526f41bb683550dd6cfbb5a10ede832" ], - "version": "==1.34.91" + "version": "==1.34.105" }, "mypy-extensions": { "hashes": [ @@ -2021,11 +2021,11 @@ }, "platformdirs": { "hashes": [ - "sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf", - "sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1" + "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee", + "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3" ], "markers": "python_version >= '3.8'", - "version": "==4.2.1" + "version": "==4.2.2" }, "pre-commit": { "hashes": [ @@ -2087,7 +2087,7 @@ "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.9.0.post0" }, "pyyaml": { @@ -2175,18 +2175,18 @@ }, "setuptools": { "hashes": [ - "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987", - "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32" + "sha256:54faa7f2e8d2d11bcd2c07bed282eef1046b5c080d1c32add737d7b5817b1ad4", + "sha256:f211a66637b8fa059bb28183da127d4e86396c991a942b028c6650d4319c3fd0" ], "markers": "python_version >= '3.8'", - "version": "==69.5.1" + "version": "==70.0.0" }, "six": { "hashes": [ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.16.0" }, "sqlparse": { @@ -2247,11 +2247,11 @@ }, "types-requests": { "hashes": [ - "sha256:4428df33c5503945c74b3f42e82b181e86ec7b724620419a2966e2de604ce1a1", - "sha256:6216cdac377c6b9a040ac1c0404f7284bd13199c0e1bb235f4324627e8898cf5" + "sha256:26b8a6de32d9f561192b9942b41c0ab2d8010df5677ca8aa146289d11d505f57", + "sha256:f19ed0e2daa74302069bbbbf9e82902854ffa780bc790742a810a9aaa52f65ec" ], "markers": "python_version >= '3.8'", - "version": "==2.31.0.20240406" + "version": "==2.32.0.20240523" }, "types-s3transfer": { "hashes": [ @@ -2263,27 +2263,27 @@ }, "typing-extensions": { "hashes": [ - "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", - "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" + "sha256:8cbcdc8606ebcb0d95453ad7dc5065e6237b6aa230a31e81d0f440c30fed5fd8", + "sha256:b349c66bea9016ac22978d800cfff206d5f9816951f12a7d0ec5578b0a819594" ], "markers": "python_version >= '3.8'", - "version": "==4.11.0" + "version": "==4.12.0" }, "urllib3": { "hashes": [ "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" ], - "markers": "python_version >= '3.8'", + "markers": "python_version >= '3.6'", "version": "==2.2.1" }, "virtualenv": { "hashes": [ - "sha256:604bfdceaeece392802e6ae48e69cec49168b9c5f4a44e483963f9242eb0e78b", - "sha256:7aa9982a728ae5892558bff6a2839c00b9ed145523ece2274fad6f414690ae75" + "sha256:82bf0f4eebbb78d36ddaee0283d43fe5736b53880b8a8cdcd37390a07ac3741c", + "sha256:a624db5e94f01ad993d476b9ee5346fdf7b9de43ccaee0e0197012dc838a0e9b" ], "markers": "python_version >= '3.7'", - "version": "==20.26.1" + "version": "==20.26.2" }, "watchdog": { "hashes": [ @@ -2582,11 +2582,11 @@ }, "mdit-py-plugins": { "hashes": [ - "sha256:b51b3bb70691f57f974e257e367107857a93b36f322a9e6d44ca5bf28ec2def9", - "sha256:d8ab27e9aed6c38aa716819fedfde15ca275715955f8a185a8e1cf90fb1d2c1b" + "sha256:1020dfe4e6bfc2c79fb49ae4e3f5b297f5ccd20f010187acc52af2921e27dc6a", + "sha256:834b8ac23d1cd60cec703646ffd22ae97b7955a6d596eb1d304be1e251ae499c" ], "markers": "python_version >= '3.8'", - "version": "==0.4.0" + "version": "==0.4.1" }, "mdurl": { "hashes": [ @@ -2772,7 +2772,7 @@ "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" ], - "markers": "python_version >= '3.8'", + "markers": "python_version >= '3.6'", "version": "==2.2.1" } } diff --git a/care/facility/api/viewsets/icd.py b/care/facility/api/viewsets/icd.py index 886348c8cf..0b2bcc5a86 100644 --- a/care/facility/api/viewsets/icd.py +++ b/care/facility/api/viewsets/icd.py @@ -28,7 +28,7 @@ def list(self, request): query = [ICD11.has_code == 1] if q := request.query_params.get("query"): - query.append(ICD11.label % query_builder(q)) + query.append(ICD11.vec % query_builder(q)) result = FindQuery(expressions=query, model=ICD11, limit=limit).execute( exhaust_results=False diff --git a/care/facility/static_data/icd11.py b/care/facility/static_data/icd11.py index f33b2e6371..dd379671e5 100644 --- a/care/facility/static_data/icd11.py +++ b/care/facility/static_data/icd11.py @@ -19,10 +19,12 @@ class ICD11Object(TypedDict): class ICD11(BaseRedisModel): id: int = Field(primary_key=True) - label: str = Field(index=True, full_text_search=True) + label: str chapter: str has_code: int = Field(index=True) + vec: str = Field(index=True, full_text_search=True) + def get_representation(self) -> ICD11Object: return { "id": self.id, @@ -45,6 +47,7 @@ def load_icd11_diagnosis(): label=diagnosis[1], chapter=diagnosis[2] or "", has_code=1 if re.match(DISEASE_CODE_PATTERN, diagnosis[1]) else 0, + vec=diagnosis[1].replace(".", "\\.", 1), ).save() Migrator().run() print("Done") diff --git a/care/facility/tests/test_icd11_api.py b/care/facility/tests/test_icd11_api.py index 69bacc7029..f18f2a9c75 100644 --- a/care/facility/tests/test_icd11_api.py +++ b/care/facility/tests/test_icd11_api.py @@ -39,6 +39,9 @@ def test_search_with_disease_code(self): res = self.search_icd11("ME24.A1") self.assertContains(res, "ME24.A1 Haemorrhage of anus and rectum") + res = self.search_icd11("CA22.Z") + self.assertContains(res, "CA22.Z Chronic obstructive pulmonary disease") + res = self.search_icd11("1A00 Cholera") self.assertContains(res, "1A00 Cholera") diff --git a/care/utils/static_data/helpers.py b/care/utils/static_data/helpers.py index 6c0f1c2567..ff0c611cc2 100644 --- a/care/utils/static_data/helpers.py +++ b/care/utils/static_data/helpers.py @@ -2,12 +2,12 @@ from redis_om.model.token_escaper import TokenEscaper -token_escaper = TokenEscaper(re.compile(r"[,<>{}\[\]\\\"\':;!@#$%^&*()\-+=~\/ ]")) +token_escaper = TokenEscaper(re.compile(r"[,.<>{}\[\]\\\"\':;!@#$%^&*()\-+=~\/ ]")) def query_builder(query: str) -> str: """ Builds a query for redis full text search from a given query string. """ - words = query.strip().rstrip(".").rsplit(maxsplit=3) + words = query.strip().rsplit(maxsplit=3) return f"{'* '.join([token_escaper.escape(word) for word in words])}*" From 28bedb1c07b48ecc11d5e4f177532e7567ad24f3 Mon Sep 17 00:00:00 2001 From: Rithvik Nishad Date: Tue, 28 May 2024 16:12:09 +0530 Subject: [PATCH 5/8] Fixes state and district admin not able to see users of same user type level (#2200) * Fixes state and district admin not able to see users of same user type level * correct test --------- Co-authored-by: Vignesh Hari --- care/users/api/viewsets/users.py | 4 +-- care/users/tests/test_facility_user_create.py | 32 ++++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/care/users/api/viewsets/users.py b/care/users/api/viewsets/users.py index 7916d08418..f2152d3762 100644 --- a/care/users/api/viewsets/users.py +++ b/care/users/api/viewsets/users.py @@ -127,7 +127,7 @@ def get_queryset(self): if self.request.user.user_type >= User.TYPE_VALUE_MAP["StateReadOnlyAdmin"]: query |= Q( state=self.request.user.state, - user_type__lt=User.TYPE_VALUE_MAP["StateAdmin"], + user_type__lte=User.TYPE_VALUE_MAP["StateAdmin"], is_superuser=False, ) elif ( @@ -135,7 +135,7 @@ def get_queryset(self): ): query |= Q( district=self.request.user.district, - user_type__lt=User.TYPE_VALUE_MAP["DistrictAdmin"], + user_type__lte=User.TYPE_VALUE_MAP["DistrictAdmin"], is_superuser=False, ) else: diff --git a/care/users/tests/test_facility_user_create.py b/care/users/tests/test_facility_user_create.py index 54d7edea3b..e8af56e9cd 100644 --- a/care/users/tests/test_facility_user_create.py +++ b/care/users/tests/test_facility_user_create.py @@ -16,6 +16,12 @@ 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("staff1", cls.district, home_facility=cls.facility) + cls.state_admin = cls.create_user( + "stateadmin1", + cls.district, + home_facility=cls.facility, + user_type=User.TYPE_VALUE_MAP["StateAdmin"], + ) def get_base_url(self): return "/api/v1/users/add_user/" @@ -46,8 +52,8 @@ def get_detail_representation(self, obj: User = None) -> dict: "ward": getattr(obj.ward, "id", None), } - def get_new_user_data(self): - return { + def get_user_data(self, **kwargs): + data = { "username": "roopak", "user_type": "Staff", "phone_number": "+917795937091", @@ -60,18 +66,28 @@ def get_new_user_data(self): "verified": True, "facilities": [self.facility.external_id], } + data.update(kwargs) + return data.copy() def test_create_facility_user__should_fail__when_higher_level(self): - data = self.get_new_user_data().copy() - data.update({"user_type": "DistrictAdmin"}) - + data = self.get_user_data(user_type="DistrictAdmin") response = self.client.post(self.get_base_url(), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) def test_create_facility_user__should_fail__when_different_location(self): new_district = self.clone_object(self.district) - data = self.get_new_user_data().copy() - data.update({"district": new_district.id}) - + data = self.get_user_data(district=new_district.id) response = self.client.post(self.get_base_url(), data=data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_user_of_same_type(self): + self.client.force_authenticate(self.state_admin) + + data = self.get_user_data( + username="stateadmin2", user_type=User.TYPE_VALUE_MAP["StateAdmin"] + ) + res = self.client.post(self.get_base_url(), data=data, format="json") + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + + res = self.client.get("/api/v1/users/", {"username": "stateadmin2"}) + self.assertContains(res, "stateadmin2") From 5c88ca146088f75e028b563172ab38cfb65cc654 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Tue, 28 May 2024 16:14:53 +0530 Subject: [PATCH 6/8] merge migrations (#2202) --- .../migrations/0440_merge_20240528_1613.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 care/facility/migrations/0440_merge_20240528_1613.py diff --git a/care/facility/migrations/0440_merge_20240528_1613.py b/care/facility/migrations/0440_merge_20240528_1613.py new file mode 100644 index 0000000000..9fd0cd9bd3 --- /dev/null +++ b/care/facility/migrations/0440_merge_20240528_1613.py @@ -0,0 +1,15 @@ +# Generated by Django 4.2.10 on 2024-05-28 10:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0439_encounter_symptoms"), + ( + "facility", + "0439_historicalpatientregistration_ration_card_category_and_more", + ), + ] + + operations = [] From 51235a952ee5b0fb1aee4c353c93050f11946ec0 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Tue, 28 May 2024 21:29:57 +0530 Subject: [PATCH 7/8] fix state admin access to daily rounds (#2203) --- care/facility/models/daily_round.py | 3 +-- .../tests/test_patient_daily_rounds_api.py | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/care/facility/models/daily_round.py b/care/facility/models/daily_round.py index 4b2457e50c..30c18b91c4 100644 --- a/care/facility/models/daily_round.py +++ b/care/facility/models/daily_round.py @@ -589,8 +589,7 @@ def has_object_read_permission(self, request): request.user.user_type >= User.TYPE_VALUE_MAP["StateLabAdmin"] and ( self.consultation.patient.facility - and request.user.state - == self.consultation.patient.facility.district + and request.user.state == self.consultation.patient.facility.state ) ) ) diff --git a/care/facility/tests/test_patient_daily_rounds_api.py b/care/facility/tests/test_patient_daily_rounds_api.py index 06195fecb0..5145e4827e 100644 --- a/care/facility/tests/test_patient_daily_rounds_api.py +++ b/care/facility/tests/test_patient_daily_rounds_api.py @@ -16,6 +16,12 @@ def setUpTestData(cls) -> None: 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.state_admin = cls.create_user( + "state_admin", cls.district, home_facility=cls.facility, user_type=40 + ) + cls.district_admin = cls.create_user( + "district_admin", cls.district, home_facility=cls.facility, user_type=30 + ) cls.user = cls.create_user("staff1", cls.district, home_facility=cls.facility) cls.patient = cls.create_patient(district=cls.district, facility=cls.facility) cls.asset_location = cls.create_asset_location(cls.facility) @@ -72,6 +78,24 @@ def test_action_in_log_update( patient.action, PatientRegistration.ActionEnum.DISCHARGE_RECOMMENDED.value ) + def test_log_update_access_by_state_admin(self): + self.client.force_authenticate(user=self.state_admin) + response = self.client.post( + f"/api/v1/consultation/{self.consultation_with_bed.external_id}/daily_rounds/", + data=self.log_update, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_log_update_access_by_district_admin(self): + self.client.force_authenticate(user=self.district_admin) + response = self.client.post( + f"/api/v1/consultation/{self.consultation_with_bed.external_id}/daily_rounds/", + data=self.log_update, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + def test_log_update_without_bed_for_admission( self, ): From 59c05b95d098b6718531e01fd3f745e784c28d66 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Sat, 1 Jun 2024 17:25:24 +0530 Subject: [PATCH 8/8] Fix discharge summary filters (#2210) --- care/facility/templatetags/filters.py | 3 ++- care/templates/reports/patient_discharge_summary_pdf.html | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/care/facility/templatetags/filters.py b/care/facility/templatetags/filters.py index 9a8bc576fa..045819279c 100644 --- a/care/facility/templatetags/filters.py +++ b/care/facility/templatetags/filters.py @@ -22,7 +22,8 @@ def suggestion_string(suggestion_code: str): @register.filter() def field_name_to_label(value): - return value.replace("_", " ").capitalize() + if value: + return value.replace("_", " ").capitalize() @register.filter(expects_localtime=True) diff --git a/care/templates/reports/patient_discharge_summary_pdf.html b/care/templates/reports/patient_discharge_summary_pdf.html index b5b1594b64..2a2125c788 100644 --- a/care/templates/reports/patient_discharge_summary_pdf.html +++ b/care/templates/reports/patient_discharge_summary_pdf.html @@ -67,7 +67,7 @@

Address: {{patient.address}}

- Ration Card Category: {{patient.get_ration_card_category_display|field_name_to_label}} + Ration Card Category: {{patient.get_ration_card_category_display}}