Skip to content

Commit 744a8f5

Browse files
bwang-icfJamesDemeryNavajadudmclewellyn-nava
authored
Adds the pathways for v3 handling around fhir_ids (BB-4166) (#1419)
* initial commit * adding version specific permission checking so v3 resources are recognized as v3 * TODO in authorization/views.py. Need to write unit tests for new utils function still * Address TODOs in bb2_tools/admin.py * found request.path to contain version. Different from other places but appears to work. Will be open to refactoring * Ensure fhir_id v2 or v3 are updated if none when re-authorizing. v3 testclient patient/EOB/coverage are now broken, some tests are broken as well. Will fix in morning * adding extra validation for incorrect versions in utils and using it in create_token_response * V2 calls all working, v3 patient returning correct data but built in function throwing 404 * adding more generalized version grabbing for crosswalk permissions * Revert log to reduce test failures (will revisit this log once all functionality is working) * Ensure v3 coverage and EOD endpoints work. Currently 7 test failures and 11 errors * changes to get_patient_id, test_read_and_search and other tests to extend more off version * Modify some tests, pass a version to generate_info_headers. Tests are failing and we have some TODOs, but v3 and v2 endpoints all work * added notes and some changes to mymedicare views * Interim collab commit * Remove completed TODOs * Remove more completed TODOs * Remove more TODOs that had been completed * Fix audit_logger tests, WIP on authentication * Fixed mocked tests * Removing print statements * Add version param to create_fhir_mock to help tests succeed * Revert unneeded test changes, ensure still passes * Fix last failing test in test_authentication.py * Add unit test, remove completed TODOs * WIP On fixing tests * Interim * Fixed callback tests * fix for test_callback_allow_slsx_changes_to_hicn_and_mbi * Revert responses.py changes * Fixed import * Renaming. * Rename function, modify return type per PR feedback * Modify imports to reflect new file name * Modify default param and update to absolute paths * Remove unneeded TODO * Modify a function name and call to more accurately reflect what it does * revert to have v2 as default to fhir_id, as it broke tests, and we only do not pass a value for a log statement * Check on blank string in get_and_update_user to accommodate changes from BB2-4224 * Single quotes and comment context * Refactor get_and_update_user to reduce BFD calls, and to ensure fhir_ids are always updated if DB values differ from what BFD returns * Updates based on PR self-review with Brandon and Matt * adding latest versions and changes to the offset math test * added defaults if there is no fhir_id found for a version * addressing comments * changing check to empty string over None but will change back to None some day * Address TODO that arose from a change made in BB2-4233 * use self.version instead --------- Co-authored-by: James Demery <[email protected]> Co-authored-by: Matt Jadud <[email protected]> Co-authored-by: Connor Lewellyn <[email protected]>
1 parent d8b898d commit 744a8f5

36 files changed

+500
-334
lines changed

apps/accounts/views/oauth2_profile.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from apps.fhir.bluebutton.models import Crosswalk
1010
from apps.fhir.bluebutton.permissions import ApplicationActivePermission
1111

12-
from apps.constants import Versions
12+
from apps.versions import Versions
1313

1414

1515
def _get_userinfo(user, version=Versions.NOT_AN_API_VERSION):
@@ -46,8 +46,6 @@ def _get_userinfo(user, version=Versions.NOT_AN_API_VERSION):
4646
def _openidconnect_userinfo(request, version=Versions.NOT_AN_API_VERSION):
4747
# NOTE: The **kwargs are not used anywhere down the callchain, and are being ignored.
4848

49-
# BB2-4166-TODO: will the request have a version? do we get here from redirects or is this
50-
# a straight url that we need to get the version from the url (like we do in the fhir app)
5149
return JsonResponse(_get_userinfo(request.resource_owner, version))
5250

5351

apps/authorization/permissions.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from django.conf import settings
22
from rest_framework import (permissions, exceptions)
3+
from apps.versions import Versions, VersionNotMatched
34

45
from .models import DataAccessGrant
56

@@ -32,8 +33,10 @@ def has_object_permission(self, request, view, obj):
3233
# Patient resources were taken care of above
3334
# Return 404 on error to avoid notifying unauthorized user the object exists
3435

35-
# BB2-4166-TODO: this is hardcoded to be version 2
36-
return is_resource_for_patient(obj, request.crosswalk.fhir_id(2))
36+
if view.version in Versions.supported_versions():
37+
return is_resource_for_patient(obj, request.crosswalk.fhir_id(view.version))
38+
else:
39+
raise VersionNotMatched()
3740

3841

3942
def is_resource_for_patient(obj, patient_id):

apps/authorization/views.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@
1414
from oauth2_provider.views.base import OAuthLibMixin
1515
from oauth2_provider.views.generic import ClientProtectedResourceView
1616

17+
from apps.versions import VersionNotMatched, Versions
1718
from apps.dot_ext.authentication import SLSAuthentication
18-
from .models import DataAccessGrant
19-
from ..dot_ext.utils import get_application_from_meta
20-
from ..fhir.bluebutton.models import Crosswalk
19+
from apps.authorization.models import DataAccessGrant
20+
from apps.dot_ext.utils import get_application_from_meta, get_api_version_number_from_url
21+
from apps.fhir.bluebutton.models import Crosswalk
2122

2223

2324
Application = get_application_model()
@@ -68,9 +69,21 @@ class ExpireDataAccessGrantView(ClientProtectedResourceView, OAuthLibMixin):
6869
@staticmethod
6970
def post(request, *args, **kwargs):
7071
try:
72+
path_info = request.__dict__.get('path_info')
73+
version = get_api_version_number_from_url(path_info)
7174
patient_id = kwargs.pop('patient_id', None)
72-
# BB2-4166-TODO: currently hardcoded for v2, might need to not be static
73-
user = Crosswalk.objects.get(fhir_id_v2=patient_id).user
75+
76+
# V1 is treated as the same as V2 since their pathways are very similar and use the same fhir_id_v2 despite the name
77+
match version:
78+
case Versions.V1:
79+
user = Crosswalk.objects.get(fhir_id_v2=patient_id).user
80+
case Versions.V2:
81+
user = Crosswalk.objects.get(fhir_id_v2=patient_id).user
82+
case Versions.V3:
83+
user = Crosswalk.objects.get(fhir_id_v3=patient_id).user
84+
case _:
85+
raise VersionNotMatched(f"{version} is not a valid version constant")
86+
7487
client = get_application_from_meta(request)
7588
DataAccessGrant.objects.get(beneficiary=user.id, application=client).delete()
7689
except Crosswalk.DoesNotExist:

apps/bb2_tools/admin.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
DummyAdminObject,
2222
UserStats,
2323
)
24-
from apps.fhir.bluebutton.utils import get_patient_by_id
24+
from apps.fhir.bluebutton.utils import get_v2_patient_by_id
2525

