From 89c323a31dce757e4d8220333052414ddfc4a529 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Fri, 3 Jan 2025 15:30:53 +0530 Subject: [PATCH 1/4] Add discharge summary for encounters --- Makefile | 3 +- care/emr/api/viewsets/encounter.py | 148 ++++++- ...er_medicationrequest_dosage_instruction.py | 18 + care/emr/models/file_upload.py | 10 +- care/emr/models/patient.py | 25 ++ .../templatetags => emr/reports}/__init__.py | 0 care/emr/reports/discharge_summary.py | 257 +++++++++++ .../resources/encounter/enum_display_names.py | 58 +++ care/emr/resources/file_upload/spec.py | 1 + .../utils/reports => emr/tasks}/__init__.py | 0 .../tasks/discharge_summary.py | 18 +- care/emr/templatetags/__init__.py | 0 .../templatetags/data_formatting_extras.py} | 28 +- .../templatetags/discharge_summary_utils.py | 62 +++ .../viewsets/legacy/patient_consultation.py | 147 ------- .../templatetags/data_formatting_tags.py | 35 -- .../templatetags/prescription_tags.py | 12 - care/facility/tests/test_pdf_generation.py | 4 +- .../utils/reports/discharge_summary.py | 310 ------------- care/static/images/logos/black-logo.svg | 40 +- care/static/images/logos/light-logo.svg | 40 +- ...patient_discharge_summary_pdf_template.typ | 411 +++++------------- config/urls.py | 6 +- docker/dev.Dockerfile | 2 +- docker/prod.Dockerfile | 2 +- 25 files changed, 783 insertions(+), 854 deletions(-) create mode 100644 care/emr/migrations/0061_alter_medicationrequest_dosage_instruction.py rename care/{facility/templatetags => emr/reports}/__init__.py (100%) create mode 100644 care/emr/reports/discharge_summary.py create mode 100644 care/emr/resources/encounter/enum_display_names.py rename care/{facility/utils/reports => emr/tasks}/__init__.py (100%) rename care/{facility => emr}/tasks/discharge_summary.py (68%) create mode 100644 care/emr/templatetags/__init__.py rename care/{facility/templatetags/filters.py => emr/templatetags/data_formatting_extras.py} (50%) create mode 100644 care/emr/templatetags/discharge_summary_utils.py delete mode 100644 care/facility/templatetags/data_formatting_tags.py delete mode 100644 care/facility/templatetags/prescription_tags.py delete mode 100644 care/facility/utils/reports/discharge_summary.py diff --git a/Makefile b/Makefile index e32d247f05..f1d589c0a3 100644 --- a/Makefile +++ b/Makefile @@ -69,7 +69,8 @@ load-db: docker compose exec db sh -c "pg_restore -U postgres --clean --if-exists -d care /tmp/care_db.dump" reset-db: - docker compose exec backend bash -c "python manage.py reset_db --noinput" + docker compose exec db sh -c "dropdb -U postgres care -f" + docker compose exec db sh -c "createdb -U postgres care" ruff-all: ruff check . diff --git a/care/emr/api/viewsets/encounter.py b/care/emr/api/viewsets/encounter.py index b5fee0c84b..95a7a84955 100644 --- a/care/emr/api/viewsets/encounter.py +++ b/care/emr/api/viewsets/encounter.py @@ -1,6 +1,13 @@ +import tempfile + +from django.core.validators import validate_email as django_validate_email from django.db import transaction +from django.http import HttpResponse +from django.utils import timezone from django_filters import rest_framework as filters -from pydantic import UUID4, BaseModel +from drf_spectacular.utils import extend_schema +from pydantic import UUID4, BaseModel, field_validator +from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.generics import get_object_or_404 @@ -17,8 +24,10 @@ Encounter, EncounterOrganization, FacilityOrganization, + FileUpload, Patient, ) +from care.emr.reports import discharge_summary from care.emr.resources.encounter.constants import COMPLETED_CHOICES from care.emr.resources.encounter.spec import ( EncounterCreateSpec, @@ -27,6 +36,18 @@ EncounterUpdateSpec, ) from care.emr.resources.facility_organization.spec import FacilityOrganizationReadSpec +from care.emr.resources.file_upload.spec import ( + FileCategoryChoices, + FileTypeChoices, + FileUploadRetrieveSpec, +) +from care.emr.tasks.discharge_summary import ( + email_discharge_summary_task, + generate_discharge_summary_task, +) +from care.facility.api.serializers.patient_consultation import ( + EmailDischargeSummarySerializer, +) from care.facility.models import Facility from care.security.authorization import AuthorizationController @@ -196,3 +217,128 @@ def organizations_remove(self, request, *args, **kwargs): encounter=instance, organization=organization ).delete() return Response({}, status=204) + + def _generate_discharge_summary(self, encounter_ext_id: str): + current_progress = discharge_summary.get_progress(encounter_ext_id) + if current_progress is not None: + return Response( + { + "detail": ( + "Discharge Summary is already being generated, " + f"current progress {current_progress}%" + ) + }, + status=status.HTTP_406_NOT_ACCEPTABLE, + ) + discharge_summary.set_lock(encounter_ext_id, 1) + generate_discharge_summary_task.delay(encounter_ext_id) + return Response( + {"detail": "Discharge Summary will be generated shortly"}, + status=status.HTTP_202_ACCEPTED, + ) + + @extend_schema( + description="Generate a discharge summary", + responses={ + 200: "Success", + }, + tags=["encounter"], + ) + @action(detail=True, methods=["POST"]) + def generate_discharge_summary(self, request, *args, **kwargs): + encounter = self.get_object() + return self._generate_discharge_summary(encounter.external_id) + + @extend_schema( + description="Get the discharge summary", + responses={200: "Success"}, + tags=["encounter"], + ) + @action(detail=True, methods=["GET"]) + def preview_discharge_summary(self, request, *args, **kwargs): + encounter = self.get_object() + summary_file = ( + FileUpload.objects.filter( + file_type=FileTypeChoices.encounter.value, + file_category=FileCategoryChoices.discharge_summary.value, + associating_id=encounter.external_id, + upload_completed=True, + ) + .order_by("id") + .last() + ) + if summary_file: + return Response(FileUploadRetrieveSpec.serialize(summary_file).to_json()) + return self._generate_discharge_summary(encounter.external_id) + + class EmailDischargeSummarySpec(BaseModel): + email: str + + @field_validator("email") + @classmethod + def validate_email(cls, value): + django_validate_email(value) + return value + + @extend_schema( + description="Email the discharge summary to the user", + request=EmailDischargeSummarySerializer, + responses={200: "Success"}, + tags=["encounter"], + ) + @action(detail=True, methods=["POST"]) + def email_discharge_summary(self, request, *args, **kwargs): + encounter_ext_id = kwargs["external_id"] + existing_progress = discharge_summary.get_progress(encounter_ext_id) + if existing_progress: + return Response( + { + "detail": ( + "Discharge Summary is already being generated, " + f"current progress {existing_progress}%" + ) + }, + status=status.HTTP_406_NOT_ACCEPTABLE, + ) + + request_data = self.EmailDischargeSummarySpec(**request.data) + email = request_data.email + summary_file = ( + FileUpload.objects.filter( + file_type=FileTypeChoices.encounter.value, + file_category=FileCategoryChoices.discharge_summary.value, + associating_id=encounter_ext_id, + upload_completed=True, + ) + .order_by("id") + .last() + ) + if not summary_file: + ( + generate_discharge_summary_task.s(encounter_ext_id) + | email_discharge_summary_task.s(emails=[email]) + ).delay() + else: + email_discharge_summary_task.delay(summary_file.id, [email]) + return Response( + {"detail": "Discharge Summary will be emailed shortly"}, + status=status.HTTP_202_ACCEPTED, + ) + + +def dev_preview_discharge_summary(request, encounter_id): + """ + This is a dev only view to preview the discharge summary template + """ + encounter = get_object_or_404(Encounter, external_id=encounter_id) + data = discharge_summary.get_discharge_summary_data(encounter) + data["date"] = timezone.now() + + with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp_file: + discharge_summary.generate_discharge_summary_pdf(data, tmp_file) + tmp_file.seek(0) + + response = HttpResponse(tmp_file, content_type="application/pdf") + response["Content-Disposition"] = 'inline; filename="discharge_summary.pdf"' + + return response diff --git a/care/emr/migrations/0061_alter_medicationrequest_dosage_instruction.py b/care/emr/migrations/0061_alter_medicationrequest_dosage_instruction.py new file mode 100644 index 0000000000..87bf1714eb --- /dev/null +++ b/care/emr/migrations/0061_alter_medicationrequest_dosage_instruction.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.3 on 2025-01-02 20:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('emr', '0060_alter_medicationrequest_dosage_instruction'), + ] + + operations = [ + migrations.AlterField( + model_name='medicationrequest', + name='dosage_instruction', + field=models.JSONField(blank=True, default=list, null=True), + ), + ] diff --git a/care/emr/models/file_upload.py b/care/emr/models/file_upload.py index b7d0bc70ea..5bd92990b6 100644 --- a/care/emr/models/file_upload.py +++ b/care/emr/models/file_upload.py @@ -32,6 +32,7 @@ class FileUpload(EMRBaseModel): files_manager = S3FilesManager(BucketType.PATIENT) def get_extension(self): + # TODO: improve this logic to handle files with multiple extensions parts = self.internal_name.split(".") return f".{parts[-1]}" if len(parts) > 1 else "" @@ -40,11 +41,10 @@ def save(self, *args, **kwargs): Create a random internal name to internally manage the file This is used as an intermediate step to avoid leakage of PII in-case of data leak """ - if "force_insert" in kwargs or (not self.internal_name) or not self.id: + skip_internal_name = kwargs.pop("skip_internal_name", False) + if (not self.internal_name or not self.id) and not skip_internal_name: internal_name = str(uuid4()) + str(int(time.time())) - if self.internal_name: - parts = self.internal_name.split(".") - if len(parts) > 1: - internal_name = f"{internal_name}.{parts[-1]}" + if self.internal_name and (extension := self.get_extension()): + internal_name = f"{internal_name}{extension}" self.internal_name = internal_name return super().save(*args, **kwargs) diff --git a/care/emr/models/patient.py b/care/emr/models/patient.py index f0fda19688..430e89cbb0 100644 --- a/care/emr/models/patient.py +++ b/care/emr/models/patient.py @@ -1,6 +1,9 @@ +from dateutil.relativedelta import relativedelta from django.contrib.postgres.fields import ArrayField from django.core.validators import MinValueValidator from django.db import models +from django.template.defaultfilters import pluralize +from django.utils import timezone from care.emr.models import EMRBaseModel from care.users.models import User @@ -39,6 +42,28 @@ class Patient(EMRBaseModel): users_cache = ArrayField(models.IntegerField(), default=list) + def get_age(self) -> str: + start = self.date_of_birth or timezone.date(self.year_of_birth, 1, 1) + end = (self.deceased_datetime or timezone.now()).date() + + delta = relativedelta(end, start) + + if delta.years > 0: + year_str = f"{delta.years} year{pluralize(delta.years)}" + return f"{year_str}" + + if delta.months > 0: + month_str = f"{delta.months} month{pluralize(delta.months)}" + day_str = ( + f" {delta.days} day{pluralize(delta.days)}" if delta.days > 0 else "" + ) + return f"{month_str}{day_str}" + + if delta.days > 0: + return f"{delta.days} day{pluralize(delta.days)}" + + return "0 days" + def rebuild_organization_cache(self): organization_parents = [] if self.geo_organization: diff --git a/care/facility/templatetags/__init__.py b/care/emr/reports/__init__.py similarity index 100% rename from care/facility/templatetags/__init__.py rename to care/emr/reports/__init__.py diff --git a/care/emr/reports/discharge_summary.py b/care/emr/reports/discharge_summary.py new file mode 100644 index 0000000000..7b8252daf7 --- /dev/null +++ b/care/emr/reports/discharge_summary.py @@ -0,0 +1,257 @@ +import logging +import subprocess +import tempfile +import time +from collections.abc import Iterable +from pathlib import Path +from uuid import uuid4 + +from django.conf import settings +from django.core.cache import cache +from django.core.mail import EmailMessage +from django.template.loader import render_to_string +from django.utils import timezone + +from care.emr.models import ( + AllergyIntolerance, + Condition, + Encounter, + FileUpload, + Observation, + medication_request, +) +from care.emr.resources.allergy_intolerance.spec import ( + VerificationStatusChoices as AllergyVerificationStatusChoices, +) +from care.emr.resources.condition.spec import CategoryChoices, VerificationStatusChoices +from care.emr.resources.file_upload.spec import FileCategoryChoices, FileTypeChoices +from care.emr.resources.medication.request.spec import MedicationRequestStatus + +logger = logging.getLogger(__name__) + +LOCK_DURATION = 2 * 60 # 2 minutes + + +def lock_key(encounter_ext_id: str): + return f"discharge_summary_{encounter_ext_id}" + + +def set_lock(encounter_ext_id: str, progress: int): + cache.set(lock_key(encounter_ext_id), progress, timeout=LOCK_DURATION) + + +def get_progress(encounter_ext_id: str): + return cache.get(lock_key(encounter_ext_id)) + + +def clear_lock(encounter_ext_id: str): + cache.delete(lock_key(encounter_ext_id)) + + +def parse_iso_datetime(date_str): + try: + return timezone.datetime.fromisoformat(date_str) + except ValueError: + return None + + +def format_duration(duration): + if not duration: + return "" + + if duration.days > 0: + return f"{duration.days} days" + hours, remainder = divmod(duration.seconds, 3600) + minutes, _ = divmod(remainder, 60) + return f"{hours:02}:{minutes:02}" + + +def get_discharge_summary_data(encounter: Encounter): + logger.info("fetching discharge summary data for %s", encounter.external_id) + symptoms = Condition.objects.filter( + encounter=encounter, + category=CategoryChoices.problem_list_item.value, + ).exclude(verification_status=VerificationStatusChoices.entered_in_error) + diagnoses = ( + Condition.objects.filter( + encounter=encounter, + category=CategoryChoices.encounter_diagnosis.value, + ) + .exclude(verification_status=VerificationStatusChoices.entered_in_error) + .order_by("id") + ) + principal_diagnosis = diagnoses[0] if diagnoses else None + + allergies = sorted( + AllergyIntolerance.objects.filter(encounter=encounter).exclude( + verification_status=AllergyVerificationStatusChoices.entered_in_error + ), + key=lambda x: ("high", "low", "unable-to-assess", "", None).index( + x.criticality + ), + ) + + observations = ( + Observation.objects.filter( + encounter=encounter, + ) + .select_related("data_entered_by") + .order_by("id") + ) + + medication_requests = ( + medication_request.MedicationRequest.objects.filter(encounter=encounter) + .exclude(status=MedicationRequestStatus.entered_in_error.value) + .select_related("created_by") + ) + + files = FileUpload.objects.filter( + associating_id=encounter.external_id, + upload_completed=True, + is_archived=False, + ) + + admission_duration = ( + format_duration( + parse_iso_datetime(encounter.period.get("end")) + - parse_iso_datetime(encounter.period.get("start")) + ) + if encounter.period.get("end", None) and encounter.period.get("start", None) + else None + ) + + return { + "encounter": encounter, + "admission_duration": admission_duration, + "patient": encounter.patient, + "symptoms": symptoms, + "diagnoses": diagnoses, + "principal_diagnosis": principal_diagnosis, + "allergies": allergies, + "observations": observations, + "medication_requests": medication_requests, + "files": files, + } + + +def compile_typ(output_file, data): + try: + logo_path = ( + Path(settings.BASE_DIR) + / "staticfiles" + / "images" + / "logos" + / "black-logo.svg" + ) + + data["logo_path"] = str(logo_path) + + content = render_to_string( + "reports/patient_discharge_summary_pdf_template.typ", context=data + ) + + subprocess.run( # noqa: S603 + [ # noqa: S607 + "typst", + "compile", + "-", + str(output_file), + ], + input=content.encode("utf-8"), + capture_output=True, + check=True, + cwd="/", + ) + + logging.info( + "Successfully Compiled Summary pdf for %s", data["encounter"].external_id + ) + return True + + except subprocess.CalledProcessError as e: + logging.error( + "Error compiling summary pdf for %s: %s", + data["encounter"].external_id, + e.stderr.decode("utf-8"), + ) + return False + + +def generate_discharge_summary_pdf(data, file): + logger.info( + "Generating Discharge Summary pdf for %s", data["encounter"].external_id + ) + compile_typ(output_file=file.name, data=data) + logger.info( + "Successfully Generated Discharge Summary pdf for %s", + data["encounter"].external_id, + ) + + +def generate_and_upload_discharge_summary(encounter: Encounter): + logger.info("Generating Discharge Summary for %s", encounter.external_id) + + set_lock(encounter.external_id, 5) + try: + current_date = timezone.now() + timestamp = int(current_date.timestamp() * 1000) + patient_name_slug: str = encounter.patient.name.lower().replace(" ", "_") + summary_file = FileUpload( + name=f"discharge_summary-{patient_name_slug}-{timestamp}.pdf", + internal_name=f"{uuid4()}{int(time.time())}.pdf", + file_type=FileTypeChoices.encounter.value, + file_category=FileCategoryChoices.discharge_summary.value, + associating_id=encounter.external_id, + ) + + set_lock(encounter.external_id, 10) + data = get_discharge_summary_data(encounter) + data["date"] = current_date + + set_lock(encounter.external_id, 50) + with tempfile.NamedTemporaryFile(suffix=".pdf") as file: + generate_discharge_summary_pdf(data, file) + logger.info("Uploading Discharge Summary for %s", encounter.external_id) + summary_file.files_manager.put_object( + summary_file, file, ContentType="application/pdf" + ) + summary_file.upload_completed = True + summary_file.save(skip_internal_name=True) + logger.info( + "Uploaded Discharge Summary for %s, file id: %s", + encounter.external_id, + summary_file.id, + ) + finally: + clear_lock(encounter.external_id) + + return summary_file + + +def email_discharge_summary(summary_file: FileUpload, emails: Iterable[str]): + msg = EmailMessage( + "Patient Discharge Summary", + "Please find the attached file", + settings.DEFAULT_FROM_EMAIL, + emails, + ) + msg.content_subtype = "html" + _, data = summary_file.files_manager.file_contents(summary_file) + msg.attach(summary_file.name, data, "application/pdf") + return msg.send() + + +def generate_discharge_report_signed_url(patient_external_id: str): + encounter = ( + Encounter() + .objects.filter(patient__external_id=patient_external_id) + .order_by("-created_date") + .first() + ) + if not encounter: + return None + + summary_file = generate_and_upload_discharge_summary(encounter) + return summary_file.files_manager.signed_url( + summary_file, duration=2 * 24 * 60 * 60 + ) diff --git a/care/emr/resources/encounter/enum_display_names.py b/care/emr/resources/encounter/enum_display_names.py new file mode 100644 index 0000000000..21bf27326e --- /dev/null +++ b/care/emr/resources/encounter/enum_display_names.py @@ -0,0 +1,58 @@ +from care.emr.resources.encounter.constants import ( + AdmitSourcesChoices, + DischargeDispositionChoices, +) + + +def get_admit_source_display(value: str) -> str: # noqa: PLR0911 + match value: + case AdmitSourcesChoices.hosp_trans.value: + return "Transferred from other hospital" + case AdmitSourcesChoices.emd.value: + return "From accident/emergency department" + case AdmitSourcesChoices.outp.value: + return "From outpatient department" + case AdmitSourcesChoices.born.value: + return "Born in hospital" + case AdmitSourcesChoices.gp.value: + return "General Practitioner referral" + case AdmitSourcesChoices.mp.value: + return "Medical Practitioner/physician referral" + case AdmitSourcesChoices.nursing.value: + return "From nursing home" + case AdmitSourcesChoices.psych.value: + return "From psychiatric hospital" + case AdmitSourcesChoices.rehab.value: + return "From rehabilitation facility" + case AdmitSourcesChoices.other.value: + return "Other" + case _: + return "Unknown" + + +def get_discharge_disposition_display(value: str) -> str: # noqa: PLR0911 + match value: + case DischargeDispositionChoices.home.value: + return "Home" + case DischargeDispositionChoices.alt_home.value: + return "Alternate Home" + case DischargeDispositionChoices.other_hcf.value: + return "Other Health Care Facility" + case DischargeDispositionChoices.hosp.value: + return "Hospital" + case DischargeDispositionChoices.long.value: + return "Long-term Care Facility" + case DischargeDispositionChoices.aadvice.value: + return "Against Medical Advice" + case DischargeDispositionChoices.exp.value: + return "Expired" + case DischargeDispositionChoices.psy.value: + return "Psychiatric Hospital" + case DischargeDispositionChoices.rehab.value: + return "Rehabilitation Facility" + case DischargeDispositionChoices.snf.value: + return "Skilled Nursing Facility" + case DischargeDispositionChoices.oth.value: + return "Other" + case _: + return "N/A" diff --git a/care/emr/resources/file_upload/spec.py b/care/emr/resources/file_upload/spec.py index 69a866e3f7..f8c5341dda 100644 --- a/care/emr/resources/file_upload/spec.py +++ b/care/emr/resources/file_upload/spec.py @@ -18,6 +18,7 @@ class FileCategoryChoices(str, Enum): xray = "xray" identity_proof = "identity_proof" unspecified = "unspecified" + discharge_summary = "discharge_summary" class FileUploadBaseSpec(EMRResource): diff --git a/care/facility/utils/reports/__init__.py b/care/emr/tasks/__init__.py similarity index 100% rename from care/facility/utils/reports/__init__.py rename to care/emr/tasks/__init__.py diff --git a/care/facility/tasks/discharge_summary.py b/care/emr/tasks/discharge_summary.py similarity index 68% rename from care/facility/tasks/discharge_summary.py rename to care/emr/tasks/discharge_summary.py index 8f47a469c8..5ab69192ef 100644 --- a/care/facility/tasks/discharge_summary.py +++ b/care/emr/tasks/discharge_summary.py @@ -6,9 +6,9 @@ from celery import shared_task from celery.utils.log import get_task_logger -from care.facility.models import PatientConsultation -from care.facility.models.file_upload import FileUpload -from care.facility.utils.reports.discharge_summary import ( +from care.emr.models.encounter import Encounter +from care.emr.models.file_upload import FileUpload +from care.emr.reports.discharge_summary import ( email_discharge_summary, generate_and_upload_discharge_summary, ) @@ -20,18 +20,18 @@ @shared_task( autoretry_for=(ClientError,), retry_kwargs={"max_retries": 3}, expires=10 * 60 ) -def generate_discharge_summary_task(consultation_ext_id: str): +def generate_discharge_summary_task(encounter_ext_id: str): """ Generate and Upload the Discharge Summary """ - logger.info("Generating Discharge Summary for %s", consultation_ext_id) + logger.info("Generating Discharge Summary for %s", encounter_ext_id) try: - consultation = PatientConsultation.objects.get(external_id=consultation_ext_id) - except PatientConsultation.DoesNotExist as e: - msg = f"Consultation {consultation_ext_id} does not exist" + encounter = Encounter.objects.get(external_id=encounter_ext_id) + except Encounter.DoesNotExist as e: + msg = f"Encounter {encounter_ext_id} does not exist" raise CeleryTaskError(msg) from e - summary_file = generate_and_upload_discharge_summary(consultation) + summary_file = generate_and_upload_discharge_summary(encounter) if not summary_file: msg = "Unable to generate discharge summary" raise CeleryTaskError(msg) diff --git a/care/emr/templatetags/__init__.py b/care/emr/templatetags/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/facility/templatetags/filters.py b/care/emr/templatetags/data_formatting_extras.py similarity index 50% rename from care/facility/templatetags/filters.py rename to care/emr/templatetags/data_formatting_extras.py index 2a15bc2d55..2e239099c6 100644 --- a/care/facility/templatetags/filters.py +++ b/care/emr/templatetags/data_formatting_extras.py @@ -1,23 +1,17 @@ from datetime import datetime from django.template import Library +from django.utils import timezone register = Library() -@register.filter(name="suggestion_string") -def suggestion_string(suggestion_code: str): - if suggestion_code == "A": - return "Admission" - if suggestion_code == "HI": - return "Home Isolation" - if suggestion_code == "R": - return "Referral" - if suggestion_code == "OP": - return "OP Consultation" - if suggestion_code == "DC": - return "Domiciliary Care" - return "Other" +@register.filter() +def format_empty_data(data): + if data in (None, "", 0.0, []): + return "N/A" + + return data @register.filter() @@ -33,3 +27,11 @@ def parse_datetime(value): return datetime.strptime(value, "%Y-%m-%dT%H:%M") # noqa: DTZ007 except ValueError: return None + + +@register.filter(expects_localtime=True) +def parse_iso_datetime(value): + try: + return timezone.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%fZ") + except ValueError: + return None diff --git a/care/emr/templatetags/discharge_summary_utils.py b/care/emr/templatetags/discharge_summary_utils.py new file mode 100644 index 0000000000..d0fb01fe76 --- /dev/null +++ b/care/emr/templatetags/discharge_summary_utils.py @@ -0,0 +1,62 @@ +from django import template + +from care.emr.models.medication_request import MedicationRequest +from care.emr.models.observation import Observation +from care.emr.resources.encounter.constants import ( + ClassChoices, +) +from care.emr.resources.encounter.enum_display_names import ( + get_admit_source_display, + get_discharge_disposition_display, +) + +register = template.Library() + + +@register.filter +def admit_source_display(value: str) -> str: + return get_admit_source_display(value) + + +@register.filter +def discharge_summary_display(value: str) -> str: + match value: + case ClassChoices.imp.value | ClassChoices.emer.value: + return "Discharge Summary" + case ClassChoices.amb.value: + return "Outpatient Summary" + case ClassChoices.hh.value: + return "Home Health Summary" + case ClassChoices.vr.value: + return "Virtual Care Summary" + case ClassChoices.obsenc.value: + return "Observation Summary" + case _: + return "Patient Summary" + + +@register.filter +def discharge_disposition_display(value: str) -> str: + return get_discharge_disposition_display(value) + + +@register.filter +def observation_value_display(observation: Observation) -> str | None: + if observation.value.get("value_code", None): + return observation.value.value_code.get("display", None) + if observation.value.get("value_quantity", None): + unit: str = observation.value.value_quantity.get("unit", {}).get( + "display", None + ) + value: float | None = observation.value.value_quantity.get("value", None) + value = int(value) if value and value.is_integer() else value + return f"{value} {unit}" if unit else value + return observation.value.get("value", None) + + +@register.filter +def medication_dosage_display(medication: MedicationRequest) -> str: + try: + return medication.dosage_instruction[0]["text"] + except (IndexError, KeyError, TypeError): + return None diff --git a/care/facility/api/viewsets/legacy/patient_consultation.py b/care/facility/api/viewsets/legacy/patient_consultation.py index c236179448..855b4a353d 100644 --- a/care/facility/api/viewsets/legacy/patient_consultation.py +++ b/care/facility/api/viewsets/legacy/patient_consultation.py @@ -1,11 +1,7 @@ -import tempfile - from django.db import transaction from django.db.models import Prefetch from django.db.models.query_utils import Q -from django.http import HttpResponse from django.shortcuts import get_object_or_404 -from django.utils import timezone from django_filters import rest_framework as filters from drf_spectacular.utils import extend_schema from dry_rest_permissions.generics import DRYPermissions @@ -16,7 +12,6 @@ from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet -from care.facility.api.serializers.file_upload import FileUploadRetrieveSerializer from care.facility.api.serializers.patient_consultation import ( EmailDischargeSummarySerializer, PatientConsentSerializer, @@ -26,17 +21,11 @@ ) from care.facility.api.viewsets.mixins.access import AssetUserAccessMixin from care.facility.models.bed import AssetBed, ConsultationBed -from care.facility.models.file_upload import FileUpload from care.facility.models.mixins.permissions.asset import IsAssetUser from care.facility.models.patient_consultation import ( PatientConsent, PatientConsultation, ) -from care.facility.tasks.discharge_summary import ( - email_discharge_summary_task, - generate_discharge_summary_task, -) -from care.facility.utils.reports import discharge_summary from care.users.models import Skill, User from care.utils.cache.cache_allowed_facilities import get_accessible_facilities from care.utils.queryset.consultation import get_consultation_queryset @@ -124,119 +113,8 @@ def discharge_patient(self, request, *args, **kwargs): serializer = self.get_serializer(consultation, data=request.data) serializer.is_valid(raise_exception=True) serializer.save(current_bed=None) - discharge_summary.set_lock(consultation.external_id, 0) - generate_discharge_summary_task.delay(consultation.external_id) return Response(status=status.HTTP_200_OK) - def _generate_discharge_summary(self, consultation_ext_id: str): - current_progress = discharge_summary.get_progress(consultation_ext_id) - if current_progress is not None: - return Response( - { - "detail": ( - "Discharge Summary is already being generated, " - f"current progress {current_progress}%" - ) - }, - status=status.HTTP_406_NOT_ACCEPTABLE, - ) - discharge_summary.set_lock(consultation_ext_id, 1) - generate_discharge_summary_task.delay(consultation_ext_id) - return Response( - {"detail": "Discharge Summary will be generated shortly"}, - status=status.HTTP_202_ACCEPTED, - ) - - @extend_schema( - description="Generate a discharge summary", - responses={ - 200: "Success", - }, - tags=["consultation"], - ) - @action(detail=True, methods=["POST"]) - def generate_discharge_summary(self, request, *args, **kwargs): - consultation = self.get_object() - if consultation.discharge_date: - return Response( - { - "detail": ( - "Cannot generate a new discharge summary for already " - "discharged patient" - ) - }, - status=status.HTTP_406_NOT_ACCEPTABLE, - ) - return self._generate_discharge_summary(consultation.external_id) - - @extend_schema( - description="Get the discharge summary", - responses={200: "Success"}, - tags=["consultation"], - ) - @action(detail=True, methods=["GET"]) - def preview_discharge_summary(self, request, *args, **kwargs): - consultation = self.get_object() - summary_file = ( - FileUpload.objects.filter( - file_type=FileUpload.FileType.DISCHARGE_SUMMARY.value, - associating_id=consultation.external_id, - upload_completed=True, - ) - .order_by("-created_date") - .first() - ) - if summary_file: - return Response(FileUploadRetrieveSerializer(summary_file).data) - return self._generate_discharge_summary(consultation.external_id) - - @extend_schema( - description="Email the discharge summary to the user", - request=EmailDischargeSummarySerializer, - responses={200: "Success"}, - tags=["consultation"], - ) - @action(detail=True, methods=["POST"]) - def email_discharge_summary(self, request, *args, **kwargs): - consultation_ext_id = kwargs["external_id"] - existing_progress = discharge_summary.get_progress(consultation_ext_id) - if existing_progress: - return Response( - { - "detail": ( - "Discharge Summary is already being generated, " - f"current progress {existing_progress}%" - ) - }, - status=status.HTTP_406_NOT_ACCEPTABLE, - ) - - serializer = self.get_serializer( - data=request.data, context={"request": request} - ) - serializer.is_valid(raise_exception=True) - email = serializer.validated_data["email"] - summary_file = ( - FileUpload.objects.filter( - file_type=FileUpload.FileType.DISCHARGE_SUMMARY.value, - associating_id=consultation_ext_id, - upload_completed=True, - ) - .order_by("-created_date") - .first() - ) - if not summary_file: - ( - generate_discharge_summary_task.s(consultation_ext_id) - | email_discharge_summary_task.s(emails=[email]) - ).delay() - else: - email_discharge_summary_task.delay(summary_file.id, [email]) - return Response( - {"detail": "Discharge Summary will be emailed shortly"}, - status=status.HTTP_202_ACCEPTED, - ) - @extend_schema( responses={200: PatientConsultationIDSerializer}, tags=["consultation", "asset"] ) @@ -286,31 +164,6 @@ def patient_from_asset(self, request): ) -def dev_preview_discharge_summary(request, consultation_id): - """ - This is a dev only view to preview the discharge summary template - """ - consultation = ( - PatientConsultation.objects.select_related("patient") - .order_by("-id") - .filter(external_id=consultation_id) - .first() - ) - if not consultation: - raise NotFound({"detail": "Consultation not found"}) - data = discharge_summary.get_discharge_summary_data(consultation) - data["date"] = timezone.now() - - with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp_file: - discharge_summary.generate_discharge_summary_pdf(data, tmp_file) - tmp_file.seek(0) - - response = HttpResponse(tmp_file, content_type="application/pdf") - response["Content-Disposition"] = 'inline; filename="discharge_summary.pdf"' - - return response - - class PatientConsentViewSet( AssetUserAccessMixin, mixins.CreateModelMixin, diff --git a/care/facility/templatetags/data_formatting_tags.py b/care/facility/templatetags/data_formatting_tags.py deleted file mode 100644 index 5e2a1ce087..0000000000 --- a/care/facility/templatetags/data_formatting_tags.py +++ /dev/null @@ -1,35 +0,0 @@ -from django import template - -register = template.Library() - - -@register.filter(name="format_empty_data") -def format_empty_data(data): - if data is None or data in ("", 0.0, []): - return "N/A" - - return data - - -@register.filter(name="format_to_sentence_case") -def format_to_sentence_case(data): - if data is None: - return None - - def convert_to_sentence_case(s): - if s == "ICU": - return "ICU" - s = s.lower() - s = s.replace("_", " ") - return s.capitalize() - - if isinstance(data, str): - items = data.split(", ") - converted_items = [convert_to_sentence_case(item) for item in items] - return ", ".join(converted_items) - - if isinstance(data, list | tuple): - converted_items = [convert_to_sentence_case(item) for item in data] - return ", ".join(converted_items) - - return data diff --git a/care/facility/templatetags/prescription_tags.py b/care/facility/templatetags/prescription_tags.py deleted file mode 100644 index 30d9f11a27..0000000000 --- a/care/facility/templatetags/prescription_tags.py +++ /dev/null @@ -1,12 +0,0 @@ -from django import template - -register = template.Library() - - -@register.filter(name="format_prescription") -def format_prescription(prescription): - if prescription.dosage_type == "TITRATED": - return f"{prescription.medicine_name}, titration from {prescription.base_dosage} to {prescription.target_dosage}, {prescription.route}, {prescription.frequency} for {prescription.days} days." - if prescription.dosage_type == "PRN": - return f"{prescription.medicine_name}, {prescription.base_dosage}, {prescription.route}" - return f"{prescription.medicine_name}, {prescription.base_dosage}, {prescription.route}, {prescription.frequency} for {prescription.days} days." diff --git a/care/facility/tests/test_pdf_generation.py b/care/facility/tests/test_pdf_generation.py index 470601b14f..e3cb7d934a 100644 --- a/care/facility/tests/test_pdf_generation.py +++ b/care/facility/tests/test_pdf_generation.py @@ -10,12 +10,12 @@ from PIL import Image from rest_framework.test import APIClient +from care.emr.reports import discharge_summary +from care.emr.reports.discharge_summary import compile_typ from care.facility.models import ( PrescriptionDosageType, PrescriptionType, ) -from care.facility.utils.reports import discharge_summary -from care.facility.utils.reports.discharge_summary import compile_typ from care.utils.tests.test_utils import TestUtils diff --git a/care/facility/utils/reports/discharge_summary.py b/care/facility/utils/reports/discharge_summary.py deleted file mode 100644 index 633e8849d4..0000000000 --- a/care/facility/utils/reports/discharge_summary.py +++ /dev/null @@ -1,310 +0,0 @@ -import logging -import subprocess -import tempfile -from collections.abc import Iterable -from pathlib import Path -from uuid import uuid4 - -from django.conf import settings -from django.core.cache import cache -from django.core.mail import EmailMessage -from django.db.models import Case, IntegerField, Q, Value, When -from django.template.loader import render_to_string -from django.utils import timezone - -from care.facility.models import ( - BedType, - ConsultationBed, - Disease, - EncounterSymptom, - InvestigationValue, - PatientConsultation, - PatientSample, - Prescription, - 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, - ConditionVerificationStatus, -) - -logger = logging.getLogger(__name__) - -LOCK_DURATION = 2 * 60 # 2 minutes - - -def lock_key(consultation_ext_id: str): - return f"discharge_summary_{consultation_ext_id}" - - -def set_lock(consultation_ext_id: str, progress: int): - cache.set(lock_key(consultation_ext_id), progress, timeout=LOCK_DURATION) - - -def get_progress(consultation_ext_id: str): - return cache.get(lock_key(consultation_ext_id)) - - -def clear_lock(consultation_ext_id: str): - cache.delete(lock_key(consultation_ext_id)) - - -def get_diagnoses_data(consultation: PatientConsultation): - entries = ( - consultation.diagnoses.filter( - verification_status__in=ACTIVE_CONDITION_VERIFICATION_STATUSES - ) - .order_by("-created_date") - .values_list( - "diagnosis_id", - "verification_status", - "is_principal", - ) - ) - - # retrieve diagnosis objects - diagnoses = [] - for _ in entries: - diagnose = [] - if diagnose: - diagnoses.append(diagnose) - principal, unconfirmed, provisional, differential, confirmed = [], [], [], [], [] - - for diagnosis, record in zip(diagnoses, entries, strict=False): - _, verification_status, is_principal = record - - diagnosis.verification_status = verification_status - - if is_principal: - principal.append(diagnosis) - if verification_status == ConditionVerificationStatus.UNCONFIRMED: - unconfirmed.append(diagnosis) - if verification_status == ConditionVerificationStatus.PROVISIONAL: - provisional.append(diagnosis) - if verification_status == ConditionVerificationStatus.DIFFERENTIAL: - differential.append(diagnosis) - if verification_status == ConditionVerificationStatus.CONFIRMED: - confirmed.append(diagnosis) - - return { - "principal": principal, - "unconfirmed": unconfirmed, - "provisional": provisional, - "differential": differential, - "confirmed": confirmed, - } - - -def format_duration(duration): - if not duration: - return "" - - days = duration.days - if days > 0: - return f"{days} days" - hours, remainder = divmod(duration.seconds, 3600) - minutes, _ = divmod(remainder, 60) - return f"{hours:02}:{minutes:02}" - - -def get_discharge_summary_data(consultation: PatientConsultation): - logger.info("fetching discharge summary data for %s", consultation.external_id) - samples = PatientSample.objects.filter( - patient=consultation.patient, consultation=consultation - ) - symptoms = EncounterSymptom.objects.filter( - consultation=consultation, onset_date__lt=consultation.encounter_date - ).exclude(clinical_impression_status=ClinicalImpressionStatus.ENTERED_IN_ERROR) - diagnoses = get_diagnoses_data(consultation) - investigations = InvestigationValue.objects.filter( - Q(consultation=consultation.id) - & (Q(value__isnull=False) | Q(notes__isnull=False)) - ) - medical_history = Disease.objects.filter(patient=consultation.patient) - prescriptions = ( - Prescription.objects.filter( - consultation=consultation, prescription_type=PrescriptionType.REGULAR.value - ) - .annotate( - order_priority=Case( - When(dosage_type=PrescriptionDosageType.PRN.value, then=Value(2)), - When(dosage_type=PrescriptionDosageType.TITRATED.value, then=Value(1)), - default=Value(0), - output_field=IntegerField(), - ) - ) - .order_by("order_priority", "id") - ) - discharge_prescriptions = ( - Prescription.objects.filter( - consultation=consultation, - prescription_type=PrescriptionType.DISCHARGE.value, - ) - .annotate( - order_priority=Case( - When(dosage_type=PrescriptionDosageType.PRN.value, then=Value(2)), - When(dosage_type=PrescriptionDosageType.TITRATED.value, then=Value(1)), - default=Value(0), - output_field=IntegerField(), - ) - ) - .order_by("order_priority", "id") - ) - files = FileUpload.objects.filter( - associating_id=consultation.id, - file_type=FileUpload.FileType.CONSULTATION.value, - upload_completed=True, - is_archived=False, - ) - admitted_to = set() - if ConsultationBed.objects.filter(consultation=consultation).exists(): - for bed in ConsultationBed.objects.filter(consultation=consultation).order_by( - "-created_date" - ): - admitted_to.add(BedType(bed.bed.bed_type).name) - admitted_to = list(admitted_to) - if not admitted_to: - admitted_to = None - - admission_duration = ( - format_duration(consultation.discharge_date - consultation.encounter_date) - if consultation.discharge_date - else None - ) - - return { - "patient": consultation.patient, - "samples": samples, - "symptoms": symptoms, - "admitted_to": admitted_to, - "admission_duration": admission_duration, - "diagnoses": diagnoses["confirmed"] - + diagnoses["provisional"] - + diagnoses["unconfirmed"] - + diagnoses["differential"], - "primary_diagnoses": diagnoses["principal"], - "consultation": consultation, - "prescriptions": prescriptions, - "discharge_prescriptions": discharge_prescriptions, - "medical_history": medical_history, - "investigations": investigations, - "files": files, - } - - -def compile_typ(output_file, data): - try: - logo_path = ( - Path(settings.BASE_DIR) - / "staticfiles" - / "images" - / "logos" - / "black-logo.svg" - ) - - data["logo_path"] = str(logo_path) - - content = render_to_string( - "reports/patient_discharge_summary_pdf_template.typ", context=data - ) - - subprocess.run( # noqa: S603 - [ # noqa: S607 - "typst", - "compile", - "-", - str(output_file), - ], - input=content.encode("utf-8"), - capture_output=True, - check=True, - cwd="/", - ) - - logging.info( - "Successfully Compiled Summary pdf for %s", data["consultation"].external_id - ) - return True - - except subprocess.CalledProcessError as e: - logging.error( - "Error compiling summary pdf for %s: %s", - data["consultation"].external_id, - e.stderr.decode("utf-8"), - ) - return False - - -def generate_discharge_summary_pdf(data, file): - logger.info( - "Generating Discharge Summary pdf for %s", data["consultation"].external_id - ) - compile_typ(output_file=file.name, data=data) - logger.info( - "Successfully Generated Discharge Summary pdf for %s", - data["consultation"].external_id, - ) - - -def generate_and_upload_discharge_summary(consultation: PatientConsultation): - logger.info("Generating Discharge Summary for %s", consultation.external_id) - - set_lock(consultation.external_id, 5) - try: - current_date = timezone.now() - summary_file = FileUpload( - name=f"discharge_summary-{consultation.patient.name}-{current_date}", - internal_name=f"{uuid4()}.pdf", - file_type=FileUpload.FileType.DISCHARGE_SUMMARY.value, - associating_id=consultation.external_id, - ) - - set_lock(consultation.external_id, 10) - data = get_discharge_summary_data(consultation) - data["date"] = current_date - - set_lock(consultation.external_id, 50) - with tempfile.NamedTemporaryFile(suffix=".pdf") as file: - generate_discharge_summary_pdf(data, file) - logger.info("Uploading Discharge Summary for %s", consultation.external_id) - summary_file.put_object(file, ContentType="application/pdf") - summary_file.upload_completed = True - summary_file.save() - logger.info( - "Uploaded Discharge Summary for %s, file id: %s", - consultation.external_id, - summary_file.id, - ) - finally: - clear_lock(consultation.external_id) - - return summary_file - - -def email_discharge_summary(summary_file: FileUpload, emails: Iterable[str]): - msg = EmailMessage( - "Patient Discharge Summary", - "Please find the attached file", - settings.DEFAULT_FROM_EMAIL, - emails, - ) - msg.content_subtype = "html" - _, data = summary_file.file_contents() - msg.attach(summary_file.name, data, "application/pdf") - return msg.send() - - -def generate_discharge_report_signed_url(patient_external_id: str): - consultation = ( - PatientConsultation.objects.filter(patient__external_id=patient_external_id) - .order_by("-created_date") - .first() - ) - if not consultation: - return None - - summary_file = generate_and_upload_discharge_summary(consultation) - return summary_file.read_signed_url(duration=2 * 24 * 60 * 60) diff --git a/care/static/images/logos/black-logo.svg b/care/static/images/logos/black-logo.svg index cc5e8b4fa2..1c6722c1da 100644 --- a/care/static/images/logos/black-logo.svg +++ b/care/static/images/logos/black-logo.svg @@ -1,7 +1,35 @@ - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/care/static/images/logos/light-logo.svg b/care/static/images/logos/light-logo.svg index d455764a13..3194746f90 100644 --- a/care/static/images/logos/light-logo.svg +++ b/care/static/images/logos/light-logo.svg @@ -1,7 +1,35 @@ - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/care/templates/reports/patient_discharge_summary_pdf_template.typ b/care/templates/reports/patient_discharge_summary_pdf_template.typ index c24079b6dc..256e75a4a6 100644 --- a/care/templates/reports/patient_discharge_summary_pdf_template.typ +++ b/care/templates/reports/patient_discharge_summary_pdf_template.typ @@ -1,7 +1,7 @@ {% load static %} {% load filters static %} -{% load prescription_tags %} -{% load data_formatting_tags %} +{% load data_formatting_extras %} +{% load discharge_summary_utils %} #set page("a4",margin: 40pt) #set text(font: "DejaVu Sans",size: 10pt,hyphenate: true) @@ -19,7 +19,7 @@ stroke: frame(rgb("21222C")), ) -#let facility_name="{{patient.facility.name}}" +#let facility_name="{{encounter.facility.name}}" #align(center, text(24pt,weight: "bold")[= #facility_name]) @@ -29,12 +29,8 @@ columns: (auto, 1fr), row-gutter: 1em, align: (left, right), - {% if consultation.suggestion == "A" %} - text(size: 15pt)[= Patient Discharge Summary], - {% else %} - text(size: 15pt)[= Patient Summary], - {% endif %} - grid.cell(align: right, rowspan: 2)[#scale(x:90%, y:90%, reflow: true)[#image("{{ logo_path }}")]], + text(size: 15pt)[= {{ encounter.encounter_class|discharge_summary_display }}], + grid.cell(align: right, rowspan: 2)[#image("{{ logo_path }}", width: 32%)], [#text(fill: mygray, weight: 500)[*Created on {{date}}*]] ) @@ -47,9 +43,9 @@ columns: (1fr, 1fr, 1fr, 1fr), row-gutter: 1.5em, [Full name:], "{{patient.name}}", - [Gender:], "{{patient.get_gender_display }}", + [Gender:], "{{patient.gender|field_name_to_label }}", [Age:], "{{patient.get_age }}", - [Blood Group:], "{{patient.blood_group }}", + [Blood Group:], "{{patient.blood_group|field_name_to_label }}", [Phone Number:], "{{patient.phone_number }}", [Ration Card Category:], "{{patient.get_ration_card_category_display|format_empty_data }}", [Address:], grid.cell(colspan: 3, "{{patient.address }}"), @@ -57,332 +53,145 @@ #line(length: 100%, stroke: mygray) -{% if consultation.suggestion == "A" %} -#align(left, text(18pt)[== Admission Details]) -{% else %} -#align(left, text(18pt)[== Patient Details]) -{% endif %} +#align(left, text(18pt)[== Visit Details]) #text("") #grid( columns: (1.1fr, 2fr), row-gutter: 1.2em, align: (left), - [Route to Facility:], "{{ consultation.get_route_to_facility_display | field_name_to_label }}", - {% if consultation.suggestion == "A" %} - [Admitted To:], "{{ admitted_to|format_to_sentence_case|format_empty_data }}", + [Route to Facility:], "{{ encounter.hospitalization.admit_source|admit_source_display }}", + {% if encounter.encounter_class == "imp" %} + [Admitted To:], "{{ encounter.facility.name|format_empty_data }}", // TODO: show bed info instead of facility name [Duration of Admission:], "{{admission_duration|format_empty_data}}", - [Date of admission:], "{{ consultation.encounter_date }}", - [IP No:], "{{ consultation.patient_no }}", - [Weight:], - {% if consultation.weight == 0.0 %} - "N/A" - {% else %} - "{{ consultation.weight }} kg" - {% endif %}, - [Height:], - {% if consultation.height == 0.0 %} - "N/A" - {% else %} - "{{ consultation.height }} cm" - {% endif %}, - [Diagnosis at admission:],[#stack( - dir: ttb, - spacing: 10pt, - {% for diagnose in diagnoses %} - "{{ diagnose.label }} ({{diagnose.verification_status }})", - {% endfor %} - )], - [Reason for admission:],[#stack( - dir: ttb, - spacing: 10pt, - {% if primary_diagnoses %} - {% for diagnose in primary_diagnoses %} - "{{ diagnose.label }}", - {% endfor %} - {% else %} - "N/A" - {% endif %} - )], - [Symptoms at admission], [#stack( - dir: ttb, - spacing: 10pt, - {% if symptoms %} - {% for symptom in symptoms %} - {% if symptom.symptom == 9 %} - "{{ symptom.other_symptom }}", - {% else %} - "{{ symptom.get_symptom_display }}", - {% endif %} - {% endfor %} - {% else %} - "Asymptomatic" - {% endif %} - )], + [Date of admission:], "{{ encounter.period.start|parse_iso_datetime|format_empty_data }}", + [IP No:], "{{ encounter.external_identifier }}", {% else %} - [OP No:], "{{ consultation.patient_no }}", - [Weight:], - {% if consultation.weight == 0.0 %} - "N/A" - {% else %} - "{{ consultation.weight }} kg" - {% endif %}, - [Height:], - {% if consultation.height == 0.0 %} - "N/A" + [OP No:], "{{ encounter.external_identifier }}", + {% endif %} + [Diagnosis:],[#stack( + dir: ttb, + spacing: 10pt, + {% for diagnose in diagnoses %} + "{{ diagnose.code.display }} ({{diagnose.verification_status }})", + {% endfor %} + )], + [Principal Diagnosis:], + {% if principal_diagnosis %} + "{{ principal_diagnosis.code.display }}", + {% else %} + "N/A", + {% endif %} + [Symptoms:], [#stack( + dir: ttb, + spacing: 10pt, + {% if symptoms %} + {% for symptom in symptoms %} + "{{ symptom.code.display }}", + {% endfor %} {% else %} - "{{ consultation.height }} cm" - {% endif %}, - [Diagnosis:],[#stack( + "Asymptomatic" + {% endif %} + )], + [Reported Allergies:], + {% if allergies %} + [#stack( dir: ttb, spacing: 10pt, - {% for diagnose in diagnoses %} - "{{ diagnose.label }} ({{diagnose.verification_status }})", + {% for allergy in allergies %} + "{{ allergy.code.display }}", {% endfor %} )], - [Principal Diagnosis:],[#stack( - dir: ttb, - spacing: 10pt, - {% if primary_diagnoses %} - {% for diagnose in primary_diagnoses %} - "{{ diagnose.label }}", - {% endfor %} - {% else %} - "N/A" - {% endif %} - )], - [Symptoms], [#stack( - dir: ttb, - spacing: 10pt, - {% if symptoms %} - {% for symptom in symptoms %} - {% if symptom.symptom == 9 %} - "{{ symptom.other_symptom }}", - {% else %} - "{{ symptom.get_symptom_display }}", - {% endif %} - {% endfor %} - {% else %} - "Asymptomatic" - {% endif %} - )], + {% else %} + "N/A", {% endif %} - [Reported Allergies:], "{{ patient.allergies |format_empty_data }}", ) -#text("\n") +#text("") #align(center, [#line(length: 40%, stroke: mygray)]) -{% if medical_history.0.get_disease_display != "NO" %} - -#align(left, text(14pt,weight: "bold",)[=== Medication History:]) - -#table( - columns: (1.5fr, 3.5fr), - inset: 10pt, - align: horizon, - table.header( - [*COMORBIDITY*], [*DETAILS*], - ), - {% for disease in medical_history %} - "{{disease.get_disease_display }}", "{{disease.details|format_empty_data }}", - {% endfor %} -) -#align(center, [#line(length: 40%, stroke: mygray,)]) -{% endif %} -{% if consultation.suggestion != 'DD' %} - {% if patient.disease_status == 2 or prescriptions or consultation.investigation or consultation.procedure or investigations or samples %} - #align(left, text(18pt,)[== Treatment Summary]) - #text("") - {% endif %} +// TODO: add comorbidity info - {% if patient.disease_status == 2 %} - #grid( - columns: (1fr, 1fr), - gutter: 1.4em, - align: (left), - [COVID Disease Status:], [Positive], - {% if patient.date_of_result %} - [Test Result Date:], "{{ patient.date_of_result.date }}", - {% endif %} - [Vaccinated against COVID:], [ - {% if patient.is_vaccinated %} - Yes - {% else %} - No +{% if medication_requests %} + #align(left, text(14pt,weight: "bold",)[=== Medication Requests:]) + #table( + columns: (1fr,), + inset: 10pt, + align: horizon, + stroke: 1pt, + table.header( + align(center, text([*MEDICATION REQUESTS*])) + ), + {% for medication in medication_requests %} + [#grid( + columns: (1fr, 3fr), + row-gutter: 1.2em, + align: (left), + [Name:], "{{ medication.meta.medication.display }} ({{ medication.meta.medication.system }} {{ medication.meta.medication.code }})", + [Dosage:], "{{ medication|medication_dosage_display|format_empty_data }}", + {% if medication.created_by %} + [Prescribed By:], "{{ medication.created_by.fullname|default:medication.created_by.username }}", {% endif %} - ], - ) - {% endif %} - - {% if prescriptions %} - #align(left, text(14pt,weight: "bold",)[=== Medication Administered:]) - #table( - columns: (1fr,), - inset: 10pt, - align: horizon, - stroke: 1pt, - table.header( - align(center, text([*MEDICATION DETAILS*])) - ), - {% for prescription in prescriptions %} - [#grid( - columns: (0.5fr, 9.5fr), - row-gutter: 1.2em, - align: (left), - "{{ forloop.counter }}", - "{{ prescription|format_prescription }}", - )], - {% endfor %} - ) - - #align(center, [#line(length: 40%, stroke: mygray,)]) - {% endif %} - - {% if consultation.investigation %} - #align(left, text(14pt,weight: "bold",)[=== Investigations Conducted:]) - - #table( - columns: (1.5fr, 1fr, 1.5fr), - inset: 10pt, - align: horizon, - table.header( - [*TYPE*], [*TIME*], [*NOTES*] - ), - {% for investigation in consultation.investigation %} - "{{ investigation.type|join:", " }}", - "{% if investigation.repetitive %}every {{ investigation.frequency }}{% else %}{{ investigation.time|date:"DATETIME_FORMAT" }}{% endif %}", - "{{ investigation.notes |format_empty_data }}", - {% endfor %} - ) - - #align(center, [#line(length: 40%, stroke: mygray,)]) - {% endif %} - - {% if consultation.procedure %} - #align(left, text(14pt,weight: "bold",)[=== Procedures Conducted:]) - - #table( - columns: (1fr, 1fr, 2fr), - inset: 10pt, - align: horizon, - table.header( - [*PROCEDURE*], [*TIME*], [*NOTES*] - ), - {% for procedure in consultation.procedure %} - "{{ procedure.procedure }}", - "{% if procedure.repetitive %} every {{procedure.frequency }} {% else %} {{procedure.time|parse_datetime }} {% endif %}", - "{{ procedure.notes |format_empty_data }}", - {% endfor %} - ) - - #align(center, [#line(length: 40%, stroke: mygray,)]) - {% endif %} + [Date:], "{{ medication.authored_on|default:medication.created_date }}", + )], + {% endfor %} + ) - {% if samples %} - #align(left, text(14pt,weight: "bold",)[=== Lab Reports:]) + #align(center, [#line(length: 40%, stroke: mygray)]) - #table( - columns: (1fr, 1fr, 1fr,1fr), - inset: 10pt, - align: horizon, - table.header( - [*REQUESTED ON*], [*SAMPLE TYPE*], [*LABEL*],[*RESULT*], - ), - {% for sample in samples %} - "{{ sample.created_date }}", "{{ sample.get_sample_type_display }}", "{{ sample.icmr_label }}","{{ sample.get_result_display }}", - {% endfor %} - ) +{% endif %} - #align(center, [#line(length: 40%, stroke: mygray,)]) - {% endif %} - {% if investigations %} - #align(left, text(14pt,weight: "bold")[=== Investigation History:]) - #table( - columns: (1fr,), - inset: 10pt, - align: horizon, - stroke: 1pt, - table.header( - align(center, text([*INVESTIGATION DETAILS*])) - ), - {% for investigation in investigations %} +{% if observations %} + #align(left, text(14pt,weight: "bold")[=== Observations:]) + #table( + columns: (1fr,), + inset: 10pt, + align: horizon, + stroke: 1pt, + table.header( + align(center, text([*OBSERVATION DETAILS*])) + ), + {% for observation in observations %} + {% if observation.main_code.display and observation|observation_value_display %} [#grid( columns: (1fr, 3fr), row-gutter: 1.2em, align: (left), - [Group:], "{{ investigation.investigation.groups.first }}", - [Name:], "{{ investigation.investigation.name }}", - [Result:], [{% if investigation.value %}{{ investigation.value }}{% else %}{{ investigation.notes }}{% endif %}], - [Range:], [{% if investigation.investigation.min_value and investigation.investigation.max_value %} - {{ investigation.investigation.min_value }} - {{ investigation.investigation.max_value }} - {% else %} - - - {% endif %} - {% if investigation.investigation.unit %} - {{ investigation.investigation.unit }} - {% endif %} - ], - [Date:], "{{ investigation.created_date }}", + [Name:], "{{ observation.main_code.display }} ({{ observation.main_code.system }} {{ observation.main_code.code }})", + [Value:], "{{ observation|observation_value_display|field_name_to_label|format_empty_data }}", + {% if observation.body_site %} + [Body Site:], "{{ observation.body_site.display }}", + {% endif %} + [Date:], "{{ observation.effective_datetime }}", + [Data Entered By:], "{{ observation.data_entered_by.fullname|default:observation.data_entered_by.username }}", + {% if observation.note %} + [Note:], "{{ observation.note }}", + {% endif %} )], - {% endfor %} - ) - - #align(center, [#line(length: 40%, stroke: mygray)]) - {% endif %} + {% endif %} + {% endfor %} + ) + #align(center, [#line(length: 40%, stroke: mygray)]) {% endif %} -#align(left, text(18pt,)[== Discharge Summary]) -#grid( - columns: (1fr,3fr), - row-gutter: 1.2em, - align: (left), - [Discharge Date:], "{{consultation.discharge_date|format_empty_data }}", - [Discharge Reason:], "{{consultation.get_new_discharge_reason_display|format_to_sentence_case|format_empty_data }}", - [Discharge Advice:], "{{consultation.discharge_notes|format_empty_data }}", -) -{% if consultation.new_discharge_reason == 1 %} - {% if discharge_prescriptions %} - #align(left, text(14pt,weight: "bold",)[=== Discharge Prescription :]) - #table( - columns: (1fr,), - inset: 10pt, - align: horizon, - stroke: 1pt, - table.header( - align(center, text([*MEDICATION DETAILS*])) - ), - {% for prescription in discharge_prescriptions %} - [#grid( - columns: (0.5fr, 9.5fr), - row-gutter: 1.2em, - align: (left), - "{{ forloop.counter }}", - "{{ prescription|format_prescription }}", - )], - {% endfor %} - ) - {% endif %} +{% if encounter.hospitalization and encounter.hospitalization.discharge_disposition %} + #align(left, text(18pt,)[== Discharge Summary]) + #grid( + columns: (1fr,3fr), + row-gutter: 1.2em, + align: (left), + [Discharge Date:], "{{ encounter.period.end|parse_iso_datetime|format_empty_data }}", + [Discharge Disposition:], "{{ encounter.hospitalization.discharge_disposition|discharge_disposition_display }}", + ) -{% elif consultation.new_discharge_reason == 2 %} -{% elif consultation.new_discharge_reason == 3 %} -{% elif consultation.new_discharge_reason == 4 %} + #align(center, [#line(length: 40%, stroke: mygray)]) {% endif %} -#text("") - -#align(right)[#text(12pt,fill: mygray)[*Treating Physician* :] #text(10pt,weight: "bold")[{% if consultation.treating_physician %} - {{ consultation.treating_physician.first_name }} {{ consultation.treating_physician.last_name }} -{% else %} - - -{% endif %}]] {% if files %} - #align(center, [#line(length: 40%, stroke: mygray,)]) - #align(left, text(18pt,)[== Annexes]) #align(left, text(14pt,weight: "bold",)[=== Uploaded Files:]) @@ -394,8 +203,8 @@ [*UPLOADED AT*], [*NAME*], ), {% for file in files %} - "{{file.modified_date }}", "{{file.name }}", + "{{file.modified_date }}", text(hyphenate: true)["{{file.name }}"], {% endfor %} ) {% endif %} -#line(length: 100%, stroke: mygray) +// #line(length: 100%, stroke: mygray) diff --git a/config/urls.py b/config/urls.py index 5ba0ea998a..8503959fc7 100644 --- a/config/urls.py +++ b/config/urls.py @@ -9,9 +9,7 @@ SpectacularSwaggerView, ) -from care.facility.api.viewsets.legacy.patient_consultation import ( - dev_preview_discharge_summary, -) +from care.emr.api.viewsets.encounter import dev_preview_discharge_summary from care.users.api.viewsets.change_password import ChangePasswordView from care.users.reset_password_views import ( ResetPasswordCheck, @@ -90,7 +88,7 @@ ), path("500/", default_views.server_error), path( - "preview_discharge_summary//", + "preview_discharge_summary//", dev_preview_discharge_summary, ), ] diff --git a/docker/dev.Dockerfile b/docker/dev.Dockerfile index 6b605913d8..59bab5abb9 100644 --- a/docker/dev.Dockerfile +++ b/docker/dev.Dockerfile @@ -1,6 +1,6 @@ FROM python:3.13-slim-bookworm -ARG TYPST_VERSION=0.11.0 +ARG TYPST_VERSION=0.12.0 ARG APP_HOME=/app WORKDIR $APP_HOME diff --git a/docker/prod.Dockerfile b/docker/prod.Dockerfile index 99f724066f..5cf23f8676 100644 --- a/docker/prod.Dockerfile +++ b/docker/prod.Dockerfile @@ -1,7 +1,7 @@ FROM python:3.13-slim-bookworm AS base ARG APP_HOME=/app -ARG TYPST_VERSION=0.11.0 +ARG TYPST_VERSION=0.12.0 ARG BUILD_ENVIRONMENT="production" ARG APP_VERSION="unknown" From 9dfb6440eec8db0c0ca55e29ba416510ac7c3734 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Fri, 3 Jan 2025 16:44:14 +0530 Subject: [PATCH 2/4] Use ContentDisposition to set correct file names for read_signed_urls --- care/emr/utils/file_manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/care/emr/utils/file_manager.py b/care/emr/utils/file_manager.py index 7374cba7d1..ef73263ed3 100644 --- a/care/emr/utils/file_manager.py +++ b/care/emr/utils/file_manager.py @@ -38,6 +38,7 @@ def read_signed_url(self, file_obj, duration=60 * 60): Params={ "Bucket": bucket_name, "Key": f"{file_obj.file_type}/{file_obj.internal_name}", + "ResponseContentDisposition": f"attachment; filename={file_obj.name}{file_obj.get_extension()}", }, ExpiresIn=duration, # seconds ) From b7c9a9e3dce16a9afaf67f0b7c264fc311158089 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Fri, 3 Jan 2025 16:57:06 +0530 Subject: [PATCH 3/4] Add authorization check for discharge summary access --- care/emr/api/viewsets/encounter.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/care/emr/api/viewsets/encounter.py b/care/emr/api/viewsets/encounter.py index 95a7a84955..c31046b905 100644 --- a/care/emr/api/viewsets/encounter.py +++ b/care/emr/api/viewsets/encounter.py @@ -218,9 +218,14 @@ def organizations_remove(self, request, *args, **kwargs): ).delete() return Response({}, status=204) + def _check_discharge_summary_access(self, encounter): + if not AuthorizationController.call( + "can_view_clinical_data", self.request.user, encounter.patient + ): + raise PermissionDenied("Permission denied to user") + def _generate_discharge_summary(self, encounter_ext_id: str): - current_progress = discharge_summary.get_progress(encounter_ext_id) - if current_progress is not None: + if current_progress := discharge_summary.get_progress(encounter_ext_id): return Response( { "detail": ( @@ -247,6 +252,7 @@ def _generate_discharge_summary(self, encounter_ext_id: str): @action(detail=True, methods=["POST"]) def generate_discharge_summary(self, request, *args, **kwargs): encounter = self.get_object() + self._check_discharge_summary_access(encounter) return self._generate_discharge_summary(encounter.external_id) @extend_schema( @@ -257,6 +263,7 @@ def generate_discharge_summary(self, request, *args, **kwargs): @action(detail=True, methods=["GET"]) def preview_discharge_summary(self, request, *args, **kwargs): encounter = self.get_object() + self._check_discharge_summary_access(encounter) summary_file = ( FileUpload.objects.filter( file_type=FileTypeChoices.encounter.value, @@ -288,9 +295,10 @@ def validate_email(cls, value): ) @action(detail=True, methods=["POST"]) def email_discharge_summary(self, request, *args, **kwargs): - encounter_ext_id = kwargs["external_id"] - existing_progress = discharge_summary.get_progress(encounter_ext_id) - if existing_progress: + encounter = self.get_object() + self._check_discharge_summary_access(encounter) + encounter_ext_id = encounter.external_id + if existing_progress := discharge_summary.get_progress(encounter_ext_id): return Response( { "detail": ( From 4689a502863645c8486eae4d87b08cbc7fc79dd0 Mon Sep 17 00:00:00 2001 From: Vignesh Hari <14056798+vigneshhari@users.noreply.github.com> Date: Fri, 3 Jan 2025 18:49:48 +0530 Subject: [PATCH 4/4] Delete care/emr/migrations/0061_alter_medicationrequest_dosage_instruction.py --- ...ter_medicationrequest_dosage_instruction.py | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 care/emr/migrations/0061_alter_medicationrequest_dosage_instruction.py diff --git a/care/emr/migrations/0061_alter_medicationrequest_dosage_instruction.py b/care/emr/migrations/0061_alter_medicationrequest_dosage_instruction.py deleted file mode 100644 index 87bf1714eb..0000000000 --- a/care/emr/migrations/0061_alter_medicationrequest_dosage_instruction.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.3 on 2025-01-02 20:13 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('emr', '0060_alter_medicationrequest_dosage_instruction'), - ] - - operations = [ - migrations.AlterField( - model_name='medicationrequest', - name='dosage_instruction', - field=models.JSONField(blank=True, default=list, null=True), - ), - ]