Commit 209ddc70 by Dmitry Viskov

Difficulty selectbox in Studio (based on new XBlockAside functionality). Include:

- adaptation asides to be imported from the XML
- updating SplitMongo to handle XBlockAsides (CRUD operations)
- updating Studio to handle XBlockAsides handler calls
- updating xblock/core.js to properly init XBlockAsides JavaScript
parent d4817685
......@@ -266,3 +266,4 @@ Kaloian Doganov <doganov@gmail.com>
Sanford Student <sstudent@edx.org>
Florian Haas <florian@hastexo.com>
Leonardo Quiñonez <leonardo.quinonez@edunext.co>
Dmitry Viskov <dmitry.viskov@webenterprise.ru>
......@@ -9,7 +9,8 @@ from django.http import Http404, HttpResponseBadRequest
from django.contrib.auth.decorators import login_required
from edxmako.shortcuts import render_to_string
from openedx.core.lib.xblock_utils import replace_static_urls, wrap_xblock, wrap_fragment, request_token
from openedx.core.lib.xblock_utils import replace_static_urls, wrap_xblock, wrap_fragment, wrap_xblock_aside,\
request_token
from xmodule.x_module import PREVIEW_VIEWS, STUDENT_VIEW, AUTHOR_VIEW
from xmodule.contentstore.django import contentstore
from xmodule.error_module import ErrorDescriptor
......@@ -19,6 +20,7 @@ from xmodule.services import SettingsService
from xmodule.modulestore.django import modulestore, ModuleI18nService
from xmodule.mixin import wrap_with_license
from opaque_keys.edx.keys import UsageKey
from opaque_keys.edx.asides import AsideUsageKeyV1
from xmodule.x_module import ModuleSystem
from xblock.runtime import KvsFieldData
from xblock.django.request import webob_to_django_response, django_to_webob_request
......@@ -56,8 +58,18 @@ def preview_handler(request, usage_key_string, handler, suffix=''):
"""
usage_key = UsageKey.from_string(usage_key_string)
descriptor = modulestore().get_item(usage_key)
instance = _load_preview_module(request, descriptor)
if isinstance(usage_key, AsideUsageKeyV1):
descriptor = modulestore().get_item(usage_key.usage_key)
for aside in descriptor.runtime.get_asides(descriptor):
if aside.scope_ids.block_type == usage_key.aside_type:
asides = [aside]
instance = aside
break
else:
descriptor = modulestore().get_item(usage_key)
instance = _load_preview_module(request, descriptor)
asides = []
# Let the module handle the AJAX
req = django_to_webob_request(request)
try:
......@@ -80,6 +92,7 @@ def preview_handler(request, usage_key_string, handler, suffix=''):
log.exception("error processing ajax call")
raise
modulestore().update_item(descriptor, request.user.id, asides=asides)
return webob_to_django_response(resp)
......@@ -184,6 +197,15 @@ def _preview_module_system(request, descriptor, field_data):
_studio_wrap_xblock,
]
wrappers_asides = [
partial(
wrap_xblock_aside,
'PreviewRuntime',
usage_id_serializer=unicode,
request_token=request_token(request)
)
]
if settings.FEATURES.get("LICENSING", False):
# stick the license wrapper in front
wrappers.insert(0, wrap_with_license)
......@@ -208,6 +230,7 @@ def _preview_module_system(request, descriptor, field_data):
# Set up functions to modify the fragment produced by student_view
wrappers=wrappers,
wrappers_asides=wrappers_asides,
error_descriptor_class=ErrorDescriptor,
get_user_role=lambda: get_user_role(request.user, course_id),
# Get the raw DescriptorSystem, not the CombinedSystem
......
"""
Example implementation of Structured Tagging based on XBlockAsides
Structured Tagging based on XBlockAsides
"""
from xblock.core import XBlockAside
from xblock.core import XBlockAside, XBlock
from xblock.fragment import Fragment
from xblock.fields import Scope, Dict
from xmodule.x_module import STUDENT_VIEW
from xmodule.capa_module import CapaModule
from abc import ABCMeta, abstractproperty
from edxmako.shortcuts import render_to_string
from django.conf import settings
from webob import Response
from collections import OrderedDict
_ = lambda text: text
......@@ -42,24 +45,24 @@ class AbstractTag(object):
raise NotImplementedError('Subclasses must implement allowed_values')
class LearningOutcomeTag(AbstractTag):
class DifficultyTag(AbstractTag):
"""
Particular implementation tags for learning outcomes
Particular implementation tags for difficulty
"""
@property
def key(self):
""" Identifier for the learning outcome selector """
return 'learning_outcome_tag'
""" Identifier for the difficulty selector """
return 'difficulty_tag'
@property
def name(self):
""" Label for the learning outcome selector """
return _('Learning outcomes')
""" Label for the difficulty selector """
return _('Difficulty')
@property
def allowed_values(self):
""" Allowed values for the learning outcome selector """
return {'test1': 'Test 1', 'test2': 'Test 2', 'test3': 'Test 3'}
""" Allowed values for the difficulty selector """
return OrderedDict([('easy', 'Easy'), ('medium', 'Medium'), ('hard', 'Hard')])
class StructuredTagsAside(XBlockAside):
......@@ -69,10 +72,16 @@ class StructuredTagsAside(XBlockAside):
saved_tags = Dict(help=_("Dictionary with the available tags"),
scope=Scope.content,
default={},)
available_tags = [LearningOutcomeTag()]
available_tags = [DifficultyTag()]
def _get_studio_resource_url(self, relative_url):
"""
Returns the Studio URL to a static resource.
"""
return settings.STATIC_URL + relative_url
@XBlockAside.aside_for(STUDENT_VIEW)
def student_view_aside(self, block, context):
def student_view_aside(self, block, context): # pylint: disable=unused-argument
"""
Display the tag selector with specific categories and allowed values,
depending on the context.
......@@ -86,7 +95,34 @@ class StructuredTagsAside(XBlockAside):
'values': tag.allowed_values,
'current_value': self.saved_tags.get(tag.key, None),
})
return Fragment(render_to_string('structured_tags_block.html', {'tags': tags}))
#return Fragment(u'<div class="xblock-render">Hello world!!!</div>')
fragment = Fragment(render_to_string('structured_tags_block.html', {'tags': tags}))
fragment.add_javascript_url(self._get_studio_resource_url('/js/xblock_asides/structured_tags.js'))
fragment.initialize_js('StructuredTagsInit')
return fragment
else:
return Fragment(u'')
@XBlock.handler
def save_tags(self, request=None, suffix=None): # pylint: disable=unused-argument
"""
Handler to save choosen tags with connected XBlock
"""
found = False
if 'tag' not in request.params:
return Response("The required parameter 'tag' is not passed", status=400)
tag = request.params['tag'].split(':')
for av_tag in self.available_tags:
if av_tag.key == tag[0]:
if tag[1] in av_tag.allowed_values:
self.saved_tags[tag[0]] = tag[1]
found = True
elif tag[1] == '':
self.saved_tags[tag[0]] = None
found = True
if not found:
return Response("Invalid 'tag' parameter", status=400)
return Response()
"""
Tests for the Studio Tagging XBlockAside
"""
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xblock_config.models import StudioConfig
from cms.lib.xblock.tagging import StructuredTagsAside
from contentstore.views.preview import get_preview_fragment
from contentstore.utils import reverse_usage_url
from contentstore.tests.utils import AjaxEnabledTestClient
from django.test.client import RequestFactory
from student.tests.factories import UserFactory
from opaque_keys.edx.asides import AsideUsageKeyV1
from datetime import datetime
from pytz import UTC
from lxml import etree
from StringIO import StringIO
class StructuredTagsAsideTestCase(ModuleStoreTestCase):
"""
Base class for tests of StructuredTagsAside (tagging.py)
"""
def setUp(self):
"""
Preparation for the test execution
"""
self.user_password = super(StructuredTagsAsideTestCase, self).setUp()
self.aside_name = 'tagging_aside'
self.aside_tag = 'difficulty_tag'
self.aside_tag_value = 'hard'
course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split)
self.course = ItemFactory.create(
parent_location=course.location,
category="course",
display_name="Test course",
)
self.chapter = ItemFactory.create(
parent_location=self.course.location,
category='chapter',
display_name="Week 1",
publish_item=True,
start=datetime(2015, 3, 1, tzinfo=UTC),
)
self.sequential = ItemFactory.create(
parent_location=self.chapter.location,
category='sequential',
display_name="Lesson 1",
publish_item=True,
start=datetime(2015, 3, 1, tzinfo=UTC),
)
self.vertical = ItemFactory.create(
parent_location=self.sequential.location,
category='vertical',
display_name='Subsection 1',
publish_item=True,
start=datetime(2015, 4, 1, tzinfo=UTC),
)
self.problem = ItemFactory.create(
category="problem",
parent_location=self.vertical.location,
display_name="A Problem Block",
weight=1,
user_id=self.user.id,
publish_item=False,
)
self.video = ItemFactory.create(
parent_location=self.vertical.location,
category='video',
display_name='My Video',
user_id=self.user.id
)
config = StudioConfig.current()
config.enabled = True
config.save()
def test_aside_contains_tags(self):
"""
Checks that available_tags list is not empty
"""
self.assertGreater(len(StructuredTagsAside.available_tags), 0,
"StructuredTagsAside should contains at least one available tag")
def test_preview_html(self):
"""
Checks that html for the StructuredTagsAside is generated correctly
"""
request = RequestFactory().get('/dummy-url')
request.user = UserFactory()
request.session = {}
# Call get_preview_fragment directly.
context = {
'reorderable_items': set(),
'read_only': True
}
problem_html = get_preview_fragment(request, self.problem, context).content
parser = etree.HTMLParser()
tree = etree.parse(StringIO(problem_html), parser)
main_div_nodes = tree.xpath('/html/body/div/section/div')
self.assertEquals(len(main_div_nodes), 1)
div_node = main_div_nodes[0]
self.assertEquals(div_node.get('data-init'), 'StructuredTagsInit')
self.assertEquals(div_node.get('data-runtime-class'), 'PreviewRuntime')
self.assertEquals(div_node.get('data-block-type'), 'tagging_aside')
self.assertEquals(div_node.get('data-runtime-version'), '1')
self.assertIn('xblock_asides-v1', div_node.get('class'))
select_nodes = div_node.xpath('div/select')
self.assertEquals(len(select_nodes), 1)
select_node = select_nodes[0]
self.assertEquals(select_node.get('name'), self.aside_tag)
# Now ensure the acid_aside is not in the result
self.assertNotRegexpMatches(problem_html, r"data-block-type=[\"\']acid_aside[\"\']")
# Ensure about video don't have asides
video_html = get_preview_fragment(request, self.video, context).content
self.assertNotRegexpMatches(video_html, "<select")
def test_handle_requests(self):
"""
Checks that handler to save tags in StructuredTagsAside works properly
"""
handler_url = reverse_usage_url(
'preview_handler',
'%s:%s::%s' % (AsideUsageKeyV1.CANONICAL_NAMESPACE, self.problem.location, self.aside_name),
kwargs={'handler': 'save_tags'}
)
client = AjaxEnabledTestClient()
client.login(username=self.user.username, password=self.user_password)
response = client.post(path=handler_url, data={})
self.assertEqual(response.status_code, 400)
response = client.post(path=handler_url, data={'tag': 'undefined_tag:undefined'})
self.assertEqual(response.status_code, 400)
val = '%s:undefined' % self.aside_tag
response = client.post(path=handler_url, data={'tag': val})
self.assertEqual(response.status_code, 400)
val = '%s:%s' % (self.aside_tag, self.aside_tag_value)
response = client.post(path=handler_url, data={'tag': val})
self.assertEqual(response.status_code, 200)
problem = modulestore().get_item(self.problem.location)
asides = problem.runtime.get_asides(problem)
tag_aside = None
for aside in asides:
if isinstance(aside, StructuredTagsAside):
tag_aside = aside
break
self.assertIsNotNone(tag_aside, "Necessary StructuredTagsAside object isn't found")
self.assertEqual(tag_aside.saved_tags[self.aside_tag], self.aside_tag_value)
......@@ -331,7 +331,8 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "common/j
success: function() {
self.onXBlockRefresh(temporaryView, block_added, is_duplicate);
temporaryView.unbind(); // Remove the temporary view
}
},
initRuntimeData: this
});
},
......
......@@ -30,6 +30,13 @@ define(["jquery", "underscore", "common/js/components/utils/view_utils", "js/vie
});
},
initRuntimeData: function(xblock, options) {
if (options && options.initRuntimeData && xblock && xblock.runtime && !xblock.runtime.page) {
xblock.runtime.page = options.initRuntimeData;
}
return xblock;
},
handleXBlockFragment: function(fragment, options) {
var self = this,
wrapper = this.$el,
......@@ -44,8 +51,14 @@ define(["jquery", "underscore", "common/js/components/utils/view_utils", "js/vie
xblockElement = self.$('.xblock').first();
try {
xblock = XBlock.initializeBlock(xblockElement);
self.xblock = xblock;
self.xblockReady(xblock);
self.xblock = self.initRuntimeData(xblock, options);
self.xblockReady(self.xblock);
self.$('.xblock_asides-v1').each(function() {
if (!$(this).hasClass('xblock-initialized')) {
var aside = XBlock.initializeBlock($(this));
self.initRuntimeData(aside, options);
}
});
if (successCallback) {
successCallback(xblock);
}
......@@ -76,6 +89,15 @@ define(["jquery", "underscore", "common/js/components/utils/view_utils", "js/vie
var runtime = this.xblock && this.xblock.runtime;
if (runtime) {
runtime.notify(eventName, data);
} else if (this.xblock) {
var xblock_children = this.xblock.element && $(this.xblock.element).prop('xblock_children');
if (xblock_children) {
$(xblock_children).each(function () {
if (this.runtime) {
this.runtime.notify(eventName, data);
}
});
}
}
},
......
(function($) {
'use strict';
function StructuredTagsView(runtime, element) {
var $element = $(element);
$element.find("select").each(function() {
var loader = this;
var sts = $(this).attr('structured-tags-select-init');
if (typeof sts === typeof undefined || sts === false) {
$(this).attr('structured-tags-select-init', 1);
$(this).change(function(e) {
e.preventDefault();
var selectedKey = $(loader).find('option:selected').val();
runtime.notify('save', {
state: 'start',
element: element,
message: gettext('Updating Tags')
});
$.post(runtime.handlerUrl(element, 'save_tags'), {
'tag': $(loader).attr('name') + ':' + selectedKey
}).done(function() {
runtime.notify('save', {
state: 'end',
element: element
});
});
});
}
});
}
function initializeStructuredTags(runtime, element) {
return new StructuredTagsView(runtime, element);
}
window.StructuredTagsInit = initializeStructuredTags;
})($);
<div class="xblock-render">
% for tag in tags:
<label for="problem_tags_${tag['key']}">${tag['title']}</label>:
<select id="problem_tags_${tag['key']}" name="problem_tags_${tag['key']}">
<select id="problem_tags_${tag['key']}" name="${tag['key']}">
<option value="" ${'' if tag['current_value'] else 'selected=""'}>Not selected</option>
% for k,v in tag['values'].iteritems():
<%
selected = ''
......
......@@ -428,7 +428,8 @@ class BlockData(object):
'block_type': self.block_type,
'definition': self.definition,
'defaults': self.defaults,
'edit_info': self.edit_info.to_storable()
'asides': self.get_asides(),
'edit_info': self.edit_info.to_storable(),
}
def from_storable(self, block_data):
......@@ -449,9 +450,21 @@ class BlockData(object):
# blocks are copied from a library to a course)
self.defaults = block_data.get('defaults', {})
# Additional field data that stored in connected XBlockAsides
self.asides = block_data.get('asides', {})
# EditInfo object containing all versioning/editing data.
self.edit_info = EditInfo(**block_data.get('edit_info', {}))
def get_asides(self):
"""
For the situations if block_data has no asides attribute
(in case it was taken from memcache)
"""
if not hasattr(self, 'asides'):
self.asides = {} # pylint: disable=attribute-defined-outside-init
return self.asides
def __repr__(self):
# pylint: disable=bad-continuation, redundant-keyword-arg
return ("{classname}(fields={self.fields}, "
......@@ -459,17 +472,19 @@ class BlockData(object):
"definition={self.definition}, "
"definition_loaded={self.definition_loaded}, "
"defaults={self.defaults}, "
"asides={asides}, "
"edit_info={self.edit_info})").format(
self=self,
classname=self.__class__.__name__,
asides=self.get_asides()
) # pylint: disable=bad-continuation
def __eq__(self, block_data):
"""
Two BlockData objects are equal iff all their attributes are equal.
"""
attrs = ['fields', 'block_type', 'definition', 'defaults', 'edit_info']
return all(getattr(self, attr) == getattr(block_data, attr) for attr in attrs)
attrs = ['fields', 'block_type', 'definition', 'defaults', 'asides', 'edit_info']
return all(getattr(self, attr, None) == getattr(block_data, attr, None) for attr in attrs)
def __neq__(self, block_data):
"""
......
......@@ -95,6 +95,40 @@ def strip_key(func):
return inner
def prepare_asides(func):
"""
A decorator to handle optional asides param
"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
"""
Supported kwargs:
asides - list with connected asides data for the passed block
"""
if 'asides' in kwargs:
kwargs['asides'] = prepare_asides_to_store(kwargs['asides'])
return func(*args, **kwargs)
return wrapper
def prepare_asides_to_store(asides_source):
"""
Convert Asides Xblocks objects to the list of dicts (to store this information in MongoDB)
"""
asides = None
if asides_source:
asides = []
for asd in asides_source:
aside_fields = {}
for asd_field_key, asd_field_val in asd.fields.iteritems():
aside_fields[asd_field_key] = asd_field_val.read_from(asd)
asides.append({
'aside_type': asd.scope_ids.block_type,
'fields': aside_fields
})
return asides
class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
"""
ModuleStore knows how to route requests to the right persistence ms
......@@ -687,6 +721,7 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
))
@strip_key
@prepare_asides
def create_item(self, user_id, course_key, block_type, block_id=None, fields=None, **kwargs):
"""
Creates and saves a new item in a course.
......@@ -707,6 +742,7 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
return modulestore.create_item(user_id, course_key, block_type, block_id=block_id, fields=fields, **kwargs)
@strip_key
@prepare_asides
def create_child(self, user_id, parent_usage_key, block_type, block_id=None, fields=None, **kwargs):
"""
Creates and saves a new xblock that is a child of the specified block
......@@ -727,6 +763,7 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
return modulestore.create_child(user_id, parent_usage_key, block_type, block_id=block_id, fields=fields, **kwargs)
@strip_key
@prepare_asides
def import_xblock(self, user_id, course_key, block_type, block_id, fields=None, runtime=None, **kwargs):
"""
See :py:meth `ModuleStoreDraftAndPublished.import_xblock`
......@@ -734,7 +771,7 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
Defer to the course's modulestore if it supports this method
"""
store = self._verify_modulestore_support(course_key, 'import_xblock')
return store.import_xblock(user_id, course_key, block_type, block_id, fields, runtime)
return store.import_xblock(user_id, course_key, block_type, block_id, fields, runtime, **kwargs)
@strip_key
def copy_from_template(self, source_keys, dest_key, user_id, **kwargs):
......@@ -745,6 +782,7 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
return store.copy_from_template(source_keys, dest_key, user_id)
@strip_key
@prepare_asides
def update_item(self, xblock, user_id, allow_not_found=False, **kwargs):
"""
Update the xblock persisted to be the same as the given for all types of fields
......
......@@ -3,7 +3,7 @@ import logging
from contracts import contract, new_contract
from fs.osfs import OSFS
from lazy import lazy
from xblock.runtime import KvsFieldData
from xblock.runtime import KvsFieldData, KeyValueStore
from xblock.fields import ScopeIds
from xblock.core import XBlock
from opaque_keys.edx.locator import BlockUsageLocator, LocalId, CourseLocator, LibraryLocator, DefinitionLocator
......@@ -19,6 +19,7 @@ from xmodule.modulestore.split_mongo import BlockKey, CourseEnvelope
from xmodule.modulestore.split_mongo.id_manager import SplitMongoIdManager
from xmodule.modulestore.split_mongo.definition_lazy_loader import DefinitionLazyLoader
from xmodule.modulestore.split_mongo.split_mongo_kvs import SplitMongoKVS
from xmodule.x_module import XModuleMixin
log = logging.getLogger(__name__)
......@@ -209,12 +210,26 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
parent = course_key.make_usage_key(parent_key.type, parent_key.id)
else:
parent = None
aside_fields = None
# for the situation if block_data has no asides attribute
# (in case it was taken from memcache)
try:
if block_data.asides:
aside_fields = {block_key.type: {}}
for aside in block_data.asides:
aside_fields[block_key.type].update(aside['fields'])
except AttributeError:
pass
try:
kvs = SplitMongoKVS(
definition_loader,
converted_fields,
converted_defaults,
parent=parent,
aside_fields=aside_fields,
field_decorator=kwargs.get('field_decorator')
)
......@@ -338,3 +353,30 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
block_data.edit_info._subtree_edited_on = max_date
block_data.edit_info._subtree_edited_by = max_date_by
def get_aside_of_type(self, block, aside_type):
"""
See `runtime.Runtime.get_aside_of_type`
This override adds the field data from the block to the aside
"""
asides_cached = block.get_asides() if isinstance(block, XModuleMixin) else None
if asides_cached:
for aside in asides_cached:
if aside.scope_ids.block_type == aside_type:
return aside
new_aside = super(CachingDescriptorSystem, self).get_aside_of_type(block, aside_type)
new_aside._field_data = block._field_data # pylint: disable=protected-access
for key, _ in new_aside.fields.iteritems():
if isinstance(key, KeyValueStore.Key) and block._field_data.has(new_aside, key): # pylint: disable=protected-access
try:
value = block._field_data.get(new_aside, key) # pylint: disable=protected-access
except KeyError:
pass
else:
setattr(new_aside, key, value)
block.add_aside(new_aside)
return new_aside
......@@ -1604,11 +1604,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
serial += 1
@contract(returns='XBlock')
def create_item(
self, user_id, course_key, block_type, block_id=None,
definition_locator=None, fields=None,
force=False, **kwargs
):
def create_item(self, user_id, course_key, block_type, block_id=None, definition_locator=None, fields=None,
asides=None, force=False, **kwargs):
"""
Add a descriptor to persistence as an element
of the course. Return the resulting post saved version with populated locators.
......@@ -1695,6 +1692,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
block_fields,
definition_locator.definition_id,
new_id,
asides=asides
))
self.update_structure(course_key, new_structure)
......@@ -1723,7 +1721,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
# reconstruct the new_item from the cache
return self.get_item(item_loc)
def create_child(self, user_id, parent_usage_key, block_type, block_id=None, fields=None, **kwargs):
def create_child(self, user_id, parent_usage_key, block_type, block_id=None, fields=None, asides=None, **kwargs):
"""
Creates and saves a new xblock that as a child of the specified block
......@@ -1738,10 +1736,12 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
a new identifier will be generated
fields (dict): A dictionary specifying initial values for some or all fields
in the newly created block
asides (dict): A dictionary specifying initial values for some or all aside fields
in the newly created block
"""
with self.bulk_operations(parent_usage_key.course_key):
xblock = self.create_item(
user_id, parent_usage_key.course_key, block_type, block_id=block_id, fields=fields,
user_id, parent_usage_key.course_key, block_type, block_id=block_id, fields=fields, asides=asides,
**kwargs)
# skip attach to parent if xblock has 'detached' tag
......@@ -1986,10 +1986,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
partitioned_fields, descriptor.definition_locator, allow_not_found, force, **kwargs
) or descriptor
def _update_item_from_fields(
self, user_id, course_key, block_key, partitioned_fields,
definition_locator, allow_not_found, force, **kwargs
):
def _update_item_from_fields(self, user_id, course_key, block_key, partitioned_fields, # pylint: disable=too-many-statements
definition_locator, allow_not_found, force, asides=None, **kwargs):
"""
Broke out guts of update_item for short-circuited internal use only
"""
......@@ -1999,7 +1997,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
for subfields in partitioned_fields.itervalues():
fields.update(subfields)
return self.create_item(
user_id, course_key, block_key.type, fields=fields, force=force
user_id, course_key, block_key.type, fields=fields, asides=asides, force=force
)
original_structure = self._lookup_course(course_key).structure
......@@ -2011,9 +2009,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
fields = {}
for subfields in partitioned_fields.itervalues():
fields.update(subfields)
return self.create_item(
user_id, course_key, block_key.type, block_id=block_key.id, fields=fields, force=force,
)
return self.create_item(user_id, course_key, block_key.type, block_id=block_key.id, fields=fields,
asides=asides, force=force)
else:
raise ItemNotFoundError(course_key.make_usage_key(block_key.type, block_key.id))
......@@ -2039,14 +2036,24 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
if is_updated:
settings['children'] = serialized_children
asides_data_to_update = None
if asides:
asides_data_to_update, asides_updated = self._get_asides_to_update_from_structure(original_structure,
block_key, asides)
else:
asides_updated = False
# if updated, rev the structure
if is_updated:
if is_updated or asides_updated:
new_structure = self.version_structure(course_key, original_structure, user_id)
block_data = self._get_block_from_structure(new_structure, block_key)
block_data.definition = definition_locator.definition_id
block_data.fields = settings
if asides_updated:
block_data.asides = asides_data_to_update
new_id = new_structure['_id']
# source_version records which revision a block was copied from. In this method, we're updating
......@@ -3215,7 +3222,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
self._delete_if_true_orphan(BlockKey(*child), structure)
@contract(returns=BlockData)
def _new_block(self, user_id, category, block_fields, definition_id, new_id, raw=False, block_defaults=None):
def _new_block(self, user_id, category, block_fields, definition_id, new_id, raw=False,
asides=None, block_defaults=None):
"""
Create the core document structure for a block.
......@@ -3226,10 +3234,13 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
"""
if not raw:
block_fields = self._serialize_fields(category, block_fields)
if not asides:
asides = []
document = {
'block_type': category,
'definition': definition_id,
'fields': block_fields,
'asides': asides,
'edit_info': {
'edited_on': datetime.datetime.now(UTC),
'edited_by': user_id,
......@@ -3249,6 +3260,38 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
"""
return structure['blocks'].get(block_key)
@contract(block_key=BlockKey)
def _get_asides_to_update_from_structure(self, structure, block_key, asides):
"""
Get list of aside fields that should be updated/inserted
"""
block = self._get_block_from_structure(structure, block_key)
if asides:
updated = False
tmp_new_asides_data = {}
for asd in asides:
aside_type = asd['aside_type']
tmp_new_asides_data[aside_type] = asd
result_list = []
for i, aside in enumerate(block.asides):
if aside['aside_type'] in tmp_new_asides_data:
result_list.append(tmp_new_asides_data.pop(aside['aside_type']))
updated = True
else:
result_list.append(aside)
if tmp_new_asides_data:
for _, asd in tmp_new_asides_data.iteritems():
result_list.append(asd)
updated = True
return result_list, updated
else:
return block.asides, False
@contract(block_key=BlockKey, content=BlockData)
def _update_block_in_structure(self, structure, block_key, content):
"""
......
......@@ -137,7 +137,7 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
keys_to_check.extend(children)
return new_keys
def update_item(self, descriptor, user_id, allow_not_found=False, force=False, **kwargs):
def update_item(self, descriptor, user_id, allow_not_found=False, force=False, asides=None, **kwargs):
old_descriptor_locn = descriptor.location
descriptor.location = self._map_revision_to_branch(old_descriptor_locn)
emit_signals = descriptor.location.branch == ModuleStoreEnum.BranchName.published \
......@@ -149,17 +149,15 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
user_id,
allow_not_found=allow_not_found,
force=force,
asides=asides,
**kwargs
)
self._auto_publish_no_children(item.location, item.location.category, user_id, **kwargs)
descriptor.location = old_descriptor_locn
return item
def create_item(
self, user_id, course_key, block_type, block_id=None,
definition_locator=None, fields=None,
force=False, skip_auto_publish=False, **kwargs
):
def create_item(self, user_id, course_key, block_type, block_id=None, # pylint: disable=too-many-statements
definition_locator=None, fields=None, asides=None, force=False, skip_auto_publish=False, **kwargs):
"""
See :py:meth `ModuleStoreDraftAndPublished.create_item`
"""
......@@ -169,7 +167,7 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
with self.bulk_operations(course_key, emit_signals=emit_signals):
item = super(DraftVersioningModuleStore, self).create_item(
user_id, course_key, block_type, block_id=block_id,
definition_locator=definition_locator, fields=fields,
definition_locator=definition_locator, fields=fields, asides=asides,
force=force, **kwargs
)
if not skip_auto_publish:
......@@ -178,13 +176,13 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
def create_child(
self, user_id, parent_usage_key, block_type, block_id=None,
fields=None, **kwargs
fields=None, asides=None, **kwargs
):
parent_usage_key = self._map_revision_to_branch(parent_usage_key)
with self.bulk_operations(parent_usage_key.course_key):
item = super(DraftVersioningModuleStore, self).create_child(
user_id, parent_usage_key, block_type, block_id=block_id,
fields=fields, **kwargs
fields=fields, asides=asides, **kwargs
)
# Publish both the child and the parent, if the child is a direct-only category
self._auto_publish_no_children(item.location, item.location.category, user_id, **kwargs)
......@@ -552,14 +550,16 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
draft_course = course_key.for_branch(ModuleStoreEnum.BranchName.draft)
with self.branch_setting(ModuleStoreEnum.Branch.draft_preferred, draft_course):
# Importing the block and publishing the block links the draft & published blocks' version history.
draft_block = self.import_xblock(user_id, draft_course, block_type, block_id, fields, runtime)
draft_block = self.import_xblock(user_id, draft_course, block_type, block_id, fields,
runtime, **kwargs)
return self.publish(draft_block.location.version_agnostic(), user_id, blacklist=EXCLUDE_ALL, **kwargs)
# do the import
partitioned_fields = self.partition_fields_by_scope(block_type, fields)
course_key = self._map_revision_to_branch(course_key) # cast to branch_setting
return self._update_item_from_fields(
user_id, course_key, BlockKey(block_type, block_id), partitioned_fields, None, allow_not_found=True, force=True
user_id, course_key, BlockKey(block_type, block_id), partitioned_fields, None,
allow_not_found=True, force=True, **kwargs
) or self.get_item(new_usage_key)
def compute_published_info_internal(self, xblock):
......
......@@ -6,6 +6,7 @@ from xblock.exceptions import InvalidScopeError
from .definition_lazy_loader import DefinitionLazyLoader
from xmodule.modulestore.inheritance import InheritanceKeyValueStore
from opaque_keys.edx.locator import BlockUsageLocator
from xblock.core import XBlockAside
# id is a BlockUsageLocator, def_id is the definition's guid
SplitMongoKVSid = namedtuple('SplitMongoKVSid', 'id, def_id')
......@@ -21,7 +22,7 @@ class SplitMongoKVS(InheritanceKeyValueStore):
VALID_SCOPES = (Scope.parent, Scope.children, Scope.settings, Scope.content)
@contract(parent="BlockUsageLocator | None")
def __init__(self, definition, initial_values, default_values, parent, field_decorator=None):
def __init__(self, definition, initial_values, default_values, parent, aside_fields=None, field_decorator=None):
"""
:param definition: either a lazyloader or definition id for the definition
......@@ -42,34 +43,52 @@ class SplitMongoKVS(InheritanceKeyValueStore):
self.field_decorator = field_decorator
self.parent = parent
self.aside_fields = aside_fields if aside_fields else {}
def get(self, key):
# load the field, if needed
if key.field_name not in self._fields:
# parent undefined in editing runtime (I think)
if key.scope == Scope.parent:
return self.parent
if key.scope == Scope.children:
# didn't find children in _fields; so, see if there's a default
raise KeyError()
elif key.scope == Scope.settings:
# get default which may be the inherited value
raise KeyError()
elif key.scope == Scope.content:
if isinstance(self._definition, DefinitionLazyLoader):
self._load_definition()
else:
raise KeyError()
else:
if key.block_family == XBlockAside.entry_point:
if key.scope not in [Scope.settings, Scope.content]:
raise InvalidScopeError(key, self.VALID_SCOPES)
if key.field_name in self._fields:
field_value = self._fields[key.field_name]
if key.block_scope_id.block_type not in self.aside_fields:
# load the definition to see if it has the aside_fields
self._load_definition()
if key.block_scope_id.block_type not in self.aside_fields:
raise KeyError()
aside_fields = self.aside_fields[key.block_scope_id.block_type]
# load the field, if needed
if key.field_name not in aside_fields:
self._load_definition()
if key.field_name in aside_fields:
return self.field_decorator(aside_fields[key.field_name])
raise KeyError()
else:
# load the field, if needed
if key.field_name not in self._fields:
if key.scope == Scope.parent:
return self.parent
if key.scope == Scope.children:
# didn't find children in _fields; so, see if there's a default
raise KeyError()
elif key.scope == Scope.settings:
# get default which may be the inherited value
raise KeyError()
elif key.scope == Scope.content:
if isinstance(self._definition, DefinitionLazyLoader):
self._load_definition()
else:
raise KeyError()
else:
raise InvalidScopeError(key)
# return the "decorated" field value
return self.field_decorator(field_value)
if key.field_name in self._fields:
field_value = self._fields[key.field_name]
# return the "decorated" field value
return self.field_decorator(field_value)
return None
return None
def set(self, key, value):
# handle any special cases
......@@ -78,17 +97,23 @@ class SplitMongoKVS(InheritanceKeyValueStore):
if key.scope == Scope.content:
self._load_definition()
# set the field
self._fields[key.field_name] = value
if key.block_family == XBlockAside.entry_point:
if key.scope == Scope.children:
raise InvalidScopeError(key)
# This function is currently incomplete: it doesn't handle side effects.
# To complete this function, here is some pseudocode for what should happen:
#
# if key.scope == Scope.children:
# remove inheritance from any exchildren
# add inheritance to any new children
# if key.scope == Scope.settings:
# if inheritable, push down to children
self.aside_fields.setdefault(key.block_scope_id.block_type, {})[key.field_name] = value
else:
# set the field
self._fields[key.field_name] = value
# This function is currently incomplete: it doesn't handle side effects.
# To complete this function, here is some pseudocode for what should happen:
#
# if key.scope == Scope.children:
# remove inheritance from any exchildren
# add inheritance to any new children
# if key.scope == Scope.settings:
# if inheritable, push down to children
def delete(self, key):
# handle any special cases
......@@ -97,9 +122,17 @@ class SplitMongoKVS(InheritanceKeyValueStore):
if key.scope == Scope.content:
self._load_definition()
# delete the field value
if key.field_name in self._fields:
del self._fields[key.field_name]
if key.block_family == XBlockAside.entry_point:
if key.scope == Scope.children:
raise InvalidScopeError(key)
if key.block_scope_id.block_type in self.aside_fields \
and key.field_name in self.aside_fields[key.block_scope_id.block_type]:
del self.aside_fields[key.block_scope_id.block_type][key.field_name]
else:
# delete the field value
if key.field_name in self._fields:
del self._fields[key.field_name]
def has(self, key):
"""
......@@ -111,9 +144,16 @@ class SplitMongoKVS(InheritanceKeyValueStore):
elif key.scope == Scope.parent:
return True
# it's not clear whether inherited values should return True. Right now they don't
# if someone changes it so that they do, then change any tests of field.name in xx._field_data
return key.field_name in self._fields
if key.block_family == XBlockAside.entry_point:
if key.scope == Scope.children:
return False
b_type = key.block_scope_id.block_type
return b_type in self.aside_fields and key.field_name in self.aside_fields[b_type]
else:
# it's not clear whether inherited values should return True. Right now they don't
# if someone changes it so that they do, then change any tests of field.name in xx._field_data
return key.field_name in self._fields
def default(self, key):
"""
......@@ -134,5 +174,10 @@ class SplitMongoKVS(InheritanceKeyValueStore):
if persisted_definition is not None:
fields = self._definition.field_converter(persisted_definition.get('fields'))
self._fields.update(fields)
aside_fields_p = persisted_definition.get('aside_fields')
if aside_fields_p:
aside_fields = self._definition.field_converter(aside_fields_p)
for aside_type, fields in aside_fields.iteritems():
self.aside_fields.setdefault(aside_type, {}).update(fields)
# do we want to cache any of the edit_info?
self._definition = None # already loaded
......@@ -6,6 +6,7 @@ import ddt
import itertools
from collections import namedtuple
from xmodule.course_module import CourseSummary
from mock import patch
from xmodule.modulestore.tests.utils import (
PureModulestoreTestCase, MongoModulestoreBuilder,
......@@ -15,7 +16,10 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.draft_and_published import DIRECT_ONLY_CATEGORIES
from xblock.core import XBlock
from xblock.core import XBlock, XBlockAside
from xblock.fields import Scope, String
from xblock.runtime import DictKeyValueStore, KvsFieldData
from xblock.test.tools import TestRuntime
DETACHED_BLOCK_TYPES = dict(XBlock.load_tagged_classes('detached'))
......@@ -26,6 +30,13 @@ TESTABLE_BLOCK_TYPES.discard('course')
TestField = namedtuple('TestField', ['field_name', 'initial', 'updated'])
class AsideTest(XBlockAside):
"""
Test xblock aside class
"""
content = String(default="content", scope=Scope.content)
@ddt.ddt
class DirectOnlyCategorySemantics(PureModulestoreTestCase):
"""
......@@ -43,6 +54,8 @@ class DirectOnlyCategorySemantics(PureModulestoreTestCase):
'course_info': TestField('data', '<div>test data</div>', '<div>different test data</div>'),
}
ASIDE_DATA_FIELD = TestField('content', '<div>aside test data</div>', '<div>aside different test data</div>')
def setUp(self):
super(DirectOnlyCategorySemantics, self).setUp()
self.course = CourseFactory.create(
......@@ -73,7 +86,8 @@ class DirectOnlyCategorySemantics(PureModulestoreTestCase):
with self.assertRaises(ItemNotFoundError):
self.store.get_item(block_usage_key)
def assertBlockHasContent(self, block_usage_key, field_name, content, draft=None):
def assertBlockHasContent(self, block_usage_key, field_name, content,
aside_field_name=None, aside_content=None, draft=None):
"""
Assert that the block ``block_usage_key`` has the value ``content`` for ``field_name``
when it is loaded.
......@@ -82,6 +96,8 @@ class DirectOnlyCategorySemantics(PureModulestoreTestCase):
block_usage_key: The xblock to check.
field_name (string): The name of the field to check.
content: The value to assert is in the field.
aside_field_name (string): The name of the field to check (in connected xblock aside)
aside_content: The value to assert is in the xblock aside field.
draft (optional): If omitted, verify both published and draft branches.
If True, verify only the draft branch. If False, verify only the
published branch.
......@@ -92,6 +108,10 @@ class DirectOnlyCategorySemantics(PureModulestoreTestCase):
block_usage_key,
)
self.assertEquals(content, target_block.fields[field_name].read_from(target_block))
if aside_field_name and aside_content:
aside = self._get_aside(target_block)
self.assertIsNotNone(aside)
self.assertEquals(aside_content, aside.fields[aside_field_name].read_from(aside))
if draft is None or draft:
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
......@@ -99,6 +119,10 @@ class DirectOnlyCategorySemantics(PureModulestoreTestCase):
block_usage_key,
)
self.assertEquals(content, target_block.fields[field_name].read_from(target_block))
if aside_field_name and aside_content:
aside = self._get_aside(target_block)
self.assertIsNotNone(aside)
self.assertEquals(aside_content, aside.fields[aside_field_name].read_from(aside))
def assertParentOf(self, parent_usage_key, child_usage_key, draft=None):
"""
......@@ -202,9 +226,29 @@ class DirectOnlyCategorySemantics(PureModulestoreTestCase):
def test_create(self, block_type):
self._do_create(block_type)
def _prepare_asides(self, scope_ids):
"""
Return list with connected aside xblocks
"""
key_store = DictKeyValueStore()
field_data = KvsFieldData(key_store)
aside = AsideTest(scope_ids=scope_ids, runtime=TestRuntime(services={'field-data': field_data})) # pylint: disable=abstract-class-instantiated
aside.fields[self.ASIDE_DATA_FIELD.field_name].write_to(aside, self.ASIDE_DATA_FIELD.initial)
return [aside]
def _get_aside(self, block):
"""
Return connected xblock aside
"""
for aside in block.runtime.get_asides(block):
if isinstance(aside, AsideTest):
return aside
return None
# This function is split out from the test_create method so that it can be called
# by other tests
def _do_create(self, block_type):
def _do_create(self, block_type, with_asides=False):
"""
Create a block of block_type (which should be a DIRECT_ONLY_CATEGORY),
and then verify that it was created successfully, and is visible in
......@@ -228,21 +272,33 @@ class DirectOnlyCategorySemantics(PureModulestoreTestCase):
block_id=block_usage_key.block_id
)
block.fields[test_data.field_name].write_to(block, initial_field_value)
self.store.update_item(block, ModuleStoreEnum.UserID.test, allow_not_found=True)
asides = []
if with_asides:
asides = self._prepare_asides(self.course.scope_ids.usage_id)
self.store.update_item(block, ModuleStoreEnum.UserID.test, asides=asides, allow_not_found=True)
else:
block = self.store.create_child(
asides = []
if with_asides:
asides = self._prepare_asides(self.course.scope_ids.usage_id)
self.store.create_child(
user_id=ModuleStoreEnum.UserID.test,
parent_usage_key=self.course.scope_ids.usage_id,
block_type=block_type,
block_id=block_usage_key.block_id,
fields={test_data.field_name: initial_field_value},
asides=asides
)
if self.is_detached(block_type):
self.assertCourseDoesntPointToBlock(block_usage_key)
else:
self.assertCoursePointsToBlock(block_usage_key)
self.assertBlockHasContent(block_usage_key, test_data.field_name, initial_field_value)
if with_asides:
self.assertBlockHasContent(block_usage_key, test_data.field_name, initial_field_value,
self.ASIDE_DATA_FIELD.field_name, self.ASIDE_DATA_FIELD.initial)
else:
self.assertBlockHasContent(block_usage_key, test_data.field_name, initial_field_value)
return block_usage_key
......@@ -354,6 +410,7 @@ class DirectOnlyCategorySemantics(PureModulestoreTestCase):
self.assertBlockDoesntExist(child_usage_key)
@ddt.ddt
class TestSplitDirectOnlyCategorySemantics(DirectOnlyCategorySemantics):
"""
Verify DIRECT_ONLY_CATEGORY semantics against the SplitMongoModulestore.
......@@ -361,6 +418,32 @@ class TestSplitDirectOnlyCategorySemantics(DirectOnlyCategorySemantics):
MODULESTORE = SPLIT_MODULESTORE_SETUP
__test__ = True
@ddt.data(*TESTABLE_BLOCK_TYPES)
@XBlockAside.register_temp_plugin(AsideTest, 'test_aside')
@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types',
lambda self, block: ['test_aside'])
def test_create_with_asides(self, block_type):
self._do_create(block_type, with_asides=True)
@ddt.data(*TESTABLE_BLOCK_TYPES)
@XBlockAside.register_temp_plugin(AsideTest, 'test_aside')
@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types',
lambda self, block: ['test_aside'])
def test_update_asides(self, block_type):
block_usage_key = self._do_create(block_type, with_asides=True)
test_data = self.DATA_FIELDS[block_type]
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
block = self.store.get_item(block_usage_key)
aside = self._get_aside(block)
self.assertIsNotNone(aside)
aside.fields[self.ASIDE_DATA_FIELD.field_name].write_to(aside, self.ASIDE_DATA_FIELD.updated)
self.store.update_item(block, ModuleStoreEnum.UserID.test, allow_not_found=True, asides=[aside])
self.assertBlockHasContent(block_usage_key, test_data.field_name, test_data.initial,
self.ASIDE_DATA_FIELD.field_name, self.ASIDE_DATA_FIELD.updated)
class TestMongoDirectOnlyCategorySemantics(DirectOnlyCategorySemantics):
"""
......
......@@ -736,10 +736,11 @@ def _update_and_import_module(
)
fields = _update_module_references(module, source_course_id, dest_course_id)
asides = module.get_asides() if isinstance(module, XModuleMixin) else None
return store.import_xblock(
user_id, dest_course_id, module.location.category,
module.location.block_id, fields, runtime
module.location.block_id, fields, runtime, asides=asides
)
......
......@@ -287,7 +287,7 @@ class TestLibraryContentRender(LibraryContentTest):
"""
Rendering unit tests for LibraryContentModule
"""
def test_preivew_view(self):
def test_preview_view(self):
""" Test preview view rendering """
self.lc_block.refresh_children()
self.lc_block = self.store.get_item(self.lc_block.location)
......
......@@ -41,6 +41,7 @@ class InMemorySystem(XMLParsingSystem, MakoDescriptorSystem): # pylint: disable
def process_xml(self, xml): # pylint: disable=method-hidden
"""Parse `xml` as an XBlock, and add it to `self._descriptors`"""
self.get_asides = Mock(return_value=[])
descriptor = self.xblock_from_node(
etree.fromstring(xml),
None,
......
......@@ -298,6 +298,7 @@ class XModuleMixin(XModuleFields, XBlock):
def __init__(self, *args, **kwargs):
self.xmodule_runtime = None
self._asides = []
super(XModuleMixin, self).__init__(*args, **kwargs)
......@@ -382,6 +383,18 @@ class XModuleMixin(XModuleFields, XBlock):
"""
return self._field_data
def add_aside(self, aside):
"""
save connected asides
"""
self._asides.append(aside)
def get_asides(self):
"""
get the list of connected asides
"""
return self._asides
def get_explicitly_set_fields_by_scope(self, scope=Scope.content):
"""
Get a dictionary of the fields for the given scope which are set explicitly on this xblock. (Including
......@@ -1194,7 +1207,7 @@ class ConfigurableFragmentWrapper(object):
"""
Runtime mixin that allows for composition of many `wrap_xblock` wrappers
"""
def __init__(self, wrappers=None, **kwargs):
def __init__(self, wrappers=None, wrappers_asides=None, **kwargs):
"""
:param wrappers: A list of wrappers, where each wrapper is:
......@@ -1207,6 +1220,10 @@ class ConfigurableFragmentWrapper(object):
self.wrappers = wrappers
else:
self.wrappers = []
if wrappers_asides is not None:
self.wrappers_asides = wrappers_asides
else:
self.wrappers_asides = []
def wrap_xblock(self, block, view, frag, context):
"""
......@@ -1217,6 +1234,15 @@ class ConfigurableFragmentWrapper(object):
return frag
def wrap_aside(self, block, aside, view, frag, context): # pylint: disable=unused-argument
"""
See :func:`Runtime.wrap_child`
"""
for wrapper in self.wrappers_asides:
frag = wrapper(aside, view, frag, context)
return frag
# This function exists to give applications (LMS/CMS) a place to monkey-patch until
# we can refactor modulestore to split out the FieldData half of its interface from
......@@ -1524,13 +1550,19 @@ class XMLParsingSystem(DescriptorSystem):
keys = ScopeIds(None, block_type, def_id, usage_id)
block_class = self.mixologist.mix(self.load_block_type(block_type))
self.parse_asides(node, def_id, usage_id, id_generator)
aside_children = self.parse_asides(node, def_id, usage_id, id_generator)
asides_tags = [x.tag for x in aside_children]
block = block_class.parse_xml(node, self, keys, id_generator)
self._convert_reference_fields_to_keys(block) # difference from XBlock.runtime
block.parent = parent_id
block.save()
asides = self.get_asides(block)
for asd in asides:
if asd.scope_ids.block_type in asides_tags:
block.add_aside(asd)
return block
def parse_asides(self, node, def_id, usage_id, id_generator):
......@@ -1547,6 +1579,7 @@ class XMLParsingSystem(DescriptorSystem):
for child in aside_children:
self._aside_from_xml(child, def_id, usage_id, id_generator)
node.remove(child)
return aside_children
def _make_usage_key(self, course_key, value):
"""
......
......@@ -223,6 +223,7 @@ class XmlParserMixin(object):
if filename is None:
definition_xml = copy.deepcopy(xml_object)
filepath = ''
aside_children = []
else:
dog_stats_api.increment(
DEPRECATION_VSCOMPAT_EVENT,
......@@ -250,7 +251,7 @@ class XmlParserMixin(object):
definition_xml = cls.load_file(filepath, system.resources_fs, def_id)
usage_id = id_generator.create_usage(def_id)
system.parse_asides(definition_xml, def_id, usage_id, id_generator)
aside_children = system.parse_asides(definition_xml, def_id, usage_id, id_generator)
# Add the attributes from the pointer node
definition_xml.attrib.update(xml_object.attrib)
......@@ -262,6 +263,9 @@ class XmlParserMixin(object):
definition['definition_metadata'] = definition_metadata
definition['filename'] = [filepath, filename]
if aside_children:
definition['aside_children'] = aside_children
return definition, children
@classmethod
......@@ -333,6 +337,7 @@ class XmlParserMixin(object):
url_name = node.get('url_name', node.get('slug'))
def_id = id_generator.create_definition(node.tag, url_name)
usage_id = id_generator.create_usage(def_id)
aside_children = []
# VS[compat] -- detect new-style each-in-a-file mode
if is_pointer_tag(node):
......@@ -340,7 +345,7 @@ class XmlParserMixin(object):
# read the actual definition file--named using url_name.replace(':','/')
filepath = cls._format_filepath(node.tag, name_to_pathname(url_name))
definition_xml = cls.load_file(filepath, runtime.resources_fs, def_id)
runtime.parse_asides(definition_xml, def_id, usage_id, id_generator)
aside_children = runtime.parse_asides(definition_xml, def_id, usage_id, id_generator)
else:
filepath = None
definition_xml = node
......@@ -370,6 +375,10 @@ class XmlParserMixin(object):
log.debug('Error in loading metadata %r', dmdata, exc_info=True)
metadata['definition_metadata_err'] = str(err)
definition_aside_children = definition.pop('aside_children', None)
if definition_aside_children:
aside_children.extend(definition_aside_children)
# Set/override any metadata specified by policy
cls.apply_policy(metadata, runtime.get_policy(usage_id))
......@@ -382,13 +391,22 @@ class XmlParserMixin(object):
kvs = InheritanceKeyValueStore(initial_values=field_data)
field_data = KvsFieldData(kvs)
return runtime.construct_xblock_from_class(
xblock = runtime.construct_xblock_from_class(
cls,
# We're loading a descriptor, so student_id is meaningless
ScopeIds(None, node.tag, def_id, usage_id),
field_data,
)
if aside_children:
asides_tags = [x.tag for x in aside_children]
asides = runtime.get_asides(xblock)
for asd in asides:
if asd.scope_ids.block_type in asides_tags:
xblock.add_aside(asd)
return xblock
@classmethod
def _format_filepath(cls, category, name):
return u'{category}/{name}.{ext}'.format(category=category,
......
......@@ -92,6 +92,10 @@
var requestToken = requestToken || $element.data('request-token');
var children = XBlock.initializeXBlocks($element, requestToken);
var asides = XBlock.initializeXBlockAsides($element, requestToken);
if (asides) {
children = children.concat(asides);
}
$element.prop('xblock_children', children);
return constructBlock(element, [initArgs(element)]);
......@@ -132,8 +136,12 @@
* If neither is available, then use the request tokens of the immediateDescendent xblocks.
*/
initializeBlocks: function(element, requestToken) {
XBlock.initializeXBlockAsides(element, requestToken);
return XBlock.initializeXBlocks(element, requestToken);
var asides = XBlock.initializeXBlockAsides(element, requestToken);
var xblocks = XBlock.initializeXBlocks(element, requestToken);
if (asides) {
xblocks = xblocks.concat(asides);
}
return xblocks;
}
};
......
......@@ -143,6 +143,72 @@ def wrap_xblock(
return wrap_fragment(frag, render_to_string('xblock_wrapper.html', template_context))
def wrap_xblock_aside(
runtime_class,
aside,
view,
frag,
context, # pylint: disable=unused-argument
usage_id_serializer,
request_token, # pylint: disable=redefined-outer-name
extra_data=None
):
"""
Wraps the results of rendering an XBlockAside view in a standard <section> with identifying
data so that the appropriate javascript module can be loaded onto it.
:param runtime_class: The name of the javascript runtime class to use to load this block
:param aside: An XBlockAside
:param view: The name of the view that rendered the fragment being wrapped
:param frag: The :class:`Fragment` to be wrapped
:param context: The context passed to the view being rendered
:param usage_id_serializer: A function to serialize the block's usage_id for use by the
front-end Javascript Runtime.
:param request_token: An identifier that is unique per-request, so that only xblocks
rendered as part of this request will have their javascript initialized.
:param extra_data: A dictionary with extra data values to be set on the wrapper
"""
if extra_data is None:
extra_data = {}
data = {}
data.update(extra_data)
css_classes = [
'xblock-{}'.format(markupsafe.escape(view)),
'xblock-{}-{}'.format(
markupsafe.escape(view),
markupsafe.escape(aside.scope_ids.block_type),
),
'xblock_asides-v1'
]
if frag.js_init_fn:
data['init'] = frag.js_init_fn
data['runtime-class'] = runtime_class
data['runtime-version'] = frag.js_init_version
data['block-type'] = aside.scope_ids.block_type
data['usage-id'] = usage_id_serializer(aside.scope_ids.usage_id)
data['request-token'] = request_token
template_context = {
'content': frag.content,
'classes': css_classes,
'data_attributes': u' '.join(u'data-{}="{}"'.format(markupsafe.escape(key), markupsafe.escape(value))
for key, value in data.iteritems()),
}
if hasattr(frag, 'json_init_args') and frag.json_init_args is not None:
# Replace / with \/ so that "</script>" in the data won't break things.
template_context['js_init_parameters'] = json.dumps(frag.json_init_args).replace("/", r"\/")
else:
template_context['js_init_parameters'] = ""
return wrap_fragment(frag, render_to_string('xblock_wrapper.html', template_context))
def replace_jump_to_id_urls(course_id, jump_to_id_base_url, block, view, frag, context): # pylint: disable=unused-argument
"""
This will replace a link between courseware in the format
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment