Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README-bb2-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,17 @@ The overall goals are to:

### Run a local version of sample client that consumes a local version of the SDK

Ensure that in bluebutton-sample-client-python-react/server/Dockerfile, uncomment the following line. Replace the version number (1.0.4 in the example) of the .whl file with what has been generated from the previous build command.
Ensure that in bluebutton-sample-client-python-react/server/Dockerfile, comment out this block to not use the currently published SDK:

```
RUN if [ "$BUILD_DEVELOPMENT" = "True" ]; then \
pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ cms-bluebutton-sdk; \
else \
pip install cms-bluebutton-sdk; \
fi
```

then uncomment the following line. Replace the version number (1.0.4 in the example) of the .whl file with what has been generated from the previous build command.

```
RUN pip install cms_bluebutton_sdk-1.0.4-py3-none-any.whl
Expand All @@ -119,6 +129,7 @@ The overall goals are to:
cd server
unzip -l cms_bluebutton_sdk-1.0.4-py3-none-any.whl
pip install cms_bluebutton_sdk-1.0.4-py3-none-any.whl
cd ..
docker compose up
```

Expand Down
2 changes: 2 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Header from '../src/components/header';
import Patient from '../src/components/patient';
import PatientData from './components/patientData';
import Records from './components/records';
import InsuranceCard from './components/insuranceCard';
import { BrowserRouter as Router} from "react-router-dom";
import { TabPanel, Tabs } from '@cmsgov/design-system';

