this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

remove typst from care (#3457)

* remove typst

* remove typst from docker

* remove typst from settings files

authored by

Prafful Sharma and committed by
GitHub
a8f1efcb 01ec20df

+1 -845
-4
.env.example
··· 400 400 # Default: "http://165.22.211.144/fhir" 401 401 # SNOWSTORM_DEPLOYMENT_URL=http://165.22.211.144/fhir 402 402 403 - # Path to typst binary for document generation (string) 404 - # Default: "typst" 405 - # TYPST_BIN=typst 406 - 407 403 # ============================================================================ 408 404 # BUSINESS LOGIC / EMR CONFIGURATION 409 405 # ============================================================================
-1
.gitignore
··· 360 360 361 361 care_db.dump 362 362 363 - typst* 364 363 .nix-data
-3
.vscode/settings.json
··· 22 22 "files.trimTrailingWhitespace": true, 23 23 "githubPullRequests.ignoredPullRequestBranches": ["develop", "staging"], 24 24 "python.languageServer": "Pylance", 25 - "cSpell.words": [ 26 - "Typst" 27 - ], 28 25 "python.testing.unittestArgs": [ 29 26 "--noinput", 30 27 "--shuffle",
-55
care/emr/api/viewsets/encounter.py
··· 1 - import tempfile 2 1 from datetime import timedelta 3 2 4 3 from django.conf import settings 5 4 from django.db import transaction 6 - from django.http import HttpResponse 7 - from django.utils import timezone 8 5 from django_filters import rest_framework as filters 9 6 from drf_spectacular.utils import extend_schema 10 7 from pydantic import UUID4, BaseModel ··· 30 27 Patient, 31 28 ) 32 29 from care.emr.models.patient import PatientIdentifier, PatientIdentifierConfig 33 - from care.emr.reports import discharge_summary 34 30 from care.emr.resources.encounter.constants import COMPLETED_CHOICES, StatusChoices 35 31 from care.emr.resources.encounter.spec import ( 36 32 EncounterCareTeamMemberWriteSpec, ··· 46 42 ) 47 43 from care.emr.resources.tag.config_spec import TagResource 48 44 from care.emr.tagging.filters import SingleFacilityTagFilter 49 - from care.emr.tasks.discharge_summary import generate_discharge_summary_task 50 45 from care.facility.models import Facility 51 46 from care.security.authorization import AuthorizationController 52 47 from care.users.models import User ··· 313 308 ).delete() 314 309 return Response({}) 315 310 316 - @extend_schema( 317 - description="Generate a discharge summary", 318 - responses={ 319 - 200: "Success", 320 - }, 321 - tags=["encounter"], 322 - ) 323 - @action(detail=True, methods=["POST"]) 324 - def generate_discharge_summary(self, request, *args, **kwargs): 325 - encounter = self.get_object() 326 - if not AuthorizationController.call( 327 - "can_view_clinical_data", self.request.user, encounter.patient 328 - ): 329 - raise PermissionDenied("Permission denied to user") 330 - encounter_ext_id = encounter.external_id 331 - if current_progress := discharge_summary.get_progress(encounter_ext_id): 332 - return Response( 333 - { 334 - "detail": ( 335 - "Discharge Summary is already being generated, " 336 - f"current progress {current_progress}%" 337 - ) 338 - }, 339 - status=status.HTTP_409_CONFLICT, 340 - ) 341 - discharge_summary.set_lock(encounter_ext_id, 1) 342 - generate_discharge_summary_task.delay(encounter_ext_id) 343 - return Response( 344 - {"detail": "Discharge Summary will be generated shortly"}, 345 - status=status.HTTP_202_ACCEPTED, 346 - ) 347 - 348 311 class EncounterFacilityIdentifierWriteSpec(BaseModel): 349 312 identifier: UUID4 350 313 value: str | None = None ··· 427 390 encounter.care_team = members 428 391 encounter.save(update_fields=["care_team"]) 429 392 return Response({}, status=status.HTTP_200_OK) 430 - 431 - 432 - def dev_preview_discharge_summary(request, encounter_id): 433 - """ 434 - This is a dev only view to preview the discharge summary template 435 - """ 436 - encounter = get_object_or_404(Encounter, external_id=encounter_id) 437 - data = discharge_summary.get_discharge_summary_data(encounter) 438 - data["date"] = timezone.now() 439 - 440 - with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp_file: 441 - discharge_summary.generate_discharge_summary_pdf(data, tmp_file) 442 - tmp_file.seek(0) 443 - 444 - response = HttpResponse(tmp_file, content_type="application/pdf") 445 - response["Content-Disposition"] = 'inline; filename="discharge_summary.pdf"' 446 - 447 - return response
-266
care/emr/reports/discharge_summary.py
··· 1 - import html 2 - import logging 3 - import subprocess 4 - import tempfile 5 - import time 6 - from pathlib import Path 7 - from uuid import uuid4 8 - 9 - from django.conf import settings 10 - from django.core.cache import cache 11 - from django.template.loader import render_to_string 12 - from django.utils import timezone 13 - 14 - from care.emr.models import ( 15 - AllergyIntolerance, 16 - Condition, 17 - Encounter, 18 - FileUpload, 19 - Observation, 20 - medication_request, 21 - ) 22 - from care.emr.resources.allergy_intolerance.spec import ( 23 - VerificationStatusChoices as AllergyVerificationStatusChoices, 24 - ) 25 - from care.emr.resources.condition.spec import CategoryChoices, VerificationStatusChoices 26 - from care.emr.resources.file_upload.spec import FileCategoryChoices, FileTypeChoices 27 - from care.emr.resources.medication.request.spec import MedicationRequestStatus 28 - from care.users.models import User 29 - 30 - logger = logging.getLogger(__name__) 31 - 32 - LOCK_DURATION = 2 * 60 # 2 minutes 33 - 34 - 35 - def lock_key(encounter_ext_id: str): 36 - return f"discharge_summary_{encounter_ext_id}" 37 - 38 - 39 - def set_lock(encounter_ext_id: str, progress: int): 40 - cache.set(lock_key(encounter_ext_id), progress, timeout=LOCK_DURATION) 41 - 42 - 43 - def get_progress(encounter_ext_id: str): 44 - return cache.get(lock_key(encounter_ext_id)) 45 - 46 - 47 - def clear_lock(encounter_ext_id: str): 48 - cache.delete(lock_key(encounter_ext_id)) 49 - 50 - 51 - def parse_iso_datetime(date_str): 52 - try: 53 - return timezone.datetime.fromisoformat(date_str) 54 - except ValueError: 55 - return None 56 - 57 - 58 - def format_duration(duration): 59 - if not duration: 60 - return "" 61 - 62 - if duration.days > 0: 63 - return f"{duration.days} days" 64 - hours, remainder = divmod(duration.seconds, 3600) 65 - minutes, _ = divmod(remainder, 60) 66 - return f"{hours:02}:{minutes:02}" 67 - 68 - 69 - def get_discharge_summary_data(encounter: Encounter): 70 - logger.info("fetching discharge summary data for %s", encounter.external_id) 71 - symptoms = Condition.objects.filter( 72 - encounter=encounter, 73 - category=CategoryChoices.problem_list_item.value, 74 - ).exclude(verification_status=VerificationStatusChoices.entered_in_error) 75 - diagnoses = ( 76 - Condition.objects.filter( 77 - encounter=encounter, 78 - category=CategoryChoices.encounter_diagnosis.value, 79 - ) 80 - .exclude(verification_status=VerificationStatusChoices.entered_in_error) 81 - .order_by("id") 82 - ) 83 - principal_diagnosis = diagnoses[0] if diagnoses else None 84 - 85 - allergies = sorted( 86 - AllergyIntolerance.objects.filter(encounter=encounter).exclude( 87 - verification_status=AllergyVerificationStatusChoices.entered_in_error 88 - ), 89 - key=lambda x: ("high", "low", "unable-to-assess", "", None).index( 90 - x.criticality 91 - ), 92 - ) 93 - 94 - observations = ( 95 - Observation.objects.filter( 96 - encounter=encounter, 97 - ) 98 - .select_related("data_entered_by") 99 - .order_by("id") 100 - ) 101 - 102 - medication_requests = ( 103 - medication_request.MedicationRequest.objects.filter(encounter=encounter) 104 - .exclude(status=MedicationRequestStatus.entered_in_error.value) 105 - .select_related("created_by") 106 - ) 107 - 108 - files = FileUpload.objects.filter( 109 - associating_id=encounter.external_id, 110 - upload_completed=True, 111 - is_archived=False, 112 - ) 113 - 114 - admission_duration = ( 115 - format_duration( 116 - parse_iso_datetime(encounter.period.get("end")) 117 - - parse_iso_datetime(encounter.period.get("start")) 118 - ) 119 - if encounter.period.get("end", None) and encounter.period.get("start", None) 120 - else None 121 - ) 122 - 123 - user_roles = { 124 - member["user_id"]: member["role"]["display"] for member in encounter.care_team 125 - } 126 - 127 - care_team_users = User.objects.filter(id__in=user_roles.keys()) 128 - 129 - care_team_display = [ 130 - f"{user.full_name} ({user_roles[user.id]})" for user in care_team_users 131 - ] 132 - 133 - return { 134 - "encounter": encounter, 135 - "admission_duration": admission_duration, 136 - "patient": encounter.patient, 137 - "symptoms": symptoms, 138 - "diagnoses": diagnoses, 139 - "principal_diagnosis": principal_diagnosis, 140 - "allergies": allergies, 141 - "observations": observations, 142 - "medication_requests": medication_requests, 143 - "files": files, 144 - "care_team": care_team_display, 145 - "discharge_summary_advice": encounter.discharge_summary_advice, 146 - } 147 - 148 - 149 - def compile_typ(output_file, data): 150 - try: 151 - logo_path = ( 152 - Path(settings.BASE_DIR) 153 - / "staticfiles" 154 - / "images" 155 - / "logos" 156 - / "logo-light.svg" 157 - ) 158 - 159 - data["logo_path"] = "logo-light.svg" 160 - with tempfile.TemporaryDirectory() as tmpdir: 161 - template = Path(tmpdir) / "template.typ" 162 - template.write_text( 163 - html.unescape( 164 - render_to_string( 165 - "reports/patient_discharge_summary_pdf_template.typ", 166 - context=data, 167 - ) 168 - ) 169 - ) 170 - 171 - logo_dest = Path(tmpdir) / "logo-light.svg" 172 - logo_dest.write_text(logo_path.read_text()) 173 - 174 - subprocess.run( # noqa: S603 175 - [ 176 - settings.TYPST_BIN, 177 - "compile", 178 - str(template.name), 179 - str(output_file), 180 - ], 181 - capture_output=True, 182 - check=True, 183 - shell=False, 184 - cwd=tmpdir, 185 - ) 186 - 187 - logger.info( 188 - "Successfully Compiled Summary pdf for %s", data["encounter"].external_id 189 - ) 190 - 191 - except subprocess.CalledProcessError as e: 192 - logger.error( 193 - "Error compiling summary pdf for %s: %s", 194 - data["encounter"].external_id, 195 - e.stderr.decode("utf-8"), 196 - ) 197 - raise e 198 - 199 - 200 - def generate_discharge_summary_pdf(data, file): 201 - logger.info( 202 - "Generating Discharge Summary pdf for %s", data["encounter"].external_id 203 - ) 204 - compile_typ(output_file=file.name, data=data) 205 - logger.info( 206 - "Successfully Generated Discharge Summary pdf for %s", 207 - data["encounter"].external_id, 208 - ) 209 - 210 - 211 - def generate_and_upload_discharge_summary(encounter: Encounter): 212 - logger.info("Generating Discharge Summary for %s", encounter.external_id) 213 - 214 - set_lock(encounter.external_id, 5) 215 - try: 216 - current_date = timezone.now() 217 - timestamp = int(current_date.timestamp() * 1000) 218 - patient_name_slug: str = ( 219 - encounter.patient.name.lower().strip().replace(" ", "_") 220 - ) 221 - summary_file = FileUpload( 222 - name=f"discharge_summary-{patient_name_slug}-{int(timestamp)}", 223 - internal_name=f"{uuid4()}{int(time.time())}.pdf", 224 - file_type=FileTypeChoices.encounter.value, 225 - file_category=FileCategoryChoices.discharge_summary.value, 226 - associating_id=encounter.external_id, 227 - ) 228 - 229 - set_lock(encounter.external_id, 10) 230 - data = get_discharge_summary_data(encounter) 231 - data["date"] = current_date 232 - 233 - set_lock(encounter.external_id, 50) 234 - with tempfile.NamedTemporaryFile(suffix=".pdf") as file: 235 - generate_discharge_summary_pdf(data, file) 236 - logger.info("Uploading Discharge Summary for %s", encounter.external_id) 237 - summary_file.files_manager.put_object( 238 - summary_file, file, ContentType="application/pdf" 239 - ) 240 - summary_file.upload_completed = True 241 - summary_file.save(skip_internal_name=True) 242 - logger.info( 243 - "Uploaded Discharge Summary for %s, file id: %s", 244 - encounter.external_id, 245 - summary_file.id, 246 - ) 247 - finally: 248 - clear_lock(encounter.external_id) 249 - 250 - return summary_file 251 - 252 - 253 - def generate_discharge_report_signed_url(patient_external_id: str): 254 - encounter = ( 255 - Encounter() 256 - .objects.filter(patient__external_id=patient_external_id) 257 - .order_by("-created_date") 258 - .first() 259 - ) 260 - if not encounter: 261 - return None 262 - 263 - summary_file = generate_and_upload_discharge_summary(encounter) 264 - return summary_file.files_manager.signed_url( 265 - summary_file, duration=2 * 24 * 60 * 60 266 - )
-33
care/emr/tasks/discharge_summary.py
··· 1 - from logging import Logger 2 - 3 - from botocore.exceptions import ClientError 4 - from celery import shared_task 5 - from celery.utils.log import get_task_logger 6 - 7 - from care.emr.models.encounter import Encounter 8 - from care.emr.reports.discharge_summary import generate_and_upload_discharge_summary 9 - from care.utils.exceptions import CeleryTaskError 10 - 11 - logger: Logger = get_task_logger(__name__) 12 - 13 - 14 - @shared_task( 15 - autoretry_for=(ClientError,), retry_kwargs={"max_retries": 3}, expires=10 * 60 16 - ) 17 - def generate_discharge_summary_task(encounter_ext_id: str): 18 - """ 19 - Generate and Upload the Discharge Summary 20 - """ 21 - logger.info("Generating Discharge Summary for %s", encounter_ext_id) 22 - try: 23 - encounter = Encounter.objects.get(external_id=encounter_ext_id) 24 - except Encounter.DoesNotExist as e: 25 - msg = f"Encounter {encounter_ext_id} does not exist" 26 - raise CeleryTaskError(msg) from e 27 - 28 - summary_file = generate_and_upload_discharge_summary(encounter) 29 - if not summary_file: 30 - msg = "Unable to generate discharge summary" 31 - raise CeleryTaskError(msg) 32 - 33 - return summary_file.id
-89
care/emr/templatetags/discharge_summary_utils.py
··· 1 - from django import template 2 - 3 - from care.emr.models.medication_request import MedicationRequest 4 - from care.emr.models.observation import Observation 5 - from care.emr.resources.encounter.constants import ( 6 - ClassChoices, 7 - ) 8 - from care.emr.resources.encounter.enum_display_names import ( 9 - get_admit_source_display, 10 - get_discharge_disposition_display, 11 - ) 12 - 13 - register = template.Library() 14 - 15 - 16 - @register.filter 17 - def admit_source_display(value: str) -> str: 18 - return get_admit_source_display(value) 19 - 20 - 21 - @register.filter 22 - def discharge_summary_display(value: str) -> str: 23 - match value: 24 - case ClassChoices.imp.value | ClassChoices.emer.value: 25 - return "Discharge Summary" 26 - case ClassChoices.amb.value: 27 - return "Outpatient Summary" 28 - case ClassChoices.hh.value: 29 - return "Home Health Summary" 30 - case ClassChoices.vr.value: 31 - return "Virtual Care Summary" 32 - case ClassChoices.obsenc.value: 33 - return "Observation Summary" 34 - case _: 35 - return "Patient Summary" 36 - 37 - 38 - @register.filter 39 - def discharge_disposition_display(value: str) -> str: 40 - return get_discharge_disposition_display(value) 41 - 42 - 43 - @register.filter 44 - def observation_value_display(observation: Observation) -> str | None: 45 - if observation.value.get("display", None): 46 - return observation.value.get("display", None) 47 - if observation.value.get("unit", None): 48 - unit: str = observation.value.get("unit", {}).get("display", None) 49 - value: float | None = observation.value.get("value", None) 50 - value = int(value) if value and value.is_integer() else value 51 - return f"{value} {unit}" if unit else value 52 - return observation.value.get("value", None) 53 - 54 - 55 - @register.filter 56 - def medication_dosage_display(medication: MedicationRequest) -> str: 57 - try: 58 - dosage = medication.dosage_instruction[0] 59 - # Prefer text if available 60 - if dosage.get("text"): 61 - return dosage["text"] 62 - dose_val = dosage.get("dose_and_rate", {}).get("dose_quantity", {}).get("value") 63 - dose_unit = ( 64 - dosage.get("dose_and_rate", {}) 65 - .get("dose_quantity", {}) 66 - .get("unit", {}) 67 - .get("display", "") 68 - ) 69 - timing = dosage.get("timing", {}) 70 - timing_display = timing.get("code", {}).get("display") 71 - repeat = timing.get("repeat", {}) 72 - freq = repeat.get("frequency") 73 - period = repeat.get("period") 74 - period_unit = repeat.get("period_unit") 75 - duration = repeat.get("bounds_duration", {}).get("value") 76 - duration_unit = repeat.get("bounds_duration", {}).get("unit") 77 - # Build readable string 78 - parts = [] 79 - if dose_val and dose_unit: 80 - parts.append(f"Take {dose_val} {dose_unit}") 81 - if timing_display: 82 - parts.append(f"{timing_display}") 83 - elif freq and period and period_unit: 84 - parts.append(f"every {period} {period_unit}") 85 - if duration and duration_unit: 86 - parts.append(f"for {duration} {duration_unit}") 87 - return " ".join(parts) if parts else None 88 - except Exception: 89 - return None
-68
care/emr/tests/test_encounter_api.py
··· 1 1 import uuid 2 2 from datetime import timedelta 3 - from unittest.mock import patch 4 3 5 4 from django.conf import settings 6 5 from django.urls import reverse ··· 635 634 ) 636 635 self.assertEqual(response.status_code, 400) 637 636 self.assertIn("Organization does not exist", response.data["errors"][0]["msg"]) 638 - 639 - def test_generate_discharge_summary_with_permissions(self): 640 - self.patient.year_of_birth = 2000 641 - self.patient.save() 642 - role = self.create_role_with_permissions( 643 - permissions=[ 644 - PatientPermissions.can_view_clinical_data.name, 645 - ] 646 - ) 647 - self.attach_role_facility_organization_user( 648 - self.facility_organization, self.user, role 649 - ) 650 - # 3. Mock the necessary functions to isolate the test 651 - with ( 652 - patch( 653 - "care.emr.reports.discharge_summary.get_progress" 654 - ) as mock_get_progress, 655 - patch("care.emr.reports.discharge_summary.set_lock") as mock_set_lock, 656 - patch( 657 - "care.emr.tasks.discharge_summary.generate_discharge_summary_task.delay" 658 - ) as mock_task, 659 - ): 660 - mock_get_progress.return_value = None 661 - 662 - path = "generate_discharge_summary" 663 - url = self._get_detail_url(path) 664 - response = self.client.post( 665 - url, {"external_id": str(self.encounter.external_id)}, format="json" 666 - ) 667 - self.assertEqual(response.status_code, 202) 668 - self.assertIn( 669 - "Discharge Summary will be generated shortly", response.data["detail"] 670 - ) 671 - 672 - mock_get_progress.assert_called_once_with(self.encounter.external_id) 673 - mock_set_lock.assert_called_once_with(self.encounter.external_id, 1) 674 - mock_task.assert_called_once_with(self.encounter.external_id) 675 - 676 - def test_generate_discharge_summary_without_permissions(self): 677 - path = "generate_discharge_summary" 678 - url = self._get_detail_url(path) 679 - response = self.client.post( 680 - url, {"external_id": str(self.encounter.external_id)}, format="json" 681 - ) 682 - self.assertEqual(response.status_code, 403) 683 - self.assertIn("Permission denied to user", response.data["detail"]) 684 - 685 - def test_generate_discharge_summary_with_conflict(self): 686 - self.patient.year_of_birth = 2000 687 - self.patient.save() 688 - self.get_role_with_permissions() 689 - with patch( 690 - "care.emr.reports.discharge_summary.get_progress" 691 - ) as mock_get_progress: 692 - # Return 75% to simulate a discharge summary that's already being generated 693 - mock_get_progress.return_value = 75 694 - 695 - path = "generate_discharge_summary" 696 - url = self._get_detail_url(path) 697 - response = self.client.post( 698 - url, {"external_id": str(self.encounter.external_id)}, format="json" 699 - ) 700 - self.assertEqual(response.status_code, 409) 701 - self.assertIn( 702 - "Discharge Summary is already being generated", response.data["detail"] 703 - ) 704 - self.assertIn("75%", response.data["detail"]) 705 637 706 638 # TESTS FOR CARE TEAM MANAGEMENT 707 639
-217
care/templates/reports/patient_discharge_summary_pdf_template.typ
··· 1 - {% load static %} 2 - {% load data_formatting_extras %} 3 - {% load discharge_summary_utils %} 4 - 5 - #set page("a4",margin: 40pt) 6 - #set text(font: "DejaVu Sans",size: 10pt,hyphenate: true, fallback: true) 7 - #let mygray = luma(100) 8 - 9 - #let frame(stroke) = (x, y) => ( 10 - left: if x > 0 { 0pt } else { stroke }, 11 - right: stroke, 12 - top: if y < 2 { stroke } else { 0pt }, 13 - bottom: stroke, 14 - ) 15 - 16 - #set table( 17 - fill: (_, y) => if calc.odd(y) { rgb("EAF2F5") }, 18 - stroke: frame(rgb("21222C")), 19 - ) 20 - 21 - #let facility_name="{{encounter.facility.name}}" 22 - 23 - #align(center, text(24pt,weight: "bold")[= #facility_name]) 24 - 25 - #line(length: 100%, stroke: mygray) 26 - 27 - #grid( 28 - columns: (auto, 1fr), 29 - row-gutter: 1em, 30 - align: (left, right), 31 - text(size: 15pt)[= {{ encounter.encounter_class|discharge_summary_display }}], 32 - grid.cell(align: right, rowspan: 2)[#image("{{ logo_path }}", width: 32%)], 33 - [#text(fill: mygray, weight: 500)[*Created on {{date}}*]] 34 - ) 35 - 36 - #line(length: 100%, stroke: mygray) 37 - 38 - #show grid.cell.where(x: 0): set text(fill: mygray,weight: "bold") 39 - #show grid.cell.where(x: 2): set text(fill: mygray,weight: "bold") 40 - 41 - #grid( 42 - columns: (1fr, 1fr, 1fr, 1fr), 43 - row-gutter: 1.5em, 44 - [Full name:], "{{patient.name}}", 45 - [Gender:], "{{patient.gender|field_name_to_label }}", 46 - [Age:], "{{patient.get_age }}", 47 - [Blood Group:], "{{patient.blood_group|field_name_to_label }}", 48 - [Phone Number:], "{{patient.phone_number }}", 49 - [Ration Card Category:], "{{patient.get_ration_card_category_display|format_empty_data }}", 50 - [Address:], grid.cell(colspan: 3, "{{patient.address }}"), 51 - ) 52 - 53 - #line(length: 100%, stroke: mygray) 54 - 55 - #align(left, text(18pt)[== Visit Details]) 56 - #text("") 57 - #grid( 58 - columns: (1.1fr, 2fr), 59 - row-gutter: 1.2em, 60 - align: (left), 61 - [Route to Facility:], "{{ encounter.hospitalization.admit_source|admit_source_display }}", 62 - {% if encounter.encounter_class == "imp" %} 63 - [Admitted To:], "{{ encounter.facility.name|format_empty_data }}", // TODO: show bed info instead of facility name 64 - [Duration of Admission:], "{{admission_duration|format_empty_data}}", 65 - [Date of admission:], "{{ encounter.period.start|parse_iso_datetime|format_empty_data }}", 66 - [IP No:], "{{ encounter.external_identifier }}", 67 - {% else %} 68 - [OP No:], "{{ encounter.external_identifier }}", 69 - {% endif %} 70 - [Diagnosis:],[#stack( 71 - dir: ttb, 72 - spacing: 10pt, 73 - {% for diagnose in diagnoses %} 74 - "{{ diagnose.code.display }} ({{diagnose.verification_status }})", 75 - {% endfor %} 76 - )], 77 - [Principal Diagnosis:], 78 - {% if principal_diagnosis %} 79 - "{{ principal_diagnosis.code.display }}", 80 - {% else %} 81 - "N/A", 82 - {% endif %} 83 - [Symptoms:], [#stack( 84 - dir: ttb, 85 - spacing: 10pt, 86 - {% if symptoms %} 87 - {% for symptom in symptoms %} 88 - "{{ symptom.code.display }}", 89 - {% endfor %} 90 - {% else %} 91 - "Asymptomatic" 92 - {% endif %} 93 - )], 94 - [Reported Allergies:], 95 - {% if allergies %} 96 - [#stack( 97 - dir: ttb, 98 - spacing: 10pt, 99 - {% for allergy in allergies %} 100 - "{{ allergy.code.display }}", 101 - {% endfor %} 102 - )], 103 - {% else %} 104 - "N/A", 105 - {% endif %} 106 - ) 107 - 108 - #text("") 109 - 110 - #align(center, [#line(length: 40%, stroke: mygray)]) 111 - 112 - // TODO: add comorbidity info 113 - 114 - {% if medication_requests %} 115 - #align(left, text(14pt,weight: "bold",)[=== Medication Requests:]) 116 - #table( 117 - columns: (1fr,), 118 - inset: 10pt, 119 - align: horizon, 120 - stroke: 1pt, 121 - table.header( 122 - align(center, text([*MEDICATION REQUESTS*])) 123 - ), 124 - {% for medication in medication_requests %} 125 - [#grid( 126 - columns: (1fr, 3fr), 127 - row-gutter: 1.2em, 128 - align: (left), 129 - [Name:], "{{ medication.medication.display }}", 130 - [Dosage:], "{{ medication|medication_dosage_display|format_empty_data }}", 131 - {% if medication.created_by %} 132 - [Prescribed By:], "{{ medication.created_by.full_name|default:medication.created_by.username }}", 133 - {% else %} 134 - [Prescribed By:], "Unknown", 135 - {% endif %} 136 - [Date:], "{{ medication.authored_on|default:medication.created_date }}", 137 - [Intent:], "{{ medication.intent|title }}", 138 - [Priority:], "{{ medication.priority|title }}", 139 - {% if medication.status_reason %} 140 - [Status Reason:], "{{ medication.status_reason|title }}", 141 - {% endif %} 142 - [Status:], "{{ medication.status|title }}", 143 - )], 144 - {% endfor %} 145 - ) 146 - 147 - #align(center, [#line(length: 40%, stroke: mygray)]) 148 - 149 - {% endif %} 150 - 151 - 152 - {% if observations %} 153 - #align(left, text(14pt,weight: "bold")[=== Observations:]) 154 - #table( 155 - columns: (1fr,), 156 - inset: 10pt, 157 - align: horizon, 158 - stroke: 1pt, 159 - table.header( 160 - align(center, text([*OBSERVATION DETAILS*])) 161 - ), 162 - {% for observation in observations %} 163 - {% if observation.main_code.display and observation|observation_value_display %} 164 - [#grid( 165 - columns: (1fr, 3fr), 166 - row-gutter: 1.2em, 167 - align: (left), 168 - [Name:], "{{ observation.main_code.display }}", 169 - [Value:], "{{ observation|observation_value_display|field_name_to_label|format_empty_data }}", 170 - {% if observation.body_site %} 171 - [Body Site:], "{{ observation.body_site.display }}", 172 - {% endif %} 173 - [Date:], "{{ observation.effective_datetime }}", 174 - {% if observation.data_entered_by %} 175 - [Data Entered By:], "{{ observation.data_entered_by.full_name|default:observation.data_entered_by.username }}", 176 - {% endif %} 177 - {% if observation.note %} 178 - [Note:], "{{ observation.note }}", 179 - {% endif %} 180 - )], 181 - {% endif %} 182 - {% endfor %} 183 - ) 184 - 185 - #align(center, [#line(length: 40%, stroke: mygray)]) 186 - {% endif %} 187 - 188 - 189 - {% if encounter.hospitalization and encounter.hospitalization.discharge_disposition %} 190 - #align(left, text(18pt,)[== Discharge Summary]) 191 - #grid( 192 - columns: (1fr,3fr), 193 - row-gutter: 1.2em, 194 - align: (left), 195 - [Discharge Date:], "{{ encounter.period.end|parse_iso_datetime|format_empty_data }}", 196 - [Discharge Disposition:], "{{ encounter.hospitalization.discharge_disposition|discharge_disposition_display }}", 197 - ) 198 - 199 - #align(center, [#line(length: 40%, stroke: mygray)]) 200 - {% endif %} 201 - 202 - 203 - #align(right)[#text(12pt,fill: mygray)[*Care Team* :] #text(10pt,weight: "bold")[{% if care_team %} 204 - {% for member in care_team %} 205 - {{ member }} 206 - {% endfor %} 207 - {% else %} 208 - - 209 - {% endif %}]] 210 - 211 - #text("") 212 - #line(length: 100%, stroke: mygray) 213 - #text("") 214 - {% if discharge_summary_advice %} 215 - = Discharge Summary Advice 216 - #`{{ discharge_summary_advice }}`.text 217 - {% endif %}
-3
config/settings/base.py
··· 685 685 "SNOWSTORM_DEPLOYMENT_URL", default="http://165.22.211.144/fhir" 686 686 ) 687 687 688 - # Path to the typst binary, see scripts/install_typst.sh 689 - TYPST_BIN = env("TYPST_BIN", default="typst") 690 - 691 688 DJANGO_REST_MULTITOKENAUTH_REQUIRE_USABLE_PASSWORD = False 692 689 693 690 SMS_BACKEND = "care.utils.sms.backend.sns.SnsBackend"
-5
config/urls.py
··· 10 10 SpectacularSwaggerView, 11 11 ) 12 12 13 - from care.emr.api.viewsets.encounter import dev_preview_discharge_summary 14 13 from care.users.api.viewsets.change_password import ChangePasswordView 15 14 from care.users.reset_password_views import ( 16 15 ResetPasswordCheck, ··· 87 86 kwargs={"exception": Exception("Page not Found")}, 88 87 ), 89 88 path("500/", default_views.server_error), 90 - path( 91 - "preview_discharge_summary/<str:encounter_id>/", 92 - dev_preview_discharge_summary, 93 - ), 94 89 ] 95 90 if "debug_toolbar" in settings.INSTALLED_APPS: 96 91 urlpatterns += [path("__debug__/", include("debug_toolbar.urls"))]
-4
docker/dev.Dockerfile
··· 1 1 FROM python:3.13-slim-bookworm 2 2 3 3 ARG APP_HOME=/app 4 - ARG TYPST_VERSION=0.12.0 5 4 6 5 WORKDIR $APP_HOME 7 6 ··· 13 12 libharfbuzz0b libharfbuzz-subset0 libffi-dev libjpeg-dev libopenjp2-7-dev \ 14 13 && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ 15 14 && rm -rf /var/lib/apt/lists/* 16 - 17 - COPY --chmod=0755 scripts/install_typst.sh $APP_HOME 18 - RUN TYPST_VERSION=${TYPST_VERSION} $APP_HOME/install_typst.sh 19 15 20 16 # use pipenv to manage virtualenv 21 17 ENV PATH=/.venv/bin:$PATH
-6
docker/prod.Dockerfile
··· 1 1 FROM python:3.13-slim-bookworm AS base 2 2 3 3 ARG APP_HOME=/app 4 - ARG TYPST_VERSION=0.12.0 5 4 6 5 ARG BUILD_ENVIRONMENT="production" 7 6 ··· 25 24 && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ 26 25 && rm -rf /var/lib/apt/lists/* 27 26 28 - COPY --chmod=0755 scripts/install_typst.sh $APP_HOME 29 - RUN TYPST_VERSION=${TYPST_VERSION} $APP_HOME/install_typst.sh 30 - 31 27 # use pipenv to manage virtualenv 32 28 RUN pip install pipenv==2025.1.1 33 29 ··· 54 50 && rm -rf /var/lib/apt/lists/* 55 51 56 52 RUN chown django:django $APP_HOME 57 - 58 - COPY --from=builder --chmod=0755 /usr/local/bin/typst /usr/local/bin/typst 59 53 60 54 COPY --from=builder --chown=django:django $APP_HOME/.venv $APP_HOME/.venv 61 55
-1
docs/development/nix-development.md
··· 386 386 - **PostgreSQL 15**: Database server and client tools 387 387 - **Redis**: In-memory data structure store 388 388 - **MinIO**: S3-compatible object storage 389 - - **Typst**: Modern typesetting system 390 389 - **Pre-commit**: Git hook framework 391 390 - **GCC & build tools**: For compiling Python packages 392 391
+1 -5
flake.nix
··· 239 239 240 240 echo "✅ Development environment setup complete!" 241 241 echo "" 242 - echo "Note: Typst ${pkgs.typst.version} and ruff ${pkgs.ruff.version} are available from Nix store" 242 + echo "Note: ruff ${pkgs.ruff.version} is available from Nix store" 243 243 ''; 244 244 245 245 # Django management commands ··· 429 429 libpq 430 430 redis 431 431 minio 432 - typst # Typst directly from nixpkgs 433 432 ruff # Ruff from Nix for NixOS compatibility 434 433 435 434 # System dependencies for building Python packages ··· 496 495 echo " PostgreSQL: ${pkgs.postgresql_15.version}" 497 496 echo " Redis: ${pkgs.redis.version}" 498 497 echo " MinIO: ${pkgs.minio.version}" 499 - echo " Typst: ${pkgs.typst.version}" 500 498 echo " Ruff: ${pkgs.ruff.version}" 501 499 echo "" 502 500 echo "Available commands:" ··· 542 540 543 541 # Verify tools are available 544 542 echo "✅ PostgreSQL development tools available (${pkgs.postgresql_15.version})" 545 - echo "✅ Typst available (${pkgs.typst.version})" 546 543 echo "✅ Ruff available (${pkgs.ruff.version})" 547 544 ''; 548 545 }; ··· 555 552 pkgs.postgresql_15 556 553 pkgs.redis 557 554 pkgs.minio 558 - pkgs.typst 559 555 pkgs.ruff 560 556 ]; 561 557 text = ''
-85
scripts/install_typst.sh
··· 1 - #!/bin/sh 2 - 3 - # This script installs Typst binary based on the given version and path 4 - # Supported platforms: Linux(x86_64, aarch64), Darwin(x86_64, arm64) 5 - # 6 - # Environment variables: 7 - # TYPST_VERSION: The version of Typst to install 8 - # TYPST_INSTALL_DIR: The directory to install Typst to. Defaults to /usr/local/bin 9 - # 10 - # Example usage: 11 - # TYPST_VERSION=0.12.0 ./scripts/install_typst.sh 12 - # TYPST_VERSION=0.12.0 TYPST_INSTALL_DIR=./bin ./scripts/install_typst.sh 13 - 14 - 15 - INSTALL_PATH="${TYPST_INSTALL_DIR:-/usr/local/bin}" 16 - 17 - if [ -z "${TYPST_VERSION}" ]; then 18 - echo "TYPST_VERSION is not set. Exiting." 19 - exit 1 20 - fi 21 - 22 - if ! command -v wget >/dev/null 2>&1; then 23 - echo "wget is required but not installed. Exiting." 24 - exit 1 25 - fi 26 - 27 - if ! command -v tar >/dev/null 2>&1; then 28 - echo "tar is required but not installed. Exiting." 29 - exit 1 30 - fi 31 - 32 - 33 - get_arch() { 34 - OS=$(uname -s) 35 - MACHINE=$(uname -m) 36 - case "$OS" in 37 - Darwin) 38 - case "$MACHINE" in 39 - x86_64) 40 - echo "x86_64-apple-darwin" 41 - ;; 42 - arm64) 43 - echo "aarch64-apple-darwin" 44 - ;; 45 - *) 46 - echo "Unsupported architecture: $MACHINE on Darwin" >&2 47 - exit 1 48 - ;; 49 - esac 50 - ;; 51 - Linux) 52 - case "$MACHINE" in 53 - x86_64|amd64) 54 - echo "x86_64-unknown-linux-musl" 55 - ;; 56 - arm64|aarch64) 57 - echo "aarch64-unknown-linux-musl" 58 - ;; 59 - *) 60 - echo "Unsupported architecture: $MACHINE on Linux" >&2 61 - exit 1 62 - ;; 63 - esac 64 - ;; 65 - *) 66 - echo "Unsupported OS: $OS" >&2 67 - exit 1 68 - ;; 69 - esac 70 - } 71 - 72 - TYPST_ARCH=$(get_arch) 73 - 74 - wget -qO typst.tar.xz \ 75 - "https://github.com/typst/typst/releases/download/v${TYPST_VERSION}/typst-${TYPST_ARCH}.tar.xz" 76 - 77 - tar -xf typst.tar.xz 78 - 79 - mkdir -p "${INSTALL_PATH}" 80 - mv "typst-${TYPST_ARCH}/typst" "${INSTALL_PATH}/typst" 81 - chmod +x "${INSTALL_PATH}/typst" 82 - 83 - rm -rf "typst.tar.xz" "typst-${TYPST_ARCH}" 84 - 85 - echo "Typst v${TYPST_VERSION} has been installed to ${INSTALL_PATH}/typst"