Merge pull request #6459 from edx/content-libraries

Content libraries MVP
This commit is contained in:
Braden MacDonald
2015-01-13 11:20:20 -08:00
132 changed files with 9358 additions and 1409 deletions

View File

@@ -58,6 +58,8 @@ registry = TagRegistry()
CorrectMap = correctmap.CorrectMap # pylint: disable=invalid-name
CORRECTMAP_PY = None
# Make '_' a no-op so we can scrape strings
_ = lambda text: text
#-----------------------------------------------------------------------------
# Exceptions
@@ -439,6 +441,7 @@ class JavascriptResponse(LoncapaResponse):
Javascript using Node.js.
"""
human_name = _('JavaScript Input')
tags = ['javascriptresponse']
max_inputfields = 1
allowed_inputfields = ['javascriptinput']
@@ -684,6 +687,7 @@ class ChoiceResponse(LoncapaResponse):
"""
human_name = _('Checkboxes')
tags = ['choiceresponse']
max_inputfields = 1
allowed_inputfields = ['checkboxgroup', 'radiogroup']
@@ -754,6 +758,7 @@ class MultipleChoiceResponse(LoncapaResponse):
"""
# TODO: handle direction and randomize
human_name = _('Multiple Choice')
tags = ['multiplechoiceresponse']
max_inputfields = 1
allowed_inputfields = ['choicegroup']
@@ -1042,6 +1047,7 @@ class MultipleChoiceResponse(LoncapaResponse):
@registry.register
class TrueFalseResponse(MultipleChoiceResponse):
human_name = _('True/False Choice')
tags = ['truefalseresponse']
def mc_setup_response(self):
@@ -1073,6 +1079,7 @@ class OptionResponse(LoncapaResponse):
TODO: handle direction and randomize
"""
human_name = _('Dropdown')
tags = ['optionresponse']
hint_tag = 'optionhint'
allowed_inputfields = ['optioninput']
@@ -1108,6 +1115,7 @@ class NumericalResponse(LoncapaResponse):
to a number (e.g. `4+5/2^2`), and accepts with a tolerance.
"""
human_name = _('Numerical Input')
tags = ['numericalresponse']
hint_tag = 'numericalhint'
allowed_inputfields = ['textline', 'formulaequationinput']
@@ -1308,6 +1316,7 @@ class StringResponse(LoncapaResponse):
</hintgroup>
</stringresponse>
"""
human_name = _('Text Input')
tags = ['stringresponse']
hint_tag = 'stringhint'
allowed_inputfields = ['textline']
@@ -1426,6 +1435,7 @@ class CustomResponse(LoncapaResponse):
or in a <script>...</script>
"""
human_name = _('Custom Evaluated Script')
tags = ['customresponse']
allowed_inputfields = ['textline', 'textbox', 'crystallography',
@@ -1800,6 +1810,7 @@ class SymbolicResponse(CustomResponse):
Symbolic math response checking, using symmath library.
"""
human_name = _('Symbolic Math Input')
tags = ['symbolicresponse']
max_inputfields = 1
@@ -1868,6 +1879,7 @@ class CodeResponse(LoncapaResponse):
"""
human_name = _('Code Input')
tags = ['coderesponse']
allowed_inputfields = ['textbox', 'filesubmission', 'matlabinput']
max_inputfields = 1
@@ -2145,6 +2157,7 @@ class ExternalResponse(LoncapaResponse):
"""
human_name = _('External Grader')
tags = ['externalresponse']
allowed_inputfields = ['textline', 'textbox']
awdmap = {
@@ -2302,6 +2315,7 @@ class FormulaResponse(LoncapaResponse):
Checking of symbolic math response using numerical sampling.
"""
human_name = _('Math Expression Input')
tags = ['formularesponse']
hint_tag = 'formulahint'
allowed_inputfields = ['textline', 'formulaequationinput']
@@ -2514,6 +2528,7 @@ class SchematicResponse(LoncapaResponse):
"""
Circuit schematic response type.
"""
human_name = _('Circuit Schematic Builder')
tags = ['schematicresponse']
allowed_inputfields = ['schematic']
@@ -2592,6 +2607,7 @@ class ImageResponse(LoncapaResponse):
True, if click is inside any region or rectangle. Otherwise False.
"""
human_name = _('Image Mapped Input')
tags = ['imageresponse']
allowed_inputfields = ['imageinput']
@@ -2710,6 +2726,7 @@ class AnnotationResponse(LoncapaResponse):
The response contains both a comment (student commentary) and an option (student tag).
Only the tag is currently graded. Answers may be incorrect, partially correct, or correct.
"""
human_name = _('Annotation Input')
tags = ['annotationresponse']
allowed_inputfields = ['annotationinput']
max_inputfields = 1
@@ -2834,6 +2851,7 @@ class ChoiceTextResponse(LoncapaResponse):
ChoiceResponse.
"""
human_name = _('Checkboxes With Text Input')
tags = ['choicetextresponse']
max_inputfields = 1
allowed_inputfields = ['choicetextgroup',

View File

@@ -11,6 +11,7 @@ XMODULES = [
"discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"html = xmodule.html_module:HtmlDescriptor",
"image = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"library_content = xmodule.library_content_module:LibraryContentDescriptor",
"error = xmodule.error_module:ErrorDescriptor",
"peergrading = xmodule.peer_grading_module:PeerGradingDescriptor",
"poll_question = xmodule.poll_module:PollDescriptor",

View File

@@ -2,10 +2,12 @@
import json
import logging
import sys
from lxml import etree
from pkg_resources import resource_string
from .capa_base import CapaMixin, CapaFields, ComplexEncoder
from capa import responsetypes
from .progress import Progress
from xmodule.x_module import XModule, module_attr
from xmodule.raw_module import RawDescriptor
@@ -172,6 +174,13 @@ class CapaDescriptor(CapaFields, RawDescriptor):
])
return non_editable_fields
@property
def problem_types(self):
""" Low-level problem type introspection for content libraries filtering by problem type """
tree = etree.XML(self.data) # pylint: disable=no-member
registered_tags = responsetypes.registry.registered_tags()
return set([node.tag for node in tree.iter() if node.tag in registered_tags])
# Proxy to CapaModule for access to any of its attributes
answer_available = module_attr('answer_available')
check_button_name = module_attr('check_button_name')

View File

@@ -0,0 +1,534 @@
# -*- coding: utf-8 -*-
"""
LibraryContent: The XBlock used to include blocks from a library in a course.
"""
from bson.objectid import ObjectId, InvalidId
from collections import namedtuple
from copy import copy
from capa.responsetypes import registry
from gettext import ngettext
from .mako_module import MakoModuleDescriptor
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locator import LibraryLocator
import random
from webob import Response
from xblock.core import XBlock
from xblock.fields import Scope, String, List, Integer, Boolean
from xblock.fragment import Fragment
from xmodule.validation import StudioValidationMessage, StudioValidation
from xmodule.x_module import XModule, STUDENT_VIEW
from xmodule.studio_editable import StudioEditableModule, StudioEditableDescriptor
from .xml_module import XmlDescriptor
from pkg_resources import resource_string # pylint: disable=no-name-in-module
# Make '_' a no-op so we can scrape strings
_ = lambda text: text
ANY_CAPA_TYPE_VALUE = 'any'
def enum(**enums):
""" enum helper in lieu of enum34 """
return type('Enum', (), enums)
def _get_human_name(problem_class):
"""
Get the human-friendly name for a problem type.
"""
return getattr(problem_class, 'human_name', problem_class.__name__)
def _get_capa_types():
"""
Gets capa types tags and labels
"""
capa_types = {tag: _get_human_name(registry.get_class_for_tag(tag)) for tag in registry.registered_tags()}
return [{'value': ANY_CAPA_TYPE_VALUE, 'display_name': _('Any Type')}] + sorted([
{'value': capa_type, 'display_name': caption}
for capa_type, caption in capa_types.items()
], key=lambda item: item.get('display_name'))
class LibraryVersionReference(namedtuple("LibraryVersionReference", "library_id version")):
"""
A reference to a specific library, with an optional version.
The version is used to find out when the LibraryContentXBlock was last
updated with the latest content from the library.
library_id is a LibraryLocator
version is an ObjectId or None
"""
def __new__(cls, library_id, version=None):
# pylint: disable=super-on-old-class
if not isinstance(library_id, LibraryLocator):
library_id = LibraryLocator.from_string(library_id)
if library_id.version_guid:
assert (version is None) or (version == library_id.version_guid)
if not version:
version = library_id.version_guid
library_id = library_id.for_version(None)
if version and not isinstance(version, ObjectId):
try:
version = ObjectId(version)
except InvalidId:
raise ValueError(version)
return super(LibraryVersionReference, cls).__new__(cls, library_id, version)
@staticmethod
def from_json(value):
"""
Implement from_json to convert from JSON
"""
return LibraryVersionReference(*value)
def to_json(self):
"""
Implement to_json to convert value to JSON
"""
# TODO: Is there anyway for an xblock to *store* an ObjectId as
# part of the List() field value?
return [unicode(self.library_id), unicode(self.version) if self.version else None] # pylint: disable=no-member
class LibraryList(List):
"""
Special List class for listing references to content libraries.
Is simply a list of LibraryVersionReference tuples.
"""
def from_json(self, values):
"""
Implement from_json to convert from JSON.
values might be a list of lists, or a list of strings
Normally the runtime gives us:
[[u'library-v1:ProblemX+PR0B', '5436ffec56c02c13806a4c1b'], ...]
But the studio editor gives us:
[u'library-v1:ProblemX+PR0B,5436ffec56c02c13806a4c1b', ...]
"""
def parse(val):
""" Convert this list entry from its JSON representation """
if isinstance(val, basestring):
val = val.strip(' []')
parts = val.rsplit(',', 1)
val = [parts[0], parts[1] if len(parts) > 1 else None]
try:
return LibraryVersionReference.from_json(val)
except InvalidKeyError:
try:
friendly_val = val[0] # Just get the library key part, not the version
except IndexError:
friendly_val = unicode(val)
raise ValueError(_('"{value}" is not a valid library ID.').format(value=friendly_val))
return [parse(v) for v in values]
def to_json(self, values):
"""
Implement to_json to convert value to JSON
"""
return [lvr.to_json() for lvr in values]
class LibraryContentFields(object):
"""
Fields for the LibraryContentModule.
Separated out for now because they need to be added to the module and the
descriptor.
"""
# Please note the display_name of each field below is used in
# common/test/acceptance/pages/studio/overview.py:StudioLibraryContentXBlockEditModal
# to locate input elements - keep synchronized
display_name = String(
display_name=_("Display Name"),
help=_("Display name for this module"),
default="Randomized Content Block",
scope=Scope.settings,
)
source_libraries = LibraryList(
display_name=_("Libraries"),
help=_("Enter a library ID for each library from which you want to draw content."),
default=[],
scope=Scope.settings,
)
mode = String(
display_name=_("Mode"),
help=_("Determines how content is drawn from the library"),
default="random",
values=[
{"display_name": _("Choose n at random"), "value": "random"}
# Future addition: Choose a new random set of n every time the student refreshes the block, for self tests
# Future addition: manually selected blocks
],
scope=Scope.settings,
)
max_count = Integer(
display_name=_("Count"),
help=_("Enter the number of components to display to each student."),
default=1,
scope=Scope.settings,
)
capa_type = String(
display_name=_("Problem Type"),
help=_('Choose a problem type to fetch from the library. If "Any Type" is selected no filtering is applied.'),
default=ANY_CAPA_TYPE_VALUE,
values=_get_capa_types(),
scope=Scope.settings,
)
filters = String(default="") # TBD
has_score = Boolean(
display_name=_("Scored"),
help=_("Set this value to True if this module is either a graded assignment or a practice problem."),
default=False,
scope=Scope.settings,
)
selected = List(
# This is a list of (block_type, block_id) tuples used to record
# which random/first set of matching blocks was selected per user
default=[],
scope=Scope.user_state,
)
has_children = True
#pylint: disable=abstract-method
@XBlock.wants('library_tools') # Only needed in studio
class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule):
"""
An XBlock whose children are chosen dynamically from a content library.
Can be used to create randomized assessments among other things.
Note: technically, all matching blocks from the content library are added
as children of this block, but only a subset of those children are shown to
any particular student.
"""
def selected_children(self):
"""
Returns a set() of block_ids indicating which of the possible children
have been selected to display to the current user.
This reads and updates the "selected" field, which has user_state scope.
Note: self.selected and the return value contain block_ids. To get
actual BlockUsageLocators, it is necessary to use self.children,
because the block_ids alone do not specify the block type.
"""
if hasattr(self, "_selected_set"):
# Already done:
return self._selected_set # pylint: disable=access-member-before-definition
selected = set(tuple(k) for k in self.selected) # set of (block_type, block_id) tuples assigned to this student
previous_count = len(selected)
lib_tools = self.runtime.service(self, 'library_tools')
format_block_keys = lambda keys: lib_tools.create_block_analytics_summary(self.location.course_key, keys)
def publish_event(event_name, **kwargs):
""" Publish an event for analytics purposes """
event_data = {
"location": unicode(self.location),
"result": format_block_keys(selected),
"previous_count": previous_count,
"max_count": self.max_count,
}
event_data.update(kwargs)
self.runtime.publish(self, "edx.librarycontentblock.content.{}".format(event_name), event_data)
# Determine which of our children we will show:
valid_block_keys = set([(c.block_type, c.block_id) for c in self.children]) # pylint: disable=no-member
# Remove any selected blocks that are no longer valid:
invalid_block_keys = (selected - valid_block_keys)
if invalid_block_keys:
selected -= invalid_block_keys
# Publish an event for analytics purposes:
# reason "invalid" means deleted from library or a different library is now being used.
publish_event("removed", removed=format_block_keys(invalid_block_keys), reason="invalid")
# If max_count has been decreased, we may have to drop some previously selected blocks:
overlimit_block_keys = set()
while len(selected) > self.max_count:
overlimit_block_keys.add(selected.pop())
if overlimit_block_keys:
# Publish an event for analytics purposes:
publish_event("removed", removed=format_block_keys(overlimit_block_keys), reason="overlimit")
# Do we have enough blocks now?
num_to_add = self.max_count - len(selected)
if num_to_add > 0:
added_block_keys = None
# We need to select [more] blocks to display to this user:
pool = valid_block_keys - selected
if self.mode == "random":
num_to_add = min(len(pool), num_to_add)
added_block_keys = set(random.sample(pool, num_to_add))
# We now have the correct n random children to show for this user.
else:
raise NotImplementedError("Unsupported mode.")
selected |= added_block_keys
if added_block_keys:
# Publish an event for analytics purposes:
publish_event("assigned", added=format_block_keys(added_block_keys))
# Save our selections to the user state, to ensure consistency:
self.selected = list(selected) # TODO: this doesn't save from the LMS "Progress" page.
# Cache the results
self._selected_set = selected # pylint: disable=attribute-defined-outside-init
return selected
def _get_selected_child_blocks(self):
"""
Generator returning XBlock instances of the children selected for the
current user.
"""
for block_type, block_id in self.selected_children():
yield self.runtime.get_block(self.location.course_key.make_usage_key(block_type, block_id))
def student_view(self, context):
fragment = Fragment()
contents = []
child_context = {} if not context else copy(context)
for child in self._get_selected_child_blocks():
for displayable in child.displayable_items():
rendered_child = displayable.render(STUDENT_VIEW, child_context)
fragment.add_frag_resources(rendered_child)
contents.append({
'id': displayable.location.to_deprecated_string(),
'content': rendered_child.content,
})
fragment.add_content(self.system.render_template('vert_module.html', {
'items': contents,
'xblock_context': context,
}))
return fragment
def validate(self):
"""
Validates the state of this Library Content Module Instance.
"""
return self.descriptor.validate()
def author_view(self, context):
"""
Renders the Studio views.
Normal studio view: If block is properly configured, displays library status summary
Studio container view: displays a preview of all possible children.
"""
fragment = Fragment()
root_xblock = context.get('root_xblock')
is_root = root_xblock and root_xblock.location == self.location
if is_root:
# User has clicked the "View" link. Show a preview of all possible children:
if self.children: # pylint: disable=no-member
fragment.add_content(self.system.render_template("library-block-author-preview-header.html", {
'max_count': self.max_count,
'display_name': self.display_name or self.url_name,
}))
self.render_children(context, fragment, can_reorder=False, can_add=False)
# else: When shown on a unit page, don't show any sort of preview -
# just the status of this block in the validation area.
# The following JS is used to make the "Update now" button work on the unit page and the container view:
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_content_edit.js'))
fragment.initialize_js('LibraryContentAuthorView')
return fragment
def get_child_descriptors(self):
"""
Return only the subset of our children relevant to the current student.
"""
return list(self._get_selected_child_blocks())
@XBlock.wants('user')
@XBlock.wants('library_tools') # Only needed in studio
@XBlock.wants('studio_user_permissions') # Only available in studio
class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDescriptor, StudioEditableDescriptor):
"""
Descriptor class for LibraryContentModule XBlock.
"""
module_class = LibraryContentModule
mako_template = 'widgets/metadata-edit.html'
js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]}
js_module_name = "VerticalDescriptor"
@property
def non_editable_metadata_fields(self):
non_editable_fields = super(LibraryContentDescriptor, self).non_editable_metadata_fields
# The only supported mode is currently 'random'.
# Add the mode field to non_editable_metadata_fields so that it doesn't
# render in the edit form.
non_editable_fields.append(LibraryContentFields.mode)
return non_editable_fields
@XBlock.handler
def refresh_children(self, request=None, suffix=None): # pylint: disable=unused-argument
"""
Refresh children:
This method is to be used when any of the libraries that this block
references have been updated. It will re-fetch all matching blocks from
the libraries, and copy them as children of this block. The children
will be given new block_ids, but the definition ID used should be the
exact same definition ID used in the library.
This method will update this block's 'source_libraries' field to store
the version number of the libraries used, so we easily determine if
this block is up to date or not.
"""
lib_tools = self.runtime.service(self, 'library_tools')
user_service = self.runtime.service(self, 'user')
user_perms = self.runtime.service(self, 'studio_user_permissions')
user_id = user_service.user_id if user_service else None # May be None when creating bok choy test fixtures
lib_tools.update_children(self, user_id, user_perms)
return Response()
def _validate_library_version(self, validation, lib_tools, version, library_key):
"""
Validates library version
"""
latest_version = lib_tools.get_library_version(library_key)
if latest_version is not None:
if version is None or version != latest_version:
validation.set_summary(
StudioValidationMessage(
StudioValidationMessage.WARNING,
_(u'This component is out of date. The library has new content.'),
# TODO: change this to action_runtime_event='...' once the unit page supports that feature.
# See https://openedx.atlassian.net/browse/TNL-993
action_class='library-update-btn',
# Translators: {refresh_icon} placeholder is substituted to "↻" (without double quotes)
action_label=_(u"{refresh_icon} Update now.").format(refresh_icon=u"")
)
)
return False
else:
validation.set_summary(
StudioValidationMessage(
StudioValidationMessage.ERROR,
_(u'Library is invalid, corrupt, or has been deleted.'),
action_class='edit-button',
action_label=_(u"Edit Library List.")
)
)
return False
return True
def _set_validation_error_if_empty(self, validation, summary):
""" Helper method to only set validation summary if it's empty """
if validation.empty:
validation.set_summary(summary)
def validate(self):
"""
Validates the state of this Library Content Module Instance. This
is the override of the general XBlock method, and it will also ask
its superclass to validate.
"""
validation = super(LibraryContentDescriptor, self).validate()
if not isinstance(validation, StudioValidation):
validation = StudioValidation.copy(validation)
if not self.source_libraries:
validation.set_summary(
StudioValidationMessage(
StudioValidationMessage.NOT_CONFIGURED,
_(u"A library has not yet been selected."),
action_class='edit-button',
action_label=_(u"Select a Library.")
)
)
return validation
lib_tools = self.runtime.service(self, 'library_tools')
for library_key, version in self.source_libraries:
if not self._validate_library_version(validation, lib_tools, version, library_key):
break
# Note: we assume refresh_children() has been called
# since the last time fields like source_libraries or capa_types were changed.
matching_children_count = len(self.children) # pylint: disable=no-member
if matching_children_count == 0:
self._set_validation_error_if_empty(
validation,
StudioValidationMessage(
StudioValidationMessage.WARNING,
_(u'There are no matching problem types in the specified libraries.'),
action_class='edit-button',
action_label=_(u"Select another problem type.")
)
)
if matching_children_count < self.max_count:
self._set_validation_error_if_empty(
validation,
StudioValidationMessage(
StudioValidationMessage.WARNING,
(
ngettext(
u'The specified libraries are configured to fetch {count} problem, ',
u'The specified libraries are configured to fetch {count} problems, ',
self.max_count
) +
ngettext(
u'but there are only {actual} matching problem.',
u'but there are only {actual} matching problems.',
matching_children_count
)
).format(count=self.max_count, actual=matching_children_count),
action_class='edit-button',
action_label=_(u"Edit the library configuration.")
)
)
return validation
def editor_saved(self, user, old_metadata, old_content):
"""
If source_libraries or capa_type has been edited, refresh_children automatically.
"""
old_source_libraries = LibraryList().from_json(old_metadata.get('source_libraries', []))
if (set(old_source_libraries) != set(self.source_libraries) or
old_metadata.get('capa_type', ANY_CAPA_TYPE_VALUE) != self.capa_type):
try:
self.refresh_children()
except ValueError:
pass # The validation area will display an error message, no need to do anything now.
def has_dynamic_children(self):
"""
Inform the runtime that our children vary per-user.
See get_child_descriptors() above
"""
return True
def get_content_titles(self):
"""
Returns list of friendly titles for our selected children only; without
thi, all possible children's titles would be seen in the sequence bar in
the LMS.
This overwrites the get_content_titles method included in x_module by default.
"""
titles = []
for child in self._xmodule.get_child_descriptors():
titles.extend(child.get_content_titles())
return titles
@classmethod
def definition_from_xml(cls, xml_object, system):
""" XML support not yet implemented. """
raise NotImplementedError
def definition_to_xml(self, resource_fs):
""" XML support not yet implemented. """
raise NotImplementedError
@classmethod
def from_xml(cls, xml_data, system, id_generator):
""" XML support not yet implemented. """
raise NotImplementedError
def export_to_xml(self, resource_fs):
""" XML support not yet implemented. """
raise NotImplementedError