2626
ADMIN_PREPEND = getattr(settings, "ADMIN_PREPEND_URL", "")
2727
BB2_TOOLS_PATH = (
@@ -332,8 +332,7 @@ class BeneficiaryDashboardAdmin(ReadOnlyAdmin):
332332
"get_connected_applications",
333333
"date_created",
334334
)
335-
# BB2-4166-TODO: add support for v3
336-
search_fields = ('user__username', 'fhir_id_v2', '_user_id_hash', '_user_mbi')
335+
search_fields = ('user__username', 'fhir_id_v2', 'fhir_id_v3', '_user_id_hash', '_user_mbi')
337336
readonly_fields = ('date_created',)
338337
raw_id_fields = ('user',)
339338

@@ -361,10 +360,9 @@ def get_access_tokens(self, obj):
361360
ordering="MyIdentities",
362361
)
363362
def get_identities(self, obj):
364-
# BB2-4166-TODO: add support for v3
365363
return format_html(
366-
'<div><ul><li>FHIR_ID_V2:{}</li><li>HICN HASH:{}</li><li>MBI:{}</li>'.format(
367-
obj.fhir_id(2), obj.user_hicn_hash, obj.user_mbi
364+
'<div><ul><li>FHIR_ID_V2:{}</li><li>FHIR_ID_V3:{}</li><li>HICN HASH:{}</li><li>MBI:{}</li>'.format(
365+
obj.fhir_id(2), obj.fhir_id(3), obj.user_hicn_hash, obj.user_mbi
368366
)
369367
)
370368

@@ -416,10 +414,9 @@ def change_view(self, request, object_id, form_url="", extra_context=None):
416414
crosswalk = BeneficiaryDashboard.objects.get(pk=int(object_id))
417415

418416
json_resp = None
419-
420417
try:
421-
# BB2-4166-TODO: this is hardcoded to be version 2
422-
json_resp = get_patient_by_id(crosswalk.fhir_id(2), request)
418+
# DEPRECATE_V2: If we ever deprecate v2, this function and call need to be updated
419+
json_resp = get_v2_patient_by_id(crosswalk.fhir_id(2), request)
423420
except Exception as e:
424421
json_resp = {"backend_error": str(e)}
425422

apps/dot_ext/oauth2_backends.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from ..fhir.bluebutton.models import Crosswalk
55
from .loggers import (clear_session_auth_flow_trace, update_session_auth_flow_trace_from_code,
66
set_session_auth_flow_trace_value)
7+
from apps.dot_ext.utils import get_api_version_number_from_url
78

89

910
class OAuthLibSMARTonFHIR(OAuthLibCore):
@@ -31,8 +32,8 @@ def create_token_response(self, request):
3132
if Crosswalk.objects.filter(user=token.user).exists():
3233
fhir_body = json.loads(body)
3334
cw = Crosswalk.objects.get(user=token.user)
34-
# BB2-4166-TODO: this is hardcoded to be version 2
35-
fhir_body["patient"] = cw.fhir_id(2)
35+
version = get_api_version_number_from_url(request.path)
36+
fhir_body['patient'] = cw.fhir_id(version)
3637
body = json.dumps(fhir_body)
3738

3839
return uri, headers, body, status

apps/dot_ext/tests/test_api_error_codes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from apps.authorization.models import (
77
DataAccessGrant,
88
)
9-
from apps.constants import AccessType
9+
from apps.versions import AccessType
1010
from datetime import datetime
1111
from dateutil.relativedelta import relativedelta
1212
from unittest import mock

apps/dot_ext/tests/test_utils.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from django.test import TestCase
2+
3+
from apps.versions import VersionNotMatched
4+
from ..utils import get_api_version_number_from_url
5+
6+
SUPPORTED_VERSION_TEST_CASES = [
7+
{'url_path': '/v2/fhir/Patient/', 'expected': 2},
8+
# return 0 because v2 does not have a leading /
9+
{'url_path': 'v2/fhir/Patient/', 'expected': 0},
10+
{'url_path': '/v3/fhir/Coverage/', 'expected': 3},
11+
{'url_path': '/v3/fhir/Coverage/v2/', 'expected': 3},
12+
]
13+
14+
15+
class TestDOTUtils(TestCase):
16+
def test_get_api_version_number(self):
17+
for test in SUPPORTED_VERSION_TEST_CASES:
18+
result = get_api_version_number_from_url(test['url_path'])
19+
assert result == test['expected']
20+
21+
def test_get_api_version_number_unsupported_version(self):
22+
# unsupported version will raise an exception
23+
with self.assertRaises(VersionNotMatched, msg='4 extracted from /v4/fhir/Coverage/'):
24+
get_api_version_number_from_url('/v4/fhir/Coverage/')

apps/dot_ext/utils.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from oauth2_provider.models import AccessToken, RefreshToken, get_application_model
88
from oauthlib.oauth2.rfc6749.errors import InvalidClientError, InvalidGrantError, InvalidRequestError
99
from http import HTTPStatus
10+
import re
11+
from apps.versions import Versions, VersionNotMatched
1012

1113
from apps.authorization.models import DataAccessGrant
1214

@@ -252,3 +254,26 @@ def json_response_from_oauth2_error(error):
252254
ret_data['error_description'] = error.description
253255

254256
return JsonResponse(ret_data, status=error.status_code)
257+
258+
259+
def get_api_version_number_from_url(url_path: str) -> int:
260+
"""Utility function to extract what version of the API a URL is
261+
If there are multiple occurrences of 'v{{VERSION}} in a url path,
262+
only return the first one
263+
EX. /v2/o/authorize will return v2.
264+
265+
Args:
266+
url_path (str): The url being called that we want to extract the api version
267+
268+
Returns:
269+
Optional[str]: Returns a string of v2
270+
"""
271+
match = re.search(r'/v(\d+)/', url_path, re.IGNORECASE)
272+
if match:
273+
version = int(match.group(1))
274+
if version in Versions.supported_versions():
275+
return version
276+
else:
277+
raise VersionNotMatched(f'{version} extracted from {url_path}')
278+
279+
return Versions.NOT_AN_API_VERSION

apps/fhir/bluebutton/models.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from django.core.validators import MinLengthValidator
1313
from apps.accounts.models import get_user_id_salt
1414

15+
from apps.versions import Versions, VersionNotMatched
16+
1517

1618
class BBFhirBluebuttonModelException(APIException):
1719
# BB2-237 custom exception
@@ -164,29 +166,25 @@ class Meta:
164166
)
165167
]
166168

