From 51a7a40d1fdd60c4c4642f4ebc454fbf8b7f19c7 Mon Sep 17 00:00:00 2001 From: prafful Date: Mon, 18 Nov 2024 02:01:58 +0530 Subject: [PATCH 01/13] added a camera plugin application --- care/camera_plugin/__init__.py | 0 care/camera_plugin/admin.py | 1 + care/camera_plugin/api/__init__.py | 0 .../camera_plugin/api/serializers/__init__.py | 0 .../serializers/camera_position_preset.py} | 8 +- care/camera_plugin/api/viewsets/__init__.py | 0 .../api/viewsets/camera_position_preset.py} | 18 ++- care/camera_plugin/apps.py | 12 ++ care/camera_plugin/migrations/0001_initial.py | 39 +++++ care/camera_plugin/migrations/__init__.py | 0 care/camera_plugin/models/__init__.py | 0 care/camera_plugin/models/camera_asset.py | 5 + .../models/camera_position_preset.py | 24 +++ .../models/json_schema/__init__.py | 0 .../models/json_schema/position.py | 11 ++ .../tests/test_camera_preset_apis.py | 139 ++++++++++++++++++ care/camera_plugin/urls.py | 33 +++++ care/camera_plugin/utils/__init__.py | 0 .../utils}/onvif.py | 6 + care/camera_plugin/views.py | 1 + care/facility/api/serializers/asset.py | 27 +--- care/facility/api/serializers/bed.py | 20 +-- care/facility/api/viewsets/asset.py | 36 ++--- .../0468_alter_asset_asset_class.py | 18 +++ .../migrations/0469_delete_camerapreset.py | 16 ++ care/facility/models/__init__.py | 1 - care/facility/models/camera_preset.py | 33 ----- care/facility/tests/test_asset_bed_api.py | 133 ----------------- care/utils/assetintegration/asset_classes.py | 14 +- care/utils/assetintegration/base.py | 4 + care/utils/assetintegration/hl7monitor.py | 6 + care/utils/assetintegration/utils.py | 70 +++++++++ care/utils/assetintegration/ventilator.py | 6 + config/api_router.py | 15 +- config/settings/base.py | 1 + 35 files changed, 452 insertions(+), 245 deletions(-) create mode 100644 care/camera_plugin/__init__.py create mode 100644 care/camera_plugin/admin.py create mode 100644 care/camera_plugin/api/__init__.py create mode 100644 care/camera_plugin/api/serializers/__init__.py rename care/{facility/api/serializers/camera_preset.py => camera_plugin/api/serializers/camera_position_preset.py} (87%) create mode 100644 care/camera_plugin/api/viewsets/__init__.py rename care/{facility/api/viewsets/camera_preset.py => camera_plugin/api/viewsets/camera_position_preset.py} (77%) create mode 100644 care/camera_plugin/apps.py create mode 100644 care/camera_plugin/migrations/0001_initial.py create mode 100644 care/camera_plugin/migrations/__init__.py create mode 100644 care/camera_plugin/models/__init__.py create mode 100644 care/camera_plugin/models/camera_asset.py create mode 100644 care/camera_plugin/models/camera_position_preset.py create mode 100644 care/camera_plugin/models/json_schema/__init__.py create mode 100644 care/camera_plugin/models/json_schema/position.py create mode 100644 care/camera_plugin/tests/test_camera_preset_apis.py create mode 100644 care/camera_plugin/urls.py create mode 100644 care/camera_plugin/utils/__init__.py rename care/{utils/assetintegration => camera_plugin/utils}/onvif.py (93%) create mode 100644 care/camera_plugin/views.py create mode 100644 care/facility/migrations/0468_alter_asset_asset_class.py create mode 100644 care/facility/migrations/0469_delete_camerapreset.py delete mode 100644 care/facility/models/camera_preset.py create mode 100644 care/utils/assetintegration/utils.py diff --git a/care/camera_plugin/__init__.py b/care/camera_plugin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/camera_plugin/admin.py b/care/camera_plugin/admin.py new file mode 100644 index 0000000000..846f6b4061 --- /dev/null +++ b/care/camera_plugin/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/care/camera_plugin/api/__init__.py b/care/camera_plugin/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/camera_plugin/api/serializers/__init__.py b/care/camera_plugin/api/serializers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/facility/api/serializers/camera_preset.py b/care/camera_plugin/api/serializers/camera_position_preset.py similarity index 87% rename from care/facility/api/serializers/camera_preset.py rename to care/camera_plugin/api/serializers/camera_position_preset.py index 7157b5245a..188295b2f2 100644 --- a/care/facility/api/serializers/camera_preset.py +++ b/care/camera_plugin/api/serializers/camera_position_preset.py @@ -1,19 +1,19 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError +from care.camera_plugin.models.camera_position_preset import CameraPositionPreset from care.facility.api.serializers.bed import AssetBedSerializer -from care.facility.models import CameraPreset from care.users.api.serializers.user import UserBaseMinimumSerializer -class CameraPresetSerializer(serializers.ModelSerializer): +class CameraPositionPresetSerializer(serializers.ModelSerializer): id = serializers.UUIDField(source="external_id", read_only=True) created_by = UserBaseMinimumSerializer(read_only=True) updated_by = UserBaseMinimumSerializer(read_only=True) asset_bed = AssetBedSerializer(read_only=True) class Meta: - model = CameraPreset + model = CameraPositionPreset exclude = ( "external_id", "deleted", @@ -32,7 +32,7 @@ def get_asset_bed_obj(self): ) def validate_name(self, value): - if CameraPreset.objects.filter( + if CameraPositionPreset.objects.filter( asset_bed__bed_id=self.get_asset_bed_obj().bed_id, name=value ).exists(): msg = "Name should be unique. Another preset related to this bed already uses the same name." diff --git a/care/camera_plugin/api/viewsets/__init__.py b/care/camera_plugin/api/viewsets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/facility/api/viewsets/camera_preset.py b/care/camera_plugin/api/viewsets/camera_position_preset.py similarity index 77% rename from care/facility/api/viewsets/camera_preset.py rename to care/camera_plugin/api/viewsets/camera_position_preset.py index bfb168834b..5798e7ff94 100644 --- a/care/facility/api/viewsets/camera_preset.py +++ b/care/camera_plugin/api/viewsets/camera_position_preset.py @@ -4,8 +4,10 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.viewsets import GenericViewSet, ModelViewSet -from care.facility.api.serializers.camera_preset import CameraPresetSerializer -from care.facility.models import CameraPreset +from care.camera_plugin.api.serializers.camera_position_preset import ( + CameraPositionPresetSerializer, +) +from care.camera_plugin.models.camera_position_preset import CameraPositionPreset from care.utils.queryset.asset_bed import ( get_asset_bed_queryset, get_asset_queryset, @@ -13,9 +15,9 @@ ) -class AssetBedCameraPresetViewSet(ModelViewSet): - serializer_class = CameraPresetSerializer - queryset = CameraPreset.objects.all().select_related( +class AssetBedCameraPositionPresetViewSet(ModelViewSet): + serializer_class = CameraPositionPresetSerializer + queryset = CameraPositionPreset.objects.all().select_related( "asset_bed", "created_by", "updated_by" ) lookup_field = "external_id" @@ -36,9 +38,9 @@ def get_serializer_context(self): return context -class CameraPresetViewSet(GenericViewSet, ListModelMixin): - serializer_class = CameraPresetSerializer - queryset = CameraPreset.objects.all().select_related( +class CameraPresetPositionViewSet(GenericViewSet, ListModelMixin): + serializer_class = CameraPositionPresetSerializer + queryset = CameraPositionPreset.objects.all().select_related( "asset_bed", "created_by", "updated_by" ) lookup_field = "external_id" diff --git a/care/camera_plugin/apps.py b/care/camera_plugin/apps.py new file mode 100644 index 0000000000..2ff6ecef1a --- /dev/null +++ b/care/camera_plugin/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + +from care.camera_plugin.utils.onvif import OnvifAsset +from care.utils.assetintegration.asset_classes import AssetClasses + + +class CameraPluginConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "care.camera_plugin" + + def ready(self): + AssetClasses.register("ONVIF", OnvifAsset) diff --git a/care/camera_plugin/migrations/0001_initial.py b/care/camera_plugin/migrations/0001_initial.py new file mode 100644 index 0000000000..eb2e51cd72 --- /dev/null +++ b/care/camera_plugin/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1.1 on 2024-11-17 19:54 + +import care.utils.models.validators +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('facility', '0469_delete_camerapreset'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CameraPositionPreset', + 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)), + ('name', models.CharField(max_length=255, null=True)), + ('position', models.JSONField(validators=[care.utils.models.validators.JSONFieldSchemaValidator({'$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'properties': {'x': {'type': 'number'}, 'y': {'type': 'number'}, 'zoom': {'type': 'number'}}, 'required': ['x', 'y', 'zoom'], 'type': 'object'})])), + ('is_migrated', models.BooleanField(default=False)), + ('asset_bed', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='camera_presets', to='facility.assetbed')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/care/camera_plugin/migrations/__init__.py b/care/camera_plugin/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/camera_plugin/models/__init__.py b/care/camera_plugin/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/camera_plugin/models/camera_asset.py b/care/camera_plugin/models/camera_asset.py new file mode 100644 index 0000000000..fea09f876b --- /dev/null +++ b/care/camera_plugin/models/camera_asset.py @@ -0,0 +1,5 @@ +from care.facility.models import Asset + + +class CameraAsset(Asset): + pass diff --git a/care/camera_plugin/models/camera_position_preset.py b/care/camera_plugin/models/camera_position_preset.py new file mode 100644 index 0000000000..ce94aa2c23 --- /dev/null +++ b/care/camera_plugin/models/camera_position_preset.py @@ -0,0 +1,24 @@ +from django.db import models + +from care.camera_plugin.models.json_schema.position import CAMERA_PRESET_POSITION_SCHEMA +from care.facility.models import AssetBed +from care.users.models import User +from care.utils.models.base import BaseModel +from care.utils.models.validators import JSONFieldSchemaValidator + + +class CameraPositionPreset(BaseModel): + name = models.CharField(max_length=255, null=True) + asset_bed = models.ForeignKey( + AssetBed, on_delete=models.PROTECT, related_name="camera_presets" + ) + position = models.JSONField( + validators=[JSONFieldSchemaValidator(CAMERA_PRESET_POSITION_SCHEMA)] + ) + created_by = models.ForeignKey( + User, null=True, blank=True, on_delete=models.PROTECT, related_name="+" + ) + updated_by = models.ForeignKey( + User, null=True, blank=True, on_delete=models.PROTECT, related_name="+" + ) + is_migrated = models.BooleanField(default=False) diff --git a/care/camera_plugin/models/json_schema/__init__.py b/care/camera_plugin/models/json_schema/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/camera_plugin/models/json_schema/position.py b/care/camera_plugin/models/json_schema/position.py new file mode 100644 index 0000000000..7609b04e2b --- /dev/null +++ b/care/camera_plugin/models/json_schema/position.py @@ -0,0 +1,11 @@ +CAMERA_PRESET_POSITION_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + "zoom": {"type": "number"}, + }, + "required": ["x", "y", "zoom"], + "additionalProperties": False, +} diff --git a/care/camera_plugin/tests/test_camera_preset_apis.py b/care/camera_plugin/tests/test_camera_preset_apis.py new file mode 100644 index 0000000000..326d331845 --- /dev/null +++ b/care/camera_plugin/tests/test_camera_preset_apis.py @@ -0,0 +1,139 @@ +from rest_framework import status +from rest_framework.test import APITestCase + +from care.users.models import User +from care.utils.assetintegration.asset_classes import AssetClasses +from care.utils.tests.test_utils import TestUtils + + +class AssetBedCameraPresetViewSetTestCase(TestUtils, APITestCase): + @classmethod + def setUpTestData(cls): + cls.state = cls.create_state() + cls.district = cls.create_district(cls.state) + cls.local_body = cls.create_local_body(cls.district) + cls.super_user = cls.create_super_user("su", cls.district) + cls.facility = cls.create_facility(cls.super_user, cls.district, cls.local_body) + cls.user = cls.create_user( + User.TYPE_VALUE_MAP["DistrictAdmin"], + cls.district, + home_facility=cls.facility, + ) + cls.asset_location = cls.create_asset_location(cls.facility) + cls.asset1 = cls.create_asset( + cls.asset_location, asset_class=AssetClasses.ONVIF.name + ) + cls.asset2 = cls.create_asset( + cls.asset_location, asset_class=AssetClasses.ONVIF.name + ) + cls.bed = cls.create_bed(cls.facility, cls.asset_location) + cls.asset_bed1 = cls.create_asset_bed(cls.asset1, cls.bed) + cls.asset_bed2 = cls.create_asset_bed(cls.asset2, cls.bed) + + def get_base_url(self, asset_bed_id=None): + return f"/api/v1/assetbed/{asset_bed_id or self.asset_bed1.external_id}/camera_presets/" + + def test_create_camera_preset_without_position(self): + res = self.client.post( + self.get_base_url(), + { + "name": "Preset without position", + "position": {}, + }, + format="json", + ) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_camera_preset_with_missing_required_keys_in_position(self): + res = self.client.post( + self.get_base_url(), + { + "name": "Preset with invalid position", + "position": {"key": "value"}, + }, + format="json", + ) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_camera_preset_with_position_not_number(self): + res = self.client.post( + self.get_base_url(), + { + "name": "Preset with invalid position", + "position": { + "x": "not a number", + "y": 1, + "zoom": 1, + }, + }, + format="json", + ) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_camera_preset_with_position_values_as_string(self): + res = self.client.post( + self.get_base_url(), + { + "name": "Preset with invalid position", + "position": { + "x": "1", + "y": "1", + "zoom": "1", + }, + }, + format="json", + ) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_camera_preset_and_presence_in_various_preset_list_apis(self): + asset_bed = self.asset_bed1 + res = self.client.post( + self.get_base_url(asset_bed.external_id), + { + "name": "Preset with proper position", + "position": { + "x": 1.0, + "y": 1.0, + "zoom": 1.0, + }, + }, + format="json", + ) + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + preset_external_id = res.data["id"] + + # Check if preset in asset-bed preset list + res = self.client.get(self.get_base_url(asset_bed.external_id)) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertContains(res, preset_external_id) + + # Check if preset in asset preset list + res = self.client.get( + f"/api/v1/asset/{asset_bed.asset.external_id}/camera_presets/" + ) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertContains(res, preset_external_id) + + # Check if preset in bed preset list + res = self.client.get( + f"/api/v1/bed/{asset_bed.bed.external_id}/camera_presets/" + ) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertContains(res, preset_external_id) + + def test_create_camera_preset_with_same_name_in_same_bed(self): + data = { + "name": "Duplicate Preset Name", + "position": { + "x": 1.0, + "y": 1.0, + "zoom": 1.0, + }, + } + self.client.post( + self.get_base_url(self.asset_bed1.external_id), data, format="json" + ) + res = self.client.post( + self.get_base_url(self.asset_bed2.external_id), data, format="json" + ) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/care/camera_plugin/urls.py b/care/camera_plugin/urls.py new file mode 100644 index 0000000000..29ea7959f8 --- /dev/null +++ b/care/camera_plugin/urls.py @@ -0,0 +1,33 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from care.camera_plugin.api.viewsets.camera_position_preset import ( + AssetBedCameraPositionPresetViewSet, + CameraPresetPositionViewSet, +) +from config.api_router import ( + asset_nested_router, + assetbed_nested_router, + bed_nested_router, +) + +camera_router = DefaultRouter() + +asset_nested_router.register( + r"camera_presets", CameraPresetPositionViewSet, basename="asset-camera-presets" +) +bed_nested_router.register( + r"camera_presets", CameraPresetPositionViewSet, basename="bed-camera-presets" +) +assetbed_nested_router.register( + r"camera_presets", + AssetBedCameraPositionPresetViewSet, + basename="assetbed-camera-presets", +) + +# Include in urlpatterns +urlpatterns = [ + path("", include(asset_nested_router.urls)), + path("", include(bed_nested_router.urls)), + path("", include(assetbed_nested_router.urls)), +] diff --git a/care/camera_plugin/utils/__init__.py b/care/camera_plugin/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/utils/assetintegration/onvif.py b/care/camera_plugin/utils/onvif.py similarity index 93% rename from care/utils/assetintegration/onvif.py rename to care/camera_plugin/utils/onvif.py index 815994855e..a1ec2e90d1 100644 --- a/care/utils/assetintegration/onvif.py +++ b/care/camera_plugin/utils/onvif.py @@ -64,3 +64,9 @@ def handle_action(self, action): ) raise ValidationError({"action": "invalid action type"}) + + @classmethod + def get_action_choices(cls): + choices = [] + choices += [(e.value, e.name) for e in cls.OnvifActions] + return choices diff --git a/care/camera_plugin/views.py b/care/camera_plugin/views.py new file mode 100644 index 0000000000..60f00ef0ef --- /dev/null +++ b/care/camera_plugin/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/care/facility/api/serializers/asset.py b/care/facility/api/serializers/asset.py index f403361e1a..93c6f55ded 100644 --- a/care/facility/api/serializers/asset.py +++ b/care/facility/api/serializers/asset.py @@ -33,9 +33,6 @@ ) from care.users.api.serializers.user import UserBaseMinimumSerializer from care.utils.assetintegration.asset_classes import AssetClasses -from care.utils.assetintegration.hl7monitor import HL7MonitorAsset -from care.utils.assetintegration.onvif import OnvifAsset -from care.utils.assetintegration.ventilator import VentilatorAsset from care.utils.models.validators import MiddlewareDomainAddressValidator from care.utils.queryset.facility import get_facility_queryset from care.utils.serializers.fields import ChoiceField @@ -217,6 +214,9 @@ def validate(self, attrs): or current_location.facility.middleware_address ) if ip_address and middleware_hostname: + valid_asset_classes = [AssetClasses.HL7MONITOR.name] + if hasattr(AssetClasses, "ONVIF"): + valid_asset_classes.append(AssetClasses.ONVIF.name) asset_using_ip = ( Asset.objects.annotate( resolved_middleware_hostname=Coalesce( @@ -229,10 +229,7 @@ def validate(self, attrs): ) ) .filter( - asset_class__in=[ - AssetClasses.ONVIF.name, - AssetClasses.HL7MONITOR.name, - ], + asset_class__in=valid_asset_classes, current_location__facility=current_location.facility_id, resolved_middleware_hostname=middleware_hostname, meta__local_ip_address=ip_address, @@ -407,19 +404,11 @@ class Meta: class AssetActionSerializer(Serializer): - def action_choices(): - actions = [ - OnvifAsset.OnvifActions, - HL7MonitorAsset.HL7MonitorActions, - VentilatorAsset.VentilatorActions, - ] - choices = [] - for action in actions: - choices += [(e.value, e.name) for e in action] - return choices - + choices = [] + for asset_class in AssetClasses.all(): + choices.append(asset_class.value.get_action_choices()) type = ChoiceField( - choices=action_choices(), + choices=choices, required=True, ) data = JSONField(required=False) diff --git a/care/facility/api/serializers/bed.py b/care/facility/api/serializers/bed.py index 031d2a68c1..8200b92a87 100644 --- a/care/facility/api/serializers/bed.py +++ b/care/facility/api/serializers/bed.py @@ -115,10 +115,12 @@ def validate(self, attrs): raise ValidationError( {"non_field_errors": "Asset is already linked to bed"} ) - if asset.asset_class not in [ - AssetClasses.HL7MONITOR.name, - AssetClasses.ONVIF.name, - ]: + + in_valid_asset_classes = [AssetClasses.HL7MONITOR.name] + if hasattr(AssetClasses, "ONVIF"): + in_valid_asset_classes.append(AssetClasses.ONVIF.name) + + if asset.asset_class not in in_valid_asset_classes: raise ValidationError({"asset": "Asset is not a monitor or camera"}) attrs["asset"] = asset attrs["bed"] = bed @@ -309,6 +311,9 @@ def create(self, validated_data) -> ConsultationBed: if assets_ids := validated_data.pop("assets", None): # we check assets in use here as they might have been in use in # the previous bed + exclude_asset_classes = [AssetClasses.HL7MONITOR.name] + if hasattr(AssetClasses, "ONVIF"): + exclude_asset_classes.append(AssetClasses.ONVIF.name) assets = ( Asset.objects.annotate( is_in_use=Exists( @@ -324,12 +329,7 @@ def create(self, validated_data) -> ConsultationBed: external_id__in=assets_ids, current_location__facility=consultation.facility_id, ) - .exclude( - asset_class__in=[ - AssetClasses.HL7MONITOR.name, - AssetClasses.ONVIF.name, - ] - ) + .exclude(asset_class__in=exclude_asset_classes) .values_list("external_id", flat=True) ) not_found_assets = set(assets_ids) - set(assets) diff --git a/care/facility/api/viewsets/asset.py b/care/facility/api/viewsets/asset.py index 8b24bebb51..9e795d33fd 100644 --- a/care/facility/api/viewsets/asset.py +++ b/care/facility/api/viewsets/asset.py @@ -73,7 +73,6 @@ logger = logging.getLogger(__name__) - inverse_asset_type = inverse_choices(AssetTypeChoices) inverse_asset_status = inverse_choices(StatusChoices) @@ -201,20 +200,13 @@ def filter_in_use_by_consultation(self, queryset, _, value): def filter_is_permanent(self, queryset, _, value): if value not in EMPTY_VALUES: + valid_asset_classes = [AssetClasses.HL7MONITOR.name] + if hasattr(AssetClasses, "ONVIF"): + valid_asset_classes.append(AssetClasses.ONVIF.name) if value: - queryset = queryset.filter( - asset_class__in=[ - AssetClasses.ONVIF.name, - AssetClasses.HL7MONITOR.name, - ] - ) + queryset = queryset.filter(asset_class__in=valid_asset_classes) else: - queryset = queryset.exclude( - asset_class__in=[ - AssetClasses.ONVIF.name, - AssetClasses.HL7MONITOR.name, - ] - ) + queryset = queryset.exclude(asset_class__in=valid_asset_classes) return queryset.distinct() @@ -398,6 +390,15 @@ def operate_assets(self, request, *args, **kwargs): or asset.current_location.middleware_address or asset.current_location.facility.middleware_address ) + available_asset_classes = [asset.name for asset in AssetClasses.all()] + if asset.asset_class not in available_asset_classes: + Response( + { + "error": f"Install {asset.asset_class}'s plugins to use it", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + asset_class: BaseAssetIntegration = AssetClasses[asset.asset_class].value( { **asset.meta, @@ -467,14 +468,15 @@ def list(self, request, *args, **kwargs): status=status.HTTP_400_BAD_REQUEST, ) + valid_asset_classes = [AssetClasses.HL7MONITOR.name] + if hasattr(AssetClasses, "ONVIF"): + valid_asset_classes.append(AssetClasses.ONVIF.name) + queryset = ( self.get_queryset() .filter( current_location__facility=self.request.user.facility, - asset_class__in=[ - AssetClasses.ONVIF.name, - AssetClasses.HL7MONITOR.name, - ], + asset_class__in=valid_asset_classes, ) .annotate( resolved_middleware_hostname=Coalesce( diff --git a/care/facility/migrations/0468_alter_asset_asset_class.py b/care/facility/migrations/0468_alter_asset_asset_class.py new file mode 100644 index 0000000000..b2d7c6e0cd --- /dev/null +++ b/care/facility/migrations/0468_alter_asset_asset_class.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.1 on 2024-11-17 17:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('facility', '0467_alter_hospitaldoctors_area'), + ] + + operations = [ + migrations.AlterField( + model_name='asset', + name='asset_class', + field=models.CharField(blank=True, choices=[('HL7MONITOR', 'hl7monitor'), ('VENTILATOR', 'ventilator')], default=None, max_length=20, null=True), + ), + ] diff --git a/care/facility/migrations/0469_delete_camerapreset.py b/care/facility/migrations/0469_delete_camerapreset.py new file mode 100644 index 0000000000..390ce65ffc --- /dev/null +++ b/care/facility/migrations/0469_delete_camerapreset.py @@ -0,0 +1,16 @@ +# Generated by Django 5.1.1 on 2024-11-17 19:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('facility', '0468_alter_asset_asset_class'), + ] + + operations = [ + migrations.DeleteModel( + name='CameraPreset', + ), + ] diff --git a/care/facility/models/__init__.py b/care/facility/models/__init__.py index d6d63cacca..df41476768 100644 --- a/care/facility/models/__init__.py +++ b/care/facility/models/__init__.py @@ -4,7 +4,6 @@ from .ambulance import * # noqa from .asset import * # noqa from .bed import * # noqa -from .camera_preset import * # noqa from .daily_round import * # noqa from .encounter_symptom import * # noqa from .events import * # noqa diff --git a/care/facility/models/camera_preset.py b/care/facility/models/camera_preset.py deleted file mode 100644 index b1128f8817..0000000000 --- a/care/facility/models/camera_preset.py +++ /dev/null @@ -1,33 +0,0 @@ -from django.db import models - -from care.utils.models.base import BaseModel -from care.utils.models.validators import JSONFieldSchemaValidator - -CAMERA_PRESET_POSITION_SCHEMA = { - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "x": {"type": "number"}, - "y": {"type": "number"}, - "zoom": {"type": "number"}, - }, - "required": ["x", "y", "zoom"], - "additionalProperties": False, -} - - -class CameraPreset(BaseModel): - name = models.CharField(max_length=255, null=True) - asset_bed = models.ForeignKey( - "facility.AssetBed", on_delete=models.PROTECT, related_name="camera_presets" - ) - position = models.JSONField( - validators=[JSONFieldSchemaValidator(CAMERA_PRESET_POSITION_SCHEMA)] - ) - created_by = models.ForeignKey( - "users.User", null=True, blank=True, on_delete=models.PROTECT, related_name="+" - ) - updated_by = models.ForeignKey( - "users.User", null=True, blank=True, on_delete=models.PROTECT, related_name="+" - ) - is_migrated = models.BooleanField(default=False) diff --git a/care/facility/tests/test_asset_bed_api.py b/care/facility/tests/test_asset_bed_api.py index 4ed81a36b8..9ae6720b4f 100644 --- a/care/facility/tests/test_asset_bed_api.py +++ b/care/facility/tests/test_asset_bed_api.py @@ -84,136 +84,3 @@ def test_linking_multiple_hl7_monitors_to_a_bed(self): data["asset"] = self.monitor_asset_2.external_id res = self.client.post("/api/v1/assetbed/", data) self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) - - -class AssetBedCameraPresetViewSetTestCase(TestUtils, APITestCase): - @classmethod - def setUpTestData(cls): - cls.state = cls.create_state() - cls.district = cls.create_district(cls.state) - cls.local_body = cls.create_local_body(cls.district) - cls.super_user = cls.create_super_user("su", cls.district) - cls.facility = cls.create_facility(cls.super_user, cls.district, cls.local_body) - cls.user = cls.create_user( - User.TYPE_VALUE_MAP["DistrictAdmin"], - cls.district, - home_facility=cls.facility, - ) - cls.asset_location = cls.create_asset_location(cls.facility) - cls.asset1 = cls.create_asset( - cls.asset_location, asset_class=AssetClasses.ONVIF.name - ) - cls.asset2 = cls.create_asset( - cls.asset_location, asset_class=AssetClasses.ONVIF.name - ) - cls.bed = cls.create_bed(cls.facility, cls.asset_location) - cls.asset_bed1 = cls.create_asset_bed(cls.asset1, cls.bed) - cls.asset_bed2 = cls.create_asset_bed(cls.asset2, cls.bed) - - def get_base_url(self, asset_bed_id=None): - return f"/api/v1/assetbed/{asset_bed_id or self.asset_bed1.external_id}/camera_presets/" - - def test_create_camera_preset_without_position(self): - res = self.client.post( - self.get_base_url(), - { - "name": "Preset without position", - "position": {}, - }, - format="json", - ) - self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) - - def test_create_camera_preset_with_missing_required_keys_in_position(self): - res = self.client.post( - self.get_base_url(), - { - "name": "Preset with invalid position", - "position": {"key": "value"}, - }, - format="json", - ) - self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) - - def test_create_camera_preset_with_position_not_number(self): - res = self.client.post( - self.get_base_url(), - { - "name": "Preset with invalid position", - "position": { - "x": "not a number", - "y": 1, - "zoom": 1, - }, - }, - format="json", - ) - self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) - - def test_create_camera_preset_with_position_values_as_string(self): - res = self.client.post( - self.get_base_url(), - { - "name": "Preset with invalid position", - "position": { - "x": "1", - "y": "1", - "zoom": "1", - }, - }, - format="json", - ) - self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) - - def test_create_camera_preset_and_presence_in_various_preset_list_apis(self): - asset_bed = self.asset_bed1 - res = self.client.post( - self.get_base_url(asset_bed.external_id), - { - "name": "Preset with proper position", - "position": { - "x": 1.0, - "y": 1.0, - "zoom": 1.0, - }, - }, - format="json", - ) - self.assertEqual(res.status_code, status.HTTP_201_CREATED) - preset_external_id = res.data["id"] - - # Check if preset in asset-bed preset list - res = self.client.get(self.get_base_url(asset_bed.external_id)) - self.assertEqual(res.status_code, status.HTTP_200_OK) - self.assertContains(res, preset_external_id) - - # Check if preset in asset preset list - res = self.client.get( - f"/api/v1/asset/{asset_bed.asset.external_id}/camera_presets/" - ) - self.assertEqual(res.status_code, status.HTTP_200_OK) - self.assertContains(res, preset_external_id) - - # Check if preset in bed preset list - res = self.client.get( - f"/api/v1/bed/{asset_bed.bed.external_id}/camera_presets/" - ) - self.assertEqual(res.status_code, status.HTTP_200_OK) - self.assertContains(res, preset_external_id) - - def test_create_camera_preset_with_same_name_in_same_bed(self): - data = { - "name": "Duplicate Preset Name", - "position": { - "x": 1.0, - "y": 1.0, - "zoom": 1.0, - }, - } - self.client.post( - self.get_base_url(self.asset_bed1.external_id), data, format="json" - ) - res = self.client.post( - self.get_base_url(self.asset_bed2.external_id), data, format="json" - ) - self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/care/utils/assetintegration/asset_classes.py b/care/utils/assetintegration/asset_classes.py index a521e9edc0..7e38fd9026 100644 --- a/care/utils/assetintegration/asset_classes.py +++ b/care/utils/assetintegration/asset_classes.py @@ -1,11 +1,11 @@ -import enum - from care.utils.assetintegration.hl7monitor import HL7MonitorAsset -from care.utils.assetintegration.onvif import OnvifAsset +from care.utils.assetintegration.utils import MutableEnum from care.utils.assetintegration.ventilator import VentilatorAsset -class AssetClasses(enum.Enum): - ONVIF = OnvifAsset - HL7MONITOR = HL7MonitorAsset - VENTILATOR = VentilatorAsset +class AssetClasses(MutableEnum): + pass + + +AssetClasses.register("HL7MONITOR", HL7MonitorAsset) +AssetClasses.register("VENTILATOR", VentilatorAsset) diff --git a/care/utils/assetintegration/base.py b/care/utils/assetintegration/base.py index 334bcecfa5..8a7e504d1d 100644 --- a/care/utils/assetintegration/base.py +++ b/care/utils/assetintegration/base.py @@ -61,3 +61,7 @@ def api_get(self, url, data=None): url, params=data, headers=self.get_headers(), timeout=self.timeout ) ) + + @classmethod + def get_action_choices(cls): + pass diff --git a/care/utils/assetintegration/hl7monitor.py b/care/utils/assetintegration/hl7monitor.py index abd14247d3..ee09fd5d09 100644 --- a/care/utils/assetintegration/hl7monitor.py +++ b/care/utils/assetintegration/hl7monitor.py @@ -37,3 +37,9 @@ def handle_action(self, action): ) raise ValidationError({"action": "invalid action type"}) + + @classmethod + def get_action_choices(cls): + choices = [] + choices += [(e.value, e.name) for e in cls.HL7MonitorActions] + return choices diff --git a/care/utils/assetintegration/utils.py b/care/utils/assetintegration/utils.py new file mode 100644 index 0000000000..2ab2b17da5 --- /dev/null +++ b/care/utils/assetintegration/utils.py @@ -0,0 +1,70 @@ +class MutableEnumMember: + """ + Represents a member of the MutableEnum with name and value. + """ + + def __init__(self, name, value): + self.name = name + self.value = value + + def __repr__(self): + return f"<{self.__class__.__name__} name={self.name}, value={self.value}>" + + def __str__(self): + return self.name + + +class MutableEnumMeta(type): + """ + Metaclass to mimic enum behavior with mutability. + """ + + def __iter__(cls): + return iter(cls._registry.values()) + + def __getitem__(cls, name): + return cls._registry[name] + + def __getattr__(cls, name): + if name in cls._registry: + return cls._registry[name] + error = f"{name} not found in {cls.__name__}" + raise AttributeError(error) + + def __setattr__(cls, name, value): + if name.startswith("_"): # Allow setting private attributes + super().__setattr__(name, value) + else: + cls.register(name, value) + + +class MutableEnum(metaclass=MutableEnumMeta): + """ + A base class to mimic enum behavior with mutability. + """ + + _registry = {} + + @classmethod + def register(cls, name, value): + """ + Register a new member dynamically. + """ + if name in cls._registry: + error = f"{name} is already registered." + raise ValueError(error) + cls._registry[name] = MutableEnumMember(name, value) + + @classmethod + def all(cls): + """ + Retrieve all registered members. + """ + return cls._registry.values() + + @classmethod + def get(cls, name): + """ + Retrieve a member by name. + """ + return cls._registry.get(name) diff --git a/care/utils/assetintegration/ventilator.py b/care/utils/assetintegration/ventilator.py index 23a5280960..53a5fe497d 100644 --- a/care/utils/assetintegration/ventilator.py +++ b/care/utils/assetintegration/ventilator.py @@ -37,3 +37,9 @@ def handle_action(self, action): ) raise ValidationError({"action": "invalid action type"}) + + @classmethod + def get_action_choices(cls): + choices = [] + choices += [(e.value, e.name) for e in cls.VentilatorActions] + return choices diff --git a/config/api_router.py b/config/api_router.py index 94b18f61de..ce5f815156 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -20,10 +20,6 @@ ConsultationBedViewSet, PatientAssetBedViewSet, ) -from care.facility.api.viewsets.camera_preset import ( - AssetBedCameraPresetViewSet, - CameraPresetViewSet, -) from care.facility.api.viewsets.consultation_diagnosis import ( ConsultationDiagnosisViewSet, ) @@ -225,9 +221,7 @@ router.register("asset", AssetViewSet, basename="asset") asset_nested_router = NestedSimpleRouter(router, r"asset", lookup="asset") -asset_nested_router.register( - r"camera_presets", CameraPresetViewSet, basename="asset-camera-presets" -) + asset_nested_router.register( r"availability", AvailabilityViewSet, basename="asset-availability" ) @@ -240,16 +234,10 @@ router.register("bed", BedViewSet, basename="bed") bed_nested_router = NestedSimpleRouter(router, r"bed", lookup="bed") -bed_nested_router.register( - r"camera_presets", CameraPresetViewSet, basename="bed-camera-presets" -) router.register("assetbed", AssetBedViewSet, basename="asset-bed") router.register("consultationbed", ConsultationBedViewSet, basename="consultation-bed") assetbed_nested_router = NestedSimpleRouter(router, r"assetbed", lookup="assetbed") -assetbed_nested_router.register( - r"camera_presets", AssetBedCameraPresetViewSet, basename="assetbed-camera-presets" -) router.register("patient/search", PatientSearchViewSet, basename="patient-search") router.register("patient", PatientViewSet, basename="patient") @@ -333,4 +321,5 @@ path("", include(consultation_nested_router.urls)), path("", include(resource_nested_router.urls)), path("", include(shifting_nested_router.urls)), + path("", include("care.camera_plugin.urls")), ] diff --git a/config/settings/base.py b/config/settings/base.py index 6983433f86..5626901840 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -128,6 +128,7 @@ "care.facility", "care.users", "care.audit_log", + "care.camera_plugin", ] PLUGIN_APPS = manager.get_apps() From 3b6f32ef3adfc6d294bd628c4cfd396f5aed2c4e Mon Sep 17 00:00:00 2001 From: prafful Date: Mon, 18 Nov 2024 02:02:03 +0530 Subject: [PATCH 02/13] added a camera plugin application --- care/camera_plugin/tests/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 care/camera_plugin/tests/__init__.py diff --git a/care/camera_plugin/tests/__init__.py b/care/camera_plugin/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 From d30ec6a51a09b071d0d1c51a32ad32b17e0e8b10 Mon Sep 17 00:00:00 2001 From: prafful Date: Mon, 18 Nov 2024 02:04:01 +0530 Subject: [PATCH 03/13] merged conflicts --- care/facility/tests/test_asset_api.py | 141 +++++ .../facility/tests/test_asset_location_api.py | 6 +- care/facility/tests/test_assetbed_api.py | 531 ++++++++++++++++++ care/facility/tests/test_bed_api.py | 17 +- care/facility/tests/test_patient_api.py | 164 +++++- 5 files changed, 854 insertions(+), 5 deletions(-) create mode 100644 care/facility/tests/test_assetbed_api.py diff --git a/care/facility/tests/test_asset_api.py b/care/facility/tests/test_asset_api.py index a0771b089b..3989a19eef 100644 --- a/care/facility/tests/test_asset_api.py +++ b/care/facility/tests/test_asset_api.py @@ -1,10 +1,14 @@ from django.utils.timezone import now, timedelta from rest_framework import status +from rest_framework.exceptions import ValidationError from rest_framework.test import APITestCase from care.facility.models import Asset, Bed from care.users.models import User from care.utils.assetintegration.asset_classes import AssetClasses +from care.utils.assetintegration.hl7monitor import HL7MonitorAsset +from care.utils.assetintegration.onvif import OnvifAsset +from care.utils.assetintegration.ventilator import VentilatorAsset from care.utils.tests.test_utils import TestUtils @@ -31,6 +35,143 @@ def setUp(self) -> None: super().setUp() self.asset = self.create_asset(self.asset_location) + def validate_invalid_meta(self, asset_class, meta): + with self.assertRaises(ValidationError): + asset_class(meta) + + def test_meta_validations_for_onvif_asset(self): + valid_meta = { + "local_ip_address": "192.168.0.1", + "camera_access_key": "username:password:access_key", + "middleware_hostname": "middleware.local", + "insecure_connection": True, + } + onvif_asset = OnvifAsset(valid_meta) + self.assertEqual(onvif_asset.middleware_hostname, "middleware.local") + self.assertEqual(onvif_asset.host, "192.168.0.1") + self.assertEqual(onvif_asset.username, "username") + self.assertEqual(onvif_asset.password, "password") + self.assertEqual(onvif_asset.access_key, "access_key") + self.assertTrue(onvif_asset.insecure_connection) + + invalid_meta_cases = [ + # Invalid format for camera_access_key + { + "id": "123", + "local_ip_address": "192.168.0.1", + "middleware_hostname": "middleware.local", + "camera_access_key": "invalid_format", + }, + # Missing username/password in camera_access_key + { + "local_ip_address": "192.168.0.1", + "middleware_hostname": "middleware.local", + "camera_access_key": "invalid_format", + }, + # Missing middleware_hostname + { + "local_ip_address": "192.168.0.1", + "camera_access_key": "username:password:access_key", + }, + # Missing local_ip_address + { + "middleware_hostname": "middleware.local", + "camera_access_key": "username:password:access_key", + }, + # Invalid value for insecure_connection + { + "local_ip_address": "192.168.0.1", + "camera_access_key": "username:password:access_key", + "middleware_hostname": "middleware.local", + "insecure_connection": "invalid_value", + }, + ] + for meta in invalid_meta_cases: + self.validate_invalid_meta(OnvifAsset, meta) + + def test_meta_validations_for_ventilator_asset(self): + valid_meta = { + "id": "123", + "local_ip_address": "192.168.0.1", + "middleware_hostname": "middleware.local", + "insecure_connection": True, + } + ventilator_asset = VentilatorAsset(valid_meta) + self.assertEqual(ventilator_asset.middleware_hostname, "middleware.local") + self.assertEqual(ventilator_asset.host, "192.168.0.1") + self.assertTrue(ventilator_asset.insecure_connection) + + invalid_meta_cases = [ + # Missing id + { + "local_ip_address": "192.168.0.1", + "middleware_hostname": "middleware.local", + }, + # Missing middleware_hostname + {"id": "123", "local_ip_address": "192.168.0.1"}, + # Missing local_ip_address + {"id": "123", "middleware_hostname": "middleware.local"}, + # Invalid insecure_connection + { + "id": "123", + "local_ip_address": "192.168.0.1", + "middleware_hostname": "middleware.local", + "insecure_connection": "invalid_value", + }, + # Camera access key not required for ventilator, invalid meta + { + "id": "21", + "local_ip_address": "192.168.0.1", + "camera_access_key": "username:password:access_key", + "middleware_hostname": "middleware.local", + "insecure_connection": True, + }, + ] + for meta in invalid_meta_cases: + self.validate_invalid_meta(VentilatorAsset, meta) + + def test_meta_validations_for_hl7monitor_asset(self): + valid_meta = { + "id": "123", + "local_ip_address": "192.168.0.1", + "middleware_hostname": "middleware.local", + "insecure_connection": True, + } + hl7monitor_asset = HL7MonitorAsset(valid_meta) + self.assertEqual(hl7monitor_asset.middleware_hostname, "middleware.local") + self.assertEqual(hl7monitor_asset.host, "192.168.0.1") + self.assertEqual(hl7monitor_asset.id, "123") + self.assertTrue(hl7monitor_asset.insecure_connection) + + invalid_meta_cases = [ + # Missing id + { + "local_ip_address": "192.168.0.1", + "middleware_hostname": "middleware.local", + }, + # Missing middleware_hostname + {"id": "123", "local_ip_address": "192.168.0.1"}, + # Missing local_ip_address + {"id": "123", "middleware_hostname": "middleware.local"}, + # Invalid insecure_connection + { + "id": "123", + "local_ip_address": "192.168.0.1", + "middleware_hostname": "middleware.local", + "insecure_connection": "invalid_value", + }, + # Camera access key not required for HL7Monitor, invalid meta + { + "id": "123", + "local_ip_address": "192.168.0.1", + "camera_access_key": "username:password:access_key", + "middleware_hostname": "middleware.local", + "insecure_connection": True, + }, + ] + for meta in invalid_meta_cases: + self.validate_invalid_meta(HL7MonitorAsset, meta) + def test_list_assets(self): response = self.client.get("/api/v1/asset/") self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/care/facility/tests/test_asset_location_api.py b/care/facility/tests/test_asset_location_api.py index 9e8280d617..6bcfed7850 100644 --- a/care/facility/tests/test_asset_location_api.py +++ b/care/facility/tests/test_asset_location_api.py @@ -21,7 +21,7 @@ def setUpTestData(cls) -> None: asset_class=AssetClasses.HL7MONITOR.name, ) cls.bed = cls.create_bed(cls.facility, cls.asset_location_with_linked_bed) - cls.asset_bed = cls.create_asset_bed(cls.asset, cls.bed) + cls.asset_bed = cls.create_assetbed(cls.bed, cls.asset) cls.patient = cls.create_patient(cls.district, cls.facility) cls.consultation = cls.create_consultation(cls.patient, cls.facility) cls.consultation_bed = cls.create_consultation_bed(cls.consultation, cls.bed) @@ -36,8 +36,8 @@ def setUpTestData(cls) -> None: cls.asset_second_location, asset_class=AssetClasses.HL7MONITOR.name ) cls.asset_bed_second = cls.create_bed(cls.facility, cls.asset_second_location) - cls.assetbed_second = cls.create_asset_bed( - cls.asset_second, cls.asset_bed_second + cls.assetbed_second = cls.create_assetbed( + cls.asset_bed_second, cls.asset_second ) def test_list_asset_locations(self): diff --git a/care/facility/tests/test_assetbed_api.py b/care/facility/tests/test_assetbed_api.py new file mode 100644 index 0000000000..2129ef1a00 --- /dev/null +++ b/care/facility/tests/test_assetbed_api.py @@ -0,0 +1,531 @@ +from rest_framework import status +from rest_framework.test import APITestCase + +from care.facility.models import AssetBed, Bed +from care.users.models import User +from care.utils.assetintegration.asset_classes import AssetClasses +from care.utils.tests.test_utils import TestUtils + + +class AssetBedViewSetTests(TestUtils, APITestCase): + """ + Test class for AssetBed + """ + + @classmethod + def setUpTestData(cls) -> None: + cls.state = cls.create_state() + cls.district = cls.create_district(cls.state) + cls.local_body = cls.create_local_body(cls.district) + cls.super_user = cls.create_super_user("su", cls.district) + cls.facility = cls.create_facility(cls.super_user, cls.district, cls.local_body) + cls.facility2 = cls.create_facility( + cls.super_user, cls.district, cls.local_body + ) + cls.user = cls.create_user( + "user", + district=cls.district, + local_body=cls.local_body, + home_facility=cls.facility, + ) # user from facility + cls.foreign_user = cls.create_user( + "foreign_user", + district=cls.district, + local_body=cls.local_body, + home_facility=cls.facility2, + ) # user outside the facility + cls.patient = cls.create_patient( + cls.district, cls.facility, local_body=cls.local_body + ) + cls.asset_location1 = cls.create_asset_location(cls.facility) + cls.asset1 = cls.create_asset( + cls.asset_location1, asset_class=AssetClasses.HL7MONITOR.name + ) + cls.bed1 = Bed.objects.create( + name="bed1", location=cls.asset_location1, facility=cls.facility + ) + cls.asset_location2 = cls.create_asset_location(cls.facility) + # camera asset + cls.asset2 = cls.create_asset( + cls.asset_location2, asset_class=AssetClasses.ONVIF.name + ) + cls.bed2 = Bed.objects.create( + name="bed2", location=cls.asset_location2, facility=cls.facility + ) + cls.asset_location3 = cls.create_asset_location(cls.facility) + cls.asset3 = cls.create_asset( + cls.asset_location3, asset_class=AssetClasses.VENTILATOR.name + ) + cls.bed3 = Bed.objects.create( + name="bed3", location=cls.asset_location3, facility=cls.facility + ) + # for testing create, put and patch requests + cls.bed4 = Bed.objects.create( + name="bed4", location=cls.asset_location3, facility=cls.facility + ) + cls.foreign_asset_location = cls.create_asset_location(cls.facility2) + cls.foreign_asset = cls.create_asset(cls.foreign_asset_location) + cls.foreign_bed = Bed.objects.create( + name="foreign_bed", + location=cls.foreign_asset_location, + facility=cls.facility2, + ) + + cls.create_assetbed(bed=cls.bed2, asset=cls.asset2) + cls.create_assetbed(bed=cls.bed3, asset=cls.asset3) + + # assetbed for different facility + cls.create_assetbed(bed=cls.foreign_bed, asset=cls.foreign_asset) + + def setUp(self) -> None: + super().setUp() + self.assetbed = self.create_assetbed(bed=self.bed1, asset=self.asset1) + + def get_base_url(self) -> str: + return "/api/v1/assetbed" + + def get_url(self, external_id=None): + """ + Constructs the url for ambulance api + """ + base_url = f"{self.get_base_url()}/" + if external_id is not None: + base_url += f"{external_id}/" + return base_url + + def test_list_assetbed(self): + # assetbed accessible to facility 1 user (current user) + response = self.client.get(self.get_url()) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 3) + + # logging in as foreign user + self.client.force_login(self.foreign_user) + + # assetbed accessible to facility 2 user (foreign user) + response = self.client.get(self.get_url()) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + + # logging in as superuser + self.client.force_login(self.super_user) + + # access to all assetbed + response = self.client.get(self.get_url()) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], AssetBed.objects.count()) + + # testing for filters + response = self.client.get(self.get_url(), {"asset": self.asset1.external_id}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["count"], AssetBed.objects.filter(asset=self.asset1).count() + ) + + response = self.client.get(self.get_url(), {"bed": self.bed1.external_id}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["count"], AssetBed.objects.filter(bed=self.bed1).count() + ) + self.assertEqual( + response.data["results"][0]["bed_object"]["name"], self.bed1.name + ) + + response = self.client.get( + self.get_url(), {"facility": self.facility.external_id} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["count"], + AssetBed.objects.filter(bed__facility=self.facility).count(), + ) + + def test_create_assetbed(self): + # Missing asset and bed + missing_fields_data = {} + response = self.client.post(self.get_url(), missing_fields_data, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("asset", response.data) + self.assertIn("bed", response.data) + + # Invalid asset UUID + invalid_asset_uuid_data = { + "asset": "invalid-uuid", + "bed": str(self.bed1.external_id), + } + response = self.client.post( + self.get_url(), invalid_asset_uuid_data, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("asset", response.data) + + # Invalid bed UUID + invalid_bed_uuid_data = { + "asset": str(self.asset1.external_id), + "bed": "invalid-uuid", + } + response = self.client.post( + self.get_url(), invalid_bed_uuid_data, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("bed", response.data) + + # Non-existent asset UUID + non_existent_asset_uuid_data = { + "asset": "11111111-1111-1111-1111-111111111111", + "bed": str(self.bed1.external_id), + } + response = self.client.post( + self.get_url(), non_existent_asset_uuid_data, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # Non-existent bed UUID + non_existent_bed_uuid_data = { + "asset": str(self.asset1.external_id), + "bed": "11111111-1111-1111-1111-111111111111", + } + response = self.client.post( + self.get_url(), non_existent_bed_uuid_data, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # User does not have access to foreign facility + foreign_user_data = { + "asset": str(self.foreign_asset.external_id), + "bed": str(self.foreign_bed.external_id), + } + self.client.force_login(self.user) # Ensure current user is logged in + response = self.client.post(self.get_url(), foreign_user_data, format="json") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # Invalid asset class (e.g., VENTILATOR) + invalid_asset_class_data = { + "asset": str(self.asset3.external_id), # VENTILATOR asset class + "bed": str(self.bed1.external_id), + } + response = self.client.post( + self.get_url(), invalid_asset_class_data, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("asset", response.data) + + # Asset and bed in different facilities + asset_bed_different_facilities = { + "asset": str(self.foreign_asset.external_id), + "bed": str(self.bed1.external_id), + } + response = self.client.post( + self.get_url(), asset_bed_different_facilities, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # Trying to create a duplicate assetbed with bed2 and asset2 (assetbed already exist with same bed and asset) + duplicate_asset_class_data = { + "asset": str(self.asset2.external_id), # asset2 is already assigned to bed2 + "bed": str(self.bed2.external_id), + } + response = self.client.post( + self.get_url(), duplicate_asset_class_data, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # Successfully create AssetBed with valid data + valid_data = { + "asset": str(self.asset1.external_id), + "bed": str(self.bed4.external_id), + } + response = self.client.post(self.get_url(), valid_data, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_retrieve_assetbed(self): + response = self.client.get(self.get_url(external_id=self.assetbed.external_id)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["asset_object"]["id"], str(self.assetbed.asset.external_id) + ) + self.assertEqual( + response.data["bed_object"]["id"], str(self.assetbed.bed.external_id) + ) + + def test_update_assetbed(self): + # checking old values + response = self.client.get(self.get_url(external_id=self.assetbed.external_id)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["meta"], {}) + self.assertEqual( + response.data["asset_object"]["id"], str(self.assetbed.asset.external_id) + ) + self.assertEqual( + response.data["bed_object"]["id"], str(self.assetbed.bed.external_id) + ) + + invalid_updated_data = { + "asset": self.asset2.external_id, + "meta": {"sample_data": "sample value"}, + } + response = self.client.put( + self.get_url(external_id=self.assetbed.external_id), + invalid_updated_data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + invalid_updated_data = { + "bed": self.bed2.external_id, + "meta": {"sample_data": "sample value"}, + } + response = self.client.put( + self.get_url(external_id=self.assetbed.external_id), + invalid_updated_data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + updated_data = { + "bed": self.bed4.external_id, + "asset": self.asset2.external_id, + "meta": {"sample_data": "sample value"}, + } + response = self.client.put( + self.get_url(external_id=self.assetbed.external_id), + updated_data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assetbed.refresh_from_db() + + self.assertEqual(self.assetbed.bed.external_id, self.bed4.external_id) + self.assertEqual(self.assetbed.asset.external_id, self.asset2.external_id) + self.assertEqual(self.assetbed.meta, {"sample_data": "sample value"}) + + def test_patch_assetbed(self): + # checking old values + response = self.client.get(self.get_url(external_id=self.assetbed.external_id)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["meta"], {}) + self.assertEqual( + response.data["asset_object"]["id"], str(self.assetbed.asset.external_id) + ) + self.assertEqual( + response.data["bed_object"]["id"], str(self.assetbed.bed.external_id) + ) + + invalid_updated_data = { + "asset": self.asset2.external_id, + "meta": {"sample_data": "sample value"}, + } + response = self.client.patch( + self.get_url(external_id=self.assetbed.external_id), + invalid_updated_data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + invalid_updated_data = { + "bed": self.bed4.external_id, + "meta": {"sample_data": "sample value"}, + } + response = self.client.patch( + self.get_url(external_id=self.assetbed.external_id), + invalid_updated_data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + updated_data = { + "bed": self.bed4.external_id, + "asset": self.asset2.external_id, + } + + response = self.client.patch( + self.get_url(external_id=self.assetbed.external_id), + updated_data, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assetbed.refresh_from_db() + + self.assertEqual(self.assetbed.bed.external_id, self.bed4.external_id) + self.assertEqual(self.assetbed.asset.external_id, self.asset2.external_id) + + def test_delete_assetbed(self): + # confirming that the object exist + response = self.client.get(self.get_url(external_id=self.assetbed.external_id)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = self.client.delete( + self.get_url(external_id=self.assetbed.external_id) + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + # confirming if it's deleted + response = self.client.get(self.get_url(external_id=self.assetbed.external_id)) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # confirming using db + self.assetbed.refresh_from_db() + self.assertFalse( + AssetBed.objects.filter(external_id=self.assetbed.external_id).exists() + ) + + def test_linking_multiple_cameras_to_a_bed(self): + # We already have camera linked(asset2) to bed2 + # Attempt linking another camera to same bed. + new_camera_asset = self.create_asset( + self.asset_location2, asset_class=AssetClasses.ONVIF.name + ) + data = { + "bed": self.bed2.external_id, + "asset": new_camera_asset.external_id, + } + res = self.client.post(self.get_url(), data, format="json") + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + + def test_linking_multiple_hl7_monitors_to_a_bed(self): + # We already have hl7 monitor linked(asset1) to bed1) + # Attempt linking another hl7 monitor to same bed. + new_hl7_monitor_asset = self.create_asset( + self.asset_location2, asset_class=AssetClasses.HL7MONITOR.name + ) + data = { + "bed": self.bed1.external_id, + "asset": new_hl7_monitor_asset.external_id, + } + res = self.client.post("/api/v1/assetbed/", data, format="json") + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + +class AssetBedCameraPresetViewSetTestCase(TestUtils, APITestCase): + @classmethod + def setUpTestData(cls): + cls.state = cls.create_state() + cls.district = cls.create_district(cls.state) + cls.local_body = cls.create_local_body(cls.district) + cls.super_user = cls.create_super_user("su", cls.district) + cls.facility = cls.create_facility(cls.super_user, cls.district, cls.local_body) + cls.user = cls.create_user( + User.TYPE_VALUE_MAP["DistrictAdmin"], + cls.district, + home_facility=cls.facility, + ) + cls.asset_location = cls.create_asset_location(cls.facility) + cls.asset1 = cls.create_asset( + cls.asset_location, asset_class=AssetClasses.ONVIF.name + ) + cls.asset2 = cls.create_asset( + cls.asset_location, asset_class=AssetClasses.ONVIF.name + ) + cls.bed = cls.create_bed(cls.facility, cls.asset_location) + cls.asset_bed1 = cls.create_assetbed(cls.bed, cls.asset1) + cls.asset_bed2 = cls.create_assetbed(cls.bed, cls.asset2) + + def get_base_url(self, asset_bed_id=None): + return f"/api/v1/assetbed/{asset_bed_id or self.asset_bed1.external_id}/camera_presets/" + + def test_create_camera_preset_without_position(self): + res = self.client.post( + self.get_base_url(), + { + "name": "Preset without position", + "position": {}, + }, + format="json", + ) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_camera_preset_with_missing_required_keys_in_position(self): + res = self.client.post( + self.get_base_url(), + { + "name": "Preset with invalid position", + "position": {"key": "value"}, + }, + format="json", + ) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_camera_preset_with_position_not_number(self): + res = self.client.post( + self.get_base_url(), + { + "name": "Preset with invalid position", + "position": { + "x": "not a number", + "y": 1, + "zoom": 1, + }, + }, + format="json", + ) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_camera_preset_with_position_values_as_string(self): + res = self.client.post( + self.get_base_url(), + { + "name": "Preset with invalid position", + "position": { + "x": "1", + "y": "1", + "zoom": "1", + }, + }, + format="json", + ) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_camera_preset_and_presence_in_various_preset_list_apis(self): + asset_bed = self.asset_bed1 + res = self.client.post( + self.get_base_url(asset_bed.external_id), + { + "name": "Preset with proper position", + "position": { + "x": 1.0, + "y": 1.0, + "zoom": 1.0, + }, + }, + format="json", + ) + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + preset_external_id = res.data["id"] + + # Check if preset in asset-bed preset list + res = self.client.get(self.get_base_url(asset_bed.external_id)) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertContains(res, preset_external_id) + + # Check if preset in asset preset list + res = self.client.get( + f"/api/v1/asset/{asset_bed.asset.external_id}/camera_presets/" + ) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertContains(res, preset_external_id) + + # Check if preset in bed preset list + res = self.client.get( + f"/api/v1/bed/{asset_bed.bed.external_id}/camera_presets/" + ) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertContains(res, preset_external_id) + + def test_create_camera_preset_with_same_name_in_same_bed(self): + data = { + "name": "Duplicate Preset Name", + "position": { + "x": 1.0, + "y": 1.0, + "zoom": 1.0, + }, + } + self.client.post( + self.get_base_url(self.asset_bed1.external_id), data, format="json" + ) + res = self.client.post( + self.get_base_url(self.asset_bed2.external_id), data, format="json" + ) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/care/facility/tests/test_bed_api.py b/care/facility/tests/test_bed_api.py index ce334dd6e4..9bceece342 100644 --- a/care/facility/tests/test_bed_api.py +++ b/care/facility/tests/test_bed_api.py @@ -75,7 +75,7 @@ def test_list_beds(self): self.client.logout() def test_create_beds(self): - sample_data = { + base_data = { "name": "Sample Beds", "bed_type": 2, "location": self.asset_location.external_id, @@ -83,23 +83,27 @@ def test_create_beds(self): "number_of_beds": 10, "description": "This is a sample bed description", } + sample_data = base_data.copy() # Create a fresh copy of the base data response = self.client.post("/api/v1/bed/", sample_data, format="json") self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(Bed.objects.filter(bed_type=2).count(), 10) # without location + sample_data = base_data.copy() sample_data.update({"location": None}) response = self.client.post("/api/v1/bed/", sample_data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.data["location"][0], "This field may not be null.") # without facility + sample_data = base_data.copy() sample_data.update({"facility": None}) response = self.client.post("/api/v1/bed/", sample_data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.data["facility"][0], "This field may not be null.") # Test - if beds > 100 + sample_data = base_data.copy() sample_data.update({"number_of_beds": 200}) response = self.client.post("/api/v1/bed/", sample_data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -108,6 +112,17 @@ def test_create_beds(self): "Cannot create more than 100 beds at once.", ) + # creating bed in different facility + sample_data = base_data.copy() + sample_data.update( + { + "location": self.asset_location2.external_id, + "facility": self.facility2.external_id, + } + ) + response = self.client.post("/api/v1/bed/", sample_data, format="json") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_retrieve_bed(self): response = self.client.get(f"/api/v1/bed/{self.bed1.external_id}/") self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/care/facility/tests/test_patient_api.py b/care/facility/tests/test_patient_api.py index 6facfdd3ad..37accc7c2c 100644 --- a/care/facility/tests/test_patient_api.py +++ b/care/facility/tests/test_patient_api.py @@ -4,7 +4,7 @@ from rest_framework import status from rest_framework.test import APITestCase -from care.facility.models import PatientNoteThreadChoices +from care.facility.models import PatientNoteThreadChoices, ShiftingRequest from care.facility.models.file_upload import FileUpload from care.facility.models.icd11_diagnosis import ( ConditionVerificationStatus, @@ -913,6 +913,8 @@ def setUpTestData(cls): cls.patient.save() def test_patient_transfer(self): + # test transfer of patient to a outside facility with allow_transfer set to "True" + # test transfer patient with dob self.client.force_authenticate(user=self.super_user) response = self.client.post( f"/api/v1/patient/{self.patient.external_id}/transfer/", @@ -1000,6 +1002,166 @@ def test_transfer_disallowed_by_facility(self): "Patient transfer cannot be completed because the source facility does not permit it", ) + def test_transfer_within_facility(self): + self.client.force_authenticate(user=self.super_user) + response = self.client.post( + f"/api/v1/patient/{self.patient.external_id}/transfer/", + { + "date_of_birth": "1992-04-01", + "facility": self.facility.external_id, + }, + ) + self.assertEqual(response.status_code, status.HTTP_406_NOT_ACCEPTABLE) + self.assertEqual( + response.data["Patient"], + "Patient transfer cannot be completed because the patient has an active consultation in the same facility", + ) + + def test_transfer_without_dob(self): + self.client.force_authenticate(user=self.super_user) + response = self.client.post( + f"/api/v1/patient/{self.patient.external_id}/transfer/", + { + "age": "32", + "facility": self.destination_facility.external_id, + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.patient.refresh_from_db() + self.consultation.refresh_from_db() + + self.assertEqual(self.patient.facility, self.destination_facility) + + self.assertEqual( + self.consultation.new_discharge_reason, NewDischargeReasonEnum.REFERRED + ) + self.assertIsNotNone(self.consultation.discharge_date) + + def test_transfer_with_no_active_consultation(self): + # Mocking discharge of the patient + self.consultation.discharge_date = now() + self.consultation.save() + + # Ensure transfer succeeds when there's no active consultation + self.client.force_authenticate(user=self.super_user) + response = self.client.post( + f"/api/v1/patient/{self.patient.external_id}/transfer/", + { + "year_of_birth": 1992, + "facility": self.destination_facility.external_id, + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Refresh patient data + self.patient.refresh_from_db() + + # Assert the patient's facility has been updated + self.assertEqual(self.patient.facility, self.destination_facility) + + def test_transfer_with_incorrect_year_of_birth(self): + # Set the patient's facility to allow transfers + self.patient.allow_transfer = True + self.patient.save() + + self.client.force_authenticate(user=self.super_user) + response = self.client.post( + f"/api/v1/patient/{self.patient.external_id}/transfer/", + { + "year_of_birth": 1990, # Incorrect year of birth + "facility": self.destination_facility.external_id, + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data["year_of_birth"][0], "Year of birth does not match" + ) + + def test_auto_reject_active_shifting_requests_upon_transfer(self): + # Create a mock shifting request that is still active (PENDING status) + shifting_request = ShiftingRequest.objects.create( + patient=self.patient, + origin_facility=self.facility, + status=10, # PENDING status + comments="Initial request", + created_by=self.super_user, + ) + + # Perform the patient transfer + self.client.force_authenticate(user=self.super_user) + response = self.client.post( + f"/api/v1/patient/{self.patient.external_id}/transfer/", + { + "year_of_birth": 1992, + "facility": self.destination_facility.external_id, + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Refresh the shifting request and verify it was auto-rejected + shifting_request.refresh_from_db() + self.assertEqual(shifting_request.status, 30) # REJECTED status + self.assertIn( + f"The shifting request was auto rejected by the system as the patient was moved to {self.destination_facility.name}", + shifting_request.comments, + ) + + def test_transfer_with_matching_year_of_birth(self): + # Set the patient's facility to allow transfers + self.patient.allow_transfer = True + self.patient.save() + + self.client.force_authenticate(user=self.super_user) + response = self.client.post( + f"/api/v1/patient/{self.patient.external_id}/transfer/", + { + "year_of_birth": self.patient.year_of_birth, # Correct year of birth + "facility": self.destination_facility.external_id, + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Refresh patient data + self.patient.refresh_from_db() + + # Assert the patient's facility has been updated + self.assertEqual(self.patient.facility, self.destination_facility) + + def test_transfer_to_non_existent_facility(self): + self.client.force_authenticate(user=self.super_user) + response = self.client.post( + f"/api/v1/patient/{self.patient.external_id}/transfer/", + { + "year_of_birth": 1992, + "facility": "dff237c5-9410-4714-9101-399941b60ede", # Non-existent facility + }, + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_transfer_with_invalid_data(self): + self.client.force_authenticate(user=self.super_user) + response = self.client.post( + f"/api/v1/patient/{self.patient.external_id}/transfer/", + { + "year_of_birth": "invalid-year", # Invalid year of birth + "facility": self.destination_facility.external_id, + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("year_of_birth", response.data) + + def test_unauthorized_transfer_request(self): + # Not authenticating the user to test unauthorized access + response = self.client.post( + f"/api/v1/patient/{self.patient.external_id}/transfer/", + { + "year_of_birth": 1992, + "facility": self.destination_facility.external_id, + }, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + class PatientSearchTestCase(TestUtils, APITestCase): @classmethod From 6128345b60760cc7a8a5670e11645cb8d200618a Mon Sep 17 00:00:00 2001 From: prafful Date: Mon, 18 Nov 2024 02:25:12 +0530 Subject: [PATCH 04/13] merged conflicts resolved --- .../tests/test_camera_preset_apis.py | 4 +- .../tests/test_onvif_validations.py | 52 +++++ care/camera_plugin/utils/onvif.py | 20 +- care/facility/api/serializers/asset.py | 9 +- care/facility/api/serializers/bed.py | 12 +- care/facility/api/viewsets/asset.py | 4 +- care/facility/api/viewsets/patient.py | 2 +- care/facility/tests/test_asset_api.py | 51 ----- care/facility/tests/test_assetbed_api.py | 192 ++---------------- care/utils/assetintegration/base.py | 38 +++- care/utils/assetintegration/hl7monitor.py | 10 +- care/utils/assetintegration/schema.py | 34 ++++ care/utils/assetintegration/ventilator.py | 10 +- care/utils/tests/test_utils.py | 12 +- 14 files changed, 174 insertions(+), 276 deletions(-) create mode 100644 care/camera_plugin/tests/test_onvif_validations.py create mode 100644 care/utils/assetintegration/schema.py diff --git a/care/camera_plugin/tests/test_camera_preset_apis.py b/care/camera_plugin/tests/test_camera_preset_apis.py index 326d331845..4dbc9cd0b8 100644 --- a/care/camera_plugin/tests/test_camera_preset_apis.py +++ b/care/camera_plugin/tests/test_camera_preset_apis.py @@ -27,8 +27,8 @@ def setUpTestData(cls): cls.asset_location, asset_class=AssetClasses.ONVIF.name ) cls.bed = cls.create_bed(cls.facility, cls.asset_location) - cls.asset_bed1 = cls.create_asset_bed(cls.asset1, cls.bed) - cls.asset_bed2 = cls.create_asset_bed(cls.asset2, cls.bed) + cls.asset_bed1 = cls.create_assetbed(cls.bed, cls.asset1) + cls.asset_bed2 = cls.create_assetbed(cls.bed, cls.asset2) def get_base_url(self, asset_bed_id=None): return f"/api/v1/assetbed/{asset_bed_id or self.asset_bed1.external_id}/camera_presets/" diff --git a/care/camera_plugin/tests/test_onvif_validations.py b/care/camera_plugin/tests/test_onvif_validations.py new file mode 100644 index 0000000000..ef1109a8c6 --- /dev/null +++ b/care/camera_plugin/tests/test_onvif_validations.py @@ -0,0 +1,52 @@ +from care.camera_plugin.utils.onvif import OnvifAsset + + +def test_meta_validations_for_onvif_asset(self): + valid_meta = { + "local_ip_address": "192.168.0.1", + "camera_access_key": "username:password:access_key", + "middleware_hostname": "middleware.local", + "insecure_connection": True, + } + onvif_asset = OnvifAsset(valid_meta) + self.assertEqual(onvif_asset.middleware_hostname, "middleware.local") + self.assertEqual(onvif_asset.host, "192.168.0.1") + self.assertEqual(onvif_asset.username, "username") + self.assertEqual(onvif_asset.password, "password") + self.assertEqual(onvif_asset.access_key, "access_key") + self.assertTrue(onvif_asset.insecure_connection) + + invalid_meta_cases = [ + # Invalid format for camera_access_key + { + "id": "123", + "local_ip_address": "192.168.0.1", + "middleware_hostname": "middleware.local", + "camera_access_key": "invalid_format", + }, + # Missing username/password in camera_access_key + { + "local_ip_address": "192.168.0.1", + "middleware_hostname": "middleware.local", + "camera_access_key": "invalid_format", + }, + # Missing middleware_hostname + { + "local_ip_address": "192.168.0.1", + "camera_access_key": "username:password:access_key", + }, + # Missing local_ip_address + { + "middleware_hostname": "middleware.local", + "camera_access_key": "username:password:access_key", + }, + # Invalid value for insecure_connection + { + "local_ip_address": "192.168.0.1", + "camera_access_key": "username:password:access_key", + "middleware_hostname": "middleware.local", + "insecure_connection": "invalid_value", + }, + ] + for meta in invalid_meta_cases: + self.validate_invalid_meta(OnvifAsset, meta) diff --git a/care/camera_plugin/utils/onvif.py b/care/camera_plugin/utils/onvif.py index a1ec2e90d1..ecc13c17b5 100644 --- a/care/camera_plugin/utils/onvif.py +++ b/care/camera_plugin/utils/onvif.py @@ -2,7 +2,7 @@ from rest_framework.exceptions import ValidationError -from care.utils.assetintegration.base import BaseAssetIntegration +from care.utils.assetintegration.base import ActionParams, BaseAssetIntegration class OnvifAsset(BaseAssetIntegration): @@ -27,9 +27,10 @@ def __init__(self, meta): {key: f"{key} not found in asset metadata" for key in e.args} ) from e - def handle_action(self, action): - action_type = action["type"] - action_data = action.get("data", {}) + def handle_action(self, **kwargs: ActionParams): + action_type = kwargs["type"] + action_data = kwargs.get("data", {}) + timeout = kwargs.get("timeout") request_body = { "hostname": self.host, @@ -41,19 +42,19 @@ def handle_action(self, action): } if action_type == self.OnvifActions.GET_CAMERA_STATUS.value: - return self.api_get(self.get_url("status"), request_body) + return self.api_get(self.get_url("status"), request_body, timeout) if action_type == self.OnvifActions.GET_PRESETS.value: - return self.api_get(self.get_url("presets"), request_body) + return self.api_get(self.get_url("presets"), request_body, timeout) if action_type == self.OnvifActions.GOTO_PRESET.value: - return self.api_post(self.get_url("gotoPreset"), request_body) + return self.api_post(self.get_url("gotoPreset"), request_body, timeout) if action_type == self.OnvifActions.ABSOLUTE_MOVE.value: - return self.api_post(self.get_url("absoluteMove"), request_body) + return self.api_post(self.get_url("absoluteMove"), request_body, timeout) if action_type == self.OnvifActions.RELATIVE_MOVE.value: - return self.api_post(self.get_url("relativeMove"), request_body) + return self.api_post(self.get_url("relativeMove"), request_body, timeout) if action_type == self.OnvifActions.GET_STREAM_TOKEN.value: return self.api_post( @@ -61,6 +62,7 @@ def handle_action(self, action): { "stream_id": self.access_key, }, + timeout, ) raise ValidationError({"action": "invalid action type"}) diff --git a/care/facility/api/serializers/asset.py b/care/facility/api/serializers/asset.py index 93c6f55ded..99fd14264a 100644 --- a/care/facility/api/serializers/asset.py +++ b/care/facility/api/serializers/asset.py @@ -7,7 +7,7 @@ from django.utils.timezone import now from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.serializers import ( CharField, JSONField, @@ -171,11 +171,14 @@ def validate(self, attrs): facilities = get_facility_queryset(user) if not facilities.filter(id=location.facility.id).exists(): - raise PermissionError + error_message = ( + "You do not have permission to access this facility's asset." + ) + raise PermissionDenied(error_message) del attrs["location"] attrs["current_location"] = location - # validate that warraty date is not in the past + # validate that warranty date is not in the past if warranty_amc_end_of_validity := attrs.get("warranty_amc_end_of_validity"): # pop out warranty date if it is not changed if ( diff --git a/care/facility/api/serializers/bed.py b/care/facility/api/serializers/bed.py index 8200b92a87..54f024f0cf 100644 --- a/care/facility/api/serializers/bed.py +++ b/care/facility/api/serializers/bed.py @@ -2,7 +2,7 @@ from django.db.models import Exists, OuterRef, Q from django.shortcuts import get_object_or_404 from django.utils import timezone -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.serializers import ( BooleanField, CharField, @@ -74,7 +74,10 @@ def validate(self, attrs): if (not facilities.filter(id=location.facility.id).exists()) or ( not facilities.filter(id=facility.id).exists() ): - raise PermissionError + error_message = ( + "You do not have permission to access this facility's bed." + ) + raise PermissionDenied(error_message) del attrs["location"] attrs["location"] = location attrs["facility"] = facility @@ -110,7 +113,10 @@ def validate(self, attrs): if ( not facilities.filter(id=asset.current_location.facility.id).exists() ) or (not facilities.filter(id=bed.facility.id).exists()): - raise PermissionError + error_message = ( + "You do not have permission to access this facility's assetbed." + ) + raise PermissionDenied(error_message) if AssetBed.objects.filter(asset=asset, bed=bed).exists(): raise ValidationError( {"non_field_errors": "Asset is already linked to bed"} diff --git a/care/facility/api/viewsets/asset.py b/care/facility/api/viewsets/asset.py index 9e795d33fd..bd24514e55 100644 --- a/care/facility/api/viewsets/asset.py +++ b/care/facility/api/viewsets/asset.py @@ -61,6 +61,7 @@ from care.facility.models.bed import AssetBed, ConsultationBed from care.users.models import User from care.utils.assetintegration.asset_classes import AssetClasses +from care.utils.assetintegration.base import BaseAssetIntegration from care.utils.cache.cache_allowed_facilities import get_accessible_facilities from care.utils.filters.choicefilter import CareChoiceFilter, inverse_choices from care.utils.queryset.asset_bed import get_asset_queryset @@ -381,7 +382,6 @@ def operate_assets(self, request, *args, **kwargs): This API is used to operate assets. API accepts the asset_id and action as parameters. """ try: - action = request.data["action"] asset: Asset = self.get_object() middleware_hostname = ( asset.meta.get( @@ -406,7 +406,7 @@ def operate_assets(self, request, *args, **kwargs): "middleware_hostname": middleware_hostname, } ) - result = asset_class.handle_action(action) + result = asset_class.handle_action(**request.data["action"]) return Response({"result": result}, status=status.HTTP_200_OK) except ValidationError as e: diff --git a/care/facility/api/viewsets/patient.py b/care/facility/api/viewsets/patient.py index 72731cd6e2..963b6d4731 100644 --- a/care/facility/api/viewsets/patient.py +++ b/care/facility/api/viewsets/patient.py @@ -589,7 +589,7 @@ def list(self, request, *args, **kwargs): @action(detail=True, methods=["POST"]) def transfer(self, request, *args, **kwargs): patient = PatientRegistration.objects.get(external_id=kwargs["external_id"]) - facility = Facility.objects.get(external_id=request.data["facility"]) + facility = get_object_or_404(Facility, external_id=request.data["facility"]) if patient.is_expired: return Response( diff --git a/care/facility/tests/test_asset_api.py b/care/facility/tests/test_asset_api.py index 3989a19eef..4045e64634 100644 --- a/care/facility/tests/test_asset_api.py +++ b/care/facility/tests/test_asset_api.py @@ -7,7 +7,6 @@ from care.users.models import User from care.utils.assetintegration.asset_classes import AssetClasses from care.utils.assetintegration.hl7monitor import HL7MonitorAsset -from care.utils.assetintegration.onvif import OnvifAsset from care.utils.assetintegration.ventilator import VentilatorAsset from care.utils.tests.test_utils import TestUtils @@ -39,56 +38,6 @@ def validate_invalid_meta(self, asset_class, meta): with self.assertRaises(ValidationError): asset_class(meta) - def test_meta_validations_for_onvif_asset(self): - valid_meta = { - "local_ip_address": "192.168.0.1", - "camera_access_key": "username:password:access_key", - "middleware_hostname": "middleware.local", - "insecure_connection": True, - } - onvif_asset = OnvifAsset(valid_meta) - self.assertEqual(onvif_asset.middleware_hostname, "middleware.local") - self.assertEqual(onvif_asset.host, "192.168.0.1") - self.assertEqual(onvif_asset.username, "username") - self.assertEqual(onvif_asset.password, "password") - self.assertEqual(onvif_asset.access_key, "access_key") - self.assertTrue(onvif_asset.insecure_connection) - - invalid_meta_cases = [ - # Invalid format for camera_access_key - { - "id": "123", - "local_ip_address": "192.168.0.1", - "middleware_hostname": "middleware.local", - "camera_access_key": "invalid_format", - }, - # Missing username/password in camera_access_key - { - "local_ip_address": "192.168.0.1", - "middleware_hostname": "middleware.local", - "camera_access_key": "invalid_format", - }, - # Missing middleware_hostname - { - "local_ip_address": "192.168.0.1", - "camera_access_key": "username:password:access_key", - }, - # Missing local_ip_address - { - "middleware_hostname": "middleware.local", - "camera_access_key": "username:password:access_key", - }, - # Invalid value for insecure_connection - { - "local_ip_address": "192.168.0.1", - "camera_access_key": "username:password:access_key", - "middleware_hostname": "middleware.local", - "insecure_connection": "invalid_value", - }, - ] - for meta in invalid_meta_cases: - self.validate_invalid_meta(OnvifAsset, meta) - def test_meta_validations_for_ventilator_asset(self): valid_meta = { "id": "123", diff --git a/care/facility/tests/test_assetbed_api.py b/care/facility/tests/test_assetbed_api.py index 2129ef1a00..bc07c62439 100644 --- a/care/facility/tests/test_assetbed_api.py +++ b/care/facility/tests/test_assetbed_api.py @@ -2,7 +2,6 @@ from rest_framework.test import APITestCase from care.facility.models import AssetBed, Bed -from care.users.models import User from care.utils.assetintegration.asset_classes import AssetClasses from care.utils.tests.test_utils import TestUtils @@ -45,13 +44,6 @@ def setUpTestData(cls) -> None: name="bed1", location=cls.asset_location1, facility=cls.facility ) cls.asset_location2 = cls.create_asset_location(cls.facility) - # camera asset - cls.asset2 = cls.create_asset( - cls.asset_location2, asset_class=AssetClasses.ONVIF.name - ) - cls.bed2 = Bed.objects.create( - name="bed2", location=cls.asset_location2, facility=cls.facility - ) cls.asset_location3 = cls.create_asset_location(cls.facility) cls.asset3 = cls.create_asset( cls.asset_location3, asset_class=AssetClasses.VENTILATOR.name @@ -71,7 +63,6 @@ def setUpTestData(cls) -> None: facility=cls.facility2, ) - cls.create_assetbed(bed=cls.bed2, asset=cls.asset2) cls.create_assetbed(bed=cls.bed3, asset=cls.asset3) # assetbed for different facility @@ -86,7 +77,7 @@ def get_base_url(self) -> str: def get_url(self, external_id=None): """ - Constructs the url for ambulance api + Constructs the url for assetbed api """ base_url = f"{self.get_base_url()}/" if external_id is not None: @@ -97,7 +88,7 @@ def test_list_assetbed(self): # assetbed accessible to facility 1 user (current user) response = self.client.get(self.get_url()) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 3) + self.assertEqual(response.data["count"], 2) # logging in as foreign user self.client.force_login(self.foreign_user) @@ -220,10 +211,10 @@ def test_create_assetbed(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - # Trying to create a duplicate assetbed with bed2 and asset2 (assetbed already exist with same bed and asset) + # Trying to create a duplicate assetbed with bed3 and asset3 (assetbed already exists) duplicate_asset_class_data = { - "asset": str(self.asset2.external_id), # asset2 is already assigned to bed2 - "bed": str(self.bed2.external_id), + "asset": str(self.asset3.external_id), + "bed": str(self.bed3.external_id), } response = self.client.post( self.get_url(), duplicate_asset_class_data, format="json" @@ -261,7 +252,7 @@ def test_update_assetbed(self): ) invalid_updated_data = { - "asset": self.asset2.external_id, + "asset": self.asset3.external_id, "meta": {"sample_data": "sample value"}, } response = self.client.put( @@ -272,7 +263,7 @@ def test_update_assetbed(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) invalid_updated_data = { - "bed": self.bed2.external_id, + "bed": self.bed3.external_id, "meta": {"sample_data": "sample value"}, } response = self.client.put( @@ -284,7 +275,7 @@ def test_update_assetbed(self): updated_data = { "bed": self.bed4.external_id, - "asset": self.asset2.external_id, + "asset": self.asset1.external_id, "meta": {"sample_data": "sample value"}, } response = self.client.put( @@ -297,7 +288,7 @@ def test_update_assetbed(self): self.assetbed.refresh_from_db() self.assertEqual(self.assetbed.bed.external_id, self.bed4.external_id) - self.assertEqual(self.assetbed.asset.external_id, self.asset2.external_id) + self.assertEqual(self.assetbed.asset.external_id, self.asset1.external_id) self.assertEqual(self.assetbed.meta, {"sample_data": "sample value"}) def test_patch_assetbed(self): @@ -313,7 +304,7 @@ def test_patch_assetbed(self): ) invalid_updated_data = { - "asset": self.asset2.external_id, + "asset": self.asset3.external_id, "meta": {"sample_data": "sample value"}, } response = self.client.patch( @@ -336,7 +327,7 @@ def test_patch_assetbed(self): updated_data = { "bed": self.bed4.external_id, - "asset": self.asset2.external_id, + "asset": self.asset1.external_id, } response = self.client.patch( @@ -349,7 +340,7 @@ def test_patch_assetbed(self): self.assetbed.refresh_from_db() self.assertEqual(self.assetbed.bed.external_id, self.bed4.external_id) - self.assertEqual(self.assetbed.asset.external_id, self.asset2.external_id) + self.assertEqual(self.assetbed.asset.external_id, self.asset1.external_id) def test_delete_assetbed(self): # confirming that the object exist @@ -370,162 +361,3 @@ def test_delete_assetbed(self): self.assertFalse( AssetBed.objects.filter(external_id=self.assetbed.external_id).exists() ) - - def test_linking_multiple_cameras_to_a_bed(self): - # We already have camera linked(asset2) to bed2 - # Attempt linking another camera to same bed. - new_camera_asset = self.create_asset( - self.asset_location2, asset_class=AssetClasses.ONVIF.name - ) - data = { - "bed": self.bed2.external_id, - "asset": new_camera_asset.external_id, - } - res = self.client.post(self.get_url(), data, format="json") - self.assertEqual(res.status_code, status.HTTP_201_CREATED) - - def test_linking_multiple_hl7_monitors_to_a_bed(self): - # We already have hl7 monitor linked(asset1) to bed1) - # Attempt linking another hl7 monitor to same bed. - new_hl7_monitor_asset = self.create_asset( - self.asset_location2, asset_class=AssetClasses.HL7MONITOR.name - ) - data = { - "bed": self.bed1.external_id, - "asset": new_hl7_monitor_asset.external_id, - } - res = self.client.post("/api/v1/assetbed/", data, format="json") - self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) - - -class AssetBedCameraPresetViewSetTestCase(TestUtils, APITestCase): - @classmethod - def setUpTestData(cls): - cls.state = cls.create_state() - cls.district = cls.create_district(cls.state) - cls.local_body = cls.create_local_body(cls.district) - cls.super_user = cls.create_super_user("su", cls.district) - cls.facility = cls.create_facility(cls.super_user, cls.district, cls.local_body) - cls.user = cls.create_user( - User.TYPE_VALUE_MAP["DistrictAdmin"], - cls.district, - home_facility=cls.facility, - ) - cls.asset_location = cls.create_asset_location(cls.facility) - cls.asset1 = cls.create_asset( - cls.asset_location, asset_class=AssetClasses.ONVIF.name - ) - cls.asset2 = cls.create_asset( - cls.asset_location, asset_class=AssetClasses.ONVIF.name - ) - cls.bed = cls.create_bed(cls.facility, cls.asset_location) - cls.asset_bed1 = cls.create_assetbed(cls.bed, cls.asset1) - cls.asset_bed2 = cls.create_assetbed(cls.bed, cls.asset2) - - def get_base_url(self, asset_bed_id=None): - return f"/api/v1/assetbed/{asset_bed_id or self.asset_bed1.external_id}/camera_presets/" - - def test_create_camera_preset_without_position(self): - res = self.client.post( - self.get_base_url(), - { - "name": "Preset without position", - "position": {}, - }, - format="json", - ) - self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) - - def test_create_camera_preset_with_missing_required_keys_in_position(self): - res = self.client.post( - self.get_base_url(), - { - "name": "Preset with invalid position", - "position": {"key": "value"}, - }, - format="json", - ) - self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) - - def test_create_camera_preset_with_position_not_number(self): - res = self.client.post( - self.get_base_url(), - { - "name": "Preset with invalid position", - "position": { - "x": "not a number", - "y": 1, - "zoom": 1, - }, - }, - format="json", - ) - self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) - - def test_create_camera_preset_with_position_values_as_string(self): - res = self.client.post( - self.get_base_url(), - { - "name": "Preset with invalid position", - "position": { - "x": "1", - "y": "1", - "zoom": "1", - }, - }, - format="json", - ) - self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) - - def test_create_camera_preset_and_presence_in_various_preset_list_apis(self): - asset_bed = self.asset_bed1 - res = self.client.post( - self.get_base_url(asset_bed.external_id), - { - "name": "Preset with proper position", - "position": { - "x": 1.0, - "y": 1.0, - "zoom": 1.0, - }, - }, - format="json", - ) - self.assertEqual(res.status_code, status.HTTP_201_CREATED) - preset_external_id = res.data["id"] - - # Check if preset in asset-bed preset list - res = self.client.get(self.get_base_url(asset_bed.external_id)) - self.assertEqual(res.status_code, status.HTTP_200_OK) - self.assertContains(res, preset_external_id) - - # Check if preset in asset preset list - res = self.client.get( - f"/api/v1/asset/{asset_bed.asset.external_id}/camera_presets/" - ) - self.assertEqual(res.status_code, status.HTTP_200_OK) - self.assertContains(res, preset_external_id) - - # Check if preset in bed preset list - res = self.client.get( - f"/api/v1/bed/{asset_bed.bed.external_id}/camera_presets/" - ) - self.assertEqual(res.status_code, status.HTTP_200_OK) - self.assertContains(res, preset_external_id) - - def test_create_camera_preset_with_same_name_in_same_bed(self): - data = { - "name": "Duplicate Preset Name", - "position": { - "x": 1.0, - "y": 1.0, - "zoom": 1.0, - }, - } - self.client.post( - self.get_base_url(self.asset_bed1.external_id), data, format="json" - ) - res = self.client.post( - self.get_base_url(self.asset_bed2.external_id), data, format="json" - ) - self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/care/utils/assetintegration/base.py b/care/utils/assetintegration/base.py index 8a7e504d1d..08da4fcfa2 100644 --- a/care/utils/assetintegration/base.py +++ b/care/utils/assetintegration/base.py @@ -1,17 +1,35 @@ import json +from typing import TypedDict +import jsonschema import requests from django.conf import settings +from jsonschema import ValidationError as JSONValidationError from rest_framework import status -from rest_framework.exceptions import APIException +from rest_framework.exceptions import APIException, ValidationError from care.utils.jwks.token_generator import generate_jwt +from .schema import meta_object_schema + + +class ActionParams(TypedDict, total=False): + type: str + data: dict | None + timeout: int | None + class BaseAssetIntegration: auth_header_type = "Care_Bearer " def __init__(self, meta): + try: + meta["_name"] = self._name + jsonschema.validate(instance=meta, schema=meta_object_schema) + except JSONValidationError as e: + error_message = f"Invalid metadata: {e.message}" + raise ValidationError(error_message) from e + self.meta = meta self.id = self.meta.get("id", "") self.host = self.meta["local_ip_address"] @@ -19,8 +37,8 @@ def __init__(self, meta): self.insecure_connection = self.meta.get("insecure_connection", False) self.timeout = settings.MIDDLEWARE_REQUEST_TIMEOUT - def handle_action(self, action): - pass + def handle_action(self, **kwargs): + """Handle actions using kwargs instead of dict.""" def get_url(self, endpoint): protocol = "http" @@ -48,18 +66,16 @@ def _validate_response(self, response: requests.Response): {"error": "Invalid Response"}, response.status_code ) from e - def api_post(self, url, data=None): + def api_post(self, url, data=None, timeout=None): + timeout = timeout or self.timeout return self._validate_response( - requests.post( - url, json=data, headers=self.get_headers(), timeout=self.timeout - ) + requests.post(url, json=data, headers=self.get_headers(), timeout=timeout) ) - def api_get(self, url, data=None): + def api_get(self, url, data=None, timeout=None): + timeout = timeout or self.timeout return self._validate_response( - requests.get( - url, params=data, headers=self.get_headers(), timeout=self.timeout - ) + requests.get(url, params=data, headers=self.get_headers(), timeout=timeout) ) @classmethod diff --git a/care/utils/assetintegration/hl7monitor.py b/care/utils/assetintegration/hl7monitor.py index ee09fd5d09..520178d69c 100644 --- a/care/utils/assetintegration/hl7monitor.py +++ b/care/utils/assetintegration/hl7monitor.py @@ -2,7 +2,7 @@ from rest_framework.exceptions import ValidationError -from care.utils.assetintegration.base import BaseAssetIntegration +from care.utils.assetintegration.base import ActionParams, BaseAssetIntegration class HL7MonitorAsset(BaseAssetIntegration): @@ -20,12 +20,13 @@ def __init__(self, meta): {key: f"{key} not found in asset metadata" for key in e.args} ) from e - def handle_action(self, action): - action_type = action["type"] + def handle_action(self, **kwargs: ActionParams): + action_type = kwargs["type"] + timeout = kwargs.get("timeout") if action_type == self.HL7MonitorActions.GET_VITALS.value: request_params = {"device_id": self.host} - return self.api_get(self.get_url("vitals"), request_params) + return self.api_get(self.get_url("vitals"), request_params, timeout) if action_type == self.HL7MonitorActions.GET_STREAM_TOKEN.value: return self.api_post( @@ -34,6 +35,7 @@ def handle_action(self, action): "asset_id": self.id, "ip": self.host, }, + timeout, ) raise ValidationError({"action": "invalid action type"}) diff --git a/care/utils/assetintegration/schema.py b/care/utils/assetintegration/schema.py new file mode 100644 index 0000000000..3396747162 --- /dev/null +++ b/care/utils/assetintegration/schema.py @@ -0,0 +1,34 @@ +meta_object_schema = { + "type": "object", + "properties": { + "id": {"type": "string"}, + "local_ip_address": {"type": "string", "format": "ipv4"}, + "middleware_hostname": {"type": "string"}, + "insecure_connection": {"type": "boolean", "default": False}, + "camera_access_key": { + "type": "string", + "pattern": "^[^:]+:[^:]+:[^:]+$", # valid pattern for "abc:def:ghi" , "123:456:789" + }, + }, + "required": ["local_ip_address", "middleware_hostname"], + "allOf": [ + { + "if": {"properties": {"_name": {"const": "onvif"}}}, + "then": { + "properties": {"camera_access_key": {"type": "string"}}, + "required": [ + "camera_access_key" + ], # Require camera_access_key for Onvif + }, + "else": { + "properties": {"id": {"type": "string"}}, + "required": ["id"], # Require id for non-Onvif assets + "not": { + "required": [ + "camera_access_key" + ] # Make camera_access_key not required for non-Onvif + }, + }, + } + ], +} diff --git a/care/utils/assetintegration/ventilator.py b/care/utils/assetintegration/ventilator.py index 53a5fe497d..5a5da00db7 100644 --- a/care/utils/assetintegration/ventilator.py +++ b/care/utils/assetintegration/ventilator.py @@ -2,7 +2,7 @@ from rest_framework.exceptions import ValidationError -from care.utils.assetintegration.base import BaseAssetIntegration +from care.utils.assetintegration.base import ActionParams, BaseAssetIntegration class VentilatorAsset(BaseAssetIntegration): @@ -20,12 +20,13 @@ def __init__(self, meta): {key: f"{key} not found in asset metadata" for key in e.args} ) from e - def handle_action(self, action): - action_type = action["type"] + def handle_action(self, **kwargs: ActionParams): + action_type = kwargs["type"] + timeout = kwargs.get("timeout") if action_type == self.VentilatorActions.GET_VITALS.value: request_params = {"device_id": self.host} - return self.api_get(self.get_url("vitals"), request_params) + return self.api_get(self.get_url("vitals"), request_params, timeout) if action_type == self.VentilatorActions.GET_STREAM_TOKEN.value: return self.api_post( @@ -34,6 +35,7 @@ def handle_action(self, action): "asset_id": self.id, "ip": self.host, }, + timeout, ) raise ValidationError({"action": "invalid action type"}) diff --git a/care/utils/tests/test_utils.py b/care/utils/tests/test_utils.py index 91d4ac8d67..fbc286a337 100644 --- a/care/utils/tests/test_utils.py +++ b/care/utils/tests/test_utils.py @@ -446,12 +446,6 @@ def create_bed(cls, facility: Facility, location: AssetLocation, **kwargs): data.update(kwargs) return Bed.objects.create(**data) - @classmethod - def create_asset_bed(cls, asset: Asset, bed: Bed, **kwargs): - data = {"asset": asset, "bed": bed} - data.update(kwargs) - return AssetBed.objects.create(**data) - @classmethod def create_consultation_bed( cls, @@ -728,6 +722,12 @@ def create_prescription( data.update(**kwargs) return Prescription.objects.create(**data) + @classmethod + def create_assetbed(cls, bed: Bed, asset: Asset, **kwargs) -> AssetBed: + data = {"bed": bed, "asset": asset} + data.update(kwargs) + return AssetBed.objects.create(**data) + def get_list_representation(self, obj) -> dict: """ Returns the dict representation of the obj in list API From c4a890476202f0ddf042de9ba12cb42325e1850c Mon Sep 17 00:00:00 2001 From: prafful Date: Thu, 21 Nov 2024 01:40:46 +0530 Subject: [PATCH 05/13] removed camera related code --- care/camera_plugin/admin.py | 1 - care/camera_plugin/api/__init__.py | 0 .../camera_plugin/api/serializers/__init__.py | 0 .../api/serializers/camera_position_preset.py | 49 ------ care/camera_plugin/api/viewsets/__init__.py | 0 .../api/viewsets/camera_position_preset.py | 65 -------- care/camera_plugin/apps.py | 12 -- care/camera_plugin/migrations/0001_initial.py | 39 ----- care/camera_plugin/migrations/__init__.py | 0 care/camera_plugin/models/__init__.py | 0 care/camera_plugin/models/camera_asset.py | 5 - .../models/camera_position_preset.py | 24 --- .../models/json_schema/__init__.py | 0 .../models/json_schema/position.py | 11 -- care/camera_plugin/tests/__init__.py | 0 .../tests/test_camera_preset_apis.py | 139 ------------------ .../tests/test_onvif_validations.py | 52 ------- care/camera_plugin/urls.py | 33 ----- care/camera_plugin/utils/__init__.py | 0 care/camera_plugin/utils/onvif.py | 74 ---------- care/camera_plugin/views.py | 1 - care/facility/api/serializers/asset.py | 10 +- care/facility/api/serializers/bed.py | 23 +-- care/facility/api/viewsets/asset.py | 21 ++- .../0468_alter_asset_asset_class.py | 18 --- .../migrations/0469_delete_camerapreset.py | 16 -- care/facility/models/asset.py | 36 ++++- care/facility/models/bed.py | 4 - care/facility/models/camera_preset.py | 33 +++++ care/facility/models/json_schema/asset.py | 115 ++++++++------- care/facility/tasks/asset_monitor.py | 26 +--- care/utils/assetintegration/base.py | 19 +++ care/utils/assetintegration/hl7monitor.py | 15 ++ care/utils/assetintegration/ventilator.py | 15 ++ care/utils/models/validators.py | 43 +++++- config/api_router.py | 1 - config/settings/base.py | 2 +- plug_config.py | 6 +- 38 files changed, 257 insertions(+), 651 deletions(-) delete mode 100644 care/camera_plugin/admin.py delete mode 100644 care/camera_plugin/api/__init__.py delete mode 100644 care/camera_plugin/api/serializers/__init__.py delete mode 100644 care/camera_plugin/api/serializers/camera_position_preset.py delete mode 100644 care/camera_plugin/api/viewsets/__init__.py delete mode 100644 care/camera_plugin/api/viewsets/camera_position_preset.py delete mode 100644 care/camera_plugin/apps.py delete mode 100644 care/camera_plugin/migrations/0001_initial.py delete mode 100644 care/camera_plugin/migrations/__init__.py delete mode 100644 care/camera_plugin/models/__init__.py delete mode 100644 care/camera_plugin/models/camera_asset.py delete mode 100644 care/camera_plugin/models/camera_position_preset.py delete mode 100644 care/camera_plugin/models/json_schema/__init__.py delete mode 100644 care/camera_plugin/models/json_schema/position.py delete mode 100644 care/camera_plugin/tests/__init__.py delete mode 100644 care/camera_plugin/tests/test_camera_preset_apis.py delete mode 100644 care/camera_plugin/tests/test_onvif_validations.py delete mode 100644 care/camera_plugin/urls.py delete mode 100644 care/camera_plugin/utils/__init__.py delete mode 100644 care/camera_plugin/utils/onvif.py delete mode 100644 care/camera_plugin/views.py delete mode 100644 care/facility/migrations/0468_alter_asset_asset_class.py delete mode 100644 care/facility/migrations/0469_delete_camerapreset.py create mode 100644 care/facility/models/camera_preset.py diff --git a/care/camera_plugin/admin.py b/care/camera_plugin/admin.py deleted file mode 100644 index 846f6b4061..0000000000 --- a/care/camera_plugin/admin.py +++ /dev/null @@ -1 +0,0 @@ -# Register your models here. diff --git a/care/camera_plugin/api/__init__.py b/care/camera_plugin/api/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/care/camera_plugin/api/serializers/__init__.py b/care/camera_plugin/api/serializers/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/care/camera_plugin/api/serializers/camera_position_preset.py b/care/camera_plugin/api/serializers/camera_position_preset.py deleted file mode 100644 index 188295b2f2..0000000000 --- a/care/camera_plugin/api/serializers/camera_position_preset.py +++ /dev/null @@ -1,49 +0,0 @@ -from rest_framework import serializers -from rest_framework.exceptions import ValidationError - -from care.camera_plugin.models.camera_position_preset import CameraPositionPreset -from care.facility.api.serializers.bed import AssetBedSerializer -from care.users.api.serializers.user import UserBaseMinimumSerializer - - -class CameraPositionPresetSerializer(serializers.ModelSerializer): - id = serializers.UUIDField(source="external_id", read_only=True) - created_by = UserBaseMinimumSerializer(read_only=True) - updated_by = UserBaseMinimumSerializer(read_only=True) - asset_bed = AssetBedSerializer(read_only=True) - - class Meta: - model = CameraPositionPreset - exclude = ( - "external_id", - "deleted", - ) - read_only_fields = ( - "created_date", - "modified_date", - "is_migrated", - "created_by", - "updated_by", - ) - - def get_asset_bed_obj(self): - return ( - self.instance.asset_bed if self.instance else self.context.get("asset_bed") - ) - - def validate_name(self, value): - if CameraPositionPreset.objects.filter( - asset_bed__bed_id=self.get_asset_bed_obj().bed_id, name=value - ).exists(): - msg = "Name should be unique. Another preset related to this bed already uses the same name." - raise ValidationError(msg) - return value - - def create(self, validated_data): - validated_data["created_by"] = self.context["request"].user - validated_data["asset_bed"] = self.get_asset_bed_obj() - return super().create(validated_data) - - def update(self, instance, validated_data): - validated_data["updated_by"] = self.context["request"].user - return super().update(instance, validated_data) diff --git a/care/camera_plugin/api/viewsets/__init__.py b/care/camera_plugin/api/viewsets/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/care/camera_plugin/api/viewsets/camera_position_preset.py b/care/camera_plugin/api/viewsets/camera_position_preset.py deleted file mode 100644 index 5798e7ff94..0000000000 --- a/care/camera_plugin/api/viewsets/camera_position_preset.py +++ /dev/null @@ -1,65 +0,0 @@ -from django.shortcuts import get_object_or_404 -from rest_framework.exceptions import NotFound -from rest_framework.mixins import ListModelMixin -from rest_framework.permissions import IsAuthenticated -from rest_framework.viewsets import GenericViewSet, ModelViewSet - -from care.camera_plugin.api.serializers.camera_position_preset import ( - CameraPositionPresetSerializer, -) -from care.camera_plugin.models.camera_position_preset import CameraPositionPreset -from care.utils.queryset.asset_bed import ( - get_asset_bed_queryset, - get_asset_queryset, - get_bed_queryset, -) - - -class AssetBedCameraPositionPresetViewSet(ModelViewSet): - serializer_class = CameraPositionPresetSerializer - queryset = CameraPositionPreset.objects.all().select_related( - "asset_bed", "created_by", "updated_by" - ) - lookup_field = "external_id" - permission_classes = (IsAuthenticated,) - - def get_asset_bed_obj(self): - queryset = get_asset_bed_queryset(self.request.user).filter( - external_id=self.kwargs["assetbed_external_id"] - ) - return get_object_or_404(queryset) - - def get_queryset(self): - return super().get_queryset().filter(asset_bed=self.get_asset_bed_obj()) - - def get_serializer_context(self): - context = super().get_serializer_context() - context["asset_bed"] = self.get_asset_bed_obj() - return context - - -class CameraPresetPositionViewSet(GenericViewSet, ListModelMixin): - serializer_class = CameraPositionPresetSerializer - queryset = CameraPositionPreset.objects.all().select_related( - "asset_bed", "created_by", "updated_by" - ) - lookup_field = "external_id" - permission_classes = (IsAuthenticated,) - - def get_bed_obj(self, external_id: str): - queryset = get_bed_queryset(self.request.user).filter(external_id=external_id) - return get_object_or_404(queryset) - - def get_asset_obj(self, external_id: str): - queryset = get_asset_queryset(self.request.user).filter(external_id=external_id) - return get_object_or_404(queryset) - - def get_queryset(self): - queryset = super().get_queryset() - if asset_external_id := self.kwargs.get("asset_external_id"): - return queryset.filter( - asset_bed__asset=self.get_asset_obj(asset_external_id) - ) - if bed_external_id := self.kwargs.get("bed_external_id"): - return queryset.filter(asset_bed__bed=self.get_bed_obj(bed_external_id)) - raise NotFound diff --git a/care/camera_plugin/apps.py b/care/camera_plugin/apps.py deleted file mode 100644 index 2ff6ecef1a..0000000000 --- a/care/camera_plugin/apps.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.apps import AppConfig - -from care.camera_plugin.utils.onvif import OnvifAsset -from care.utils.assetintegration.asset_classes import AssetClasses - - -class CameraPluginConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "care.camera_plugin" - - def ready(self): - AssetClasses.register("ONVIF", OnvifAsset) diff --git a/care/camera_plugin/migrations/0001_initial.py b/care/camera_plugin/migrations/0001_initial.py deleted file mode 100644 index eb2e51cd72..0000000000 --- a/care/camera_plugin/migrations/0001_initial.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 5.1.1 on 2024-11-17 19:54 - -import care.utils.models.validators -import django.db.models.deletion -import uuid -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('facility', '0469_delete_camerapreset'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='CameraPositionPreset', - 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)), - ('name', models.CharField(max_length=255, null=True)), - ('position', models.JSONField(validators=[care.utils.models.validators.JSONFieldSchemaValidator({'$schema': 'http://json-schema.org/draft-07/schema#', 'additionalProperties': False, 'properties': {'x': {'type': 'number'}, 'y': {'type': 'number'}, 'zoom': {'type': 'number'}}, 'required': ['x', 'y', 'zoom'], 'type': 'object'})])), - ('is_migrated', models.BooleanField(default=False)), - ('asset_bed', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='camera_presets', to='facility.assetbed')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.AUTH_USER_MODEL)), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/care/camera_plugin/migrations/__init__.py b/care/camera_plugin/migrations/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/care/camera_plugin/models/__init__.py b/care/camera_plugin/models/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/care/camera_plugin/models/camera_asset.py b/care/camera_plugin/models/camera_asset.py deleted file mode 100644 index fea09f876b..0000000000 --- a/care/camera_plugin/models/camera_asset.py +++ /dev/null @@ -1,5 +0,0 @@ -from care.facility.models import Asset - - -class CameraAsset(Asset): - pass diff --git a/care/camera_plugin/models/camera_position_preset.py b/care/camera_plugin/models/camera_position_preset.py deleted file mode 100644 index ce94aa2c23..0000000000 --- a/care/camera_plugin/models/camera_position_preset.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.db import models - -from care.camera_plugin.models.json_schema.position import CAMERA_PRESET_POSITION_SCHEMA -from care.facility.models import AssetBed -from care.users.models import User -from care.utils.models.base import BaseModel -from care.utils.models.validators import JSONFieldSchemaValidator - - -class CameraPositionPreset(BaseModel): - name = models.CharField(max_length=255, null=True) - asset_bed = models.ForeignKey( - AssetBed, on_delete=models.PROTECT, related_name="camera_presets" - ) - position = models.JSONField( - validators=[JSONFieldSchemaValidator(CAMERA_PRESET_POSITION_SCHEMA)] - ) - created_by = models.ForeignKey( - User, null=True, blank=True, on_delete=models.PROTECT, related_name="+" - ) - updated_by = models.ForeignKey( - User, null=True, blank=True, on_delete=models.PROTECT, related_name="+" - ) - is_migrated = models.BooleanField(default=False) diff --git a/care/camera_plugin/models/json_schema/__init__.py b/care/camera_plugin/models/json_schema/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/care/camera_plugin/models/json_schema/position.py b/care/camera_plugin/models/json_schema/position.py deleted file mode 100644 index 7609b04e2b..0000000000 --- a/care/camera_plugin/models/json_schema/position.py +++ /dev/null @@ -1,11 +0,0 @@ -CAMERA_PRESET_POSITION_SCHEMA = { - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "x": {"type": "number"}, - "y": {"type": "number"}, - "zoom": {"type": "number"}, - }, - "required": ["x", "y", "zoom"], - "additionalProperties": False, -} diff --git a/care/camera_plugin/tests/__init__.py b/care/camera_plugin/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/care/camera_plugin/tests/test_camera_preset_apis.py b/care/camera_plugin/tests/test_camera_preset_apis.py deleted file mode 100644 index 4dbc9cd0b8..0000000000 --- a/care/camera_plugin/tests/test_camera_preset_apis.py +++ /dev/null @@ -1,139 +0,0 @@ -from rest_framework import status -from rest_framework.test import APITestCase - -from care.users.models import User -from care.utils.assetintegration.asset_classes import AssetClasses -from care.utils.tests.test_utils import TestUtils - - -class AssetBedCameraPresetViewSetTestCase(TestUtils, APITestCase): - @classmethod - def setUpTestData(cls): - cls.state = cls.create_state() - cls.district = cls.create_district(cls.state) - cls.local_body = cls.create_local_body(cls.district) - cls.super_user = cls.create_super_user("su", cls.district) - cls.facility = cls.create_facility(cls.super_user, cls.district, cls.local_body) - cls.user = cls.create_user( - User.TYPE_VALUE_MAP["DistrictAdmin"], - cls.district, - home_facility=cls.facility, - ) - cls.asset_location = cls.create_asset_location(cls.facility) - cls.asset1 = cls.create_asset( - cls.asset_location, asset_class=AssetClasses.ONVIF.name - ) - cls.asset2 = cls.create_asset( - cls.asset_location, asset_class=AssetClasses.ONVIF.name - ) - cls.bed = cls.create_bed(cls.facility, cls.asset_location) - cls.asset_bed1 = cls.create_assetbed(cls.bed, cls.asset1) - cls.asset_bed2 = cls.create_assetbed(cls.bed, cls.asset2) - - def get_base_url(self, asset_bed_id=None): - return f"/api/v1/assetbed/{asset_bed_id or self.asset_bed1.external_id}/camera_presets/" - - def test_create_camera_preset_without_position(self): - res = self.client.post( - self.get_base_url(), - { - "name": "Preset without position", - "position": {}, - }, - format="json", - ) - self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) - - def test_create_camera_preset_with_missing_required_keys_in_position(self): - res = self.client.post( - self.get_base_url(), - { - "name": "Preset with invalid position", - "position": {"key": "value"}, - }, - format="json", - ) - self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) - - def test_create_camera_preset_with_position_not_number(self): - res = self.client.post( - self.get_base_url(), - { - "name": "Preset with invalid position", - "position": { - "x": "not a number", - "y": 1, - "zoom": 1, - }, - }, - format="json", - ) - self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) - - def test_create_camera_preset_with_position_values_as_string(self): - res = self.client.post( - self.get_base_url(), - { - "name": "Preset with invalid position", - "position": { - "x": "1", - "y": "1", - "zoom": "1", - }, - }, - format="json", - ) - self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) - - def test_create_camera_preset_and_presence_in_various_preset_list_apis(self): - asset_bed = self.asset_bed1 - res = self.client.post( - self.get_base_url(asset_bed.external_id), - { - "name": "Preset with proper position", - "position": { - "x": 1.0, - "y": 1.0, - "zoom": 1.0, - }, - }, - format="json", - ) - self.assertEqual(res.status_code, status.HTTP_201_CREATED) - preset_external_id = res.data["id"] - - # Check if preset in asset-bed preset list - res = self.client.get(self.get_base_url(asset_bed.external_id)) - self.assertEqual(res.status_code, status.HTTP_200_OK) - self.assertContains(res, preset_external_id) - - # Check if preset in asset preset list - res = self.client.get( - f"/api/v1/asset/{asset_bed.asset.external_id}/camera_presets/" - ) - self.assertEqual(res.status_code, status.HTTP_200_OK) - self.assertContains(res, preset_external_id) - - # Check if preset in bed preset list - res = self.client.get( - f"/api/v1/bed/{asset_bed.bed.external_id}/camera_presets/" - ) - self.assertEqual(res.status_code, status.HTTP_200_OK) - self.assertContains(res, preset_external_id) - - def test_create_camera_preset_with_same_name_in_same_bed(self): - data = { - "name": "Duplicate Preset Name", - "position": { - "x": 1.0, - "y": 1.0, - "zoom": 1.0, - }, - } - self.client.post( - self.get_base_url(self.asset_bed1.external_id), data, format="json" - ) - res = self.client.post( - self.get_base_url(self.asset_bed2.external_id), data, format="json" - ) - self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/care/camera_plugin/tests/test_onvif_validations.py b/care/camera_plugin/tests/test_onvif_validations.py deleted file mode 100644 index ef1109a8c6..0000000000 --- a/care/camera_plugin/tests/test_onvif_validations.py +++ /dev/null @@ -1,52 +0,0 @@ -from care.camera_plugin.utils.onvif import OnvifAsset - - -def test_meta_validations_for_onvif_asset(self): - valid_meta = { - "local_ip_address": "192.168.0.1", - "camera_access_key": "username:password:access_key", - "middleware_hostname": "middleware.local", - "insecure_connection": True, - } - onvif_asset = OnvifAsset(valid_meta) - self.assertEqual(onvif_asset.middleware_hostname, "middleware.local") - self.assertEqual(onvif_asset.host, "192.168.0.1") - self.assertEqual(onvif_asset.username, "username") - self.assertEqual(onvif_asset.password, "password") - self.assertEqual(onvif_asset.access_key, "access_key") - self.assertTrue(onvif_asset.insecure_connection) - - invalid_meta_cases = [ - # Invalid format for camera_access_key - { - "id": "123", - "local_ip_address": "192.168.0.1", - "middleware_hostname": "middleware.local", - "camera_access_key": "invalid_format", - }, - # Missing username/password in camera_access_key - { - "local_ip_address": "192.168.0.1", - "middleware_hostname": "middleware.local", - "camera_access_key": "invalid_format", - }, - # Missing middleware_hostname - { - "local_ip_address": "192.168.0.1", - "camera_access_key": "username:password:access_key", - }, - # Missing local_ip_address - { - "middleware_hostname": "middleware.local", - "camera_access_key": "username:password:access_key", - }, - # Invalid value for insecure_connection - { - "local_ip_address": "192.168.0.1", - "camera_access_key": "username:password:access_key", - "middleware_hostname": "middleware.local", - "insecure_connection": "invalid_value", - }, - ] - for meta in invalid_meta_cases: - self.validate_invalid_meta(OnvifAsset, meta) diff --git a/care/camera_plugin/urls.py b/care/camera_plugin/urls.py deleted file mode 100644 index 29ea7959f8..0000000000 --- a/care/camera_plugin/urls.py +++ /dev/null @@ -1,33 +0,0 @@ -from django.urls import include, path -from rest_framework.routers import DefaultRouter - -from care.camera_plugin.api.viewsets.camera_position_preset import ( - AssetBedCameraPositionPresetViewSet, - CameraPresetPositionViewSet, -) -from config.api_router import ( - asset_nested_router, - assetbed_nested_router, - bed_nested_router, -) - -camera_router = DefaultRouter() - -asset_nested_router.register( - r"camera_presets", CameraPresetPositionViewSet, basename="asset-camera-presets" -) -bed_nested_router.register( - r"camera_presets", CameraPresetPositionViewSet, basename="bed-camera-presets" -) -assetbed_nested_router.register( - r"camera_presets", - AssetBedCameraPositionPresetViewSet, - basename="assetbed-camera-presets", -) - -# Include in urlpatterns -urlpatterns = [ - path("", include(asset_nested_router.urls)), - path("", include(bed_nested_router.urls)), - path("", include(assetbed_nested_router.urls)), -] diff --git a/care/camera_plugin/utils/__init__.py b/care/camera_plugin/utils/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/care/camera_plugin/utils/onvif.py b/care/camera_plugin/utils/onvif.py deleted file mode 100644 index ecc13c17b5..0000000000 --- a/care/camera_plugin/utils/onvif.py +++ /dev/null @@ -1,74 +0,0 @@ -import enum - -from rest_framework.exceptions import ValidationError - -from care.utils.assetintegration.base import ActionParams, BaseAssetIntegration - - -class OnvifAsset(BaseAssetIntegration): - _name = "onvif" - - class OnvifActions(enum.Enum): - GET_CAMERA_STATUS = "get_status" - GET_PRESETS = "get_presets" - GOTO_PRESET = "goto_preset" - ABSOLUTE_MOVE = "absolute_move" - RELATIVE_MOVE = "relative_move" - GET_STREAM_TOKEN = "get_stream_token" - - def __init__(self, meta): - try: - super().__init__(meta) - self.username = self.meta["camera_access_key"].split(":")[0] - self.password = self.meta["camera_access_key"].split(":")[1] - self.access_key = self.meta["camera_access_key"].split(":")[2] - except KeyError as e: - raise ValidationError( - {key: f"{key} not found in asset metadata" for key in e.args} - ) from e - - def handle_action(self, **kwargs: ActionParams): - action_type = kwargs["type"] - action_data = kwargs.get("data", {}) - timeout = kwargs.get("timeout") - - request_body = { - "hostname": self.host, - "port": 80, - "username": self.username, - "password": self.password, - "accessKey": self.access_key, - **action_data, - } - - if action_type == self.OnvifActions.GET_CAMERA_STATUS.value: - return self.api_get(self.get_url("status"), request_body, timeout) - - if action_type == self.OnvifActions.GET_PRESETS.value: - return self.api_get(self.get_url("presets"), request_body, timeout) - - if action_type == self.OnvifActions.GOTO_PRESET.value: - return self.api_post(self.get_url("gotoPreset"), request_body, timeout) - - if action_type == self.OnvifActions.ABSOLUTE_MOVE.value: - return self.api_post(self.get_url("absoluteMove"), request_body, timeout) - - if action_type == self.OnvifActions.RELATIVE_MOVE.value: - return self.api_post(self.get_url("relativeMove"), request_body, timeout) - - if action_type == self.OnvifActions.GET_STREAM_TOKEN.value: - return self.api_post( - self.get_url("api/stream/getToken/videoFeed"), - { - "stream_id": self.access_key, - }, - timeout, - ) - - raise ValidationError({"action": "invalid action type"}) - - @classmethod - def get_action_choices(cls): - choices = [] - choices += [(e.value, e.name) for e in cls.OnvifActions] - return choices diff --git a/care/camera_plugin/views.py b/care/camera_plugin/views.py deleted file mode 100644 index 60f00ef0ef..0000000000 --- a/care/camera_plugin/views.py +++ /dev/null @@ -1 +0,0 @@ -# Create your views here. diff --git a/care/facility/api/serializers/asset.py b/care/facility/api/serializers/asset.py index 99fd14264a..a861d7b2f4 100644 --- a/care/facility/api/serializers/asset.py +++ b/care/facility/api/serializers/asset.py @@ -141,6 +141,7 @@ class AssetSerializer(ModelSerializer): id = UUIDField(source="external_id", read_only=True) status = ChoiceField(choices=StatusChoices, read_only=True) asset_type = ChoiceField(choices=AssetTypeChoices) + asset_class = serializers.ChoiceField(choices=Asset.get_asset_class_choices()) location_object = AssetLocationSerializer(source="current_location", read_only=True) location = UUIDField(write_only=True, required=True) last_service = AssetServiceSerializer(read_only=True) @@ -217,9 +218,6 @@ def validate(self, attrs): or current_location.facility.middleware_address ) if ip_address and middleware_hostname: - valid_asset_classes = [AssetClasses.HL7MONITOR.name] - if hasattr(AssetClasses, "ONVIF"): - valid_asset_classes.append(AssetClasses.ONVIF.name) asset_using_ip = ( Asset.objects.annotate( resolved_middleware_hostname=Coalesce( @@ -232,7 +230,11 @@ def validate(self, attrs): ) ) .filter( - asset_class__in=valid_asset_classes, + asset_class__in=[ + member.name + for member in AssetClasses.all() + if member.value.can_be_linked_to_asset_bed # need better naming + ], current_location__facility=current_location.facility_id, resolved_middleware_hostname=middleware_hostname, meta__local_ip_address=ip_address, diff --git a/care/facility/api/serializers/bed.py b/care/facility/api/serializers/bed.py index 54f024f0cf..6ffc8808e8 100644 --- a/care/facility/api/serializers/bed.py +++ b/care/facility/api/serializers/bed.py @@ -122,12 +122,15 @@ def validate(self, attrs): {"non_field_errors": "Asset is already linked to bed"} ) - in_valid_asset_classes = [AssetClasses.HL7MONITOR.name] - if hasattr(AssetClasses, "ONVIF"): - in_valid_asset_classes.append(AssetClasses.ONVIF.name) - - if asset.asset_class not in in_valid_asset_classes: - raise ValidationError({"asset": "Asset is not a monitor or camera"}) + valid_asset_classes = [ + member.name + for member in AssetClasses.all() + if member.value.can_be_linked_to_asset_bed() + ] + if asset.asset_class not in valid_asset_classes: + raise ValidationError( + {"asset": "This Asset Class cannot be linked to a bed"} + ) attrs["asset"] = asset attrs["bed"] = bed if asset.current_location.facility.id != bed.facility.id: @@ -317,9 +320,11 @@ def create(self, validated_data) -> ConsultationBed: if assets_ids := validated_data.pop("assets", None): # we check assets in use here as they might have been in use in # the previous bed - exclude_asset_classes = [AssetClasses.HL7MONITOR.name] - if hasattr(AssetClasses, "ONVIF"): - exclude_asset_classes.append(AssetClasses.ONVIF.name) + exclude_asset_classes = [ + member.name + for member in AssetClasses.all() + if not member.value.can_be_linked_to_consultation_bed() + ] assets = ( Asset.objects.annotate( is_in_use=Exists( diff --git a/care/facility/api/viewsets/asset.py b/care/facility/api/viewsets/asset.py index bd24514e55..5033f7e50c 100644 --- a/care/facility/api/viewsets/asset.py +++ b/care/facility/api/viewsets/asset.py @@ -201,13 +201,13 @@ def filter_in_use_by_consultation(self, queryset, _, value): def filter_is_permanent(self, queryset, _, value): if value not in EMPTY_VALUES: - valid_asset_classes = [AssetClasses.HL7MONITOR.name] - if hasattr(AssetClasses, "ONVIF"): - valid_asset_classes.append(AssetClasses.ONVIF.name) + movable_assets = [ + member.name for member in AssetClasses.all() if not member.is_movable + ] if value: - queryset = queryset.filter(asset_class__in=valid_asset_classes) + queryset = queryset.filter(asset_class__in=movable_assets) else: - queryset = queryset.exclude(asset_class__in=valid_asset_classes) + queryset = queryset.exclude(asset_class__in=movable_assets) return queryset.distinct() @@ -467,16 +467,15 @@ def list(self, request, *args, **kwargs): {"middleware_hostname": "Invalid middleware hostname"}, status=status.HTTP_400_BAD_REQUEST, ) - - valid_asset_classes = [AssetClasses.HL7MONITOR.name] - if hasattr(AssetClasses, "ONVIF"): - valid_asset_classes.append(AssetClasses.ONVIF.name) - queryset = ( self.get_queryset() .filter( current_location__facility=self.request.user.facility, - asset_class__in=valid_asset_classes, + asset_class__in=[ + member.name + for member in AssetClasses.all() + if member.value.can_be_linked_to_asset_bed() # better naming required + ], ) .annotate( resolved_middleware_hostname=Coalesce( diff --git a/care/facility/migrations/0468_alter_asset_asset_class.py b/care/facility/migrations/0468_alter_asset_asset_class.py deleted file mode 100644 index b2d7c6e0cd..0000000000 --- a/care/facility/migrations/0468_alter_asset_asset_class.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.1 on 2024-11-17 17:14 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('facility', '0467_alter_hospitaldoctors_area'), - ] - - operations = [ - migrations.AlterField( - model_name='asset', - name='asset_class', - field=models.CharField(blank=True, choices=[('HL7MONITOR', 'hl7monitor'), ('VENTILATOR', 'ventilator')], default=None, max_length=20, null=True), - ), - ] diff --git a/care/facility/migrations/0469_delete_camerapreset.py b/care/facility/migrations/0469_delete_camerapreset.py deleted file mode 100644 index 390ce65ffc..0000000000 --- a/care/facility/migrations/0469_delete_camerapreset.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 5.1.1 on 2024-11-17 19:54 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('facility', '0468_alter_asset_asset_class'), - ] - - operations = [ - migrations.DeleteModel( - name='CameraPreset', - ), - ] diff --git a/care/facility/models/asset.py b/care/facility/models/asset.py index bf2ab5e317..ff5fedd031 100644 --- a/care/facility/models/asset.py +++ b/care/facility/models/asset.py @@ -7,14 +7,17 @@ from care.facility.models import reverse_choices from care.facility.models.facility import Facility -from care.facility.models.json_schema.asset import ASSET_META +from care.facility.models.json_schema.asset import get_dynamic_asset_meta from care.facility.models.mixins.permissions.facility import ( FacilityRelatedPermissionMixin, ) from care.users.models import User from care.utils.assetintegration.asset_classes import AssetClasses from care.utils.models.base import BaseModel -from care.utils.models.validators import JSONFieldSchemaValidator, PhoneNumberValidator +from care.utils.models.validators import ( + DynamicJSONFieldSchemaValidator, + PhoneNumberValidator, +) def get_random_asset_id(): @@ -62,8 +65,6 @@ class AssetType(enum.Enum): AssetTypeChoices = [(e.value, e.name) for e in AssetType] -AssetClassChoices = [(e.name, e.value._name) for e in AssetClasses] # noqa: SLF001 - class Status(enum.Enum): ACTIVE = 50 @@ -83,7 +84,10 @@ class Asset(BaseModel): choices=AssetTypeChoices, default=AssetType.INTERNAL.value ) asset_class = models.CharField( - choices=AssetClassChoices, default=None, null=True, blank=True, max_length=20 + max_length=20, + blank=True, + null=True, + default=None, ) status = models.IntegerField(choices=StatusChoices, default=Status.ACTIVE.value) current_location = models.ForeignKey( @@ -94,7 +98,9 @@ class Asset(BaseModel): serial_number = models.CharField(max_length=1024, blank=True, null=True) warranty_details = models.TextField(null=True, blank=True, default="") # Deprecated meta = JSONField( - default=dict, blank=True, validators=[JSONFieldSchemaValidator(ASSET_META)] + default=dict, + blank=True, + validators=[DynamicJSONFieldSchemaValidator(get_dynamic_asset_meta)], ) # Vendor Details vendor_name = models.CharField(max_length=1024, blank=True, null=True) @@ -136,7 +142,6 @@ class Asset(BaseModel): "last_service__serviced_on": "Last Service Date", "last_service__note": "Notes", "meta__local_ip_address": "Config - IP Address", - "meta__camera_access_key": "Config: Camera Access Key", } CSV_MAKE_PRETTY = { @@ -199,6 +204,23 @@ def has_read_permission(request): def has_object_read_permission(self, request): return self.has_read_permission(request) + @staticmethod + def get_asset_class_choices(): + """ + Dynamically fetch choices from the registered AssetClasses. + """ + return [e.name for e in AssetClasses.all()] + + def save(self, *args, **kwargs): + """ + Validate the asset_class field against dynamically fetched choices. + """ + valid_choices = self.get_asset_class_choices() + if self.asset_class and self.asset_class not in valid_choices: + error = f"'{self.asset_class}' is not a valid asset class." + raise ValueError(error) + super().save(*args, **kwargs) + def __str__(self): return self.name diff --git a/care/facility/models/bed.py b/care/facility/models/bed.py index 992f36ac74..7b99fa0290 100644 --- a/care/facility/models/bed.py +++ b/care/facility/models/bed.py @@ -80,10 +80,6 @@ class Meta: def __str__(self): return f"{self.asset.name} - {self.bed.name}" - def delete(self, *args): - self.camera_presets.update(deleted=True) - return super().delete(*args) - class ConsultationBed(BaseModel): consultation = models.ForeignKey( diff --git a/care/facility/models/camera_preset.py b/care/facility/models/camera_preset.py new file mode 100644 index 0000000000..b1128f8817 --- /dev/null +++ b/care/facility/models/camera_preset.py @@ -0,0 +1,33 @@ +from django.db import models + +from care.utils.models.base import BaseModel +from care.utils.models.validators import JSONFieldSchemaValidator + +CAMERA_PRESET_POSITION_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + "zoom": {"type": "number"}, + }, + "required": ["x", "y", "zoom"], + "additionalProperties": False, +} + + +class CameraPreset(BaseModel): + name = models.CharField(max_length=255, null=True) + asset_bed = models.ForeignKey( + "facility.AssetBed", on_delete=models.PROTECT, related_name="camera_presets" + ) + position = models.JSONField( + validators=[JSONFieldSchemaValidator(CAMERA_PRESET_POSITION_SCHEMA)] + ) + created_by = models.ForeignKey( + "users.User", null=True, blank=True, on_delete=models.PROTECT, related_name="+" + ) + updated_by = models.ForeignKey( + "users.User", null=True, blank=True, on_delete=models.PROTECT, related_name="+" + ) + is_migrated = models.BooleanField(default=False) diff --git a/care/facility/models/json_schema/asset.py b/care/facility/models/json_schema/asset.py index 0adf6048de..224a3b5381 100644 --- a/care/facility/models/json_schema/asset.py +++ b/care/facility/models/json_schema/asset.py @@ -1,54 +1,69 @@ -HL7_META = { - "type": "object", - "required": ["local_ip_address"], - "properties": { - "local_ip_address": {"type": "string"}, - "middleware_hostname": {"type": "string"}, - "asset_type": {"type": "string"}, - "insecure_connection": {"type": "boolean"}, - }, - "additionalProperties": False, -} +class AssetMetaRegistry: + _registry = {} -VENTILATOR_META = { - "type": "object", - "required": ["local_ip_address"], - "properties": { - "local_ip_address": {"type": "string"}, - "middleware_hostname": {"type": "string"}, - "asset_type": {"type": "string"}, - "insecure_connection": {"type": "boolean"}, - }, - "additionalProperties": False, -} + @classmethod + def register_meta(cls, name, schema): + """ + Register a schema for a specific asset class. + """ + cls._registry[name] = schema -ONVIF_META = { - "type": "object", - "required": ["local_ip_address", "camera_access_key"], - "properties": { - "local_ip_address": {"type": "string"}, - "middleware_hostname": {"type": "string"}, - "camera_access_key": {"type": "string"}, - "camera_type": {"type": "string"}, - "asset_type": {"type": "string"}, - "insecure_connection": {"type": "boolean"}, - }, - "additionalProperties": False, -} + @classmethod + def get_meta(cls, name): + """ + Retrieve a schema by name. + """ + return cls._registry.get(name) -EMPTY_META = {"type": "object", "additionalProperties": False} + @classmethod + def all_metas(cls): + """ + Retrieve all registered schemas. + """ + return cls._registry -ASSET_META = { - "$schema": "http://json-schema.org/draft-07/schema#", - "anyOf": [ - {"$ref": "#/definitions/onvif"}, - {"$ref": "#/definitions/hl7monitor"}, - {"$ref": "#/definitions/empty"}, - ], - "definitions": { - "onvif": ONVIF_META, - "hl7monitor": HL7_META, - "ventilator": VENTILATOR_META, - "empty": EMPTY_META, - }, -} + +def get_dynamic_asset_meta(): + """ + Dynamically construct the ASSET_META schema to include registered plugin schemas. + """ + return { + "$schema": "http://json-schema.org/draft-07/schema#", + "anyOf": [ + {"$ref": "#/definitions/hl7monitor"}, + {"$ref": "#/definitions/ventilator"}, + {"$ref": "#/definitions/empty"}, + # Include all registered plugin schemas + *[ + {"$ref": f"#/definitions/{key}"} + for key in AssetMetaRegistry.all_metas() + ], + ], + "definitions": { + "hl7monitor": { + "type": "object", + "required": ["local_ip_address"], + "properties": { + "local_ip_address": {"type": "string"}, + "middleware_hostname": {"type": "string"}, + "asset_type": {"type": "string"}, + "insecure_connection": {"type": "boolean"}, + }, + "additionalProperties": False, + }, + "ventilator": { + "type": "object", + "required": ["local_ip_address"], + "properties": { + "local_ip_address": {"type": "string"}, + "middleware_hostname": {"type": "string"}, + "asset_type": {"type": "string"}, + "insecure_connection": {"type": "boolean"}, + }, + "additionalProperties": False, + }, + # Including plugin-specific schemas + **AssetMetaRegistry.all_metas(), + "empty": {"type": "object", "additionalProperties": False}, + }, + } diff --git a/care/facility/tasks/asset_monitor.py b/care/facility/tasks/asset_monitor.py index 9c8701618c..72c08a76fc 100644 --- a/care/facility/tasks/asset_monitor.py +++ b/care/facility/tasks/asset_monitor.py @@ -17,7 +17,7 @@ @shared_task -def check_asset_status(): # noqa: PLR0912 +def check_asset_status(): logger.info("Checking Asset Status: %s", timezone.now()) assets = ( @@ -70,28 +70,8 @@ def check_asset_status(): # noqa: PLR0912 } ) # Fetching the status of the device - if asset.asset_class == "ONVIF": - try: - # TODO: Remove this block after all assets are migrated to the new middleware - asset_config = asset.meta["camera_access_key"].split(":") - assets_config = [ - { - "hostname": asset.meta.get("local_ip_address"), - "port": 80, - "username": asset_config[0], - "password": asset_config[1], - } - ] - - result = asset_class.api_post( - asset_class.get_url("cameras/status"), data=assets_config - ) - except Exception: - result = asset_class.api_get( - asset_class.get_url("cameras/status") - ) - else: - result = asset_class.api_get(asset_class.get_url("devices/status")) + result = asset_class.get_asset_status() + except Exception as e: logger.warning("Middleware %s is down: %s", resolved_middleware, e) diff --git a/care/utils/assetintegration/base.py b/care/utils/assetintegration/base.py index 08da4fcfa2..a7d23d3fa3 100644 --- a/care/utils/assetintegration/base.py +++ b/care/utils/assetintegration/base.py @@ -81,3 +81,22 @@ def api_get(self, url, data=None, timeout=None): @classmethod def get_action_choices(cls): pass + + @staticmethod + def can_be_linked_to_consultation_bed(): + error = "'can_be_linked_to_consultation_bed()' method is not implemented" + raise NotImplementedError(error) + + @staticmethod + def can_be_linked_to_asset_bed(): + error = "'can_be_linked_to_asset_bed()' method is not implemented" + raise NotImplementedError(error) + + @staticmethod + def is_movable(): + error = "'is_movable()' method is not implemented" + raise (NotImplementedError(error)) + + def get_asset_status(self, **kwargs): + error = "Subclasses must implement the 'get_asset_status' method." + raise NotImplementedError(error) diff --git a/care/utils/assetintegration/hl7monitor.py b/care/utils/assetintegration/hl7monitor.py index 520178d69c..c79388aa40 100644 --- a/care/utils/assetintegration/hl7monitor.py +++ b/care/utils/assetintegration/hl7monitor.py @@ -45,3 +45,18 @@ def get_action_choices(cls): choices = [] choices += [(e.value, e.name) for e in cls.HL7MonitorActions] return choices + + @staticmethod + def is_movable(): + return False + + @staticmethod + def can_be_linked_to_asset_bed(): + return True + + @staticmethod + def can_be_linked_to_consultation_bed(): + return False + + def get_asset_status(self): + return self.api_get(self.get_url("devices/status")) diff --git a/care/utils/assetintegration/ventilator.py b/care/utils/assetintegration/ventilator.py index 5a5da00db7..ed5930c25a 100644 --- a/care/utils/assetintegration/ventilator.py +++ b/care/utils/assetintegration/ventilator.py @@ -45,3 +45,18 @@ def get_action_choices(cls): choices = [] choices += [(e.value, e.name) for e in cls.VentilatorActions] return choices + + @staticmethod + def is_movable(): + return True + + @staticmethod + def can_be_linked_to_consultation_bed(): + return True + + @staticmethod + def can_be_linked_to_asset_bed(): + return False + + def get_asset_status(self): + return self.api_get(self.get_url("devices/status")) diff --git a/care/utils/models/validators.py b/care/utils/models/validators.py index 03ff80fc51..c7ac669f6f 100644 --- a/care/utils/models/validators.py +++ b/care/utils/models/validators.py @@ -1,5 +1,5 @@ import re -from collections.abc import Iterable +from collections.abc import Callable, Iterable from fractions import Fraction import jsonschema @@ -51,6 +51,47 @@ def _extract_errors( return None +@deconstructible +class DynamicJSONFieldSchemaValidator(JSONFieldSchemaValidator): + """ + A dynamic JSONField schema validator that generates the schema at runtime. + Inherits from JSONFieldSchemaValidator for reusability. + """ + + def __init__(self, schema_generator: Callable[[], dict]): + """ + Initialize with a schema generator function. + + Args: + schema_generator (Callable): A callable that dynamically returns a JSON schema. + """ + if not callable(schema_generator): + error = "schema_generator must be a callable that returns a schema." + raise TypeError(error) + self.schema_generator = schema_generator + super().__init__( + schema={} + ) # Initialize with an empty schema, updated dynamically in __call__ + + def __call__(self, value): + """ + Override the parent class's __call__ method to dynamically generate the schema. + + Args: + value: The JSON value to validate. + + Raises: + ValidationError: If the value does not match the dynamically generated schema. + """ + self.schema = self.schema_generator() + + if not isinstance(self.schema, dict): + error = "Generated schema must be a dictionary." + raise ValueError(error) + + super().__call__(value) + + @deconstructible class UsernameValidator(validators.RegexValidator): regex = r"^(?!.*[._-]{2})[a-z0-9](?:[a-z0-9._-]{2,14}[a-z0-9])$" diff --git a/config/api_router.py b/config/api_router.py index ce5f815156..1c19d46925 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -321,5 +321,4 @@ path("", include(consultation_nested_router.urls)), path("", include(resource_nested_router.urls)), path("", include(shifting_nested_router.urls)), - path("", include("care.camera_plugin.urls")), ] diff --git a/config/settings/base.py b/config/settings/base.py index 5626901840..e6a6f7f800 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -128,7 +128,7 @@ "care.facility", "care.users", "care.audit_log", - "care.camera_plugin", + # "camera_plugin", ] PLUGIN_APPS = manager.get_apps() diff --git a/plug_config.py b/plug_config.py index 27be9de162..f4a3e0d560 100644 --- a/plug_config.py +++ b/plug_config.py @@ -15,6 +15,10 @@ configs={}, ) -plugs = [hcx_plugin, abdm_plugin] +camera_plugin = Plug( + name="camera", package_name="/app/camera_plugin", version="", configs={} +) + +plugs = [hcx_plugin, abdm_plugin, camera_plugin] manager = PlugManager(plugs) From d3c5b14deafd4747684acd0620367f459779a1ff Mon Sep 17 00:00:00 2001 From: prafful Date: Thu, 21 Nov 2024 01:41:36 +0530 Subject: [PATCH 06/13] removed camera related code --- care/camera_plugin/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 care/camera_plugin/__init__.py diff --git a/care/camera_plugin/__init__.py b/care/camera_plugin/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 From b29def8a2b5157e2eaf2e4742c68b3f2f7ce1124 Mon Sep 17 00:00:00 2001 From: prafful Date: Thu, 21 Nov 2024 02:27:58 +0530 Subject: [PATCH 07/13] asset model updates for making asset data dynamic --- care/facility/api/serializers/asset.py | 4 ++- ...lter_asset_asset_class_alter_asset_meta.py | 25 +++++++++++++++++++ care/facility/tests/test_asset_api.py | 1 + plug_config.py | 6 +---- 4 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 care/facility/migrations/0468_alter_asset_asset_class_alter_asset_meta.py diff --git a/care/facility/api/serializers/asset.py b/care/facility/api/serializers/asset.py index a861d7b2f4..149fc5b413 100644 --- a/care/facility/api/serializers/asset.py +++ b/care/facility/api/serializers/asset.py @@ -141,7 +141,9 @@ class AssetSerializer(ModelSerializer): id = UUIDField(source="external_id", read_only=True) status = ChoiceField(choices=StatusChoices, read_only=True) asset_type = ChoiceField(choices=AssetTypeChoices) - asset_class = serializers.ChoiceField(choices=Asset.get_asset_class_choices()) + asset_class = serializers.ChoiceField( + choices=Asset.get_asset_class_choices(), required=False + ) location_object = AssetLocationSerializer(source="current_location", read_only=True) location = UUIDField(write_only=True, required=True) last_service = AssetServiceSerializer(read_only=True) diff --git a/care/facility/migrations/0468_alter_asset_asset_class_alter_asset_meta.py b/care/facility/migrations/0468_alter_asset_asset_class_alter_asset_meta.py new file mode 100644 index 0000000000..3d62f5dc43 --- /dev/null +++ b/care/facility/migrations/0468_alter_asset_asset_class_alter_asset_meta.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.2 on 2024-11-20 20:53 + +import care.facility.models.json_schema.asset +import care.utils.models.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('facility', '0467_alter_hospitaldoctors_area'), + ] + + operations = [ + migrations.AlterField( + model_name='asset', + name='asset_class', + field=models.CharField(blank=True, default=None, max_length=20, null=True), + ), + migrations.AlterField( + model_name='asset', + name='meta', + field=models.JSONField(blank=True, default=dict, validators=[care.utils.models.validators.DynamicJSONFieldSchemaValidator(care.facility.models.json_schema.asset.get_dynamic_asset_meta)]), + ), + ] diff --git a/care/facility/tests/test_asset_api.py b/care/facility/tests/test_asset_api.py index 4045e64634..ed11a9f909 100644 --- a/care/facility/tests/test_asset_api.py +++ b/care/facility/tests/test_asset_api.py @@ -130,6 +130,7 @@ def test_create_asset(self): "name": "Test Asset", "asset_type": 50, "location": self.asset_location.external_id, + "asset_class": "VENTILATOR", } response = self.client.post("/api/v1/asset/", sample_data) self.assertEqual(response.status_code, status.HTTP_201_CREATED) diff --git a/plug_config.py b/plug_config.py index f4a3e0d560..27be9de162 100644 --- a/plug_config.py +++ b/plug_config.py @@ -15,10 +15,6 @@ configs={}, ) -camera_plugin = Plug( - name="camera", package_name="/app/camera_plugin", version="", configs={} -) - -plugs = [hcx_plugin, abdm_plugin, camera_plugin] +plugs = [hcx_plugin, abdm_plugin] manager = PlugManager(plugs) From 75d42046b235a6a1b8d8da0dd322bd7fe4f1ebbc Mon Sep 17 00:00:00 2001 From: prafful Date: Fri, 22 Nov 2024 21:53:44 +0530 Subject: [PATCH 08/13] update plug configs --- plug_config.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/plug_config.py b/plug_config.py index 27be9de162..257b6b07f8 100644 --- a/plug_config.py +++ b/plug_config.py @@ -15,6 +15,13 @@ configs={}, ) -plugs = [hcx_plugin, abdm_plugin] +camera_plugin = Plug( + name="camera", + package_name="git+https://github.com/ohcnetwork/care_camera_asset.git", + version="@main", + configs={}, +) + +plugs = [hcx_plugin, abdm_plugin, camera_plugin] manager = PlugManager(plugs) From e7c774e022ce855371bce8f46071b1176cef4565 Mon Sep 17 00:00:00 2001 From: prafful Date: Sat, 23 Nov 2024 04:09:59 +0530 Subject: [PATCH 09/13] resolve code rabbit comments --- care/facility/api/serializers/asset.py | 9 +++++---- care/facility/api/viewsets/asset.py | 4 ++-- care/facility/tests/test_asset_api.py | 10 ++++++++++ care/utils/models/validators.py | 6 +++++- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/care/facility/api/serializers/asset.py b/care/facility/api/serializers/asset.py index 149fc5b413..47905b8216 100644 --- a/care/facility/api/serializers/asset.py +++ b/care/facility/api/serializers/asset.py @@ -411,11 +411,12 @@ class Meta: class AssetActionSerializer(Serializer): - choices = [] - for asset_class in AssetClasses.all(): - choices.append(asset_class.value.get_action_choices()) type = ChoiceField( - choices=choices, + choices=[ + choice + for asset_class in AssetClasses.all() + for choice in asset_class.value.get_action_choices() + ], required=True, ) data = JSONField(required=False) diff --git a/care/facility/api/viewsets/asset.py b/care/facility/api/viewsets/asset.py index 571ab106b6..1e98d6fcbb 100644 --- a/care/facility/api/viewsets/asset.py +++ b/care/facility/api/viewsets/asset.py @@ -392,9 +392,9 @@ def operate_assets(self, request, *args, **kwargs): ) available_asset_classes = [asset.name for asset in AssetClasses.all()] if asset.asset_class not in available_asset_classes: - Response( + return Response( { - "error": f"Install {asset.asset_class}'s plugins to use it", + "error": f"Cannot operate asset: Plugin for {asset.asset_class} is not installed", }, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/care/facility/tests/test_asset_api.py b/care/facility/tests/test_asset_api.py index ed11a9f909..28f0a32761 100644 --- a/care/facility/tests/test_asset_api.py +++ b/care/facility/tests/test_asset_api.py @@ -135,6 +135,16 @@ def test_create_asset(self): response = self.client.post("/api/v1/asset/", sample_data) self.assertEqual(response.status_code, status.HTTP_201_CREATED) + def test_create_asset_with_invalid_asset_class(self): + sample_data = { + "name": "Test Asset", + "asset_type": 50, + "location": self.asset_location.external_id, + "asset_class": "INVALID_CLASS", + } + response = self.client.post("/api/v1/asset/", sample_data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + def test_create_asset_read_only(self): sample_data = { "name": "Test Asset", diff --git a/care/utils/models/validators.py b/care/utils/models/validators.py index c7ac669f6f..5b878fa7f2 100644 --- a/care/utils/models/validators.py +++ b/care/utils/models/validators.py @@ -83,7 +83,11 @@ def __call__(self, value): Raises: ValidationError: If the value does not match the dynamically generated schema. """ - self.schema = self.schema_generator() + try: + self.schema = self.schema_generator() + except Exception as e: + error = f"Schema generator failed: {e!s}" + raise ValueError(error) from e if not isinstance(self.schema, dict): error = "Generated schema must be a dictionary." From 6d4bf278443bd541d3f7213575cc3157b744ad98 Mon Sep 17 00:00:00 2001 From: rithviknishad Date: Tue, 26 Nov 2024 15:03:55 +0530 Subject: [PATCH 10/13] revert removing reference to old camera preset model --- care/facility/models/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/care/facility/models/__init__.py b/care/facility/models/__init__.py index df41476768..d6d63cacca 100644 --- a/care/facility/models/__init__.py +++ b/care/facility/models/__init__.py @@ -4,6 +4,7 @@ from .ambulance import * # noqa from .asset import * # noqa from .bed import * # noqa +from .camera_preset import * # noqa from .daily_round import * # noqa from .encounter_symptom import * # noqa from .events import * # noqa From 19c2b74168f7f7baa49b9b3cdcf7ace33cb553dd Mon Sep 17 00:00:00 2001 From: prafful Date: Fri, 29 Nov 2024 17:15:42 +0530 Subject: [PATCH 11/13] fixed classname field in serializer to be dynamic --- care/facility/api/serializers/asset.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/care/facility/api/serializers/asset.py b/care/facility/api/serializers/asset.py index 47905b8216..3c171b988a 100644 --- a/care/facility/api/serializers/asset.py +++ b/care/facility/api/serializers/asset.py @@ -141,9 +141,7 @@ class AssetSerializer(ModelSerializer): id = UUIDField(source="external_id", read_only=True) status = ChoiceField(choices=StatusChoices, read_only=True) asset_type = ChoiceField(choices=AssetTypeChoices) - asset_class = serializers.ChoiceField( - choices=Asset.get_asset_class_choices(), required=False - ) + asset_class = serializers.CharField(required=False) location_object = AssetLocationSerializer(source="current_location", read_only=True) location = UUIDField(write_only=True, required=True) last_service = AssetServiceSerializer(read_only=True) @@ -166,6 +164,13 @@ def validate_qr_code_id(self, value): return value def validate(self, attrs): + if ( + "asset_class" in attrs + and attrs["asset_class"] not in Asset.get_asset_class_choices() + ): + error = f"{attrs['asset_class']} is not a valid asset class" + raise ValidationError(error) + user = self.context["request"].user if "location" in attrs: location = get_object_or_404( From 8d4581f351c04fbed1d17c942240df94617bb642 Mon Sep 17 00:00:00 2001 From: prafful Date: Sat, 30 Nov 2024 02:26:21 +0530 Subject: [PATCH 12/13] fixed member function addition --- care/facility/api/serializers/asset.py | 2 +- care/facility/api/viewsets/asset.py | 4 +++- config/settings/base.py | 1 - 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/care/facility/api/serializers/asset.py b/care/facility/api/serializers/asset.py index 3c171b988a..a4007607fe 100644 --- a/care/facility/api/serializers/asset.py +++ b/care/facility/api/serializers/asset.py @@ -240,7 +240,7 @@ def validate(self, attrs): asset_class__in=[ member.name for member in AssetClasses.all() - if member.value.can_be_linked_to_asset_bed # need better naming + if member.value.can_be_linked_to_asset_bed() # need better naming ], current_location__facility=current_location.facility_id, resolved_middleware_hostname=middleware_hostname, diff --git a/care/facility/api/viewsets/asset.py b/care/facility/api/viewsets/asset.py index 1e98d6fcbb..ac10810548 100644 --- a/care/facility/api/viewsets/asset.py +++ b/care/facility/api/viewsets/asset.py @@ -202,7 +202,9 @@ def filter_in_use_by_consultation(self, queryset, _, value): def filter_is_permanent(self, queryset, _, value): if value not in EMPTY_VALUES: movable_assets = [ - member.name for member in AssetClasses.all() if not member.is_movable + member.name + for member in AssetClasses.all() + if not member.value.is_movable() ] if value: queryset = queryset.filter(asset_class__in=movable_assets) diff --git a/config/settings/base.py b/config/settings/base.py index e6a6f7f800..6983433f86 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -128,7 +128,6 @@ "care.facility", "care.users", "care.audit_log", - # "camera_plugin", ] PLUGIN_APPS = manager.get_apps() From 421400a549bbb8739e4562a9b9899f62f1542214 Mon Sep 17 00:00:00 2001 From: Prafful Date: Sun, 29 Dec 2024 00:48:33 +0530 Subject: [PATCH 13/13] fixed migrations --- .../migrations/0468_alter_asset_asset_class_alter_asset_meta.py | 2 +- ...ter_facilitypatientstatshistory_unique_together_and_more.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename care/facility/migrations/{0468_alter_facilitypatientstatshistory_unique_together_and_more.py => 0469_alter_facilitypatientstatshistory_unique_together_and_more.py} (100%) diff --git a/care/facility/migrations/0468_alter_asset_asset_class_alter_asset_meta.py b/care/facility/migrations/0468_alter_asset_asset_class_alter_asset_meta.py index 3d62f5dc43..3152fde382 100644 --- a/care/facility/migrations/0468_alter_asset_asset_class_alter_asset_meta.py +++ b/care/facility/migrations/0468_alter_asset_asset_class_alter_asset_meta.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ - ('facility', '0467_alter_hospitaldoctors_area'), + ('facility', '0468_alter_facilitypatientstatshistory_unique_together_and_more'), ] operations = [ diff --git a/care/facility/migrations/0468_alter_facilitypatientstatshistory_unique_together_and_more.py b/care/facility/migrations/0469_alter_facilitypatientstatshistory_unique_together_and_more.py similarity index 100% rename from care/facility/migrations/0468_alter_facilitypatientstatshistory_unique_together_and_more.py rename to care/facility/migrations/0469_alter_facilitypatientstatshistory_unique_together_and_more.py