View File

@@ -3,10 +3,10 @@
"""
import logging
from .studio_editable import StudioEditableModule
from xblock.core import XBlock
from xblock.fields import Scope, String, List
from xblock.fragment import Fragment
from xmodule.studio_editable import StudioEditableModule
log = logging.getLogger(__name__)
@@ -42,29 +42,53 @@ class LibraryRoot(XBlock):
def author_view(self, context):
"""
Renders the Studio preview view, which supports drag and drop.
Renders the Studio preview view.
"""
fragment = Fragment()
self.render_children(context, fragment, can_reorder=False, can_add=True)
return fragment
def render_children(self, context, fragment, can_reorder=False, can_add=False): # pylint: disable=unused-argument
"""
Renders the children of the module with HTML appropriate for Studio. Reordering is not supported.
"""
contents = []
for child_key in self.children: # pylint: disable=E1101
context['reorderable_items'].add(child_key)
paging = context.get('paging', None)
children_count = len(self.children) # pylint: disable=no-member
item_start, item_end = 0, children_count
# TODO sort children
if paging:
page_number = paging.get('page_number', 0)
raw_page_size = paging.get('page_size', None)
page_size = raw_page_size if raw_page_size is not None else children_count
item_start, item_end = page_size * page_number, page_size * (page_number + 1)
children_to_show = self.children[item_start:item_end] # pylint: disable=no-member
for child_key in children_to_show: # pylint: disable=E1101
child = self.runtime.get_block(child_key)
rendered_child = self.runtime.render_child(child, StudioEditableModule.get_preview_view_name(child), context)
child_view_name = StudioEditableModule.get_preview_view_name(child)
rendered_child = self.runtime.render_child(child, child_view_name, context)
fragment.add_frag_resources(rendered_child)
contents.append({
'id': unicode(child_key),
'id': unicode(child.location),
'content': rendered_child.content,
})
fragment.add_content(self.runtime.render_template("studio_render_children_view.html", {
'items': contents,
'xblock_context': context,
'can_add': True,
'can_reorder': True,
}))
return fragment
fragment.add_content(
self.runtime.render_template("studio_render_paged_children_view.html", {
'items': contents,
'xblock_context': context,
'can_add': can_add,
'first_displayed': item_start,
'total_children': children_count,
'displayed_children': len(children_to_show)
})
)
@property
def display_org_with_default(self):

View File

@@ -0,0 +1,135 @@
"""
XBlock runtime services for LibraryContentModule
"""
from django.core.exceptions import PermissionDenied
from opaque_keys.edx.locator import LibraryLocator
from xmodule.library_content_module import LibraryVersionReference, ANY_CAPA_TYPE_VALUE
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.capa_module import CapaDescriptor
class LibraryToolsService(object):
"""
Service that allows LibraryContentModule to interact with libraries in the
modulestore.
"""
def __init__(self, modulestore):
self.store = modulestore
def _get_library(self, library_key):
"""
Given a library key like "library-v1:ProblemX+PR0B", return the
'library' XBlock with meta-information about the library.
Returns None on error.
"""
if not isinstance(library_key, LibraryLocator):
library_key = LibraryLocator.from_string(library_key)
assert library_key.version_guid is None
try:
return self.store.get_library(library_key, remove_version=False, remove_branch=False)
except ItemNotFoundError:
return None
def get_library_version(self, lib_key):
"""
Get the version (an ObjectID) of the given library.
Returns None if the library does not exist.
"""
library = self._get_library(lib_key)
if library:
# We need to know the library's version so ensure it's set in library.location.library_key.version_guid
assert library.location.library_key.version_guid is not None
return library.location.library_key.version_guid
return None
def create_block_analytics_summary(self, course_key, block_keys):
"""
Given a CourseKey and a list of (block_type, block_id) pairs,
prepare the JSON-ready metadata needed for analytics logging.
This is [
{"usage_key": x, "original_usage_key": y, "original_usage_version": z, "descendants": [...]}
]
where the main list contains all top-level blocks, and descendants contains a *flat* list of all
descendants of the top level blocks, if any.
"""
def summarize_block(usage_key):
""" Basic information about the given block """
orig_key, orig_version = self.store.get_block_original_usage(usage_key)
return {
"usage_key": unicode(usage_key),
"original_usage_key": unicode(orig_key) if orig_key else None,
"original_usage_version": unicode(orig_version) if orig_version else None,
}
result_json = []
for block_key in block_keys:
key = course_key.make_usage_key(*block_key)
info = summarize_block(key)
info['descendants'] = []
try:
block = self.store.get_item(key, depth=None) # Load the item and all descendants
children = list(getattr(block, "children", []))
while children:
child_key = children.pop()
child = self.store.get_item(child_key)
info['descendants'].append(summarize_block(child_key))
children.extend(getattr(child, "children", []))
except ItemNotFoundError:
pass # The block has been deleted
result_json.append(info)
return result_json
def _filter_child(self, usage_key, capa_type):
"""
Filters children by CAPA problem type, if configured
"""
if capa_type == ANY_CAPA_TYPE_VALUE:
return True
if usage_key.block_type != "problem":
return False
descriptor = self.store.get_item(usage_key, depth=0)
assert isinstance(descriptor, CapaDescriptor)
return capa_type in descriptor.problem_types
def update_children(self, dest_block, user_id, user_perms=None):
"""
This method is to be used when any of the libraries that a LibraryContentModule
references have been updated. It will re-fetch all matching blocks from
the libraries, and copy them as children of dest_block. The children
will be given new block_ids, but the definition ID used should be the
exact same definition ID used in the library.
This method will update dest_block's 'source_libraries' field to store
the version number of the libraries used, so we easily determine if
dest_block is up to date or not.
"""
if user_perms and not user_perms.can_write(dest_block.location.course_key):
raise PermissionDenied()
new_libraries = []
source_blocks = []
for library_key, __ in dest_block.source_libraries:
library = self._get_library(library_key)
if library is None:
raise ValueError("Required library not found.")
if user_perms and not user_perms.can_read(library_key):
raise PermissionDenied()
filter_children = (dest_block.capa_type != ANY_CAPA_TYPE_VALUE)
if filter_children:
# Apply simple filtering based on CAPA problem types:
source_blocks.extend([key for key in library.children if self._filter_child(key, dest_block.capa_type)])
else:
source_blocks.extend(library.children)
new_libraries.append(LibraryVersionReference(library_key, library.location.library_key.version_guid))
with self.store.bulk_operations(dest_block.location.course_key):
dest_block.source_libraries = new_libraries
self.store.update_item(dest_block, user_id)
dest_block.children = self.store.copy_from_template(source_blocks, dest_block.location, user_id)
# ^-- copy_from_template updates the children in the DB
# but we must also set .children here to avoid overwriting the DB again

View File

@@ -211,8 +211,8 @@ def inherit_metadata(descriptor, inherited_data):
def own_metadata(module):
"""
Return a dictionary that contains only non-inherited field keys,
mapped to their serialized values
Return a JSON-friendly dictionary that contains only non-inherited field
keys, mapped to their serialized values
"""
return module.get_explicitly_set_fields_by_scope(Scope.settings)
@@ -283,6 +283,8 @@ class InheritanceKeyValueStore(KeyValueStore):
def default(self, key):
"""
Check to see if the default should be from inheritance rather than from the field's global default
Check to see if the default should be from inheritance. If not
inheriting, this will raise KeyError which will cause the caller to use
the field's global default.
"""
return self.inherited_settings[key.field_name]

View File

@@ -509,6 +509,18 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
store = self._get_modulestore_for_courseid(location.course_key)
return store.get_parent_location(location, **kwargs)
def get_block_original_usage(self, usage_key):
"""
If a block was inherited into another structure using copy_from_template,
this will return the original block usage locator from which the
copy was inherited.
"""
try:
store = self._verify_modulestore_support(usage_key.course_key, 'get_block_original_usage')
return store.get_block_original_usage(usage_key)
except NotImplementedError:
return None, None
def get_modulestore_type(self, course_id):
"""
Returns a type which identifies which modulestore is servicing the given course_id.
@@ -676,6 +688,14 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
store = self._verify_modulestore_support(course_key, 'import_xblock')
return store.import_xblock(user_id, course_key, block_type, block_id, fields, runtime)
@strip_key
def copy_from_template(self, source_keys, dest_key, user_id, **kwargs):
"""
See :py:meth `SplitMongoModuleStore.copy_from_template`
"""
store = self._verify_modulestore_support(dest_key.course_key, 'copy_from_template')
return store.copy_from_template(source_keys, dest_key, user_id)
@strip_key
def update_item(self, xblock, user_id, allow_not_found=False, **kwargs):
"""

View File

@@ -6,6 +6,7 @@ from lazy import lazy
from xblock.runtime import KvsFieldData
from xblock.fields import ScopeIds
from opaque_keys.edx.locator import BlockUsageLocator, LocalId, CourseLocator, LibraryLocator, DefinitionLocator
from xmodule.library_tools import LibraryToolsService
from xmodule.mako_module import MakoDescriptorSystem
from xmodule.error_module import ErrorDescriptor
from xmodule.errortracker import exc_info_to_str
@@ -71,6 +72,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
self.module_data = module_data
self.default_class = default_class
self.local_modules = {}
self._services['library_tools'] = LibraryToolsService(modulestore)
@lazy
@contract(returns="dict(BlockKey: BlockKey)")
@@ -167,16 +169,17 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
if block_key is None:
block_key = BlockKey(json_data['block_type'], LocalId())
convert_fields = lambda field: self.modulestore.convert_references_to_keys(
course_key, class_, field, self.course_entry.structure['blocks'],
)
if definition_id is not None and not json_data.get('definition_loaded', False):
definition_loader = DefinitionLazyLoader(
self.modulestore,
course_key,
block_key.type,
definition_id,
lambda fields: self.modulestore.convert_references_to_keys(
course_key, self.load_block_type(block_key.type),
fields, self.course_entry.structure['blocks'],
)
convert_fields,
)
else:
definition_loader = None
@@ -191,9 +194,8 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
block_id=block_key.id,
)
converted_fields = self.modulestore.convert_references_to_keys(
block_locator.course_key, class_, json_data.get('fields', {}), self.course_entry.structure['blocks'],
)
converted_fields = convert_fields(json_data.get('fields', {}))
converted_defaults = convert_fields(json_data.get('defaults', {}))
if block_key in self._parent_map:
parent_key = self._parent_map[block_key]
parent = course_key.make_usage_key(parent_key.type, parent_key.id)
@@ -202,6 +204,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
kvs = SplitMongoKVS(
definition_loader,
converted_fields,
converted_defaults,
parent=parent,
field_decorator=kwargs.get('field_decorator')
)

View File

@@ -255,7 +255,7 @@ class MongoConnection(object):
"""
Retrieve all definitions listed in `definitions`.
"""
return self.definitions.find({'$in': {'_id': definitions}})
return self.definitions.find({'_id': {'$in': definitions}})
def insert_definition(self, definition):
"""

