Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Re-Architecture Changes #2549

Draft
wants to merge 13 commits into
base: develop
Choose a base branch
from
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ build:
up:
docker compose -f docker-compose.yaml -f $(docker_config_file) up -d --wait

build-up-live:
docker compose -f docker-compose.yaml -f $(docker_config_file) up --build

down:
docker compose -f docker-compose.yaml -f $(docker_config_file) down

Expand Down
5 changes: 3 additions & 2 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ djangorestframework-simplejwt = "==5.3.1"
dry-rest-permissions = "==0.1.10"
drf-nested-routers = "==0.94.1"
drf-spectacular = "==0.27.2"
"fhir.resources" = "==6.5.0"
gunicorn = "==23.0.0"
healthy-django = "==0.1.0"
jsonschema = "==4.23.0"
Expand All @@ -33,7 +32,7 @@ newrelic = "==10.0.0"
pillow = "==10.4.0"
psycopg = { extras = ["c"], version = "==3.2.2" }
pycryptodome = "==3.20.0"
pydantic = "==1.10.18" # fix for fhir.resources < 7.0.2
pydantic = "==2.9.2"
pyjwt = "==2.9.0"
python-slugify = "==8.0.4"
pywebpush = "==2.0.0"
Expand All @@ -42,6 +41,8 @@ redis-om = "==0.3.1" # > 0.3.1 broken with pydantic < 2
requests = "==2.32.3"
sentry-sdk = "==2.14.0"
whitenoise = "==6.7.0"
simplejson = "*"
json-fingerprint = "*"

[dev-packages]
boto3-stubs = { extras = ["s3", "boto3"], version = "==1.35.29" }
Expand Down
2,188 changes: 1,248 additions & 940 deletions Pipfile.lock

Large diffs are not rendered by default.

Empty file added care/emr/__init__.py
Empty file.
Empty file.
25 changes: 25 additions & 0 deletions care/emr/api/viewsets/allergy_intolerance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from drf_spectacular.utils import extend_schema, extend_schema_view

from care.emr.api.viewsets.base import EMRModelViewSet
from care.emr.models.allergy_intolerance import AllergyIntolerance
from care.emr.resources.allergy_intolerance.spec import (
AllergyIntoleranceSpec,
AllergyIntrolanceSpecRead,
)


@extend_schema_view(
create=extend_schema(request=AllergyIntoleranceSpec),
)
class AllergyIntoleranceViewSet(EMRModelViewSet):
database_model = AllergyIntolerance
pydantic_model = AllergyIntoleranceSpec
pydantic_read_model = AllergyIntrolanceSpecRead

def clean_create_data(self, request, *args, **kwargs):
data = request.data
data["encounter"] = kwargs["consultation_external_id"]
return data

def get_queryset(self):
return super().get_queryset().select_related("patient", "encounter")
129 changes: 129 additions & 0 deletions care/emr/api/viewsets/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import json

from pydantic import ValidationError
from rest_framework.decorators import action
from rest_framework.generics import get_object_or_404
from rest_framework.response import Response
from rest_framework.views import exception_handler as drf_exception_handler
from rest_framework.viewsets import GenericViewSet

from care.emr.models.base import EMRBaseModel
from care.emr.resources.base import FHIRResource


def emr_exception_handler(exc, context):
if isinstance(exc, ValidationError):
return Response({"errors": json.loads(exc.json())}, status=400)
return drf_exception_handler(exc, context)


class EMRQuestionnaireMixin:
@action(detail=False, methods=["GET"])
def questionnaire_spec(self, request, *args, **kwargs):
return Response(
{"version": 1, "questions": self.pydantic_model.questionnaire()}
)


class EMRRetrieveMixin:
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
data = self.get_read_pydantic_model().serialize(instance)
return Response(data.model_dump(exclude=["meta"]))


class EMRCreateMixin:
def perform_create(self, instance):
instance.save()

def clean_create_data(self, request, *args, **kwargs):
return request.data

def create(self, request, *args, **kwargs):
clean_data = self.clean_create_data(request, *args, **kwargs)
instance = self.pydantic_model(**clean_data)
model_instance = instance.de_serialize()
self.perform_create(model_instance)
return Response(
self.get_read_pydantic_model()
.serialize(model_instance)
.model_dump(exclude=["meta"])
)


class EMRListMixin:
def list(self, request, *args, **kwargs):
queryset = self.get_queryset()
paginator = self.pagination_class()
page = paginator.paginate_queryset(queryset, request)
if page is not None:
data = [
self.get_read_pydantic_model()
.serialize(obj)
.model_dump(exclude=["meta"])
for obj in page
]
return paginator.get_paginated_response(data)
data = [
self.get_read_pydantic_model().serialize(obj).model_dump(exclude=["meta"])
for obj in queryset
]
return Response(data)


class EMRBaseViewSet(GenericViewSet):
pydantic_model: FHIRResource = None
pydantic_read_model: FHIRResource = None
database_model: EMRBaseModel = None
lookup_field = "external_id"

def get_exception_handler(self):
return emr_exception_handler

def get_queryset(self):
return self.database_model.objects.all()

def get_read_pydantic_model(self):
if self.pydantic_read_model:
return self.pydantic_read_model
return self.pydantic_model

def get_object(self):
queryset = self.get_queryset()
return get_object_or_404(
queryset, **{self.lookup_field: self.kwargs[self.lookup_field]}
)

def update(self, request, *args, **kwargs):
return Response({"update": "working"})

def delete(self, request, *args, **kwargs):
return Response({"delete": "working"})


class EMRModelViewSet(
EMRCreateMixin,
EMRRetrieveMixin,
EMRListMixin,
EMRQuestionnaireMixin,
EMRBaseViewSet,
):
pass


