diff --git a/README.md b/README.md index ed20fe7431..63903106d9 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Care backend makes the following features possible: ### Docs and Guides -You can find the docs at https://care-be-docs.coronasafe.network +You can find the docs at https://care-be-docs.ohc.network ### Staging Deployments diff --git a/care/contrib/sites/migrations/0003_set_site_domain_and_name.py b/care/contrib/sites/migrations/0003_set_site_domain_and_name.py index 2078c433f6..4dbc0f2c62 100644 --- a/care/contrib/sites/migrations/0003_set_site_domain_and_name.py +++ b/care/contrib/sites/migrations/0003_set_site_domain_and_name.py @@ -14,7 +14,7 @@ def update_site_forward(apps, schema_editor): Site.objects.update_or_create( id=settings.SITE_ID, defaults={ - "domain": "coronasafe.in", + "domain": "ohc.network", "name": "Care", }, ) diff --git a/care/emr/api/otp_viewsets/login.py b/care/emr/api/otp_viewsets/login.py index 625a5318d3..cdc9750dcf 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) diff --git a/care/emr/api/otp_viewsets/slot.py b/care/emr/api/otp_viewsets/slot.py index 903d3cc951..40e005f9bd 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) diff --git a/care/emr/api/viewsets/base.py b/care/emr/api/viewsets/base.py index 26f623d3fc..6727fbef1c 100644 --- a/care/emr/api/viewsets/base.py +++ b/care/emr/api/viewsets/base.py @@ -3,6 +3,7 @@ from django.db import transaction from django.http.response import Http404 from pydantic import ValidationError +from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import ValidationError as RestFrameworkValidationError from rest_framework.generics import get_object_or_404 @@ -183,11 +184,15 @@ def perform_destroy(self, instance): instance.deleted = True instance.save(update_fields=["deleted"]) + def validate_destroy(self, instance): + pass + def destroy(self, request, *args, **kwargs): instance = self.get_object() + self.validate_destroy(instance) self.authorize_destroy(instance) self.perform_destroy(instance) - return Response(status=204) + return Response(status=status.HTTP_204_NO_CONTENT) class EMRUpsertMixin: 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/encounter.py b/care/emr/api/viewsets/encounter.py index b8e36e5ee6..11eee096c3 100644 --- a/care/emr/api/viewsets/encounter.py +++ b/care/emr/api/viewsets/encounter.py @@ -75,6 +75,7 @@ class EncounterFilters(filters.FilterSet): field_name="patient__phone_number", lookup_expr="icontains" ) name = filters.CharFilter(field_name="patient__name", lookup_expr="icontains") + location = filters.UUIDFilter(field_name="current_location__external_id") live = LiveFilter() @@ -123,7 +124,14 @@ def get_queryset(self): qs = ( super() .get_queryset() - .select_related("patient", "facility", "appointment") + .select_related( + "patient", + "facility", + "appointment", + "current_location", + "created_by", + "updated_by", + ) .order_by("-created_date") ) if ( @@ -175,6 +183,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,6 +207,9 @@ def organizations_add(self, request, *args, **kwargs): ) return Response(FacilityOrganizationReadSpec.serialize(organization).to_json()) + @extend_schema( + request=EncounterOrganizationManageSpec, + ) @action(detail=True, methods=["DELETE"]) def organizations_remove(self, request, *args, **kwargs): instance = self.get_object() @@ -213,7 +228,7 @@ def organizations_remove(self, request, *args, **kwargs): EncounterOrganization.objects.filter( encounter=instance, organization=organization ).delete() - return Response({}, status=204) + return Response({}, status=status.HTTP_204_NO_CONTENT) def _check_discharge_summary_access(self, encounter): if not AuthorizationController.call( @@ -284,6 +299,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() diff --git a/care/emr/api/viewsets/facility.py b/care/emr/api/viewsets/facility.py index f416a3edf0..594c7b373c 100644 --- a/care/emr/api/viewsets/facility.py +++ b/care/emr/api/viewsets/facility.py @@ -112,23 +112,24 @@ 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": + if not facility.cover_image_url: + return Response({"detail": "No cover image to delete"}, status=404) + delete_cover_image(facility.cover_image_url, "cover_images") + facility.cover_image_url = None + facility.save() + return Response(status=204) + return Response({"detail": "Method not allowed"}, status=405) class FacilitySchedulableUsersViewSet(EMRModelReadOnlyViewSet): diff --git a/care/emr/api/viewsets/file_upload.py b/care/emr/api/viewsets/file_upload.py index 033d0ba98c..eb1a7845bc 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() diff --git a/care/emr/api/viewsets/location.py b/care/emr/api/viewsets/location.py new file mode 100644 index 0000000000..e39bb7249b --- /dev/null +++ b/care/emr/api/viewsets/location.py @@ -0,0 +1,392 @@ +from django_filters import rest_framework as filters +from pydantic import UUID4, BaseModel +from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.generics import get_object_or_404 +from rest_framework.response import Response + +from care.emr.api.viewsets.base import ( + EMRBaseViewSet, + EMRCreateMixin, + EMRDestroyMixin, + EMRListMixin, + EMRModelViewSet, + EMRRetrieveMixin, + EMRUpdateMixin, +) +from care.emr.models import ( + Encounter, + FacilityLocation, + FacilityLocationEncounter, + FacilityLocationOrganization, +) +from care.emr.models.organization import FacilityOrganization, FacilityOrganizationUser +from care.emr.resources.facility_organization.spec import FacilityOrganizationReadSpec +from care.emr.resources.location.spec import ( + FacilityLocationEncounterCreateSpec, + FacilityLocationEncounterReadSpec, + FacilityLocationEncounterUpdateSpec, + FacilityLocationListSpec, + FacilityLocationModeChoices, + FacilityLocationRetrieveSpec, + FacilityLocationUpdateSpec, + FacilityLocationWriteSpec, + LocationAvailabilityStatusChoices, + LocationEncounterAvailabilityStatusChoices, +) +from care.facility.models import Facility +from care.security.authorization import AuthorizationController +from care.utils.lock import Lock + + +class FacilityLocationFilter(filters.FilterSet): + parent = filters.UUIDFilter(field_name="parent__external_id") + name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + +class FacilityLocationViewSet(EMRModelViewSet): + database_model = FacilityLocation + pydantic_model = FacilityLocationWriteSpec + pydantic_read_model = FacilityLocationListSpec + pydantic_retrieve_model = FacilityLocationRetrieveSpec + pydantic_update_model = FacilityLocationUpdateSpec + filterset_class = FacilityLocationFilter + filter_backends = [filters.DjangoFilterBackend] + + def get_facility_obj(self): + return get_object_or_404( + Facility, external_id=self.kwargs["facility_external_id"] + ) + + def validate_destroy(self, instance): + # Validate that there is no children if exists + if FacilityLocation.objects.filter(parent=instance).exists(): + raise ValidationError("Location has active children") + # TODO Add validation to check if patient association exists + + def validate_data(self, instance, model_obj=None): + if not model_obj and instance.parent: + parent = get_object_or_404(FacilityLocation, external_id=instance.parent) + if parent.facility_id != instance.facility_id: + raise PermissionDenied("Parent Incompatible with Location") + if parent.mode == FacilityLocationModeChoices.instance.value: + raise ValidationError("Instances cannot have children") + + def authorize_create(self, instance): + facility = self.get_facility_obj() + if instance.parent: + parent = get_object_or_404(FacilityLocation, external_id=instance.parent) + else: + parent = None + if not AuthorizationController.call( + "can_create_facility_location_obj", self.request.user, parent, facility + ): + raise PermissionDenied("You do not have permission to create a location") + if instance.organizations: + for organization in instance.organizations: + organization_obj = get_object_or_404( + FacilityOrganization, external_id=organization + ) + self.authorize_organization(facility, organization_obj) + + def authorize_update(self, request_obj, model_instance): + if not AuthorizationController.call( + "can_update_facility_location_obj", self.request.user, model_instance + ): + raise PermissionDenied("You do not have permission to update this location") + + def authorize_destroy(self, instance): + self.authorize_update({}, instance) + + def perform_create(self, instance): + facility = self.get_facility_obj() + instance.facility = facility + return super().perform_create(instance) + + def get_queryset(self): + facility = self.get_facility_obj() + base_qs = FacilityLocation.objects.filter(facility=facility) + if "mine" in self.request.GET: + # Filter based on direct association + organization_ids = list( + FacilityOrganizationUser.objects.filter( + user=self.request.user, organization__facility=facility + ).values_list("organization_id", flat=True) + ) + base_qs = base_qs.filter( + id__in=FacilityLocationOrganization.objects.filter( + organization_id__in=organization_ids + ).values_list("location_id", flat=True) + ) + return AuthorizationController.call( + "get_accessible_facility_locations", base_qs, self.request.user, facility + ) + + @action(detail=True, methods=["GET"]) + def organizations(self, request, *args, **kwargs): + # AuthZ is controlled from the get_queryset method, no need to repeat + instance = self.get_object() + encounter_organizations = FacilityLocationOrganization.objects.filter( + location=instance + ).select_related("organization") + data = [ + FacilityOrganizationReadSpec.serialize( + encounter_organization.organization + ).to_json() + for encounter_organization in encounter_organizations + ] + return Response({"results": data}) + + class FacilityLocationOrganizationManageSpec(BaseModel): + organization: UUID4 + + def authorize_organization(self, facility, organization): + if organization.facility.id != facility.id: + raise PermissionDenied("Organization Incompatible with Location") + if not AuthorizationController.call( + "can_manage_facility_organization_obj", self.request.user, organization + ): + raise PermissionDenied("You do not have permission to given organizations") + + @action(detail=True, methods=["POST"]) + def organizations_add(self, request, *args, **kwargs): + instance = self.get_object() + request_data = self.FacilityLocationOrganizationManageSpec(**request.data) + organization = get_object_or_404( + FacilityOrganization, external_id=request_data.organization + ) + self.authorize_update({}, instance) + self.authorize_organization(instance.facility, organization) + location_organization = FacilityLocationOrganization.objects.filter( + location=instance, organization=organization + ) + if location_organization.exists(): + raise ValidationError("Organization already exists") + FacilityLocationOrganization.objects.create( + location=instance, organization=organization + ) + return Response(FacilityOrganizationReadSpec.serialize(organization).to_json()) + + @action(detail=True, methods=["POST"]) + def organizations_remove(self, request, *args, **kwargs): + instance = self.get_object() + request_data = self.FacilityLocationOrganizationManageSpec(**request.data) + organization = get_object_or_404( + FacilityOrganization, external_id=request_data.organization + ) + self.authorize_update({}, instance) + self.authorize_organization(instance.facility, organization) + encounter_organization = FacilityLocationOrganization.objects.filter( + location=instance, organization=organization + ) + if not encounter_organization.exists(): + raise ValidationError("Organization does not exist") + FacilityLocationOrganization.objects.filter( + encounter=instance, organization=organization + ).delete() + instance.save() # Recalculate Metadata + instance.cascade_changes() # Recalculate Metadata for children as well. + return Response({}, status=204) + + class FacilityLocationEncounterAssignSpec(BaseModel): + encounter: UUID4 + + @action(detail=True, methods=["POST"]) + def associate_encounter(self, request, *args, **kwargs): + instance = self.get_object() + facility = self.get_facility_obj() + request_data = self.FacilityLocationEncounterAssignSpec(**request.data) + encounter = get_object_or_404(Encounter, external_id=request_data.encounter) + if instance.facility_id != encounter.facility_id: + raise PermissionDenied("Encounter Incompatible with Location") + if not AuthorizationController.call( + "can_list_facility_location_obj", self.request.user, facility, instance + ): + raise PermissionDenied("You do not have permission to given location") + if not AuthorizationController.call( + "can_update_encounter_obj", self.request.user, encounter + ): + raise PermissionDenied("You do not have permission to update encounter") + # TODO, Association models yet to be built + + +class FacilityLocationEncounterFilter(filters.FilterSet): + encounter = filters.UUIDFilter(field_name="encounter__external_id") + + +class FacilityLocationEncounterViewSet( + EMRCreateMixin, + EMRRetrieveMixin, + EMRUpdateMixin, + EMRListMixin, + EMRDestroyMixin, + EMRBaseViewSet, +): + database_model = FacilityLocationEncounter + pydantic_model = FacilityLocationEncounterCreateSpec + pydantic_read_model = FacilityLocationEncounterReadSpec + pydantic_update_model = FacilityLocationEncounterUpdateSpec + filterset_class = FacilityLocationEncounterFilter + filter_backends = [filters.DjangoFilterBackend] + + def get_facility_obj(self): + return get_object_or_404( + Facility, external_id=self.kwargs["facility_external_id"] + ) + + def get_location_obj(self): + return get_object_or_404( + FacilityLocation, external_id=self.kwargs["location_external_id"] + ) + + def authorize_update(self, request_obj, model_instance): + return self.authorize_create(model_instance) + + def authorize_destroy(self, instance): + return self.authorize_create(instance) + + def reset_encounter_location_association(self, location): + """ + Reset encounters to the right location. + """ + active_location_encounter = FacilityLocationEncounter.objects.filter( + location=location, + status=LocationEncounterAvailabilityStatusChoices.active.value, + ).first() + all_encounters = Encounter.objects.filter(current_location=location) + if active_location_encounter: + active_location_encounter.encounter.current_location = location + active_location_encounter.encounter.save(update_fields=["current_location"]) + all_encounters = all_encounters.exclude( + id=active_location_encounter.encounter_id + ) + all_encounters.update(current_location=None) + + def reset_location_availability_status(self, location): + """ + Reset location availability status to the right status. + """ + if FacilityLocationEncounter.objects.filter( + location=location, + status=LocationEncounterAvailabilityStatusChoices.active.value, + ).exists(): + location.availability_status = ( + LocationAvailabilityStatusChoices.unavailable.value + ) + else: + location.availability_status = ( + LocationAvailabilityStatusChoices.available.value + ) + location.save(update_fields=["availability_status"]) + + def authorize_create(self, instance): + facility = self.get_facility_obj() + location = self.get_location_obj() + encounter = instance.encounter + if not isinstance(instance.encounter, Encounter): + encounter = get_object_or_404(Encounter, external_id=encounter) + if location.facility_id != encounter.facility_id: + raise PermissionDenied("Encounter Incompatible with Location") + if not AuthorizationController.call( + "can_list_facility_location_obj", self.request.user, facility, location + ): + raise PermissionDenied("You do not have permission to given location") + 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): + location = self.get_location_obj() + with Lock(f"facility_location:{location.id}"): + instance.location = location + self._validate_data(instance) + super().perform_create(instance) + self.reset_encounter_location_association(location) + self.reset_location_availability_status(location) + + def perform_update(self, instance): + location = self.get_location_obj() + with Lock(f"facility_location:{location.id}"): + # Keep in mind that instance here is an ORM instance and not pydantic + self._validate_data(instance, self.get_object()) + super().perform_update(instance) + self.reset_encounter_location_association(location) + self.reset_location_availability_status(location) + + def perform_destroy(self, instance): + super().perform_destroy(instance) + self.reset_encounter_location_association(instance.location) + self.reset_location_availability_status(instance.location) + + def _validate_data(self, instance, model_obj=None): + """ + This method will be called separately to maintain a lock when the validation is being performed + """ + location = self.get_location_obj() + if location.mode == FacilityLocationModeChoices.instance.value: + raise ValidationError("Cannot assign encounters for Location instances") + + start_datetime = instance.start_datetime + base_qs = FacilityLocationEncounter.objects.filter(location=location) + if model_obj: + # Validate if the current dates are not in conflict with other dates + end_datetime = model_obj.end_datetime + base_qs = base_qs.exclude(id=model_obj.id) + status = model_obj.status + else: + status = instance.status + end_datetime = instance.end_datetime + # Validate end time is greater than start time + if end_datetime and start_datetime > end_datetime: + raise ValidationError("End Datetime should be greater than Start Datetime") + # Completed, reserved or planned status should have end_datetime + if ( + status + in ( + LocationEncounterAvailabilityStatusChoices.completed.value, + LocationEncounterAvailabilityStatusChoices.reserved.value, + LocationEncounterAvailabilityStatusChoices.planned.value, + ) + and not end_datetime + ): + raise ValidationError("End Datetime is required for completed status") + + # Ensure that there is no conflict in the schedule + if end_datetime: + if base_qs.filter( + start_datetime__lte=end_datetime, end_datetime__gte=start_datetime + ).exists(): + raise ValidationError("Conflict in schedule") + elif base_qs.filter(start_datetime__gte=start_datetime).exists(): + raise ValidationError("Conflict in schedule") + + # Ensure that there is no other association at this point + if ( + status == LocationEncounterAvailabilityStatusChoices.active.value + and base_qs.filter( + status=LocationEncounterAvailabilityStatusChoices.active.value + ).exists() + ): + raise ValidationError( + "Another active encounter already exists for this location" + ) + + # Don't allow changes to the status once the status has reached completed + + if ( + model_obj + and model_obj.status + == LocationEncounterAvailabilityStatusChoices.completed.value + and instance.status != model_obj.status + ): + raise ValidationError("Cannot change status after marking completed") + + def get_queryset(self): + location = self.get_location_obj() + facility = self.get_facility_obj() + if not AuthorizationController.call( + "can_list_facility_location_obj", self.request.user, facility, location + ): + raise PermissionDenied("You do not have permission to given location") + return FacilityLocationEncounter.objects.filter(location=location) diff --git a/care/emr/api/viewsets/observation.py b/care/emr/api/viewsets/observation.py index f954a80e29..510667d207 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) diff --git a/care/emr/api/viewsets/patient.py b/care/emr/api/viewsets/patient.py index 1c3e2bb009..51581f064f 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) diff --git a/care/emr/api/viewsets/questionnaire.py b/care/emr/api/viewsets/questionnaire.py index 6273d1b6f1..b6767b22df 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 @@ -132,6 +133,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 +183,7 @@ 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 +202,7 @@ 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): """ diff --git a/care/emr/api/viewsets/resource_request.py b/care/emr/api/viewsets/resource_request.py index 6981f6da53..1b87fa4eb8 100644 --- a/care/emr/api/viewsets/resource_request.py +++ b/care/emr/api/viewsets/resource_request.py @@ -1,4 +1,5 @@ from django.db.models import Q +from django_filters import rest_framework as filters from rest_framework.generics import get_object_or_404 from care.emr.api.viewsets.base import ( @@ -21,11 +22,22 @@ ) +class ResourceRequestFilters(filters.FilterSet): + origin_facility = filters.UUIDFilter(field_name="origin_facility__external_id") + approving_facility = filters.UUIDFilter( + field_name="approving_facility__external_id" + ) + assigned_facility = filters.UUIDFilter(field_name="assigned_facility__external_id") + related_patient = filters.UUIDFilter(field_name="related_patient__external_id") + + class ResourceRequestViewSet(EMRModelViewSet): database_model = ResourceRequest pydantic_model = ResourceRequestCreateSpec pydantic_read_model = ResourceRequestListSpec pydantic_retrieve_model = ResourceRequestRetrieveSpec + filterset_class = ResourceRequestFilters + filter_backends = [filters.DjangoFilterBackend] @classmethod def build_queryset(cls, queryset, user): diff --git a/care/emr/api/viewsets/user.py b/care/emr/api/viewsets/user.py index ad9e35851b..97d2f19a9f 100644 --- a/care/emr/api/viewsets/user.py +++ b/care/emr/api/viewsets/user.py @@ -98,25 +98,27 @@ 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": + if not user.profile_picture_url: + return Response({"detail": "No cover image to delete"}, status=404) + delete_cover_image(user.profile_picture_url, "avatars") + user.profile_picture_url = None + user.save() + return Response(status=204) + return Response({"detail": "Method not allowed"}, status=405) @action( detail=True, diff --git a/care/emr/apps.py b/care/emr/apps.py index 52b407ce5d..1244982f5c 100644 --- a/care/emr/apps.py +++ b/care/emr/apps.py @@ -5,3 +5,6 @@ class EMRConfig(AppConfig): name = "care.emr" verbose_name = _("Electronic Medical Record") + + def ready(self): + pass diff --git a/care/emr/migrations/0013_facilitylocation_facilitylocationorganization.py b/care/emr/migrations/0013_facilitylocation_facilitylocationorganization.py new file mode 100644 index 0000000000..4cd3d8df19 --- /dev/null +++ b/care/emr/migrations/0013_facilitylocation_facilitylocationorganization.py @@ -0,0 +1,71 @@ +# Generated by Django 5.1.4 on 2025-01-26 20:41 + +import django.contrib.postgres.fields +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('emr', '0012_alter_condition_encounter'), + ('facility', '0476_facility_default_internal_organization_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='FacilityLocation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('external_id', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)), + ('created_date', models.DateTimeField(auto_now_add=True, db_index=True, null=True)), + ('modified_date', models.DateTimeField(auto_now=True, db_index=True, null=True)), + ('deleted', models.BooleanField(db_index=True, default=False)), + ('history', models.JSONField(default=dict)), + ('meta', models.JSONField(default=dict)), + ('status', models.CharField(max_length=255)), + ('operational_status', models.CharField(max_length=255)), + ('name', models.CharField(max_length=255)), + ('description', models.CharField(max_length=255)), + ('mode', models.CharField(max_length=255)), + ('location_type', models.JSONField(blank=True, default=dict, null=True)), + ('form', models.CharField(max_length=255)), + ('facility_organization_cache', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), default=list, size=None)), + ('has_children', models.BooleanField(default=False)), + ('level_cache', models.IntegerField(default=0)), + ('parent_cache', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), default=list, size=None)), + ('metadata', models.JSONField(default=dict)), + ('cached_parent_json', models.JSONField(default=dict)), + ('created_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created_by', to=settings.AUTH_USER_MODEL)), + ('facility', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='facility.facility')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='emr.facilitylocation')), + ('root_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='root', to='emr.facilitylocation')), + ('updated_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='FacilityLocationOrganization', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('external_id', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)), + ('created_date', models.DateTimeField(auto_now_add=True, db_index=True, null=True)), + ('modified_date', models.DateTimeField(auto_now=True, db_index=True, null=True)), + ('deleted', models.BooleanField(db_index=True, default=False)), + ('history', models.JSONField(default=dict)), + ('meta', models.JSONField(default=dict)), + ('created_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created_by', to=settings.AUTH_USER_MODEL)), + ('location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='emr.facilitylocation')), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='emr.facilityorganization')), + ('updated_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/care/emr/migrations/0014_encounter_current_location_facilitylocationencounter.py b/care/emr/migrations/0014_encounter_current_location_facilitylocationencounter.py new file mode 100644 index 0000000000..6ba8773e5b --- /dev/null +++ b/care/emr/migrations/0014_encounter_current_location_facilitylocationencounter.py @@ -0,0 +1,43 @@ +# Generated by Django 5.1.4 on 2025-01-26 21:03 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('emr', '0013_facilitylocation_facilitylocationorganization'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='encounter', + name='current_location', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='emr.facilitylocation'), + ), + migrations.CreateModel( + name='FacilityLocationEncounter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('external_id', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)), + ('created_date', models.DateTimeField(auto_now_add=True, db_index=True, null=True)), + ('modified_date', models.DateTimeField(auto_now=True, db_index=True, null=True)), + ('deleted', models.BooleanField(db_index=True, default=False)), + ('history', models.JSONField(default=dict)), + ('meta', models.JSONField(default=dict)), + ('start_datetime', models.DateTimeField()), + ('end_datetime', models.DateTimeField(blank=True, default=None, null=True)), + ('created_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created_by', to=settings.AUTH_USER_MODEL)), + ('encounter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='emr.encounter')), + ('location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='emr.facilitylocation')), + ('updated_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/care/emr/migrations/0015_facilitylocation_availability_status_and_more.py b/care/emr/migrations/0015_facilitylocation_availability_status_and_more.py new file mode 100644 index 0000000000..33ef73dfa0 --- /dev/null +++ b/care/emr/migrations/0015_facilitylocation_availability_status_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.4 on 2025-01-27 17:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('emr', '0014_encounter_current_location_facilitylocationencounter'), + ] + + operations = [ + migrations.AddField( + model_name='facilitylocation', + name='availability_status', + field=models.CharField(default='', max_length=25), + preserve_default=False, + ), + migrations.AddField( + model_name='facilitylocationencounter', + name='status', + field=models.CharField(default='', max_length=25), + preserve_default=False, + ), + ] diff --git a/care/emr/models/__init__.py b/care/emr/models/__init__.py index ef2f4a6b99..2fd98a5daf 100644 --- a/care/emr/models/__init__.py +++ b/care/emr/models/__init__.py @@ -7,3 +7,4 @@ from .encounter import * # noqa F403 from .patient import * # noqa F403 from .file_upload import * # noqa F403 +from .location import * # noqa F403 diff --git a/care/emr/models/encounter.py b/care/emr/models/encounter.py index 0e018919e1..9fbdeb3fef 100644 --- a/care/emr/models/encounter.py +++ b/care/emr/models/encounter.py @@ -21,6 +21,10 @@ class Encounter(EMRBaseModel): # Organization fields facility_organization_cache = ArrayField(models.IntegerField(), default=list) + current_location = models.ForeignKey( + "emr.FacilityLocation", on_delete=models.SET_NULL, null=True, blank=True + ) # Cached field, used for easier querying + def sync_organization_cache(self): orgs = set() for encounter_organization in EncounterOrganization.objects.filter( diff --git a/care/emr/models/location.py b/care/emr/models/location.py new file mode 100644 index 0000000000..ad5e5bef47 --- /dev/null +++ b/care/emr/models/location.py @@ -0,0 +1,157 @@ +from datetime import datetime, timedelta + +from django.contrib.postgres.fields import ArrayField +from django.db import models +from django.utils import timezone + +from care.emr.models import EMRBaseModel, Encounter, FacilityOrganization +from config.celery_app import app + + +class FacilityLocation(EMRBaseModel): + status = models.CharField(max_length=255) + operational_status = models.CharField(max_length=255) + name = models.CharField(max_length=255) + availability_status = models.CharField( + max_length=25 + ) # Populated from FacilityLocationEncounter + description = models.CharField(max_length=255) + mode = models.CharField(max_length=255) + location_type = models.JSONField(default=dict, null=True, blank=True) + form = models.CharField(max_length=255) + facility_organization_cache = ArrayField(models.IntegerField(), default=list) + facility = models.ForeignKey("facility.Facility", on_delete=models.PROTECT) + parent = models.ForeignKey( + "emr.FacilityLocation", on_delete=models.SET_NULL, null=True, blank=True + ) + has_children = models.BooleanField(default=False) + level_cache = models.IntegerField(default=0) + parent_cache = ArrayField(models.IntegerField(), default=list) + metadata = models.JSONField(default=dict) + cached_parent_json = models.JSONField(default=dict) + root_location = models.ForeignKey( + "self", on_delete=models.CASCADE, related_name="root", null=True, blank=True + ) + cache_expiry_days = 15 + + def get_parent_json(self): + from care.emr.resources.location.spec import FacilityLocationListSpec + + if self.parent_id: + if self.cached_parent_json and timezone.now() < datetime.fromisoformat( + self.cached_parent_json["cache_expiry"] + ): + return self.cached_parent_json + self.parent.get_parent_json() + temp_data = FacilityLocationListSpec.serialize(self.parent).to_json() + temp_data["cache_expiry"] = str( + timezone.now() + timedelta(days=self.cache_expiry_days) + ) + self.cached_parent_json = temp_data + super().save(update_fields=["cached_parent_json"]) + return self.cached_parent_json + return {} + + @classmethod + def validate_uniqueness(cls, queryset, pydantic_instance, model_instance): + if model_instance: + name = model_instance.name + level_cache = model_instance.level_cache + root_location = model_instance.root_location + queryset = queryset.exclude(id=model_instance.id) + else: + name = pydantic_instance.name + if pydantic_instance.parent: + parent = cls.objects.get(external_id=pydantic_instance.parent) + level_cache = parent.level_cache + 1 + root_location = parent.root_location + if not root_location: + root_location = parent + else: + level_cache = 0 + root_location = None + if root_location: + queryset = queryset.filter(root_location=root_location) + else: + queryset = queryset.filter(root_location__isnull=True) + queryset = queryset.filter(level_cache=level_cache, name=name) + return queryset.exists() + + def sync_organization_cache(self): + orgs = set() + for encounter_organization in FacilityLocationOrganization.objects.filter( + location=self + ): + orgs = orgs.union( + { + *encounter_organization.organization.parent_cache, + encounter_organization.organization.id, + } + ) + + facility_root_org = FacilityOrganization.objects.filter( + org_type="root", facility=self.facility + ).first() + if facility_root_org: + orgs = orgs.union({facility_root_org.id}) + + self.facility_organization_cache = list(orgs) + super().save(update_fields=["facility_organization_cache"]) + + def save(self, *args, **kwargs): + if not self.id: + super().save(*args, **kwargs) + if self.parent: + self.level_cache = self.parent.level_cache + 1 + if self.parent.root_location is None: + self.root_location = self.parent + else: + self.root_location = self.parent.root_location + if not self.parent.has_children: + self.parent.has_children = True + self.parent.save(update_fields=["has_children"]) + else: + self.cached_parent_json = dict + super().save(*args, **kwargs) + self.sync_organization_cache() + + def cascade_changes(self): + handle_cascade.delay(self.id) + + +class FacilityLocationOrganization(EMRBaseModel): + """ + This relation denotes which organization can access a given Facility Location + """ + + location = models.ForeignKey(FacilityLocation, on_delete=models.CASCADE) + organization = models.ForeignKey( + "emr.FacilityOrganization", on_delete=models.CASCADE + ) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + handle_cascade.delay(self.location.id) + + +class FacilityLocationEncounter(EMRBaseModel): + """ + This relation denotes how a bed was associated to an encounter + """ + + status = models.CharField(max_length=25) + location = models.ForeignKey(FacilityLocation, on_delete=models.CASCADE) + encounter = models.ForeignKey(Encounter, on_delete=models.CASCADE) + start_datetime = models.DateTimeField() + end_datetime = models.DateTimeField(default=None, null=True, blank=True) + + +@app.task +def handle_cascade(base_location): + """ + Cascade changes to a location organization to all its children + """ + + for child in FacilityLocation.objects.filter(parent_location_id=base_location): + child.save(update_fields=["cached_parent_json"]) + handle_cascade(child) diff --git a/care/emr/models/organization.py b/care/emr/models/organization.py index dc1e8609e9..1cef2a8eac 100644 --- a/care/emr/models/organization.py +++ b/care/emr/models/organization.py @@ -46,8 +46,7 @@ def get_parent_json(self): self.cached_parent_json["cache_expiry"] ): return self.cached_parent_json - if self.parent: - self.parent.get_parent_json() + self.parent.get_parent_json() self.cached_parent_json = { "id": str(self.parent.external_id), "name": self.parent.name, diff --git a/care/emr/resources/base.py b/care/emr/resources/base.py index 830a2f25c6..6d016185b1 100644 --- a/care/emr/resources/base.py +++ b/care/emr/resources/base.py @@ -145,6 +145,15 @@ def as_questionnaire(cls, parent_classes=None): # noqa PLR0912 def to_json(self): return self.model_dump(mode="json", exclude=["meta"]) + @classmethod + def serialize_audit_users(cls, mapping, obj): + from care.emr.resources.user.spec import UserSpec + + if obj.created_by: + mapping["created_by"] = UserSpec.serialize(obj.created_by) + if obj.updated_by: + mapping["updated_by"] = UserSpec.serialize(obj.updated_by) + PhoneNumber = Annotated[ Union[str, phonenumbers.PhoneNumber()], # noqa: UP007 diff --git a/care/emr/resources/location/__init__.py b/care/emr/resources/location/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/emr/resources/location/spec.py b/care/emr/resources/location/spec.py new file mode 100644 index 0000000000..349fce23ea --- /dev/null +++ b/care/emr/resources/location/spec.py @@ -0,0 +1,176 @@ +import datetime +from enum import Enum + +from pydantic import UUID4, model_validator + +from care.emr.models import Encounter, FacilityLocationEncounter +from care.emr.models.location import FacilityLocation +from care.emr.resources.base import EMRResource +from care.emr.resources.common import Coding +from care.emr.resources.user.spec import UserSpec + + +class LocationEncounterAvailabilityStatusChoices(str, Enum): + planned = "planned" + active = "active" + reserved = "reserved" + completed = "completed" + + +class LocationAvailabilityStatusChoices(str, Enum): + available = "available" + unavailable = "unavailable" + + +class StatusChoices(str, Enum): + active = "active" + inactive = "inactive" + unknown = "unknown" + + +class FacilityLocationStatusChoices(str, Enum): + C = "C" + H = "H" + O = "O" # noqa E741 + U = "U" + K = "K" + I = "I" # noqa E741 + + +class FacilityLocationModeChoices(str, Enum): + instance = "instance" + kind = "kind" + + +class FacilityLocationFormChoices(str, Enum): + si = "si" + bu = "bu" + wi = "wi" + wa = "wa" + lvl = "lvl" + co = "co" + ro = "ro" + bd = "bd" + ve = "ve" + ho = "ho" + ca = "ca" + rd = "rd" + area = "area" + jdn = "jdn" + vi = "vi" + + +class FacilityLocationBaseSpec(EMRResource): + __model__ = FacilityLocation + __exclude__ = ["parent", "facility", "organizations"] + + id: UUID4 | None = None + + +class FacilityLocationSpec(FacilityLocationBaseSpec): + status: StatusChoices + operational_status: FacilityLocationStatusChoices + name: str + description: str + location_type: Coding | None = None + form: FacilityLocationFormChoices + + +class FacilityLocationUpdateSpec(FacilityLocationSpec): + pass + + +class FacilityLocationWriteSpec(FacilityLocationSpec): + parent: UUID4 | None = None + organizations: list[UUID4] + mode: FacilityLocationModeChoices + + @model_validator(mode="after") + def validate_parent_organization(self): + if ( + self.parent + and not FacilityLocation.objects.filter( + external_id=self.parent, mode=FacilityLocationModeChoices.instance.value + ).exists() + ): + err = "Parent not found" + raise ValueError(err) + return self + + def perform_extra_deserialization(self, is_update, obj): + if self.parent: + obj.parent = FacilityLocation.objects.get(external_id=self.parent) + else: + obj.parent = None + obj.availability_status = LocationAvailabilityStatusChoices.available.value + + +class FacilityLocationListSpec(FacilityLocationSpec): + parent: dict + mode: str + has_children: bool + availability_status: str + + @classmethod + def perform_extra_serialization(cls, mapping, obj): + mapping["id"] = obj.external_id + mapping["parent"] = obj.get_parent_json() + + +class FacilityLocationRetrieveSpec(FacilityLocationListSpec): + created_by: dict | None = None + updated_by: dict | None = None + + @classmethod + def perform_extra_serialization(cls, mapping, obj): + super().perform_extra_serialization(mapping, obj) + if obj.created_by: + mapping["created_by"] = UserSpec.serialize(obj.created_by) + if obj.updated_by: + mapping["updated_by"] = UserSpec.serialize(obj.updated_by) + + +class FacilityLocationEncounterBaseSpec(EMRResource): + __model__ = FacilityLocationEncounter + __exclude__ = ["encounter", "location"] + + id: UUID4 | None = None + + +class FacilityLocationEncounterCreateSpec(FacilityLocationEncounterBaseSpec): + status: LocationEncounterAvailabilityStatusChoices + encounter: UUID4 + start_datetime: datetime.datetime + end_datetime: datetime.datetime | None = None + + @model_validator(mode="after") + def validate_encounter(self): + if not Encounter.objects.filter(external_id=self.encounter).exists(): + err = "Encounter not found" + raise ValueError(err) + return self + + def perform_extra_deserialization(self, is_update, obj): + obj.encounter = Encounter.objects.get(external_id=self.encounter) + + +class FacilityLocationEncounterUpdateSpec(FacilityLocationEncounterBaseSpec): + status: LocationEncounterAvailabilityStatusChoices + + start_datetime: datetime.datetime + end_datetime: datetime.datetime | None = None + + +class FacilityLocationEncounterReadSpec(FacilityLocationEncounterBaseSpec): + encounter: UUID4 + start_datetime: datetime.datetime + end_datetime: datetime.datetime | None = None + status: str + + created_by: dict | None = None + updated_by: dict | None = None + + @classmethod + def perform_extra_serialization(cls, mapping, obj): + mapping["id"] = obj.external_id + cls.serialize_audit_users(mapping, obj) diff --git a/care/security/authorization/__init__.py b/care/security/authorization/__init__.py index 1fe9ef9aad..80fd8c7fa3 100644 --- a/care/security/authorization/__init__.py +++ b/care/security/authorization/__init__.py @@ -7,3 +7,4 @@ from .facility import * # noqa from .user import * # noqa from .user_schedule import * # noqa +from .facility_location import * # noqa diff --git a/care/security/authorization/encounter.py b/care/security/authorization/encounter.py index 91e26d3e03..47eceadcb0 100644 --- a/care/security/authorization/encounter.py +++ b/care/security/authorization/encounter.py @@ -1,3 +1,5 @@ +from django.db.models import Q + from care.emr.models.organization import FacilityOrganizationUser from care.emr.resources.encounter.constants import COMPLETED_CHOICES from care.security.authorization.base import ( @@ -20,10 +22,14 @@ def can_view_encounter_obj(self, user, encounter): """ Check if the user has permission to read encounter under this facility """ + orgs = [*encounter.facility_organization_cache] + if encounter.current_location: + orgs.extend(encounter.current_location.facility_organization_cache) + return self.check_permission_in_facility_organization( [EncounterPermissions.can_read_encounter.name], user, - orgs=encounter.facility_organization_cache, + orgs=orgs, ) def can_submit_encounter_questionnaire_obj(self, user, encounter): @@ -33,10 +39,15 @@ def can_submit_encounter_questionnaire_obj(self, user, encounter): if encounter.status in COMPLETED_CHOICES: # Cannot write to a closed encounter return False + + orgs = [*encounter.facility_organization_cache] + if encounter.current_location: + orgs.extend(encounter.current_location.facility_organization_cache) + return self.check_permission_in_facility_organization( [EncounterPermissions.can_submit_encounter_questionnaire.name], user, - orgs=encounter.facility_organization_cache, + orgs=orgs, ) def can_update_encounter_obj(self, user, encounter): @@ -46,10 +57,13 @@ def can_update_encounter_obj(self, user, encounter): if encounter.status in COMPLETED_CHOICES: # Cannot write to a closed encounter return False + orgs = [*encounter.facility_organization_cache] + if encounter.current_location: + orgs.extend(encounter.current_location.facility_organization_cache) return self.check_permission_in_facility_organization( [EncounterPermissions.can_write_encounter.name], user, - orgs=encounter.facility_organization_cache, + orgs=orgs, ) def get_filtered_encounters(self, qs, user, facility): @@ -63,7 +77,10 @@ def get_filtered_encounters(self, qs, user, facility): user=user, organization__facility=facility, role_id__in=roles ).values_list("organization_id", flat=True) ) - return qs.filter(facility_organization_cache__overlap=organization_ids) + return qs.filter( + Q(facility_organization_cache__overlap=organization_ids) + | Q(current_location__facility_organization_cache__overlap=organization_ids) + ) AuthorizationController.register_internal_controller(EncounterAccess) diff --git a/care/security/authorization/facility_location.py b/care/security/authorization/facility_location.py new file mode 100644 index 0000000000..2ed3b209ea --- /dev/null +++ b/care/security/authorization/facility_location.py @@ -0,0 +1,70 @@ +from care.emr.models import FacilityOrganization +from care.emr.models.organization import FacilityOrganizationUser +from care.security.authorization.base import ( + AuthorizationController, + AuthorizationHandler, +) +from care.security.permissions.facility_organization import ( + FacilityOrganizationPermissions, +) +from care.security.permissions.location import FacilityLocationPermissions + + +class FacilityLocationAccess(AuthorizationHandler): + def can_list_facility_location_obj(self, user, facility, location): + return self.check_permission_in_facility_organization( + [FacilityLocationPermissions.can_list_facility_locations.name], + user, + facility=facility, + orgs=location.facility_organization_cache, + ) + + def can_create_facility_location_obj(self, user, location, facility): + """ + Check if the user has permission to create locations under the given location + """ + + if location: + # If a parent is present then the user should have permission to create locations under the parent + return self.check_permission_in_facility_organization( + [FacilityLocationPermissions.can_write_facility_locations.name], + user, + location.facility_organization_cache, + ) + # If no parent exists, the user must have sufficient permissions in the root organization + root_organization = FacilityOrganization.objects.get( + facility=facility, org_type="root" + ) + return self.check_permission_in_facility_organization( + [FacilityOrganizationPermissions.can_create_facility_organization.name], + user, + [root_organization.id], + ) + + def can_update_facility_location_obj(self, user, location): + """ + Check if the user has permission to write locations under the given location + """ + + return self.check_permission_in_facility_organization( + [FacilityLocationPermissions.can_write_facility_locations.name], + user, + location.facility_organization_cache, + ) + + def get_accessible_facility_locations(self, qs, user, facility): + if user.is_superuser: + return qs + + roles = self.get_role_from_permissions( + [FacilityLocationPermissions.can_list_facility_locations.name] + ) + organization_ids = list( + FacilityOrganizationUser.objects.filter( + user=user, organization__facility=facility, role_id__in=roles + ).values_list("organization_id", flat=True) + ) + return qs.filter(facility_organization_cache__overlap=organization_ids) + + +AuthorizationController.register_internal_controller(FacilityLocationAccess) diff --git a/care/security/authorization/patient.py b/care/security/authorization/patient.py index 707f6d18ca..5485d4b73d 100644 --- a/care/security/authorization/patient.py +++ b/care/security/authorization/patient.py @@ -18,11 +18,18 @@ def find_roles_on_patient(self, user, patient): encounters = ( Encounter.objects.filter(patient=patient) .exclude(status__in=COMPLETED_CHOICES) - .values_list("facility_organization_cache", flat=True) + .values_list( + "facility_organization_cache", + "current_location__facility_organization_cache", + ) ) encounter_set = set() for encounter in encounters: - encounter_set = encounter_set.union(set(encounter)) + encounter_set = encounter_set.union(set(encounter[0])) + # Through Location + if encounter[1]: + encounter_set = encounter_set.union(set(encounter[1])) + # Find roles based on Location and roles = FacilityOrganizationUser.objects.filter( organization_id__in=encounter_set, user=user ).values_list("role_id", flat=True) diff --git a/care/security/permissions/base.py b/care/security/permissions/base.py index 6c7eedd0f4..55596c6fd9 100644 --- a/care/security/permissions/base.py +++ b/care/security/permissions/base.py @@ -1,9 +1,9 @@ -from care.security.models import RoleAssociation, RolePermission from care.security.permissions.encounter import EncounterPermissions from care.security.permissions.facility import FacilityPermissions from care.security.permissions.facility_organization import ( FacilityOrganizationPermissions, ) +from care.security.permissions.location import FacilityLocationPermissions from care.security.permissions.organization import OrganizationPermissions from care.security.permissions.patient import PatientPermissions from care.security.permissions.questionnaire import QuestionnairePermissions @@ -33,6 +33,7 @@ class PermissionController: PatientPermissions, UserPermissions, UserSchedulePermissions, + FacilityLocationPermissions, ] cache = {} @@ -48,29 +49,6 @@ def build_cache(cls): for permission in handler: cls.cache[permission.name] = permission.value - @classmethod - def has_permission(cls, user, permission, context, context_id): - # TODO : Cache permissions and invalidate when they change - # TODO : Fetch the user role from the previous role management implementation as well. - # Need to maintain some sort of mapping from previous generation to new generation of roles - from care.security.roles.role import RoleController - - mapped_role = RoleController.map_old_role_to_new(user.role) - permission_roles = RolePermission.objects.filter( - permission__slug=permission, permission__context=context - ).values("role_id") - if RoleAssociation.objects.filter( - context_id=context_id, context=context, role__in=permission_roles, user=user - ).exists(): - return True - # Check for old cases - return RolePermission.objects.filter( - permission__slug=permission, - permission__context=context, - role__name=mapped_role.name, - role__context=mapped_role.context.value, - ).exists() - @classmethod def get_permissions(cls): if not cls.cache: diff --git a/care/security/permissions/location.py b/care/security/permissions/location.py new file mode 100644 index 0000000000..4fd81ff046 --- /dev/null +++ b/care/security/permissions/location.py @@ -0,0 +1,45 @@ +import enum + +from care.security.permissions.constants import Permission, PermissionContext +from care.security.roles.role import ( + ADMIN_ROLE, + DOCTOR_ROLE, + FACILITY_ADMIN_ROLE, + GEO_ADMIN, + NURSE_ROLE, + STAFF_ROLE, +) + + +class FacilityLocationPermissions(enum.Enum): + can_list_facility_locations = Permission( + "Can List Facility Locations", + "", + PermissionContext.FACILITY, + [ + ADMIN_ROLE, + DOCTOR_ROLE, + FACILITY_ADMIN_ROLE, + GEO_ADMIN, + NURSE_ROLE, + STAFF_ROLE, + ], + ) + can_write_facility_locations = Permission( + "Can Create/Update Facility Locations", + "", + PermissionContext.FACILITY, + [FACILITY_ADMIN_ROLE, ADMIN_ROLE, STAFF_ROLE], + ) + can_list_facility_location_organizations = Permission( + "Can List Facility Location Organizations", + "", + PermissionContext.FACILITY, + [FACILITY_ADMIN_ROLE, ADMIN_ROLE, STAFF_ROLE], + ) + can_create_facility_location_organizations = Permission( + "Can Create/Update Facility Location Organizations", + "", + PermissionContext.FACILITY, + [FACILITY_ADMIN_ROLE, ADMIN_ROLE, STAFF_ROLE], + ) diff --git a/care/templates/base.html b/care/templates/base.html index a2d766b9ec..00f4845933 100644 --- a/care/templates/base.html +++ b/care/templates/base.html @@ -5,10 +5,11 @@
-