diff --git a/care/facility/api/serializers/asset.py b/care/facility/api/serializers/asset.py index fbb9f44970..7e672c4a01 100644 --- a/care/facility/api/serializers/asset.py +++ b/care/facility/api/serializers/asset.py @@ -18,6 +18,7 @@ from care.facility.api.serializers.facility import FacilityBareMinimumSerializer from care.facility.models.asset import ( Asset, + AssetAvailabilityRecord, AssetLocation, AssetTransaction, UserDefaultAssetLocation, @@ -165,6 +166,15 @@ class Meta: exclude = ("deleted", "external_id") +class AssetAvailabilitySerializer(ModelSerializer): + id = UUIDField(source="external_id", read_only=True) + asset = AssetBareMinimumSerializer(read_only=True) + + class Meta: + model = AssetAvailabilityRecord + exclude = ("deleted", "external_id") + + class UserDefaultAssetLocationSerializer(ModelSerializer): location_object = AssetLocationSerializer(source="location", read_only=True) diff --git a/care/facility/api/viewsets/asset.py b/care/facility/api/viewsets/asset.py index 57ec16da51..2b5fc6e6b1 100644 --- a/care/facility/api/viewsets/asset.py +++ b/care/facility/api/viewsets/asset.py @@ -23,6 +23,7 @@ from rest_framework.viewsets import GenericViewSet from care.facility.api.serializers.asset import ( + AssetAvailabilitySerializer, AssetLocationSerializer, AssetSerializer, AssetTransactionSerializer, @@ -32,6 +33,7 @@ ) from care.facility.models.asset import ( Asset, + AssetAvailabilityRecord, AssetLocation, AssetTransaction, UserDefaultAssetLocation, @@ -128,6 +130,17 @@ def retrieve(self, request, *args, **kwargs): return Response(hit) +class AssetAvailabilityFilter(filters.FilterSet): + external_id = filters.CharFilter(field_name="asset__external_id") + + +class AssetAvailabilityViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): + queryset = AssetAvailabilityRecord.objects.all().select_related("asset") + serializer_class = AssetAvailabilitySerializer + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = AssetAvailabilityFilter + + class AssetViewSet( ListModelMixin, RetrieveModelMixin, diff --git a/care/facility/migrations/0372_assetavailabilityrecord.py b/care/facility/migrations/0372_assetavailabilityrecord.py new file mode 100644 index 0000000000..2d3dc2daf4 --- /dev/null +++ b/care/facility/migrations/0372_assetavailabilityrecord.py @@ -0,0 +1,66 @@ +# Generated by Django 4.2.2 on 2023-07-18 05:00 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0371_metaicd11diagnosis_chapter_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="AssetAvailabilityRecord", + 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)), + ( + "status", + models.CharField( + choices=[ + ("Not Monitored", "Not Monitored"), + ("Operational", "Operational"), + ("Down", "Down"), + ("Under Maintenance", "Under Maintenance"), + ], + default="Not Monitored", + max_length=20, + ), + ), + ("timestamp", models.DateTimeField()), + ( + "asset", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="facility.asset" + ), + ), + ], + options={ + "ordering": ["-timestamp"], + "unique_together": {("asset", "timestamp")}, + }, + ), + ] diff --git a/care/facility/models/asset.py b/care/facility/models/asset.py index ac14fba3a0..fdeaff4263 100644 --- a/care/facility/models/asset.py +++ b/care/facility/models/asset.py @@ -17,6 +17,13 @@ def get_random_asset_id(): return str(uuid.uuid4()) +class AvailabilityStatus(models.TextChoices): + NOT_MONITORED = "Not Monitored" + OPERATIONAL = "Operational" + DOWN = "Down" + UNDER_MAINTENANCE = "Under Maintenance" + + class AssetLocation(BaseModel, AssetsPermissionMixin): """ This model is also used to store rooms that the assets are in, Since these rooms are mapped to @@ -105,6 +112,34 @@ def __str__(self): return self.name +class AssetAvailabilityRecord(BaseModel): + """ + Model to store the availability status of an asset at a particular timestamp. + + Fields: + - asset: ForeignKey to Asset model + - status: CharField with choices from AvailabilityStatus + - timestamp: DateTimeField to store the timestamp of the availability record + + Note: A pair of asset and timestamp together should be unique, not just the timestamp alone. + """ + + asset = models.ForeignKey(Asset, on_delete=models.PROTECT, null=False, blank=False) + status = models.CharField( + choices=AvailabilityStatus.choices, + default=AvailabilityStatus.NOT_MONITORED, + max_length=20, + ) + timestamp = models.DateTimeField(null=False, blank=False) + + class Meta: + unique_together = (("asset", "timestamp"),) + ordering = ["-timestamp"] + + def __str__(self): + return f"{self.asset.name} - {self.status} - {self.timestamp}" + + class UserDefaultAssetLocation(BaseModel): user = models.ForeignKey(User, on_delete=models.PROTECT, null=False, blank=False) location = models.ForeignKey( diff --git a/care/facility/tasks/__init__.py b/care/facility/tasks/__init__.py index 7ebf63cdaa..1a9383d32d 100644 --- a/care/facility/tasks/__init__.py +++ b/care/facility/tasks/__init__.py @@ -1,6 +1,7 @@ from celery import current_app from celery.schedules import crontab +from care.facility.tasks.asset_monitor import check_asset_status from care.facility.tasks.cleanup import delete_old_notifications from care.facility.tasks.summarisation import ( summarise_district_patient, @@ -19,12 +20,12 @@ def setup_periodic_tasks(sender, **kwargs): name="delete_old_notifications", ) sender.add_periodic_task( - crontab(hour="*/4", minute=59), + crontab(hour="*/4", minute="59"), summarise_triage.s(), name="summarise_triage", ) sender.add_periodic_task( - crontab(hour=23, minute=59), + crontab(hour="23", minute="59"), summarise_tests.s(), name="summarise_tests", ) @@ -34,12 +35,17 @@ def setup_periodic_tasks(sender, **kwargs): name="summarise_facility_capacity", ) sender.add_periodic_task( - crontab(hour="*/1", minute=59), + crontab(hour="*/1", minute="59"), summarise_patient.s(), name="summarise_patient", ) sender.add_periodic_task( - crontab(hour="*/1", minute=59), + crontab(hour="*/1", minute="59"), summarise_district_patient.s(), name="summarise_district_patient", ) + sender.add_periodic_task( + crontab(minute="*/30"), + check_asset_status.s(), + name="check_asset_status", + ) diff --git a/care/facility/tasks/asset_monitor.py b/care/facility/tasks/asset_monitor.py new file mode 100644 index 0000000000..82a0bcd0ee --- /dev/null +++ b/care/facility/tasks/asset_monitor.py @@ -0,0 +1,96 @@ +import logging +from datetime import datetime +from typing import Any + +from celery import shared_task +from django.utils import timezone + +from care.facility.models.asset import ( + Asset, + AssetAvailabilityRecord, + AvailabilityStatus, +) +from care.utils.assetintegration.asset_classes import AssetClasses +from care.utils.assetintegration.base import BaseAssetIntegration + +logger = logging.getLogger(__name__) + + +@shared_task +def check_asset_status(): + logger.info(f"Checking Asset Status: {timezone.now()}") + + assets = Asset.objects.all() + middleware_status_cache = {} + + for asset in assets: + if not asset.asset_class or not asset.meta.get("local_ip_address", None): + continue + try: + hostname = asset.meta.get( + "middleware_hostname", + asset.current_location.facility.middleware_address, + ) + result: Any = {} + + if hostname in middleware_status_cache: + result = middleware_status_cache[hostname] + else: + try: + asset_class: BaseAssetIntegration = AssetClasses[ + asset.asset_class + ].value( + { + **asset.meta, + "middleware_hostname": hostname, + } + ) + result = asset_class.api_get(asset_class.get_url("devices/status")) + middleware_status_cache[hostname] = result + except Exception: + logger.exception("Error in Asset Status Check - Fetching Status") + middleware_status_cache[hostname] = None + continue + + if not result: + continue + + new_status = None + for status_record in result: + if asset.meta.get("local_ip_address") in status_record.get( + "status", {} + ): + new_status = status_record["status"][ + asset.meta.get("local_ip_address") + ] + else: + new_status = "not_monitored" + + last_record = ( + AssetAvailabilityRecord.objects.filter(asset=asset) + .order_by("-timestamp") + .first() + ) + + if new_status == "up": + new_status = AvailabilityStatus.OPERATIONAL + elif new_status == "down": + new_status = AvailabilityStatus.DOWN + elif new_status == "maintenance": + new_status = AvailabilityStatus.UNDER_MAINTENANCE + else: + new_status = AvailabilityStatus.NOT_MONITORED + + if not last_record or ( + datetime.fromisoformat(status_record.get("time")) + > last_record.timestamp + and last_record.status != new_status.value + ): + AssetAvailabilityRecord.objects.create( + asset=asset, + status=new_status.value, + timestamp=status_record.get("time", timezone.now()), + ) + + except Exception: + logger.exception("Error in Asset Status Check") diff --git a/care/facility/tests/test_asset_api.py b/care/facility/tests/test_asset_api.py index dbb8c27d42..36cb493e49 100644 --- a/care/facility/tests/test_asset_api.py +++ b/care/facility/tests/test_asset_api.py @@ -11,17 +11,19 @@ class AssetViewSetTestCase(TestBase, TestClassMixin, APITestCase): asset_id = None - def setUp(self): - self.factory = APIRequestFactory() - state = self.create_state() - district = self.create_district(state=state) - self.user = self.create_user(district=district, username="test user") - facility = self.create_facility(district=district, user=self.user) - self.asset1_location = AssetLocation.objects.create( + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.factory = APIRequestFactory() + state = cls.create_state() + district = cls.create_district(state=state) + cls.user = cls.create_user(district=district, username="test user") + facility = cls.create_facility(district=district, user=cls.user) + cls.asset1_location = AssetLocation.objects.create( name="asset1 location", location_type=1, facility=facility ) - self.asset = Asset.objects.create( - name="Test Asset", current_location=self.asset1_location, asset_type=50 + cls.asset = Asset.objects.create( + name="Test Asset", current_location=cls.asset1_location, asset_type=50 ) def test_list_assets(self): diff --git a/care/facility/tests/test_asset_availability_api.py b/care/facility/tests/test_asset_availability_api.py new file mode 100644 index 0000000000..65b7d36e40 --- /dev/null +++ b/care/facility/tests/test_asset_availability_api.py @@ -0,0 +1,57 @@ +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIRequestFactory, APITestCase + +from care.facility.api.viewsets.asset import AssetAvailabilityViewSet +from care.facility.models import Asset, AssetAvailabilityRecord, AssetLocation +from care.facility.models.asset import AvailabilityStatus +from care.facility.tests.mixins import TestClassMixin +from care.utils.tests.test_base import TestBase + + +class AssetAvailabilityViewSetTestCase(TestBase, TestClassMixin, APITestCase): + @classmethod + def setUp(cls): + cls.factory = APIRequestFactory() + state = cls.create_state() + district = cls.create_district(state=state) + cls.user = cls.create_user(district=district, username="test user") + facility = cls.create_facility(district=district, user=cls.user) + cls.asset_from_location = AssetLocation.objects.create( + name="asset from location", location_type=1, facility=facility + ) + cls.asset_to_location = AssetLocation.objects.create( + name="asset to location", location_type=1, facility=facility + ) + cls.asset = Asset.objects.create( + name="Test Asset", current_location=cls.asset_from_location, asset_type=50 + ) + + cls.asset_availability = AssetAvailabilityRecord.objects.create( + asset=cls.asset, + status=AvailabilityStatus.OPERATIONAL.value, + timestamp=timezone.now(), + ) + + def test_list_asset_availability(self): + response = self.new_request( + ("/api/v1/asset_availability/",), + {"get": "list"}, + AssetAvailabilityViewSet, + True, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["results"][0]["status"], AvailabilityStatus.OPERATIONAL.value + ) + + def test_retrieve_asset_availability(self): + response = self.new_request( + (f"/api/v1/asset_availability/{self.asset_availability.id}/",), + {"get": "retrieve"}, + AssetAvailabilityViewSet, + True, + {"pk": self.asset_availability.id}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["status"], AvailabilityStatus.OPERATIONAL.value) diff --git a/care/facility/tests/test_medibase_api.py b/care/facility/tests/test_medibase_api.py index 34ce13d7d5..ec6b53afad 100644 --- a/care/facility/tests/test_medibase_api.py +++ b/care/facility/tests/test_medibase_api.py @@ -9,17 +9,17 @@ def get_url(self, query=None): def test_search_by_name_exact_word(self): response = self.client.get(self.get_url(query="dolo")) - self.assertEquals(response.status_code, status.HTTP_200_OK) - self.assertEquals(response.data[0]["name"], "DOLO") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data[0]["name"], "DOLO") def test_search_by_generic_exact_word(self): response = self.client.get(self.get_url(query="pAraCetAmoL")) - self.assertEquals(response.status_code, status.HTTP_200_OK) - self.assertEquals(response.data[0]["generic"], "paracetamol") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data[0]["generic"], "paracetamol") def test_search_by_name_and_generic_exact_word(self): response = self.client.get(self.get_url(query="panadol paracetamol")) - self.assertEquals(response.status_code, status.HTTP_200_OK) - self.assertEquals(response.data[0]["name"], "PANADOL") - self.assertEquals(response.data[0]["generic"], "paracetamol") - self.assertEquals(response.data[0]["company"], "GSK") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data[0]["name"], "PANADOL") + self.assertEqual(response.data[0]["generic"], "paracetamol") + self.assertEqual(response.data[0]["company"], "GSK") diff --git a/care/facility/tests/test_patient_daily_rounds_api.py b/care/facility/tests/test_patient_daily_rounds_api.py index 4b675274c6..22733cfadb 100644 --- a/care/facility/tests/test_patient_daily_rounds_api.py +++ b/care/facility/tests/test_patient_daily_rounds_api.py @@ -10,4 +10,4 @@ def get_url(self, external_consultation_id=None): def test_external_consultation_does_not_exists_returns_404(self): sample_uuid = "e4a3d84a-d678-4992-9287-114f029046d8" response = self.client.get(self.get_url(sample_uuid)) - self.assertEquals(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/care/users/tests/test_facility_user_create.py b/care/users/tests/test_facility_user_create.py index 75849c3180..66ae51d4f6 100644 --- a/care/users/tests/test_facility_user_create.py +++ b/care/users/tests/test_facility_user_create.py @@ -55,7 +55,7 @@ def test_create_facility_user__should_fail__when_higher_level(self): response = self.client.post(self.get_url(), data=data, format="json") # Test Creation - self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) def test_create_facility_user__should_fail__when_different_location(self): new_district = self.clone_object(self.district) @@ -64,4 +64,4 @@ def test_create_facility_user__should_fail__when_different_location(self): response = self.client.post(self.get_url(), data=data, format="json") # Test Creation - self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/care/utils/assetintegration/base.py b/care/utils/assetintegration/base.py index f703c277ac..92d318c3a5 100644 --- a/care/utils/assetintegration/base.py +++ b/care/utils/assetintegration/base.py @@ -46,9 +46,9 @@ def api_get(self, url, data=None): headers={"Authorization": (self.auth_header_type + generate_jwt())}, ) try: - response = req.json() if req.status_code >= 400: - raise APIException(response, req.status_code) + raise APIException(req.text, req.status_code) + response = req.json() return response except json.decoder.JSONDecodeError: return {"error": "Invalid Response"} diff --git a/config/api_router.py b/config/api_router.py index 918ae39302..13d85f0da2 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -8,6 +8,7 @@ AmbulanceViewSet, ) from care.facility.api.viewsets.asset import ( + AssetAvailabilityViewSet, AssetLocationViewSet, AssetPublicViewSet, AssetTransactionViewSet, @@ -185,6 +186,7 @@ router.register("asset", AssetViewSet) router.register("asset_transaction", AssetTransactionViewSet) +router.register("asset_availability", AssetAvailabilityViewSet) patient_nested_router = NestedSimpleRouter(router, r"patient", lookup="patient") patient_nested_router.register(r"test_sample", PatientSampleViewSet)