View File

@@ -27,6 +27,8 @@ Representation:
**** 'definition': the db id of the record containing the content payload for this xblock
**** 'fields': the Scope.settings and children field values
***** 'children': This is stored as a list of (block_type, block_id) pairs
**** 'defaults': Scope.settings default values copied from a template block (used e.g. when
blocks are copied from a library to a course)
**** 'edit_info': dictionary:
***** 'edited_on': when was this xblock's fields last changed (will be edited_on value of
update_version structure)
@@ -53,6 +55,7 @@ Representation:
import copy
import threading
import datetime
import hashlib
import logging
from contracts import contract, new_contract
from importlib import import_module
@@ -451,12 +454,17 @@ class SplitBulkWriteMixin(BulkOperationsMixin):
if block_info['edit_info'].get('update_version') == update_version:
return
original_usage = block_info['edit_info'].get('original_usage')
original_usage_version = block_info['edit_info'].get('original_usage_version')
block_info['edit_info'] = {
'edited_on': datetime.datetime.now(UTC),
'edited_by': user_id,
'previous_version': block_info['edit_info']['update_version'],
'update_version': update_version,
}
if original_usage:
block_info['edit_info']['original_usage'] = original_usage
block_info['edit_info']['original_usage_version'] = original_usage_version
def find_matching_course_indexes(self, branch=None, search_targets=None):
"""
@@ -670,7 +678,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
new_module_data = {}
for block_id in base_block_ids:
new_module_data = self.descendants(
system.course_entry.structure['blocks'],
# copy or our changes like setting 'definition_loaded' will affect the active bulk operation data
copy.deepcopy(system.course_entry.structure['blocks']),
block_id,
depth,
new_module_data
@@ -691,12 +700,9 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
for block in new_module_data.itervalues():
if block['definition'] in definitions:
converted_fields = self.convert_references_to_keys(
course_key, system.load_block_type(block['block_type']),
definitions[block['definition']].get('fields'),
system.course_entry.structure['blocks'],
)
block['fields'].update(converted_fields)
definition = definitions[block['definition']]
# convert_fields was being done here, but it gets done later in the runtime's xblock_from_json
block['fields'].update(definition.get('fields'))
block['definition_loaded'] = True
system.module_data.update(new_module_data)
@@ -1255,6 +1261,21 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
# TODO implement
pass
def get_block_original_usage(self, usage_key):
"""
If a block was inherited into another structure using copy_from_template,
this will return the original block usage locator and version from
which the copy was inherited.
Returns usage_key, version if the data is available, otherwise returns (None, None)
"""
blocks = self._lookup_course(usage_key.course_key).structure['blocks']
block = blocks.get(BlockKey.from_usage_key(usage_key))
if block and 'original_usage' in block['edit_info']:
usage_key = BlockUsageLocator.from_string(block['edit_info']['original_usage'])
return usage_key, block['edit_info'].get('original_usage_version')
return None, None
def create_definition_from_data(self, course_key, new_def_data, category, user_id):
"""
Pull the definition fields out of descriptor and save to the db as a new definition
@@ -2073,6 +2094,168 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
self.update_structure(destination_course, destination_structure)
self._update_head(destination_course, index_entry, destination_course.branch, destination_structure['_id'])
@contract(source_keys="list(BlockUsageLocator)", dest_usage=BlockUsageLocator)
def copy_from_template(self, source_keys, dest_usage, user_id):
"""
Flexible mechanism for inheriting content from an external course/library/etc.
Will copy all of the XBlocks whose keys are passed as `source_course` so that they become
children of the XBlock whose key is `dest_usage`. Any previously existing children of
`dest_usage` that haven't been replaced/updated by this copy_from_template operation will
be deleted.
Unlike `copy()`, this does not care whether the resulting blocks are positioned similarly
in their new course/library. However, the resulting blocks will be in the same relative
order as `source_keys`.
If any of the blocks specified already exist as children of the destination block, they
will be updated rather than duplicated or replaced. If they have Scope.settings field values
overriding inherited default values, those overrides will be preserved.
IMPORTANT: This method does not preserve block_id - in other words, every block that is
copied will be assigned a new block_id. This is because we assume that the same source block
may be copied into one course in multiple places. However, it *is* guaranteed that every
time this method is called for the same source block and dest_usage, the same resulting
block id will be generated.
:param source_keys: a list of BlockUsageLocators. Order is preserved.
:param dest_usage: The BlockUsageLocator that will become the parent of an inherited copy
of all the xblocks passed in `source_keys`.
:param user_id: The user who will get credit for making this change.
"""
# Preload the block structures for all source courses/libraries/etc.
# so that we can access descendant information quickly
source_structures = {}
for key in source_keys:
course_key = key.course_key.for_version(None)
if course_key.branch is None:
raise ItemNotFoundError("branch is required for all source keys when using copy_from_template")
if course_key not in source_structures:
with self.bulk_operations(course_key):
source_structures[course_key] = self._lookup_course(course_key).structure
destination_course = dest_usage.course_key
with self.bulk_operations(destination_course):
index_entry = self.get_course_index(destination_course)
if index_entry is None:
raise ItemNotFoundError(destination_course)
dest_structure = self._lookup_course(destination_course).structure
old_dest_structure_version = dest_structure['_id']
dest_structure = self.version_structure(destination_course, dest_structure, user_id)
# Set of all descendent block IDs of dest_usage that are to be replaced:
block_key = BlockKey(dest_usage.block_type, dest_usage.block_id)
orig_descendants = set(self.descendants(dest_structure['blocks'], block_key, depth=None, descendent_map={}))
# The descendants() method used above adds the block itself, which we don't consider a descendant.
orig_descendants.remove(block_key)
new_descendants = self._copy_from_template(
source_structures, source_keys, dest_structure, block_key, user_id
)
# Update the edit info:
dest_info = dest_structure['blocks'][block_key]
# Update the edit_info:
dest_info['edit_info']['previous_version'] = dest_info['edit_info']['update_version']
dest_info['edit_info']['update_version'] = old_dest_structure_version
dest_info['edit_info']['edited_by'] = user_id
dest_info['edit_info']['edited_on'] = datetime.datetime.now(UTC)
orphans = orig_descendants - new_descendants
for orphan in orphans:
del dest_structure['blocks'][orphan]
self.update_structure(destination_course, dest_structure)
self._update_head(destination_course, index_entry, destination_course.branch, dest_structure['_id'])
# Return usage locators for all the new children:
return [
destination_course.make_usage_key(*k)
for k in dest_structure['blocks'][block_key]['fields']['children']
]
def _copy_from_template(self, source_structures, source_keys, dest_structure, new_parent_block_key, user_id):
"""
Internal recursive implementation of copy_from_template()
Returns the new set of BlockKeys that are the new descendants of the block with key 'block_key'
"""
# pylint: disable=no-member
# ^-- Until pylint gets namedtuple support, it will give warnings about BlockKey attributes
new_blocks = set()
new_children = list() # ordered list of the new children of new_parent_block_key
for usage_key in source_keys:
src_course_key = usage_key.course_key.for_version(None)
block_key = BlockKey(usage_key.block_type, usage_key.block_id)
source_structure = source_structures.get(src_course_key, [])
if block_key not in source_structure['blocks']:
raise ItemNotFoundError(usage_key)
source_block_info = source_structure['blocks'][block_key]
# Compute a new block ID. This new block ID must be consistent when this
# method is called with the same (source_key, dest_structure) pair
unique_data = "{}:{}:{}".format(
unicode(src_course_key).encode("utf-8"),
block_key.id,
new_parent_block_key.id,
)
new_block_id = hashlib.sha1(unique_data).hexdigest()[:20]
new_block_key = BlockKey(block_key.type, new_block_id)
# Now clone block_key to new_block_key:
new_block_info = copy.deepcopy(source_block_info)
# Note that new_block_info now points to the same definition ID entry as source_block_info did
existing_block_info = dest_structure['blocks'].get(new_block_key, {})
# Inherit the Scope.settings values from 'fields' to 'defaults'
new_block_info['defaults'] = new_block_info['fields']
# <workaround>
# CAPA modules store their 'markdown' value (an alternate representation of their content)
# in Scope.settings rather than Scope.content :-/
# markdown is a field that really should not be overridable - it fundamentally changes the content.
# capa modules also use a custom editor that always saves their markdown field to the metadata,
# even if it hasn't changed, which breaks our override system.
# So until capa modules are fixed, we special-case them and remove their markdown fields,
# forcing the inherited version to use XML only.
if usage_key.block_type == 'problem' and 'markdown' in new_block_info['defaults']:
del new_block_info['defaults']['markdown']
# </workaround>
new_block_info['fields'] = existing_block_info.get('fields', {}) # Preserve any existing overrides
if 'children' in new_block_info['defaults']:
del new_block_info['defaults']['children'] # Will be set later
new_block_info['block_id'] = new_block_key.id
new_block_info['edit_info'] = existing_block_info.get('edit_info', {})
new_block_info['edit_info']['previous_version'] = new_block_info['edit_info'].get('update_version', None)
new_block_info['edit_info']['update_version'] = dest_structure['_id']
# Note we do not set 'source_version' - it's only used for copying identical blocks
# from draft to published as part of publishing workflow.
# Setting it to the source_block_info structure version here breaks split_draft's has_changes() method.
new_block_info['edit_info']['edited_by'] = user_id
new_block_info['edit_info']['edited_on'] = datetime.datetime.now(UTC)
new_block_info['edit_info']['original_usage'] = unicode(usage_key.replace(branch=None, version_guid=None))
new_block_info['edit_info']['original_usage_version'] = source_block_info['edit_info'].get('update_version')
dest_structure['blocks'][new_block_key] = new_block_info
children = source_block_info['fields'].get('children')
if children:
children = [src_course_key.make_usage_key(child.type, child.id) for child in children]
new_blocks |= self._copy_from_template(
source_structures, children, dest_structure, new_block_key, user_id
)
new_blocks.add(new_block_key)
# And add new_block_key to the list of new_parent_block_key's new children:
new_children.append(new_block_key)
# Update the children of new_parent_block_key
dest_structure['blocks'][new_parent_block_key]['fields']['children'] = new_children
return new_blocks
def delete_item(self, usage_locator, user_id, force=False):
"""
Delete the block or tree rooted at block (if delete_children) and any references w/in the course to the block
@@ -2702,7 +2885,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
self._filter_blacklist(copy.copy(new_block['fields']), blacklist),
new_block['definition'],
destination_version,
raw=True
raw=True,
block_defaults=new_block.get('defaults')
)
# introduce new edit info field for tracing where copied/published blocks came
@@ -2741,7 +2925,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
self._delete_if_true_orphan(BlockKey(*child), structure)
del structure['blocks'][orphan]
def _new_block(self, user_id, category, block_fields, definition_id, new_id, raw=False):
def _new_block(self, user_id, category, block_fields, definition_id, new_id, raw=False, block_defaults=None):
"""
Create the core document structure for a block.
@@ -2752,7 +2936,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
"""
if not raw:
block_fields = self._serialize_fields(category, block_fields)
return {
document = {
'block_type': category,
'definition': definition_id,
'fields': block_fields,
@@ -2763,6 +2947,9 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
'update_version': new_id
}
}
if block_defaults:
document['defaults'] = block_defaults
return document
@contract(block_key=BlockKey)
def _get_block_from_structure(self, structure, block_key):

View File

@@ -5,7 +5,7 @@ Module for the dual-branch fall-back Draft->Published Versioning ModuleStore
from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore, EXCLUDE_ALL
from xmodule.exceptions import InvalidVersionError
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.exceptions import InsufficientSpecificationError
from xmodule.modulestore.exceptions import InsufficientSpecificationError, ItemNotFoundError
from xmodule.modulestore.draft_and_published import (
ModuleStoreDraftAndPublished, DIRECT_ONLY_CATEGORIES, UnsupportedRevisionError
)
@@ -93,6 +93,27 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
# version_agnostic b/c of above assumption in docstring
self.publish(location.version_agnostic(), user_id, blacklist=EXCLUDE_ALL, **kwargs)
def copy_from_template(self, source_keys, dest_key, user_id, **kwargs):
"""
See :py:meth `SplitMongoModuleStore.copy_from_template`
"""
source_keys = [self._map_revision_to_branch(key) for key in source_keys]
dest_key = self._map_revision_to_branch(dest_key)
new_keys = super(DraftVersioningModuleStore, self).copy_from_template(source_keys, dest_key, user_id)
if dest_key.branch == ModuleStoreEnum.BranchName.draft:
# Check if any of new_keys or their descendants need to be auto-published.
# We don't use _auto_publish_no_children since children may need to be published.
with self.bulk_operations(dest_key.course_key):
keys_to_check = list(new_keys)
while keys_to_check:
usage_key = keys_to_check.pop()
if usage_key.category in DIRECT_ONLY_CATEGORIES:
self.publish(usage_key.version_agnostic(), user_id, blacklist=EXCLUDE_ALL, **kwargs)
children = getattr(self.get_item(usage_key, **kwargs), "children", [])
# e.g. if usage_key is a chapter, it may have an auto-publish sequential child
keys_to_check.extend(children)
return new_keys
def update_item(self, descriptor, user_id, allow_not_found=False, force=False, **kwargs):
old_descriptor_locn = descriptor.location
descriptor.location = self._map_revision_to_branch(old_descriptor_locn)
@@ -247,6 +268,15 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
location = self._map_revision_to_branch(location, revision=revision)
return super(DraftVersioningModuleStore, self).get_parent_location(location, **kwargs)
def get_block_original_usage(self, usage_key):
"""
If a block was inherited into another structure using copy_from_template,
this will return the original block usage locator from which the
copy was inherited.
"""
usage_key = self._map_revision_to_branch(usage_key)
return super(DraftVersioningModuleStore, self).get_block_original_usage(usage_key)
def get_orphans(self, course_key, **kwargs):
course_key = self._map_revision_to_branch(course_key)
return super(DraftVersioningModuleStore, self).get_orphans(course_key, **kwargs)
@@ -421,7 +451,12 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
pass
def _get_head(self, xblock, branch):
course_structure = self._lookup_course(xblock.location.course_key.for_branch(branch)).structure
""" Gets block at the head of specified branch """
try:
course_structure = self._lookup_course(xblock.location.course_key.for_branch(branch)).structure
except ItemNotFoundError:
# There is no published version xblock container, e.g. Library
return None
return self._get_block_from_structure(course_structure, BlockKey.from_usage_key(xblock.location))
def _get_version(self, block):

View File

@@ -19,17 +19,20 @@ class SplitMongoKVS(InheritanceKeyValueStore):
"""
@contract(parent="BlockUsageLocator | None")
def __init__(self, definition, initial_values, parent, field_decorator=None):
def __init__(self, definition, initial_values, default_values, parent, field_decorator=None):
"""
:param definition: either a lazyloader or definition id for the definition
:param initial_values: a dictionary of the locally set values
:param default_values: any Scope.settings field defaults that are set locally
(copied from a template block with copy_from_template)
"""
# deepcopy so that manipulations of fields does not pollute the source
super(SplitMongoKVS, self).__init__(copy.deepcopy(initial_values))
self._definition = definition # either a DefinitionLazyLoader or the db id of the definition.
# if the db id, then the definition is presumed to be loaded into _fields
self._defaults = default_values
# a decorator function for field values (to be called when a field is accessed)
if field_decorator is None:
self.field_decorator = lambda x: x
@@ -110,6 +113,16 @@ class SplitMongoKVS(InheritanceKeyValueStore):
# 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):
"""
Check to see if the default should be from the template's defaults (if any)
rather than the global default or inheritance.
"""
if self._defaults and key.field_name in self._defaults:
return self._defaults[key.field_name]
# If not, try inheriting from a parent, then use the XBlock type's normal default value:
return super(SplitMongoKVS, self).default(key)
def _load_definition(self):
"""
Update fields w/ the lazily loaded definitions

View File

@@ -6,14 +6,11 @@ Higher-level tests are in `cms/djangoapps/contentstore`.
"""
from bson.objectid import ObjectId
import ddt
from mock import patch
from opaque_keys.edx.locator import LibraryLocator
from xblock.fragment import Fragment
from xblock.runtime import Runtime as VanillaRuntime
from xmodule.modulestore.exceptions import DuplicateCourseError
from xmodule.modulestore.tests.factories import LibraryFactory, ItemFactory, check_mongo_calls
from xmodule.modulestore.tests.utils import MixedSplitTestCase
from xmodule.x_module import AUTHOR_VIEW
@ddt.ddt
@@ -179,30 +176,13 @@ class TestLibraries(MixedSplitTestCase):
version = lib.location.library_key.version_guid
self.assertIsInstance(version, ObjectId)
@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render)
def test_library_author_view(self):
"""
Test that LibraryRoot.author_view can run and includes content from its
children.
We have to patch the runtime (module system) in order to be able to
render blocks in our test environment.
"""
def test_xblock_in_lib_have_published_version_returns_false(self):
library = LibraryFactory.create(modulestore=self.store)
# Add one HTML block to the library:
ItemFactory.create(
block = ItemFactory.create(
category="html",
parent_location=library.location,
user_id=self.user_id,
publish_item=False,
modulestore=self.store,
)
library = self.store.get_library(library.location.library_key)
context = {'reorderable_items': set(), }
# Patch the HTML block to always render "Hello world"
message = u"Hello world"
hello_render = lambda _, context: Fragment(message)
with patch('xmodule.html_module.HtmlDescriptor.author_view', hello_render, create=True):
with patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []):
result = library.render(AUTHOR_VIEW, context)
self.assertIn(message, result.content)
self.assertFalse(self.store.has_published_version(block))

