feat!: remove library_sourced block (#33257)

Originally, we planned to add support for V2 libraries and for static
(hand-selected) library block reference via new block type: library_sourced.

We have since decided that it would be better to add those capabilities
in-place to the existing library_content block. This will ease V1->V2
library migration and make adoption of the new features easier for current
library users. It will also avoid duplication of logic between two block types,
we we fear would be error-prone. For details, see this ADR:
 https://github.com/openedx/edx-platform/pull/33231

So, we are removing the library_sourced block.
This block has existed in edx-platform for a few years now, but was not
enabled by default and never officially supported. It was only usable via the
experimental V2 content library feature. Operators who added library_sourced
blocks to their course will now see them render as `HiddenBlock` instances, i.e.:

> ERROR: "library_sourced" is an unknown component type...

This should not impact other component types in such courses and should not
impact import/export.
This commit is contained in:
Kyle McCormick
2023-09-15 10:04:38 -04:00
committed by GitHub
parent e820162572
commit 265701c01d
13 changed files with 13 additions and 618 deletions

View File

@@ -2675,10 +2675,7 @@ class TestComponentTemplates(CourseTestCase):
XBlockStudioConfiguration.objects.create(
name="openassessment", enabled=True, support_level="us"
)
# Library Sourced Block and Library Content block has it's own category.
XBlockStudioConfiguration.objects.create(
name="library_sourced", enabled=True, support_level="fs"
)
# Library Content block has its own category.
XBlockStudioConfiguration.objects.create(
name="library_content", enabled=True, support_level="fs"
)

View File

@@ -3,7 +3,13 @@ Referencing Content Blocks in Library V2
Status
=======
Pending
**Deferred** as of September 2023.
The goals described in the ADR are still relevant to future development,
but for the intial launch of Blockstore-backed content libraries,
the existing ``library_content`` block will be used instead,
and it will continue to copy blocks from Blockstore into Modulestore as necessary.
Context
=======

View File

@@ -452,7 +452,7 @@ def xblock_resource_pkg(block):
ProblemBlock, and most other built-in blocks currently. Handling for these
assets does not interact with this function.
2. The (preferred) standard XBlock runtime resource loading system, used by
LibrarySourcedBlock. Handling for these assets *does* interact with this
LibraryContentBlock. Handling for these assets *does* interact with this
function.
We hope to migrate to (2) eventually, tracked by:

View File

@@ -22,7 +22,6 @@ XBLOCKS = [
"image = xmodule.template_block:TranslateCustomTagBlock",
"library = xmodule.library_root_xblock:LibraryRoot",
"library_content = xmodule.library_content_block:LibraryContentBlock",
"library_sourced = xmodule.library_sourced_block:LibrarySourcedBlock",
"lti = xmodule.lti_block:LTIBlock",
"poll_question = xmodule.poll_block:PollBlock",
"problem = xmodule.capa_block:ProblemBlock",

View File

@@ -77,7 +77,6 @@ module.exports = Merge.smart({
// Studio
Import: './cms/static/js/features/import/factories/import.js',
CourseOrLibraryListing: './cms/static/js/features_jsx/studio/CourseOrLibraryListing.jsx',
LibrarySourcedBlockPicker: './xmodule/assets/library_source_block/LibrarySourcedBlockPicker.jsx', // eslint-disable-line max-len
'js/factories/textbooks': './cms/static/js/factories/textbooks.js',
'js/factories/container': './cms/static/js/factories/container.js',
'js/factories/context_course': './cms/static/js/factories/context_course.js',

View File

@@ -46,11 +46,6 @@ It is collected into the static root, and then linked to from XBlock fragments b
.. _annotatable/_display.scss: https://github.com/openedx/edx-platform/tree/master/xmodule/assets/annotatable/_display.scss
.. _simplify things: https://github.com/openedx/edx-platform/issues/32621
Static CSS (.css)
*****************
Non-themable, ready-to-seve CSS may also be added to the any block type's
subdirectory. For example, see `library_source_block/style.css`_.
JavaScript (.js)
****************
@@ -72,7 +67,7 @@ Currently, edx-platform XBlock JS is defined both here in `xmodule/assets`_ and
* For other "purer" blocks, the JS is used as a standard XBlock fragment. Example blocks:
* `VerticalBlock`_
* `LibrarySourcedBlock`_
* `LibraryContentBlock`_
As part of an `active build refactoring`_, we will soon consolidate all edx-platform XBlock JS here in `xmodule/assets`_.
@@ -82,7 +77,7 @@ As part of an `active build refactoring`_, we will soon consolidate all edx-plat
.. _HtmlBlock: https://github.com/openedx/edx-platform/blob/master/xmodule/html_block.py
.. _AnnotatableBlock: https://github.com/openedx/edx-platform/blob/master/xmodule/annotatable_block.py
.. _VerticalBlock: https://github.com/openedx/edx-platform/blob/master/xmodule/vertical_block.py
.. _LibrarySourcedBlock: https://github.com/openedx/edx-platform/blob/master/xmodule/library_sourced_block.py
.. _LibraryContentBlock: https://github.com/openedx/edx-platform/blob/master/xmodule/library_content_block.py
.. _active build refactoring: https://github.com/openedx/edx-platform/issues/31624
.. _builtin_assets.py: https://github.com/openedx/edx-platform/tree/master/xmodule/util/builtin_assets.py
.. _static_content.py: https://github.com/openedx/edx-platform/blob/master/xmodule/static_content.py

View File

@@ -1,236 +0,0 @@
/* globals gettext */
import 'whatwg-fetch';
import PropTypes from 'prop-types';
import React from 'react';
import _ from 'underscore';
import styles from './style.css';
class LibrarySourcedBlockPicker extends React.Component {
constructor(props) {
super(props);
this.state = {
libraries: [],
xblocks: [],
// eslint-disable-next-line react/no-unused-state
searchedLibrary: '',
libraryLoading: false,
xblocksLoading: false,
selectedLibrary: undefined,
selectedXblocks: new Set(this.props.selectedXblocks),
};
this.onLibrarySearchInput = this.onLibrarySearchInput.bind(this);
this.onXBlockSearchInput = this.onXBlockSearchInput.bind(this);
this.onLibrarySelected = this.onLibrarySelected.bind(this);
this.onXblockSelected = this.onXblockSelected.bind(this);
this.onDeleteClick = this.onDeleteClick.bind(this);
}
componentDidMount() {
this.fetchLibraries();
}
// eslint-disable-next-line react/sort-comp
fetchLibraries(textSearch = '', page = 1, append = false) {
this.setState({
// eslint-disable-next-line react/no-access-state-in-setstate
libraries: append ? this.state.libraries : [],
libraryLoading: true,
}, async function() {
try {
let res = await fetch(`/api/libraries/v2/?pagination=true&page=${page}&text_search=${textSearch}`);
res = await res.json();
this.setState({
// eslint-disable-next-line react/no-access-state-in-setstate
libraries: this.state.libraries.concat(res.results),
libraryLoading: false,
}, () => {
if (res.next) {
this.fetchLibraries(textSearch, page + 1, true);
}
});
} catch (error) {
$('#library-sourced-block-picker').trigger('error', {
title: 'Could not fetch library',
message: error,
});
this.setState({
libraries: [],
libraryLoading: false,
});
}
});
}
fetchXblocks(library, textSearch = '', page = 1, append = false) {
this.setState({
// eslint-disable-next-line react/no-access-state-in-setstate
xblocks: append ? this.state.xblocks : [],
xblocksLoading: true,
}, async function() {
try {
let res = await fetch(`/api/libraries/v2/${library}/blocks/?pagination=true&page=${page}&text_search=${textSearch}`);
res = await res.json();
this.setState({
// eslint-disable-next-line react/no-access-state-in-setstate
xblocks: this.state.xblocks.concat(res.results),
xblocksLoading: false,
}, () => {
if (res.next) {
this.fetchXblocks(library, textSearch, page + 1, true);
}
});
} catch (error) {
$('#library-sourced-block-picker').trigger('error', {
title: 'Could not fetch xblocks',
message: error,
});
this.setState({
xblocks: [],
xblocksLoading: false,
});
}
});
}
onLibrarySearchInput(event) {
event.persist();
this.setState({
// eslint-disable-next-line react/no-unused-state
searchedLibrary: event.target.value,
});
if (!this.debouncedFetchLibraries) {
this.debouncedFetchLibraries = _.debounce(value => {
this.fetchLibraries(value);
}, 300);
}
this.debouncedFetchLibraries(event.target.value);
}
onXBlockSearchInput(event) {
event.persist();
if (!this.debouncedFetchXblocks) {
this.debouncedFetchXblocks = _.debounce(value => {
this.fetchXblocks(this.state.selectedLibrary, value);
}, 300);
}
this.debouncedFetchXblocks(event.target.value);
}
onLibrarySelected(event) {
this.setState({
selectedLibrary: event.target.value,
});
this.fetchXblocks(event.target.value);
}
onXblockSelected(event) {
// eslint-disable-next-line prefer-const, react/no-access-state-in-setstate
let state = new Set(this.state.selectedXblocks);
if (event.target.checked) {
state.add(event.target.value);
} else {
state.delete(event.target.value);
}
this.setState({
selectedXblocks: state,
}, this.updateList);
}
onDeleteClick(event) {
let value;
if (event.target.tagName == 'SPAN') {
value = event.target.parentElement.dataset.value;
} else {
value = event.target.dataset.value;
}
// eslint-disable-next-line prefer-const, react/no-access-state-in-setstate
let state = new Set(this.state.selectedXblocks);
state.delete(value);
this.setState({
selectedXblocks: state,
}, this.updateList);
}
updateList(list) {
$('#library-sourced-block-picker').trigger('selected-xblocks', {
sourceBlockIds: Array.from(this.state.selectedXblocks),
});
}
render() {
return (
<section>
<div className="container-message wrapper-message">
<div className="message has-warnings" style={{margin: 0, color: 'white'}}>
<p className="warning">
<span className="icon fa fa-warning" aria-hidden="true" />
{/* eslint-disable-next-line react/no-unescaped-entities */}
Hitting 'Save and Import' will import the latest versions of the selected blocks, overwriting any changes done to this block post-import.
</p>
</div>
</div>
<div style={{display: 'flex', flexDirection: 'row', justifyContent: 'center'}}>
<div className={styles.column}>
<input type="text" className={[styles.search]} aria-label="Search for library" placeholder="Search for library" label="Search for library" name="librarySearch" onChange={this.onLibrarySearchInput} />
<div className={styles.elementList} onChange={this.onLibrarySelected}>
{
this.state.libraries.map(lib => (
<div key={lib.id} className={styles.element}>
<input id={`sourced-library-${lib.id}`} type="radio" value={lib.id} name="library" />
<label className={styles.elementItem} htmlFor={`sourced-library-${lib.id}`}>{lib.title}</label>
</div>
))
}
{ this.state.libraryLoading && <span>{gettext('Loading...')}</span> }
</div>
</div>
<div className={styles.column}>
<input type="text" className={[styles.search]} aria-label="Search for XBlocks" placeholder="Search for XBlocks" name="xblockSearch" onChange={this.onXBlockSearchInput} disabled={!this.state.selectedLibrary || this.state.libraryLoading} />
<div className={styles.elementList} onChange={this.onXblockSelected}>
{
this.state.xblocks.map(block => (
<div key={block.id} className={styles.element}>
<input id={`sourced-block-${block.id}`} type="checkbox" value={block.id} name="block" checked={this.state.selectedXblocks.has(block.id)} readOnly />
<label className={styles.elementItem} htmlFor={`sourced-block-${block.id}`}>{block.display_name} ({block.id})</label>
</div>
))
}
{ this.state.xblocksLoading && <span>{gettext('Loading...')}</span> }
</div>
</div>
<div className={styles.column}>
<h4 className={styles.selectedBlocks}>{gettext('Selected blocks')}</h4>
<ul>
{
Array.from(this.state.selectedXblocks).map(block => (
<li key={block} className={styles.element} style={{display: 'flex'}}>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label className={styles.elementItem}>
{block}
</label>
{/* eslint-disable-next-line react/button-has-type */}
<button className={[styles.remove]} data-value={block} onClick={this.onDeleteClick} aria-label="Remove block">
<span aria-hidden="true" className="icon fa fa-times" />
</button>
</li>
))
}
</ul>
</div>
</div>
</section>
);
}
}
LibrarySourcedBlockPicker.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
selectedXblocks: PropTypes.array,
};
LibrarySourcedBlockPicker.defaultProps = {
selectedXblocks: [],
};
export {LibrarySourcedBlockPicker}; // eslint-disable-line import/prefer-default-export

View File

@@ -1,58 +0,0 @@
/* JavaScript for allowing editing options on LibrarySourceBlock's studio view */
window.LibrarySourceBlockStudioView = function(runtime, element) {
'use strict';
var self = this;
$('#library-sourced-block-picker', element).on('selected-xblocks', function(e, params) {
self.sourceBlockIds = params.sourceBlockIds;
});
$('#library-sourced-block-picker', element).on('error', function(e, params) {
runtime.notify('error', {title: gettext(params.title), message: params.message});
});
$('.save-button', element).on('click', function(e) {
e.preventDefault();
var url = $(e.target).data('submit-url');
var data = {
values: {
source_block_ids: self.sourceBlockIds
},
defaults: ['display_name']
};
runtime.notify('save', {
state: 'start',
message: gettext('Saving'),
element: element
});
$.ajax({
type: 'POST',
url: url,
data: JSON.stringify(data),
global: false // Disable error handling that conflicts with studio's notify('save') and notify('cancel')
}).done(function() {
runtime.notify('save', {
state: 'end',
element: element
});
}).fail(function(jqXHR) {
var message = gettext('This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.'); // eslint-disable-line max-len
if (jqXHR.responseText) { // Is there a more specific error message we can show?
try {
message = JSON.parse(jqXHR.responseText).error;
if (typeof message === 'object' && message.messages) {
// e.g. {"error": {"messages": [{"text": "Unknown user 'bob'!", "type": "error"}, ...]}} etc.
message = $.map(message.messages, function(msg) { return msg.text; }).join(', ');
}
} catch (error) { message = jqXHR.responseText.substr(0, 300); }
}
runtime.notify('error', {title: gettext('Unable to update settings'), message: message});
});
});
$('.cancel-button', element).on('click', function(e) {
e.preventDefault();
runtime.notify('cancel', {});
});
};

View File

@@ -1,58 +0,0 @@
.column {
display: flex;
flex-direction: column;
margin: 10px;
max-width: 300px;
flex-grow: 1;
}
.elementList {
margin-top: 10px;
}
input.search {
width: 100% !important;
height: auto !important;
}
.element > input[type='checkbox'],
.element > input[type='radio'] {
position: absolute;
width: 0 !important;
height: 0 !important;
top: -9999px;
}
.element > .elementItem {
display: flex;
flex-grow: 1;
padding: 0.625rem 1.25rem;
border: 1px solid rgba(0, 0, 0, 0.25);
}
.element + .element > label {
border-top: 0;
}
.element > input[type='checkbox']:focus + label,
.element > input[type='radio']:focus + label,
.element > input:hover + label {
background: #f6f6f7;
cursor: pointer;
}
.element > input:checked + label {
background: #23419f;
color: #fff;
}
.element > input[type='checkbox']:checked:focus + label,
.element > input[type='radio']:checked:focus + label,
.element > input:checked:hover + label {
background: #193787;
cursor: pointer;
}
.selectedBlocks {
padding: 12px 8px 20px;
}
button.remove {
background: #e00;
color: #fff;
border: solid rgba(0,0,0,0.25) 1px;
}
button.remove:focus,
button.remove:hover {
background: #d00;
}

View File

@@ -1,159 +0,0 @@
"""
Library Sourced Content XBlock
"""
import logging
from copy import copy
from mako.template import Template as MakoTemplate
from xblock.core import XBlock
from xblock.fields import Scope, String, List
from xblock.validation import ValidationMessage
from xblockutils.resources import ResourceLoader
from xblockutils.studio_editable import StudioEditableXBlockMixin
from webob import Response
from web_fragments.fragment import Fragment
from xmodule.studio_editable import StudioEditableBlock as EditableChildrenMixin
from xmodule.validation import StudioValidation, StudioValidationMessage
log = logging.getLogger(__name__)
loader = ResourceLoader(__name__)
# Make '_' a no-op so we can scrape strings. Using lambda instead of
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
_ = lambda text: text
@XBlock.wants('library_tools') # Only needed in studio
class LibrarySourcedBlock(StudioEditableXBlockMixin, EditableChildrenMixin, XBlock):
"""
Library Sourced Content XBlock
Allows copying specific XBlocks from a Blockstore-based content library into
a modulestore-based course. The selected blocks are copied and become
children of this block.
When we implement support for Blockstore-based courses, it's expected we'll
use a different mechanism for importing library content into a course.
"""
display_name = String(
help=_("The display name for this component."),
default="Library Sourced Content",
display_name=_("Display Name"),
scope=Scope.content,
)
source_block_ids = List(
display_name=_("Library Blocks List"),
help=_("Enter the IDs of the library XBlocks that you wish to use."),
scope=Scope.content,
)
editable_fields = ("display_name", "source_block_ids")
has_children = True
has_author_view = True
resources_dir = 'assets/library_source_block'
MAX_BLOCKS_ALLOWED = 10
def __str__(self):
return f"LibrarySourcedBlock: {self.display_name}"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.source_block_ids:
self.has_children = False
def studio_view(self, context):
"""
Render a form for editing this XBlock
"""
fragment = Fragment()
static_content = ResourceLoader('common.djangoapps.pipeline_mako').load_unicode('templates/static_content.html')
render_react = MakoTemplate(static_content, default_filters=[]).get_def('renderReact')
react_content = render_react.render(
component="LibrarySourcedBlockPicker",
id="library-sourced-block-picker",
props={
'selectedXblocks': self.source_block_ids,
}
)
fragment.content = loader.render_django_template('templates/library-sourced-block-studio-view.html', {
'react_content': react_content,
'save_url': self.runtime.handler_url(self, 'submit_studio_edits'),
})
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_source_block.js'))
fragment.initialize_js('LibrarySourceBlockStudioView')
return fragment
def author_view(self, context):
"""
Renders the Studio preview view.
"""
fragment = Fragment()
context = {} if not context else copy(context) # Isolate context - without this there are weird bugs in Studio
# EditableChildrenMixin.render_children will render HTML that allows instructors to make edits to the children
context['can_move'] = False
self.render_children(context, fragment, can_reorder=False, can_add=False)
return fragment
def student_view(self, context):
"""
Renders the view that learners see.
"""
result = Fragment()
child_frags = self.runtime.render_children(self, context=context)
result.add_resources(child_frags)
result.add_content('<div class="library-sourced-content">')
for frag in child_frags:
result.add_content(frag.content)
result.add_content('</div>')
return result
def validate_field_data(self, validation, data):
"""
Validate this block's field data. Instead of checking fields like self.name, check the
fields set on data, e.g. data.name. This allows the same validation method to be re-used
for the studio editor.
"""
if len(data.source_block_ids) > self.MAX_BLOCKS_ALLOWED:
# Because importing library blocks is an expensive operation
validation.add(
ValidationMessage(
ValidationMessage.ERROR,
_("A maximum of {0} components may be added.").format(self.MAX_BLOCKS_ALLOWED)
)
)
def validate(self):
"""
Validates the state of this library_sourced_xblock Instance. This is the override of the general XBlock method,
and it will also ask its superclass to validate.
"""
validation = super().validate()
validation = StudioValidation.copy(validation)
if not self.source_block_ids:
validation.set_summary(
StudioValidationMessage(
StudioValidationMessage.NOT_CONFIGURED,
_("No XBlock has been configured for this component. Use the editor to select the target blocks."),
action_class='edit-button',
action_label=_("Open Editor")
)
)
return validation
@XBlock.handler
def submit_studio_edits(self, data, suffix=''):
"""
Save changes to this block, applying edits made in Studio.
"""
response = super().submit_studio_edits(data, suffix)
# Replace our current children with the latest ones from the libraries.
lib_tools = self.runtime.service(self, 'library_tools')
try:
lib_tools.import_from_blockstore(self, self.source_block_ids)
except Exception as err: # pylint: disable=broad-except
log.exception(err)
return Response(_("Importing Library Block failed - are the IDs valid and readable?"), status=400)
return response

View File

@@ -196,7 +196,7 @@ class LibraryToolsService:
content library) into modulestore, as a new child of dest_block.
Any existing children of dest_block are replaced.
This is only used by LibrarySourcedBlock. It should verify first that
This is only used by LibraryContentBlock. It should verify first that
the number of block IDs is reasonable.
"""
dest_key = dest_block.scope_ids.usage_id
@@ -216,7 +216,7 @@ class LibraryToolsService:
raise PermissionDenied()
# Read the source block; this will also confirm that user has permission to read it.
# (This could be slow and use lots of memory, except for the fact that LibrarySourcedBlock which calls this
# (This could be slow and use lots of memory, except for the fact that LibraryContentBlock which calls this
# should be limiting the number of blocks to a reasonable limit. We load them all now instead of one at a
# time in order to raise any errors before we start actually copying blocks over.)
orig_blocks = [load_block(UsageKey.from_string(key), user) for key in blockstore_block_ids]

View File

@@ -1,19 +0,0 @@
{% load i18n %}
<div class="editor-with-buttons">
<div class="wrapper-comp-settings is-active editor-with-buttons" id="settings-tab">
<div class="list-input settings-list">
{{ react_content|safe }}
</div>
</div>
<div class="xblock-actions">
<ul>
<li class="action-item">
<a href="#" class="button action-primary save-button" data-submit-url="{{save_url}}">{% trans "Save and Import" as tmsg %} {{tmsg|force_escape}}</a>
</li>
<li class="action-item">
<a href="#" class="button cancel-button">{% trans "Cancel" as tmsg %} {{tmsg|force_escape}}</a>
</li>
</ul>
</div>
</div>

View File

@@ -1,71 +0,0 @@
"""
Tests for Source from Library XBlock
"""
from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest
from common.djangoapps.student.roles import CourseInstructorRole
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
from xmodule.tests import prepare_block_runtime
from xmodule.x_module import STUDENT_VIEW # lint-amnesty, pylint: disable=unused-import
class LibrarySourcedBlockTestCase(ContentLibrariesRestApiTest):
"""
Tests for LibraryToolsService which interact with blockstore-based content libraries
"""
def setUp(self):
super().setUp()
self.store = modulestore()
# Create a modulestore course
course = CourseFactory.create(modulestore=self.store, user_id=self.user.id)
CourseInstructorRole(course.id).add_users(self.user)
# Add a "Source from Library" block to the course
self.source_block = BlockFactory.create(
category="library_sourced",
parent=course,
parent_location=course.location,
user_id=self.user.id,
modulestore=self.store
)
self.submit_url = f'/xblock/{self.source_block.scope_ids.usage_id}/handler/submit_studio_edits'
def test_block_views(self):
# Create a blockstore content library
library = self._create_library(slug="testlib1_preview", title="Test Library 1", description="Testing XBlocks")
# Add content to the library
html_block_1 = self._add_block_to_library(library["id"], "html", "html_student_preview_1")["id"]
self._set_library_block_olx(html_block_1, '<html>Student Preview Test 1</html>')
html_block_2 = self._add_block_to_library(library["id"], "html", "html_student_preview_2")["id"]
self._set_library_block_olx(html_block_2, '<html>Student Preview Test 2</html>')
# Import the html blocks from the library to the course
post_data = {"values": {"source_block_ids": [html_block_1, html_block_2]}, "defaults": ["display_name"]}
res = self.client.post(self.submit_url, data=post_data, format='json')
# Check if student_view renders the children correctly
res = self.get_block_view(self.source_block, STUDENT_VIEW)
assert 'Student Preview Test 1' in res
assert 'Student Preview Test 2' in res
def test_block_limits(self):
# Create a blockstore content library
library = self._create_library(slug="testlib2_preview", title="Test Library 2", description="Testing XBlocks")
# Add content to the library
blocks = [self._add_block_to_library(library["id"], "html", f"block_{i}")["id"] for i in range(11)]
# Import the html blocks from the library to the course
post_data = {"values": {"source_block_ids": blocks}, "defaults": ["display_name"]}
res = self.client.post(self.submit_url, data=post_data, format='json')
assert res.status_code == 400
assert res.json()['error']['messages'][0]['text'] == 'A maximum of 10 components may be added.'
def get_block_view(self, block, view, context=None):
"""
Renders the specified view for a given XBlock
"""
context = context or {}
block = self.store.get_item(block.location)
prepare_block_runtime(block.runtime)
block.bind_for_student(self.user.id)
return block.runtime.render(block, view, context).content