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 c23679cb18..599dfd9a60 100644
--- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py
+++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py
@@ -3,7 +3,9 @@ Tests for Blockstore-based Content Libraries
"""
from uuid import UUID
from unittest.mock import patch
+from urllib.parse import urlparse, parse_qsl
+import json
import ddt
from django.conf import settings
from django.contrib.auth.models import Group
@@ -11,6 +13,9 @@ from django.test.client import Client
from django.test.utils import override_settings
from organizations.models import Organization
from rest_framework.test import APITestCase
+from web_fragments.fragment import Fragment
+from webob import Response
+from xblock.core import XBlock
from openedx.core.djangoapps.content_libraries.libraries_index import LibraryBlockIndexer, ContentLibraryIndexer
from openedx.core.djangoapps.content_libraries.tests.base import (
@@ -20,6 +25,7 @@ from openedx.core.djangoapps.content_libraries.tests.base import (
URL_BLOCK_RENDER_VIEW,
URL_BLOCK_GET_HANDLER_URL,
URL_BLOCK_XBLOCK_HANDLER,
+ URL_LIB_BLOCK_OLX,
)
from openedx.core.djangoapps.content_libraries.constants import VIDEO, COMPLEX, PROBLEM, CC_4_BY, ALL_RIGHTS_RESERVED
from openedx.core.djangolib.blockstore_cache import cache
@@ -903,3 +909,164 @@ class ContentLibraryXBlockValidationTest(APITestCase):
self.assertEqual(response.json(), {
'detail': f"XBlock {valid_not_found_key} does not exist, or you don't have permission to view it.",
})
+
+
+class AltBlock(XBlock):
+ """Class for testing LabXchange XBlock type overrides."""
+ @XBlock.handler
+ def student_view_user_state(self, request, suffix=""):
+ """
+ Returns a JSON response for testing.
+ """
+ view_state = {
+ "id": str(self.location),
+ "block_type": str(self.location.block_type),
+ "override_type": str(self.__class__),
+ }
+ return Response(
+ json.dumps(view_state),
+ content_type='application/json',
+ charset='UTF-8',
+ )
+
+ def student_view(self, context=None):
+ """
+ Returns an HTML fragment for testing.
+ """
+ return Fragment(f"
")
+
+
+@ddt.ddt
+@elasticsearch_test
+class ContentLibrariesXBlockTypeOverrideTest(ContentLibrariesRestApiTest):
+ """
+ Tests for Blockstore-based Content Libraries XBlock API,
+ where the expected XBlock type returned is overridden in the request.
+ """
+ BLOCK_DATA = (
+ ('block-wo-override', {}, 'video'),
+ ('block-w-override', {'lx_block_types': '1'}, 'alt-block'),
+ )
+
+ def setUp(self):
+ super().setUp()
+ if settings.ENABLE_ELASTICSEARCH_FOR_TESTS:
+ ContentLibraryIndexer.remove_all_items()
+ LibraryBlockIndexer.remove_all_items()
+
+ self.olx = """
+
+ """.strip()
+
+ def create_block(self, slug, block_type='video'):
+ """
+ Add a new library containing a block, using the given slug to keep them unique.
+ """
+ lib = self._create_library(slug=slug, title='Test Block Type Overrides', library_type=COMPLEX)
+ block = self._add_block_to_library(lib['id'], block_type, slug)
+ self._set_library_block_olx(block['id'], self.olx)
+ self._commit_library_changes(lib['id'])
+ return block['id']
+
+ @ddt.data(*BLOCK_DATA)
+ @ddt.unpack
+ @patch("openedx.core.djangoapps.xblock.rest_api.views.LX_BLOCK_TYPES_OVERRIDE", {'video': 'alt-block'})
+ @XBlock.register_temp_plugin(AltBlock, 'alt-block')
+ def test_block_type_metadata(self, slug, api_args, expected_type):
+ """
+ Check that the metadata API returns the overridden block type.
+ """
+ block_key = self.create_block(f"metadata-{slug}")
+ response = self.client.get(
+ URL_BLOCK_METADATA_URL.format(block_key=block_key),
+ api_args,
+ )
+ assert response.data['block_id'] == str(block_key)
+ assert response.data['block_type'] == expected_type
+ assert response.data['display_name'] == 'Test Block Type Overrides'
+
+ @ddt.data(*BLOCK_DATA)
+ @ddt.unpack
+ @patch("openedx.core.djangoapps.xblock.rest_api.views.LX_BLOCK_TYPES_OVERRIDE", {'video': 'alt-block'})
+ @XBlock.register_temp_plugin(AltBlock, 'alt-block')
+ def test_block_type_olx(self, slug, api_args, expected_type):
+ """
+ Check that the OLX API is unchanged when overriding the block type.
+ """
+ block_key = self.create_block(f"olx-{slug}")
+ response = self.client.get(
+ URL_LIB_BLOCK_OLX.format(block_key=block_key),
+ api_args,
+ )
+ assert response.data['olx'] == self.olx
+
+ @ddt.data(*BLOCK_DATA)
+ @ddt.unpack
+ @patch("openedx.core.djangoapps.xblock.rest_api.views.LX_BLOCK_TYPES_OVERRIDE", {'video': 'alt-block'})
+ @XBlock.register_temp_plugin(AltBlock, 'alt-block')
+ def test_block_type_render(self, slug, api_args, expected_type):
+ """
+ Check that the rendered block HTML uses the overridden block type.
+ """
+ block_key = self.create_block(f"render-{slug}")
+ response = self.client.get(
+ URL_BLOCK_RENDER_VIEW.format(block_key=block_key, view_name='student_view'),
+ api_args,
+ )
+ assert response.data['block_id'] == str(block_key)
+ assert response.data['block_type'] == expected_type
+ assert response.data['display_name'] == 'Test Block Type Overrides'
+ assert f"data-usage='{block_key}'" in response.data['content']
+ assert f"data-block-type='{expected_type}'" in response.data['content']
+ if expected_type == 'video':
+ assert 'class="video-wrapper"' in response.data['content']
+ else:
+ assert "class='AltBlock-wrapper'" in response.data['content']
+
+ @ddt.data(*BLOCK_DATA)
+ @ddt.unpack
+ @patch("openedx.core.djangoapps.xblock.rest_api.views.LX_BLOCK_TYPES_OVERRIDE", {'video': 'alt-block'})
+ @XBlock.register_temp_plugin(AltBlock, 'alt-block')
+ def test_block_type_handler(self, slug, api_args, expected_type):
+ # Check that the handler_url contains the block type override params
+ block_key = self.create_block(f"handler-{slug}")
+ response = self.client.get(
+ URL_BLOCK_GET_HANDLER_URL.format(block_key=block_key, handler_name='student_view_user_state'),
+ api_args,
+ )
+ handler_url = response.data['handler_url']
+ parsed_url = urlparse(handler_url)
+ parsed_qs = dict(parse_qsl(parsed_url.query))
+ assert parsed_qs == api_args
+
+ # Ensure the invoked handler hits the expected Block
+ if expected_type == 'video':
+ expected_response = {
+ 'all_sources': [],
+ 'duration': None,
+ 'encoded_videos': {
+ 'youtube': {
+ 'file_size': 0,
+ 'url': 'https://www.youtube.com/watch?v=rE42zZ-3wNo',
+ }
+ },
+ 'only_on_web': False,
+ 'saved_video_position': 0.0,
+ 'speed': None,
+ 'transcripts': {},
+ }
+ else:
+ expected_response = {
+ "id": f'lb:CL-TEST:handler-{slug}:video:handler-{slug}',
+ "block_type": 'video',
+ "override_type": "",
+ }
+ response = self.client.post(handler_url).json()
+ # Can't match the Transcripts download URL exactly, but we can check that it's there and roughly correct
+ if 'transcripts' in response:
+ assert f"lb:CL-TEST:handler-{slug}:video:handler-{slug}" in response['transcripts']['en']
+ del response['transcripts']['en']
+ assert response == expected_response
diff --git a/openedx/core/djangoapps/xblock/api.py b/openedx/core/djangoapps/xblock/api.py
index 60b8a1e13b..d55d634917 100644
--- a/openedx/core/djangoapps/xblock/api.py
+++ b/openedx/core/djangoapps/xblock/api.py
@@ -10,6 +10,7 @@ Studio APIs cover use cases like adding/deleting/editing blocks.
import logging
import threading
+from urllib.parse import urlencode
from django.urls import reverse
from django.utils.translation import gettext as _
@@ -58,7 +59,7 @@ def get_runtime_system():
return getattr(get_runtime_system, cache_name)
-def load_block(usage_key, user):
+def load_block(usage_key, user, block_type_overrides=None):
"""
Load the specified XBlock for the given user.
@@ -67,6 +68,12 @@ def load_block(usage_key, user):
Exceptions:
NotFound - if the XBlock doesn't exist or if the user doesn't have the
necessary permissions
+
+ Args:
+ usage_key(OpaqueKey): block identifier
+ user(User): user requesting the block
+ block_type_overrides(dict): optional dict of block types to override in returned block metadata:
+ {'from_block_type': 'to_block_type'}
"""
# Is this block part of a course, a library, or what?
# Get the Learning Context Implementation based on the usage key
@@ -85,7 +92,7 @@ def load_block(usage_key, user):
runtime = get_runtime_system().get_runtime(user=user)
- return runtime.get_block(usage_key)
+ return runtime.get_block(usage_key, block_type_overrides=block_type_overrides)
def get_block_metadata(block, includes=()):
@@ -215,7 +222,7 @@ def render_block_view(block, view_name, user): # pylint: disable=unused-argumen
return fragment
-def get_handler_url(usage_key, handler_name, user):
+def get_handler_url(usage_key, handler_name, user, extra_params=None):
"""
A method for getting the URL to any XBlock handler. The URL must be usable
without any authentication (no cookie, no OAuth/JWT), and may expire. (So
@@ -232,6 +239,7 @@ def get_handler_url(usage_key, handler_name, user):
usage_key - Usage Key (Opaque Key object or string)
handler_name - Name of the handler or a dummy name like 'any_handler'
user - Django User (registered or anonymous)
+ extra_params - Optional extra params to append to the handler_url (dict)
This view does not check/care if the XBlock actually exists.
"""
@@ -255,8 +263,11 @@ def get_handler_url(usage_key, handler_name, user):
'secure_token': secure_token,
'handler_name': handler_name,
})
+ qstring = urlencode(extra_params) if extra_params else ''
+ if qstring:
+ qstring = '?' + qstring
# We must return an absolute URL. We can't just use
# rest_framework.reverse.reverse to get the absolute URL because this method
# can be called by the XBlock from python as well and in that case we don't
# have access to the request.
- return site_root_url + path
+ return site_root_url + path + qstring
diff --git a/openedx/core/djangoapps/xblock/rest_api/views.py b/openedx/core/djangoapps/xblock/rest_api/views.py
index 024b8baf42..520fee8d7f 100644
--- a/openedx/core/djangoapps/xblock/rest_api/views.py
+++ b/openedx/core/djangoapps/xblock/rest_api/views.py
@@ -29,11 +29,31 @@ from ..utils import validate_secure_token_for_xblock_handler
User = get_user_model()
+LX_BLOCK_TYPES_OVERRIDE = {
+ 'problem': 'lx_question',
+ 'video': 'lx_video',
+ 'html': 'lx_html',
+}
+
class InvalidNotFound(NotFound):
default_detail = "Invalid XBlock key"
+def _block_type_overrides(request_args):
+ """
+ If the request contains the argument `lx_block_types=1`, then
+ returns a dict of LabXchange block types, which override the default block types.
+
+ Otherwise, returns None.
+
+ FYI: This is a temporary change, added to assist LabXchange with the transition to using their custom runtime.
+ """
+ if request_args.get('lx_block_types'):
+ return LX_BLOCK_TYPES_OVERRIDE
+ return None
+
+
@api_view(['GET'])
@view_auth_classes(is_authenticated=False)
@permission_classes((permissions.AllowAny, )) # Permissions are handled at a lower level, by the learning context
@@ -41,15 +61,19 @@ def block_metadata(request, usage_key_str):
"""
Get metadata about the specified block.
- Accepts an "include" query parameter which must be a comma separated list of keys to include. Valid keys are
- "index_dictionary" and "student_view_data".
+ Accepts the following query parameters:
+
+ * "include": a comma-separated list of keys to include.
+ Valid keys are "index_dictionary" and "student_view_data".
+ * "lx_block_types": optional boolean; set to use the LabXchange XBlock classes to load the requested block.
+ The block ID and OLX remain unchanged; they will use the original block type.
"""
try:
usage_key = UsageKey.from_string(usage_key_str)
except InvalidKeyError as e:
raise InvalidNotFound from e
- block = load_block(usage_key, request.user)
+ block = load_block(usage_key, request.user, block_type_overrides=_block_type_overrides(request.GET))
includes = request.GET.get("include", "").split(",")
metadata_dict = get_block_metadata(block, includes=includes)
if 'children' in metadata_dict:
@@ -65,13 +89,17 @@ def block_metadata(request, usage_key_str):
def render_block_view(request, usage_key_str, view_name):
"""
Get the HTML, JS, and CSS needed to render the given XBlock.
+
+ Accepts the following query parameters:
+ * "lx_block_types": optional boolean; set to use the LabXchange XBlock classes to load the requested block.
+ The block ID and OLX remain unchanged; they will use the original block type.
"""
try:
usage_key = UsageKey.from_string(usage_key_str)
except InvalidKeyError as e:
raise InvalidNotFound from e
- block = load_block(usage_key, request.user)
+ block = load_block(usage_key, request.user, block_type_overrides=_block_type_overrides(request.GET))
fragment = _render_block_view(block, view_name, request.user)
response_data = get_block_metadata(block)
response_data.update(fragment.to_dict())
@@ -86,13 +114,17 @@ def get_handler_url(request, usage_key_str, handler_name):
the given XBlock handler.
The URL will expire but is guaranteed to be valid for a minimum of 2 days.
+
+ The following query parameters will be appended to the returned handler_url:
+ * "lx_block_types": optional boolean; set to use the LabXchange XBlock classes to load the requested block.
+ The block ID and OLX remain unchanged; they will use the original block type.
"""
try:
usage_key = UsageKey.from_string(usage_key_str)
except InvalidKeyError as e:
raise InvalidNotFound from e
- handler_url = _get_handler_url(usage_key, handler_name, request.user)
+ handler_url = _get_handler_url(usage_key, handler_name, request.user, request.GET)
return Response({"handler_url": handler_url})
@@ -109,6 +141,11 @@ def xblock_handler(request, user_id, secure_token, usage_key_str, handler_name,
This endpoint has a unique authentication scheme that involves a temporary
auth token included in the URL (see below). As a result it can be exempt
from CSRF, session auth, and JWT/OAuth.
+
+ Accepts the following query parameters (in addition to those passed to the handler):
+
+ * "lx_block_types": optional boolean; set to use the LabXchange XBlock classes to load the requested block.
+ The block ID and OLX remain unchanged; they will use the original block type.
"""
try:
usage_key = UsageKey.from_string(usage_key_str)
@@ -149,7 +186,7 @@ def xblock_handler(request, user_id, secure_token, usage_key_str, handler_name,
raise AuthenticationFailed("Invalid user ID format.")
request_webob = DjangoWebobRequest(request) # Convert from django request to the webob format that XBlocks expect
- block = load_block(usage_key, user)
+ block = load_block(usage_key, user, block_type_overrides=_block_type_overrides(request.GET))
# 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/blockstore_runtime.py b/openedx/core/djangoapps/xblock/runtime/blockstore_runtime.py
index dcb59b1e6d..f48ae7d0b8 100644
--- a/openedx/core/djangoapps/xblock/runtime/blockstore_runtime.py
+++ b/openedx/core/djangoapps/xblock/runtime/blockstore_runtime.py
@@ -33,11 +33,14 @@ class BlockstoreXBlockRuntime(XBlockRuntime):
def parse_xml_file(self, fileobj, id_generator=None):
raise NotImplementedError("Use parse_olx_file() instead")
- def get_block(self, usage_id, for_parent=None):
+ def get_block(self, usage_id, for_parent=None, block_type_overrides=None): # pylint: disable=arguments-differ
"""
Create an XBlock instance in this runtime.
- The `usage_id` is used to find the XBlock class and data.
+ Args:
+ usage_key(OpaqueKey): identifier used to find the XBlock class and data.
+ block_type_overrides(dict): optional dict of block types to override in returned block metadata:
+ {'from_block_type': 'to_block_type'}
"""
def_id = self.id_reader.get_definition_id(usage_id)
if def_id is None:
@@ -46,6 +49,8 @@ class BlockstoreXBlockRuntime(XBlockRuntime):
raise TypeError("This runtime can only load blocks stored in Blockstore bundles.")
try:
block_type = self.id_reader.get_block_type(def_id)
+ if block_type_overrides and block_type in block_type_overrides:
+ block_type = block_type_overrides[block_type]
except NoSuchDefinition:
raise NoSuchUsage(repr(usage_id)) # lint-amnesty, pylint: disable=raise-missing-from
keys = ScopeIds(self.user_id, block_type, def_id, usage_id)