Merge pull request #6459 from edx/content-libraries
Content libraries MVP
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
534
common/lib/xmodule/xmodule/library_content_module.py
Normal file
534
common/lib/xmodule/xmodule/library_content_module.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
135
common/lib/xmodule/xmodule/library_tools.py
Normal file
135
common/lib/xmodule/xmodule/library_tools.py
Normal 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
|
||||
@@ -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]
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"]))
|
||||
@@ -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
|
||||
)
|
||||
|
||||
36
common/lib/xmodule/xmodule/public/js/library_content_edit.js
Normal file
36
common/lib/xmodule/xmodule/public/js/library_content_edit.js
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
484
common/lib/xmodule/xmodule/tests/test_library_content.py
Normal file
484
common/lib/xmodule/xmodule/tests/test_library_content.py
Normal 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")
|
||||
85
common/lib/xmodule/xmodule/tests/test_library_root.py
Normal file
85
common/lib/xmodule/xmodule/tests/test_library_root.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user