Skip to content

Commit

Permalink
PDF Generation Alternative - GSoC (#2132)
Browse files Browse the repository at this point in the history
* updated docker file

* added test for testing Typst installations

* specified typst version

* updated helper functions and tests for it

* updated helper functions and added typst template

* integrated typst with django template

* deleted example.typ

* updated prod docker and removed old dependencies

* fixed prod.Dockerfile

* added test utils

* added test for generated pdf

* removed print statements

* updated template and prescription formatting

* added test data

* improved prescription tag

* updated test images for the prev update

* fixed created_on date in template

* completed the todos mentioned

* updated discharge notes field name

* updated sample png files

* updated test for diagnosis

* fixed the test failing issue

* fixed the fetch_icd11_data by ids function

* updated sample pngs

* added data formatting tags

* removed print statement

* updated sample png files

* improved formatting and updated tests

* show age with days if patient less than 1 year old

* fixed date formatting

* updated the docker files

* relock

* improved docker files

---------

Co-authored-by: Aakash Singh <[email protected]>
  • Loading branch information
DraKen0009 and sainak authored Aug 23, 2024
1 parent 8cd1032 commit 45b51a8
Show file tree
Hide file tree
Showing 15 changed files with 1,139 additions and 1,133 deletions.
1 change: 0 additions & 1 deletion Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ django = "==4.2.15"
django-environ = "==0.11.2"
django-cors-headers = "==4.3.1"
django-filter = "==24.2"
django-hardcopy = "==0.1.4"
django-maintenance-mode = "==0.21.1"
django-model-utils = "==4.5.1"
django-multiselectfield = "==0.1.12"
Expand Down
10 changes: 1 addition & 9 deletions Pipfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 17 additions & 2 deletions care/facility/api/viewsets/patient_consultation.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import tempfile

from django.db import transaction
from django.db.models import Prefetch
from django.db.models.query_utils import Q
from django.shortcuts import get_object_or_404, render
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
Expand Down Expand Up @@ -296,7 +300,18 @@ def dev_preview_discharge_summary(request, consultation_id):
if not consultation:
raise NotFound({"detail": "Consultation not found"})
data = discharge_summary.get_discharge_summary_data(consultation)
return render(request, "reports/patient_discharge_summary_pdf.html", data)
data["date"] = timezone.now()

with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp_file:
discharge_summary.generate_discharge_summary_pdf(data, tmp_file)

with open(tmp_file.name, "rb") as pdf_file:
pdf_content = pdf_file.read()

response = HttpResponse(pdf_content, content_type="application/pdf")
response["Content-Disposition"] = 'inline; filename="discharge_summary.pdf"'

return response


class PatientConsentViewSet(
Expand Down
27 changes: 24 additions & 3 deletions care/facility/models/patient.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import enum
from datetime import date

from dateutil.relativedelta import relativedelta
from django.contrib.postgres.aggregates import ArrayAgg
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Case, F, Func, JSONField, Value, When
from django.db.models.functions import Coalesce, Now
from django.template.defaultfilters import pluralize
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from simple_history.models import HistoricalRecords
Expand Down Expand Up @@ -481,10 +483,29 @@ def save(self, *args, **kwargs) -> None:
self._alias_recovery_to_recovered()
super().save(*args, **kwargs)

def get_age(self) -> int:
start = self.date_of_birth or timezone.datetime(self.year_of_birth, 1, 1).date()
def get_age(self) -> str:
start = self.date_of_birth or date(self.year_of_birth, 1, 1)
end = (self.death_datetime or timezone.now()).date()
return relativedelta(end, start).years

delta = relativedelta(end, start)

if delta.years > 0:
year_str = f"{delta.years} year{pluralize(delta.years)}"
return f"{year_str}"

elif 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}"

elif delta.days > 0:
day_str = f"{delta.days} day{pluralize(delta.days)}"
return day_str

else:
return "0 days"

def annotate_diagnosis_ids(*args, **kwargs):
return ArrayAgg(
Expand Down
35 changes: 35 additions & 0 deletions care/facility/templatetags/data_formatting_tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from django import template

register = template.Library()


@register.filter(name="format_empty_data")
def format_empty_data(data):
if data is None or data == "" or data == 0.0 or data == []:
return "N/A"

return data


@register.filter(name="format_to_sentence_case")
def format_to_sentence_case(data):
if data is None:
return

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)

elif isinstance(data, (list, tuple)):
converted_items = [convert_to_sentence_case(item) for item in data]
return ", ".join(converted_items)

return data
13 changes: 13 additions & 0 deletions care/facility/templatetags/prescription_tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
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}"
else:
return f"{prescription.medicine_name}, {prescription.base_dosage}, {prescription.route}, {prescription.frequency} for {prescription.days} days."
Binary file added care/facility/tests/sample_reports/sample1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added care/facility/tests/sample_reports/sample2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
230 changes: 230 additions & 0 deletions care/facility/tests/test_pdf_generation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import os
import subprocess
import tempfile
from datetime import date
from pathlib import Path

from django.conf import settings
from django.template.loader import render_to_string
from django.test import TestCase
from PIL import Image
from rest_framework.test import APIClient

