From 8d109096f58064bdee6d567d0d92ea8c0976e7f1 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 18 Oct 2024 12:50:26 -0700 Subject: [PATCH] refactor: simplify REST API handling of usage keys --- .../tests/test_content_libraries.py | 4 -- openedx/core/djangoapps/xblock/api.py | 5 +- .../xblock/rest_api/url_converters.py | 23 ++++++++ .../core/djangoapps/xblock/rest_api/urls.py | 16 +++--- .../core/djangoapps/xblock/rest_api/views.py | 53 +++++-------------- 5 files changed, 48 insertions(+), 53 deletions(-) create mode 100644 openedx/core/djangoapps/xblock/rest_api/url_converters.py diff --git a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py index 03b32e08ad36..04cf96abbf36 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -1101,10 +1101,6 @@ def test_invalid_key(self, endpoint, endpoint_parameters): endpoint.format(**endpoint_parameters), ) self.assertEqual(response.status_code, 404) - msg = f"XBlock {endpoint_parameters.get('block_key')} does not exist, or you don't have permission to view it." - self.assertEqual(response.json(), { - 'detail': msg, - }) def test_xblock_handler_invalid_key(self): """This endpoint is tested separately from the previous ones as it's not a DRF endpoint.""" diff --git a/openedx/core/djangoapps/xblock/api.py b/openedx/core/djangoapps/xblock/api.py index 919b4b63ec4b..773bf81edb66 100644 --- a/openedx/core/djangoapps/xblock/api.py +++ b/openedx/core/djangoapps/xblock/api.py @@ -279,7 +279,6 @@ def get_handler_url( This view does not check/care if the XBlock actually exists. """ - usage_key_str = str(usage_key) site_root_url = get_xblock_app_config().get_site_root_url() if not user: raise TypeError("Cannot get handler URLs without specifying a specific user ID.") @@ -291,10 +290,10 @@ def get_handler_url( raise ValueError("Invalid user value") # Now generate a token-secured URL for this handler, specific to this user # and this XBlock: - secure_token = get_secure_token_for_xblock_handler(user_id, usage_key_str) + secure_token = get_secure_token_for_xblock_handler(user_id, str(usage_key)) # Now generate the URL to that handler: kwargs = { - 'usage_key_str': usage_key_str, + 'usage_key': usage_key, 'user_id': user_id, 'secure_token': secure_token, 'handler_name': handler_name, diff --git a/openedx/core/djangoapps/xblock/rest_api/url_converters.py b/openedx/core/djangoapps/xblock/rest_api/url_converters.py new file mode 100644 index 000000000000..4b660fdc0bf4 --- /dev/null +++ b/openedx/core/djangoapps/xblock/rest_api/url_converters.py @@ -0,0 +1,23 @@ +""" +URL pattern converters +https://docs.djangoproject.com/en/5.1/topics/http/urls/#registering-custom-path-converters +""" +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import UsageKeyV2 + + +class UsageKeyV2Converter: + """ + Converter that matches V2 usage keys like: + lb:Org:LIB:drag-and-drop-v2:91c2b1d5 + """ + regex = r'[\w-]+(:[\w\-.]+)+' + + def to_python(self, value): + try: + return UsageKeyV2.from_string(value) + except InvalidKeyError as exc: + raise ValueError from exc + + def to_url(self, value): + return str(value) diff --git a/openedx/core/djangoapps/xblock/rest_api/urls.py b/openedx/core/djangoapps/xblock/rest_api/urls.py index 7bc61db144ba..884ca9cf37b3 100644 --- a/openedx/core/djangoapps/xblock/rest_api/urls.py +++ b/openedx/core/djangoapps/xblock/rest_api/urls.py @@ -1,7 +1,8 @@ """ URL configuration for the new XBlock API """ -from django.urls import include, path, re_path +from django.urls import include, path, re_path, register_converter +from .url_converters import UsageKeyV2Converter from . import views # Note that the exact same API URLs are used in Studio and the LMS, but the API @@ -9,6 +10,8 @@ # If necessary at some point in the future, these URLs could be duplicated into # urls_studio and urls_lms, and/or the views could be likewise duplicated. app_name = 'openedx.core.djangoapps.xblock.rest_api' + +register_converter(UsageKeyV2Converter, "usage_v2") block_endpoints = [ # get metadata about an XBlock: @@ -16,9 +19,9 @@ # get/post full json fields of an XBlock: path('fields/', views.BlockFieldsView.as_view()), # render one of this XBlock's views (e.g. student_view) - re_path(r'^view/(?P[\w\-]+)/$', views.render_block_view), + path('view//', views.render_block_view), # get the URL needed to call this XBlock's handlers - re_path(r'^handler_url/(?P[\w\-]+)/$', views.get_handler_url), + path('handler_url//', views.get_handler_url), # call one of this block's handlers re_path( r'^handler/(?P\w+)-(?P\w+)/(?P[\w\-]+)/(?P.+)?$', @@ -30,12 +33,11 @@ urlpatterns = [ path('api/xblock/v2/', include([ - # This pattern with the version must come first so '@' isn't included in usage_key_str below - path(r'xblocks/@/', include(block_endpoints)), - path(r'xblocks//', include(block_endpoints)), + path(r'xblocks//', include(block_endpoints)), + path(r'xblocks/@/', include(block_endpoints)), ])), # Non-API views (these return HTML, not JSON): - path('xblocks/v2//', include([ + path('xblocks/v2//', include([ # render one of this XBlock's views (e.g. student_view) for embedding in an iframe # NOTE: this endpoint is **unstable** and subject to changes after Sumac path('embed//', views.embed_block_view), diff --git a/openedx/core/djangoapps/xblock/rest_api/views.py b/openedx/core/djangoapps/xblock/rest_api/views.py index 20fa926983ac..7d29e513ddd3 100644 --- a/openedx/core/djangoapps/xblock/rest_api/views.py +++ b/openedx/core/djangoapps/xblock/rest_api/views.py @@ -65,7 +65,7 @@ def parse_version_request(version_str: str | None) -> LatestVersion | int: @api_view(['GET']) @view_auth_classes(is_authenticated=False) @permission_classes((permissions.AllowAny, )) # Permissions are handled at a lower level, by the learning context -def block_metadata(request, usage_key_str): +def block_metadata(request, usage_key, version=None): """ Get metadata about the specified block. @@ -74,10 +74,8 @@ def block_metadata(request, usage_key_str): * "include": a comma-separated list of keys to include. Valid keys are "index_dictionary" and "student_view_data". """ - try: - usage_key = UsageKey.from_string(usage_key_str) - except InvalidKeyError as e: - raise NotFound(invalid_not_found_fmt.format(usage_key=usage_key_str)) from e + if version: + raise NotImplementedError() block = load_block(usage_key, request.user) includes = request.GET.get("include", "").split(",") @@ -92,14 +90,12 @@ def block_metadata(request, usage_key_str): @api_view(['GET']) @view_auth_classes(is_authenticated=False) @permission_classes((permissions.AllowAny, )) # Permissions are handled at a lower level, by the learning context -def render_block_view(request, usage_key_str, view_name): +def render_block_view(request, usage_key, view_name, version=None): """ Get the HTML, JS, and CSS needed to render the given XBlock. """ - try: - usage_key = UsageKey.from_string(usage_key_str) - except InvalidKeyError as e: - raise NotFound(invalid_not_found_fmt.format(usage_key=usage_key_str)) from e + if version: + raise NotImplementedError() try: block = load_block(usage_key, request.user) @@ -116,17 +112,12 @@ def render_block_view(request, usage_key_str, view_name): @view_auth_classes(is_authenticated=False) @permission_classes((permissions.AllowAny, )) # Permissions are handled at a lower level, by the learning context @xframe_options_exempt -def embed_block_view(request, usage_key_str, view_name): +def embed_block_view(request, usage_key, view_name): """ Render the given XBlock in an