From a9f42e72a95b557f885e92a40f149523d86cbd9a Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Sat, 1 Feb 2025 16:43:10 +0530 Subject: [PATCH 01/17] Cleanups --- care/emr/api/viewsets/device/__init__.py | 0 care/emr/resources/encounter/constants.py | 2 +- config/authentication.py | 380 +++++++++++----------- config/health_views.py | 34 +- config/settings/base.py | 6 - 5 files changed, 199 insertions(+), 223 deletions(-) create mode 100644 care/emr/api/viewsets/device/__init__.py diff --git a/care/emr/api/viewsets/device/__init__.py b/care/emr/api/viewsets/device/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/emr/resources/encounter/constants.py b/care/emr/resources/encounter/constants.py index 2cf88b7336..59ffa03ef1 100644 --- a/care/emr/resources/encounter/constants.py +++ b/care/emr/resources/encounter/constants.py @@ -59,7 +59,7 @@ class DischargeDispositionChoices(str, Enum): class DietPreferenceChoices(str, Enum): vegetarian = "vegetarian" - diary_free = "diary_free" + dairy_free = "dairy_free" nut_free = "nut_free" gluten_free = "gluten_free" vegan = "vegan" diff --git a/config/authentication.py b/config/authentication.py index 619ae9ab22..f1570316ce 100644 --- a/config/authentication.py +++ b/config/authentication.py @@ -1,23 +1,11 @@ import logging -import jwt -import requests -from django.contrib.auth.models import AnonymousUser -from django.core.cache import cache -from django.core.exceptions import ValidationError -from django.utils import timezone from django.utils.translation import gettext_lazy as _ from drf_spectacular.extensions import OpenApiAuthenticationExtension from drf_spectacular.plumbing import build_bearer_security_scheme_object -from rest_framework import HTTP_HEADER_ENCODING from rest_framework.authentication import BasicAuthentication from rest_framework_simplejwt.authentication import JWTAuthentication -from rest_framework_simplejwt.exceptions import AuthenticationFailed, InvalidToken -from rest_framework_simplejwt.tokens import Token - -from care.facility.models import Facility -from care.facility.models.asset import Asset -from care.users.models import User +from rest_framework_simplejwt.exceptions import InvalidToken logger = logging.getLogger(__name__) @@ -25,23 +13,24 @@ OPENID_REQUEST_TIMEOUT = 5 -def jwk_response_cache_key(url: str) -> str: - return f"jwk_response:{url}" - - -class MiddlewareUser(AnonymousUser): - """ - Read-only user class for middleware authentication - """ +# def jwk_response_cache_key(url: str) -> str: +# return f"jwk_response:{url}" - def __init__(self, facility, *args, **kwargs): - super().__init__(*args, **kwargs) - self.facility = facility - self.username = f"middleware{facility.external_id}" - @property - def is_authenticated(self): - return True +# class MiddlewareUser(AnonymousUser): +# """ +# Read-only user class for middleware authentication +# """ +# +# def __init__(self, facility, *args, **kwargs): +# super().__init__(*args, **kwargs) +# self.facility = facility +# self.username = f"middleware{facility.external_id}" +# +# @property +# def is_authenticated(self): +# return True +# class CustomJWTAuthentication(JWTAuthentication): @@ -65,136 +54,138 @@ def authenticate_header(self, request): return "" -class MiddlewareAuthentication(JWTAuthentication): - """ - An authentication plugin that authenticates requests through a JSON web - token provided in a request header. - """ - - facility_header = "X-Facility-Id" - auth_header_type = "Middleware_Bearer" - auth_header_type_bytes = auth_header_type.encode(HTTP_HEADER_ENCODING) - - def get_public_key(self, url): - public_key_json = cache.get(jwk_response_cache_key(url)) - if not public_key_json: - res = requests.get(url, timeout=OPENID_REQUEST_TIMEOUT) - res.raise_for_status() - public_key_json = res.json() - cache.set(jwk_response_cache_key(url), public_key_json, timeout=60 * 5) - return public_key_json["keys"][0] - - def open_id_authenticate(self, url, token): - public_key_response = self.get_public_key(url) - public_key = jwt.algorithms.RSAAlgorithm.from_jwk(public_key_response) - return jwt.decode(token, key=public_key, algorithms=["RS256"]) - - def authenticate_header(self, request): - return f'{self.auth_header_type} realm="{self.www_authenticate_realm}"' - - def get_user(self, _: Token, facility: Facility): - return MiddlewareUser(facility=facility) - - def authenticate(self, request): - header = self.get_header(request) - if header is None: - return None - - raw_token = self.get_raw_token(header) - if raw_token is None or self.facility_header not in request.headers: - return None - - external_id = request.headers[self.facility_header] - - try: - facility = Facility.objects.get(external_id=external_id) - except (Facility.DoesNotExist, ValidationError) as e: - raise InvalidToken({"detail": "Invalid Facility", "messages": []}) from e - - if not facility.middleware_address: - raise InvalidToken({"detail": "Facility not connected to a middleware"}) - - open_id_url = ( - f"https://{facility.middleware_address}/.well-known/openid-configuration/" - ) - - validated_token = self.get_validated_token(open_id_url, raw_token) - - return self.get_user(validated_token, facility), validated_token - - def get_raw_token(self, header): - """ - Extracts an un-validated JSON web token from the given "Authorization" - header value. - """ - parts = header.split() - - if len(parts) == 0: - # Empty AUTHORIZATION header sent - return None - - if parts[0] != self.auth_header_type_bytes: - # Assume the header does not contain a JSON web token - return None - - if len(parts) != 2: # noqa: PLR2004 - raise AuthenticationFailed( - _("Authorization header must contain two space-delimited values"), - code="bad_authorization_header", - ) - - return parts[1] - - def get_validated_token(self, url, raw_token): - """ - Validates an encoded JSON web token and returns a validated token - wrapper object. - """ - try: - return self.open_id_authenticate(url, raw_token) - except Exception as e: - logger.info(e, "Token: ", raw_token) - - raise InvalidToken({"detail": "Given token not valid for any token type"}) - - -class MiddlewareAssetAuthentication(MiddlewareAuthentication): - def get_user(self, validated_token, facility): - """ - Attempts to find and return a user using the given validated token. - """ - if "asset_id" not in validated_token: - raise InvalidToken({"detail": "Given token does not contain asset_id"}) - - try: - asset_obj = Asset.objects.select_related("current_location__facility").get( - external_id=validated_token["asset_id"] - ) - except (Asset.DoesNotExist, ValidationError) as e: - raise InvalidToken( - {"detail": "Invalid Asset ID", "messages": [str(e)]} - ) from e - - if asset_obj.current_location.facility != facility: - raise InvalidToken({"detail": "Facility not connected to Asset"}) - - # Create/Retrieve User and return them - asset_user = User.objects.filter(asset=asset_obj).first() - if not asset_user: - password = User.objects.make_random_password() - asset_user = User( - username=f"asset{asset_obj.external_id!s}", - email="support@ohc.network", - password=f"{password}xyz", # The xyz makes it inaccessible without hashing - gender=3, - phone_number="919999999999", - user_type=User.TYPE_VALUE_MAP["Nurse"], - verified=True, - asset=asset_obj, - date_of_birth=timezone.now().date(), - ) - asset_user.save() - return asset_user +# +# +# class MiddlewareAuthentication(JWTAuthentication): +# """ +# An authentication plugin that authenticates requests through a JSON web +# token provided in a request header. +# """ +# +# facility_header = "X-Facility-Id" +# auth_header_type = "Middleware_Bearer" +# auth_header_type_bytes = auth_header_type.encode(HTTP_HEADER_ENCODING) +# +# def get_public_key(self, url): +# public_key_json = cache.get(jwk_response_cache_key(url)) +# if not public_key_json: +# res = requests.get(url, timeout=OPENID_REQUEST_TIMEOUT) +# res.raise_for_status() +# public_key_json = res.json() +# cache.set(jwk_response_cache_key(url), public_key_json, timeout=60 * 5) +# return public_key_json["keys"][0] +# +# def open_id_authenticate(self, url, token): +# public_key_response = self.get_public_key(url) +# public_key = jwt.algorithms.RSAAlgorithm.from_jwk(public_key_response) +# return jwt.decode(token, key=public_key, algorithms=["RS256"]) +# +# def authenticate_header(self, request): +# return f'{self.auth_header_type} realm="{self.www_authenticate_realm}"' +# +# def get_user(self, _: Token, facility: Facility): +# return MiddlewareUser(facility=facility) +# +# def authenticate(self, request): +# header = self.get_header(request) +# if header is None: +# return None +# +# raw_token = self.get_raw_token(header) +# if raw_token is None or self.facility_header not in request.headers: +# return None +# +# external_id = request.headers[self.facility_header] +# +# try: +# facility = Facility.objects.get(external_id=external_id) +# except (Facility.DoesNotExist, ValidationError) as e: +# raise InvalidToken({"detail": "Invalid Facility", "messages": []}) from e +# +# if not facility.middleware_address: +# raise InvalidToken({"detail": "Facility not connected to a middleware"}) +# +# open_id_url = ( +# f"https://{facility.middleware_address}/.well-known/openid-configuration/" +# ) +# +# validated_token = self.get_validated_token(open_id_url, raw_token) +# +# return self.get_user(validated_token, facility), validated_token +# +# def get_raw_token(self, header): +# """ +# Extracts an un-validated JSON web token from the given "Authorization" +# header value. +# """ +# parts = header.split() +# +# if len(parts) == 0: +# # Empty AUTHORIZATION header sent +# return None +# +# if parts[0] != self.auth_header_type_bytes: +# # Assume the header does not contain a JSON web token +# return None +# +# if len(parts) != 2: +# raise AuthenticationFailed( +# _("Authorization header must contain two space-delimited values"), +# code="bad_authorization_header", +# ) +# +# return parts[1] +# +# def get_validated_token(self, url, raw_token): +# """ +# Validates an encoded JSON web token and returns a validated token +# wrapper object. +# """ +# try: +# return self.open_id_authenticate(url, raw_token) +# except Exception as e: +# logger.info(e, "Token: ", raw_token) +# +# raise InvalidToken({"detail": "Given token not valid for any token type"}) +# +# +# class MiddlewareAssetAuthentication(MiddlewareAuthentication): +# def get_user(self, validated_token, facility): +# """ +# Attempts to find and return a user using the given validated token. +# """ +# if "asset_id" not in validated_token: +# raise InvalidToken({"detail": "Given token does not contain asset_id"}) +# +# try: +# asset_obj = Asset.objects.select_related("current_location__facility").get( +# external_id=validated_token["asset_id"] +# ) +# except (Asset.DoesNotExist, ValidationError) as e: +# raise InvalidToken( +# {"detail": "Invalid Asset ID", "messages": [str(e)]} +# ) from e +# +# if asset_obj.current_location.facility != facility: +# raise InvalidToken({"detail": "Facility not connected to Asset"}) +# +# # Create/Retrieve User and return them +# asset_user = User.objects.filter(asset=asset_obj).first() +# if not asset_user: +# password = User.objects.make_random_password() +# asset_user = User( +# username=f"asset{asset_obj.external_id!s}", +# email="support@ohc.network", +# password=f"{password}xyz", # The xyz makes it inaccessible without hashing +# gender=3, +# phone_number="919999999999", +# user_type=User.TYPE_VALUE_MAP["Nurse"], +# verified=True, +# asset=asset_obj, +# date_of_birth=timezone.now().date(), +# ) +# asset_user.save() +# return asset_user class CustomJWTAuthenticationScheme(OpenApiAuthenticationExtension): @@ -209,42 +200,43 @@ def get_security_definition(self, auto_schema): ) -class MiddlewareAuthenticationScheme(OpenApiAuthenticationExtension): - target_class = "config.authentication.MiddlewareAuthentication" - name = "middlewareAuth" - - def get_security_definition(self, auto_schema): - return { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT", - "description": _( - "Used for authenticating requests from the middleware. " - "The scheme requires a valid JWT token in the Authorization header " - "along with the facility id in the X-Facility-Id header. " - "--The value field is just for preview, filling it will show allowed " - "endpoints.--" - ), - } - - -class MiddlewareAssetAuthenticationScheme(OpenApiAuthenticationExtension): - target_class = "config.authentication.MiddlewareAssetAuthentication" - name = "middlewareAssetAuth" - - def get_security_definition(self, auto_schema): - return { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT", - "description": _( - "Used for authenticating requests from the middleware on behalf of assets. " - "The scheme requires a valid JWT token in the Authorization header " - "along with the facility id in the X-Facility-Id header. " - "--The value field is just for preview, filling it will show allowed " - "endpoints.--" - ), - } +# class MiddlewareAuthenticationScheme(OpenApiAuthenticationExtension): +# target_class = "config.authentication.MiddlewareAuthentication" +# name = "middlewareAuth" +# +# def get_security_definition(self, auto_schema): +# return { +# "type": "http", +# "scheme": "bearer", +# "bearerFormat": "JWT", +# "description": _( +# "Used for authenticating requests from the middleware. " +# "The scheme requires a valid JWT token in the Authorization header " +# "along with the facility id in the X-Facility-Id header. " +# "--The value field is just for preview, filling it will show allowed " +# "endpoints.--" +# ), +# } + + +# class MiddlewareAssetAuthenticationScheme(OpenApiAuthenticationExtension): +# target_class = "config.authentication.MiddlewareAssetAuthentication" +# name = "middlewareAssetAuth" +# +# def get_security_definition(self, auto_schema): +# return { +# "type": "http", +# "scheme": "bearer", +# "bearerFormat": "JWT", +# "description": _( +# "Used for authenticating requests from the middleware on behalf of assets. " +# "The scheme requires a valid JWT token in the Authorization header " +# "along with the facility id in the X-Facility-Id header. " +# "--The value field is just for preview, filling it will show allowed " +# "endpoints.--" +# ), +# } +# class CustomBasicAuthenticationScheme(OpenApiAuthenticationExtension): diff --git a/config/health_views.py b/config/health_views.py index 7e0801bb74..67e5e0aedc 100644 --- a/config/health_views.py +++ b/config/health_views.py @@ -1,22 +1,12 @@ -from rest_framework.response import Response -from rest_framework.views import APIView - -from care.users.api.serializers.user import UserBaseMinimumSerializer -from config.authentication import ( - MiddlewareAssetAuthentication, - MiddlewareAuthentication, -) - - -class MiddlewareAuthenticationVerifyView(APIView): - authentication_classes = (MiddlewareAuthentication,) - - def get(self, request): - return Response(UserBaseMinimumSerializer(request.user).data) - - -class MiddlewareAssetAuthenticationVerifyView(APIView): - authentication_classes = (MiddlewareAssetAuthentication,) - - def get(self, request): - return Response(UserBaseMinimumSerializer(request.user).data) +# class MiddlewareAuthenticationVerifyView(APIView): +# authentication_classes = (MiddlewareAuthentication,) +# +# def get(self, request): +# return Response(UserBaseMinimumSerializer(request.user).data) +# +# +# class MiddlewareAssetAuthenticationVerifyView(APIView): +# authentication_classes = (MiddlewareAssetAuthentication,) +# +# def get(self, request): +# return Response(UserBaseMinimumSerializer(request.user).data) diff --git a/config/settings/base.py b/config/settings/base.py index 7a16a2db96..803e451e40 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -444,12 +444,6 @@ # https://github.com/anexia-it/django-rest-passwordreset#custom-email-lookup DJANGO_REST_LOOKUP_FIELD = "username" -# Hardcopy settings (pdf generation) -# ------------------------------------------------------------------------------ -# https://github.com/loftylabs/django-hardcopy#installation -CHROME_WINDOW_SIZE = "2480,3508" -CHROME_PATH = "/usr/bin/chromium" - # Health Django (Health Check Config) # ------------------------------------------------------------------------------ # https://github.com/vigneshhari/healthy_django From f0ba4fa664dd1ce0ca1204f2c04be990fc0465fb Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Sun, 2 Feb 2025 19:48:54 +0530 Subject: [PATCH 02/17] Added status filter for questionnaire --- care/emr/api/viewsets/questionnaire.py | 1 + 1 file changed, 1 insertion(+) diff --git a/care/emr/api/viewsets/questionnaire.py b/care/emr/api/viewsets/questionnaire.py index b6767b22df..93599ee1ce 100644 --- a/care/emr/api/viewsets/questionnaire.py +++ b/care/emr/api/viewsets/questionnaire.py @@ -67,6 +67,7 @@ class QuestionnaireFilter(filters.FilterSet): title = filters.CharFilter(field_name="title", lookup_expr="icontains") subject_type = filters.CharFilter(field_name="subject_type", lookup_expr="iexact") tag_slug = QuestionnaireTagSlugFilter(field_name="tag_slug") + status = filters.CharFilter(field_name="status", lookup_expr="iexact") class QuestionnaireViewSet(EMRModelViewSet): From c2d51da46d8b56648813490f4df757d31d8c3262 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Mon, 3 Feb 2025 13:09:46 +0530 Subject: [PATCH 03/17] Added MAR update spec --- .../emr/api/viewsets/medication_administration.py | 3 ++- .../resources/medication/administration/spec.py | 15 +++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/care/emr/api/viewsets/medication_administration.py b/care/emr/api/viewsets/medication_administration.py index 6a7436460a..872edb9057 100644 --- a/care/emr/api/viewsets/medication_administration.py +++ b/care/emr/api/viewsets/medication_administration.py @@ -8,7 +8,7 @@ ) from care.emr.resources.medication.administration.spec import ( MedicationAdministrationReadSpec, - MedicationAdministrationSpec, + MedicationAdministrationSpec, MedicationAdministrationUpdateSpec, ) from care.emr.resources.questionnaire.spec import SubjectType @@ -25,6 +25,7 @@ class MedicationAdministrationViewSet( ): database_model = MedicationAdministration pydantic_model = MedicationAdministrationSpec + pydantic_update_model = MedicationAdministrationUpdateSpec pydantic_read_model = MedicationAdministrationReadSpec questionnaire_type = "medication_administration" questionnaire_title = "Medication Administration" diff --git a/care/emr/resources/medication/administration/spec.py b/care/emr/resources/medication/administration/spec.py index 57e7c6ae32..840b7d1bcc 100644 --- a/care/emr/resources/medication/administration/spec.py +++ b/care/emr/resources/medication/administration/spec.py @@ -142,10 +142,8 @@ class BaseMedicationAdministrationSpec(EMRResource): occurrence_period_start: datetime = Field( description="When the medication was administration started", ) - occurrence_period_end: datetime | None = Field( - None, - description="When the medication administration ended. If not provided, it is assumed to be ongoing", - ) + occurrence_period_end: datetime | None = None + recorded: datetime | None = Field( None, description="When administration was recorded", @@ -167,10 +165,7 @@ class BaseMedicationAdministrationSpec(EMRResource): description="The dosage of the medication", ) - note: str | None = Field( - None, - description="Any additional notes about the medication", - ) + note: str | None = None class MedicationAdministrationSpec(BaseMedicationAdministrationSpec): @@ -216,6 +211,10 @@ def perform_extra_deserialization(self, is_update, obj): obj.patient = obj.encounter.patient obj.request = MedicationRequest.objects.get(external_id=self.request) +class MedicationAdministrationUpdateSpec(BaseMedicationAdministrationSpec): + status: MedicationAdministrationStatus + note: str | None = None + occurrence_period_end: datetime | None = None class MedicationAdministrationReadSpec(BaseMedicationAdministrationSpec): created_by: UserSpec = dict From 91b42400efec4b61637795a64f2c926b1524ad40 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Mon, 3 Feb 2025 13:09:58 +0530 Subject: [PATCH 04/17] Added MAR update spec --- care/emr/api/viewsets/medication_administration.py | 3 ++- care/emr/resources/medication/administration/spec.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/care/emr/api/viewsets/medication_administration.py b/care/emr/api/viewsets/medication_administration.py index 872edb9057..c766d55a3c 100644 --- a/care/emr/api/viewsets/medication_administration.py +++ b/care/emr/api/viewsets/medication_administration.py @@ -8,7 +8,8 @@ ) from care.emr.resources.medication.administration.spec import ( MedicationAdministrationReadSpec, - MedicationAdministrationSpec, MedicationAdministrationUpdateSpec, + MedicationAdministrationSpec, + MedicationAdministrationUpdateSpec, ) from care.emr.resources.questionnaire.spec import SubjectType diff --git a/care/emr/resources/medication/administration/spec.py b/care/emr/resources/medication/administration/spec.py index 840b7d1bcc..8aef8a06ea 100644 --- a/care/emr/resources/medication/administration/spec.py +++ b/care/emr/resources/medication/administration/spec.py @@ -211,11 +211,13 @@ def perform_extra_deserialization(self, is_update, obj): obj.patient = obj.encounter.patient obj.request = MedicationRequest.objects.get(external_id=self.request) + class MedicationAdministrationUpdateSpec(BaseMedicationAdministrationSpec): status: MedicationAdministrationStatus note: str | None = None occurrence_period_end: datetime | None = None + class MedicationAdministrationReadSpec(BaseMedicationAdministrationSpec): created_by: UserSpec = dict From 713839255c64a4c23d67a182600630fc365b358e Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Mon, 3 Feb 2025 13:28:31 +0530 Subject: [PATCH 05/17] Added MAR update spec --- care/emr/resources/medication/administration/spec.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/care/emr/resources/medication/administration/spec.py b/care/emr/resources/medication/administration/spec.py index 8aef8a06ea..e319c72301 100644 --- a/care/emr/resources/medication/administration/spec.py +++ b/care/emr/resources/medication/administration/spec.py @@ -212,7 +212,10 @@ def perform_extra_deserialization(self, is_update, obj): obj.request = MedicationRequest.objects.get(external_id=self.request) -class MedicationAdministrationUpdateSpec(BaseMedicationAdministrationSpec): +class MedicationAdministrationUpdateSpec(EMRResource): + __model__ = MedicationAdministration + __exclude__ = ["patient", "encounter", "request"] + status: MedicationAdministrationStatus note: str | None = None occurrence_period_end: datetime | None = None From 5d8cc0f9265873cf75fd19232868108336c40fae Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Tue, 4 Feb 2025 18:26:29 +0530 Subject: [PATCH 06/17] Fix Questionnaire submit units --- care/emr/resources/questionnaire/utils.py | 19 ++++++++++--------- .../resources/questionnaire_response/spec.py | 10 +++++++--- docker-compose.yaml | 4 ++-- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/care/emr/resources/questionnaire/utils.py b/care/emr/resources/questionnaire/utils.py index 8fb69c1beb..6ed7275051 100644 --- a/care/emr/resources/questionnaire/utils.py +++ b/care/emr/resources/questionnaire/utils.py @@ -225,21 +225,22 @@ def create_observation_spec(questionnaire, responses, parent_id=None): for value in responses[questionnaire["id"]].values: observation = spec.copy() observation["id"] = str(uuid.uuid4()) - if questionnaire["type"] == QuestionType.choice.value and value.value_code: - observation["value"] = { - "value_code": (value.value_code.model_dump(exclude_defaults=True)) - } + if questionnaire["type"] == QuestionType.choice.value and value.code: + observation["value"] = value.value_code.model_dump( + exclude_defaults=True + ) + elif ( questionnaire["type"] == QuestionType.quantity.value and value.value_quantity ): - observation["value"] = { - "value_quantity": ( - value.value_quantity.model_dump(exclude_defaults=True) - ) - } + observation["value"] = value.value_quantity.model_dump( + exclude_defaults=True + ) elif value: observation["value"] = {"value": value.value} + if "unit" in questionnaire: + observation["value"]["unit"] = questionnaire["unit"] if responses[questionnaire["id"]].note: observation["note"] = responses[questionnaire["id"]].note if parent_id: diff --git a/care/emr/resources/questionnaire_response/spec.py b/care/emr/resources/questionnaire_response/spec.py index b7d359edc0..f365aa8a3e 100644 --- a/care/emr/resources/questionnaire_response/spec.py +++ b/care/emr/resources/questionnaire_response/spec.py @@ -4,15 +4,19 @@ from care.emr.models.questionnaire import QuestionnaireResponse from care.emr.resources.base import EMRResource -from care.emr.resources.common import Coding, Quantity +from care.emr.resources.common import Coding from care.emr.resources.questionnaire.spec import QuestionnaireReadSpec from care.emr.resources.user.spec import UserSpec class QuestionnaireSubmitResultValue(BaseModel): value: str | None = None - value_code: Coding | None = None - value_quantity: Quantity | None = None + # For Quantity + unit: Coding | None = None + # For Codes + system: str | None = None + code: str | None = None + display: str | None = None class QuestionnaireSubmitResult(BaseModel): diff --git a/docker-compose.yaml b/docker-compose.yaml index 7b7398fa42..0fe48b4db3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -19,7 +19,7 @@ services: image: redis:7.2-alpine restart: unless-stopped volumes: - - redis-data:/data + - care-redis-data:/data ports: - "6380:6379" @@ -47,4 +47,4 @@ services: volumes: postgres-data: - redis-data: + care-redis-data: From 9967353d5d53fe151cf8329fb1813ad5030fae0a Mon Sep 17 00:00:00 2001 From: Rithvik Nishad Date: Tue, 4 Feb 2025 16:52:47 +0000 Subject: [PATCH 07/17] Disallow creating availabilities of duration not multiple of slot size duration; Prevent overlapping availability creation (#2795) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- care/emr/api/viewsets/scheduling/schedule.py | 8 +- .../emr/resources/scheduling/schedule/spec.py | 82 ++++++++-- care/emr/tests/test_schedule_api.py | 147 ++++++++++++++++-- 3 files changed, 213 insertions(+), 24 deletions(-) diff --git a/care/emr/api/viewsets/scheduling/schedule.py b/care/emr/api/viewsets/scheduling/schedule.py index 41815002b3..7da9e02a40 100644 --- a/care/emr/api/viewsets/scheduling/schedule.py +++ b/care/emr/api/viewsets/scheduling/schedule.py @@ -15,6 +15,7 @@ from care.emr.models.scheduling.booking import TokenSlot from care.emr.models.scheduling.schedule import Availability, Schedule from care.emr.resources.scheduling.schedule.spec import ( + AvailabilityCreateSpec, AvailabilityForScheduleSpec, ScheduleCreateSpec, ScheduleReadSpec, @@ -132,7 +133,8 @@ def get_queryset(self): class AvailabilityViewSet(EMRCreateMixin, EMRDestroyMixin, EMRBaseViewSet): database_model = Availability - pydantic_model = AvailabilityForScheduleSpec + pydantic_model = AvailabilityCreateSpec + pydantic_retrieve_model = AvailabilityForScheduleSpec def get_facility_obj(self): return get_object_or_404( @@ -164,6 +166,10 @@ def get_queryset(self): .order_by("-modified_date") ) + def clean_create_data(self, request_data): + request_data["schedule"] = self.kwargs["schedule_external_id"] + return request_data + def perform_create(self, instance): schedule = self.get_schedule_obj() instance.schedule = schedule diff --git a/care/emr/resources/scheduling/schedule/spec.py b/care/emr/resources/scheduling/schedule/spec.py index 0b28b2d43b..1eb52f87c8 100644 --- a/care/emr/resources/scheduling/schedule/spec.py +++ b/care/emr/resources/scheduling/schedule/spec.py @@ -1,4 +1,5 @@ import datetime +from datetime import UTC from enum import Enum from django.db.models import Sum @@ -51,17 +52,11 @@ class AvailabilityForScheduleSpec(AvailabilityBaseSpec): @field_validator("availability") @classmethod def validate_availability(cls, availabilities: list[AvailabilityDateTimeSpec]): - # Validates if availability overlaps for the same day - for i in range(len(availabilities)): - for j in range(i + 1, len(availabilities)): - if availabilities[i].day_of_week != availabilities[j].day_of_week: - continue - # Check if time ranges overlap - if ( - availabilities[i].start_time <= availabilities[j].end_time - and availabilities[j].start_time <= availabilities[i].end_time - ): - raise ValueError("Availability time ranges are overlapping") + if has_overlapping_availability(availabilities): + raise ValueError("Availability time ranges are overlapping") + for availability in availabilities: + if availability.start_time >= availability.end_time: + raise ValueError("Start time must be earlier than end time") return availabilities @model_validator(mode="after") @@ -73,12 +68,50 @@ def validate_for_slot_type(self): ) if not self.tokens_per_slot: raise ValueError("Tokens per slot is required for appointment slots") + + for availability in self.availability: + start_time = datetime.datetime.combine( + datetime.datetime.now(tz=UTC).date(), availability.start_time + ) + end_time = datetime.datetime.combine( + datetime.datetime.now(tz=UTC).date(), availability.end_time + ) + slot_size_in_seconds = self.slot_size_in_minutes * 60 + if (end_time - start_time).total_seconds() % slot_size_in_seconds != 0: + raise ValueError( + "Availability duration must be a multiple of slot size in minutes" + ) else: self.slot_size_in_minutes = None self.tokens_per_slot = None return self +class AvailabilityCreateSpec(AvailabilityForScheduleSpec): + schedule: UUID4 + + @model_validator(mode="after") + def check_for_overlaps(self): + availabilities = Availability.objects.filter( + schedule__external_id=self.schedule + ) + all_availabilities = [*self.availability] + for availability in availabilities: + all_availabilities.extend( + [ + AvailabilityDateTimeSpec( + day_of_week=availability["day_of_week"], + start_time=availability["start_time"], + end_time=availability["end_time"], + ) + for availability in availability.availability + ] + ) + if has_overlapping_availability(all_availabilities): + raise ValueError("Availability time ranges are overlapping") + return self + + class ScheduleBaseSpec(EMRResource): __model__ = Schedule __exclude__ = ["resource", "facility"] @@ -100,6 +133,18 @@ def validate_period(self): raise ValidationError("Valid from cannot be greater than valid to") return self + @field_validator("availabilities") + @classmethod + def validate_availabilities_not_overlapping( + cls, availabilities: list[AvailabilityForScheduleSpec] + ): + all_availabilities = [] + for availability in availabilities: + all_availabilities.extend(availability.availability) + if has_overlapping_availability(all_availabilities): + raise ValueError("Availability time ranges are overlapping") + return availabilities + def perform_extra_deserialization(self, is_update, obj): user = get_object_or_404(User, external_id=self.user) # TODO Validation that user is in given facility @@ -172,3 +217,18 @@ def perform_extra_serialization(cls, mapping, obj): AvailabilityForScheduleSpec.serialize(o) for o in Availability.objects.filter(schedule=obj) ] + + +def has_overlapping_availability(availabilities: list[AvailabilityDateTimeSpec]): + for i in range(len(availabilities)): + for j in range(i + 1, len(availabilities)): + # Skip checking for overlap if it's not the same day of week + if availabilities[i].day_of_week != availabilities[j].day_of_week: + continue + # Check if time ranges overlap + if ( + availabilities[i].start_time <= availabilities[j].end_time + and availabilities[j].start_time <= availabilities[i].end_time + ): + return True + return False diff --git a/care/emr/tests/test_schedule_api.py b/care/emr/tests/test_schedule_api.py index 894dfed579..9c5ea7edae 100644 --- a/care/emr/tests/test_schedule_api.py +++ b/care/emr/tests/test_schedule_api.py @@ -186,11 +186,55 @@ def test_create_schedule_with_invalid_dates(self): valid_from=valid_from.isoformat(), valid_to=valid_to.isoformat() ) response = self.client.post(self.base_url, schedule_data, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertContains( response, "Valid from cannot be greater than valid to", status_code=400 ) + def test_create_schedule_with_overlapping_availability(self): + """Schedule creation fails when availability sessions overlap""" + permissions = [UserSchedulePermissions.can_write_user_schedule.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + schedule_data = self.generate_schedule_data( + availabilities=[ + { + "name": "Availability 1", + "slot_type": SlotTypeOptions.appointment.value, + "slot_size_in_minutes": 30, + "tokens_per_slot": 1, + "create_tokens": True, + "reason": "Regular schedule", + "availability": [ + { + "day_of_week": 1, + "start_time": "09:00:00", + "end_time": "13:00:00", + }, + ], + }, + { + "name": "Availability 2", + "slot_type": SlotTypeOptions.appointment.value, + "slot_size_in_minutes": 30, + "tokens_per_slot": 1, + "create_tokens": True, + "reason": "Regular schedule", + "availability": [ + { + "day_of_week": 1, + "start_time": "08:00:00", + "end_time": "10:00:00", + }, + ], + }, + ] + ) + response = self.client.post(self.base_url, schedule_data, format="json") + self.assertContains( + response, "Availability time ranges are overlapping", status_code=400 + ) + def test_create_schedule_with_user_not_part_of_facility(self): """Users cannot write schedules for user not belonging to the facility.""" permissions = [UserSchedulePermissions.can_write_user_schedule.name] @@ -709,7 +753,7 @@ def generate_availability_data(self, **kwargs): "reason": "Regular schedule", "availability": [ { - "day_of_week": 1, + "day_of_week": 2, "start_time": "09:00:00", "end_time": "13:00:00", } @@ -728,6 +772,40 @@ def test_create_availability_with_permissions(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["name"], availability_data["name"]) + def test_create_availability_overlapping_with_existing_availabilities(self): + """Users cannot create availability that overlaps with existing availabilities.""" + permissions = [UserSchedulePermissions.can_write_user_schedule.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + self.create_availability( + availability=[ + {"day_of_week": 1, "start_time": "08:00:00", "end_time": "10:00:00"}, + ] + ) + + availability_data = self.generate_availability_data() + response = self.client.post(self.base_url, availability_data, format="json") + self.assertContains( + response, "Availability time ranges are overlapping", status_code=400 + ) + + def test_create_availability_not_overlapping_with_existing_availabilities(self): + """Users can create availability that does not overlap with existing availabilities.""" + permissions = [UserSchedulePermissions.can_write_user_schedule.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + self.create_availability( + availability=[ + {"day_of_week": 1, "start_time": "14:00:00", "end_time": "20:00:00"}, + ] + ) + + availability_data = self.generate_availability_data() + response = self.client.post(self.base_url, availability_data, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + def test_create_availability_without_permissions(self): """Users without can_write_user_schedule permission cannot create availability.""" availability_data = self.generate_availability_data() @@ -809,35 +887,32 @@ def test_create_availability_validate_availability(self): data = self.generate_availability_data( availability=[ { - "day_of_week": 1, # Monday + "day_of_week": 2, # Monday "start_time": "09:00:00", "end_time": "13:00:00", }, { - "day_of_week": 1, # Same day (Monday) + "day_of_week": 2, # Same day (Monday) "start_time": "12:00:00", # Overlaps with previous range "end_time": "17:00:00", }, ] ) response = self.client.post(self.base_url, data, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertContains( - response, - "Availability time ranges are overlapping", - status_code=400, + response, "Availability time ranges are overlapping", status_code=400 ) # Verify that non-overlapping ranges on same day are allowed data = self.generate_availability_data( availability=[ { - "day_of_week": 1, + "day_of_week": 2, "start_time": "09:00:00", "end_time": "12:00:00", }, { - "day_of_week": 1, + "day_of_week": 2, "start_time": "13:00:00", # No overlap "end_time": "17:00:00", }, @@ -851,12 +926,12 @@ def test_create_availability_validate_availability(self): data = self.generate_availability_data( availability=[ { - "day_of_week": 1, # Monday + "day_of_week": 3, # Tuesday "start_time": "09:00:00", "end_time": "17:00:00", }, { - "day_of_week": 2, # Tuesday + "day_of_week": 4, # Wednesday "start_time": "09:00:00", # Same time range but different day "end_time": "17:00:00", }, @@ -866,6 +941,54 @@ def test_create_availability_validate_availability(self): response = self.client.post(self.base_url, data, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) + def test_create_availability_validate_duration_multiple_of_slot_size_in_minutes( + self, + ): + """Test validation rules for ensuring availability duration is multiple of slot size in minutes.""" + permissions = [UserSchedulePermissions.can_write_user_schedule.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + # Try to create availability with duration not multiple of slot size + data = self.generate_availability_data( + availability=[ + { + "day_of_week": 2, # Monday + "start_time": "09:00:00", + "end_time": "13:13:00", + }, + ] + ) + response = self.client.post(self.base_url, data, format="json") + self.assertContains( + response, + "Availability duration must be a multiple of slot size in minutes", + status_code=400, + ) + + def test_create_availability_start_time_greater_than_end_time(self): + """Test validation rules for ensuring start time is before end time.""" + permissions = [UserSchedulePermissions.can_write_user_schedule.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + # Try to create availability with end time before start time + data = self.generate_availability_data( + availability=[ + { + "day_of_week": 1, # Monday + "start_time": "13:00:00", + "end_time": "09:00:00", + }, + ] + ) + response = self.client.post(self.base_url, data, format="json") + self.assertContains( + response, + "Start time must be earlier than end time", + status_code=400, + ) + def test_create_availability_validate_slot_type(self): """Test validation rules for different slot types when creating availability slots.""" permissions = [UserSchedulePermissions.can_write_user_schedule.name] From 26c3cf179653f4ec4288b739c3a94c68d6d7b80b Mon Sep 17 00:00:00 2001 From: Amjith Titus Date: Wed, 5 Feb 2025 12:19:19 +0530 Subject: [PATCH 08/17] Allergy Read | Add note (#2813) --- care/emr/resources/allergy_intolerance/spec.py | 1 + 1 file changed, 1 insertion(+) diff --git a/care/emr/resources/allergy_intolerance/spec.py b/care/emr/resources/allergy_intolerance/spec.py index 40a16c95fb..5c381dc20a 100644 --- a/care/emr/resources/allergy_intolerance/spec.py +++ b/care/emr/resources/allergy_intolerance/spec.py @@ -123,6 +123,7 @@ class AllergyIntoleranceReadSpec(BaseAllergyIntoleranceSpec): recorded_date: datetime.datetime | None = None created_by: dict = {} updated_by: dict = {} + note: str | None = None @classmethod def perform_extra_serialization(cls, mapping, obj): From aa7c0593b0ebae8ddf2ac1c5e3ccdbe6d054bc59 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Wed, 5 Feb 2025 15:18:00 +0530 Subject: [PATCH 09/17] Update piplock --- Makefile | 5 ++ Pipfile | 10 +-- Pipfile.lock | 168 +++++++++++++++++++++++++++------------------------ 3 files changed, 98 insertions(+), 85 deletions(-) diff --git a/Makefile b/Makefile index e7ff114154..9476042de1 100644 --- a/Makefile +++ b/Makefile @@ -54,6 +54,11 @@ migrate: test: docker compose exec backend bash -c "python manage.py test $(path) --keepdb --parallel --shuffle" + +test-no-keep: + docker compose exec backend bash -c "python manage.py test $(path) --parallel --shuffle" + + test-coverage: docker compose exec backend bash -c "coverage run manage.py test --settings=config.settings.test --keepdb --parallel --shuffle" docker compose exec backend bash -c "coverage combine || true; coverage xml" diff --git a/Pipfile b/Pipfile index 57a948b8dd..7facadf240 100644 --- a/Pipfile +++ b/Pipfile @@ -6,7 +6,7 @@ name = "pypi" [packages] argon2-cffi = "==23.1.0" authlib = "==1.4.0" -boto3 = "==1.36.7" +boto3 = "==1.36.12" celery = "==5.4.0" django = "==5.1.4" django-environ = "==0.12.0" @@ -23,7 +23,7 @@ djangorestframework = "==3.15.2" djangorestframework-simplejwt = "==5.4.0" dry-rest-permissions = "==0.1.10" drf-nested-routers = "==0.94.1" -drf-spectacular = "==0.27.2" +drf-spectacular = "==0.28.0" gunicorn = "==23.0.0" healthy-django = "==0.1.0" json-fingerprint = "==0.14.0" @@ -43,17 +43,17 @@ sentry-sdk = "==2.18.0" whitenoise = "==6.8.2" django-anymail = {extras = ["amazon-ses"], version = "*"} pydantic-extra-types = "2.10.2" -phonenumberslite = "==8.13.52" +phonenumberslite = "==8.13.54" [dev-packages] boto3-stubs = { extras = ["s3", "boto3"], version = "*" } coverage = "==7.6.10" -debugpy = "==1.8.11" +debugpy = "==1.8.12" django-coverage-plugin = "==3.1.0" django-extensions = "==3.2.3" django-silk = "==5.3.2" djangorestframework-stubs = "==3.15.2" -factory-boy = "==3.3.1" +factory-boy = "==3.3.3" freezegun = "==1.5.1" ipython = "==8.31.0" mypy = "==1.14.1" diff --git a/Pipfile.lock b/Pipfile.lock index 1dba5ee6ed..2cfd8c3722 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "663cc58c322331774e6872c6042add29781929bf015aeea6b0755231e4757ac4" + "sha256": "8af244adc2d682ff27dbc3b2a2af83d09694ba33eb08486fa72e2f35fd1fb3d1" }, "pipfile-spec": 6, "requires": { @@ -201,20 +201,20 @@ }, "boto3": { "hashes": [ - "sha256:ab501f75557863e2d2c9fa731e4fe25c45f35e0d92ea0ee11a4eaa63929d3ede", - "sha256:ae98634efa7b47ced1b0d7342e2940b32639eee913f33ab406590b8ed55ee94b" + "sha256:287d84f49bba3255a17b374578127d42b6251e72f55914a62e0ad9ca78c0954b", + "sha256:32cdf0967287f3ec25a9dc09df0d29cb86b8900c3e0546a63d672775d8127abf" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.36.7" + "version": "==1.36.12" }, "botocore": { "hashes": [ - "sha256:59d3fdfbae6d916b046e973bebcbeb70a102f9e570ca86d5ba512f1854b78fc2", - "sha256:81c88e5566cf018e1411a68304dc1fb9e4156ca2b50a3a0f0befc274299e67fa" + "sha256:50a3ff292f8dfdde21074b5c916afe847b01e074ab16d9c9fe71b34960c77134", + "sha256:d644a814440bf8d55f4e29b1c0e6f021e2573b7784e0c91f55f4d9d689e08005" ], "markers": "python_version >= '3.8'", - "version": "==1.36.8" + "version": "==1.36.13" }, "celery": { "hashes": [ @@ -227,11 +227,11 @@ }, "certifi": { "hashes": [ - "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", - "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db" + "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", + "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe" ], "markers": "python_version >= '3.6'", - "version": "==2024.12.14" + "version": "==2025.1.31" }, "cffi": { "hashes": [ @@ -601,12 +601,12 @@ }, "drf-spectacular": { "hashes": [ - "sha256:a199492f2163c4101055075ebdbb037d59c6e0030692fc83a1a8c0fc65929981", - "sha256:b1c04bf8b2fbbeaf6f59414b4ea448c8787aba4d32f76055c3b13335cf7ec37b" + "sha256:2c778a47a40ab2f5078a7c42e82baba07397bb35b074ae4680721b2805943061", + "sha256:856e7edf1056e49a4245e87a61e8da4baff46c83dbc25be1da2df77f354c7cb4" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==0.27.2" + "version": "==0.28.0" }, "dry-rest-permissions": { "hashes": [ @@ -1062,11 +1062,11 @@ }, "phonenumberslite": { "hashes": [ - "sha256:02da5e78c67b213bae95afd6289f40486c93e302e518769911dfa5e7287ddeee", - "sha256:dfa44a4bae2e46d737ae5301cb96b14cdcbf45063236c74c6ddb08f5fd471b0d" + "sha256:2dbfc80d38aa3a6c3da222cb7a9bca5ccb1f1eb405d5cede27e29ba83d9714e8", + "sha256:6810bf3e256c0e5e43b834ed36e80f9b36b06155e5e04a3e866edebad7d88b57" ], "index": "pypi", - "version": "==8.13.52" + "version": "==8.13.54" }, "pillow": { "hashes": [ @@ -2023,12 +2023,12 @@ }, "boto3": { "hashes": [ - "sha256:ab501f75557863e2d2c9fa731e4fe25c45f35e0d92ea0ee11a4eaa63929d3ede", - "sha256:ae98634efa7b47ced1b0d7342e2940b32639eee913f33ab406590b8ed55ee94b" + "sha256:287d84f49bba3255a17b374578127d42b6251e72f55914a62e0ad9ca78c0954b", + "sha256:32cdf0967287f3ec25a9dc09df0d29cb86b8900c3e0546a63d672775d8127abf" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.36.7" + "version": "==1.36.12" }, "boto3-stubs": { "extras": [ @@ -2036,35 +2036,35 @@ "s3" ], "hashes": [ - "sha256:197bdbacd3a9085c6310a06f21616f30f6103ed8be67705962620ac4587ba1fb", - "sha256:d5d3f1f537c4d317f1f11b1cb4ce8f427822204936e29419b43c709ec54758ea" + "sha256:3637af3b29db13ec2349ef765f4e7fae7f09bb708c3b8bc93dd9a8113e3e4e9c", + "sha256:84ea0b281a37b363358364a87c2764cbe685166fc47bdac6d08c61bbfc13af50" ], "markers": "python_version >= '3.8'", - "version": "==1.36.7" + "version": "==1.36.12" }, "botocore": { "hashes": [ - "sha256:59d3fdfbae6d916b046e973bebcbeb70a102f9e570ca86d5ba512f1854b78fc2", - "sha256:81c88e5566cf018e1411a68304dc1fb9e4156ca2b50a3a0f0befc274299e67fa" + "sha256:50a3ff292f8dfdde21074b5c916afe847b01e074ab16d9c9fe71b34960c77134", + "sha256:d644a814440bf8d55f4e29b1c0e6f021e2573b7784e0c91f55f4d9d689e08005" ], "markers": "python_version >= '3.8'", - "version": "==1.36.8" + "version": "==1.36.13" }, "botocore-stubs": { "hashes": [ - "sha256:8f966e0cf994981f1086906fd72531acc434baea1fbc149fa2666a9087a0c17d", - "sha256:a8b65d7f6ae14aa90d03fbc1c27b7c3c6b3ca3d7c2eb1d814441ba04a22dd981" + "sha256:31d17381def93fa41d009e11ccfb0a846a878dcf84f97c229bfb7ceffad7a56a", + "sha256:c1e7eac810e42de49b47cb23c118db206c0e719130e468ddedcb0eb82c21904c" ], "markers": "python_version >= '3.8'", - "version": "==1.36.8" + "version": "==1.36.12" }, "certifi": { "hashes": [ - "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", - "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db" + "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", + "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe" ], "markers": "python_version >= '3.6'", - "version": "==2024.12.14" + "version": "==2025.1.31" }, "cfgv": { "hashes": [ @@ -2243,36 +2243,36 @@ }, "debugpy": { "hashes": [ - "sha256:0e22f846f4211383e6a416d04b4c13ed174d24cc5d43f5fd52e7821d0ebc8920", - "sha256:116bf8342062246ca749013df4f6ea106f23bc159305843491f64672a55af2e5", - "sha256:189058d03a40103a57144752652b3ab08ff02b7595d0ce1f651b9acc3a3a35a0", - "sha256:23dc34c5e03b0212fa3c49a874df2b8b1b8fda95160bd79c01eb3ab51ea8d851", - "sha256:28e45b3f827d3bf2592f3cf7ae63282e859f3259db44ed2b129093ca0ac7940b", - "sha256:2b26fefc4e31ff85593d68b9022e35e8925714a10ab4858fb1b577a8a48cb8cd", - "sha256:32db46ba45849daed7ccf3f2e26f7a386867b077f39b2a974bb5c4c2c3b0a280", - "sha256:40499a9979c55f72f4eb2fc38695419546b62594f8af194b879d2a18439c97a9", - "sha256:44b1b8e6253bceada11f714acf4309ffb98bfa9ac55e4fce14f9e5d4484287a1", - "sha256:52c3cf9ecda273a19cc092961ee34eb9ba8687d67ba34cc7b79a521c1c64c4c0", - "sha256:52d8a3166c9f2815bfae05f386114b0b2d274456980d41f320299a8d9a5615a7", - "sha256:61bc8b3b265e6949855300e84dc93d02d7a3a637f2aec6d382afd4ceb9120c9f", - "sha256:654130ca6ad5de73d978057eaf9e582244ff72d4574b3e106fb8d3d2a0d32458", - "sha256:6ad2688b69235c43b020e04fecccdf6a96c8943ca9c2fb340b8adc103c655e57", - "sha256:6c1f6a173d1140e557347419767d2b14ac1c9cd847e0b4c5444c7f3144697e4e", - "sha256:84e511a7545d11683d32cdb8f809ef63fc17ea2a00455cc62d0a4dbb4ed1c308", - "sha256:85de8474ad53ad546ff1c7c7c89230db215b9b8a02754d41cb5a76f70d0be296", - "sha256:8988f7163e4381b0da7696f37eec7aca19deb02e500245df68a7159739bbd0d3", - "sha256:8da1db4ca4f22583e834dcabdc7832e56fe16275253ee53ba66627b86e304da1", - "sha256:8ffc382e4afa4aee367bf413f55ed17bd91b191dcaf979890af239dda435f2a1", - "sha256:987bce16e86efa86f747d5151c54e91b3c1e36acc03ce1ddb50f9d09d16ded0e", - "sha256:ad7efe588c8f5cf940f40c3de0cd683cc5b76819446abaa50dc0829a30c094db", - "sha256:bb3b15e25891f38da3ca0740271e63ab9db61f41d4d8541745cfc1824252cb28", - "sha256:c928bbf47f65288574b78518449edaa46c82572d340e2750889bbf8cd92f3737", - "sha256:ce291a5aca4985d82875d6779f61375e959208cdf09fcec40001e65fb0a54768", - "sha256:d8768edcbeb34da9e11bcb8b5c2e0958d25218df7a6e56adf415ef262cd7b6d1" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==1.8.11" + "sha256:086b32e233e89a2740c1615c2f775c34ae951508b28b308681dbbb87bba97d06", + "sha256:22a11c493c70413a01ed03f01c3c3a2fc4478fc6ee186e340487b2edcd6f4180", + "sha256:274b6a2040349b5c9864e475284bce5bb062e63dce368a394b8cc865ae3b00c6", + "sha256:2ae5df899732a6051b49ea2632a9ea67f929604fd2b036613a9f12bc3163b92d", + "sha256:36f4829839ef0afdfdd208bb54f4c3d0eea86106d719811681a8627ae2e53dd5", + "sha256:39dfbb6fa09f12fae32639e3286112fc35ae976114f1f3d37375f3130a820969", + "sha256:4703575b78dd697b294f8c65588dc86874ed787b7348c65da70cfc885efdf1e1", + "sha256:4ad9a94d8f5c9b954e0e3b137cc64ef3f579d0df3c3698fe9c3734ee397e4abb", + "sha256:557cc55b51ab2f3371e238804ffc8510b6ef087673303890f57a24195d096e61", + "sha256:5cc45235fefac57f52680902b7d197fb2f3650112379a6fa9aa1b1c1d3ed3f02", + "sha256:646530b04f45c830ceae8e491ca1c9320a2d2f0efea3141487c82130aba70dce", + "sha256:696d8ae4dff4cbd06bf6b10d671e088b66669f110c7c4e18a44c43cf75ce966f", + "sha256:7e94b643b19e8feb5215fa508aee531387494bf668b2eca27fa769ea11d9f498", + "sha256:88a77f422f31f170c4b7e9ca58eae2a6c8e04da54121900651dfa8e66c29901a", + "sha256:898fba72b81a654e74412a67c7e0a81e89723cfe2a3ea6fcd3feaa3395138ca9", + "sha256:9649eced17a98ce816756ce50433b2dd85dfa7bc92ceb60579d68c053f98dff9", + "sha256:9af40506a59450f1315168d47a970db1a65aaab5df3833ac389d2899a5d63b3f", + "sha256:a28ed481d530e3138553be60991d2d61103ce6da254e51547b79549675f539b7", + "sha256:a2ba7ffe58efeae5b8fad1165357edfe01464f9aef25e814e891ec690e7dd82a", + "sha256:a4042edef80364239f5b7b5764e55fd3ffd40c32cf6753da9bda4ff0ac466018", + "sha256:b0232cd42506d0c94f9328aaf0d1d0785f90f87ae72d9759df7e5051be039738", + "sha256:b202f591204023b3ce62ff9a47baa555dc00bb092219abf5caf0e3718ac20e7c", + "sha256:b5c6c967d02fee30e157ab5227706f965d5c37679c687b1e7bbc5d9e7128bd41", + "sha256:cbbd4149c4fc5e7d508ece083e78c17442ee13b0e69bfa6bd63003e486770f45", + "sha256:f30b03b0f27608a0b26c75f0bb8a880c752c0e0b01090551b9d87c7d783e2069", + "sha256:fdb3c6d342825ea10b90e43d7f20f01535a72b3a1997850c0c3cefa5c27a4a2c" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==1.8.12" }, "decorator": { "hashes": [ @@ -2368,12 +2368,12 @@ }, "factory-boy": { "hashes": [ - "sha256:7b1113c49736e1e9995bc2a18f4dbf2c52cf0f841103517010b1d825712ce3ca", - "sha256:8317aa5289cdfc45f9cae570feb07a6177316c82e34d14df3c2e1f22f26abef0" + "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", + "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==3.3.1" + "version": "==3.3.3" }, "faker": { "hashes": [ @@ -2581,11 +2581,11 @@ }, "mypy-boto3-s3": { "hashes": [ - "sha256:80a881847b0e1fbc5edcad8b2870c110e31e7ef128db42402b70c159b7e93d5a", - "sha256:a65ccb6be7b7ebf907887268d44975e435b1fc1164fc0a25de310e2b832f7e91" + "sha256:368c963969eda65bb3a9df61e87510dd8b3247cce59f559c2ec6c43d5796bef5", + "sha256:506edd56892452dff5b673e3c79a11b6f8935076ce4a9daaac4cda708a176201" ], "markers": "python_version >= '3.8'", - "version": "==1.36.0" + "version": "==1.36.9" }, "mypy-extensions": { "hashes": [ @@ -2843,11 +2843,11 @@ }, "types-awscrt": { "hashes": [ - "sha256:2141391a8f4d36cf098406c19d9060b34f13a558c22d4aadac250a0c57d12710", - "sha256:d66b3817565769f5311b7e171a3c48d3dbf8a8f9c22f02686c2f003b6559a2a5" + "sha256:57ec68d45ef873458df7307ec80578a6334696f088549ab349c3d655e7e3562b", + "sha256:71181a6c5188352ae934e74a7633d80c82ac5c6f89054bd7d653bb1b5bba240b" ], "markers": "python_version >= '3.8'", - "version": "==0.23.8" + "version": "==0.23.9" }, "types-pyyaml": { "hashes": [ @@ -2962,27 +2962,27 @@ }, "babel": { "hashes": [ - "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", - "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316" + "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", + "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2" ], "markers": "python_version >= '3.8'", - "version": "==2.16.0" + "version": "==2.17.0" }, "beautifulsoup4": { "hashes": [ - "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051", - "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed" + "sha256:1bd32405dacc920b42b83ba01644747ed77456a65760e285fbc47633ceddaf8b", + "sha256:99045d7d3f08f91f0d656bc9b7efbae189426cd913d830294a15eefa0ea4df16" ], - "markers": "python_full_version >= '3.6.0'", - "version": "==4.12.3" + "markers": "python_full_version >= '3.7.0'", + "version": "==4.13.3" }, "certifi": { "hashes": [ - "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", - "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db" + "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", + "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe" ], "markers": "python_version >= '3.6'", - "version": "==2024.12.14" + "version": "==2025.1.31" }, "charset-normalizer": { "hashes": [ @@ -3387,6 +3387,14 @@ "markers": "python_version >= '3.9'", "version": "==2.0.0" }, + "typing-extensions": { + "hashes": [ + "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", + "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" + ], + "markers": "python_version >= '3.8'", + "version": "==4.12.2" + }, "urllib3": { "hashes": [ "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", From 05322bf7c4269c57e0eca4d47b6c94450f66ee28 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Wed, 5 Feb 2025 16:44:59 +0530 Subject: [PATCH 10/17] Prevent server disclosure --- config/wsgi.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/config/wsgi.py b/config/wsgi.py index c9f43409b4..c517d0ae32 100644 --- a/config/wsgi.py +++ b/config/wsgi.py @@ -33,6 +33,14 @@ # This application object is used by any WSGI server configured to use this # file. This includes Django's development server, if the WSGI_APPLICATION # setting points here. + +try: + import gunicorn + + gunicorn.SERVER = "care" +except BaseException: # noqa S110 + pass + application = get_wsgi_application() # Apply WSGI middleware here. # from helloworld.wsgi import HelloWorldApplication From 17932f1490cf6ec1c34ab12319c7e6d5424cea65 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Wed, 5 Feb 2025 17:26:37 +0530 Subject: [PATCH 11/17] Prevent server disclosure --- config/auth_views.py | 49 +++++++++++++++++++++++++++++++++++++++- config/authentication.py | 5 ++++ config/urls.py | 8 ++++++- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/config/auth_views.py b/config/auth_views.py index 4391d37195..640a2196da 100644 --- a/config/auth_views.py +++ b/config/auth_views.py @@ -1,18 +1,25 @@ from django.contrib.auth import authenticate, get_user_model +from django.core.cache import cache +from django.core.exceptions import PermissionDenied from django.utils.timezone import localtime, now from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema from rest_framework import serializers, status +from rest_framework.response import Response +from rest_framework.views import APIView from rest_framework_simplejwt.exceptions import AuthenticationFailed from rest_framework_simplejwt.serializers import PasswordField from rest_framework_simplejwt.settings import api_settings -from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework_simplejwt.tokens import AccessToken, RefreshToken from rest_framework_simplejwt.views import TokenVerifyView, TokenViewBase from config.ratelimit import ratelimit User = get_user_model() +ACCESS_TOKEN_INVALIDATION_PREFIX = "ACCESS_TOKEN_INVALIDATE:" +REFRESH_TOKEN_INVALIDATION_PREFIX = "REFRESH_TOKEN_INVALIDATE:" + class CaptchaRequiredException(AuthenticationFailed): status_code = status.HTTP_429_TOO_MANY_REQUESTS @@ -84,6 +91,9 @@ class TokenRefreshSerializer(serializers.Serializer): def validate(self, attrs): refresh = RefreshToken(attrs["refresh"]) + if cache.get(REFRESH_TOKEN_INVALIDATION_PREFIX + attrs["refresh"]): + raise PermissionDenied("Invalid Token") + data = {"access": str(refresh.access_token)} if api_settings.ROTATE_REFRESH_TOKENS: @@ -144,6 +154,43 @@ def post(self, request, *args, **kwargs): return super().post(request, *args, **kwargs) +class LogoutView(APIView): + """ + Logout a user + """ + + def post(self, request, *args, **kwargs): + if "access" not in request.data or "refresh" not in request.data: + return Response({"detail": "Missing access or refresh token"}, status=400) + try: + refresh_token = RefreshToken(request.data["refresh"]) + refresh_token_payload = refresh_token.payload + + if refresh_token_payload["user_id"] != str(request.user.external_id): + raise Exception("Invalid Token") + + access_token = AccessToken(request.data["access"]) + access_token_payload = access_token.payload + + if access_token_payload["user_id"] != str(request.user.external_id): + raise Exception("Invalid Token") + + cache.set( + ACCESS_TOKEN_INVALIDATION_PREFIX + request.data["access"], + "1", + timeout=1800, + ) + cache.set( + REFRESH_TOKEN_INVALIDATION_PREFIX + request.data["refresh"], + "1", + timeout=1800, + ) + + except: # noqa E722 + return Response({}, status=400) + return Response({}) + + class TokenRefreshView(TokenViewBase): """ Refresh access token. diff --git a/config/authentication.py b/config/authentication.py index f1570316ce..4a0fb59171 100644 --- a/config/authentication.py +++ b/config/authentication.py @@ -1,5 +1,6 @@ import logging +from django.core.cache import cache from django.utils.translation import gettext_lazy as _ from drf_spectacular.extensions import OpenApiAuthenticationExtension from drf_spectacular.plumbing import build_bearer_security_scheme_object @@ -38,6 +39,10 @@ def authenticate_header(self, request): return "" def get_validated_token(self, raw_token): + from config.auth_views import ACCESS_TOKEN_INVALIDATION_PREFIX + + if cache.get(ACCESS_TOKEN_INVALIDATION_PREFIX + raw_token.decode()): + raise InvalidToken("Invalid Token") try: return super().get_validated_token(raw_token) except InvalidToken as e: diff --git a/config/urls.py b/config/urls.py index c614d0ab91..61ee45fd07 100644 --- a/config/urls.py +++ b/config/urls.py @@ -19,7 +19,12 @@ ) from config import api_router -from .auth_views import AnnotatedTokenVerifyView, TokenObtainPairView, TokenRefreshView +from .auth_views import ( + AnnotatedTokenVerifyView, + LogoutView, + TokenObtainPairView, + TokenRefreshView, +) from .views import app_version, home_view, ping urlpatterns = [ @@ -30,6 +35,7 @@ path(f"{settings.ADMIN_URL.rstrip('/')}/", admin.site.urls), # Rest API path("api/v1/auth/login/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("api/v1/auth/logout/", LogoutView.as_view(), name="token_obtain_pair"), path( "api/v1/auth/token/refresh/", TokenRefreshView.as_view(), name="token_refresh" ), From 339d942bc39ff03aa8f3b7cc97f4c48eec9afa4d Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Wed, 5 Feb 2025 17:35:32 +0530 Subject: [PATCH 12/17] Prevent server disclosure --- config/gunicorn.py | 7 +++++++ config/wsgi.py | 7 ------- scripts/start.sh | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 config/gunicorn.py diff --git a/config/gunicorn.py b/config/gunicorn.py new file mode 100644 index 0000000000..be0a158379 --- /dev/null +++ b/config/gunicorn.py @@ -0,0 +1,7 @@ +# Gunicorn configuration file +# https://docs.gunicorn.org/en/stable/configure.html#configuration-file +# https://docs.gunicorn.org/en/stable/settings.html +import gunicorn + +gunicorn.SERVER_SOFTWARE = "care" +gunicorn.SERVER = "care" diff --git a/config/wsgi.py b/config/wsgi.py index c517d0ae32..c7b9b8f172 100644 --- a/config/wsgi.py +++ b/config/wsgi.py @@ -34,13 +34,6 @@ # file. This includes Django's development server, if the WSGI_APPLICATION # setting points here. -try: - import gunicorn - - gunicorn.SERVER = "care" -except BaseException: # noqa S110 - pass - application = get_wsgi_application() # Apply WSGI middleware here. # from helloworld.wsgi import HelloWorldApplication diff --git a/scripts/start.sh b/scripts/start.sh index 2e3cc2328f..80c176266b 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -20,7 +20,7 @@ python manage.py compilemessages -v 0 export NEW_RELIC_CONFIG_FILE=/etc/newrelic.ini if [[ -f "$NEW_RELIC_CONFIG_FILE" ]]; then - newrelic-admin run-program gunicorn config.wsgi:application --bind 0.0.0.0:9000 --chdir=/app + newrelic-admin run-program gunicorn --config python:config.gunicorn config.wsgi:application --bind 0.0.0.0:9000 --chdir=/app else - gunicorn config.wsgi:application --bind 0.0.0.0:9000 --chdir=/app --workers 2 + gunicorn --config python:config.gunicorn config.wsgi:application --bind 0.0.0.0:9000 --chdir=/app --workers 2 fi From f6fbaee7edb156c4d7cb224b86bc7e8bb4ddb1ae Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Wed, 5 Feb 2025 18:56:43 +0530 Subject: [PATCH 13/17] Allergy Edits are now visible to origin encounter --- care/emr/api/viewsets/allergy_intolerance.py | 43 ++++++++++++++++--- .../0016_allergyintolerance_copied_from.py | 18 ++++++++ care/emr/models/allergy_intolerance.py | 3 ++ 3 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 care/emr/migrations/0016_allergyintolerance_copied_from.py diff --git a/care/emr/api/viewsets/allergy_intolerance.py b/care/emr/api/viewsets/allergy_intolerance.py index 0de276f771..91dec0cf5c 100644 --- a/care/emr/api/viewsets/allergy_intolerance.py +++ b/care/emr/api/viewsets/allergy_intolerance.py @@ -1,3 +1,6 @@ +import uuid + +from django.db import transaction from django_filters import CharFilter, FilterSet from django_filters import rest_framework as filters from django_filters.rest_framework import DjangoFilterBackend @@ -68,6 +71,22 @@ def authorize_create(self, instance): ): raise PermissionDenied("You do not have permission to update encounter") + def perform_update(self, instance): + """ + Updates need to check if the encounter of the instance has been changed, If, so a copy object needs to be created. + """ + database_copy = AllergyIntolerance.objects.get(id=instance.id) + with transaction.atomic(): + if database_copy.encounter != instance.encounter: + database_copy.copied_from = database_copy.id + database_copy.id = None + database_copy.external_id = uuid.uuid4() + database_copy.save() + AllergyIntolerance.objects.filter( + encounter=instance.encounter, copied_from=instance.id + ).delete() + return super().perform_update(instance) + def authorize_update(self, request_obj, model_instance): encounter = get_object_or_404(Encounter, external_id=request_obj.encounter) if not AuthorizationController.call( @@ -81,11 +100,7 @@ def clean_update_data(self, request_data): return super().clean_update_data(request_data, keep_fields={"encounter"}) def get_queryset(self): - if not AuthorizationController.call( - "can_view_clinical_data", self.request.user, self.get_patient_obj() - ): - raise PermissionDenied("Permission denied for patient data") - return ( + queryset = ( super() .get_queryset() .filter(patient__external_id=self.kwargs["patient_external_id"]) @@ -93,5 +108,23 @@ def get_queryset(self): .order_by("-modified_date") ) + if not AuthorizationController.call( + "can_view_clinical_data", self.request.user, self.get_patient_obj() + ): + encounter = Encounter.objects.filter( + external_id=self.request.GET.get("encounter") + ).first() + + # Check for encounter access + if not encounter or not AuthorizationController.call( + "can_view_encounter_obj", self.request.user, encounter + ): + raise PermissionDenied("Permission denied to user") + queryset = queryset.filter(encounter=encounter) + + else: + queryset = queryset.filter(copied_from__isnull=True) + return queryset + InternalQuestionnaireRegistry.register(AllergyIntoleranceViewSet) diff --git a/care/emr/migrations/0016_allergyintolerance_copied_from.py b/care/emr/migrations/0016_allergyintolerance_copied_from.py new file mode 100644 index 0000000000..4d71e1c4e7 --- /dev/null +++ b/care/emr/migrations/0016_allergyintolerance_copied_from.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.4 on 2025-02-04 17:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('emr', '0015_facilitylocation_availability_status_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='allergyintolerance', + name='copied_from', + field=models.BigIntegerField(blank=True, default=None, null=True), + ), + ] diff --git a/care/emr/models/allergy_intolerance.py b/care/emr/models/allergy_intolerance.py index 8d661a3679..2f4a83d683 100644 --- a/care/emr/models/allergy_intolerance.py +++ b/care/emr/models/allergy_intolerance.py @@ -15,3 +15,6 @@ class AllergyIntolerance(EMRBaseModel): recorded_date = models.DateTimeField(null=True, blank=True) last_occurrence = models.DateTimeField(null=True, blank=True) note = models.TextField(null=True, blank=True) + copied_from = models.BigIntegerField( + default=None, null=True, blank=True + ) # If True, the record is a copy maintained of the given ID From 99bae4d6c6d736b83ba04fba4123abb60811b18a Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Wed, 5 Feb 2025 19:35:35 +0530 Subject: [PATCH 14/17] Add location filters --- care/emr/api/viewsets/location.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/care/emr/api/viewsets/location.py b/care/emr/api/viewsets/location.py index d82a2d9c87..80fab0ccb4 100644 --- a/care/emr/api/viewsets/location.py +++ b/care/emr/api/viewsets/location.py @@ -43,7 +43,10 @@ class FacilityLocationFilter(filters.FilterSet): parent = filters.UUIDFilter(field_name="parent__external_id") name = filters.CharFilter(field_name="name", lookup_expr="icontains") - + mode = filters.CharFilter(field_name="mode", lookup_expr="iexact") + availability_status = filters.CharFilter(field_name="availability_status", lookup_expr="iexact") + operational_status = filters.CharFilter(field_name="operational_status", lookup_expr="iexact") + status = filters.CharFilter(field_name="status", lookup_expr="iexact") class FacilityLocationViewSet(EMRModelViewSet): database_model = FacilityLocation From 29fede381d71b147c1440f1f0b0ae5c2c9d13734 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Wed, 5 Feb 2025 19:35:45 +0530 Subject: [PATCH 15/17] Add location filters --- care/emr/api/viewsets/location.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/care/emr/api/viewsets/location.py b/care/emr/api/viewsets/location.py index 80fab0ccb4..cdd440344b 100644 --- a/care/emr/api/viewsets/location.py +++ b/care/emr/api/viewsets/location.py @@ -43,10 +43,15 @@ class FacilityLocationFilter(filters.FilterSet): parent = filters.UUIDFilter(field_name="parent__external_id") name = filters.CharFilter(field_name="name", lookup_expr="icontains") - mode = filters.CharFilter(field_name="mode", lookup_expr="iexact") - availability_status = filters.CharFilter(field_name="availability_status", lookup_expr="iexact") - operational_status = filters.CharFilter(field_name="operational_status", lookup_expr="iexact") - status = filters.CharFilter(field_name="status", lookup_expr="iexact") + mode = filters.CharFilter(field_name="mode", lookup_expr="iexact") + availability_status = filters.CharFilter( + field_name="availability_status", lookup_expr="iexact" + ) + operational_status = filters.CharFilter( + field_name="operational_status", lookup_expr="iexact" + ) + status = filters.CharFilter(field_name="status", lookup_expr="iexact") + class FacilityLocationViewSet(EMRModelViewSet): database_model = FacilityLocation From 41877f10d4487bca3d16bd620c15db204da6fa9a Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Thu, 6 Feb 2025 15:56:06 +0530 Subject: [PATCH 16/17] Add location history to encounter --- care/emr/api/viewsets/user.py | 5 +++-- care/emr/resources/encounter/spec.py | 25 ++++++++++++++++++------- care/emr/resources/location/spec.py | 11 +++++++++++ 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/care/emr/api/viewsets/user.py b/care/emr/api/viewsets/user.py index 97d2f19a9f..ea086e41f3 100644 --- a/care/emr/api/viewsets/user.py +++ b/care/emr/api/viewsets/user.py @@ -72,8 +72,9 @@ def perform_create(self, instance): def authorize_update(self, request_obj, model_instance): if self.request.user.is_superuser: - return True - return self.request.user.id == model_instance.id + return + if not self.request.user.id == model_instance.id: + raise PermissionDenied("You do not have permission to update this user") def authorize_create(self, instance): if not AuthorizationController.call("can_create_user", self.request.user): diff --git a/care/emr/resources/encounter/spec.py b/care/emr/resources/encounter/spec.py index 48a31006c2..0a50aed23e 100644 --- a/care/emr/resources/encounter/spec.py +++ b/care/emr/resources/encounter/spec.py @@ -4,7 +4,12 @@ from django.utils import timezone from pydantic import UUID4, BaseModel, model_validator -from care.emr.models import Encounter, EncounterOrganization, TokenBooking +from care.emr.models import ( + Encounter, + EncounterOrganization, + FacilityLocationEncounter, + TokenBooking, +) from care.emr.models.patient import Patient from care.emr.resources.base import EMRResource from care.emr.resources.encounter.constants import ( @@ -17,10 +22,12 @@ ) from care.emr.resources.facility.spec import FacilityBareMinimumSpec from care.emr.resources.facility_organization.spec import FacilityOrganizationReadSpec -from care.emr.resources.location.spec import FacilityLocationListSpec +from care.emr.resources.location.spec import ( + FacilityLocationEncounterListSpec, + FacilityLocationListSpec, +) from care.emr.resources.patient.spec import PatientListSpec from care.emr.resources.scheduling.slot.spec import TokenBookingReadSpec -from care.emr.resources.user.spec import UserSpec from care.facility.models import Facility @@ -118,6 +125,7 @@ class EncounterRetrieveSpec(EncounterListSpec): updated_by: dict = {} organizations: list[dict] = [] current_location: dict | None = None + location_history: list[dict] = [] @classmethod def perform_extra_serialization(cls, mapping, obj): @@ -136,7 +144,10 @@ def perform_extra_serialization(cls, mapping, obj): mapping["current_location"] = FacilityLocationListSpec.serialize( obj.current_location ).to_json() - if obj.created_by: - mapping["created_by"] = UserSpec.serialize(obj.created_by) - if obj.updated_by: - mapping["updated_by"] = UserSpec.serialize(obj.updated_by) + mapping["location_history"] = [ + FacilityLocationEncounterListSpec.serialize(i) + for i in FacilityLocationEncounter.objects.filter(encounter=obj).order_by( + "-created_date" + ) + ] + cls.serialize_audit_users(mapping, obj) diff --git a/care/emr/resources/location/spec.py b/care/emr/resources/location/spec.py index f6f9e0b904..db2b7f85c3 100644 --- a/care/emr/resources/location/spec.py +++ b/care/emr/resources/location/spec.py @@ -161,6 +161,17 @@ class FacilityLocationEncounterUpdateSpec(FacilityLocationEncounterBaseSpec): end_datetime: datetime.datetime | None = None +class FacilityLocationEncounterListSpec(FacilityLocationEncounterBaseSpec): + encounter: UUID4 + start_datetime: datetime.datetime + end_datetime: datetime.datetime | None = None + status: str + + @classmethod + def perform_extra_serialization(cls, mapping, obj): + mapping["id"] = obj.external_id + + class FacilityLocationEncounterReadSpec(FacilityLocationEncounterBaseSpec): encounter: UUID4 start_datetime: datetime.datetime From 6b3482075479ab825487279fd4c317e16a3fb87e Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Fri, 7 Feb 2025 13:11:55 +0530 Subject: [PATCH 17/17] Added location in encounter location history --- care/emr/resources/encounter/spec.py | 4 ++-- care/emr/resources/location/spec.py | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/care/emr/resources/encounter/spec.py b/care/emr/resources/encounter/spec.py index 0a50aed23e..467a50a1ab 100644 --- a/care/emr/resources/encounter/spec.py +++ b/care/emr/resources/encounter/spec.py @@ -23,7 +23,7 @@ from care.emr.resources.facility.spec import FacilityBareMinimumSpec from care.emr.resources.facility_organization.spec import FacilityOrganizationReadSpec from care.emr.resources.location.spec import ( - FacilityLocationEncounterListSpec, + FacilityLocationEncounterListSpecWithLocation, FacilityLocationListSpec, ) from care.emr.resources.patient.spec import PatientListSpec @@ -145,7 +145,7 @@ def perform_extra_serialization(cls, mapping, obj): obj.current_location ).to_json() mapping["location_history"] = [ - FacilityLocationEncounterListSpec.serialize(i) + FacilityLocationEncounterListSpecWithLocation.serialize(i) for i in FacilityLocationEncounter.objects.filter(encounter=obj).order_by( "-created_date" ) diff --git a/care/emr/resources/location/spec.py b/care/emr/resources/location/spec.py index db2b7f85c3..870fc98d2b 100644 --- a/care/emr/resources/location/spec.py +++ b/care/emr/resources/location/spec.py @@ -172,6 +172,15 @@ def perform_extra_serialization(cls, mapping, obj): mapping["id"] = obj.external_id +class FacilityLocationEncounterListSpecWithLocation(FacilityLocationEncounterListSpec): + location: dict + + @classmethod + def perform_extra_serialization(cls, mapping, obj): + super().perform_extra_serialization(mapping, obj) + mapping["location"] = FacilityLocationListSpec.serialize(obj.location).to_json() + + class FacilityLocationEncounterReadSpec(FacilityLocationEncounterBaseSpec): encounter: UUID4 start_datetime: datetime.datetime