From dc8fd749370b4db93bd98d54881bce719a44576b Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 18 Oct 2024 11:16:58 -0700 Subject: [PATCH 1/5] fix: problem block could not be used with versioned handler URls --- openedx/core/djangoapps/xblock/api.py | 4 +- .../core/djangoapps/xblock/rest_api/urls.py | 38 +++++++++++-------- .../core/djangoapps/xblock/rest_api/views.py | 6 +-- .../core/djangoapps/xblock/runtime/runtime.py | 9 ++++- 4 files changed, 35 insertions(+), 22 deletions(-) diff --git a/openedx/core/djangoapps/xblock/api.py b/openedx/core/djangoapps/xblock/api.py index 3cdafd4c909d..919b4b63ec4b 100644 --- a/openedx/core/djangoapps/xblock/api.py +++ b/openedx/core/djangoapps/xblock/api.py @@ -299,9 +299,9 @@ def get_handler_url( 'secure_token': secure_token, 'handler_name': handler_name, } - path = reverse('xblock_api:xblock_handler', kwargs=kwargs) if version != LatestVersion.AUTO: - path += "?version=" + (str(version) if isinstance(version, int) else version.value) + kwargs["version"] = str(version) if isinstance(version, int) else version.value + path = reverse('xblock_api:xblock_handler', kwargs=kwargs) # We must return an absolute URL. We can't just use # rest_framework.reverse.reverse to get the absolute URL because this method diff --git a/openedx/core/djangoapps/xblock/rest_api/urls.py b/openedx/core/djangoapps/xblock/rest_api/urls.py index 6432291178ec..7bc61db144ba 100644 --- a/openedx/core/djangoapps/xblock/rest_api/urls.py +++ b/openedx/core/djangoapps/xblock/rest_api/urls.py @@ -10,25 +10,31 @@ # urls_studio and urls_lms, and/or the views could be likewise duplicated. app_name = 'openedx.core.djangoapps.xblock.rest_api' +block_endpoints = [ + # get metadata about an XBlock: + path('', views.block_metadata), + # 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), + # get the URL needed to call this XBlock's handlers + re_path(r'^handler_url/(?P[\w\-]+)/$', views.get_handler_url), + # call one of this block's handlers + re_path( + r'^handler/(?P\w+)-(?P\w+)/(?P[\w\-]+)/(?P.+)?$', + views.xblock_handler, + name='xblock_handler', + ), + # API endpoints related to a specific version of this XBlock: +] + urlpatterns = [ path('api/xblock/v2/', include([ - path('xblocks//', include([ - # get metadata about an XBlock: - path('', views.block_metadata), - # 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), - # get the URL needed to call this XBlock's handlers - re_path(r'^handler_url/(?P[\w\-]+)/$', views.get_handler_url), - # call one of this block's handlers - re_path( - r'^handler/(?P\w+)-(?P\w+)/(?P[\w\-]+)/(?P.+)?$', - views.xblock_handler, - name='xblock_handler', - ), - ])), + # 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)), ])), + # Non-API views (these return HTML, not JSON): 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 diff --git a/openedx/core/djangoapps/xblock/rest_api/views.py b/openedx/core/djangoapps/xblock/rest_api/views.py index 32e19ebad882..20fa926983ac 100644 --- a/openedx/core/djangoapps/xblock/rest_api/views.py +++ b/openedx/core/djangoapps/xblock/rest_api/views.py @@ -58,7 +58,7 @@ def parse_version_request(version_str: str | None) -> LatestVersion | int: return int(version_str) except ValueError: raise serializers.ValidationError( # pylint: disable=raise-missing-from - "Invalid version specifier '{version_str}'. Expected 'draft', 'published', or an integer." + f"Invalid version specifier '{version_str}'. Expected 'draft', 'published', or an integer." ) @@ -184,7 +184,7 @@ def get_handler_url(request, usage_key_str, handler_name): # and https://github.com/openedx/XBlock/pull/383 for context. @csrf_exempt @xframe_options_exempt -def xblock_handler(request, user_id, secure_token, usage_key_str, handler_name, suffix=None): +def xblock_handler(request, user_id, secure_token, usage_key_str, handler_name, suffix=None, version=None): """ Run an XBlock's handler and return the result @@ -232,7 +232,7 @@ def xblock_handler(request, user_id, secure_token, usage_key_str, handler_name, request_webob = DjangoWebobRequest(request) # Convert from django request to the webob format that XBlocks expect - block = load_block(usage_key, user, version=parse_version_request(request.GET.get("version"))) + block = load_block(usage_key, user, version=parse_version_request(version)) # Run the handler, and save any resulting XBlock field value changes: response_webob = block.handle(handler_name, request_webob, suffix) response = webob_to_django_response(response_webob) diff --git a/openedx/core/djangoapps/xblock/runtime/runtime.py b/openedx/core/djangoapps/xblock/runtime/runtime.py index c1fb6870c007..2bf84e995417 100644 --- a/openedx/core/djangoapps/xblock/runtime/runtime.py +++ b/openedx/core/djangoapps/xblock/runtime/runtime.py @@ -156,7 +156,14 @@ def handler_url(self, block, handler_name: str, suffix='', query='', thirdparty= # Note: it's important that we call handlers based on the same version of the block # (draft block -> draft data available to handler; published block -> published data available to handler) - kwargs = {"version": block._runtime_requested_version} if hasattr(block, "_runtime_requested_version") else {} # pylint: disable=protected-access + kwargs = {} + if hasattr(block, "_runtime_requested_version"): # pylint: disable=protected-access + if self.authored_data_mode == AuthoredDataMode.DEFAULT_DRAFT: + default_version = LatestVersion.DRAFT + else: + default_version = LatestVersion.PUBLISHED + if block._runtime_requested_version != default_version: # pylint: disable=protected-access + kwargs["version"] = block._runtime_requested_version # pylint: disable=protected-access url = self.handler_url_fn(block.usage_key, handler_name, self.user, **kwargs) if suffix: if not url.endswith('/'): From ea7b04995b2f7d69aaab43ddd7aa95b0c011f099 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 18 Oct 2024 12:50:26 -0700 Subject: [PATCH 2/5] 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 | 56 +++++-------------- 5 files changed, 48 insertions(+), 56 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..b7504b7474a0 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 @@ -10,15 +11,17 @@ # 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: path('', views.block_metadata), # 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..33066d97ab1b 100644 --- a/openedx/core/djangoapps/xblock/rest_api/views.py +++ b/openedx/core/djangoapps/xblock/rest_api/views.py @@ -8,7 +8,6 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.db.transaction import atomic -from django.http import Http404 from django.shortcuts import render from django.utils.translation import gettext as _ from django.views.decorators.clickjacking import xframe_options_exempt @@ -22,8 +21,6 @@ from xblock.exceptions import NoSuchUsage from xblock.fields import Scope -from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import UsageKey import openedx.core.djangoapps.site_configuration.helpers as configuration_helpers from openedx.core.djangoapps.xblock.learning_context.manager import get_learning_context_impl from openedx.core.lib.api.view_utils import view_auth_classes @@ -65,7 +62,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 +71,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 +87,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 +109,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