Implement a custom editor for the split_module.
STUD-1529
This commit is contained in:
@@ -5,11 +5,14 @@ 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
|
||||
|
||||
@@ -19,21 +22,76 @@ 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 appears in the horizontal navigation at the top of the page.",
|
||||
display_name=_("Display Name"),
|
||||
help=_("This name is used for organizing your course content, but is not shown to students."),
|
||||
scope=Scope.settings,
|
||||
default="Experiment Block"
|
||||
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="Which user partition is used for this test",
|
||||
scope=Scope.content
|
||||
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
|
||||
@@ -46,7 +104,7 @@ class SplitTestFields(object):
|
||||
# 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",
|
||||
help=_("Which child module students in a particular group_id should see"),
|
||||
scope=Scope.content
|
||||
)
|
||||
|
||||
@@ -185,10 +243,19 @@ class SplitTestModule(SplitTestFields, XModule, StudioEditableModule):
|
||||
Renders the Studio preview by rendering each child so that they can all be seen and edited.
|
||||
"""
|
||||
fragment = Fragment()
|
||||
# Only render the children when this block is being shown as the container
|
||||
root_xblock = context.get('root_xblock')
|
||||
if root_xblock and root_xblock.location == self.location:
|
||||
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):
|
||||
@@ -244,12 +311,14 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
|
||||
|
||||
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
|
||||
@@ -287,6 +356,14 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
|
||||
'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
|
||||
@@ -294,10 +371,96 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
|
||||
"""
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user