From 7eee8e6ab76bf51c45bb97c11e13936f2089e8ee Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Thu, 6 Feb 2025 19:34:28 +0530 Subject: [PATCH 01/20] Added Base spec for devices --- care/emr/models/device.py | 60 +++++++++++++++++++ care/emr/registries/device_type/__init__.py | 0 .../registries/device_type/device_registry.py | 43 +++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 care/emr/models/device.py create mode 100644 care/emr/registries/device_type/__init__.py create mode 100644 care/emr/registries/device_type/device_registry.py diff --git a/care/emr/models/device.py b/care/emr/models/device.py new file mode 100644 index 0000000000..b7f6eb7bca --- /dev/null +++ b/care/emr/models/device.py @@ -0,0 +1,60 @@ +from django.contrib.postgres.fields import ArrayField +from django.db import models + +from care.emr.models import EMRBaseModel + + +class Device(EMRBaseModel): + # Device Data + identifier = models.CharField(max_length=1024, null=True, blank=True) + status = models.CharField(max_length=14) + availability_status = models.CharField(max_length=14) + manufacturer = models.CharField(max_length=1024) + manufacture_date = models.DateTimeField(null=True, blank=True) + expiration_date = models.DateTimeField(null=True, blank=True) + lot_number = models.CharField(max_length=1024, null=True, blank=True) + serial_number = models.CharField(max_length=1024, null=True, blank=True) + registered_name = models.CharField(max_length=1024, null=True, blank=True) + user_friendly_name = models.CharField(max_length=1024, null=True, blank=True) + model_number = models.CharField(max_length=1024, null=True, blank=True) + part_number = models.CharField(max_length=1024, null=True, blank=True) + contact = models.JSONField(default=dict) + care_type = models.CharField(max_length=1024, null=True, blank=True) + + # Relations + facility = models.ForeignKey("facility.Facility", on_delete=models.CASCADE) + managing_organization = models.ForeignKey( + "emr.FacilityOrganization", on_delete=models.SET_NULL, null=True, blank=True + ) + current_location = models.ForeignKey( + "emr.FacilityLocation", on_delete=models.SET_NULL, null=True, blank=True + ) + current_encounter = models.ForeignKey( + "emr.Encounter", on_delete=models.SET_NULL, null=True, blank=True + ) + + # metadata + facility_organization_cache = ArrayField(models.IntegerField(), default=list) + + +class DeviceEncounterHistory(EMRBaseModel): + device = models.ForeignKey("emr.Device", on_delete=models.CASCADE) + encounter = models.ForeignKey("emr.Encounter", on_delete=models.CASCADE) + start = models.DateTimeField() + end = models.DateTimeField(null=True, blank=True) + + +class DeviceLocationHistory(EMRBaseModel): + device = models.ForeignKey("emr.Device", on_delete=models.CASCADE) + location = models.ForeignKey("emr.FacilityLocation", on_delete=models.CASCADE) + start = models.DateTimeField() + end = models.DateTimeField(null=True, blank=True) + + +class DeviceServiceHistory(EMRBaseModel): + device = models.ForeignKey( + Device, on_delete=models.PROTECT, null=False, blank=False + ) + serviced_on = models.DateField(default=None, null=True, blank=False) + note = models.TextField(default="", null=True, blank=True) + edit_history = models.JSONField(default=list) diff --git a/care/emr/registries/device_type/__init__.py b/care/emr/registries/device_type/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/emr/registries/device_type/device_registry.py b/care/emr/registries/device_type/device_registry.py new file mode 100644 index 0000000000..6677b5f9ae --- /dev/null +++ b/care/emr/registries/device_type/device_registry.py @@ -0,0 +1,43 @@ +class DeviceTypeBase: + def handle_create(self, request_data, obj): + """ + Handle Creates, the original source request along with the base object created is passed along. + Update the obj as needed and create any extra metadata needed. This method is called within a transaction + """ + return obj + + def handle_update(self, request_data, obj): + """ + Handle Updates, the original source request along with the base object updated is passed along. + Update the obj as needed and create any extra metadata needed. This method is called within a transaction + """ + return obj + + def handle_delete(self, obj): + """ + Handle Deletes, the object to be deleted is passed along. + Perform validation or any other changes required here + """ + return obj + + def list(self, obj): + """ + Return Extra metadata for the given obj for lists, N+1 queries is okay, caching is recommended for performance + """ + return {} + + def retrieve(self, obj): + """ + Return Extra metadata for the given obj during retrieves + """ + return {} + + +class InternalQuestionnaireRegistry: + _device_types = {} + + @classmethod + def register(cls, device_type, device_class) -> None: + if not issubclass(device_class, DeviceTypeBase): + raise ValueError("The provided class is not a subclass of DeviceTypeBase") + cls._device_types[device_type] = device_class From 32ffbc84ca4aabccb26acba762d0845f50ba6e1f Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Thu, 6 Feb 2025 19:39:14 +0530 Subject: [PATCH 02/20] Adding Devices Spec --- ...rhistory_devicelocationhistory_and_more.py | 118 ++++++++++++++++++ care/emr/models/__init__.py | 1 + .../registries/device_type/device_registry.py | 2 +- 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 care/emr/migrations/0017_device_deviceencounterhistory_devicelocationhistory_and_more.py diff --git a/care/emr/migrations/0017_device_deviceencounterhistory_devicelocationhistory_and_more.py b/care/emr/migrations/0017_device_deviceencounterhistory_devicelocationhistory_and_more.py new file mode 100644 index 0000000000..3753cf5eb1 --- /dev/null +++ b/care/emr/migrations/0017_device_deviceencounterhistory_devicelocationhistory_and_more.py @@ -0,0 +1,118 @@ +# Generated by Django 5.1.4 on 2025-02-06 14:08 + +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', '0016_allergyintolerance_copied_from'), + ('facility', '0476_facility_default_internal_organization_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Device', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('external_id', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)), + ('created_date', models.DateTimeField(auto_now_add=True, db_index=True, null=True)), + ('modified_date', models.DateTimeField(auto_now=True, db_index=True, null=True)), + ('deleted', models.BooleanField(db_index=True, default=False)), + ('history', models.JSONField(default=dict)), + ('meta', models.JSONField(default=dict)), + ('identifier', models.CharField(blank=True, max_length=1024, null=True)), + ('status', models.CharField(max_length=14)), + ('availability_status', models.CharField(max_length=14)), + ('manufacturer', models.CharField(max_length=1024)), + ('manufacture_date', models.DateTimeField(blank=True, null=True)), + ('expiration_date', models.DateTimeField(blank=True, null=True)), + ('lot_number', models.CharField(blank=True, max_length=1024, null=True)), + ('serial_number', models.CharField(blank=True, max_length=1024, null=True)), + ('registered_name', models.CharField(blank=True, max_length=1024, null=True)), + ('user_friendly_name', models.CharField(blank=True, max_length=1024, null=True)), + ('model_number', models.CharField(blank=True, max_length=1024, null=True)), + ('part_number', models.CharField(blank=True, max_length=1024, null=True)), + ('contact', models.JSONField(default=dict)), + ('care_type', models.CharField(blank=True, max_length=1024, null=True)), + ('facility_organization_cache', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), default=list, size=None)), + ('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)), + ('current_encounter', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='emr.encounter')), + ('current_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='emr.facilitylocation')), + ('facility', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='facility.facility')), + ('managing_organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, 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, + }, + ), + migrations.CreateModel( + name='DeviceEncounterHistory', + 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', models.DateTimeField()), + ('end', models.DateTimeField(blank=True, null=True)), + ('created_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created_by', to=settings.AUTH_USER_MODEL)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='emr.device')), + ('encounter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='emr.encounter')), + ('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='DeviceLocationHistory', + 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', models.DateTimeField()), + ('end', models.DateTimeField(blank=True, null=True)), + ('created_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created_by', to=settings.AUTH_USER_MODEL)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='emr.device')), + ('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, + }, + ), + migrations.CreateModel( + name='DeviceServiceHistory', + 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)), + ('serviced_on', models.DateField(default=None, null=True)), + ('note', models.TextField(blank=True, default='', null=True)), + ('edit_history', models.JSONField(default=list)), + ('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)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='emr.device')), + ('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/models/__init__.py b/care/emr/models/__init__.py index 2fd98a5daf..68c6406ce8 100644 --- a/care/emr/models/__init__.py +++ b/care/emr/models/__init__.py @@ -8,3 +8,4 @@ from .patient import * # noqa F403 from .file_upload import * # noqa F403 from .location import * # noqa F403 +from .device import * # noqa F403 diff --git a/care/emr/registries/device_type/device_registry.py b/care/emr/registries/device_type/device_registry.py index 6677b5f9ae..6b3cf008f1 100644 --- a/care/emr/registries/device_type/device_registry.py +++ b/care/emr/registries/device_type/device_registry.py @@ -33,7 +33,7 @@ def retrieve(self, obj): return {} -class InternalQuestionnaireRegistry: +class DeviceTypeRegistry: _device_types = {} @classmethod From 175002928c9f9401132b05c399427642d4f163b3 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Thu, 6 Feb 2025 23:59:24 +0530 Subject: [PATCH 03/20] Added Perform Action --- care/emr/models/__init__.py | 2 +- care/emr/models/device.py | 2 +- care/emr/registries/device_type/device_registry.py | 6 ++++++ care/emr/resources/device/__init__.py | 0 care/emr/resources/device/spec.py | 0 5 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 care/emr/resources/device/__init__.py create mode 100644 care/emr/resources/device/spec.py diff --git a/care/emr/models/__init__.py b/care/emr/models/__init__.py index 68c6406ce8..c400aa1793 100644 --- a/care/emr/models/__init__.py +++ b/care/emr/models/__init__.py @@ -8,4 +8,4 @@ from .patient import * # noqa F403 from .file_upload import * # noqa F403 from .location import * # noqa F403 -from .device import * # noqa F403 +from .device import * # noqa F403 diff --git a/care/emr/models/device.py b/care/emr/models/device.py index b7f6eb7bca..fc2d475aa3 100644 --- a/care/emr/models/device.py +++ b/care/emr/models/device.py @@ -19,7 +19,7 @@ class Device(EMRBaseModel): model_number = models.CharField(max_length=1024, null=True, blank=True) part_number = models.CharField(max_length=1024, null=True, blank=True) contact = models.JSONField(default=dict) - care_type = models.CharField(max_length=1024, null=True, blank=True) + care_type = models.CharField(max_length=1024, null=True, blank=True , default=None) # Relations facility = models.ForeignKey("facility.Facility", on_delete=models.CASCADE) diff --git a/care/emr/registries/device_type/device_registry.py b/care/emr/registries/device_type/device_registry.py index 6b3cf008f1..77005c91a4 100644 --- a/care/emr/registries/device_type/device_registry.py +++ b/care/emr/registries/device_type/device_registry.py @@ -32,6 +32,12 @@ def retrieve(self, obj): """ return {} + def perform_action(self , obj, action, request): + """ + Perform some kind of action on an asset, the HTTP request is proxied through as is. + an HTTP response object is expected as the return. + """ + return None # Return an HTTP Response class DeviceTypeRegistry: _device_types = {} diff --git a/care/emr/resources/device/__init__.py b/care/emr/resources/device/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/emr/resources/device/spec.py b/care/emr/resources/device/spec.py new file mode 100644 index 0000000000..e69de29bb2 From 9debe8dc3ea2b9abcca882eee071b4b673da3cf7 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Thu, 6 Feb 2025 23:59:40 +0530 Subject: [PATCH 04/20] Added Perform Action --- care/emr/models/device.py | 2 +- care/emr/registries/device_type/device_registry.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/care/emr/models/device.py b/care/emr/models/device.py index fc2d475aa3..9b2ba631a2 100644 --- a/care/emr/models/device.py +++ b/care/emr/models/device.py @@ -19,7 +19,7 @@ class Device(EMRBaseModel): model_number = models.CharField(max_length=1024, null=True, blank=True) part_number = models.CharField(max_length=1024, null=True, blank=True) contact = models.JSONField(default=dict) - care_type = models.CharField(max_length=1024, null=True, blank=True , default=None) + care_type = models.CharField(max_length=1024, null=True, blank=True, default=None) # Relations facility = models.ForeignKey("facility.Facility", on_delete=models.CASCADE) diff --git a/care/emr/registries/device_type/device_registry.py b/care/emr/registries/device_type/device_registry.py index 77005c91a4..979c9fc78b 100644 --- a/care/emr/registries/device_type/device_registry.py +++ b/care/emr/registries/device_type/device_registry.py @@ -32,12 +32,13 @@ def retrieve(self, obj): """ return {} - def perform_action(self , obj, action, request): + def perform_action(self, obj, action, request): """ Perform some kind of action on an asset, the HTTP request is proxied through as is. an HTTP response object is expected as the return. """ - return None # Return an HTTP Response + return # Return an HTTP Response + class DeviceTypeRegistry: _device_types = {} From 0122beb4cda14a19ce776e834a0a286e96552b8e Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Fri, 7 Feb 2025 04:12:52 +0530 Subject: [PATCH 05/20] Added API Base --- care/emr/api/viewsets/device.py | 70 ++++++++++++++++++++++ care/emr/api/viewsets/device/__init__.py | 0 care/emr/models/device.py | 19 ++++++ care/emr/resources/common/contact_point.py | 27 +++++++++ care/emr/resources/device/spec.py | 64 ++++++++++++++++++++ care/security/permissions/device.py | 25 ++++++++ config/api_router.py | 7 +++ 7 files changed, 212 insertions(+) create mode 100644 care/emr/api/viewsets/device.py delete mode 100644 care/emr/api/viewsets/device/__init__.py create mode 100644 care/emr/resources/common/contact_point.py create mode 100644 care/security/permissions/device.py diff --git a/care/emr/api/viewsets/device.py b/care/emr/api/viewsets/device.py new file mode 100644 index 0000000000..f61bd0cef8 --- /dev/null +++ b/care/emr/api/viewsets/device.py @@ -0,0 +1,70 @@ +from django_filters import rest_framework as filters +from rest_framework.generics import get_object_or_404 + +from care.emr.api.viewsets.base import EMRModelViewSet +from care.emr.models import Device +from care.emr.models.organization import FacilityOrganizationUser +from care.emr.resources.device.spec import ( + DeviceCreateSpec, + DeviceListSpec, + DeviceRetrieveSpec, + DeviceUpdateSpec, +) +from care.facility.models import Facility + + +class DeviceFilters(filters.FilterSet): + pass + + +class DeviceViewSet(EMRModelViewSet): + database_model = Device + pydantic_model = DeviceCreateSpec + pydantic_update_model = DeviceUpdateSpec + pydantic_read_model = DeviceListSpec + pydantic_retrieve_model = DeviceRetrieveSpec + filterset_class = DeviceFilters + filter_backends = [filters.DjangoFilterBackend] + + def get_facility_obj(self): + return get_object_or_404( + Facility, external_id=self.kwargs["facility_external_id"] + ) + + def perform_create(self, instance): + instance.facility = self.get_facility_obj() + super().perform_create(instance) + + def get_queryset(self): + """ + When Location is specified, Location permission is checked (or) organization filters are applied + If location is not specified the organization cache is used + """ + queryset = Device.objects.all() + + if self.request.user.is_superuser: + return queryset + + facility = self.get_facility_obj() + + users_facility_organizations = FacilityOrganizationUser.objects.filter( + organization__facility=facility, user=self.request.user + ).values_list("organization_id", flat=True) + + if "location" in self.request.GET: + queryset = queryset.filter( + facility_organization_cache__overlap=users_facility_organizations + ) + # TODO Check access to location with permission and then allow filter + # If location access then allow all, otherwise apply organization filter + else: + queryset = queryset.filter( + facility_organization_cache__overlap=users_facility_organizations + ) + + return queryset + + # TODO Action for Associating Encounter + # TODO Action for Associating Location + # TODO RO API's for Device Location and Encounter History + # TODO Serialize current location and history in the retrieve API diff --git a/care/emr/api/viewsets/device/__init__.py b/care/emr/api/viewsets/device/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/care/emr/models/device.py b/care/emr/models/device.py index 9b2ba631a2..0f5352d6fb 100644 --- a/care/emr/models/device.py +++ b/care/emr/models/device.py @@ -36,6 +36,25 @@ class Device(EMRBaseModel): # metadata facility_organization_cache = ArrayField(models.IntegerField(), default=list) + def save(self, *args, **kwargs): + from care.emr.models.organization import FacilityOrganization + + facility_root_org = FacilityOrganization.objects.filter( + org_type="root", facility=self.facility + ).first() + orgs = set() + if facility_root_org: + orgs = orgs.union({facility_root_org.id}) + if self.managing_organization: + orgs = orgs.union( + { + *self.managing_organization.parent_cache, + self.managing_organization.id, + } + ) + self.facility_organization_cache = list(orgs) + return super().save(*args, **kwargs) + class DeviceEncounterHistory(EMRBaseModel): device = models.ForeignKey("emr.Device", on_delete=models.CASCADE) diff --git a/care/emr/resources/common/contact_point.py b/care/emr/resources/common/contact_point.py new file mode 100644 index 0000000000..474a98002b --- /dev/null +++ b/care/emr/resources/common/contact_point.py @@ -0,0 +1,27 @@ +from enum import Enum + +from pydantic import BaseModel + + +class ContactPointSystemChoices(str, Enum): + phone = "phone" + fax = "fax" + email = "email" + pager = "pager" + url = "url" + sms = "sms" + other = "other" + + +class ContactPointUseChoices(str, Enum): + home = "home" + work = "work" + temp = "temp" + old = "old" + mobile = "mobile" + + +class ContactPoint(BaseModel): + system: ContactPointSystemChoices + value: str + use: ContactPointUseChoices diff --git a/care/emr/resources/device/spec.py b/care/emr/resources/device/spec.py index e69de29bb2..3fe76e804b 100644 --- a/care/emr/resources/device/spec.py +++ b/care/emr/resources/device/spec.py @@ -0,0 +1,64 @@ +from datetime import datetime +from enum import Enum + +from pydantic import UUID4 + +from care.emr.models import Device +from care.emr.resources.base import EMRResource +from care.emr.resources.common.contact_point import ContactPoint + + +class DeviceStatusChoices(str, Enum): + active = "active" + inactive = "inactive" + entered_in_error = "entered_in_error" + + +class DeviceAvailabilityStatusChoices(str, Enum): + lost = "lost" + damaged = "damaged" + destroyed = "destroyed" + available = "available" + + +class DeviceSpecBase(EMRResource): + __model__ = Device + __exclude__ = [ + "facility", + "managing_organization", + "current_location", + "current_encounter", + ] + + id: UUID4 = None + + identifier: str | None = None + status: DeviceStatusChoices + availability_status: DeviceAvailabilityStatusChoices + manufacturer: str | None = None + manufacture_date: datetime | None = None + expiration_date: datetime | None = None + lot_number: str | None = None + serial_number: str | None = None + registered_name: str + user_friendly_name: str | None = None + model_number: str | None = None + part_number: str | None = None + contact: list[ContactPoint] = [] + care_type: str | None = None + + +class DeviceCreateSpec(DeviceSpecBase): + pass + + +class DeviceUpdateSpec(DeviceSpecBase): + pass + + +class DeviceListSpec(DeviceCreateSpec): + pass + + +class DeviceRetrieveSpec(DeviceListSpec): + pass diff --git a/care/security/permissions/device.py b/care/security/permissions/device.py new file mode 100644 index 0000000000..8f5f391744 --- /dev/null +++ b/care/security/permissions/device.py @@ -0,0 +1,25 @@ +import enum + +from care.security.permissions.constants import Permission, PermissionContext +from care.security.roles.role import ( + ADMIN_ROLE, + DOCTOR_ROLE, + FACILITY_ADMIN_ROLE, + NURSE_ROLE, + STAFF_ROLE, +) + + +class DevicePermissions(enum.Enum): + can_list_devices = Permission( + "Can List Devices on Facility", + "", + PermissionContext.FACILITY, + [STAFF_ROLE, ADMIN_ROLE, DOCTOR_ROLE, NURSE_ROLE, FACILITY_ADMIN_ROLE], + ) + can_manage_devices = Permission( + "Can Manage Devices on Facility", + "", + PermissionContext.FACILITY, + [STAFF_ROLE, ADMIN_ROLE, FACILITY_ADMIN_ROLE], + ) diff --git a/config/api_router.py b/config/api_router.py index e1accb3cfd..20edb99aa9 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -13,6 +13,7 @@ DiagnosisViewSet, SymptomViewSet, ) +from care.emr.api.viewsets.device import DeviceViewSet from care.emr.api.viewsets.encounter import EncounterViewSet from care.emr.api.viewsets.facility import ( AllFacilityViewSet, @@ -165,6 +166,12 @@ basename="location", ) +facility_nested_router.register( + r"device", + DeviceViewSet, + basename="device", +) + facility_location_nested_router = NestedSimpleRouter( facility_nested_router, r"location", lookup="location" ) From 3708edc936f8d65eb7ed15864d1c0a1cf91afe01 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Sat, 8 Feb 2025 22:57:27 +0530 Subject: [PATCH 06/20] Added more API's --- care/emr/api/viewsets/device.py | 83 ++++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 6 deletions(-) diff --git a/care/emr/api/viewsets/device.py b/care/emr/api/viewsets/device.py index f61bd0cef8..f6d1da9917 100644 --- a/care/emr/api/viewsets/device.py +++ b/care/emr/api/viewsets/device.py @@ -1,8 +1,19 @@ +from django.db import transaction +from django.utils import timezone from django_filters import rest_framework as filters +from pydantic import UUID4, BaseModel +from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError from rest_framework.generics import get_object_or_404 -from care.emr.api.viewsets.base import EMRModelViewSet -from care.emr.models import Device +from care.emr.api.viewsets.base import EMRModelReadOnlyViewSet, EMRModelViewSet +from care.emr.models import ( + Device, + DeviceEncounterHistory, + DeviceLocationHistory, + Encounter, + FacilityLocation, +) from care.emr.models.organization import FacilityOrganizationUser from care.emr.resources.device.spec import ( DeviceCreateSpec, @@ -64,7 +75,67 @@ def get_queryset(self): return queryset - # TODO Action for Associating Encounter - # TODO Action for Associating Location - # TODO RO API's for Device Location and Encounter History - # TODO Serialize current location and history in the retrieve API + class DeviceEncounterAssociationRequest(BaseModel): + encounter: UUID4 + + @action(detail=True, methods=["POST"]) + def associate_encounter(self, request, *args, **kwargs): + request_data = self.DeviceEncounterAssociationRequest(**request.data) + encounter = get_object_or_404(Encounter, external_id=request_data.encounter) + device = self.get_object() + # TODO Perform Authz for encounter + if device.current_encounter_id == encounter.id: + raise ValidationError("Encounter already associated") + with transaction.atomic(): + if device.current_encounter: + old_obj = DeviceEncounterHistory.objects.filter( + device=device, encounter=device.current_encounter, end__isnull=True + ).first() + if old_obj: + old_obj.end = timezone.now() + old_obj.save() + device.current_encounter = encounter + device.save(update_fields=["current_encounter"]) + DeviceEncounterHistory.objects.create( + device=device, encounter=encounter, start=timezone.now() + ) + + class DeviceLocationAssociationRequest(BaseModel): + location: UUID4 + + @action(detail=True, methods=["POST"]) + def associate_location(self, request, *args, **kwargs): + request_data = self.DeviceLocationAssociationRequest(**request.data) + location = get_object_or_404( + FacilityLocation, external_id=request_data.location + ) + device = self.get_object() + # TODO Perform Authz for location + if device.current_location == location.id: + raise ValidationError("Location already associated") + with transaction.atomic(): + if device.current_encounter: + old_obj = DeviceLocationHistory.objects.filter( + device=device, location=device.current_location, end__isnull=True + ).first() + if old_obj: + old_obj.end = timezone.now() + old_obj.save() + device.current_location = location + device.save(update_fields=["current_location"]) + DeviceLocationHistory.objects.create( + device=device, location=location, start=timezone.now() + ) + + +class DeviceLocationHistoryViewSet(EMRModelReadOnlyViewSet): + pass + + +class DeviceEncounterHistoryViewSet(EMRModelReadOnlyViewSet): + pass + + +# TODO AuthZ +# TODO RO API's for Device Location and Encounter History +# TODO Serialize current location and history in the retrieve API From f6101e53db258aae48adbe989c00ab6f50907560 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Sun, 9 Feb 2025 21:40:12 +0530 Subject: [PATCH 07/20] Added more API's --- care/emr/api/viewsets/device.py | 32 ++++++++++++--- care/emr/resources/device/spec.py | 67 +++++++++++++++++++++++++++++-- config/api_router.py | 23 ++++++++++- 3 files changed, 113 insertions(+), 9 deletions(-) diff --git a/care/emr/api/viewsets/device.py b/care/emr/api/viewsets/device.py index f6d1da9917..964e48c524 100644 --- a/care/emr/api/viewsets/device.py +++ b/care/emr/api/viewsets/device.py @@ -17,7 +17,9 @@ from care.emr.models.organization import FacilityOrganizationUser from care.emr.resources.device.spec import ( DeviceCreateSpec, + DeviceEncounterHistoryListSpec, DeviceListSpec, + DeviceLocationHistoryListSpec, DeviceRetrieveSpec, DeviceUpdateSpec, ) @@ -25,7 +27,8 @@ class DeviceFilters(filters.FilterSet): - pass + current_location = filters.UUIDFilter(field_name="current_location__external_id") + current_encounter = filters.UUIDFilter(field_name="current_encounter__external_id") class DeviceViewSet(EMRModelViewSet): @@ -114,7 +117,7 @@ def associate_location(self, request, *args, **kwargs): if device.current_location == location.id: raise ValidationError("Location already associated") with transaction.atomic(): - if device.current_encounter: + if device.current_location: old_obj = DeviceLocationHistory.objects.filter( device=device, location=device.current_location, end__isnull=True ).first() @@ -129,13 +132,32 @@ def associate_location(self, request, *args, **kwargs): class DeviceLocationHistoryViewSet(EMRModelReadOnlyViewSet): - pass + database_model = DeviceLocationHistory + pydantic_read_model = DeviceLocationHistoryListSpec + + def get_device(self): + return get_object_or_404(Device, external_id=self.kwargs["device_external_id"]) + + def get_queryset(self): + return DeviceLocationHistory.objects.filter( + device=self.get_device() + ).select_related("location") + + # TODO Authz class DeviceEncounterHistoryViewSet(EMRModelReadOnlyViewSet): - pass + database_model = DeviceLocationHistory + pydantic_read_model = DeviceEncounterHistoryListSpec + + def get_device(self): + return get_object_or_404(Device, external_id=self.kwargs["device_external_id"]) + + def get_queryset(self): + return DeviceLocationHistory.objects.filter( + device=self.get_device() + ).select_related("encounter") # TODO AuthZ -# TODO RO API's for Device Location and Encounter History # TODO Serialize current location and history in the retrieve API diff --git a/care/emr/resources/device/spec.py b/care/emr/resources/device/spec.py index 3fe76e804b..ce4ae2fd53 100644 --- a/care/emr/resources/device/spec.py +++ b/care/emr/resources/device/spec.py @@ -3,9 +3,11 @@ from pydantic import UUID4 -from care.emr.models import Device +from care.emr.models import Device, DeviceEncounterHistory, DeviceLocationHistory from care.emr.resources.base import EMRResource from care.emr.resources.common.contact_point import ContactPoint +from care.emr.resources.encounter.spec import EncounterListSpec +from care.emr.resources.location.spec import FacilityLocationListSpec class DeviceStatusChoices(str, Enum): @@ -57,8 +59,67 @@ class DeviceUpdateSpec(DeviceSpecBase): class DeviceListSpec(DeviceCreateSpec): - pass + @classmethod + def perform_extra_serialization(cls, mapping, obj): + mapping["id"] = obj.external_id class DeviceRetrieveSpec(DeviceListSpec): - pass + current_encounter: dict | None = None + current_location: dict + + created_by: dict | None = None + updated_by: dict | None = None + + @classmethod + def perform_extra_serialization(cls, mapping, obj): + super().perform_extra_serialization(mapping, obj) + mapping["current_location"] = None + mapping["current_encounter"] = None + if obj.current_location: + mapping["current_location"] = FacilityLocationListSpec.serialize( + obj.current_location + ).to_json() + if obj.current_encounter: + mapping["current_encounter"] = EncounterListSpec.serialize( + obj.current_encounter + ).to_json() + cls.serialize_audit_users(mapping, obj) + + +class DeviceLocationHistoryListSpec(EMRResource): + __model__ = DeviceLocationHistory + __exclude__ = [ + "device", + "location", + ] + id: UUID4 = None + location: dict + created_by: dict + + @classmethod + def perform_extra_serialization(cls, mapping, obj): + mapping["id"] = obj.external_id + if obj.location: + mapping["location"] = FacilityLocationListSpec.serialize( + obj.location + ).to_json() + cls.serialize_audit_users(mapping, obj) + + +class DeviceEncounterHistoryListSpec(EMRResource): + __model__ = DeviceEncounterHistory + __exclude__ = [ + "device", + "encounter", + ] + id: UUID4 = None + encounter: dict + created_by: dict + + @classmethod + def perform_extra_serialization(cls, mapping, obj): + mapping["id"] = obj.external_id + if obj.encounter: + mapping["encounter"] = EncounterListSpec.serialize(obj.encounter).to_json() + cls.serialize_audit_users(mapping, obj) diff --git a/config/api_router.py b/config/api_router.py index 20edb99aa9..29ea5b4418 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -13,7 +13,11 @@ DiagnosisViewSet, SymptomViewSet, ) -from care.emr.api.viewsets.device import DeviceViewSet +from care.emr.api.viewsets.device import ( + DeviceEncounterHistoryViewSet, + DeviceLocationHistoryViewSet, + DeviceViewSet, +) from care.emr.api.viewsets.encounter import EncounterViewSet from care.emr.api.viewsets.facility import ( AllFacilityViewSet, @@ -172,6 +176,23 @@ basename="device", ) +device_nested_router = NestedSimpleRouter( + facility_nested_router, r"device", lookup="device" +) + +facility_nested_router.register( + r"location_history", + DeviceLocationHistoryViewSet, + basename="device_location_history", +) + + +facility_nested_router.register( + r"encounter_history", + DeviceEncounterHistoryViewSet, + basename="device_encounter_history", +) + facility_location_nested_router = NestedSimpleRouter( facility_nested_router, r"location", lookup="location" ) From 0c3caa64e2171f802e7cb8c2c4bf4d0405848216 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Sun, 9 Feb 2025 22:47:33 +0530 Subject: [PATCH 08/20] Added more API's --- care/emr/api/viewsets/device.py | 73 +++++++++++++++++++------------ care/emr/resources/device/spec.py | 5 +++ config/api_router.py | 6 ++- 3 files changed, 54 insertions(+), 30 deletions(-) diff --git a/care/emr/api/viewsets/device.py b/care/emr/api/viewsets/device.py index 964e48c524..ef924bc5d2 100644 --- a/care/emr/api/viewsets/device.py +++ b/care/emr/api/viewsets/device.py @@ -5,7 +5,7 @@ from rest_framework.decorators import action from rest_framework.exceptions import ValidationError from rest_framework.generics import get_object_or_404 - +from rest_framework.response import Response from care.emr.api.viewsets.base import EMRModelReadOnlyViewSet, EMRModelViewSet from care.emr.models import ( Device, @@ -69,7 +69,8 @@ def get_queryset(self): queryset = queryset.filter( facility_organization_cache__overlap=users_facility_organizations ) - # TODO Check access to location with permission and then allow filter + # TODO Check access to location with permission and then allow filter with or, + # Access should not be limited by location if the device has org access # If location access then allow all, otherwise apply organization filter else: queryset = queryset.filter( @@ -79,16 +80,21 @@ def get_queryset(self): return queryset class DeviceEncounterAssociationRequest(BaseModel): - encounter: UUID4 + encounter: UUID4 | None = None @action(detail=True, methods=["POST"]) def associate_encounter(self, request, *args, **kwargs): request_data = self.DeviceEncounterAssociationRequest(**request.data) - encounter = get_object_or_404(Encounter, external_id=request_data.encounter) + encounter = None + if request_data.encounter: + encounter = get_object_or_404(Encounter, external_id=request_data.encounter) device = self.get_object() - # TODO Perform Authz for encounter - if device.current_encounter_id == encounter.id: + facility = self.get_facility_obj() + # TODO Perform Authz for encounter and device + if encounter and device.current_encounter_id == encounter.id: raise ValidationError("Encounter already associated") + if encounter and encounter.facility_id != facility.id: + raise ValidationError("Encounter is not part of given facility") with transaction.atomic(): if device.current_encounter: old_obj = DeviceEncounterHistory.objects.filter( @@ -99,23 +105,32 @@ def associate_encounter(self, request, *args, **kwargs): old_obj.save() device.current_encounter = encounter device.save(update_fields=["current_encounter"]) - DeviceEncounterHistory.objects.create( - device=device, encounter=encounter, start=timezone.now() - ) + if encounter: + obj = DeviceEncounterHistory.objects.create( + device=device, encounter=encounter, start=timezone.now() , created_by = request.user + ) + return Response(DeviceEncounterHistoryListSpec.serialize(obj).to_json()) + else: + return Response({}) class DeviceLocationAssociationRequest(BaseModel): - location: UUID4 + location: UUID4 | None = None @action(detail=True, methods=["POST"]) def associate_location(self, request, *args, **kwargs): request_data = self.DeviceLocationAssociationRequest(**request.data) - location = get_object_or_404( - FacilityLocation, external_id=request_data.location - ) + location = None + if request_data.location: + location = get_object_or_404( + FacilityLocation, external_id=request_data.location + ) + facility = self.get_facility_obj() device = self.get_object() - # TODO Perform Authz for location - if device.current_location == location.id: + # TODO Perform Authz for location and device + if location and device.current_location_id == location.id: raise ValidationError("Location already associated") + if location and location.facility_id != facility.id: + raise ValidationError("Location is not part of given facility") with transaction.atomic(): if device.current_location: old_obj = DeviceLocationHistory.objects.filter( @@ -126,10 +141,12 @@ def associate_location(self, request, *args, **kwargs): old_obj.save() device.current_location = location device.save(update_fields=["current_location"]) - DeviceLocationHistory.objects.create( - device=device, location=location, start=timezone.now() - ) - + if location: + obj = DeviceLocationHistory.objects.create( + device=device, location=location, start=timezone.now() , created_by = request.user + ) + return Response(DeviceLocationHistoryListSpec.serialize(obj).to_json()) + return Response({}) class DeviceLocationHistoryViewSet(EMRModelReadOnlyViewSet): database_model = DeviceLocationHistory @@ -139,25 +156,25 @@ def get_device(self): return get_object_or_404(Device, external_id=self.kwargs["device_external_id"]) def get_queryset(self): + # Todo Check access to device + return DeviceLocationHistory.objects.filter( device=self.get_device() - ).select_related("location") - - # TODO Authz + ).select_related("location").order_by("-end") class DeviceEncounterHistoryViewSet(EMRModelReadOnlyViewSet): - database_model = DeviceLocationHistory + database_model = DeviceEncounterHistory pydantic_read_model = DeviceEncounterHistoryListSpec def get_device(self): return get_object_or_404(Device, external_id=self.kwargs["device_external_id"]) def get_queryset(self): - return DeviceLocationHistory.objects.filter( - device=self.get_device() - ).select_related("encounter") + # Todo Check access to device + + return DeviceEncounterHistory.objects.filter( + device=self.get_device() + ).select_related("encounter" ,"encounter__patient" , "encounter__facility").order_by("-end") -# TODO AuthZ -# TODO Serialize current location and history in the retrieve API diff --git a/care/emr/resources/device/spec.py b/care/emr/resources/device/spec.py index ce4ae2fd53..da0b5f093f 100644 --- a/care/emr/resources/device/spec.py +++ b/care/emr/resources/device/spec.py @@ -96,6 +96,9 @@ class DeviceLocationHistoryListSpec(EMRResource): id: UUID4 = None location: dict created_by: dict + start : datetime + end: datetime | None = None + @classmethod def perform_extra_serialization(cls, mapping, obj): @@ -116,6 +119,8 @@ class DeviceEncounterHistoryListSpec(EMRResource): id: UUID4 = None encounter: dict created_by: dict + start : datetime + end: datetime | None = None @classmethod def perform_extra_serialization(cls, mapping, obj): diff --git a/config/api_router.py b/config/api_router.py index 29ea5b4418..591f08c3f8 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -180,14 +180,14 @@ facility_nested_router, r"device", lookup="device" ) -facility_nested_router.register( +device_nested_router.register( r"location_history", DeviceLocationHistoryViewSet, basename="device_location_history", ) -facility_nested_router.register( +device_nested_router.register( r"encounter_history", DeviceEncounterHistoryViewSet, basename="device_encounter_history", @@ -271,4 +271,6 @@ path("", include(organization_nested_router.urls)), path("", include(facility_organization_nested_router.urls)), path("", include(facility_location_nested_router.urls)), + path("", include(device_nested_router.urls)), + ] From 928a6ddfb42fa3a5cc28938945e3adfdb8c21906 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Sun, 9 Feb 2025 22:47:46 +0530 Subject: [PATCH 09/20] Linting issues --- care/emr/api/viewsets/device.py | 35 +++++++++++++++++++------------ care/emr/resources/device/spec.py | 5 ++--- config/api_router.py | 1 - 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/care/emr/api/viewsets/device.py b/care/emr/api/viewsets/device.py index ef924bc5d2..b847a45a9b 100644 --- a/care/emr/api/viewsets/device.py +++ b/care/emr/api/viewsets/device.py @@ -6,6 +6,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.generics import get_object_or_404 from rest_framework.response import Response + from care.emr.api.viewsets.base import EMRModelReadOnlyViewSet, EMRModelViewSet from care.emr.models import ( Device, @@ -107,11 +108,13 @@ def associate_encounter(self, request, *args, **kwargs): device.save(update_fields=["current_encounter"]) if encounter: obj = DeviceEncounterHistory.objects.create( - device=device, encounter=encounter, start=timezone.now() , created_by = request.user + device=device, + encounter=encounter, + start=timezone.now(), + created_by=request.user, ) return Response(DeviceEncounterHistoryListSpec.serialize(obj).to_json()) - else: - return Response({}) + return Response({}) class DeviceLocationAssociationRequest(BaseModel): location: UUID4 | None = None @@ -129,7 +132,7 @@ def associate_location(self, request, *args, **kwargs): # TODO Perform Authz for location and device if location and device.current_location_id == location.id: raise ValidationError("Location already associated") - if location and location.facility_id != facility.id: + if location and location.facility_id != facility.id: raise ValidationError("Location is not part of given facility") with transaction.atomic(): if device.current_location: @@ -143,11 +146,15 @@ def associate_location(self, request, *args, **kwargs): device.save(update_fields=["current_location"]) if location: obj = DeviceLocationHistory.objects.create( - device=device, location=location, start=timezone.now() , created_by = request.user + device=device, + location=location, + start=timezone.now(), + created_by=request.user, ) return Response(DeviceLocationHistoryListSpec.serialize(obj).to_json()) return Response({}) + class DeviceLocationHistoryViewSet(EMRModelReadOnlyViewSet): database_model = DeviceLocationHistory pydantic_read_model = DeviceLocationHistoryListSpec @@ -158,9 +165,11 @@ def get_device(self): def get_queryset(self): # Todo Check access to device - return DeviceLocationHistory.objects.filter( - device=self.get_device() - ).select_related("location").order_by("-end") + return ( + DeviceLocationHistory.objects.filter(device=self.get_device()) + .select_related("location") + .order_by("-end") + ) class DeviceEncounterHistoryViewSet(EMRModelReadOnlyViewSet): @@ -171,10 +180,10 @@ def get_device(self): return get_object_or_404(Device, external_id=self.kwargs["device_external_id"]) def get_queryset(self): - # Todo Check access to device - return DeviceEncounterHistory.objects.filter( - device=self.get_device() - ).select_related("encounter" ,"encounter__patient" , "encounter__facility").order_by("-end") - + return ( + DeviceEncounterHistory.objects.filter(device=self.get_device()) + .select_related("encounter", "encounter__patient", "encounter__facility") + .order_by("-end") + ) diff --git a/care/emr/resources/device/spec.py b/care/emr/resources/device/spec.py index da0b5f093f..575f296e27 100644 --- a/care/emr/resources/device/spec.py +++ b/care/emr/resources/device/spec.py @@ -96,10 +96,9 @@ class DeviceLocationHistoryListSpec(EMRResource): id: UUID4 = None location: dict created_by: dict - start : datetime + start: datetime end: datetime | None = None - @classmethod def perform_extra_serialization(cls, mapping, obj): mapping["id"] = obj.external_id @@ -119,7 +118,7 @@ class DeviceEncounterHistoryListSpec(EMRResource): id: UUID4 = None encounter: dict created_by: dict - start : datetime + start: datetime end: datetime | None = None @classmethod diff --git a/config/api_router.py b/config/api_router.py index 591f08c3f8..88e75bae0d 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -272,5 +272,4 @@ path("", include(facility_organization_nested_router.urls)), path("", include(facility_location_nested_router.urls)), path("", include(device_nested_router.urls)), - ] From ca09db148ffb77b8649e383d607c0f7f115f79be Mon Sep 17 00:00:00 2001 From: Prafful Date: Wed, 12 Feb 2025 01:14:30 +0530 Subject: [PATCH 10/20] added tests --- care/emr/api/viewsets/device.py | 44 +- care/emr/api/viewsets/encounter.py | 18 + ...er_device_care_type_alter_device_status.py | 23 + care/emr/models/device.py | 2 +- care/emr/tests/test_device_api.py | 465 ++++++++++++++++++ care/security/authorization/__init__.py | 1 + care/security/authorization/base.py | 20 +- care/security/authorization/device.py | 41 ++ 8 files changed, 601 insertions(+), 13 deletions(-) create mode 100644 care/emr/migrations/0018_alter_device_care_type_alter_device_status.py create mode 100644 care/emr/tests/test_device_api.py create mode 100644 care/security/authorization/device.py diff --git a/care/emr/api/viewsets/device.py b/care/emr/api/viewsets/device.py index b847a45a9b..8c7a091d4d 100644 --- a/care/emr/api/viewsets/device.py +++ b/care/emr/api/viewsets/device.py @@ -3,7 +3,7 @@ from django_filters import rest_framework as filters from pydantic import UUID4, BaseModel from rest_framework.decorators import action -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.generics import get_object_or_404 from rest_framework.response import Response @@ -25,6 +25,7 @@ DeviceUpdateSpec, ) from care.facility.models import Facility +from care.security.authorization import AuthorizationController class DeviceFilters(filters.FilterSet): @@ -46,6 +47,18 @@ def get_facility_obj(self): Facility, external_id=self.kwargs["facility_external_id"] ) + def authorize_create(self, instance): + if not AuthorizationController.call("can_manage_devices", self.request.user): + raise PermissionDenied("You do not have permission to create device") + + def authorize_update(self, instance, model_instance): + if not AuthorizationController.call("can_manage_devices", self.request.user): + raise PermissionDenied("You do not have permission to update device") + + def authorize_destroy(self, instance): + if not AuthorizationController.call("can_manage_devices", self.request.user): + raise PermissionDenied("You do not have permission to delete device") + def perform_create(self, instance): instance.facility = self.get_facility_obj() super().perform_create(instance) @@ -91,7 +104,14 @@ def associate_encounter(self, request, *args, **kwargs): encounter = get_object_or_404(Encounter, external_id=request_data.encounter) device = self.get_object() facility = self.get_facility_obj() - # TODO Perform Authz for encounter and device + + if not AuthorizationController.call( + "can_associate_device_encounter", self.request.user, facility + ): + raise PermissionDenied( + "You do not have permission to associate encounter to this device" + ) + if encounter and device.current_encounter_id == encounter.id: raise ValidationError("Encounter already associated") if encounter and encounter.facility_id != facility.id: @@ -129,7 +149,13 @@ def associate_location(self, request, *args, **kwargs): ) facility = self.get_facility_obj() device = self.get_object() - # TODO Perform Authz for location and device + + if not AuthorizationController.call( + "can_associate_device_location", self.request.user, facility + ): + raise PermissionDenied( + "You do not have permission to associate location to this device" + ) if location and device.current_location_id == location.id: raise ValidationError("Location already associated") if location and location.facility_id != facility.id: @@ -163,7 +189,11 @@ def get_device(self): return get_object_or_404(Device, external_id=self.kwargs["device_external_id"]) def get_queryset(self): - # Todo Check access to device + if not AuthorizationController.call( + "can_list_devices", + self.request.user, + ): + raise PermissionDenied("You do not have permission to access the device") return ( DeviceLocationHistory.objects.filter(device=self.get_device()) @@ -180,7 +210,11 @@ def get_device(self): return get_object_or_404(Device, external_id=self.kwargs["device_external_id"]) def get_queryset(self): - # Todo Check access to device + if not AuthorizationController.call( + "can_list_devices", + self.request.user, + ): + raise PermissionDenied("You do not have permission to access the device") return ( DeviceEncounterHistory.objects.filter(device=self.get_device()) diff --git a/care/emr/api/viewsets/encounter.py b/care/emr/api/viewsets/encounter.py index fc521bb6f7..6226648a31 100644 --- a/care/emr/api/viewsets/encounter.py +++ b/care/emr/api/viewsets/encounter.py @@ -21,6 +21,8 @@ EMRUpdateMixin, ) from care.emr.models import ( + Device, + DeviceEncounterHistory, Encounter, EncounterOrganization, FacilityOrganization, @@ -106,6 +108,22 @@ def perform_create(self, instance): if not organizations: instance.sync_organization_cache() + def perform_update(self, instance): + with transaction.atomic(): + if instance.status in COMPLETED_CHOICES: + device_ids = list( + Device.objects.filter(current_encounter=instance).values_list( + "id", flat=True + ) + ) + Device.objects.filter(id__in=device_ids).update(current_encounter=None) + + DeviceEncounterHistory.objects.filter( + device_id__in=device_ids, encounter=instance, end__isnull=True + ).update(end=timezone.now()) + + super().perform_update(instance) + def authorize_update(self, request_obj, model_instance): if not AuthorizationController.call( "can_update_encounter_obj", self.request.user, model_instance diff --git a/care/emr/migrations/0018_alter_device_care_type_alter_device_status.py b/care/emr/migrations/0018_alter_device_care_type_alter_device_status.py new file mode 100644 index 0000000000..b3314614a2 --- /dev/null +++ b/care/emr/migrations/0018_alter_device_care_type_alter_device_status.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.4 on 2025-02-11 10:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('emr', '0017_device_deviceencounterhistory_devicelocationhistory_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='device', + name='care_type', + field=models.CharField(blank=True, default=None, max_length=1024, null=True), + ), + migrations.AlterField( + model_name='device', + name='status', + field=models.CharField(max_length=16), + ), + ] diff --git a/care/emr/models/device.py b/care/emr/models/device.py index 0f5352d6fb..f1e1ec1646 100644 --- a/care/emr/models/device.py +++ b/care/emr/models/device.py @@ -7,7 +7,7 @@ class Device(EMRBaseModel): # Device Data identifier = models.CharField(max_length=1024, null=True, blank=True) - status = models.CharField(max_length=14) + status = models.CharField(max_length=16) availability_status = models.CharField(max_length=14) manufacturer = models.CharField(max_length=1024) manufacture_date = models.DateTimeField(null=True, blank=True) diff --git a/care/emr/tests/test_device_api.py b/care/emr/tests/test_device_api.py new file mode 100644 index 0000000000..2dd28002a2 --- /dev/null +++ b/care/emr/tests/test_device_api.py @@ -0,0 +1,465 @@ +from secrets import choice +from uuid import uuid4 + +from django.urls import reverse + +from care.emr.models import Device +from care.emr.resources.device.spec import ( + DeviceAvailabilityStatusChoices, + DeviceStatusChoices, +) +from care.emr.resources.encounter.constants import ( + ClassChoices, + EncounterPriorityChoices, + StatusChoices, +) +from care.emr.tests.test_location_api import FacilityLocationMixin +from care.security.permissions.device import DevicePermissions +from care.security.permissions.encounter import EncounterPermissions +from care.security.permissions.location import FacilityLocationPermissions +from care.utils.tests.base import CareAPITestBase + + +class DeviceBaseTest(CareAPITestBase, FacilityLocationMixin): + def setUp(self): + self.user = self.create_user() + self.facility = self.create_facility(user=self.user) + self.facility_organization = self.create_facility_organization( + facility=self.facility + ) + self.client.force_authenticate(user=self.user) + self.patient = self.create_patient() + self.super_user = self.create_super_user() + + def generate_device_data(self, **kwargs): + data = { + "status": choice(list(DeviceStatusChoices)).value, + "availability_status": choice(list(DeviceAvailabilityStatusChoices)).value, + "registered_name": self.fake.name(), + } + data.update(**kwargs) + return data + + def create_device(self): + self.client.force_authenticate(self.super_user) + url = reverse( + "device-list", kwargs={"facility_external_id": self.facility.external_id} + ) + data = self.generate_device_data() + response = self.client.post(url, data=data, format="json") + self.assertEqual(response.status_code, 200) + self.client.force_authenticate(self.user) + return response.json() + + def add_permissions(self, permissions): + role = self.create_role_with_permissions(permissions) + self.attach_role_facility_organization_user( + self.facility_organization, self.user, role + ) + + def get_device_detail_url(self, device): + return reverse( + "device-detail", + kwargs={ + "facility_external_id": self.facility.external_id, + "external_id": device["id"], + }, + ) + + def get_associate_encounter_url(self, device): + return reverse( + "device-associate-encounter", + kwargs={ + "facility_external_id": self.facility.external_id, + "external_id": device["id"], + }, + ) + + def get_associate_location_url(self, device): + return reverse( + "device-associate-location", + kwargs={ + "facility_external_id": self.facility.external_id, + "external_id": device["id"], + }, + ) + + +class TestDeviceViewSet(DeviceBaseTest): + def setUp(self): + super().setUp() + self.base_url = reverse( + "device-list", kwargs={"facility_external_id": self.facility.external_id} + ) + + # -------------------- Device CRUD Tests -------------------- + def test_create_device_without_permissions(self): + data = self.generate_device_data() + response = self.client.post(self.base_url, data=data, format="json") + self.assertEqual(response.status_code, 403) + + def test_create_device_with_permissions(self): + self.add_permissions([DevicePermissions.can_manage_devices.name]) + data = self.generate_device_data() + response = self.client.post(self.base_url, data=data, format="json") + self.assertEqual(response.status_code, 200) + + def test_update_device_without_permissions(self): + device = self.create_device() + url = self.get_device_detail_url(device) + data = self.generate_device_data() + response = self.client.put(url, data=data, format="json") + self.assertEqual(response.status_code, 403) + + def test_update_device_with_permissions(self): + device = self.create_device() + self.add_permissions([DevicePermissions.can_manage_devices.name]) + url = self.get_device_detail_url(device) + data = self.generate_device_data() + response = self.client.put(url, data=data, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["registered_name"], data["registered_name"]) + + def test_delete_device_without_permissions(self): + device = self.create_device() + url = self.get_device_detail_url(device) + response = self.client.delete(url) + self.assertEqual(response.status_code, 403) + + def test_delete_device_with_permissions(self): + device = self.create_device() + self.add_permissions([DevicePermissions.can_manage_devices.name]) + url = self.get_device_detail_url(device) + response = self.client.delete(url) + self.assertEqual(response.status_code, 204) + + # ------------- Device Encounter Association Tests ------------- + def test_associate_device_encounter_without_device_permission(self): + device = self.create_device() + encounter = self.create_encounter( + self.patient, self.facility, self.facility_organization + ) + # Only encounter permission attached (missing device permission). + self.add_permissions([EncounterPermissions.can_write_encounter.name]) + url = self.get_associate_encounter_url(device) + data = {"encounter": encounter.external_id} + response = self.client.post(url, data=data, format="json") + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.json()["detail"], + "You do not have permission to associate encounter to this device", + ) + + def test_associate_device_encounter_invalid_encounter(self): + device = self.create_device() + self.add_permissions( + [ + EncounterPermissions.can_write_encounter.name, + DevicePermissions.can_manage_devices.name, + ] + ) + url = self.get_associate_encounter_url(device) + data = {"encounter": str(uuid4())} # Non-existent encounter ID. + response = self.client.post(url, data=data, format="json") + self.assertEqual(response.status_code, 404) + + def test_associate_device_encounter_different_facility(self): + device = self.create_device() + external_facility = self.create_facility(self.user) + external_org = self.create_facility_organization(external_facility) + encounter_diff = self.create_encounter( + self.patient, external_facility, external_org + ) + self.add_permissions( + [ + EncounterPermissions.can_write_encounter.name, + DevicePermissions.can_manage_devices.name, + ] + ) + url = self.get_associate_encounter_url(device) + data = {"encounter": encounter_diff.external_id} + response = self.client.post(url, data=data, format="json") + self.assertEqual(response.status_code, 400) + error = response.json()["errors"][0] + self.assertEqual(error["type"], "validation_error") + self.assertIn("Encounter is not part of given facility", error["msg"]) + + def test_associate_device_encounter_success(self): + device = self.create_device() + encounter = self.create_encounter( + self.patient, self.facility, self.facility_organization + ) + self.add_permissions( + [ + EncounterPermissions.can_write_encounter.name, + DevicePermissions.can_manage_devices.name, + ] + ) + url = self.get_associate_encounter_url(device) + data = {"encounter": encounter.external_id} + response = self.client.post(url, data=data, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual( + Device.objects.get(external_id=device["id"]).current_encounter, encounter + ) + + def test_associate_device_encounter_duplicate(self): + device = self.create_device() + encounter = self.create_encounter( + self.patient, self.facility, self.facility_organization + ) + self.add_permissions( + [ + EncounterPermissions.can_write_encounter.name, + DevicePermissions.can_manage_devices.name, + ] + ) + url = self.get_associate_encounter_url(device) + data = {"encounter": encounter.external_id} + # First association succeeds. + response = self.client.post(url, data=data, format="json") + self.assertEqual(response.status_code, 200) + # Duplicate association should return a validation error. + response_dup = self.client.post(url, data=data, format="json") + self.assertEqual(response_dup.status_code, 400) + error = response_dup.json()["errors"][0] + self.assertEqual(error["type"], "validation_error") + self.assertIn("Encounter already associated", error["msg"]) + + def test_disassociate_encounter(self): + device = self.create_device() + self.add_permissions( + [ + EncounterPermissions.can_write_encounter.name, + DevicePermissions.can_manage_devices.name, + ] + ) + url = self.get_associate_encounter_url(device) + data = {"encounter": None} + response = self.client.post(url, data=data, format="json") + self.assertEqual(response.status_code, 200) + self.assertIsNone( + Device.objects.get(external_id=device["id"]).current_encounter + ) + + # ------------- Device Location Association Tests ------------- + def test_associate_device_location_without_permission(self): + device = self.create_device() + location = self.create_facility_location() + self.add_permissions( + [FacilityLocationPermissions.can_write_facility_locations.name] + ) + url = self.get_associate_location_url(device) + data = {"location": location["id"]} + response = self.client.post(url, data=data, format="json") + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.json()["detail"], + "You do not have permission to associate location to this device", + ) + + def test_associate_device_location_invalid_location(self): + device = self.create_device() + self.add_permissions( + [ + DevicePermissions.can_manage_devices.name, + FacilityLocationPermissions.can_write_facility_locations.name, + ] + ) + url = self.get_associate_location_url(device) + data = {"location": str(uuid4())} # Non-existent location. + response = self.client.post(url, data=data, format="json") + self.assertEqual(response.status_code, 404) + + def test_associate_device_location_success(self): + device = self.create_device() + location = self.create_facility_location() + self.add_permissions( + [ + DevicePermissions.can_manage_devices.name, + FacilityLocationPermissions.can_write_facility_locations.name, + ] + ) + url = self.get_associate_location_url(device) + data = {"location": location["id"]} + response = self.client.post(url, data=data, format="json") + self.assertEqual(response.status_code, 200) + updated_device = Device.objects.get(external_id=device["id"]) + self.assertEqual( + str(updated_device.current_location.external_id), location["id"] + ) + + def test_associate_device_location_duplicate(self): + device = self.create_device() + location = self.create_facility_location() + self.add_permissions( + [ + DevicePermissions.can_manage_devices.name, + FacilityLocationPermissions.can_write_facility_locations.name, + ] + ) + url = self.get_associate_location_url(device) + data = {"location": location["id"]} + response_first = self.client.post(url, data=data, format="json") + self.assertEqual(response_first.status_code, 200) + response_dup = self.client.post(url, data=data, format="json") + self.assertEqual(response_dup.status_code, 400) + error = response_dup.json()["errors"][0] + self.assertEqual(error["type"], "validation_error") + self.assertIn("Location already associated", error["msg"]) + + def test_disassociate_device_location(self): + device = self.create_device() + location = self.create_facility_location() + self.add_permissions( + [ + DevicePermissions.can_manage_devices.name, + FacilityLocationPermissions.can_write_facility_locations.name, + ] + ) + url = self.get_associate_location_url(device) + data = {"location": location["id"]} + # First associate, then disassociate. + response_associate = self.client.post(url, data=data, format="json") + self.assertEqual(response_associate.status_code, 200) + response_clear = self.client.post(url, data={}, format="json") + self.assertEqual(response_clear.status_code, 200) + self.assertIsNone(Device.objects.get(external_id=device["id"]).current_location) + + def test_dissociation_device_encounter_after_encounter_status_update(self): + device = self.create_device() + encounter = self.create_encounter( + self.patient, + self.facility, + self.facility_organization, + status_history={"history": []}, + encounter_class=ClassChoices.imp.value, + ) + self.add_permissions( + [ + EncounterPermissions.can_write_encounter.name, + DevicePermissions.can_manage_devices.name, + ] + ) + associate_url = self.get_associate_encounter_url(device) + data = {"encounter": encounter.external_id} + response = self.client.post(associate_url, data=data, format="json") + self.assertEqual(response.status_code, 200) + device_instance = Device.objects.get(external_id=device["id"]) + self.assertEqual(device_instance.current_encounter, encounter) + self.client.force_authenticate(self.super_user) + encounter_update_url = reverse( + "encounter-detail", kwargs={"external_id": encounter.external_id} + ) + update_data = { + "status": StatusChoices.completed.value, + "priority": EncounterPriorityChoices.urgent.value, + "encounter_class": ClassChoices.imp.value, + } + update_response = self.client.put( + encounter_update_url, data=update_data, format="json" + ) + self.assertEqual(update_response.status_code, 200) + device_instance.refresh_from_db() + self.assertIsNone(device_instance.current_encounter) + + +class TestDeviceLocationHistoryViewSet(DeviceBaseTest): + def setUp(self): + super().setUp() + self.device = self.create_device() + self.location = self.create_facility_location() + self.base_url = reverse( + "device_location_history-list", + kwargs={ + "facility_external_id": self.facility.external_id, + "device_external_id": self.device["id"], + }, + ) + + def associate_location_with_device(self, device, location): + self.client.force_authenticate(self.super_user) + url = self.get_associate_location_url(device) + data = {"location": location["id"]} + response = self.client.post(url, data=data, format="json") + self.assertEqual(response.status_code, 200) + self.client.force_authenticate(self.user) + return response.json() + + def test_list_device_location_history(self): + self.associate_location_with_device(self.device, self.location) + # Without list permission → 403 + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, 403) + self.add_permissions([DevicePermissions.can_list_devices.name]) + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["count"], 1) + + def test_retrieve_device_location_history(self): + history = self.associate_location_with_device(self.device, self.location) + url = reverse( + "device_location_history-detail", + kwargs={ + "facility_external_id": self.facility.external_id, + "device_external_id": self.device["id"], + "external_id": history["id"], + }, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + self.add_permissions([DevicePermissions.can_list_devices.name]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["id"], history["id"]) + + +class TestDeviceEncounterHistoryViewSet(DeviceBaseTest): + def setUp(self): + super().setUp() + self.device = self.create_device() + self.encounter = self.create_encounter( + self.patient, self.facility, self.facility_organization + ) + self.base_url = reverse( + "device_encounter_history-list", + kwargs={ + "facility_external_id": self.facility.external_id, + "device_external_id": self.device["id"], + }, + ) + + def associate_encounter_with_device(self, device, encounter): + self.client.force_authenticate(self.super_user) + url = self.get_associate_encounter_url(device) + data = {"encounter": encounter.external_id} + response = self.client.post(url, data=data, format="json") + self.assertEqual(response.status_code, 200) + self.client.force_authenticate(self.user) + return response.json() + + def test_list_device_encounter_history(self): + self.associate_encounter_with_device(self.device, self.encounter) + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, 403) + self.add_permissions([DevicePermissions.can_list_devices.name]) + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["count"], 1) + + def test_retrieve_device_encounter_history(self): + history = self.associate_encounter_with_device(self.device, self.encounter) + url = reverse( + "device_encounter_history-detail", + kwargs={ + "facility_external_id": self.facility.external_id, + "device_external_id": self.device["id"], + "external_id": history["id"], + }, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + self.add_permissions([DevicePermissions.can_list_devices.name]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["id"], history["id"]) diff --git a/care/security/authorization/__init__.py b/care/security/authorization/__init__.py index 80fd8c7fa3..08721a8635 100644 --- a/care/security/authorization/__init__.py +++ b/care/security/authorization/__init__.py @@ -8,3 +8,4 @@ from .user import * # noqa from .user_schedule import * # noqa from .facility_location import * # noqa +from .device import * # noqa diff --git a/care/security/authorization/base.py b/care/security/authorization/base.py index aa699b8072..07a0719e2e 100644 --- a/care/security/authorization/base.py +++ b/care/security/authorization/base.py @@ -38,13 +38,19 @@ def check_permission_in_facility_organization( ): if user.is_superuser: return True - roles = self.get_role_from_permissions(permissions) - filters = {"role_id__in": roles, "user": user} - if orgs: - filters["organization_id__in"] = orgs - if facility: - filters["organization__facility"] = facility - return FacilityOrganizationUser.objects.filter(**filters).exists() + + for perm in permissions: + roles = self.get_role_from_permissions([perm]) + filters = {"role_id__in": roles, "user": user} + if orgs: + filters["organization_id__in"] = orgs + if facility: + filters["organization__facility"] = facility + + if not FacilityOrganizationUser.objects.filter(**filters).exists(): + return False + + return True def get_role_from_permissions(self, permissions): # TODO Cache this endpoint diff --git a/care/security/authorization/device.py b/care/security/authorization/device.py new file mode 100644 index 0000000000..ae64b0e74a --- /dev/null +++ b/care/security/authorization/device.py @@ -0,0 +1,41 @@ +from care.security.authorization import AuthorizationController, AuthorizationHandler +from care.security.permissions.device import DevicePermissions +from care.security.permissions.encounter import EncounterPermissions +from care.security.permissions.location import FacilityLocationPermissions + + +class DeviceAccess(AuthorizationHandler): + def can_list_devices(self, user): + return self.check_permission_in_facility_organization( + [DevicePermissions.can_list_devices.name], + user, + ) + + def can_manage_devices(self, user): + return self.check_permission_in_facility_organization( + [DevicePermissions.can_manage_devices.name], + user, + ) + + def can_associate_device_encounter(self, user, facility): + return self.check_permission_in_facility_organization( + [ + DevicePermissions.can_manage_devices.name, + EncounterPermissions.can_write_encounter.name, + ], + user, + facility=facility, + ) + + def can_associate_device_location(self, user, facility): + return self.check_permission_in_facility_organization( + [ + DevicePermissions.can_manage_devices.name, + FacilityLocationPermissions.can_write_facility_locations.name, + ], + user, + facility=facility, + ) + + +AuthorizationController.register_internal_controller(DeviceAccess) From a24061d026b3fa1a85fe2e152ac9b60eac08ae8e Mon Sep 17 00:00:00 2001 From: Prafful Date: Thu, 13 Feb 2025 22:07:35 +0530 Subject: [PATCH 11/20] updated some authz --- care/emr/api/viewsets/device.py | 92 ++++++++++++++++++++++++-- care/emr/api/viewsets/encounter.py | 16 +---- care/emr/tests/test_device_api.py | 93 +++++++++++++++++++++++---- care/security/authorization/device.py | 21 ++++-- 4 files changed, 184 insertions(+), 38 deletions(-) diff --git a/care/emr/api/viewsets/device.py b/care/emr/api/viewsets/device.py index 8c7a091d4d..ffdcff7072 100644 --- a/care/emr/api/viewsets/device.py +++ b/care/emr/api/viewsets/device.py @@ -15,7 +15,7 @@ Encounter, FacilityLocation, ) -from care.emr.models.organization import FacilityOrganizationUser +from care.emr.models.organization import FacilityOrganization, FacilityOrganizationUser from care.emr.resources.device.spec import ( DeviceCreateSpec, DeviceEncounterHistoryListSpec, @@ -24,6 +24,7 @@ DeviceRetrieveSpec, DeviceUpdateSpec, ) +from care.emr.resources.encounter.constants import COMPLETED_CHOICES from care.facility.models import Facility from care.security.authorization import AuthorizationController @@ -52,11 +53,16 @@ def authorize_create(self, instance): raise PermissionDenied("You do not have permission to create device") def authorize_update(self, instance, model_instance): - if not AuthorizationController.call("can_manage_devices", self.request.user): + if not AuthorizationController.call( + "can_manage_devices", self.request.user, model_instance + ): raise PermissionDenied("You do not have permission to update device") def authorize_destroy(self, instance): - if not AuthorizationController.call("can_manage_devices", self.request.user): + device = self.get_object() + if not AuthorizationController.call( + "can_manage_devices", self.request.user, device + ): raise PermissionDenied("You do not have permission to delete device") def perform_create(self, instance): @@ -106,7 +112,7 @@ def associate_encounter(self, request, *args, **kwargs): facility = self.get_facility_obj() if not AuthorizationController.call( - "can_associate_device_encounter", self.request.user, facility + "can_associate_device_encounter", self.request.user, device, facility ): raise PermissionDenied( "You do not have permission to associate encounter to this device" @@ -151,7 +157,7 @@ def associate_location(self, request, *args, **kwargs): device = self.get_object() if not AuthorizationController.call( - "can_associate_device_location", self.request.user, facility + "can_associate_device_location", self.request.user, device, facility ): raise PermissionDenied( "You do not have permission to associate location to this device" @@ -180,6 +186,64 @@ def associate_location(self, request, *args, **kwargs): return Response(DeviceLocationHistoryListSpec.serialize(obj).to_json()) return Response({}) + class DeviceManageOrganizationRequest(BaseModel): + managing_organization: UUID4 + + @action(detail=True, methods=["POST"]) + def add_managing_organization(self, request, *args, **kwargs): + request_data = self.DeviceManageOrganizationRequest(**request.data) + device = self.get_object() + facility = self.get_facility_obj() + organization = get_object_or_404( + FacilityOrganization, external_id=request_data.managing_organization + ) + + if not AuthorizationController.call("can_manage_devices", request.user, device): + raise PermissionDenied( + "You do not have permission to remove organization from this device" + ) + if not AuthorizationController.call( + "can_manage_facility_organization_obj", request.user, organization + ): + raise PermissionDenied( + "You do not have permission to manage facility organization" + ) + + if organization.facility_id != facility.id: + raise ValidationError("Organization is not part of given facility") + if Device.objects.filter( + id=device.id, managing_organization=organization + ).exists(): + raise ValidationError("Organization is already associated with this device") + device.managing_organization = organization + device.save(update_fields=["managing_organization"]) + return Response({}) + + @action(detail=True, methods=["POST"]) + def remove_managing_organization(self, request, *args, **kwargs): + device = self.get_object() + + if device.managing_organization is None: + raise ValidationError( + "No managing organization is associated with this device" + ) + if not AuthorizationController.call("can_manage_devices", request.user, device): + raise PermissionDenied( + "You do not have permission to remove organization from this device" + ) + if not AuthorizationController.call( + "can_manage_facility_organization_obj", + request.user, + device.managing_organization, + ): + raise PermissionDenied( + "You do not have permission to manage facility organization" + ) + + device.managing_organization = None + device.save(update_fields=["managing_organization"]) + return Response({}) + class DeviceLocationHistoryViewSet(EMRModelReadOnlyViewSet): database_model = DeviceLocationHistory @@ -190,8 +254,7 @@ def get_device(self): def get_queryset(self): if not AuthorizationController.call( - "can_list_devices", - self.request.user, + "can_list_devices", self.request.user, self.get_device() ): raise PermissionDenied("You do not have permission to access the device") @@ -213,6 +276,7 @@ def get_queryset(self): if not AuthorizationController.call( "can_list_devices", self.request.user, + self.get_device(), ): raise PermissionDenied("You do not have permission to access the device") @@ -221,3 +285,17 @@ def get_queryset(self): .select_related("encounter", "encounter__patient", "encounter__facility") .order_by("-end") ) + + +def disassociate_device_from_encounter(instance): + if instance.status in COMPLETED_CHOICES: + device_ids = list( + Device.objects.filter(current_encounter=instance).values_list( + "id", flat=True + ) + ) + Device.objects.filter(id__in=device_ids).update(current_encounter=None) + + DeviceEncounterHistory.objects.filter( + device_id__in=device_ids, encounter=instance, end__isnull=True + ).update(end=timezone.now()) diff --git a/care/emr/api/viewsets/encounter.py b/care/emr/api/viewsets/encounter.py index 89f1c9c436..1f9d84ce12 100644 --- a/care/emr/api/viewsets/encounter.py +++ b/care/emr/api/viewsets/encounter.py @@ -19,9 +19,8 @@ EMRRetrieveMixin, EMRUpdateMixin, ) +from care.emr.api.viewsets.device import disassociate_device_from_encounter from care.emr.models import ( - Device, - DeviceEncounterHistory, Encounter, EncounterOrganization, FacilityOrganization, @@ -100,18 +99,7 @@ def perform_create(self, instance): def perform_update(self, instance): with transaction.atomic(): - if instance.status in COMPLETED_CHOICES: - device_ids = list( - Device.objects.filter(current_encounter=instance).values_list( - "id", flat=True - ) - ) - Device.objects.filter(id__in=device_ids).update(current_encounter=None) - - DeviceEncounterHistory.objects.filter( - device_id__in=device_ids, encounter=instance, end__isnull=True - ).update(end=timezone.now()) - + disassociate_device_from_encounter(instance) super().perform_update(instance) def authorize_update(self, request_obj, model_instance): diff --git a/care/emr/tests/test_device_api.py b/care/emr/tests/test_device_api.py index 2dd28002a2..c2bc7d2944 100644 --- a/care/emr/tests/test_device_api.py +++ b/care/emr/tests/test_device_api.py @@ -16,6 +16,9 @@ from care.emr.tests.test_location_api import FacilityLocationMixin from care.security.permissions.device import DevicePermissions from care.security.permissions.encounter import EncounterPermissions +from care.security.permissions.facility_organization import ( + FacilityOrganizationPermissions, +) from care.security.permissions.location import FacilityLocationPermissions from care.utils.tests.base import CareAPITestBase @@ -24,9 +27,6 @@ class DeviceBaseTest(CareAPITestBase, FacilityLocationMixin): def setUp(self): self.user = self.create_user() self.facility = self.create_facility(user=self.user) - self.facility_organization = self.create_facility_organization( - facility=self.facility - ) self.client.force_authenticate(user=self.user) self.patient = self.create_patient() self.super_user = self.create_super_user() @@ -54,7 +54,7 @@ def create_device(self): def add_permissions(self, permissions): role = self.create_role_with_permissions(permissions) self.attach_role_facility_organization_user( - self.facility_organization, self.user, role + self.facility.default_internal_organization, self.user, role ) def get_device_detail_url(self, device): @@ -137,7 +137,7 @@ def test_delete_device_with_permissions(self): def test_associate_device_encounter_without_device_permission(self): device = self.create_device() encounter = self.create_encounter( - self.patient, self.facility, self.facility_organization + self.patient, self.facility, self.facility.default_internal_organization ) # Only encounter permission attached (missing device permission). self.add_permissions([EncounterPermissions.can_write_encounter.name]) @@ -187,7 +187,7 @@ def test_associate_device_encounter_different_facility(self): def test_associate_device_encounter_success(self): device = self.create_device() encounter = self.create_encounter( - self.patient, self.facility, self.facility_organization + self.patient, self.facility, self.facility.default_internal_organization ) self.add_permissions( [ @@ -206,7 +206,7 @@ def test_associate_device_encounter_success(self): def test_associate_device_encounter_duplicate(self): device = self.create_device() encounter = self.create_encounter( - self.patient, self.facility, self.facility_organization + self.patient, self.facility, self.facility.default_internal_organization ) self.add_permissions( [ @@ -331,7 +331,7 @@ def test_dissociation_device_encounter_after_encounter_status_update(self): encounter = self.create_encounter( self.patient, self.facility, - self.facility_organization, + self.facility.default_internal_organization, status_history={"history": []}, encounter_class=ClassChoices.imp.value, ) @@ -363,6 +363,67 @@ def test_dissociation_device_encounter_after_encounter_status_update(self): device_instance.refresh_from_db() self.assertIsNone(device_instance.current_encounter) + def test_add_managing_organization(self): + device = self.create_device() + managing_org = self.create_facility_organization(self.facility) + self.add_permissions( + [ + DevicePermissions.can_manage_devices.name, + FacilityOrganizationPermissions.can_manage_facility_organization.name, + ] + ) + add_url = reverse( + "device-add-managing-organization", + kwargs={ + "facility_external_id": self.facility.external_id, + "external_id": device["id"], + }, + ) + data = {"managing_organization": managing_org.external_id} + response = self.client.post(add_url, data=data, format="json") + self.assertEqual(response.status_code, 200) + updated_device = Device.objects.get(external_id=device["id"]) + self.assertEqual(updated_device.managing_organization, managing_org) + + def test_remove_managing_organization(self): + # First, add the managing organization + device = self.create_device() + managing_org = self.create_facility_organization(self.facility) + self.add_permissions( + [ + DevicePermissions.can_manage_devices.name, + FacilityOrganizationPermissions.can_manage_facility_organization.name, + ] + ) + add_url = reverse( + "device-add-managing-organization", + kwargs={ + "facility_external_id": self.facility.external_id, + "external_id": device["id"], + }, + ) + data = {"managing_organization": managing_org.external_id} + response = self.client.post(add_url, data=data, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual( + Device.objects.get(external_id=device["id"]).managing_organization, + managing_org, + ) + + # Now remove the managing organization + remove_url = reverse( + "device-remove-managing-organization", + kwargs={ + "facility_external_id": self.facility.external_id, + "external_id": device["id"], + }, + ) + response = self.client.post(remove_url, format="json") + self.assertEqual(response.status_code, 200) + self.assertIsNone( + Device.objects.get(external_id=device["id"]).managing_organization + ) + class TestDeviceLocationHistoryViewSet(DeviceBaseTest): def setUp(self): @@ -419,7 +480,7 @@ def setUp(self): super().setUp() self.device = self.create_device() self.encounter = self.create_encounter( - self.patient, self.facility, self.facility_organization + self.patient, self.facility, self.facility.default_internal_organization ) self.base_url = reverse( "device_encounter_history-list", @@ -442,7 +503,12 @@ def test_list_device_encounter_history(self): self.associate_encounter_with_device(self.device, self.encounter) response = self.client.get(self.base_url) self.assertEqual(response.status_code, 403) - self.add_permissions([DevicePermissions.can_list_devices.name]) + self.add_permissions( + [ + DevicePermissions.can_list_devices.name, + EncounterPermissions.can_list_encounter.name, + ] + ) response = self.client.get(self.base_url) self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["count"], 1) @@ -459,7 +525,12 @@ def test_retrieve_device_encounter_history(self): ) response = self.client.get(url) self.assertEqual(response.status_code, 403) - self.add_permissions([DevicePermissions.can_list_devices.name]) + self.add_permissions( + [ + DevicePermissions.can_list_devices.name, + FacilityLocationPermissions.can_list_facility_locations.name, + ] + ) response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["id"], history["id"]) diff --git a/care/security/authorization/device.py b/care/security/authorization/device.py index ae64b0e74a..4cb5e824a6 100644 --- a/care/security/authorization/device.py +++ b/care/security/authorization/device.py @@ -5,36 +5,45 @@ class DeviceAccess(AuthorizationHandler): - def can_list_devices(self, user): + def can_list_devices(self, user, device): return self.check_permission_in_facility_organization( [DevicePermissions.can_list_devices.name], user, + device.facility_organization_cache, ) - def can_manage_devices(self, user): + def can_manage_devices(self, user, device=None): + if not device: + return self.check_permission_in_facility_organization( + [DevicePermissions.can_manage_devices.name], + user, + ) return self.check_permission_in_facility_organization( [DevicePermissions.can_manage_devices.name], user, + device.facility_organization_cache, ) - def can_associate_device_encounter(self, user, facility): + def can_associate_device_encounter(self, user, device, facility): return self.check_permission_in_facility_organization( [ DevicePermissions.can_manage_devices.name, EncounterPermissions.can_write_encounter.name, ], user, - facility=facility, + device.facility_organization_cache, + facility, ) - def can_associate_device_location(self, user, facility): + def can_associate_device_location(self, user, device, facility): return self.check_permission_in_facility_organization( [ DevicePermissions.can_manage_devices.name, FacilityLocationPermissions.can_write_facility_locations.name, ], user, - facility=facility, + device.facility_organization_cache, + facility, ) From c17890b1da44ab1e1898d65a369cddac3ff64567 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Fri, 14 Feb 2025 01:59:23 +0530 Subject: [PATCH 12/20] Fix permissions --- care/emr/api/viewsets/device.py | 108 ++++++++++++++------------ care/security/authorization/device.py | 43 ++++------ 2 files changed, 75 insertions(+), 76 deletions(-) diff --git a/care/emr/api/viewsets/device.py b/care/emr/api/viewsets/device.py index ffdcff7072..ff96d77b57 100644 --- a/care/emr/api/viewsets/device.py +++ b/care/emr/api/viewsets/device.py @@ -30,7 +30,6 @@ class DeviceFilters(filters.FilterSet): - current_location = filters.UUIDFilter(field_name="current_location__external_id") current_encounter = filters.UUIDFilter(field_name="current_encounter__external_id") @@ -49,21 +48,20 @@ def get_facility_obj(self): ) def authorize_create(self, instance): - if not AuthorizationController.call("can_manage_devices", self.request.user): + facility = self.get_facility_obj() + if not AuthorizationController.call( + "can_create_device", self.request.user, facility + ): raise PermissionDenied("You do not have permission to create device") def authorize_update(self, instance, model_instance): if not AuthorizationController.call( - "can_manage_devices", self.request.user, model_instance + "can_manage_device", self.request.user, model_instance ): raise PermissionDenied("You do not have permission to update device") def authorize_destroy(self, instance): - device = self.get_object() - if not AuthorizationController.call( - "can_manage_devices", self.request.user, device - ): - raise PermissionDenied("You do not have permission to delete device") + self.authorize_update(None, instance) def perform_create(self, instance): instance.facility = self.get_facility_obj() @@ -86,12 +84,18 @@ def get_queryset(self): ).values_list("organization_id", flat=True) if "location" in self.request.GET: - queryset = queryset.filter( - facility_organization_cache__overlap=users_facility_organizations + location = get_object_or_404( + FacilityLocation, external_id=self.request.GET["location"] ) - # TODO Check access to location with permission and then allow filter with or, - # Access should not be limited by location if the device has org access - # If location access then allow all, otherwise apply organization filter + if AuthorizationController.call( + "can_read_devices_on_location", self.request.user, location + ): + queryset = queryset.filter(current_location=location) + else: + queryset = queryset.filter( + facility_organization_cache__overlap=users_facility_organizations, + location=location, + ) else: queryset = queryset.filter( facility_organization_cache__overlap=users_facility_organizations @@ -111,17 +115,20 @@ def associate_encounter(self, request, *args, **kwargs): device = self.get_object() facility = self.get_facility_obj() - if not AuthorizationController.call( - "can_associate_device_encounter", self.request.user, device, facility + if encounter and device.current_encounter_id == encounter.id: + raise ValidationError("Encounter already associated") + if encounter and encounter.facility_id != facility.id: + raise ValidationError("Encounter is not part of given facility") + + self.authorize_update(None, device) + + if encounter and not AuthorizationController.call( + "can_update_encounter_obj", self.request.user, encounter ): raise PermissionDenied( "You do not have permission to associate encounter to this device" ) - if encounter and device.current_encounter_id == encounter.id: - raise ValidationError("Encounter already associated") - if encounter and encounter.facility_id != facility.id: - raise ValidationError("Encounter is not part of given facility") with transaction.atomic(): if device.current_encounter: old_obj = DeviceEncounterHistory.objects.filter( @@ -156,16 +163,19 @@ def associate_location(self, request, *args, **kwargs): facility = self.get_facility_obj() device = self.get_object() - if not AuthorizationController.call( - "can_associate_device_location", self.request.user, device, facility - ): - raise PermissionDenied( - "You do not have permission to associate location to this device" - ) if location and device.current_location_id == location.id: raise ValidationError("Location already associated") if location and location.facility_id != facility.id: raise ValidationError("Location is not part of given facility") + + self.authorize_update(None, device) + + if location and not AuthorizationController.call( + "can_update_facility_location_obj", self.request.user, location + ): + raise PermissionDenied( + "You do not have permission to associate location to this device" + ) with transaction.atomic(): if device.current_location: old_obj = DeviceLocationHistory.objects.filter( @@ -197,20 +207,16 @@ def add_managing_organization(self, request, *args, **kwargs): organization = get_object_or_404( FacilityOrganization, external_id=request_data.managing_organization ) + if organization.facility_id != facility.id: + raise ValidationError("Organization is not part of given facility") - if not AuthorizationController.call("can_manage_devices", request.user, device): - raise PermissionDenied( - "You do not have permission to remove organization from this device" - ) + self.authorize_update(None, device) if not AuthorizationController.call( "can_manage_facility_organization_obj", request.user, organization ): raise PermissionDenied( "You do not have permission to manage facility organization" ) - - if organization.facility_id != facility.id: - raise ValidationError("Organization is not part of given facility") if Device.objects.filter( id=device.id, managing_organization=organization ).exists(): @@ -227,10 +233,7 @@ def remove_managing_organization(self, request, *args, **kwargs): raise ValidationError( "No managing organization is associated with this device" ) - if not AuthorizationController.call("can_manage_devices", request.user, device): - raise PermissionDenied( - "You do not have permission to remove organization from this device" - ) + self.authorize_update(None, device) if not AuthorizationController.call( "can_manage_facility_organization_obj", request.user, @@ -253,13 +256,14 @@ def get_device(self): return get_object_or_404(Device, external_id=self.kwargs["device_external_id"]) def get_queryset(self): + device = self.get_device() if not AuthorizationController.call( - "can_list_devices", self.request.user, self.get_device() + "can_read_device", self.request.user, device ): raise PermissionDenied("You do not have permission to access the device") return ( - DeviceLocationHistory.objects.filter(device=self.get_device()) + DeviceLocationHistory.objects.filter(device=device) .select_related("location") .order_by("-end") ) @@ -273,15 +277,18 @@ def get_device(self): return get_object_or_404(Device, external_id=self.kwargs["device_external_id"]) def get_queryset(self): + """ + Encounter history access is limited to everyone within the location or associated with the managing org + """ + device = self.get_device() if not AuthorizationController.call( - "can_list_devices", + "can_read_device", self.request.user, - self.get_device(), + device, ): raise PermissionDenied("You do not have permission to access the device") - return ( - DeviceEncounterHistory.objects.filter(device=self.get_device()) + DeviceEncounterHistory.objects.filter(device=device) .select_related("encounter", "encounter__patient", "encounter__facility") .order_by("-end") ) @@ -289,13 +296,14 @@ def get_queryset(self): def disassociate_device_from_encounter(instance): if instance.status in COMPLETED_CHOICES: - device_ids = list( - Device.objects.filter(current_encounter=instance).values_list( - "id", flat=True + with transaction.atomic(): + device_ids = list( + Device.objects.filter(current_encounter=instance).values_list( + "id", flat=True + ) ) - ) - Device.objects.filter(id__in=device_ids).update(current_encounter=None) + Device.objects.filter(id__in=device_ids).update(current_encounter=None) - DeviceEncounterHistory.objects.filter( - device_id__in=device_ids, encounter=instance, end__isnull=True - ).update(end=timezone.now()) + DeviceEncounterHistory.objects.filter( + device_id__in=device_ids, encounter=instance, end__isnull=True + ).update(end=timezone.now()) diff --git a/care/security/authorization/device.py b/care/security/authorization/device.py index 4cb5e824a6..cee34806c0 100644 --- a/care/security/authorization/device.py +++ b/care/security/authorization/device.py @@ -1,49 +1,40 @@ from care.security.authorization import AuthorizationController, AuthorizationHandler from care.security.permissions.device import DevicePermissions -from care.security.permissions.encounter import EncounterPermissions -from care.security.permissions.location import FacilityLocationPermissions class DeviceAccess(AuthorizationHandler): - def can_list_devices(self, user, device): + def can_read_devices_on_location(self, user, location): return self.check_permission_in_facility_organization( [DevicePermissions.can_list_devices.name], user, - device.facility_organization_cache, + location.facility_organization_cache, ) - def can_manage_devices(self, user, device=None): - if not device: - return self.check_permission_in_facility_organization( - [DevicePermissions.can_manage_devices.name], - user, - ) - return self.check_permission_in_facility_organization( - [DevicePermissions.can_manage_devices.name], + def can_read_device(self, user, device): + org_permission = self.check_permission_in_facility_organization( + [DevicePermissions.can_list_devices.name], user, device.facility_organization_cache, ) + location_permission = False + if device.current_location: + location_permission = self.check_permission_in_facility_organization( + [DevicePermissions.can_list_devices.name], + user, + device.current_location.facility_organization_cache, + ) + return org_permission or location_permission - def can_associate_device_encounter(self, user, device, facility): + def can_create_device(self, user, facility): return self.check_permission_in_facility_organization( - [ - DevicePermissions.can_manage_devices.name, - EncounterPermissions.can_write_encounter.name, - ], - user, - device.facility_organization_cache, - facility, + [DevicePermissions.can_manage_devices.name], user, facility=facility ) - def can_associate_device_location(self, user, device, facility): + def can_manage_device(self, user, device): return self.check_permission_in_facility_organization( - [ - DevicePermissions.can_manage_devices.name, - FacilityLocationPermissions.can_write_facility_locations.name, - ], + [DevicePermissions.can_manage_devices.name], user, device.facility_organization_cache, - facility, ) From d8ffda47df9f5bc8273e19847c5a7c1a89dec127 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Fri, 14 Feb 2025 02:34:50 +0530 Subject: [PATCH 13/20] Add Example for device integration --- care/emr/api/viewsets/device.py | 27 ++++++++- care/emr/migrations/0019_device_metadata.py | 18 ++++++ care/emr/models/device.py | 1 + .../registries/device_type/device_registry.py | 57 +++++++++++++++++++ care/emr/resources/device/spec.py | 28 +++++++-- 5 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 care/emr/migrations/0019_device_metadata.py diff --git a/care/emr/api/viewsets/device.py b/care/emr/api/viewsets/device.py index ff96d77b57..86ad249f21 100644 --- a/care/emr/api/viewsets/device.py +++ b/care/emr/api/viewsets/device.py @@ -16,6 +16,7 @@ FacilityLocation, ) from care.emr.models.organization import FacilityOrganization, FacilityOrganizationUser +from care.emr.registries.device_type.device_registry import DeviceTypeRegistry from care.emr.resources.device.spec import ( DeviceCreateSpec, DeviceEncounterHistoryListSpec, @@ -65,7 +66,31 @@ def authorize_destroy(self, instance): def perform_create(self, instance): instance.facility = self.get_facility_obj() - super().perform_create(instance) + with transaction.atomic(): + super().perform_create(instance) + if instance.care_type: + care_device_class = DeviceTypeRegistry.get_care_device_class( + instance.care_type + ) + care_device_class().handle_create(self.request.data, instance) + + def perform_update(self, instance): + with transaction.atomic(): + super().perform_update(instance) + if instance.care_type: + care_device_class = DeviceTypeRegistry.get_care_device_class( + instance.care_type + ) + care_device_class().handle_update(self.request.data, instance) + + def perform_destroy(self, instance): + with transaction.atomic(): + if instance.care_type: + care_device_class = DeviceTypeRegistry.get_care_device_class( + instance.care_type + ) + care_device_class().handle_update(self.request.data, instance) + super().perform_destroy(instance) def get_queryset(self): """ diff --git a/care/emr/migrations/0019_device_metadata.py b/care/emr/migrations/0019_device_metadata.py new file mode 100644 index 0000000000..99b1a9ff28 --- /dev/null +++ b/care/emr/migrations/0019_device_metadata.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.4 on 2025-02-13 20:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('emr', '0018_alter_device_care_type_alter_device_status'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='metadata', + field=models.JSONField(default=dict), + ), + ] diff --git a/care/emr/models/device.py b/care/emr/models/device.py index f1e1ec1646..5a2649dc01 100644 --- a/care/emr/models/device.py +++ b/care/emr/models/device.py @@ -20,6 +20,7 @@ class Device(EMRBaseModel): part_number = models.CharField(max_length=1024, null=True, blank=True) contact = models.JSONField(default=dict) care_type = models.CharField(max_length=1024, null=True, blank=True, default=None) + metadata = models.JSONField(default=dict) # Relations facility = models.ForeignKey("facility.Facility", on_delete=models.CASCADE) diff --git a/care/emr/registries/device_type/device_registry.py b/care/emr/registries/device_type/device_registry.py index 979c9fc78b..ab00575224 100644 --- a/care/emr/registries/device_type/device_registry.py +++ b/care/emr/registries/device_type/device_registry.py @@ -48,3 +48,60 @@ def register(cls, device_type, device_class) -> None: if not issubclass(device_class, DeviceTypeBase): raise ValueError("The provided class is not a subclass of DeviceTypeBase") cls._device_types[device_type] = device_class + + @classmethod + def get_care_device_class(cls, device_type): + if device_type not in cls._device_types: + raise ValueError("Invalid Device Type") + return cls._device_types.get(device_type) + + +class SomeCameraPlugin(DeviceTypeBase): + def handle_create(self, request_data, obj): + """ + Handle Creates, the original source request along with the base object created is passed along. + Update the obj as needed and create any extra metadata needed. This method is called within a transaction + """ + some_data = request_data.get("some_data", "Not Really There") + obj.metadata["some_data"] = ( + some_data # The metadata objects is left to the plug to use as needed + ) + obj.save(update_fields=["metadata"]) + return obj + + def handle_update(self, request_data, obj): + """ + Handle Updates, the original source request along with the base object updated is passed along. + Update the obj as needed and create any extra metadata needed. This method is called within a transaction + """ + return obj + + def handle_delete(self, obj): + """ + Handle Deletes, the object to be deleted is passed along. + Perform validation or any other changes required here + Delete method is called after this method is invoked, handle as required. + """ + return obj + + def list(self, obj): + """ + Return Extra metadata for the given obj for lists, N+1 queries is okay, caching is recommended for performance + """ + return {"Hello": "There"} + + def retrieve(self, obj): + """ + Return Extra metadata for the given obj during retrieves + """ + return {"Hello": "There from retrieve"} + + def perform_action(self, obj, action, request): + """ + Perform some kind of action on an asset, the HTTP request is proxied through as is. + an HTTP response object is expected as the return. + """ + return # Return an HTTP Response + + +DeviceTypeRegistry.register("camera", SomeCameraPlugin) diff --git a/care/emr/resources/device/spec.py b/care/emr/resources/device/spec.py index 575f296e27..bcee0e3be3 100644 --- a/care/emr/resources/device/spec.py +++ b/care/emr/resources/device/spec.py @@ -1,9 +1,10 @@ from datetime import datetime from enum import Enum -from pydantic import UUID4 +from pydantic import UUID4, field_validator from care.emr.models import Device, DeviceEncounterHistory, DeviceLocationHistory +from care.emr.registries.device_type.device_registry import DeviceTypeRegistry from care.emr.resources.base import EMRResource from care.emr.resources.common.contact_point import ContactPoint from care.emr.resources.encounter.spec import EncounterListSpec @@ -30,9 +31,10 @@ class DeviceSpecBase(EMRResource): "managing_organization", "current_location", "current_encounter", + "care_metadata", ] - id: UUID4 = None + id: UUID4 | None = None identifier: str | None = None status: DeviceStatusChoices @@ -47,21 +49,32 @@ class DeviceSpecBase(EMRResource): model_number: str | None = None part_number: str | None = None contact: list[ContactPoint] = [] - care_type: str | None = None class DeviceCreateSpec(DeviceSpecBase): - pass + care_type: str | None = None + care_metadata: dict = {} + + @field_validator("care_type") + @classmethod + def validate_care_type(cls, value): + DeviceTypeRegistry.get_care_device_class(value) + return value class DeviceUpdateSpec(DeviceSpecBase): - pass + care_metadata: dict = {} class DeviceListSpec(DeviceCreateSpec): + care_metadata: dict = {} + @classmethod def perform_extra_serialization(cls, mapping, obj): mapping["id"] = obj.external_id + if obj.care_type: + care_device_class = DeviceTypeRegistry.get_care_device_class(obj.care_type) + mapping["care_metadata"] = care_device_class().list(obj) class DeviceRetrieveSpec(DeviceListSpec): @@ -73,7 +86,7 @@ class DeviceRetrieveSpec(DeviceListSpec): @classmethod def perform_extra_serialization(cls, mapping, obj): - super().perform_extra_serialization(mapping, obj) + mapping["id"] = obj.external_id mapping["current_location"] = None mapping["current_encounter"] = None if obj.current_location: @@ -85,6 +98,9 @@ def perform_extra_serialization(cls, mapping, obj): obj.current_encounter ).to_json() cls.serialize_audit_users(mapping, obj) + if obj.care_type: + care_device_class = DeviceTypeRegistry.get_care_device_class(obj.care_type) + mapping["care_metadata"] = care_device_class().retrieve(obj) class DeviceLocationHistoryListSpec(EMRResource): From 037b4be53412a0a95d0a7760767605996f2ff262 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Fri, 14 Feb 2025 02:38:35 +0530 Subject: [PATCH 14/20] Fix bug --- care/emr/api/viewsets/device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/care/emr/api/viewsets/device.py b/care/emr/api/viewsets/device.py index 86ad249f21..a6b31ec075 100644 --- a/care/emr/api/viewsets/device.py +++ b/care/emr/api/viewsets/device.py @@ -119,7 +119,7 @@ def get_queryset(self): else: queryset = queryset.filter( facility_organization_cache__overlap=users_facility_organizations, - location=location, + current_location=location, ) else: queryset = queryset.filter( From b99c4358ce5a49ec789019c88e3e9e41352aef48 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Fri, 14 Feb 2025 03:11:14 +0530 Subject: [PATCH 15/20] Add Service Model --- care/emr/api/viewsets/device.py | 76 ++++++++++++++++++++++- care/emr/models/device.py | 2 +- care/emr/resources/device/history_spec.py | 53 ++++++++++++++++ config/api_router.py | 8 ++- 4 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 care/emr/resources/device/history_spec.py diff --git a/care/emr/api/viewsets/device.py b/care/emr/api/viewsets/device.py index a6b31ec075..9a69804563 100644 --- a/care/emr/api/viewsets/device.py +++ b/care/emr/api/viewsets/device.py @@ -7,16 +7,27 @@ from rest_framework.generics import get_object_or_404 from rest_framework.response import Response -from care.emr.api.viewsets.base import EMRModelReadOnlyViewSet, EMRModelViewSet +from care.emr.api.viewsets.base import ( + EMRBaseViewSet, + EMRCreateMixin, + EMRListMixin, + EMRModelReadOnlyViewSet, + EMRModelViewSet, + EMRRetrieveMixin, + EMRUpdateMixin, +) from care.emr.models import ( Device, DeviceEncounterHistory, DeviceLocationHistory, + DeviceServiceHistory, Encounter, FacilityLocation, ) from care.emr.models.organization import FacilityOrganization, FacilityOrganizationUser from care.emr.registries.device_type.device_registry import DeviceTypeRegistry +from care.emr.resources.device.history_spec import DeviceServiceHistoryWriteSpec, DeviceServiceHistoryRetrieveSpec, \ + DeviceServiceHistoryListSpec from care.emr.resources.device.spec import ( DeviceCreateSpec, DeviceEncounterHistoryListSpec, @@ -319,6 +330,69 @@ def get_queryset(self): ) +class DeviceServiceHistoryViewSet( + EMRCreateMixin, + EMRRetrieveMixin, + EMRUpdateMixin, + EMRListMixin, + EMRBaseViewSet, +): + database_model = DeviceServiceHistory + pydantic_model = DeviceServiceHistoryWriteSpec + pydantic_read_model = DeviceServiceHistoryListSpec + pydantic_retrieve_model = DeviceServiceHistoryRetrieveSpec + + def get_device(self): + return get_object_or_404(Device, external_id=self.kwargs["device_external_id"]) + + def perform_create(self, instance): + device = self.get_device() + instance.device = device + super().perform_create(instance) + + def authorize_create(self, instance): + device = self.get_device() + if not AuthorizationController.call( + "can_manage_device", + self.request.user, + device, + ): + raise PermissionDenied("You do not have permission to access the device") + + def authorize_update(self, request_obj, model_instance): + self.authorize_create(model_instance) + + def perform_update(self, instance): + if instance.edit_history and len(instance.edit_history) >= 50: + raise ValidationError("Cannot Edit instance anymore") + if not instance.edit_history: + instance.edit_history = [] + current_instance = DeviceServiceHistory.objects.get(id=instance.id) + instance.edit_history.append( + { + "serviced_on": str(current_instance.serviced_on), + "note": current_instance.note, + "updated_by" : current_instance.updated_by.id + } + ) + super().perform_update(instance) + + def get_queryset(self): + """ + Encounter history access is limited to everyone within the location or associated with the managing org + """ + device = self.get_device() + if not AuthorizationController.call( + "can_read_device", + self.request.user, + device, + ): + raise PermissionDenied("You do not have permission to access the device") + return DeviceServiceHistory.objects.filter(device=device).order_by( + "-serviced_on" + ) + + def disassociate_device_from_encounter(instance): if instance.status in COMPLETED_CHOICES: with transaction.atomic(): diff --git a/care/emr/models/device.py b/care/emr/models/device.py index 5a2649dc01..533feb38db 100644 --- a/care/emr/models/device.py +++ b/care/emr/models/device.py @@ -75,6 +75,6 @@ class DeviceServiceHistory(EMRBaseModel): device = models.ForeignKey( Device, on_delete=models.PROTECT, null=False, blank=False ) - serviced_on = models.DateField(default=None, null=True, blank=False) + serviced_on = models.DateTimeField(default=None, null=True, blank=False) note = models.TextField(default="", null=True, blank=True) edit_history = models.JSONField(default=list) diff --git a/care/emr/resources/device/history_spec.py b/care/emr/resources/device/history_spec.py new file mode 100644 index 0000000000..778bf01ccb --- /dev/null +++ b/care/emr/resources/device/history_spec.py @@ -0,0 +1,53 @@ +from datetime import datetime + +from pydantic import UUID4 + +from care.emr.models import DeviceServiceHistory +from care.emr.resources.base import EMRResource +from care.emr.resources.user.spec import UserSpec +from care.users.models import User + + +class DeviceServiceHistorySpecBase(EMRResource): + __model__ = DeviceServiceHistory + __exclude__ = ["device" , "edit_history"] + id: UUID4 | None = None + + +class DeviceServiceHistoryWriteSpec(DeviceServiceHistorySpecBase): + serviced_on: datetime + note: str + + +class DeviceServiceHistoryListSpec(DeviceServiceHistoryWriteSpec): + + created_date: datetime + modified_date: datetime + + @classmethod + def perform_extra_serialization(cls, mapping, obj): + mapping["id"] = obj.external_id + + +class DeviceServiceHistoryRetrieveSpec(DeviceServiceHistoryListSpec): + edit_history: list[dict] = [] + + 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) + edit_history = [] + for history in obj.edit_history: + user = history.get("updated_by") + user_obj = User.objects.filter(id=user).first() + if user_obj: + history["updated_by"] = UserSpec.serialize(user_obj).to_json() + else: + history["updated_by"] = {} # Edge Case + edit_history.append( + history + ) + mapping["edit_history"] = edit_history diff --git a/config/api_router.py b/config/api_router.py index 88e75bae0d..c9bb7ab3aa 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -16,7 +16,7 @@ from care.emr.api.viewsets.device import ( DeviceEncounterHistoryViewSet, DeviceLocationHistoryViewSet, - DeviceViewSet, + DeviceViewSet, DeviceServiceHistoryViewSet, ) from care.emr.api.viewsets.encounter import EncounterViewSet from care.emr.api.viewsets.facility import ( @@ -193,6 +193,12 @@ basename="device_encounter_history", ) +device_nested_router.register( + r"service_history", + DeviceServiceHistoryViewSet, + basename="device_service_history", +) + facility_location_nested_router = NestedSimpleRouter( facility_nested_router, r"location", lookup="location" ) From 7423e67e9e9c0ff79abfd90c5323024c2caeb498 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Fri, 14 Feb 2025 03:11:25 +0530 Subject: [PATCH 16/20] Fix Linting --- care/emr/api/viewsets/device.py | 9 ++++++--- care/emr/resources/device/history_spec.py | 9 +++------ config/api_router.py | 3 ++- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/care/emr/api/viewsets/device.py b/care/emr/api/viewsets/device.py index 9a69804563..f63db79ca2 100644 --- a/care/emr/api/viewsets/device.py +++ b/care/emr/api/viewsets/device.py @@ -26,8 +26,11 @@ ) from care.emr.models.organization import FacilityOrganization, FacilityOrganizationUser from care.emr.registries.device_type.device_registry import DeviceTypeRegistry -from care.emr.resources.device.history_spec import DeviceServiceHistoryWriteSpec, DeviceServiceHistoryRetrieveSpec, \ - DeviceServiceHistoryListSpec +from care.emr.resources.device.history_spec import ( + DeviceServiceHistoryListSpec, + DeviceServiceHistoryRetrieveSpec, + DeviceServiceHistoryWriteSpec, +) from care.emr.resources.device.spec import ( DeviceCreateSpec, DeviceEncounterHistoryListSpec, @@ -372,7 +375,7 @@ def perform_update(self, instance): { "serviced_on": str(current_instance.serviced_on), "note": current_instance.note, - "updated_by" : current_instance.updated_by.id + "updated_by": current_instance.updated_by.id, } ) super().perform_update(instance) diff --git a/care/emr/resources/device/history_spec.py b/care/emr/resources/device/history_spec.py index 778bf01ccb..35dda7b4f9 100644 --- a/care/emr/resources/device/history_spec.py +++ b/care/emr/resources/device/history_spec.py @@ -10,7 +10,7 @@ class DeviceServiceHistorySpecBase(EMRResource): __model__ = DeviceServiceHistory - __exclude__ = ["device" , "edit_history"] + __exclude__ = ["device", "edit_history"] id: UUID4 | None = None @@ -20,7 +20,6 @@ class DeviceServiceHistoryWriteSpec(DeviceServiceHistorySpecBase): class DeviceServiceHistoryListSpec(DeviceServiceHistoryWriteSpec): - created_date: datetime modified_date: datetime @@ -46,8 +45,6 @@ def perform_extra_serialization(cls, mapping, obj): if user_obj: history["updated_by"] = UserSpec.serialize(user_obj).to_json() else: - history["updated_by"] = {} # Edge Case - edit_history.append( - history - ) + history["updated_by"] = {} # Edge Case + edit_history.append(history) mapping["edit_history"] = edit_history diff --git a/config/api_router.py b/config/api_router.py index c9bb7ab3aa..0d0a7578e0 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -16,7 +16,8 @@ from care.emr.api.viewsets.device import ( DeviceEncounterHistoryViewSet, DeviceLocationHistoryViewSet, - DeviceViewSet, DeviceServiceHistoryViewSet, + DeviceServiceHistoryViewSet, + DeviceViewSet, ) from care.emr.api.viewsets.encounter import EncounterViewSet from care.emr.api.viewsets.facility import ( From 0fca43eeb723f923a457c8f560e1687cd3fc7bcf Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Fri, 14 Feb 2025 11:30:54 +0530 Subject: [PATCH 17/20] Add managing organization to view --- care/emr/resources/device/spec.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/care/emr/resources/device/spec.py b/care/emr/resources/device/spec.py index bcee0e3be3..820be13f12 100644 --- a/care/emr/resources/device/spec.py +++ b/care/emr/resources/device/spec.py @@ -8,6 +8,7 @@ from care.emr.resources.base import EMRResource from care.emr.resources.common.contact_point import ContactPoint from care.emr.resources.encounter.spec import EncounterListSpec +from care.emr.resources.facility_organization.spec import FacilityOrganizationReadSpec from care.emr.resources.location.spec import FacilityLocationListSpec @@ -83,6 +84,7 @@ class DeviceRetrieveSpec(DeviceListSpec): created_by: dict | None = None updated_by: dict | None = None + managing_organization : dict | None = None @classmethod def perform_extra_serialization(cls, mapping, obj): @@ -102,6 +104,8 @@ def perform_extra_serialization(cls, mapping, obj): care_device_class = DeviceTypeRegistry.get_care_device_class(obj.care_type) mapping["care_metadata"] = care_device_class().retrieve(obj) + if obj.managing_organization: + mapping["managing_organization"] = FacilityOrganizationReadSpec.serialize(obj.managing_organization).to_json() class DeviceLocationHistoryListSpec(EMRResource): __model__ = DeviceLocationHistory From a7913cd27f362fb2da044ba5e8c4f7646c2d4de2 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Fri, 14 Feb 2025 11:33:10 +0530 Subject: [PATCH 18/20] Add managing organization to retrieve --- care/emr/api/viewsets/device.py | 2 +- care/emr/resources/device/spec.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/care/emr/api/viewsets/device.py b/care/emr/api/viewsets/device.py index f63db79ca2..0520235d69 100644 --- a/care/emr/api/viewsets/device.py +++ b/care/emr/api/viewsets/device.py @@ -366,7 +366,7 @@ def authorize_update(self, request_obj, model_instance): self.authorize_create(model_instance) def perform_update(self, instance): - if instance.edit_history and len(instance.edit_history) >= 50: + if instance.edit_history and len(instance.edit_history) >= 50: # noqa PLR2004 raise ValidationError("Cannot Edit instance anymore") if not instance.edit_history: instance.edit_history = [] diff --git a/care/emr/resources/device/spec.py b/care/emr/resources/device/spec.py index 820be13f12..2211d953ce 100644 --- a/care/emr/resources/device/spec.py +++ b/care/emr/resources/device/spec.py @@ -84,7 +84,7 @@ class DeviceRetrieveSpec(DeviceListSpec): created_by: dict | None = None updated_by: dict | None = None - managing_organization : dict | None = None + managing_organization: dict | None = None @classmethod def perform_extra_serialization(cls, mapping, obj): @@ -105,7 +105,10 @@ def perform_extra_serialization(cls, mapping, obj): mapping["care_metadata"] = care_device_class().retrieve(obj) if obj.managing_organization: - mapping["managing_organization"] = FacilityOrganizationReadSpec.serialize(obj.managing_organization).to_json() + mapping["managing_organization"] = FacilityOrganizationReadSpec.serialize( + obj.managing_organization + ).to_json() + class DeviceLocationHistoryListSpec(EMRResource): __model__ = DeviceLocationHistory From 46f8a4d5da923f17a4578a6a69dd52ff47aa1592 Mon Sep 17 00:00:00 2001 From: Prafful Date: Sat, 15 Feb 2025 21:01:36 +0530 Subject: [PATCH 19/20] fixed and added more tests --- care/emr/tests/test_device_api.py | 352 ++++++++++++++++++++++++++---- 1 file changed, 305 insertions(+), 47 deletions(-) diff --git a/care/emr/tests/test_device_api.py b/care/emr/tests/test_device_api.py index c2bc7d2944..e7fc424b77 100644 --- a/care/emr/tests/test_device_api.py +++ b/care/emr/tests/test_device_api.py @@ -1,7 +1,9 @@ +import uuid from secrets import choice from uuid import uuid4 from django.urls import reverse +from django.utils.timezone import now from care.emr.models import Device from care.emr.resources.device.spec import ( @@ -40,12 +42,12 @@ def generate_device_data(self, **kwargs): data.update(**kwargs) return data - def create_device(self): + def create_device(self, **kwargs): self.client.force_authenticate(self.super_user) url = reverse( "device-list", kwargs={"facility_external_id": self.facility.external_id} ) - data = self.generate_device_data() + data = self.generate_device_data(**kwargs) response = self.client.post(url, data=data, format="json") self.assertEqual(response.status_code, 200) self.client.force_authenticate(self.user) @@ -91,8 +93,36 @@ def setUp(self): self.base_url = reverse( "device-list", kwargs={"facility_external_id": self.facility.external_id} ) + self.device = self.create_device() + self.managing_org = self.create_facility_organization(self.facility) + self.add_url = reverse( + "device-add-managing-organization", + kwargs={ + "facility_external_id": self.facility.external_id, + "external_id": self.device["id"], + }, + ) + self.remove_url = reverse( + "device-remove-managing-organization", + kwargs={ + "facility_external_id": self.facility.external_id, + "external_id": self.device["id"], + }, + ) # -------------------- Device CRUD Tests -------------------- + + def test_list_devices(self): + self.add_permissions([DevicePermissions.can_list_devices.name]) + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, 200) + + def test_retrieve_device(self): + self.add_permissions([DevicePermissions.can_list_devices.name]) + url = self.get_device_detail_url(self.device) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + def test_create_device_without_permissions(self): data = self.generate_device_data() response = self.client.post(self.base_url, data=data, format="json") @@ -104,6 +134,23 @@ def test_create_device_with_permissions(self): response = self.client.post(self.base_url, data=data, format="json") self.assertEqual(response.status_code, 200) + def test_create_device_with_care_type(self): + self.add_permissions([DevicePermissions.can_manage_devices.name]) + data = self.generate_device_data( + care_type="some_care_type" + ) # invalid care type + response = self.client.post(self.base_url, data=data, format="json") + self.assertEqual(response.status_code, 400) + response_data = response.json() + self.assertEqual(response_data["errors"][0]["type"], "value_error") + self.assertEqual( + response_data["errors"][0]["msg"], "Value error, Invalid Device Type" + ) + + data = self.generate_device_data(care_type="camera") # valid care type + response = self.client.post(self.base_url, data=data, format="json") + self.assertEqual(response.status_code, 200) + def test_update_device_without_permissions(self): device = self.create_device() url = self.get_device_detail_url(device) @@ -120,6 +167,15 @@ def test_update_device_with_permissions(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["registered_name"], data["registered_name"]) + def test_update_device_with_care_plan(self): + device = self.create_device(care_type="camera") + self.add_permissions([DevicePermissions.can_manage_devices.name]) + url = self.get_device_detail_url(device) + data = self.generate_device_data() + response = self.client.put(url, data=data, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["registered_name"], data["registered_name"]) + def test_delete_device_without_permissions(self): device = self.create_device() url = self.get_device_detail_url(device) @@ -127,7 +183,7 @@ def test_delete_device_without_permissions(self): self.assertEqual(response.status_code, 403) def test_delete_device_with_permissions(self): - device = self.create_device() + device = self.create_device(care_type="camera") self.add_permissions([DevicePermissions.can_manage_devices.name]) url = self.get_device_detail_url(device) response = self.client.delete(url) @@ -140,7 +196,7 @@ def test_associate_device_encounter_without_device_permission(self): self.patient, self.facility, self.facility.default_internal_organization ) # Only encounter permission attached (missing device permission). - self.add_permissions([EncounterPermissions.can_write_encounter.name]) + self.add_permissions([DevicePermissions.can_manage_devices.name]) url = self.get_associate_encounter_url(device) data = {"encounter": encounter.external_id} response = self.client.post(url, data=data, format="json") @@ -228,6 +284,9 @@ def test_associate_device_encounter_duplicate(self): def test_disassociate_encounter(self): device = self.create_device() + encounter = self.create_encounter( + self.patient, self.facility, self.facility.default_internal_organization + ) self.add_permissions( [ EncounterPermissions.can_write_encounter.name, @@ -235,6 +294,9 @@ def test_disassociate_encounter(self): ] ) url = self.get_associate_encounter_url(device) + data = {"encounter": encounter.external_id} + response = self.client.post(url, data=data, format="json") + self.assertEqual(response.status_code, 200) data = {"encounter": None} response = self.client.post(url, data=data, format="json") self.assertEqual(response.status_code, 200) @@ -246,9 +308,7 @@ def test_disassociate_encounter(self): def test_associate_device_location_without_permission(self): device = self.create_device() location = self.create_facility_location() - self.add_permissions( - [FacilityLocationPermissions.can_write_facility_locations.name] - ) + self.add_permissions([DevicePermissions.can_manage_devices.name]) url = self.get_associate_location_url(device) data = {"location": location["id"]} response = self.client.post(url, data=data, format="json") @@ -271,6 +331,18 @@ def test_associate_device_location_invalid_location(self): response = self.client.post(url, data=data, format="json") self.assertEqual(response.status_code, 404) + outer_facility = self.create_facility(user=self.user) + data = { + "location": self.create_facility_location( + facility=outer_facility.external_id + )["id"] + } + response = self.client.post(url, data=data, format="json") + self.assertEqual(response.status_code, 400) + error = response.json()["errors"][0] + self.assertEqual(error["type"], "validation_error") + self.assertIn("Location is not part of given facility", error["msg"]) + def test_associate_device_location_success(self): device = self.create_device() location = self.create_facility_location() @@ -364,64 +436,101 @@ def test_dissociation_device_encounter_after_encounter_status_update(self): self.assertIsNone(device_instance.current_encounter) def test_add_managing_organization(self): - device = self.create_device() - managing_org = self.create_facility_organization(self.facility) + self.add_permissions([DevicePermissions.can_manage_devices.name]) + response = self.client.post( + self.add_url, + data={"managing_organization": self.managing_org.external_id}, + format="json", + ) + self.assertEqual(response.status_code, 403) + self.add_permissions( - [ - DevicePermissions.can_manage_devices.name, - FacilityOrganizationPermissions.can_manage_facility_organization.name, - ] + [FacilityOrganizationPermissions.can_manage_facility_organization.name] ) - add_url = reverse( - "device-add-managing-organization", - kwargs={ - "facility_external_id": self.facility.external_id, - "external_id": device["id"], - }, + invalid_org = self.create_facility_organization(self.create_facility(self.user)) + response = self.client.post( + self.add_url, + data={"managing_organization": invalid_org.external_id}, + format="json", + ) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json()["errors"][0]["msg"], + "Organization is not part of given facility", + ) + + response = self.client.post( + self.add_url, + data={"managing_organization": self.managing_org.external_id}, + format="json", ) - data = {"managing_organization": managing_org.external_id} - response = self.client.post(add_url, data=data, format="json") self.assertEqual(response.status_code, 200) - updated_device = Device.objects.get(external_id=device["id"]) - self.assertEqual(updated_device.managing_organization, managing_org) + self.assertEqual( + Device.objects.get(external_id=self.device["id"]).managing_organization, + self.managing_org, + ) + + response = self.client.post( + self.add_url, + data={"managing_organization": self.managing_org.external_id}, + format="json", + ) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json()["errors"][0]["msg"], + "Organization is already associated with this device", + ) def test_remove_managing_organization(self): - # First, add the managing organization - device = self.create_device() - managing_org = self.create_facility_organization(self.facility) self.add_permissions( [ DevicePermissions.can_manage_devices.name, FacilityOrganizationPermissions.can_manage_facility_organization.name, ] ) - add_url = reverse( - "device-add-managing-organization", - kwargs={ - "facility_external_id": self.facility.external_id, - "external_id": device["id"], - }, - ) - data = {"managing_organization": managing_org.external_id} - response = self.client.post(add_url, data=data, format="json") - self.assertEqual(response.status_code, 200) + response = self.client.post(self.remove_url, format="json") + self.assertEqual(response.status_code, 400) self.assertEqual( - Device.objects.get(external_id=device["id"]).managing_organization, - managing_org, + response.json()["errors"][0]["msg"], + "No managing organization is associated with this device", ) - # Now remove the managing organization - remove_url = reverse( - "device-remove-managing-organization", - kwargs={ - "facility_external_id": self.facility.external_id, - "external_id": device["id"], - }, + self.client.post( + self.add_url, + data={"managing_organization": self.managing_org.external_id}, + format="json", ) - response = self.client.post(remove_url, format="json") + self.assertEqual( + Device.objects.get(external_id=self.device["id"]).managing_organization, + self.managing_org, + ) + + response = self.client.post(self.remove_url, format="json") self.assertEqual(response.status_code, 200) self.assertIsNone( - Device.objects.get(external_id=device["id"]).managing_organization + Device.objects.get(external_id=self.device["id"]).managing_organization + ) + + def test_remove_managing_organization_without_permissions(self): + self.client.force_authenticate(self.super_user) + self.client.post( + self.add_url, + data={"managing_organization": self.managing_org.external_id}, + format="json", + ) + self.assertEqual( + Device.objects.get(external_id=self.device["id"]).managing_organization, + self.managing_org, + ) + + self.client.force_authenticate(self.user) + self.add_permissions([DevicePermissions.can_manage_devices.name]) + response = self.client.post(self.remove_url, format="json") + + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.json()["detail"], + "You do not have permission to manage facility organization", ) @@ -474,6 +583,36 @@ def test_retrieve_device_location_history(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["id"], history["id"]) + def test_list_device_with_location(self): + self.associate_location_with_device(self.device, self.location) + url = reverse( + "device-list", kwargs={"facility_external_id": self.facility.external_id} + ) + response = self.client.get(url + f"?location={uuid.uuid4()}") + self.assertEqual(response.status_code, 404) + response = self.client.get( + url + f"?location={self.create_facility_location()['id']}" + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["count"], 0) + response = self.client.get(url + f"?location={self.location['id']}") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["count"], 1) + + url = reverse( + "device-list", + kwargs={ + "facility_external_id": self.create_facility(self.user).external_id + }, + ) + response = self.client.get(url + f"?location={self.location['id']}") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["count"], 0) + self.add_permissions([DevicePermissions.can_list_devices.name]) + response = self.client.get(url + f"?location={self.location['id']}") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["count"], 1) + class TestDeviceEncounterHistoryViewSet(DeviceBaseTest): def setUp(self): @@ -534,3 +673,122 @@ def test_retrieve_device_encounter_history(self): response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["id"], history["id"]) + + +class TestDeviceServiceHistoryViewSet(DeviceBaseTest): + def setUp(self): + super().setUp() + self.device = self.create_device() + self.base_url = reverse( + "device_service_history-list", + kwargs={ + "facility_external_id": self.facility.external_id, + "device_external_id": self.device["id"], + }, + ) + + def generate_data_for_device_service_history(self, **kwargs): + data = { + "serviced_on": now(), + "note": self.fake.text(), + } + data.update(**kwargs) + return data + + def create_device_service_history(self, device, **kwargs): + self.client.force_authenticate(self.super_user) + url = reverse( + "device_service_history-list", + kwargs={ + "facility_external_id": self.facility.external_id, + "device_external_id": device["id"], + }, + ) + data = self.generate_data_for_device_service_history(**kwargs) + response = self.client.post(url, data=data, format="json") + self.assertEqual(response.status_code, 200) + self.client.force_authenticate(self.user) + return response.json() + + def test_list_device_service_history(self): + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, 403) + self.add_permissions([DevicePermissions.can_list_devices.name]) + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["count"], 0) + + def test_create_device_service_history(self): + data = self.generate_data_for_device_service_history() + response = self.client.post(self.base_url, data=data, format="json") + self.assertEqual(response.status_code, 403) + self.add_permissions([DevicePermissions.can_manage_devices.name]) + response = self.client.post(self.base_url, data=data, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["note"], data["note"]) + + def test_retrieve_device_service_history(self): + history = self.create_device_service_history(self.device) + url = reverse( + "device_service_history-detail", + kwargs={ + "facility_external_id": self.facility.external_id, + "device_external_id": self.device["id"], + "external_id": history["id"], + }, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + self.add_permissions([DevicePermissions.can_list_devices.name]) + response = self.client.get(url) + self.assertEqual(response.json()["id"], history["id"]) + + def test_update_device_service_history(self): + history = self.create_device_service_history(self.device) + url = reverse( + "device_service_history-detail", + kwargs={ + "facility_external_id": self.facility.external_id, + "device_external_id": self.device["id"], + "external_id": history["id"], + }, + ) + data = self.generate_data_for_device_service_history() + response = self.client.put(url, data=data, format="json") + self.assertEqual(response.status_code, 403) + + self.add_permissions( + [ + DevicePermissions.can_manage_devices.name, + DevicePermissions.can_list_devices.name, + ] + ) + response = self.client.put(url, data=data, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["note"], data["note"]) + + for _ in range(49): + response = self.client.put(url, data=data, format="json") + self.assertEqual(response.status_code, 200) + + response = self.client.put(url, data=data, format="json") + self.assertEqual(response.status_code, 400) + response_data = response.json() + self.assertEqual(response_data["errors"][0]["type"], "validation_error") + self.assertEqual( + response_data["errors"][0]["msg"], "Cannot Edit instance anymore" + ) + + def test_delete_device_service_history(self): + history = self.create_device_service_history(self.device) + url = reverse( + "device_service_history-detail", + kwargs={ + "facility_external_id": self.facility.external_id, + "device_external_id": self.device["id"], + "external_id": history["id"], + }, + ) + response = self.client.delete(url) + self.assertEqual(response.status_code, 405) # delete doesn't exist From c9965e5d30c87ecc32c6200a6bcd87fe0f36724e Mon Sep 17 00:00:00 2001 From: Prafful Date: Sat, 15 Feb 2025 21:09:00 +0530 Subject: [PATCH 20/20] fixed permission --- care/emr/tests/test_device_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/care/emr/tests/test_device_api.py b/care/emr/tests/test_device_api.py index e7fc424b77..2c3f581a16 100644 --- a/care/emr/tests/test_device_api.py +++ b/care/emr/tests/test_device_api.py @@ -667,7 +667,6 @@ def test_retrieve_device_encounter_history(self): self.add_permissions( [ DevicePermissions.can_list_devices.name, - FacilityLocationPermissions.can_list_facility_locations.name, ] ) response = self.client.get(url)