Merge pull request #29517 from open-craft/jill/lx-2365
[LX-2365] allows XBlock API users to optionally use LabXchange block types
This commit is contained in:
@@ -267,6 +267,12 @@ TEST_ELASTICSEARCH_USE_SSL = os.environ.get(
|
||||
TEST_ELASTICSEARCH_HOST = os.environ.get('EDXAPP_TEST_ELASTICSEARCH_HOST', 'edx.devstack.elasticsearch710')
|
||||
TEST_ELASTICSEARCH_PORT = int(os.environ.get('EDXAPP_TEST_ELASTICSEARCH_PORT', '9200'))
|
||||
|
||||
############################# TEMPLATE CONFIGURATION #############################
|
||||
# Adds mako template dirs for content_libraries tests
|
||||
MAKO_TEMPLATE_DIRS_BASE.append(
|
||||
COMMON_ROOT / 'lib' / 'capa' / 'capa' / 'templates'
|
||||
)
|
||||
|
||||
########################## AUTHOR PERMISSION #######################
|
||||
FEATURES['ENABLE_CREATOR_GROUP'] = False
|
||||
|
||||
|
||||
96
common/test/problem.html
Normal file
96
common/test/problem.html
Normal file
@@ -0,0 +1,96 @@
|
||||
<!--
|
||||
common/test/problem.html
|
||||
Placeholder template for openedx content_library tests -->
|
||||
<%page expression_filter="h"/>
|
||||
<%!
|
||||
from django.utils.translation import ngettext, gettext as _
|
||||
from openedx.core.djangolib.markup import HTML
|
||||
%>
|
||||
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<h3 class="hd hd-3 problem-header" id="${ short_id }-problem-title" aria-describedby="${ id }-problem-progress" tabindex="-1">
|
||||
${ problem['name'] }
|
||||
</h3>
|
||||
|
||||
<div class="problem-progress" id="${ id }-problem-progress"></div>
|
||||
|
||||
<div class="problem">
|
||||
${ HTML(problem['html']) }
|
||||
<div class="action">
|
||||
<input type="hidden" name="problem_id" value="${ problem['name'] }" />
|
||||
|
||||
<div class="problem-action-buttons-wrapper">
|
||||
% if demand_hint_possible:
|
||||
<span class="problem-action-button-wrapper">
|
||||
<button type="button" class="hint-button problem-action-btn btn-link btn-small" data-value="${_('Hint')}" ${'' if should_enable_next_hint else 'disabled'}>${_('Hint')}</button>
|
||||
</span>
|
||||
% endif
|
||||
% if save_button:
|
||||
<span class="problem-action-button-wrapper">
|
||||
<button type="button" class="save problem-action-btn btn-link btn-small" data-value="${_('Save')}">
|
||||
<span aria-hidden="true">${_('Save')}</span>
|
||||
<span class="sr">${_("Save your answer")}</span>
|
||||
</button>
|
||||
</span>
|
||||
% endif
|
||||
% if reset_button:
|
||||
<span class="problem-action-button-wrapper">
|
||||
<button type="button" class="reset problem-action-btn btn-link btn-small" data-value="${_('Reset')}"><span aria-hidden="true">${_('Reset')}</span><span class="sr">${_("Reset your answer")}</span></button>
|
||||
</span>
|
||||
% endif
|
||||
% if answer_available:
|
||||
<span class="problem-action-button-wrapper">
|
||||
<button type="button" class="show problem-action-btn btn-link btn-small" aria-describedby="${ short_id }-problem-title"><span class="show-label">${_('Show answer')}</span></button>
|
||||
</span>
|
||||
% endif
|
||||
</div>
|
||||
<div class="submit-attempt-container">
|
||||
<button type="button" class="submit btn-brand" data-submitting="${ submit_button_submitting }" data-value="${ submit_button }" data-should-enable-submit-button="${ should_enable_submit_button }" aria-describedby="submission_feedback_${short_id}" ${'' if should_enable_submit_button else 'disabled'}>
|
||||
<span class="submit-label">${ submit_button }</span>
|
||||
</button>
|
||||
|
||||
% if submit_disabled_cta:
|
||||
% if submit_disabled_cta.get('event_data'):
|
||||
<button class="submit-cta-link-button btn-link btn-small" onclick="emit_event(${submit_disabled_cta['event_data']})">
|
||||
${submit_disabled_cta['link_name']}
|
||||
</button>
|
||||
<span class="submit-cta-description" tabindex="0" role="note" aria-label="description">
|
||||
<span data-tooltip="${submit_disabled_cta['description']}" data-tooltip-show-on-click="true"
|
||||
class="fa fa-info-circle fa-lg" aria-hidden="true">
|
||||
</span>
|
||||
</span>
|
||||
<span class="sr">(${submit_disabled_cta['description']})</span>
|
||||
% else:
|
||||
<form class="submit-cta" method="post" action="${submit_disabled_cta['link']}">
|
||||
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
|
||||
% for form_name, form_value in submit_disabled_cta['form_values'].items():
|
||||
<input type="hidden" name="${form_name}" value="${form_value}">
|
||||
% endfor
|
||||
<button class="submit-cta-link-button btn-link btn-small">
|
||||
${submit_disabled_cta['link_name']}
|
||||
</button>
|
||||
<span class="submit-cta-description" tabindex="0" role="note" aria-label="description">
|
||||
<span data-tooltip="${submit_disabled_cta['description']}" data-tooltip-show-on-click="true"
|
||||
class="fa fa-info-circle fa-lg" aria-hidden="true">
|
||||
</span>
|
||||
</span>
|
||||
<span class="sr">(${submit_disabled_cta['description']})</span>
|
||||
</form>
|
||||
% endif
|
||||
% endif
|
||||
<div class="submission-feedback ${'cta-enabled' if submit_disabled_cta else ''}" id="submission_feedback_${short_id}">
|
||||
## When attempts are not 0, the CTA above will contain a message about the number of used attempts
|
||||
% if attempts_allowed and (not submit_disabled_cta or attempts_used == 0):
|
||||
${ngettext("You have used {num_used} of {num_total} attempt", "You have used {num_used} of {num_total} attempts", attempts_allowed).format(num_used=attempts_used, num_total=attempts_allowed)}
|
||||
% endif
|
||||
<span class="sr">${_("Some problems have options such as save, reset, hints, or show answer. These options follow the Submit button.")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function emit_event(message) {
|
||||
parent.postMessage(message, '*');
|
||||
}
|
||||
</script>
|
||||
16
common/test/problem_ajax.html
Normal file
16
common/test/problem_ajax.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!--
|
||||
common/tests/problem_ajax.html
|
||||
Placeholder template for openedx content_library tests -->
|
||||
<div id="problem_${element_id}" class="problems-wrapper" role="group"
|
||||
aria-labelledby="${element_id}-problem-title"
|
||||
data-problem-id="${id}" data-url="${ajax_url}"
|
||||
data-problem-score="${current_score}"
|
||||
data-problem-total-possible="${total_possible}"
|
||||
data-attempts-used="${attempts_used}"
|
||||
data-content="${content | h}"
|
||||
data-graded="${graded}">
|
||||
<p class="loading-spinner">
|
||||
<i class="fa fa-spinner fa-pulse fa-2x fa-fw"></i>
|
||||
<span class="sr">Loading…</span>
|
||||
</p>
|
||||
</div>
|
||||
122
common/test/video.html
Normal file
122
common/test/video.html
Normal file
@@ -0,0 +1,122 @@
|
||||
<!--
|
||||
common/test/video.html
|
||||
Placeholder template for openedx content_library tests -->
|
||||
<%page expression_filter="h"/>
|
||||
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from openedx.core.djangolib.js_utils import (
|
||||
dump_js_escaped_json, js_escaped_string
|
||||
)
|
||||
%>
|
||||
% if display_name is not UNDEFINED and display_name is not None:
|
||||
<h3 class="hd hd-2">${display_name}</h3>
|
||||
% endif
|
||||
|
||||
<div
|
||||
id="video_${id}"
|
||||
class="video closed"
|
||||
data-metadata='${metadata}'
|
||||
data-bumper-metadata='${bumper_metadata}'
|
||||
data-autoadvance-enabled="${autoadvance_enabled}"
|
||||
data-poster='${poster}'
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="focus_grabber first"></div>
|
||||
|
||||
<div class="tc-wrapper">
|
||||
<div class="video-wrapper">
|
||||
<span tabindex="0" class="spinner" aria-hidden="false" aria-label="${_('Loading video player')}"></span>
|
||||
<span tabindex="-1" class="btn-play fa fa-youtube-play fa-2x is-hidden" aria-hidden="true" aria-label="${_('Play video')}"></span>
|
||||
<div class="video-player-pre"></div>
|
||||
<div class="video-player">
|
||||
<div id="${id}"></div>
|
||||
<h4 class="hd hd-4 video-error is-hidden">${_('No playable video sources found.')}</h4>
|
||||
<h4 class="hd hd-4 video-hls-error is-hidden">
|
||||
${_('Your browser does not support this video format. Try using a different browser.')}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="video-player-post"></div>
|
||||
<div class="closed-captions"></div>
|
||||
<div class="video-controls is-hidden">
|
||||
<div>
|
||||
<div class="vcr"><div class="vidtime">0:00 / 0:00</div></div>
|
||||
<div class="secondary-controls"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="focus_grabber last"></div>
|
||||
|
||||
% if download_video_link or track or handout or branding_info:
|
||||
<h3 class="hd hd-4 downloads-heading sr" id="video-download-transcripts_${id}">${_('Downloads and transcripts')}</h3>
|
||||
<div class="wrapper-downloads" role="region" aria-labelledby="video-download-transcripts_${id}">
|
||||
% if download_video_link:
|
||||
<div class="wrapper-download-video">
|
||||
<h4 class="hd hd-5">${_('Video')}</h4>
|
||||
<a class="btn-link video-sources video-download-button" href="${download_video_link}">
|
||||
${_('Download video file')}
|
||||
</a>
|
||||
</div>
|
||||
% endif
|
||||
% if track:
|
||||
<div class="wrapper-download-transcripts">
|
||||
<h4 class="hd hd-5">${_('Transcripts')}</h4>
|
||||
% if transcript_download_format:
|
||||
<ul class="list-download-transcripts">
|
||||
% for item in transcript_download_formats_list:
|
||||
<li class="transcript-option">
|
||||
<% dname = _("Download {file}").format(file=item['display_name']) %>
|
||||
<a class="btn btn-link" href="${track}" data-value="${item['value']}">${dname}</a>
|
||||
</li>
|
||||
% endfor
|
||||
</ul>
|
||||
% else:
|
||||
<a class="btn-link external-track" href="${track}">${_('Download transcript')}</a>
|
||||
% endif
|
||||
</div>
|
||||
% endif
|
||||
% if handout:
|
||||
<div class="wrapper-handouts">
|
||||
<h4 class="hd hd-5">${_('Handouts')}</h4>
|
||||
<a class="btn-link" href="${handout}">${_('Download Handout')}</a>
|
||||
</div>
|
||||
% endif
|
||||
% if branding_info:
|
||||
<div class="branding">
|
||||
<span class="host-tag">${branding_info['logo_tag']}</span>
|
||||
<a href="${branding_info['url']}"><img class="brand-logo" src="${branding_info['logo_src']}" alt="${branding_info['logo_tag']}" /></a>
|
||||
</div>
|
||||
% endif
|
||||
</div>
|
||||
% endif
|
||||
</div>
|
||||
% if cdn_eval:
|
||||
<script>
|
||||
//TODO: refactor this js into a separate file.
|
||||
function sendPerformanceBeacon(id, expgroup, value, event_name) {
|
||||
var data = {event: event_name, id: id, expgroup: expgroup, value: value, page: "html5vid"};
|
||||
$.ajax({method: "POST", url: "/performance", data: data});
|
||||
}
|
||||
var cdnStartTime;
|
||||
var salt = Math.floor((1 + Math.random()) * 0x100000).toString(36);
|
||||
var id = "${id | n, js_escaped_string}";
|
||||
function initializeCDNExperiment() {
|
||||
sendPerformanceBeacon(id + "_" + salt, ${cdn_exp_group | n, dump_js_escaped_json}, "", "load");
|
||||
cdnStartTime = Date.now();
|
||||
$.each(['loadstart', 'abort', 'error', 'stalled', 'loadedmetadata',
|
||||
'loadeddata', 'canplay', 'canplaythrough', 'seeked'],
|
||||
function(index, eventName) {
|
||||
$("#video_" + id).bind("html5:" + eventName, null, function() {
|
||||
timeElapsed = Date.now() - cdnStartTime;
|
||||
sendPerformanceBeacon(id + "_" + salt, ${cdn_exp_group | n, dump_js_escaped_json}, timeElapsed, eventName);
|
||||
});
|
||||
});
|
||||
}
|
||||
$("#video_" + id).bind("initialize", null, initializeCDNExperiment);
|
||||
if ($("#video_" + id).hasClass("is-initialized")) {
|
||||
initializeCDNExperiment();
|
||||
}
|
||||
</script>
|
||||
% endif;
|
||||
@@ -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