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}>".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}>".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 + )