split testing support in the LMS.
Adds a split_test_module XModule, that can choose one of its children
to display, based on a get_condition_for_user API added to the runtime.
To test, add something like this to an xml course, or make equivalent
tweaks in mongo.
<vertical url_name="split_test_vert">
<split_test url_name="split1" experiment_id="0" condition_id_to_child='{"0": "i4x://MITx/6.00x/html/split_test_cond0", "1": "i4x://MITx/6.00x/html/split_test_cond1"}'>
<html url_name="split_test_cond0">condition 0</html>
<html url_name="split_test_cond1">condition 1</html>
</split_test>
</vertical>
Also needs an experiment configured in the course policy json: e.g.
"user_partitions": [{"id": 0,
"name": "Experiment 0",
"description": "Unicorns?",
"version": 1,
"groups": [{"id": 0,
"name": "group 0",
"version": 1},
{"id": 1,
"name": "group 1",
"version": 1}]}]
(This particular snippet will work inside a course with org MITx
and course name 6.00x)
Co-Author: Sarina Canelake <sarina@edx.org>
Co-Author: Julia Hansbrough <julia@edx.org>
Co-Author: Diana Huang <diana@edx.org>
Co-Author: Calen Pennington <cale@edx.org>
[LMS-2095]
This commit is contained in:
committed by
Calen Pennington
parent
72e876fe40
commit
281ad63d2b
185
common/lib/xmodule/xmodule/split_test_module.py
Normal file
185
common/lib/xmodule/xmodule/split_test_module.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Module for running content split tests
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from xmodule.progress import Progress
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
from xmodule.x_module import XModule, module_attr
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from xblock.core import XBlock
|
||||
from xblock.fields import Scope, Integer, Dict
|
||||
from xblock.fragment import Fragment
|
||||
|
||||
log = logging.getLogger('edx.' + __name__)
|
||||
|
||||
|
||||
class SplitTestFields(object):
|
||||
user_partition_id = Integer(help="Which user partition is used for this test",
|
||||
scope=Scope.content)
|
||||
|
||||
# group_id is an int
|
||||
# child is a serialized UsageId (aka Location). This child
|
||||
# location needs to actually match one of the children of this
|
||||
# Block. (expected invariant that we'll need to test, and handle
|
||||
# authoring tools that mess this up)
|
||||
|
||||
# TODO: is there a way to add some validation around this, to
|
||||
# be run on course load or in studio or ....
|
||||
|
||||
group_id_to_child = Dict(help="Which child module students in a particular "
|
||||
"group_id should see",
|
||||
scope=Scope.content)
|
||||
|
||||
|
||||
@XBlock.needs('user_tags')
|
||||
@XBlock.needs('partitions')
|
||||
class SplitTestModule(SplitTestFields, XModule):
|
||||
"""
|
||||
Show the user the appropriate child. Uses the ExperimentState
|
||||
API to figure out which child to show.
|
||||
|
||||
Course staff still get put in an experimental condition, but have the option
|
||||
to see the other conditions. The only thing that counts toward their
|
||||
grade/progress is the condition they are actually in.
|
||||
|
||||
Technical notes:
|
||||
- There is more dark magic in this code than I'd like. The whole varying-children +
|
||||
grading interaction is a tangle between super and subclasses of descriptors and
|
||||
modules.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(SplitTestModule, self).__init__(*args, **kwargs)
|
||||
|
||||
self.child_descriptor = self.get_child_descriptors()[0]
|
||||
if self.child_descriptor is not None:
|
||||
self.child = self.system.get_module(self.child_descriptor)
|
||||
else:
|
||||
self.child = None
|
||||
|
||||
def get_child_descriptor_by_location(self, location):
|
||||
"""
|
||||
Look through the children and look for one with the given location.
|
||||
Returns the descriptor.
|
||||
If none match, return None
|
||||
"""
|
||||
# NOTE: calling self.get_children() creates a circular reference--
|
||||
# it calls get_child_descriptors() internally, but that doesn't work until
|
||||
# we've picked a choice. Use self.descriptor.get_children() instead.
|
||||
|
||||
for child in self.descriptor.get_children():
|
||||
if child.location.url() == location:
|
||||
return child
|
||||
|
||||
return None
|
||||
|
||||
def get_child_descriptors(self):
|
||||
"""
|
||||
For grading--return just the chosen child.
|
||||
"""
|
||||
group_id = self.runtime.service(self, 'partitions').get_user_group_for_partition(self.user_partition_id)
|
||||
|
||||
# group_id_to_child comes from json, so it has to have string keys
|
||||
str_group_id = str(group_id)
|
||||
if str_group_id in self.group_id_to_child:
|
||||
child_location = self.group_id_to_child[str_group_id]
|
||||
child_descriptor = self.get_child_descriptor_by_location(child_location)
|
||||
else:
|
||||
# Oops. Config error.
|
||||
log.debug("configuration error in split test module: invalid group_id %r (not one of %r). Showing error", str_group_id, self.group_id_to_child.keys())
|
||||
|
||||
if child_descriptor is None:
|
||||
# Peak confusion is great. Now that we set child_descriptor,
|
||||
# get_children() should return a list with one element--the
|
||||
# xmodule for the child
|
||||
log.debug("configuration error in split test module: no such child")
|
||||
return []
|
||||
|
||||
return [child_descriptor]
|
||||
|
||||
def _staff_view(self, context):
|
||||
"""
|
||||
Render the staff view for a split test module.
|
||||
"""
|
||||
fragment = Fragment()
|
||||
contents = []
|
||||
|
||||
for group_id in self.group_id_to_child:
|
||||
child_location = self.group_id_to_child[group_id]
|
||||
child_descriptor = self.get_child_descriptor_by_location(child_location)
|
||||
child = self.system.get_module(child_descriptor)
|
||||
rendered_child = child.render('student_view', context)
|
||||
fragment.add_frag_resources(rendered_child)
|
||||
|
||||
contents.append({
|
||||
'group_id': group_id,
|
||||
'id': child.id,
|
||||
'content': rendered_child.content
|
||||
})
|
||||
|
||||
# Use the new template
|
||||
fragment.add_content(self.system.render_template('split_test_staff_view.html', {
|
||||
'items': contents,
|
||||
}))
|
||||
frag_js = """
|
||||
$(document).ready(function() {{
|
||||
ABTestSelector($('.split-test-view'));
|
||||
}});
|
||||
"""
|
||||
fragment.add_javascript(frag_js)
|
||||
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/split_test_staff.js'))
|
||||
return fragment
|
||||
|
||||
def student_view(self, context):
|
||||
"""
|
||||
Render the contents of the chosen condition for students, and all the
|
||||
conditions for staff.
|
||||
"""
|
||||
if self.child is None:
|
||||
# raise error instead? In fact, could complain on descriptor load...
|
||||
return Fragment(content=u"<div>Nothing here. Move along.</div>")
|
||||
|
||||
if self.system.user_is_staff:
|
||||
return self._staff_view(context)
|
||||
else:
|
||||
return self.child.render('student_view', context)
|
||||
|
||||
def get_icon_class(self):
|
||||
return self.child.get_icon_class() if self.child else 'other'
|
||||
|
||||
def get_progress(self):
|
||||
children = self.get_children()
|
||||
progresses = [child.get_progress() for child in children]
|
||||
progress = reduce(Progress.add_counts, progresses, None)
|
||||
return progress
|
||||
|
||||
|
||||
@XBlock.needs('user_tags')
|
||||
@XBlock.needs('partitions')
|
||||
class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
|
||||
# the editing interface can be the same as for sequences -- just a container
|
||||
module_class = SplitTestModule
|
||||
|
||||
filename_extension = "xml"
|
||||
|
||||
child_descriptor = module_attr('child_descriptor')
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
|
||||
xml_object = etree.Element('split_test')
|
||||
# TODO: also save the experiment id and the condition map
|
||||
for child in self.get_children():
|
||||
xml_object.append(
|
||||
etree.fromstring(child.export_to_xml(resource_fs)))
|
||||
return xml_object
|
||||
|
||||
def has_dynamic_children(self):
|
||||
"""
|
||||
Grading needs to know that only one of the children is actually "real". This
|
||||
makes it use module.get_child_descriptors().
|
||||
"""
|
||||
return True
|
||||
Reference in New Issue
Block a user