diff --git a/.github/workflows/deployment-branch.yaml b/.github/workflows/deployment-branch.yaml index e7761b0697..b376a6f98f 100644 --- a/.github/workflows/deployment-branch.yaml +++ b/.github/workflows/deployment-branch.yaml @@ -5,13 +5,13 @@ on: push: branches: + - abdm - abdm-m2 - hcx-communications paths-ignore: - "docs/**" jobs: - build-image: name: Build & Push Staging to container registries runs-on: ubuntu-latest @@ -28,7 +28,6 @@ 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 diff --git a/aws/backend.json b/aws/backend.json index ec3dc98474..a080073629 100644 --- a/aws/backend.json +++ b/aws/backend.json @@ -100,6 +100,26 @@ { "name": "USE_S3", "value": "True" + }, + { + "name": "ENABLE_ABDM", + "value": "True" + }, + { + "name": "ABDM_URL", + "value": "https://dev.abdm.gov.in" + }, + { + "name": "HEALTH_SERVICE_API_URL", + "value": "https://healthidsbx.abdm.gov.in/api" + }, + { + "name": "X_CM_ID", + "value": "sbx" + }, + { + "name": "FIDELIUS_URL", + "value": "https://fidelius.ohc.network" } ], "repositoryCredentials": { @@ -222,6 +242,14 @@ { "valueFrom": "/care/backend/HCX_IG_URL", "name": "HCX_IG_URL" + }, + { + "valueFrom": "/care/backend/ABDM_CLIENT_ID", + "name": "ABDM_CLIENT_ID" + }, + { + "valueFrom": "/care/backend/ABDM_CLIENT_SECRET", + "name": "ABDM_CLIENT_SECRET" } ], "name": "care-backend" diff --git a/aws/celery.json b/aws/celery.json index e79e4f66be..52cbb8b8f7 100644 --- a/aws/celery.json +++ b/aws/celery.json @@ -96,8 +96,8 @@ "repositoryCredentials": { "credentialsParameter": "arn:aws:secretsmanager:ap-south-1:299650323339:secret:/github/pat-UHmr0B" }, - "memory": 256, - "memoryReservation": 256, + "memory": 512, + "memoryReservation": 512, "workingDirectory": "/app", "secrets": [ { @@ -232,8 +232,8 @@ "/app/celery_worker-ecs.sh" ], "cpu": 384, - "memory": 768, - "memoryReservation": 768, + "memory": 1536, + "memoryReservation": 1536, "environment": [ { "name": "AUDIT_LOG_ENABLED", @@ -437,7 +437,7 @@ "name": "care-celery-worker" } ], - "memory": "1024", + "memory": "2048", "taskRoleArn": "arn:aws:iam::299650323339:role/ecsTaskExecutionRole", "family": "care-celery", "requiresCompatibilities": [ diff --git a/care/abdm/__init__.py b/care/abdm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/abdm/admin.py b/care/abdm/admin.py new file mode 100644 index 0000000000..846f6b4061 --- /dev/null +++ b/care/abdm/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/care/abdm/api/__init__.py b/care/abdm/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/abdm/api/serializers/abha.py b/care/abdm/api/serializers/abha.py new file mode 100644 index 0000000000..c4d88dbc0f --- /dev/null +++ b/care/abdm/api/serializers/abha.py @@ -0,0 +1,9 @@ +from rest_framework.serializers import ModelSerializer + +from care.abdm.models import AbhaNumber + + +class AbhaSerializer(ModelSerializer): + class Meta: + exclude = ("deleted",) + model = AbhaNumber diff --git a/care/abdm/api/serializers/abhanumber.py b/care/abdm/api/serializers/abhanumber.py new file mode 100644 index 0000000000..1dfdf95d4e --- /dev/null +++ b/care/abdm/api/serializers/abhanumber.py @@ -0,0 +1,10 @@ +# ModelSerializer +from rest_framework import serializers + +from care.abdm.models import AbhaNumber + + +class AbhaNumberSerializer(serializers.ModelSerializer): + class Meta: + model = AbhaNumber + exclude = ("access_token", "refresh_token", "txn_id") diff --git a/care/abdm/api/serializers/auth.py b/care/abdm/api/serializers/auth.py new file mode 100644 index 0000000000..9b533d0c9b --- /dev/null +++ b/care/abdm/api/serializers/auth.py @@ -0,0 +1,24 @@ +from rest_framework.serializers import CharField, IntegerField, Serializer + + +class AbdmAuthResponseSerializer(Serializer): + """ + Serializer for the response of the authentication API + """ + + accessToken = CharField() + refreshToken = CharField() + expiresIn = IntegerField() + refreshExpiresIn = IntegerField() + tokenType = CharField() + + +class AbdmAuthInitResponseSerializer(Serializer): + """ + Serializer for the response of the authentication API + """ + + token = CharField() + refreshToken = CharField() + expiresIn = IntegerField() + refreshExpiresIn = IntegerField() diff --git a/care/abdm/api/serializers/healthid.py b/care/abdm/api/serializers/healthid.py new file mode 100644 index 0000000000..aa2b7cc1fd --- /dev/null +++ b/care/abdm/api/serializers/healthid.py @@ -0,0 +1,61 @@ +from rest_framework.serializers import CharField, Serializer, UUIDField + + +class AadharOtpGenerateRequestPayloadSerializer(Serializer): + aadhaar = CharField(max_length=16, min_length=12, required=True) + + +class AadharOtpResendRequestPayloadSerializer(Serializer): + txnId = CharField(max_length=64, min_length=1, required=True) + + +class HealthIdSerializer(Serializer): + healthId = CharField(max_length=64, min_length=1, required=True) + + +class QRContentSerializer(Serializer): + hidn = CharField(max_length=17, min_length=17, required=True) + phr = CharField(max_length=64, min_length=1, required=True) + name = CharField(max_length=64, min_length=1, required=True) + gender = CharField(max_length=1, min_length=1, required=True) + dob = CharField(max_length=10, min_length=8, required=True) + + +class HealthIdAuthSerializer(Serializer): + authMethod = CharField(max_length=64, min_length=1, required=True) + healthid = CharField(max_length=64, min_length=1, required=True) + + +class ABHASearchRequestSerializer: + name = CharField(max_length=64, min_length=1, required=False) + mobile = CharField( + max_length=10, + min_length=10, + required=False, + ) + gender = CharField(max_length=1, min_length=1, required=False) + yearOfBirth = CharField(max_length=4, min_length=4, required=False) + + +class GenerateMobileOtpRequestPayloadSerializer(Serializer): + mobile = CharField(max_length=10, min_length=10, required=True) + txnId = CharField(max_length=64, min_length=1, required=True) + + +class VerifyOtpRequestPayloadSerializer(Serializer): + otp = CharField(max_length=6, min_length=6, required=True, help_text="OTP") + txnId = CharField(max_length=64, min_length=1, required=True) + patientId = UUIDField(required=False) + + +class VerifyDemographicsRequestPayloadSerializer(Serializer): + gender = CharField(max_length=10, min_length=1, required=True) + name = CharField(max_length=64, min_length=1, required=True) + yearOfBirth = CharField(max_length=4, min_length=4, required=True) + txnId = CharField(max_length=64, min_length=1, required=True) + + +class CreateHealthIdSerializer(Serializer): + healthId = CharField(max_length=64, min_length=1, required=False) + txnId = CharField(max_length=64, min_length=1, required=True) + patientId = UUIDField(required=False) diff --git a/care/abdm/api/serializers/hip.py b/care/abdm/api/serializers/hip.py new file mode 100644 index 0000000000..4e3bb0f9ab --- /dev/null +++ b/care/abdm/api/serializers/hip.py @@ -0,0 +1,33 @@ +from rest_framework.serializers import CharField, IntegerField, Serializer + + +class AddressSerializer(Serializer): + line = CharField() + district = CharField() + state = CharField() + pincode = CharField() + + +class PatientSerializer(Serializer): + healthId = CharField(allow_null=True) + healthIdNumber = CharField() + name = CharField() + gender = CharField() + yearOfBirth = IntegerField() + dayOfBirth = IntegerField() + monthOfBirth = IntegerField() + address = AddressSerializer() + + +class ProfileSerializer(Serializer): + hipCode = CharField() + patient = PatientSerializer() + + +class HipShareProfileSerializer(Serializer): + """ + Serializer for the request of the share_profile + """ + + requestId = CharField() + profile = ProfileSerializer() diff --git a/care/abdm/api/viewsets/abha.py b/care/abdm/api/viewsets/abha.py new file mode 100644 index 0000000000..7fcf102850 --- /dev/null +++ b/care/abdm/api/viewsets/abha.py @@ -0,0 +1,38 @@ +from rest_framework.decorators import action +from rest_framework.generics import get_object_or_404 +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from care.abdm.api.serializers.abha import AbhaSerializer +from care.abdm.models import AbhaNumber +from care.abdm.utils.api_call import HealthIdGateway +from care.utils.queryset.patient import get_patient_queryset + + +class AbhaViewSet(GenericViewSet): + serializer_class = AbhaSerializer + model = AbhaNumber + queryset = AbhaNumber.objects.all() + permission_classes = (IsAuthenticated,) + + def get_abha_object(self): + queryset = get_patient_queryset(self.request.user) + patient_obj = get_object_or_404( + queryset.filter(external_id=self.kwargs.get("patient_external_id")) + ) + return patient_obj.abha_number + + @action(detail=False, methods=["GET"]) + def get_qr_code(self, request, *args, **kwargs): + obj = self.get_abha_object() + gateway = HealthIdGateway() + response = gateway.get_qr_code(obj) + return Response(response) + + @action(detail=False, methods=["GET"]) + def get_profile(self, request, *args, **kwargs): + obj = self.get_abha_object() + gateway = HealthIdGateway() + response = gateway.get_profile(obj) + return Response(response) diff --git a/care/abdm/api/viewsets/auth.py b/care/abdm/api/viewsets/auth.py new file mode 100644 index 0000000000..e72b3975d7 --- /dev/null +++ b/care/abdm/api/viewsets/auth.py @@ -0,0 +1,342 @@ +import json +from datetime import datetime, timedelta + +from django.core.cache import cache +from rest_framework import status +from rest_framework.generics import GenericAPIView, get_object_or_404 +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from care.abdm.utils.api_call import AbdmGateway +from care.abdm.utils.cipher import Cipher +from care.abdm.utils.fhir import Fhir +from care.facility.models.patient import PatientRegistration +from care.facility.models.patient_consultation import PatientConsultation +from config.authentication import ABDMAuthentication + + +class OnFetchView(GenericAPIView): + permission_classes = (IsAuthenticated,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + + AbdmGateway().init(data["resp"]["requestId"]) + + return Response({}, status=status.HTTP_202_ACCEPTED) + + +class OnInitView(GenericAPIView): + permission_classes = (IsAuthenticated,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + + AbdmGateway().confirm(data["auth"]["transactionId"], data["resp"]["requestId"]) + + return Response({}, status=status.HTTP_202_ACCEPTED) + + +class OnConfirmView(GenericAPIView): + permission_classes = (IsAuthenticated,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + + if "validity" in data["auth"]: + if data["auth"]["validity"]["purpose"] == "LINK": + AbdmGateway().add_care_context( + data["auth"]["accessToken"], + data["resp"]["requestId"], + ) + else: + AbdmGateway().save_linking_token( + data["auth"]["patient"], + data["auth"]["accessToken"], + data["resp"]["requestId"], + ) + else: + AbdmGateway().save_linking_token( + data["auth"]["patient"], + data["auth"]["accessToken"], + data["resp"]["requestId"], + ) + AbdmGateway().add_care_context( + data["auth"]["accessToken"], + data["resp"]["requestId"], + ) + + return Response({}, status=status.HTTP_202_ACCEPTED) + + +class AuthNotifyView(GenericAPIView): + permission_classes = (IsAuthenticated,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + + if data["auth"]["status"] != "GRANTED": + return + + AbdmGateway.auth_on_notify({"request_id": data["auth"]["transactionId"]}) + + # AbdmGateway().add_care_context( + # data["auth"]["accessToken"], + # data["resp"]["requestId"], + # ) + + +class OnAddContextsView(GenericAPIView): + permission_classes = (IsAuthenticated,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + return Response({}, status=status.HTTP_202_ACCEPTED) + + +class DiscoverView(GenericAPIView): + permission_classes = (IsAuthenticated,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + + patients = PatientRegistration.objects.all() + verified_identifiers = data["patient"]["verifiedIdentifiers"] + matched_by = [] + if len(verified_identifiers) == 0: + return Response( + "No matching records found, need more data", + status=status.HTTP_404_NOT_FOUND, + ) + else: + for identifier in verified_identifiers: + if identifier["value"] is None: + continue + + # if identifier["type"] == "MOBILE": + # matched_by.append(identifier["value"]) + # mobile = identifier["value"].replace("+91", "").replace("-", "") + # patients = patients.filter( + # Q(phone_number=f"+91{mobile}") | Q(phone_number=mobile) + # ) + + if identifier["type"] == "NDHM_HEALTH_NUMBER": + matched_by.append(identifier["value"]) + patients = patients.filter( + abha_number__abha_number=identifier["value"] + ) + + if identifier["type"] == "HEALTH_ID": + matched_by.append(identifier["value"]) + patients = patients.filter( + abha_number__health_id=identifier["value"] + ) + + # TODO: also filter by demographics + patient = patients.last() + + if not patient: + return Response( + "No matching records found, need more data", + status=status.HTTP_404_NOT_FOUND, + ) + + AbdmGateway().on_discover( + { + "request_id": data["requestId"], + "transaction_id": data["transactionId"], + "patient_id": str(patient.external_id), + "patient_name": patient.name, + "care_contexts": list( + map( + lambda consultation: { + "id": str(consultation.external_id), + "name": f"Encounter: {str(consultation.created_date.date())}", + }, + PatientConsultation.objects.filter(patient=patient), + ) + ), + "matched_by": matched_by, + } + ) + return Response({}, status=status.HTTP_202_ACCEPTED) + + +class LinkInitView(GenericAPIView): + permission_classes = (IsAuthenticated,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + + # TODO: send otp to patient + + AbdmGateway().on_link_init( + { + "request_id": data["requestId"], + "transaction_id": data["transactionId"], + "patient_id": data["patient"]["referenceNumber"], + "phone": "7639899448", + } + ) + return Response({}, status=status.HTTP_202_ACCEPTED) + + +class LinkConfirmView(GenericAPIView): + permission_classes = (IsAuthenticated,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + + # TODO: verify otp + + patient = get_object_or_404( + PatientRegistration.objects.filter( + external_id=data["confirmation"]["linkRefNumber"] + ) + ) + AbdmGateway().on_link_confirm( + { + "request_id": data["requestId"], + "patient_id": str(patient.external_id), + "patient_name": patient.name, + "care_contexts": list( + map( + lambda consultation: { + "id": str(consultation.external_id), + "name": f"Encounter: {str(consultation.created_date.date())}", + }, + PatientConsultation.objects.filter(patient=patient), + ) + ), + } + ) + + return Response({}, status=status.HTTP_202_ACCEPTED) + + +class NotifyView(GenericAPIView): + permission_classes = (IsAuthenticated,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + + cache.set(data["notification"]["consentId"], json.dumps(data)) + + AbdmGateway().on_notify( + { + "request_id": data["requestId"], + "consent_id": data["notification"]["consentId"], + } + ) + return Response({}, status=status.HTTP_202_ACCEPTED) + + +class RequestDataView(GenericAPIView): + permission_classes = (IsAuthenticated,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + + consent_id = data["hiRequest"]["consent"]["id"] + consent = json.loads(cache.get(consent_id)) if consent_id in cache else None + if not consent or not consent["notification"]["status"] == "GRANTED": + return Response({}, status=status.HTTP_401_UNAUTHORIZED) + + # TODO: check if from and to are in range and consent expiry is greater than today + # consent_from = datetime.fromisoformat( + # consent["notification"]["permission"]["dateRange"]["from"][:-1] + # ) + # consent_to = datetime.fromisoformat( + # consent["notification"]["permission"]["dateRange"]["to"][:-1] + # ) + # now = datetime.now() + # if not consent_from < now and now > consent_to: + # return Response({}, status=status.HTTP_403_FORBIDDEN) + + on_data_request_response = AbdmGateway().on_data_request( + {"request_id": data["requestId"], "transaction_id": data["transactionId"]} + ) + + if not on_data_request_response.status_code == 202: + return Response({}, status=status.HTTP_202_ACCEPTED) + return Response( + on_data_request_response, status=status.HTTP_400_BAD_REQUEST + ) + + cipher = Cipher( + data["hiRequest"]["keyMaterial"]["dhPublicKey"]["keyValue"], + data["hiRequest"]["keyMaterial"]["nonce"], + ) + + AbdmGateway().data_transfer( + { + "transaction_id": data["transactionId"], + "data_push_url": data["hiRequest"]["dataPushUrl"], + "care_contexts": sum( + list( + map( + lambda context: list( + map( + lambda record: { + "patient_id": context["patientReference"], + "consultation_id": context[ + "careContextReference" + ], + "data": cipher.encrypt( + Fhir( + PatientConsultation.objects.get( + external_id=context[ + "careContextReference" + ] + ) + ).create_record(record) + )["data"], + }, + consent["notification"]["consentDetail"]["hiTypes"], + ) + ), + consent["notification"]["consentDetail"]["careContexts"][ + :-2:-1 + ], + ) + ), + [], + ), + "key_material": { + "cryptoAlg": "ECDH", + "curve": "Curve25519", + "dhPublicKey": { + "expiry": (datetime.now() + timedelta(days=2)).isoformat(), + "parameters": "Curve25519/32byte random key", + "keyValue": cipher.key_to_share, + }, + "nonce": cipher.sender_nonce, + }, + } + ) + + AbdmGateway().data_notify( + { + "consent_id": data["hiRequest"]["consent"]["id"], + "transaction_id": data["transactionId"], + "care_contexts": list( + map( + lambda context: {"id": context["careContextReference"]}, + consent["notification"]["consentDetail"]["careContexts"][ + :-2:-1 + ], + ) + ), + } + ) + + return Response({}, status=status.HTTP_202_ACCEPTED) diff --git a/care/abdm/api/viewsets/healthid.py b/care/abdm/api/viewsets/healthid.py new file mode 100644 index 0000000000..c30818eac0 --- /dev/null +++ b/care/abdm/api/viewsets/healthid.py @@ -0,0 +1,648 @@ +# ABDM HealthID APIs + +from datetime import datetime + +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError +from rest_framework.mixins import CreateModelMixin +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from care.abdm.api.serializers.abhanumber import AbhaNumberSerializer +from care.abdm.api.serializers.healthid import ( + AadharOtpGenerateRequestPayloadSerializer, + AadharOtpResendRequestPayloadSerializer, + CreateHealthIdSerializer, + GenerateMobileOtpRequestPayloadSerializer, + HealthIdAuthSerializer, + HealthIdSerializer, + QRContentSerializer, + VerifyDemographicsRequestPayloadSerializer, + VerifyOtpRequestPayloadSerializer, +) +from care.abdm.models import AbhaNumber +from care.abdm.utils.api_call import AbdmGateway, HealthIdGateway +from care.facility.api.serializers.patient import PatientDetailSerializer +from care.facility.models.patient import PatientConsultation, PatientRegistration +from care.utils.queryset.patient import get_patient_queryset +from config.auth_views import CaptchaRequiredException +from config.ratelimit import ratelimit + + +# API for Generating OTP for HealthID +class ABDMHealthIDViewSet(GenericViewSet, CreateModelMixin): + base_name = "healthid" + model = AbhaNumber + permission_classes = (IsAuthenticated,) + + @extend_schema( + operation_id="generate_aadhaar_otp", + request=AadharOtpGenerateRequestPayloadSerializer, + responses={"200": "{'txnId': 'string'}"}, + tags=["ABDM HealthID"], + ) + @action(detail=False, methods=["post"]) + def generate_aadhaar_otp(self, request): + data = request.data + + if ratelimit(request, "generate_aadhaar_otp", [data["aadhaar"]]): + raise CaptchaRequiredException( + detail={"status": 429, "detail": "Too Many Requests Provide Captcha"}, + code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + serializer = AadharOtpGenerateRequestPayloadSerializer(data=data) + serializer.is_valid(raise_exception=True) + response = HealthIdGateway().generate_aadhaar_otp(data) + return Response(response, status=status.HTTP_200_OK) + + @extend_schema( + # /v1/registration/aadhaar/resendAadhaarOtp + operation_id="resend_aadhaar_otp", + request=AadharOtpResendRequestPayloadSerializer, + responses={"200": "{'txnId': 'string'}"}, + tags=["ABDM HealthID"], + ) + @action(detail=False, methods=["post"]) + def resend_aadhaar_otp(self, request): + data = request.data + + if ratelimit(request, "resend_aadhaar_otp", [data["txnId"]]): + raise CaptchaRequiredException( + detail={"status": 429, "detail": "Too Many Requests Provide Captcha"}, + code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + serializer = AadharOtpResendRequestPayloadSerializer(data=data) + serializer.is_valid(raise_exception=True) + response = HealthIdGateway().resend_aadhaar_otp(data) + return Response(response, status=status.HTTP_200_OK) + + @extend_schema( + # /v1/registration/aadhaar/verifyAadhaarOtp + operation_id="verify_aadhaar_otp", + request=VerifyOtpRequestPayloadSerializer, + responses={"200": "{'txnId': 'string'}"}, + tags=["ABDM HealthID"], + ) + @action(detail=False, methods=["post"]) + def verify_aadhaar_otp(self, request): + data = request.data + + if ratelimit(request, "verify_aadhaar_otp", [data["txnId"]]): + raise CaptchaRequiredException( + detail={"status": 429, "detail": "Too Many Requests Provide Captcha"}, + code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + serializer = VerifyOtpRequestPayloadSerializer(data=data) + serializer.is_valid(raise_exception=True) + response = HealthIdGateway().verify_aadhaar_otp( + data + ) # HealthIdGatewayV2().verify_document_mobile_otp(data) + return Response(response, status=status.HTTP_200_OK) + + @extend_schema( + # /v1/registration/aadhaar/generateMobileOTP + operation_id="generate_mobile_otp", + request=GenerateMobileOtpRequestPayloadSerializer, + responses={"200": "{'txnId': 'string'}"}, + tags=["ABDM HealthID"], + ) + @action(detail=False, methods=["post"]) + def generate_mobile_otp(self, request): + data = request.data + + if ratelimit(request, "generate_mobile_otp", [data["txnId"]]): + raise CaptchaRequiredException( + detail={"status": 429, "detail": "Too Many Requests Provide Captcha"}, + code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + serializer = GenerateMobileOtpRequestPayloadSerializer(data=data) + serializer.is_valid(raise_exception=True) + response = HealthIdGateway().generate_mobile_otp(data) + return Response(response, status=status.HTTP_200_OK) + + @extend_schema( + # /v1/registration/aadhaar/verifyMobileOTP + operation_id="verify_mobile_otp", + request=VerifyOtpRequestPayloadSerializer, + responses={"200": "{'txnId': 'string'}"}, + tags=["ABDM HealthID"], + ) + @action(detail=False, methods=["post"]) + def verify_mobile_otp(self, request): + data = request.data + + if ratelimit(request, "verify_mobile_otp", [data["txnId"]]): + raise CaptchaRequiredException( + detail={"status": 429, "detail": "Too Many Requests Provide Captcha"}, + code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + serializer = VerifyOtpRequestPayloadSerializer(data=data) + serializer.is_valid(raise_exception=True) + response = HealthIdGateway().verify_mobile_otp(data) + return Response(response, status=status.HTTP_200_OK) + + def create_abha(self, abha_profile, token): + abha_object = AbhaNumber.objects.filter( + abha_number=abha_profile["healthIdNumber"] + ).first() + + if abha_object: + return abha_object + + abha_object = AbhaNumber.objects.create( + abha_number=abha_profile["healthIdNumber"], + health_id=abha_profile["healthId"], + name=abha_profile["name"], + first_name=abha_profile["firstName"], + middle_name=abha_profile["middleName"], + last_name=abha_profile["lastName"], + gender=abha_profile["gender"], + date_of_birth=str( + datetime.strptime( + f"{abha_profile['yearOfBirth']}-{abha_profile['monthOfBirth']}-{abha_profile['dayOfBirth']}", + "%Y-%m-%d", + ) + )[0:10], + address=abha_profile["address"] if "address" in abha_profile else "", + district=abha_profile["districtName"], + state=abha_profile["stateName"], + pincode=abha_profile["pincode"], + email=abha_profile["email"], + profile_photo=abha_profile["profilePhoto"], + txn_id=token["txn_id"], + access_token=token["access_token"], + refresh_token=token["refresh_token"], + ) + abha_object.save() + + return abha_object + + def add_abha_details_to_patient(self, abha_object, patient_object): + patient_object.abha_number = abha_object + patient_object.save() + return True + + @extend_schema( + # /v1/registration/aadhaar/createHealthId + operation_id="create_health_id", + request=CreateHealthIdSerializer, + responses={"200": "{'txnId': 'string'}"}, + tags=["ABDM HealthID"], + ) + @action(detail=False, methods=["post"]) + def create_health_id(self, request): + data = request.data + + if ratelimit(request, "create_health_id", [data["txnId"]]): + raise CaptchaRequiredException( + detail={"status": 429, "detail": "Too Many Requests Provide Captcha"}, + code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + serializer = CreateHealthIdSerializer(data=data) + serializer.is_valid(raise_exception=True) + abha_profile = HealthIdGateway().create_health_id(data) + + if "token" not in abha_profile: + return Response( + abha_profile, + status=status.HTTP_400_BAD_REQUEST, + ) + + # have a serializer to verify data of abha_profile + abha_object = self.create_abha( + abha_profile, + { + "txn_id": data["txnId"], + "access_token": abha_profile["token"], + "refresh_token": abha_profile["refreshToken"], + }, + ) + + if "patientId" in data: + patient_id = data.pop("patientId") + allowed_patients = get_patient_queryset(request.user) + patient_obj = allowed_patients.filter(external_id=patient_id).first() + if not patient_obj: + raise ValidationError({"patient": "Not Found"}) + + if not self.add_abha_details_to_patient( + abha_object, + patient_obj, + ): + return Response( + {"message": "Failed to add abha Number to the patient"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response( + {"id": abha_object.external_id, "abha_profile": abha_profile}, + status=status.HTTP_200_OK, + ) + + # APIs to Find & Link Existing HealthID + # searchByHealthId + @extend_schema( + # /v1/registration/aadhaar/searchByHealthId + operation_id="search_by_health_id", + request=HealthIdSerializer, + responses={"200": "{'status': 'boolean'}"}, + tags=["ABDM HealthID"], + ) + @action(detail=False, methods=["post"]) + def search_by_health_id(self, request): + data = request.data + + if ratelimit( + request, "search_by_health_id", [data["healthId"]], increment=False + ): + raise CaptchaRequiredException( + detail={"status": 429, "detail": "Too Many Requests Provide Captcha"}, + code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + serializer = HealthIdSerializer(data=data) + serializer.is_valid(raise_exception=True) + response = HealthIdGateway().search_by_health_id(data) + return Response(response, status=status.HTTP_200_OK) + + @action(detail=False, methods=["post"]) + def get_abha_card(self, request): + data = request.data + + if ratelimit(request, "get_abha_card", [data["patient"]], increment=False): + raise CaptchaRequiredException( + detail={"status": 429, "detail": "Too Many Requests Provide Captcha"}, + code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + allowed_patients = get_patient_queryset(request.user) + patient = allowed_patients.filter(external_id=data["patient"]).first() + if not patient: + raise ValidationError({"patient": "Not Found"}) + + if not patient.abha_number: + raise ValidationError({"abha": "Patient hasn't linked thier abha"}) + + if data["type"] == "png": + response = HealthIdGateway().get_abha_card_png( + {"refreshToken": patient.abha_number.refresh_token} + ) + return Response(response, status=status.HTTP_200_OK) + + response = HealthIdGateway().get_abha_card_pdf( + {"refreshToken": patient.abha_number.refresh_token} + ) + return Response(response, status=status.HTTP_200_OK) + + @extend_schema( + # /v1/registration/aadhaar/searchByHealthId + operation_id="link_via_qr", + request=HealthIdSerializer, + responses={"200": "{'status': 'boolean'}"}, + tags=["ABDM HealthID"], + ) + @action(detail=False, methods=["post"]) + def link_via_qr(self, request): + data = request.data + + if ratelimit(request, "link_via_qr", [data["hidn"]], increment=False): + raise CaptchaRequiredException( + detail={"status": 429, "detail": "Too Many Requests Provide Captcha"}, + code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + serializer = QRContentSerializer(data=data) + serializer.is_valid(raise_exception=True) + + dob = datetime.strptime(data["dob"], "%d-%m-%Y").date() + + patient = PatientRegistration.objects.filter( + abha_number__abha_number=data["hidn"] + ).first() + if patient: + return Response( + { + "message": "A patient is already associated with the provided Abha Number" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + abha_number = AbhaNumber.objects.filter(abha_number=data["hidn"]).first() + + if not abha_number: + abha_number = AbhaNumber.objects.create( + abha_number=data["hidn"], + health_id=data["phr"], + name=data["name"], + gender=data["gender"], + date_of_birth=str(dob)[0:10], + address=data["address"], + district=data["dist name"], + state=data["state name"], + ) + + abha_number.save() + + AbdmGateway().fetch_modes( + { + "healthId": data["phr"] or data["hidn"], + "name": data["name"], + "gender": data["gender"], + "dateOfBirth": str(datetime.strptime(data["dob"], "%d-%m-%Y"))[ + 0:10 + ], + } + ) + + if "patientId" in data and data["patientId"] is not None: + patient = PatientRegistration.objects.filter( + external_id=data["patientId"] + ).first() + + if not patient: + return Response( + {"message": "Enter a valid patientId"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + patient.abha_number = abha_number + patient.save() + + abha_serialized = AbhaNumberSerializer(abha_number).data + return Response( + {"id": abha_serialized["external_id"], "abha_profile": abha_serialized}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + operation_id="get_new_linking_token", + responses={"200": "{'status': 'boolean'}"}, + tags=["ABDM HealthID"], + ) + @action(detail=False, methods=["post"]) + def get_new_linking_token(self, request): + data = request.data + + if ratelimit(request, "get_new_linking_token", [data["patient"]]): + raise CaptchaRequiredException( + detail={"status": 429, "detail": "Too Many Requests Provide Captcha"}, + code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + patient = PatientDetailSerializer( + PatientRegistration.objects.get(external_id=data["patient"]) + ).data + + AbdmGateway().fetch_modes( + { + "healthId": patient["abha_number_object"]["abha_number"], + "name": patient["abha_number_object"]["name"], + "gender": patient["abha_number_object"]["gender"], + "dateOfBirth": str(patient["abha_number_object"]["date_of_birth"]), + } + ) + + return Response({}, status=status.HTTP_200_OK) + + @action(detail=False, methods=["POST"]) + def add_care_context(self, request, *args, **kwargs): + consultation_id = request.data["consultation"] + + if ratelimit(request, "add_care_context", [consultation_id]): + raise CaptchaRequiredException( + detail={"status": 429, "detail": "Too Many Requests Provide Captcha"}, + code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + consultation = PatientConsultation.objects.get(external_id=consultation_id) + + if not consultation: + return Response( + {"consultation": "No matching records found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + AbdmGateway().fetch_modes( + { + "healthId": consultation.patient.abha_number.health_id, + "name": request.data["name"] + if "name" in request.data + else consultation.patient.abha_number.name, + "gender": request.data["gender"] + if "gender" in request.data + else consultation.patient.abha_number.gender, + "dateOfBirth": request.data["dob"] + if "dob" in request.data + else str(consultation.patient.abha_number.date_of_birth), + "consultationId": consultation_id, + # "authMode": "DIRECT", + "purpose": "LINK", + } + ) + + return Response(status=status.HTTP_202_ACCEPTED) + + @action(detail=False, methods=["POST"]) + def patient_sms_notify(self, request, *args, **kwargs): + patient_id = request.data["patient"] + + if ratelimit(request, "patient_sms_notify", [patient_id]): + raise CaptchaRequiredException( + detail={"status": 429, "detail": "Too Many Requests Provide Captcha"}, + code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + patient = PatientRegistration.objects.filter(external_id=patient_id).first() + + if not patient: + return Response( + {"consultation": "No matching records found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + response = AbdmGateway().patient_sms_notify({"phone": patient.phone_number}) + + return Response(response, status=status.HTTP_202_ACCEPTED) + + # auth/init + @extend_schema( + # /v1/auth/init + operation_id="auth_init", + request=HealthIdAuthSerializer, + responses={"200": "{'txnId': 'string'}"}, + tags=["ABDM HealthID"], + ) + @action(detail=False, methods=["post"]) + def auth_init(self, request): + data = request.data + + if ratelimit(request, "auth_init", [data["healthid"]]): + raise CaptchaRequiredException( + detail={"status": 429, "detail": "Too Many Requests Provide Captcha"}, + code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + serializer = HealthIdAuthSerializer(data=data) + serializer.is_valid(raise_exception=True) + response = HealthIdGateway().auth_init(data) + return Response(response, status=status.HTTP_200_OK) + + # /v1/auth/confirmWithAadhaarOtp + @extend_schema( + operation_id="confirm_with_aadhaar_otp", + request=VerifyOtpRequestPayloadSerializer, + responses={"200": "{'txnId': 'string'}"}, + tags=["ABDM HealthID"], + ) + @action(detail=False, methods=["post"]) + def confirm_with_aadhaar_otp(self, request): + data = request.data + + if ratelimit(request, "confirm_with_aadhaar_otp", [data["txnId"]]): + raise CaptchaRequiredException( + detail={"status": 429, "detail": "Too Many Requests Provide Captcha"}, + code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + serializer = VerifyOtpRequestPayloadSerializer(data=data) + serializer.is_valid(raise_exception=True) + response = HealthIdGateway().confirm_with_aadhaar_otp(data) + abha_profile = HealthIdGateway().get_profile(response) + + # have a serializer to verify data of abha_profile + abha_object = self.create_abha( + abha_profile, + { + "access_token": response["token"], + "refresh_token": response["refreshToken"], + "txn_id": data["txnId"], + }, + ) + + if "patientId" in data: + patient_id = data.pop("patientId") + allowed_patients = get_patient_queryset(request.user) + patient_obj = allowed_patients.filter(external_id=patient_id).first() + if not patient_obj: + raise ValidationError({"patient": "Not Found"}) + + if not self.add_abha_details_to_patient( + abha_object, + patient_obj, + ): + return Response( + {"message": "Failed to add abha Number to the patient"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response( + {"id": abha_object.external_id, "abha_profile": abha_profile}, + status=status.HTTP_200_OK, + ) + + # /v1/auth/confirmWithMobileOtp + @extend_schema( + operation_id="confirm_with_mobile_otp", + request=VerifyOtpRequestPayloadSerializer, + # responses={"200": "{'txnId': 'string'}"}, + tags=["ABDM HealthID"], + ) + @action(detail=False, methods=["post"]) + def confirm_with_mobile_otp(self, request): + data = request.data + + if ratelimit(request, "confirm_with_mobile_otp", [data["txnId"]]): + raise CaptchaRequiredException( + detail={"status": 429, "detail": "Too Many Requests Provide Captcha"}, + code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + serializer = VerifyOtpRequestPayloadSerializer(data=data) + serializer.is_valid(raise_exception=True) + response = HealthIdGateway().confirm_with_mobile_otp(data) + abha_profile = HealthIdGateway().get_profile(response) + + # have a serializer to verify data of abha_profile + abha_object = self.create_abha( + abha_profile, + { + "access_token": response["token"], + "refresh_token": response["refreshToken"], + "txn_id": data["txnId"], + }, + ) + + if "patientId" in data: + patient_id = data.pop("patientId") + allowed_patients = get_patient_queryset(request.user) + patient_obj = allowed_patients.filter(external_id=patient_id).first() + if not patient_obj: + raise ValidationError({"patient": "Not Found"}) + + if not self.add_abha_details_to_patient( + abha_object, + patient_obj, + ): + return Response( + {"message": "Failed to add abha Number to the patient"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response( + {"id": abha_object.external_id, "abha_profile": abha_profile}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + operation_id="confirm_with_demographics", + request=VerifyDemographicsRequestPayloadSerializer, + responses={"200": "{'status': true}"}, + tags=["ABDM HealthID"], + ) + @action(detail=False, methods=["post"]) + def confirm_with_demographics(self, request): + data = request.data + + if ratelimit(request, "confirm_with_demographics", [data["txnId"]]): + raise CaptchaRequiredException( + detail={"status": 429, "detail": "Too Many Requests Provide Captcha"}, + code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + serializer = VerifyDemographicsRequestPayloadSerializer(data=data) + serializer.is_valid(raise_exception=True) + response = HealthIdGateway().confirm_with_demographics(data) + return Response(response, status=status.HTTP_200_OK) + + ############################################################################################################ + # HealthID V2 APIs + @extend_schema( + # /v2/registration/aadhaar/checkAndGenerateMobileOTP + operation_id="check_and_generate_mobile_otp", + request=GenerateMobileOtpRequestPayloadSerializer, + responses={"200": "{'txnId': 'string'}"}, + tags=["ABDM HealthID V2"], + ) + @action(detail=False, methods=["post"]) + def check_and_generate_mobile_otp(self, request): + data = request.data + + if ratelimit(request, "check_and_generate_mobile_otp", [data["txnId"]]): + raise CaptchaRequiredException( + detail={"status": 429, "detail": "Too Many Requests Provide Captcha"}, + code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + serializer = GenerateMobileOtpRequestPayloadSerializer(data=data) + serializer.is_valid(raise_exception=True) + response = HealthIdGateway().check_and_generate_mobile_otp(data) + return Response(response, status=status.HTTP_200_OK) diff --git a/care/abdm/api/viewsets/hip.py b/care/abdm/api/viewsets/hip.py new file mode 100644 index 0000000000..ac93ef17de --- /dev/null +++ b/care/abdm/api/viewsets/hip.py @@ -0,0 +1,139 @@ +import uuid +from datetime import datetime, timezone + +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from care.abdm.api.serializers.hip import HipShareProfileSerializer +from care.abdm.models import AbhaNumber +from care.abdm.utils.api_call import AbdmGateway, HealthIdGateway +from care.facility.models.facility import Facility +from care.facility.models.patient import PatientRegistration +from config.authentication import ABDMAuthentication + + +class HipViewSet(GenericViewSet): + permission_classes = (IsAuthenticated,) + authentication_classes = [ABDMAuthentication] + + def get_linking_token(self, data): + AbdmGateway().fetch_modes(data) + return True + + @action(detail=False, methods=["POST"]) + def share(self, request, *args, **kwargs): + data = request.data + + patient_data = data["profile"]["patient"] + counter_id = ( + data["profile"]["hipCode"] + if len(data["profile"]["hipCode"]) == 36 + else Facility.objects.first().external_id + ) + + patient_data["mobile"] = "" + for identifier in patient_data["identifiers"]: + if identifier["type"] == "MOBILE": + patient_data["mobile"] = identifier["value"] + + serializer = HipShareProfileSerializer(data=data) + serializer.is_valid(raise_exception=True) + + if HealthIdGateway().verify_demographics( + patient_data["healthIdNumber"], + patient_data["name"], + patient_data["gender"], + patient_data["yearOfBirth"], + ): + patient = PatientRegistration.objects.filter( + abha_number__abha_number=patient_data["healthIdNumber"] + ).first() + + if not patient: + patient = PatientRegistration.objects.create( + facility=Facility.objects.get(external_id=counter_id), + name=patient_data["name"], + gender={"M": 1, "F": 2}.get(patient_data["gender"], 3), + is_antenatal=False, + phone_number=patient_data["mobile"], + emergency_phone_number=patient_data["mobile"], + date_of_birth=datetime.strptime( + f"{patient_data['yearOfBirth']}-{patient_data['monthOfBirth']}-{patient_data['dayOfBirth']}", + "%Y-%m-%d", + ).date(), + blood_group="UNK", + nationality="India", + address=patient_data["address"]["line"], + pincode=patient_data["address"]["pincode"], + ) + + abha_number = AbhaNumber.objects.create( + abha_number=patient_data["healthIdNumber"], + health_id=patient_data["healthId"], + name=patient_data["name"], + gender=patient_data["gender"], + date_of_birth=str( + datetime.strptime( + f"{patient_data['yearOfBirth']}-{patient_data['monthOfBirth']}-{patient_data['dayOfBirth']}", + "%Y-%m-%d", + ) + )[0:10], + address=patient_data["address"]["line"], + district=patient_data["address"]["district"], + state=patient_data["address"]["state"], + pincode=patient_data["address"]["pincode"], + ) + + abha_number.save() + patient.abha_number = abha_number + patient.save() + + self.get_linking_token( + { + "healthId": patient_data["healthId"] + or patient_data["healthIdNumber"], + "name": patient_data["name"], + "gender": patient_data["gender"], + "dateOfBirth": str( + datetime.strptime( + f"{patient_data['yearOfBirth']}-{patient_data['monthOfBirth']}-{patient_data['dayOfBirth']}", + "%Y-%m-%d", + ) + )[0:10], + } + ) + + payload = { + "requestId": str(uuid.uuid4()), + "timestamp": str( + datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z") + ), + "acknowledgement": { + "status": "SUCCESS", + "healthId": patient_data["healthId"] + or patient_data["healthIdNumber"], + "tokenNumber": "100", + }, + "error": None, + "resp": { + "requestId": data["requestId"], + }, + } + + on_share_response = AbdmGateway().on_share(payload) + if on_share_response.status_code == 202: + return Response( + on_share_response.request.body, + status=status.HTTP_202_ACCEPTED, + ) + + return Response( + { + "status": "ACCEPTED", + "healthId": patient_data["healthId"] or patient_data["healthIdNumber"], + }, + status=status.HTTP_202_ACCEPTED, + ) diff --git a/care/abdm/api/viewsets/monitoring.py b/care/abdm/api/viewsets/monitoring.py new file mode 100644 index 0000000000..54cfe30069 --- /dev/null +++ b/care/abdm/api/viewsets/monitoring.py @@ -0,0 +1,23 @@ +from datetime import datetime, timezone + +from rest_framework import status +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + + +class HeartbeatView(GenericAPIView): + permission_classes = (AllowAny,) + authentication_classes = [] + + def get(self, request, *args, **kwargs): + return Response( + { + "timestamp": str( + datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z") + ), + "status": "UP", + "error": None, + }, + status=status.HTTP_200_OK, + ) diff --git a/care/abdm/api/viewsets/status.py b/care/abdm/api/viewsets/status.py new file mode 100644 index 0000000000..8c126ec7ef --- /dev/null +++ b/care/abdm/api/viewsets/status.py @@ -0,0 +1,36 @@ +from rest_framework import status +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from care.abdm.models import AbhaNumber +from care.abdm.utils.api_call import AbdmGateway +from care.facility.models.patient import PatientRegistration +from config.authentication import ABDMAuthentication + + +class NotifyView(GenericAPIView): + permission_classes = (IsAuthenticated,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + + PatientRegistration.objects.filter( + abha_number__health_id=data["notification"]["patient"]["id"] + ).update(abha_number=None) + AbhaNumber.objects.filter( + health_id=data["notification"]["patient"]["id"] + ).delete() + + AbdmGateway().patient_status_on_notify({"request_id": data["requestId"]}) + + return Response(status=status.HTTP_202_ACCEPTED) + + +class SMSOnNotifyView(GenericAPIView): + permission_classes = (IsAuthenticated,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + return Response(status=status.HTTP_202_ACCEPTED) diff --git a/care/abdm/apps.py b/care/abdm/apps.py new file mode 100644 index 0000000000..54e278d631 --- /dev/null +++ b/care/abdm/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AbdmConfig(AppConfig): + name = "care.abdm" + verbose_name = _("ABDM Integration") diff --git a/care/abdm/migrations/0001_initial_squashed_0007_alter_abhanumber_id.py b/care/abdm/migrations/0001_initial_squashed_0007_alter_abhanumber_id.py new file mode 100644 index 0000000000..ad5d70caa0 --- /dev/null +++ b/care/abdm/migrations/0001_initial_squashed_0007_alter_abhanumber_id.py @@ -0,0 +1,69 @@ +# Generated by Django 4.2.2 on 2023-07-20 17:41 + +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + replaces = [ + ("abdm", "0001_initial"), + ("abdm", "0002_auto_20221220_2312"), + ("abdm", "0003_auto_20221220_2321"), + ("abdm", "0004_auto_20221220_2325"), + ("abdm", "0005_auto_20221220_2327"), + ("abdm", "0006_auto_20230208_0915"), + ("abdm", "0007_alter_abhanumber_id"), + ] + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="AbhaNumber", + 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)), + ("abha_number", models.TextField(blank=True, null=True)), + ("email", models.EmailField(blank=True, max_length=254, null=True)), + ("first_name", models.TextField(blank=True, null=True)), + ("health_id", models.TextField(blank=True, null=True)), + ("last_name", models.TextField(blank=True, null=True)), + ("middle_name", models.TextField(blank=True, null=True)), + ("profile_photo", models.TextField(blank=True, null=True)), + ("txn_id", models.TextField(blank=True, null=True)), + ("access_token", models.TextField(blank=True, null=True)), + ("refresh_token", models.TextField(blank=True, null=True)), + ("address", models.TextField(blank=True, null=True)), + ("date_of_birth", models.TextField(blank=True, null=True)), + ("district", models.TextField(blank=True, null=True)), + ("gender", models.TextField(blank=True, null=True)), + ("name", models.TextField(blank=True, null=True)), + ("pincode", models.TextField(blank=True, null=True)), + ("state", models.TextField(blank=True, null=True)), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/care/abdm/migrations/__init__.py b/care/abdm/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/abdm/models.py b/care/abdm/models.py new file mode 100644 index 0000000000..8b5abbfd65 --- /dev/null +++ b/care/abdm/models.py @@ -0,0 +1,35 @@ +# from django.db import models + +# Create your models here. + +from django.db import models + +from care.utils.models.base import BaseModel + + +class AbhaNumber(BaseModel): + abha_number = models.TextField(null=True, blank=True) + health_id = models.TextField(null=True, blank=True) + + name = models.TextField(null=True, blank=True) + first_name = models.TextField(null=True, blank=True) + middle_name = models.TextField(null=True, blank=True) + last_name = models.TextField(null=True, blank=True) + + gender = models.TextField(null=True, blank=True) + date_of_birth = models.TextField(null=True, blank=True) + + address = models.TextField(null=True, blank=True) + district = models.TextField(null=True, blank=True) + state = models.TextField(null=True, blank=True) + pincode = models.TextField(null=True, blank=True) + + email = models.EmailField(null=True, blank=True) + profile_photo = models.TextField(null=True, blank=True) + + txn_id = models.TextField(null=True, blank=True) + access_token = models.TextField(null=True, blank=True) + refresh_token = models.TextField(null=True, blank=True) + + def __str__(self): + return self.abha_number diff --git a/care/abdm/tests.py b/care/abdm/tests.py new file mode 100644 index 0000000000..a79ca8be56 --- /dev/null +++ b/care/abdm/tests.py @@ -0,0 +1,3 @@ +# from django.test import TestCase + +# Create your tests here. diff --git a/care/abdm/urls.py b/care/abdm/urls.py new file mode 100644 index 0000000000..a6efbe58ea --- /dev/null +++ b/care/abdm/urls.py @@ -0,0 +1,99 @@ +from django.urls import path +from rest_framework.routers import SimpleRouter + +from care.abdm.api.viewsets.auth import ( + AuthNotifyView, + DiscoverView, + LinkConfirmView, + LinkInitView, + NotifyView, + OnAddContextsView, + OnConfirmView, + OnFetchView, + OnInitView, + RequestDataView, +) +from care.abdm.api.viewsets.hip import HipViewSet +from care.abdm.api.viewsets.monitoring import HeartbeatView +from care.abdm.api.viewsets.status import NotifyView as PatientStatusNotifyView +from care.abdm.api.viewsets.status import SMSOnNotifyView + + +class OptionalSlashRouter(SimpleRouter): + def __init__(self): + super().__init__() + self.trailing_slash = "/?" + + +abdm_router = OptionalSlashRouter() + +abdm_router.register("profile/v1.0/patients/", HipViewSet, basename="hip") + +abdm_urlpatterns = [ + *abdm_router.urls, + path( + "v0.5/users/auth/on-fetch-modes", + OnFetchView.as_view(), + name="abdm_on_fetch_modes_view", + ), + path( + "v0.5/users/auth/on-init", + OnInitView.as_view(), + name="abdm_on_init_view", + ), + path( + "v0.5/users/auth/on-confirm", + OnConfirmView.as_view(), + name="abdm_on_confirm_view", + ), + path( + "v0.5/users/auth/notify", + AuthNotifyView.as_view(), + name="abdm_auth_notify_view", + ), + path( + "v0.5/links/link/on-add-contexts", + OnAddContextsView.as_view(), + name="abdm_on_add_context_view", + ), + path( + "v0.5/care-contexts/discover", + DiscoverView.as_view(), + name="abdm_discover_view", + ), + path( + "v0.5/links/link/init", + LinkInitView.as_view(), + name="abdm_link_init_view", + ), + path( + "v0.5/links/link/confirm", + LinkConfirmView.as_view(), + name="abdm_link_confirm_view", + ), + path( + "v0.5/consents/hip/notify", + NotifyView.as_view(), + name="abdm_notify_view", + ), + path( + "v0.5/health-information/hip/request", + RequestDataView.as_view(), + name="abdm_request_data_view", + ), + path( + "v0.5/patients/status/notify", + PatientStatusNotifyView.as_view(), + name="abdm_patient_status_notify_view", + ), + path( + "v0.5/patients/sms/on-notify", + SMSOnNotifyView.as_view(), + name="abdm_patient_status_notify_view", + ), + path( + "v0.5/heartbeat", + HeartbeatView.as_view(), + name="abdm_monitoring_heartbeat_view", + ), +] diff --git a/care/abdm/utils/api_call.py b/care/abdm/utils/api_call.py new file mode 100644 index 0000000000..4ff2a157ea --- /dev/null +++ b/care/abdm/utils/api_call.py @@ -0,0 +1,768 @@ +import json +import logging +import uuid +from base64 import b64encode +from datetime import datetime, timedelta, timezone + +import requests +from Crypto.Cipher import PKCS1_v1_5 +from Crypto.PublicKey import RSA +from django.conf import settings +from django.core.cache import cache +from django.db.models import Q + +from care.abdm.models import AbhaNumber +from care.facility.models.patient_consultation import PatientConsultation + +GATEWAY_API_URL = settings.ABDM_URL +HEALTH_SERVICE_API_URL = settings.HEALTH_SERVICE_API_URL +ABDM_GATEWAY_URL = GATEWAY_API_URL + "/gateway" +ABDM_TOKEN_URL = ABDM_GATEWAY_URL + "/v0.5/sessions" +ABDM_TOKEN_CACHE_KEY = "abdm_token" + +# TODO: Exception handling for all api calls, need to gracefully handle known exceptions + +logger = logging.getLogger(__name__) + + +def encrypt_with_public_key(a_message): + rsa_public_key = RSA.importKey( + requests.get(HEALTH_SERVICE_API_URL + "/v2/auth/cert").text.strip() + ) + rsa_public_key = PKCS1_v1_5.new(rsa_public_key) + encrypted_text = rsa_public_key.encrypt(a_message.encode()) + return b64encode(encrypted_text).decode() + + +class APIGateway: + def __init__(self, gateway, token): + if gateway == "health": + self.url = HEALTH_SERVICE_API_URL + elif gateway == "abdm": + self.url = GATEWAY_API_URL + elif gateway == "abdm_gateway": + self.url = ABDM_GATEWAY_URL + else: + self.url = GATEWAY_API_URL + self.token = token + + # def encrypt(self, data): + # cert = cache.get("abdm_cert") + # if not cert: + # cert = requests.get(settings.ABDM_CERT_URL).text + # cache.set("abdm_cert", cert, 3600) + + def add_user_header(self, headers, user_token): + headers.update( + { + "X-Token": "Bearer " + user_token, + } + ) + return headers + + def add_auth_header(self, headers): + token = cache.get(ABDM_TOKEN_CACHE_KEY) + if not token: + logger.info("No Token in Cache") + data = { + "clientId": settings.ABDM_CLIENT_ID, + "clientSecret": settings.ABDM_CLIENT_SECRET, + } + auth_headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + resp = requests.post( + ABDM_TOKEN_URL, data=json.dumps(data), headers=auth_headers + ) + logger.info("Token Response Status: {}".format(resp.status_code)) + if resp.status_code < 300: + # Checking if Content-Type is application/json + if resp.headers["Content-Type"] != "application/json": + logger.info( + "Unsupported Content-Type: {}".format( + resp.headers["Content-Type"] + ) + ) + logger.info("Response: {}".format(resp.text)) + return None + else: + data = resp.json() + token = data["accessToken"] + expires_in = data["expiresIn"] + logger.info("New Token: {}".format(token)) + logger.info("Expires in: {}".format(expires_in)) + cache.set(ABDM_TOKEN_CACHE_KEY, token, expires_in) + else: + logger.info("Bad Response: {}".format(resp.text)) + return None + # logger.info("Returning Authorization Header: Bearer {}".format(token)) + logger.info("Adding Authorization Header") + auth_header = {"Authorization": "Bearer {}".format(token)} + return {**headers, **auth_header} + + def add_additional_headers(self, headers, additional_headers): + return {**headers, **additional_headers} + + def get(self, path, params=None, auth=None): + url = self.url + path + headers = {} + headers = self.add_auth_header(headers) + if auth: + headers = self.add_user_header(headers, auth) + logger.info("Making GET Request to: {}".format(url)) + response = requests.get(url, headers=headers, params=params) + logger.info("{} Response: {}".format(response.status_code, response.text)) + return response + + def post(self, path, data=None, auth=None, additional_headers=None): + url = self.url + path + headers = { + "Content-Type": "application/json", + "accept": "*/*", + "Accept-Language": "en-US", + } + headers = self.add_auth_header(headers) + if auth: + headers = self.add_user_header(headers, auth) + if additional_headers: + headers = self.add_additional_headers(headers, additional_headers) + # headers_string = " ".join( + # ['-H "{}: {}"'.format(k, v) for k, v in headers.items()] + # ) + data_json = json.dumps(data) + # logger.info("curl -X POST {} {} -d {}".format(url, headers_string, data_json)) + logger.info("Posting Request to: {}".format(url)) + response = requests.post(url, headers=headers, data=data_json) + logger.info("{} Response: {}".format(response.status_code, response.text)) + return response + + +class HealthIdGateway: + def __init__(self): + self.api = APIGateway("health", None) + + def generate_aadhaar_otp(self, data): + path = "/v1/registration/aadhaar/generateOtp" + response = self.api.post(path, data) + logger.info("{} Response: {}".format(response.status_code, response.text)) + return response.json() + + def resend_aadhaar_otp(self, data): + path = "/v1/registration/aadhaar/resendAadhaarOtp" + response = self.api.post(path, data) + return response.json() + + def verify_aadhaar_otp(self, data): + path = "/v1/registration/aadhaar/verifyOTP" + response = self.api.post(path, data) + return response.json() + + def check_and_generate_mobile_otp(self, data): + path = "/v2/registration/aadhaar/checkAndGenerateMobileOTP" + response = self.api.post(path, data) + return response.json() + + def generate_mobile_otp(self, data): + path = "/v2/registration/aadhaar/generateMobileOTP" + response = self.api.post(path, data) + return response.json() + + # /v1/registration/aadhaar/verifyMobileOTP + def verify_mobile_otp(self, data): + path = "/v1/registration/aadhaar/verifyMobileOTP" + response = self.api.post(path, data) + return response.json() + + # /v1/registration/aadhaar/createHealthIdWithPreVerified + def create_health_id(self, data): + path = "/v1/registration/aadhaar/createHealthIdWithPreVerified" + logger.info("Creating Health ID with data: {}".format(data)) + # data.pop("healthId", None) + response = self.api.post(path, data) + return response.json() + + # /v1/search/existsByHealthId + # API checks if ABHA Address/ABHA Number is reserved/used which includes permanently deleted ABHA Addresses + # Return { status: true } + def exists_by_health_id(self, data): + path = "/v1/search/existsByHealthId" + response = self.api.post(path, data) + return response.json() + + # /v1/search/searchByHealthId + # API returns only Active or Deactive ABHA Number/ Address (Never returns Permanently Deleted ABHA Number/Address) + # Returns { + # "authMethods": [ + # "AADHAAR_OTP" + # ], + # "healthId": "deepakndhm", + # "healthIdNumber": "43-4221-5105-6749", + # "name": "kishan kumar singh", + # "status": "ACTIVE" + # } + def search_by_health_id(self, data): + path = "/v1/search/searchByHealthId" + response = self.api.post(path, data) + return response.json() + + # /v1/search/searchByMobile + def search_by_mobile(self, data): + path = "/v1/search/searchByMobile" + response = self.api.post(path, data) + return response.json() + + # Auth APIs + + # /v1/auth/init + def auth_init(self, data): + path = "/v1/auth/init" + response = self.api.post(path, data) + return response.json() + + # /v1/auth/confirmWithAadhaarOtp + def confirm_with_aadhaar_otp(self, data): + path = "/v1/auth/confirmWithAadhaarOtp" + response = self.api.post(path, data) + return response.json() + + # /v1/auth/confirmWithMobileOTP + def confirm_with_mobile_otp(self, data): + path = "/v1/auth/confirmWithMobileOTP" + response = self.api.post(path, data) + return response.json() + + # /v1/auth/confirmWithDemographics + def confirm_with_demographics(self, data): + path = "/v1/auth/confirmWithDemographics" + response = self.api.post(path, data) + return response.json() + + def verify_demographics(self, health_id, name, gender, year_of_birth): + auth_init_response = HealthIdGateway().auth_init( + {"authMethod": "DEMOGRAPHICS", "healthid": health_id} + ) + if "txnId" in auth_init_response: + demographics_response = HealthIdGateway().confirm_with_demographics( + { + "txnId": auth_init_response["txnId"], + "name": name, + "gender": gender, + "yearOfBirth": year_of_birth, + } + ) + return "status" in demographics_response and demographics_response["status"] + + return False + + # /v1/auth/generate/access-token + def generate_access_token(self, data): + if "access_token" in data: + return data["access_token"] + elif "accessToken" in data: + return data["accessToken"] + elif "token" in data: + return data["token"] + + if "refreshToken" in data: + refreshToken = data["refreshToken"] + elif "refresh_token" in data: + refreshToken = data["refresh_token"] + else: + return None + path = "/v1/auth/generate/access-token" + response = self.api.post(path, {"refreshToken": refreshToken}) + return response.json()["accessToken"] + + # Account APIs + + # /v1/account/profile + def get_profile(self, data): + path = "/v1/account/profile" + access_token = self.generate_access_token(data) + response = self.api.get(path, {}, access_token) + return response.json() + + # /v1/account/getPngCard + def get_abha_card_png(self, data): + path = "/v1/account/getPngCard" + access_token = self.generate_access_token(data) + response = self.api.get(path, {}, access_token) + + return b64encode(response.content) + + def get_abha_card_pdf(self, data): + path = "/v1/account/getCard" + access_token = self.generate_access_token(data) + response = self.api.get(path, {}, access_token) + + return b64encode(response.content) + + # /v1/account/qrCode + def get_qr_code(self, data, auth): + path = "/v1/account/qrCode" + access_token = self.generate_access_token(data) + logger.info("Getting QR Code for: {}".format(data)) + response = self.api.get(path, {}, access_token) + logger.info("QR Code Response: {}".format(response.text)) + return response.json() + + +class HealthIdGatewayV2: + def __init__(self): + self.api = APIGateway("health", None) + + # V2 APIs + def generate_aadhaar_otp(self, data): + path = "/v2/registration/aadhaar/generateOtp" + data["aadhaar"] = encrypt_with_public_key(data["aadhaar"]) + data.pop("cancelToken", {}) + response = self.api.post(path, data) + return response.json() + + def generate_document_mobile_otp(self, data): + path = "/v2/document/generate/mobile/otp" + data["mobile"] = "ENTER MOBILE NUMBER HERE" # Hard Coding for test + data.pop("cancelToken", {}) + response = self.api.post(path, data) + return response.json() + + def verify_document_mobile_otp(self, data): + path = "/v2/document/verify/mobile/otp" + data["otp"] = encrypt_with_public_key(data["otp"]) + data.pop("cancelToken", {}) + response = self.api.post(path, data) + return response.json() + + +class AbdmGateway: + # TODO: replace this with in-memory db (redis) + temp_memory = {} + hip_name = "Coronasafe Care 01" + hip_id = "IN3210000017" + + def __init__(self): + self.api = APIGateway("abdm_gateway", None) + + def add_care_context(self, access_token, request_id): + if request_id not in self.temp_memory: + return + + data = self.temp_memory[request_id] + + if "consultationId" in data: + consultation = PatientConsultation.objects.get( + external_id=data["consultationId"] + ) + + response = self.add_contexts( + { + "access_token": access_token, + "patient_id": str(consultation.patient.external_id), + "patient_name": consultation.patient.name, + "context_id": str(consultation.external_id), + "context_name": f"Encounter: {str(consultation.created_date.date())}", + } + ) + + return response + + return False + + def save_linking_token(self, patient, access_token, request_id): + if request_id not in self.temp_memory: + return + + data = self.temp_memory[request_id] + health_id = patient and patient["id"] or data["healthId"] + + abha_object = AbhaNumber.objects.filter( + Q(abha_number=health_id) | Q(health_id=health_id) + ).first() + + if abha_object: + abha_object.access_token = access_token + abha_object.save() + return True + + return False + + # /v0.5/users/auth/fetch-modes + def fetch_modes(self, data): + path = "/v0.5/users/auth/fetch-modes" + additional_headers = {"X-CM-ID": settings.X_CM_ID} + request_id = str(uuid.uuid4()) + + """ + data = { + healthId, + name, + gender, + dateOfBirth, + } + """ + self.temp_memory[request_id] = data + + if "authMode" in data and data["authMode"] == "DIRECT": + self.init(request_id) + return + + payload = { + "requestId": request_id, + "timestamp": datetime.now(tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.000Z" + ), + "query": { + "id": data["healthId"], + "purpose": data["purpose"] if "purpose" in data else "KYC_AND_LINK", + "requester": {"type": "HIP", "id": self.hip_id}, + }, + } + response = self.api.post(path, payload, None, additional_headers) + return response + + # "/v0.5/users/auth/init" + def init(self, prev_request_id): + if prev_request_id not in self.temp_memory: + return + + path = "/v0.5/users/auth/init" + additional_headers = {"X-CM-ID": settings.X_CM_ID} + + request_id = str(uuid.uuid4()) + + data = self.temp_memory[prev_request_id] + self.temp_memory[request_id] = data + + payload = { + "requestId": request_id, + "timestamp": datetime.now(tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.000Z" + ), + "query": { + "id": data["healthId"], + "purpose": data["purpose"] if "purpose" in data else "KYC_AND_LINK", + "authMode": data["authMode"] if "authMode" in data else "DEMOGRAPHICS", + "requester": {"type": "HIP", "id": self.hip_id}, + }, + } + response = self.api.post(path, payload, None, additional_headers) + return response + + # "/v0.5/users/auth/confirm" + def confirm(self, transaction_id, prev_request_id): + if prev_request_id not in self.temp_memory: + return + + path = "/v0.5/users/auth/confirm" + additional_headers = {"X-CM-ID": settings.X_CM_ID} + + request_id = str(uuid.uuid4()) + + data = self.temp_memory[prev_request_id] + self.temp_memory[request_id] = data + + payload = { + "requestId": request_id, + "timestamp": datetime.now(tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.000Z" + ), + "transactionId": transaction_id, + "credential": { + "demographic": { + "name": data["name"], + "gender": data["gender"], + "dateOfBirth": data["dateOfBirth"], + }, + "authCode": "", + }, + } + + response = self.api.post(path, payload, None, additional_headers) + return response + + def auth_on_notify(self, data): + path = "/v0.5/links/link/on-init" + additional_headers = {"X-CM-ID": settings.X_CM_ID} + + request_id = str(uuid.uuid4()) + payload = { + "requestId": request_id, + "timestamp": datetime.now(tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.000Z" + ), + "acknowledgement": {"status": "OK"}, + # "error": {"code": 1000, "message": "string"}, + "resp": {"requestId": data["request_id"]}, + } + + response = self.api.post(path, payload, None, additional_headers) + return response + + # TODO: make it dynamic and call it at discharge (call it from on_confirm) + def add_contexts(self, data): + path = "/v0.5/links/link/add-contexts" + additional_headers = {"X-CM-ID": settings.X_CM_ID} + + request_id = str(uuid.uuid4()) + + payload = { + "requestId": request_id, + "timestamp": datetime.now(tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.000Z" + ), + "link": { + "accessToken": data["access_token"], + "patient": { + "referenceNumber": data["patient_id"], + "display": data["patient_name"], + "careContexts": [ + { + "referenceNumber": data["context_id"], + "display": data["context_name"], + } + ], + }, + }, + } + + response = self.api.post(path, payload, None, additional_headers) + return response + + def on_discover(self, data): + path = "/v0.5/care-contexts/on-discover" + additional_headers = {"X-CM-ID": settings.X_CM_ID} + + request_id = str(uuid.uuid4()) + payload = { + "requestId": request_id, + "timestamp": datetime.now(tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.000Z" + ), + "transactionId": data["transaction_id"], + "patient": { + "referenceNumber": data["patient_id"], + "display": data["patient_name"], + "careContexts": list( + map( + lambda context: { + "referenceNumber": context["id"], + "display": context["name"], + }, + data["care_contexts"], + ) + ), + "matchedBy": data["matched_by"], + }, + # "error": {"code": 1000, "message": "string"}, + "resp": {"requestId": data["request_id"]}, + } + + response = self.api.post(path, payload, None, additional_headers) + return response + + def on_link_init(self, data): + path = "/v0.5/links/link/on-init" + additional_headers = {"X-CM-ID": settings.X_CM_ID} + + request_id = str(uuid.uuid4()) + payload = { + "requestId": request_id, + "timestamp": datetime.now(tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.000Z" + ), + "transactionId": data["transaction_id"], + "link": { + "referenceNumber": data["patient_id"], + "authenticationType": "DIRECT", + "meta": { + "communicationMedium": "MOBILE", + "communicationHint": data["phone"], + "communicationExpiry": str( + (datetime.now() + timedelta(minutes=15)).strftime( + "%Y-%m-%dT%H:%M:%S.000Z" + ) + ), + }, + }, + # "error": {"code": 1000, "message": "string"}, + "resp": {"requestId": data["request_id"]}, + } + + response = self.api.post(path, payload, None, additional_headers) + return response + + def on_link_confirm(self, data): + path = "/v0.5/links/link/on-confirm" + additional_headers = {"X-CM-ID": settings.X_CM_ID} + + request_id = str(uuid.uuid4()) + payload = { + "requestId": request_id, + "timestamp": datetime.now(tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.000Z" + ), + "patient": { + "referenceNumber": data["patient_id"], + "display": data["patient_name"], + "careContexts": list( + map( + lambda context: { + "referenceNumber": context["id"], + "display": context["name"], + }, + data["care_contexts"], + ) + ), + }, + # "error": {"code": 1000, "message": "string"}, + "resp": {"requestId": data["request_id"]}, + } + + response = self.api.post(path, payload, None, additional_headers) + return response + + def on_notify(self, data): + path = "/v0.5/consents/hip/on-notify" + additional_headers = {"X-CM-ID": settings.X_CM_ID} + + request_id = str(uuid.uuid4()) + payload = { + "requestId": request_id, + "timestamp": datetime.now(tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.000Z" + ), + "acknowledgement": {"status": "OK", "consentId": data["consent_id"]}, + # "error": {"code": 1000, "message": "string"}, + "resp": {"requestId": data["request_id"]}, + } + + response = self.api.post(path, payload, None, additional_headers) + return response + + def on_data_request(self, data): + path = "/v0.5/health-information/hip/on-request" + additional_headers = {"X-CM-ID": settings.X_CM_ID} + + request_id = str(uuid.uuid4()) + payload = { + "requestId": request_id, + "timestamp": datetime.now(tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.000Z" + ), + "hiRequest": { + "transactionId": data["transaction_id"], + "sessionStatus": "ACKNOWLEDGED", + }, + # "error": {"code": 1000, "message": "string"}, + "resp": {"requestId": data["request_id"]}, + } + + response = self.api.post(path, payload, None, additional_headers) + return response + + def data_transfer(self, data): + headers = {"Content-Type": "application/json"} + + payload = { + "pageNumber": 1, + "pageCount": 1, + "transactionId": data["transaction_id"], + "entries": list( + map( + lambda context: { + "content": context["data"], + "media": "application/fhir+json", + "checksum": "string", + "careContextReference": context["consultation_id"], + }, + data["care_contexts"], + ) + ), + "keyMaterial": data["key_material"], + } + + response = requests.post( + data["data_push_url"], data=json.dumps(payload), headers=headers + ) + return response + + def data_notify(self, data): + path = "/v0.5/health-information/notify" + additional_headers = {"X-CM-ID": settings.X_CM_ID} + + request_id = str(uuid.uuid4()) + payload = { + "requestId": request_id, + "timestamp": datetime.now(tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.000Z" + ), + "notification": { + "consentId": data["consent_id"], + "transactionId": data["transaction_id"], + "doneAt": str( + datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z") + ), + "statusNotification": { + "sessionStatus": "TRANSFERRED", + "hipId": self.hip_id, + "statusResponses": list( + map( + lambda context: { + "careContextReference": context["id"], + "hiStatus": "OK", + "description": "success", # not sure what to put + }, + data["care_contexts"], + ) + ), + }, + }, + } + + response = self.api.post(path, payload, None, additional_headers) + return response + + def patient_status_on_notify(self, data): + path = "/v0.5/patients/status/on-notify" + additional_headers = {"X-CM-ID": settings.X_CM_ID} + + request_id = str(uuid.uuid4()) + payload = { + "requestId": request_id, + "timestamp": datetime.now(tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.000Z" + ), + "acknowledgement": {"status": "OK"}, + # "error": {"code": 1000, "message": "string"}, + "resp": {"requestId": data["request_id"]}, + } + + response = self.api.post(path, payload, None, additional_headers) + return response + + def patient_sms_notify(self, data): + path = "/v0.5/patients/sms/notify2" + additional_headers = {"X-CM-ID": settings.X_CM_ID} + + request_id = str(uuid.uuid4()) + payload = { + "requestId": request_id, + "timestamp": datetime.now(tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.000Z" + ), + "notification": { + "phoneNo": f"+91-{data['phone']}", + "hip": {"name": self.hip_name, "id": self.hip_id}, + }, + } + + response = self.api.post(path, payload, None, additional_headers) + return response + + # /v1.0/patients/profile/on-share + def on_share(self, data): + path = "/v1.0/patients/profile/on-share" + additional_headers = {"X-CM-ID": settings.X_CM_ID} + response = self.api.post(path, data, None, additional_headers) + return response diff --git a/care/abdm/utils/cipher.py b/care/abdm/utils/cipher.py new file mode 100644 index 0000000000..2401d1e1ed --- /dev/null +++ b/care/abdm/utils/cipher.py @@ -0,0 +1,66 @@ +import json + +import requests +from django.conf import settings + + +class Cipher: + server_url = settings.FIDELIUS_URL + + def __init__(self, reciever_public_key, reciever_nonce): + self.reciever_public_key = reciever_public_key + self.reciever_nonce = reciever_nonce + + self.sender_private_key = None + self.sender_public_key = None + self.sender_nonce = None + + self.key_to_share = None + + def generate_key_pair(self): + response = requests.get(f"{self.server_url}/keys/generate") + + if response.status_code == 200: + key_material = response.json() + + self.sender_private_key = key_material["privateKey"] + self.sender_public_key = key_material["publicKey"] + self.sender_nonce = key_material["nonce"] + + return key_material + + return None + + def encrypt(self, paylaod): + if not self.sender_private_key: + key_material = self.generate_key_pair() + + if not key_material: + return None + + response = requests.post( + f"{self.server_url}/encrypt", + headers={"Content-Type": "application/json"}, + data=json.dumps( + { + "receiverPublicKey": self.reciever_public_key, + "receiverNonce": self.reciever_nonce, + "senderPrivateKey": self.sender_private_key, + "senderPublicKey": self.sender_public_key, + "senderNonce": self.sender_nonce, + "plainTextData": paylaod, + } + ), + ) + + if response.status_code == 200: + data = response.json() + self.key_to_share = data["keyToShare"] + + return { + "public_key": self.key_to_share, + "data": data["encryptedData"], + "nonce": self.sender_nonce, + } + + return None diff --git a/care/abdm/utils/fhir.py b/care/abdm/utils/fhir.py new file mode 100644 index 0000000000..b28ab3eb70 --- /dev/null +++ b/care/abdm/utils/fhir.py @@ -0,0 +1,1201 @@ +import base64 +from datetime import datetime, timezone +from uuid import uuid4 as uuid + +from fhir.resources.address import Address +from fhir.resources.annotation import Annotation +from fhir.resources.attachment import Attachment +from fhir.resources.bundle import Bundle, BundleEntry +from fhir.resources.careplan import CarePlan +from fhir.resources.codeableconcept import CodeableConcept +from fhir.resources.coding import Coding +from fhir.resources.composition import Composition, CompositionSection +from fhir.resources.condition import Condition +from fhir.resources.contactpoint import ContactPoint +from fhir.resources.diagnosticreport import DiagnosticReport +from fhir.resources.documentreference import DocumentReference, DocumentReferenceContent +from fhir.resources.dosage import Dosage +from fhir.resources.encounter import Encounter, EncounterDiagnosis +from fhir.resources.humanname import HumanName +from fhir.resources.identifier import Identifier +from fhir.resources.immunization import Immunization, ImmunizationProtocolApplied +from fhir.resources.medication import Medication +from fhir.resources.medicationrequest import MedicationRequest +from fhir.resources.meta import Meta +from fhir.resources.observation import Observation, ObservationComponent +from fhir.resources.organization import Organization +from fhir.resources.patient import Patient +from fhir.resources.period import Period +from fhir.resources.practitioner import Practitioner +from fhir.resources.procedure import Procedure +from fhir.resources.quantity import Quantity +from fhir.resources.reference import Reference + +from care.facility.models.file_upload import FileUpload +from care.facility.models.patient_investigation import InvestigationValue +from care.facility.static_data.icd11 import ICDDiseases + + +class Fhir: + def __init__(self, consultation): + self.consultation = consultation + + self._patient_profile = None + self._practitioner_profile = None + self._organization_profile = None + self._encounter_profile = None + self._careplan_profile = None + self._diagnostic_report_profile = None + self._immunization_profile = None + self._medication_profiles = [] + self._medication_request_profiles = [] + self._observation_profiles = [] + self._document_reference_profiles = [] + self._condition_profiles = [] + self._procedure_profiles = [] + + def _reference_url(self, resource=None): + if resource is None: + return "" + + return f"{resource.resource_type}/{resource.id}" + + def _reference(self, resource=None): + if resource is None: + return None + + return Reference(reference=self._reference_url(resource)) + + def _patient(self): + if self._patient_profile is not None: + return self._patient_profile + + id = str(self.consultation.patient.external_id) + name = self.consultation.patient.name + gender = self.consultation.patient.gender + self._patient_profile = Patient( + id=id, + identifier=[Identifier(value=id)], + name=[HumanName(text=name)], + gender="male" if gender == 1 else "female" if gender == 2 else "other", + ) + + return self._patient_profile + + def _practioner(self): + if self._practitioner_profile is not None: + return self._practitioner_profile + + id = str(uuid()) + name = ( + self.consultation.verified_by + or f"{self.consultation.created_by.first_name} {self.consultation.created_by.last_name}" + ) + self._practitioner_profile = Practitioner( + id=id, + identifier=[Identifier(value=id)], + name=[HumanName(text=name)], + ) + + return self._practitioner_profile + + def _organization(self): + if self._organization_profile is not None: + return self._organization_profile + + id = str(self.consultation.facility.external_id) + hip_id = "IN3210000017" # TODO: make it dynamic + name = self.consultation.facility.name + phone = self.consultation.facility.phone_number + address = self.consultation.facility.address + local_body = self.consultation.facility.local_body.name + district = self.consultation.facility.district.name + state = self.consultation.facility.state.name + pincode = self.consultation.facility.pincode + self._organization_profile = Organization( + id=id, + identifier=[ + Identifier(system="https://facilitysbx.ndhm.gov.in", value=hip_id) + ], + name=name, + telecom=[ContactPoint(system="phone", value=phone)], + address=[ + Address( + line=[address, local_body], + district=district, + state=state, + postalCode=pincode, + country="INDIA", + ) + ], + ) + + return self._organization_profile + + def _condition(self, diagnosis_id, provisional=False): + diagnosis = ICDDiseases.by.id[diagnosis_id] + [code, label] = diagnosis.label.split(" ", 1) + condition_profile = Condition( + id=diagnosis_id, + identifier=[Identifier(value=diagnosis_id)], + category=[ + CodeableConcept( + coding=[ + Coding( + system="http://terminology.hl7.org/CodeSystem/condition-category", + code="encounter-diagnosis", + display="Encounter Diagnosis", + ) + ], + text="Encounter Diagnosis", + ) + ], + verificationStatus=CodeableConcept( + coding=[ + Coding( + system="http://terminology.hl7.org/CodeSystem/condition-ver-status", + code="provisional" if provisional else "confirmed", + display="Provisional" if provisional else "Confirmed", + ) + ] + ), + code=CodeableConcept( + coding=[ + Coding( + system="http://id.who.int/icd/release/11/mms", + code=code, + display=label, + ) + ], + text=diagnosis.label, + ), + subject=self._reference(self._patient()), + ) + + self._condition_profiles.append(condition_profile) + return condition_profile + + def _procedure(self, procedure): + procedure_profile = Procedure( + id=str(uuid()), + status="completed", + code=CodeableConcept( + text=procedure["procedure"], + ), + subject=self._reference(self._patient()), + performedDateTime=f"{procedure['time']}:00+05:30" + if not procedure["repetitive"] + else None, + performedString=f"Every {procedure['frequency']}" + if procedure["repetitive"] + else None, + ) + + self._procedure_profiles.append(procedure_profile) + return procedure_profile + + def _careplan(self): + if self._careplan_profile: + return self._careplan_profile + + self._careplan_profile = CarePlan( + id=str(uuid()), + status="completed", + intent="plan", + title="Care Plan", + description="This includes Treatment Summary, Prescribed Medication, General Notes and Special Instructions", + period=Period( + start=self.consultation.admission_date.isoformat(), + end=self.consultation.discharge_date.isoformat() + if self.consultation.discharge_date + else None, + ), + note=[ + Annotation(text=self.consultation.prescribed_medication), + Annotation(text=self.consultation.consultation_notes), + Annotation(text=self.consultation.special_instruction), + ], + subject=self._reference(self._patient()), + ) + + return self._careplan_profile + + def _diagnostic_report(self): + if self._diagnostic_report_profile: + return self._diagnostic_report_profile + + self._diagnostic_report_profile = DiagnosticReport( + id=str(uuid()), + status="final", + code=CodeableConcept(text="Investigation/Test Results"), + result=list( + map( + lambda investigation: self._reference( + self._observation( + title=investigation.investigation.name, + value={ + "value": investigation.value, + "unit": investigation.investigation.unit, + }, + id=str(investigation.external_id), + date=investigation.created_date.isoformat(), + ) + ), + InvestigationValue.objects.filter(consultation=self.consultation), + ) + ), + subject=self._reference(self._patient()), + performer=[self._reference(self._organization())], + resultsInterpreter=[self._reference(self._organization())], + conclusion="Refer to Doctor. To be correlated with further study.", + ) + + return self._diagnostic_report_profile + + def _observation(self, title, value, id, date): + if not value or (type(value) == dict and not value["value"]): + return + + return Observation( + id=f"{id}.{title.replace(' ', '').replace('_', '-')}" + if id and title + else str(uuid()), + status="final", + effectiveDateTime=date if date else None, + code=CodeableConcept(text=title), + valueQuantity=Quantity(value=str(value["value"]), unit=value["unit"]) + if type(value) == dict + else None, + valueString=value if type(value) == str else None, + component=list( + map( + lambda component: ObservationComponent( + code=CodeableConcept(text=component["title"]), + valueQuantity=Quantity( + value=component["value"], unit=component["unit"] + ) + if type(component) == dict + else None, + valueString=component if type(component) == str else None, + ), + value, + ) + ) + if type(value) == list + else None, + ) + + def _observations_from_daily_round(self, daily_round): + id = str(daily_round.external_id) + date = daily_round.created_date.isoformat() + observation_profiles = [ + self._observation( + "Temperature", + {"value": daily_round.temperature, "unit": "F"}, + id, + date, + ), + self._observation( + "SpO2", + {"value": daily_round.spo2, "unit": "%"}, + id, + date, + ), + self._observation( + "Pulse", + {"value": daily_round.pulse, "unit": "bpm"}, + id, + date, + ), + self._observation( + "Resp", + {"value": daily_round.resp, "unit": "bpm"}, + id, + date, + ), + self._observation( + "Blood Pressure", + [ + { + "title": "Systolic Blood Pressure", + "value": daily_round.bp["systolic"], + "unit": "mmHg", + }, + { + "title": "Diastolic Blood Pressure", + "value": daily_round.bp["diastolic"], + "unit": "mmHg", + }, + ] + if "systolic" in daily_round.bp and "diastolic" in daily_round.bp + else None, + id, + date, + ), + ] + + # TODO: do it for other fields like bp, pulse, spo2, ... + + observation_profiles = list( + filter(lambda profile: profile is not None, observation_profiles) + ) + self._observation_profiles.extend(observation_profiles) + return observation_profiles + + def _encounter(self, include_diagnosis=False): + if self._encounter_profile is not None: + return self._encounter_profile + + id = str(self.consultation.external_id) + status = "finished" if self.consultation.discharge_date else "in-progress" + period_start = self.consultation.admission_date.isoformat() + period_end = ( + self.consultation.discharge_date.isoformat() + if self.consultation.discharge_date + else None + ) + self._encounter_profile = Encounter( + **{ + "id": id, + "identifier": [Identifier(value=id)], + "status": status, + "class": Coding(code="IMP", display="Inpatient Encounter"), + "subject": self._reference(self._patient()), + "period": Period(start=period_start, end=period_end), + "diagnosis": list( + map( + lambda diagnosis: EncounterDiagnosis( + condition=self._reference( + self._condition(diagnosis), + ) + ), + self.consultation.icd11_diagnoses, + ) + ) + + list( + map( + lambda diagnosis: EncounterDiagnosis( + condition=self._reference(self._condition(diagnosis)) + ), + self.consultation.icd11_provisional_diagnoses, + ) + ) + if include_diagnosis + else None, + } + ) + + return self._encounter_profile + + def _immunization(self): + if self._immunization_profile: + return self._immunization_profile + + if not self.consultation.patient.is_vaccinated: + return + + self._immunization_profile = Immunization( + id=str(uuid()), + status="completed", + identifier=[ + Identifier( + type=CodeableConcept(text="Covin Id"), + value=self.consultation.patient.covin_id, + ) + ], + vaccineCode=CodeableConcept( + coding=[ + Coding( + system="http://snomed.info/sct", + code="1119305005", + display="COVID-19 antigen vaccine", + ) + ], + text=self.consultation.patient.vaccine_name, + ), + patient=self._reference(self._patient()), + route=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + code="47625008", + display="Intravenous route", + ) + ] + ), + occurrenceDateTime=self.consultation.patient.last_vaccinated_date.isoformat(), + protocolApplied=[ + ImmunizationProtocolApplied( + doseNumberPositiveInt=self.consultation.patient.number_of_doses + ) + ], + ) + + def _document_reference(self, file): + id = str(file.external_id) + content_type, content = file.file_contents() + document_reference_profile = DocumentReference( + id=id, + identifier=[Identifier(value=id)], + status="current", + type=CodeableConcept(text=file.internal_name.split(".")[0]), + content=[ + DocumentReferenceContent( + attachment=Attachment( + contentType=content_type, data=base64.b64encode(content) + ) + ) + ], + author=[self._reference(self._organization())], + ) + + self._document_reference_profiles.append(document_reference_profile) + return document_reference_profile + + def _medication(self, name): + medication_profile = Medication(id=str(uuid()), code=CodeableConcept(text=name)) + + self._medication_profiles.append(medication_profile) + return medication_profile + + def _medication_request(self, medicine): + id = str(uuid()) + prescription_date = ( + self.consultation.admission_date.isoformat() + ) # TODO: change to the time of prescription + status = "unknown" # TODO: get correct status active | on-hold | cancelled | completed | entered-in-error | stopped | draft | unknown + dosage_text = f"{medicine['dosage_new']} / {medicine['dosage']} for {medicine['days']} days" + + medication_profile = self._medication(medicine["medicine"]) + medication_request_profile = MedicationRequest( + id=id, + identifier=[Identifier(value=id)], + status=status, + intent="order", + authoredOn=prescription_date, + dosageInstruction=[Dosage(text=dosage_text)], + medicationReference=self._reference(medication_profile), + subject=self._reference(self._patient()), + requester=self._reference(self._practioner()), + ) + + self._medication_request_profiles.append(medication_request_profile) + return medication_profile, medication_request_profile + + def _prescription_composition(self): + id = str(uuid()) # TODO: use identifiable id + return Composition( + id=id, + identifier=Identifier(value=id), + status="final", # TODO: use appropriate one + type=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + code="440545006", + display="Prescription record", + ) + ] + ), + title="Prescription", + date=datetime.now(timezone.utc).isoformat(), + section=[ + CompositionSection( + title="In Patient Prescriptions", + code=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + code="440545006", + display="Prescription record", + ) + ] + ), + entry=list( + map( + lambda medicine: self._reference( + self._medication_request(medicine)[1] + ), + self.consultation.discharge_advice, + ) + ), + ) + ], + subject=self._reference(self._patient()), + encounter=self._reference(self._encounter()), + author=[self._reference(self._organization())], + ) + + def _health_document_composition(self): + id = str(uuid()) # TODO: use identifiable id + return Composition( + id=id, + identifier=Identifier(value=id), + status="final", # TODO: use appropriate one + type=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + code="419891008", + display="Record artifact", + ) + ] + ), + title="Health Document Record", + date=datetime.now(timezone.utc).isoformat(), + section=[ + CompositionSection( + title="Health Document Record", + code=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + code="419891008", + display="Record artifact", + ) + ] + ), + entry=list( + map( + lambda file: self._reference( + self._document_reference(file) + ), + FileUpload.objects.filter( + associating_id=self.consultation.id + ), + ) + ), + ) + ], + subject=self._reference(self._patient()), + encounter=self._reference(self._encounter()), + author=[self._reference(self._organization())], + ) + + def _wellness_composition(self): + id = str(uuid()) # TODO: use identifiable id + return Composition( + id=id, + identifier=Identifier(value=id), + status="final", # TODO: use appropriate one + type=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + display="Wellness Record", + ) + ] + ), + title="Wellness Record", + date=datetime.now(timezone.utc).isoformat(), + section=list( + map( + lambda daily_round: CompositionSection( + title=f"Daily Round - {daily_round.created_date}", + code=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + display="Wellness Record", + ) + ] + ), + entry=list( + map( + lambda observation_profile: self._reference( + observation_profile + ), + self._observations_from_daily_round(daily_round), + ) + ), + ), + self.consultation.daily_rounds.all(), + ) + ), + subject=self._reference(self._patient()), + encounter=self._reference(self._encounter()), + author=[self._reference(self._organization())], + ) + + def _immunization_composition(self): + id = str(uuid()) # TODO: use identifiable id + return Composition( + id=id, + identifier=Identifier(value=id), + status="final", # TODO: use appropriate one + type=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + code="41000179103", + display="Immunization Record", + ), + ], + ), + title="Immunization", + date=datetime.now(timezone.utc).isoformat(), + section=[ + CompositionSection( + title="IPD Immunization", + code=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + code="41000179103", + display="Immunization Record", + ), + ], + ), + entry=[ + *( + [self._reference(self._immunization())] + if self._immunization() + else [] + ) + ], + emptyReason=None + if self._immunization() + else CodeableConcept( + coding=[Coding(code="notasked", display="Not Asked")] + ), + ), + ], + subject=self._reference(self._patient()), + encounter=self._reference(self._encounter()), + author=[self._reference(self._organization())], + ) + + def _diagnostic_report_composition(self): + id = str(uuid()) # TODO: use identifiable id + return Composition( + id=id, + identifier=Identifier(value=id), + status="final", # TODO: use appropriate one + type=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + code="721981007", + display="Diagnostic Report", + ), + ], + ), + title="Diagnostic Report", + date=datetime.now(timezone.utc).isoformat(), + section=[ + CompositionSection( + title="Investigation Report", + code=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + code="721981007", + display="Diagnostic Report", + ), + ], + ), + entry=[self._reference(self._diagnostic_report())], + ), + ], + subject=self._reference(self._patient()), + encounter=self._reference(self._encounter()), + author=[self._reference(self._organization())], + ) + + def _discharge_summary_composition(self): + id = str(uuid()) # TODO: use identifiable id + return Composition( + id=id, + identifier=Identifier(value=id), + status="final", # TODO: use appropriate one + type=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + code="373942005", + display="Discharge Summary Record", + ) + ] + ), + title="Discharge Summary Document", + date=datetime.now(timezone.utc).isoformat(), + section=[ + CompositionSection( + title="Prescribed medications", + code=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + code="440545006", + display="Prescription", + ) + ] + ), + entry=list( + map( + lambda medicine: self._reference( + self._medication_request(medicine)[1] + ), + self.consultation.discharge_advice, + ) + ), + ), + CompositionSection( + title="Health Documents", + code=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + code="419891008", + display="Record", + ) + ] + ), + entry=list( + map( + lambda file: self._reference( + self._document_reference(file) + ), + FileUpload.objects.filter( + associating_id=self.consultation.id + ), + ) + ), + ), + *list( + map( + lambda daily_round: CompositionSection( + title=f"Daily Round - {daily_round.created_date}", + code=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + display="Wellness Record", + ) + ] + ), + entry=list( + map( + lambda observation_profile: self._reference( + observation_profile + ), + self._observations_from_daily_round(daily_round), + ) + ), + ), + self.consultation.daily_rounds.all(), + ) + ), + CompositionSection( + title="Procedures", + code=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + code="371525003", + display="Clinical procedure report", + ) + ] + ), + entry=list( + map( + lambda procedure: self._reference( + self._procedure(procedure) + ), + self.consultation.procedure, + ) + ), + ), + CompositionSection( + title="Care Plan", + code=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + code="734163000", + display="Care Plan", + ) + ] + ), + entry=[self._reference(self._careplan())], + ), + ], + subject=self._reference(self._patient()), + encounter=self._reference(self._encounter(include_diagnosis=True)), + author=[self._reference(self._organization())], + ) + + def _op_consultation_composition(self): + id = str(uuid()) # TODO: use identifiable id + return Composition( + id=id, + identifier=Identifier(value=id), + status="final", # TODO: use appropriate one + type=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + code="371530004", + display="Clinical consultation report", + ) + ] + ), + title="OP Consultation Document", + date=datetime.now(timezone.utc).isoformat(), + section=[ + CompositionSection( + title="Prescribed medications", + code=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + code="440545006", + display="Prescription", + ) + ] + ), + entry=list( + map( + lambda medicine: self._reference( + self._medication_request(medicine)[1] + ), + self.consultation.discharge_advice, + ) + ), + ), + CompositionSection( + title="Health Documents", + code=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + code="419891008", + display="Record", + ) + ] + ), + entry=list( + map( + lambda file: self._reference( + self._document_reference(file) + ), + FileUpload.objects.filter( + associating_id=self.consultation.id + ), + ) + ), + ), + *list( + map( + lambda daily_round: CompositionSection( + title=f"Daily Round - {daily_round.created_date}", + code=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + display="Wellness Record", + ) + ] + ), + entry=list( + map( + lambda observation_profile: self._reference( + observation_profile + ), + self._observations_from_daily_round(daily_round), + ) + ), + ), + self.consultation.daily_rounds.all(), + ) + ), + CompositionSection( + title="Procedures", + code=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + code="371525003", + display="Clinical procedure report", + ) + ] + ), + entry=list( + map( + lambda procedure: self._reference( + self._procedure(procedure) + ), + self.consultation.procedure, + ) + ), + ), + CompositionSection( + title="Care Plan", + code=CodeableConcept( + coding=[ + Coding( + system="https://projecteka.in/sct", + code="734163000", + display="Care Plan", + ) + ] + ), + entry=[self._reference(self._careplan())], + ), + ], + subject=self._reference(self._patient()), + encounter=self._reference(self._encounter(include_diagnosis=True)), + author=[self._reference(self._organization())], + ) + + def _bundle_entry(self, resource): + return BundleEntry(fullUrl=self._reference_url(resource), resource=resource) + + def create_prescription_record(self): + id = str(uuid()) + now = datetime.now(timezone.utc).isoformat() + return Bundle( + id=id, + identifier=Identifier(value=id), + type="document", + meta=Meta(lastUpdated=now), + timestamp=now, + entry=[ + self._bundle_entry(self._prescription_composition()), + self._bundle_entry(self._practioner()), + self._bundle_entry(self._patient()), + self._bundle_entry(self._organization()), + self._bundle_entry(self._encounter()), + *list( + map( + lambda resource: self._bundle_entry(resource), + self._medication_profiles, + ) + ), + *list( + map( + lambda resource: self._bundle_entry(resource), + self._medication_request_profiles, + ) + ), + ], + ).json() + + def create_wellness_record(self): + id = str(uuid()) + now = datetime.now(timezone.utc).isoformat() + return Bundle( + id=id, + identifier=Identifier(value=id), + type="document", + meta=Meta(lastUpdated=now), + timestamp=now, + entry=[ + self._bundle_entry(self._wellness_composition()), + self._bundle_entry(self._practioner()), + self._bundle_entry(self._patient()), + self._bundle_entry(self._organization()), + self._bundle_entry(self._encounter()), + *list( + map( + lambda resource: self._bundle_entry(resource), + self._observation_profiles, + ) + ), + ], + ).json() + + def create_immunization_record(self): + id = str(uuid()) + now = datetime.now(timezone.utc).isoformat() + return Bundle( + id=id, + identifier=Identifier(value=id), + type="document", + meta=Meta(lastUpdated=now), + timestamp=now, + entry=[ + self._bundle_entry(self._immunization_composition()), + self._bundle_entry(self._practioner()), + self._bundle_entry(self._patient()), + self._bundle_entry(self._organization()), + self._bundle_entry(self._encounter()), + self._bundle_entry(self._immunization()), + ], + ).json() + + def create_diagnostic_report_record(self): + id = str(uuid()) + now = datetime.now(timezone.utc).isoformat() + return Bundle( + id=id, + identifier=Identifier(value=id), + type="document", + meta=Meta(lastUpdated=now), + timestamp=now, + entry=[ + self._bundle_entry(self._diagnostic_report_composition()), + self._bundle_entry(self._practioner()), + self._bundle_entry(self._patient()), + self._bundle_entry(self._organization()), + self._bundle_entry(self._encounter()), + *list( + map( + lambda resource: self._bundle_entry(resource), + self._observation_profiles, + ) + ), + ], + ).json() + + def create_health_document_record(self): + id = str(uuid()) + now = datetime.now(timezone.utc).isoformat() + return Bundle( + id=id, + identifier=Identifier(value=id), + type="document", + meta=Meta(lastUpdated=now), + timestamp=now, + entry=[ + self._bundle_entry(self._health_document_composition()), + self._bundle_entry(self._practioner()), + self._bundle_entry(self._patient()), + self._bundle_entry(self._organization()), + self._bundle_entry(self._encounter()), + *list( + map( + lambda resource: self._bundle_entry(resource), + self._document_reference_profiles, + ) + ), + ], + ).json() + + def create_discharge_summary_record(self): + id = str(uuid()) + now = datetime.now(timezone.utc).isoformat() + return Bundle( + id=id, + identifier=Identifier(value=id), + type="document", + meta=Meta(lastUpdated=now), + timestamp=now, + entry=[ + self._bundle_entry(self._discharge_summary_composition()), + self._bundle_entry(self._practioner()), + self._bundle_entry(self._patient()), + self._bundle_entry(self._organization()), + self._bundle_entry(self._encounter()), + self._bundle_entry(self._careplan()), + *list( + map( + lambda resource: self._bundle_entry(resource), + self._medication_profiles, + ) + ), + *list( + map( + lambda resource: self._bundle_entry(resource), + self._medication_request_profiles, + ) + ), + *list( + map( + lambda resource: self._bundle_entry(resource), + self._condition_profiles, + ) + ), + *list( + map( + lambda resource: self._bundle_entry(resource), + self._procedure_profiles, + ) + ), + *list( + map( + lambda resource: self._bundle_entry(resource), + self._document_reference_profiles, + ) + ), + *list( + map( + lambda resource: self._bundle_entry(resource), + self._observation_profiles, + ) + ), + ], + ).json() + + def create_op_consultation_record(self): + id = str(uuid()) + now = datetime.now(timezone.utc).isoformat() + return Bundle( + id=id, + identifier=Identifier(value=id), + type="document", + meta=Meta(lastUpdated=now), + timestamp=now, + entry=[ + self._bundle_entry(self._op_consultation_composition()), + self._bundle_entry(self._practioner()), + self._bundle_entry(self._patient()), + self._bundle_entry(self._organization()), + self._bundle_entry(self._encounter()), + self._bundle_entry(self._careplan()), + *list( + map( + lambda resource: self._bundle_entry(resource), + self._medication_profiles, + ) + ), + *list( + map( + lambda resource: self._bundle_entry(resource), + self._medication_request_profiles, + ) + ), + *list( + map( + lambda resource: self._bundle_entry(resource), + self._condition_profiles, + ) + ), + *list( + map( + lambda resource: self._bundle_entry(resource), + self._procedure_profiles, + ) + ), + *list( + map( + lambda resource: self._bundle_entry(resource), + self._document_reference_profiles, + ) + ), + *list( + map( + lambda resource: self._bundle_entry(resource), + self._observation_profiles, + ) + ), + ], + ).json() + + def create_record(self, record_type): + if record_type == "Prescription": + return self.create_prescription_record() + elif record_type == "WellnessRecord": + return self.create_wellness_record() + elif record_type == "ImmunizationRecord": + return self.create_immunization_record() + elif record_type == "HealthDocumentRecord": + return self.create_health_document_record() + elif record_type == "DiagnosticReport": + return self.create_diagnostic_report_record() + elif record_type == "DischargeSummary": + return self.create_discharge_summary_record() + elif record_type == "OPConsultation": + return self.create_op_consultation_record() + else: + return self.create_discharge_summary_record() diff --git a/care/abdm/views.py b/care/abdm/views.py new file mode 100644 index 0000000000..60f00ef0ef --- /dev/null +++ b/care/abdm/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/care/facility/api/serializers/asset.py b/care/facility/api/serializers/asset.py index fbb9f44970..7e672c4a01 100644 --- a/care/facility/api/serializers/asset.py +++ b/care/facility/api/serializers/asset.py @@ -18,6 +18,7 @@ from care.facility.api.serializers.facility import FacilityBareMinimumSerializer from care.facility.models.asset import ( Asset, + AssetAvailabilityRecord, AssetLocation, AssetTransaction, UserDefaultAssetLocation, @@ -165,6 +166,15 @@ class Meta: exclude = ("deleted", "external_id") +class AssetAvailabilitySerializer(ModelSerializer): + id = UUIDField(source="external_id", read_only=True) + asset = AssetBareMinimumSerializer(read_only=True) + + class Meta: + model = AssetAvailabilityRecord + exclude = ("deleted", "external_id") + + class UserDefaultAssetLocationSerializer(ModelSerializer): location_object = AssetLocationSerializer(source="location", read_only=True) diff --git a/care/facility/api/serializers/patient.py b/care/facility/api/serializers/patient.py index e1fe82f098..b0d60fc81b 100644 --- a/care/facility/api/serializers/patient.py +++ b/care/facility/api/serializers/patient.py @@ -6,6 +6,8 @@ from django.utils.timezone import localtime, make_aware, now from rest_framework import serializers +from care.abdm.api.serializers.abhanumber import AbhaNumberSerializer +from care.abdm.models import AbhaNumber from care.facility.api.serializers import TIMESTAMP_FIELDS from care.facility.api.serializers.facility import ( FacilityBasicInfoSerializer, @@ -209,6 +211,11 @@ class Meta: allow_transfer = serializers.BooleanField(default=settings.PEACETIME_MODE) + abha_number = ExternalIdSerializerField( + queryset=AbhaNumber.objects.all(), required=False, allow_null=True + ) + abha_number_object = AbhaNumberSerializer(source="abha_number", read_only=True) + class Meta: model = PatientRegistration exclude = ( @@ -455,6 +462,7 @@ def save(self, **kwargs): class PatientNotesSerializer(serializers.ModelSerializer): facility = FacilityBasicInfoSerializer(read_only=True) created_by_object = UserBaseMinimumSerializer(source="created_by", read_only=True) + created_by_local_user = serializers.BooleanField(read_only=True) def validate_empty_values(self, data): if not data.get("note", "").strip(): @@ -463,5 +471,11 @@ def validate_empty_values(self, data): class Meta: model = PatientNotes - fields = ("note", "facility", "created_by_object", "created_date") + fields = ( + "note", + "facility", + "created_by_object", + "created_by_local_user", + "created_date", + ) read_only_fields = ("created_date",) diff --git a/care/facility/api/serializers/patient_consultation.py b/care/facility/api/serializers/patient_consultation.py index 35977940e5..a282c7cb4c 100644 --- a/care/facility/api/serializers/patient_consultation.py +++ b/care/facility/api/serializers/patient_consultation.py @@ -5,6 +5,7 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError +from care.abdm.utils.api_call import AbdmGateway from care.facility.api.serializers import TIMESTAMP_FIELDS from care.facility.api.serializers.bed import ConsultationBedDetailSerializer from care.facility.api.serializers.daily_round import DailyRoundSerializer @@ -419,6 +420,7 @@ class Meta: model = PatientConsultation fields = ( "discharge_reason", + "referred_to_external", "discharge_notes", "discharge_date", "discharge_prescription", @@ -435,7 +437,10 @@ def validate(self, attrs): raise ValidationError( {"death_datetime": "This field value cannot be in the future."} ) - if attrs.get("death_datetime") < self.instance.admission_date: + if ( + self.instance.admission_date + and attrs.get("death_datetime") < self.instance.admission_date + ): raise ValidationError( { "death_datetime": "This field value cannot be before the admission date." @@ -452,7 +457,10 @@ def validate(self, attrs): raise ValidationError( {"discharge_date": "This field value cannot be in the future."} ) - elif attrs.get("discharge_date") < self.instance.admission_date: + elif ( + self.instance.admission_date + and attrs.get("discharge_date") < self.instance.admission_date + ): raise ValidationError( { "discharge_date": "This field value cannot be before the admission date." @@ -471,6 +479,18 @@ def save(self, **kwargs): ConsultationBed.objects.filter( consultation=self.instance, end_date__isnull=True ).update(end_date=now()) + if patient.abha_number: + abha_number = patient.abha_number + AbdmGateway().fetch_modes( + { + "healthId": abha_number.abha_number, + "name": abha_number.name, + "gender": abha_number.gender, + "dateOfBirth": str(abha_number.date_of_birth), + "consultationId": abha_number.external_id, + "purpose": "LINK", + } + ) return instance def create(self, validated_data): diff --git a/care/facility/api/serializers/patient_otp.py b/care/facility/api/serializers/patient_otp.py index 9d6951e883..7457ac8454 100644 --- a/care/facility/api/serializers/patient_otp.py +++ b/care/facility/api/serializers/patient_otp.py @@ -26,7 +26,7 @@ def send_sms(otp, phone_number): sendSMS( phone_number, ( - f"CoronaSafe Network Patient Management System Login, OTP is {otp} . " + f"Open Healthcare Network Patient Management System Login, OTP is {otp} . " "Please do not share this Confidential Login Token with anyone else" ), ) diff --git a/care/facility/api/serializers/shifting.py b/care/facility/api/serializers/shifting.py index 17bc352215..8be8fc8863 100644 --- a/care/facility/api/serializers/shifting.py +++ b/care/facility/api/serializers/shifting.py @@ -12,6 +12,7 @@ ) from care.facility.models import ( BREATHLESSNESS_CHOICES, + CATEGORY_CHOICES, FACILITY_TYPES, SHIFTING_STATUS_CHOICES, VEHICLE_CHOICES, @@ -217,6 +218,7 @@ class ShiftingSerializer(serializers.ModelSerializer): last_edited_by_object = UserBaseMinimumSerializer( source="last_edited_by", read_only=True ) + patient_category = ChoiceField(choices=CATEGORY_CHOICES, required=False) ambulance_driver_name = serializers.CharField( required=False, allow_null=True, allow_blank=True ) @@ -327,8 +329,10 @@ def update(self, instance, validated_data): new_instance = super().update(instance, validated_data) patient = new_instance.patient - patient.last_consultation.category = self.initial_data["patient_category"] - patient.last_consultation.save() + patient_category = validated_data.pop("patient_category") + if patient.last_consultation and patient_category is not None: + patient.last_consultation.category = patient_category + patient.last_consultation.save(update_fields=["category"]) if ( "status" in validated_data @@ -390,9 +394,10 @@ def create(self, validated_data): patient.allow_transfer = True patient.save() - if patient.last_consultation: - patient.last_consultation.category = self.initial_data["patient_category"] - patient.last_consultation.save() + patient_category = validated_data.pop("patient_category") + if patient.last_consultation and patient_category is not None: + patient.last_consultation.category = patient_category + patient.last_consultation.save(update_fields=["category"]) validated_data["origin_facility"] = patient.facility diff --git a/care/facility/api/viewsets/asset.py b/care/facility/api/viewsets/asset.py index 57ec16da51..2b5fc6e6b1 100644 --- a/care/facility/api/viewsets/asset.py +++ b/care/facility/api/viewsets/asset.py @@ -23,6 +23,7 @@ from rest_framework.viewsets import GenericViewSet from care.facility.api.serializers.asset import ( + AssetAvailabilitySerializer, AssetLocationSerializer, AssetSerializer, AssetTransactionSerializer, @@ -32,6 +33,7 @@ ) from care.facility.models.asset import ( Asset, + AssetAvailabilityRecord, AssetLocation, AssetTransaction, UserDefaultAssetLocation, @@ -128,6 +130,17 @@ def retrieve(self, request, *args, **kwargs): return Response(hit) +class AssetAvailabilityFilter(filters.FilterSet): + external_id = filters.CharFilter(field_name="asset__external_id") + + +class AssetAvailabilityViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): + queryset = AssetAvailabilityRecord.objects.all().select_related("asset") + serializer_class = AssetAvailabilitySerializer + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = AssetAvailabilityFilter + + class AssetViewSet( ListModelMixin, RetrieveModelMixin, diff --git a/care/facility/api/viewsets/facility_users.py b/care/facility/api/viewsets/facility_users.py index 8422991791..a03319ecf8 100644 --- a/care/facility/api/viewsets/facility_users.py +++ b/care/facility/api/viewsets/facility_users.py @@ -1,3 +1,4 @@ +from django.db.models import Prefetch from django_filters import rest_framework as filters from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import mixins @@ -7,7 +8,7 @@ from care.facility.models.facility import Facility from care.users.api.serializers.user import UserAssignedSerializer -from care.users.models import User +from care.users.models import Skill, User class UserFilter(filters.FilterSet): @@ -34,6 +35,12 @@ def get_queryset(self): facility = Facility.objects.get( external_id=self.kwargs.get("facility_external_id") ) - return facility.users.filter(deleted=False).order_by("-last_login") + queryset = facility.users.filter(deleted=False).order_by("-last_login") + queryset = queryset.prefetch_related( + Prefetch( + "skills", queryset=Skill.objects.filter(userskill__deleted=False) + ) + ) + return queryset except Facility.DoesNotExist: raise ValidationError({"Facility": "Facility not found"}) diff --git a/care/facility/api/viewsets/patient.py b/care/facility/api/viewsets/patient.py index 6f458c2902..5e895eab62 100644 --- a/care/facility/api/viewsets/patient.py +++ b/care/facility/api/viewsets/patient.py @@ -5,7 +5,7 @@ from django.conf import settings from django.contrib.postgres.search import TrigramSimilarity from django.db import models -from django.db.models import Case, When +from django.db.models import BooleanField, Case, F, Value, When from django.db.models.query_utils import Q from django_filters import rest_framework as filters from djqscsv import render_to_csv_response @@ -595,6 +595,16 @@ class PatientNotesViewSet( queryset = ( PatientNotes.objects.all() .select_related("facility", "patient", "created_by") + .annotate( + created_by_local_user=Case( + When( + created_by__home_facility__external_id=F("facility__external_id"), + then=Value(True), + ), + default=Value(False), + output_field=BooleanField(), + ) + ) .order_by("-created_date") ) serializer_class = PatientNotesSerializer @@ -617,6 +627,7 @@ def get_queryset(self): q_filters |= Q(patient__last_consultation__assigned_to=user) q_filters |= Q(patient__assigned_to=user) queryset = queryset.filter(q_filters) + return queryset def perform_create(self, serializer): diff --git a/care/facility/api/viewsets/patient_consultation.py b/care/facility/api/viewsets/patient_consultation.py index dc18b65851..98b60ba393 100644 --- a/care/facility/api/viewsets/patient_consultation.py +++ b/care/facility/api/viewsets/patient_consultation.py @@ -1,3 +1,4 @@ +from django.db.models import Prefetch from django.db.models.query_utils import Q from django_filters import rest_framework as filters from drf_spectacular.utils import extend_schema @@ -24,7 +25,7 @@ email_discharge_summary, generate_and_upload_discharge_summary_task, ) -from care.users.models import User +from care.users.models import Skill, User from care.utils.cache.cache_allowed_facilities import get_accessible_facilities @@ -69,6 +70,14 @@ def get_permissions(self): return super().get_permissions() def get_queryset(self): + if self.serializer_class == PatientConsultationSerializer: + self.queryset = self.queryset.prefetch_related( + "assigned_to", + Prefetch( + "assigned_to__skills", + queryset=Skill.objects.filter(userskill__deleted=False), + ), + ) if self.request.user.is_superuser: return self.queryset elif self.request.user.user_type >= User.TYPE_VALUE_MAP["StateLabAdmin"]: diff --git a/care/facility/api/viewsets/prescription.py b/care/facility/api/viewsets/prescription.py index ff1dfaa3a1..e6007e72c3 100644 --- a/care/facility/api/viewsets/prescription.py +++ b/care/facility/api/viewsets/prescription.py @@ -1,5 +1,3 @@ -from re import IGNORECASE - from django.shortcuts import get_object_or_404 from django_filters import rest_framework as filters from drf_spectacular.utils import extend_schema @@ -140,36 +138,30 @@ class MedibaseViewSet(ViewSet): permission_classes = (IsAuthenticated,) def serailize_data(self, objects): - result = [] - for object in objects: - if type(object) == tuple: - object = object[0] - result.append( - { - "id": object.external_id, - "name": object.name, - "type": object.type, - "generic": object.generic, - "company": object.company, - "contents": object.contents, - "cims_class": object.cims_class, - "atc_classification": object.atc_classification, - } - ) - return result + return [ + { + "id": x[0], + "name": x[1], + "type": x[2], + "generic": x[3], + "company": x[4], + "contents": x[5], + "cims_class": x[6], + "atc_classification": x[7], + } + for x in objects + ] def sort(self, query, results): exact_matches = [] partial_matches = [] - for result in results: - if type(result) == tuple: - result = result[0] - words = result.searchable.lower().split() + for x in results: + words = f"{x[1]} {x[3]} {x[4]}".lower().split() if query in words: - exact_matches.append(result) + exact_matches.append(x) else: - partial_matches.append(result) + partial_matches.append(x) return exact_matches + partial_matches @@ -178,10 +170,8 @@ def list(self, request): queryset = MedibaseMedicineTable - if request.GET.get("query", False): - query = request.GET.get("query").strip().lower() - queryset = queryset.where( - searchable=queryset.re_match(r".*" + query + r".*", IGNORECASE) - ) + if query := request.query_params.get("query"): + query = query.strip().lower() + queryset = [x for x in queryset if query in f"{x[1]} {x[3]} {x[4]}".lower()] queryset = self.sort(query, queryset) return Response(self.serailize_data(queryset[:15])) diff --git a/care/facility/management/commands/load_meta_icd11_diagnosis.py b/care/facility/management/commands/load_meta_icd11_diagnosis.py index 9a339df9bb..2895608657 100644 --- a/care/facility/management/commands/load_meta_icd11_diagnosis.py +++ b/care/facility/management/commands/load_meta_icd11_diagnosis.py @@ -13,10 +13,70 @@ class Command(BaseCommand): help = "Loads ICD11 data to a table in to database." + data = [] + roots_lookup = {} + """ + Eg: + ``` + { + "http://id.who.int/icd/entity/594985340": { + "chapter": "Certain infectious or parasitic diseases", + "block": "Intestinal infectious diseases", + "category": None, + }, + } + ``` + """ + + CLASS_KIND_DB_KEYS = { + "block": "root_block", + "category": "root_category", + } + + def find_roots(self, item): + id = item["ID"] + + if id in self.roots_lookup: + return self.roots_lookup[id] + + if not item["parentId"]: + self.roots_lookup[id] = {item["classKind"]: item["label"]} + return self.roots_lookup[id] + + if parent := self.roots_lookup.get(item["parentId"]): + + def my(x): + return item["label"] if item["classKind"] == x else None + + self.roots_lookup[id] = { + "chapter": parent.get("chapter") or my("chapter"), + "block": parent.get("block") or my("block"), + "category": parent.get("category") or my("category"), + } + return self.roots_lookup[id] + + # The following code is never executed as the `icd11.json` file is + # pre-sorted and hence the parent is always present before the child. + print("Full-scan for", id, item["label"]) + return self.find_roots( + [ + icd11_object + for icd11_object in self.data + if icd11_object["ID"] == item["parentId"] + ][0] + ) + def handle(self, *args, **options): - print("Loading ICD11 data to database...") + print("Loading ICD11 data to DB Table (meta_icd11_diagnosis)...") try: - icd11_objects = fetch_data() + self.data = fetch_data() + + def roots(item): + return { + self.CLASS_KIND_DB_KEYS.get(k, k): v + for k, v in self.find_roots(item).items() + } + MetaICD11Diagnosis.objects.all().delete() MetaICD11Diagnosis.objects.bulk_create( [ @@ -30,8 +90,9 @@ def handle(self, *args, **options): is_leaf=icd11_object["isLeaf"], label=icd11_object["label"], breadth_value=icd11_object["breadthValue"], + **roots(icd11_object), ) - for icd11_object in icd11_objects + for icd11_object in self.data if icd11_object["ID"].split("/")[-1].isnumeric() ] ) diff --git a/care/facility/migrations/0371_metaicd11diagnosis_chapter_and_more.py b/care/facility/migrations/0371_metaicd11diagnosis_chapter_and_more.py new file mode 100644 index 0000000000..342c2014d3 --- /dev/null +++ b/care/facility/migrations/0371_metaicd11diagnosis_chapter_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.2 on 2023-07-12 13:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0370_merge_20230705_1500"), + ] + + operations = [ + migrations.AddField( + model_name="metaicd11diagnosis", + name="chapter", + field=models.CharField(default="", max_length=255), + preserve_default=False, + ), + migrations.AddField( + model_name="metaicd11diagnosis", + name="root_block", + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name="metaicd11diagnosis", + name="root_category", + field=models.CharField(max_length=255, null=True), + ), + ] diff --git a/care/facility/migrations/0372_assetavailabilityrecord.py b/care/facility/migrations/0372_assetavailabilityrecord.py new file mode 100644 index 0000000000..2d3dc2daf4 --- /dev/null +++ b/care/facility/migrations/0372_assetavailabilityrecord.py @@ -0,0 +1,66 @@ +# Generated by Django 4.2.2 on 2023-07-18 05:00 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0371_metaicd11diagnosis_chapter_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="AssetAvailabilityRecord", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "external_id", + models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), + ), + ( + "created_date", + models.DateTimeField(auto_now_add=True, db_index=True, null=True), + ), + ( + "modified_date", + models.DateTimeField(auto_now=True, db_index=True, null=True), + ), + ("deleted", models.BooleanField(db_index=True, default=False)), + ( + "status", + models.CharField( + choices=[ + ("Not Monitored", "Not Monitored"), + ("Operational", "Operational"), + ("Down", "Down"), + ("Under Maintenance", "Under Maintenance"), + ], + default="Not Monitored", + max_length=20, + ), + ), + ("timestamp", models.DateTimeField()), + ( + "asset", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="facility.asset" + ), + ), + ], + options={ + "ordering": ["-timestamp"], + "unique_together": {("asset", "timestamp")}, + }, + ), + ] diff --git a/care/facility/migrations/0373_remove_patientconsultation_hba1c.py b/care/facility/migrations/0373_remove_patientconsultation_hba1c.py new file mode 100644 index 0000000000..d4b200cce1 --- /dev/null +++ b/care/facility/migrations/0373_remove_patientconsultation_hba1c.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2.2 on 2023-07-20 13:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0372_assetavailabilityrecord"), + ] + + operations = [ + migrations.RemoveField( + model_name="patientconsultation", + name="HBA1C", + ), + ] diff --git a/care/facility/migrations/0374_historicalpatientregistration_abha_number_and_more.py b/care/facility/migrations/0374_historicalpatientregistration_abha_number_and_more.py new file mode 100644 index 0000000000..304bebb163 --- /dev/null +++ b/care/facility/migrations/0374_historicalpatientregistration_abha_number_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.2 on 2023-07-20 17:45 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("abdm", "0001_initial_squashed_0007_alter_abhanumber_id"), + ("facility", "0373_remove_patientconsultation_hba1c"), + ] + + replaces = [ + ("facility", "0329_auto_20221219_1936"), + ("facility", "0330_auto_20221220_2312"), + ("facility", "0366_merge_20230628_1428"), + ("facility", "0366_merge_20230628_1428"), + ("facility", "0373_merge_20230719_1143"), + ] + + operations = [ + migrations.AddField( + model_name="historicalpatientregistration", + name="abha_number", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="abdm.abhanumber", + ), + ), + migrations.AddField( + model_name="patientregistration", + name="abha_number", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="abdm.abhanumber", + ), + ), + ] diff --git a/care/facility/models/asset.py b/care/facility/models/asset.py index ac14fba3a0..fdeaff4263 100644 --- a/care/facility/models/asset.py +++ b/care/facility/models/asset.py @@ -17,6 +17,13 @@ def get_random_asset_id(): return str(uuid.uuid4()) +class AvailabilityStatus(models.TextChoices): + NOT_MONITORED = "Not Monitored" + OPERATIONAL = "Operational" + DOWN = "Down" + UNDER_MAINTENANCE = "Under Maintenance" + + class AssetLocation(BaseModel, AssetsPermissionMixin): """ This model is also used to store rooms that the assets are in, Since these rooms are mapped to @@ -105,6 +112,34 @@ def __str__(self): return self.name +class AssetAvailabilityRecord(BaseModel): + """ + Model to store the availability status of an asset at a particular timestamp. + + Fields: + - asset: ForeignKey to Asset model + - status: CharField with choices from AvailabilityStatus + - timestamp: DateTimeField to store the timestamp of the availability record + + Note: A pair of asset and timestamp together should be unique, not just the timestamp alone. + """ + + asset = models.ForeignKey(Asset, on_delete=models.PROTECT, null=False, blank=False) + status = models.CharField( + choices=AvailabilityStatus.choices, + default=AvailabilityStatus.NOT_MONITORED, + max_length=20, + ) + timestamp = models.DateTimeField(null=False, blank=False) + + class Meta: + unique_together = (("asset", "timestamp"),) + ordering = ["-timestamp"] + + def __str__(self): + return f"{self.asset.name} - {self.status} - {self.timestamp}" + + class UserDefaultAssetLocation(BaseModel): user = models.ForeignKey(User, on_delete=models.PROTECT, null=False, blank=False) location = models.ForeignKey( diff --git a/care/facility/models/file_upload.py b/care/facility/models/file_upload.py index d8873e3d28..d42958220b 100644 --- a/care/facility/models/file_upload.py +++ b/care/facility/models/file_upload.py @@ -125,3 +125,14 @@ def get_object(self, bucket=settings.FILE_UPLOAD_BUCKET, **kwargs): Key=f"{self.FileType(self.file_type).name}/{self.internal_name}", **kwargs, ) + + def file_contents(self): + s3Client = boto3.client("s3", **cs_provider.get_client_config()) + response = s3Client.get_object( + Bucket=settings.FILE_UPLOAD_BUCKET, + Key=self.FileType(self.file_type).name + "/" + self.internal_name, + ) + + content_type = response["ContentType"] + content = response["Body"].read() + return content_type, content diff --git a/care/facility/models/meta_icd11_diagnosis.py b/care/facility/models/meta_icd11_diagnosis.py index d59cc4ebfd..a3ea15f5b5 100644 --- a/care/facility/models/meta_icd11_diagnosis.py +++ b/care/facility/models/meta_icd11_diagnosis.py @@ -2,6 +2,10 @@ class MetaICD11Diagnosis(models.Model): + """ + Not for production use. For Metabase purposes only. Do not build relations to this model. + """ + id = models.CharField(max_length=255, primary_key=True) _id = models.IntegerField() average_depth = models.IntegerField() @@ -11,6 +15,9 @@ class MetaICD11Diagnosis(models.Model): is_leaf = models.BooleanField() label = models.CharField(max_length=255) breadth_value = models.DecimalField(max_digits=24, decimal_places=22) + chapter = models.CharField(max_length=255) + root_block = models.CharField(max_length=255, null=True) + root_category = models.CharField(max_length=255, null=True) class Meta: db_table = "meta_icd11_diagnosis" diff --git a/care/facility/models/patient.py b/care/facility/models/patient.py index 36fa2c9c62..4131fef636 100644 --- a/care/facility/models/patient.py +++ b/care/facility/models/patient.py @@ -6,6 +6,7 @@ from django.db.models import JSONField from simple_history.models import HistoricalRecords +from care.abdm.models import AbhaNumber from care.facility.models import ( DISEASE_CHOICES, DiseaseStatusEnum, @@ -410,6 +411,11 @@ class TestTypeEnum(enum.Enum): related_name="root_patient_assigned_to", ) + # ABDM Health ID + abha_number = models.OneToOneField( + AbhaNumber, on_delete=models.SET_NULL, null=True, blank=True + ) + history = HistoricalRecords(excluded_fields=["meta_info"]) objects = BaseManager() diff --git a/care/facility/models/patient_consultation.py b/care/facility/models/patient_consultation.py index de097a16ce..281401aa26 100644 --- a/care/facility/models/patient_consultation.py +++ b/care/facility/models/patient_consultation.py @@ -173,12 +173,6 @@ class PatientConsultation(PatientBaseModel, PatientRelatedPermissionMixin): verbose_name="Patient's Weight in KG", validators=[MinValueValidator(0)], ) - HBA1C = models.FloatField( - default=None, - null=True, - verbose_name="HBA1C parameter for reference to current blood sugar levels", - validators=[MinValueValidator(0)], - ) # ICU Information diff --git a/care/facility/models/patient_icmr.py b/care/facility/models/patient_icmr.py index 660c6cfcfd..e2f0ac9752 100644 --- a/care/facility/models/patient_icmr.py +++ b/care/facility/models/patient_icmr.py @@ -225,7 +225,7 @@ def is_symptomatic(self): def symptomatic_international_traveller( self, ): - return ( + return bool( self.patient.countries_travelled and len(self.patient.countries_travelled) != 0 and ( diff --git a/care/facility/models/prescription.py b/care/facility/models/prescription.py index 93221302ad..b6aca21eab 100644 --- a/care/facility/models/prescription.py +++ b/care/facility/models/prescription.py @@ -64,7 +64,7 @@ class MedibaseMedicine(BaseModel): atc_classification = models.TextField(blank=True, null=True) def __str__(self): - return " - ".join([self.name, self.generic, self.company]) + return " - ".join(filter(None, [self.name, self.generic, self.company])) class Prescription(BaseModel): diff --git a/care/facility/static_data/medibase.py b/care/facility/static_data/medibase.py index 946360635f..fb5718c3df 100644 --- a/care/facility/static_data/medibase.py +++ b/care/facility/static_data/medibase.py @@ -1,26 +1,34 @@ -from littletable import Table +from django.db.models import CharField, TextField, Value +from django.db.models.functions import Coalesce from care.facility.models.prescription import MedibaseMedicine -MedibaseMedicineTable = Table("MedibaseMedicine") -medibase_objects = MedibaseMedicine.objects.all() - -for obj in medibase_objects: - MedibaseMedicineTable.insert( - { - "id": obj.id, - "external_id": obj.external_id, - "name": obj.name, - "type": obj.type, - "generic": obj.generic or "", - "company": obj.company or "", - "contents": obj.contents or "", - "cims_class": obj.cims_class or "", - "atc_classification": obj.atc_classification or "", - "searchable": f"{obj.name} {obj.generic} {obj.company}", - } +def load_medibase_in_memory(): + return ( + MedibaseMedicine.objects.all() + .annotate( + generic_pretty=Coalesce("generic", Value(""), output_field=CharField()), + company_pretty=Coalesce("company", Value(""), output_field=CharField()), + contents_pretty=Coalesce("contents", Value(""), output_field=TextField()), + cims_class_pretty=Coalesce( + "cims_class", Value(""), output_field=CharField() + ), + atc_classification_pretty=Coalesce( + "atc_classification", Value(""), output_field=TextField() + ), + ) + .values_list( + "external_id", + "name", + "type", + "generic_pretty", + "company_pretty", + "contents_pretty", + "cims_class_pretty", + "atc_classification_pretty", + ) ) -MedibaseMedicineTable.create_index("id", unique=True) -MedibaseMedicineTable.create_search_index("searchable") + +MedibaseMedicineTable = load_medibase_in_memory() diff --git a/care/facility/tasks/__init__.py b/care/facility/tasks/__init__.py index 7ebf63cdaa..1a9383d32d 100644 --- a/care/facility/tasks/__init__.py +++ b/care/facility/tasks/__init__.py @@ -1,6 +1,7 @@ from celery import current_app from celery.schedules import crontab +from care.facility.tasks.asset_monitor import check_asset_status from care.facility.tasks.cleanup import delete_old_notifications from care.facility.tasks.summarisation import ( summarise_district_patient, @@ -19,12 +20,12 @@ def setup_periodic_tasks(sender, **kwargs): name="delete_old_notifications", ) sender.add_periodic_task( - crontab(hour="*/4", minute=59), + crontab(hour="*/4", minute="59"), summarise_triage.s(), name="summarise_triage", ) sender.add_periodic_task( - crontab(hour=23, minute=59), + crontab(hour="23", minute="59"), summarise_tests.s(), name="summarise_tests", ) @@ -34,12 +35,17 @@ def setup_periodic_tasks(sender, **kwargs): name="summarise_facility_capacity", ) sender.add_periodic_task( - crontab(hour="*/1", minute=59), + crontab(hour="*/1", minute="59"), summarise_patient.s(), name="summarise_patient", ) sender.add_periodic_task( - crontab(hour="*/1", minute=59), + crontab(hour="*/1", minute="59"), summarise_district_patient.s(), name="summarise_district_patient", ) + sender.add_periodic_task( + crontab(minute="*/30"), + check_asset_status.s(), + name="check_asset_status", + ) diff --git a/care/facility/tasks/asset_monitor.py b/care/facility/tasks/asset_monitor.py new file mode 100644 index 0000000000..2a30571b05 --- /dev/null +++ b/care/facility/tasks/asset_monitor.py @@ -0,0 +1,101 @@ +import logging +from datetime import datetime +from typing import Any + +from celery import shared_task +from django.utils import timezone + +from care.facility.models.asset import ( + Asset, + AssetAvailabilityRecord, + AvailabilityStatus, +) +from care.utils.assetintegration.asset_classes import AssetClasses +from care.utils.assetintegration.base import BaseAssetIntegration + +logger = logging.getLogger(__name__) + + +@shared_task +def check_asset_status(): + logger.info(f"Checking Asset Status: {timezone.now()}") + + assets = Asset.objects.all() + middleware_status_cache = {} + + for asset in assets: + # Skipping if asset class or local IP address is not present + if not asset.asset_class or not asset.meta.get("local_ip_address", None): + continue + try: + # Fetching middleware hostname + hostname = asset.meta.get( + "middleware_hostname", + asset.current_location.facility.middleware_address, + ) + result: Any = None + + # Checking if middleware status is already cached + if hostname in middleware_status_cache: + result = middleware_status_cache[hostname] + else: + try: + # Creating an instance of the asset class + asset_class: BaseAssetIntegration = AssetClasses[ + asset.asset_class + ].value( + { + **asset.meta, + "middleware_hostname": hostname, + } + ) + # Fetching the status of the device + result = asset_class.api_get(asset_class.get_url("devices/status")) + except Exception: + logger.warn(f"Middleware {hostname} is down", exc_info=True) + + # If no status is returned, setting default status as down + if not result: + result = [{"time": timezone.now().isoformat(), "status": []}] + + middleware_status_cache[hostname] = result + + # Setting new status as down by default + new_status = AvailabilityStatus.DOWN + for status_record in result: + if asset.meta.get("local_ip_address") in status_record.get( + "status", {} + ): + asset_status = status_record["status"][ + asset.meta.get("local_ip_address") + ] + else: + asset_status = "down" + + # Fetching the last record of the asset + last_record = ( + AssetAvailabilityRecord.objects.filter(asset=asset) + .order_by("-timestamp") + .first() + ) + + # Setting new status based on the status returned by the device + if asset_status == "up": + new_status = AvailabilityStatus.OPERATIONAL + elif asset_status == "maintenance": + new_status = AvailabilityStatus.UNDER_MAINTENANCE + + # Creating a new record if the status has changed + if not last_record or ( + datetime.fromisoformat(status_record.get("time")) + > last_record.timestamp + and last_record.status != new_status.value + ): + AssetAvailabilityRecord.objects.create( + asset=asset, + status=new_status.value, + timestamp=status_record.get("time", timezone.now()), + ) + + except Exception: + logger.error("Error in Asset Status Check", exc_info=True) diff --git a/care/facility/templatetags/filters.py b/care/facility/templatetags/filters.py index e3e4910300..9a8bc576fa 100644 --- a/care/facility/templatetags/filters.py +++ b/care/facility/templatetags/filters.py @@ -27,4 +27,7 @@ def field_name_to_label(value): @register.filter(expects_localtime=True) def parse_datetime(value): - return datetime.strptime(value, "%Y-%m-%dT%H:%M") + try: + return datetime.strptime(value, "%Y-%m-%dT%H:%M") + except ValueError: + return None diff --git a/care/facility/tests/test_asset_api.py b/care/facility/tests/test_asset_api.py index dbb8c27d42..36cb493e49 100644 --- a/care/facility/tests/test_asset_api.py +++ b/care/facility/tests/test_asset_api.py @@ -11,17 +11,19 @@ class AssetViewSetTestCase(TestBase, TestClassMixin, APITestCase): asset_id = None - def setUp(self): - self.factory = APIRequestFactory() - state = self.create_state() - district = self.create_district(state=state) - self.user = self.create_user(district=district, username="test user") - facility = self.create_facility(district=district, user=self.user) - self.asset1_location = AssetLocation.objects.create( + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.factory = APIRequestFactory() + state = cls.create_state() + district = cls.create_district(state=state) + cls.user = cls.create_user(district=district, username="test user") + facility = cls.create_facility(district=district, user=cls.user) + cls.asset1_location = AssetLocation.objects.create( name="asset1 location", location_type=1, facility=facility ) - self.asset = Asset.objects.create( - name="Test Asset", current_location=self.asset1_location, asset_type=50 + cls.asset = Asset.objects.create( + name="Test Asset", current_location=cls.asset1_location, asset_type=50 ) def test_list_assets(self): diff --git a/care/facility/tests/test_asset_availability_api.py b/care/facility/tests/test_asset_availability_api.py new file mode 100644 index 0000000000..65b7d36e40 --- /dev/null +++ b/care/facility/tests/test_asset_availability_api.py @@ -0,0 +1,57 @@ +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIRequestFactory, APITestCase + +from care.facility.api.viewsets.asset import AssetAvailabilityViewSet +from care.facility.models import Asset, AssetAvailabilityRecord, AssetLocation +from care.facility.models.asset import AvailabilityStatus +from care.facility.tests.mixins import TestClassMixin +from care.utils.tests.test_base import TestBase + + +class AssetAvailabilityViewSetTestCase(TestBase, TestClassMixin, APITestCase): + @classmethod + def setUp(cls): + cls.factory = APIRequestFactory() + state = cls.create_state() + district = cls.create_district(state=state) + cls.user = cls.create_user(district=district, username="test user") + facility = cls.create_facility(district=district, user=cls.user) + cls.asset_from_location = AssetLocation.objects.create( + name="asset from location", location_type=1, facility=facility + ) + cls.asset_to_location = AssetLocation.objects.create( + name="asset to location", location_type=1, facility=facility + ) + cls.asset = Asset.objects.create( + name="Test Asset", current_location=cls.asset_from_location, asset_type=50 + ) + + cls.asset_availability = AssetAvailabilityRecord.objects.create( + asset=cls.asset, + status=AvailabilityStatus.OPERATIONAL.value, + timestamp=timezone.now(), + ) + + def test_list_asset_availability(self): + response = self.new_request( + ("/api/v1/asset_availability/",), + {"get": "list"}, + AssetAvailabilityViewSet, + True, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["results"][0]["status"], AvailabilityStatus.OPERATIONAL.value + ) + + def test_retrieve_asset_availability(self): + response = self.new_request( + (f"/api/v1/asset_availability/{self.asset_availability.id}/",), + {"get": "retrieve"}, + AssetAvailabilityViewSet, + True, + {"pk": self.asset_availability.id}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["status"], AvailabilityStatus.OPERATIONAL.value) diff --git a/care/facility/tests/test_facilityuser_api.py b/care/facility/tests/test_facilityuser_api.py new file mode 100644 index 0000000000..0e9d35edec --- /dev/null +++ b/care/facility/tests/test_facilityuser_api.py @@ -0,0 +1,47 @@ +from django.test import TestCase +from rest_framework import status + +from care.facility.api.viewsets.facility_users import FacilityUserViewSet +from care.facility.models.facility import Facility +from care.facility.tests.mixins import TestClassMixin +from care.users.models import Skill + + +class FacilityUserTest(TestClassMixin, TestCase): + def setUp(self): + super().setUp() + self.creator = self.users[0] + + sample_data = { + "name": "Hospital X", + "ward": self.creator.ward, + "local_body": self.creator.local_body, + "district": self.creator.district, + "state": self.creator.state, + "facility_type": 1, + "address": "Nearby", + "pincode": 390024, + "features": [], + } + self.facility = Facility.objects.create( + external_id="550e8400-e29b-41d4-a716-446655440000", + created_by=self.creator, + **sample_data, + ) + + self.skill1 = Skill.objects.create(name="Skill 1") + self.skill2 = Skill.objects.create(name="Skill 2") + + self.users[0].skills.add(self.skill1, self.skill2) + + def test_get_queryset_with_prefetching(self): + response = self.new_request( + (f"/api/v1/facility/{self.facility.external_id}/get_users/",), + {"get": "list"}, + FacilityUserViewSet, + self.users[0], + {"facility_external_id": self.facility.external_id}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertNumQueries(2) diff --git a/care/facility/tests/test_medibase_api.py b/care/facility/tests/test_medibase_api.py index 34ce13d7d5..ec6b53afad 100644 --- a/care/facility/tests/test_medibase_api.py +++ b/care/facility/tests/test_medibase_api.py @@ -9,17 +9,17 @@ def get_url(self, query=None): def test_search_by_name_exact_word(self): response = self.client.get(self.get_url(query="dolo")) - self.assertEquals(response.status_code, status.HTTP_200_OK) - self.assertEquals(response.data[0]["name"], "DOLO") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data[0]["name"], "DOLO") def test_search_by_generic_exact_word(self): response = self.client.get(self.get_url(query="pAraCetAmoL")) - self.assertEquals(response.status_code, status.HTTP_200_OK) - self.assertEquals(response.data[0]["generic"], "paracetamol") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data[0]["generic"], "paracetamol") def test_search_by_name_and_generic_exact_word(self): response = self.client.get(self.get_url(query="panadol paracetamol")) - self.assertEquals(response.status_code, status.HTTP_200_OK) - self.assertEquals(response.data[0]["name"], "PANADOL") - self.assertEquals(response.data[0]["generic"], "paracetamol") - self.assertEquals(response.data[0]["company"], "GSK") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data[0]["name"], "PANADOL") + self.assertEqual(response.data[0]["generic"], "paracetamol") + self.assertEqual(response.data[0]["company"], "GSK") diff --git a/care/facility/tests/test_patient_api.py b/care/facility/tests/test_patient_api.py index 86d96ade62..fb5b8bdd18 100644 --- a/care/facility/tests/test_patient_api.py +++ b/care/facility/tests/test_patient_api.py @@ -13,6 +13,7 @@ class ExpectedPatientNoteKeys(Enum): FACILITY = "facility" CREATED_BY_OBJECT = "created_by_object" CREATED_DATE = "created_date" + CREATED_BY_LOCAL_USER = "created_by_local_user" class ExpectedFacilityKeys(Enum): @@ -71,7 +72,6 @@ class ExpectedCreatedByObjectKeys(Enum): LAST_NAME = "last_name" USER_TYPE = "user_type" LAST_LOGIN = "last_login" - HOME_FACILITY = "home_facility" class PatientNotesTestCase(TestBase, TestClassMixin, APITestCase): @@ -85,11 +85,23 @@ def setUp(self): # Create users and facility self.user = self.create_user(district=district, username="test user") facility = self.create_facility(district=district, user=self.user) + self.user.home_facility = facility + self.user.save() + + # Create another user from different facility + self.user2 = self.create_user(district=district, username="test user 2") + facility2 = self.create_facility(district=district, user=self.user2) + self.user2.home_facility = facility2 + self.user2.save() self.patient = self.create_patient(district=district.id) self.patient_note = self.create_patient_note( - patient=self.patient, facility=facility + patient=self.patient, facility=facility, created_by=self.user + ) + + self.patient_note2 = self.create_patient_note( + patient=self.patient, facility=facility, created_by=self.user2 ) refresh_token = RefreshToken.for_user(self.user) @@ -103,14 +115,23 @@ def test_patient_notes(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIsInstance(response.json()["results"], list) - # Ensure only necessary data is being sent and no extra data + # Test created_by_local_user field if user is not from same facility as patient + data2 = response.json()["results"][0] - data = response.json()["results"][0] + created_by_local_user_content2 = data2["created_by_local_user"] + self.assertEqual(created_by_local_user_content2, False) + + # Ensure only necessary data is being sent and no extra data + data = response.json()["results"][1] self.assertCountEqual( data.keys(), [item.value for item in ExpectedPatientNoteKeys] ) + created_by_local_user_content = data["created_by_local_user"] + + self.assertEqual(created_by_local_user_content, True) + facility_content = data["facility"] if facility_content is not None: diff --git a/care/facility/tests/test_patient_consultation_api.py b/care/facility/tests/test_patient_consultation_api.py index 82e6a914ab..a3993f6c22 100644 --- a/care/facility/tests/test_patient_consultation_api.py +++ b/care/facility/tests/test_patient_consultation_api.py @@ -1,18 +1,62 @@ import datetime +from django.test import TestCase from django.utils.timezone import make_aware from rest_framework import status from rest_framework.test import APIRequestFactory, APITestCase +from care.facility.api.viewsets.facility_users import FacilityUserViewSet from care.facility.api.viewsets.patient_consultation import PatientConsultationViewSet +from care.facility.models.facility import Facility from care.facility.models.patient_consultation import ( CATEGORY_CHOICES, PatientConsultation, ) from care.facility.tests.mixins import TestClassMixin +from care.users.models import Skill from care.utils.tests.test_base import TestBase +class FacilityUserTest(TestClassMixin, TestCase): + def setUp(self): + super().setUp() + self.creator = self.users[0] + + sample_data = { + "name": "Hospital X", + "ward": self.creator.ward, + "local_body": self.creator.local_body, + "district": self.creator.district, + "state": self.creator.state, + "facility_type": 1, + "address": "Nearby", + "pincode": 390024, + "features": [], + } + self.facility = Facility.objects.create( + external_id="550e8400-e29b-41d4-a716-446655440000", + created_by=self.creator, + **sample_data, + ) + + self.skill1 = Skill.objects.create(name="Skill 1") + self.skill2 = Skill.objects.create(name="Skill 2") + + self.users[0].skills.add(self.skill1, self.skill2) + + def test_get_queryset_with_prefetching(self): + response = self.new_request( + (f"/api/v1/facility/{self.facility.external_id}/get_users/",), + {"get": "list"}, + FacilityUserViewSet, + self.users[0], + {"facility_external_id": self.facility.external_id}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertNumQueries(2) + + class TestPatientConsultation(TestBase, TestClassMixin, APITestCase): default_data = { "symptoms": [1], @@ -31,11 +75,7 @@ def setUp(self): ) def create_admission_consultation(self, patient=None, **kwargs): - patient = ( - self.create_patient(facility_id=self.facility.id) - if not patient - else patient - ) + patient = patient or self.create_patient(facility_id=self.facility.id) data = self.default_data.copy() kwargs.update( { @@ -148,3 +188,46 @@ def test_discharge_as_expired_after_admission(self): discharge_date="2319-04-01T15:30:00Z", ) self.assertEqual(res.status_code, status.HTTP_200_OK) + + def test_referred_to_external_null(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="REF", + discharge_date="2023-07-01T12:00:00Z", + discharge_notes="Discharged with null referred_to_external", + referred_to_external=None, + ) + self.assertEqual(res.status_code, status.HTTP_200_OK) + + def test_referred_to_external_empty_string(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="REF", + discharge_date="2023-07-01T12:00:00Z", + discharge_notes="Discharged with empty referred_to_external", + referred_to_external="", + ) + self.assertEqual(res.status_code, status.HTTP_200_OK) + + def test_referred_to_external_valid_value(self): + consultation = self.create_admission_consultation( + suggestion="A", + admission_date=make_aware(datetime.datetime(2020, 4, 1, 15, 30, 00)), + ) + referred_to_external = "Test Hospital" + res = self.discharge( + consultation, + discharge_reason="REF", + discharge_date="2023-07-01T12:00:00Z", + discharge_notes="Discharged with valid referred_to_external", + referred_to_external=referred_to_external, + ) + self.assertEqual(res.status_code, status.HTTP_200_OK) diff --git a/care/facility/tests/test_patient_daily_rounds_api.py b/care/facility/tests/test_patient_daily_rounds_api.py index 4b675274c6..22733cfadb 100644 --- a/care/facility/tests/test_patient_daily_rounds_api.py +++ b/care/facility/tests/test_patient_daily_rounds_api.py @@ -10,4 +10,4 @@ def get_url(self, external_consultation_id=None): def test_external_consultation_does_not_exists_returns_404(self): sample_uuid = "e4a3d84a-d678-4992-9287-114f029046d8" response = self.client.get(self.get_url(sample_uuid)) - self.assertEquals(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/care/facility/utils/summarisation/facility_capacity.py b/care/facility/utils/summarisation/facility_capacity.py index 607e4535c6..63548b9c3c 100644 --- a/care/facility/utils/summarisation/facility_capacity.py +++ b/care/facility/utils/summarisation/facility_capacity.py @@ -17,12 +17,7 @@ def facility_capacity_summary(): - capacity_objects = FacilityCapacity.objects.all().select_related( - "facility", - "facility__state", - "facility__district", - "facility__local_body", - ) + capacity_objects = FacilityCapacity.objects.all() capacity_summary = {} current_date = localtime(now()).replace(hour=0, minute=0, second=0, microsecond=0) @@ -106,11 +101,10 @@ def facility_capacity_summary(): capacity_summary[facility_obj.id]["inventory"] = temp_inventory_summary_obj for capacity_object in capacity_objects: - facility_id = capacity_object.facility.id + facility_id = capacity_object.facility_id if facility_id not in capacity_summary: - capacity_summary[facility_id] = FacilitySerializer( - capacity_object.facility - ).data + # This facility is either deleted or not active + continue if "availability" not in capacity_summary[facility_id]: capacity_summary[facility_id]["availability"] = [] capacity_summary[facility_id]["availability"].append( diff --git a/care/templates/base.html b/care/templates/base.html index 9270456239..53b13042d1 100644 --- a/care/templates/base.html +++ b/care/templates/base.html @@ -104,7 +104,7 @@ alt="Digital Public Goods logo" /> - CoronaSafe Network is an open-source digital public good designed by + Open Healthcare Network is an open-source digital public good designed by a multi-disciplinary team of innovators and volunteers who are working on a model to support Government efforts.  (Github) diff --git a/care/templates/email/user_reset_password.html b/care/templates/email/user_reset_password.html index 7c5e29a087..8eb0100c40 100644 --- a/care/templates/email/user_reset_password.html +++ b/care/templates/email/user_reset_password.html @@ -1,5 +1,5 @@ Hi, -Greetings from Coronasafe Network, +Greetings from Open Healthcare Network, Please click the following link to reset your password for your account with username {{username}} Click Here diff --git a/care/templates/email/user_reset_password.txt b/care/templates/email/user_reset_password.txt index df328882a5..2a2e8a703f 100644 --- a/care/templates/email/user_reset_password.txt +++ b/care/templates/email/user_reset_password.txt @@ -1,5 +1,5 @@ Hi, -Greetings from Coronasafe Network, +Greetings from Open Healthcare Network, Please click the following link to reset your password {{reset_password_url}} diff --git a/care/users/api/serializers/user.py b/care/users/api/serializers/user.py index c1fb589171..b845d3bdc4 100644 --- a/care/users/api/serializers/user.py +++ b/care/users/api/serializers/user.py @@ -344,7 +344,6 @@ class Meta: "last_name", "user_type", "last_login", - "home_facility", ) @@ -353,11 +352,7 @@ class UserAssignedSerializer(serializers.ModelSerializer): home_facility_object = FacilityBareMinimumSerializer( source="home_facility", read_only=True ) - skills = serializers.SerializerMethodField() - - def get_skills(self, obj): - qs = obj.skills.filter(userskill__deleted=False) - return SkillSerializer(qs, many=True).data + skills = SkillSerializer(many=True, read_only=True) class Meta: model = User diff --git a/care/users/tests/test_facility_user_create.py b/care/users/tests/test_facility_user_create.py index 75849c3180..66ae51d4f6 100644 --- a/care/users/tests/test_facility_user_create.py +++ b/care/users/tests/test_facility_user_create.py @@ -55,7 +55,7 @@ def test_create_facility_user__should_fail__when_higher_level(self): response = self.client.post(self.get_url(), data=data, format="json") # Test Creation - self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) def test_create_facility_user__should_fail__when_different_location(self): new_district = self.clone_object(self.district) @@ -64,4 +64,4 @@ def test_create_facility_user__should_fail__when_different_location(self): response = self.client.post(self.get_url(), data=data, format="json") # Test Creation - self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/care/utils/assetintegration/base.py b/care/utils/assetintegration/base.py index f703c277ac..92d318c3a5 100644 --- a/care/utils/assetintegration/base.py +++ b/care/utils/assetintegration/base.py @@ -46,9 +46,9 @@ def api_get(self, url, data=None): headers={"Authorization": (self.auth_header_type + generate_jwt())}, ) try: - response = req.json() if req.status_code >= 400: - raise APIException(response, req.status_code) + raise APIException(req.text, req.status_code) + response = req.json() return response except json.decoder.JSONDecodeError: return {"error": "Invalid Response"} diff --git a/config/api_router.py b/config/api_router.py index 918ae39302..ff6235afb2 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -3,11 +3,14 @@ from rest_framework.routers import DefaultRouter, SimpleRouter from rest_framework_nested.routers import NestedSimpleRouter +from care.abdm.api.viewsets.abha import AbhaViewSet +from care.abdm.api.viewsets.healthid import ABDMHealthIDViewSet from care.facility.api.viewsets.ambulance import ( AmbulanceCreateViewSet, AmbulanceViewSet, ) from care.facility.api.viewsets.asset import ( + AssetAvailabilityViewSet, AssetLocationViewSet, AssetPublicViewSet, AssetTransactionViewSet, @@ -185,11 +188,13 @@ router.register("asset", AssetViewSet) router.register("asset_transaction", AssetTransactionViewSet) +router.register("asset_availability", AssetAvailabilityViewSet) patient_nested_router = NestedSimpleRouter(router, r"patient", lookup="patient") patient_nested_router.register(r"test_sample", PatientSampleViewSet) patient_nested_router.register(r"investigation", PatientInvestigationSummaryViewSet) patient_nested_router.register(r"notes", PatientNotesViewSet) +patient_nested_router.register(r"abha", AbhaViewSet) consultation_nested_router = NestedSimpleRouter( router, r"consultation", lookup="consultation" @@ -211,6 +216,10 @@ # Public endpoints router.register("public/asset", AssetPublicViewSet) +# ABDM endpoints +if settings.ENABLE_ABDM: + router.register("abdm/healthid", ABDMHealthIDViewSet, basename="abdm-healthid") + app_name = "api" urlpatterns = [ path("", include(router.urls)), diff --git a/config/authentication.py b/config/authentication.py index 0577dfa6b1..8d5a3e8281 100644 --- a/config/authentication.py +++ b/config/authentication.py @@ -2,6 +2,7 @@ import jwt import requests +from django.conf import settings from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from drf_spectacular.extensions import OpenApiAuthenticationExtension @@ -156,6 +157,57 @@ def get_user(self, validated_token, facility): return asset_user +class ABDMAuthentication(JWTAuthentication): + def open_id_authenticate(self, url, token): + public_key = requests.get(url) + jwk = public_key.json()["keys"][0] + public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk)) + return jwt.decode( + token, key=public_key, audience="account", algorithms=["RS256"] + ) + + def authenticate_header(self, request): + return "Bearer" + + def authenticate(self, request): + jwt_token = request.META.get("HTTP_AUTHORIZATION") + if jwt_token is None: + return None + jwt_token = self.get_jwt_token(jwt_token) + + abdm_cert_url = f"{settings.ABDM_URL}/gateway/v0.5/certs" + validated_token = self.get_validated_token(abdm_cert_url, jwt_token) + + return self.get_user(validated_token), validated_token + + def get_jwt_token(self, token): + return token.replace("Bearer", "").replace(" ", "") + + def get_validated_token(self, url, token): + try: + return self.open_id_authenticate(url, token) + except Exception as e: + print(e) + raise InvalidToken({"detail": f"Invalid Authorization token: {e}"}) + + def get_user(self, validated_token): + user = User.objects.filter(username=settings.ABDM_USERNAME).first() + if not user: + password = User.objects.make_random_password() + user = User( + username=settings.ABDM_USERNAME, + email="hcx@coronasafe.network", + password=f"{password}123", + gender=3, + phone_number="917777777777", + user_type=User.TYPE_VALUE_MAP["Volunteer"], + verified=True, + age=10, + ) + user.save() + return user + + class CustomJWTAuthenticationScheme(OpenApiAuthenticationExtension): target_class = "config.authentication.CustomJWTAuthentication" name = "jwtAuth" diff --git a/config/ratelimit.py b/config/ratelimit.py index e1c7172795..9ba26a2704 100644 --- a/config/ratelimit.py +++ b/config/ratelimit.py @@ -25,6 +25,7 @@ def validatecaptcha(request): return False +# refer https://django-ratelimit.readthedocs.io/en/stable/rates.html for rate def ratelimit( request, group="", keys=[None], rate=settings.DJANGO_RATE_LIMIT, increment=True ): diff --git a/config/settings/base.py b/config/settings/base.py index 102e9a0af3..6e1fb60fae 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -112,6 +112,7 @@ ] LOCAL_APPS = [ "care.facility", + "care.abdm", "care.users", "care.audit_log", "care.hcx", @@ -539,11 +540,22 @@ json.loads(base64.b64decode(env("JWKS_BASE64", default=generate_encoded_jwks()))) ) +# ABDM +ENABLE_ABDM = env.bool("ENABLE_ABDM", default=False) +ABDM_CLIENT_ID = env("ABDM_CLIENT_ID", default="") +ABDM_CLIENT_SECRET = env("ABDM_CLIENT_SECRET", default="") +ABDM_URL = env("ABDM_URL", default="https://dev.abdm.gov.in") +HEALTH_SERVICE_API_URL = env( + "HEALTH_SERVICE_API_URL", default="https://healthidsbx.abdm.gov.in/api" +) +ABDM_USERNAME = env("ABDM_USERNAME", default="abdm_user_internal") +X_CM_ID = env("X_CM_ID", default="sbx") +FIDELIUS_URL = env("FIDELIUS_URL", default="http://fidelius:8090") + IS_PRODUCTION = False # HCX - HCX_PROTOCOL_BASE_PATH = env( "HCX_PROTOCOL_BASE_PATH", default="http://staging-hcx.swasth.app/api/v0.7" ) diff --git a/config/urls.py b/config/urls.py index 9662e6383f..d2c22c5ce1 100644 --- a/config/urls.py +++ b/config/urls.py @@ -9,6 +9,7 @@ SpectacularSwaggerView, ) +from care.abdm.urls import abdm_urlpatterns from care.facility.api.viewsets.open_id import OpenIdConfigView from care.hcx.api.viewsets.listener import ( ClaimOnSubmitView, @@ -94,6 +95,9 @@ path("health/", include("healthy_django.urls", namespace="healthy_django")), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) +if settings.ENABLE_ABDM: + urlpatterns += abdm_urlpatterns + if settings.DEBUG: # This allows the error pages to be debugged during development, just visit # these url in browser to see how these error pages look like. diff --git a/config/wsgi.py b/config/wsgi.py index 835d5c2ece..9827d177fd 100644 --- a/config/wsgi.py +++ b/config/wsgi.py @@ -36,3 +36,5 @@ # Apply WSGI middleware here. # from helloworld.wsgi import HelloWorldApplication # application = HelloWorldApplication(application) + +from care.facility.static_data.medibase import MedibaseMedicineTable # noqa diff --git a/data/dummy/facility.json b/data/dummy/facility.json index e6b997828f..1564622b72 100644 --- a/data/dummy/facility.json +++ b/data/dummy/facility.json @@ -206,7 +206,6 @@ "current_bed": null, "height": 0.0, "weight": 0.0, - "HBA1C": null, "operation": null, "special_instruction": "", "intubation_history": [] diff --git a/docker-compose.yaml b/docker-compose.yaml index 61b59e95cf..495805aefb 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -38,5 +38,10 @@ services: - "${TEMPDIR:-/tmp/localstack}:/tmp/localstack" - "./docker/awslocal:/docker-entrypoint-initaws.d" + fidelius: + container_name: care_fidelius + image: khavinshankar/fidelius:v1.0 + restart: always + volumes: postgres-data: diff --git a/docker/.local.env b/docker/.local.env index 0de74cbf10..e755fb55b5 100644 --- a/docker/.local.env +++ b/docker/.local.env @@ -4,6 +4,7 @@ POSTGRES_HOST=db POSTGRES_DB=care POSTGRES_PORT=5432 DATABASE_URL=postgres://postgres:postgres@db:5432/care +REDIS_URL=redis://redis:6379 CELERY_BROKER_URL=redis://redis:6379/0 DJANGO_DEBUG=False