temp: let XBlock API users optionally use LabXchange block types
when fetching block metadata and rendering blocks while maintaining the original usage IDs/OLX. This change is marked temporary because LabXchange need it during the transition to a custom runtime, but it's not really useful to anyone else. We will revert this change with a future PR.
This commit is contained in:
@@ -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"<div data-usage='{self.location}' data-block-type='{self.location.block_type}'>"
|
||||
"<div class='AltBlock-wrapper'/></div>")
|
||||
|
||||
|
||||
@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 = """
|
||||
<video display_name="Test Block Type Overrides"
|
||||
youtube_id_1_0="rE42zZ-3wNo"
|
||||
transcripts="{"en": "transcript.srt"}" />
|
||||
""".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": "<class 'xblock.internal.AltBlockWithMixins'>",
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user