diff --git a/care/emr/api/viewsets/diagnostic_report.py b/care/emr/api/viewsets/diagnostic_report.py new file mode 100644 index 0000000000..bc86a7e415 --- /dev/null +++ b/care/emr/api/viewsets/diagnostic_report.py @@ -0,0 +1,164 @@ +from datetime import UTC, datetime + +from django_filters import CharFilter, FilterSet, OrderingFilter, UUIDFilter +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema +from pydantic import BaseModel, Field +from rest_framework.decorators import action +from rest_framework.response import Response + +from care.emr.api.viewsets.base import EMRModelViewSet +from care.emr.api.viewsets.encounter_authz_base import EncounterBasedAuthorizationBase +from care.emr.models.diagnostic_report import DiagnosticReport +from care.emr.models.observation import Observation +from care.emr.resources.diagnostic_report.spec import ( + DiagnosticReportCreateSpec, + DiagnosticReportListSpec, + DiagnosticReportRetrieveSpec, + DiagnosticReportUpdateSpec, + StatusChoices, +) +from care.emr.resources.observation.spec import ( + ObservationSpec, + Performer, + PerformerType, +) + + +class DiagnosticReportFilters(FilterSet): + phase = CharFilter(field_name="based_on__phase", lookup_expr="iexact") + status = CharFilter(field_name="status", lookup_expr="iexact") + specimen = UUIDFilter(field_name="specimen__external_id") + based_on = UUIDFilter(field_name="based_on__external_id") + + ordering = OrderingFilter( + fields=( + ("created_date", "created_date"), + ("modified_date", "modified_date"), + ) + ) + + +class DiagnosticReportViewSet(EncounterBasedAuthorizationBase, EMRModelViewSet): + database_model = DiagnosticReport + pydantic_model = DiagnosticReportCreateSpec + pydantic_update_model = DiagnosticReportUpdateSpec + pydantic_read_model = DiagnosticReportListSpec + pydantic_retrieve_model = DiagnosticReportRetrieveSpec + filter_backends = [DjangoFilterBackend] + filterset_class = DiagnosticReportFilters + + def perform_create(self, instance): + instance.performer = self.request.user + super().perform_create(instance) + + class DiagnosticReportObservationRequest(BaseModel): + observations: list[ObservationSpec] = Field( + default=[], + description="List of observations that are part of the diagnostic report", + ) + + @extend_schema( + request=DiagnosticReportObservationRequest, + responses={200: DiagnosticReportRetrieveSpec}, + tags=["diagnostic_report"], + ) + @action(detail=True, methods=["POST"]) + def observations(self, request, *args, **kwargs): + data = self.DiagnosticReportObservationRequest(**request.data) + report: DiagnosticReport = self.get_object() + self.authorize_update({}, report) + + observations = [] + for observation in data.observations: + if not observation.performer: + observation.performer = Performer( + type=PerformerType.user, + id=str(request.user.external_id), + ) + + observation_instance = observation.de_serialize() + observation_instance.subject_id = report.subject.id + observation_instance.encounter = report.encounter + observation_instance.patient = report.subject + + observations.append(observation_instance) + + observation_instances = Observation.objects.bulk_create(observations) + report.result.set(observation_instances) + report.status = StatusChoices.partial + report.save() + + return Response( + self.get_retrieve_pydantic_model().serialize(report).to_json(), + ) + + class DiagnosticReportVerifyRequest(BaseModel): + is_approved: bool = Field( + description="Indicates whether the diagnostic report is approved or rejected", + ) + + @extend_schema( + request=DiagnosticReportVerifyRequest, + responses={200: DiagnosticReportRetrieveSpec}, + tags=["diagnostic_report"], + ) + @action(detail=True, methods=["POST"]) + def verify(self, request, *args, **kwargs): + data = self.DiagnosticReportVerifyRequest(**request.data) + report: DiagnosticReport = self.get_object() + self.authorize_update({}, report) + + if data.is_approved: + report.status = StatusChoices.preliminary + else: + report.status = StatusChoices.cancelled + + report.issued = datetime.now(UTC) + report.save() + + return Response( + self.get_retrieve_pydantic_model().serialize(report).to_json(), + ) + + class DiagnosticReportReviewRequest(BaseModel): + is_approved: bool = Field( + description="Indicates whether the diagnostic report is approved or rejected", + ) + conclusion: str | None = Field( + default=None, + description="Additional notes about the review", + ) + + @extend_schema( + request=DiagnosticReportReviewRequest, + responses={200: DiagnosticReportRetrieveSpec}, + tags=["diagnostic_report"], + ) + @action(detail=True, methods=["POST"]) + def review(self, request, *args, **kwargs): + data = self.DiagnosticReportReviewRequest(**request.data) + report: DiagnosticReport = self.get_object() + self.authorize_update({}, report) + + if ( + report.results_interpreter + and report.results_interpreter.external_id != request.user.external_id + ): + return Response( + {"detail": "This report is assigned to a different user for review."}, + status=403, + ) + + if data.is_approved: + report.status = StatusChoices.final + else: + report.status = StatusChoices.cancelled + + report.conclusion = data.conclusion + report.results_interpreter = request.user + report.save() + + return Response( + self.get_retrieve_pydantic_model().serialize(report).to_json(), + ) diff --git a/care/emr/api/viewsets/service_request.py b/care/emr/api/viewsets/service_request.py new file mode 100644 index 0000000000..3202711502 --- /dev/null +++ b/care/emr/api/viewsets/service_request.py @@ -0,0 +1,39 @@ +from django_filters import CharFilter, FilterSet, OrderingFilter, UUIDFilter +from django_filters.rest_framework import DjangoFilterBackend + +from care.emr.api.viewsets.base import EMRModelViewSet +from care.emr.api.viewsets.encounter_authz_base import EncounterBasedAuthorizationBase +from care.emr.models.service_request import ServiceRequest +from care.emr.resources.service_request.spec import ( + ServiceRequestCreateSpec, + ServiceRequestListSpec, + ServiceRequestRetrieveSpec, + ServiceRequestUpdateSpec, +) + + +class ServiceRequestFilters(FilterSet): + phase = CharFilter(field_name="phase", lookup_expr="iexact") + subject = UUIDFilter(field_name="subject__external_id") + encounter = UUIDFilter(field_name="encounter__external_id") + + ordering = OrderingFilter( + fields=( + ("created_date", "created_date"), + ("modified_date", "modified_date"), + ) + ) + + +class ServiceRequestViewSet(EncounterBasedAuthorizationBase, EMRModelViewSet): + database_model = ServiceRequest + pydantic_model = ServiceRequestCreateSpec + pydantic_update_model = ServiceRequestUpdateSpec + pydantic_read_model = ServiceRequestListSpec + pydantic_retrieve_model = ServiceRequestRetrieveSpec + filter_backends = [DjangoFilterBackend] + filterset_class = ServiceRequestFilters + + def perform_create(self, instance): + instance.requester = self.request.user + return super().perform_create(instance) diff --git a/care/emr/api/viewsets/specimen.py b/care/emr/api/viewsets/specimen.py new file mode 100644 index 0000000000..332c18301b --- /dev/null +++ b/care/emr/api/viewsets/specimen.py @@ -0,0 +1,186 @@ +from datetime import UTC, datetime + +from django.db.models import Q +from django_filters import CharFilter, FilterSet, OrderingFilter, UUIDFilter +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema +from pydantic import UUID4, BaseModel, Field, field_validator +from rest_framework.decorators import action +from rest_framework.generics import get_object_or_404 +from rest_framework.response import Response + +from care.emr.api.viewsets.base import EMRModelViewSet +from care.emr.api.viewsets.encounter_authz_base import EncounterBasedAuthorizationBase +from care.emr.fhir.schema.base import CodeableConcept +from care.emr.models.specimen import Specimen +from care.emr.resources.specimen.spec import ( + SpecimenCreateSpec, + SpecimenListSpec, + SpecimenProcessingSpec, + SpecimenRetrieveSpec, + SpecimenSpec, + SpecimenUpdateSpec, + StatusChoices, +) + + +class SpecimenFilters(FilterSet): + phase = CharFilter(field_name="request__phase", lookup_expr="iexact") + request = UUIDFilter(field_name="request__external_id") + encounter = UUIDFilter(field_name="request__encounter__external_id") + + ordering = OrderingFilter( + fields=( + ("created_date", "created_date"), + ("modified_date", "modified_date"), + ) + ) + + +class SpecimenViewSet(EncounterBasedAuthorizationBase, EMRModelViewSet): + database_model = Specimen + pydantic_model = SpecimenCreateSpec + pydantic_update_model = SpecimenUpdateSpec + pydantic_read_model = SpecimenListSpec + pydantic_retrieve_model = SpecimenRetrieveSpec + filter_backends = [DjangoFilterBackend] + filterset_class = SpecimenFilters + + def get_object(self) -> Specimen: + self.authorize_read_encounter() + return get_object_or_404( + self.get_queryset(), + Q(external_id__iexact=self.kwargs[self.lookup_field]) + | Q(identifier=self.kwargs[self.lookup_field]) + | Q(accession_identifier=self.kwargs[self.lookup_field]), + ) + + class SpecimenCollectRequest(BaseModel): + identifier: str | None = Field( + default=None, + description="The identifier assigned to the specimen while collecting, this can be barcode or any other identifier", + ) + + @extend_schema( + request=SpecimenCollectRequest, + responses={200: SpecimenRetrieveSpec}, + tags=["specimen"], + ) + @action(detail=True, methods=["POST"]) + def collect(self, request, *args, **kwargs): + data = self.SpecimenCollectRequest(**request.data) + specimen = self.get_object() + self.authorize_update({}, specimen) + + specimen.identifier = data.identifier + specimen.status = StatusChoices.available + specimen.collected_at = datetime.now(UTC) + specimen.collected_by = request.user + specimen.save() + + return Response( + self.get_retrieve_pydantic_model().serialize(specimen).to_json(), + ) + + class SpecimenSendToLabRequest(BaseModel): + lab: UUID4 = Field( + description="The laboratory to which the specimen is being sent", + ) + + @extend_schema( + request=SpecimenSendToLabRequest, + responses={200: SpecimenRetrieveSpec}, + tags=["specimen"], + ) + @action(detail=True, methods=["POST"]) + def send_to_lab(self, request, *args, **kwargs): + data = self.SpecimenSendToLabRequest(**request.data) + specimen = self.get_object() + service_request = specimen.request + self.authorize_update({}, specimen) + + service_request.location = data.lab + specimen.dispatched_at = datetime.now(UTC) + specimen.dispatched_by = request.user + service_request.save() + specimen.save() + + return Response( + self.get_retrieve_pydantic_model().serialize(specimen).to_json(), + ) + + class SpecimenReceiveAtLabRequest(BaseModel): + accession_identifier: str | None = Field( + default=None, + description="The identifier assigned to the specimen by the laboratory", + ) + + condition: list[CodeableConcept] | None = Field( + default=None, + description="The condition of the specimen while received at the laboratory", + ) + + note: str | None = Field( + default=None, + description="Comments made about the specimen while received at the laboratory", + ) + + @field_validator("condition") + @classmethod + def validate_condition(cls, value: CodeableConcept): + return SpecimenSpec.validate_condition(value) + + @extend_schema( + request=SpecimenReceiveAtLabRequest, + responses={200: SpecimenRetrieveSpec}, + tags=["specimen"], + ) + @action(detail=True, methods=["POST"]) + def receive_at_lab(self, request, *args, **kwargs): + data = self.SpecimenReceiveAtLabRequest(**request.data) + specimen = self.get_object() + self.authorize_update({}, specimen) + + specimen.accession_identifier = data.accession_identifier + specimen.condition = data.condition + specimen.received_at = datetime.now(UTC) + specimen.received_by = request.user + specimen.note = data.note + specimen.save() + + return Response( + self.get_retrieve_pydantic_model().serialize(specimen).to_json(), + ) + + class SpecimenProcessRequest(BaseModel): + process: list[SpecimenProcessingSpec] = Field( + description="The processing steps that have been performed on the specimen", + ) + + @extend_schema( + request=SpecimenProcessRequest, + responses={200: SpecimenRetrieveSpec}, + tags=["specimen"], + ) + @action(detail=True, methods=["POST"]) + def process(self, request, *args, **kwargs): + data = self.SpecimenProcessRequest(**request.data) + specimen = self.get_object() + self.authorize_update({}, specimen) + + processes = [] + for process in data.process: + if not process.time: + process.time = datetime.now(UTC) + + if not process.performer: + process.performer = request.user.external_id + + processes.append(process.model_dump(mode="json")) + + specimen.processing.extend(processes) + specimen.save() + + return Response( + self.get_retrieve_pydantic_model().serialize(specimen).to_json(), + ) diff --git a/care/emr/apps.py b/care/emr/apps.py index 52b407ce5d..cda99e52c6 100644 --- a/care/emr/apps.py +++ b/care/emr/apps.py @@ -5,3 +5,6 @@ class EMRConfig(AppConfig): name = "care.emr" verbose_name = _("Electronic Medical Record") + + def ready(self): + import care.emr.signals # noqa diff --git a/care/emr/fhir/resources/base.py b/care/emr/fhir/resources/base.py index 8a18da3f69..3d0c7c8ab2 100644 --- a/care/emr/fhir/resources/base.py +++ b/care/emr/fhir/resources/base.py @@ -12,7 +12,7 @@ default_fhir_client = FHIRClient(server_url=settings.SNOWSTORM_DEPLOYMENT_URL) -class ResourceManger: +class ResourceManager: _fhir_client = default_fhir_client resource = "" allowed_properties = [] diff --git a/care/emr/fhir/resources/code_concept.py b/care/emr/fhir/resources/code_concept.py index 0399d1ac7a..d9c56b0835 100644 --- a/care/emr/fhir/resources/code_concept.py +++ b/care/emr/fhir/resources/code_concept.py @@ -1,10 +1,10 @@ from pydantic.main import BaseModel -from care.emr.fhir.resources.base import ResourceManger +from care.emr.fhir.resources.base import ResourceManager from care.emr.fhir.utils import parse_fhir_parameter_output -class CodeConceptResource(ResourceManger): +class CodeConceptResource(ResourceManager): allowed_properties = ["system", "code", "property"] resource = "CodeConcept" diff --git a/care/emr/fhir/resources/code_system.py b/care/emr/fhir/resources/code_system.py index 590560c976..ff31b4b953 100644 --- a/care/emr/fhir/resources/code_system.py +++ b/care/emr/fhir/resources/code_system.py @@ -3,13 +3,13 @@ from pydantic.main import BaseModel from care.emr.fhir.exceptions import MoreThanOneFHIRResourceFoundError -from care.emr.fhir.resources.base import ResourceManger +from care.emr.fhir.resources.base import ResourceManager from care.facility.models import User User.objects.filter() -class CodeSystemResource(ResourceManger): +class CodeSystemResource(ResourceManager): allowed_properties = ["name", "url"] resource = "CodeSystem" diff --git a/care/emr/fhir/resources/concept_map.py b/care/emr/fhir/resources/concept_map.py new file mode 100644 index 0000000000..ada58a50a6 --- /dev/null +++ b/care/emr/fhir/resources/concept_map.py @@ -0,0 +1,91 @@ +from enum import Enum + +from pydantic.main import BaseModel + +from care.emr.fhir.resources.base import ResourceManager +from care.emr.fhir.utils import parse_fhir_parameter_output + + +class ConceptMapResource(ResourceManager): + allowed_properties = ["system", "code"] + resource = "ConceptMap" + + def serialize_lookup(self, result): + structured_output = parse_fhir_parameter_output(result) + + return ConceptMapResult( + result=structured_output.get("metadata", {}).get("result", False), + match=[ + ConceptMapMatch( + equivalence=match.get("equivalence"), + concept=ConceptMapConcept( + display=match.get("concept", {}).get("display"), + code=match.get("concept", {}).get("code"), + ), + source=match["source"], + ) + for match in structured_output.get("match", []) + ], + ) + + def translate(self): + if "system" not in self._filters or "code" not in self._filters: + err = "Both system and code are required" + raise ValueError(err) + full_result = self.query("GET", "ConceptMap/$translate", self._filters) + + return self.serialize_lookup(full_result["parameter"]) + + +class ConceptMapConcept(BaseModel): + display: str + code: str + + +class ConceptMapEquivalence(str, Enum): + def __new__(cls, value, priority=None): + obj = str.__new__(cls, value) + obj._value_ = value + obj.priority = priority + return obj + + # This is the strongest form of equality. + equal = "equal", 1 + + # Indicates equivalence, almost identical. + equivalent = "equivalent", 2 + + # Concepts are related, exact nature unspecified. + relatedto = "relatedto", 3 + + # The source concept is more specific than the target. + specializes = "specializes", 4 + + # The source concept is more general and subsumes the target. + subsumes = "subsumes", 5 + + # The source is broader in meaning than the target. + wider = "wider", 6 + + # The source is narrower in meaning than the target. + narrower = "narrower", 7 + + # The relationship is approximate but not exact. + inexact = "inexact", 8 + + # Indicates a complete lack of relationship or overlap. + disjoint = "disjoint", 9 + + # No match exists for the source concept in the target system. + unmatched = "unmatched", 10 + + +class ConceptMapMatch(BaseModel): + equivalence: ConceptMapEquivalence + concept: ConceptMapConcept + source: str + + +class ConceptMapResult(BaseModel): + result: bool + match: list[ConceptMapMatch] diff --git a/care/emr/fhir/resources/valueset.py b/care/emr/fhir/resources/valueset.py index 5bba40f906..d5ee6d1959 100644 --- a/care/emr/fhir/resources/valueset.py +++ b/care/emr/fhir/resources/valueset.py @@ -1,6 +1,6 @@ from pydantic.main import BaseModel -from care.emr.fhir.resources.base import ResourceManger +from care.emr.fhir.resources.base import ResourceManager from care.emr.fhir.resources.code_concept import MinimalCodeConcept from care.emr.fhir.schema.base import Coding from care.emr.fhir.schema.valueset.valueset import ValueSetInclude @@ -13,7 +13,7 @@ class ValueSetFilterValidation(BaseModel): count: int = None -class ValueSetResource(ResourceManger): +class ValueSetResource(ResourceManager): allowed_properties = ["include", "exclude", "search", "count", "display_language"] def serialize(self, result): diff --git a/care/emr/fhir/utils.py b/care/emr/fhir/utils.py index 824ee4ef93..10942794b9 100644 --- a/care/emr/fhir/utils.py +++ b/care/emr/fhir/utils.py @@ -63,6 +63,23 @@ def parse_fhir_property_part(parts: list[dict[str, Any]]) -> dict[str, Any]: return {code: value} if code else {} +def parse_fhir_match_part(parameters: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Parse FHIR match response into a structured format. + + Args: + matches: List of FHIR matches + + Returns: + List containing parsed FHIR data + """ + response = {} + for parameter in parameters: + name = parameter.get("name") + if name in ["equivalence", "concept", "source"]: + response[name] = parse_value_field(parameter) + return response + def parse_fhir_parameter_output(parameters: list[dict[str, Any]]) -> dict[str, Any]: """ @@ -80,7 +97,15 @@ def parse_fhir_parameter_output(parameters: list[dict[str, Any]]) -> dict[str, A name = parameter.get("name") # Handle basic fields - if name in ["code", "display", "system", "version", "name", "inactive"]: + if name in [ + "code", + "display", + "system", + "version", + "name", + "inactive", + "result", + ]: response["metadata"][name] = parse_value_field(parameter) # Handle property fields @@ -104,4 +129,9 @@ def parse_fhir_parameter_output(parameters: list[dict[str, Any]]) -> dict[str, A if "extension" in parameter: designation_data["context"] = parse_extension(parameter["extension"]) response["designations"].append(designation_data) + + elif name == "match": + if "match" not in response: + response["match"] = [] + response["match"].append(parse_fhir_match_part(parameter["part"])) return response diff --git a/care/emr/migrations/0002_servicerequest_specimen_diagnosticreport.py b/care/emr/migrations/0002_servicerequest_specimen_diagnosticreport.py new file mode 100644 index 0000000000..ed184df195 --- /dev/null +++ b/care/emr/migrations/0002_servicerequest_specimen_diagnosticreport.py @@ -0,0 +1,118 @@ +# Generated by Django 5.1.3 on 2025-01-06 07:47 + +import care.emr.models.organization +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('emr', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ServiceRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('external_id', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)), + ('created_date', models.DateTimeField(auto_now_add=True, db_index=True, null=True)), + ('modified_date', models.DateTimeField(auto_now=True, db_index=True, null=True)), + ('deleted', models.BooleanField(db_index=True, default=False)), + ('history', models.JSONField(default=dict)), + ('meta', models.JSONField(default=dict)), + ('status', models.CharField(blank=True, max_length=100, null=True)), + ('intent', models.CharField(blank=True, max_length=100, null=True)), + ('priority', models.CharField(blank=True, max_length=100, null=True)), + ('category', models.CharField(blank=True, max_length=100, null=True)), + ('code', models.JSONField(default=dict)), + ('do_not_perform', models.BooleanField(default=False)), + ('occurrence_datetime', models.DateTimeField(blank=True, null=True)), + ('occurrence_timing', models.JSONField(blank=True, null=True)), + ('as_needed', models.BooleanField(default=False)), + ('as_needed_for', models.JSONField(blank=True, null=True)), + ('authored_on', models.DateTimeField(blank=True, null=True)), + ('location', models.UUIDField(blank=True, null=True, verbose_name=care.emr.models.organization.Organization)), + ('note', models.TextField(blank=True, null=True)), + ('patient_instruction', models.TextField(blank=True, null=True)), + ('created_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created_by', to=settings.AUTH_USER_MODEL)), + ('encounter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='service_request', to='emr.encounter')), + ('replaces', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='emr.servicerequest')), + ('requester', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requested_service_request', to=settings.AUTH_USER_MODEL)), + ('subject', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='service_request', to='emr.patient')), + ('updated_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Specimen', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('external_id', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)), + ('created_date', models.DateTimeField(auto_now_add=True, db_index=True, null=True)), + ('modified_date', models.DateTimeField(auto_now=True, db_index=True, null=True)), + ('deleted', models.BooleanField(db_index=True, default=False)), + ('history', models.JSONField(default=dict)), + ('meta', models.JSONField(default=dict)), + ('identifier', models.CharField(blank=True, max_length=100, null=True)), + ('accession_identifier', models.CharField(blank=True, max_length=100, null=True)), + ('status', models.CharField(blank=True, max_length=100, null=True)), + ('type', models.JSONField(default=dict)), + ('collected_at', models.DateTimeField(blank=True, null=True)), + ('dispatched_at', models.DateTimeField(blank=True, null=True)), + ('received_at', models.DateTimeField(blank=True, null=True)), + ('condition', models.JSONField(blank=True, null=True)), + ('processing', models.JSONField(blank=True, default=list, null=True)), + ('note', models.TextField(blank=True, null=True)), + ('collected_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='collected_specimen', to=settings.AUTH_USER_MODEL)), + ('created_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created_by', to=settings.AUTH_USER_MODEL)), + ('dispatched_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='dispatched_specimen', to=settings.AUTH_USER_MODEL)), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='emr.specimen')), + ('received_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_specimen', to=settings.AUTH_USER_MODEL)), + ('request', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='specimen', to='emr.servicerequest')), + ('subject', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='specimen', to='emr.patient')), + ('updated_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='DiagnosticReport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('external_id', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)), + ('created_date', models.DateTimeField(auto_now_add=True, db_index=True, null=True)), + ('modified_date', models.DateTimeField(auto_now=True, db_index=True, null=True)), + ('deleted', models.BooleanField(db_index=True, default=False)), + ('history', models.JSONField(default=dict)), + ('meta', models.JSONField(default=dict)), + ('status', models.CharField(blank=True, max_length=100, null=True)), + ('category', models.JSONField(blank=True, null=True)), + ('code', models.JSONField(default=dict)), + ('issued', models.DateTimeField(blank=True, null=True)), + ('effective_period', models.JSONField(blank=True, null=True)), + ('media', models.JSONField(blank=True, default=list, null=True)), + ('note', models.TextField(blank=True, null=True)), + ('conclusion', models.TextField(blank=True, null=True)), + ('created_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created_by', to=settings.AUTH_USER_MODEL)), + ('encounter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='diagnostic_report', to='emr.encounter')), + ('performer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='performed_diagnostic_report', to=settings.AUTH_USER_MODEL)), + ('result', models.ManyToManyField(blank=True, related_name='diagnostic_report', to='emr.observation')), + ('results_interpreter', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interpreted_diagnostic_report', to=settings.AUTH_USER_MODEL)), + ('subject', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='diagnostic_report', to='emr.patient')), + ('updated_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL)), + ('based_on', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='diagnostic_report', to='emr.servicerequest')), + ('specimen', models.ManyToManyField(blank=True, related_name='diagnostic_report', to='emr.specimen')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/care/emr/migrations/0006_merge_20250110_1637.py b/care/emr/migrations/0006_merge_20250110_1637.py new file mode 100644 index 0000000000..826b652579 --- /dev/null +++ b/care/emr/migrations/0006_merge_20250110_1637.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.3 on 2025-01-10 11:07 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('emr', '0002_servicerequest_specimen_diagnosticreport'), + ('emr', '0005_alter_availability_slot_size_in_minutes_and_more'), + ] + + operations = [ + ] diff --git a/care/emr/migrations/0007_servicerequest_phase.py b/care/emr/migrations/0007_servicerequest_phase.py new file mode 100644 index 0000000000..1fd6e218d1 --- /dev/null +++ b/care/emr/migrations/0007_servicerequest_phase.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.3 on 2025-01-10 13:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('emr', '0006_merge_20250110_1637'), + ] + + operations = [ + migrations.AddField( + model_name='servicerequest', + name='phase', + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/care/emr/migrations/0008_merge_20250111_0054.py b/care/emr/migrations/0008_merge_20250111_0054.py new file mode 100644 index 0000000000..b05ec23490 --- /dev/null +++ b/care/emr/migrations/0008_merge_20250111_0054.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.3 on 2025-01-10 19:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('emr', '0006_alter_patient_blood_group'), + ('emr', '0007_servicerequest_phase'), + ] + + operations = [ + ] diff --git a/care/emr/models/diagnostic_report.py b/care/emr/models/diagnostic_report.py new file mode 100644 index 0000000000..1a7f2990af --- /dev/null +++ b/care/emr/models/diagnostic_report.py @@ -0,0 +1,65 @@ +from django.db import models + +from care.emr.models.base import EMRBaseModel +from care.emr.models.encounter import Encounter +from care.emr.models.observation import Observation +from care.emr.models.patient import Patient +from care.emr.models.service_request import ServiceRequest +from care.emr.models.specimen import Specimen +from care.users.models import User + + +class DiagnosticReport(EMRBaseModel): + status = models.CharField(max_length=100, null=True, blank=True) + + category = models.JSONField(null=True, blank=True) + code = models.JSONField(default=dict, null=False, blank=False) + + based_on = models.ForeignKey( + ServiceRequest, on_delete=models.CASCADE, related_name="diagnostic_report" + ) # TODO: Make it GenericForeignKey when needed + subject = models.ForeignKey( + Patient, + on_delete=models.CASCADE, + related_name="diagnostic_report", + ) + encounter = models.ForeignKey( + Encounter, + on_delete=models.CASCADE, + related_name="diagnostic_report", + ) + + performer = models.ForeignKey( + User, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="performed_diagnostic_report", + ) + results_interpreter = models.ForeignKey( + User, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="interpreted_diagnostic_report", + ) + + issued = models.DateTimeField(null=True, blank=True) + effective_period = models.JSONField(null=True, blank=True) + + specimen = models.ManyToManyField( + Specimen, blank=True, related_name="diagnostic_report" + ) + result = models.ManyToManyField( + Observation, blank=True, related_name="diagnostic_report" + ) + + media = models.JSONField(default=list, null=True, blank=True) + + note = models.TextField(null=True, blank=True) + conclusion = models.TextField(null=True, blank=True) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.based_on: + self.based_on.save() diff --git a/care/emr/models/service_request.py b/care/emr/models/service_request.py new file mode 100644 index 0000000000..4432dd7c5e --- /dev/null +++ b/care/emr/models/service_request.py @@ -0,0 +1,106 @@ +from django.apps import apps +from django.db import models + +from care.emr.models.base import EMRBaseModel +from care.emr.models.encounter import Encounter +from care.emr.models.organization import Organization +from care.emr.models.patient import Patient +from care.users.models import User + + +class ServiceRequest(EMRBaseModel): + status = models.CharField(max_length=100, null=True, blank=True) + intent = models.CharField(max_length=100, null=True, blank=True) + priority = models.CharField(max_length=100, null=True, blank=True) + + category = models.CharField(max_length=100, null=True, blank=True) + code = models.JSONField(default=dict, null=False, blank=False) + + do_not_perform = models.BooleanField(default=False) + + subject = models.ForeignKey( + Patient, + on_delete=models.CASCADE, + related_name="service_request", + ) + encounter = models.ForeignKey( + Encounter, + on_delete=models.CASCADE, + related_name="service_request", + ) + + occurrence_datetime = models.DateTimeField(null=True, blank=True) + occurrence_timing = models.JSONField(null=True, blank=True) + as_needed = models.BooleanField(default=False) + as_needed_for = models.JSONField(null=True, blank=True) + + authored_on = models.DateTimeField(null=True, blank=True) + requester = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="requested_service_request" + ) + + location = models.UUIDField(Organization, null=True, blank=True) + + note = models.TextField(null=True, blank=True) + patient_instruction = models.TextField(null=True, blank=True) + + replaces = models.ForeignKey( + "self", on_delete=models.CASCADE, null=True, blank=True + ) + + phase = models.CharField(max_length=100, null=True, blank=True) + + def calculate_phase(self): # noqa: PLR0911 PLR0912 + DiagnosticReport = apps.get_model("emr", "DiagnosticReport") + Specimen = apps.get_model("emr", "Specimen") + + if not (self.category == "laboratory_procedure" and self.intent == "order"): + return None + + if self.status == "revoked": + return "order_cancelled" + + if DiagnosticReport.objects.filter(based_on=self.pk, status="final").exists(): + return "order_completed" + + if DiagnosticReport.objects.filter(based_on=self.pk, status="invalid").exists(): + return "result_invalid" + + if DiagnosticReport.objects.filter( + based_on=self.pk, status="preliminary", issued__isnull=False + ).exists(): + return "result_under_review" + + if DiagnosticReport.objects.filter(based_on=self.pk, status="partial").exists(): + return "result_under_verification" + + if Specimen.objects.filter(request=self.pk, processing__gt=[]).exists(): + return "sample_in_process" + + if Specimen.objects.filter(request=self.pk, status="unsatisfactory").exists(): + return "sample_rejected" + + if Specimen.objects.filter(request=self.pk, received_at__isnull=False).exists(): + return "sample_received_at_lab" + + if Specimen.objects.filter( + request=self.pk, dispatched_at__isnull=False + ).exists(): + return "sample_sent_to_lab" + + if Specimen.objects.filter( + request=self.pk, collected_at__isnull=False + ).exists(): + return "sample_collected" + + if self.status == "active": + return "order_in_progress" + + if self.status == "draft": + return "order_placed" + + return None + + def save(self, *args, **kwargs): + self.phase = self.calculate_phase() + super().save(*args, **kwargs) diff --git a/care/emr/models/specimen.py b/care/emr/models/specimen.py new file mode 100644 index 0000000000..f877a8c44c --- /dev/null +++ b/care/emr/models/specimen.py @@ -0,0 +1,65 @@ + +from django.db import models + +from care.emr.models.base import EMRBaseModel +from care.emr.models.patient import Patient +from care.emr.models.service_request import ServiceRequest +from care.users.models import User + + +class Specimen(EMRBaseModel): + identifier = models.CharField(max_length=100, null=True, blank=True) + accession_identifier = models.CharField(max_length=100, null=True, blank=True) + + status = models.CharField(max_length=100, null=True, blank=True) + + type = models.JSONField(default=dict, null=False, blank=False) + + subject = models.ForeignKey( + Patient, + on_delete=models.CASCADE, + related_name="specimen", + ) + request = models.ForeignKey( + ServiceRequest, on_delete=models.CASCADE, related_name="specimen" + ) + + collected_by = models.ForeignKey( + User, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="collected_specimen", + ) + collected_at = models.DateTimeField(null=True, blank=True) + + dispatched_by = models.ForeignKey( + User, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="dispatched_specimen", + ) + dispatched_at = models.DateTimeField(null=True, blank=True) + + received_by = models.ForeignKey( + User, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="received_specimen", + ) + received_at = models.DateTimeField(null=True, blank=True) + + condition = models.JSONField(null=True, blank=True) + + processing = models.JSONField(default=list, null=True, blank=True) + + note = models.TextField(null=True, blank=True) + + parent = models.ForeignKey("self", on_delete=models.CASCADE, null=True, blank=True) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.request: + self.request.save() diff --git a/care/emr/resources/base.py b/care/emr/resources/base.py index 9f5f4fa46a..e32f20df86 100644 --- a/care/emr/resources/base.py +++ b/care/emr/resources/base.py @@ -73,7 +73,7 @@ def de_serialize(self, obj=None): obj = self.__model__() database_fields = self.get_database_mapping() meta = {} - dump = self.model_dump(mode="json", exclude_defaults=True) + dump = self.model_dump(mode="json", exclude_unset=True) for field in dump: if ( field in database_fields diff --git a/care/emr/resources/diagnostic_report/__init__.py b/care/emr/resources/diagnostic_report/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/emr/resources/diagnostic_report/spec.py b/care/emr/resources/diagnostic_report/spec.py new file mode 100644 index 0000000000..631ab5c27e --- /dev/null +++ b/care/emr/resources/diagnostic_report/spec.py @@ -0,0 +1,240 @@ +from datetime import datetime +from enum import Enum + +from pydantic import UUID4, Field, field_validator + +from care.emr.fhir.schema.base import Coding, Period +from care.emr.models.diagnostic_report import DiagnosticReport +from care.emr.models.service_request import ServiceRequest +from care.emr.models.specimen import Specimen +from care.emr.registries.care_valueset.care_valueset import validate_valueset +from care.emr.resources.base import EMRResource +from care.emr.resources.diagnostic_report.valueset import ( + CARE_DIAGNOSTIC_REPORT_CATEGORY_VALUESET, + CARE_DIAGNOSTIC_REPORT_CODE_VALUESET, +) +from care.emr.resources.encounter.spec import EncounterRetrieveSpec +from care.emr.resources.observation.spec import ObservationReadSpec, ObservationSpec +from care.emr.resources.patient.spec import PatientRetrieveSpec +from care.emr.resources.service_request.spec import ServiceRequestRetrieveSpec +from care.emr.resources.specimen.spec import SpecimenRetrieveSpec +from care.emr.resources.user.spec import UserSpec + + +class DiagnosticReportMedia(EMRResource): + comment: str | None = Field( + default=None, + description="A description or comment about the media file", + ) + link: UUID4 = Field( + description="References the FileUpload object that contains the media file", + ) + + +class StatusChoices(str, Enum): + registered = "registered" + partial = "partial" + preliminary = "preliminary" + modified = "modified" + final = "final" + amended = "amended" + corrected = "corrected" + appended = "appended" + cancelled = "cancelled" + entered_in_error = "entered-in-error" + unknown = "unknown" + + +class DiagnosticReportSpec(EMRResource): + __model__ = DiagnosticReport + __exclude__ = [ + "subject", + "based_on", + "encounter", + "performer", + "results_interpreter", + "specimen", + "result", + ] + + id: UUID4 = None + + status: StatusChoices = Field( + default=StatusChoices.registered, + description="Indicates the status of the report, used internally to track the lifecycle of the report", + ) + + category: Coding | None = Field( + default=None, + json_schema_extra={"slug": CARE_DIAGNOSTIC_REPORT_CATEGORY_VALUESET.slug}, + description="Identifies the broad category of service that is to be performed", + ) + code: Coding = Field( + default=None, + json_schema_extra={"slug": CARE_DIAGNOSTIC_REPORT_CODE_VALUESET.slug}, + description="Name/Code for this diagnostic report", + ) + + based_on: UUID4 = Field( + description="The resource that this report is based on, this can be a service request, a medication request, or other resource", + ) + subject: UUID4 = Field( + default=None, + description="The patient this report is about", + ) + encounter: UUID4 = Field( + default=None, + description="The encounter within which this report was created", + ) + + performer: UUID4 | None = Field( + default=None, + description="The user that is responsible for the report", + ) + results_interpreter: UUID4 | None = Field( + default=None, + description="The primary result interpreter", + ) + + issued: datetime | None = Field( + default=None, + description="The datetime at which the report was issued", + ) + effective_period: Period | None = Field( + default=None, + description="The period during which the report is valid", + ) + + specimen: list[UUID4] = Field( + default=[], + description="The specimens on which this report is based", + ) + result: list[UUID4] = Field( + default=[], + description="The observations that are part of this report", + ) + + media: list[DiagnosticReportMedia] = Field( + default=[], + description="Media files associated with the report", + ) + + note: str | None = Field( + default=None, + description="Comments made about the service request by the requester, performer, subject, or other participants", + ) + conclusion: str | None = Field( + default=None, + description="The clinical conclusion of the report", + ) + + @field_validator("category") + @classmethod + def validate_category(cls, value: Coding | None): + if value is None: + return None + + return validate_valueset( + "category", cls.model_fields["category"].json_schema_extra["slug"], value + ) + + @field_validator("code") + @classmethod + def validate_code(cls, value: Coding): + return validate_valueset( + "code", cls.model_fields["code"].json_schema_extra["slug"], value + ) + + +class DiagnosticReportCreateSpec(DiagnosticReportSpec): + def perform_extra_deserialization(self, is_update, obj): + if not is_update: + obj.based_on = ServiceRequest.objects.get(external_id=self.based_on) + obj.subject = obj.based_on.subject + obj.encounter = obj.based_on.encounter + + if not obj.code: + obj.code = obj.based_on.code + + obj.save() + + if self.specimen: + specimens = Specimen.objects.filter(external_id__in=self.specimen) + if specimens.count() != len(self.specimen): + message = "One or more specimens are not found in the database" + raise ValueError(message) + obj.specimen.set(specimens) + + +class DiagnosticReportUpdateSpec(DiagnosticReportCreateSpec): + class Config: + exclude_unset = True + + +class DiagnosticReportListSpec(DiagnosticReportSpec): + based_on: ServiceRequestRetrieveSpec = {} + subject: PatientRetrieveSpec = {} + encounter: EncounterRetrieveSpec = {} + performer: UserSpec | None = None + results_interpreter: UserSpec | None = None + specimen: list[SpecimenRetrieveSpec] = [] + result: list[ObservationSpec] = [] + + created_by: UserSpec | None = None + updated_by: UserSpec | None = None + created_date: datetime | None = None + modified_date: datetime | None = None + + @classmethod + def perform_extra_serialization(cls, mapping, obj): + mapping["id"] = obj.external_id + + if obj.based_on: + mapping["based_on"] = ServiceRequestRetrieveSpec.serialize( + obj.based_on + ).to_json() + if obj.subject: + mapping["subject"] = PatientRetrieveSpec.serialize(obj.subject).to_json() + if obj.specimen.exists(): + mapping["specimen"] = [ + SpecimenRetrieveSpec.serialize(specimen).to_json() + for specimen in obj.specimen.all() + ] + + +class DiagnosticReportRetrieveSpec(DiagnosticReportListSpec): + @classmethod + def perform_extra_serialization(cls, mapping, obj): + mapping["id"] = obj.external_id + + if obj.based_on: + mapping["based_on"] = ServiceRequestRetrieveSpec.serialize( + obj.based_on + ).to_json() + if obj.subject: + mapping["subject"] = PatientRetrieveSpec.serialize(obj.subject).to_json() + if obj.encounter: + mapping["encounter"] = EncounterRetrieveSpec.serialize( + obj.encounter + ).to_json() + if obj.performer: + mapping["performer"] = UserSpec.serialize(obj.performer).to_json() + if obj.results_interpreter: + mapping["results_interpreter"] = UserSpec.serialize( + obj.results_interpreter + ).to_json() + if obj.specimen.exists(): + mapping["specimen"] = [ + SpecimenRetrieveSpec.serialize(specimen).to_json() + for specimen in obj.specimen.all() + ] + if obj.result.exists(): + mapping["result"] = [ + ObservationReadSpec.serialize(observation).to_json() + for observation in obj.result.all() + ] + + if obj.created_by: + mapping["created_by"] = UserSpec.serialize(obj.created_by).to_json() + if obj.updated_by: + mapping["updated_by"] = UserSpec.serialize(obj.updated_by).to_json() diff --git a/care/emr/resources/diagnostic_report/valueset.py b/care/emr/resources/diagnostic_report/valueset.py new file mode 100644 index 0000000000..a3804400d7 --- /dev/null +++ b/care/emr/resources/diagnostic_report/valueset.py @@ -0,0 +1,39 @@ +from care.emr.fhir.schema.valueset.valueset import ValueSetCompose, ValueSetInclude +from care.emr.registries.care_valueset.care_valueset import CareValueset +from care.emr.resources.valueset.spec import ValueSetStatusOptions + +CARE_DIAGNOSTIC_REPORT_CATEGORY_VALUESET = CareValueset( + "Diagnostic Report Category", + "system-diagnostic-report-category", + ValueSetStatusOptions.active, +) + +CARE_DIAGNOSTIC_REPORT_CATEGORY_VALUESET.register_valueset( + ValueSetCompose( + include=[ + ValueSetInclude( + system="http://terminology.hl7.org/CodeSystem/v2-0074", + ) + ] + ) +) + +CARE_DIAGNOSTIC_REPORT_CATEGORY_VALUESET.register_as_system() + +CARE_DIAGNOSTIC_REPORT_CODE_VALUESET = CareValueset( + "Diagnostic Report Code", + "system-diagnostic-report-code", + ValueSetStatusOptions.active, +) + +CARE_DIAGNOSTIC_REPORT_CODE_VALUESET.register_valueset( + ValueSetCompose( + include=[ + ValueSetInclude( + system="http://loinc.org", + ) + ] + ) +) + +CARE_DIAGNOSTIC_REPORT_CODE_VALUESET.register_as_system() diff --git a/care/emr/resources/service_request/__init__.py b/care/emr/resources/service_request/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/emr/resources/service_request/spec.py b/care/emr/resources/service_request/spec.py new file mode 100644 index 0000000000..ca4c147cd5 --- /dev/null +++ b/care/emr/resources/service_request/spec.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from enum import Enum + +from pydantic import UUID4, Field, field_validator + +from care.emr.fhir.schema.base import Coding, Timing +from care.emr.models.encounter import Encounter +from care.emr.models.service_request import ServiceRequest +from care.emr.registries.care_valueset.care_valueset import validate_valueset +from care.emr.resources.base import EMRResource +from care.emr.resources.encounter.spec import EncounterRetrieveSpec +from care.emr.resources.organization.spec import OrganizationRetrieveSpec +from care.emr.resources.patient.spec import PatientRetrieveSpec +from care.emr.resources.service_request.valueset import ( + CARE_LAB_ORDER_CODE_VALUESET, + CARE_MEDICATION_AS_NEEDED_REASON_VALUESET, +) +from care.emr.resources.user.spec import UserSpec + + +class ServiceRequestStatusChoices(str, Enum): + draft = "draft" + active = "active" + on_hold = "on-hold" + revoked = "revoked" + completed = "completed" + entered_in_error = "entered-in-error" + unknown = "unknown" + + +class ServiceRequestIntentChoices(str, Enum): + proposal = "proposal" + plan = "plan" + directive = "directive" + order = "order" + + +class ServiceRequestPriorityChoices(str, Enum): + routine = "routine" + urgent = "urgent" + asap = "asap" + stat = "stat" + + +class ServiceRequestCategoryChoices(str, Enum): + laboratory_procedure = "laboratory_procedure" + imaging = "imaging" + counselling = "counselling" + education = "education" + surgical_procedure = "surgical_procedure" + + +class ServiceRequestPhaseChoices(str, Enum): + order_placed = "order_placed" + order_in_progress = "order_in_progress" + sample_collected = "sample_collected" + sample_sent_to_lab = "sample_sent_to_lab" + sample_received_at_lab = "sample_received_at_lab" + sample_rejected = "sample_rejected" + sample_in_process = "sample_in_process" + result_under_verification = "result_under_verification" + result_under_review = "result_under_review" + result_invalid = "result_invalid" + order_completed = "order_completed" + order_cancelled = "order_cancelled" + + +class ServiceRequestSpec(EMRResource): + __model__ = ServiceRequest + __exclude__ = ["subject", "encounter", "requester", "location", "replaces"] + + id: UUID4 | None = None + + status: ServiceRequestStatusChoices = Field( + default=ServiceRequestStatusChoices.draft, + description="Indicates the status of the request, used internally to track the lifecycle of the request", + ) + intent: ServiceRequestIntentChoices = Field( + default=ServiceRequestIntentChoices.order, + description="Indicates the level of authority/intentionality associated with the request", + ) + priority: ServiceRequestPriorityChoices = Field( + default=ServiceRequestPriorityChoices.routine, + description="Indicates the urgency of the request", + ) + + category: ServiceRequestCategoryChoices | None = Field( + default=None, + description="Identifies the broad category of service that is to be performed", + ) + code: Coding = Field( + json_schema_extra={ + "slug": CARE_LAB_ORDER_CODE_VALUESET.slug + }, # TODO: consider using a broader value set (https://build.fhir.org/valueset-procedure-code.html) + description="Identifies the service or product to be supplied", + ) + + do_not_perform: bool = Field( + default=False, + description="If true indicates that the service/procedure should NOT be performed", + ) + + subject: UUID4 = Field( + description="The patient for whom the service/procedure is being requested", + ) + encounter: UUID4 = Field( + description="The encounter within which this service request was created", + ) + + occurrence_datetime: datetime | None = Field( + default=None, + description="The datetime at which the requested service should occur", + ) + occurrence_timing: Timing | None = Field( + default=None, + description="The timing schedule for the requested service, used when the occurrence repeats", + ) + as_needed: bool = Field( + default=False, + description="If true indicates that the service/procedure can be performed as needed", + ) + as_needed_for: Coding | None = Field( + default=None, + json_schema_extra={"slug": CARE_MEDICATION_AS_NEEDED_REASON_VALUESET.slug}, + description="The condition under which the service/procedure should be performed", + ) + + authored_on: datetime = Field( + default=datetime.now(UTC), + description="The date when the request was made", + ) + requester: UUID4 | None = Field( + default=None, + description="The individual who initiated the request and has responsibility for its activation. If None, the current user is assumed to be the requester", + ) + + location: UUID4 | None = Field( + default=None, + description="The location where the service will be performed", + ) + + note: str | None = Field( + default=None, + description="Comments made about the service request by the requester, performer, subject, or other participants", + ) + patient_instruction: str | None = Field( + default=None, + description="Instructions for the patient on how the service should be performed", + ) + + replaces: UUID4 | None = Field( + None, + description="The request that is being replaced by this request, used in the case of re-orders", + ) + + phase: ServiceRequestPhaseChoices | None = Field( + None, + description="Indicates the current phase of the lab orders, used internally to track the lifecycle of the order. It is a read-only field and its value is None if the request is not a lab order", + ) + + @field_validator("code") + @classmethod + def validate_code(cls, value: Coding): + return validate_valueset( + "code", cls.model_fields["code"].json_schema_extra["slug"], value + ) + + @field_validator("as_needed_for") + @classmethod + def validate_as_needed_for(cls, value: Coding | None): + if value is None: + return None + + return validate_valueset( + "as_needed_for", + cls.model_fields["as_needed_for"].json_schema_extra["slug"], + value, + ) + + +class ServiceRequestCreateSpec(ServiceRequestSpec): + def perform_extra_deserialization(self, is_update, obj): + if not is_update: + obj.encounter = Encounter.objects.get(external_id=self.encounter) + obj.subject = obj.encounter.patient + + +class ServiceRequestUpdateSpec(ServiceRequestCreateSpec): + class Config: + exclude_unset = True + + +class ServiceRequestListSpec(ServiceRequestSpec): + code: Coding = {} + subject: PatientRetrieveSpec = {} + encounter: EncounterRetrieveSpec = {} + requester: UserSpec = {} + location: OrganizationRetrieveSpec | None = None + replaces: ServiceRequestRetrieveSpec | None = None + + created_by: UserSpec | None = None + updated_by: UserSpec | None = None + created_date: datetime | None = None + modified_date: datetime | None = None + + @classmethod + def perform_extra_serialization(cls, mapping, obj): + mapping["id"] = obj.external_id + + if obj.subject: + mapping["subject"] = PatientRetrieveSpec.serialize(obj.subject).to_json() + if obj.encounter: + mapping["encounter"] = EncounterRetrieveSpec.serialize( + obj.encounter + ).to_json() + if obj.requester: + mapping["requester"] = UserSpec.serialize(obj.requester).to_json() + + +class ServiceRequestRetrieveSpec(ServiceRequestListSpec): + @classmethod + def perform_extra_serialization(cls, mapping, obj): + mapping["id"] = obj.external_id + + if obj.subject: + mapping["subject"] = PatientRetrieveSpec.serialize(obj.subject).to_json() + if obj.encounter: + mapping["encounter"] = EncounterRetrieveSpec.serialize( + obj.encounter + ).to_json() + if obj.requester: + mapping["requester"] = UserSpec.serialize(obj.requester).to_json() + # if obj.location: + # mapping["location"] = OrganizationRetrieveSpec.serialize( + # obj.location + # ).to_json() + if obj.replaces: + mapping["replaces"] = ServiceRequestRetrieveSpec.serialize( + obj.replaces + ).to_json() + + if obj.created_by: + mapping["created_by"] = UserSpec.serialize(obj.created_by).to_json() + if obj.updated_by: + mapping["updated_by"] = UserSpec.serialize(obj.updated_by).to_json() diff --git a/care/emr/resources/service_request/valueset.py b/care/emr/resources/service_request/valueset.py new file mode 100644 index 0000000000..9262b4cba7 --- /dev/null +++ b/care/emr/resources/service_request/valueset.py @@ -0,0 +1,64 @@ +from care.emr.fhir.schema.valueset.valueset import ValueSetCompose, ValueSetInclude +from care.emr.registries.care_valueset.care_valueset import CareValueset +from care.emr.resources.valueset.spec import ValueSetStatusOptions + +CARE_LAB_ORDER_CODE_VALUESET = CareValueset( + "Lab Order", "system-lab-order-code", ValueSetStatusOptions.active +) + +CARE_LAB_ORDER_CODE_VALUESET.register_valueset( + ValueSetCompose( + include=[ + ValueSetInclude( + system="http://loinc.org", + filter=[{"property": "ancestor", "op": "is-a", "value": "LP29693-6"}], + ) + ] + ) +) + +CARE_LAB_ORDER_CODE_VALUESET.register_as_system() + +CARE_SERVICE_REQUEST_CATEGORY_VALUESET = CareValueset( + "Service Request Category", + "system-service-request-category", + ValueSetStatusOptions.active, +) + +CARE_SERVICE_REQUEST_CATEGORY_VALUESET.register_valueset( + ValueSetCompose( + include=[ + ValueSetInclude( + system="http://snomed.info/sct", + concept=[ + {"code": "108252007"}, + {"code": "363679005"}, + {"code": "409063005"}, + {"code": "409073007"}, + {"code": "387713003"}, + ], + ) + ] + ) +) + +CARE_SERVICE_REQUEST_CATEGORY_VALUESET.register_as_system() + +CARE_MEDICATION_AS_NEEDED_REASON_VALUESET = CareValueset( + "Medication As Needed Reason", + "system-medication-as-needed-reason", + ValueSetStatusOptions.active, +) + +CARE_MEDICATION_AS_NEEDED_REASON_VALUESET.register_valueset( + ValueSetCompose( + include=[ + ValueSetInclude( + system="http://snomed.info/sct", + filter=[{"property": "concept", "op": "is-a", "value": "404684003"}], + ) + ] + ) +) + +CARE_MEDICATION_AS_NEEDED_REASON_VALUESET.register_as_system() diff --git a/care/emr/resources/specimen/__init__.py b/care/emr/resources/specimen/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/emr/resources/specimen/spec.py b/care/emr/resources/specimen/spec.py new file mode 100644 index 0000000000..7c594f7e82 --- /dev/null +++ b/care/emr/resources/specimen/spec.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from enum import Enum + +from pydantic import UUID4, Field, field_validator + +from care.emr.fhir.schema.base import CodeableConcept, Coding +from care.emr.models.service_request import ServiceRequest +from care.emr.models.specimen import Specimen +from care.emr.registries.care_valueset.care_valueset import validate_valueset +from care.emr.resources.base import EMRResource +from care.emr.resources.patient.spec import PatientRetrieveSpec +from care.emr.resources.service_request.spec import ( + ServiceRequestListSpec, + ServiceRequestRetrieveSpec, +) +from care.emr.resources.specimen.valueset import ( + CARE_SPECIMEN_CONDITION_VALUESET, + CARE_SPECIMEN_PROCESSING_METHOD_VALUESET, + CARE_SPECIMEN_TYPE_VALUESET, +) +from care.emr.resources.user.spec import UserSpec + + +class SpecimenProcessingSpec(EMRResource): + description: str | None = Field( + default=None, + description="A description of the processing step", + ) + method: Coding | None = Field( + default=None, + json_schema_extra={"slug": CARE_SPECIMEN_PROCESSING_METHOD_VALUESET.slug}, + description="The treatment/processing step applied to the specimen", + ) + time: datetime = Field( + default_factory=lambda: datetime.now(UTC), + description="The datetime at which the processing step was performed", + ) + performer: UUID4 | None = Field( + default=None, + description="References user who performed the processing step", + ) + + @field_validator("method") + @classmethod + def validate_method(cls, value: Coding | None): + if value is None: + return None + + return validate_valueset( + "method", + cls.model_fields["method"].json_schema_extra["slug"], + value, + ) + + +class StatusChoices(str, Enum): + available = "available" + unavailable = "unavailable" + unsatisfactory = "unsatisfactory" + entered_in_error = "entered-in-error" + + +class SpecimenSpec(EMRResource): + __model__ = Specimen + __exclude__ = ["subject", "request"] + + id: UUID4 = None + + identifier: str | None = Field( + default=None, + description="The unique identifier assigned to the specimen after collection", + ) + accession_identifier: str | None = Field( + default=None, + description="The identifier assigned to the specimen by the laboratory", + ) + + status: StatusChoices | None = Field( + default=None, + description="Indicates the status of the specimen, used internally to track the lifecycle of the specimen, None indicates that the specimen is not yet collected", + ) + + type: Coding = Field( + json_schema_extra={"slug": CARE_SPECIMEN_TYPE_VALUESET.slug}, + description="Indicates the type of specimen being collected", + ) + + subject: UUID4 = Field( + description="The patient from whom the specimen is collected", + ) + request: UUID4 = Field( + description="The service request that initiated the collection of the specimen", + ) + + collected_by: UUID4 | None = Field( + default=None, + description="References the user who collected the specimen", + ) + collected_at: datetime | None = Field( + default=None, + description="The datetime at which the specimen was collected", + ) + + dispatched_by: UUID4 | None = Field( + default=None, + description="References the user who dispatched the specimen to the laboratory", + ) + dispatched_at: datetime | None = Field( + default=None, + description="The datetime at which the specimen was dispatched to the laboratory", + ) + + received_by: UUID4 | None = Field( + default=None, + description="References the user who received the specimen at the laboratory", + ) + received_at: datetime | None = Field( + default=None, + description="The datetime at which the specimen was received at the laboratory", + ) + + condition: list[CodeableConcept] | None = Field( + default=None, + json_schema_extra={"slug": CARE_SPECIMEN_CONDITION_VALUESET.slug}, + description="The condition of the specimen while received at the laboratory", + ) + + processing: list[SpecimenProcessingSpec] = Field( + default=[], + description="The processing steps that have been performed on the specimen", + ) + + note: str | None = Field( + default=None, + description="Comments made about the service request by the requester, performer, subject, or other participants", + ) + + parent: UUID4 | None = Field( + default=None, + description="References the parent specimen from which this specimen was derived, used for aliquots and derived specimens", + ) + + @field_validator("type") + @classmethod + def validate_type(cls, value: Coding): + return validate_valueset( + "type", cls.model_fields["type"].json_schema_extra["slug"], value + ) + + @field_validator("condition") + @classmethod + def validate_condition(cls, concepts: list[CodeableConcept]): + if not concepts: + return concepts + + for concept in concepts: + codings = concept.coding + if not codings: + continue + + for coding in codings: + validate_valueset( + "condition", + cls.model_fields["condition"].json_schema_extra["slug"], + coding.code, + ) + + return concepts + + +class SpecimenCreateSpec(SpecimenSpec): + def perform_extra_deserialization(self, is_update, obj): + if not is_update: + obj.request = ServiceRequest.objects.get(external_id=self.request) + obj.subject = obj.request.subject + + +class SpecimenUpdateSpec(SpecimenCreateSpec): + class Config: + exclude_unset = True + + +class SpecimenListSpec(SpecimenSpec): + type: Coding = {} + request: ServiceRequestRetrieveSpec = {} + subject: PatientRetrieveSpec = {} + collected_by: UserSpec | None = None + dispatched_by: UserSpec | None = None + received_by: UserSpec | None = None + parent: SpecimenRetrieveSpec | None = None + + created_by: UserSpec | None = None + updated_by: UserSpec | None = None + created_date: datetime | None = None + modified_date: datetime | None = None + + @classmethod + def perform_extra_serialization(cls, mapping, obj): + mapping["id"] = obj.external_id + + if obj.request: + mapping["request"] = ( + ServiceRequestListSpec().serialize(obj.request).to_json() + ) + if obj.subject: + mapping["subject"] = PatientRetrieveSpec.serialize(obj.subject).to_json() + + +class SpecimenRetrieveSpec(SpecimenListSpec): + @classmethod + def perform_extra_serialization(cls, mapping, obj): + mapping["id"] = obj.external_id + + if obj.request: + mapping["request"] = ( + ServiceRequestRetrieveSpec().serialize(obj.request).to_json() + ) + if obj.subject: + mapping["subject"] = PatientRetrieveSpec.serialize(obj.subject).to_json() + if obj.collected_by: + mapping["collected_by"] = UserSpec.serialize(obj.collected_by).to_json() + if obj.dispatched_by: + mapping["dispatched_by"] = UserSpec.serialize(obj.dispatched_by).to_json() + if obj.received_by: + mapping["received_by"] = UserSpec.serialize(obj.received_by).to_json() + if obj.parent: + mapping["parent"] = SpecimenRetrieveSpec.serialize(obj.parent).to_json() + + if obj.created_by: + mapping["created_by"] = UserSpec.serialize(obj.created_by).to_json() + if obj.updated_by: + mapping["updated_by"] = UserSpec.serialize(obj.updated_by).to_json() diff --git a/care/emr/resources/specimen/valueset.py b/care/emr/resources/specimen/valueset.py new file mode 100644 index 0000000000..14fcf3813c --- /dev/null +++ b/care/emr/resources/specimen/valueset.py @@ -0,0 +1,57 @@ +from care.emr.fhir.schema.valueset.valueset import ValueSetCompose, ValueSetInclude +from care.emr.registries.care_valueset.care_valueset import CareValueset +from care.emr.resources.valueset.spec import ValueSetStatusOptions + +CARE_SPECIMEN_TYPE_VALUESET = CareValueset( + "Specimen Type", "system-specimen-type", ValueSetStatusOptions.active +) # https://nrces.in/ndhm/fhir/r4/ValueSet/ndhm-specimen-types + +CARE_SPECIMEN_TYPE_VALUESET.register_valueset( + ValueSetCompose( + include=[ + ValueSetInclude( + system="http://snomed.info/sct", + filter=[{"property": "concept", "op": "is-a", "value": "123038009"}], + ) + ] + ) +) + +CARE_SPECIMEN_TYPE_VALUESET.register_as_system() + +CARE_SPECIMEN_PROCESSING_METHOD_VALUESET = CareValueset( + "Specimen Processing Method", + "system-specimen-processing-method", + ValueSetStatusOptions.active, +) + +CARE_SPECIMEN_PROCESSING_METHOD_VALUESET.register_valueset( + ValueSetCompose( + include=[ + ValueSetInclude( + system="http://snomed.info/sct", + filter=[{"property": "concept", "op": "is-a", "value": "9265001"}], + ) + ] + ) +) + +CARE_SPECIMEN_PROCESSING_METHOD_VALUESET.register_as_system() + +CARE_SPECIMEN_CONDITION_VALUESET = CareValueset( + "Specimen Condition", + "system-specimen-condition", + ValueSetStatusOptions.active, +) + +CARE_SPECIMEN_CONDITION_VALUESET.register_valueset( + ValueSetCompose( + include=[ + ValueSetInclude( + system="http://terminology.hl7.org/CodeSystem/v2-0493", + ) + ] + ) +) + +CARE_SPECIMEN_CONDITION_VALUESET.register_as_system() diff --git a/care/emr/signals/__init__.py b/care/emr/signals/__init__.py new file mode 100644 index 0000000000..9578be6b34 --- /dev/null +++ b/care/emr/signals/__init__.py @@ -0,0 +1 @@ +from .auto_create_specimen import * # noqa diff --git a/care/emr/signals/auto_create_specimen.py b/care/emr/signals/auto_create_specimen.py new file mode 100644 index 0000000000..9d33817b69 --- /dev/null +++ b/care/emr/signals/auto_create_specimen.py @@ -0,0 +1,62 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from rest_framework.exceptions import ValidationError + +from care.emr.fhir.resources.code_concept import CodeConceptResource +from care.emr.fhir.resources.concept_map import ConceptMapResource +from care.emr.models.service_request import ServiceRequest +from care.emr.resources.specimen.spec import SpecimenCreateSpec + + +@receiver(post_save, sender=ServiceRequest) +def create_specimen(sender, instance: ServiceRequest, created: bool, **kwargs): + """ + Auto create a Specimen resource when a ServiceRequest is created. + + TODO: Change the trigger later to when the billing is done. + """ + + if not created: + return None + + code_concept = ( + CodeConceptResource() + .filter(system="http://loinc.org", code=instance.code.get("code")) + .get() + ) + + loinc_specimen_code = ( + code_concept.get("properties", {}).get("system-core", {}).get("code") + ) + concept_map = ( + ConceptMapResource() + .filter(system="http://loinc.org", code=loinc_specimen_code) + .translate() + ) + + specimen_matches = list( + filter( + lambda x: "(specimen)" in x.concept.display, + concept_map.match, + ) + ) + specimen_matches.sort(key=lambda x: x.equivalence.priority) + + if len(specimen_matches) == 0: + err = f"No Specimen found for the given Service Request code {instance.code}" + raise ValidationError(err) + + specimen_coding = specimen_matches[0].concept + + specimen = SpecimenCreateSpec( + type={ + "code": specimen_coding.code, + "display": specimen_coding.display, + "system": "http://snomed.info/sct", + }, + request=instance.external_id, + subject=instance.subject.external_id, + ).de_serialize() + specimen.save() + + return specimen diff --git a/care/facility/api/viewsets/facility.py b/care/facility/api/viewsets/facility.py index 50277507d6..4d9bff61ed 100644 --- a/care/facility/api/viewsets/facility.py +++ b/care/facility/api/viewsets/facility.py @@ -22,11 +22,7 @@ FacilitySerializer, FacilitySpokeSerializer, ) -from care.facility.models import ( - Facility, - FacilityCapacity, - HospitalDoctors, -) +from care.facility.models import Facility, FacilityCapacity, HospitalDoctors from care.facility.models.facility import FacilityHubSpoke from care.users.models import User from care.utils.file_uploads.cover_image import delete_cover_image @@ -43,7 +39,7 @@ def filter(self, qs, value): class FacilityFilter(filters.FilterSet): name = filters.CharFilter(field_name="name", lookup_expr="icontains") - facility_type = filters.NumberFilter(field_name="facility_type") + facility_type = filters.BaseInFilter(field_name="facility_type", lookup_expr="in") organization = GeoOrganizationFilter() diff --git a/config/api_router.py b/config/api_router.py index 8a9f62969b..11b1174c5d 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -9,6 +9,7 @@ from care.emr.api.viewsets.allergy_intolerance import AllergyIntoleranceViewSet from care.emr.api.viewsets.batch_request import BatchRequestView from care.emr.api.viewsets.condition import DiagnosisViewSet, SymptomViewSet +from care.emr.api.viewsets.diagnostic_report import DiagnosticReportViewSet from care.emr.api.viewsets.encounter import EncounterViewSet from care.emr.api.viewsets.facility import ( FacilitySchedulableUsersViewSet, @@ -52,6 +53,8 @@ AvailabilityExceptionsViewSet, ) from care.emr.api.viewsets.scheduling.booking import TokenBookingViewSet +from care.emr.api.viewsets.service_request import ServiceRequestViewSet +from care.emr.api.viewsets.specimen import SpecimenViewSet from care.emr.api.viewsets.user import UserViewSet from care.emr.api.viewsets.valueset import ValueSetViewSet from care.facility.api.viewsets.facility import AllFacilityViewSet @@ -100,6 +103,12 @@ "govt/organization", OrganizationPublicViewSet, basename="govt-organization" ) +router.register("service_request", ServiceRequestViewSet, basename="service-request") +router.register("specimen", SpecimenViewSet, basename="specimen") +router.register( + "diagnostic_report", DiagnosticReportViewSet, basename="diagnostic-report" +) + router.register("role", RoleViewSet, basename="role") router.register("encounter", EncounterViewSet, basename="encounter")