diff --git a/.github/workflows/deployment-branch.yaml b/.github/workflows/deployment-branch.yaml index 77682cf49c..de274d679f 100644 --- a/.github/workflows/deployment-branch.yaml +++ b/.github/workflows/deployment-branch.yaml @@ -33,7 +33,7 @@ jobs: uses: docker/setup-buildx-action@v2 - name: Cache Docker layers - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ hashFiles('r*/base.txt', 'r*/production.txt', 'Dockerfile') }} diff --git a/.github/workflows/deployment-lambda.yaml b/.github/workflows/deployment-lambda.yaml index 598634c355..372d99e877 100644 --- a/.github/workflows/deployment-lambda.yaml +++ b/.github/workflows/deployment-lambda.yaml @@ -34,7 +34,7 @@ jobs: uses: docker/setup-buildx-action@v2 - name: Cache Docker layers - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ hashFiles('r*/base.txt', 'r*/production.txt', 'Dockerfile') }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1036f79400..a6d012e3f0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -66,7 +66,7 @@ jobs: touch build/.nojekyll - name: Deploy docs - uses: JamesIves/github-pages-deploy-action@v4.3.3 + uses: JamesIves/github-pages-deploy-action@v4.4.3 with: branch: gh-pages folder: build diff --git a/LICENSE b/LICENSE index 9a62c4cb53..146e799954 100644 --- a/LICENSE +++ b/LICENSE @@ -1,9 +1,21 @@ +MIT License -The MIT License (MIT) -Copyright (c) 2020, 👪 +Copyright (c) 2023 Open Healthcare Network -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/aws/backend.json b/aws/backend.json index 815bfbc7bf..a0a32179ae 100644 --- a/aws/backend.json +++ b/aws/backend.json @@ -262,6 +262,18 @@ { "valueFrom": "/care/backend/ABDM_CLIENT_SECRET", "name": "ABDM_CLIENT_SECRET" + }, + { + "valueFrom": "/care/backend/PLAUSIBLE_HOST", + "name": "PLAUSIBLE_HOST" + }, + { + "valueFrom": "/care/backend/PLAUSIBLE_SITE_ID", + "name": "PLAUSIBLE_SITE_ID" + }, + { + "valueFrom": "/care/backend/PLAUSIBLE_AUTH_TOKEN", + "name": "PLAUSIBLE_AUTH_TOKEN" } ], "name": "care-backend" diff --git a/aws/celery.json b/aws/celery.json index e9844ba75f..3b0aa109e3 100644 --- a/aws/celery.json +++ b/aws/celery.json @@ -248,6 +248,18 @@ "valueFrom": "/care/backend/HCX_IG_URL", "name": "HCX_IG_URL" }, + { + "valueFrom": "/care/backend/PLAUSIBLE_HOST", + "name": "PLAUSIBLE_HOST" + }, + { + "valueFrom": "/care/backend/PLAUSIBLE_SITE_ID", + "name": "PLAUSIBLE_SITE_ID" + }, + { + "valueFrom": "/care/backend/PLAUSIBLE_AUTH_TOKEN", + "name": "PLAUSIBLE_AUTH_TOKEN" + }, { "valueFrom": "/care/backend/ABDM_CLIENT_ID", "name": "ABDM_CLIENT_ID" @@ -505,6 +517,18 @@ "valueFrom": "/care/backend/HCX_IG_URL", "name": "HCX_IG_URL" }, + { + "valueFrom": "/care/backend/PLAUSIBLE_HOST", + "name": "PLAUSIBLE_HOST" + }, + { + "valueFrom": "/care/backend/PLAUSIBLE_SITE_ID", + "name": "PLAUSIBLE_SITE_ID" + }, + { + "valueFrom": "/care/backend/PLAUSIBLE_AUTH_TOKEN", + "name": "PLAUSIBLE_AUTH_TOKEN" + }, { "valueFrom": "/care/backend/ABDM_CLIENT_ID", "name": "ABDM_CLIENT_ID" diff --git a/care/facility/api/serializers/prescription.py b/care/facility/api/serializers/prescription.py index 4af84080ea..20f74133a4 100644 --- a/care/facility/api/serializers/prescription.py +++ b/care/facility/api/serializers/prescription.py @@ -29,11 +29,11 @@ class PrescriptionSerializer(serializers.ModelSerializer): def get_last_administered_on(self, obj): last_administration = ( MedicineAdministration.objects.filter(prescription=obj) - .order_by("-created_date") + .order_by("-administered_date") .first() ) if last_administration: - return last_administration.created_date + return last_administration.administered_date return None class Meta: diff --git a/care/facility/api/viewsets/asset.py b/care/facility/api/viewsets/asset.py index be7c203ebd..d1b7446bc4 100644 --- a/care/facility/api/viewsets/asset.py +++ b/care/facility/api/viewsets/asset.py @@ -1,6 +1,10 @@ +import uuid + from django.conf import settings from django.core.cache import cache from django.db.models import Exists, OuterRef, Q +from django.db.models.signals import post_save +from django.dispatch import receiver from django.http import Http404 from django.shortcuts import get_object_or_404 from django.utils import timezone @@ -58,6 +62,13 @@ inverse_asset_status = inverse_choices(StatusChoices) +@receiver(post_save, sender=Asset) +def delete_asset_cache(sender, instance, created, **kwargs): + cache.delete("asset:" + str(instance.external_id)) + cache.delete("asset:qr:" + str(instance.qr_code_id)) + cache.delete("asset:qr:" + str(instance.id)) + + class AssetLocationViewSet( ListModelMixin, RetrieveModelMixin, @@ -122,6 +133,7 @@ class AssetFilter(filters.FilterSet): method="filter_in_use_by_consultation" ) is_permanent = filters.BooleanFilter(method="filter_is_permanent") + warranty_amc_end_of_validity = filters.DateFromToRangeFilter() def filter_in_use_by_consultation(self, queryset, _, value): if value not in EMPTY_VALUES: @@ -174,6 +186,36 @@ def retrieve(self, request, *args, **kwargs): return Response(hit) +class AssetPublicQRViewSet(GenericViewSet): + queryset = Asset.objects.all() + serializer_class = AssetSerializer + lookup_field = "qr_code_id" + + def retrieve(self, request, *args, **kwargs): + is_uuid = True + try: + uuid.UUID(kwargs["qr_code_id"]) + except ValueError: + # If the qr_code_id is not a UUID, then it is the pk of the asset + is_uuid = False + if not kwargs["qr_code_id"].isnumeric(): + return Response(status=status.HTTP_404_NOT_FOUND) + + key = "asset:qr:" + kwargs["qr_code_id"] + hit = cache.get(key) + if not hit: + if is_uuid: + instance = self.get_object() + else: + instance = get_object_or_404( + self.get_queryset(), pk=kwargs["qr_code_id"] + ) + serializer = self.get_serializer(instance) + cache.set(key, serializer.data, 60 * 60 * 24) + return Response(serializer.data) + return Response(hit) + + class AssetAvailabilityFilter(filters.FilterSet): external_id = filters.CharFilter(field_name="asset__external_id") diff --git a/care/facility/migrations/0388_goal_goalentry_goalproperty_goalpropertyentry.py b/care/facility/migrations/0388_goal_goalentry_goalproperty_goalpropertyentry.py new file mode 100644 index 0000000000..afe651f1c0 --- /dev/null +++ b/care/facility/migrations/0388_goal_goalentry_goalproperty_goalpropertyentry.py @@ -0,0 +1,175 @@ +# Generated by Django 4.2.2 on 2023-09-06 08:36 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0387_merge_20230911_2303"), + ] + + operations = [ + migrations.CreateModel( + name="Goal", + 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=200)), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="GoalEntry", + 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)), + ("date", models.DateField()), + ("visitors", models.IntegerField(null=True)), + ("events", models.IntegerField(null=True)), + ( + "goal", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="entries", + to="facility.goal", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="GoalProperty", + 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=200)), + ( + "goal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="properties", + to="facility.goal", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="GoalPropertyEntry", + 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)), + ("value", models.CharField(max_length=200)), + ("visitors", models.IntegerField(null=True)), + ("events", models.IntegerField(null=True)), + ( + "goal_entry", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="properties", + to="facility.goalentry", + ), + ), + ( + "goal_property", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="entries", + to="facility.goalproperty", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/care/facility/models/asset.py b/care/facility/models/asset.py index 353a2d5a84..1ad2d5d772 100644 --- a/care/facility/models/asset.py +++ b/care/facility/models/asset.py @@ -113,19 +113,18 @@ class Asset(BaseModel): "description": "Description", "asset_type": "Type", "asset_class": "Class", - "status": "Working Status", - "current_location": "Current Location", - "is_working": "Is Working", + "status": "Status", + "current_location__name": "Current Location Name", + "is_working": "Working Status", "not_working_reason": "Not Working Reason", "serial_number": "Serial Number", - "warranty_details": "Warranty Details", "vendor_name": "Vendor Name", "support_name": "Support Name", "support_phone": "Support Phone Number", "support_email": "Support Email", "qr_code_id": "QR Code ID", "manufacturer": "Manufacturer", - "warranty_amc_end_of_validity": "Warrenty End Date", + "warranty_amc_end_of_validity": "Warranty End Date", "last_service__serviced_on": "Last Service Date", "last_service__note": "Notes", "meta__local_ip_address": "Config - IP Address", @@ -135,6 +134,7 @@ class Asset(BaseModel): CSV_MAKE_PRETTY = { "asset_type": (lambda x: REVERSE_ASSET_TYPE[x]), "status": (lambda x: REVERSE_STATUS[x]), + "is_working": (lambda x: "WORKING" if x else "NOT WORKING"), } class Meta: diff --git a/care/facility/models/stats.py b/care/facility/models/stats.py new file mode 100644 index 0000000000..47f2ba734c --- /dev/null +++ b/care/facility/models/stats.py @@ -0,0 +1,43 @@ +from django.db import models + +from care.utils.models.base import BaseModel + + +class Goal(BaseModel): + name = models.CharField(max_length=200) + + +class GoalEntry(BaseModel): + goal = models.ForeignKey( + Goal, + on_delete=models.PROTECT, + related_name="entries", + ) + date = models.DateField() + visitors = models.IntegerField(null=True) + events = models.IntegerField(null=True) + + +class GoalProperty(BaseModel): + name = models.CharField(max_length=200) + goal = models.ForeignKey( + Goal, + on_delete=models.CASCADE, + related_name="properties", + ) + + +class GoalPropertyEntry(BaseModel): + goal_entry = models.ForeignKey( + GoalEntry, + on_delete=models.PROTECT, + related_name="properties", + ) + goal_property = models.ForeignKey( + GoalProperty, + on_delete=models.PROTECT, + related_name="entries", + ) + value = models.CharField(max_length=200) + visitors = models.IntegerField(null=True) + events = models.IntegerField(null=True) diff --git a/care/facility/tasks/__init__.py b/care/facility/tasks/__init__.py index 1a9383d32d..6231b4f9a3 100644 --- a/care/facility/tasks/__init__.py +++ b/care/facility/tasks/__init__.py @@ -3,6 +3,7 @@ from care.facility.tasks.asset_monitor import check_asset_status from care.facility.tasks.cleanup import delete_old_notifications +from care.facility.tasks.plausible_stats import capture_goals from care.facility.tasks.summarisation import ( summarise_district_patient, summarise_facility_capacity, @@ -15,7 +16,7 @@ @current_app.on_after_finalize.connect def setup_periodic_tasks(sender, **kwargs): sender.add_periodic_task( - crontab(minute="0", hour="0"), + crontab(hour="0", minute="0"), delete_old_notifications.s(), name="delete_old_notifications", ) @@ -49,3 +50,8 @@ def setup_periodic_tasks(sender, **kwargs): check_asset_status.s(), name="check_asset_status", ) + sender.add_periodic_task( + crontab(hour="0", minute="0"), + capture_goals.s(), + name="capture_goals", + ) diff --git a/care/facility/tasks/plausible_stats.py b/care/facility/tasks/plausible_stats.py new file mode 100644 index 0000000000..ea9e89b1d7 --- /dev/null +++ b/care/facility/tasks/plausible_stats.py @@ -0,0 +1,151 @@ +import logging +from datetime import timedelta +from enum import Enum + +import requests +from celery import shared_task +from django.conf import settings +from django.utils.timezone import now + +from care.facility.models.stats import Goal, GoalEntry, GoalProperty, GoalPropertyEntry + +logger = logging.getLogger(__name__) + + +class Goals(Enum): + PATIENT_CONSULTATION_VIEWED = ("facilityId", "consultationId", "userId") + DOCTOR_CONNECT_CLICKED = ("consultationId", "facilityId", "userId", "page") + CAMERA_PRESET_CLICKED = ("presetName", "consultationId", "userId", "result") + CAMERA_FEED_MOVED = ("direction", "consultationId", "userId") + PATIENT_PROFILE_VIEWED = ("facilityId", "userId") + DEVICE_VIEWED = ("bedId", "assetId", "userId") + PAGEVIEW = ("page",) + + @property + def formatted_name(self): + if self == Goals.PAGEVIEW: + return "pageview" # pageview is a reserved goal in plausible + return self.name.replace("_", " ").title() + + +def get_goal_stats(plausible_host, site_id, date, goal_name): + goal_filter = f"event:name=={goal_name}" + url = f"https://{plausible_host}/api/v1/stats/aggregate" + + params = { + "site_id": site_id, + "filters": goal_filter, + "period": "day", + "date": date, + "metrics": "visitors,events", + } + + response = requests.get( + url, + params=params, + headers={ + "Authorization": "Bearer " + settings.PLAUSIBLE_AUTH_TOKEN, + }, + timeout=60, + ) + + response.raise_for_status() + + return response.json() + + +def get_goal_event_stats(plausible_host, site_id, date, goal_name, event_name): + goal_filter = f"event:name=={goal_name}" + + # pageview is a reserved goal in plausible which uses event:page + if goal_name == "pageview" and event_name == "page": + goal_event = "event:page" + else: + goal_event = f"event:props:{event_name}" + + url = f"https://{plausible_host}/api/v1/stats/breakdown" + + params = { + "site_id": site_id, + "property": goal_event, + "filters": goal_filter, + "period": "day", + "date": date, + "metrics": "visitors,events", + } + + response = requests.get( + url, + params=params, + headers={ + "Authorization": "Bearer " + settings.PLAUSIBLE_AUTH_TOKEN, + }, + timeout=60, + ) + + response.raise_for_status() + + return response.json() + + +@shared_task +def capture_goals(): + if ( + not settings.PLAUSIBLE_HOST + or not settings.PLAUSIBLE_SITE_ID + or not settings.PLAUSIBLE_AUTH_TOKEN + ): + logger.info("Plausible is not configured, skipping") + return + today = now().date() + yesterday = today - timedelta(days=1) + logger.info(f"Capturing Goals for {yesterday}") + + for goal in Goals: + try: + goal_name = goal.formatted_name + goal_data = get_goal_stats( + settings.PLAUSIBLE_HOST, + settings.PLAUSIBLE_SITE_ID, + yesterday, + goal_name, + ) + goal_object, _ = Goal.objects.get_or_create( + name=goal_name, + ) + goal_entry_object, _ = GoalEntry.objects.get_or_create( + goal=goal_object, + date=yesterday, + ) + goal_entry_object.visitors = goal_data["results"]["visitors"]["value"] + goal_entry_object.events = goal_data["results"]["events"]["value"] + goal_entry_object.save() + + logger.info(f"Saved goal entry for {goal_name} on {yesterday}") + + for property_name in goal.value: + goal_property_stats = get_goal_event_stats( + settings.PLAUSIBLE_HOST, + settings.PLAUSIBLE_SITE_ID, + yesterday, + goal_name, + property_name, + ) + for property_statistic in goal_property_stats["results"]: + property_object, _ = GoalProperty.objects.get_or_create( + goal=goal_object, + name=property_name, + ) + property_entry_object, _ = GoalPropertyEntry.objects.get_or_create( + goal_property=property_object, + goal_entry=goal_entry_object, + value=property_statistic[property_name], + ) + property_entry_object.visitors = property_statistic["visitors"] + property_entry_object.events = property_statistic["events"] + property_entry_object.save() + logger.info( + f"Saved goal property entry for {goal_name} and property {property_name} on {yesterday}" + ) + except Exception as e: + logger.error(f"Failed to process goal {goal_name} due to error: {str(e)}") diff --git a/care/facility/tests/test_asset_api.py b/care/facility/tests/test_asset_api.py index 58e7612f26..8467da905b 100644 --- a/care/facility/tests/test_asset_api.py +++ b/care/facility/tests/test_asset_api.py @@ -1,4 +1,4 @@ -from django.utils.timezone import datetime +from django.utils.timezone import now, timedelta from rest_framework import status from rest_framework.test import APITestCase @@ -119,7 +119,7 @@ def test_asset_filter_in_use_by_consultation(self): { "consultation": consultation.external_id, "bed": bed.external_id, - "start_date": datetime.now().isoformat(), + "start_date": now().isoformat(), "assets": [asset1.external_id, asset2.external_id], }, ) @@ -131,3 +131,37 @@ def test_asset_filter_in_use_by_consultation(self): response = self.client.get("/api/v1/asset/?in_use_by_consultation=false") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data["results"]), 1) + + def test_asset_filter_warranty_amc_end_of_validity(self): + asset1 = Asset.objects.create( + name="asset1", + current_location=self.asset_location, + warranty_amc_end_of_validity=now().date(), + ) + asset2 = Asset.objects.create( + name="asset2", + current_location=self.asset_location, + warranty_amc_end_of_validity=now().date() + timedelta(days=1), + ) + + response = self.client.get( + f"/api/v1/asset/?warranty_amc_end_of_validity_before={now().date() + timedelta(days=2)}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn( + str(asset1.external_id), [asset["id"] for asset in response.data["results"]] + ) + self.assertIn( + str(asset2.external_id), [asset["id"] for asset in response.data["results"]] + ) + + response = self.client.get( + f"/api/v1/asset/?warranty_amc_end_of_validity_after={now().date() + timedelta(days=1)}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn( + str(asset2.external_id), [asset["id"] for asset in response.data["results"]] + ) + self.assertNotIn( + str(asset1.external_id), [asset["id"] for asset in response.data["results"]] + ) diff --git a/care/facility/tests/test_asset_public_api.py b/care/facility/tests/test_asset_public_api.py index 7ad9f579c3..12b728c45e 100644 --- a/care/facility/tests/test_asset_public_api.py +++ b/care/facility/tests/test_asset_public_api.py @@ -1,7 +1,9 @@ +from django.core.cache import cache from rest_framework import status from rest_framework.test import APITestCase -from care.utils.tests.test_utils import TestUtils +from care.facility.api.serializers.asset import AssetSerializer +from care.utils.tests.test_utils import TestUtils, override_cache class AssetPublicViewSetTestCase(TestUtils, APITestCase): @@ -23,3 +25,48 @@ def test_retrieve_asset(self): def test_retrieve_nonexistent_asset(self): response = self.client.get("/api/v1/public/asset/nonexistent/") self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_retrieve_asset_qr_code(self): + response = self.client.get(f"/api/v1/public/asset_qr/{self.asset.qr_code_id}/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = self.client.get(f"/api/v1/public/asset_qr/{self.asset.id}/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_retrieve_nonexistent_asset_qr_code(self): + response = self.client.get("/api/v1/public/asset_qr/nonexistent/") + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_retrieve_asset_qr_cached(self): + with override_cache(self): + response = self.client.get( + f"/api/v1/public/asset_qr/{self.asset.qr_code_id}/" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["name"], self.asset.name) + + # Update the asset to invalidate the cache + + updated_data = { + "name": "New Updated Test Asset", + } + response = self.client.patch( + f"/api/v1/asset/{self.asset.external_id}/", updated_data + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = self.client.get( + f"/api/v1/public/asset_qr/{self.asset.qr_code_id}/" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["name"], updated_data["name"]) + + def test_retrieve_asset_qr_pre_cached(self): + with override_cache(self): + serializer = AssetSerializer(self.asset) + cache.set(f"asset:qr:{self.asset.qr_code_id}", serializer.data) + response = self.client.get( + f"/api/v1/public/asset_qr/{self.asset.qr_code_id}/" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["name"], self.asset.name) diff --git a/care/utils/tests/test_utils.py b/care/utils/tests/test_utils.py index d5cb0de873..0c884afba2 100644 --- a/care/utils/tests/test_utils.py +++ b/care/utils/tests/test_utils.py @@ -340,7 +340,8 @@ def create_asset(cls, location: AssetLocation, **kwargs) -> Asset: "name": "Test Asset", "current_location": location, "asset_type": 50, - "warranty_amc_end_of_validity": make_aware(datetime(2030, 4, 1)), + "warranty_amc_end_of_validity": make_aware(datetime(2030, 4, 1)).date(), + "qr_code_id": "3dcee5fa-8fb8-4b07-be12-8e0d0baf6692", } data.update(kwargs) return Asset.objects.create(**data) diff --git a/config/api_router.py b/config/api_router.py index 33b612d8d1..289df33b2a 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -13,6 +13,7 @@ from care.facility.api.viewsets.asset import ( AssetAvailabilityViewSet, AssetLocationViewSet, + AssetPublicQRViewSet, AssetPublicViewSet, AssetServiceViewSet, AssetTransactionViewSet, @@ -219,6 +220,7 @@ # Public endpoints router.register("public/asset", AssetPublicViewSet) +router.register("public/asset_qr", AssetPublicQRViewSet) # ABDM endpoints if settings.ENABLE_ABDM: diff --git a/config/settings/base.py b/config/settings/base.py index 053c81f882..dfdad9bf37 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -567,3 +567,7 @@ HCX_PASSWORD = env("HCX_PASSWORD", default="") HCX_ENCRYPTION_PRIVATE_KEY_URL = env("HCX_ENCRYPTION_PRIVATE_KEY_URL", default="") HCX_IG_URL = env("HCX_IG_URL", default="https://ig.hcxprotocol.io/v0.7.1") + +PLAUSIBLE_HOST = env("PLAUSIBLE_HOST", default="") +PLAUSIBLE_SITE_ID = env("PLAUSIBLE_SITE_ID", default="") +PLAUSIBLE_AUTH_TOKEN = env("PLAUSIBLE_AUTH_TOKEN", default="")