From 228dbe6b294e369b34e48905d4070b023f46eb17 Mon Sep 17 00:00:00 2001 From: Prafful Date: Sat, 28 Dec 2024 22:59:20 +0530 Subject: [PATCH 1/5] added boundary model and validations --- camera/models/asset_bed_boundary.py | 37 +++++++++++++++++++++++++++ camera/models/json_schema/boundary.py | 12 +++++++++ camera/utils/onvif.py | 14 ++++++++++ 3 files changed, 63 insertions(+) create mode 100644 camera/models/asset_bed_boundary.py create mode 100644 camera/models/json_schema/boundary.py diff --git a/camera/models/asset_bed_boundary.py b/camera/models/asset_bed_boundary.py new file mode 100644 index 0000000..8f283d4 --- /dev/null +++ b/camera/models/asset_bed_boundary.py @@ -0,0 +1,37 @@ +from django.db import models +from django.core.exceptions import ValidationError +from jsonschema import validate +from jsonschema.exceptions import ValidationError as JSONSchemaValidationError +from camera.models.json_schema.boundary import ASSET_BED_BOUNDARY_SCHEMA +from care.facility.models import AssetBed +from care.utils.models.base import BaseModel + + + +class AssetBedBoundary(BaseModel): + asset_bed = models.OneToOneField( + AssetBed, on_delete=models.PROTECT, related_name="assetbed_camera_boundary" + ) + x0 = models.IntegerField() + y0 = models.IntegerField() + x1 = models.IntegerField() + y1 = models.IntegerField() + + def save(self, *args, **kwargs): + if self.asset_bed.asset.asset_class != "ONVIF": + raise ValidationError("AssetBedBoundary is only applicable for AssetBeds with ONVIF assets.") + + data = { + "x0": self.x0, + "y0": self.y0, + "x1": self.x1, + "y1": self.y1, + } + + try: + validate(instance=data, schema=ASSET_BED_BOUNDARY_SCHEMA) + except JSONSchemaValidationError as e: + error=f"Invalid data: {str(e)}" + raise ValidationError(error) + + super().save(*args, **kwargs) diff --git a/camera/models/json_schema/boundary.py b/camera/models/json_schema/boundary.py new file mode 100644 index 0000000..245d7ec --- /dev/null +++ b/camera/models/json_schema/boundary.py @@ -0,0 +1,12 @@ +ASSET_BED_BOUNDARY_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "x0": {"type": "number"}, + "y0": {"type": "number"}, + "x1": {"type": "number"}, + "y1": {"type": "number"}, + }, + "required": ["x0", "y0", "x1", "y1"], + "additionalProperties": False, +} \ No newline at end of file diff --git a/camera/utils/onvif.py b/camera/utils/onvif.py index 3fe7e62..4507e38 100644 --- a/camera/utils/onvif.py +++ b/camera/utils/onvif.py @@ -2,6 +2,7 @@ from rest_framework.exceptions import ValidationError +from care.facility.models.bed import AssetBed from care.utils.assetintegration.base import ActionParams, BaseAssetIntegration @@ -30,6 +31,7 @@ def __init__(self, meta): def handle_action(self, **kwargs: ActionParams): action_type = kwargs["type"] action_data = kwargs.get("data", {}) + action_options = kwargs.get("options", {}) timeout = kwargs.get("timeout") request_body = { @@ -54,6 +56,18 @@ def handle_action(self, **kwargs: ActionParams): return self.api_post(self.get_url("absoluteMove"), request_body, timeout) if action_type == self.OnvifActions.RELATIVE_MOVE.value: + action_asset_bed_id = action_options.get("asset_bed_id") + + if action_asset_bed_id: + asset_bed = AssetBed.objects.filter( + external_id=action_asset_bed_id + ).first() + + if not asset_bed: + raise ValidationError({"asset_bed_id": "Invalid Asset Bed ID"}) + + if asset_bed.boundary: + request_body.update({"boundary": asset_bed.boundary}) return self.api_post(self.get_url("relativeMove"), request_body, timeout) if action_type == self.OnvifActions.GET_STREAM_TOKEN.value: From a7a32794a3988dc15d49786b6d02c0f83ad6eafc Mon Sep 17 00:00:00 2001 From: Prafful Date: Sun, 29 Dec 2024 00:27:04 +0530 Subject: [PATCH 2/5] added and updated migrations --- camera/migrations/0001_initial.py | 2 +- camera/migrations/0004_assetbedboundary.py | 34 ++++++++++++++++++++++ camera/models/__init__.py | 1 + camera/models/asset_bed_boundary.py | 15 ++++++++-- camera/utils/onvif.py | 5 ++-- 5 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 camera/migrations/0004_assetbedboundary.py diff --git a/camera/migrations/0001_initial.py b/camera/migrations/0001_initial.py index 0d4b746..5cd595d 100644 --- a/camera/migrations/0001_initial.py +++ b/camera/migrations/0001_initial.py @@ -12,7 +12,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('facility', '0468_alter_asset_asset_class_alter_asset_meta'), + ('facility', '0469_alter_asset_asset_class_alter_asset_meta'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] diff --git a/camera/migrations/0004_assetbedboundary.py b/camera/migrations/0004_assetbedboundary.py new file mode 100644 index 0000000..1833f0d --- /dev/null +++ b/camera/migrations/0004_assetbedboundary.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.3 on 2024-12-28 18:51 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('camera', '0003_alter_positionpreset_created_date_and_more'), + ('facility', '0469_alter_asset_asset_class_alter_asset_meta'), + ] + + operations = [ + migrations.CreateModel( + name='AssetBedBoundary', + 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)), + ('x0', models.IntegerField()), + ('y0', models.IntegerField()), + ('x1', models.IntegerField()), + ('y1', models.IntegerField()), + ('asset_bed', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='assetbed_camera_boundary', to='facility.assetbed')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/camera/models/__init__.py b/camera/models/__init__.py index e69de29..021dfff 100644 --- a/camera/models/__init__.py +++ b/camera/models/__init__.py @@ -0,0 +1 @@ +from camera.models.asset_bed_boundary import AssetBedBoundary diff --git a/camera/models/asset_bed_boundary.py b/camera/models/asset_bed_boundary.py index 8f283d4..963add6 100644 --- a/camera/models/asset_bed_boundary.py +++ b/camera/models/asset_bed_boundary.py @@ -19,7 +19,8 @@ class AssetBedBoundary(BaseModel): def save(self, *args, **kwargs): if self.asset_bed.asset.asset_class != "ONVIF": - raise ValidationError("AssetBedBoundary is only applicable for AssetBeds with ONVIF assets.") + error="AssetBedBoundary is only applicable for AssetBeds with ONVIF assets." + raise ValidationError(error) data = { "x0": self.x0, @@ -32,6 +33,16 @@ def save(self, *args, **kwargs): validate(instance=data, schema=ASSET_BED_BOUNDARY_SCHEMA) except JSONSchemaValidationError as e: error=f"Invalid data: {str(e)}" - raise ValidationError(error) + raise ValidationError(error) from e super().save(*args, **kwargs) + + + def to_dict(self): + """Serialize boundary data as a dictionary.""" + return { + "x0": self.x0, + "y0": self.y0, + "x1": self.x1, + "y1": self.y1, + } diff --git a/camera/utils/onvif.py b/camera/utils/onvif.py index 4507e38..17fe1b7 100644 --- a/camera/utils/onvif.py +++ b/camera/utils/onvif.py @@ -66,8 +66,9 @@ def handle_action(self, **kwargs: ActionParams): if not asset_bed: raise ValidationError({"asset_bed_id": "Invalid Asset Bed ID"}) - if asset_bed.boundary: - request_body.update({"boundary": asset_bed.boundary}) + if hasattr(asset_bed, "assetbed_camera_boundary"): + boundary = asset_bed.assetbed_camera_boundary.to_dict() + request_body.update({"boundary": boundary}) return self.api_post(self.get_url("relativeMove"), request_body, timeout) if action_type == self.OnvifActions.GET_STREAM_TOKEN.value: From 55d3e856e2fb4186f4a1c3145f60c1bf29948394 Mon Sep 17 00:00:00 2001 From: Prafful Date: Sun, 29 Dec 2024 00:29:39 +0530 Subject: [PATCH 3/5] updated workflow branch to test --- .github/workflows/test-plugin-integration.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-plugin-integration.yml b/.github/workflows/test-plugin-integration.yml index 167714c..7b0e082 100644 --- a/.github/workflows/test-plugin-integration.yml +++ b/.github/workflows/test-plugin-integration.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v3 with: repository: DraKen0009/care - ref: adding-camera-plugin + ref: camera-boundary-changes # Update the plug_config.py file with the required content @@ -33,14 +33,14 @@ jobs: cat > ./plug_config.py < Date: Sun, 29 Dec 2024 17:15:04 +0530 Subject: [PATCH 4/5] Privacy consultation bed for camera plugin --- camera/tests/test_asset_usage_manager.py | 118 ++++++++++++++++++++++ camera/tests/test_camera_preset_apis.py | 4 +- camera/utils/onvif.py | 47 ++++++++- camera/utils/usage_manager.py | 122 +++++++++++++++++++++++ 4 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 camera/tests/test_asset_usage_manager.py create mode 100644 camera/utils/usage_manager.py diff --git a/camera/tests/test_asset_usage_manager.py b/camera/tests/test_asset_usage_manager.py new file mode 100644 index 0000000..4743e94 --- /dev/null +++ b/camera/tests/test_asset_usage_manager.py @@ -0,0 +1,118 @@ +from unittest import mock + +from camera.utils.usage_manager import UsageManager +from rest_framework.test import APITestCase + +from care.users.models import User +from care.utils.tests.test_utils import TestUtils + + +class UsageManagerTestCase(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.asset_location = cls.create_asset_location(cls.facility) + cls.asset = cls.create_asset(cls.asset_location) + + def setUp(self): + self.user1 = self.create_user( + username="test_user_1", + state=self.state, + district=self.district, + user_type=User.TYPE_VALUE_MAP["StateAdmin"], + ) + self.user2 = self.create_user( + username="test_user_2", + state=self.state, + district=self.district, + user_type=User.TYPE_VALUE_MAP["StateAdmin"], + ) + + self.mock_cache = mock.MagicMock() + self.cache_patcher = mock.patch( + "camera.utils.usage_manager.cache", self.mock_cache + ) + self.cache_patcher.start() + + self.usage_manager_user1 = UsageManager( + asset_id=self.asset.external_id, user=self.user1 + ) + self.usage_manager_user2 = UsageManager( + asset_id=self.asset.external_id, user=self.user2 + ) + + self.mock_redis_client = mock.MagicMock() + self.usage_manager_user1.redis_client = self.mock_redis_client + self.usage_manager_user2.redis_client = self.mock_redis_client + + def tearDown(self): + self.cache_patcher.stop() + + def test_has_access(self): + self.mock_cache.get.return_value = None + self.assertTrue(self.usage_manager_user1.has_access()) + + self.mock_cache.get.return_value = self.user1.id + self.assertTrue(self.usage_manager_user1.has_access()) + + self.mock_cache.get.return_value = self.user2.id + self.assertFalse(self.usage_manager_user1.has_access()) + + def test_unlock_camera(self): + self.mock_cache.get.return_value = self.user1.id + + with mock.patch.object( + self.usage_manager_user1, "notify_waiting_list_on_asset_availabe" + ) as mock_notify: + self.usage_manager_user1.unlock_camera() + + self.mock_cache.delete.assert_called_once_with( + self.usage_manager_user1.current_user_cache_key + ) + + mock_notify.assert_called_once() + + def test_request_access(self): + self.mock_cache.get.return_value = None + self.assertTrue(self.usage_manager_user1.request_access()) + + self.mock_cache.get.return_value = self.user2.id + with mock.patch( + "care.utils.notification_handler.send_webpush" + ) as mock_send_webpush: + result = self.usage_manager_user1.request_access() + self.assertFalse(result) + mock_send_webpush.assert_called_once() + + def test_lock_camera(self): + self.mock_cache.get.return_value = None + self.assertTrue(self.usage_manager_user1.lock_camera()) + self.mock_cache.set.assert_called_once_with( + self.usage_manager_user1.current_user_cache_key, + self.user1.id, + timeout=60 * 5, + ) + + self.mock_cache.get.return_value = self.user2.id + self.assertFalse(self.usage_manager_user1.lock_camera()) + + def test_current_user(self): + self.mock_cache.get.return_value = self.user1.id + + mock_serializer = mock.MagicMock() + mock_serializer.data = { + "id": self.user1.id, + "username": self.user1.username, + } + + with mock.patch( + "care.facility.api.serializers.asset.UserBaseMinimumSerializer", + return_value=mock_serializer, + ): + current_user_data = self.usage_manager_user1.current_user() + self.assertIsNotNone(current_user_data) + self.assertEqual(current_user_data["id"], self.user1.id) \ No newline at end of file diff --git a/camera/tests/test_camera_preset_apis.py b/camera/tests/test_camera_preset_apis.py index abfd5c2..0642ddc 100644 --- a/camera/tests/test_camera_preset_apis.py +++ b/camera/tests/test_camera_preset_apis.py @@ -170,7 +170,7 @@ def test_list_bed_with_deleted_assetbed(self): format="json", ) self.assertEqual(res.status_code, status.HTTP_201_CREATED) - + res = self.client.get(f"/api/camera/position-presets/?bed_external_id={self.bed.external_id}") self.assertEqual(len(res.json()["results"]), 2) @@ -178,7 +178,7 @@ def test_list_bed_with_deleted_assetbed(self): self.asset_bed1.refresh_from_db() res = self.client.get(f"/api/camera/position-presets/?bed_external_id={self.bed.external_id}") self.assertEqual(len(res.json()["results"]), 1) - + def test_meta_validations_for_onvif_asset(self): valid_meta = { "local_ip_address": "192.168.0.1", diff --git a/camera/utils/onvif.py b/camera/utils/onvif.py index 17fe1b7..ef7f8a9 100644 --- a/camera/utils/onvif.py +++ b/camera/utils/onvif.py @@ -1,6 +1,7 @@ import enum -from rest_framework.exceptions import ValidationError +from camera.utils.usage_manager import UsageManager +from rest_framework.exceptions import ValidationError,PermissionDenied from care.facility.models.bed import AssetBed from care.utils.assetintegration.base import ActionParams, BaseAssetIntegration @@ -17,6 +18,11 @@ class OnvifActions(enum.Enum): RELATIVE_MOVE = "relative_move" GET_STREAM_TOKEN = "get_stream_token" + LOCK_CAMERA = "lock_camera" + UNLOCK_CAMERA = "unlock_camera" + REQUEST_ACCESS = "request_access" + TAKE_CONTROL = "take_control" + def __init__(self, meta): try: super().__init__(meta) @@ -28,11 +34,12 @@ def __init__(self, meta): {key: f"{key} not found in asset metadata" for key in e.args} ) from e - def handle_action(self, **kwargs: ActionParams): + def handle_action(self,user, **kwargs: ActionParams): action_type = kwargs["type"] action_data = kwargs.get("data", {}) action_options = kwargs.get("options", {}) timeout = kwargs.get("timeout") + camera_manager = UsageManager(self.id, user) request_body = { "hostname": self.host, @@ -43,6 +50,42 @@ def handle_action(self, **kwargs: ActionParams): **action_data, } + if action_type == self.OnvifActions.LOCK_CAMERA.value: + if camera_manager.lock_camera(): + return { + "message": "You now have access to the camera controls, the camera is locked for other users", + "camera_user": camera_manager.current_user(), + } + + raise PermissionDenied( + { + "message": "Camera is currently in used by another user, you have been added to the waiting list for camera controls access", + "camera_user": camera_manager.current_user(), + } + ) + if action_type == self.OnvifActions.UNLOCK_CAMERA.value: + camera_manager.unlock_camera() + return {"message": "Camera controls unlocked"} + + if action_type == self.OnvifActions.REQUEST_ACCESS.value: + if camera_manager.request_access(): + return { + "message": "Access to camera camera controls granted", + "camera_user": camera_manager.current_user(), + } + + return { + "message": "Requested access to camera controls, waiting for current user to release", + "camera_user": camera_manager.current_user(), + } + if not camera_manager.has_access(): + raise PermissionDenied( + { + "message": "Camera is currently in used by another user, you have been added to the waiting list for camera controls access", + "camera_user": camera_manager.current_user(), + } + ) + if action_type == self.OnvifActions.GET_CAMERA_STATUS.value: return self.api_get(self.get_url("status"), request_body, timeout) diff --git a/camera/utils/usage_manager.py b/camera/utils/usage_manager.py new file mode 100644 index 0000000..d3959cc --- /dev/null +++ b/camera/utils/usage_manager.py @@ -0,0 +1,122 @@ +import json + +from django.core.cache import cache + +from care.users.models import User + + +class UsageManager: + def __init__(self, asset_id: str, user: User): + self.redis_client = cache.client.get_client() + self.asset = str(asset_id) + self.user = user + self.waiting_list_cache_key = f"onvif_waiting_list:{asset_id}" + self.current_user_cache_key = f"onvif_current_user:{asset_id}" + + def get_waiting_list(self) -> list[User]: + asset_queue = self.redis_client.lrange(self.waiting_list_cache_key, 0, -1) + return list(User.objects.filter(id__in=asset_queue)) + + def add_to_waiting_list(self) -> int: + if self.user.id not in self.redis_client.lrange( + self.waiting_list_cache_key, 0, -1 + ): + self.redis_client.rpush(self.waiting_list_cache_key, self.user.id) + + return self.redis_client.llen(self.waiting_list_cache_key) + + def remove_from_waiting_list(self) -> None: + self.redis_client.lrem(self.waiting_list_cache_key, 0, self.user.id) + + def clear_waiting_list(self) -> None: + self.redis_client.delete(self.waiting_list_cache_key) + + def current_user(self) -> dict: + from care.facility.api.serializers.asset import UserBaseMinimumSerializer + + current_user = cache.get(self.current_user_cache_key) + + if current_user is None: + return None + + user = User.objects.filter(id=current_user).first() + + if user is None: + cache.delete(self.current_user_cache_key) + return None + + return UserBaseMinimumSerializer(user).data + + def has_access(self) -> bool: + current_user = cache.get(self.current_user_cache_key) + return current_user is None or current_user == self.user.id + + def notify_waiting_list_on_asset_availabe(self) -> None: + from care.utils.notification_handler import send_webpush + + message = json.dumps( + { + "type": "MESSAGE", + "asset_id": self.asset, + "message": "Camera is now available", + "action": "CAMERA_AVAILABILITY", + } + ) + + for user in self.get_waiting_list(): + send_webpush(username=user.username, message=message) + + def notify_current_user_on_request_access(self) -> None: + from care.utils.notification_handler import send_webpush + + current_user = cache.get(self.current_user_cache_key) + + if current_user is None: + return + + requester = User.objects.filter(id=self.user.id).first() + + if requester is None: + return + + message = json.dumps( + { + "type": "MESSAGE", + "asset_id": self.asset, + "message": f"{User.REVERSE_TYPE_MAP[requester.user_type]}, {requester.full_name} ({requester.username}) has requested access to the camera", + "action": "CAMERA_ACCESS_REQUEST", + } + ) + + user = User.objects.filter(id=current_user).first() + send_webpush(username=user.username, message=message) + + def lock_camera(self) -> bool: + current_user = cache.get(self.current_user_cache_key) + + if current_user is None or current_user == self.user.id: + cache.set(self.current_user_cache_key, self.user.id, timeout=60 * 5) + self.remove_from_waiting_list() + return True + + self.add_to_waiting_list() + return False + + def unlock_camera(self) -> None: + current_user = cache.get(self.current_user_cache_key) + + if current_user == self.user.id: + cache.delete(self.current_user_cache_key) + self.notify_waiting_list_on_asset_availabe() + + self.remove_from_waiting_list() + + def request_access(self) -> bool: + if self.lock_camera(): + return True + + self.notify_current_user_on_request_access() + return False + + def take_control(self): + pass From 381b009f221b37b7a31786f88d9f4e68bd168068 Mon Sep 17 00:00:00 2001 From: Prafful Date: Sun, 29 Dec 2024 17:21:27 +0530 Subject: [PATCH 5/5] updated workflow for this case --- .github/workflows/test-plugin-integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-plugin-integration.yml b/.github/workflows/test-plugin-integration.yml index 7b0e082..667e713 100644 --- a/.github/workflows/test-plugin-integration.yml +++ b/.github/workflows/test-plugin-integration.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v3 with: repository: DraKen0009/care - ref: camera-boundary-changes + ref: provacy-consultation-bed-camera-plugin # Update the plug_config.py file with the required content