diff --git a/.github/workflows/deployment-branch.yaml b/.github/workflows/deployment-branch.yaml index 77682cf49c..776646c1ed 100644 --- a/.github/workflows/deployment-branch.yaml +++ b/.github/workflows/deployment-branch.yaml @@ -5,10 +5,8 @@ on: push: branches: - - abdm - - abdm-m2 - - hcx-communications - - fix-hcx + - abdm-m3 + - hcx_refactors paths-ignore: - "docs/**" @@ -29,33 +27,49 @@ jobs: type=raw,value=${{ github.ref_name}}-${{ github.run_number }} type=raw,value=${{ github.ref_name}} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 - - name: Cache Docker layers - uses: actions/cache@v2 - with: - path: /tmp/.buildx-cache - key: ${{ runner.os }}-buildx-${{ hashFiles('r*/base.txt', 'r*/production.txt', 'Dockerfile') }} - restore-keys: | - ${{ runner.os }}-buildx- + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Cache Docker layers + uses: actions/cache@v3 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ hashFiles('Pipfile.lock', 'docker/prod.Dockerfile') }} + restore-keys: | + ${{ runner.os }}-buildx- + - name: Build image - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v5 with: context: . - file: Dockerfile + file: docker/prod.Dockerfile push: true + provenance: false + platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} + build-args: | + APP_VERSION=${{ github.sha }} cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new + cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max + + - name: Create Sentry release + uses: getsentry/action-release@v1 + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + with: + version: ${{ github.sha }} - name: Move cache run: | 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/deployment.yaml b/.github/workflows/deployment.yaml index ca12a7dd38..f50eafbbee 100644 --- a/.github/workflows/deployment.yaml +++ b/.github/workflows/deployment.yaml @@ -10,7 +10,7 @@ on: - "docs/**" concurrency: - group: ${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: @@ -55,33 +55,46 @@ jobs: flavor: | latest=true + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Cache Docker layers + uses: actions/cache@v3 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ hashFiles('Pipfile.lock', 'docker/prod.Dockerfile') }} + restore-keys: | + ${{ runner.os }}-buildx- + - name: Build image - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v5 with: context: . file: docker/prod.Dockerfile push: true + provenance: false + platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} build-args: | APP_VERSION=${{ github.sha }} - cache-from: type=gha,scope=cached-stage - cache-to: type=gha,scope=cached-stage,mode=max + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max - name: Create Sentry release uses: getsentry/action-release@v1 @@ -92,6 +105,11 @@ jobs: with: version: ${{ github.sha }} + - name: Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + build-production: needs: test name: Build & Push Production to container registries @@ -100,9 +118,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - name: Docker meta id: meta uses: docker/metadata-action@v4 @@ -118,30 +133,46 @@ jobs: flavor: | latest=false + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Cache Docker layers + uses: actions/cache@v3 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ hashFiles('Pipfile.lock', 'docker/prod.Dockerfile') }} + restore-keys: | + ${{ runner.os }}-buildx- + - name: Build image - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v5 with: context: . file: docker/prod.Dockerfile push: true + provenance: false + platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} build-args: | APP_VERSION=${{ github.sha }} - cache-from: type=gha,scope=cached-stage - cache-to: type=gha,scope=cached-stage,mode=max + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max - name: Create Sentry release uses: getsentry/action-release@v1 @@ -152,6 +183,11 @@ jobs: with: version: ${{ github.sha }} + - name: Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + deploy-staging-egov: needs: build-staging name: Deploy to ECS API Egov 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/.github/workflows/test-base.yml b/.github/workflows/test-base.yml index b5ef741be5..87a611f4a0 100644 --- a/.github/workflows/test-base.yml +++ b/.github/workflows/test-base.yml @@ -10,17 +10,23 @@ jobs: - uses: actions/checkout@v4 - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 + + - name: Cache Docker layers + uses: actions/cache@v3 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ hashFiles('Pipfile.lock', 'docker/prod.Dockerfile') }} + restore-keys: | + ${{ runner.os }}-buildx- - name: Bake docker images - uses: docker/bake-action@v3.1.0 + uses: docker/bake-action@v4 with: load: true - builder: ${{ steps.buildx.outputs.name }} set: | - *.cache-from=type=gha,scope=cached-stage - *.cache-to=type=gha,scope=cached-stage,mode=max + *.cache-from=type=local,src=/tmp/.buildx-cache + *.cache-to=type=local,dest=/tmp/.buildx-cache-new files: docker-compose.yaml,docker-compose.local.yaml - name: Start services @@ -34,3 +40,8 @@ jobs: - name: Upload coverage report uses: codecov/codecov-action@v3 + + - name: Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d2cfbb4aac..e50648ab5a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,7 +4,7 @@ on: pull_request: concurrency: - group: ${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: 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/asset.py b/care/facility/api/serializers/asset.py index 57692a8519..d52c46fe34 100644 --- a/care/facility/api/serializers/asset.py +++ b/care/facility/api/serializers/asset.py @@ -34,12 +34,22 @@ from care.utils.assetintegration.ventilator import VentilatorAsset from care.utils.queryset.facility import get_facility_queryset from config.serializers import ChoiceField +from config.validators import MiddlewareDomainAddressValidator class AssetLocationSerializer(ModelSerializer): facility = FacilityBareMinimumSerializer(read_only=True) id = UUIDField(source="external_id", read_only=True) + def validate_middleware_address(self, value): + value = (value or "").strip() + if not value: + return value + + # Check if the address is valid + MiddlewareDomainAddressValidator()(value) + return value + def validate(self, data): facility = self.context["facility"] if "name" in data: diff --git a/care/facility/api/serializers/daily_round.py b/care/facility/api/serializers/daily_round.py index 340962d86d..94f1297977 100644 --- a/care/facility/api/serializers/daily_round.py +++ b/care/facility/api/serializers/daily_round.py @@ -211,7 +211,6 @@ def create(self, validated_data): "other_symptoms", "physical_examination_info", "other_details", - "recommend_discharge", "bp", "pulse", "resp", @@ -291,8 +290,8 @@ def create(self, validated_data): self.update_last_daily_round(daily_round_obj) return daily_round_obj - def validate(self, obj): - validated = super().validate(obj) + def validate(self, attrs): + validated = super().validate(attrs) if validated["consultation"].discharge_date: raise ValidationError( diff --git a/care/facility/api/serializers/facility.py b/care/facility/api/serializers/facility.py index eebd4a5d23..a7ac3820ed 100644 --- a/care/facility/api/serializers/facility.py +++ b/care/facility/api/serializers/facility.py @@ -138,6 +138,8 @@ class Meta: read_only_fields = ("modified_date", "created_date") def validate_middleware_address(self, value): + if not value: + raise serializers.ValidationError("Middleware Address is required") value = value.strip() if not value: return value diff --git a/care/facility/api/serializers/patient_consultation.py b/care/facility/api/serializers/patient_consultation.py index bc86b8515e..726facf027 100644 --- a/care/facility/api/serializers/patient_consultation.py +++ b/care/facility/api/serializers/patient_consultation.py @@ -106,6 +106,8 @@ class PatientConsultationSerializer(serializers.ModelSerializer): read_only=True ) + medico_legal_case = serializers.BooleanField(default=False, required=False) + def get_discharge_prescription(self, consultation): return Prescription.objects.filter( consultation=consultation, @@ -153,9 +155,14 @@ def update(self, instance, validated_data): instance.last_edited_by = self.context["request"].user if instance.discharge_date: - raise ValidationError( - {"consultation": ["Discharged Consultation data cannot be updated"]} - ) + if "medico_legal_case" not in validated_data: + raise ValidationError( + {"consultation": ["Discharged Consultation data cannot be updated"]} + ) + else: + instance.medico_legal_case = validated_data.pop("medico_legal_case") + instance.save() + return instance if instance.suggestion == SuggestionChoices.OP: instance.discharge_date = localtime(now()) diff --git a/care/facility/api/serializers/prescription.py b/care/facility/api/serializers/prescription.py index 4af84080ea..49ad3d9913 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: @@ -94,6 +94,7 @@ class MedicineAdministrationSerializer(serializers.ModelSerializer): administered_by = UserBaseMinimumSerializer(read_only=True) prescription = PrescriptionSerializer(read_only=True) + archived_by = UserBaseMinimumSerializer(read_only=True) def validate_administered_date(self, value): if value > timezone.now(): @@ -112,6 +113,8 @@ class Meta: read_only_fields = ( "external_id", "administered_by", + "archived_by", + "archived_on", "created_date", "modified_date", "prescription", diff --git a/care/facility/api/viewsets/asset.py b/care/facility/api/viewsets/asset.py index be7c203ebd..ae807ac347 100644 --- a/care/facility/api/viewsets/asset.py +++ b/care/facility/api/viewsets/asset.py @@ -1,6 +1,8 @@ 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 +60,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 +131,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 +184,27 @@ 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): + qr_code_id = kwargs["qr_code_id"] + key = "asset:qr:" + qr_code_id + hit = cache.get(key) + if not hit: + instance = self.get_queryset().filter(qr_code_id=qr_code_id).first() + if not instance: # If the asset is not found, try to find it by pk + if not qr_code_id.isnumeric(): + return Response(status=status.HTTP_404_NOT_FOUND) + instance = get_object_or_404(self.get_queryset(), pk=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") @@ -290,10 +321,17 @@ def operate_assets(self, request, *args, **kwargs): try: action = request.data["action"] asset: Asset = self.get_object() + middleware_hostname = ( + asset.meta.get( + "middleware_hostname", + asset.current_location.middleware_address, + ) + or asset.current_location.facility.middleware_address + ) asset_class: BaseAssetIntegration = AssetClasses[asset.asset_class].value( { **asset.meta, - "middleware_hostname": asset.current_location.facility.middleware_address, + "middleware_hostname": middleware_hostname, } ) result = asset_class.handle_action(action) diff --git a/care/facility/api/viewsets/icd.py b/care/facility/api/viewsets/icd.py index 2b5851d05c..56561b9b70 100644 --- a/care/facility/api/viewsets/icd.py +++ b/care/facility/api/viewsets/icd.py @@ -24,6 +24,7 @@ def list(self, request): if request.GET.get("query", False): query = request.GET.get("query") queryset = queryset.where( - label=queryset.re_match(r".*" + query + r".*", IGNORECASE) + label=queryset.re_match(r".*" + query + r".*", IGNORECASE), + is_leaf=True, ) # can accept regex from FE if needed. return Response(serailize_data(queryset[0:100])) diff --git a/care/facility/api/viewsets/patient.py b/care/facility/api/viewsets/patient.py index 0a502dc06a..2fe2e08954 100644 --- a/care/facility/api/viewsets/patient.py +++ b/care/facility/api/viewsets/patient.py @@ -149,6 +149,9 @@ def filter_by_category(self, queryset, name, value): last_consultation_admitted_bed_type_list = MultiSelectFilter( method="filter_by_bed_type", ) + last_consultation_medico_legal_case = filters.BooleanFilter( + field_name="last_consultation__medico_legal_case" + ) def filter_by_bed_type(self, queryset, name, value): if not value: diff --git a/care/facility/api/viewsets/prescription.py b/care/facility/api/viewsets/prescription.py index b64a6a6f44..5af3a7473f 100644 --- a/care/facility/api/viewsets/prescription.py +++ b/care/facility/api/viewsets/prescription.py @@ -1,4 +1,5 @@ from django.shortcuts import get_object_or_404 +from django.utils import timezone from django_filters import rest_framework as filters from drf_spectacular.utils import extend_schema from rest_framework import mixins, status @@ -34,6 +35,12 @@ def inverse_choices(choices): class MedicineAdminstrationFilter(filters.FilterSet): prescription = filters.UUIDFilter(field_name="prescription__external_id") administered_date = filters.DateFromToRangeFilter(field_name="administered_date") + archived = filters.BooleanFilter(method="archived_filter") + + def archived_filter(self, queryset, name, value): + if value is None: + return queryset + return queryset.exclude(archived_on__isnull=value) class MedicineAdministrationViewSet( @@ -57,6 +64,19 @@ def get_queryset(self): consultation_obj = self.get_consultation_obj() return self.queryset.filter(prescription__consultation_id=consultation_obj.id) + @extend_schema(tags=["prescription_administration"]) + @action(methods=["POST"], detail=True) + def archive(self, request, *args, **kwargs): + instance = self.get_object() + if instance.archived_on: + return Response( + {"error": "Already Archived"}, status=status.HTTP_400_BAD_REQUEST + ) + instance.archived_by = request.user + instance.archived_on = timezone.now() + instance.save() + return Response({}, status=status.HTTP_200_OK) + class ConsultationPrescriptionFilter(filters.FilterSet): is_prn = filters.BooleanFilter() @@ -126,22 +146,6 @@ def administer(self, request, *args, **kwargs): serializer.save(prescription=prescription_obj, administered_by=request.user) return Response(serializer.data, status=status.HTTP_201_CREATED) - # @action(methods=["GET"], detail=True) - # def get_administrations(self, request, *args, **kwargs): - # prescription_obj = self.get_object() - # serializer = MedicineAdministrationSerializer( - # MedicineAdministration.objects.filter(prescription_id=prescription_obj.id), - # many=True) - # return Response(serializer.data) - - # @action(methods=["DELETE"], detail=True) - # def delete_administered(self, request, *args, **kwargs): - # if not request.query_params.get("id", None): - # return Response({"success": False, "error": "id is required"}, status=status.HTTP_400_BAD_REQUEST) - # administered_obj = MedicineAdministration.objects.get(external_id=request.query_params.get("id", None)) - # administered_obj.delete() - # return Response({"success": True}, status=status.HTTP_200_OK) - class MedibaseViewSet(ViewSet): permission_classes = (IsAuthenticated,) diff --git a/care/facility/management/commands/load_meta_icd11_diagnosis.py b/care/facility/management/commands/load_icd11_diagnoses_data.py similarity index 68% rename from care/facility/management/commands/load_meta_icd11_diagnosis.py rename to care/facility/management/commands/load_icd11_diagnoses_data.py index 1ffae591b0..4ad383bba7 100644 --- a/care/facility/management/commands/load_meta_icd11_diagnosis.py +++ b/care/facility/management/commands/load_icd11_diagnoses_data.py @@ -1,17 +1,46 @@ +import json + from django.core.management import BaseCommand, CommandError -from care.facility.models.meta_icd11_diagnosis import MetaICD11Diagnosis -from care.facility.static_data.icd11 import fetch_data +from care.facility.models.icd11_diagnosis import ICD11Diagnosis + + +def fetch_data(): + with open("data/icd11.json", "r") as json_file: + return json.load(json_file) + + +ICD11_ID_SUFFIX_TO_INT = { + "mms": 1, + "other": 2, + "unspecified": 3, +} + + +def icd11_id_to_int(icd11_id): + """ + Maps ICD11 ID to an integer. + + Eg: + - http://id.who.int/icd/entity/594985340 -> 594985340 + - http://id.who.int/icd/entity/594985340/mms -> 5949853400001 + - http://id.who.int/icd/entity/594985340/mms/unspecified -> 5949853400003 + """ + entity_id = icd11_id.replace("http://id.who.int/icd/entity/", "") + if entity_id.isnumeric(): + return int(entity_id) + segments = entity_id.split("/") + return int(segments[0]) * 1e3 + ICD11_ID_SUFFIX_TO_INT[segments[-1]] class Command(BaseCommand): """ Management command to load ICD11 diagnoses to database. Not for production use. - Usage: python manage.py load_meta_icd11_diagnosis + Usage: python manage.py load_icd11_diagnoses """ - help = "Loads ICD11 data to a table in to database." + help = "Loads ICD11 diagnoses data to database" data = [] roots_lookup = {} @@ -29,8 +58,9 @@ class Command(BaseCommand): """ CLASS_KIND_DB_KEYS = { - "block": "root_block", - "category": "root_category", + "chapter": "meta_chapter", + "block": "meta_root_block", + "category": "meta_root_category", } ICD11_GROUP_LABEL_PRETTY = { @@ -55,13 +85,12 @@ class Command(BaseCommand): "19 Certain conditions originating in the perinatal period": "Neonatology", "20 Developmental anomalies": "Developmental Anomalies", "21 Symptoms, signs or clinical findings, not elsewhere classified": "Others", - "22 Injury, poisoning or certain other consequences of external causes": "Injury, Poisoning ", + "22 Injury, poisoning or certain other consequences of external causes": "Injury, Poisoning", "23 External causes of morbidity or mortality": "External Causes of Injury", "24 Factors influencing health status or contact with health services": None, "25 Codes for special purposes": "Codes for special purposes", "26 Supplementary Chapter Traditional Medicine Conditions - Module I": None, - "V Supplementary section for functioning assessment": "Functioning assessment ", - "X Extension Codes": "NOT RELEVANT", + "V Supplementary section for functioning assessment": "Functioning assessment", } def find_roots(self, item): @@ -98,7 +127,7 @@ def my(x): ) def handle(self, *args, **options): - print("Loading ICD11 data to DB Table (meta_icd11_diagnosis)...") + print("Loading ICD11 diagnoses data to database...") try: self.data = fetch_data() @@ -110,29 +139,27 @@ def roots(item): result = { self.CLASS_KIND_DB_KEYS.get(k, k): v for k, v in roots.items() } - result["chapter_short"] = mapped - result["deleted"] = mapped is None + result["meta_chapter_short"] = mapped + result["meta_hidden"] = mapped is None return result - MetaICD11Diagnosis.objects.all().delete() - MetaICD11Diagnosis.objects.bulk_create( + ICD11Diagnosis.objects.all().delete() + ICD11Diagnosis.objects.bulk_create( [ - MetaICD11Diagnosis( - id=icd11_object["ID"], - _id=int(icd11_object["ID"].split("/")[-1]), - average_depth=icd11_object["averageDepth"], - is_adopted_child=icd11_object["isAdoptedChild"], - parent_id=icd11_object["parentId"], - class_kind=icd11_object["classKind"], - is_leaf=icd11_object["isLeaf"], - label=icd11_object["label"], - breadth_value=icd11_object["breadthValue"], - **roots(icd11_object), + ICD11Diagnosis( + id=icd11_id_to_int(obj["ID"]), + icd11_id=obj["ID"], + label=obj["label"], + class_kind=obj["classKind"], + is_leaf=obj["isLeaf"], + parent_id=obj["parentId"] and icd11_id_to_int(obj["parentId"]), + average_depth=obj["averageDepth"], + is_adopted_child=obj["isAdoptedChild"], + breadth_value=obj["breadthValue"], + **roots(obj), ) - for icd11_object in self.data - if icd11_object["ID"].split("/")[-1].isnumeric() + for obj in self.data ] ) - print("Done loading ICD11 data to database.") except Exception as e: raise CommandError(e) diff --git a/care/facility/migrations/0388_alter_historicalpatientregistration_action_and_more.py b/care/facility/migrations/0388_alter_historicalpatientregistration_action_and_more.py new file mode 100644 index 0000000000..c8324749b6 --- /dev/null +++ b/care/facility/migrations/0388_alter_historicalpatientregistration_action_and_more.py @@ -0,0 +1,62 @@ +from django.db import migrations, models + + +def update_recommend_discharge(apps, schema_editor): + patient_model = apps.get_model("facility", "PatientRegistration") + patient_model.objects.filter( + last_consultation__last_daily_round__recommend_discharge=True + ).update(action=90) + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0387_merge_20230911_2303"), + ] + + operations = [ + migrations.AlterField( + model_name="historicalpatientregistration", + name="action", + field=models.IntegerField( + blank=True, + choices=[ + (10, "NO_ACTION"), + (20, "PENDING"), + (30, "SPECIALIST_REQUIRED"), + (40, "PLAN_FOR_HOME_CARE"), + (50, "FOLLOW_UP_NOT_REQUIRED"), + (60, "COMPLETE"), + (70, "REVIEW"), + (80, "NOT_REACHABLE"), + (90, "DISCHARGE_RECOMMENDED"), + ], + default=10, + null=True, + ), + ), + migrations.AlterField( + model_name="patientregistration", + name="action", + field=models.IntegerField( + blank=True, + choices=[ + (10, "NO_ACTION"), + (20, "PENDING"), + (30, "SPECIALIST_REQUIRED"), + (40, "PLAN_FOR_HOME_CARE"), + (50, "FOLLOW_UP_NOT_REQUIRED"), + (60, "COMPLETE"), + (70, "REVIEW"), + (80, "NOT_REACHABLE"), + (90, "DISCHARGE_RECOMMENDED"), + ], + default=10, + null=True, + ), + ), + migrations.RunPython(update_recommend_discharge), + migrations.RemoveField( + model_name="dailyround", + name="recommend_discharge", + ), + ] 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/migrations/0388_icd11diagnosis.py b/care/facility/migrations/0388_icd11diagnosis.py new file mode 100644 index 0000000000..97de51a8c9 --- /dev/null +++ b/care/facility/migrations/0388_icd11diagnosis.py @@ -0,0 +1,57 @@ +# Generated by Django 4.2.2 on 2023-09-25 13:00 + +import django.db.models.deletion +from django.core.management import call_command +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0387_merge_20230911_2303"), + ] + + operations = [ + migrations.CreateModel( + name="ICD11Diagnosis", + fields=[ + ("id", models.BigIntegerField(primary_key=True, serialize=False)), + ("icd11_id", models.CharField(max_length=255, unique=True)), + ("label", models.CharField(max_length=255)), + ( + "class_kind", + models.CharField( + choices=[ + ("chapter", "Chapter"), + ("block", "Block"), + ("category", "Category"), + ], + max_length=255, + ), + ), + ("is_leaf", models.BooleanField()), + ("average_depth", models.IntegerField()), + ("is_adopted_child", models.BooleanField()), + ( + "breadth_value", + models.DecimalField(decimal_places=22, max_digits=24), + ), + ("meta_hidden", models.BooleanField(default=False)), + ("meta_chapter", models.CharField(max_length=255)), + ("meta_chapter_short", models.CharField(max_length=255, null=True)), + ("meta_root_block", models.CharField(max_length=255, null=True)), + ("meta_root_category", models.CharField(max_length=255, null=True)), + ( + "parent", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to="facility.icd11diagnosis", + ), + ), + ], + ), + migrations.RunPython( + lambda apps, schema_editor: call_command("load_icd11_diagnoses_data"), + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/care/facility/migrations/0389_assetlocation_middleware_address.py b/care/facility/migrations/0389_assetlocation_middleware_address.py new file mode 100644 index 0000000000..9e162451eb --- /dev/null +++ b/care/facility/migrations/0389_assetlocation_middleware_address.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.2 on 2023-09-29 06:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0388_goal_goalentry_goalproperty_goalpropertyentry"), + ] + + operations = [ + migrations.AddField( + model_name="assetlocation", + name="middleware_address", + field=models.CharField(blank=True, default=None, max_length=200, null=True), + ), + ] diff --git a/care/facility/migrations/0389_medicineadministration_archived_by_and_more.py b/care/facility/migrations/0389_medicineadministration_archived_by_and_more.py new file mode 100644 index 0000000000..4188b48723 --- /dev/null +++ b/care/facility/migrations/0389_medicineadministration_archived_by_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.2 on 2023-09-27 09:38 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("facility", "0388_goal_goalentry_goalproperty_goalpropertyentry"), + ] + + operations = [ + migrations.AddField( + model_name="medicineadministration", + name="archived_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="medicineadministration", + name="archived_on", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/care/facility/migrations/0389_merge_20231002_1802.py b/care/facility/migrations/0389_merge_20231002_1802.py new file mode 100644 index 0000000000..17a11528c7 --- /dev/null +++ b/care/facility/migrations/0389_merge_20231002_1802.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.5 on 2023-10-02 12:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0388_alter_historicalpatientregistration_action_and_more"), + ("facility", "0388_goal_goalentry_goalproperty_goalpropertyentry"), + ] + + operations = [] diff --git a/care/facility/migrations/0389_merge_20231005_2247.py b/care/facility/migrations/0389_merge_20231005_2247.py new file mode 100644 index 0000000000..28de9d2966 --- /dev/null +++ b/care/facility/migrations/0389_merge_20231005_2247.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.5 on 2023-10-05 17:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0388_goal_goalentry_goalproperty_goalpropertyentry"), + ("facility", "0388_icd11diagnosis"), + ] + + operations = [] diff --git a/care/facility/migrations/0390_patientconsultation_medico_legal_case.py b/care/facility/migrations/0390_patientconsultation_medico_legal_case.py new file mode 100644 index 0000000000..1e90f034ba --- /dev/null +++ b/care/facility/migrations/0390_patientconsultation_medico_legal_case.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.6 on 2023-10-11 06:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0389_merge_20231005_2247"), + ] + + operations = [ + migrations.AddField( + model_name="patientconsultation", + name="medico_legal_case", + field=models.BooleanField(default=False), + ), + ] diff --git a/care/facility/migrations/0391_merge_20231016_1845.py b/care/facility/migrations/0391_merge_20231016_1845.py new file mode 100644 index 0000000000..cce70587d2 --- /dev/null +++ b/care/facility/migrations/0391_merge_20231016_1845.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.5 on 2023-10-16 13:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0389_assetlocation_middleware_address"), + ("facility", "0389_medicineadministration_archived_by_and_more"), + ("facility", "0389_merge_20231002_1802"), + ("facility", "0390_patientconsultation_medico_legal_case"), + ] + + operations = [] diff --git a/care/facility/models/__init__.py b/care/facility/models/__init__.py index 2c4ef59373..92cf8dc70b 100644 --- a/care/facility/models/__init__.py +++ b/care/facility/models/__init__.py @@ -6,6 +6,7 @@ from .bed import * # noqa from .daily_round import * # noqa from .facility import * # noqa +from .icd11_diagnosis import * # noqa from .inventory import * # noqa from .meta_icd11_diagnosis import * # noqa from .patient import * # noqa diff --git a/care/facility/models/asset.py b/care/facility/models/asset.py index 353a2d5a84..bbdf39f957 100644 --- a/care/facility/models/asset.py +++ b/care/facility/models/asset.py @@ -46,6 +46,10 @@ class RoomType(enum.Enum): Facility, on_delete=models.PROTECT, null=False, blank=False ) + middleware_address = models.CharField( + null=True, blank=True, default=None, max_length=200 + ) + class AssetType(enum.Enum): INTERNAL = 50 @@ -113,19 +117,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 +138,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/daily_round.py b/care/facility/models/daily_round.py index b004baa0cb..1b3fdddb6c 100644 --- a/care/facility/models/daily_round.py +++ b/care/facility/models/daily_round.py @@ -122,7 +122,9 @@ class InsulinIntakeFrequencyType(enum.Enum): ] consultation = models.ForeignKey( - PatientConsultation, on_delete=models.PROTECT, related_name="daily_rounds" + PatientConsultation, + on_delete=models.PROTECT, + related_name="daily_rounds", ) temperature = models.DecimalField( decimal_places=2, @@ -158,9 +160,6 @@ class InsulinIntakeFrequencyType(enum.Enum): current_health = models.IntegerField( default=0, choices=CURRENT_HEALTH_CHOICES, blank=True ) - recommend_discharge = models.BooleanField( - default=False, verbose_name="Recommend Discharging Patient" - ) other_details = models.TextField(null=True, blank=True) medication_given = JSONField(default=dict) # To be Used Later on @@ -168,7 +167,10 @@ class InsulinIntakeFrequencyType(enum.Enum): created_by_telemedicine = models.BooleanField(default=False) created_by = models.ForeignKey( - User, on_delete=models.SET_NULL, null=True, related_name="update_created_user" + User, + on_delete=models.SET_NULL, + null=True, + related_name="update_created_user", ) last_edited_by = models.ForeignKey( @@ -265,7 +267,8 @@ class InsulinIntakeFrequencyType(enum.Enum): rhythm = models.IntegerField(choices=RythmnChoice, default=RythmnType.UNKNOWN.value) rhythm_detail = models.TextField(default=None, null=True, blank=True) ventilator_interface = models.IntegerField( - choices=VentilatorInterfaceChoice, default=VentilatorInterfaceType.UNKNOWN.value + choices=VentilatorInterfaceChoice, + default=VentilatorInterfaceType.UNKNOWN.value, ) ventilator_mode = models.IntegerField( choices=VentilatorModeChoice, default=VentilatorModeType.UNKNOWN.value @@ -339,7 +342,8 @@ class InsulinIntakeFrequencyType(enum.Enum): validators=[MinValueValidator(0), MaxValueValidator(10)], ) pain_scale_enhanced = JSONField( - default=list, validators=[JSONFieldSchemaValidator(PAIN_SCALE_ENHANCED)] + default=list, + validators=[JSONFieldSchemaValidator(PAIN_SCALE_ENHANCED)], ) ph = models.DecimalField( decimal_places=2, @@ -457,9 +461,26 @@ def cztn(self, value): return value def update_pressure_sore(self): - area_interval_points = [0.1, 0.3, 0.7, 1.1, 2.1, 3.1, 4.1, 8.1, 12.1, 25] + area_interval_points = [ + 0.1, + 0.3, + 0.7, + 1.1, + 2.1, + 3.1, + 4.1, + 8.1, + 12.1, + 25, + ] exudate_amounts = ["None", "Light", "Moderate", "Heavy"] - tissue_types = ["Closed", "Epithelial", "Granulation", "Slough", "Necrotic"] + tissue_types = [ + "Closed", + "Epithelial", + "Granulation", + "Slough", + "Necrotic", + ] def cal_push_score(item): push_score = item.get("base_score", 0.0) diff --git a/care/facility/models/icd11_diagnosis.py b/care/facility/models/icd11_diagnosis.py new file mode 100644 index 0000000000..959340fe12 --- /dev/null +++ b/care/facility/models/icd11_diagnosis.py @@ -0,0 +1,33 @@ +from django.db import models + + +class ICD11ClassKind(models.TextChoices): + CHAPTER = "chapter" + BLOCK = "block" + CATEGORY = "category" + + +class ICD11Diagnosis(models.Model): + """ + Use ICDDiseases for in-memory search. + """ + + id = models.BigIntegerField(primary_key=True) + icd11_id = models.CharField(max_length=255, unique=True) + label = models.CharField(max_length=255) + class_kind = models.CharField(max_length=255, choices=ICD11ClassKind.choices) + is_leaf = models.BooleanField() + parent = models.ForeignKey("self", on_delete=models.DO_NOTHING, null=True) + average_depth = models.IntegerField() + is_adopted_child = models.BooleanField() + breadth_value = models.DecimalField(max_digits=24, decimal_places=22) + + # Meta fields + meta_hidden = models.BooleanField(default=False) + meta_chapter = models.CharField(max_length=255) + meta_chapter_short = models.CharField(max_length=255, null=True) + meta_root_block = models.CharField(max_length=255, null=True) + meta_root_category = models.CharField(max_length=255, null=True) + + def __str__(self) -> str: + return self.label diff --git a/care/facility/models/meta_icd11_diagnosis.py b/care/facility/models/meta_icd11_diagnosis.py index 4a2bb45da7..8d5a16bee0 100644 --- a/care/facility/models/meta_icd11_diagnosis.py +++ b/care/facility/models/meta_icd11_diagnosis.py @@ -4,6 +4,8 @@ class MetaICD11Diagnosis(models.Model): """ Not for production use. For Metabase purposes only. Do not build relations to this model. + + Deprecated in favor of ICD11Diagnosis. This table will be removed in the future. """ id = models.CharField(max_length=255, primary_key=True) diff --git a/care/facility/models/patient.py b/care/facility/models/patient.py index 44fe0ee1a6..cd14e5fcfa 100644 --- a/care/facility/models/patient.py +++ b/care/facility/models/patient.py @@ -77,6 +77,7 @@ class ActionEnum(enum.Enum): COMPLETE = 60 REVIEW = 70 NOT_REACHABLE = 80 + DISCHARGE_RECOMMENDED = 90 ActionChoices = [(e.value, e.name) for e in ActionEnum] diff --git a/care/facility/models/patient_consultation.py b/care/facility/models/patient_consultation.py index c43f6fd5da..6707e967a5 100644 --- a/care/facility/models/patient_consultation.py +++ b/care/facility/models/patient_consultation.py @@ -143,6 +143,8 @@ class PatientConsultation(PatientBaseModel, PatientRelatedPermissionMixin): related_name="patient_assigned_to", ) + medico_legal_case = models.BooleanField(default=False) + deprecated_verified_by = models.TextField(default="", null=True, blank=True) verified_by = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, blank=True diff --git a/care/facility/models/prescription.py b/care/facility/models/prescription.py index b6aca21eab..fc05467f4e 100644 --- a/care/facility/models/prescription.py +++ b/care/facility/models/prescription.py @@ -156,6 +156,13 @@ class MedicineAdministration(BaseModel): administered_date = models.DateTimeField( null=False, blank=False, default=timezone.now ) + archived_on = models.DateTimeField(null=True, blank=True) + archived_by = models.ForeignKey( + "users.User", + on_delete=models.PROTECT, + null=True, + related_name="+", + ) def __str__(self): return ( 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/static_data/icd11.py b/care/facility/static_data/icd11.py index b535acd70c..ceb4aa48de 100644 --- a/care/facility/static_data/icd11.py +++ b/care/facility/static_data/icd11.py @@ -1,43 +1,30 @@ import contextlib -import json +from django.db import connection from littletable import Table +from care.facility.models.icd11_diagnosis import ICD11Diagnosis -def fetch_data(): - with open("data/icd11.json", "r") as json_file: - return json.load(json_file) - -def is_numeric(val): - if str(val).isnumeric(): - return val - return -1 +def fetch_from_db(): + # This is a hack to prevent the migration from failing when the table does not exist + all_tables = connection.introspection.table_names() + if "facility_icd11diagnosis" in all_tables: + return [ + { + "id": str(diagnosis["id"]), + "label": diagnosis["label"], + "is_leaf": diagnosis["is_leaf"], + } + for diagnosis in ICD11Diagnosis.objects.filter().values( + "id", "label", "is_leaf" + ) + ] + return [] ICDDiseases = Table("ICD11") -icd11_objects = fetch_data() -entity_id = "" -IGNORE_FIELDS = [ - "isLeaf", - "classKind", - "isAdoptedChild", - "averageDepth", - "breadthValue", - "Suggested", -] - -for icd11_object in icd11_objects: - for field in IGNORE_FIELDS: - icd11_object.pop(field, "") - icd11_object["id"] = icd11_object.pop("ID") - entity_id = icd11_object["id"].split("/")[-1] - icd11_object["id"] = is_numeric(entity_id) - if icd11_object["id"] == -1: - continue - if icd11_object["id"]: - ICDDiseases.insert(icd11_object) - +ICDDiseases.insert_many(fetch_from_db()) ICDDiseases.create_search_index("label") ICDDiseases.create_index("id", unique=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/asset_monitor.py b/care/facility/tasks/asset_monitor.py index 3c0b8e2fbe..89210d6892 100644 --- a/care/facility/tasks/asset_monitor.py +++ b/care/facility/tasks/asset_monitor.py @@ -31,10 +31,18 @@ def check_asset_status(): continue try: # Fetching middleware hostname - hostname = asset.meta.get( - "middleware_hostname", - asset.current_location.facility.middleware_address, + hostname = ( + asset.meta.get( + "middleware_hostname", + asset.current_location.middleware_address, + ) + or asset.current_location.facility.middleware_address ) + if not hostname: + logger.warn( + f"Asset {asset.external_id} does not have a middleware hostname" + ) + continue result: Any = None # Checking if middleware status is already cached @@ -62,6 +70,7 @@ def check_asset_status(): asset_class="ONVIF" ).filter( Q(meta__middleware_hostname=hostname) + | Q(current_location__middleware_address=hostname) | Q(current_location__facility__middleware_address=hostname) ) assets_config = [] 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_location_api.py b/care/facility/tests/test_asset_location_api.py index 0228a5dcb9..b859823c1b 100644 --- a/care/facility/tests/test_asset_location_api.py +++ b/care/facility/tests/test_asset_location_api.py @@ -27,21 +27,51 @@ def test_retrieve_asset_location(self): f"/api/v1/facility/{self.facility.external_id}/asset_location/{self.asset_location.external_id}/" ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertContains(response, self.asset_location.external_id) + self.assertEqual(response.data["id"], str(self.asset_location.external_id)) + self.assertEqual( + response.data["middleware_address"], self.asset_location.middleware_address + ) def test_create_asset_location(self): - sample_data = {"name": "Test Asset Location"} + sample_data = { + "name": "Test Asset Location", + "middleware_address": "example.com", + } response = self.client.post( f"/api/v1/facility/{self.facility.external_id}/asset_location/", sample_data, ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["name"], sample_data["name"]) + self.assertEqual( + response.data["middleware_address"], sample_data["middleware_address"] + ) def test_update_asset_location(self): - sample_data = {"name": "Updated Test Asset Location"} + sample_data = { + "name": "Updated Test Asset Location", + "middleware_address": "updated.example.com", + } response = self.client.patch( f"/api/v1/facility/{self.facility.external_id}/asset_location/{self.asset_location.external_id}/", sample_data, ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["name"], sample_data["name"]) + self.assertEqual( + response.data["middleware_address"], sample_data["middleware_address"] + ) + + def test_create_asset_location_invalid_middleware(self): + sample_data = { + "name": "Test Asset Location", + "middleware_address": "https://invalid.middleware.///", + } + response = self.client.post( + f"/api/v1/facility/{self.facility.external_id}/asset_location/", + sample_data, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data["middleware_address"][0].code, "invalid_domain_name" + ) 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/facility/tests/test_medicine_administrations_api.py b/care/facility/tests/test_medicine_administrations_api.py index 6283543e7a..dd99877261 100644 --- a/care/facility/tests/test_medicine_administrations_api.py +++ b/care/facility/tests/test_medicine_administrations_api.py @@ -36,7 +36,8 @@ def create_prescription(self, **kwargs): **{**data, **kwargs, "prescribed_by": self.user} ) - def test_administer(self): + def test_administer_and_archive(self): + # test administer prescription = self.normal_prescription res = self.client.post( f"/api/v1/consultation/{prescription.consultation.external_id}/prescriptions/{prescription.external_id}/administer/", @@ -44,6 +45,44 @@ def test_administer(self): ) self.assertEqual(res.status_code, status.HTTP_201_CREATED) + administration_id = res.data["id"] + + # test archive + archive_path = f"/api/v1/consultation/{prescription.consultation.external_id}/prescription_administration/{administration_id}/archive/" + res = self.client.post(archive_path, {}) + self.assertEqual(res.status_code, status.HTTP_200_OK) + + # test archive again + res = self.client.post(archive_path, {}) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + + # test list administrations + res = self.client.get( + f"/api/v1/consultation/{prescription.consultation.external_id}/prescription_administration/" + ) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertTrue( + any([administration_id == x["id"] for x in res.data["results"]]) + ) + + # test archived list administrations + res = self.client.get( + f"/api/v1/consultation/{prescription.consultation.external_id}/prescription_administration/?archived=true" + ) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertTrue( + any([administration_id == x["id"] for x in res.data["results"]]) + ) + + # test archived list administrations + res = self.client.get( + f"/api/v1/consultation/{prescription.consultation.external_id}/prescription_administration/?archived=false" + ) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertFalse( + any([administration_id == x["id"] for x in res.data["results"]]) + ) + def test_administer_in_future(self): prescription = self.normal_prescription res = self.client.post( diff --git a/care/facility/tests/test_patient_consultation_api.py b/care/facility/tests/test_patient_consultation_api.py index 4330ebb170..2ca154bfe5 100644 --- a/care/facility/tests/test_patient_consultation_api.py +++ b/care/facility/tests/test_patient_consultation_api.py @@ -256,3 +256,54 @@ def test_referred_to_external_valid_value(self): referred_to_external=referred_to_external, ) self.assertEqual(res.status_code, status.HTTP_200_OK) + + def test_medico_legal_case(self): + consultation = self.create_admission_consultation( + medico_legal_case=True, + ) + url = self.get_url(consultation) + + data = self.client.get(url) + self.assertEqual(data.status_code, status.HTTP_200_OK) + self.assertEqual(data.data["medico_legal_case"], True) + + # Test Patch + response = self.update_consultation(consultation, medico_legal_case=False) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["medico_legal_case"], False) + + # Test Patch after discharge + response = self.discharge( + consultation, + discharge_reason="REC", + discharge_date="2023-07-01T12:00:00Z", + discharge_notes="Discharged with valid referred_to_external", + medico_legal_case=False, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = self.client.get(url) + self.assertEqual(data.status_code, status.HTTP_200_OK) + self.assertEqual(data.data["medico_legal_case"], False) + + response = self.update_consultation(consultation, medico_legal_case=True) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["medico_legal_case"], True) + + def test_update_consultation_after_discharge(self): + consultation = self.create_admission_consultation( + suggestion="A", + admission_date=make_aware(datetime.datetime(2020, 4, 1, 15, 30, 00)), + ) + res = self.discharge( + consultation, + discharge_reason="REC", + discharge_date="2020-04-02T15:30:00Z", + discharge_notes="Discharge as recovered after admission before future", + ) + self.assertEqual(res.status_code, status.HTTP_200_OK) + + res = self.update_consultation( + consultation, symptoms=[1, 2], category="MILD", suggestion="A" + ) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/care/facility/tests/test_patient_daily_rounds_api.py b/care/facility/tests/test_patient_daily_rounds_api.py index cd5a1eb56b..f53562db9a 100644 --- a/care/facility/tests/test_patient_daily_rounds_api.py +++ b/care/facility/tests/test_patient_daily_rounds_api.py @@ -1,6 +1,9 @@ +import datetime + from rest_framework import status from rest_framework.test import APITestCase +from care.facility.models import PatientRegistration from care.utils.tests.test_utils import TestUtils @@ -13,6 +16,10 @@ def setUpTestData(cls) -> None: 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("staff1", cls.district, home_facility=cls.facility) + cls.patient = cls.create_patient(district=cls.district, facility=cls.facility) + cls.consultation = cls.create_consultation( + facility=cls.facility, patient=cls.patient + ) def get_url(self, external_consultation_id=None): return f"/api/v1/consultation/{external_consultation_id}/daily_rounds/analyse/" @@ -21,3 +28,26 @@ 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.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_action_in_log_update( + self, + ): + log_update = { + "clone_last": False, + "rounds_type": "NORMAL", + "patient_category": "Comfort", + "action": "DISCHARGE_RECOMMENDED", + "taken_at": datetime.datetime.now().isoformat(), + } + response = self.client.post( + f"/api/v1/consultation/{self.consultation.external_id}/daily_rounds/", + data=log_update, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["patient_category"], "Comfort Care") + self.assertEqual(response.data["rounds_type"], "NORMAL") + patient = PatientRegistration.objects.get(id=self.consultation.patient_id) + self.assertEqual( + patient.action, PatientRegistration.ActionEnum.DISCHARGE_RECOMMENDED.value + ) diff --git a/care/utils/tests/test_utils.py b/care/utils/tests/test_utils.py index d5cb0de873..d2b19705d1 100644 --- a/care/utils/tests/test_utils.py +++ b/care/utils/tests/test_utils.py @@ -220,7 +220,8 @@ def create_facility( "created_by": user, } data.update(kwargs) - return Facility.objects.create(**data) + facility = Facility.objects.create(**data) + return facility @classmethod def get_patient_data(cls, district, state) -> dict: @@ -330,7 +331,12 @@ def create_consultation( @classmethod def create_asset_location(cls, facility: Facility, **kwargs) -> AssetLocation: - data = {"name": "asset1 location", "location_type": 1, "facility": facility} + data = { + "name": "asset1 location", + "location_type": 1, + "facility": facility, + "middleware_address": "example.com", + } data.update(kwargs) return AssetLocation.objects.create(**data) @@ -340,7 +346,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="")