diff --git a/.github/workflows/deployment-branch.yaml b/.github/workflows/deployment-branch.yaml index 3d514b128f..8881bd107a 100644 --- a/.github/workflows/deployment-branch.yaml +++ b/.github/workflows/deployment-branch.yaml @@ -10,7 +10,6 @@ on: - "docs/**" jobs: - build-image: name: Build & Push Staging to container registries runs-on: ubuntu-latest @@ -29,7 +28,7 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} flavor: | - latest=true + latest=false - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 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..25b157208e --- /dev/null +++ b/care/abdm/api/serializers/healthid.py @@ -0,0 +1,198 @@ +from rest_framework.serializers import CharField, Serializer + + +class AadharOtpGenerateRequestPayloadSerializer(Serializer): + aadhaar = CharField( + max_length=16, + min_length=12, + required=True, + help_text="Aadhaar Number", + validators=[], + ) + + +class AadharOtpResendRequestPayloadSerializer(Serializer): + txnId = CharField( + max_length=64, + min_length=1, + required=True, + help_text="Transaction ID", + validators=[], + ) + + +class HealthIdSerializer(Serializer): + healthId = CharField( + max_length=64, + min_length=1, + required=True, + help_text="Health ID", + ) + + +class QRContentSerializer(Serializer): + hidn = CharField( + max_length=17, + min_length=17, + required=True, + help_text="Health ID Number", + ) + phr = CharField( + max_length=64, + min_length=1, + required=True, + help_text="Health ID", + ) + name = CharField( + max_length=64, + min_length=1, + required=True, + help_text="Name", + ) + gender = CharField( + max_length=1, + min_length=1, + required=True, + help_text="Name", + ) + dob = CharField( + max_length=10, + min_length=8, + required=True, + help_text="Name", + ) + # {"statelgd":"33","distlgd":"573","address":"C/O Gopalsamy NO 33 A WESTSTREET ODANILAI KASTHURIBAI GRAMAM ARACHALUR Erode","state name":"TAMIL NADU","dist name":"Erode","mobile":"7639899448"} + + +# { +# "authMethod": "AADHAAR_OTP", +# "healthid": "43-4221-5105-6749" +# } +class HealthIdAuthSerializer(Serializer): + authMethod = CharField( + max_length=64, + min_length=1, + required=True, + help_text="Auth Method", + ) + healthid = CharField( + max_length=64, + min_length=1, + required=True, + help_text="Health ID", + ) + + +# "gender": "M", +# "mobile": "9545812125", +# "name": "suraj singh karki", +# "yearOfBirth": "1994" + + +class ABHASearchRequestSerializer: + name = CharField(max_length=64, min_length=1, required=False, help_text="Name") + mobile = CharField( + max_length=10, min_length=10, required=False, help_text="Mobile Number" + ) + gender = CharField(max_length=1, min_length=1, required=False, help_text="Gender") + yearOfBirth = CharField( + max_length=4, min_length=4, required=False, help_text="Year of Birth" + ) + + +class GenerateMobileOtpRequestPayloadSerializer(Serializer): + mobile = CharField( + max_length=10, + min_length=10, + required=True, + help_text="Mobile Number", + validators=[], + ) + txnId = CharField( + max_length=64, + min_length=1, + required=True, + help_text="Transaction ID", + validators=[], + ) + + +class VerifyOtpRequestPayloadSerializer(Serializer): + otp = CharField( + max_length=6, + min_length=6, + required=True, + help_text="OTP", + validators=[], + ) + txnId = CharField( + max_length=64, + min_length=1, + required=True, + help_text="Transaction ID", + validators=[], + ) + patientId = CharField( + required=False, help_text="Patient ID to be linked", validators=[] + ) # TODO: Add UUID Validation + + +class VerifyDemographicsRequestPayloadSerializer(Serializer): + gender = CharField( + max_length=10, + min_length=1, + required=True, + help_text="Gender", + validators=[], + ) + name = CharField( + max_length=64, + min_length=1, + required=True, + help_text="Name", + validators=[], + ) + yearOfBirth = CharField( + max_length=4, + min_length=4, + required=True, + help_text="Year Of Birth", + validators=[], + ) + txnId = CharField( + max_length=64, + min_length=1, + required=True, + help_text="Transaction ID", + validators=[], + ) + + +# { +# "email": "Example@Demo.com", +# "firstName": "manoj", +# "healthId": "deepak.pant", +# "lastName": "singh", +# "middleName": "kishan", +# "password": "India@143", +# "profilePhoto": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAkJCQkJCQoLCwoODw0PDhQSERESFB4WFxYXFh4uHSEdHSEdLikxKCUoMSlJOTMzOUlUR0NHVGZbW2aBeoGoqOIBCQkJCQkJCgsLCg4PDQ8OFBIRERIUHhYXFhcWHi4dIR0dIR0uKTEoJSgxKUk5MzM5SVRHQ0dUZltbZoF6gaio4v/CABEIBLAHgAMBIgACEQEDEQH/xAAbAAACAwEBAQAAAAAAAAAAAAACAwABBAUGB//aAAgBAQAAAADwawLpMspcK7qrlE5F0Vtul2bVywMUNeBHUkW/bmxvYELGuNjh2VDvixxo5ViljKjDRMoahCULjs2JCShjhjh2OGxo0Y2MoXHOLszsKLhw7tD99mpZQxj8xceofmLEKFwXLTIyHwY1Ls+iEotjHY0M0pjRYxtGj4VFKLPohQlFQyy4Qipc0XG9pS+CP/2Q==", +# "txnId": "a825f76b-0696-40f3-864c-5a3a5b389a83" +# } +class CreateHealthIdSerializer(Serializer): + healthId = CharField( + max_length=64, + min_length=1, + required=False, + help_text="Health ID", + validators=[], + ) + txnId = CharField( + max_length=64, + min_length=1, + required=True, + help_text="PreVerified Transaction ID", + validators=[], + ) + patientId = CharField( + required=False, help_text="Patient ID to be linked", validators=[] + ) # TODO: Add UUID Validation 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..ac1957a82d --- /dev/null +++ b/care/abdm/api/viewsets/abha.py @@ -0,0 +1,43 @@ +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) + print( + "Finding patient with external_id: ", self.kwargs.get("patient_external_id") + ) + 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() + # Empty Dict as data, obj.access_token as auth + 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() + # Empty Dict as data, obj.access_token as auth + 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..7b94a9b9b0 --- /dev/null +++ b/care/abdm/api/viewsets/auth.py @@ -0,0 +1,358 @@ +import json +from datetime import datetime, timedelta + +from django.core.cache import cache +from django.db.models import Q +from rest_framework import status +from rest_framework.generics import GenericAPIView, get_object_or_404 +from rest_framework.permissions import AllowAny +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 = (AllowAny,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + print("on-fetch-modes", data) + AbdmGateway().init(data["resp"]["requestId"]) + return Response({}, status=status.HTTP_202_ACCEPTED) + + +class OnInitView(GenericAPIView): + permission_classes = (AllowAny,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + print("on-init", data) + + AbdmGateway().confirm(data["auth"]["transactionId"], data["resp"]["requestId"]) + + return Response({}, status=status.HTTP_202_ACCEPTED) + + +class OnConfirmView(GenericAPIView): + permission_classes = (AllowAny,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + print(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 = (AllowAny,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + print("auth-notify", 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 = (AllowAny,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + print(data) + return Response({}, status=status.HTTP_202_ACCEPTED) + + +class DiscoverView(GenericAPIView): + permission_classes = (AllowAny,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + print(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 = (AllowAny,) + 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 = (AllowAny,) + 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 = (AllowAny,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + print(data) + + # TODO: create a seperate cache and also add a expiration time + 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 = (AllowAny,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + print(data) + + # TODO: uncomment later + 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"], + ) + + print(consent["notification"]["consentDetail"]["careContexts"][:1:-1]) + + 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, + }, + } + ) + + print("______________________________________________") + print(consent["notification"]["consentDetail"]["careContexts"][:-2:-1]) + print("______________________________________________") + + 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..4278289f16 --- /dev/null +++ b/care/abdm/api/viewsets/healthid.py @@ -0,0 +1,656 @@ +# ABDM HealthID APIs + +from datetime import datetime + +from drf_yasg.utils import swagger_auto_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,) + + # TODO: Ratelimiting for all endpoints that generate OTP's / Critical API's + @swagger_auto_schema( + operation_id="generate_aadhaar_otp", + request_body=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) + + @swagger_auto_schema( + # /v1/registration/aadhaar/resendAadhaarOtp + operation_id="resend_aadhaar_otp", + request_body=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) + + @swagger_auto_schema( + # /v1/registration/aadhaar/verifyAadhaarOtp + operation_id="verify_aadhaar_otp", + request_body=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) + + @swagger_auto_schema( + # /v1/registration/aadhaar/generateMobileOTP + operation_id="generate_mobile_otp", + request_body=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) + + @swagger_auto_schema( + # /v1/registration/aadhaar/verifyMobileOTP + operation_id="verify_mobile_otp", + request_body=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 = PatientRegistration.objects.filter( + # abha_number__abha_number=abha_object.abha_number + # ).first() + + # if patient or patient_object.abha_number: + # return False + + patient_object.abha_number = abha_object + patient_object.save() + return True + + @swagger_auto_schema( + # /v1/registration/aadhaar/createHealthId + operation_id="create_health_id", + request_body=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 + @swagger_auto_schema( + # /v1/registration/aadhaar/searchByHealthId + operation_id="search_by_health_id", + request_body=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) + + @swagger_auto_schema( + # /v1/registration/aadhaar/searchByHealthId + operation_id="link_via_qr", + request_body=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, + ) + + @swagger_auto_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 + @swagger_auto_schema( + # /v1/auth/init + operation_id="auth_init", + request_body=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 + @swagger_auto_schema( + operation_id="confirm_with_aadhaar_otp", + request_body=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 + @swagger_auto_schema( + operation_id="confirm_with_mobile_otp", + request_body=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, + ) + + @swagger_auto_schema( + operation_id="confirm_with_demographics", + request_body=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 + @swagger_auto_schema( + # /v2/registration/aadhaar/checkAndGenerateMobileOTP + operation_id="check_and_generate_mobile_otp", + request_body=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..4958e23d94 --- /dev/null +++ b/care/abdm/api/viewsets/hip.py @@ -0,0 +1,157 @@ +import uuid +from datetime import datetime, timezone + +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.permissions import AllowAny +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 = (AllowAny,) + 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 + print(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=1 + if patient_data["gender"] == "M" + else 2 + if patient_data["gender"] == "F" + else 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"], + created_by=None, + state=None, + district=None, + local_body=None, + ward=None, + ) + + 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, + ) + + return Response( + { + "status": "FAILURE", + "healthId": patient_data["healthId"] or patient_data["healthIdNumber"], + }, + status=status.HTTP_401_UNAUTHORIZED, + ) 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..3da724c722 --- /dev/null +++ b/care/abdm/api/viewsets/status.py @@ -0,0 +1,40 @@ +from rest_framework import status +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import AllowAny +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 = (AllowAny,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + print("patient_status_notify", 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 = (AllowAny,) + authentication_classes = [ABDMAuthentication] + + def post(self, request, *args, **kwargs): + data = request.data + print("patient_sms_on_notify", data) + + 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..f00da32cb9 --- /dev/null +++ b/care/abdm/apps.py @@ -0,0 +1,13 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AbdmConfig(AppConfig): + name = "abdm" + verbose_name = _("ABDM Integration") + + def ready(self): + try: + import care.abdm.signals # noqa F401 + except ImportError: + pass diff --git a/care/abdm/migrations/0001_initial.py b/care/abdm/migrations/0001_initial.py new file mode 100644 index 0000000000..bcd8410442 --- /dev/null +++ b/care/abdm/migrations/0001_initial.py @@ -0,0 +1,55 @@ +# Generated by Django 2.2.11 on 2022-12-19 14:06 + +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="AbhaNumber", + fields=[ + ( + "id", + models.AutoField( + 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.CharField(max_length=50)), + ("email", models.CharField(max_length=50)), + ("first_name", models.CharField(max_length=50)), + ("health_id", models.CharField(max_length=50)), + ("last_name", models.CharField(max_length=50)), + ("middle_name", models.CharField(max_length=50)), + ("password", models.CharField(max_length=50)), + ("profile_photo", models.CharField(max_length=50)), + ("txn_id", models.CharField(max_length=50)), + ("access_token", models.CharField(max_length=50)), + ("refresh_token", models.CharField(max_length=50)), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/care/abdm/migrations/0002_auto_20221220_2312.py b/care/abdm/migrations/0002_auto_20221220_2312.py new file mode 100644 index 0000000000..73253c5c75 --- /dev/null +++ b/care/abdm/migrations/0002_auto_20221220_2312.py @@ -0,0 +1,32 @@ +# Generated by Django 2.2.11 on 2022-12-20 17:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("abdm", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="abhanumber", + name="email", + field=models.EmailField(max_length=254), + ), + migrations.AlterField( + model_name="abhanumber", + name="first_name", + field=models.CharField(max_length=512), + ), + migrations.AlterField( + model_name="abhanumber", + name="last_name", + field=models.CharField(max_length=512), + ), + migrations.AlterField( + model_name="abhanumber", + name="middle_name", + field=models.CharField(max_length=512), + ), + ] diff --git a/care/abdm/migrations/0003_auto_20221220_2321.py b/care/abdm/migrations/0003_auto_20221220_2321.py new file mode 100644 index 0000000000..aaa8357d2c --- /dev/null +++ b/care/abdm/migrations/0003_auto_20221220_2321.py @@ -0,0 +1,21 @@ +# Generated by Django 2.2.11 on 2022-12-20 17:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("abdm", "0002_auto_20221220_2312"), + ] + + operations = [ + migrations.RemoveField( + model_name="abhanumber", + name="password", + ), + migrations.AlterField( + model_name="abhanumber", + name="profile_photo", + field=models.TextField(), + ), + ] diff --git a/care/abdm/migrations/0004_auto_20221220_2325.py b/care/abdm/migrations/0004_auto_20221220_2325.py new file mode 100644 index 0000000000..613d539e15 --- /dev/null +++ b/care/abdm/migrations/0004_auto_20221220_2325.py @@ -0,0 +1,52 @@ +# Generated by Django 2.2.11 on 2022-12-20 17:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("abdm", "0003_auto_20221220_2321"), + ] + + operations = [ + migrations.AlterField( + model_name="abhanumber", + name="abha_number", + field=models.TextField(), + ), + migrations.AlterField( + model_name="abhanumber", + name="access_token", + field=models.TextField(), + ), + migrations.AlterField( + model_name="abhanumber", + name="first_name", + field=models.TextField(), + ), + migrations.AlterField( + model_name="abhanumber", + name="health_id", + field=models.TextField(), + ), + migrations.AlterField( + model_name="abhanumber", + name="last_name", + field=models.TextField(), + ), + migrations.AlterField( + model_name="abhanumber", + name="middle_name", + field=models.TextField(), + ), + migrations.AlterField( + model_name="abhanumber", + name="refresh_token", + field=models.TextField(), + ), + migrations.AlterField( + model_name="abhanumber", + name="txn_id", + field=models.TextField(), + ), + ] diff --git a/care/abdm/migrations/0005_auto_20221220_2327.py b/care/abdm/migrations/0005_auto_20221220_2327.py new file mode 100644 index 0000000000..1c0b9bc736 --- /dev/null +++ b/care/abdm/migrations/0005_auto_20221220_2327.py @@ -0,0 +1,62 @@ +# Generated by Django 2.2.11 on 2022-12-20 17:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("abdm", "0004_auto_20221220_2325"), + ] + + operations = [ + migrations.AlterField( + model_name="abhanumber", + name="abha_number", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="abhanumber", + name="access_token", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="abhanumber", + name="email", + field=models.EmailField(blank=True, max_length=254, null=True), + ), + migrations.AlterField( + model_name="abhanumber", + name="first_name", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="abhanumber", + name="health_id", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="abhanumber", + name="last_name", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="abhanumber", + name="middle_name", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="abhanumber", + name="profile_photo", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="abhanumber", + name="refresh_token", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="abhanumber", + name="txn_id", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/care/abdm/migrations/0006_auto_20230208_0915.py b/care/abdm/migrations/0006_auto_20230208_0915.py new file mode 100644 index 0000000000..940ed863c8 --- /dev/null +++ b/care/abdm/migrations/0006_auto_20230208_0915.py @@ -0,0 +1,47 @@ +# Generated by Django 2.2.11 on 2023-02-08 03:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("abdm", "0005_auto_20221220_2327"), + ] + + operations = [ + migrations.AddField( + model_name="abhanumber", + name="address", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="abhanumber", + name="date_of_birth", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="abhanumber", + name="district", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="abhanumber", + name="gender", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="abhanumber", + name="name", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="abhanumber", + name="pincode", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="abhanumber", + name="state", + field=models.TextField(blank=True, null=True), + ), + ] 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..18e4c0add7 --- /dev/null +++ b/care/abdm/models.py @@ -0,0 +1,37 @@ +# 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 + ) # What is profile photo? how is it stored as? + + 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/utils/api_call.py b/care/abdm/utils/api_call.py new file mode 100644 index 0000000000..6fcc8c244a --- /dev/null +++ b/care/abdm/utils/api_call.py @@ -0,0 +1,775 @@ +import json +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 = "https://dev.abdm.gov.in/" +HEALTH_SERVICE_API_URL = "https://healthidsbx.abdm.gov.in/api" +ABDM_TOKEN_URL = GATEWAY_API_URL + "gateway/v0.5/sessions" +ABDM_GATEWAY_URL = GATEWAY_API_URL + "gateway" +ABDM_TOKEN_CACHE_KEY = "abdm_token" + +# TODO: Exception handling for all api calls, need to gracefully handle known exceptions + + +def encrypt_with_public_key(a_message): + rsa_public_key = RSA.importKey( + requests.get( + HEALTH_SERVICE_API_URL + "/v2/auth/cert", verify=False + ).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) + print("Using Cached Token") + if not token: + print("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, + verify=False, + ) + print("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": + print( + "Unsupported Content-Type: {}".format( + resp.headers["Content-Type"] + ) + ) + print("Response: {}".format(resp.text)) + return None + else: + data = resp.json() + token = data["accessToken"] + expires_in = data["expiresIn"] + print("New Token: {}".format(token)) + print("Expires in: {}".format(expires_in)) + cache.set(ABDM_TOKEN_CACHE_KEY, token, expires_in) + else: + print("Bad Response: {}".format(resp.text)) + return None + # print("Returning Authorization Header: Bearer {}".format(token)) + print("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) + print("Making GET Request to: {}".format(url)) + response = requests.get(url, headers=headers, params=params, verify=False) + print("{} 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) + # print("curl -X POST {} {} -d {}".format(url, headers_string, data_json)) + print("Posting Request to: {}".format(url)) + response = requests.post(url, headers=headers, data=data_json, verify=False) + print("{} 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) + print("{} 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" + print("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) + print("Getting QR Code for: {}".format(data)) + response = self.api.get(path, {}, access_token) + print("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": str( + 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 + + print("auth-init", data) + + payload = { + "requestId": request_id, + "timestamp": str( + 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": str( + 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": "", + }, + } + + print(payload) + + 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": str( + 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": str( + 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": str( + 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": str( + 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": str( + 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": str( + 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": str( + 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": str( + 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": str( + 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": str( + 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/patient.py b/care/facility/api/serializers/patient.py index 9326fc03a3..ec6a09394f 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, @@ -216,6 +218,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 = ( diff --git a/care/facility/api/serializers/patient_consultation.py b/care/facility/api/serializers/patient_consultation.py index 49872139d1..ed255dfc0a 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 ConsultationBedSerializer from care.facility.api.serializers.daily_round import DailyRoundSerializer @@ -452,6 +453,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/migrations/0329_auto_20221219_1936.py b/care/facility/migrations/0329_auto_20221219_1936.py new file mode 100644 index 0000000000..589981551d --- /dev/null +++ b/care/facility/migrations/0329_auto_20221219_1936.py @@ -0,0 +1,36 @@ +# Generated by Django 2.2.11 on 2022-12-19 14:06 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0328_merge_20221208_1110"), + ] + + operations = [ + migrations.AlterField( + model_name="fileupload", + name="archived_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="archived_by", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="fileupload", + name="uploaded_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="uploaded_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/care/facility/migrations/0330_auto_20221220_2312.py b/care/facility/migrations/0330_auto_20221220_2312.py new file mode 100644 index 0000000000..b0808c90ea --- /dev/null +++ b/care/facility/migrations/0330_auto_20221220_2312.py @@ -0,0 +1,36 @@ +# Generated by Django 2.2.11 on 2022-12-20 17:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("abdm", "0002_auto_20221220_2312"), + ("facility", "0329_auto_20221219_1936"), + ] + + 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/migrations/0331_auto_20230130_1652.py b/care/facility/migrations/0331_auto_20230130_1652.py new file mode 100644 index 0000000000..334743b8c0 --- /dev/null +++ b/care/facility/migrations/0331_auto_20230130_1652.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.11 on 2023-01-30 11:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0330_auto_20221220_2312"), + ] + + operations = [ + migrations.AlterField( + model_name="patientsearch", + name="state_id", + field=models.IntegerField(null=True), + ), + ] diff --git a/care/facility/migrations/0344_merge_20230413_1120.py b/care/facility/migrations/0344_merge_20230413_1120.py new file mode 100644 index 0000000000..79bc41b1d5 --- /dev/null +++ b/care/facility/migrations/0344_merge_20230413_1120.py @@ -0,0 +1,12 @@ +# Generated by Django 2.2.11 on 2023-04-13 05:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0343_auto_20230407_1850"), + ("facility", "0331_auto_20230130_1652"), + ] + + operations = [] diff --git a/care/facility/migrations/0361_merge_20230609_2014.py b/care/facility/migrations/0361_merge_20230609_2014.py new file mode 100644 index 0000000000..66a67446c3 --- /dev/null +++ b/care/facility/migrations/0361_merge_20230609_2014.py @@ -0,0 +1,12 @@ +# Generated by Django 2.2.11 on 2023-06-09 14:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0360_auto_20230608_1750"), + ("facility", "0344_merge_20230413_1120"), + ] + + operations = [] diff --git a/care/facility/models/file_upload.py b/care/facility/models/file_upload.py index 4255f6b409..0356fb4a37 100644 --- a/care/facility/models/file_upload.py +++ b/care/facility/models/file_upload.py @@ -124,3 +124,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/patient.py b/care/facility/models/patient.py index 729bd4f8f5..24c056740f 100644 --- a/care/facility/models/patient.py +++ b/care/facility/models/patient.py @@ -7,6 +7,7 @@ from partial_index import PQ, PartialIndex from simple_history.models import HistoricalRecords +from care.abdm.models import AbhaNumber from care.facility.models import ( DISEASE_CHOICES, DiseaseStatusEnum, @@ -415,6 +416,12 @@ 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=["patient_search_id", "meta_info"]) objects = BaseManager() @@ -575,7 +582,7 @@ class PatientSearch(PatientBaseModel): phone_number = models.CharField(max_length=14) date_of_birth = models.DateField(null=True) year_of_birth = models.IntegerField() - state_id = models.IntegerField() + state_id = models.IntegerField(null=True) facility = models.ForeignKey("Facility", on_delete=models.SET_NULL, null=True) patient_external_id = EncryptedCharField(max_length=100, default="") diff --git a/care/utils/assetintegration/onvif.py b/care/utils/assetintegration/onvif.py index 8e21c77d43..ecdae6d179 100644 --- a/care/utils/assetintegration/onvif.py +++ b/care/utils/assetintegration/onvif.py @@ -14,6 +14,8 @@ class OnvifActions(enum.Enum): GOTO_PRESET = "goto_preset" ABSOLUTE_MOVE = "absolute_move" RELATIVE_MOVE = "relative_move" + # STEP 1 | Part 1 + # GET_STREAMING_TOKEN = "getStreamingToken" def __init__(self, meta): try: @@ -54,4 +56,8 @@ def handle_action(self, action): if action_type == self.OnvifActions.RELATIVE_MOVE.value: return self.api_post(self.get_url("relativeMove"), request_body) + # STEP 1 | Part 3 + # if action_type == self.OnvifActions.GET_STREAMING_TOKEN.value: + # return self.api_post(self.get_url("getStreamingToken"), request_body) + raise ValidationError({"action": "invalid action type"}) diff --git a/config/api_router.py b/config/api_router.py index 4882b4d329..15cdb87fee 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -3,6 +3,9 @@ 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.abdm.api.viewsets.hip import HipViewSet from care.facility.api.viewsets.ambulance import ( AmbulanceCreateViewSet, AmbulanceViewSet, @@ -15,9 +18,9 @@ ) from care.facility.api.viewsets.bed import ( AssetBedViewSet, - PatientAssetBedViewSet, BedViewSet, ConsultationBedViewSet, + PatientAssetBedViewSet, ) from care.facility.api.viewsets.daily_round import DailyRoundsViewSet from care.facility.api.viewsets.facility import AllFacilityViewSet, FacilityViewSet @@ -87,10 +90,19 @@ from care.users.api.viewsets.users import UserViewSet from care.users.api.viewsets.userskill import UserSkillViewSet + +class OptionalSlashRouter(SimpleRouter): + def __init__(self): + super().__init__() + self.trailing_slash = "/?" + + if settings.DEBUG: router = DefaultRouter() + # abdm_router = DefaultRouter() else: router = SimpleRouter() +abdm_router = OptionalSlashRouter() router.register("users", UserViewSet) user_nested_rotuer = NestedSimpleRouter(router, r"users", lookup="users") @@ -192,6 +204,7 @@ 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 +224,10 @@ # Public endpoints router.register("public/asset", AssetPublicViewSet) +# ABDM endpoints +router.register("abdm/healthid", ABDMHealthIDViewSet, basename="abdm-healthid") +abdm_router.register("profile", HipViewSet, basename="hip") + app_name = "api" urlpatterns = [ url(r"^", include(router.urls)), @@ -221,3 +238,7 @@ url(r"^", include(resource_nested_router.urls)), url(r"^", include(shifting_nested_router.urls)), ] + +abdm_urlpatterns = [ + url(r"^", include(abdm_router.urls)), +] diff --git a/config/authentication.py b/config/authentication.py index 86edff3cc1..a03e2daa2b 100644 --- a/config/authentication.py +++ b/config/authentication.py @@ -2,9 +2,11 @@ import jwt import requests +from django.conf import settings +from django.contrib.auth import authenticate from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ -from rest_framework import HTTP_HEADER_ENCODING +from rest_framework import HTTP_HEADER_ENCODING, exceptions, status from rest_framework.authentication import BasicAuthentication from rest_framework_simplejwt.authentication import JWTAuthentication from rest_framework_simplejwt.exceptions import AuthenticationFailed, InvalidToken @@ -12,6 +14,7 @@ from care.facility.models import Facility from care.facility.models.asset import Asset from care.users.models import User +from config.ratelimit import ratelimit class CustomJWTAuthentication(JWTAuthentication): @@ -31,6 +34,30 @@ def get_validated_token(self, raw_token): class CustomBasicAuthentication(BasicAuthentication): + def authenticate_credentials(self, userid, password, request=None): + """ + Authenticate the userid and password against username and password + with optional request for context. + """ + from config.auth_views import CaptchaRequiredException + + credentials = {User.USERNAME_FIELD: userid, "password": password} + if ratelimit(request, "login", [userid], increment=False): + raise CaptchaRequiredException( + detail={"status": 429, "detail": "Too Many Requests Provide Captcha"}, + code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + user = authenticate(request=request, **credentials) + + if user is None: + ratelimit(request, "login", [userid]) + raise exceptions.AuthenticationFailed(_("Invalid username/password.")) + + if not user.is_active: + raise exceptions.AuthenticationFailed(_("User inactive or deleted.")) + + return (user, None) + def authenticate_header(self, request): return "" @@ -152,3 +179,54 @@ def get_user(self, validated_token, facility): ) asset_user.save() 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 diff --git a/config/ratelimit.py b/config/ratelimit.py index 4d59c8dcf3..435076c726 100644 --- a/config/ratelimit.py +++ b/config/ratelimit.py @@ -21,6 +21,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 984f66aff6..e8f38e7732 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -29,7 +29,7 @@ # GENERAL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#debug -DEBUG = env.bool("DJANGO_DEBUG", False) +DEBUG = env.bool("DJANGO_DEBUG", True) # Local time zone. Choices are # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name # though not all of them may be available with every OS. @@ -103,6 +103,7 @@ "care.users.apps.UsersConfig", "care.facility", "care.audit_log.apps.AuditLogConfig", + "care.abdm", "care.hcx", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps @@ -325,6 +326,7 @@ def GETKEY(group, request): return "ratelimit" +# https://django-ratelimit.readthedocs.io/en/stable/rates.html DJANGO_RATE_LIMIT = env("RATE_LIMIT", default="5/10m") GOOGLE_RECAPTCHA_SECRET_KEY = env("GOOGLE_RECAPTCHA_SECRET_KEY", default="") @@ -410,9 +412,8 @@ def GETKEY(group, request): ####################### # File Upload Parameters - FILE_UPLOAD_BUCKET = env("FILE_UPLOAD_BUCKET", default="") -# FILE_UPLOAD_REGION = env("FILE_UPLOAD_REGION", default="care-patient-staging") +FILE_UPLOAD_REGION = env("FILE_UPLOAD_REGION", default="care-patient-staging") FILE_UPLOAD_KEY = env("FILE_UPLOAD_KEY", default="") FILE_UPLOAD_SECRET = env("FILE_UPLOAD_SECRET", default="") FILE_UPLOAD_BUCKET_ENDPOINT = env( @@ -512,3 +513,10 @@ def GETKEY(group, request): ) PEACETIME_MODE = env.bool("PEACETIME_MODE", default=True) + +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") +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") diff --git a/config/urls.py b/config/urls.py index c9c1f26e5c..0d891e1a37 100644 --- a/config/urls.py +++ b/config/urls.py @@ -9,6 +9,21 @@ from rest_framework import permissions from rest_framework_simplejwt.views import TokenVerifyView +from care.abdm.api.viewsets.auth import ( + AuthNotifyView, + DiscoverView, + LinkConfirmView, + LinkInitView, + NotifyView, + OnAddContextsView, + OnConfirmView, + OnFetchView, + OnInitView, + RequestDataView, +) +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 from care.facility.api.viewsets.open_id import OpenIdConfigView from care.hcx.api.viewsets.listener import ( ClaimOnSubmitView, @@ -72,6 +87,72 @@ name="change_password_view", ), path("api/v1/", include(api_router.urlpatterns)), + path("v1.0/patients/", include(api_router.abdm_urlpatterns)), + 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", + ), # Hcx Listeners path( "coverageeligibility/on_check", 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/requirements/base.txt b/requirements/base.txt index b74cc4ef75..c3a351f815 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -64,7 +64,12 @@ typed-ast==1.5.0 # In Memory Database # ------------------------------------------------------------------------------ littletable==2.0.7 + +# ABDM +pycryptodome==3.16.0 +pycryptodomex==3.16.0 # HCX fhir.resources==6.5.0 +pydantic==1.* jwcrypto==1.4.2 requests==2.31.0 diff --git a/requirements/local.txt b/requirements/local.txt index 74774794d8..8a80173749 100644 --- a/requirements/local.txt +++ b/requirements/local.txt @@ -2,7 +2,7 @@ -r ./docs.txt Werkzeug==1.0.0 # https://github.com/pallets/werkzeug ipdb==0.13.2 # https://github.com/gotcha/ipdb -psycopg2-binary==2.8.4 # https://github.com/psycopg/psycopg2 +psycopg2-binary==2.8.6 # https://github.com/psycopg/psycopg2 # Code quality # ------------------------------------------------------------------------------ isort==5.11.5 # https://github.com/PyCQA/isort