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
This commit is contained in:
@@ -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()
|
||||
|
||||
# return the "decorated" field value
|
||||
return self.field_decorator(field_value)
|
||||
if key.field_name in aside_fields:
|
||||
return self.field_decorator(aside_fields[key.field_name])
|
||||
|
||||
return None
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -17,6 +17,7 @@ from mock import patch, Mock, call
|
||||
from django.conf import settings
|
||||
# This import breaks this test file when run separately. Needs to be fixed! (PLAT-449)
|
||||
from nose.plugins.attrib import attr
|
||||
from nose import SkipTest
|
||||
import pymongo
|
||||
from pytz import UTC
|
||||
from shutil import rmtree
|
||||
@@ -30,6 +31,12 @@ from xmodule.contentstore.content import StaticContent
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from xmodule.modulestore.xml_importer import import_course_from_xml
|
||||
from xmodule.modulestore.xml_exporter import export_course_to_xml
|
||||
from xmodule.modulestore.tests.test_asides import AsideTestType
|
||||
from xblock.core import XBlockAside
|
||||
from xblock.fields import Scope, String, ScopeIds
|
||||
from xblock.fragment import Fragment
|
||||
from xblock.runtime import DictKeyValueStore, KvsFieldData
|
||||
from xblock.test.tools import TestRuntime
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure()
|
||||
@@ -156,7 +163,7 @@ class CommonMixedModuleStoreSetup(CourseComparisonTest):
|
||||
self.user_id = ModuleStoreEnum.UserID.test
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def _create_course(self, course_key):
|
||||
def _create_course(self, course_key, asides=None):
|
||||
"""
|
||||
Create a course w/ one item in the persistence store using the given course & item location.
|
||||
"""
|
||||
@@ -169,7 +176,8 @@ class CommonMixedModuleStoreSetup(CourseComparisonTest):
|
||||
self.assertEqual(self.course.id, course_key)
|
||||
|
||||
# create chapter
|
||||
chapter = self.store.create_child(self.user_id, self.course.location, 'chapter', block_id='Overview')
|
||||
chapter = self.store.create_child(self.user_id, self.course.location, 'chapter',
|
||||
block_id='Overview', asides=asides)
|
||||
self.writable_chapter_location = chapter.location
|
||||
|
||||
def _create_block_hierarchy(self):
|
||||
@@ -296,6 +304,36 @@ class CommonMixedModuleStoreSetup(CourseComparisonTest):
|
||||
self.assertEquals(default, self.store.get_modulestore_type(self.course.id))
|
||||
|
||||
|
||||
class AsideFoo(XBlockAside):
|
||||
"""
|
||||
Test xblock aside class
|
||||
"""
|
||||
FRAG_CONTENT = u"<p>Aside Foo rendered</p>"
|
||||
|
||||
field11 = String(default="aside1_default_value1", scope=Scope.content)
|
||||
field12 = String(default="aside1_default_value2", scope=Scope.settings)
|
||||
|
||||
@XBlockAside.aside_for('student_view')
|
||||
def student_view_aside(self, block, context): # pylint: disable=unused-argument
|
||||
"""Add to the student view"""
|
||||
return Fragment(self.FRAG_CONTENT)
|
||||
|
||||
|
||||
class AsideBar(XBlockAside):
|
||||
"""
|
||||
Test xblock aside class
|
||||
"""
|
||||
FRAG_CONTENT = u"<p>Aside Bar rendered</p>"
|
||||
|
||||
field21 = String(default="aside2_default_value1", scope=Scope.content)
|
||||
field22 = String(default="aside2_default_value2", scope=Scope.settings)
|
||||
|
||||
@XBlockAside.aside_for('student_view')
|
||||
def student_view_aside(self, block, context): # pylint: disable=unused-argument
|
||||
"""Add to the student view"""
|
||||
return Fragment(self.FRAG_CONTENT)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@attr('mongo')
|
||||
class TestMixedModuleStore(CommonMixedModuleStoreSetup):
|
||||
@@ -2978,3 +3016,307 @@ class TestPublishOverExportImport(CommonMixedModuleStoreSetup):
|
||||
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only, source_course_key):
|
||||
component = self.store.get_item(unit.location)
|
||||
self.assertEqual(component.display_name, updated_display_name)
|
||||
|
||||
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
|
||||
@XBlockAside.register_temp_plugin(AsideTestType, 'test_aside')
|
||||
@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types',
|
||||
lambda self, block: ['test_aside'])
|
||||
def test_aside_crud(self, default_store):
|
||||
"""
|
||||
Check that asides could be imported from XML and the modulestores handle asides crud
|
||||
"""
|
||||
if default_store == ModuleStoreEnum.Type.mongo:
|
||||
raise SkipTest("asides not supported in old mongo")
|
||||
with MongoContentstoreBuilder().build() as contentstore:
|
||||
self.store = MixedModuleStore(
|
||||
contentstore=contentstore,
|
||||
create_modulestore_instance=create_modulestore_instance,
|
||||
mappings={},
|
||||
**self.OPTIONS
|
||||
)
|
||||
self.addCleanup(self.store.close_all_connections)
|
||||
with self.store.default_store(default_store):
|
||||
dest_course_key = self.store.make_course_key('edX', "aside_test", "2012_Fall")
|
||||
courses = import_course_from_xml(
|
||||
self.store, self.user_id, DATA_DIR, ['aside'],
|
||||
load_error_modules=False,
|
||||
static_content_store=contentstore,
|
||||
target_id=dest_course_key,
|
||||
create_if_not_present=True,
|
||||
)
|
||||
|
||||
# check that the imported blocks have the right asides and values
|
||||
def check_block(block):
|
||||
"""
|
||||
Check whether block has the expected aside w/ its fields and then recurse to the block's children
|
||||
"""
|
||||
asides = block.runtime.get_asides(block)
|
||||
|
||||
self.assertEqual(len(asides), 1, "Found {} asides but expected only test_aside".format(asides))
|
||||
self.assertIsInstance(asides[0], AsideTestType)
|
||||
category = block.scope_ids.block_type
|
||||
self.assertEqual(asides[0].data_field, "{} aside data".format(category))
|
||||
self.assertEqual(asides[0].content, "{} Aside".format(category.capitalize()))
|
||||
|
||||
for child in block.get_children():
|
||||
check_block(child)
|
||||
|
||||
check_block(courses[0])
|
||||
|
||||
# create a new block and ensure its aside magically appears with the right fields
|
||||
new_chapter = self.store.create_child(self.user_id, courses[0].location, 'chapter', 'new_chapter')
|
||||
asides = new_chapter.runtime.get_asides(new_chapter)
|
||||
|
||||
self.assertEqual(len(asides), 1, "Found {} asides but expected only test_aside".format(asides))
|
||||
chapter_aside = asides[0]
|
||||
self.assertIsInstance(chapter_aside, AsideTestType)
|
||||
self.assertFalse(
|
||||
chapter_aside.fields['data_field'].is_set_on(chapter_aside),
|
||||
"data_field says it's assigned to {}".format(chapter_aside.data_field)
|
||||
)
|
||||
self.assertFalse(
|
||||
chapter_aside.fields['content'].is_set_on(chapter_aside),
|
||||
"content says it's assigned to {}".format(chapter_aside.content)
|
||||
)
|
||||
|
||||
# now update the values
|
||||
chapter_aside.data_field = 'new value'
|
||||
self.store.update_item(new_chapter, self.user_id, asides=[chapter_aside])
|
||||
|
||||
new_chapter = self.store.get_item(new_chapter.location)
|
||||
chapter_aside = new_chapter.runtime.get_asides(new_chapter)[0]
|
||||
self.assertEqual('new value', chapter_aside.data_field)
|
||||
|
||||
# update the values the second time
|
||||
chapter_aside.data_field = 'another one value'
|
||||
self.store.update_item(new_chapter, self.user_id, asides=[chapter_aside])
|
||||
|
||||
new_chapter2 = self.store.get_item(new_chapter.location)
|
||||
chapter_aside2 = new_chapter2.runtime.get_asides(new_chapter2)[0]
|
||||
self.assertEqual('another one value', chapter_aside2.data_field)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@attr('mongo')
|
||||
class TestAsidesWithMixedModuleStore(CommonMixedModuleStoreSetup):
|
||||
"""
|
||||
Tests of the MixedModulestore interface methods with XBlock asides.
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Setup environment for testing
|
||||
"""
|
||||
super(TestAsidesWithMixedModuleStore, self).setUp()
|
||||
key_store = DictKeyValueStore()
|
||||
field_data = KvsFieldData(key_store)
|
||||
self.runtime = TestRuntime(services={'field-data': field_data}) # pylint: disable=abstract-class-instantiated
|
||||
|
||||
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
|
||||
@XBlockAside.register_temp_plugin(AsideFoo, 'test_aside1')
|
||||
@XBlockAside.register_temp_plugin(AsideBar, 'test_aside2')
|
||||
@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types',
|
||||
lambda self, block: ['test_aside1', 'test_aside2'])
|
||||
def test_get_and_update_asides(self, default_store):
|
||||
"""
|
||||
Tests that connected asides could be stored, received and updated along with connected course items
|
||||
"""
|
||||
if default_store == ModuleStoreEnum.Type.mongo:
|
||||
raise SkipTest("asides not supported in old mongo")
|
||||
|
||||
self.initdb(default_store)
|
||||
|
||||
block_type1 = 'test_aside1'
|
||||
def_id = self.runtime.id_generator.create_definition(block_type1)
|
||||
usage_id = self.runtime.id_generator.create_usage(def_id)
|
||||
|
||||
# the first aside item
|
||||
aside1 = AsideFoo(scope_ids=ScopeIds('user', block_type1, def_id, usage_id), runtime=self.runtime)
|
||||
aside1.field11 = 'new_value11'
|
||||
aside1.field12 = 'new_value12'
|
||||
|
||||
block_type2 = 'test_aside2'
|
||||
def_id = self.runtime.id_generator.create_definition(block_type1)
|
||||
usage_id = self.runtime.id_generator.create_usage(def_id)
|
||||
|
||||
# the second aside item
|
||||
aside2 = AsideBar(scope_ids=ScopeIds('user', block_type2, def_id, usage_id), runtime=self.runtime)
|
||||
aside2.field21 = 'new_value21'
|
||||
|
||||
# create new item with two asides
|
||||
published_xblock = self.store.create_item(
|
||||
self.user_id,
|
||||
self.course.id,
|
||||
'vertical',
|
||||
block_id='test_vertical',
|
||||
asides=[aside1, aside2]
|
||||
)
|
||||
|
||||
def _check_asides(asides, field11, field12, field21, field22):
|
||||
""" Helper function to check asides """
|
||||
self.assertEqual(len(asides), 2)
|
||||
self.assertEqual({type(asides[0]), type(asides[1])}, {AsideFoo, AsideBar})
|
||||
self.assertEqual(asides[0].field11, field11)
|
||||
self.assertEqual(asides[0].field12, field12)
|
||||
self.assertEqual(asides[1].field21, field21)
|
||||
self.assertEqual(asides[1].field22, field22)
|
||||
|
||||
# get saved item and check asides
|
||||
component = self.store.get_item(published_xblock.location)
|
||||
asides = component.runtime.get_asides(component)
|
||||
_check_asides(asides, 'new_value11', 'new_value12', 'new_value21', 'aside2_default_value2')
|
||||
|
||||
asides[0].field11 = 'other_value11'
|
||||
|
||||
# update the first aside item and check that it was stored correctly
|
||||
self.store.update_item(component, self.user_id, asides=[asides[0]])
|
||||
cached_asides = component.runtime.get_asides(component)
|
||||
_check_asides(cached_asides, 'other_value11', 'new_value12', 'new_value21', 'aside2_default_value2')
|
||||
|
||||
new_component = self.store.get_item(published_xblock.location)
|
||||
new_asides = new_component.runtime.get_asides(new_component)
|
||||
_check_asides(new_asides, 'other_value11', 'new_value12', 'new_value21', 'aside2_default_value2')
|
||||
|
||||
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
|
||||
@XBlockAside.register_temp_plugin(AsideFoo, 'test_aside1')
|
||||
@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types',
|
||||
lambda self, block: ['test_aside1'])
|
||||
def test_clone_course_with_asides(self, default_store):
|
||||
"""
|
||||
Tests that connected asides will be cloned together with the parent courses
|
||||
"""
|
||||
if default_store == ModuleStoreEnum.Type.mongo:
|
||||
raise SkipTest("asides not supported in old mongo")
|
||||
|
||||
with MongoContentstoreBuilder().build() as contentstore:
|
||||
# initialize the mixed modulestore
|
||||
self._initialize_mixed(contentstore=contentstore, mappings={})
|
||||
|
||||
with self.store.default_store(default_store):
|
||||
block_type1 = 'test_aside1'
|
||||
def_id = self.runtime.id_generator.create_definition(block_type1)
|
||||
usage_id = self.runtime.id_generator.create_usage(def_id)
|
||||
|
||||
aside1 = AsideFoo(scope_ids=ScopeIds('user', block_type1, def_id, usage_id), runtime=self.runtime)
|
||||
aside1.field11 = 'test1'
|
||||
|
||||
source_course_key = self.store.make_course_key("org.source", "course.source", "run.source")
|
||||
self._create_course(source_course_key, asides=[aside1])
|
||||
|
||||
dest_course_id = self.store.make_course_key("org.other", "course.other", "run.other")
|
||||
self.store.clone_course(source_course_key, dest_course_id, self.user_id)
|
||||
|
||||
source_store = self.store._get_modulestore_by_type(default_store) # pylint: disable=protected-access
|
||||
self.assertCoursesEqual(source_store, source_course_key, source_store, dest_course_id)
|
||||
|
||||
# after clone get connected aside and check that it was cloned correctly
|
||||
actual_items = source_store.get_items(dest_course_id,
|
||||
revision=ModuleStoreEnum.RevisionOption.published_only)
|
||||
chapter_is_found = False
|
||||
|
||||
for block in actual_items:
|
||||
if block.scope_ids.block_type == 'chapter':
|
||||
asides = block.runtime.get_asides(block)
|
||||
self.assertEqual(len(asides), 1)
|
||||
self.assertEqual(asides[0].field11, 'test1')
|
||||
self.assertEqual(asides[0].field12, 'aside1_default_value2')
|
||||
chapter_is_found = True
|
||||
break
|
||||
|
||||
self.assertTrue(chapter_is_found)
|
||||
|
||||
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
|
||||
@XBlockAside.register_temp_plugin(AsideFoo, 'test_aside1')
|
||||
@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types',
|
||||
lambda self, block: ['test_aside1'])
|
||||
def test_delete_item_with_asides(self, default_store):
|
||||
"""
|
||||
Tests that connected asides will be removed together with the connected items
|
||||
"""
|
||||
if default_store == ModuleStoreEnum.Type.mongo:
|
||||
raise SkipTest("asides not supported in old mongo")
|
||||
|
||||
self.initdb(default_store)
|
||||
|
||||
block_type1 = 'test_aside1'
|
||||
def_id = self.runtime.id_generator.create_definition(block_type1)
|
||||
usage_id = self.runtime.id_generator.create_usage(def_id)
|
||||
|
||||
aside1 = AsideFoo(scope_ids=ScopeIds('user', block_type1, def_id, usage_id), runtime=self.runtime)
|
||||
aside1.field11 = 'new_value11'
|
||||
aside1.field12 = 'new_value12'
|
||||
|
||||
published_xblock = self.store.create_item(
|
||||
self.user_id,
|
||||
self.course.id,
|
||||
'vertical',
|
||||
block_id='test_vertical',
|
||||
asides=[aside1]
|
||||
)
|
||||
|
||||
asides = published_xblock.runtime.get_asides(published_xblock)
|
||||
self.assertEquals(asides[0].field11, 'new_value11')
|
||||
self.assertEquals(asides[0].field12, 'new_value12')
|
||||
|
||||
# remove item
|
||||
self.store.delete_item(published_xblock.location, self.user_id)
|
||||
|
||||
# create item again
|
||||
published_xblock2 = self.store.create_item(
|
||||
self.user_id,
|
||||
self.course.id,
|
||||
'vertical',
|
||||
block_id='test_vertical'
|
||||
)
|
||||
|
||||
# check that aside has default values
|
||||
asides2 = published_xblock2.runtime.get_asides(published_xblock2)
|
||||
self.assertEquals(asides2[0].field11, 'aside1_default_value1')
|
||||
self.assertEquals(asides2[0].field12, 'aside1_default_value2')
|
||||
|
||||
@ddt.data((ModuleStoreEnum.Type.mongo, 1, 0), (ModuleStoreEnum.Type.split, 2, 0))
|
||||
@XBlockAside.register_temp_plugin(AsideFoo, 'test_aside1')
|
||||
@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types',
|
||||
lambda self, block: ['test_aside1'])
|
||||
@ddt.unpack
|
||||
def test_published_and_unpublish_item_with_asides(self, default_store, max_find, max_send):
|
||||
"""
|
||||
Tests that public/unpublish doesn't affect connected stored asides
|
||||
"""
|
||||
if default_store == ModuleStoreEnum.Type.mongo:
|
||||
raise SkipTest("asides not supported in old mongo")
|
||||
|
||||
self.initdb(default_store)
|
||||
|
||||
block_type1 = 'test_aside1'
|
||||
def_id = self.runtime.id_generator.create_definition(block_type1)
|
||||
usage_id = self.runtime.id_generator.create_usage(def_id)
|
||||
|
||||
aside1 = AsideFoo(scope_ids=ScopeIds('user', block_type1, def_id, usage_id), runtime=self.runtime)
|
||||
aside1.field11 = 'new_value11'
|
||||
aside1.field12 = 'new_value12'
|
||||
|
||||
def _check_asides(item):
|
||||
""" Helper function to check asides """
|
||||
asides = item.runtime.get_asides(item)
|
||||
self.assertEquals(asides[0].field11, 'new_value11')
|
||||
self.assertEquals(asides[0].field12, 'new_value12')
|
||||
|
||||
# start off as Private
|
||||
item = self.store.create_child(self.user_id, self.writable_chapter_location, 'problem',
|
||||
'test_compute_publish_state', asides=[aside1])
|
||||
item_location = item.location
|
||||
with check_mongo_calls(max_find, max_send):
|
||||
self.assertFalse(self.store.has_published_version(item))
|
||||
_check_asides(item)
|
||||
|
||||
# Private -> Public
|
||||
self.store.publish(item_location, self.user_id)
|
||||
item = self.store.get_item(item_location)
|
||||
self.assertTrue(self.store.has_published_version(item))
|
||||
_check_asides(item)
|
||||
|
||||
# Public -> Private
|
||||
self.store.unpublish(item_location, self.user_id)
|
||||
item = self.store.get_item(item_location)
|
||||
self.assertFalse(self.store.has_published_version(item))
|
||||
_check_asides(item)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user