View File

@@ -0,0 +1,175 @@
"""
Tests for split's copy_from_template method.
Currently it is only used for content libraries.
However for these tests, we make sure it also works when copying from course to course.
"""
import ddt
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory
from xmodule.modulestore.tests.utils import MixedSplitTestCase
@ddt.ddt
class TestSplitCopyTemplate(MixedSplitTestCase):
"""
Test for split's copy_from_template method.
"""
@ddt.data(
LibraryFactory,
CourseFactory,
)
def test_copy_from_template(self, source_type):
"""
Test that the behavior of copy_from_template() matches its docstring
"""
source_container = source_type.create(modulestore=self.store) # Either a library or a course
course = CourseFactory.create(modulestore=self.store)
# Add a vertical with a capa child to the source library/course:
vertical_block = self.make_block("vertical", source_container)
problem_library_display_name = "Problem Library Display Name"
problem_block = self.make_block(
"problem", vertical_block, display_name=problem_library_display_name, markdown="Problem markdown here"
)
if source_type == LibraryFactory:
source_container = self.store.get_library(
source_container.location.library_key, remove_version=False, remove_branch=False
)
else:
source_container = self.store.get_course(
source_container.location.course_key, remove_version=False, remove_branch=False
)
# Inherit the vertical and the problem from the library into the course:
source_keys = [source_container.children[0]]
new_blocks = self.store.copy_from_template(source_keys, dest_key=course.location, user_id=self.user_id)
self.assertEqual(len(new_blocks), 1)
course = self.store.get_course(course.location.course_key) # Reload from modulestore
self.assertEqual(len(course.children), 1)
vertical_block_course = self.store.get_item(course.children[0])
self.assertEqual(new_blocks[0], vertical_block_course.location)
problem_block_course = self.store.get_item(vertical_block_course.children[0])
self.assertEqual(problem_block_course.display_name, problem_library_display_name)
# Check that when capa modules are copied, their "markdown" fields (Scope.settings) are removed.
# (See note in split.py:copy_from_template())
self.assertIsNotNone(problem_block.markdown)
self.assertIsNone(problem_block_course.markdown)
# Override the display_name and weight:
new_display_name = "The Trouble with Tribbles"
new_weight = 20
problem_block_course.display_name = new_display_name
problem_block_course.weight = new_weight
self.store.update_item(problem_block_course, self.user_id)
# Test that "Any previously existing children of `dest_usage`
# that haven't been replaced/updated by this copy_from_template operation will be deleted."
extra_block = self.make_block("html", vertical_block_course)
# Repeat the copy_from_template():
new_blocks2 = self.store.copy_from_template(source_keys, dest_key=course.location, user_id=self.user_id)
self.assertEqual(new_blocks, new_blocks2)
# Reload problem_block_course:
problem_block_course = self.store.get_item(problem_block_course.location)
self.assertEqual(problem_block_course.display_name, new_display_name)
self.assertEqual(problem_block_course.weight, new_weight)
# Ensure that extra_block was deleted:
vertical_block_course = self.store.get_item(new_blocks2[0])
self.assertEqual(len(vertical_block_course.children), 1)
with self.assertRaises(ItemNotFoundError):
self.store.get_item(extra_block.location)
def test_copy_from_template_publish(self):
"""
Test that copy_from_template's "defaults" data is not lost
when blocks are published.
"""
# Create a library with a problem:
source_library = LibraryFactory.create(modulestore=self.store)
display_name_expected = "CUSTOM Library Display Name"
self.make_block("problem", source_library, display_name=display_name_expected)
# Reload source_library since we need its branch and version to use copy_from_template:
source_library = self.store.get_library(
source_library.location.library_key, remove_version=False, remove_branch=False
)
# And a course with a vertical:
course = CourseFactory.create(modulestore=self.store)
self.make_block("vertical", course)
problem_key_in_course = self.store.copy_from_template(
source_library.children, dest_key=course.location, user_id=self.user_id
)[0]
# We do the following twice because different methods get used inside
# split modulestore on first vs. subsequent publish
for __ in range(0, 2):
# Publish:
self.store.publish(problem_key_in_course, self.user_id)
# Test that the defaults values are there.
problem_published = self.store.get_item(
problem_key_in_course.for_branch(ModuleStoreEnum.BranchName.published)
)
self.assertEqual(problem_published.display_name, display_name_expected)
def test_copy_from_template_auto_publish(self):
"""
Make sure that copy_from_template works with things like 'chapter' that
are always auto-published.
"""
source_course = CourseFactory.create(modulestore=self.store)
course = CourseFactory.create(modulestore=self.store)
# Populate the course:
about = self.make_block("about", source_course)
chapter = self.make_block("chapter", source_course)
sequential = self.make_block("sequential", chapter)
# And three blocks that are NOT auto-published:
vertical = self.make_block("vertical", sequential)
self.make_block("problem", vertical)
html = self.make_block("html", source_course)
# Reload source_course since we need its branch and version to use copy_from_template:
source_course = self.store.get_course(
source_course.location.course_key, remove_version=False, remove_branch=False
)
# Inherit the vertical and the problem from the library into the course:
source_keys = [block.location for block in [about, chapter, html]]
block_keys = self.store.copy_from_template(source_keys, dest_key=course.location, user_id=self.user_id)
self.assertEqual(len(block_keys), len(source_keys))
# Build dict of the new blocks in 'course', keyed by category (which is a unique key in our case)
new_blocks = {}
block_keys = set(block_keys)
while block_keys:
key = block_keys.pop()
block = self.store.get_item(key)
new_blocks[block.category] = block
block_keys.update(set(getattr(block, "children", [])))
# Check that auto-publish blocks with no children are indeed published:
def published_version_exists(block):
""" Does a published version of block exist? """
try:
self.store.get_item(block.location.for_branch(ModuleStoreEnum.BranchName.published))
return True
except ItemNotFoundError:
return False
# Check that the auto-publish blocks have been published:
self.assertFalse(self.store.has_changes(new_blocks["about"]))
# We can't use has_changes because it includes descendants
self.assertTrue(published_version_exists(new_blocks["chapter"]))
self.assertTrue(published_version_exists(new_blocks["sequential"])) # Ditto
# Check that non-auto-publish blocks and blocks with non-auto-publish descendants show changes:
self.assertTrue(self.store.has_changes(new_blocks["html"]))
self.assertTrue(self.store.has_changes(new_blocks["problem"]))
# Will have changes since a child block has changes.
self.assertTrue(self.store.has_changes(new_blocks["chapter"]))
# Verify that our published_version_exists works
self.assertFalse(published_version_exists(new_blocks["vertical"]))