from care.facility.models import (
ConditionVerificationStatus,
ICD11Diagnosis,
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


def compare_pngs(png_path1, png_path2):
with Image.open(png_path1) as img1, Image.open(png_path2) as img2:
if img1.mode != img2.mode:
return False

if img1.size != img2.size:
return False

img1_data = list(img1.getdata())
img2_data = list(img2.getdata())

if img1_data == img2_data:
return True
else:
return False


def test_compile_typ(data):
sample_file_path = os.path.join(
os.getcwd(), "care", "facility", "tests", "sample_reports", "sample{n}.png"
)
test_output_file_path = os.path.join(
os.getcwd(), "care", "facility", "tests", "sample_reports", "test_output{n}.png"
)
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(
["typst", "compile", "-", test_output_file_path, "--format", "png"],
input=content.encode("utf-8"),
capture_output=True,
check=True,
cwd="/",
)

number_of_pngs_generated = 2
# To be updated only if the number of sample png increase in future

for i in range(1, number_of_pngs_generated + 1):
current_sample_file_path = sample_file_path
current_sample_file_path = str(current_sample_file_path).replace(
"{n}", str(i)
)

current_test_output_file_path = test_output_file_path
current_test_output_file_path = str(current_test_output_file_path).replace(
"{n}", str(i)
)

if not compare_pngs(
Path(current_sample_file_path), Path(current_test_output_file_path)
):
return False
return True
except Exception:
return False
finally:
count = 1
while True:
current_test_output_file_path = test_output_file_path
current_test_output_file_path = current_test_output_file_path.replace(
"{n}", str(count)
)
if Path(current_test_output_file_path).exists():
os.remove(Path(current_test_output_file_path))
else:
break
count += 1


class TestTypstInstallation(TestCase):
def test_typst_installed(self):
try:
subprocess.run(["typst", "--version"], check=True)
typst_installed = True
except subprocess.CalledProcessError:
typst_installed = False

self.assertTrue(typst_installed, "Typst is not installed or not accessible")


class TestGenerateDischargeSummaryPDF(TestCase, TestUtils):
@classmethod
def setUpTestData(cls) -> None:
cls.state = cls.create_state(name="sample_state")
cls.district = cls.create_district(cls.state, name="sample_district")
cls.local_body = cls.create_local_body(cls.district, name="sample_local_body")
cls.super_user = cls.create_super_user("su", cls.district)
cls.facility = cls.create_facility(
cls.super_user, cls.district, cls.local_body, name="_Sample_Facility"
)
cls.user = cls.create_user("staff1", cls.district, home_facility=cls.facility)
cls.treating_physician = cls.create_user(
"test Doctor",
cls.district,
home_facility=cls.facility,
first_name="Doctor",
last_name="Tester",
user_type=15,
)
cls.patient = cls.create_patient(
cls.district, cls.facility, local_body=cls.local_body
)
cls.consultation = cls.create_consultation(
cls.patient,
cls.facility,
patient_no="123456",
doctor=cls.treating_physician,
height=178,
weight=80,
suggestion="A",
)
cls.create_patient_sample(cls.patient, cls.consultation, cls.facility, cls.user)
cls.create_policy(patient=cls.patient, user=cls.user)
cls.create_encounter_symptom(cls.consultation, cls.user)
cls.patient_investigation_group = cls.create_patient_investigation_group()
cls.patient_investigation = cls.create_patient_investigation(
cls.patient_investigation_group
)
cls.patient_investigation_session = cls.create_patient_investigation_session(
cls.user
)
cls.create_investigation_value(
cls.patient_investigation,
cls.consultation,
cls.patient_investigation_session,
cls.patient_investigation_group,
)
cls.create_disease(cls.patient)
cls.create_prescription(cls.consultation, cls.user)
cls.create_prescription(
cls.consultation, cls.user, dosage_type=PrescriptionDosageType.TITRATED
)
cls.create_prescription(
cls.consultation, cls.user, dosage_type=PrescriptionDosageType.PRN
)
cls.create_prescription(
cls.consultation, cls.user, prescription_type=PrescriptionType.DISCHARGE
)
cls.create_prescription(
cls.consultation,
cls.user,
prescription_type=PrescriptionType.DISCHARGE,
dosage_type=PrescriptionDosageType.TITRATED,
)
cls.create_prescription(
cls.consultation,
cls.user,
prescription_type=PrescriptionType.DISCHARGE,
dosage_type=PrescriptionDosageType.PRN,
)
cls.create_consultation_diagnosis(
cls.consultation,
ICD11Diagnosis.objects.filter(
label="SG31 Conception vessel pattern (TM1)"
).first(),
verification_status=ConditionVerificationStatus.CONFIRMED,
)
cls.create_consultation_diagnosis(
cls.consultation,
ICD11Diagnosis.objects.filter(
label="SG2B Liver meridian pattern (TM1)"
).first(),
verification_status=ConditionVerificationStatus.DIFFERENTIAL,
)
cls.create_consultation_diagnosis(
cls.consultation,
ICD11Diagnosis.objects.filter(
label="SG29 Triple energizer meridian pattern (TM1)"
).first(),
verification_status=ConditionVerificationStatus.PROVISIONAL,
)
cls.create_consultation_diagnosis(
cls.consultation,
ICD11Diagnosis.objects.filter(
label="SG60 Early yang stage pattern (TM1)"
).first(),
verification_status=ConditionVerificationStatus.UNCONFIRMED,
)

def setUp(self) -> None:
self.client = APIClient()

def test_pdf_generation_success(self):
test_data = {"consultation": self.consultation}

with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as file:
compile_typ(file.name, test_data)

self.assertTrue(os.path.exists(file.name))
self.assertGreater(os.path.getsize(file.name), 0)

def test_pdf_generation(self):
data = discharge_summary.get_discharge_summary_data(self.consultation)
data["date"] = date(2020, 1, 1)

# This sorting is test's specific and done in order to keep the values in order
self.assertTrue(test_compile_typ(data))
Loading

0 comments on commit 45b51a8

Please sign in to comment.