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:
Victor Shnayder
2013-12-31 10:44:34 -05:00
committed by Calen Pennington
parent 72e876fe40
commit 281ad63d2b
22 changed files with 1229 additions and 6 deletions

View 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