Files
edx-platform/common/lib/xmodule/xmodule/split_test_module.py
2014-06-05 12:16:16 -04:00

467 lines
19 KiB
Python

"""
Module for running content split tests
"""
import logging
import json
from webob import Response
from uuid import uuid4
from pkg_resources import resource_string
from xmodule.progress import Progress
from xmodule.seq_module import SequenceDescriptor
from xmodule.studio_editable import StudioEditableModule
from xmodule.x_module import XModule, module_attr
from xmodule.modulestore.inheritance import UserPartitionList
from lxml import etree
from xblock.core import XBlock
from xblock.fields import Scope, Integer, String, ReferenceValueDict
from xblock.fragment import Fragment
log = logging.getLogger('edx.' + __name__)
# Make '_' a no-op so we can scrape strings
_ = lambda text: text
class ValidationMessageType(object):
"""
The type for a validation message -- currently 'information', 'warning' or 'error'.
"""
information = 'information'
warning = 'warning'
error = 'error'
@staticmethod
def display_name(message_type):
"""
Returns the display name for the specified validation message type.
"""
if message_type == ValidationMessageType.warning:
# Translators: This message will be added to the front of messages of type warning,
# e.g. "Warning: this component has not been configured yet".
return _(u"Warning")
elif message_type == ValidationMessageType.error:
# Translators: This message will be added to the front of messages of type error,
# e.g. "Error: required field is missing".
return _(u"Error")
else:
return None
class SplitTestFields(object):
"""Fields needed for split test module"""
has_children = True
# All available user partitions (with value and display name). This is updated each time
# editable_metadata_fields is called.
user_partition_values = []
# Default value used for user_partition_id
no_partition_selected = {'display_name': _("Not Selected"), 'value': -1}
@staticmethod
def build_partition_values(all_user_partitions):
"""
This helper method builds up the user_partition values that will
be passed to the Studio editor
"""
SplitTestFields.user_partition_values = [SplitTestFields.no_partition_selected]
for user_partition in all_user_partitions:
SplitTestFields.user_partition_values.append({"display_name": user_partition.name, "value": user_partition.id})
return SplitTestFields.user_partition_values
display_name = String(
display_name=_("Display Name"),
help=_("This name is used for organizing your course content, but is not shown to students."),
scope=Scope.settings,
default=_("Content Experiment")
)
# Specified here so we can see what the value set at the course-level is.
user_partitions = UserPartitionList(
help=_("The list of group configurations for partitioning students in content experiments."),
default=[],
scope=Scope.settings
)
user_partition_id = Integer(
help=_("The configuration for how users are grouped for this content experiment. After you select the group configuration and save the content experiment, you cannot change this setting."),
scope=Scope.content,
display_name=_("Group Configuration"),
default=no_partition_selected["value"],
values=lambda: SplitTestFields.user_partition_values # Will be populated before the Studio editor is shown.
)
# 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 = ReferenceValueDict(
help=_("Which child module students in a particular group_id should see"),
scope=Scope.content
)
@XBlock.needs('user_tags') # pylint: disable=abstract-method
@XBlock.wants('partitions')
class SplitTestModule(SplitTestFields, XModule, StudioEditableModule):
"""
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 = None
child_descriptors = self.get_child_descriptors()
if len(child_descriptors) >= 1:
self.child_descriptor = 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 == location:
return child
return None
def get_content_titles(self):
"""
Returns list of content titles for split_test's child.
This overwrites the get_content_titles method included in x_module by default.
WHY THIS OVERWRITE IS NECESSARY: If we fetch *all* of split_test's children,
we'll end up getting all of the possible conditions users could ever see.
Ex: If split_test shows a video to group A and HTML to group B, the
regular get_content_titles in x_module will get the title of BOTH the video
AND the HTML.
We only want the content titles that should actually be displayed to the user.
split_test's .child property contains *only* the child that should actually
be shown to the user, so we call get_content_titles() on only that child.
"""
return self.child.get_content_titles()
def get_child_descriptors(self):
"""
For grading--return just the chosen child.
"""
group_id = self.get_group_id()
if group_id is None:
return []
# 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 get_group_id(self):
"""
Returns the group ID, or None if none is available.
"""
partitions_service = self.runtime.service(self, 'partitions')
if not partitions_service:
return None
return partitions_service.get_user_group_for_partition(self.user_partition_id)
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.location.to_deprecated_string(),
'content': rendered_child.content
})
# Use the new template
fragment.add_content(self.system.render_template('split_test_staff_view.html', {
'items': contents,
}))
fragment.add_css('.split-test-child { display: none; }')
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/split_test_staff.js'))
fragment.initialize_js('ABTestSelector')
return fragment
def studio_preview_view(self, context):
"""
Renders the Studio preview by rendering each child so that they can all be seen and edited.
"""
fragment = Fragment()
root_xblock = context.get('root_xblock')
is_root = root_xblock and root_xblock.location == self.location
# First render a header at the top of the split test module...
fragment.add_content(self.system.render_template('split_test_studio_header.html', {
'split_test': self,
'is_root': is_root,
}))
# ... then render the children only when this block is being shown as the container
if is_root:
self.render_children(context, fragment, can_reorder=False)
return fragment
def student_view(self, context):
"""
Render the contents of the chosen condition for students, and all the
conditions for staff.
"""
# When rendering a Studio preview, render all of the block's children
if context and context.get('runtime_type', None) == 'studio':
return self.studio_preview_view(context)
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:
child_fragment = self.child.render('student_view', context)
fragment = Fragment(self.system.render_template('split_test_student_view.html', {
'child_content': child_fragment.content,
'child_id': self.child.scope_ids.usage_id,
}))
fragment.add_frag_resources(child_fragment)
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/split_test_student.js'))
fragment.initialize_js('SplitTestStudentView')
return fragment
@XBlock.handler
def log_child_render(self, request, suffix=''): # pylint: disable=unused-argument
"""
Record in the tracking logs which child was rendered
"""
# TODO: use publish instead, when publish is wired to the tracking logs
self.system.track_function('xblock.split_test.child_render', {'child-id': self.child.scope_ids.usage_id})
return Response()
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') # pylint: disable=abstract-method
@XBlock.wants('partitions')
class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
# the editing interface can be the same as for sequences -- just a container
module_class = SplitTestModule
filename_extension = "xml"
mako_template = "widgets/split-edit.html"
css = {'scss': [resource_string(__name__, 'css/split_test/edit.scss')]}
child_descriptor = module_attr('child_descriptor')
log_child_render = module_attr('log_child_render')
get_content_titles = module_attr('get_content_titles')
def definition_to_xml(self, resource_fs):
xml_object = etree.Element('split_test')
renderable_groups = {}
# json.dumps doesn't know how to handle Location objects
for group in self.group_id_to_child:
renderable_groups[group] = self.group_id_to_child[group].to_deprecated_string()
xml_object.set('group_id_to_child', json.dumps(renderable_groups))
xml_object.set('user_partition_id', str(self.user_partition_id))
for child in self.get_children():
self.runtime.add_block_as_child_node(child, xml_object)
return xml_object
@classmethod
def definition_from_xml(cls, xml_object, system):
children = []
raw_group_id_to_child = xml_object.attrib.get('group_id_to_child', None)
user_partition_id = xml_object.attrib.get('user_partition_id', None)
try:
group_id_to_child = json.loads(raw_group_id_to_child)
except ValueError:
msg = "group_id_to_child is not valid json"
log.exception(msg)
system.error_tracker(msg)
for child in xml_object:
try:
descriptor = system.process_xml(etree.tostring(child))
children.append(descriptor.scope_ids.usage_id)
except Exception:
msg = "Unable to load child when parsing split_test module."
log.exception(msg)
system.error_tracker(msg)
return ({
'group_id_to_child': group_id_to_child,
'user_partition_id': user_partition_id
}, children)
def get_context(self):
_context = super(SplitTestDescriptor, self).get_context()
_context.update({
'disable_user_partition_editing': self._disable_user_partition_editing(),
'selected_partition': self._get_selected_partition()
})
return _context
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
def editor_saved(self, user, old_metadata, old_content):
"""
Used to create default verticals for the groups.
Assumes that a mutable modulestore is being used.
"""
# Any existing value of user_partition_id will be in "old_content" instead of "old_metadata"
# because it is Scope.content.
if 'user_partition_id' not in old_content or old_content['user_partition_id'] != self.user_partition_id:
selected_partition = self._get_selected_partition()
if selected_partition is not None:
assert hasattr(self.system, 'modulestore') and hasattr(self.system.modulestore, 'create_and_save_xmodule'), \
"editor_saved should only be called when a mutable modulestore is available"
modulestore = self.system.modulestore
group_id_mapping = {}
for group in selected_partition.groups:
dest_usage_key = self.location.replace(category="vertical", name=uuid4().hex)
metadata = {'display_name': group.name}
modulestore.create_and_save_xmodule(
dest_usage_key,
definition_data=None,
metadata=metadata,
system=self.system,
)
self.children.append(dest_usage_key) # pylint: disable=no-member
group_id_mapping[unicode(group.id)] = dest_usage_key
self.group_id_to_child = group_id_mapping
# Don't need to call update_item in the modulestore because the caller of this method will do it.
@property
def editable_metadata_fields(self):
# Update the list of partitions based on the currently available user_partitions.
SplitTestFields.build_partition_values(self.user_partitions)
editable_fields = super(SplitTestDescriptor, self).editable_metadata_fields
if not self._disable_user_partition_editing():
# Explicitly add user_partition_id, which does not automatically get picked up because it is Scope.content.
# Note that this means it will be saved by the Studio editor as "metadata", but the field will
# still update correctly.
editable_fields[SplitTestFields.user_partition_id.name] = self._create_metadata_editor_info(
SplitTestFields.user_partition_id
)
return editable_fields
@property
def non_editable_metadata_fields(self):
non_editable_fields = super(SplitTestDescriptor, self).non_editable_metadata_fields
non_editable_fields.extend([
SplitTestDescriptor.due,
SplitTestDescriptor.user_partitions
])
return non_editable_fields
def _disable_user_partition_editing(self):
"""
If user_partition_id has been set to anything besides the default value, disable editing.
"""
return self.user_partition_id != SplitTestFields.user_partition_id.default
def _get_selected_partition(self):
"""
Returns the partition that this split module is currently using, or None
if the currently selected partition ID does not match any of the defined partitions.
"""
for user_partition in self.user_partitions:
if user_partition.id == self.user_partition_id:
return user_partition
return None
def validation_message(self):
"""
Returns a validation message describing the current state of the block, as well as a message type
indicating whether the message represents information, a warning or an error.
"""
_ = self.runtime.service(self, "i18n").ugettext # pylint: disable=redefined-outer-name
if self.user_partition_id < 0:
return _(u"You must select a group configuration for this content experiment."), ValidationMessageType.warning
user_partition = self._get_selected_partition()
if not user_partition:
return \
_(u"This content experiment will not be shown to students because it refers to a group configuration that has been deleted. You can delete this experiment or reinstate the group configuration to repair it."), \
ValidationMessageType.error
groups = user_partition.groups
if not len(groups) == len(self.get_children()):
return _(u"This content experiment is in an invalid state and cannot be repaired. Please delete and recreate."), ValidationMessageType.error
return _(u"This content experiment uses group configuration '{experiment_name}'.").format(
experiment_name=user_partition.name
), ValidationMessageType.information