diff --git a/Makefile b/Makefile index ba022d225..e7fce1197 100644 --- a/Makefile +++ b/Makefile @@ -20,4 +20,8 @@ build-local: cd dev-local ; make build-local ; cd .. run-local: - cd dev-local ; make run-local ; cd .. \ No newline at end of file + cd dev-local ; make run-local ; cd .. + +exec-web: + cd dev-local ; make exec-web ; cd .. + \ No newline at end of file diff --git a/apps/authorization/permissions.py b/apps/authorization/permissions.py index 2b0d89dca..cf0b481c9 100644 --- a/apps/authorization/permissions.py +++ b/apps/authorization/permissions.py @@ -9,7 +9,8 @@ class DataAccessGrantPermission(permissions.BasePermission): """ Permission check for a Grant related to the token used. """ - def has_permission(self, request, view): + + def has_permission(self, request, view) -> bool: # type: ignore dag = None try: dag = DataAccessGrant.objects.get( @@ -34,6 +35,11 @@ def has_object_permission(self, request, view, obj): # Return 404 on error to avoid notifying unauthorized user the object exists if view.version in Versions.supported_versions(): + # If we're handling a digital insurance card, it is *not* an actual + # FHIR resource, but something of a conglomeration. We have to handle + # it specially here. We're going to gate it to v3 as well. + if view.version == Versions.V3 and 'DigitalInsuranceCard' in request.path: + return True return is_resource_for_patient(obj, request.crosswalk.fhir_id(view.version)) else: raise VersionNotMatched() diff --git a/apps/capabilities/management/commands/create_blue_button_scopes.py b/apps/capabilities/management/commands/create_blue_button_scopes.py index 8fa57ee94..9b1dcd5ce 100644 --- a/apps/capabilities/management/commands/create_blue_button_scopes.py +++ b/apps/capabilities/management/commands/create_blue_button_scopes.py @@ -146,6 +146,7 @@ def create_eob_capability(group, fhir_prefix, title="My Medicare claim informati protected_resources=json.dumps(pr, indent=4)) return c + def create_eob_read_capability(group, fhir_prefix, title="Read my Medicare claim information."): c = None description = "ExplanationOfBenefit FHIR Resource" @@ -161,6 +162,7 @@ def create_eob_read_capability(group, fhir_prefix, title="Read my Medicare claim protected_resources=json.dumps(pr, indent=4)) return c + def create_eob_search_capability(group, fhir_prefix, title="Search my Medicare claim information."): c = None description = "ExplanationOfBenefit FHIR Resource" @@ -175,6 +177,7 @@ def create_eob_search_capability(group, fhir_prefix, title="Search my Medicare c protected_resources=json.dumps(pr, indent=4)) return c + def create_eob_read_search_capability(group, fhir_prefix, title="Read and search my Medicare claim information."): c = None @@ -208,6 +211,7 @@ def create_coverage_capability(group, fhir_prefix, title="My Medicare and supple protected_resources=json.dumps(pr, indent=4)) return c + def create_coverage_read_capability(group, fhir_prefix, title="Read my Medicare and supplemental coverage information."): @@ -225,6 +229,7 @@ def create_coverage_read_capability(group, protected_resources=json.dumps(pr, indent=4)) return c + def create_coverage_search_capability(group, fhir_prefix, title="Search my Medicare and supplemental coverage information."): @@ -242,6 +247,7 @@ def create_coverage_search_capability(group, protected_resources=json.dumps(pr, indent=4)) return c + def create_coverage_read_search_capability(group, fhir_prefix, title="Read and search my Medicare and supplemental coverage information."): diff --git a/apps/capabilities/permissions.py b/apps/capabilities/permissions.py index 2994e7ffb..5e5643968 100644 --- a/apps/capabilities/permissions.py +++ b/apps/capabilities/permissions.py @@ -16,7 +16,7 @@ class BBCapabilitiesPermissionTokenScopeMissingException(APIException): class TokenHasProtectedCapability(permissions.BasePermission): - def has_permission(self, request, view): + def has_permission(self, request, view) -> bool: # type: ignore token = request.auth access_token_query_param = request.GET.get("access_token", None) @@ -51,6 +51,7 @@ def has_permission(self, request, view): return True if re.fullmatch(path, request.path) is not None: return True + return False else: # BB2-237: Replaces ASSERT with exception. We should never reach here. diff --git a/apps/dot_ext/views/authorization.py b/apps/dot_ext/views/authorization.py index 9a372fe2d..349a1bb02 100644 --- a/apps/dot_ext/views/authorization.py +++ b/apps/dot_ext/views/authorization.py @@ -358,9 +358,11 @@ def dispatch(self, request, uuid, *args, **kwargs): approval = Approval.objects.get(uuid=uuid) if approval.expired: raise Approval.DoesNotExist - if approval.application\ - and approval.application.client_id != request.GET.get('client_id', None)\ - and approval.application.client_id != request.POST.get('client_id', None): + if ( + approval.application + and approval.application.client_id != request.GET.get('client_id', None) + and approval.application.client_id != request.POST.get('client_id', None) + ): raise Approval.DoesNotExist request.user = approval.user except Approval.DoesNotExist: diff --git a/apps/fhir/bluebutton/constants.py b/apps/fhir/bluebutton/constants.py index 0900bd756..5fb38b566 100644 --- a/apps/fhir/bluebutton/constants.py +++ b/apps/fhir/bluebutton/constants.py @@ -1,3 +1,3 @@ -ALLOWED_RESOURCE_TYPES = ['Patient', 'Coverage', 'ExplanationOfBenefit'] +ALLOWED_RESOURCE_TYPES = ['Patient', 'Coverage', 'ExplanationOfBenefit', 'Bundle'] DEFAULT_PAGE_SIZE = 10 MAX_PAGE_SIZE = 50 diff --git a/apps/fhir/bluebutton/permissions.py b/apps/fhir/bluebutton/permissions.py index cf3a62022..4395914c8 100644 --- a/apps/fhir/bluebutton/permissions.py +++ b/apps/fhir/bluebutton/permissions.py @@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model from rest_framework import permissions, exceptions from rest_framework.exceptions import AuthenticationFailed -from .constants import ALLOWED_RESOURCE_TYPES +from apps.fhir.bluebutton.constants import ALLOWED_RESOURCE_TYPES from apps.versions import Versions, VersionNotMatched import apps.logging.request_logger as bb2logging @@ -72,7 +72,7 @@ def has_object_permission(self, request, view, obj): class SearchCrosswalkPermission(HasCrosswalk): - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request, view, obj) -> bool: # type: ignore if view.version in Versions.supported_versions(): patient_id = request.crosswalk.fhir_id(view.version) else: @@ -106,3 +106,8 @@ def has_permission(self, request, view): ) return True + + +class AlwaysDeny(permissions.BasePermission): + def has_permission(self, request, view): + return False diff --git a/apps/fhir/bluebutton/tests/test_fhir_resources_read_search_w_validation.py b/apps/fhir/bluebutton/tests/test_fhir_resources_read_search_w_validation.py index 06e17999f..4ed0632ef 100755 --- a/apps/fhir/bluebutton/tests/test_fhir_resources_read_search_w_validation.py +++ b/apps/fhir/bluebutton/tests/test_fhir_resources_read_search_w_validation.py @@ -224,7 +224,7 @@ def _search_eob_by_parameter_tag(self, version=1): def catchall_w_tag_qparam(url, req): # this is called in case EOB search with good tag self.assertIn(f'{FHIR_SERVER["FHIR_URL"]}/v{version}/fhir/ExplanationOfBenefit/', req.url) - self.assertIn('_format=application%2Fjson%2Bfhir', req.url) + self.assertIn('_format=application%2Ffhir%2Bjson', req.url) # parameters encoded in prepared request's body self.assertTrue(('_tag=Adjudicated' in req.url) or ('_tag=PartiallyAdjudicated' in req.url)) @@ -236,7 +236,7 @@ def catchall_w_tag_qparam(url, req): @all_requests def catchall(url, req): self.assertIn(f'{FHIR_SERVER["FHIR_URL"]}/v{version}/fhir/ExplanationOfBenefit/', req.url) - self.assertIn('_format=application%2Fjson%2Bfhir', req.url) + self.assertIn('_format=application%2Ffhir%2Bjson', req.url) return { 'status_code': 200, @@ -280,7 +280,7 @@ def _search_eob_by_parameters_request(self, version=1): @all_requests def catchall(url, req): self.assertIn(f'{FHIR_SERVER["FHIR_URL"]}/v{version}/fhir/ExplanationOfBenefit/', req.url) - self.assertIn('_format=application%2Fjson%2Bfhir', req.url) + self.assertIn('_format=application%2Ffhir%2Bjson', req.url) return { 'status_code': 200, diff --git a/apps/fhir/bluebutton/tests/test_insurancecard.py b/apps/fhir/bluebutton/tests/test_insurancecard.py new file mode 100644 index 000000000..3dd6e3bc5 --- /dev/null +++ b/apps/fhir/bluebutton/tests/test_insurancecard.py @@ -0,0 +1,62 @@ + +from django.conf import settings +from django.test.client import Client +from django.urls import reverse +from httmock import all_requests, HTTMock +from http import HTTPStatus +from oauth2_provider.models import get_access_token_model + +from apps.test import BaseApiTest + +# Get the pre-defined Conformance statement +from waffle.testutils import override_switch + +AccessToken = get_access_token_model() + +FHIR_ID_V2 = settings.DEFAULT_SAMPLE_FHIR_ID_V2 + +POSSIBLE_COVERAGE_SCOPES = ['patient/Coverage.read', 'patient/Coverage.rs', 'patient/Coverage.s'] +POSSIBLE_PATIENT_SCOPES = ['patient/Patient.read', 'patient/Patient.rs', 'patient/Patient.r'] + + +class InsuranceCardTest(BaseApiTest): + TEST_TABLE = [] + # Add all of the passing combos. These should be 200s. + for C in POSSIBLE_COVERAGE_SCOPES: + for P in POSSIBLE_PATIENT_SCOPES: + TEST_TABLE.append({'status': HTTPStatus.OK, 'scope': [C, P]}) + # If we only have one, those should fail with a 403. + for C in POSSIBLE_COVERAGE_SCOPES: + TEST_TABLE.append({'status': HTTPStatus.FORBIDDEN, 'scope': [C]}) + for P in POSSIBLE_PATIENT_SCOPES: + TEST_TABLE.append({'status': HTTPStatus.FORBIDDEN, 'scope': [P]}) + + def setUp(self): + # create read and write capabilities + self.read_capability = self._create_capability('Read', []) + self.write_capability = self._create_capability('Write', []) + self.client = Client() + + @override_switch('v3_endpoints', active=True) + def test_scope_combinations(self): + for tt in InsuranceCardTest.TEST_TABLE: + with self.subTest(tt=tt): + token = self.create_token('Annie', 'User', fhir_id_v2=FHIR_ID_V2) + ac = AccessToken.objects.get(token=token) + ac.scope = " ".join(tt['scope']) + ac.save() + + @all_requests + def catchall(url, req): + return { + 'status_code': 200, + 'content': { + 'doesnot': 'matter', + } + } + with HTTMock(catchall): + response = self.client.get( + reverse('bb_oauth_fhir_dic_read'), + Authorization='Bearer %s' % (token) + ) + self.assertEqual(response.status_code, tt['status']) diff --git a/apps/fhir/bluebutton/tests/test_read_and_search.py b/apps/fhir/bluebutton/tests/test_read_and_search.py index 4f36fcfed..96ed16c58 100644 --- a/apps/fhir/bluebutton/tests/test_read_and_search.py +++ b/apps/fhir/bluebutton/tests/test_read_and_search.py @@ -26,7 +26,7 @@ def get_expected_read_request(version: int): return { 'method': 'GET', - 'url': f'{FHIR_SERVER["FHIR_URL"]}/v{version}/fhir/Patient/{FHIR_ID_V2}/?_format=json&_id={FHIR_ID_V2}', + 'url': f'{FHIR_SERVER["FHIR_URL"]}/v{version}/fhir/Patient/{FHIR_ID_V2}/?_format=application/fhir+json&_id={FHIR_ID_V2}', 'headers': { # 'User-Agent': 'python-requests/2.20.0', 'Accept-Encoding': 'gzip, deflate', @@ -284,7 +284,7 @@ def _search_request(self, version: int = 1): @all_requests def catchall(url, req): self.assertIn(f'{FHIR_SERVER["FHIR_URL"]}/v{version}/fhir/Patient/', req.url) - self.assertIn('_format=application%2Fjson%2Bfhir', req.url) + self.assertIn('_format=application%2Ffhir%2Bjson', req.url) self.assertIn(f'_id={FHIR_ID_V2}', req.url) self.assertIn('_count=5', req.url) self.assertNotIn('hello', req.url) @@ -362,7 +362,7 @@ def _search_request_not_found(self, version: int = 1): @all_requests def catchall(url, req): self.assertIn(f'{FHIR_SERVER["FHIR_URL"]}/v{version}/fhir/Patient/', req.url) - self.assertIn('_format=application%2Fjson%2Bfhir', req.url) + self.assertIn('_format=application%2Ffhir%2Bjson', req.url) self.assertIn(f'_id={FHIR_ID_V2}', req.url) self.assertEqual(expected_request['method'], req.method) self.assertDictContainsSubset(expected_request['headers'], req.headers) @@ -444,7 +444,7 @@ def _search_request_failed(self, version: int = 1, bfd_status_code=500): @all_requests def catchall(url, req): self.assertIn(f'{FHIR_SERVER["FHIR_URL"]}/v{version}/fhir/Patient/', req.url) - self.assertIn('_format=application%2Fjson%2Bfhir', req.url) + self.assertIn('_format=application%2Ffhir%2Bjson', req.url) self.assertIn(f'_id={FHIR_ID_V2}', req.url) self.assertEqual(expected_request['method'], req.method) self.assertDictContainsSubset(expected_request['headers'], req.headers) @@ -499,7 +499,7 @@ def fhir_request(url, req): @all_requests def catchall(url, req): self.assertIn(f'{FHIR_SERVER["FHIR_URL"]}/v{version}/fhir/Patient/', req.url) - self.assertIn('_format=application%2Fjson%2Bfhir', req.url) + self.assertIn('_format=application%2Ffhir%2Bjson', req.url) self.assertIn(f'_id={FHIR_ID_V2}', req.url) self.assertEqual(expected_request['method'], req.method) self.assertDictContainsSubset(expected_request['headers'], req.headers) @@ -532,7 +532,7 @@ def _search_parameters_request(self, version: int = 1): @all_requests def catchall(url, req): self.assertIn(f'{FHIR_SERVER["FHIR_URL"]}/v{version}/fhir/ExplanationOfBenefit/', req.url) - self.assertIn('_format=application%2Fjson%2Bfhir', req.url) + self.assertIn('_format=application%2Ffhir%2Bjson', req.url) return { 'status_code': 200, diff --git a/apps/fhir/bluebutton/tests/test_utils.py b/apps/fhir/bluebutton/tests/test_utils.py index 0d1eb33ba..14527e4da 100644 --- a/apps/fhir/bluebutton/tests/test_utils.py +++ b/apps/fhir/bluebutton/tests/test_utils.py @@ -7,6 +7,8 @@ from apps.test import BaseApiTest from apps.fhir.bluebutton.models import Crosswalk from apps.versions import Versions +from apps.fhir.server.settings import fhir_settings + from apps.fhir.bluebutton.utils import ( notNone, @@ -16,7 +18,6 @@ prepend_q, dt_patient_reference, crosswalk_patient_id, - get_resourcerouter, build_oauth_resource, valid_patient_read_or_search_call, ) @@ -123,13 +124,12 @@ def test_FhirServerAuth(self): """ Test 1: pass nothing""" - resource_router = get_resourcerouter() expected = {} - expected['client_auth'] = resource_router.client_auth + expected['client_auth'] = fhir_settings.client_auth expected['cert_file'] = os.path.join(settings.FHIR_CLIENT_CERTSTORE, - resource_router.cert_file) + fhir_settings.cert_file) expected['key_file'] = os.path.join(settings.FHIR_CLIENT_CERTSTORE, - resource_router.key_file) + fhir_settings.key_file) response = FhirServerAuth() diff --git a/apps/fhir/bluebutton/urls.py b/apps/fhir/bluebutton/urls.py index d47d902be..43e0f3f60 100644 --- a/apps/fhir/bluebutton/urls.py +++ b/apps/fhir/bluebutton/urls.py @@ -2,14 +2,14 @@ from django.contrib import admin from apps.fhir.bluebutton.views.read import ( + ReadViewPatient, ReadViewCoverage, ReadViewExplanationOfBenefit, - ReadViewPatient, ) from apps.fhir.bluebutton.views.search import ( + SearchViewPatient, SearchViewCoverage, SearchViewExplanationOfBenefit, - SearchViewPatient, ) admin.autodiscover() diff --git a/apps/fhir/bluebutton/utils.py b/apps/fhir/bluebutton/utils.py index 271d76a6b..1fc845bcc 100644 --- a/apps/fhir/bluebutton/utils.py +++ b/apps/fhir/bluebutton/utils.py @@ -221,20 +221,15 @@ def request_call(request, call_url, crosswalk=None, timeout=None, get_parameters call_url = target server URL and search parameters to be sent crosswalk = Crosswalk record. The crosswalk is keyed off Request.user timeout allows a timeout in seconds to be set. - - FhirServer is joined to Crosswalk. - FhirServerAuth and FhirServerVerify receive crosswalk and lookup - values in the linked fhir_server model. - """ logger_perf = bb2logging.getLogger(bb2logging.PERFORMANCE_LOGGER, request) # Updated to receive crosswalk (Crosswalk entry for user) # call FhirServer_Auth(crosswalk) to get authentication - auth_state = FhirServerAuth(crosswalk) + auth_state = FhirServerAuth() - verify_state = FhirServerVerify(crosswalk) + verify_state = fhir_settings.verify_server if auth_state["client_auth"]: # cert puts cert and key file together # (cert_file_path, key_file_path) @@ -326,16 +321,22 @@ def notNone(value=None, default=None): return value -def FhirServerAuth(crosswalk=None): - # Get default clientauth settings from base.py - # Receive a crosswalk.id or None - # Return a dict +def FhirServerAuth() -> dict: + """Helper class to modify cert paths if client_auth is true + TODO - this can probably be refactored or removed, rolled into the FHIRServerSettings class, all it does is a conditional + settings check + + Returns: + dict: A dictionary with the following: + - client_auth (bool): if client authentication is required + - cert_file (str): path to cert file + - key_file (str): path to key file + """ auth_settings = {} - resource_router = get_resourcerouter() - auth_settings["client_auth"] = resource_router.client_auth - auth_settings["cert_file"] = resource_router.cert_file - auth_settings["key_file"] = resource_router.key_file + auth_settings["client_auth"] = fhir_settings.client_auth + auth_settings["cert_file"] = fhir_settings.cert_file + auth_settings["key_file"] = fhir_settings.key_file if auth_settings["client_auth"]: # join settings.FHIR_CLIENT_CERTSTORE to cert_file and key_file @@ -351,12 +352,6 @@ def FhirServerAuth(crosswalk=None): return auth_settings -def FhirServerVerify(crosswalk=None): - # Get default Server Verify Setting - # Return True or False (Default) - return get_resourcerouter().verify_server - - def mask_with_this_url(request, host_path="", in_text="", find_url=""): """find_url in in_text and replace with url for this server""" @@ -455,10 +450,6 @@ def get_crosswalk(user): return None -def get_resourcerouter(crosswalk=None): - return fhir_settings - - def handle_http_error(e): """Handle http error from request_call @@ -605,7 +596,7 @@ def get_response_text(fhir_response=None): return text_in -def build_oauth_resource(request, version=Versions.NOT_AN_API_VERSION, format_type="json"): +def build_oauth_resource(request, version=Versions.NOT_AN_API_VERSION, format_type="json") -> dict | str: """ Create a resource entry for oauth endpoint(s) for insertion into the conformance/capabilityStatement @@ -707,7 +698,7 @@ def get_v2_patient_by_id(id, request): a helper adapted to just get patient given an id out of band of auth flow or normal data flow, use by tools such as BB2-Tools admin viewers """ - auth_settings = FhirServerAuth(None) + auth_settings = FhirServerAuth() certs = (auth_settings["cert_file"], auth_settings["key_file"]) headers = generate_info_headers(request) @@ -715,9 +706,7 @@ def get_v2_patient_by_id(id, request): headers["includeIdentifiers"] = "true" # for now this will only work for v1/v2 patients, but we'll need to be able to # determine if the user is V3 and use those endpoints later - url = "{}/v2/fhir/Patient/{}?_format={}".format( - get_resourcerouter().fhir_url, id, settings.FHIR_PARAM_FORMAT - ) + url = f'{fhir_settings.fhir_url}/v2/fhir/Patient/{id}?_format={settings.FHIR_PARAM_FORMAT}' s = requests.Session() req = requests.Request("GET", url, headers=headers) prepped = req.prepare() @@ -730,7 +719,7 @@ def get_v2_patient_by_id(id, request): # of the ticket to remove the user_mbi_hash column from the crosswalk table # We can remove this entire function at that point def get_patient_by_mbi_hash(mbi_hash, request): - auth_settings = FhirServerAuth(None) + auth_settings = FhirServerAuth() certs = (auth_settings["cert_file"], auth_settings["key_file"]) headers = generate_info_headers(request) headers["BlueButton-Application"] = "BB2-Tools" @@ -738,9 +727,7 @@ def get_patient_by_mbi_hash(mbi_hash, request): search_identifier = f'https://bluebutton.cms.gov/resources/identifier/mbi-hash|{mbi_hash}' # noqa: E231 payload = {'identifier': search_identifier} - url = '{}/v2/fhir/Patient/_search'.format( - get_resourcerouter().fhir_url - ) + url = f'{fhir_settings.fhir_url}/v2/fhir/Patient/_search' s = requests.Session() req = requests.Request("POST", url, headers=headers, data=payload) diff --git a/apps/fhir/bluebutton/v3/urls.py b/apps/fhir/bluebutton/v3/urls.py index 0f90720e1..dac3e38bf 100644 --- a/apps/fhir/bluebutton/v3/urls.py +++ b/apps/fhir/bluebutton/v3/urls.py @@ -12,10 +12,17 @@ SearchViewExplanationOfBenefit, SearchViewPatient, ) +from apps.fhir.bluebutton.views.insurancecard import DigitalInsuranceCardView admin.autodiscover() urlpatterns = [ + # IF WE DECIDE TO MIRROR BFD + # re_path( + # r"Patient/(?P/$generate_insurance_card[^/]+)", + # waffle_switch('v3_endpoints')(DigitalInsuranceCardView.as_view(version=3)), + # name='bb_oauth_fhir_dic_read', + # ), # Patient ReadView re_path( r"Patient/(?P[^/]+)", @@ -52,4 +59,14 @@ waffle_switch("v3_endpoints")(SearchViewExplanationOfBenefit.as_view(version=3)), name="bb_oauth_fhir_eob_search_v3", ), + # C4DIC + # Digital Insurance Card ViewSet + # TODO - Change the URI for this endpoint when we finalize + # TODO - We are sending this to list even though it is a retrieve BECAUSE we're not asking for a resource id by the + # application, which means we're kinda breaking REST principles here. + re_path( + r'DigitalInsuranceCard[/]?', + waffle_switch('v3_endpoints')(DigitalInsuranceCardView.as_view(version=3)), + name='bb_oauth_fhir_dic_read', + ), ] diff --git a/apps/fhir/bluebutton/views/generic.py b/apps/fhir/bluebutton/views/generic.py index 6cb6e6cb0..5c210eadb 100644 --- a/apps/fhir/bluebutton/views/generic.py +++ b/apps/fhir/bluebutton/views/generic.py @@ -21,19 +21,20 @@ from apps.dot_ext.throttling import TokenRateThrottle from apps.fhir.parsers import FHIRParser from apps.fhir.renderers import FHIRRenderer +from apps.fhir.server.settings import fhir_settings from apps.fhir.server import connection as backend_connection - -from ..authentication import OAuth2ResourceOwner -from ..exceptions import process_error_response -from ..permissions import (HasCrosswalk, ResourcePermission, ApplicationActivePermission) -from ..signals import ( +from apps.fhir.bluebutton.authentication import OAuth2ResourceOwner +from apps.fhir.bluebutton.exceptions import process_error_response +from apps.fhir.bluebutton.permissions import (HasCrosswalk, ResourcePermission, ApplicationActivePermission) +from apps.fhir.bluebutton.signals import ( pre_fetch, post_fetch ) -from ..utils import (build_fhir_response, - FhirServerVerify, - get_resourcerouter, - valid_patient_read_or_search_call) +from apps.fhir.bluebutton.utils import ( + FhirServerAuth, + build_fhir_response, + valid_patient_read_or_search_call +) logger = logging.getLogger(bb2logging.HHS_SERVER_LOGNAME_FMT.format(__name__)) @@ -50,7 +51,8 @@ class FhirDataView(APIView): ApplicationActivePermission, HasCrosswalk, ResourcePermission, - DataAccessGrantPermission] + DataAccessGrantPermission + ] def __init__(self, version=1): self.version = version @@ -63,8 +65,11 @@ def check_resource_permission(self, request, **kwargs): def build_parameters(self, request): raise NotImplementedError() + def build_url(self, resource_router, resource_type, resource_id, **kwargs): + raise NotImplementedError() + def map_parameters(self, params): - transforms = getattr(self, "QUERY_TRANSFORMS", {}) + transforms = getattr(self, 'QUERY_TRANSFORMS', {}) for key, correct in transforms.items(): val = params.pop(key, None) if val is not None: @@ -77,7 +82,7 @@ def filter_parameters(self, request): params['_lastUpdated'] = request.query_params.getlist('_lastUpdated') schema = voluptuous.Schema( - getattr(self, "QUERY_SCHEMA", {}), + getattr(self, 'QUERY_SCHEMA', {}), extra=voluptuous.REMOVE_EXTRA) return schema(params) @@ -88,21 +93,21 @@ def initial(self, request, resource_type, *args, **kwargs): # curl -X GET http://127.0.0.1:8000/fhir/Patient/1234 """ - logger.debug("resource_type: %s" % resource_type) - logger.debug("Interaction: read") - logger.debug("Request.path: %s" % request.path) + logger.debug('resource_type: %s' % resource_type) + logger.debug('Interaction: read') + logger.debug('Request.path: %s' % request.path) req_meta = request.META - if "HTTP_AUTHORIZATION" in req_meta: - access_token = req_meta["HTTP_AUTHORIZATION"].split(" ")[1] + if 'HTTP_AUTHORIZATION' in req_meta: + access_token = req_meta['HTTP_AUTHORIZATION'].split(' ')[1] try: at = AccessToken.objects.get(token=access_token) log_message = { - "name": "FHIR Endpoint AT Logging", - "access_token_id": at.id, - "access_token_application_id": at.application.id, - "access_token_hash": {hashlib.sha256(str(access_token).encode('utf-8')).hexdigest()}, - "access_token_username": at.user.username, + 'name': 'FHIR Endpoint AT Logging', + 'access_token_id': at.id, + 'access_token_application_id': at.application.id, + 'access_token_hash': {hashlib.sha256(str(access_token).encode('utf-8')).hexdigest()}, + 'access_token_username': at.user.username, } logger.info(log_message) except ObjectDoesNotExist: @@ -119,9 +124,7 @@ def get(self, request, resource_type, *args, **kwargs): return Response(out_data) def fetch_data(self, request, resource_type, *args, **kwargs): - resource_router = get_resourcerouter(request.crosswalk) - - target_url = self.build_url(resource_router, + target_url = self.build_url(fhir_settings, resource_type, *args, **kwargs) @@ -144,11 +147,11 @@ def fetch_data(self, request, resource_type, *args, **kwargs): s = Session() # BB2-1544 request header url encode if header value (app name) contains char (>256) - if req.headers.get("BlueButton-Application") is not None: + if req.headers.get('BlueButton-Application') is not None: try: - req.headers.get("BlueButton-Application").encode("latin1") + req.headers.get('BlueButton-Application').encode('latin1') except UnicodeEncodeError: - req.headers["BlueButton-Application"] = quote(req.headers.get("BlueButton-Application")) + req.headers['BlueButton-Application'] = quote(req.headers.get('BlueButton-Application')) prepped = s.prepare_request(req) @@ -179,15 +182,17 @@ def fetch_data(self, request, resource_type, *args, **kwargs): case Versions.V3: api_ver_str = 'v3' case _: - raise VersionNotMatched(f"{self.version} is not a valid version constant") + raise VersionNotMatched(f'{self.version} is not a valid version constant') # Send signal + fhir_server_auth = FhirServerAuth() pre_fetch.send_robust(FhirDataView, request=req, auth_request=request, api_ver=api_ver_str) r = s.send( prepped, - cert=backend_connection.certs(crosswalk=request.crosswalk), - timeout=resource_router.wait_time, - verify=FhirServerVerify(crosswalk=request.crosswalk)) + cert=(fhir_server_auth['cert_file'], fhir_server_auth['key_file']), + timeout=fhir_settings.wait_time, + verify=fhir_settings.verify_server + ) # Send signal post_fetch.send_robust(FhirDataView, request=prepped, auth_request=request, response=r, api_ver=api_ver_str) response = build_fhir_response(request._request, target_url, request.crosswalk, r=r, e=None) diff --git a/apps/fhir/bluebutton/views/home.py b/apps/fhir/bluebutton/views/home.py index d5d44b003..f29094770 100644 --- a/apps/fhir/bluebutton/views/home.py +++ b/apps/fhir/bluebutton/views/home.py @@ -10,9 +10,9 @@ from apps.fhir.bluebutton import constants from apps.fhir.bluebutton.utils import (request_call, prepend_q, - get_resourcerouter, get_response_text, build_oauth_resource) +from apps.fhir.server.settings import fhir_settings from apps.versions import Versions, VersionNotMatched import apps.logging.request_logger as bb2logging @@ -72,15 +72,14 @@ def _fhir_conformance(request, version=Versions.NOT_AN_API_VERSION, *args): :return: """ crosswalk = None - resource_router = get_resourcerouter() match version: case Versions.V1: - fhir_url = resource_router.fhir_url + fhir_url = fhir_settings.fhir_url case Versions.V2: - fhir_url = resource_router.fhir_url + fhir_url = fhir_settings.fhir_url case Versions.V3: - fhir_url = resource_router.fhir_url_v3 + fhir_url = fhir_settings.fhir_url_v3 case _: raise VersionNotMatched('Could not match API version in _fhir_conformance') diff --git a/apps/fhir/bluebutton/views/insurancecard.py b/apps/fhir/bluebutton/views/insurancecard.py new file mode 100644 index 000000000..5ac2793db --- /dev/null +++ b/apps/fhir/bluebutton/views/insurancecard.py @@ -0,0 +1,90 @@ +from apps.fhir.bluebutton.views.generic import FhirDataView +from apps.fhir.bluebutton.permissions import (SearchCrosswalkPermission, + ResourcePermission, + ApplicationActivePermission) +from apps.authorization.permissions import DataAccessGrantPermission +from apps.fhir.bluebutton.models import Crosswalk + +from rest_framework import permissions # pyright: ignore[reportMissingImports] + + +class HasDigitalInsuranceCardScope(permissions.BasePermission): + + required_coverage_search_scopes = ['patient/Coverage.rs', 'patient/Coverage.s', 'patient/Coverage.read'] + required_patient_read_scopes = ['patient/Patient.r', 'patient/Patient.rs', 'patient/Patient.read'] + + def _is_not_empty(s: set) -> bool: + return len(s) > 0 + + def has_permission(self, request, view) -> bool: # type: ignore + # Is this an authorized request? If not, exit. + if not hasattr(request, 'auth'): + return False + if request.auth is None: + return False + + # If we're authenticated, then we can check the scopes from the token. + token_scope_string = request.auth.scope + # This will be a space-separated string. + token_scopes = list(map(lambda s: s.strip(), token_scope_string.split(" "))) + + # Two things need to be true: + # 1. At least one of the scopes in the token needs to be one of the above coverage scopes. + # 2. At leaset one of the scopes in the token needs to be one of the above read scopes. + coverage_set = set(HasDigitalInsuranceCardScope.required_coverage_search_scopes) + patient_set = set(HasDigitalInsuranceCardScope.required_patient_read_scopes) + token_set = set(token_scopes) + + return (HasDigitalInsuranceCardScope._is_not_empty(coverage_set.intersection(token_set)) + and HasDigitalInsuranceCardScope._is_not_empty(patient_set.intersection(token_set))) + + +class DigitalInsuranceCardView(FhirDataView): + '''Digital Insurance Card view for handling BFD Endpoint''' + + permission_classes = [ + permissions.IsAuthenticated, + ApplicationActivePermission, + ResourcePermission, + SearchCrosswalkPermission, + DataAccessGrantPermission, + # 20251205: We are bypassing ProtectedCapabilities at this time because + # the existing capability model has no notion of multiple capabilities for a single + # endpoint. In the case of C4DIC, the permission check HasDigitalInsuranceCardScope + # handles the set checks that are required for this particular API call. + # TokenHasProtectedCapability + HasDigitalInsuranceCardScope, + ] + + # TODO/FIXME: What are the version=1? doing? Check/look into. + def __init__(self, version=1): + super().__init__(version) + self.resource_type = 'Bundle' + + def initial(self, request, *args, **kwargs): + return super().initial(request, self.resource_type, *args, **kwargs) + + def get(self, request, *args, **kwargs): + return super().get(request, self.resource_type, *args, **kwargs) + # return JsonResponse(status=200, data={"consternation": "vorciferous"}) + + def get_full_path(self): + return f"/{DigitalInsuranceCardView.version}/fhir/DigitalInsuranceCard" + + def build_parameters(self, request): + return { + '_format': 'application/fhir+json' + } + + def build_url(self, fhir_settings, resource_type, resource_id=None, *args, **kwargs): + if fhir_settings.fhir_url.endswith('v1/fhir/'): + # only if called by tests + return f"{fhir_settings.fhir_url}{resource_type}/" + else: + # TODO - is this preferred (explicit), or should we keep using the implicit model APIS that Django creates? + fhir_id = Crosswalk.objects.get(user=self.request.user).fhir_id(self.version) + if self.version == 3 and getattr(fhir_settings, 'fhir_url_v3', None): + fhir_url = fhir_settings.fhir_url_v3 + else: + fhir_url = fhir_settings.fhir_url + return f"{fhir_url}/v{self.version}/fhir/Patient/{fhir_id}/$generate-insurance-card" diff --git a/apps/fhir/bluebutton/views/insurancecard_viewset.py b/apps/fhir/bluebutton/views/insurancecard_viewset.py new file mode 100644 index 000000000..5845ab4dd --- /dev/null +++ b/apps/fhir/bluebutton/views/insurancecard_viewset.py @@ -0,0 +1,86 @@ +from rest_framework import permissions +from apps.fhir.bluebutton.models import Crosswalk +from apps.fhir.bluebutton.views.viewsets_base import ResourceViewSet +from apps.authorization.permissions import DataAccessGrantPermission +from apps.capabilities.permissions import TokenHasProtectedCapability +from apps.fhir.bluebutton.permissions import ( + SearchCrosswalkPermission, + ResourcePermission, + ApplicationActivePermission, +) + +from rest_framework.response import Response + + +def _is_not_empty(s: set) -> bool: + if len(s) > 0: + return True + else: + return False + + +class HasDigitalInsuranceCardScope(permissions.BasePermission): + + required_coverage_search_scopes = ['patient/Coverage.rs', 'patient/Coverage.s', 'patient/Coverage.read'] + required_patient_read_scopes = ['patient/Patient.r', 'patient/Patient.rs', 'patient/Patient.read'] + + def has_permission(self, request, view) -> bool: # type: ignore + # Is this an authorized request? If not, exit. + if request.GET.get('auth', None) is None: + return False + + # If we're authenticated, then we can check the scopes from the token. + token_scopes = request.auth.scope + # Two things need to be true: + # 1. At least one of the scopes in the token needs to be one of the above coverage scopes. + # 2. At leaset one of the scopes in the token needs to be one of the above read scopes. + coverage_set = set(HasDigitalInsuranceCardScope.required_coverage_search_scopes) + patient_set = set(HasDigitalInsuranceCardScope.required_patient_read_scopes) + token_set = set(token_scopes) + return (_is_not_empty(coverage_set.intersection(token_set)) + and _is_not_empty(patient_set.intersection(token_set))) + + +class DigitalInsuranceCardViewSet(ResourceViewSet): + """Digital Insurance Card (bundle) django-rest-framework ViewSet experiment + + Args: + FhirDataView: Base mixin, unchanged + viewsets: django-rest-framework ViewSet base class + """ + + required_coverage_search_scopes = ['patient/Coverage.rs', 'patient/Coverage.s', 'patient/Coverage.read'] + required_patient_read_scopes = ['patient/Patient.r', 'patient/Patient.rs', 'patient/Patient.read'] + + permission_classes = [ + permissions.IsAuthenticated, + ApplicationActivePermission, + ResourcePermission, + SearchCrosswalkPermission, + DataAccessGrantPermission, + TokenHasProtectedCapability, + HasDigitalInsuranceCardScope, + ] + + def __init__(self, version, **kwargs): + super().__init__(version) + self.resource_type = 'Bundle' + + def initial(self, request, *args, **kwargs): + return super().initial(request, self.resource_type, *args, **kwargs) + + def list(self, request, resource_id, *args, **kwargs): + return Response({"ok": "go this is the viewset"}) + + def build_url(self, fhir_settings, resource_type, resource_id=None, *args, **kwargs): + if fhir_settings.fhir_url.endswith('v1/fhir/'): + # only if called by tests + return f"{fhir_settings.fhir_url}{resource_type}/" + else: + # TODO - is this preferred (explicit), or should we keep using the implicit model APIS that Django creates? + fhir_id = Crosswalk.objects.get(user=self.request.user).fhir_id(self.version) + if self.version == 3 and getattr(fhir_settings, 'fhir_url_v3', None): + fhir_url = fhir_settings.fhir_url_v3 + else: + fhir_url = fhir_settings.fhir_url + return f"{fhir_url}/v{self.version}/fhir/Patient/{fhir_id}/$generate-insurance-card" diff --git a/apps/fhir/bluebutton/views/patient_viewset.py b/apps/fhir/bluebutton/views/patient_viewset.py new file mode 100644 index 000000000..47e39a1cf --- /dev/null +++ b/apps/fhir/bluebutton/views/patient_viewset.py @@ -0,0 +1,91 @@ +from apps.fhir.bluebutton.views.viewsets_base import ResourceViewSet +from voluptuous import ( + Required, + All, + Match, + Range, + Coerce, +) +from apps.fhir.bluebutton.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE +from apps.authorization.permissions import DataAccessGrantPermission +from apps.capabilities.permissions import TokenHasProtectedCapability +from apps.fhir.bluebutton.permissions import ( + ReadCrosswalkPermission, + SearchCrosswalkPermission, + ResourcePermission, + ApplicationActivePermission, +) +from rest_framework import permissions + + +class HasSearchScope(permissions.BasePermission): + def has_permission(self, request, view) -> bool: # type: ignore + required_scopes = getattr(view, 'required_scopes', None) + if required_scopes is None: + return True + + if hasattr(request, 'auth') and request.auth is not None: + token_scopes = request.auth.scope + return any(scope in token_scopes for scope in required_scopes) + return False + + +class PatientViewSet(ResourceViewSet): + """Patient django-rest-framework ViewSet experiment + + Args: + FhirDataView: Base mixin, unchanged + viewsets: django-rest-framework ViewSet base class + """ + + # TODO - I don't love the separation here, could be indicative that we don't want to move to resource based ViewSets, or that + # we need a better base class, or these differences should be defined in PatientViewSet. + REGEX_LASTUPDATED_VALUE = r'^((lt)|(le)|(gt)|(ge)).+' + SEARCH_QUERY_TRANSFORMS = { + 'count': '_count', + } + SEARCH_QUERY_SCHEMA = { + 'startIndex': Coerce(int), + Required('_count', default=DEFAULT_PAGE_SIZE): All(Coerce(int), Range(min=0, max=MAX_PAGE_SIZE)), # type: ignore + '_lastUpdated': [Match(REGEX_LASTUPDATED_VALUE, msg='the _lastUpdated operator is not valid')] + } + + SEARCH_PERMISSION_CLASSES = ( + permissions.IsAuthenticated, + ApplicationActivePermission, + ResourcePermission, + SearchCrosswalkPermission, + DataAccessGrantPermission, + TokenHasProtectedCapability, + HasSearchScope, + ) + + READ_PERMISSION_CLASSES = ( + permissions.IsAuthenticated, + ApplicationActivePermission, + ResourcePermission, + ReadCrosswalkPermission, + DataAccessGrantPermission, + TokenHasProtectedCapability, + ) + + required_scopes = ['patient/Patient.read', 'patient/Patient.rs', 'patient/Patient.s'] + + def __init__(self, version, **kwargs): + super().__init__(version) + self.resource_type = 'Patient' + + def build_url(self, fhir_settings, resource_type, resource_id=None, *args, **kwargs): + if fhir_settings.fhir_url.endswith('v1/fhir/'): + # only if called by tests + return '{}{}/{}/'.format(fhir_settings.fhir_url, resource_type, resource_id) + else: + if self.version == 3 and getattr(fhir_settings, 'fhir_url_v3', None): + fhir_url = fhir_settings.fhir_url_v3 + else: + fhir_url = fhir_settings.fhir_url + + if resource_id: + return f"{fhir_url}/v{self.version}/fhir/{resource_type}/{resource_id}/" + else: + return f"{fhir_url}/v{self.version}/fhir/{resource_type}/" diff --git a/apps/fhir/bluebutton/views/read.py b/apps/fhir/bluebutton/views/read.py index 2513d30b9..79f8841fb 100644 --- a/apps/fhir/bluebutton/views/read.py +++ b/apps/fhir/bluebutton/views/read.py @@ -2,7 +2,7 @@ from apps.authorization.permissions import DataAccessGrantPermission from apps.capabilities.permissions import TokenHasProtectedCapability -from ..permissions import (ReadCrosswalkPermission, ResourcePermission, ApplicationActivePermission) +from apps.fhir.bluebutton.permissions import (ReadCrosswalkPermission, ResourcePermission, ApplicationActivePermission) from apps.fhir.bluebutton.views.generic import FhirDataView @@ -35,37 +35,37 @@ def get(self, request, *args, **kwargs): def build_parameters(self, *args, **kwargs): return { - "_format": "json" + '_format': 'application/fhir+json' } - def build_url(self, resource_router, resource_type, resource_id, **kwargs): - if resource_router.fhir_url.endswith('v1/fhir/'): + def build_url(self, fhir_settings, resource_type, resource_id, **kwargs): # type: ignore + if fhir_settings.fhir_url.endswith('v1/fhir/'): # only if called by tests - return "{}{}/{}/".format(resource_router.fhir_url, resource_type, resource_id) + return '{}{}/{}/'.format(fhir_settings.fhir_url, resource_type, resource_id) else: - if self.version == 3 and resource_router.fhir_url_v3: - fhir_url = resource_router.fhir_url_v3 + if self.version == 3 and fhir_settings.fhir_url_v3: + fhir_url = fhir_settings.fhir_url_v3 else: - fhir_url = resource_router.fhir_url - return f"{fhir_url}/v{self.version}/fhir/{resource_type}/{resource_id}/" + fhir_url = fhir_settings.fhir_url + return f'{fhir_url}/v{self.version}/fhir/{resource_type}/{resource_id}/' class ReadViewPatient(ReadView): # Class used for Patient resource def __init__(self, version=1): super().__init__(version) - self.resource_type = "Patient" + self.resource_type = 'Patient' class ReadViewCoverage(ReadView): # Class used for Patient resource def __init__(self, version=1): super().__init__(version) - self.resource_type = "Coverage" + self.resource_type = 'Coverage' class ReadViewExplanationOfBenefit(ReadView): # Class used for Patient resource def __init__(self, version=1): super().__init__(version) - self.resource_type = "ExplanationOfBenefit" + self.resource_type = 'ExplanationOfBenefit' diff --git a/apps/fhir/bluebutton/views/search.py b/apps/fhir/bluebutton/views/search.py index b1cd87ec4..e389a849d 100644 --- a/apps/fhir/bluebutton/views/search.py +++ b/apps/fhir/bluebutton/views/search.py @@ -16,11 +16,11 @@ from apps.fhir.bluebutton.views.generic import FhirDataView from apps.authorization.permissions import DataAccessGrantPermission from apps.capabilities.permissions import TokenHasProtectedCapability -from ..permissions import (SearchCrosswalkPermission, ResourcePermission, ApplicationActivePermission) +from apps.fhir.bluebutton.permissions import (SearchCrosswalkPermission, ResourcePermission, ApplicationActivePermission) class HasSearchScope(permissions.BasePermission): - def has_permission(self, request, view): + def has_permission(self, request, view) -> bool: # type: ignore required_scopes = getattr(view, 'required_scopes', None) if required_scopes is None: return True @@ -54,7 +54,7 @@ class SearchView(FhirDataView): QUERY_SCHEMA = { 'startIndex': Coerce(int), - Required('_count', default=DEFAULT_PAGE_SIZE): All(Coerce(int), Range(min=0, max=MAX_PAGE_SIZE)), + Required('_count', default=DEFAULT_PAGE_SIZE): All(Coerce(int), Range(min=0, max=MAX_PAGE_SIZE)), # type: ignore '_lastUpdated': [Match(REGEX_LASTUPDATED_VALUE, msg='the _lastUpdated operator is not valid')] } @@ -68,16 +68,16 @@ def initial(self, request, *args, **kwargs): def get(self, request, *args, **kwargs): return super().get(request, self.resource_type, *args, **kwargs) - def build_url(self, resource_router, resource_type, *args, **kwargs): - if resource_router.fhir_url.endswith('v1/fhir/'): + def build_url(self, fhir_settings, resource_type, *args, **kwargs): + if fhir_settings.fhir_url.endswith('v1/fhir/'): # only if called by tests - return "{}{}/".format(resource_router.fhir_url, resource_type) + return '{}{}/'.format(fhir_settings.fhir_url, resource_type) else: - if self.version == 3 and resource_router.fhir_url_v3: - fhir_url = resource_router.fhir_url_v3 + if self.version == 3 and fhir_settings.fhir_url_v3: + fhir_url = fhir_settings.fhir_url_v3 else: - fhir_url = resource_router.fhir_url - return f"{fhir_url}/v{self.version}/fhir/{resource_type}/" + fhir_url = fhir_settings.fhir_url + return f'{fhir_url}/v{self.version}/fhir/{resource_type}/' class SearchViewPatient(SearchView): @@ -91,11 +91,11 @@ class SearchViewPatient(SearchView): def __init__(self, version=1): super().__init__(version) - self.resource_type = "Patient" + self.resource_type = 'Patient' def build_parameters(self, request, *args, **kwargs): return { - '_format': 'application/json+fhir', + '_format': 'application/fhir+json', } @@ -105,11 +105,11 @@ class SearchViewCoverage(SearchView): def __init__(self, version=1): super().__init__(version) - self.resource_type = "Coverage" + self.resource_type = 'Coverage' def build_parameters(self, request, *args, **kwargs): return { - '_format': 'application/json+fhir', + '_format': 'application/fhir+json', 'beneficiary': 'Patient/' + request.crosswalk.fhir_id(self.version) } @@ -119,7 +119,7 @@ class SearchViewExplanationOfBenefit(SearchView): def validate_tag(self): def validator(value): for v in value: - if not (v in ["Adjudicated", "PartiallyAdjudicated"]): + if not (v in ['Adjudicated', 'PartiallyAdjudicated']): msg = f"Invalid _tag value (='{v}'), 'PartiallyAdjudicated' or 'Adjudicated' expected." raise Invalid(msg) return value @@ -129,23 +129,23 @@ def validator(value): required_scopes = ['patient/ExplanationOfBenefit.read', 'patient/ExplanationOfBenefit.rs', 'patient/ExplanationOfBenefit.s'] # Regex to match a valid type value - REGEX_TYPE_VALUE = r"(carrier)|" + \ - r"(pde)|" + \ - r"(dme)|" + \ - r"(hha)|" + \ - r"(hospice)|" + \ - r"(inpatient)|" + \ - r"(outpatient)|" + \ - r"(snf)|" + \ - r"(https://bluebutton.cms.gov/resources/codesystem/eob-type\|)|" + \ - r"(https://bluebutton.cms.gov/resources/codesystem/eob-type\|carrier)|" + \ - r"(https://bluebutton.cms.gov/resources/codesystem/eob-type\|pde)|" + \ - r"(https://bluebutton.cms.gov/resources/codesystem/eob-type\|dme)|" + \ - r"(https://bluebutton.cms.gov/resources/codesystem/eob-type\|hha)|" + \ - r"(https://bluebutton.cms.gov/resources/codesystem/eob-type\|hospice)|" + \ - r"(https://bluebutton.cms.gov/resources/codesystem/eob-type\|inpatient)|" + \ - r"(https://bluebutton.cms.gov/resources/codesystem/eob-type\|outpatient)|" + \ - r"(https://bluebutton.cms.gov/resources/codesystem/eob-type\|snf)" + REGEX_TYPE_VALUE = r'(carrier)|' + \ + r'(pde)|' + \ + r'(dme)|' + \ + r'(hha)|' + \ + r'(hospice)|' + \ + r'(inpatient)|' + \ + r'(outpatient)|' + \ + r'(snf)|' + \ + r'(https://bluebutton.cms.gov/resources/codesystem/eob-type\|)|' + \ + r'(https://bluebutton.cms.gov/resources/codesystem/eob-type\|carrier)|' + \ + r'(https://bluebutton.cms.gov/resources/codesystem/eob-type\|pde)|' + \ + r'(https://bluebutton.cms.gov/resources/codesystem/eob-type\|dme)|' + \ + r'(https://bluebutton.cms.gov/resources/codesystem/eob-type\|hha)|' + \ + r'(https://bluebutton.cms.gov/resources/codesystem/eob-type\|hospice)|' + \ + r'(https://bluebutton.cms.gov/resources/codesystem/eob-type\|inpatient)|' + \ + r'(https://bluebutton.cms.gov/resources/codesystem/eob-type\|outpatient)|' + \ + r'(https://bluebutton.cms.gov/resources/codesystem/eob-type\|snf)' # Regex to match a list of comma separated type values with IGNORECASE REGEX_TYPE_VALUES_LIST = r'(?i)^((' + REGEX_TYPE_VALUE + r')\s*,*\s*)+$' @@ -155,17 +155,17 @@ def validator(value): # Add type parameter to schema only for EOB QUERY_SCHEMA = {**SearchView.QUERY_SCHEMA, - 'type': Match(REGEX_TYPE_VALUES_LIST, msg="the type parameter value is not valid"), - 'service-date': [Match(REGEX_SERVICE_DATE_VALUE, msg="the service-date operator is not valid")], + 'type': Match(REGEX_TYPE_VALUES_LIST, msg='the type parameter value is not valid'), + 'service-date': [Match(REGEX_SERVICE_DATE_VALUE, msg='the service-date operator is not valid')], } def __init__(self, version=1): super().__init__(version) - self.resource_type = "ExplanationOfBenefit" + self.resource_type = 'ExplanationOfBenefit' def build_parameters(self, request, *args, **kwargs): return { - '_format': 'application/json+fhir', + '_format': 'application/fhir+json', 'patient': request.crosswalk.fhir_id(self.version), } @@ -178,7 +178,7 @@ def filter_parameters(self, request): if service_dates: params['service-date'] = service_dates - query_schema = getattr(self, "QUERY_SCHEMA", {}) + query_schema = getattr(self, 'QUERY_SCHEMA', {}) if waffle.switch_is_active('v3_endpoints'): query_schema['_tag'] = self.validate_tag() diff --git a/apps/fhir/bluebutton/views/viewsets_base.py b/apps/fhir/bluebutton/views/viewsets_base.py new file mode 100644 index 000000000..89a88e105 --- /dev/null +++ b/apps/fhir/bluebutton/views/viewsets_base.py @@ -0,0 +1,85 @@ +from rest_framework import viewsets, permissions +from rest_framework.response import Response +from voluptuous import Schema, REMOVE_EXTRA + +from apps.fhir.bluebutton.permissions import AlwaysDeny +from apps.fhir.bluebutton.views.generic import FhirDataView + + +class ResourceViewSet(FhirDataView, viewsets.ViewSet): + """Base FHIR resource ViewSet, would replace FhirDataView if we decide to go in that direction + + Args: + FhirDataView: Base mixin, unchanged + viewsets: django-rest-framework ViewSet base class + """ + + SEARCH_PERMISSION_CLASSES = (permissions.IsAuthenticated,) + READ_PERMISSION_CLASSES = (permissions.IsAuthenticated,) + + def __init__(self, version=1): + self.resource_type = None + super().__init__(version) + + def initial(self, request, *args, **kwargs): + return super().initial(request, self.resource_type, *args, **kwargs) + + def get_permissions(self): + action = getattr(self, 'action', None) + if action == 'search': + permission_classes = getattr(self, 'SEARCH_PERMISSION_CLASSES', self.SEARCH_PERMISSION_CLASSES) + elif action == 'read': + permission_classes = getattr(self, 'READ_PERMISSION_CLASSES', self.READ_PERMISSION_CLASSES) + else: + # If it's not a read or search call, make the permissions_classes list contain a single class, + # permissions.AlwaysDeny, as if it is not a search or read call, we should not proceed. + permission_classes = (AlwaysDeny,) + return [permission_class() for permission_class in permission_classes] + + def search(self, request, *args, **kwargs): + out = self.fetch_data(request, self.resource_type, *args, **kwargs) + return Response(out) + + def read(self, request, resource_id, *args, **kwargs): + out = self.fetch_data(request, self.resource_type, resource_id=resource_id, *args, **kwargs) + return Response(out) + + # A lot of this is copied (haphazardly) from generic, and the names and handling of the schema could be cleaned up + def get_query_schema(self) -> dict: + if getattr(self, 'action', None) == 'search': + return getattr(self, 'SEARCH_QUERY_SCHEMA', getattr(self, 'READ_QUERY_TRANSFORMS', {})) + return {} + + def get_query_transforms(self) -> dict: + if getattr(self, 'action', None) == 'search': + return getattr(self, 'SEARCH_QUERY_TRANSFORMS', getattr(self, 'READ_QUERY_TRANSFORMS', {})) + return {} + + def map_parameters(self, params) -> dict: + transforms = self.get_query_transforms() + for key, correct in transforms.items(): + val = params.pop(key, None) + if val is not None: + params[correct] = val + return params + + def filter_parameters(self, request) -> dict: + if getattr(self, 'action', None) != 'search': + return {} + + params = self.map_parameters(request.query_params.dict()) + params['_lastUpdated'] = request.query_params.getlist('_lastUpdated') + + schema = Schema(self.get_query_schema(), extra=REMOVE_EXTRA) + return schema(params) + + # TODO - investigate a better way to structure this + # TODO - seems like we could be adding parameters that don't need to be added + def build_parameters(self, request): + if getattr(self, 'action', None) != 'search': + return { + '_format': 'application/json+fhir', + } + return { + '_format': 'application/json+fhir', + } diff --git a/apps/fhir/server/authentication.py b/apps/fhir/server/authentication.py index 1ddf4d620..11d965f48 100644 --- a/apps/fhir/server/authentication.py +++ b/apps/fhir/server/authentication.py @@ -13,9 +13,9 @@ from apps.fhir.bluebutton.utils import (generate_info_headers, set_default_header) -from ..bluebutton.exceptions import UpstreamServerException -from ..bluebutton.utils import (FhirServerAuth, - get_resourcerouter) +from apps.fhir.bluebutton.exceptions import UpstreamServerException +from apps.fhir.bluebutton.utils import FhirServerAuth +from apps.fhir.server.settings import fhir_settings from .loggers import log_match_fhir_id @@ -48,7 +48,7 @@ def search_fhir_id_by_identifier(search_identifier, request=None, version=Versio UpstreamServerException: For backend response issues. """ # Get certs from FHIR server settings - auth_settings = FhirServerAuth(None) + auth_settings = FhirServerAuth() certs = (auth_settings['cert_file'], auth_settings['key_file']) # Add headers for FHIR backend logging, including auth_flow_dict if request: @@ -75,12 +75,11 @@ def search_fhir_id_by_identifier(search_identifier, request=None, version=Versio headers = None # Build URL based on BFD version - resource_router = get_resourcerouter() ver = f'v{version}' - fhir_url = resource_router.fhir_url - if ver == 'v3' and resource_router.fhir_url_v3: - fhir_url = resource_router.fhir_url_v3 + fhir_url = fhir_settings.fhir_url + if ver == 'v3' and fhir_settings.fhir_url_v3: + fhir_url = fhir_settings.fhir_url_v3 url = f"{fhir_url}/{ver}/fhir/Patient/_search" max_retries = 3 diff --git a/apps/fhir/server/connection.py b/apps/fhir/server/connection.py index 959be0b3f..1c541ec36 100644 --- a/apps/fhir/server/connection.py +++ b/apps/fhir/server/connection.py @@ -1,16 +1,9 @@ from apps.fhir.bluebutton.utils import ( - FhirServerAuth, generate_info_headers, set_default_header, ) -# return certs -def certs(crosswalk=None): - auth_state = FhirServerAuth(crosswalk) - return (auth_state.get('cert_file', None), auth_state.get('key_file', None)) - - def headers(request, url=None): header_info = generate_info_headers(request) diff --git a/apps/health/checks.py b/apps/health/checks.py index 572e927c2..b1216d8c5 100644 --- a/apps/health/checks.py +++ b/apps/health/checks.py @@ -5,8 +5,8 @@ from django.db import connection from waffle import switch_is_active -from apps.fhir.bluebutton.utils import get_resourcerouter -from apps.fhir.server import connection as backend_connection +from apps.fhir.server.settings import fhir_settings +from apps.fhir.bluebutton.utils import FhirServerAuth from apps.mymedicare_cb.authorization import OAuth2ConfigSLSx import apps.logging.request_logger as bb2logging @@ -29,11 +29,12 @@ def splunk_services(v2=False): def bfd_fhir_dataserver(v2=False): - resource_router = get_resourcerouter() - target_url = "{}{}".format(resource_router.fhir_url, "/v2/fhir/metadata" if v2 else "/v1/fhir/metadata") + fhir_server_auth = FhirServerAuth() + + target_url = "{}{}".format(fhir_settings.fhir_url, "/v2/fhir/metadata" if v2 else "/v1/fhir/metadata") r = requests.get(target_url, params={"_format": "json"}, - cert=backend_connection.certs(), + cert=(fhir_server_auth['cert_file'], fhir_server_auth['key_file']), verify=False, timeout=5) try: diff --git a/apps/test.py b/apps/test.py index 3a65a9db2..2ded79ad9 100644 --- a/apps/test.py +++ b/apps/test.py @@ -74,6 +74,12 @@ def _create_user( """ fhir_id_v2 = fhir_id_v2 or settings.DEFAULT_SAMPLE_FHIR_ID_V2 fhir_id_v3 = fhir_id_v3 or settings.DEFAULT_SAMPLE_FHIR_ID_V3 + + # Some of our tests might create users more than once in the DB. + # Just return them if they exist. + if User.objects.filter(username=username).exists(): + return User.objects.get(username=username) + user = User.objects.create_user(username, password=password, **extra_fields) self._create_crosswalk( user=user, @@ -490,6 +496,7 @@ def create_token( self, first_name, last_name, fhir_id_v2=None, fhir_id_v3=None, hicn_hash=None, mbi=None ): passwd = "123456" + user = self._create_user( first_name, passwd, @@ -499,7 +506,7 @@ def create_token( fhir_id_v3=fhir_id_v3, user_hicn_hash=hicn_hash if hicn_hash is not None else self.test_hicn_hash, user_mbi=mbi if mbi is not None else self.test_mbi, - email="%s@%s.net" % (first_name, last_name), + email="%s@%s.notanagency.gov" % (first_name, last_name), ) # create a oauth2 application and add capabilities diff --git a/apps/testclient/constants.py b/apps/testclient/constants.py index 2133c25ba..bd7f9fcc7 100644 --- a/apps/testclient/constants.py +++ b/apps/testclient/constants.py @@ -29,8 +29,11 @@ class EndpointUrl: patient = "patient" explanation_of_benefit = "eob" coverage = "coverage" + digital_insurance_card = "digital_insurance_card" nav = "nav" + # TODO - theses are all format=json, not format=application/fhir+json, is this good? + @staticmethod def fmt(name: str, uri: str, version: int, patient: str = BAD_PATIENT_ID): version_as_string = Versions.as_str(version) match name: @@ -40,11 +43,15 @@ def fmt(name: str, uri: str, version: int, patient: str = BAD_PATIENT_ID): if patient is None or patient == BAD_PATIENT_ID: logger.error('EndpointUrl format called with invalid patient id') raise EndpointFormatException('EndpointUrl format called with invalid patient id') - return f'{uri}/{version_as_string}/fhir/Patient/{patient}?_format=json' + return f'{uri}/{version_as_string}/fhir/Patient/{patient}?_format=application/fhir+json' case EndpointUrl.explanation_of_benefit: - return f'{uri}/{version_as_string}/fhir/ExplanationOfBenefit/?_format=json' + return f'{uri}/{version_as_string}/fhir/ExplanationOfBenefit/?_format=application/fhir+json' case EndpointUrl.coverage: - return f'{uri}/{version_as_string}/fhir/Coverage/?_format=json' + return f'{uri}/{version_as_string}/fhir/Coverage/?_format=application/fhir+json' + case EndpointUrl.digital_insurance_card: + return f'{uri}/{version_as_string}/fhir/DigitalInsuranceCard/?_format=application/fhir+json' + # return f"{uri}/{version_as_string}/fhir/Patient/{patient}/$generate-insurance-card" + # ?_format=application/fhir+json case _: logger.error(f'Could not match name in EndpointUrl: {name}') @@ -52,6 +59,7 @@ def fmt(name: str, uri: str, version: int, patient: str = BAD_PATIENT_ID): # never occur, and therefore we want something to break. raise EndpointFormatException(f'Could not format URI name[{name}] uri[{uri}] version[{version_as_string}]') + @staticmethod def nav_uri(uri, count, start_index, id_type=None, id=None): return f'{uri}&_count={count}&startIndex={start_index}&{id_type}={id}' diff --git a/apps/testclient/templates/home.html b/apps/testclient/templates/home.html index 92fb915f7..c0f4584da 100644 --- a/apps/testclient/templates/home.html +++ b/apps/testclient/templates/home.html @@ -72,6 +72,7 @@

