Merge pull request #1407 from MITx/feature/ichuang/conditional-xmodule-v2
Provides "stop" feature via a new "conditional" XModule
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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?
|
||||
'''
|
||||
|
||||
141
common/lib/xmodule/xmodule/conditional_module.py
Normal file
141
common/lib/xmodule/xmodule/conditional_module.py
Normal file
@@ -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:
|
||||
|
||||
<conditional condition="require_completed" required="tag/url_name1&tag/url_name2">
|
||||
<video url_name="secret_video" />
|
||||
</conditional>
|
||||
|
||||
<conditional condition="require_attempted" required="tag/url_name1&tag/url_name2">
|
||||
<video url_name="secret_video" />
|
||||
</conditional>
|
||||
|
||||
'''
|
||||
|
||||
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]
|
||||
|
||||
26
common/lib/xmodule/xmodule/js/src/conditional/display.coffee
Normal file
26
common/lib/xmodule/xmodule/js/src/conditional/display.coffee
Normal file
@@ -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)
|
||||
|
||||
119
common/lib/xmodule/xmodule/tests/test_conditional.py
Normal file
119
common/lib/xmodule/xmodule/tests/test_conditional.py
Normal file
@@ -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
|
||||
|
||||
|
||||
@@ -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"""
|
||||
|
||||
3
common/test/data/conditional/README.md
Normal file
3
common/test/data/conditional/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
course for testing conditional module
|
||||
|
||||
|
||||
3
common/test/data/conditional/conditional/condone.xml
Normal file
3
common/test/data/conditional/conditional/condone.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<conditional condition="require_attempted" required="problem/choiceprob">
|
||||
<html url_name="secret_page" />
|
||||
</conditional>
|
||||
8
common/test/data/conditional/course.xml
Normal file
8
common/test/data/conditional/course.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<course name="Conditional Course" org="edX" course="cond_test" graceperiod="1 day 5 hours 59 minutes 59 seconds" slug="2012_Fall" start="2015-07-17T12:00">
|
||||
<chapter name="Problems with Condition">
|
||||
<sequential>
|
||||
<problem url_name="choiceprob" />
|
||||
<conditional url_name="condone"/>
|
||||
</sequential>
|
||||
</chapter>
|
||||
</course>
|
||||
4
common/test/data/conditional/html/secret_page.xml
Normal file
4
common/test/data/conditional/html/secret_page.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<html display_name="Secret Page">
|
||||
<p>This is a secret!</p>
|
||||
</html>
|
||||
|
||||
22
common/test/data/conditional/problem/choiceprob.xml
Normal file
22
common/test/data/conditional/problem/choiceprob.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<problem display_name="S3E2: Lorentz Force">
|
||||
|
||||
<startouttext/>
|
||||
<p>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…</p>
|
||||
<center><img width="400" src="/static/images/LSQimages/LSQ_W01_8.png"/></center>
|
||||
|
||||
<p>a. The magnitude of the force experienced by the electron is proportional the product of which of the following? (Select all that apply.)</p>
|
||||
<endouttext/>
|
||||
<choiceresponse>
|
||||
<checkboxgroup>
|
||||
<!-- include ellipses to test non-ascii characters -->
|
||||
<choice correct="true"><text>Magnetic field strength…</text></choice>
|
||||
<choice correct="false"><text>Electric field strength…</text></choice>
|
||||
<choice correct="true"><text>Electric charge of the electron…</text></choice>
|
||||
<choice correct="false"><text>Radius of the electron…</text></choice>
|
||||
<choice correct="false"><text>Mass of the electron…</text></choice>
|
||||
<choice correct="true"><text>Velocity of the electron…</text></choice>
|
||||
|
||||
</checkboxgroup>
|
||||
</choiceresponse>
|
||||
|
||||
</problem>
|
||||
@@ -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:
|
||||
|
||||
<conditional condition="require_completed" required="problem/choiceprob">
|
||||
<video url_name="secret_video" />
|
||||
</conditional>
|
||||
|
||||
<conditional condition="require_attempted" required="problem/choiceprob&problem/sumprob">
|
||||
<html url_name="secret_page" />
|
||||
</conditional>
|
||||
|
||||
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 `<customtag impl="special" animal="unicorn" hat="blue"/>`, we will:
|
||||
|
||||
@@ -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
|
||||
|
||||
1
lms/templates/conditional_ajax.html
Normal file
1
lms/templates/conditional_ajax.html
Normal file
@@ -0,0 +1 @@
|
||||
<div id="conditional_${element_id}" class="conditional-wrapper" data-problem-id="${id}" data-url="${ajax_url}"></div>
|
||||
9
lms/templates/conditional_module.html
Normal file
9
lms/templates/conditional_module.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<%
|
||||
from django.core.urlresolvers import reverse
|
||||
reqm = module.required_modules[0]
|
||||
course_id = module.system.course_id
|
||||
%>
|
||||
|
||||
<p><a
|
||||
href="${reverse('jump_to',kwargs=dict(course_id=course_id, location=reqm.location.url()))}">${reqm.display_name}</a>
|
||||
must be completed before this will become visible.</p>
|
||||
Reference in New Issue
Block a user