diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 29227c3188..a1b059b889 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -20,6 +20,7 @@ setup( "book = xmodule.backcompat_module:TranslateCustomTagDescriptor", "chapter = xmodule.seq_module:SequenceDescriptor", "combinedopenended = xmodule.combined_open_ended_module:CombinedOpenEndedDescriptor", + "conditional = xmodule.conditional_module:ConditionalDescriptor", "course = xmodule.course_module:CourseDescriptor", "customtag = xmodule.template_module:CustomTagDescriptor", "discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor", diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index f33da6e3a4..0cec95287d 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -398,6 +398,15 @@ class CapaModule(XModule): return False + def is_completed(self): + # used by conditional module + # return self.answer_available() + return self.lcp.done + + def is_attempted(self): + # used by conditional module + return self.attempts > 0 + def answer_available(self): ''' Is the user allowed to see an answer? ''' diff --git a/common/lib/xmodule/xmodule/conditional_module.py b/common/lib/xmodule/xmodule/conditional_module.py new file mode 100644 index 0000000000..e20681e614 --- /dev/null +++ b/common/lib/xmodule/xmodule/conditional_module.py @@ -0,0 +1,141 @@ +import json +import logging + +from xmodule.x_module import XModule +from xmodule.modulestore import Location +from xmodule.seq_module import SequenceDescriptor + +from pkg_resources import resource_string + +log = logging.getLogger('mitx.' + __name__) + +class ConditionalModule(XModule): + ''' + Blocks child module from showing unless certain conditions are met. + + Example: + + + + + + + + ''' + + js = {'coffee': [resource_string(__name__, 'js/src/conditional/display.coffee'), + resource_string(__name__, 'js/src/collapsible.coffee'), + resource_string(__name__, 'js/src/javascript_loader.coffee'), + ]} + + js_module_name = "Conditional" + css = {'scss': [resource_string(__name__, 'css/capa/display.scss')]} + + + def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs): + """ + In addition to the normal XModule init, provide: + + self.condition = string describing condition required + + """ + XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs) + self.contents = None + self.condition = self.metadata.get('condition','') + #log.debug('conditional module required=%s' % self.required_modules_list) + + def _get_required_modules(self): + self.required_modules = [] + for descriptor in self.descriptor.get_required_module_descriptors(): + module = self.system.get_module(descriptor) + self.required_modules.append(module) + #log.debug('required_modules=%s' % (self.required_modules)) + + def is_condition_satisfied(self): + self._get_required_modules() + + if self.condition=='require_completed': + # all required modules must be completed, as determined by + # the modules .is_completed() method + for module in self.required_modules: + #log.debug('in is_condition_satisfied; student_answers=%s' % module.lcp.student_answers) + #log.debug('in is_condition_satisfied; instance_state=%s' % module.instance_state) + if not hasattr(module, 'is_completed'): + raise Exception('Error in conditional module: required module %s has no .is_completed() method' % module) + if not module.is_completed(): + log.debug('conditional module: %s not completed' % module) + return False + else: + log.debug('conditional module: %s IS completed' % module) + return True + elif self.condition=='require_attempted': + # all required modules must be attempted, as determined by + # the modules .is_attempted() method + for module in self.required_modules: + if not hasattr(module, 'is_attempted'): + raise Exception('Error in conditional module: required module %s has no .is_attempted() method' % module) + if not module.is_attempted(): + log.debug('conditional module: %s not attempted' % module) + return False + else: + log.debug('conditional module: %s IS attempted' % module) + return True + else: + raise Exception('Error in conditional module: unknown condition "%s"' % self.condition) + + return True + + def get_html(self): + self.is_condition_satisfied() + return self.system.render_template('conditional_ajax.html', { + 'element_id': self.location.html_id(), + 'id': self.id, + 'ajax_url': self.system.ajax_url, + }) + + def handle_ajax(self, dispatch, post): + ''' + This is called by courseware.module_render, to handle an AJAX call. + ''' + #log.debug('conditional_module handle_ajax: dispatch=%s' % dispatch) + + if not self.is_condition_satisfied(): + context = {'module': self} + html = self.system.render_template('conditional_module.html', context) + return json.dumps({'html': html}) + + if self.contents is None: + self.contents = [child.get_html() for child in self.get_display_items()] + + # for now, just deal with one child + html = self.contents[0] + + return json.dumps({'html': html}) + +class ConditionalDescriptor(SequenceDescriptor): + module_class = ConditionalModule + + filename_extension = "xml" + + stores_state = True + has_score = False + + def __init__(self, *args, **kwargs): + super(ConditionalDescriptor, self).__init__(*args, **kwargs) + + required_module_list = [tuple(x.split('/',1)) for x in self.metadata.get('required','').split('&')] + self.required_module_locations = [] + for (tag, name) in required_module_list: + loc = self.location.dict() + loc['category'] = tag + loc['name'] = name + self.required_module_locations.append(Location(loc)) + log.debug('ConditionalDescriptor required_module_locations=%s' % self.required_module_locations) + + def get_required_module_descriptors(self): + """Returns a list of XModuleDescritpor instances upon which this module depends, but are + not children of this module""" + return [self.system.load_item(loc) for loc in self.required_module_locations] + diff --git a/common/lib/xmodule/xmodule/js/src/conditional/display.coffee b/common/lib/xmodule/xmodule/js/src/conditional/display.coffee new file mode 100644 index 0000000000..5e8dc41dd8 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/conditional/display.coffee @@ -0,0 +1,26 @@ +class @Conditional + + constructor: (element) -> + @el = $(element).find('.conditional-wrapper') + @id = @el.data('problem-id') + @element_id = @el.attr('id') + @url = @el.data('url') + @render() + + $: (selector) -> + $(selector, @el) + + updateProgress: (response) => + if response.progress_changed + @el.attr progress: response.progress_status + @el.trigger('progressChanged') + + render: (content) -> + if content + @el.html(content) + XModule.loadModules('display', @el) + else + $.postWithPrefix "#{@url}/conditional_get", (response) => + @el.html(response.html) + XModule.loadModules('display', @el) + diff --git a/common/lib/xmodule/xmodule/tests/test_conditional.py b/common/lib/xmodule/xmodule/tests/test_conditional.py new file mode 100644 index 0000000000..f889ec7111 --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_conditional.py @@ -0,0 +1,119 @@ +import json +from path import path +import unittest +from fs.memoryfs import MemoryFS + +from lxml import etree +from mock import Mock, patch +from collections import defaultdict + +from xmodule.x_module import XMLParsingSystem, XModuleDescriptor +from xmodule.xml_module import is_pointer_tag +from xmodule.errortracker import make_error_tracker +from xmodule.modulestore import Location +from xmodule.modulestore.xml import ImportSystem, XMLModuleStore +from xmodule.modulestore.exceptions import ItemNotFoundError + +from .test_export import DATA_DIR + +ORG = 'test_org' +COURSE = 'conditional' # name of directory with course data + +from . import test_system + +class DummySystem(ImportSystem): + + @patch('xmodule.modulestore.xml.OSFS', lambda dir: MemoryFS()) + def __init__(self, load_error_modules): + + xmlstore = XMLModuleStore("data_dir", course_dirs=[], load_error_modules=load_error_modules) + course_id = "/".join([ORG, COURSE, 'test_run']) + course_dir = "test_dir" + policy = {} + error_tracker = Mock() + parent_tracker = Mock() + + super(DummySystem, self).__init__( + xmlstore, + course_id, + course_dir, + policy, + error_tracker, + parent_tracker, + load_error_modules=load_error_modules, + ) + + def render_template(self, template, context): + raise Exception("Shouldn't be called") + + + +class ConditionalModuleTest(unittest.TestCase): + + @staticmethod + def get_system(load_error_modules=True): + '''Get a dummy system''' + return DummySystem(load_error_modules) + + def get_course(self, name): + """Get a test course by directory name. If there's more than one, error.""" + print "Importing {0}".format(name) + + modulestore = XMLModuleStore(DATA_DIR, course_dirs=[name]) + courses = modulestore.get_courses() + self.modulestore = modulestore + self.assertEquals(len(courses), 1) + return courses[0] + + def test_conditional_module(self): + """Make sure that conditional module works""" + + print "Starting import" + course = self.get_course('conditional') + + print "Course: ", course + print "id: ", course.id + + instance_states = dict(problem=None) + shared_state = None + + def inner_get_module(descriptor): + if isinstance(descriptor, Location): + location = descriptor + descriptor = self.modulestore.get_instance(course.id, location, depth=None) + location = descriptor.location + instance_state = instance_states.get(location.category,None) + print "inner_get_module, location.category=%s, inst_state=%s" % (location.category, instance_state) + return descriptor.xmodule_constructor(test_system)(instance_state, shared_state) + + location = Location(["i4x", "edX", "cond_test", "conditional","condone"]) + module = inner_get_module(location) + + def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/', course_namespace=None): + return text + test_system.replace_urls = replace_urls + test_system.get_module = inner_get_module + + print "module: ", module + + html = module.get_html() + print "html type: ", type(html) + print "html: ", html + html_expect = "{'ajax_url': 'courses/course_id/modx/a_location', 'element_id': 'i4x-edX-cond_test-conditional-condone', 'id': 'i4x://edX/cond_test/conditional/condone'}" + self.assertEqual(html, html_expect) + + gdi = module.get_display_items() + print "gdi=", gdi + + ajax = json.loads(module.handle_ajax('','')) + self.assertTrue('xmodule.conditional_module' in ajax['html']) + print "ajax: ", ajax + + # now change state of the capa problem to make it completed + instance_states['problem'] = json.dumps({'attempts':1}) + + ajax = json.loads(module.handle_ajax('','')) + self.assertTrue('This is a secret' in ajax['html']) + print "post-attempt ajax: ", ajax + + diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 5387a9b083..0e4e8e0f00 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -585,6 +585,11 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): self._inherited_metadata.add(attr) self.metadata[attr] = metadata[attr] + def get_required_module_descriptors(self): + """Returns a list of XModuleDescritpor instances upon which this module depends, but are + not children of this module""" + return [] + def get_children(self): """Returns a list of XModuleDescriptor instances for the children of this module""" diff --git a/common/test/data/conditional/README.md b/common/test/data/conditional/README.md new file mode 100644 index 0000000000..84b9cba58e --- /dev/null +++ b/common/test/data/conditional/README.md @@ -0,0 +1,3 @@ +course for testing conditional module + + diff --git a/common/test/data/conditional/conditional/condone.xml b/common/test/data/conditional/conditional/condone.xml new file mode 100644 index 0000000000..f283e0d4ef --- /dev/null +++ b/common/test/data/conditional/conditional/condone.xml @@ -0,0 +1,3 @@ + + + diff --git a/common/test/data/conditional/course.xml b/common/test/data/conditional/course.xml new file mode 100644 index 0000000000..643e9552b5 --- /dev/null +++ b/common/test/data/conditional/course.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/common/test/data/conditional/html/secret_page.xml b/common/test/data/conditional/html/secret_page.xml new file mode 100644 index 0000000000..63be3cfa8d --- /dev/null +++ b/common/test/data/conditional/html/secret_page.xml @@ -0,0 +1,4 @@ + +

This is a secret!

+ + diff --git a/common/test/data/conditional/problem/choiceprob.xml b/common/test/data/conditional/problem/choiceprob.xml new file mode 100644 index 0000000000..fa91954977 --- /dev/null +++ b/common/test/data/conditional/problem/choiceprob.xml @@ -0,0 +1,22 @@ + + + +

Consider a hypothetical magnetic field pointing out of your computer screen. Now imagine an electron traveling from right to left in the plane of your screen. A diagram of this situation is show below…

+
+ +

a. The magnitude of the force experienced by the electron is proportional the product of which of the following? (Select all that apply.)

+ + + + +Magnetic field strength… +Electric field strength… +Electric charge of the electron… +Radius of the electron… +Mass of the electron… +Velocity of the electron… + + + + +
diff --git a/doc/xml-format.md b/doc/xml-format.md index f4fd1054cb..b93f3bbeab 100644 --- a/doc/xml-format.md +++ b/doc/xml-format.md @@ -141,6 +141,7 @@ That's basically all there is to the organizational structure. Read the next se * `abtest` -- Support for A/B testing. TODO: add details.. * `chapter` -- top level organization unit of a course. The courseware display code currently expects the top level `course` element to contain only chapters, though there is no philosophical reason why this is required, so we may change it to properly display non-chapters at the top level. +* `conditional` -- conditional element, which shows one or more modules only if certain conditions are satisfied. * `course` -- top level tag. Contains everything else. * `customtag` -- render an html template, filling in some parameters, and return the resulting html. See below for details. * `discussion` -- Inline discussion forum @@ -163,6 +164,22 @@ Container tags include `chapter`, `sequential`, `videosequence`, `vertical`, and `course` is also a container, and is similar, with one extra wrinkle: the top level pointer tag _must_ have `org` and `course` attributes specified--the organization name, and course name. Note that `course` is referring to the platonic ideal of this course (e.g. "6.002x"), not to any particular run of this course. The `url_name` should be the particular run of this course. +### `conditional` + +`conditional` is as special kind of container tag as well. Here are two examples: + + + + + + + + +The condition can be either `require_completed`, in which case the required modules must be completed, or `require_attempted`, in which case the required modules must have been attempted. + +The required modules are specified as a set of `tag`/`url_name`, joined by an ampersand. + ### `customtag` When we see ``, we will: diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index 21ef8b3d66..bd01318f63 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -113,6 +113,9 @@ class StudentModuleCache(object): descriptor_filter=lambda descriptor: True, select_for_update=False): """ + obtain and return cache for descriptor descendents (ie children) AND modules required by the descriptor, + but which are not children of the module + course_id: the course in the context of which we want StudentModules. user: the django user for whom to load modules. descriptor: An XModuleDescriptor @@ -132,7 +135,7 @@ class StudentModuleCache(object): if depth is None or depth > 0: new_depth = depth - 1 if depth is not None else depth - for child in descriptor.get_children(): + for child in descriptor.get_children() + descriptor.get_required_module_descriptors(): descriptors.extend(get_child_descriptors(child, new_depth, descriptor_filter)) return descriptors diff --git a/lms/templates/conditional_ajax.html b/lms/templates/conditional_ajax.html new file mode 100644 index 0000000000..0a5887be04 --- /dev/null +++ b/lms/templates/conditional_ajax.html @@ -0,0 +1 @@ +
diff --git a/lms/templates/conditional_module.html b/lms/templates/conditional_module.html new file mode 100644 index 0000000000..e9a42b95ce --- /dev/null +++ b/lms/templates/conditional_module.html @@ -0,0 +1,9 @@ +<% + from django.core.urlresolvers import reverse + reqm = module.required_modules[0] + course_id = module.system.course_id +%> + +

${reqm.display_name} +must be completed before this will become visible.