View File

@@ -10,6 +10,7 @@ from xmodule.modulestore.draft_and_published import ModuleStoreDraftAndPublished
from xmodule.modulestore.edit_info import EditInfoMixin
from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.modulestore.mixed import MixedModuleStore
from xmodule.modulestore.tests.factories import ItemFactory
from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST
from xmodule.tests import DATA_DIR
@@ -108,3 +109,18 @@ class MixedSplitTestCase(TestCase):
)
self.addCleanup(self.store.close_all_connections)
self.addCleanup(self.store._drop_database) # pylint: disable=protected-access
def make_block(self, category, parent_block, **kwargs):
"""
Create a block of type `category` as a child of `parent_block`, in any
course or library. You can pass any field values as kwargs.
"""
extra = {"publish_item": False, "user_id": self.user_id}
extra.update(kwargs)
return ItemFactory.create(
category=category,
parent=parent_block,
parent_location=parent_block.location,
modulestore=self.store,
**extra
)

View File

@@ -0,0 +1,36 @@
/* JavaScript for special editing operations that can be done on LibraryContentXBlock */
window.LibraryContentAuthorView = function (runtime, element) {
"use strict";
var $element = $(element);
var usage_id = $element.data('usage-id');
// The "Update Now" button is not a child of 'element', as it is in the validation message area
// But it is still inside this xblock's wrapper element, which we can easily find:
var $wrapper = $element.parents('*[data-locator="'+usage_id+'"]');
// We can't bind to the button itself because in the bok choy test environment,
// it may not yet exist at this point in time... not sure why.
$wrapper.on('click', '.library-update-btn', function(e) {
e.preventDefault();
// Update the XBlock with the latest matching content from the library:
runtime.notify('save', {
state: 'start',
element: element,
message: gettext('Updating with latest library content')
});
$.post(runtime.handlerUrl(element, 'refresh_children')).done(function() {
runtime.notify('save', {
state: 'end',
element: element
});
if ($element.closest('.wrapper-xblock').is(':not(.level-page)')) {
// We are on a course unit page. The notify('save') should refresh this block,
// but that is only working on the container page view of this block.
// Why? On the unit page, this XBlock's runtime has no reference to the
// XBlockContainerPage - only the top-level XBlock (a vertical) runtime does.
// But unfortunately there is no way to get a reference to our parent block's
// JS 'runtime' object. So instead we must refresh the whole page:
location.reload();
}
});
});
};

