diff --git a/care/facility/api/viewsets/asset.py b/care/facility/api/viewsets/asset.py index 8b24bebb51..caf784cb9f 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 @@ -389,7 +390,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( @@ -405,7 +405,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/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/utils/assetintegration/base.py b/care/utils/assetintegration/base.py index 334bcecfa5..cc6c59e1c4 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,16 +66,14 @@ 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) ) diff --git a/care/utils/assetintegration/hl7monitor.py b/care/utils/assetintegration/hl7monitor.py index abd14247d3..bf331f71ca 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/onvif.py b/care/utils/assetintegration/onvif.py index 815994855e..2dd814b4e6 100644 --- a/care/utils/assetintegration/onvif.py +++ b/care/utils/assetintegration/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/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 23a5280960..afb896bfdb 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"})