diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py
index c6fdc00301cf..bc8975eb5d6d 100644
--- a/cms/djangoapps/contentstore/tests/test_libraries.py
+++ b/cms/djangoapps/contentstore/tests/test_libraries.py
@@ -326,6 +326,99 @@ def test_change_after_first_sync(self):
html_block = modulestore().get_item(lc_block.children[0])
self.assertEqual(html_block.data, data_value)
+ def test_refreshes_children_if_libraries_change(self):
+ library2key = self._create_library("org2", "lib2", "Library2")
+ library2 = modulestore().get_library(library2key)
+ data1, data2 = "Hello world!", "Hello other world!"
+ ItemFactory.create(
+ category="html",
+ parent_location=self.library.location,
+ user_id=self.user.id,
+ publish_item=False,
+ display_name="Lib1: HTML BLock",
+ data=data1,
+ )
+
+ ItemFactory.create(
+ category="html",
+ parent_location=library2.location,
+ user_id=self.user.id,
+ publish_item=False,
+ display_name="Lib 2: HTML BLock",
+ data=data2,
+ )
+
+ # Create a course:
+ with modulestore().default_store(ModuleStoreEnum.Type.split):
+ course = CourseFactory.create()
+
+ # Add a LibraryContent block to the course:
+ lc_block = self._add_library_content_block(course, self.lib_key)
+ lc_block = self._refresh_children(lc_block)
+ self.assertEqual(len(lc_block.children), 1)
+
+ # Now, change the block settings to have an invalid library key:
+ resp = self._update_item(
+ lc_block.location,
+ {"source_libraries": [[str(library2key)]]},
+ )
+ self.assertEqual(resp.status_code, 200)
+ lc_block = modulestore().get_item(lc_block.location)
+
+ self.assertEqual(len(lc_block.children), 1)
+ html_block = modulestore().get_item(lc_block.children[0])
+ self.assertEqual(html_block.data, data2)
+
+ def test_refreshes_children_if_capa_type_change(self):
+ name1, name2 = "Option Problem", "Multiple Choice Problem"
+ ItemFactory.create(
+ category="problem",
+ parent_location=self.library.location,
+ user_id=self.user.id,
+ publish_item=False,
+ display_name=name1,
+ data="",
+ )
+ ItemFactory.create(
+ category="problem",
+ parent_location=self.library.location,
+ user_id=self.user.id,
+ publish_item=False,
+ display_name=name2,
+ data="",
+ )
+
+ # Create a course:
+ with modulestore().default_store(ModuleStoreEnum.Type.split):
+ course = CourseFactory.create()
+
+ # Add a LibraryContent block to the course:
+ lc_block = self._add_library_content_block(course, self.lib_key)
+ lc_block = self._refresh_children(lc_block)
+ self.assertEqual(len(lc_block.children), 2)
+
+ resp = self._update_item(
+ lc_block.location,
+ {"capa_type": 'optionresponse'},
+ )
+ self.assertEqual(resp.status_code, 200)
+ lc_block = modulestore().get_item(lc_block.location)
+
+ self.assertEqual(len(lc_block.children), 1)
+ html_block = modulestore().get_item(lc_block.children[0])
+ self.assertEqual(html_block.display_name, name1)
+
+ resp = self._update_item(
+ lc_block.location,
+ {"capa_type": 'multiplechoiceresponse'},
+ )
+ self.assertEqual(resp.status_code, 200)
+ lc_block = modulestore().get_item(lc_block.location)
+
+ self.assertEqual(len(lc_block.children), 1)
+ html_block = modulestore().get_item(lc_block.children[0])
+ self.assertEqual(html_block.display_name, name2)
+
@ddt.ddt
class TestLibraryAccess(LibraryTestCase):
diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py
index a0b74372e255..6b64ccbd3304 100644
--- a/common/lib/capa/capa/responsetypes.py
+++ b/common/lib/capa/capa/responsetypes.py
@@ -58,6 +58,8 @@
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 @@ def sample_from_answer_pool(self, choices, rng, num_pool):
@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):
"""
+ human_name = _('Text Input')
tags = ['stringresponse']
hint_tag = 'stringhint'
allowed_inputfields = ['textline']
@@ -1426,6 +1435,7 @@ class CustomResponse(LoncapaResponse):
or in a
"""
+ human_name = _('Custom Evaluated Script')
tags = ['customresponse']
allowed_inputfields = ['textline', 'textbox', 'crystallography',
@@ -1797,6 +1807,7 @@ class SymbolicResponse(CustomResponse):
Symbolic math response checking, using symmath library.
"""
+ human_name = _('Symbolic Math Input')
tags = ['symbolicresponse']
max_inputfields = 1
@@ -1865,6 +1876,7 @@ class CodeResponse(LoncapaResponse):
"""
+ human_name = _('Code Input')
tags = ['coderesponse']
allowed_inputfields = ['textbox', 'filesubmission', 'matlabinput']
max_inputfields = 1
@@ -2142,6 +2154,7 @@ class ExternalResponse(LoncapaResponse):
"""
+ human_name = _('External Grader')
tags = ['externalresponse']
allowed_inputfields = ['textline', 'textbox']
awdmap = {
@@ -2299,6 +2312,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']
@@ -2511,6 +2525,7 @@ class SchematicResponse(LoncapaResponse):
"""
Circuit schematic response type.
"""
+ human_name = _('Circuit Schematic Builder')
tags = ['schematicresponse']
allowed_inputfields = ['schematic']
@@ -2589,6 +2604,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']
@@ -2707,6 +2723,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
@@ -2831,6 +2848,7 @@ class ChoiceTextResponse(LoncapaResponse):
ChoiceResponse.
"""
+ human_name = _('Checkboxes With Text Input')
tags = ['choicetextresponse']
max_inputfields = 1
allowed_inputfields = ['choicetextgroup',
diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py
index 776dc36e3fb9..47583d97065a 100644
--- a/common/lib/xmodule/xmodule/capa_module.py
+++ b/common/lib/xmodule/xmodule/capa_module.py
@@ -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 @@ def non_editable_metadata_fields(self):
])
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)
+ 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')
diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py
index 5004a5d51ae2..1b68e27a59c2 100644
--- a/common/lib/xmodule/xmodule/library_content_module.py
+++ b/common/lib/xmodule/xmodule/library_content_module.py
@@ -5,6 +5,9 @@
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
@@ -19,15 +22,38 @@
from .xml_module import XmlDescriptor
from pkg_resources import resource_string
+
# 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.
@@ -146,6 +172,13 @@ class LibraryContentFields(object):
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"),
@@ -290,7 +323,7 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe
js_module_name = "VerticalDescriptor"
@XBlock.handler
- def refresh_children(self, request, suffix, update_db=True): # pylint: disable=unused-argument
+ def refresh_children(self, request=None, suffix=None, update_db=True): # pylint: disable=unused-argument
"""
Refresh children:
This method is to be used when any of the libraries that this block
@@ -313,6 +346,42 @@ def refresh_children(self, request, suffix, update_db=True): # pylint: disable=
lib_tools.update_children(self, user_id, user_perms, update_db)
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
@@ -328,43 +397,59 @@ def validate(self):
StudioValidationMessage.NOT_CONFIGURED,
_(u"A library has not yet been selected."),
action_class='edit-button',
- action_label=_(u"Select a Library")
+ action_label=_(u"Select a Library.")
)
)
return validation
lib_tools = self.runtime.service(self, 'library_tools')
for library_key, version in self.source_libraries:
- 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.'),
- action_class='library-update-btn', # TODO: change this to action_runtime_event='...' once the unit page supports that feature.
- action_label=_(u"↻ Update now")
+ 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
)
- )
- break
- 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")
- )
+ ).format(count=self.max_count, actual=matching_children_count),
+ action_class='edit-button',
+ action_label=_(u"Edit the library configuration.")
)
- break
+ )
return validation
def editor_saved(self, user, old_metadata, old_content):
"""
- If source_libraries has been edited, refresh_children automatically.
+ 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):
+ 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(None, None, update_db=False) # update_db=False since update_item() is about to be called anyways
except ValueError:
diff --git a/common/lib/xmodule/xmodule/library_tools.py b/common/lib/xmodule/xmodule/library_tools.py
index f8dcadf80e16..f9b9f3edab83 100644
--- a/common/lib/xmodule/xmodule/library_tools.py
+++ b/common/lib/xmodule/xmodule/library_tools.py
@@ -5,8 +5,9 @@
from django.core.exceptions import PermissionDenied
from opaque_keys.edx.locator import LibraryLocator
from xblock.fields import Scope
-from xmodule.library_content_module import LibraryVersionReference
+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):
@@ -45,6 +46,20 @@ def get_library_version(self, lib_key):
return library.location.library_key.version_guid
return None
+ 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, update_db=True):
"""
This method is to be used when any of the libraries that a LibraryContentModule
@@ -91,13 +106,17 @@ def update_children(self, dest_block, user_id, user_perms=None, update_db=True):
new_libraries = []
for library_key, library in libraries:
- def copy_children_recursively(from_block):
+ def copy_children_recursively(from_block, filter_problem_type=False):
"""
Internal method to copy blocks from the library recursively
"""
new_children = []
- for child_key in from_block.children:
- child = self.store.get_item(child_key, depth=9)
+ if filter_problem_type:
+ filtered_children = [key for key in from_block.children if self._filter_child(key, dest_block.capa_type)]
+ else:
+ filtered_children = from_block.children
+ for child_key in filtered_children:
+ child = self.store.get_item(child_key, depth=None)
# We compute a block_id for each matching child block found in the library.
# block_ids are unique within any branch, but are not unique per-course or globally.
# We need our block_ids to be consistent when content in the library is updated, so
@@ -125,7 +144,7 @@ def copy_children_recursively(from_block):
)
new_children.append(new_child_info.location)
return new_children
- root_children.extend(copy_children_recursively(from_block=library))
+ root_children.extend(copy_children_recursively(from_block=library, filter_problem_type=True))
new_libraries.append(LibraryVersionReference(library_key, library.location.library_key.version_guid))
dest_block.source_libraries = new_libraries
dest_block.children = root_children
diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py
index c829e29654a4..2e0661dbbe93 100644
--- a/common/lib/xmodule/xmodule/tests/test_capa_module.py
+++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py
@@ -19,10 +19,11 @@
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 @@ def test_check_unmask_answerpool(self):
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 = "<{response_tag}>{response_tag}>".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("""
+
+ Label
+ Some comment
+
+
+ Apple
+ Banana
+ Chocolate
+ Donut
+
+
+
+ """)
+ descriptor = self._create_descriptor(xml)
+ self.assertEquals(descriptor.problem_types, {"multiplechoiceresponse"})
+
+ def test_response_types_multiple_tags(self):
+ xml = textwrap.dedent("""
+
+ Label
+ Some comment
+
+
+ Donut
+
+
+
+
+ Buggy
+
+
+
+
+
+
+ """)
+ descriptor = self._create_descriptor(xml)
+ self.assertEquals(descriptor.problem_types, {"multiplechoiceresponse", "optionresponse"})
+
+
class ComplexEncoderTest(unittest.TestCase):
def test_default(self):
"""
@@ -1690,18 +1748,10 @@ def test_choice_answer_text(self):
Which piece of furniture is built for sitting?
-
- a table
-
-
- a desk
-
-
- a chair
-
-
- a bookshelf
-
+ a table
+ a desk
+ a chair
+ a bookshelf
Which of the following are musical instruments?
diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py
index 2b52386e3740..fec957d0cda5 100644
--- a/common/lib/xmodule/xmodule/tests/test_library_content.py
+++ b/common/lib/xmodule/xmodule/tests/test_library_content.py
@@ -5,7 +5,7 @@
Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`.
"""
import ddt
-from xmodule.library_content_module import LibraryVersionReference
+from xmodule.library_content_module import LibraryVersionReference, ANY_CAPA_TYPE_VALUE
from xmodule.modulestore.tests.factories import LibraryFactory, CourseFactory, ItemFactory
from xmodule.modulestore.tests.utils import MixedSplitTestCase
from xmodule.tests import get_test_system
@@ -80,6 +80,34 @@ def get_module(descriptor):
module_system.get_module = get_module
module.xmodule_runtime = module_system
+ def _get_capa_problem_type_xml(self, *args):
+ """ Helper function to create empty CAPA problem definition """
+ problem = ""
+ for problem_type in args:
+ problem += "<{problem_type}>{problem_type}>".format(problem_type=problem_type)
+ 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:
+ ItemFactory.create(
+ category="problem",
+ parent_location=self.library.location,
+ user_id=self.user_id,
+ publish_item=False,
+ data=self._get_capa_problem_type_xml(*problem_type),
+ modulestore=self.store,
+ )
+
def test_lib_content_block(self):
"""
Test that blocks from a library are copied and added as children
@@ -110,9 +138,10 @@ def test_children_seen_by_a_user(self):
# 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(self):
+ def test_validation_of_course_libraries(self):
"""
- Test that the validation method of LibraryContent blocks is working.
+ 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 = []
@@ -138,5 +167,77 @@ def test_validation(self):
self.assertIn("out of date", result.summary.text)
# Now if we update the block, all validation should pass:
- self.lc_block.refresh_children(None, None)
+ 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
+ self.lc_block.refresh_children() # In the normal studio editing process, editor_saved() calls refresh_children at this point
+ 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)
diff --git a/common/test/acceptance/pages/lms/library.py b/common/test/acceptance/pages/lms/library.py
index 8655fae79f55..6978b5fa0b1e 100644
--- a/common/test/acceptance/pages/lms/library.py
+++ b/common/test/acceptance/pages/lms/library.py
@@ -16,6 +16,9 @@ def __init__(self, browser, locator):
self.locator = locator
def is_browser_on_page(self):
+ """
+ Checks if page is opened
+ """
return self.q(css='{}[data-id="{}"]'.format(self.BODY_SELECTOR, self.locator)).present
def _bounded_selector(self, selector):
@@ -35,3 +38,11 @@ def children_contents(self):
"""
child_blocks = self.q(css=self._bounded_selector("div[data-id]"))
return frozenset(child.text for child in child_blocks)
+
+ @property
+ def children_headers(self):
+ """
+ Gets headers of all child XBlocks as list of strings
+ """
+ child_blocks_headers = self.q(css=self._bounded_selector("div[data-id] h2.problem-header"))
+ return frozenset(child.text for child in child_blocks_headers)
diff --git a/common/test/acceptance/pages/studio/library.py b/common/test/acceptance/pages/studio/library.py
index ea7f2299f961..71df4c4ad18d 100644
--- a/common/test/acceptance/pages/studio/library.py
+++ b/common/test/acceptance/pages/studio/library.py
@@ -122,6 +122,7 @@ class StudioLibraryContentXBlockEditModal(CourseOutlineModal, PageObject):
LIBRARY_LABEL = "Libraries"
COUNT_LABEL = "Count"
SCORED_LABEL = "Scored"
+ PROBLEM_TYPE_LABEL = "Problem Type"
def is_browser_on_page(self):
"""
@@ -196,6 +197,24 @@ def scored(self, scored):
scored_select.select_by_value(str(scored))
EmptyPromise(lambda: self.scored == scored, "scored is updated in modal.").fulfill()
+ @property
+ def capa_type(self):
+ """
+ Gets value of CAPA type select
+ """
+ return self.get_metadata_input(self.PROBLEM_TYPE_LABEL).get_attribute('value')
+
+ @capa_type.setter
+ def capa_type(self, value):
+ """
+ Sets value of CAPA type select
+ """
+ select_element = self.get_metadata_input(self.PROBLEM_TYPE_LABEL)
+ select_element.click()
+ problem_type_select = Select(select_element)
+ problem_type_select.select_by_value(value)
+ EmptyPromise(lambda: self.capa_type == value, "problem type is updated in modal.").fulfill()
+
def _add_library_key(self):
"""
Adds library key input
diff --git a/common/test/acceptance/tests/lms/test_library.py b/common/test/acceptance/tests/lms/test_library.py
index f83e6b94e9d5..cb4fd238de1f 100644
--- a/common/test/acceptance/tests/lms/test_library.py
+++ b/common/test/acceptance/tests/lms/test_library.py
@@ -3,6 +3,7 @@
End-to-end tests for LibraryContent block in LMS
"""
import ddt
+import textwrap
from ..helpers import UniqueCourseTest
from ...pages.studio.auto_auth import AutoAuthPage
@@ -19,22 +20,25 @@
UNIT_NAME = 'Test Unit'
-@ddt.ddt
-class LibraryContentTest(UniqueCourseTest):
- """
- Test courseware.
- """
+class LibraryContentTestBase(UniqueCourseTest):
+ """ Base class for library content block tests """
USERNAME = "STUDENT_TESTER"
EMAIL = "student101@example.com"
STAFF_USERNAME = "STAFF_TESTER"
STAFF_EMAIL = "staff101@example.com"
+ def populate_library_fixture(self, library_fixture):
+ """
+ To be overwritten by subclassed tests. Used to install a library to
+ run tests on.
+ """
+
def setUp(self):
"""
Set up library, course and library content XBlock
"""
- super(LibraryContentTest, self).setUp()
+ super(LibraryContentTestBase, self).setUp()
self.courseware_page = CoursewarePage(self.browser, self.course_id)
@@ -46,11 +50,7 @@ def setUp(self):
)
self.library_fixture = LibraryFixture('test_org', self.unique_id, 'Test Library {}'.format(self.unique_id))
- self.library_fixture.add_children(
- XBlockFixtureDesc("html", "Html1", data='html1'),
- XBlockFixtureDesc("html", "Html2", data='html2'),
- XBlockFixtureDesc("html", "Html3", data='html3'),
- )
+ self.populate_library_fixture(self.library_fixture)
self.library_fixture.install()
self.library_info = self.library_fixture.library_info
@@ -83,7 +83,7 @@ def setUp(self):
self.course_fixture.install()
- def _refresh_library_content_children(self, count=1):
+ def _change_library_content_settings(self, count=1, capa_type=None):
"""
Performs library block refresh in Studio, configuring it to show {count} children
"""
@@ -91,6 +91,8 @@ def _refresh_library_content_children(self, count=1):
library_container_block = StudioLibraryContainerXBlockWrapper.from_xblock_wrapper(unit_page.xblocks[0])
modal = StudioLibraryContentXBlockEditModal(library_container_block.edit())
modal.count = count
+ if capa_type is not None:
+ modal.capa_type = capa_type
library_container_block.save_settings()
self._go_to_unit_page(change_login=False)
unit_page.wait_for_page()
@@ -121,9 +123,13 @@ def _goto_library_block_page(self, block_id=None):
Open library page in LMS
"""
self.courseware_page.visit()
+ paragraphs = self.courseware_page.q(css='.course-content p')
+ if paragraphs and "You were most recently in" in paragraphs.text[0]:
+ paragraphs[0].find_element_by_tag_name('a').click()
block_id = block_id if block_id is not None else self.lib_block.locator
#pylint: disable=attribute-defined-outside-init
self.library_content_page = LibraryContentXBlockWrapper(self.browser, block_id)
+ self.library_content_page.wait_for_page()
def _auto_auth(self, username, email, staff):
"""
@@ -132,6 +138,22 @@ def _auto_auth(self, username, email, staff):
AutoAuthPage(self.browser, username=username, email=email,
course_id=self.course_id, staff=staff).visit()
+
+@ddt.ddt
+class LibraryContentTest(LibraryContentTestBase):
+ """
+ Test courseware.
+ """
+ def populate_library_fixture(self, library_fixture):
+ """
+ Populates library fixture with XBlock Fixtures
+ """
+ library_fixture.add_children(
+ XBlockFixtureDesc("html", "Html1", data='html1'),
+ XBlockFixtureDesc("html", "Html2", data='html2'),
+ XBlockFixtureDesc("html", "Html3", data='html3'),
+ )
+
@ddt.data(1, 2, 3)
def test_shows_random_xblocks_from_configured(self, count):
"""
@@ -143,7 +165,7 @@ def test_shows_random_xblocks_from_configured(self, count):
When I go to LMS courseware page for library content xblock as student
Then I can see {count} random xblocks from the library
"""
- self._refresh_library_content_children(count=count)
+ self._change_library_content_settings(count=count)
self._auto_auth(self.USERNAME, self.EMAIL, False)
self._goto_library_block_page()
children_contents = self.library_content_page.children_contents
@@ -160,9 +182,122 @@ def test_shows_all_if_max_set_to_greater_value(self):
When I go to LMS courseware page for library content xblock as student
Then I can see all xblocks from the library
"""
- self._refresh_library_content_children(count=10)
+ self._change_library_content_settings(count=10)
self._auto_auth(self.USERNAME, self.EMAIL, False)
self._goto_library_block_page()
children_contents = self.library_content_page.children_contents
self.assertEqual(len(children_contents), 3)
self.assertEqual(children_contents, self.library_xblocks_texts)
+
+
+@ddt.ddt
+class StudioLibraryContainerCapaFilterTest(LibraryContentTestBase):
+ """
+ Test Library Content block in LMS
+ """
+ def _get_problem_choice_group_text(self, name, items):
+ """ Generates Choice Group CAPA problem XML """
+ items_text = "\n".join([
+ "{item}".format(correct=correct, item=item)
+ for item, correct in items
+ ])
+
+ return textwrap.dedent("""
+
+ {name}
+
+ {items}
+
+ """).format(name=name, items=items_text)
+
+ def _get_problem_select_text(self, name, items, correct):
+ """ Generates Select Option CAPA problem XML """
+ items_text = ",".join(["'{0}'".format(item) for item in items])
+
+ return textwrap.dedent("""
+
+ {name}
+
+
+
+ """).format(name=name, options=items_text, correct=correct)
+
+ def populate_library_fixture(self, library_fixture):
+ """
+ Populates library fixture with XBlock Fixtures
+ """
+ library_fixture.add_children(
+ XBlockFixtureDesc(
+ "problem", "Problem Choice Group 1",
+ data=self._get_problem_choice_group_text("Problem Choice Group 1 Text", [("1", False), ('2', True)])
+ ),
+ XBlockFixtureDesc(
+ "problem", "Problem Choice Group 2",
+ data=self._get_problem_choice_group_text("Problem Choice Group 2 Text", [("Q", True), ('W', False)])
+ ),
+ XBlockFixtureDesc(
+ "problem", "Problem Select 1",
+ data=self._get_problem_select_text("Problem Select 1 Text", ["Option 1", "Option 2"], "Option 1")
+ ),
+ XBlockFixtureDesc(
+ "problem", "Problem Select 2",
+ data=self._get_problem_select_text("Problem Select 2 Text", ["Option 3", "Option 4"], "Option 4")
+ ),
+ )
+
+ @property
+ def _problem_headers(self):
+ """ Expected XBLock headers according to populate_library_fixture """
+ return frozenset(child.display_name.upper() for child in self.library_fixture.children)
+
+ def _set_library_content_settings(self, count=1, capa_type="Any Type"):
+ """
+ Sets library content XBlock parameters, saves, publishes unit, goes to LMS unit page and
+ gets children XBlock headers to assert against them
+ """
+ self._change_library_content_settings(count=count, capa_type=capa_type)
+ self._auto_auth(self.USERNAME, self.EMAIL, False)
+ self._goto_library_block_page()
+ return self.library_content_page.children_headers
+
+ def test_problem_type_selector(self):
+ """
+ Scenario: Ensure setting "Any Type" for Problem Type does not filter out Problems
+ Given I have a library with two "Select Option" and two "Choice Group" problems, and a course containing
+ LibraryContent XBlock configured to draw XBlocks from that library
+ When I set library content xblock Problem Type to "Any Type" and Count to 3 and publish unit
+ When I go to LMS courseware page for library content xblock as student
+ Then I can see 3 xblocks from the library of any type
+ When I set library content xblock Problem Type to "Choice Group" and Count to 1 and publish unit
+ When I go to LMS courseware page for library content xblock as student
+ Then I can see 1 xblock from the library of "Choice Group" type
+ When I set library content xblock Problem Type to "Select Option" and Count to 2 and publish unit
+ When I go to LMS courseware page for library content xblock as student
+ Then I can see 2 xblock from the library of "Select Option" type
+ When I set library content xblock Problem Type to "Matlab" and Count to 2 and publish unit
+ When I go to LMS courseware page for library content xblock as student
+ Then I can see 0 xblocks from the library
+ """
+ children_headers = self._set_library_content_settings(count=3, capa_type="Any Type")
+ self.assertEqual(len(children_headers), 3)
+ self.assertLessEqual(children_headers, self._problem_headers)
+
+ # Choice group test
+ children_headers = self._set_library_content_settings(count=1, capa_type="Multiple Choice")
+ self.assertEqual(len(children_headers), 1)
+ self.assertLessEqual(
+ children_headers,
+ set([header.upper() for header in ["Problem Choice Group 1", "Problem Choice Group 2"]])
+ )
+
+ # Choice group test
+ children_headers = self._set_library_content_settings(count=2, capa_type="Dropdown")
+ self.assertEqual(len(children_headers), 2)
+ self.assertEqual(
+ children_headers,
+ set([header.upper() for header in ["Problem Select 1", "Problem Select 2"]])
+ )
+
+ # Missing problem type test
+ children_headers = self._set_library_content_settings(count=2, capa_type="Custom Evaluated Script")
+ self.assertEqual(children_headers, set())
diff --git a/common/test/acceptance/tests/studio/test_studio_library_container.py b/common/test/acceptance/tests/studio/test_studio_library_container.py
index 42fdfc4dc542..ce0464374515 100644
--- a/common/test/acceptance/tests/studio/test_studio_library_container.py
+++ b/common/test/acceptance/tests/studio/test_studio_library_container.py
@@ -1,6 +1,7 @@
"""
Acceptance tests for Library Content in LMS
"""
+import textwrap
import ddt
from .base_studio_test import StudioLibraryTest
from ...fixtures.course import CourseFixture
@@ -171,3 +172,75 @@ def test_out_of_date_message(self):
self.assertFalse(library_block.has_validation_message)
#self.assertIn("4 matching components", library_block.author_content) # Removed this assert until a summary message is added back to the author view (SOL-192)
+
+ def test_no_content_message(self):
+ """
+ Scenario: Given I have a library, a course and library content xblock in a course
+ When I go to studio unit page for library content block
+ And I set Problem Type selector so that no libraries have matching content
+ Then I can see that "No matching content" warning is shown
+ When I set Problem Type selector so that there is matching content
+ Then I can see that warning messages are not shown
+ """
+ # Add a single "Dropdown" type problem to the library (which otherwise has only HTML blocks):
+ self.library_fixture.create_xblock(self.library_fixture.library_location, XBlockFixtureDesc(
+ "problem", "Dropdown",
+ data=textwrap.dedent("""
+
+ Dropdown
+
+
+ """)
+ ))
+
+ expected_text = 'There are no matching problem types in the specified libraries. Select another problem type'
+
+ library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
+
+ # precondition check - assert library has children matching filter criteria
+ self.assertFalse(library_container.has_validation_error)
+ self.assertFalse(library_container.has_validation_warning)
+
+ edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit())
+ self.assertEqual(edit_modal.capa_type, "Any Type") # precondition check
+ edit_modal.capa_type = "Custom Evaluated Script"
+
+ library_container.save_settings()
+
+ self.assertTrue(library_container.has_validation_warning)
+ self.assertIn(expected_text, library_container.validation_warning_text)
+
+ edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit())
+ self.assertEqual(edit_modal.capa_type, "Custom Evaluated Script") # precondition check
+ edit_modal.capa_type = "Dropdown"
+ library_container.save_settings()
+
+ # Library should contain single Dropdown problem, so now there should be no errors again
+ self.assertFalse(library_container.has_validation_error)
+ self.assertFalse(library_container.has_validation_warning)
+
+ def test_not_enough_children_blocks(self):
+ """
+ Scenario: Given I have a library, a course and library content xblock in a course
+ When I go to studio unit page for library content block
+ And I set Problem Type selector so "Any"
+ Then I can see that "No matching content" warning is shown
+ """
+ expected_tpl = "The specified libraries are configured to fetch {count} problems, " \
+ "but there are only {actual} matching problems."
+
+ library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
+
+ # precondition check - assert block is configured fine
+ self.assertFalse(library_container.has_validation_error)
+ self.assertFalse(library_container.has_validation_warning)
+
+ edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit())
+ edit_modal.count = 50
+ library_container.save_settings()
+
+ self.assertTrue(library_container.has_validation_warning)
+ self.assertIn(
+ expected_tpl.format(count=50, actual=len(self.library_fixture.children)),
+ library_container.validation_warning_text
+ )