# Maybe use a different pydantic model for request and response, Response does not need validations or defined Types
# Maybe switch to use custom mixins
# Complete update and delete logic
# Create valuesets for allergy intolerance and write the logic for validation
# Convert to questionnaire spec and store it somewhere and return on the questionnaire API
# Write the history function based on the update.

# Validate valueset data on create
# Add option for extra validation being written in the model

# Model the questionnaire object in pydantic
# Create CRUD for questionnaire
# Create definition returning API for questionnaire
# Submit API for Questionnaire -> Implicitly requires observations to be completed

# Create API's for valuesets and code concepts ( integrations already built )
48 changes: 48 additions & 0 deletions care/emr/api/viewsets/batch_request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from django.db import transaction
from pydantic import BaseModel, Field
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet

from care.emr.api.viewsets.base import emr_exception_handler
from care.emr.utils.batch_requests import execute_batch_requests


class Request(BaseModel):
url: str
method: str
body: dict = {}


class BatchRequest(BaseModel):
requests: list[Request] = Field(..., min_length=1, max_length=20)


class HandledError(Exception):
pass


class BatchRequestView(GenericViewSet):
def get_exception_handler(self):
return emr_exception_handler

def create(self, request, *args, **kwargs):
requests = BatchRequest(**request.data)
errored = False
try:
with transaction.atomic():
responses = execute_batch_requests(request, requests)
structured_responses = []
for response in responses:
if response["status_code"] > 299: # noqa PLR2004
errored = True
structured_responses.append(
{
"data": response["data"],
"status_code": response["status_code"],
}
)
if errored:
raise HandledError
except HandledError:
pass
return Response({"results": structured_responses})
41 changes: 41 additions & 0 deletions care/emr/api/viewsets/valueset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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.fhir.schema.base import Coding
from care.emr.models.valueset import ValueSet
from care.emr.resources.valueset.spec import ValueSetSpec


class ExpandRequest(BaseModel):
search: str = ""
count: int = Field(10, gt=0, lt=100)


class LookupRequest(BaseModel):
code: Coding


class ValueSetViewSet(EMRModelViewSet):
database_model = ValueSet
pydantic_model = ValueSetSpec
lookup_field = "slug"

def get_serializer_class(self):
return ValueSetSpec

@extend_schema(request=ExpandRequest, responses={200: None}, methods=["POST"])
@action(detail=True, methods=["POST"])
def expand(self, request, *args, **kwargs):
request_params = ExpandRequest(**request.data)
results = self.get_object().search(**request_params.model_dump())
return Response({"results": [result.model_dump() for result in results]})

@extend_schema(request=LookupRequest, responses={200: None}, methods=["POST"])
@action(detail=True, methods=["POST"])
def lookup(self, request, *args, **kwargs):
request_params = LookupRequest(**request.data)
result = self.get_object().lookup(request_params.code)
return Response({"result": result})
7 changes: 7 additions & 0 deletions care/emr/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _


class EMRConfig(AppConfig):
name = "care.emr"
verbose_name = _("Electronic Medical Record")
3 changes: 3 additions & 0 deletions care/emr/fhir/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""
This class represents all utilities that are written for FHIR
"""
25 changes: 25 additions & 0 deletions care/emr/fhir/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import requests


class FHIRClient:
"""
This client will be used for all queries performed over the FHIR protocol
This class is designed to perform FHIR based queries to some remote server and convert them into python objects
"""

def __init__(self, server_url):
self.server_url = server_url

def query(self, *, method, resource, operation=None, parameters, detail=None):
url = f"{self.server_url}/{resource}"
if detail:
url += f"/{detail}"
if operation:
url += f"/${operation}"
request_kwargs = {}
if method == "GET":
request_kwargs["params"] = parameters
else:
request_kwargs["json"] = parameters
response = requests.request(method, url, **request_kwargs, timeout=60)
return response.json()
6 changes: 6 additions & 0 deletions care/emr/fhir/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class BaseFHIRError(Exception):
pass


class MoreThanOneFHIRResourceFoundError(BaseFHIRError):
pass
Empty file.
62 changes: 62 additions & 0 deletions care/emr/fhir/resources/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# ruff : noqa : SLF001
from copy import deepcopy

import json_fingerprint
import simplejson as json
from django.core.cache import cache
from json_fingerprint import hash_functions

from care.emr.fhir.client import FHIRClient

default_fhir_client = FHIRClient(server_url="http://165.22.211.144/fhir")


class ResourceManger:
_fhir_client = default_fhir_client
resource = ""
allowed_properties = []
cache_prefix_key = "fhir_resource:"

def __init__(self, fhir_client=None):
self._filters = {}
self._meta = {}
self._executed = False
if fhir_client:
self._fhir_client = fhir_client

def query(self, method, resource, parameters):
payload = {"method": method, "resource": resource, "parameters": parameters}
fingerprint = json_fingerprint.create(
input=json.dumps(payload), hash_function=hash_functions.SHA256, version=1
)
cache_key = f"self.cache_prefix_key{fingerprint}"
if cached_data := cache.get(cache_key):
return cached_data
results = self._fhir_client.query(**payload)
cache.set(cache_key, results, 10)
return results

def validate_filter(self):
pass

def filter(self, *args, **kwargs):
if kwargs:
for key in kwargs:
if key in self.allowed_properties:
self._filters[key] = kwargs[key]
self.validate_filter()
return self.clone()

def clone(self):
obj = self.__class__()
obj._filters = deepcopy(self._filters)
obj._meta = deepcopy(self._meta)
obj._executed = self._executed
obj._fhir_client = self._fhir_client
return obj

def handle_list(self, results):
return [self.serialize(result) for result in results]

def serialize(self, result):
raise NotImplementedError
Loading