Step 1: Sample Authorization

{% url 'test_eob_v3' as test_eob_url %} {% url 'test_patient_v3' as test_patient_url %} {% url 'test_coverage_v3' as test_coverage_url %} + {% url 'test_digital_insurance_card_v3' as test_digital_insurance_card_url %} {% endswitch %} {% else %} {% url 'authorize_link_v1' as auth_url %} @@ -100,6 +101,9 @@

Step 2: API Calls

  • Patient
  • Coverage
  • Profile (OIDC Userinfo)
  • + {% if api_ver == 3 %} +
  • Digital Insurance Card
  • + {% endif %}
  • FHIR Metadata (No Token Needed)
  • OIDC Discovery (No Token Needed)
  • diff --git a/apps/testclient/tests.py b/apps/testclient/tests.py index 9772557d2..278b8ad68 100644 --- a/apps/testclient/tests.py +++ b/apps/testclient/tests.py @@ -10,6 +10,7 @@ from apps.testclient.constants import EndpointUrl from apps.testclient.views import FhirDataParams, _build_pagination_uri from django.http import HttpRequest +from waffle.testutils import override_switch import os @@ -129,15 +130,21 @@ def _test_get_userinfo(self, version=Versions.NOT_AN_API_VERSION): response = self.client.get(url) self.assertEqual(response.status_code, 200) jr = response.json() - self.assertEqual(jr["patient"], self.patient) + print() + print(jr) + if version in [Versions.V1, Versions.V2]: + # FIXME: Is it true that V3 UserInfo does not have a patient ID + # that comes back under the key 'patient'? + self.assertEqual(jr["patient"], self.patient) self.assertEqual(jr["sub"], self.username) + self.assertFalse(True) def test_get_userinfo_v2(self): self._test_get_userinfo(Versions.V2) - # TODO BB-4208: Introduce v3 tests when ready - # def test_get_userinfo_v3(self): - # self._test_get_userinfo(Versions.V3) + @override_switch('v3_endpoints', active=True) + def test_get_userinfo_v3(self): + self._test_get_userinfo(Versions.V3) @skipIf((not settings.RUN_ONLINE_TESTS), "Can't reach external sites.") @@ -370,6 +377,7 @@ def _test_get_coverage(self, version=Versions.NOT_AN_API_VERSION): self.testclient_setup["coverage_uri"], self.patient, ) + response = self.client.get(uri) self.assertEqual(response.status_code, 200) self.assertContains(response, "Coverage") @@ -378,6 +386,10 @@ def _test_get_coverage(self, version=Versions.NOT_AN_API_VERSION): def test_get_coverage_v2(self): self._test_get_coverage(Versions.V2) + @override_switch('v3_endpoints', active=True) + def test_get_coverage_v3(self): + self._test_get_coverage(Versions.V3) + # TODO BB-4208: Introduce v3 tests when ready # def test_get_coverage_v3(self): # self._test_get_coverage(Versions.V3) @@ -391,6 +403,7 @@ def _test_get_coverage_negative(self, version=Versions.NOT_AN_API_VERSION): self.testclient_setup["coverage_uri"], self.another_patient, ) + response = self.client.get(uri) self.assertEqual(response.status_code, 403) @@ -401,6 +414,22 @@ def test_get_coverage_negative_v2(self): # def test_get_coverage_negative_v3(self): # self._test_get_coverage_negative(Versions.V3) + @override_switch('v3_endpoints', active=True) + def test_get_digital_insurance_card(self): + """ + Test DigitalInsuranceCard for CARIN C4DIC data from BFD + """ + self.versionedSetUp(Versions.V3) + uri = "%s" % ( + self.testclient_setup["digital_insurance_card_uri"], + ) + print() + print(uri) + response = self.client.get(uri) + print(response.__dict__) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Bundle") + @skipIf((not settings.RUN_ONLINE_TESTS), "Can't reach external sites.") class BlueButtonClientApiFhirMetadataDiscoveryTest(TestCase): diff --git a/apps/testclient/urls.py b/apps/testclient/urls.py index e88cecd57..115130846 100644 --- a/apps/testclient/urls.py +++ b/apps/testclient/urls.py @@ -33,6 +33,9 @@ test_patient_v3, test_userinfo_v3, + # c4dic + test_digital_insurance_card_v3 + ) urlpatterns_unversioned = [ @@ -69,6 +72,7 @@ path('openidConfigV3', test_openid_config_v3, name='test_openid_config_v3'), path('PatientV3', test_patient_v3, name='test_patient_v3'), path('userinfoV3', test_userinfo_v3, name='test_userinfo_v3'), + path('DigitalInsuranceCard', test_digital_insurance_card_v3, name='test_digital_insurance_card_v3'), ] urlpatterns = urlpatterns_unversioned + urlpatterns_v1 + urlpatterns_v2 + urlpatterns_v3 diff --git a/apps/testclient/utils.py b/apps/testclient/utils.py index 7f644cd28..2db158195 100644 --- a/apps/testclient/utils.py +++ b/apps/testclient/utils.py @@ -78,6 +78,7 @@ def testclient_http_response_setup(include_client_secret: bool = True, version: response['patient_uri'] = f'{host}/v{version}/fhir/Patient/' response['eob_uri'] = f'{host}/v{version}/fhir/ExplanationOfBenefit/' response['coverage_uri'] = f'{host}/v{version}/fhir/Coverage/' + response['digital_insurance_card_uri'] = f'{host}/v{version}/fhir/DigitalInsuranceCard/' return response diff --git a/apps/testclient/views.py b/apps/testclient/views.py index 36b415c2d..9067368b2 100644 --- a/apps/testclient/views.py +++ b/apps/testclient/views.py @@ -87,8 +87,8 @@ def _get_fhir_data_as_json(request: HttpRequest, params: FhirDataParams) -> Dict if params.version in [Versions.V1, Versions.V2] and request.GET.get('nav_link', None): uri = _build_pagination_uri(uri, params, request) - oas = _get_oauth2_session_with_token(request) - r = oas.get(uri) + oath_session = _get_oauth2_session_with_token(request) + r = oath_session.get(uri) try: result_json = r.json() @@ -539,6 +539,20 @@ def _test_userinfo(request: HttpRequest, version=Versions.NOT_AN_API_VERSION): }) +def _test_digital_insurance_card(request: HttpRequest, version=Versions.NOT_AN_API_VERSION): + if _link_session_or_version_is_bad(request.session, version): + return _link_session_or_version_is_bad(request.session, version) + + c4dic_info = _get_fhir_data_as_json(request, FhirDataParams( + EndpointUrl.digital_insurance_card, request.session['resource_uri'], version, request.session['patient'])) + + return render(request, RESULTS_PAGE, + {'fhir_json_pretty': json.dumps(c4dic_info, indent=3), + 'response_type': 'Bundle', + 'api_ver': version + }) + + ############################################################ # VERSION 1 ############################################################ @@ -739,3 +753,9 @@ def test_patient_v3(request: HttpRequest): @waffle_switch('enable_testclient') def test_userinfo_v3(request: HttpRequest): return _test_userinfo(request, version=Versions.V3) + + +@never_cache +@waffle_switch('enable_testclient') +def test_digital_insurance_card_v3(request: HttpRequest): + return _test_digital_insurance_card(request, version=Versions.V3) diff --git a/apps/versions.py b/apps/versions.py index 2920d452f..4bbb8c435 100644 --- a/apps/versions.py +++ b/apps/versions.py @@ -2,7 +2,6 @@ # we should use this class as opposed to interned strings. # e.g. A use of 'v1' should become Versions.V1. - class VersionNotMatched(Exception): """ A custom exception to be thrown when we do not match a version. @@ -23,9 +22,11 @@ class Versions: # For now, we are defaulting to v2. NOT_AN_API_VERSION = 0 + @staticmethod def as_str(version: int): return f'v{version}' + @staticmethod def as_int(version: int) -> int: match version: case Versions.V1: @@ -37,9 +38,11 @@ def as_int(version: int) -> int: case _: raise VersionNotMatched(f"{version} is not a valid version constant") + @staticmethod def supported_versions(): return [Versions.V1, Versions.V2, Versions.V3] + @staticmethod def latest_versions(): return [Versions.V2, Versions.V3] diff --git a/dev-local/.env.container b/dev-local/.env.container index a6f26fbad..203d2cea0 100644 --- a/dev-local/.env.container +++ b/dev-local/.env.container @@ -51,4 +51,5 @@ RUNNING_IN_LOCAL_STACK="${RUNNING_IN_LOCAL_STACK}" SUPER_USER_EMAIL="${SUPER_USER_EMAIL}" SUPER_USER_NAME="${SUPER_USER_NAME}" SUPER_USER_PASSWORD="${SUPER_USER_PASSWORD}" -LOCAL_TESTING_TARGET="${LOCAL_TESTING_TARGET}" \ No newline at end of file +LOCAL_TESTING_TARGET="${LOCAL_TESTING_TARGET}" +TEST="${TEST}" \ No newline at end of file diff --git a/dev-local/Makefile b/dev-local/Makefile index d3984e6cd..01ae1c9ea 100644 --- a/dev-local/Makefile +++ b/dev-local/Makefile @@ -8,17 +8,14 @@ build-local: --platform "linux/amd64" \ -t bb-local:latest \ -f Dockerfile.local .. -# TODO: Is this necessary in a local build? Probably not. -# @echo "building selenium ecr image" -# cd ../dev-local ; docker build \ -# --platform "linux/amd64" \ -# -t selenium-ecr:latest \ -# -f Dockerfile.selenium-ecr .. cd ../dev-local ; docker build \ --platform "linux/amd64" \ -t selenium-local:latest \ -f Dockerfile.selenium-local .. + run-local: @echo "Configuring for ${ENV}" ; \ ./run-appropriate-stack.bash - \ No newline at end of file + +exec-web: + docker exec -it `docker ps -aqf "name=.*web.*"` /bin/bash diff --git a/dev-local/start-local.sh b/dev-local/start-local.sh index f8b7cc1c7..ffec3f58e 100755 --- a/dev-local/start-local.sh +++ b/dev-local/start-local.sh @@ -44,36 +44,26 @@ else echo "restarting blue button server, no db image migration and models initialization will run here, you might need to manually run DB image migrations." fi -if [ "${BB20_ENABLE_REMOTE_DEBUG}" = true ] +if [ "${TEST}" ]; then - if [ "${BB20_REMOTE_DEBUG_WAIT_ATTACH}" = true ] + echo "🐛⏯️ Start bluebutton server with remote debugging and wait attach..." + python3 -m debugpy --listen 0.0.0.0:5678 --wait-for-client runtests.py ${TEST} + exit +fi + +if [ "${BB20_ENABLE_REMOTE_DEBUG}" = true ]; +then + if [ "${BB20_REMOTE_DEBUG_WAIT_ATTACH}" = true ]; then - if [ "${BB2_SERVER_STD2FILE}" = "YES" ] - then - echo "Start bluebutton server with remote debugging and wait attach..., std redirect to file: ${BB2_SERVER_STD2FILE}" - python3 -m debugpy --listen 0.0.0.0:5678 --wait-for-client manage.py runserver 0.0.0.0:8000 > ./docker-compose/tmp/bb2_email_to_stdout.log 2>&1 - else - echo "Start bluebutton server with remote debugging and wait attach..." - # NOTE: The "--noreload" option can be added below to disable if needed - python3 -m debugpy --listen 0.0.0.0:5678 --wait-for-client manage.py runserver 0.0.0.0:8000 - fi + echo "🐛⏯️ Start bluebutton server with remote debugging and wait attach..." + python3 -m debugpy --listen 0.0.0.0:5678 --wait-for-client manage.py runserver 0.0.0.0:8000 else - if [ "${BB2_SERVER_STD2FILE}" = "YES" ] - then - echo "Start bluebutton server with remote debugging..., std redirect to file: ${BB2_SERVER_STD2FILE}" - python3 -m debugpy --listen 0.0.0.0:5678 manage.py runserver 0.0.0.0:8000 > ./docker-compose/tmp/bb2_email_to_stdout.log 2>&1 - else - echo "Start bluebutton server with remote debugging..." - python3 -m debugpy --listen 0.0.0.0:5678 manage.py runserver 0.0.0.0:8000 - fi + echo "🐛 Start bluebutton server with debugging, no waiting" + # NOTE: The "--noreload" option can be added below to disable if needed + python3 -m debugpy --listen 0.0.0.0:5678 manage.py runserver 0.0.0.0:8000 ${TEST} fi else - if [ "${BB2_SERVER_STD2FILE}" = "YES" ] - then - echo "Start bluebutton server ..., std redirect to file: ${BB2_SERVER_STD2FILE}" - python3 manage.py runserver 0.0.0.0:8000 > ./docker-compose/tmp/bb2_email_to_stdout.log 2>&1 - else - echo "Start bluebutton server ..." + echo "🔵 Start bluebutton server" + # NOTE: The "--noreload" option can be added below to disable if needed python3 manage.py runserver 0.0.0.0:8000 - fi -fi +fi \ No newline at end of file