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.