Expand All @@ -24,6 +25,7 @@ function App() {
</div>
{}
<Records />
<InsuranceCard />
{}
<div>
<div>
Expand Down
84 changes: 84 additions & 0 deletions client/src/components/insuranceCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React, { useEffect, useState } from 'react';
import * as process from 'process';

export type InsuranceCardInfo = {
beneficiaryName: string,
payerName: string,
memberId: string,
groupNumber: string,
planNumber: string,
effectiveDate: string
}

export type ErrorResponse = {
type: string,
content: string,
}

/*
* DEVELOPER NOTES:
* The digital insurance card is returned from the BB2 `$generate-insurance-card`
* operation (v3-only) as a CARIN Digital Insurance Card (C4DIC) FHIR Bundle
* containing Patient, Coverage, and Organization (payer) resources.
* See https://hl7.org/fhir/us/insurance-card/ for the full resource shapes.
*/
export default function InsuranceCard() {
const [card, setCard] = useState<InsuranceCardInfo>();
const [message, setMessage] = useState<ErrorResponse>();

useEffect(() => {
const test_url = process.env.TEST_APP_API_URL ? process.env.TEST_APP_API_URL : '';
fetch(`${test_url}/api/data/insuranceCard`)
.then(res => res.json())
.then(bundleData => {
console.log('Insurance card response:', JSON.stringify(bundleData, null, 2));
if (bundleData.entry) {
const resources = bundleData.entry.map((entry: any) => entry.resource);
const patient = resources.find((r: any) => r?.resourceType === 'Patient');
const coverage = resources.find((r: any) => r?.resourceType === 'Coverage');
const organizations = resources.filter((r: any) => r?.resourceType === 'Organization');
const payerReference = coverage?.payor?.[0]?.reference?.split('/').pop();
const payer = organizations.find((r: any) => r?.id === payerReference) || organizations[0];

setCard({
beneficiaryName: patient?.name?.[0]?.text
|| [patient?.name?.[0]?.given?.join(' '), patient?.name?.[0]?.family].filter(Boolean).join(' ')
|| 'Unknown',
payerName: payer?.name || 'Unknown',
memberId: coverage?.subscriberId || coverage?.identifier?.[0]?.value || 'Unknown',
groupNumber: coverage?.class?.find((c: any) => c.type?.coding?.[0]?.code === 'group')?.value || 'Unknown',
planNumber: coverage?.class?.find((c: any) => c.type?.coding?.[0]?.code === 'plan')?.value || 'Unknown',
effectiveDate: coverage?.period?.start || 'Unknown',
});
} else if (bundleData.message) {
setMessage({ type: 'error', content: bundleData.message });
}
});
}, []);

if (message) {
return (
<div className='full-width-card'>
<p>{message.content}</p>
</div>
);
}

if (!card) {
return null;
}

return (
<div className="bb-c-card default-card">
<h3>Digital Insurance Card</h3>
<ul>
<li>Beneficiary: {card.beneficiaryName}</li>
<li>Payer: {card.payerName}</li>
<li>Member ID: {card.memberId}</li>
<li>Group Number: {card.groupNumber}</li>
<li>Plan Number: {card.planNumber}</li>
<li>Effective Date: {card.effectiveDate}</li>
</ul>
</div>
);
}
5 changes: 3 additions & 2 deletions server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@ RUN apt-get update && \
RUN pip install pipenv debugpy

# Install cms_bluebutton_sdk from pypi.org or test.pypi.org
# Comment out this entire block when running a local version of the SDK
RUN if [ "$BUILD_DEVELOPMENT" = "True" ]; then \
pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ cms-bluebutton-sdk; \
else \
pip install cms-bluebutton-sdk; \
fi

# If using a local version of the sdk, copy the wheel file and install it
# RUN pip install cms_bluebutton_sdk-1.0.4-py3-none-any.whl
# If using a local version of the sdk, uncomment out this next line and add the proper version
# RUN pip install cms_bluebutton_sdk-1.0.5-py3-none-any.whl
RUN pipenv lock
RUN pip install click
RUN pipenv install --system --deploy --ignore-pipfile
143 changes: 101 additions & 42 deletions server/app.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import os
import json
import os
import time
from http import HTTPStatus

from flask import redirect, request, Flask
import requests
from cms_bluebutton.cms_bluebutton import BlueButton

from flask import Flask, redirect, request

BENE_DENIED_ACCESS = "access_denied"
FE_MSG_ACCESS_DENIED = "Beneficiary denied app access to their data"
ERR_QUERY_EOB = "Error when querying the patient's EOB!"
ERR_QUERY_INSURANCE_CARD = "Error when querying the patient's digital insurance card!"
ERR_MISSING_AUTH_CODE = "Response was missing access code!"
ERR_MISSING_STATE = "State is required when using PKCE"
ERR_TOKEN_EXCHANGE = "Error when exchanging the authorization code for an access token!"
TOKEN_RETRY_TOTAL = 3
TOKEN_RETRY_BACKOFF_SECONDS = 2
RETRY_ERROR_DENY = [HTTPStatus.TOO_MANY_REQUESTS, HTTPStatus.BAD_REQUEST]

app = Flask(__name__)
bb = BlueButton()
Expand All @@ -18,10 +25,7 @@
# with the current logged in app user,
# in real app, this could be the app specific
# account management system
logged_in_user = {
'authToken': None,
'eobData': None
}
logged_in_user = {"authToken": None, "eobData": None, "insuranceCardData": None}

auth_data = bb.generate_auth_data()

Expand All @@ -33,51 +37,58 @@
auth_token = None


@app.route('/api/authorize/authurl', methods=['GET'])
@app.route("/api/authorize/authurl", methods=["GET"])
def get_auth_url():
# for SMART App v2 scopes usage: explicitly
# provide query parameter scope=<v2 scopes>
# where <v2 scopes> is space delimited v2 scope specs (url encoded)
# e.g. patient/ExplanationOfBenefit.rs
redirect_url = (bb.generate_authorize_url(auth_data)
+ "&scope=patient%2FExplanationOfBenefit.s")
# patient/Patient.rs is required for the $generate-insurance-card
# operation (v3-only) used to retrieve the digital insurance card
redirect_url = (
bb.generate_authorize_url(auth_data)
+ "&scope=patient%2FExplanationOfBenefit.rs+patient%2FPatient.rs+patient%2FCoverage.rs"
)
return redirect_url


@app.route('/api/bluebutton/callback/', methods=['GET'])
@app.route("/api/bluebutton/callback/", methods=["GET"])
def authorization_callback():
request_query = request.args

if (request_query.get('error') == BENE_DENIED_ACCESS):
if request_query.get("error") == BENE_DENIED_ACCESS:
# clear all cached claims eob data since the bene has denied access
# for the application
clear_bb2_data()
logged_in_user.update({'eobData': {'message': FE_MSG_ACCESS_DENIED}})
logged_in_user.update({"eobData": {"message": FE_MSG_ACCESS_DENIED}})
print(FE_MSG_ACCESS_DENIED)
return redirect(get_fe_redirect_url())

code = request_query.get('code')
code = request_query.get("code")

if code is None:
print(ERR_MISSING_AUTH_CODE)
return redirect(get_fe_redirect_url())

state = request_query.get('state')
state = request_query.get("state")

if state is None:
print(ERR_MISSING_STATE)
return redirect(get_fe_redirect_url())

auth_token = bb.get_authorization_token(auth_data, code, state)
try:
auth_token = get_authorization_token_with_retry(auth_data, code, state)
except Exception as ex:
clear_bb2_data()
logged_in_user.update({"eobData": {"message": ERR_TOKEN_EXCHANGE}})
print(ERR_TOKEN_EXCHANGE)
print(ex)
return redirect(get_fe_redirect_url())

# correlate app user with medicare bene
logged_in_user['authToken'] = auth_token
logged_in_user["authToken"] = auth_token

config = {
"auth_token": auth_token,
"params": {},
"url": "to be overriden"
}
config = {"auth_token": auth_token, "params": {}, "url": "to be overriden"}

try:
# search eob (or other fhir resources: patient, coverage, etc.)
Expand All @@ -91,61 +102,109 @@ def authorization_callback():
# 'previous' might present depending on the current page.
# Use bb.get_pages(data, config) to get all the pages

auth_token = eob_data['auth_token']
logged_in_user['authToken'] = auth_token
logged_in_user['eobData'] = eob_data['response'].json()
auth_token = eob_data["auth_token"]
logged_in_user["authToken"] = auth_token
logged_in_user["eobData"] = eob_data["response"].json()
except Exception as ex:
clear_bb2_data()
logged_in_user.update({'eobData': {'message': ERR_QUERY_EOB}})
logged_in_user.update({"eobData": {"message": ERR_QUERY_EOB}})
print(ERR_QUERY_EOB)
print(ex)
return redirect(get_fe_redirect_url())

try:
# fetch the CARIN Digital Insurance Card (C4DIC) FHIR bundle
insurance_card_data = bb.get_insurance_card_data(config)

auth_token = insurance_card_data["auth_token"]
logged_in_user["authToken"] = auth_token
logged_in_user["insuranceCardData"] = insurance_card_data["response"].json()
print(json.dumps(logged_in_user["insuranceCardData"], indent=2))
except Exception as ex:
logged_in_user.update(
{"insuranceCardData": {"message": ERR_QUERY_INSURANCE_CARD}}
)
print(ERR_QUERY_INSURANCE_CARD)
print(ex)

return redirect(get_fe_redirect_url())


@app.route('/api/bluebutton/loadDefaults', methods=['GET'])
def get_authorization_token_with_retry(auth_data, code, state):
for attempt in range(TOKEN_RETRY_TOTAL + 1):
try:
return bb.get_authorization_token(auth_data, code, state)
except requests.exceptions.HTTPError as ex:
status_code = ex.response.status_code if ex.response is not None else None
if attempt == TOKEN_RETRY_TOTAL and status_code not in RETRY_ERROR_DENY:
raise
wait_seconds = TOKEN_RETRY_BACKOFF_SECONDS * (2**attempt)
print(
f"Token endpoint returned {status_code}, retrying in "
f"{wait_seconds}s (attempt {attempt + 1}/{TOKEN_RETRY_TOTAL})"
)
time.sleep(wait_seconds)


@app.route("/api/bluebutton/loadDefaults", methods=["GET"])
def load_default_data():
# TODO: add config var or param to detemine dataset
logged_in_user['eobData'] = load_data_file("Dataset 1", "eobData")
logged_in_user["eobData"] = load_data_file("Dataset 1", "eobData")
return get_fe_redirect_url()


def load_data_file(dataset_name, resource_file_name):
response_file = open("./default_datasets/{}/{}.json".format(dataset_name, resource_file_name), 'r')
response_file = open(
"./default_datasets/{}/{}.json".format(dataset_name, resource_file_name), "r"
)
resource = json.load(response_file)
response_file.close()
return resource


@app.route('/api/data/benefit', methods=['GET'])
@app.route("/api/data/benefit", methods=["GET"])
def get_patient_eob():
"""
* this function is used directly by the front-end to
* retrieve eob data from the logged in user from within the mocked DB
* This would be replaced by a persistence service layer for whatever
* DB you would choose to use
"""
if logged_in_user and logged_in_user.get('eobData'):
return logged_in_user.get('eobData')
if logged_in_user and logged_in_user.get("eobData"):
return logged_in_user.get("eobData")
else:
return {}


@app.route("/api/data/insuranceCard", methods=["GET"])
def get_patient_insurance_card():
"""
* this function is used directly by the front-end to
* retrieve the digital insurance card data for the logged in user
* from within the mocked DB
"""
if logged_in_user and logged_in_user.get("insuranceCardData"):
return logged_in_user.get("insuranceCardData")
else:
return {}


def get_fe_redirect_url():
'''
"""
helper to figure out the correct front end redirect url per context
'''
is_selenium = os.getenv('SELENIUM_TESTS', 'False').lower() in ('true')
return 'http://client:3000' if is_selenium else 'http://localhost:3000'
"""
is_selenium = os.getenv("SELENIUM_TESTS", "False").lower() in ("true")
return "http://client:3000" if is_selenium else "http://localhost:3000"


def clear_bb2_data():
'''
"""
helper to clean up cached result
'''
logged_in_user.update({'authToken': None})
logged_in_user.update({'eobData': {}})
"""
logged_in_user.update({"authToken": None})
logged_in_user.update({"eobData": {}})
logged_in_user.update({"insuranceCardData": {}})


if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=3001)
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=3001)
2 changes: 1 addition & 1 deletion server/sample-bluebutton-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
"client_id": "<client_id>",
"client_secret": "<client_secret>",
"callback_url": "http://localhost:3001/api/bluebutton/callback/",
"version": 2
"version": 3
}
Loading
Loading