From b547baa5515c5627f240edf8694bf696273cbd22 Mon Sep 17 00:00:00 2001 From: Khavin Shankar Date: Wed, 22 Jan 2025 12:20:36 +0530 Subject: [PATCH 01/17] fix medication administration upsert api (#2769) --- care/emr/api/viewsets/medication_administration.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/care/emr/api/viewsets/medication_administration.py b/care/emr/api/viewsets/medication_administration.py index c9f08af9a3..6a7436460a 100644 --- a/care/emr/api/viewsets/medication_administration.py +++ b/care/emr/api/viewsets/medication_administration.py @@ -1,6 +1,6 @@ from django_filters import rest_framework as filters -from care.emr.api.viewsets.base import EMRModelViewSet +from care.emr.api.viewsets.base import EMRModelViewSet, EMRQuestionnaireResponseMixin from care.emr.api.viewsets.encounter_authz_base import EncounterBasedAuthorizationBase from care.emr.models.medication_administration import MedicationAdministration from care.emr.registries.system_questionnaire.system_questionnaire import ( @@ -20,7 +20,9 @@ class MedicationAdministrationFilter(filters.FilterSet): occurrence_period_end = filters.DateTimeFromToRangeFilter() -class MedicationAdministrationViewSet(EncounterBasedAuthorizationBase, EMRModelViewSet): +class MedicationAdministrationViewSet( + EncounterBasedAuthorizationBase, EMRQuestionnaireResponseMixin, EMRModelViewSet +): database_model = MedicationAdministration pydantic_model = MedicationAdministrationSpec pydantic_read_model = MedicationAdministrationReadSpec From ba647ba6125c9749faf00f2d0bf653b8ebed1f7c Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Wed, 22 Jan 2025 17:58:42 +0530 Subject: [PATCH 02/17] add now as default for authored datetime (#2770) --- care/emr/migrations/0009_medicationrequest_authored_on.py | 3 ++- care/emr/models/medication_request.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/care/emr/migrations/0009_medicationrequest_authored_on.py b/care/emr/migrations/0009_medicationrequest_authored_on.py index 88e8ab4d51..65aa78b4fd 100644 --- a/care/emr/migrations/0009_medicationrequest_authored_on.py +++ b/care/emr/migrations/0009_medicationrequest_authored_on.py @@ -1,5 +1,6 @@ # Generated by Django 5.1.4 on 2025-01-21 11:15 +import django.utils.timezone from django.db import migrations, models @@ -13,6 +14,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='medicationrequest', name='authored_on', - field=models.DateTimeField(blank=True, default=None, null=True), + field=models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True), ), ] diff --git a/care/emr/models/medication_request.py b/care/emr/models/medication_request.py index 9de05d8b02..04ed5fef77 100644 --- a/care/emr/models/medication_request.py +++ b/care/emr/models/medication_request.py @@ -1,4 +1,5 @@ from django.db import models +from django.utils import timezone from care.emr.models.base import EMRBaseModel @@ -16,4 +17,4 @@ class MedicationRequest(EMRBaseModel): encounter = models.ForeignKey("emr.Encounter", on_delete=models.CASCADE) dosage_instruction = models.JSONField(default=list, null=True, blank=True) note = models.TextField(null=True, blank=True) - authored_on = models.DateTimeField(null=True, blank=True, default=None) + authored_on = models.DateTimeField(null=True, blank=True, default=timezone.now) From 2f5982480b2dfd0addf42e5c8437fdbe441ec56e Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Wed, 22 Jan 2025 18:11:20 +0530 Subject: [PATCH 03/17] Add abatement and chronic_condition to conditions (#2771) --- .../migrations/0010_condition_abatement.py | 30 +++++++++++++++++++ care/emr/models/condition.py | 1 + care/emr/resources/condition/spec.py | 13 +++++++- 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 care/emr/migrations/0010_condition_abatement.py diff --git a/care/emr/migrations/0010_condition_abatement.py b/care/emr/migrations/0010_condition_abatement.py new file mode 100644 index 0000000000..bf8aa1e5fe --- /dev/null +++ b/care/emr/migrations/0010_condition_abatement.py @@ -0,0 +1,30 @@ +# Generated by Django 5.1.4 on 2025-01-22 12:35 + +from django.db import migrations, models + + +def fix_encounter_diagnosis_category_case(apps, schema_editor): + Condition = apps.get_model("emr", "Condition") + Condition.objects.filter(category="encounter-diagnosis").update(category="encounter_diagnosis") + +def revert_encounter_diagnosis_category_case(apps, schema_editor): + Condition = apps.get_model("emr", "Condition") + Condition.objects.filter(category="encounter_diagnosis").update(category="encounter-diagnosis") + +class Migration(migrations.Migration): + + dependencies = [ + ('emr', '0009_medicationrequest_authored_on'), + ] + + operations = [ + migrations.AddField( + model_name='condition', + name='abatement', + field=models.JSONField(default=dict), + ), + migrations.RunPython( + fix_encounter_diagnosis_category_case, + revert_encounter_diagnosis_category_case, + ) + ] diff --git a/care/emr/models/condition.py b/care/emr/models/condition.py index 89bf7c0c5e..700a2114d1 100644 --- a/care/emr/models/condition.py +++ b/care/emr/models/condition.py @@ -13,5 +13,6 @@ class Condition(EMRBaseModel): patient = models.ForeignKey("emr.Patient", on_delete=models.CASCADE) encounter = models.ForeignKey("emr.Encounter", on_delete=models.CASCADE) onset = models.JSONField(default=dict) + abatement = models.JSONField(default=dict) recorded_date = models.DateTimeField(null=True, blank=True) note = models.TextField(null=True, blank=True) diff --git a/care/emr/resources/condition/spec.py b/care/emr/resources/condition/spec.py index b06a573af5..baea0fe074 100644 --- a/care/emr/resources/condition/spec.py +++ b/care/emr/resources/condition/spec.py @@ -33,7 +33,8 @@ class VerificationStatusChoices(str, Enum): class CategoryChoices(str, Enum): problem_list_item = "problem_list_item" - encounter_diagnosis = "encounter-diagnosis" + encounter_diagnosis = "encounter_diagnosis" + chronic_condition = "chronic_condition" class SeverityChoices(str, Enum): @@ -49,6 +50,13 @@ class ConditionOnSetSpec(EMRResource): note: str | None = None +class ConditionAbatementSpec(EMRResource): + abatement_datetime: datetime.datetime | None = None + abatement_age: int | None = None + abatement_string: str | None = None + note: str | None = None + + class BaseConditionSpec(EMRResource): __model__ = Condition __exclude__ = ["patient", "encounter"] @@ -62,6 +70,7 @@ class ConditionSpec(BaseConditionSpec): code: Coding = Field(json_schema_extra={"slug": CARE_CODITION_CODE_VALUESET.slug}) encounter: UUID4 onset: ConditionOnSetSpec = {} + abatement: ConditionAbatementSpec = {} note: str | None = None @field_validator("code") @@ -102,6 +111,7 @@ class ConditionSpecRead(BaseConditionSpec): code: Coding encounter: UUID4 onset: ConditionOnSetSpec = dict + abatement: ConditionAbatementSpec = dict created_by: UserSpec = dict updated_by: UserSpec = dict note: str | None = None @@ -123,6 +133,7 @@ class ConditionSpecUpdate(BaseConditionSpec): severity: SeverityChoices | None = None code: Coding = Field(json_schema_extra={"slug": CARE_CODITION_CODE_VALUESET.slug}) onset: ConditionOnSetSpec = {} + abatement: ConditionAbatementSpec = {} note: str | None = None @field_validator("code") From 07f3f709ab9b6b1b6fabe285a527de75ad726f1a Mon Sep 17 00:00:00 2001 From: Prafful Date: Wed, 22 Jan 2025 18:36:28 +0530 Subject: [PATCH 04/17] added swaggar support --- care/emr/api/otp_viewsets/login.py | 10 +++++++ care/emr/api/otp_viewsets/patient.py | 3 ++ care/emr/api/otp_viewsets/slot.py | 13 ++++++++ care/emr/api/viewsets/allergy_intolerance.py | 2 ++ care/emr/api/viewsets/base.py | 30 +++++++++++++++++++ care/emr/api/viewsets/batch_request.py | 4 +++ care/emr/api/viewsets/condition.py | 3 +- care/emr/api/viewsets/encounter.py | 17 +++++++++-- care/emr/api/viewsets/facility.py | 12 ++++++++ .../emr/api/viewsets/facility_organization.py | 3 ++ care/emr/api/viewsets/file_upload.py | 9 ++++++ .../api/viewsets/medication_administration.py | 1 + care/emr/api/viewsets/medication_request.py | 1 + care/emr/api/viewsets/medication_statement.py | 1 + care/emr/api/viewsets/notes.py | 6 ++++ care/emr/api/viewsets/observation.py | 7 +++++ care/emr/api/viewsets/organization.py | 3 ++ care/emr/api/viewsets/patient.py | 12 ++++++++ care/emr/api/viewsets/questionnaire.py | 17 +++++++++++ .../api/viewsets/questionnaire_response.py | 3 ++ care/emr/api/viewsets/resource_request.py | 6 ++++ care/emr/api/viewsets/roles.py | 3 ++ care/emr/api/viewsets/user.py | 3 ++ care/emr/api/viewsets/valueset.py | 3 ++ 24 files changed, 169 insertions(+), 3 deletions(-) diff --git a/care/emr/api/otp_viewsets/login.py b/care/emr/api/otp_viewsets/login.py index 625a5318d3..b768fac8c1 100644 --- a/care/emr/api/otp_viewsets/login.py +++ b/care/emr/api/otp_viewsets/login.py @@ -4,6 +4,7 @@ from django.conf import settings from django.utils import timezone +from drf_spectacular.utils import extend_schema from pydantic import BaseModel, Field, field_validator from rest_framework.decorators import action from rest_framework.exceptions import ValidationError @@ -45,6 +46,9 @@ class OTPLoginView(EMRBaseViewSet): authentication_classes = [] permission_classes = [] + @extend_schema( + request=OTPLoginRequestSpec, + ) @action(detail=False, methods=["POST"]) def send(self, request): data = OTPLoginRequestSpec(**request.data) @@ -76,6 +80,9 @@ def send(self, request): otp_obj.save() return Response({"otp": "generated"}) + @extend_schema( + request=OTPLoginSpec, + ) @action(detail=False, methods=["POST"]) def login(self, request): data = OTPLoginSpec(**request.data) @@ -92,3 +99,6 @@ def login(self, request): token["phone_number"] = data.phone_number return Response({"access": str(token)}) + + +OTPLoginView.generate_swagger_schema() diff --git a/care/emr/api/otp_viewsets/patient.py b/care/emr/api/otp_viewsets/patient.py index f708b53af4..715cd7f2a2 100644 --- a/care/emr/api/otp_viewsets/patient.py +++ b/care/emr/api/otp_viewsets/patient.py @@ -22,3 +22,6 @@ def perform_create(self, instance): def get_queryset(self): return Patient.objects.filter(phone_number=self.request.user.phone_number) + + +PatientOTPView.generate_swagger_schema() diff --git a/care/emr/api/otp_viewsets/slot.py b/care/emr/api/otp_viewsets/slot.py index 903d3cc951..18e1a4dd3c 100644 --- a/care/emr/api/otp_viewsets/slot.py +++ b/care/emr/api/otp_viewsets/slot.py @@ -1,3 +1,4 @@ +from drf_spectacular.utils import extend_schema from pydantic import UUID4, BaseModel from rest_framework.decorators import action from rest_framework.exceptions import ValidationError @@ -39,6 +40,9 @@ class OTPSlotViewSet(EMRRetrieveMixin, EMRBaseViewSet): database_model = TokenSlot pydantic_read_model = TokenSlotBaseSpec + @extend_schema( + request=SlotsForDayRequestSpec, + ) @action(detail=False, methods=["POST"]) def get_slots_for_day(self, request, *args, **kwargs): request_data = SlotsForDayRequestSpec(**request.data) @@ -46,6 +50,9 @@ def get_slots_for_day(self, request, *args, **kwargs): request_data.facility, request.data ) + @extend_schema( + request=AppointmentBookingSpec, + ) @action(detail=True, methods=["POST"]) def create_appointment(self, request, *args, **kwargs): request_data = AppointmentBookingSpec(**request.data) @@ -57,6 +64,9 @@ def create_appointment(self, request, *args, **kwargs): self.get_object(), request.data, None ) + @extend_schema( + request=CancelAppointmentSpec, + ) @action(detail=False, methods=["POST"]) def cancel_appointment(self, request, *args, **kwargs): request_data = CancelAppointmentSpec(**request.data) @@ -85,3 +95,6 @@ def get_appointments(self, request, *args, **kwargs): ] } ) + + +OTPSlotViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/allergy_intolerance.py b/care/emr/api/viewsets/allergy_intolerance.py index faeb1ee54e..396baccda2 100644 --- a/care/emr/api/viewsets/allergy_intolerance.py +++ b/care/emr/api/viewsets/allergy_intolerance.py @@ -83,4 +83,6 @@ def get_queryset(self): ) +AllergyIntoleranceViewSet.generate_swagger_schema() + InternalQuestionnaireRegistry.register(AllergyIntoleranceViewSet) diff --git a/care/emr/api/viewsets/base.py b/care/emr/api/viewsets/base.py index b2db441bad..d705075770 100644 --- a/care/emr/api/viewsets/base.py +++ b/care/emr/api/viewsets/base.py @@ -2,6 +2,7 @@ from django.db import transaction from django.http.response import Http404 +from drf_spectacular.utils import extend_schema, extend_schema_view from pydantic import ValidationError from rest_framework.decorators import action from rest_framework.exceptions import ValidationError as RestFrameworkValidationError @@ -67,6 +68,9 @@ def retrieve(self, request, *args, **kwargs): class EMRCreateMixin: + pydantic_model = None # Define or override in subclasses + pydantic_read_model = None + def perform_create(self, instance): instance.created_by = self.request.user instance.updated_by = self.request.user @@ -94,6 +98,10 @@ def clean_create_data(self, request_data): def authorize_create(self, instance): pass + @extend_schema( + responses={200: pydantic_read_model}, + request=pydantic_model, + ) def create(self, request, *args, **kwargs): return Response(self.handle_create(request.data)) @@ -261,6 +269,28 @@ def fetch_encounter_from_instance(self, instance): def fetch_patient_from_instance(self, instance): return instance.patient + @classmethod + def generate_swagger_schema(cls): + """ + Dynamically extend the schema for child viewsets. + """ + return extend_schema_view( + list=extend_schema( + responses={200: cls.pydantic_read_model}, + ), + retrieve=extend_schema( + responses={200: cls.pydantic_read_model}, + ), + update=extend_schema( + request=cls.pydantic_update_model, + responses={200: cls.pydantic_read_model}, + ), + create=extend_schema( + request=cls.pydantic_model, + responses={200: cls.pydantic_read_model}, + ), + )(cls) + class EMRQuestionnaireResponseMixin: CREATE_QUESTIONNAIRE_RESPONSE = True diff --git a/care/emr/api/viewsets/batch_request.py b/care/emr/api/viewsets/batch_request.py index 5af72f38ca..77cebc3d72 100644 --- a/care/emr/api/viewsets/batch_request.py +++ b/care/emr/api/viewsets/batch_request.py @@ -1,4 +1,5 @@ from django.db import transaction +from drf_spectacular.utils import extend_schema from pydantic import BaseModel, Field from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet @@ -26,6 +27,9 @@ class BatchRequestView(GenericViewSet): def get_exception_handler(self): return emr_exception_handler + @extend_schema( + request=BatchRequest, + ) def create(self, request, *args, **kwargs): requests = BatchRequest(**request.data) errored = False diff --git a/care/emr/api/viewsets/condition.py b/care/emr/api/viewsets/condition.py index 52c2ce9a3f..057d700585 100644 --- a/care/emr/api/viewsets/condition.py +++ b/care/emr/api/viewsets/condition.py @@ -96,7 +96,6 @@ class DiagnosisViewSet( pydantic_model = ConditionSpec pydantic_read_model = ConditionSpecRead pydantic_update_model = ConditionSpecUpdate - # Filters filterset_class = ConditionFilters filter_backends = [DjangoFilterBackend] @@ -124,4 +123,6 @@ def get_queryset(self): ) +DiagnosisViewSet = DiagnosisViewSet.generate_swagger_schema() + InternalQuestionnaireRegistry.register(DiagnosisViewSet) diff --git a/care/emr/api/viewsets/encounter.py b/care/emr/api/viewsets/encounter.py index b8e36e5ee6..9f0186bf2e 100644 --- a/care/emr/api/viewsets/encounter.py +++ b/care/emr/api/viewsets/encounter.py @@ -175,6 +175,10 @@ def organizations(self, request, *args, **kwargs): class EncounterOrganizationManageSpec(BaseModel): organization: UUID4 + @extend_schema( + request=EncounterOrganizationManageSpec, + responses={200: FacilityOrganizationReadSpec}, + ) @action(detail=True, methods=["POST"]) def organizations_add(self, request, *args, **kwargs): instance = self.get_object() @@ -195,7 +199,10 @@ def organizations_add(self, request, *args, **kwargs): ) return Response(FacilityOrganizationReadSpec.serialize(organization).to_json()) - @action(detail=True, methods=["DELETE"]) + @extend_schema( + request=EncounterOrganizationManageSpec, + ) + @action(detail=True, methods=["POST"]) def organizations_remove(self, request, *args, **kwargs): instance = self.get_object() self.authorize_update({}, instance) @@ -213,7 +220,7 @@ def organizations_remove(self, request, *args, **kwargs): EncounterOrganization.objects.filter( encounter=instance, organization=organization ).delete() - return Response({}, status=204) + return Response({}) def _check_discharge_summary_access(self, encounter): if not AuthorizationController.call( @@ -284,6 +291,9 @@ def validate_email(cls, value): django_validate_email(value) return value + @extend_schema( + request=EmailDischargeSummarySpec, + ) @action(detail=True, methods=["POST"]) def email_discharge_summary(self, request, *args, **kwargs): encounter = self.get_object() @@ -325,6 +335,9 @@ def email_discharge_summary(self, request, *args, **kwargs): ) +EncounterViewSet.generate_swagger_schema() + + def dev_preview_discharge_summary(request, encounter_id): """ This is a dev only view to preview the discharge summary template diff --git a/care/emr/api/viewsets/facility.py b/care/emr/api/viewsets/facility.py index f416a3edf0..50c9595a00 100644 --- a/care/emr/api/viewsets/facility.py +++ b/care/emr/api/viewsets/facility.py @@ -131,6 +131,9 @@ def cover_image_delete(self, *args, **kwargs): return Response(status=204) +FacilityViewSet.generate_swagger_schema() + + class FacilitySchedulableUsersViewSet(EMRModelReadOnlyViewSet): database_model = User pydantic_read_model = UserSpec @@ -145,6 +148,9 @@ def get_queryset(self): ) +FacilitySchedulableUsersViewSet.generate_swagger_schema() + + class FacilityUserFilter(FilterSet): username = CharFilter(field_name="username", lookup_expr="icontains") @@ -163,6 +169,9 @@ def get_queryset(self): ) +FacilityUsersViewSet.generate_swagger_schema() + + class AllFacilityViewSet(EMRModelReadOnlyViewSet): permission_classes = () authentication_classes = () @@ -178,3 +187,6 @@ class AllFacilityViewSet(EMRModelReadOnlyViewSet): def get_queryset(self): return Facility.objects.filter(is_public=True).select_related() + + +AllFacilityViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/facility_organization.py b/care/emr/api/viewsets/facility_organization.py index f7ef7b7460..93fa5f4dd4 100644 --- a/care/emr/api/viewsets/facility_organization.py +++ b/care/emr/api/viewsets/facility_organization.py @@ -150,6 +150,9 @@ def mine(self, request, *args, **kwargs): return Response({"count": len(data), "results": data}) +FacilityOrganizationViewSet.generate_swagger_schema() + + class FacilityOrganizationUsersViewSet(EMRModelViewSet): database_model = FacilityOrganizationUser pydantic_model = FacilityOrganizationUserWriteSpec diff --git a/care/emr/api/viewsets/file_upload.py b/care/emr/api/viewsets/file_upload.py index 033d0ba98c..99cfbcb9f0 100644 --- a/care/emr/api/viewsets/file_upload.py +++ b/care/emr/api/viewsets/file_upload.py @@ -1,5 +1,6 @@ from django.utils import timezone from django_filters import rest_framework as filters +from drf_spectacular.utils import extend_schema from pydantic import BaseModel from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied @@ -110,6 +111,7 @@ def get_queryset(self): file_authorizer(self.request.user, obj.file_type, obj.associating_id, "read") return super().get_queryset() + @extend_schema(responses={200: FileUploadListSpec}) @action(detail=True, methods=["POST"]) def mark_upload_completed(self, request, *args, **kwargs): obj = self.get_object() @@ -121,6 +123,10 @@ def mark_upload_completed(self, request, *args, **kwargs): class ArchiveRequestSpec(BaseModel): archive_reason: str + @extend_schema( + request=ArchiveRequestSpec, + responses={200: FileUploadListSpec}, + ) @action(detail=True, methods=["POST"]) def archive(self, request, *args, **kwargs): obj = self.get_object() @@ -139,3 +145,6 @@ def archive(self, request, *args, **kwargs): ] ) return Response(FileUploadListSpec.serialize(obj).to_json()) + + +FileUploadViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/medication_administration.py b/care/emr/api/viewsets/medication_administration.py index 6a7436460a..929b0c88b9 100644 --- a/care/emr/api/viewsets/medication_administration.py +++ b/care/emr/api/viewsets/medication_administration.py @@ -43,4 +43,5 @@ def get_queryset(self): ) +MedicationAdministrationViewSet.generate_swagger_schema() InternalQuestionnaireRegistry.register(MedicationAdministrationViewSet) diff --git a/care/emr/api/viewsets/medication_request.py b/care/emr/api/viewsets/medication_request.py index 6786e8eeb3..01019bca39 100644 --- a/care/emr/api/viewsets/medication_request.py +++ b/care/emr/api/viewsets/medication_request.py @@ -42,4 +42,5 @@ def get_queryset(self): ) +MedicationRequestViewSet.generate_swagger_schema() InternalQuestionnaireRegistry.register(MedicationRequestViewSet) diff --git a/care/emr/api/viewsets/medication_statement.py b/care/emr/api/viewsets/medication_statement.py index 5e1258f06c..bc71589609 100644 --- a/care/emr/api/viewsets/medication_statement.py +++ b/care/emr/api/viewsets/medication_statement.py @@ -40,4 +40,5 @@ def get_queryset(self): ) +MedicationStatementViewSet.generate_swagger_schema() InternalQuestionnaireRegistry.register(MedicationStatementViewSet) diff --git a/care/emr/api/viewsets/notes.py b/care/emr/api/viewsets/notes.py index 58177c8cf0..3e52718e33 100644 --- a/care/emr/api/viewsets/notes.py +++ b/care/emr/api/viewsets/notes.py @@ -96,6 +96,9 @@ def get_queryset(self): return queryset.order_by("-created_date") +NoteThreadViewSet.generate_swagger_schema() + + class NoteMessageViewSet( EMRCreateMixin, EMRRetrieveMixin, EMRUpdateMixin, EMRListMixin, EMRBaseViewSet ): @@ -154,3 +157,6 @@ def get_queryset(self): .filter(thread__external_id=self.kwargs["thread_external_id"]) .order_by("-created_date") ) + + +NoteMessageViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/observation.py b/care/emr/api/viewsets/observation.py index f954a80e29..c5be65782c 100644 --- a/care/emr/api/viewsets/observation.py +++ b/care/emr/api/viewsets/observation.py @@ -1,4 +1,5 @@ from django_filters import rest_framework as filters +from drf_spectacular.utils import extend_schema from pydantic import BaseModel, Field from rest_framework.decorators import action from rest_framework.response import Response @@ -55,6 +56,9 @@ def get_queryset(self): return queryset.order_by("-modified_date") + @extend_schema( + request=ObservationAnalyseRequest, + ) @action(methods=["POST"], detail=False) def analyse(self, request, **kwargs): request_params = ObservationAnalyseRequest(**request.data) @@ -78,3 +82,6 @@ def analyse(self, request, **kwargs): } ) return Response({"results": results}) + + +ObservationViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/organization.py b/care/emr/api/viewsets/organization.py index 8665bfaefa..c4a70b731b 100644 --- a/care/emr/api/viewsets/organization.py +++ b/care/emr/api/viewsets/organization.py @@ -48,6 +48,9 @@ def get_queryset(self): return queryset +OrganizationPublicViewSet.generate_swagger_schema() + + class OrganizationViewSet(EMRModelViewSet): database_model = Organization pydantic_model = OrganizationWriteSpec diff --git a/care/emr/api/viewsets/patient.py b/care/emr/api/viewsets/patient.py index 1c3e2bb009..15fe121d4c 100644 --- a/care/emr/api/viewsets/patient.py +++ b/care/emr/api/viewsets/patient.py @@ -2,6 +2,7 @@ from django_filters import CharFilter, FilterSet from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema from pydantic import UUID4, BaseModel from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied, ValidationError @@ -81,6 +82,9 @@ class SearchRequestSpec(BaseModel): date_of_birth: datetime.date | None = None year_of_birth: int | None = None + @extend_schema( + request=SearchRequestSpec, + ) @action(detail=False, methods=["POST"]) def search(self, request, *args, **kwargs): max_page_size = 200 @@ -102,6 +106,9 @@ class SearchRetrieveRequestSpec(BaseModel): year_of_birth: int partial_id: str + @extend_schema( + request=SearchRetrieveRequestSpec, responses={200: PatientRetrieveSpec} + ) @action(detail=False, methods=["POST"]) def search_retrieve(self, request, *args, **kwargs): request_data = self.SearchRetrieveRequestSpec(**request.data) @@ -126,6 +133,7 @@ class PatientUserCreateSpec(BaseModel): user: UUID4 role: UUID4 + @extend_schema(request=PatientUserCreateSpec, responses={200: UserSpec}) @action(detail=True, methods=["POST"]) def add_user(self, request, *args, **kwargs): request_data = self.PatientUserCreateSpec(**self.request.data) @@ -141,6 +149,7 @@ def add_user(self, request, *args, **kwargs): class PatientUserDeleteSpec(BaseModel): user: UUID4 + @extend_schema(request=PatientUserDeleteSpec, responses={200: {}}) @action(detail=True, methods=["POST"]) def delete_user(self, request, *args, **kwargs): request_data = self.PatientUserDeleteSpec(**self.request.data) @@ -151,3 +160,6 @@ def delete_user(self, request, *args, **kwargs): raise ValidationError("User does not exist") PatientUser.objects.filter(user=user, patient=patient).delete() return Response({}) + + +PatientViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/questionnaire.py b/care/emr/api/viewsets/questionnaire.py index 6273d1b6f1..ed755cb3ca 100644 --- a/care/emr/api/viewsets/questionnaire.py +++ b/care/emr/api/viewsets/questionnaire.py @@ -1,6 +1,7 @@ from django.db import transaction from django.shortcuts import get_object_or_404 from django_filters import rest_framework as filters +from drf_spectacular.utils import extend_schema from pydantic import UUID4, BaseModel from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied, ValidationError @@ -53,6 +54,9 @@ def permissions_controller(self, request): return False +QuestionnaireTagsViewSet.generate_swagger_schema() + + class QuestionnaireTagSlugFilter(filters.CharFilter): def filter(self, qs, value): queryset = qs @@ -132,6 +136,10 @@ def get_queryset(self): ) return queryset.select_related("created_by", "updated_by") + @extend_schema( + request=QuestionnaireSubmitRequest, + responses=QuestionnaireResponseReadSpec, + ) @action(detail=True, methods=["POST"]) def submit(self, request, *args, **kwargs): request_params = QuestionnaireSubmitRequest(**request.data) @@ -178,6 +186,9 @@ def get_organizations(self, request, *args, **kwargs): class QuestionnaireTagsSetSchema(BaseModel): tags: list[str] + @extend_schema( + request=QuestionnaireTagsSetSchema, + ) @action(detail=True, methods=["POST"]) def set_tags(self, request, *args, **kwargs): questionnaire = self.get_object() @@ -196,6 +207,9 @@ def set_tags(self, request, *args, **kwargs): class QuestionnaireOrganizationUpdateSchema(BaseModel): organizations: list[UUID4] + @extend_schema( + request=QuestionnaireOrganizationUpdateSchema, + ) @action(detail=True, methods=["POST"]) def set_organizations(self, request, *args, **kwargs): """ @@ -233,3 +247,6 @@ def set_organizations(self, request, *args, **kwargs): "results": organizations_serialized, } ) + + +QuestionnaireViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/questionnaire_response.py b/care/emr/api/viewsets/questionnaire_response.py index 5189333cca..370e7bd5b0 100644 --- a/care/emr/api/viewsets/questionnaire_response.py +++ b/care/emr/api/viewsets/questionnaire_response.py @@ -65,3 +65,6 @@ def get_queryset(self): if "only_unstructured" in self.request.GET: queryset = queryset.filter(structured_response_type__isnull=True) return queryset + + +QuestionnaireResponseViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/resource_request.py b/care/emr/api/viewsets/resource_request.py index 6981f6da53..94f8a367d5 100644 --- a/care/emr/api/viewsets/resource_request.py +++ b/care/emr/api/viewsets/resource_request.py @@ -73,6 +73,9 @@ def get_queryset(self): return self.build_queryset(queryset, self.request.user) +ResourceRequestViewSet.generate_swagger_schema() + + class ResourceRequestCommentViewSet( EMRCreateMixin, EMRRetrieveMixin, EMRListMixin, EMRDestroyMixin, EMRBaseViewSet ): @@ -97,3 +100,6 @@ def get_queryset(self): return ResourceRequestComment.objects.filter( request=resource_request_obj ).select_related("created_by") + + +ResourceRequestCommentViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/roles.py b/care/emr/api/viewsets/roles.py index 01f6147d5f..410a52b4a7 100644 --- a/care/emr/api/viewsets/roles.py +++ b/care/emr/api/viewsets/roles.py @@ -8,3 +8,6 @@ class RoleViewSet(EMRModelReadOnlyViewSet): database_model = RoleModel pydantic_model = RoleSpec + + +RoleViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/user.py b/care/emr/api/viewsets/user.py index ad9e35851b..9df3967ef4 100644 --- a/care/emr/api/viewsets/user.py +++ b/care/emr/api/viewsets/user.py @@ -139,3 +139,6 @@ def pnconfig(self, request, *args, **kwargs): setattr(user, field, request.data[field]) user.save() return Response({}) + + +UserViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/valueset.py b/care/emr/api/viewsets/valueset.py index a8b67e6bec..d34d502665 100644 --- a/care/emr/api/viewsets/valueset.py +++ b/care/emr/api/viewsets/valueset.py @@ -66,3 +66,6 @@ def lookup_code(self, request, *args, **kwargs): .get() ) return Response(result) + + +ValueSetViewSet.generate_swagger_schema() From 837f0aecdd11a3f47561afeb53a1dfd61f47fc8f Mon Sep 17 00:00:00 2001 From: Prafful Date: Wed, 22 Jan 2025 19:26:52 +0530 Subject: [PATCH 05/17] added more swaggar support --- care/emr/api/viewsets/condition.py | 1 + care/emr/api/viewsets/facility_organization.py | 3 +++ care/emr/api/viewsets/organization.py | 6 ++++++ care/emr/api/viewsets/scheduling/availability_exceptions.py | 3 +++ care/emr/api/viewsets/scheduling/booking.py | 3 +++ care/emr/api/viewsets/scheduling/schedule.py | 6 ++++++ 6 files changed, 22 insertions(+) diff --git a/care/emr/api/viewsets/condition.py b/care/emr/api/viewsets/condition.py index 057d700585..484ede068f 100644 --- a/care/emr/api/viewsets/condition.py +++ b/care/emr/api/viewsets/condition.py @@ -83,6 +83,7 @@ def get_queryset(self): ) +SymptomViewSet.generate_swagger_schema() InternalQuestionnaireRegistry.register(SymptomViewSet) diff --git a/care/emr/api/viewsets/facility_organization.py b/care/emr/api/viewsets/facility_organization.py index 93fa5f4dd4..94afb1af04 100644 --- a/care/emr/api/viewsets/facility_organization.py +++ b/care/emr/api/viewsets/facility_organization.py @@ -252,3 +252,6 @@ def get_queryset(self): return FacilityOrganizationUser.objects.filter( organization=organization ).select_related("organization", "user", "role") + + +FacilityOrganizationUsersViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/organization.py b/care/emr/api/viewsets/organization.py index c4a70b731b..d3dd4aa7d0 100644 --- a/care/emr/api/viewsets/organization.py +++ b/care/emr/api/viewsets/organization.py @@ -201,6 +201,9 @@ def mine(self, request, *args, **kwargs): return Response({"count": len(data), "results": data}) +OrganizationViewSet.generate_swagger_schema() + + class OrganizationUserFilter(filters.FilterSet): username = filters.CharFilter(field_name="user__username", lookup_expr="icontains") @@ -295,3 +298,6 @@ def get_queryset(self): "User does not have the required permissions to list users" ) return OrganizationUser.objects.filter(organization=organization) + + +OrganizationUsersViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/scheduling/availability_exceptions.py b/care/emr/api/viewsets/scheduling/availability_exceptions.py index b3bebb73ef..e93dc4eeb1 100644 --- a/care/emr/api/viewsets/scheduling/availability_exceptions.py +++ b/care/emr/api/viewsets/scheduling/availability_exceptions.py @@ -71,3 +71,6 @@ def get_queryset(self): .select_related("resource", "created_by", "updated_by") .order_by("-modified_date") ) + + +AvailabilityExceptionsViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/scheduling/booking.py b/care/emr/api/viewsets/scheduling/booking.py index 0257b95e2a..e7a45b3373 100644 --- a/care/emr/api/viewsets/scheduling/booking.py +++ b/care/emr/api/viewsets/scheduling/booking.py @@ -172,3 +172,6 @@ def available_users(self, request, *args, **kwargs): ] } ) + + +TokenBookingViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/scheduling/schedule.py b/care/emr/api/viewsets/scheduling/schedule.py index af5300cd0a..21793b0093 100644 --- a/care/emr/api/viewsets/scheduling/schedule.py +++ b/care/emr/api/viewsets/scheduling/schedule.py @@ -129,6 +129,9 @@ def get_queryset(self): ) +ScheduleViewSet.generate_swagger_schema() + + class AvailabilityViewSet(EMRCreateMixin, EMRDestroyMixin, EMRBaseViewSet): database_model = Availability pydantic_model = AvailabilityForScheduleSpec @@ -191,3 +194,6 @@ def authorize_create(self, instance): def authorize_destroy(self, instance): self.authorize_create(instance) + + +AvailabilityViewSet.generate_swagger_schema() From 853427f10d2a01cab59049a2b777a824b5e681f7 Mon Sep 17 00:00:00 2001 From: Prafful Date: Wed, 22 Jan 2025 19:29:39 +0530 Subject: [PATCH 06/17] fixed create mixin change --- care/emr/api/viewsets/base.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/care/emr/api/viewsets/base.py b/care/emr/api/viewsets/base.py index d705075770..9e51a7bd80 100644 --- a/care/emr/api/viewsets/base.py +++ b/care/emr/api/viewsets/base.py @@ -68,9 +68,6 @@ def retrieve(self, request, *args, **kwargs): class EMRCreateMixin: - pydantic_model = None # Define or override in subclasses - pydantic_read_model = None - def perform_create(self, instance): instance.created_by = self.request.user instance.updated_by = self.request.user @@ -98,10 +95,6 @@ def clean_create_data(self, request_data): def authorize_create(self, instance): pass - @extend_schema( - responses={200: pydantic_read_model}, - request=pydantic_model, - ) def create(self, request, *args, **kwargs): return Response(self.handle_create(request.data)) From 58a9987923bafb22daefb1be9c8c4b285f9f49a6 Mon Sep 17 00:00:00 2001 From: Prafful Date: Wed, 22 Jan 2025 19:39:56 +0530 Subject: [PATCH 07/17] reverted status code change --- care/emr/api/viewsets/encounter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/care/emr/api/viewsets/encounter.py b/care/emr/api/viewsets/encounter.py index 9f0186bf2e..88dcd06096 100644 --- a/care/emr/api/viewsets/encounter.py +++ b/care/emr/api/viewsets/encounter.py @@ -220,7 +220,7 @@ def organizations_remove(self, request, *args, **kwargs): EncounterOrganization.objects.filter( encounter=instance, organization=organization ).delete() - return Response({}) + return Response({}, status=status.HTTP_204_NO_CONTENT) def _check_discharge_summary_access(self, encounter): if not AuthorizationController.call( From 71e238976e10f3204dcf8d46d56e3f85913b2569 Mon Sep 17 00:00:00 2001 From: Prafful Sharma <115104695+DraKen0009@users.noreply.github.com> Date: Thu, 23 Jan 2025 15:46:43 +0530 Subject: [PATCH 08/17] using isoparse for less strict validations on date (#2774) --- care/emr/resources/questionnaire/utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/care/emr/resources/questionnaire/utils.py b/care/emr/resources/questionnaire/utils.py index 73f6e81dc0..8fb69c1beb 100644 --- a/care/emr/resources/questionnaire/utils.py +++ b/care/emr/resources/questionnaire/utils.py @@ -2,6 +2,7 @@ from datetime import datetime from urllib.parse import urlparse +from dateutil.parser import isoparse from django.utils import timezone from rest_framework.exceptions import ValidationError @@ -63,9 +64,9 @@ def validate_data(values, value_type, questionnaire_ref): # noqa PLR0912 if value.value.lower() not in ["true", "false", "1", "0"]: errors.append(f"Invalid boolean value: {value.value}") elif value_type == QuestionType.date.value: - datetime.strptime(value.value, "%Y-%m-%d").date() # noqa DTZ007 + isoparse(value.value).date() elif value_type == QuestionType.datetime.value: - datetime.strptime(value.value, "%Y-%m-%dT%H:%M:%S") # noqa DTZ007 + isoparse(value.value) elif value_type == QuestionType.time.value: datetime.strptime(value.value, "%H:%M:%S") # noqa DTZ007 elif value_type == QuestionType.choice.value: From 2f9fa831ff52d72e81d8fa5bba361ba17287a6ef Mon Sep 17 00:00:00 2001 From: Rithvik Nishad Date: Thu, 23 Jan 2025 11:29:11 +0000 Subject: [PATCH 09/17] Add `requester` to `MedicationRequest` and basic authzn. tests (#2773) Add `requester` to `MedicationRequest` and basic authzn. tests (#2773) --- care/emr/api/viewsets/medication_request.py | 17 ++ .../0011_medicationrequest_requester.py | 21 ++ care/emr/models/medication_request.py | 3 + care/emr/resources/medication/request/spec.py | 15 +- care/emr/tests/test_medication_request.py | 230 ++++++++++++++++++ 5 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 care/emr/migrations/0011_medicationrequest_requester.py create mode 100644 care/emr/tests/test_medication_request.py diff --git a/care/emr/api/viewsets/medication_request.py b/care/emr/api/viewsets/medication_request.py index 6786e8eeb3..f96c88894b 100644 --- a/care/emr/api/viewsets/medication_request.py +++ b/care/emr/api/viewsets/medication_request.py @@ -1,7 +1,10 @@ from django_filters import rest_framework as filters +from rest_framework.exceptions import PermissionDenied +from rest_framework.generics import get_object_or_404 from care.emr.api.viewsets.base import EMRModelViewSet, EMRQuestionnaireResponseMixin from care.emr.api.viewsets.encounter_authz_base import EncounterBasedAuthorizationBase +from care.emr.models.encounter import Encounter from care.emr.models.medication_request import MedicationRequest from care.emr.registries.system_questionnaire.system_questionnaire import ( InternalQuestionnaireRegistry, @@ -12,6 +15,8 @@ MedicationRequestUpdateSpec, ) from care.emr.resources.questionnaire.spec import SubjectType +from care.security.authorization import AuthorizationController +from care.users.models import User class MedicationRequestFilter(filters.FilterSet): @@ -41,5 +46,17 @@ def get_queryset(self): .select_related("patient", "encounter", "created_by", "updated_by") ) + def authorize_create(self, instance): + super().authorize_create(instance) + if instance.requester: + encounter = get_object_or_404(Encounter, external_id=instance.encounter) + requester = get_object_or_404(User, external_id=instance.requester) + if not AuthorizationController.call( + "can_update_encounter_obj", requester, encounter + ): + raise PermissionDenied( + "Requester does not have permission to update encounter" + ) + InternalQuestionnaireRegistry.register(MedicationRequestViewSet) diff --git a/care/emr/migrations/0011_medicationrequest_requester.py b/care/emr/migrations/0011_medicationrequest_requester.py new file mode 100644 index 0000000000..d18a83330f --- /dev/null +++ b/care/emr/migrations/0011_medicationrequest_requester.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.4 on 2025-01-23 05:04 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('emr', '0010_condition_abatement'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='medicationrequest', + name='requester', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/care/emr/models/medication_request.py b/care/emr/models/medication_request.py index 04ed5fef77..56f67e1ca2 100644 --- a/care/emr/models/medication_request.py +++ b/care/emr/models/medication_request.py @@ -18,3 +18,6 @@ class MedicationRequest(EMRBaseModel): dosage_instruction = models.JSONField(default=list, null=True, blank=True) note = models.TextField(null=True, blank=True) authored_on = models.DateTimeField(null=True, blank=True, default=timezone.now) + requester = models.ForeignKey( + "users.User", on_delete=models.SET_NULL, null=True, blank=True + ) diff --git a/care/emr/resources/medication/request/spec.py b/care/emr/resources/medication/request/spec.py index bc08d30aa6..431ad73f1c 100644 --- a/care/emr/resources/medication/request/spec.py +++ b/care/emr/resources/medication/request/spec.py @@ -1,6 +1,7 @@ from datetime import datetime from enum import Enum +from django.shortcuts import get_object_or_404 from pydantic import UUID4, BaseModel, Field, field_validator from care.emr.models.encounter import Encounter @@ -21,6 +22,7 @@ from care.emr.resources.medication.valueset.medication import CARE_MEDICATION_VALUESET from care.emr.resources.medication.valueset.route import CARE_ROUTE_VALUESET from care.emr.resources.user.spec import UserSpec +from care.users.models import User class MedicationRequestStatus(str, Enum): @@ -197,7 +199,7 @@ def validate_method(cls, code): class MedicationRequestResource(EMRResource): __model__ = MedicationRequest - __exclude__ = ["patient", "encounter"] + __exclude__ = ["patient", "encounter", "requester"] class BaseMedicationRequestSpec(MedicationRequestResource): @@ -227,6 +229,8 @@ class BaseMedicationRequestSpec(MedicationRequestResource): class MedicationRequestSpec(BaseMedicationRequestSpec): + requester: UUID4 | None = None + @field_validator("encounter") @classmethod def validate_encounter_exists(cls, encounter): @@ -245,11 +249,10 @@ def validate_medication(cls, code): ) def perform_extra_deserialization(self, is_update, obj): - if not is_update: - obj.encounter = Encounter.objects.get( - external_id=self.encounter - ) # Needs more validation - obj.patient = obj.encounter.patient + obj.encounter = Encounter.objects.get(external_id=self.encounter) + obj.patient = obj.encounter.patient + if self.requester: + obj.requester = get_object_or_404(User, external_id=self.requester) class MedicationRequestUpdateSpec(MedicationRequestResource): diff --git a/care/emr/tests/test_medication_request.py b/care/emr/tests/test_medication_request.py new file mode 100644 index 0000000000..0c77a16dc7 --- /dev/null +++ b/care/emr/tests/test_medication_request.py @@ -0,0 +1,230 @@ +from datetime import UTC, datetime +from unittest.mock import patch + +from django.urls import reverse +from model_bakery import baker + +from care.security.permissions.encounter import EncounterPermissions +from care.security.permissions.patient import PatientPermissions +from care.utils.tests.base import CareAPITestBase + + +class TestMedicationRequestApi(CareAPITestBase): + def setUp(self): + super().setUp() + self.user = self.create_user() + self.facility = self.create_facility(user=self.user) + self.organization = self.create_facility_organization(facility=self.facility) + self.patient = self.create_patient() + self.encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + self.client.force_authenticate(user=self.user) + + self.base_url = reverse( + "medication-request-list", + kwargs={"patient_external_id": self.patient.external_id}, + ) + self.valid_code = { + "display": "Test Value", + "system": "http://test_system.care/test", + "code": "123", + } + # Mocking validate_valueset + self.patcher = patch( + "care.emr.resources.medication.request.spec.validate_valueset", + return_value=self.valid_code, + ) + self.mock_validate_valueset = self.patcher.start() + + def tearDown(self): + self.patcher.stop() + + def _get_medication_request_url(self, medication_request_id): + """Helper to get the detail URL for a specific medication request.""" + return reverse( + "medication-request-detail", + kwargs={ + "patient_external_id": self.patient.external_id, + "external_id": medication_request_id, + }, + ) + + def create_medication_request(self, **kwargs): + data = { + "patient": self.patient, + "encounter": self.encounter, + "status": "active", + "intent": "order", + "category": "inpatient", + "priority": "routine", + "do_not_perform": False, + "medication": self.valid_code, + "dosage_instruction": [], + "authored_on": datetime.now(UTC), + } + data.update(kwargs) + return baker.make("emr.MedicationRequest", **data) + + def get_medication_request_data(self, **kwargs): + data = { + "status": "active", + "intent": "order", + "category": "inpatient", + "priority": "routine", + "do_not_perform": False, + "medication": self.valid_code, + "dosage_instruction": [], + "authored_on": datetime.now(UTC), + "encounter": self.encounter.external_id, + } + data.update(kwargs) + return data + + def test_list_medication_request_with_permissions(self): + """ + Users with `can_view_clinical_data` on a non-completed encounter + can list medication requests (HTTP 200). + """ + # Attach the needed role/permission + permissions = [PatientPermissions.can_view_clinical_data.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, 200) + + def test_list_medication_request_without_permissions(self): + """ + Users without `can_view_clinical_data` => (HTTP 403). + """ + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, 403) + + def test_create_medication_request_with_permission(self): + """ + Users with `can_write_encounter_obj` permission can create medication requests (HTTP 200). + """ + permissions = [ + PatientPermissions.can_view_clinical_data.name, + EncounterPermissions.can_write_encounter.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + data = self.get_medication_request_data() + response = self.client.post(self.base_url, data, format="json") + self.assertEqual(response.status_code, 200) + + def test_create_medication_request_with_permission_for_requester(self): + """ + Users with `can_write_encounter_obj` permission can create medication requests as long as requester has the same permissions (HTTP 200). + """ + requester = self.create_user() + + permissions = [ + PatientPermissions.can_view_clinical_data.name, + EncounterPermissions.can_write_encounter.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + self.attach_role_facility_organization_user(self.organization, requester, role) + + data = self.get_medication_request_data(requester=requester.external_id) + response = self.client.post(self.base_url, data, format="json") + self.assertEqual(response.status_code, 200) + + def test_create_medication_request_without_permission_for_requester(self): + """ + Requester without `can_write_encounter_obj` permission cannot create medication requests (HTTP 200). + """ + permissions = [ + PatientPermissions.can_view_clinical_data.name, + EncounterPermissions.can_write_encounter.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + requester = self.create_user() + requester_role = self.create_role_with_permissions([]) + self.attach_role_facility_organization_user( + self.organization, requester, requester_role + ) + + data = self.get_medication_request_data(requester=requester.external_id) + response = self.client.post(self.base_url, data, format="json") + self.assertContains( + response, + "Requester does not have permission to update encounter", + status_code=403, + ) + + def test_create_medication_request_without_permission(self): + """ + Users without `can_write_encounter_obj` permission => (HTTP 403). + """ + data = self.get_medication_request_data() + response = self.client.post(self.base_url, data, format="json") + self.assertEqual(response.status_code, 403) + + def test_update_medication_request_with_permission(self): + """ + Users with `can_write_encounter_obj` and `can_view_clinical_data` permission can update medication requests (HTTP 200). + """ + permissions = [ + PatientPermissions.can_view_clinical_data.name, + EncounterPermissions.can_write_encounter.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + obj = self.create_medication_request() + url = self._get_medication_request_url(obj.external_id) + data = self.get_medication_request_data() + response = self.client.put(url, data, format="json") + self.assertEqual(response.status_code, 200) + + def test_update_medication_request_without_permission(self): + """ + Users without `can_write_encounter_obj` => HTTP 403 + """ + permissions = [ + PatientPermissions.can_view_clinical_data.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + obj = self.create_medication_request() + url = self._get_medication_request_url(obj.external_id) + data = self.get_medication_request_data() + response = self.client.put(url, data, format="json") + self.assertEqual(response.status_code, 403) + + def test_update_medication_request_requester(self): + """ + Requester cannot be updated. + """ + requester_initial, requester_updated = self.create_user(), self.create_user() + + permissions = [ + PatientPermissions.can_view_clinical_data.name, + EncounterPermissions.can_write_encounter.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + self.attach_role_facility_organization_user( + self.organization, requester_initial, role + ) + self.attach_role_facility_organization_user( + self.organization, requester_updated, role + ) + + obj = self.create_medication_request(requester=requester_initial) + url = self._get_medication_request_url(obj.external_id) + data = self.get_medication_request_data(requester=requester_updated.external_id) + self.client.put(url, data, format="json") + + obj.refresh_from_db() + self.assertEqual(obj.requester, requester_initial) From aff7fdb3ecb34a455e9dabe054374a1e1716f239 Mon Sep 17 00:00:00 2001 From: Prafful Date: Fri, 24 Jan 2025 15:02:54 +0530 Subject: [PATCH 10/17] created a decorator for schema generation --- care/emr/api/otp_viewsets/login.py | 5 ++-- care/emr/api/otp_viewsets/patient.py | 5 ++-- care/emr/api/otp_viewsets/slot.py | 5 ++-- care/emr/api/viewsets/allergy_intolerance.py | 4 ++-- care/emr/api/viewsets/base.py | 23 ------------------ care/emr/api/viewsets/condition.py | 6 ++--- care/emr/api/viewsets/encounter.py | 7 +++--- care/emr/api/viewsets/facility.py | 17 ++++--------- .../emr/api/viewsets/facility_organization.py | 9 +++---- care/emr/api/viewsets/file_upload.py | 5 ++-- .../api/viewsets/medication_administration.py | 3 ++- care/emr/api/viewsets/medication_request.py | 3 ++- care/emr/api/viewsets/medication_statement.py | 3 ++- care/emr/api/viewsets/notes.py | 9 +++---- care/emr/api/viewsets/observation.py | 5 ++-- care/emr/api/viewsets/organization.py | 13 ++++------ care/emr/api/viewsets/patient.py | 5 ++-- care/emr/api/viewsets/questionnaire.py | 9 +++---- .../api/viewsets/questionnaire_response.py | 5 ++-- care/emr/api/viewsets/resource_request.py | 9 +++---- care/emr/api/viewsets/roles.py | 5 ++-- .../api/viewsets/scheduling/availability.py | 2 ++ .../scheduling/availability_exceptions.py | 5 ++-- care/emr/api/viewsets/scheduling/booking.py | 5 ++-- care/emr/api/viewsets/scheduling/schedule.py | 9 +++---- care/emr/api/viewsets/user.py | 5 ++-- care/emr/api/viewsets/valueset.py | 5 ++-- care/utils/decorators/__init__.py | 0 care/utils/decorators/schema_decorator.py | 24 +++++++++++++++++++ config/settings/base.py | 1 + 30 files changed, 89 insertions(+), 122 deletions(-) create mode 100644 care/utils/decorators/__init__.py create mode 100644 care/utils/decorators/schema_decorator.py diff --git a/care/emr/api/otp_viewsets/login.py b/care/emr/api/otp_viewsets/login.py index b768fac8c1..291601a235 100644 --- a/care/emr/api/otp_viewsets/login.py +++ b/care/emr/api/otp_viewsets/login.py @@ -12,6 +12,7 @@ from care.emr.api.viewsets.base import EMRBaseViewSet from care.facility.models.patient import PatientMobileOTP +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator from care.utils.models.validators import mobile_validator from care.utils.sms.send_sms import send_sms from config.patient_otp_token import PatientToken @@ -42,6 +43,7 @@ class OTPLoginSpec(OTPLoginRequestSpec): otp: str = Field(min_length=settings.OTP_LENGTH, max_length=settings.OTP_LENGTH) +@generate_swagger_schema_decorator class OTPLoginView(EMRBaseViewSet): authentication_classes = [] permission_classes = [] @@ -99,6 +101,3 @@ def login(self, request): token["phone_number"] = data.phone_number return Response({"access": str(token)}) - - -OTPLoginView.generate_swagger_schema() diff --git a/care/emr/api/otp_viewsets/patient.py b/care/emr/api/otp_viewsets/patient.py index 715cd7f2a2..922e0d1230 100644 --- a/care/emr/api/otp_viewsets/patient.py +++ b/care/emr/api/otp_viewsets/patient.py @@ -4,12 +4,14 @@ PatientOTPReadSpec, PatientOTPWriteSpec, ) +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator from config.patient_otp_authentication import ( JWTTokenPatientAuthentication, OTPAuthenticatedPermission, ) +@generate_swagger_schema_decorator class PatientOTPView(EMRCreateMixin, EMRListMixin, EMRBaseViewSet): authentication_classes = [JWTTokenPatientAuthentication] permission_classes = [OTPAuthenticatedPermission] @@ -22,6 +24,3 @@ def perform_create(self, instance): def get_queryset(self): return Patient.objects.filter(phone_number=self.request.user.phone_number) - - -PatientOTPView.generate_swagger_schema() diff --git a/care/emr/api/otp_viewsets/slot.py b/care/emr/api/otp_viewsets/slot.py index 18e1a4dd3c..6b98b3ca2b 100644 --- a/care/emr/api/otp_viewsets/slot.py +++ b/care/emr/api/otp_viewsets/slot.py @@ -19,6 +19,7 @@ TokenBookingReadSpec, TokenSlotBaseSpec, ) +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator from config.patient_otp_authentication import ( JWTTokenPatientAuthentication, OTPAuthenticatedPermission, @@ -34,6 +35,7 @@ class CancelAppointmentSpec(BaseModel): appointment: UUID4 +@generate_swagger_schema_decorator class OTPSlotViewSet(EMRRetrieveMixin, EMRBaseViewSet): authentication_classes = [JWTTokenPatientAuthentication] permission_classes = [OTPAuthenticatedPermission] @@ -95,6 +97,3 @@ def get_appointments(self, request, *args, **kwargs): ] } ) - - -OTPSlotViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/allergy_intolerance.py b/care/emr/api/viewsets/allergy_intolerance.py index 396baccda2..62f7084d33 100644 --- a/care/emr/api/viewsets/allergy_intolerance.py +++ b/care/emr/api/viewsets/allergy_intolerance.py @@ -25,12 +25,14 @@ ) from care.emr.resources.questionnaire.spec import SubjectType from care.security.authorization import AuthorizationController +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator class AllergyIntoleranceFilters(FilterSet): clinical_status = CharFilter(field_name="clinical_status") +@generate_swagger_schema_decorator @extend_schema_view( create=extend_schema(request=AllergyIntoleranceSpec), ) @@ -83,6 +85,4 @@ def get_queryset(self): ) -AllergyIntoleranceViewSet.generate_swagger_schema() - InternalQuestionnaireRegistry.register(AllergyIntoleranceViewSet) diff --git a/care/emr/api/viewsets/base.py b/care/emr/api/viewsets/base.py index 9e51a7bd80..b2db441bad 100644 --- a/care/emr/api/viewsets/base.py +++ b/care/emr/api/viewsets/base.py @@ -2,7 +2,6 @@ from django.db import transaction from django.http.response import Http404 -from drf_spectacular.utils import extend_schema, extend_schema_view from pydantic import ValidationError from rest_framework.decorators import action from rest_framework.exceptions import ValidationError as RestFrameworkValidationError @@ -262,28 +261,6 @@ def fetch_encounter_from_instance(self, instance): def fetch_patient_from_instance(self, instance): return instance.patient - @classmethod - def generate_swagger_schema(cls): - """ - Dynamically extend the schema for child viewsets. - """ - return extend_schema_view( - list=extend_schema( - responses={200: cls.pydantic_read_model}, - ), - retrieve=extend_schema( - responses={200: cls.pydantic_read_model}, - ), - update=extend_schema( - request=cls.pydantic_update_model, - responses={200: cls.pydantic_read_model}, - ), - create=extend_schema( - request=cls.pydantic_model, - responses={200: cls.pydantic_read_model}, - ), - )(cls) - class EMRQuestionnaireResponseMixin: CREATE_QUESTIONNAIRE_RESPONSE = True diff --git a/care/emr/api/viewsets/condition.py b/care/emr/api/viewsets/condition.py index 484ede068f..de362b917e 100644 --- a/care/emr/api/viewsets/condition.py +++ b/care/emr/api/viewsets/condition.py @@ -17,6 +17,7 @@ ConditionSpecUpdate, ) from care.emr.resources.questionnaire.spec import SubjectType +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator class ValidateEncounterMixin: @@ -46,6 +47,7 @@ class ConditionFilters(FilterSet): severity = CharFilter(field_name="severity", lookup_expr="iexact") +@generate_swagger_schema_decorator class SymptomViewSet( ValidateEncounterMixin, EncounterBasedAuthorizationBase, @@ -83,10 +85,10 @@ def get_queryset(self): ) -SymptomViewSet.generate_swagger_schema() InternalQuestionnaireRegistry.register(SymptomViewSet) +@generate_swagger_schema_decorator class DiagnosisViewSet( ValidateEncounterMixin, EncounterBasedAuthorizationBase, @@ -124,6 +126,4 @@ def get_queryset(self): ) -DiagnosisViewSet = DiagnosisViewSet.generate_swagger_schema() - InternalQuestionnaireRegistry.register(DiagnosisViewSet) diff --git a/care/emr/api/viewsets/encounter.py b/care/emr/api/viewsets/encounter.py index 88dcd06096..84400490e2 100644 --- a/care/emr/api/viewsets/encounter.py +++ b/care/emr/api/viewsets/encounter.py @@ -47,6 +47,7 @@ ) from care.facility.models import Facility from care.security.authorization import AuthorizationController +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator class LiveFilter(filters.CharFilter): @@ -78,6 +79,7 @@ class EncounterFilters(filters.FilterSet): live = LiveFilter() +@generate_swagger_schema_decorator class EncounterViewSet( EMRCreateMixin, EMRRetrieveMixin, EMRUpdateMixin, EMRListMixin, EMRBaseViewSet ): @@ -202,7 +204,7 @@ def organizations_add(self, request, *args, **kwargs): @extend_schema( request=EncounterOrganizationManageSpec, ) - @action(detail=True, methods=["POST"]) + @action(detail=True, methods=["DELETE"]) def organizations_remove(self, request, *args, **kwargs): instance = self.get_object() self.authorize_update({}, instance) @@ -335,9 +337,6 @@ def email_discharge_summary(self, request, *args, **kwargs): ) -EncounterViewSet.generate_swagger_schema() - - def dev_preview_discharge_summary(request, encounter_id): """ This is a dev only view to preview the discharge summary template diff --git a/care/emr/api/viewsets/facility.py b/care/emr/api/viewsets/facility.py index 50c9595a00..1f2a3c5d0c 100644 --- a/care/emr/api/viewsets/facility.py +++ b/care/emr/api/viewsets/facility.py @@ -23,6 +23,7 @@ from care.facility.models import Facility from care.security.authorization import AuthorizationController from care.users.models import User +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator from care.utils.file_uploads.cover_image import delete_cover_image, upload_cover_image from care.utils.models.validators import ( cover_image_validator, @@ -71,6 +72,7 @@ class FacilityFilters(filters.FilterSet): phone_number = CharFilter(field_name="phone_number", lookup_expr="iexact") +@generate_swagger_schema_decorator class FacilityViewSet(EMRModelViewSet): database_model = Facility pydantic_model = FacilityCreateSpec @@ -131,9 +133,7 @@ def cover_image_delete(self, *args, **kwargs): return Response(status=204) -FacilityViewSet.generate_swagger_schema() - - +@generate_swagger_schema_decorator class FacilitySchedulableUsersViewSet(EMRModelReadOnlyViewSet): database_model = User pydantic_read_model = UserSpec @@ -148,13 +148,11 @@ def get_queryset(self): ) -FacilitySchedulableUsersViewSet.generate_swagger_schema() - - class FacilityUserFilter(FilterSet): username = CharFilter(field_name="username", lookup_expr="icontains") +@generate_swagger_schema_decorator class FacilityUsersViewSet(EMRModelReadOnlyViewSet): database_model = User pydantic_read_model = UserSpec @@ -169,9 +167,7 @@ def get_queryset(self): ) -FacilityUsersViewSet.generate_swagger_schema() - - +@generate_swagger_schema_decorator class AllFacilityViewSet(EMRModelReadOnlyViewSet): permission_classes = () authentication_classes = () @@ -187,6 +183,3 @@ class AllFacilityViewSet(EMRModelReadOnlyViewSet): def get_queryset(self): return Facility.objects.filter(is_public=True).select_related() - - -AllFacilityViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/facility_organization.py b/care/emr/api/viewsets/facility_organization.py index 94afb1af04..0888fbe233 100644 --- a/care/emr/api/viewsets/facility_organization.py +++ b/care/emr/api/viewsets/facility_organization.py @@ -20,6 +20,7 @@ from care.facility.models import Facility from care.security.authorization import AuthorizationController from care.security.models import RoleModel +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator class FacilityOrganizationFilter(filters.FilterSet): @@ -28,6 +29,7 @@ class FacilityOrganizationFilter(filters.FilterSet): org_type = filters.CharFilter(field_name="org_type", lookup_expr="iexact") +@generate_swagger_schema_decorator class FacilityOrganizationViewSet(EMRModelViewSet): database_model = FacilityOrganization pydantic_model = FacilityOrganizationWriteSpec @@ -150,9 +152,7 @@ def mine(self, request, *args, **kwargs): return Response({"count": len(data), "results": data}) -FacilityOrganizationViewSet.generate_swagger_schema() - - +@generate_swagger_schema_decorator class FacilityOrganizationUsersViewSet(EMRModelViewSet): database_model = FacilityOrganizationUser pydantic_model = FacilityOrganizationUserWriteSpec @@ -252,6 +252,3 @@ def get_queryset(self): return FacilityOrganizationUser.objects.filter( organization=organization ).select_related("organization", "user", "role") - - -FacilityOrganizationUsersViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/file_upload.py b/care/emr/api/viewsets/file_upload.py index 99cfbcb9f0..5956350655 100644 --- a/care/emr/api/viewsets/file_upload.py +++ b/care/emr/api/viewsets/file_upload.py @@ -23,6 +23,7 @@ FileUploadUpdateSpec, ) from care.security.authorization import AuthorizationController +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator def file_authorizer(user, file_type, associating_id, permission): @@ -58,6 +59,7 @@ class FileUploadFilter(filters.FilterSet): is_archived = filters.BooleanFilter(field_name="is_archived") +@generate_swagger_schema_decorator class FileUploadViewSet( EMRCreateMixin, EMRRetrieveMixin, EMRUpdateMixin, EMRListMixin, EMRBaseViewSet ): @@ -145,6 +147,3 @@ def archive(self, request, *args, **kwargs): ] ) return Response(FileUploadListSpec.serialize(obj).to_json()) - - -FileUploadViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/medication_administration.py b/care/emr/api/viewsets/medication_administration.py index 929b0c88b9..cd8aeaa644 100644 --- a/care/emr/api/viewsets/medication_administration.py +++ b/care/emr/api/viewsets/medication_administration.py @@ -11,6 +11,7 @@ MedicationAdministrationSpec, ) from care.emr.resources.questionnaire.spec import SubjectType +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator class MedicationAdministrationFilter(filters.FilterSet): @@ -20,6 +21,7 @@ class MedicationAdministrationFilter(filters.FilterSet): occurrence_period_end = filters.DateTimeFromToRangeFilter() +@generate_swagger_schema_decorator class MedicationAdministrationViewSet( EncounterBasedAuthorizationBase, EMRQuestionnaireResponseMixin, EMRModelViewSet ): @@ -43,5 +45,4 @@ def get_queryset(self): ) -MedicationAdministrationViewSet.generate_swagger_schema() InternalQuestionnaireRegistry.register(MedicationAdministrationViewSet) diff --git a/care/emr/api/viewsets/medication_request.py b/care/emr/api/viewsets/medication_request.py index 01019bca39..1908a20239 100644 --- a/care/emr/api/viewsets/medication_request.py +++ b/care/emr/api/viewsets/medication_request.py @@ -12,12 +12,14 @@ MedicationRequestUpdateSpec, ) from care.emr.resources.questionnaire.spec import SubjectType +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator class MedicationRequestFilter(filters.FilterSet): encounter = filters.UUIDFilter(field_name="encounter__external_id") +@generate_swagger_schema_decorator class MedicationRequestViewSet( EncounterBasedAuthorizationBase, EMRQuestionnaireResponseMixin, EMRModelViewSet ): @@ -42,5 +44,4 @@ def get_queryset(self): ) -MedicationRequestViewSet.generate_swagger_schema() InternalQuestionnaireRegistry.register(MedicationRequestViewSet) diff --git a/care/emr/api/viewsets/medication_statement.py b/care/emr/api/viewsets/medication_statement.py index bc71589609..d78a81f631 100644 --- a/care/emr/api/viewsets/medication_statement.py +++ b/care/emr/api/viewsets/medication_statement.py @@ -11,12 +11,14 @@ MedicationStatementSpec, ) from care.emr.resources.questionnaire.spec import SubjectType +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator class MedicationStatementFilter(filters.FilterSet): encounter = filters.UUIDFilter(field_name="encounter__external_id") +@generate_swagger_schema_decorator class MedicationStatementViewSet( EncounterBasedAuthorizationBase, EMRQuestionnaireResponseMixin, EMRModelViewSet ): @@ -40,5 +42,4 @@ def get_queryset(self): ) -MedicationStatementViewSet.generate_swagger_schema() InternalQuestionnaireRegistry.register(MedicationStatementViewSet) diff --git a/care/emr/api/viewsets/notes.py b/care/emr/api/viewsets/notes.py index 3e52718e33..9eb14c768e 100644 --- a/care/emr/api/viewsets/notes.py +++ b/care/emr/api/viewsets/notes.py @@ -22,8 +22,10 @@ NoteThreadUpdateSpec, ) from care.security.authorization import AuthorizationController +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator +@generate_swagger_schema_decorator class NoteThreadViewSet( EMRCreateMixin, EMRRetrieveMixin, @@ -96,9 +98,7 @@ def get_queryset(self): return queryset.order_by("-created_date") -NoteThreadViewSet.generate_swagger_schema() - - +@generate_swagger_schema_decorator class NoteMessageViewSet( EMRCreateMixin, EMRRetrieveMixin, EMRUpdateMixin, EMRListMixin, EMRBaseViewSet ): @@ -157,6 +157,3 @@ def get_queryset(self): .filter(thread__external_id=self.kwargs["thread_external_id"]) .order_by("-created_date") ) - - -NoteMessageViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/observation.py b/care/emr/api/viewsets/observation.py index c5be65782c..175075930d 100644 --- a/care/emr/api/viewsets/observation.py +++ b/care/emr/api/viewsets/observation.py @@ -10,6 +10,7 @@ from care.emr.resources.common.coding import Coding from care.emr.resources.observation.spec import ObservationReadSpec from care.emr.resources.questionnaire.spec import QuestionType +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator class MultipleCodeFilter(filters.CharFilter): @@ -38,6 +39,7 @@ class ObservationAnalyseRequest(BaseModel): page_size: int = Field(10, le=30) +@generate_swagger_schema_decorator class ObservationViewSet(EncounterBasedAuthorizationBase, EMRModelReadOnlyViewSet): database_model = Observation pydantic_model = ObservationReadSpec @@ -82,6 +84,3 @@ def analyse(self, request, **kwargs): } ) return Response({"results": results}) - - -ObservationViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/organization.py b/care/emr/api/viewsets/organization.py index d3dd4aa7d0..f91f353692 100644 --- a/care/emr/api/viewsets/organization.py +++ b/care/emr/api/viewsets/organization.py @@ -22,6 +22,7 @@ ) from care.security.authorization import AuthorizationController from care.security.models import PermissionModel, RoleModel, RolePermission +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator from care.utils.pagination.care_pagination import CareLimitOffsetPagination from config.patient_otp_authentication import JWTTokenPatientAuthentication @@ -33,6 +34,7 @@ class OrganizationFilter(filters.FilterSet): level_cache = filters.NumberFilter(field_name="level_cache") +@generate_swagger_schema_decorator class OrganizationPublicViewSet(EMRModelReadOnlyViewSet): database_model = Organization pydantic_read_model = OrganizationReadSpec @@ -48,9 +50,7 @@ def get_queryset(self): return queryset -OrganizationPublicViewSet.generate_swagger_schema() - - +@generate_swagger_schema_decorator class OrganizationViewSet(EMRModelViewSet): database_model = Organization pydantic_model = OrganizationWriteSpec @@ -201,13 +201,11 @@ def mine(self, request, *args, **kwargs): return Response({"count": len(data), "results": data}) -OrganizationViewSet.generate_swagger_schema() - - class OrganizationUserFilter(filters.FilterSet): username = filters.CharFilter(field_name="user__username", lookup_expr="icontains") +@generate_swagger_schema_decorator class OrganizationUsersViewSet(EMRModelViewSet): database_model = OrganizationUser pydantic_model = OrganizationUserWriteSpec @@ -298,6 +296,3 @@ def get_queryset(self): "User does not have the required permissions to list users" ) return OrganizationUser.objects.filter(organization=organization) - - -OrganizationUsersViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/patient.py b/care/emr/api/viewsets/patient.py index 15fe121d4c..ea87c81f48 100644 --- a/care/emr/api/viewsets/patient.py +++ b/care/emr/api/viewsets/patient.py @@ -22,6 +22,7 @@ from care.security.authorization import AuthorizationController from care.security.models import RoleModel from care.users.models import User +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator class PatientFilters(FilterSet): @@ -29,6 +30,7 @@ class PatientFilters(FilterSet): phone_number = CharFilter(field_name="phone_number", lookup_expr="iexact") +@generate_swagger_schema_decorator class PatientViewSet(EMRModelViewSet): database_model = Patient pydantic_model = PatientCreateSpec @@ -160,6 +162,3 @@ def delete_user(self, request, *args, **kwargs): raise ValidationError("User does not exist") PatientUser.objects.filter(user=user, patient=patient).delete() return Response({}) - - -PatientViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/questionnaire.py b/care/emr/api/viewsets/questionnaire.py index ed755cb3ca..47499d9e3e 100644 --- a/care/emr/api/viewsets/questionnaire.py +++ b/care/emr/api/viewsets/questionnaire.py @@ -30,6 +30,7 @@ QuestionnaireSubmitRequest, ) from care.security.authorization import AuthorizationController +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator class QuestionnaireTagFilter(filters.FilterSet): @@ -37,6 +38,7 @@ class QuestionnaireTagFilter(filters.FilterSet): slug = filters.CharFilter(field_name="slug", lookup_expr="iexact") +@generate_swagger_schema_decorator class QuestionnaireTagsViewSet(EMRModelViewSet): database_model = QuestionnaireTag pydantic_model = QuestionnaireTagSpec @@ -54,9 +56,6 @@ def permissions_controller(self, request): return False -QuestionnaireTagsViewSet.generate_swagger_schema() - - class QuestionnaireTagSlugFilter(filters.CharFilter): def filter(self, qs, value): queryset = qs @@ -72,6 +71,7 @@ class QuestionnaireFilter(filters.FilterSet): tag_slug = QuestionnaireTagSlugFilter(field_name="tag_slug") +@generate_swagger_schema_decorator class QuestionnaireViewSet(EMRModelViewSet): database_model = Questionnaire pydantic_model = QuestionnaireSpec @@ -247,6 +247,3 @@ def set_organizations(self, request, *args, **kwargs): "results": organizations_serialized, } ) - - -QuestionnaireViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/questionnaire_response.py b/care/emr/api/viewsets/questionnaire_response.py index 370e7bd5b0..82692f0d3c 100644 --- a/care/emr/api/viewsets/questionnaire_response.py +++ b/care/emr/api/viewsets/questionnaire_response.py @@ -7,6 +7,7 @@ from care.emr.models.questionnaire import QuestionnaireResponse from care.emr.resources.questionnaire_response.spec import QuestionnaireResponseReadSpec from care.security.authorization import AuthorizationController +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator class QuestionnaireResponseFilters(filters.FilterSet): @@ -16,6 +17,7 @@ class QuestionnaireResponseFilters(filters.FilterSet): questionnaire_slug = filters.CharFilter(field_name="questionnaire__slug") +@generate_swagger_schema_decorator class QuestionnaireResponseViewSet(EMRModelReadOnlyViewSet): database_model = QuestionnaireResponse pydantic_model = QuestionnaireResponseReadSpec @@ -65,6 +67,3 @@ def get_queryset(self): if "only_unstructured" in self.request.GET: queryset = queryset.filter(structured_response_type__isnull=True) return queryset - - -QuestionnaireResponseViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/resource_request.py b/care/emr/api/viewsets/resource_request.py index 94f8a367d5..12ad5fae76 100644 --- a/care/emr/api/viewsets/resource_request.py +++ b/care/emr/api/viewsets/resource_request.py @@ -19,8 +19,10 @@ ResourceRequestListSpec, ResourceRequestRetrieveSpec, ) +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator +@generate_swagger_schema_decorator class ResourceRequestViewSet(EMRModelViewSet): database_model = ResourceRequest pydantic_model = ResourceRequestCreateSpec @@ -73,9 +75,7 @@ def get_queryset(self): return self.build_queryset(queryset, self.request.user) -ResourceRequestViewSet.generate_swagger_schema() - - +@generate_swagger_schema_decorator class ResourceRequestCommentViewSet( EMRCreateMixin, EMRRetrieveMixin, EMRListMixin, EMRDestroyMixin, EMRBaseViewSet ): @@ -100,6 +100,3 @@ def get_queryset(self): return ResourceRequestComment.objects.filter( request=resource_request_obj ).select_related("created_by") - - -ResourceRequestCommentViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/roles.py b/care/emr/api/viewsets/roles.py index 410a52b4a7..712c0107ed 100644 --- a/care/emr/api/viewsets/roles.py +++ b/care/emr/api/viewsets/roles.py @@ -3,11 +3,10 @@ from care.emr.api.viewsets.base import EMRModelReadOnlyViewSet from care.emr.resources.role.spec import RoleSpec from care.security.models import RoleModel +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator +@generate_swagger_schema_decorator class RoleViewSet(EMRModelReadOnlyViewSet): database_model = RoleModel pydantic_model = RoleSpec - - -RoleViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/scheduling/availability.py b/care/emr/api/viewsets/scheduling/availability.py index e6d19053d6..da1b3ef500 100644 --- a/care/emr/api/viewsets/scheduling/availability.py +++ b/care/emr/api/viewsets/scheduling/availability.py @@ -23,6 +23,7 @@ ) from care.security.authorization import AuthorizationController from care.users.models import User +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator from care.utils.lock import Lock @@ -126,6 +127,7 @@ def lock_create_appointment(token_slot, patient, created_by, reason_for_visit): ) +@generate_swagger_schema_decorator class SlotViewSet(EMRRetrieveMixin, EMRBaseViewSet): database_model = TokenSlot pydantic_read_model = TokenSlotBaseSpec diff --git a/care/emr/api/viewsets/scheduling/availability_exceptions.py b/care/emr/api/viewsets/scheduling/availability_exceptions.py index e93dc4eeb1..b137d68a91 100644 --- a/care/emr/api/viewsets/scheduling/availability_exceptions.py +++ b/care/emr/api/viewsets/scheduling/availability_exceptions.py @@ -12,12 +12,14 @@ from care.facility.models import Facility from care.security.authorization import AuthorizationController from care.users.models import User +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator class AvailabilityExceptionFilters(FilterSet): user = UUIDFilter(field_name="resource__user__external_id") +@generate_swagger_schema_decorator class AvailabilityExceptionsViewSet(EMRModelViewSet): database_model = AvailabilityException pydantic_model = AvailabilityExceptionWriteSpec @@ -71,6 +73,3 @@ def get_queryset(self): .select_related("resource", "created_by", "updated_by") .order_by("-modified_date") ) - - -AvailabilityExceptionsViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/scheduling/booking.py b/care/emr/api/viewsets/scheduling/booking.py index e7a45b3373..8aed162209 100644 --- a/care/emr/api/viewsets/scheduling/booking.py +++ b/care/emr/api/viewsets/scheduling/booking.py @@ -27,6 +27,7 @@ from care.emr.resources.user.spec import UserSpec from care.facility.models import Facility, FacilityOrganizationUser from care.security.authorization import AuthorizationController +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator class CancelBookingSpec(BaseModel): @@ -57,6 +58,7 @@ def filter_by_user(self, queryset, name, value): return queryset.filter(token_slot__resource=resource) +@generate_swagger_schema_decorator class TokenBookingViewSet( EMRRetrieveMixin, EMRUpdateMixin, EMRListMixin, EMRBaseViewSet ): @@ -172,6 +174,3 @@ def available_users(self, request, *args, **kwargs): ] } ) - - -TokenBookingViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/scheduling/schedule.py b/care/emr/api/viewsets/scheduling/schedule.py index 21793b0093..df1fe7f94f 100644 --- a/care/emr/api/viewsets/scheduling/schedule.py +++ b/care/emr/api/viewsets/scheduling/schedule.py @@ -23,6 +23,7 @@ from care.facility.models import Facility from care.security.authorization import AuthorizationController from care.users.models import User +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator from care.utils.lock import Lock @@ -30,6 +31,7 @@ class ScheduleFilters(FilterSet): user = UUIDFilter(field_name="resource__user__external_id") +@generate_swagger_schema_decorator class ScheduleViewSet(EMRModelViewSet): database_model = Schedule pydantic_model = ScheduleCreateSpec @@ -129,9 +131,7 @@ def get_queryset(self): ) -ScheduleViewSet.generate_swagger_schema() - - +@generate_swagger_schema_decorator class AvailabilityViewSet(EMRCreateMixin, EMRDestroyMixin, EMRBaseViewSet): database_model = Availability pydantic_model = AvailabilityForScheduleSpec @@ -194,6 +194,3 @@ def authorize_create(self, instance): def authorize_destroy(self, instance): self.authorize_create(instance) - - -AvailabilityViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/user.py b/care/emr/api/viewsets/user.py index 9df3967ef4..38b3cb6d88 100644 --- a/care/emr/api/viewsets/user.py +++ b/care/emr/api/viewsets/user.py @@ -22,6 +22,7 @@ from care.security.models import RoleModel from care.users.api.serializers.user import UserImageUploadSerializer, UserSerializer from care.users.models import User +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator from care.utils.file_uploads.cover_image import delete_cover_image @@ -34,6 +35,7 @@ class UserFilter(filters.FilterSet): user_type = filters.CharFilter(field_name="username", lookup_expr="iexact") +@generate_swagger_schema_decorator class UserViewSet(EMRModelViewSet): database_model = User pydantic_model = UserCreateSpec @@ -139,6 +141,3 @@ def pnconfig(self, request, *args, **kwargs): setattr(user, field, request.data[field]) user.save() return Response({}) - - -UserViewSet.generate_swagger_schema() diff --git a/care/emr/api/viewsets/valueset.py b/care/emr/api/viewsets/valueset.py index d34d502665..ad17808493 100644 --- a/care/emr/api/viewsets/valueset.py +++ b/care/emr/api/viewsets/valueset.py @@ -8,6 +8,7 @@ from care.emr.fhir.schema.base import Coding from care.emr.models.valueset import ValueSet from care.emr.resources.valueset.spec import ValueSetReadSpec, ValueSetSpec +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator class ExpandRequest(BaseModel): @@ -16,6 +17,7 @@ class ExpandRequest(BaseModel): display_language: str = "en-gb" +@generate_swagger_schema_decorator class ValueSetViewSet(EMRModelViewSet): database_model = ValueSet pydantic_model = ValueSetSpec @@ -66,6 +68,3 @@ def lookup_code(self, request, *args, **kwargs): .get() ) return Response(result) - - -ValueSetViewSet.generate_swagger_schema() diff --git a/care/utils/decorators/__init__.py b/care/utils/decorators/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/utils/decorators/schema_decorator.py b/care/utils/decorators/schema_decorator.py new file mode 100644 index 0000000000..7a340e5f12 --- /dev/null +++ b/care/utils/decorators/schema_decorator.py @@ -0,0 +1,24 @@ +from drf_spectacular.utils import extend_schema, extend_schema_view + + +def generate_swagger_schema_decorator(cls): + schema_dict = {} + for name in ["create", "update", "list", "retrieve"]: + if name == "create": + schema_dict["create"] = extend_schema( + request=cls.pydantic_model, + responses={200: cls.pydantic_read_model or cls.pydantic_model}, + ) + elif name == "update": + schema_dict["update"] = extend_schema( + request=cls.pydantic_retrieve_model + or cls.pydantic_read_model + or cls.pydantic_model, + responses={200: cls.pydantic_read_model or cls.pydantic_model}, + ) + elif name in ["list", "retrieve"]: + schema_dict[name] = extend_schema( + responses={200: cls.pydantic_read_model or cls.pydantic_model} + ) + + return extend_schema_view(**schema_dict)(cls) diff --git a/config/settings/base.py b/config/settings/base.py index b79ce69c0d..394cf401c3 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -387,6 +387,7 @@ "TITLE": "Care API", "DESCRIPTION": "Documentation of API endpoints of Care ", "VERSION": "1.0.0", + "DISABLE_ERRORS_AND_WARNINGS": True, } # Simple JWT (JWT Authentication) From de73f58f2bf9f7cfa9edfda299f19f7a23be1c15 Mon Sep 17 00:00:00 2001 From: Prafful Date: Fri, 24 Jan 2025 15:13:50 +0530 Subject: [PATCH 11/17] updated the decorator --- care/utils/decorators/schema_decorator.py | 38 ++++++++++++----------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/care/utils/decorators/schema_decorator.py b/care/utils/decorators/schema_decorator.py index 7a340e5f12..dafa1ad47b 100644 --- a/care/utils/decorators/schema_decorator.py +++ b/care/utils/decorators/schema_decorator.py @@ -2,23 +2,25 @@ def generate_swagger_schema_decorator(cls): - schema_dict = {} - for name in ["create", "update", "list", "retrieve"]: - if name == "create": - schema_dict["create"] = extend_schema( - request=cls.pydantic_model, - responses={200: cls.pydantic_read_model or cls.pydantic_model}, - ) - elif name == "update": - schema_dict["update"] = extend_schema( - request=cls.pydantic_retrieve_model - or cls.pydantic_read_model - or cls.pydantic_model, - responses={200: cls.pydantic_read_model or cls.pydantic_model}, - ) - elif name in ["list", "retrieve"]: - schema_dict[name] = extend_schema( - responses={200: cls.pydantic_read_model or cls.pydantic_model} - ) + actions = { + "create": { + "request": cls.pydantic_model, + "responses": {200: cls.pydantic_read_model or cls.pydantic_model}, + }, + "update": { + "request": cls.pydantic_retrieve_model + or cls.pydantic_read_model + or cls.pydantic_model, + "responses": {200: cls.pydantic_read_model or cls.pydantic_model}, + }, + "list": {"responses": {200: cls.pydantic_read_model or cls.pydantic_model}}, + "retrieve": {"responses": {200: cls.pydantic_read_model or cls.pydantic_model}}, + } + + schema_dict = { + name: extend_schema(**params) + for name, params in actions.items() + if hasattr(cls, name) and callable(getattr(cls, name)) + } return extend_schema_view(**schema_dict)(cls) From ca3345e149dcb3660880510a97947cfaafa1f7f8 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Fri, 24 Jan 2025 16:44:05 +0530 Subject: [PATCH 12/17] Add chronic condition api (#2775) Add chronic condition api (#2775) --- care/abdm/migrations/0014_replace_0013.py | 30 - .../migrations_old/0013_abhanumber_patient.py | 46 -- care/audit_log/middleware.py | 2 +- care/emr/api/viewsets/allergy_intolerance.py | 1 - care/emr/api/viewsets/base.py | 11 +- care/emr/api/viewsets/condition.py | 99 ++- .../0012_alter_condition_encounter.py | 19 + care/emr/models/condition.py | 4 +- care/emr/resources/condition/spec.py | 16 +- care/emr/tests/test_booking_api.py | 5 + care/emr/tests/test_chronic_condition_api.py | 770 ++++++++++++++++++ care/emr/tests/test_schedule_api.py | 4 + care/utils/tests/test_feature_flags.py | 10 +- config/api_router.py | 9 +- config/settings/test.py | 6 +- pyproject.toml | 40 +- 16 files changed, 951 insertions(+), 121 deletions(-) delete mode 100644 care/abdm/migrations/0014_replace_0013.py delete mode 100644 care/abdm/migrations_old/0013_abhanumber_patient.py create mode 100644 care/emr/migrations/0012_alter_condition_encounter.py create mode 100644 care/emr/tests/test_chronic_condition_api.py diff --git a/care/abdm/migrations/0014_replace_0013.py b/care/abdm/migrations/0014_replace_0013.py deleted file mode 100644 index 1c30a4e0a7..0000000000 --- a/care/abdm/migrations/0014_replace_0013.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 4.2.10 on 2024-04-21 17:40 - -# This is a replacement migration for abdm.0013 that omits the RunPython operation (reverse_patient_abhanumber_relation) and facility migration dependency. - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("abdm", "0012_consentrequest_status"), - ] - - replaces = [ - ("abdm", "0013_abhanumber_patient"), - ] - - operations = [ - migrations.AddField( - model_name="abhanumber", - name="patient", - field=models.OneToOneField( - blank=True, - null=True, - on_delete=django.db.models.deletion.PROTECT, - related_name="abha_number", - to="facility.patientregistration", - ), - ), - ] diff --git a/care/abdm/migrations_old/0013_abhanumber_patient.py b/care/abdm/migrations_old/0013_abhanumber_patient.py deleted file mode 100644 index 433dea8576..0000000000 --- a/care/abdm/migrations_old/0013_abhanumber_patient.py +++ /dev/null @@ -1,46 +0,0 @@ -# Generated by Django 4.2.10 on 2024-04-21 09:33 - -import django.db.models.deletion -from django.db import migrations, models -from django.db.models.expressions import RawSQL - - -class Migration(migrations.Migration): - def reverse_patient_abhanumber_relation(apps, schema_editor): - Patient = apps.get_model("facility", "PatientRegistration") - AbhaNumber = apps.get_model("abdm", "AbhaNumber") - - patients = ( - Patient.objects.filter(abha_number__isnull=False) - ) - abha_numbers_to_update = [] - - for patient in patients: - abha_number = patient.abha_number - abha_number.patient = patient - abha_numbers_to_update.append(abha_number) - - AbhaNumber.objects.bulk_update(abha_numbers_to_update, ["patient"]) - - dependencies = [ - ("facility", "0432_alter_fileupload_file_type"), - ("abdm", "0012_consentrequest_status"), - ] - - operations = [ - migrations.AddField( - model_name="abhanumber", - name="patient", - field=models.OneToOneField( - blank=True, - null=True, - on_delete=django.db.models.deletion.PROTECT, - related_name="abha_number", - to="facility.patientregistration", - ), - ), - migrations.RunPython( - code=reverse_patient_abhanumber_relation, - reverse_code=migrations.RunPython.noop, - ), - ] diff --git a/care/audit_log/middleware.py b/care/audit_log/middleware.py index cef5e5950a..cea3cbbefe 100644 --- a/care/audit_log/middleware.py +++ b/care/audit_log/middleware.py @@ -16,7 +16,7 @@ class RequestInformation(NamedTuple): exception: Exception | None -logger = logging.getLogger(__name__) +logger = logging.getLogger("audit_log") class AuditLogMiddleware: diff --git a/care/emr/api/viewsets/allergy_intolerance.py b/care/emr/api/viewsets/allergy_intolerance.py index faeb1ee54e..d174fb5f4b 100644 --- a/care/emr/api/viewsets/allergy_intolerance.py +++ b/care/emr/api/viewsets/allergy_intolerance.py @@ -67,7 +67,6 @@ def authorize_create(self, instance): "can_write_patient_obj", self.request.user, self.get_patient_obj() ): raise PermissionDenied("You do not have permission to update encounter") - # TODO If there is an encounter, check access to the encounter def get_queryset(self): if not AuthorizationController.call( diff --git a/care/emr/api/viewsets/base.py b/care/emr/api/viewsets/base.py index b2db441bad..26f623d3fc 100644 --- a/care/emr/api/viewsets/base.py +++ b/care/emr/api/viewsets/base.py @@ -145,13 +145,14 @@ def perform_update(self, instance): updated_by=self.request.user, ) - def clean_update_data(self, request_data): + def clean_update_data(self, request_data, keep_fields: set | None = None): if type(request_data) is list: return request_data - request_data.pop("id", None) - request_data.pop("external_id", None) - request_data.pop("patient", None) - request_data.pop("encounter", None) + ignored_fields = {"id", "external_id", "patient", "encounter"} + if keep_fields: + ignored_fields = ignored_fields - set(keep_fields) + for field in ignored_fields: + request_data.pop(field, None) return request_data def update(self, request, *args, **kwargs): diff --git a/care/emr/api/viewsets/condition.py b/care/emr/api/viewsets/condition.py index 52c2ce9a3f..b30890e24d 100644 --- a/care/emr/api/viewsets/condition.py +++ b/care/emr/api/viewsets/condition.py @@ -1,22 +1,34 @@ -from django.shortcuts import get_object_or_404 from django_filters import CharFilter, FilterSet, UUIDFilter from django_filters.rest_framework import DjangoFilterBackend -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.generics import get_object_or_404 -from care.emr.api.viewsets.base import EMRModelViewSet, EMRQuestionnaireResponseMixin +from care.emr.api.viewsets.base import ( + EMRBaseViewSet, + EMRCreateMixin, + EMRListMixin, + EMRModelViewSet, + EMRQuestionnaireResponseMixin, + EMRRetrieveMixin, + EMRUpdateMixin, + EMRUpsertMixin, +) from care.emr.api.viewsets.encounter_authz_base import EncounterBasedAuthorizationBase from care.emr.models.condition import Condition from care.emr.models.encounter import Encounter +from care.emr.models.patient import Patient from care.emr.registries.system_questionnaire.system_questionnaire import ( InternalQuestionnaireRegistry, ) from care.emr.resources.condition.spec import ( CategoryChoices, + ChronicConditionUpdateSpec, + ConditionReadSpec, ConditionSpec, - ConditionSpecRead, - ConditionSpecUpdate, + ConditionUpdateSpec, ) from care.emr.resources.questionnaire.spec import SubjectType +from care.security.authorization import AuthorizationController class ValidateEncounterMixin: @@ -54,8 +66,8 @@ class SymptomViewSet( ): database_model = Condition pydantic_model = ConditionSpec - pydantic_read_model = ConditionSpecRead - pydantic_update_model = ConditionSpecUpdate + pydantic_read_model = ConditionReadSpec + pydantic_update_model = ConditionUpdateSpec # Filters filterset_class = ConditionFilters filter_backends = [DjangoFilterBackend] @@ -94,8 +106,8 @@ class DiagnosisViewSet( ): database_model = Condition pydantic_model = ConditionSpec - pydantic_read_model = ConditionSpecRead - pydantic_update_model = ConditionSpecUpdate + pydantic_read_model = ConditionReadSpec + pydantic_update_model = ConditionUpdateSpec # Filters filterset_class = ConditionFilters @@ -125,3 +137,72 @@ def get_queryset(self): InternalQuestionnaireRegistry.register(DiagnosisViewSet) + + +class ChronicConditionViewSet( + EMRQuestionnaireResponseMixin, + EMRCreateMixin, + EMRRetrieveMixin, + EMRUpdateMixin, + EMRListMixin, + EMRBaseViewSet, + EMRUpsertMixin, +): + database_model = Condition + pydantic_model = ConditionSpec + pydantic_read_model = ConditionReadSpec + pydantic_update_model = ChronicConditionUpdateSpec + + # Filters + filterset_class = ConditionFilters + filter_backends = [DjangoFilterBackend] + # Questionnaire Spec + questionnaire_type = "chronic_condition" + questionnaire_title = "Chronic Condition" + questionnaire_description = "Chronic Condition" + questionnaire_subject_type = SubjectType.patient.value + + def get_patient_obj(self): + return get_object_or_404( + Patient, external_id=self.kwargs["patient_external_id"] + ) + + def authorize_create(self, instance): + if not AuthorizationController.call( + "can_write_patient_obj", self.request.user, self.get_patient_obj() + ): + raise PermissionDenied("You do not have permission to update encounter") + + def authorize_update(self, request_obj, model_instance): + encounter = get_object_or_404(Encounter, external_id=request_obj.encounter) + if not AuthorizationController.call( + "can_update_encounter_obj", + self.request.user, + encounter, + ): + raise PermissionDenied("You do not have permission to update encounter") + + def perform_create(self, instance): + instance.category = CategoryChoices.chronic_condition.value + super().perform_create(instance) + + 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 ( + super() + .get_queryset() + .filter( + patient__external_id=self.kwargs["patient_external_id"], + category=CategoryChoices.chronic_condition.value, + ) + .select_related("patient", "encounter", "created_by", "updated_by") + ) + + +InternalQuestionnaireRegistry.register(ChronicConditionViewSet) diff --git a/care/emr/migrations/0012_alter_condition_encounter.py b/care/emr/migrations/0012_alter_condition_encounter.py new file mode 100644 index 0000000000..ffb4553434 --- /dev/null +++ b/care/emr/migrations/0012_alter_condition_encounter.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.4 on 2025-01-24 07:58 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('emr', '0011_medicationrequest_requester'), + ] + + operations = [ + migrations.AlterField( + model_name='condition', + name='encounter', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='emr.encounter'), + ), + ] diff --git a/care/emr/models/condition.py b/care/emr/models/condition.py index 700a2114d1..4b33a146c9 100644 --- a/care/emr/models/condition.py +++ b/care/emr/models/condition.py @@ -11,7 +11,9 @@ class Condition(EMRBaseModel): code = models.JSONField(default=dict, null=False, blank=False) body_site = models.JSONField(default=dict, null=False, blank=False) patient = models.ForeignKey("emr.Patient", on_delete=models.CASCADE) - encounter = models.ForeignKey("emr.Encounter", on_delete=models.CASCADE) + encounter = models.ForeignKey( + "emr.Encounter", on_delete=models.CASCADE, null=True, blank=True + ) onset = models.JSONField(default=dict) abatement = models.JSONField(default=dict) recorded_date = models.DateTimeField(null=True, blank=True) diff --git a/care/emr/resources/condition/spec.py b/care/emr/resources/condition/spec.py index baea0fe074..0157be9a52 100644 --- a/care/emr/resources/condition/spec.py +++ b/care/emr/resources/condition/spec.py @@ -2,6 +2,7 @@ from enum import Enum from pydantic import UUID4, Field, field_validator +from rest_framework.generics import get_object_or_404 from care.emr.fhir.schema.base import Coding from care.emr.models.condition import Condition @@ -96,7 +97,7 @@ def perform_extra_deserialization(self, is_update, obj): obj.patient = obj.encounter.patient -class ConditionSpecRead(BaseConditionSpec): +class ConditionReadSpec(BaseConditionSpec): """ Validation for deeper models may not be required on read, Just an extra optimisation """ @@ -119,7 +120,8 @@ class ConditionSpecRead(BaseConditionSpec): @classmethod def perform_extra_serialization(cls, mapping, obj): mapping["id"] = obj.external_id - mapping["encounter"] = obj.encounter.external_id + if obj.encounter: + mapping["encounter"] = obj.encounter.external_id if obj.created_by: mapping["created_by"] = UserSpec.serialize(obj.created_by) @@ -127,7 +129,7 @@ def perform_extra_serialization(cls, mapping, obj): mapping["updated_by"] = UserSpec.serialize(obj.updated_by) -class ConditionSpecUpdate(BaseConditionSpec): +class ConditionUpdateSpec(BaseConditionSpec): clinical_status: ClinicalStatusChoices | None = None verification_status: VerificationStatusChoices severity: SeverityChoices | None = None @@ -142,3 +144,11 @@ def validate_code(cls, code: int): return validate_valueset( "code", cls.model_fields["code"].json_schema_extra["slug"], code ) + + +class ChronicConditionUpdateSpec(ConditionUpdateSpec): + encounter: UUID4 + + def perform_extra_deserialization(self, is_update, obj): + if self.encounter: + obj.encounter = get_object_or_404(Encounter, external_id=self.encounter) diff --git a/care/emr/tests/test_booking_api.py b/care/emr/tests/test_booking_api.py index 8a8a9e903d..4ab036c536 100644 --- a/care/emr/tests/test_booking_api.py +++ b/care/emr/tests/test_booking_api.py @@ -1,5 +1,6 @@ from datetime import UTC, datetime, timedelta +from django.test.utils import ignore_warnings from django.urls import reverse from care.emr.models import ( @@ -20,6 +21,7 @@ from config.patient_otp_authentication import PatientOtpObject +@ignore_warnings(category=RuntimeWarning, message=r".*received a naive datetime.*") class TestBookingViewSet(CareAPITestBase): def setUp(self): super().setUp() @@ -376,6 +378,7 @@ def test_list_available_users(self): self.assertGreaterEqual(len(response.data["users"]), 1) +@ignore_warnings(category=RuntimeWarning, message=r".*received a naive datetime.*") class TestSlotViewSetAppointmentApi(CareAPITestBase): def setUp(self): super().setUp() @@ -582,6 +585,7 @@ def test_over_booking_a_slot(self): self.assertContains(response, status_code=400, text="Slot is already full") +@ignore_warnings(category=RuntimeWarning, message=r".*received a naive datetime.*") class TestSlotViewSetSlotStatsApis(CareAPITestBase): def setUp(self): super().setUp() @@ -932,6 +936,7 @@ def test_availability_heatmap_slots_same_as_get_slots_for_day_with_exceptions(se self.assertEqual(slot_stats["total_slots"], total_slots_for_day) +@ignore_warnings(category=RuntimeWarning, message=r".*received a naive datetime.*") class TestOtpSlotViewSet(CareAPITestBase): def setUp(self): super().setUp() diff --git a/care/emr/tests/test_chronic_condition_api.py b/care/emr/tests/test_chronic_condition_api.py new file mode 100644 index 0000000000..a97b9fb534 --- /dev/null +++ b/care/emr/tests/test_chronic_condition_api.py @@ -0,0 +1,770 @@ +import uuid +from secrets import choice +from unittest.mock import patch + +from django.forms import model_to_dict +from django.urls import reverse +from model_bakery import baker + +from care.emr.models import Condition +from care.emr.resources.condition.spec import ( + CategoryChoices, + ClinicalStatusChoices, + SeverityChoices, + VerificationStatusChoices, +) +from care.emr.resources.resource_request.spec import StatusChoices +from care.security.permissions.encounter import EncounterPermissions +from care.security.permissions.patient import PatientPermissions +from care.utils.tests.base import CareAPITestBase + + +class TestChronicConditionViewSet(CareAPITestBase): + def setUp(self): + super().setUp() + self.user = self.create_user() + self.facility = self.create_facility(user=self.user) + self.organization = self.create_facility_organization(facility=self.facility) + self.patient = self.create_patient() + self.client.force_authenticate(user=self.user) + + self.base_url = reverse( + "chronic-condition-list", + kwargs={"patient_external_id": self.patient.external_id}, + ) + self.valid_code = { + "display": "Test Value", + "system": "http://test_system.care/test", + "code": "123", + } + # Mocking validate_valueset + self.patcher = patch( + "care.emr.resources.condition.spec.validate_valueset", + return_value=self.valid_code, + ) + self.mock_validate_valueset = self.patcher.start() + + def tearDown(self): + self.patcher.stop() + + def _get_chronic_condition_url(self, chronic_condition_id): + """Helper to get the detail URL for a specific chronic_condition.""" + return reverse( + "chronic-condition-detail", + kwargs={ + "patient_external_id": self.patient.external_id, + "external_id": chronic_condition_id, + }, + ) + + def create_chronic_condition(self, encounter, patient, **kwargs): + clinical_status = kwargs.pop( + "clinical_status", choice(list(ClinicalStatusChoices)).value + ) + verification_status = kwargs.pop( + "verification_status", choice(list(VerificationStatusChoices)).value + ) + severity = kwargs.pop("severity", choice(list(SeverityChoices)).value) + + return baker.make( + Condition, + encounter=encounter, + patient=patient, + category=CategoryChoices.chronic_condition.value, + clinical_status=clinical_status, + verification_status=verification_status, + severity=severity, + **kwargs, + ) + + def generate_data_for_chronic_condition(self, encounter, **kwargs): + clinical_status = kwargs.pop( + "clinical_status", choice(list(ClinicalStatusChoices)).value + ) + verification_status = kwargs.pop( + "verification_status", choice(list(VerificationStatusChoices)).value + ) + severity = kwargs.pop("severity", choice(list(SeverityChoices)).value) + code = self.valid_code + return { + "encounter": encounter.external_id, + "category": CategoryChoices.chronic_condition.value, + "clinical_status": clinical_status, + "verification_status": verification_status, + "severity": severity, + "code": code, + **kwargs, + } + + # LIST TESTS + def test_list_chronic_condition_with_permissions(self): + """ + Users with `can_view_clinical_data` on a non-completed encounter + can list chronic_condition (HTTP 200). + """ + # Attach the needed role/permission + permissions = [PatientPermissions.can_view_clinical_data.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + # Create an active encounter + self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=None, + ) + + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, 200) + + def test_list_chronic_condition_with_permissions_and_encounter_status_as_completed( + self, + ): + """ + Users with `can_view_clinical_data` but a completed encounter => (HTTP 403). + """ + permissions = [PatientPermissions.can_view_clinical_data.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=StatusChoices.completed.value, + ) + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, 403) + + def test_list_chronic_condition_without_permissions(self): + """ + Users without `can_view_clinical_data` => (HTTP 403). + """ + # No permission attached + self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=None, + ) + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, 403) + + def test_list_chronic_condition_for_single_encounter_with_permissions(self): + """ + Users with `can_view_clinical_data` can list chronic_condition for that encounter (HTTP 200). + """ + permissions = [PatientPermissions.can_view_clinical_data.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=None, + ) + + url = f"{self.base_url}?encounter={encounter.external_id}" + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_list_chronic_condition_for_single_encounter_with_permissions_and_encounter_status_completed( + self, + ): + """ + Users with `can_view_clinical_data` on a completed encounter cannot list chronic_condition (HTTP 200). + """ + permissions = [PatientPermissions.can_view_clinical_data.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=StatusChoices.completed.value, + ) + url = f"{self.base_url}?encounter={encounter.external_id}" + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + def test_list_chronic_condition_for_single_encounter_without_permissions(self): + """ + Users without `can_view_clinical_data` or `can_view_clinical_data` => (HTTP 403). + """ + # No relevant permission + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=None, + ) + url = f"{self.base_url}?encounter={encounter.external_id}" + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + # CREATE TESTS + def test_create_chronic_condition_without_permissions(self): + """ + Users who lack `can_write_patient` get (HTTP 403) when creating. + """ + # No permission attached + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=None, + ) + chronic_condition_data_dict = self.generate_data_for_chronic_condition( + encounter + ) + + response = self.client.post( + self.base_url, chronic_condition_data_dict, format="json" + ) + self.assertEqual(response.status_code, 403) + + def test_create_chronic_condition_without_permissions_on_facility(self): + """ + Tests that a user with `can_write_patient` permissions but belonging to a different + organization receives (HTTP 403) when attempting to create a chronic_condition. + """ + permissions = [ + PatientPermissions.can_view_clinical_data.name, + PatientPermissions.can_write_patient.name, + ] + role = self.create_role_with_permissions(permissions) + external_user = self.create_user() + external_facility = self.create_facility(user=external_user) + external_organization = self.create_facility_organization( + facility=external_facility + ) + self.attach_role_facility_organization_user( + external_organization, self.user, role + ) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=None, + ) + chronic_condition_data_dict = self.generate_data_for_chronic_condition( + encounter + ) + + response = self.client.post( + self.base_url, chronic_condition_data_dict, format="json" + ) + self.assertEqual(response.status_code, 403) + + def test_create_chronic_condition_with_organization_user_with_permissions(self): + """ + Ensures that a user from a certain organization, who has both + `can_write_patient` and `can_view_clinical_data`, can successfully + view chronic_condition data (HTTP 200) and is able to edit chronic_condition + and chronic_condition can change across encounters. + """ + organization = self.create_organization(org_type="govt") + patient = self.create_patient(geo_organization=organization) + + permissions = [ + PatientPermissions.can_write_patient.name, + PatientPermissions.can_view_clinical_data.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_organization_user(organization, self.user, role) + + # Verify the user can view chronic_condition data (HTTP 200) + test_url = reverse( + "chronic-condition-list", + kwargs={"patient_external_id": patient.external_id}, + ) + response = self.client.get(test_url) + self.assertEqual(response.status_code, 200) + + encounter = self.create_encounter( + patient=patient, + facility=self.facility, + organization=self.organization, + status=None, + ) + + chronic_condition_data_dict = self.generate_data_for_chronic_condition( + encounter + ) + response = self.client.post( + test_url, chronic_condition_data_dict, format="json" + ) + + self.assertEqual(response.status_code, 200) + + def test_create_chronic_condition_with_permissions(self): + """ + Users with `can_write_patient` on a non-completed encounter => (HTTP 200). + """ + permissions = [PatientPermissions.can_write_patient.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=None, + ) + chronic_condition_data_dict = self.generate_data_for_chronic_condition( + encounter + ) + + response = self.client.post( + self.base_url, chronic_condition_data_dict, format="json" + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json()["severity"], chronic_condition_data_dict["severity"] + ) + self.assertEqual(response.json()["code"], chronic_condition_data_dict["code"]) + + def test_create_chronic_condition_with_permissions_and_encounter_status_completed( + self, + ): + """ + Users with `can_write_patient` on a completed encounter => (HTTP 403). + """ + permissions = [PatientPermissions.can_write_patient.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=StatusChoices.completed.value, + ) + chronic_condition_data_dict = self.generate_data_for_chronic_condition( + encounter + ) + + response = self.client.post( + self.base_url, chronic_condition_data_dict, format="json" + ) + self.assertEqual(response.status_code, 403) + + def test_create_chronic_condition_with_permissions_and_no_association_with_facility( + self, + ): + """ + Test that users with `can_write_patient` permission, but who are not + associated with the facility, receive an HTTP 403 (Forbidden) response + when attempting to create a chronic_condition. + """ + permissions = [PatientPermissions.can_write_patient.name] + role = self.create_role_with_permissions(permissions) + organization = self.create_organization(org_type="govt") + self.attach_role_organization_user(organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=None, + ) + chronic_condition_data_dict = self.generate_data_for_chronic_condition( + encounter + ) + + response = self.client.post( + self.base_url, chronic_condition_data_dict, format="json" + ) + self.assertEqual(response.status_code, 403) + + def test_create_chronic_condition_with_permissions_with_mismatched_patient_id(self): + """ + Users with `can_write_patient` on a encounter with different patient => (HTTP 403). + """ + permissions = [ + PatientPermissions.can_view_clinical_data.name, + PatientPermissions.can_write_patient.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.create_patient(), + facility=self.facility, + organization=self.organization, + status=None, + ) + chronic_condition_data_dict = self.generate_data_for_chronic_condition( + encounter + ) + + response = self.client.post( + self.base_url, chronic_condition_data_dict, format="json" + ) + self.assertEqual(response.status_code, 403) + + def test_create_chronic_condition_with_permissions_with_invalid_encounter_id(self): + """ + Users with `can_write_patient` on a incomplete encounter => (HTTP 400). + """ + permissions = [PatientPermissions.can_write_patient.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.create_patient(), + facility=self.facility, + organization=self.organization, + status=None, + ) + chronic_condition_data_dict = self.generate_data_for_chronic_condition( + encounter + ) + chronic_condition_data_dict["encounter"] = uuid.uuid4() + + response = self.client.post( + self.base_url, chronic_condition_data_dict, format="json" + ) + response_data = response.json() + self.assertEqual(response.status_code, 400) + self.assertIn("errors", response_data) + error = response_data["errors"][0] + self.assertEqual(error["type"], "value_error") + self.assertIn("Encounter not found", error["msg"]) + + # RETRIEVE TESTS + def test_retrieve_chronic_condition_with_permissions(self): + """ + Users with `can_view_clinical_data` => (HTTP 200). + """ + permissions = [PatientPermissions.can_view_clinical_data.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + chronic_condition = self.create_chronic_condition( + encounter=encounter, patient=self.patient + ) + + url = self._get_chronic_condition_url(chronic_condition.external_id) + retrieve_response = self.client.get(url) + self.assertEqual(retrieve_response.status_code, 200) + self.assertEqual( + retrieve_response.data["id"], str(chronic_condition.external_id) + ) + + def test_retrieve_chronic_condition_for_single_encounter_with_permissions(self): + """ + Users with `can_view_clinical_data` => (HTTP 200). + """ + permissions = [ + PatientPermissions.can_view_clinical_data.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + chronic_condition = self.create_chronic_condition( + encounter=encounter, patient=self.patient + ) + + url = self._get_chronic_condition_url(chronic_condition.external_id) + retrieve_response = self.client.get(f"{url}?encounter={encounter.external_id}") + self.assertEqual(retrieve_response.status_code, 200) + self.assertEqual( + retrieve_response.data["id"], str(chronic_condition.external_id) + ) + + def test_retrieve_chronic_condition_for_single_encounter_without_permissions(self): + """ + Lacking `can_view_clinical_data` => (HTTP 403). + """ + # No relevant permission + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + chronic_condition = self.create_chronic_condition( + encounter=encounter, patient=self.patient + ) + + url = self._get_chronic_condition_url(chronic_condition.external_id) + retrieve_response = self.client.get(f"{url}?encounter={encounter.external_id}") + self.assertEqual(retrieve_response.status_code, 403) + + def test_retrieve_chronic_condition_without_permissions(self): + """ + Users who have only `can_write_patient` => (HTTP 403). + """ + # No relevant permission + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + chronic_condition = self.create_chronic_condition( + encounter=encounter, patient=self.patient + ) + + url = self._get_chronic_condition_url(chronic_condition.external_id) + retrieve_response = self.client.get(url) + self.assertEqual(retrieve_response.status_code, 403) + + # UPDATE TESTS + def test_update_chronic_condition_with_permissions(self): + """ + Users with `can_write_encounter` + `can_write_patient` + `can_view_clinical_data` + => (HTTP 200) when updating. + """ + permissions = [ + PatientPermissions.can_view_clinical_data.name, + PatientPermissions.can_write_patient.name, + EncounterPermissions.can_write_encounter.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + chronic_condition = self.create_chronic_condition( + encounter=encounter, patient=self.patient + ) + + url = self._get_chronic_condition_url(chronic_condition.external_id) + chronic_condition_data_updated = model_to_dict(chronic_condition) + chronic_condition_data_updated["encounter"] = encounter.external_id + chronic_condition_data_updated["severity"] = "mild" + chronic_condition_data_updated["code"] = self.valid_code + + response = self.client.put(url, chronic_condition_data_updated, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["severity"], "mild") + + def test_update_chronic_condition_for_single_encounter_with_permissions(self): + """ + Users with `can_write_encounter` + `can_write_patient` + `can_view_clinical_data` + => (HTTP 200). + """ + permissions = [ + PatientPermissions.can_view_clinical_data.name, + PatientPermissions.can_write_patient.name, + EncounterPermissions.can_write_encounter.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + chronic_condition = self.create_chronic_condition( + encounter=encounter, patient=self.patient + ) + + url = self._get_chronic_condition_url(chronic_condition.external_id) + chronic_condition_data_updated = model_to_dict(chronic_condition) + chronic_condition_data_updated["encounter"] = encounter.external_id + chronic_condition_data_updated["severity"] = "mild" + chronic_condition_data_updated["code"] = self.valid_code + + update_response = self.client.put( + f"{url}?encounter={encounter.external_id}", + chronic_condition_data_updated, + format="json", + ) + self.assertEqual(update_response.status_code, 200) + self.assertEqual(update_response.json()["severity"], "mild") + + def test_update_chronic_condition_for_single_encounter_without_permissions(self): + """ + Lacking `can_view_clinical_data` => (HTTP 403). + """ + # Only write permission + permissions = [PatientPermissions.can_write_patient.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + chronic_condition = self.create_chronic_condition( + encounter=encounter, patient=self.patient + ) + + url = self._get_chronic_condition_url(chronic_condition.external_id) + chronic_condition_data_updated = model_to_dict(chronic_condition) + chronic_condition_data_updated["severity"] = "mild" + + update_response = self.client.put( + f"{url}?encounter={encounter.external_id}", + chronic_condition_data_updated, + format="json", + ) + self.assertEqual(update_response.status_code, 403) + + def test_update_chronic_condition_without_permissions(self): + """ + Users with only `can_write_patient` but not `can_view_clinical_data` + => (HTTP 403). + """ + # Only write permission (same scenario as above but no read or view clinical) + + permissions = [PatientPermissions.can_write_patient.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + chronic_condition = self.create_chronic_condition( + encounter=encounter, patient=self.patient + ) + + url = self._get_chronic_condition_url(chronic_condition.external_id) + chronic_condition_data_updated = model_to_dict(chronic_condition) + chronic_condition_data_updated["severity"] = "mild" + + update_response = self.client.put( + url, chronic_condition_data_updated, format="json" + ) + self.assertEqual(update_response.status_code, 403) + + def test_update_chronic_condition_for_closed_encounter_with_permissions(self): + """ + Encounter completed => (HTTP 403) on update, + even if user has `can_write_patient` + `can_view_clinical_data`. + """ + permissions = [ + PatientPermissions.can_write_patient.name, + PatientPermissions.can_view_clinical_data.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=StatusChoices.completed.value, + ) + chronic_condition = self.create_chronic_condition( + encounter=encounter, patient=self.patient + ) + + url = self._get_chronic_condition_url(chronic_condition.external_id) + chronic_condition_data_updated = model_to_dict(chronic_condition) + chronic_condition_data_updated["severity"] = "mild" + + update_response = self.client.put( + url, chronic_condition_data_updated, format="json" + ) + self.assertEqual(update_response.status_code, 403) + + def test_update_chronic_condition_changes_encounter_id(self): + """ + When a user with access to a new encounter + updates a chronic_condition added by a different encounter, + the encounter_id should be updated to the new encounter. + """ + permissions = [ + PatientPermissions.can_write_patient.name, + PatientPermissions.can_view_clinical_data.name, + EncounterPermissions.can_write_encounter.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + temp_facility = self.create_facility(user=self.create_user()) + encounter = self.create_encounter( + patient=self.patient, + facility=temp_facility, + organization=self.create_facility_organization(facility=temp_facility), + ) + + chronic_condition = self.create_chronic_condition( + encounter=encounter, + patient=self.patient, + code=self.valid_code, + ) + + new_encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + + url = self._get_chronic_condition_url(chronic_condition.external_id) + chronic_condition_data_updated = model_to_dict(chronic_condition) + chronic_condition_data_updated["encounter"] = new_encounter.external_id + chronic_condition_data_updated["clinical_status"] = "remission" + + update_response = self.client.put( + url, chronic_condition_data_updated, format="json" + ) + self.assertEqual(update_response.status_code, 200) + self.assertEqual( + update_response.json()["encounter"], str(new_encounter.external_id) + ) + + def test_update_chronic_condition_changes_encounter_id_without_permission(self): + """ + When a user without access to a new encounter + updates a chronic_condition added by a different encounter, + the encounter_id should not be updated to the new encounter. + """ + permissions = [ + PatientPermissions.can_write_patient.name, + PatientPermissions.can_view_clinical_data.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + temp_facility = self.create_facility(user=self.create_user()) + encounter = self.create_encounter( + patient=self.patient, + facility=temp_facility, + organization=self.create_facility_organization(facility=temp_facility), + ) + + chronic_condition = self.create_chronic_condition( + encounter=encounter, + patient=self.patient, + code=self.valid_code, + ) + + new_encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + + url = self._get_chronic_condition_url(chronic_condition.external_id) + chronic_condition_data_updated = model_to_dict(chronic_condition) + chronic_condition_data_updated["encounter"] = new_encounter.external_id + chronic_condition_data_updated["clinical_status"] = "remission" + + update_response = self.client.put( + url, chronic_condition_data_updated, format="json" + ) + self.assertEqual(update_response.status_code, 403) diff --git a/care/emr/tests/test_schedule_api.py b/care/emr/tests/test_schedule_api.py index 30a3bf4de4..2663a93538 100644 --- a/care/emr/tests/test_schedule_api.py +++ b/care/emr/tests/test_schedule_api.py @@ -1,5 +1,6 @@ from datetime import UTC, datetime, timedelta +from django.test.utils import ignore_warnings from django.urls import reverse from rest_framework import status @@ -19,6 +20,7 @@ from care.utils.tests.base import CareAPITestBase +@ignore_warnings(category=RuntimeWarning, message=r".*received a naive datetime.*") class TestScheduleViewSet(CareAPITestBase): def setUp(self): super().setUp() @@ -365,6 +367,7 @@ def test_delete_schedule_with_future_cancelled_bookings(self): self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) +@ignore_warnings(category=RuntimeWarning, message=r".*received a naive datetime.*") class TestAvailabilityExceptionsViewSet(CareAPITestBase): def setUp(self): super().setUp() @@ -612,6 +615,7 @@ def test_create_exception_with_bookings(self): ) +@ignore_warnings(category=RuntimeWarning, message=r".*received a naive datetime.*") class TestAvailabilityViewSet(CareAPITestBase): def setUp(self): super().setUp() diff --git a/care/utils/tests/test_feature_flags.py b/care/utils/tests/test_feature_flags.py index 6afda21dbb..6b7fd7c37c 100644 --- a/care/utils/tests/test_feature_flags.py +++ b/care/utils/tests/test_feature_flags.py @@ -1,10 +1,6 @@ from django.test import TestCase -from care.utils.registries.feature_flag import ( - FlagNotFoundError, - FlagRegistry, - FlagType, -) +from care.utils.registries.feature_flag import FlagNotFoundError, FlagRegistry, FlagType # ruff: noqa: SLF001 @@ -25,10 +21,6 @@ def test_unregister_flag(self): FlagRegistry.unregister(FlagType.USER, "TEST_FLAG") self.assertFalse(FlagRegistry._flags[FlagType.USER].get("TEST_FLAG")) - def test_unregister_flag_not_found(self): - FlagRegistry.unregister(FlagType.USER, "TEST_FLAG") - self.assertEqual(FlagRegistry._flags, {}) - def test_validate_flag_type(self): FlagRegistry.register(FlagType.USER, "TEST_FLAG") self.assertIsNone(FlagRegistry.validate_flag_type(FlagType.USER)) diff --git a/config/api_router.py b/config/api_router.py index 6cd54c4bc9..913ffe9974 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -8,7 +8,11 @@ from care.emr.api.otp_viewsets.slot import OTPSlotViewSet 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.condition import ( + ChronicConditionViewSet, + DiagnosisViewSet, + SymptomViewSet, +) from care.emr.api.viewsets.encounter import EncounterViewSet from care.emr.api.viewsets.facility import ( AllFacilityViewSet, @@ -243,6 +247,9 @@ patient_nested_router.register(r"symptom", SymptomViewSet, basename="symptom") patient_nested_router.register(r"diagnosis", DiagnosisViewSet, basename="diagnosis") +patient_nested_router.register( + r"chronic_condition", ChronicConditionViewSet, basename="chronic-condition" +) patient_nested_router.register( "observation", ObservationViewSet, basename="observation" diff --git a/config/settings/test.py b/config/settings/test.py index 7d53033891..306bf456f0 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -68,7 +68,11 @@ "django.request": { "handlers": ["console"], "level": "ERROR", - } + }, + "audit_log": { + "handlers": ["console"], + "level": "ERROR", + }, }, "root": {"level": "INFO", "handlers": ["console"]}, } diff --git a/pyproject.toml b/pyproject.toml index 5693068970..94bf54cb33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,27 +1,39 @@ [tool.coverage.run] branch = true -source = ["care"] -parallel = true -concurrency = ["multiprocessing"] -relative_files = true +source = ["care", "config", "plugs"] +# parallel = true +# concurrency = ["multiprocessing"] +# relative_files = true omit = [ - "*/tests/*", - "*/migrations*/*", - "*/asgi.py", - "*/wsgi.py", - "docs/*", - "manage.py", - ".venv/*", + "care/facility/**", + "care/users/**", + "*/tests/**", + "*/migrations/**" ] + [tool.coverage.report] -exclude_lines = ["pragma: no cover", "raise NotImplementedError"] +exclude_also = [ + 'def __repr__', + 'if self.debug:', + 'if settings.DEBUG', + 'raise AssertionError', + 'raise NotImplementedError', + 'if __name__ == .__main__.:', + 'if TYPE_CHECKING:', +] ignore_errors = true +omit = [ + "care/facility/**", + "care/users/**", + "*/tests/**", + "*/migrations*/**" +] [tool.ruff] -target-version = "py312" -extend-exclude = ["*/migrations*/*", "care/abdm/*"] +target-version = "py313" +extend-exclude = ["*/migrations*/*"] include = ["*.py", "pyproject.toml"] [tool.ruff.lint] From b9c52a6a2c4564746ccd8802a1f1669c139924e4 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Fri, 24 Jan 2025 17:22:09 +0530 Subject: [PATCH 13/17] Fix condition updates --- care/emr/resources/condition/spec.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/care/emr/resources/condition/spec.py b/care/emr/resources/condition/spec.py index b06a573af5..60748d76d4 100644 --- a/care/emr/resources/condition/spec.py +++ b/care/emr/resources/condition/spec.py @@ -121,13 +121,4 @@ class ConditionSpecUpdate(BaseConditionSpec): clinical_status: ClinicalStatusChoices | None = None verification_status: VerificationStatusChoices severity: SeverityChoices | None = None - code: Coding = Field(json_schema_extra={"slug": CARE_CODITION_CODE_VALUESET.slug}) - onset: ConditionOnSetSpec = {} note: str | None = None - - @field_validator("code") - @classmethod - def validate_code(cls, code: int): - return validate_valueset( - "code", cls.model_fields["code"].json_schema_extra["slug"], code - ) From 5efcfbb52830a3c607055c25ffc2efbc7d075813 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Fri, 24 Jan 2025 17:31:09 +0530 Subject: [PATCH 14/17] Fix update specs --- care/emr/api/viewsets/medication_statement.py | 2 ++ care/emr/resources/medication/request/spec.py | 1 + care/emr/resources/medication/statement/spec.py | 9 +++++++++ 3 files changed, 12 insertions(+) diff --git a/care/emr/api/viewsets/medication_statement.py b/care/emr/api/viewsets/medication_statement.py index 5e1258f06c..dc88f6246a 100644 --- a/care/emr/api/viewsets/medication_statement.py +++ b/care/emr/api/viewsets/medication_statement.py @@ -9,6 +9,7 @@ from care.emr.resources.medication.statement.spec import ( MedicationStatementReadSpec, MedicationStatementSpec, + MedicationStatementUpdateSpec, ) from care.emr.resources.questionnaire.spec import SubjectType @@ -23,6 +24,7 @@ class MedicationStatementViewSet( database_model = MedicationStatement pydantic_model = MedicationStatementSpec pydantic_read_model = MedicationStatementReadSpec + pydantic_update_model = MedicationStatementUpdateSpec questionnaire_type = "medication_statement" questionnaire_title = "Medication Statement" questionnaire_description = "Medication Statement" diff --git a/care/emr/resources/medication/request/spec.py b/care/emr/resources/medication/request/spec.py index 431ad73f1c..6f034a4299 100644 --- a/care/emr/resources/medication/request/spec.py +++ b/care/emr/resources/medication/request/spec.py @@ -257,6 +257,7 @@ def perform_extra_deserialization(self, is_update, obj): class MedicationRequestUpdateSpec(MedicationRequestResource): status: MedicationRequestStatus + note: str | None = None class MedicationRequestReadSpec(BaseMedicationRequestSpec): diff --git a/care/emr/resources/medication/statement/spec.py b/care/emr/resources/medication/statement/spec.py index 425e9b5fd0..ec412d53ed 100644 --- a/care/emr/resources/medication/statement/spec.py +++ b/care/emr/resources/medication/statement/spec.py @@ -53,6 +53,15 @@ class BaseMedicationStatementSpec(EMRResource): note: str | None = None +class MedicationStatementUpdateSpec(EMRResource): + __model__ = MedicationStatement + __exclude__ = ["patient", "encounter"] + + status: MedicationStatementStatus + effective_period: Period | None = None + note: str | None = None + + class MedicationStatementSpec(BaseMedicationStatementSpec): @field_validator("encounter") @classmethod From 6add747d24a81840d02e18d99f426ae63e1454b6 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Fri, 24 Jan 2025 18:21:18 +0530 Subject: [PATCH 15/17] Update allergy_intolerance update spec and added tests (#2778) --- care/emr/api/viewsets/allergy_intolerance.py | 28 +- .../emr/resources/allergy_intolerance/spec.py | 34 +- .../emr/tests/test_allergy_intolerance_api.py | 778 ++++++++++++++++++ 3 files changed, 823 insertions(+), 17 deletions(-) create mode 100644 care/emr/tests/test_allergy_intolerance_api.py diff --git a/care/emr/api/viewsets/allergy_intolerance.py b/care/emr/api/viewsets/allergy_intolerance.py index d174fb5f4b..979af26197 100644 --- a/care/emr/api/viewsets/allergy_intolerance.py +++ b/care/emr/api/viewsets/allergy_intolerance.py @@ -15,13 +15,14 @@ ) from care.emr.models import Patient from care.emr.models.allergy_intolerance import AllergyIntolerance +from care.emr.models.encounter import Encounter from care.emr.registries.system_questionnaire.system_questionnaire import ( InternalQuestionnaireRegistry, ) from care.emr.resources.allergy_intolerance.spec import ( - AllergyIntoleranceSpec, + AllergyIntoleranceReadSpec, + AllergyIntoleranceUpdateSpec, AllergyIntoleranceWriteSpec, - AllergyIntrolanceSpecRead, ) from care.emr.resources.questionnaire.spec import SubjectType from care.security.authorization import AuthorizationController @@ -32,7 +33,7 @@ class AllergyIntoleranceFilters(FilterSet): @extend_schema_view( - create=extend_schema(request=AllergyIntoleranceSpec), + create=extend_schema(request=AllergyIntoleranceWriteSpec), ) class AllergyIntoleranceViewSet( EMRQuestionnaireResponseMixin, @@ -44,9 +45,9 @@ class AllergyIntoleranceViewSet( EMRUpsertMixin, ): database_model = AllergyIntolerance - pydantic_model = AllergyIntoleranceSpec - pydantic_read_model = AllergyIntrolanceSpecRead - pydantic_update_model = AllergyIntoleranceWriteSpec + pydantic_model = AllergyIntoleranceWriteSpec + pydantic_read_model = AllergyIntoleranceReadSpec + pydantic_update_model = AllergyIntoleranceUpdateSpec questionnaire_type = "allergy_intolerance" questionnaire_title = "Allergy Intolerance" questionnaire_description = "Allergy Intolerance" @@ -59,15 +60,24 @@ def get_patient_obj(self): Patient, external_id=self.kwargs["patient_external_id"] ) - def authorize_update(self, request_obj, model_instance): - self.authorize_create({}) - def authorize_create(self, instance): if not AuthorizationController.call( "can_write_patient_obj", self.request.user, self.get_patient_obj() ): raise PermissionDenied("You do not have permission to update encounter") + def authorize_update(self, request_obj, model_instance): + encounter = get_object_or_404(Encounter, external_id=request_obj.encounter) + if not AuthorizationController.call( + "can_update_encounter_obj", + self.request.user, + encounter, + ): + raise PermissionDenied("You do not have permission to update encounter") + + 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() diff --git a/care/emr/resources/allergy_intolerance/spec.py b/care/emr/resources/allergy_intolerance/spec.py index d67e5bc77b..40a16c95fb 100644 --- a/care/emr/resources/allergy_intolerance/spec.py +++ b/care/emr/resources/allergy_intolerance/spec.py @@ -52,27 +52,39 @@ class BaseAllergyIntoleranceSpec(EMRResource): id: UUID4 = None -class AllergyIntoleranceWriteSpec(BaseAllergyIntoleranceSpec): +class AllergyIntoleranceUpdateSpec(BaseAllergyIntoleranceSpec): clinical_status: ClinicalStatusChoices verification_status: VerificationStatusChoices - category: CategoryChoices criticality: CriticalityChoices last_occurrence: datetime.datetime | None = None - recorded_date: datetime.datetime | None = None + note: str | None = None + encounter: UUID4 - onset: AllergyIntoleranceOnSetSpec = {} + @field_validator("encounter") + @classmethod + def validate_encounter_exists(cls, encounter): + if not Encounter.objects.filter(external_id=encounter).exists(): + err = "Encounter not found" + raise ValueError(err) + return encounter def perform_extra_deserialization(self, is_update, obj): - if not is_update: + if self.encounter: obj.encounter = Encounter.objects.get(external_id=self.encounter) - obj.patient = obj.encounter.patient -class AllergyIntoleranceSpec(AllergyIntoleranceWriteSpec): +class AllergyIntoleranceWriteSpec(BaseAllergyIntoleranceSpec): + clinical_status: ClinicalStatusChoices + verification_status: VerificationStatusChoices + category: CategoryChoices + criticality: CriticalityChoices + last_occurrence: datetime.datetime | None = None + recorded_date: datetime.datetime | None = None encounter: UUID4 code: Coding = Field( {}, json_schema_extra={"slug": CARE_ALLERGY_CODE_VALUESET.slug} ) + onset: AllergyIntoleranceOnSetSpec = {} @field_validator("code") @classmethod @@ -89,8 +101,12 @@ def validate_encounter_exists(cls, encounter): raise ValueError(err) return encounter + def perform_extra_deserialization(self, is_update, obj): + obj.encounter = Encounter.objects.get(external_id=self.encounter) + obj.patient = obj.encounter.patient + -class AllergyIntrolanceSpecRead(BaseAllergyIntoleranceSpec): +class AllergyIntoleranceReadSpec(BaseAllergyIntoleranceSpec): """ Validation for deeper models may not be required on read, Just an extra optimisation """ @@ -115,3 +131,5 @@ def perform_extra_serialization(cls, mapping, obj): mapping["created_by"] = UserSpec.serialize(obj.created_by) if obj.updated_by: mapping["updated_by"] = UserSpec.serialize(obj.updated_by) + if obj.encounter: + mapping["encounter"] = obj.encounter.external_id diff --git a/care/emr/tests/test_allergy_intolerance_api.py b/care/emr/tests/test_allergy_intolerance_api.py new file mode 100644 index 0000000000..1cda88db8b --- /dev/null +++ b/care/emr/tests/test_allergy_intolerance_api.py @@ -0,0 +1,778 @@ +import uuid +from secrets import choice +from unittest.mock import patch + +from django.forms import model_to_dict +from django.urls import reverse +from model_bakery import baker + +from care.emr.models.allergy_intolerance import AllergyIntolerance +from care.emr.resources.allergy_intolerance.spec import ( + CategoryChoices, + ClinicalStatusChoices, + CriticalityChoices, + VerificationStatusChoices, +) +from care.emr.resources.resource_request.spec import StatusChoices +from care.security.permissions.encounter import EncounterPermissions +from care.security.permissions.patient import PatientPermissions +from care.utils.tests.base import CareAPITestBase + + +class TestAllergyIntoleranceViewSet(CareAPITestBase): + def setUp(self): + super().setUp() + self.user = self.create_user() + self.facility = self.create_facility(user=self.user) + self.organization = self.create_facility_organization(facility=self.facility) + self.patient = self.create_patient() + self.client.force_authenticate(user=self.user) + + self.base_url = reverse( + "allergy-intolerance-list", + kwargs={"patient_external_id": self.patient.external_id}, + ) + self.valid_code = { + "display": "Test Value", + "system": "http://test_system.care/test", + "code": "123", + } + # Mocking validate_valueset + self.patcher = patch( + "care.emr.resources.allergy_intolerance.spec.validate_valueset", + return_value=self.valid_code, + ) + self.mock_validate_valueset = self.patcher.start() + + def tearDown(self): + self.patcher.stop() + + def _get_allergy_intolerance_url(self, allergy_intolerance_id): + """Helper to get the detail URL for a specific allergy_intolerance.""" + return reverse( + "allergy-intolerance-detail", + kwargs={ + "patient_external_id": self.patient.external_id, + "external_id": allergy_intolerance_id, + }, + ) + + def create_allergy_intolerance(self, encounter, patient, **kwargs): + clinical_status = kwargs.pop( + "clinical_status", choice(list(ClinicalStatusChoices)).value + ) + verification_status = kwargs.pop( + "verification_status", choice(list(VerificationStatusChoices)).value + ) + category = kwargs.pop("category", choice(list(CategoryChoices)).value) + criticality = kwargs.pop("criticality", choice(list(CriticalityChoices)).value) + + return baker.make( + AllergyIntolerance, + encounter=encounter, + patient=patient, + category=category, + clinical_status=clinical_status, + verification_status=verification_status, + criticality=criticality, + **kwargs, + ) + + def generate_data_for_allergy_intolerance(self, encounter, **kwargs): + clinical_status = kwargs.pop( + "clinical_status", choice(list(ClinicalStatusChoices)).value + ) + verification_status = kwargs.pop( + "verification_status", choice(list(VerificationStatusChoices)).value + ) + category = kwargs.pop("category", choice(list(CategoryChoices)).value) + criticality = kwargs.pop("criticality", choice(list(CriticalityChoices)).value) + code = self.valid_code + return { + "encounter": encounter.external_id, + "category": category, + "clinical_status": clinical_status, + "verification_status": verification_status, + "criticality": criticality, + "code": code, + **kwargs, + } + + # LIST TESTS + def test_list_allergy_intolerance_with_permissions(self): + """ + Users with `can_view_clinical_data` on a non-completed encounter + can list allergy_intolerance (HTTP 200). + """ + # Attach the needed role/permission + permissions = [PatientPermissions.can_view_clinical_data.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + # Create an active encounter + self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=None, + ) + + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, 200) + + def test_list_allergy_intolerance_with_permissions_and_encounter_status_as_completed( + self, + ): + """ + Users with `can_view_clinical_data` but a completed encounter => (HTTP 403). + """ + permissions = [PatientPermissions.can_view_clinical_data.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=StatusChoices.completed.value, + ) + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, 403) + + def test_list_allergy_intolerance_without_permissions(self): + """ + Users without `can_view_clinical_data` => (HTTP 403). + """ + # No permission attached + self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=None, + ) + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, 403) + + def test_list_allergy_intolerance_for_single_encounter_with_permissions(self): + """ + Users with `can_view_clinical_data` can list allergy_intolerance for that encounter (HTTP 200). + """ + permissions = [PatientPermissions.can_view_clinical_data.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=None, + ) + + url = f"{self.base_url}?encounter={encounter.external_id}" + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_list_allergy_intolerance_for_single_encounter_with_permissions_and_encounter_status_completed( + self, + ): + """ + Users with `can_view_clinical_data` on a completed encounter cannot list allergy_intolerance (HTTP 200). + """ + permissions = [PatientPermissions.can_view_clinical_data.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=StatusChoices.completed.value, + ) + url = f"{self.base_url}?encounter={encounter.external_id}" + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + def test_list_allergy_intolerance_for_single_encounter_without_permissions(self): + """ + Users without `can_view_clinical_data` or `can_view_clinical_data` => (HTTP 403). + """ + # No relevant permission + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=None, + ) + url = f"{self.base_url}?encounter={encounter.external_id}" + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + # CREATE TESTS + def test_create_allergy_intolerance_without_permissions(self): + """ + Users who lack `can_write_patient` get (HTTP 403) when creating. + """ + # No permission attached + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=None, + ) + allergy_intolerance_data_dict = self.generate_data_for_allergy_intolerance( + encounter + ) + + response = self.client.post( + self.base_url, allergy_intolerance_data_dict, format="json" + ) + self.assertEqual(response.status_code, 403) + + def test_create_allergy_intolerance_without_permissions_on_facility(self): + """ + Tests that a user with `can_write_patient` permissions but belonging to a different + organization receives (HTTP 403) when attempting to create a allergy_intolerance. + """ + permissions = [ + PatientPermissions.can_view_clinical_data.name, + PatientPermissions.can_write_patient.name, + ] + role = self.create_role_with_permissions(permissions) + external_user = self.create_user() + external_facility = self.create_facility(user=external_user) + external_organization = self.create_facility_organization( + facility=external_facility + ) + self.attach_role_facility_organization_user( + external_organization, self.user, role + ) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=None, + ) + allergy_intolerance_data_dict = self.generate_data_for_allergy_intolerance( + encounter + ) + + response = self.client.post( + self.base_url, allergy_intolerance_data_dict, format="json" + ) + self.assertEqual(response.status_code, 403) + + def test_create_allergy_intolerance_with_organization_user_with_permissions(self): + """ + Ensures that a user from a certain organization, who has both + `can_write_patient` and `can_view_clinical_data`, can successfully + view allergy_intolerance data (HTTP 200) and is able to edit allergy_intolerance + and allergy_intolerance can change across encounters. + """ + organization = self.create_organization(org_type="govt") + patient = self.create_patient(geo_organization=organization) + + permissions = [ + PatientPermissions.can_write_patient.name, + PatientPermissions.can_view_clinical_data.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_organization_user(organization, self.user, role) + + # Verify the user can view allergy_intolerance data (HTTP 200) + test_url = reverse( + "allergy-intolerance-list", + kwargs={"patient_external_id": patient.external_id}, + ) + response = self.client.get(test_url) + self.assertEqual(response.status_code, 200) + + encounter = self.create_encounter( + patient=patient, + facility=self.facility, + organization=self.organization, + status=None, + ) + + allergy_intolerance_data_dict = self.generate_data_for_allergy_intolerance( + encounter + ) + response = self.client.post( + test_url, allergy_intolerance_data_dict, format="json" + ) + + self.assertEqual(response.status_code, 200) + + def test_create_allergy_intolerance_with_permissions(self): + """ + Users with `can_write_patient` on a non-completed encounter => (HTTP 200). + """ + permissions = [PatientPermissions.can_write_patient.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=None, + ) + allergy_intolerance_data_dict = self.generate_data_for_allergy_intolerance( + encounter + ) + + response = self.client.post( + self.base_url, allergy_intolerance_data_dict, format="json" + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json()["criticality"], allergy_intolerance_data_dict["criticality"] + ) + self.assertEqual(response.json()["code"], allergy_intolerance_data_dict["code"]) + + def test_create_allergy_intolerance_with_permissions_and_encounter_status_completed( + self, + ): + """ + Users with `can_write_patient` on a completed encounter => (HTTP 403). + """ + permissions = [PatientPermissions.can_write_patient.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=StatusChoices.completed.value, + ) + allergy_intolerance_data_dict = self.generate_data_for_allergy_intolerance( + encounter + ) + + response = self.client.post( + self.base_url, allergy_intolerance_data_dict, format="json" + ) + self.assertEqual(response.status_code, 403) + + def test_create_allergy_intolerance_with_permissions_and_no_association_with_facility( + self, + ): + """ + Test that users with `can_write_patient` permission, but who are not + associated with the facility, receive an HTTP 403 (Forbidden) response + when attempting to create a allergy_intolerance. + """ + permissions = [PatientPermissions.can_write_patient.name] + role = self.create_role_with_permissions(permissions) + organization = self.create_organization(org_type="govt") + self.attach_role_organization_user(organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=None, + ) + allergy_intolerance_data_dict = self.generate_data_for_allergy_intolerance( + encounter + ) + + response = self.client.post( + self.base_url, allergy_intolerance_data_dict, format="json" + ) + self.assertEqual(response.status_code, 403) + + def test_create_allergy_intolerance_with_permissions_with_mismatched_patient_id( + self, + ): + """ + Users with `can_write_patient` on a encounter with different patient => (HTTP 403). + """ + permissions = [ + PatientPermissions.can_view_clinical_data.name, + PatientPermissions.can_write_patient.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.create_patient(), + facility=self.facility, + organization=self.organization, + status=None, + ) + allergy_intolerance_data_dict = self.generate_data_for_allergy_intolerance( + encounter + ) + + response = self.client.post( + self.base_url, allergy_intolerance_data_dict, format="json" + ) + self.assertEqual(response.status_code, 403) + + def test_create_allergy_intolerance_with_permissions_with_invalid_encounter_id( + self, + ): + """ + Users with `can_write_patient` on a incomplete encounter => (HTTP 400). + """ + permissions = [PatientPermissions.can_write_patient.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.create_patient(), + facility=self.facility, + organization=self.organization, + status=None, + ) + allergy_intolerance_data_dict = self.generate_data_for_allergy_intolerance( + encounter + ) + allergy_intolerance_data_dict["encounter"] = uuid.uuid4() + + response = self.client.post( + self.base_url, allergy_intolerance_data_dict, format="json" + ) + response_data = response.json() + self.assertEqual(response.status_code, 400) + self.assertIn("errors", response_data) + error = response_data["errors"][0] + self.assertEqual(error["type"], "value_error") + self.assertIn("Encounter not found", error["msg"]) + + # RETRIEVE TESTS + def test_retrieve_allergy_intolerance_with_permissions(self): + """ + Users with `can_view_clinical_data` => (HTTP 200). + """ + permissions = [PatientPermissions.can_view_clinical_data.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + allergy_intolerance = self.create_allergy_intolerance( + encounter=encounter, patient=self.patient + ) + + url = self._get_allergy_intolerance_url(allergy_intolerance.external_id) + retrieve_response = self.client.get(url) + self.assertEqual(retrieve_response.status_code, 200) + self.assertEqual( + retrieve_response.data["id"], str(allergy_intolerance.external_id) + ) + + def test_retrieve_allergy_intolerance_for_single_encounter_with_permissions(self): + """ + Users with `can_view_clinical_data` => (HTTP 200). + """ + permissions = [ + PatientPermissions.can_view_clinical_data.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + allergy_intolerance = self.create_allergy_intolerance( + encounter=encounter, patient=self.patient + ) + + url = self._get_allergy_intolerance_url(allergy_intolerance.external_id) + retrieve_response = self.client.get(f"{url}?encounter={encounter.external_id}") + self.assertEqual(retrieve_response.status_code, 200) + self.assertEqual( + retrieve_response.data["id"], str(allergy_intolerance.external_id) + ) + + def test_retrieve_allergy_intolerance_for_single_encounter_without_permissions( + self, + ): + """ + Lacking `can_view_clinical_data` => (HTTP 403). + """ + # No relevant permission + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + allergy_intolerance = self.create_allergy_intolerance( + encounter=encounter, patient=self.patient + ) + + url = self._get_allergy_intolerance_url(allergy_intolerance.external_id) + retrieve_response = self.client.get(f"{url}?encounter={encounter.external_id}") + self.assertEqual(retrieve_response.status_code, 403) + + def test_retrieve_allergy_intolerance_without_permissions(self): + """ + Users who have only `can_write_patient` => (HTTP 403). + """ + # No relevant permission + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + allergy_intolerance = self.create_allergy_intolerance( + encounter=encounter, patient=self.patient + ) + + url = self._get_allergy_intolerance_url(allergy_intolerance.external_id) + retrieve_response = self.client.get(url) + self.assertEqual(retrieve_response.status_code, 403) + + # UPDATE TESTS + def test_update_allergy_intolerance_with_permissions(self): + """ + Users with `can_write_encounter` + `can_write_patient` + `can_view_clinical_data` + => (HTTP 200) when updating. + """ + permissions = [ + PatientPermissions.can_view_clinical_data.name, + PatientPermissions.can_write_patient.name, + EncounterPermissions.can_write_encounter.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + allergy_intolerance = self.create_allergy_intolerance( + encounter=encounter, patient=self.patient + ) + + url = self._get_allergy_intolerance_url(allergy_intolerance.external_id) + allergy_intolerance_data_updated = model_to_dict(allergy_intolerance) + allergy_intolerance_data_updated["encounter"] = encounter.external_id + allergy_intolerance_data_updated["criticality"] = "high" + allergy_intolerance_data_updated["code"] = self.valid_code + + response = self.client.put(url, allergy_intolerance_data_updated, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["criticality"], "high") + + def test_update_allergy_intolerance_for_single_encounter_with_permissions(self): + """ + Users with `can_write_encounter` + `can_write_patient` + `can_view_clinical_data` + => (HTTP 200). + """ + permissions = [ + PatientPermissions.can_view_clinical_data.name, + PatientPermissions.can_write_patient.name, + EncounterPermissions.can_write_encounter.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + allergy_intolerance = self.create_allergy_intolerance( + encounter=encounter, patient=self.patient + ) + + url = self._get_allergy_intolerance_url(allergy_intolerance.external_id) + allergy_intolerance_data_updated = model_to_dict(allergy_intolerance) + allergy_intolerance_data_updated["encounter"] = encounter.external_id + allergy_intolerance_data_updated["criticality"] = "high" + allergy_intolerance_data_updated["code"] = self.valid_code + + update_response = self.client.put( + f"{url}?encounter={encounter.external_id}", + allergy_intolerance_data_updated, + format="json", + ) + self.assertEqual(update_response.status_code, 200) + self.assertEqual(update_response.json()["criticality"], "high") + + def test_update_allergy_intolerance_for_single_encounter_without_permissions(self): + """ + Lacking `can_view_clinical_data` => (HTTP 403). + """ + # Only write permission + permissions = [PatientPermissions.can_write_patient.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + allergy_intolerance = self.create_allergy_intolerance( + encounter=encounter, patient=self.patient + ) + + url = self._get_allergy_intolerance_url(allergy_intolerance.external_id) + allergy_intolerance_data_updated = model_to_dict(allergy_intolerance) + allergy_intolerance_data_updated["criticality"] = "high" + + update_response = self.client.put( + f"{url}?encounter={encounter.external_id}", + allergy_intolerance_data_updated, + format="json", + ) + self.assertEqual(update_response.status_code, 403) + + def test_update_allergy_intolerance_without_permissions(self): + """ + Users with only `can_write_patient` but not `can_view_clinical_data` + => (HTTP 403). + """ + # Only write permission (same scenario as above but no read or view clinical) + + permissions = [PatientPermissions.can_write_patient.name] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + allergy_intolerance = self.create_allergy_intolerance( + encounter=encounter, patient=self.patient + ) + + url = self._get_allergy_intolerance_url(allergy_intolerance.external_id) + allergy_intolerance_data_updated = model_to_dict(allergy_intolerance) + allergy_intolerance_data_updated["criticality"] = "high" + + update_response = self.client.put( + url, allergy_intolerance_data_updated, format="json" + ) + self.assertEqual(update_response.status_code, 403) + + def test_update_allergy_intolerance_for_closed_encounter_with_permissions(self): + """ + Encounter completed => (HTTP 403) on update, + even if user has `can_write_patient` + `can_view_clinical_data`. + """ + permissions = [ + PatientPermissions.can_write_patient.name, + PatientPermissions.can_view_clinical_data.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + status=StatusChoices.completed.value, + ) + allergy_intolerance = self.create_allergy_intolerance( + encounter=encounter, patient=self.patient + ) + + url = self._get_allergy_intolerance_url(allergy_intolerance.external_id) + allergy_intolerance_data_updated = model_to_dict(allergy_intolerance) + allergy_intolerance_data_updated["criticality"] = "high" + + update_response = self.client.put( + url, allergy_intolerance_data_updated, format="json" + ) + self.assertEqual(update_response.status_code, 403) + + def test_update_allergy_intolerance_changes_encounter_id(self): + """ + When a user with access to a new encounter + updates a allergy_intolerance added by a different encounter, + the encounter_id should be updated to the new encounter. + """ + permissions = [ + PatientPermissions.can_write_patient.name, + PatientPermissions.can_view_clinical_data.name, + EncounterPermissions.can_write_encounter.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + temp_facility = self.create_facility(user=self.create_user()) + encounter = self.create_encounter( + patient=self.patient, + facility=temp_facility, + organization=self.create_facility_organization(facility=temp_facility), + ) + + allergy_intolerance = self.create_allergy_intolerance( + encounter=encounter, + patient=self.patient, + code=self.valid_code, + ) + + new_encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + + url = self._get_allergy_intolerance_url(allergy_intolerance.external_id) + allergy_intolerance_data_updated = model_to_dict(allergy_intolerance) + allergy_intolerance_data_updated["encounter"] = new_encounter.external_id + allergy_intolerance_data_updated["clinical_status"] = "inactive" + + update_response = self.client.put( + url, allergy_intolerance_data_updated, format="json" + ) + self.assertEqual(update_response.status_code, 200) + self.assertEqual( + update_response.json()["encounter"], str(new_encounter.external_id) + ) + + def test_update_allergy_intolerance_changes_encounter_id_without_permission(self): + """ + When a user without access to a new encounter + updates a allergy_intolerance added by a different encounter, + the encounter_id should not be updated to the new encounter. + """ + permissions = [ + PatientPermissions.can_write_patient.name, + PatientPermissions.can_view_clinical_data.name, + ] + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user(self.organization, self.user, role) + + temp_facility = self.create_facility(user=self.create_user()) + encounter = self.create_encounter( + patient=self.patient, + facility=temp_facility, + organization=self.create_facility_organization(facility=temp_facility), + ) + + allergy_intolerance = self.create_allergy_intolerance( + encounter=encounter, + patient=self.patient, + code=self.valid_code, + ) + + new_encounter = self.create_encounter( + patient=self.patient, + facility=self.facility, + organization=self.organization, + ) + + url = self._get_allergy_intolerance_url(allergy_intolerance.external_id) + allergy_intolerance_data_updated = model_to_dict(allergy_intolerance) + allergy_intolerance_data_updated["encounter"] = new_encounter.external_id + allergy_intolerance_data_updated["clinical_status"] = "inactive" + + update_response = self.client.put( + url, allergy_intolerance_data_updated, format="json" + ) + self.assertEqual(update_response.status_code, 403) From d7b4084d07b5cd9572ff92a931fa6cbfc79b5fdf Mon Sep 17 00:00:00 2001 From: Prafful Date: Sun, 26 Jan 2025 18:19:23 +0530 Subject: [PATCH 16/17] updated decorator to include tags --- care/emr/api/viewsets/facility.py | 25 +++++++++--------- care/emr/api/viewsets/questionnaire.py | 9 ++++--- care/emr/api/viewsets/user.py | 28 ++++++++++---------- care/utils/decorators/schema_decorator.py | 31 ++++++++++++++++++----- care/utils/tests/test_swagger_schema.py | 8 ++++++ 5 files changed, 64 insertions(+), 37 deletions(-) create mode 100644 care/utils/tests/test_swagger_schema.py diff --git a/care/emr/api/viewsets/facility.py b/care/emr/api/viewsets/facility.py index 1f2a3c5d0c..da19c544d8 100644 --- a/care/emr/api/viewsets/facility.py +++ b/care/emr/api/viewsets/facility.py @@ -114,23 +114,22 @@ def authorize_destroy(self, instance): raise PermissionDenied("Only Super Admins can delete Facilities") @method_decorator(parser_classes([MultiPartParser])) - @action(methods=["POST"], detail=True) + @action(methods=["POST", "DELETE"], detail=True) def cover_image(self, request, external_id): facility = self.get_object() self.authorize_update({}, facility) - serializer = FacilityImageUploadSerializer(facility, data=request.data) - serializer.is_valid(raise_exception=True) - serializer.save() - return Response(serializer.data) - @cover_image.mapping.delete - def cover_image_delete(self, *args, **kwargs): - facility = self.get_object() - self.authorize_update({}, facility) - delete_cover_image(facility.cover_image_url, "cover_images") - facility.cover_image_url = None - facility.save() - return Response(status=204) + if request.method == "POST": + serializer = FacilityImageUploadSerializer(facility, data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + if request.method == "DELETE": + delete_cover_image(facility.cover_image_url, "cover_images") + facility.cover_image_url = None + facility.save() + return Response(status=204) + return Response(data="Method Not Allowed", status=405) @generate_swagger_schema_decorator diff --git a/care/emr/api/viewsets/questionnaire.py b/care/emr/api/viewsets/questionnaire.py index 47499d9e3e..51afb91fc2 100644 --- a/care/emr/api/viewsets/questionnaire.py +++ b/care/emr/api/viewsets/questionnaire.py @@ -80,6 +80,7 @@ class QuestionnaireViewSet(EMRModelViewSet): lookup_field = "slug" filterset_class = QuestionnaireFilter filter_backends = [filters.DjangoFilterBackend] + tags = ["Questionnaire"] def permissions_controller(self, request): if self.action in ["list", "retrieve", "get_organizations"]: @@ -139,6 +140,7 @@ def get_queryset(self): @extend_schema( request=QuestionnaireSubmitRequest, responses=QuestionnaireResponseReadSpec, + tags=["Questionnaire"], ) @action(detail=True, methods=["POST"]) def submit(self, request, *args, **kwargs): @@ -163,6 +165,7 @@ def submit(self, request, *args, **kwargs): response = handle_response(questionnaire, request_params, request.user) return Response(QuestionnaireResponseReadSpec.serialize(response).to_json()) + @extend_schema(tags=["Questionnaire"]) @action(detail=True, methods=["GET"]) def get_organizations(self, request, *args, **kwargs): """ @@ -186,9 +189,7 @@ def get_organizations(self, request, *args, **kwargs): class QuestionnaireTagsSetSchema(BaseModel): tags: list[str] - @extend_schema( - request=QuestionnaireTagsSetSchema, - ) + @extend_schema(request=QuestionnaireTagsSetSchema, tags=["Questionnaire"]) @action(detail=True, methods=["POST"]) def set_tags(self, request, *args, **kwargs): questionnaire = self.get_object() @@ -208,7 +209,7 @@ class QuestionnaireOrganizationUpdateSchema(BaseModel): organizations: list[UUID4] @extend_schema( - request=QuestionnaireOrganizationUpdateSchema, + request=QuestionnaireOrganizationUpdateSchema, tags=["Questionnaire"] ) @action(detail=True, methods=["POST"]) def set_organizations(self, request, *args, **kwargs): diff --git a/care/emr/api/viewsets/user.py b/care/emr/api/viewsets/user.py index 38b3cb6d88..79241effdf 100644 --- a/care/emr/api/viewsets/user.py +++ b/care/emr/api/viewsets/user.py @@ -100,25 +100,25 @@ def check_availability(self, request, username): return Response(status=200) @method_decorator(parser_classes([MultiPartParser])) - @action(detail=True, methods=["POST"], permission_classes=[IsAuthenticated]) + @action( + detail=True, methods=["POST", "DELETE"], permission_classes=[IsAuthenticated] + ) def profile_picture(self, request, *args, **kwargs): user = self.get_object() if not self.authorize_update({}, user): raise PermissionDenied("Permission Denied") - serializer = UserImageUploadSerializer(user, data=request.data) - serializer.is_valid(raise_exception=True) - serializer.save() - return Response(status=200) - @profile_picture.mapping.delete - def profile_picture_delete(self, request, *args, **kwargs): - user = self.get_object() - if not self.authorize_update({}, user): - raise PermissionDenied("Permission Denied") - delete_cover_image(user.profile_picture_url, "avatars") - user.profile_picture_url = None - user.save() - return Response(status=204) + if request.method == "POST": + serializer = UserImageUploadSerializer(user, data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(status=200) + if request.method == "DELETE": + delete_cover_image(user.profile_picture_url, "avatars") + user.profile_picture_url = None + user.save() + return Response(status=204) + return Response(data="Method Not Allowed", status=405) @action( detail=True, diff --git a/care/utils/decorators/schema_decorator.py b/care/utils/decorators/schema_decorator.py index dafa1ad47b..df7e853e8c 100644 --- a/care/utils/decorators/schema_decorator.py +++ b/care/utils/decorators/schema_decorator.py @@ -2,24 +2,43 @@ def generate_swagger_schema_decorator(cls): - actions = { + if not hasattr(cls, "tags") and not cls.tags: + cls.tags = [cls.__name__] + + base_actions = { "create": { "request": cls.pydantic_model, "responses": {200: cls.pydantic_read_model or cls.pydantic_model}, }, "update": { - "request": cls.pydantic_retrieve_model - or cls.pydantic_read_model - or cls.pydantic_model, + "request": cls.pydantic_update_model or cls.pydantic_model, "responses": {200: cls.pydantic_read_model or cls.pydantic_model}, }, "list": {"responses": {200: cls.pydantic_read_model or cls.pydantic_model}}, - "retrieve": {"responses": {200: cls.pydantic_read_model or cls.pydantic_model}}, + "retrieve": { + "responses": { + 200: cls.pydantic_retrieve_model + or cls.pydantic_read_model + or cls.pydantic_model + } + }, + "destroy": {"responses": {204: None}}, + } + + for action in base_actions.values(): + action["tags"] = cls.tags + + extra_actions = { + action.url_path: {"tags": cls.tags} + for action in cls.get_extra_actions() + if hasattr(action, "url_path") } + all_actions = {**base_actions, **extra_actions} + schema_dict = { name: extend_schema(**params) - for name, params in actions.items() + for name, params in all_actions.items() if hasattr(cls, name) and callable(getattr(cls, name)) } diff --git a/care/utils/tests/test_swagger_schema.py b/care/utils/tests/test_swagger_schema.py new file mode 100644 index 0000000000..26514c56c9 --- /dev/null +++ b/care/utils/tests/test_swagger_schema.py @@ -0,0 +1,8 @@ +from django.test import TestCase +from rest_framework import status + + +class SwaggerSchemaTest(TestCase): + def test_swagger_endpoint(self): + response = self.client.get("/swagger/") + self.assertEqual(response.status_code, status.HTTP_200_OK) From 45cdda853eb6967cf1c27ccd94aa92ffe2f67d62 Mon Sep 17 00:00:00 2001 From: Prafful Date: Sun, 26 Jan 2025 18:27:50 +0530 Subject: [PATCH 17/17] update for tags --- care/emr/api/viewsets/base.py | 1 + care/emr/api/viewsets/condition.py | 4 ++++ care/utils/decorators/schema_decorator.py | 4 ++-- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/care/emr/api/viewsets/base.py b/care/emr/api/viewsets/base.py index 26f623d3fc..35393a89a5 100644 --- a/care/emr/api/viewsets/base.py +++ b/care/emr/api/viewsets/base.py @@ -225,6 +225,7 @@ class EMRBaseViewSet(GenericViewSet): pydantic_retrieve_model: EMRResource = None database_model: EMRBaseModel = None lookup_field = "external_id" + tags = [__name__] def get_exception_handler(self): return emr_exception_handler diff --git a/care/emr/api/viewsets/condition.py b/care/emr/api/viewsets/condition.py index b30890e24d..dbc168ac49 100644 --- a/care/emr/api/viewsets/condition.py +++ b/care/emr/api/viewsets/condition.py @@ -29,6 +29,7 @@ ) from care.emr.resources.questionnaire.spec import SubjectType from care.security.authorization import AuthorizationController +from care.utils.decorators.schema_decorator import generate_swagger_schema_decorator class ValidateEncounterMixin: @@ -58,6 +59,7 @@ class ConditionFilters(FilterSet): severity = CharFilter(field_name="severity", lookup_expr="iexact") +@generate_swagger_schema_decorator class SymptomViewSet( ValidateEncounterMixin, EncounterBasedAuthorizationBase, @@ -98,6 +100,7 @@ def get_queryset(self): InternalQuestionnaireRegistry.register(SymptomViewSet) +@generate_swagger_schema_decorator class DiagnosisViewSet( ValidateEncounterMixin, EncounterBasedAuthorizationBase, @@ -139,6 +142,7 @@ def get_queryset(self): InternalQuestionnaireRegistry.register(DiagnosisViewSet) +@generate_swagger_schema_decorator class ChronicConditionViewSet( EMRQuestionnaireResponseMixin, EMRCreateMixin, diff --git a/care/utils/decorators/schema_decorator.py b/care/utils/decorators/schema_decorator.py index df7e853e8c..c082af707c 100644 --- a/care/utils/decorators/schema_decorator.py +++ b/care/utils/decorators/schema_decorator.py @@ -2,8 +2,8 @@ def generate_swagger_schema_decorator(cls): - if not hasattr(cls, "tags") and not cls.tags: - cls.tags = [cls.__name__] + if not cls.tags: + cls.tags = cls.__name__ base_actions = { "create": {