""" Module for running content split tests """ import logging import json from webob import Response from uuid import uuid4 from xmodule.progress import Progress from xmodule.seq_module import SequenceDescriptor from xmodule.studio_editable import StudioEditableModule, StudioEditableDescriptor from xmodule.x_module import XModule, module_attr, STUDENT_VIEW 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 # TODO: move this into the xblock repo once it has a formal validation contract class ValidationMessage(object): """ Represents a single validation message for an xblock. """ def __init__(self, xblock, message_text, message_type, action_class=None, action_label=None): assert isinstance(message_text, unicode) self.xblock = xblock self.message_text = message_text self.message_type = message_type self.action_class = action_class self.action_label = action_label def __unicode__(self): return self.message_text 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, selected_user_partition): """ This helper method builds up the user_partition values that will be passed to the Studio editor """ SplitTestFields.user_partition_values = [] # Add "No selection" value if there is not a valid selected user partition. if not selected_user_partition: SplitTestFields.user_partition_values.append(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 defines how users are grouped for this content experiment. Caution: Changing the group configuration of a student-visible experiment will impact the experiment data."), 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 author_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_configured = not self.user_partition_id == SplitTestFields.no_partition_selected['value'] is_root = root_xblock and root_xblock.location == self.location active_groups_preview = None inactive_groups_preview = None if is_root: [active_children, inactive_children] = self.descriptor.active_and_inactive_children() active_groups_preview = self.studio_render_children( fragment, active_children, context ) inactive_groups_preview = self.studio_render_children( fragment, inactive_children, context ) fragment.add_content(self.system.render_template('split_test_author_view.html', { 'split_test': self, 'is_root': is_root, 'is_configured': is_configured, 'active_groups_preview': active_groups_preview, 'inactive_groups_preview': inactive_groups_preview, })) fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/split_test_author_view.js')) fragment.initialize_js('SplitTestAuthorView') return fragment def studio_render_children(self, fragment, children, context): """ Renders the specified children and returns it as an HTML string. In addition, any dependencies are added to the specified fragment. """ html = "" for active_child_descriptor in children: active_child = self.system.get_module(active_child_descriptor) rendered_child = active_child.render(StudioEditableModule.get_preview_view_name(active_child), context) fragment.add_frag_resources(rendered_child) html = html + rendered_child.content return html def student_view(self, context): """ Renders 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"