167-
def fhir_id(self, version: int = 2) -> str:
169+
def fhir_id(self, version: int = Versions.V2) -> str:
168170
"""Helper method to return fhir_id based on BFD version, preferred over direct access"""
169-
if version in (1, 2):
171+
if version in [Versions.V1, Versions.V2]:
170172
if self.fhir_id_v2 is not None and self.fhir_id_v2 != '':
171173
# TODO - This is legacy code, to be removed before migration bluebutton 0010
172174
# If fhir_id is empty, try to populate it from fhir_id_v2 to support old code
173175
self._fhir_id = self.fhir_id_v2
174176
self.save()
175-
return str(self.fhir_id_v2)
177+
return self.fhir_id_v2
176178
# TODO - This is legacy code, to be removed before migration bluebutton 0010
177179
# If fhir_id_v2 is empty, try to populate it from _fhir_id to support new code
178180
if self._fhir_id is not None and self._fhir_id != '':
179181
self.fhir_id_v2 = self._fhir_id
180182
self.save()
181-
return str(self._fhir_id)
183+
return self._fhir_id
182184
return ''
183-
elif version == 3:
184-
# TODO BB2-4166: This will want to change. In order to make
185-
# BB2-4181 work, the V3 value needed to be found in the V2 column.
186-
# 4166 should flip this to _v3, and we should be able to find
187-
# values there when using (say) the test client.
188-
if self.fhir_id_v2 is not None and self.fhir_id_v2 != '':
189-
return str(self.fhir_id_v2)
185+
elif version == Versions.V3:
186+
if self.fhir_id_v3 is not None and self.fhir_id_v3 != '':
187+
return self.fhir_id_v3
190188
return ''
191189
else:
192190
raise ValidationError(f'{version} is not a valid BFD version')
@@ -203,7 +201,7 @@ def set_fhir_id(self, value, version: int = 2) -> None:
203201
elif version == 3:
204202
self.fhir_id_v3 = value
205203
else:
206-
raise ValidationError(f'{version} is not a valid BFD version')
204+
raise VersionNotMatched(f'{version} is not a valid BFD version')
207205

