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..c31046b905 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,136 @@ def organizations_remove(self, request, *args, **kwargs): encounter=instance, organization=organization ).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): + if current_progress := discharge_summary.get_progress(encounter_ext_id): + 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() + self._check_discharge_summary_access(encounter) + 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() + self._check_discharge_summary_access(encounter) + 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 = 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": ( + "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/api/viewsets/facility.py b/care/emr/api/viewsets/facility.py index 2d81df7143..94bf38fadf 100644 --- a/care/emr/api/viewsets/facility.py +++ b/care/emr/api/viewsets/facility.py @@ -104,7 +104,7 @@ def get_queryset(self): return User.objects.filter( id__in=SchedulableUserResource.objects.filter( facility__external_id=self.kwargs["facility_external_id"] - ).values("resource_id") + ).values("user_id") ) diff --git a/care/emr/api/viewsets/scheduling/availability.py b/care/emr/api/viewsets/scheduling/availability.py index 8d093a37a6..c1a5e0d542 100644 --- a/care/emr/api/viewsets/scheduling/availability.py +++ b/care/emr/api/viewsets/scheduling/availability.py @@ -7,7 +7,8 @@ from django.utils import timezone from pydantic import UUID4, BaseModel, model_validator from rest_framework.decorators import action -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.generics import get_object_or_404 from rest_framework.response import Response from care.emr.api.viewsets.base import EMRBaseViewSet, EMRRetrieveMixin @@ -20,12 +21,13 @@ TokenBookingReadSpec, TokenSlotBaseSpec, ) +from care.security.authorization import AuthorizationController from care.users.models import User from care.utils.lock import Lock class SlotsForDayRequestSpec(BaseModel): - resource: UUID4 + user: UUID4 day: datetime.date @@ -37,7 +39,7 @@ class AppointmentBookingSpec(BaseModel): class AvailabilityStatsRequestSpec(BaseModel): from_date: datetime.date to_date: datetime.date - resource: UUID4 + user: UUID4 @model_validator(mode="after") def validate_period(self): @@ -104,12 +106,10 @@ def get_slots_for_day(self, request, *args, **kwargs): @classmethod def get_slots_for_day_handler(cls, facility_external_id, request_data): request_data = SlotsForDayRequestSpec(**request_data) - user = User.objects.filter(external_id=request_data.resource).first() - if not user: - raise ValidationError("Resource does not exist") + user = get_object_or_404(User, external_id=request_data.user) schedulable_resource_obj = SchedulableUserResource.objects.filter( facility__external_id=facility_external_id, - resource=user, + user=user, ).first() if not schedulable_resource_obj: raise ValidationError("Resource is not schedulable") @@ -194,9 +194,13 @@ def create_appointment_handler(cls, obj, request_data, user): @action(detail=True, methods=["POST"]) def create_appointment(self, request, *args, **kwargs): - return self.create_appointment_handler( - self.get_object(), request.data, request.user - ) + slot_obj = self.get_object() + facility = slot_obj.resource.facility + if not AuthorizationController.call( + "can_create_appointment", self.request.user, facility + ): + raise PermissionDenied("You do not have permission to create appointments") + return self.create_appointment_handler(slot_obj, request.data, request.user) @action(detail=False, methods=["POST"]) def availability_stats(self, request, *args, **kwargs): @@ -206,10 +210,10 @@ def availability_stats(self, request, *args, **kwargs): """ request_data = AvailabilityStatsRequestSpec(**request.data) # Fetch the entire schedule and calculate total slots available for each day - user = User.objects.filter(external_id=request_data.resource).first() + user = User.objects.filter(external_id=request_data.user).first() if not user: raise ValidationError("User does not exist") - resource = SchedulableUserResource.objects.filter(resource=user).first() + resource = SchedulableUserResource.objects.filter(user=user).first() if not resource: raise ValidationError("Resource is not schedulable") diff --git a/care/emr/api/viewsets/scheduling/availability_exceptions.py b/care/emr/api/viewsets/scheduling/availability_exceptions.py index 20531a36ab..7594cd6e31 100644 --- a/care/emr/api/viewsets/scheduling/availability_exceptions.py +++ b/care/emr/api/viewsets/scheduling/availability_exceptions.py @@ -14,7 +14,7 @@ class AvailabilityExceptionFilters(FilterSet): - resource = UUIDFilter(field_name="resource__resource__external_id") + user = UUIDFilter(field_name="resource__user__external_id") class AvailabilityExceptionsViewSet(EMRModelViewSet): diff --git a/care/emr/api/viewsets/scheduling/booking.py b/care/emr/api/viewsets/scheduling/booking.py index 8cfee9dd96..e9f89edaf2 100644 --- a/care/emr/api/viewsets/scheduling/booking.py +++ b/care/emr/api/viewsets/scheduling/booking.py @@ -26,14 +26,14 @@ class TokenBookingFilters(FilterSet): status = CharFilter(field_name="status") date = DateFilter(field_name="token_slot__start_datetime__date") slot = UUIDFilter(field_name="token_slot__external_id") - resource = UUIDFilter(method="filter_by_resource") + user = UUIDFilter(method="filter_by_user") patient = UUIDFilter(field_name="patient__external_id") - def filter_by_resource(self, queryset, name, value): + def filter_by_user(self, queryset, name, value): if not value: return queryset resource = SchedulableUserResource.objects.filter( - resource__external_id=value + user__external_id=value ).first() if not resource: return queryset.none() @@ -90,13 +90,13 @@ def get_queryset(self): ) @action(detail=False, methods=["GET"]) - def available_doctors(self, request, *args, **kwargs): + def available_users(self, request, *args, **kwargs): facility = Facility.objects.get(external_id=self.kwargs["facility_external_id"]) facility_users = FacilityOrganizationUser.objects.filter( organization__facility=facility, user_id__in=SchedulableUserResource.objects.filter( facility=facility - ).values("resource_id"), + ).values("user_id"), ) return Response( diff --git a/care/emr/api/viewsets/scheduling/schedule.py b/care/emr/api/viewsets/scheduling/schedule.py index cad402b6e6..04f39bb44a 100644 --- a/care/emr/api/viewsets/scheduling/schedule.py +++ b/care/emr/api/viewsets/scheduling/schedule.py @@ -18,7 +18,7 @@ class ScheduleFilters(FilterSet): - resource = UUIDFilter(field_name="resource__resource__external_id") + user = UUIDFilter(field_name="resource__user__external_id") class ScheduleViewSet(EMRModelViewSet): @@ -51,7 +51,7 @@ def validate_data(self, instance, model_obj=None): facility = self.get_facility_obj() schedule_user = get_object_or_404(User, external_id=instance.user) if not FacilityOrganizationUser.objects.filter( - user=schedule_user, facility=facility + user=schedule_user, organization__facility=facility ).exists(): raise ValidationError("Schedule User is not part of the facility") diff --git a/care/emr/api/viewsets/user.py b/care/emr/api/viewsets/user.py index c89531597f..456c8afef7 100644 --- a/care/emr/api/viewsets/user.py +++ b/care/emr/api/viewsets/user.py @@ -18,6 +18,7 @@ UserTypeRoleMapping, UserUpdateSpec, ) +from care.security.authorization import AuthorizationController from care.security.models import RoleModel from care.users.api.serializers.user import UserImageUploadSerializer, UserSerializer from care.users.models import User @@ -72,7 +73,11 @@ def perform_create(self, instance): def authorize_update(self, request_obj, model_instance): if self.request.user.is_superuser: return True - return request_obj.user == model_instance + return self.request.user.id == model_instance.id + + def authorize_create(self, instance): + if not AuthorizationController.call("can_create_user", self.request.user): + raise PermissionDenied("You do not have permission to create Users") def authorize_delete(self, instance): return self.request.user.is_superuser diff --git a/care/emr/migrations/0061_rename_resource_schedulableuserresource_user_and_more.py b/care/emr/migrations/0061_rename_resource_schedulableuserresource_user_and_more.py new file mode 100644 index 0000000000..b0ff93e690 --- /dev/null +++ b/care/emr/migrations/0061_rename_resource_schedulableuserresource_user_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.3 on 2025-01-03 10:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("emr", "0060_alter_medicationrequest_dosage_instruction"), + ] + + operations = [ + migrations.RenameField( + model_name="schedulableuserresource", + old_name="resource", + new_name="user", + ), + 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/emr/models/scheduling/schedule.py b/care/emr/models/scheduling/schedule.py index 88db96daaf..6d10ba55ff 100644 --- a/care/emr/models/scheduling/schedule.py +++ b/care/emr/models/scheduling/schedule.py @@ -7,7 +7,7 @@ class SchedulableUserResource(EMRBaseModel): """A resource that can be scheduled for appointments.""" facility = models.ForeignKey("facility.Facility", on_delete=models.CASCADE) - resource = models.ForeignKey("users.User", on_delete=models.CASCADE) + user = models.ForeignKey("users.User", on_delete=models.CASCADE) # TODO : Index with resource and facility 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/emr/resources/questionnaire_response/spec.py b/care/emr/resources/questionnaire_response/spec.py index 113906c9c5..b7d359edc0 100644 --- a/care/emr/resources/questionnaire_response/spec.py +++ b/care/emr/resources/questionnaire_response/spec.py @@ -43,6 +43,8 @@ class QuestionnaireResponseReadSpec(EMRResource): structured_response_type: str created_by: UserSpec = dict updated_by: UserSpec = dict + created_date: datetime | None = None + modified_date: datetime | None = None @classmethod def perform_extra_serialization(cls, mapping, obj): diff --git a/care/emr/resources/scheduling/availability_exception/spec.py b/care/emr/resources/scheduling/availability_exception/spec.py index 65f4144023..c54ba987ab 100644 --- a/care/emr/resources/scheduling/availability_exception/spec.py +++ b/care/emr/resources/scheduling/availability_exception/spec.py @@ -36,9 +36,9 @@ def perform_extra_deserialization(self, is_update, obj): if not is_update: resource = None try: - user_resource = User.objects.get(external_id=self.resource) + user = User.objects.get(external_id=self.resource) resource = SchedulableUserResource.objects.get( - resource=user_resource, + user=user, facility=Facility.objects.get(external_id=self.facility), ) obj.resource = resource diff --git a/care/emr/resources/scheduling/schedule/spec.py b/care/emr/resources/scheduling/schedule/spec.py index 54d622c648..556d190dac 100644 --- a/care/emr/resources/scheduling/schedule/spec.py +++ b/care/emr/resources/scheduling/schedule/spec.py @@ -71,7 +71,7 @@ def perform_extra_deserialization(self, is_update, obj): resource, _ = SchedulableUserResource.objects.get_or_create( facility=obj.facility, - resource=user, + user=user, ) obj.resource = resource obj.availabilities = self.availabilities diff --git a/care/emr/resources/scheduling/slot/spec.py b/care/emr/resources/scheduling/slot/spec.py index e2342d1998..9c8f2de37a 100644 --- a/care/emr/resources/scheduling/slot/spec.py +++ b/care/emr/resources/scheduling/slot/spec.py @@ -77,7 +77,7 @@ class TokenBookingReadSpec(TokenBookingBaseSpec): booked_by: UserSpec status: str reason_for_visit: str - resource: dict = {} + user: dict = {} @classmethod def perform_extra_serialization(cls, mapping, obj): @@ -88,6 +88,6 @@ def perform_extra_serialization(cls, mapping, obj): mapping["patient"] = PatientOTPReadSpec.serialize(obj.patient).model_dump( exclude=["meta"] ) - mapping["resource"] = UserSpec.serialize( - User.objects.get(id=obj.token_slot.resource.resource_id) + mapping["user"] = UserSpec.serialize( + User.objects.get(id=obj.token_slot.resource.user_id) ).model_dump(exclude=["meta"]) 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/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 ) 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/security/authorization/__init__.py b/care/security/authorization/__init__.py index bb17448bf4..1fe9ef9aad 100644 --- a/care/security/authorization/__init__.py +++ b/care/security/authorization/__init__.py @@ -5,3 +5,5 @@ from .encounter import * # noqa from .patient import * # noqa from .facility import * # noqa +from .user import * # noqa +from .user_schedule import * # noqa diff --git a/care/security/authorization/user.py b/care/security/authorization/user.py new file mode 100644 index 0000000000..34ee5f9cd3 --- /dev/null +++ b/care/security/authorization/user.py @@ -0,0 +1,25 @@ +from care.emr.models.organization import FacilityOrganizationUser, OrganizationUser +from care.security.authorization.base import ( + AuthorizationController, + AuthorizationHandler, +) +from care.security.permissions.user import UserPermissions + + +class UserAccess(AuthorizationHandler): + def can_create_user(self, user): + """ + Check if the user has permission to create a user + """ + if user.is_superuser: + return True + roles = self.get_role_from_permissions([UserPermissions.can_create_user.value]) + return ( + OrganizationUser.objects.filter(user=user, roles_id__in=roles).exists() + or FacilityOrganizationUser.objects.filter( + user=user, roles_id__in=roles + ).exists() + ) + + +AuthorizationController.register_internal_controller(UserAccess) diff --git a/care/security/authorization/user_schedule.py b/care/security/authorization/user_schedule.py index 19273e6b5e..b591b08f8f 100644 --- a/care/security/authorization/user_schedule.py +++ b/care/security/authorization/user_schedule.py @@ -1,7 +1,6 @@ from care.emr.models.organization import FacilityOrganizationUser -from care.security.authorization.base import ( - AuthorizationHandler, -) +from care.security.authorization import AuthorizationController +from care.security.authorization.base import AuthorizationHandler from care.security.permissions.user_schedule import UserSchedulePermissions @@ -16,6 +15,16 @@ def can_list_user_schedule(self, user, facility): facility=facility, ) + def can_create_appointment(self, user, facility): + """ + Check if the user has permission to list schedules in a facility + """ + return self.check_permission_in_facility_organization( + [UserSchedulePermissions.can_create_appointment.name], + user, + facility=facility, + ) + def can_list_facility_user_booking(self, user, facility): """ Check if the user has permission to list schedules in a facility @@ -31,8 +40,8 @@ def can_write_user_schedule(self, user, facility, schedule_user): Check if the user has permission to write schedules in the facility """ facility_orgs = FacilityOrganizationUser.objects.filter( - user=schedule_user, facility=facility - ).values_list("parent_cache") + user=schedule_user, organization__facility=facility + ).values_list("organization__parent_cache", flat=True) cache = [] for org_cache in facility_orgs: cache.extend(org_cache) @@ -46,8 +55,8 @@ def can_write_user_booking(self, user, facility, schedule_user): Check if the user has permission to write schedules in the facility """ facility_orgs = FacilityOrganizationUser.objects.filter( - user=schedule_user, facility=facility - ).values_list("parent_cache") + user=schedule_user, organization__facility=facility + ).values_list("organization__parent_cache", flat=True) cache = [] for org_cache in facility_orgs: cache.extend(org_cache) @@ -55,3 +64,6 @@ def can_write_user_booking(self, user, facility, schedule_user): return self.check_permission_in_facility_organization( [UserSchedulePermissions.can_write_user_booking.name], user, orgs=cache ) + + +AuthorizationController.register_internal_controller(UserScheduleAccess) diff --git a/care/security/permissions/user_schedule.py b/care/security/permissions/user_schedule.py index 22f4ed5eea..484d69515f 100644 --- a/care/security/permissions/user_schedule.py +++ b/care/security/permissions/user_schedule.py @@ -35,3 +35,9 @@ class UserSchedulePermissions(enum.Enum): PermissionContext.FACILITY, [ADMIN_ROLE, STAFF_ROLE, FACILITY_ADMIN_ROLE, DOCTOR_ROLE, NURSE_ROLE], ) + can_create_appointment = Permission( + "Can create appointment on facility", + "", + PermissionContext.FACILITY, + [ADMIN_ROLE, STAFF_ROLE, FACILITY_ADMIN_ROLE, DOCTOR_ROLE, NURSE_ROLE], + ) 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"