View File

@@ -19,10 +19,11 @@ from webob.multidict import MultiDict
import xmodule
from xmodule.tests import DATA_DIR
from capa import responsetypes
from capa.responsetypes import (StudentInputError, LoncapaProblemError,
ResponseError)
from capa.xqueue_interface import XQueueInterface
from xmodule.capa_module import CapaModule, ComplexEncoder
from xmodule.capa_module import CapaModule, CapaDescriptor, ComplexEncoder
from opaque_keys.edx.locations import Location
from xblock.field_data import DictFieldData
from xblock.fields import ScopeIds
@@ -1661,6 +1662,63 @@ class CapaModuleTest(unittest.TestCase):
self.assertEquals(event_info['success'], 'incorrect')
@ddt.ddt
class CapaDescriptorTest(unittest.TestCase):
def _create_descriptor(self, xml):
""" Creates a CapaDescriptor to run test against """
descriptor = CapaDescriptor(get_test_system(), scope_ids=1)
descriptor.data = xml
return descriptor
@ddt.data(*responsetypes.registry.registered_tags())
def test_all_response_types(self, response_tag):
""" Tests that every registered response tag is correctly returned """
xml = "<problem><{response_tag}></{response_tag}></problem>".format(response_tag=response_tag)
descriptor = self._create_descriptor(xml)
self.assertEquals(descriptor.problem_types, {response_tag})
def test_response_types_ignores_non_response_tags(self):
xml = textwrap.dedent("""
<problem>
<p>Label</p>
<div>Some comment</div>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" answer-pool="4">
<choice correct="false">Apple</choice>
<choice correct="false">Banana</choice>
<choice correct="false">Chocolate</choice>
<choice correct ="true">Donut</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""")
descriptor = self._create_descriptor(xml)
self.assertEquals(descriptor.problem_types, {"multiplechoiceresponse"})
def test_response_types_multiple_tags(self):
xml = textwrap.dedent("""
<problem>
<p>Label</p>
<div>Some comment</div>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" answer-pool="1">
<choice correct ="true">Donut</choice>
</choicegroup>
</multiplechoiceresponse>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice" answer-pool="1">
<choice correct ="true">Buggy</choice>
</choicegroup>
</multiplechoiceresponse>
<optionresponse>
<optioninput label="Option" options="('1','2')" correct="2"></optioninput>
</optionresponse>
</problem>
""")
descriptor = self._create_descriptor(xml)
self.assertEquals(descriptor.problem_types, {"multiplechoiceresponse", "optionresponse"})
class ComplexEncoderTest(unittest.TestCase):
def test_default(self):
"""
@@ -1690,18 +1748,10 @@ class TestProblemCheckTracking(unittest.TestCase):
<p>Which piece of furniture is built for sitting?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice">
<choice correct="false">
<text>a table</text>
</choice>
<choice correct="false">
<text>a desk</text>
</choice>
<choice correct="true">
<text>a chair</text>
</choice>
<choice correct="false">
<text>a bookshelf</text>
</choice>
<choice correct="false"><text>a table</text></choice>
<choice correct="false"><text>a desk</text></choice>
<choice correct="true"><text>a chair</text></choice>
<choice correct="false"><text>a bookshelf</text></choice>
</choicegroup>
</multiplechoiceresponse>
<p>Which of the following are musical instruments?</p>

View File

@@ -0,0 +1,484 @@
# -*- coding: utf-8 -*-
"""
Basic unit tests for LibraryContentModule
Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`.
"""
from bson.objectid import ObjectId
from mock import Mock, patch
from opaque_keys.edx.locator import LibraryLocator
from unittest import TestCase
from xblock.fragment import Fragment
from xblock.runtime import Runtime as VanillaRuntime
from xmodule.library_content_module import (
LibraryVersionReference, LibraryList, ANY_CAPA_TYPE_VALUE, LibraryContentDescriptor
)
from xmodule.library_tools import LibraryToolsService
from xmodule.modulestore.tests.factories import LibraryFactory, CourseFactory
from xmodule.modulestore.tests.utils import MixedSplitTestCase
from xmodule.tests import get_test_system
from xmodule.validation import StudioValidationMessage
from xmodule.x_module import AUTHOR_VIEW
dummy_render = lambda block, _: Fragment(block.data) # pylint: disable=invalid-name
class LibraryContentTest(MixedSplitTestCase):
"""
Base class for tests of LibraryContentModule (library_content_module.py)
"""
def setUp(self):
super(LibraryContentTest, self).setUp()
self.tools = LibraryToolsService(self.store)
self.library = LibraryFactory.create(modulestore=self.store)
self.lib_blocks = [
self.make_block("html", self.library, data="Hello world from block {}".format(i))
for i in range(1, 5)
]
self.course = CourseFactory.create(modulestore=self.store)
self.chapter = self.make_block("chapter", self.course)
self.sequential = self.make_block("sequential", self.chapter)
self.vertical = self.make_block("vertical", self.sequential)
self.lc_block = self.make_block(
"library_content",
self.vertical,
max_count=1,
source_libraries=[LibraryVersionReference(self.library.location.library_key)]
)
def _bind_course_module(self, module):
"""
Bind a module (part of self.course) so we can access student-specific data.
"""
module_system = get_test_system(course_id=self.course.location.course_key)
module_system.descriptor_runtime = module.runtime
module_system._services['library_tools'] = self.tools # pylint: disable=protected-access
def get_module(descriptor):
"""Mocks module_system get_module function"""
sub_module_system = get_test_system(course_id=self.course.location.course_key)
sub_module_system.get_module = get_module
sub_module_system.descriptor_runtime = descriptor.runtime
descriptor.bind_for_student(sub_module_system, descriptor._field_data) # pylint: disable=protected-access
return descriptor
module_system.get_module = get_module
module.xmodule_runtime = module_system
class TestLibraryContentModule(LibraryContentTest):
"""
Basic unit tests for LibraryContentModule
"""
def _get_capa_problem_type_xml(self, *args):
""" Helper function to create empty CAPA problem definition """
problem = "<problem>"
for problem_type in args:
problem += "<{problem_type}></{problem_type}>".format(problem_type=problem_type)
problem += "</problem>"
return problem
def _create_capa_problems(self):
"""
Helper function to create a set of capa problems to test against.
Creates four blocks total.
"""
problem_types = [
["multiplechoiceresponse"], ["optionresponse"], ["optionresponse", "coderesponse"],
["coderesponse", "optionresponse"]
]
for problem_type in problem_types:
self.make_block("problem", self.library, data=self._get_capa_problem_type_xml(*problem_type))
def test_lib_content_block(self):
"""
Test that blocks from a library are copied and added as children
"""
# Check that the LibraryContent block has no children initially
# Normally the children get added when the "source_libraries" setting
# is updated, but the way we do it through a factory doesn't do that.
self.assertEqual(len(self.lc_block.children), 0)
# Update the LibraryContent module:
self.lc_block.refresh_children()
self.lc_block = self.store.get_item(self.lc_block.location)
# Check that all blocks from the library are now children of the block:
self.assertEqual(len(self.lc_block.children), len(self.lib_blocks))
def test_children_seen_by_a_user(self):
"""
Test that each student sees only one block as a child of the LibraryContent block.
"""
self.lc_block.refresh_children()
self.lc_block = self.store.get_item(self.lc_block.location)
self._bind_course_module(self.lc_block)
# Make sure the runtime knows that the block's children vary per-user:
self.assertTrue(self.lc_block.has_dynamic_children())
self.assertEqual(len(self.lc_block.children), len(self.lib_blocks))
# Check how many children each user will see:
self.assertEqual(len(self.lc_block.get_child_descriptors()), 1)
# Check that get_content_titles() doesn't return titles for hidden/unused children
self.assertEqual(len(self.lc_block.get_content_titles()), 1)
def test_validation_of_course_libraries(self):
"""
Test that the validation method of LibraryContent blocks can validate
the source_libraries setting.
"""
# When source_libraries is blank, the validation summary should say this block needs to be configured:
self.lc_block.source_libraries = []
result = self.lc_block.validate()
self.assertFalse(result) # Validation fails due to at least one warning/message
self.assertTrue(result.summary)
self.assertEqual(StudioValidationMessage.NOT_CONFIGURED, result.summary.type)
# When source_libraries references a non-existent library, we should get an error:
self.lc_block.source_libraries = [LibraryVersionReference("library-v1:BAD+WOLF")]
result = self.lc_block.validate()
self.assertFalse(result) # Validation fails due to at least one warning/message
self.assertTrue(result.summary)
self.assertEqual(StudioValidationMessage.ERROR, result.summary.type)
self.assertIn("invalid", result.summary.text)
# When source_libraries is set but the block needs to be updated, the summary should say so:
self.lc_block.source_libraries = [LibraryVersionReference(self.library.location.library_key)]
result = self.lc_block.validate()
self.assertFalse(result) # Validation fails due to at least one warning/message
self.assertTrue(result.summary)
self.assertEqual(StudioValidationMessage.WARNING, result.summary.type)
self.assertIn("out of date", result.summary.text)
# Now if we update the block, all validation should pass:
self.lc_block.refresh_children()
self.assertTrue(self.lc_block.validate())
def test_validation_of_matching_blocks(self):
"""
Test that the validation method of LibraryContent blocks can warn
the user about problems with other settings (max_count and capa_type).
"""
# Set max_count to higher value than exists in library
self.lc_block.max_count = 50
# In the normal studio editing process, editor_saved() calls refresh_children at this point
self.lc_block.refresh_children()
result = self.lc_block.validate()
self.assertFalse(result) # Validation fails due to at least one warning/message
self.assertTrue(result.summary)
self.assertEqual(StudioValidationMessage.WARNING, result.summary.type)
self.assertIn("only 4 matching problems", result.summary.text)
# Add some capa problems so we can check problem type validation messages
self.lc_block.max_count = 1
self._create_capa_problems()
self.lc_block.refresh_children()
self.assertTrue(self.lc_block.validate())
# Existing problem type should pass validation
self.lc_block.max_count = 1
self.lc_block.capa_type = 'multiplechoiceresponse'
self.lc_block.refresh_children()
self.assertTrue(self.lc_block.validate())
# ... unless requested more blocks than exists in library
self.lc_block.max_count = 10
self.lc_block.capa_type = 'multiplechoiceresponse'
self.lc_block.refresh_children()
result = self.lc_block.validate()
self.assertFalse(result) # Validation fails due to at least one warning/message
self.assertTrue(result.summary)
self.assertEqual(StudioValidationMessage.WARNING, result.summary.type)
self.assertIn("only 1 matching problem", result.summary.text)
# Missing problem type should always fail validation
self.lc_block.max_count = 1
self.lc_block.capa_type = 'customresponse'
self.lc_block.refresh_children()
result = self.lc_block.validate()
self.assertFalse(result) # Validation fails due to at least one warning/message
self.assertTrue(result.summary)
self.assertEqual(StudioValidationMessage.WARNING, result.summary.type)
self.assertIn("no matching problem types", result.summary.text)
def test_capa_type_filtering(self):
"""
Test that the capa type filter is actually filtering children
"""
self._create_capa_problems()
self.assertEqual(len(self.lc_block.children), 0) # precondition check
self.lc_block.capa_type = "multiplechoiceresponse"
self.lc_block.refresh_children()
self.assertEqual(len(self.lc_block.children), 1)
self.lc_block.capa_type = "optionresponse"
self.lc_block.refresh_children()
self.assertEqual(len(self.lc_block.children), 3)
self.lc_block.capa_type = "coderesponse"
self.lc_block.refresh_children()
self.assertEqual(len(self.lc_block.children), 2)
self.lc_block.capa_type = "customresponse"
self.lc_block.refresh_children()
self.assertEqual(len(self.lc_block.children), 0)
self.lc_block.capa_type = ANY_CAPA_TYPE_VALUE
self.lc_block.refresh_children()
self.assertEqual(len(self.lc_block.children), len(self.lib_blocks) + 4)
def test_non_editable_settings(self):
"""
Test the settings that are marked as "non-editable".
"""
non_editable_metadata_fields = self.lc_block.non_editable_metadata_fields
self.assertIn(LibraryContentDescriptor.mode, non_editable_metadata_fields)
self.assertNotIn(LibraryContentDescriptor.display_name, non_editable_metadata_fields)
@patch(
'xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render
)
@patch('xmodule.html_module.HtmlModule.author_view', dummy_render, create=True)
@patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: [])
class TestLibraryContentRender(LibraryContentTest):
"""
Rendering unit tests for LibraryContentModule
"""
def test_preivew_view(self):
""" Test preview view rendering """
self.lc_block.refresh_children()
self.lc_block = self.store.get_item(self.lc_block.location)
self.assertEqual(len(self.lc_block.children), len(self.lib_blocks))
self._bind_course_module(self.lc_block)
rendered = self.lc_block.render(AUTHOR_VIEW, {'root_xblock': self.lc_block})
self.assertIn("Hello world from block 1", rendered.content)
def test_author_view(self):
""" Test author view rendering """
self.lc_block.refresh_children()
self.lc_block = self.store.get_item(self.lc_block.location)
self.assertEqual(len(self.lc_block.children), len(self.lib_blocks))
self._bind_course_module(self.lc_block)
rendered = self.lc_block.render(AUTHOR_VIEW, {})
self.assertEqual("", rendered.content) # content should be empty
self.assertEqual("LibraryContentAuthorView", rendered.js_init_fn) # but some js initialization should happen
class TestLibraryList(TestCase):
""" Tests for LibraryList XBlock Field """
def test_from_json_runtime_style(self):
"""
Test that LibraryList can parse raw libraries list as passed by runtime
"""
lib_list = LibraryList()
lib1_key, lib1_version = u'library-v1:Org1+Lib1', '5436ffec56c02c13806a4c1b'
lib2_key, lib2_version = u'library-v1:Org2+Lib2', '112dbaf312c0daa019ce9992'
raw = [[lib1_key, lib1_version], [lib2_key, lib2_version]]
parsed = lib_list.from_json(raw)
self.assertEqual(len(parsed), 2)
self.assertEquals(parsed[0].library_id, LibraryLocator.from_string(lib1_key))
self.assertEquals(parsed[0].version, ObjectId(lib1_version))
self.assertEquals(parsed[1].library_id, LibraryLocator.from_string(lib2_key))
self.assertEquals(parsed[1].version, ObjectId(lib2_version))
def test_from_json_studio_editor_style(self):
"""
Test that LibraryList can parse raw libraries list as passed by studio editor
"""
lib_list = LibraryList()
lib1_key, lib1_version = u'library-v1:Org1+Lib1', '5436ffec56c02c13806a4c1b'
lib2_key, lib2_version = u'library-v1:Org2+Lib2', '112dbaf312c0daa019ce9992'
raw = [lib1_key + ',' + lib1_version, lib2_key + ',' + lib2_version]
parsed = lib_list.from_json(raw)
self.assertEqual(len(parsed), 2)
self.assertEquals(parsed[0].library_id, LibraryLocator.from_string(lib1_key))
self.assertEquals(parsed[0].version, ObjectId(lib1_version))
self.assertEquals(parsed[1].library_id, LibraryLocator.from_string(lib2_key))
self.assertEquals(parsed[1].version, ObjectId(lib2_version))
def test_from_json_invalid_value(self):
"""
Test that LibraryList raises Value error if invalid library key is given
"""
lib_list = LibraryList()
with self.assertRaises(ValueError):
lib_list.from_json(["Not-a-library-key,whatever"])
class TestLibraryContentAnalytics(LibraryContentTest):
"""
Test analytics features of LibraryContentModule
"""
def setUp(self):
super(TestLibraryContentAnalytics, self).setUp()
self.publisher = Mock()
self.lc_block.refresh_children()
self.lc_block = self.store.get_item(self.lc_block.location)
self._bind_course_module(self.lc_block)
self.lc_block.xmodule_runtime.publish = self.publisher
def _assert_event_was_published(self, event_type):
"""
Check that a LibraryContentModule analytics event was published by self.lc_block.
"""
self.assertTrue(self.publisher.called)
self.assertTrue(len(self.publisher.call_args[0]), 3)
_, event_name, event_data = self.publisher.call_args[0]
self.assertEqual(event_name, "edx.librarycontentblock.content.{}".format(event_type))
self.assertEqual(event_data["location"], unicode(self.lc_block.location))
return event_data
def test_assigned_event(self):
"""
Test the "assigned" event emitted when a student is assigned specific blocks.
"""
# In the beginning was the lc_block and it assigned one child to the student:
child = self.lc_block.get_child_descriptors()[0]
child_lib_location, child_lib_version = self.store.get_block_original_usage(child.location)
self.assertIsInstance(child_lib_version, ObjectId)
event_data = self._assert_event_was_published("assigned")
block_info = {
"usage_key": unicode(child.location),
"original_usage_key": unicode(child_lib_location),
"original_usage_version": unicode(child_lib_version),
"descendants": [],
}
self.assertEqual(event_data, {
"location": unicode(self.lc_block.location),
"added": [block_info],
"result": [block_info],
"previous_count": 0,
"max_count": 1,
})
self.publisher.reset_mock()
# Now increase max_count so that one more child will be added:
self.lc_block.max_count = 2
# Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access
del self.lc_block._xmodule._selected_set
children = self.lc_block.get_child_descriptors()
self.assertEqual(len(children), 2)
child, new_child = children if children[0].location == child.location else reversed(children)
event_data = self._assert_event_was_published("assigned")
self.assertEqual(event_data["added"][0]["usage_key"], unicode(new_child.location))
self.assertEqual(len(event_data["result"]), 2)
self.assertEqual(event_data["previous_count"], 1)
self.assertEqual(event_data["max_count"], 2)
def test_assigned_descendants(self):
"""
Test the "assigned" event emitted includes descendant block information.
"""
# Replace the blocks in the library with a block that has descendants:
with self.store.bulk_operations(self.library.location.library_key):
self.library.children = []
main_vertical = self.make_block("vertical", self.library)
inner_vertical = self.make_block("vertical", main_vertical)
html_block = self.make_block("html", inner_vertical)
problem_block = self.make_block("problem", inner_vertical)
self.lc_block.refresh_children()
# Reload lc_block and set it up for a student:
self.lc_block = self.store.get_item(self.lc_block.location)
self._bind_course_module(self.lc_block)
self.lc_block.xmodule_runtime.publish = self.publisher
# Get the keys of each of our blocks, as they appear in the course:
course_usage_main_vertical = self.lc_block.children[0]
course_usage_inner_vertical = self.store.get_item(course_usage_main_vertical).children[0]
inner_vertical_in_course = self.store.get_item(course_usage_inner_vertical)
course_usage_html = inner_vertical_in_course.children[0]
course_usage_problem = inner_vertical_in_course.children[1]
# Trigger a publish event:
self.lc_block.get_child_descriptors()
event_data = self._assert_event_was_published("assigned")
for block_list in (event_data["added"], event_data["result"]):
self.assertEqual(len(block_list), 1) # main_vertical is the only root block added, and is the only result.
self.assertEqual(block_list[0]["usage_key"], unicode(course_usage_main_vertical))
# Check that "descendants" is a flat, unordered list of all of main_vertical's descendants:
descendants_expected = (
(inner_vertical.location, course_usage_inner_vertical),
(html_block.location, course_usage_html),
(problem_block.location, course_usage_problem),
)
descendant_data_expected = {}
for lib_key, course_usage_key in descendants_expected:
descendant_data_expected[unicode(course_usage_key)] = {
"usage_key": unicode(course_usage_key),
"original_usage_key": unicode(lib_key),
"original_usage_version": unicode(self.store.get_block_original_usage(course_usage_key)[1]),
}
self.assertEqual(len(block_list[0]["descendants"]), len(descendant_data_expected))
for descendant in block_list[0]["descendants"]:
self.assertEqual(descendant, descendant_data_expected.get(descendant["usage_key"]))
def test_removed_overlimit(self):
"""
Test the "removed" event emitted when we un-assign blocks previously assigned to a student.
We go from one blocks assigned to none because max_count has been decreased.
"""
# Decrease max_count to 1, causing the block to be overlimit:
self.lc_block.get_child_descriptors() # This line is needed in the test environment or the change has no effect
self.publisher.reset_mock() # Clear the "assigned" event that was just published.
self.lc_block.max_count = 0
# Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access
del self.lc_block._xmodule._selected_set
# Check that the event says that one block was removed, leaving no blocks left:
children = self.lc_block.get_child_descriptors()
self.assertEqual(len(children), 0)
event_data = self._assert_event_was_published("removed")
self.assertEqual(len(event_data["removed"]), 1)
self.assertEqual(event_data["result"], [])
self.assertEqual(event_data["reason"], "overlimit")
def test_removed_invalid(self):
"""
Test the "removed" event emitted when we un-assign blocks previously assigned to a student.
We go from two blocks assigned, to one because the others have been deleted from the library.
"""
# Start by assigning two blocks to the student:
self.lc_block.get_child_descriptors() # This line is needed in the test environment or the change has no effect
self.lc_block.max_count = 2
# Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access
del self.lc_block._xmodule._selected_set
initial_blocks_assigned = self.lc_block.get_child_descriptors()
self.assertEqual(len(initial_blocks_assigned), 2)
self.publisher.reset_mock() # Clear the "assigned" event that was just published.
# Now make sure that one of the assigned blocks will have to be un-assigned.
# To cause an "invalid" event, we delete all blocks from the content library
# except for one of the two already assigned to the student:
keep_block_key = initial_blocks_assigned[0].location
keep_block_lib_usage_key, keep_block_lib_version = self.store.get_block_original_usage(keep_block_key)
deleted_block_key = initial_blocks_assigned[1].location
self.library.children = [keep_block_lib_usage_key]
self.store.update_item(self.library, self.user_id)
self.lc_block.refresh_children()
# Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access
del self.lc_block._xmodule._selected_set
# Check that the event says that one block was removed, leaving one block left:
children = self.lc_block.get_child_descriptors()
self.assertEqual(len(children), 1)
event_data = self._assert_event_was_published("removed")
self.assertEqual(event_data["removed"], [{
"usage_key": unicode(deleted_block_key),
"original_usage_key": None, # Note: original_usage_key info is sadly unavailable because the block has been
# deleted so that info can no longer be retrieved
"original_usage_version": None,
"descendants": [],
}])
self.assertEqual(event_data["result"], [{
"usage_key": unicode(keep_block_key),
"original_usage_key": unicode(keep_block_lib_usage_key),
"original_usage_version": unicode(keep_block_lib_version),
"descendants": [],
}])
self.assertEqual(event_data["reason"], "invalid")

View File

@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
"""
Basic unit tests for LibraryRoot
"""
from mock import patch
from xblock.fragment import Fragment
from xblock.runtime import Runtime as VanillaRuntime
from xmodule.x_module import AUTHOR_VIEW
from xmodule.modulestore.tests.factories import LibraryFactory, ItemFactory
from xmodule.modulestore.tests.utils import MixedSplitTestCase
dummy_render = lambda block, _: Fragment(block.data) # pylint: disable=invalid-name
@patch(
'xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render
)
@patch('xmodule.html_module.HtmlDescriptor.author_view', dummy_render, create=True)
@patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: [])
class TestLibraryRoot(MixedSplitTestCase):
"""
Basic unit tests for LibraryRoot (library_root_xblock.py)
"""
def test_library_author_view(self):
"""
Test that LibraryRoot.author_view can run and includes content from its
children.
We have to patch the runtime (module system) in order to be able to
render blocks in our test environment.
"""
message = u"Hello world"
library = LibraryFactory.create(modulestore=self.store)
# Add one HTML block to the library:
ItemFactory.create(
category="html",
parent_location=library.location,
user_id=self.user_id,
publish_item=False,
modulestore=self.store,
data=message
)
library = self.store.get_library(library.location.library_key)
context = {'reorderable_items': set(), }
# Patch the HTML block to always render "Hello world"
result = library.render(AUTHOR_VIEW, context)
self.assertIn(message, result.content)
def test_library_author_view_with_paging(self):
"""
Test that LibraryRoot.author_view can apply paging
We have to patch the runtime (module system) in order to be able to
render blocks in our test environment.
"""
library = LibraryFactory.create(modulestore=self.store)
# Add five HTML blocks to the library:
blocks = [
ItemFactory.create(
category="html",
parent_location=library.location,
user_id=self.user_id,
publish_item=False,
modulestore=self.store,
data="HtmlBlock" + str(i)
)
for i in range(5)
]
library = self.store.get_library(library.location.library_key)
def render_and_check_contents(page, page_size):
""" Renders block and asserts on returned content """
context = {'reorderable_items': set(), 'paging': {'page_number': page, 'page_size': page_size}}
expected_blocks = blocks[page_size * page:page_size * (page + 1)]
result = library.render(AUTHOR_VIEW, context)
for expected_block in expected_blocks:
self.assertIn(expected_block.data, result.content)
render_and_check_contents(0, 3)
render_and_check_contents(1, 3)
render_and_check_contents(0, 2)
render_and_check_contents(1, 2)

View File

@@ -1250,6 +1250,7 @@ class DescriptorSystem(MetricsMixin, ConfigurableFragmentWrapper, Runtime): # p
:param xblock:
:param field:
"""
# pylint: disable=protected-access
# in runtime b/c runtime contains app-specific xblock behavior. Studio's the only app
# which needs this level of introspection right now. runtime also is 'allowed' to know
# about the kvs, dbmodel, etc.
@@ -1257,12 +1258,8 @@ class DescriptorSystem(MetricsMixin, ConfigurableFragmentWrapper, Runtime): # p
result = {}
result['explicitly_set'] = xblock._field_data.has(xblock, field.name)
try:
block_inherited = xblock.xblock_kvs.inherited_settings
except AttributeError: # if inherited_settings doesn't exist on kvs
block_inherited = {}
if field.name in block_inherited:
result['default_value'] = block_inherited[field.name]
else:
result['default_value'] = xblock._field_data.default(xblock, field.name)
except KeyError:
result['default_value'] = field.to_json(field.default)
return result