From de22042e23741c8725dead138cd611405c3520a9 Mon Sep 17 00:00:00 2001
From: Aakash Singh
Date: Tue, 28 May 2024 16:08:40 +0530
Subject: [PATCH] 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:
+
+
+
+
+
+
+ Name
+ |
+
+ Onset Date
+ |
+
+ Cure Date
+ |
+
+
+
+ {% for symptom in symptoms %}
+
+
+ {% if symptom.symptom == 9 %}
+ {{symptom.other_symptom}}
+ {% else %}
+ {{symptom.get_symptom_display}}
+ {% endif %}
+ |
+
+ {{symptom.onset_date.date}}
+ |
+
+ {{symptom.cure_date.date}}
+ |
+
+ {% endfor %}
+
+
+
+ {% 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",