208206
@property
209207
def user_hicn_hash(self):

apps/fhir/bluebutton/permissions.py

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from rest_framework import permissions, exceptions
66
from rest_framework.exceptions import AuthenticationFailed
77
from .constants import ALLOWED_RESOURCE_TYPES
8+
from apps.versions import Versions, VersionNotMatched
89

910
import apps.logging.request_logger as bb2logging
1011

@@ -30,35 +31,36 @@ def has_permission(self, request, view):
3031

3132
class HasCrosswalk(permissions.BasePermission):
3233
def has_permission(self, request, view):
33-
return bool(
34-
# BB2-4166-TODO : this needs to use version to determine fhir_id, probably in request
35-
request.user and request.user.crosswalk and request.user.crosswalk.fhir_id(2)
36-
)
34+
if view.version in Versions.supported_versions():
35+
return request.user and request.user.crosswalk and request.user.crosswalk.fhir_id(view.version)
36+
else:
37+
# this should not happen where we'd get an unsupported version
38+
raise VersionNotMatched("Version not matched in has_permission")
3739

3840

3941
class ReadCrosswalkPermission(HasCrosswalk):
4042
def has_object_permission(self, request, view, obj):
4143
# Now check that the user has permission to access the data
4244
# Patient resources were taken care of above # TODO - verify this
4345
# Return 404 on error to avoid notifying unauthorized user the object exists
44-
46+
if view.version in Versions.supported_versions():
47+
fhir_id = request.crosswalk.fhir_id(view.version)
48+
else:
49+
raise VersionNotMatched("Version not matched in has_object_permission in ReadCrosswalkPermission")
4550
try:
4651
if request.resource_type == "Coverage":
4752
reference = obj["beneficiary"]["reference"]
4853
reference_id = reference.split("/")[1]
49-
# BB2-4166-TODO : this needs to use version to determine fhir_id, probably in request
50-
if reference_id != request.crosswalk.fhir_id(2):
54+
if reference_id != fhir_id:
5155
raise exceptions.NotFound()
5256
elif request.resource_type == "ExplanationOfBenefit":
5357
reference = obj["patient"]["reference"]
5458
reference_id = reference.split("/")[1]
55-
# BB2-4166-TODO : this needs to use version to determine fhir_id, probably in request
56-
if reference_id != request.crosswalk.fhir_id(2):
59+
if reference_id != fhir_id:
5760
raise exceptions.NotFound()
5861
else:
5962
reference_id = obj["id"]
60-
# BB2-4166-TODO : this needs to use version to determine fhir_id, probably in request
61-
if reference_id != request.crosswalk.fhir_id(2):
63+
if reference_id != fhir_id:
6264
raise exceptions.NotFound()
6365

6466
except exceptions.NotFound:
@@ -71,9 +73,10 @@ def has_object_permission(self, request, view, obj):
7173

7274
class SearchCrosswalkPermission(HasCrosswalk):
7375
def has_object_permission(self, request, view, obj):
74-
# BB2-4166-TODO: this is hardcoded to be version 2
75-
patient_id = request.crosswalk.fhir_id(2)
76-
76+
if view.version in Versions.supported_versions():
77+
patient_id = request.crosswalk.fhir_id(view.version)
78+
else:
79+
raise VersionNotMatched("Version not matched in has_object_permission in SearchCrosswalkPermission")
7780
if "patient" in request.GET and request.GET["patient"] != patient_id:
7881
return False
7982

0 commit comments

Comments
 (0)