In ~Palm and earlier, all built-in XBlock Sass was included into LMS and CMS
styles before being compiled. The generated CSS was coupled together with
broader LMS/CMS CSS. This means that comprehensive themes have been able to
modify built-in XBlock appearance by setting certain Sass variables. We say that
built-in XBlock Sass was, and is expected to be, "theme-aware".
Shortly after Palm, we decoupled XBlock Sass from LMS and CMS Sass [1]. Each
built-in block's Sass is now compiled into two separate CSS targets, one for
block editing and one for block display. The CSS, now located at
`common/static/css/xmodule`, is injected into the running Webpack context with
the new `XModuleWebpackLoader`. Built-in XBlocks already used
`add_webpack_to_fragment` in order to add JS Webpack bundles to their view
fragments, so when CSS was added to Webpack, it Just Worked.
This unlocked a slieu of simplifications for static asset processing [2];
however, it accidentally made XBlock Sass theme-*unaware*, or perhaps
theme-confused, since the CSS was targeted at `common/static/css/xmodule`
regardless of the theme. The result of this is that **built-in XBlock views will
use CSS based on the Sass variables _last theme to be compiled._** Sass
variables are only used in a handful of places in XBlocks, so the bug is subtle,
but it is there for those running off of master. For example, using edX.org's
theme on master, we can see that there is a default blue underline in the Studio
sequence nav [3]. With this bugfix, it becomes the standard edX.org
greenish-black [4].
This commit makes several changes, firstly to fix the bug, and secondly to leave
ourselves with a more comprehensible asset setup in the `xmodule/` directory.
* We remove the `XModuleWebpackLoader`, thus taking built-in XBlock Sass back
out of Webpack.
* We compile XBlock Sass not to `common/static/css/xmodule`, but to:
* `[lms|cms]/static/css` for the default theme, and
* `<THEME_ROOT>/[lms|cms]/static/css`, for any custom theme.
This is where the comprehensive theming system expects to find themable
assets. Unfortunately, this does mean that the Sass is compiled twice, both
for LMS and CMS. We would have liked to compile it once to somewhere in the
`common/`, but comprehensive theming does not consider `common/` assets to be
themable.
* We split `add_webpack_to_fragment` into two more specialized functions:
* `add_webpack_js_to_fragment` , for adding *just* JS from a Webpack bundle,
and
* `add_sass_to_fragment`, for adding static links to CSS compiled themable
Sass (not Webpack). Both these functions are moved to a new module
`xmodule/util/builtin_assets.py`, since the original module
(`xmodule/util/xmodule_django.py`) didn't make a ton of sense.
* In an orthogonal bugfix, we merge Sass `CourseInfoBlock`, `StaticTabBlock`,
`AboutBlock` into the `HtmlBlock` Sass files. The first three were never used,
as their styling was handled by `HtmlBlock` (their shared parent class).
* As a refactoring, we change Webpack bundle names and Sass module names to be
less misleading:
* student_view, public_view, and author_view: was `<Name>BlockPreview`, is now
`<Name>BlockDisplay`.
* studio_view: was `<Name>BlockStudio`, is now `<Name>BlockEditor`.
* As a refactoring, we move the contents of `xmodule/static` into the existing
`xmodule/assets` directory, and adopt its simper structure. We now have:
* `xmodule/assets/*.scss`: Top-level compiled Sass modules. These could be
collapsed away in a future refactoring.
* `xmodule/assets/<blocktype>/*`: Resources for each block, including both JS
modules and Sass includes (underscore-prefixed so that they aren't
compiled). This structure maps closely with what externally-defined XBlocks
do.
* `xmodule/js` still exists, but it will soon be folded into the
`xmodule/assets`.
* We add a new README [4] to explain the new structure, and also update a
docstring in `openedx/lib/xblock/utils` which had fallen out of date with
reality.
* Side note: We avoid the term "XModule" in all of this, because that's
(thankfully) become a much less useful/accurate way to describe these blocks.
Instead, we say "built-in XBlocks".
Refs:
1. https://github.com/openedx/edx-platform/pull/32018
2. https://github.com/openedx/edx-platform/issues/32292
3. https://github.com/openedx/edx-platform/assets/3628148/8b44545d-0f71-4357-9385-69d6e1cca86f
4. https://github.com/openedx/edx-platform/assets/3628148/d0b7b309-b8a4-4697-920a-8a520e903e06
5. https://github.com/openedx/edx-platform/tree/master/xmodule/assets#readme
Part of: https://github.com/openedx/edx-platform/issues/32292
745 lines
30 KiB
Python
745 lines
30 KiB
Python
"""
|
|
Block for running content split tests
|
|
"""
|
|
|
|
|
|
import json
|
|
import logging
|
|
import threading
|
|
from functools import reduce
|
|
from operator import itemgetter
|
|
from uuid import uuid4
|
|
|
|
from django.utils.functional import cached_property
|
|
from lxml import etree
|
|
from pkg_resources import resource_filename
|
|
from web_fragments.fragment import Fragment
|
|
from webob import Response
|
|
from xblock.core import XBlock
|
|
from xblock.fields import Integer, ReferenceValueDict, Scope, String
|
|
from xmodule.mako_block import MakoTemplateBlockBase
|
|
from xmodule.modulestore.inheritance import UserPartitionList
|
|
from xmodule.progress import Progress
|
|
from xmodule.seq_block import ProctoringFields, SequenceMixin
|
|
from xmodule.studio_editable import StudioEditableBlock
|
|
from xmodule.util.builtin_assets import add_webpack_js_to_fragment
|
|
from xmodule.validation import StudioValidation, StudioValidationMessage
|
|
from xmodule.xml_block import XmlMixin
|
|
from xmodule.x_module import (
|
|
HTMLSnippet,
|
|
ResourceTemplates,
|
|
shim_xmodule_js,
|
|
STUDENT_VIEW,
|
|
XModuleMixin,
|
|
XModuleToXBlockMixin,
|
|
)
|
|
|
|
log = logging.getLogger('edx.' + __name__)
|
|
|
|
# Make '_' a no-op so we can scrape strings. Using lambda instead of
|
|
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
|
|
_ = lambda text: text
|
|
|
|
DEFAULT_GROUP_NAME = _('Group ID {group_id}')
|
|
|
|
|
|
class UserPartitionValues(threading.local):
|
|
"""
|
|
A thread-local storage for available user_partitions
|
|
"""
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.values = []
|
|
|
|
def build_partition_values(self, all_user_partitions, selected_user_partition):
|
|
"""
|
|
This helper method builds up the user_partition values that will
|
|
be passed to the Studio editor
|
|
"""
|
|
self.values = []
|
|
# Add "No selection" value if there is not a valid selected user partition.
|
|
if not selected_user_partition:
|
|
self.values.append(SplitTestFields.no_partition_selected)
|
|
for user_partition in get_split_user_partitions(all_user_partitions):
|
|
self.values.append(
|
|
{"display_name": user_partition.name, "value": user_partition.id}
|
|
)
|
|
return self.values
|
|
|
|
|
|
# All available user partitions (with value and display name). This is updated each time
|
|
# editable_metadata_fields is called.
|
|
user_partition_values = UserPartitionValues()
|
|
|
|
|
|
class SplitTestFields:
|
|
"""Fields needed for split test block"""
|
|
has_children = True
|
|
|
|
# Default value used for user_partition_id
|
|
no_partition_selected = {'display_name': _("Not Selected"), 'value': -1}
|
|
|
|
display_name = String(
|
|
display_name=_("Display Name"),
|
|
help=_("The display name for this component. (Not shown to learners)"),
|
|
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."), # lint-amnesty, pylint: disable=line-too-long
|
|
scope=Scope.content,
|
|
display_name=_("Group Configuration"),
|
|
default=no_partition_selected["value"],
|
|
values=lambda: user_partition_values.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)
|
|
group_id_to_child = ReferenceValueDict(
|
|
help=_("Which child block students in a particular group_id should see"),
|
|
scope=Scope.content
|
|
)
|
|
|
|
|
|
def get_split_user_partitions(user_partitions):
|
|
"""
|
|
Helper method that filters a list of user_partitions and returns just the
|
|
ones that are suitable for the split_test block.
|
|
"""
|
|
return [user_partition for user_partition in user_partitions if user_partition.scheme.name == "random"]
|
|
|
|
|
|
@XBlock.needs("i18n")
|
|
@XBlock.needs('user_tags') # pylint: disable=abstract-method
|
|
@XBlock.needs('mako')
|
|
@XBlock.needs('partitions')
|
|
@XBlock.needs('user')
|
|
class SplitTestBlock( # lint-amnesty, pylint: disable=abstract-method
|
|
SplitTestFields,
|
|
SequenceMixin,
|
|
ProctoringFields,
|
|
MakoTemplateBlockBase,
|
|
XmlMixin,
|
|
XModuleToXBlockMixin,
|
|
HTMLSnippet,
|
|
ResourceTemplates,
|
|
XModuleMixin,
|
|
StudioEditableBlock,
|
|
):
|
|
"""
|
|
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
|
|
blocks.
|
|
"""
|
|
resources_dir = 'assets/split_test'
|
|
|
|
filename_extension = "xml"
|
|
|
|
has_author_view = True
|
|
|
|
show_in_read_only_mode = True
|
|
|
|
preview_view_js = {
|
|
'js': [],
|
|
'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
|
|
}
|
|
|
|
mako_template = "widgets/metadata-only-edit.html"
|
|
studio_js_module_name = 'SequenceDescriptor'
|
|
studio_view_js = {
|
|
'js': [resource_filename(__name__, 'js/src/sequence/edit.js')],
|
|
'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
|
|
}
|
|
|
|
@cached_property
|
|
def child_block(self):
|
|
"""
|
|
Return the child block for the partition or None.
|
|
"""
|
|
child_blocks = self.get_child_blocks()
|
|
if len(child_blocks) >= 1:
|
|
return child_blocks[0]
|
|
return None
|
|
|
|
@cached_property
|
|
def child(self):
|
|
"""
|
|
Return the user bound child block for the partition or None.
|
|
"""
|
|
if self.child_block is not None:
|
|
return self.runtime.get_block_for_descriptor(self.child_block)
|
|
else:
|
|
return None
|
|
|
|
def get_child_block_by_location(self, location):
|
|
"""
|
|
Look through the children and look for one with the given location.
|
|
Returns the block.
|
|
If none match, return None
|
|
"""
|
|
for child in self.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_blocks(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_block = self.get_child_block_by_location(child_location)
|
|
else:
|
|
# Oops. Config error.
|
|
log.debug("configuration error in split test block: invalid group_id %r (not one of %r). Showing error", str_group_id, list(self.group_id_to_child.keys())) # lint-amnesty, pylint: disable=line-too-long
|
|
|
|
if child_block is None:
|
|
# Peak confusion is great. Now that we set child_block,
|
|
# get_children() should return a list with one element--the
|
|
# xmodule for the child
|
|
log.debug("configuration error in split test block: no such child")
|
|
return []
|
|
|
|
return [child_block]
|
|
|
|
def get_group_id(self):
|
|
"""
|
|
Returns the group ID, or None if none is available.
|
|
"""
|
|
partitions_service = self.runtime.service(self, 'partitions')
|
|
user_service = self.runtime.service(self, 'user')
|
|
user = user_service._django_user # pylint: disable=protected-access
|
|
return partitions_service.get_user_group_id_for_partition(user, self.user_partition_id)
|
|
|
|
def _staff_view(self, context):
|
|
"""
|
|
Render the staff view for a split test block.
|
|
"""
|
|
fragment = Fragment()
|
|
active_contents = []
|
|
inactive_contents = []
|
|
|
|
for child_location in self.children: # pylint: disable=no-member
|
|
child_block = self.get_child_block_by_location(child_location)
|
|
child = self.runtime.get_block_for_descriptor(child_block)
|
|
rendered_child = child.render(STUDENT_VIEW, context)
|
|
fragment.add_fragment_resources(rendered_child)
|
|
group_name, updated_group_id = self.get_data_for_vertical(child)
|
|
|
|
if updated_group_id is None: # inactive group
|
|
group_name = child.display_name
|
|
updated_group_id = [g_id for g_id, loc in self.group_id_to_child.items() if loc == child_location][0]
|
|
inactive_contents.append({
|
|
'group_name': _('{group_name} (inactive)').format(group_name=group_name),
|
|
'id': str(child.location),
|
|
'content': rendered_child.content,
|
|
'group_id': updated_group_id,
|
|
})
|
|
continue
|
|
|
|
active_contents.append({
|
|
'group_name': group_name,
|
|
'id': str(child.location),
|
|
'content': rendered_child.content,
|
|
'group_id': updated_group_id,
|
|
})
|
|
|
|
# Sort active and inactive contents by group name.
|
|
sorted_active_contents = sorted(active_contents, key=itemgetter('group_name'))
|
|
sorted_inactive_contents = sorted(inactive_contents, key=itemgetter('group_name'))
|
|
|
|
# Use the new template
|
|
fragment.add_content(self.runtime.service(self, 'mako').render_template('split_test_staff_view.html', {
|
|
'items': sorted_active_contents + sorted_inactive_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_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.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.runtime.service(self, 'mako').render_template('split_test_author_view.html', {
|
|
'split_test': self,
|
|
'is_root': is_root,
|
|
'is_configured': self.is_configured,
|
|
'active_groups_preview': active_groups_preview,
|
|
'inactive_groups_preview': inactive_groups_preview,
|
|
'group_configuration_url': self.group_configuration_url,
|
|
}))
|
|
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_block in children:
|
|
active_child = self.runtime.get_block_for_descriptor(active_child_block)
|
|
rendered_child = active_child.render(StudioEditableBlock.get_preview_view_name(active_child), context)
|
|
if active_child.category == 'vertical':
|
|
group_name, group_id = self.get_data_for_vertical(active_child)
|
|
if group_name:
|
|
rendered_child.content = rendered_child.content.replace(
|
|
DEFAULT_GROUP_NAME.format(group_id=group_id),
|
|
group_name
|
|
)
|
|
fragment.add_fragment_resources(rendered_child)
|
|
html = html + rendered_child.content
|
|
|
|
return html
|
|
|
|
def studio_view(self, context):
|
|
"""
|
|
Return the studio view.
|
|
"""
|
|
fragment = Fragment(
|
|
self.runtime.service(self, 'mako').render_template(self.mako_template, self.get_context())
|
|
)
|
|
add_webpack_js_to_fragment(fragment, 'SplitTestBlockEditor')
|
|
shim_xmodule_js(fragment, self.studio_js_module_name)
|
|
return fragment
|
|
|
|
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 block load...
|
|
return Fragment(content="<div>Nothing here. Move along.</div>")
|
|
|
|
if self.runtime.user_is_staff:
|
|
return self._staff_view(context)
|
|
else:
|
|
child_fragment = self.child.render(STUDENT_VIEW, context)
|
|
fragment = Fragment(self.runtime.service(self, 'mako').render_template('split_test_student_view.html', {
|
|
'child_content': child_fragment.content,
|
|
'child_id': self.child.scope_ids.usage_id,
|
|
}))
|
|
fragment.add_fragment_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=''): # lint-amnesty, pylint: disable=unused-argument
|
|
"""
|
|
Record in the tracking logs which child was rendered
|
|
"""
|
|
try:
|
|
child_id = str(self.child.scope_ids.usage_id)
|
|
except Exception:
|
|
log.info(
|
|
"Can't get usage_id of Nonetype object in course {course_key}".format(
|
|
course_key=str(self.location.course_key)
|
|
)
|
|
)
|
|
raise
|
|
else:
|
|
self.runtime.publish(self, 'xblock.split_test.child_render', {'child_id': child_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
|
|
|
|
def get_data_for_vertical(self, vertical):
|
|
"""
|
|
Return name and id of a group corresponding to `vertical`.
|
|
"""
|
|
user_partition = self.get_selected_partition()
|
|
if user_partition:
|
|
for group in user_partition.groups:
|
|
group_id = str(group.id)
|
|
child_location = self.group_id_to_child.get(group_id, None)
|
|
if child_location == vertical.location:
|
|
return (group.name, group.id)
|
|
return (None, None)
|
|
|
|
@property
|
|
def tooltip_title(self):
|
|
return getattr(self.child, 'tooltip_title', '')
|
|
|
|
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] = str(self.group_id_to_child[group])
|
|
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:
|
|
block = system.process_xml(etree.tostring(child))
|
|
children.append(block.scope_ids.usage_id)
|
|
except Exception: # lint-amnesty, pylint: disable=broad-except
|
|
msg = "Unable to load child when parsing split_test block."
|
|
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().get_context()
|
|
_context.update({
|
|
'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 block.get_child_blocks().
|
|
"""
|
|
return True
|
|
|
|
def editor_saved(self, user, old_metadata, old_content): # lint-amnesty, pylint: disable=unused-argument
|
|
"""
|
|
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:
|
|
self.group_id_mapping = {} # pylint: disable=attribute-defined-outside-init
|
|
for group in selected_partition.groups:
|
|
self._create_vertical_for_group(group, user.id)
|
|
# Don't need to call update_item in the modulestore because the caller of this method will do it.
|
|
else:
|
|
# If children referenced in group_id_to_child have been deleted, remove them from the map.
|
|
for str_group_id, usage_key in self.group_id_to_child.items():
|
|
if usage_key not in self.children: # pylint: disable=no-member
|
|
del self.group_id_to_child[str_group_id]
|
|
|
|
@property
|
|
def editable_metadata_fields(self):
|
|
# Update the list of partitions based on the currently available user_partitions.
|
|
user_partition_values.build_partition_values(self.user_partitions, self.get_selected_partition())
|
|
|
|
editable_fields = super().editable_metadata_fields
|
|
|
|
# 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().non_editable_metadata_fields
|
|
non_editable_fields.extend([
|
|
SplitTestBlock.is_entrance_exam,
|
|
SplitTestBlock.due,
|
|
SplitTestBlock.user_partitions,
|
|
SplitTestBlock.group_id_to_child,
|
|
])
|
|
return non_editable_fields
|
|
|
|
def get_selected_partition(self):
|
|
"""
|
|
Returns the partition that this split block 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: # lint-amnesty, pylint: disable=not-an-iterable
|
|
if user_partition.id == self.user_partition_id:
|
|
return user_partition
|
|
|
|
return None
|
|
|
|
def active_and_inactive_children(self):
|
|
"""
|
|
Returns two values:
|
|
1. The active children of this split test, in the order of the groups.
|
|
2. The remaining (inactive) children, in the order they were added to the split test.
|
|
"""
|
|
children = self.get_children()
|
|
|
|
user_partition = self.get_selected_partition()
|
|
if not user_partition:
|
|
return [], children
|
|
|
|
def get_child_block(location):
|
|
"""
|
|
Returns the child block which matches the specified location, or None if one is not found.
|
|
"""
|
|
for child in children:
|
|
if child.location == location:
|
|
return child
|
|
return None
|
|
|
|
# Compute the active children in the order specified by the user partition
|
|
active_children = []
|
|
for group in user_partition.groups:
|
|
group_id = str(group.id)
|
|
child_location = self.group_id_to_child.get(group_id, None)
|
|
child = get_child_block(child_location)
|
|
if child:
|
|
active_children.append(child)
|
|
|
|
# Compute the inactive children in the order they were added to the split test
|
|
inactive_children = [child for child in children if child not in active_children]
|
|
|
|
return active_children, inactive_children
|
|
|
|
@property
|
|
def is_configured(self):
|
|
"""
|
|
Returns true if the split_test instance is associated with a UserPartition.
|
|
"""
|
|
return not self.user_partition_id == SplitTestFields.no_partition_selected['value']
|
|
|
|
def validate(self):
|
|
"""
|
|
Validates the state of this split_test instance. This is the override of the general XBlock method,
|
|
and it will also ask its superclass to validate.
|
|
"""
|
|
validation = super().validate()
|
|
split_test_validation = self.validate_split_test()
|
|
|
|
if split_test_validation:
|
|
return validation
|
|
|
|
validation = StudioValidation.copy(validation)
|
|
if validation and (not self.is_configured and len(split_test_validation.messages) == 1):
|
|
validation.summary = split_test_validation.messages[0]
|
|
else:
|
|
validation.summary = self.general_validation_message(split_test_validation)
|
|
validation.add_messages(split_test_validation)
|
|
|
|
return validation
|
|
|
|
def validate_split_test(self):
|
|
"""
|
|
Returns a StudioValidation object describing the current state of the split_test_block
|
|
(not including superclass validation messages).
|
|
"""
|
|
_ = self.runtime.service(self, "i18n").ugettext
|
|
split_validation = StudioValidation(self.location)
|
|
if self.user_partition_id < 0:
|
|
split_validation.add(
|
|
StudioValidationMessage(
|
|
StudioValidationMessage.NOT_CONFIGURED,
|
|
_("The experiment is not associated with a group configuration."),
|
|
action_class='edit-button',
|
|
action_label=_("Select a Group Configuration")
|
|
)
|
|
)
|
|
else:
|
|
user_partition = self.get_selected_partition()
|
|
if not user_partition:
|
|
split_validation.add(
|
|
StudioValidationMessage(
|
|
StudioValidationMessage.ERROR,
|
|
_("The experiment uses a deleted group configuration. Select a valid group configuration or delete this experiment.") # lint-amnesty, pylint: disable=line-too-long
|
|
)
|
|
)
|
|
else:
|
|
# If the user_partition selected is not valid for the split_test block, error.
|
|
# This can only happen via XML and import/export.
|
|
if not get_split_user_partitions([user_partition]):
|
|
split_validation.add(
|
|
StudioValidationMessage(
|
|
StudioValidationMessage.ERROR,
|
|
_("The experiment uses a group configuration that is not supported for experiments. "
|
|
"Select a valid group configuration or delete this experiment.")
|
|
)
|
|
)
|
|
else:
|
|
[active_children, inactive_children] = self.active_and_inactive_children()
|
|
if len(active_children) < len(user_partition.groups):
|
|
split_validation.add(
|
|
StudioValidationMessage(
|
|
StudioValidationMessage.ERROR,
|
|
_("The experiment does not contain all of the groups in the configuration."),
|
|
action_runtime_event='add-missing-groups',
|
|
action_label=_("Add Missing Groups")
|
|
)
|
|
)
|
|
if len(inactive_children) > 0:
|
|
split_validation.add(
|
|
StudioValidationMessage(
|
|
StudioValidationMessage.WARNING,
|
|
_("The experiment has an inactive group. "
|
|
"Move content into active groups, then delete the inactive group.")
|
|
)
|
|
)
|
|
return split_validation
|
|
|
|
def general_validation_message(self, validation=None):
|
|
"""
|
|
Returns just a summary message about whether or not this split_test instance has
|
|
validation issues (not including superclass validation messages). If the split_test instance
|
|
validates correctly, this method returns None.
|
|
"""
|
|
if validation is None:
|
|
validation = self.validate_split_test()
|
|
|
|
if not validation:
|
|
has_error = any(message.type == StudioValidationMessage.ERROR for message in validation.messages)
|
|
return StudioValidationMessage(
|
|
StudioValidationMessage.ERROR if has_error else StudioValidationMessage.WARNING,
|
|
_("This content experiment has issues that affect content visibility.")
|
|
)
|
|
return None
|
|
|
|
@XBlock.handler
|
|
def add_missing_groups(self, request, suffix=''): # lint-amnesty, pylint: disable=unused-argument
|
|
"""
|
|
Create verticals for any missing groups in the split test instance.
|
|
|
|
Called from Studio view.
|
|
"""
|
|
user_partition = self.get_selected_partition()
|
|
|
|
changed = False
|
|
for group in user_partition.groups:
|
|
str_group_id = str(group.id)
|
|
if str_group_id not in self.group_id_to_child:
|
|
user_id = self.runtime.service(self, 'user').get_current_user().opt_attrs['edx-platform.user_id']
|
|
self._create_vertical_for_group(group, user_id)
|
|
changed = True
|
|
|
|
if changed:
|
|
# user.id - to be fixed by Publishing team
|
|
self.runtime.modulestore.update_item(self, None)
|
|
return Response()
|
|
|
|
@property
|
|
def group_configuration_url(self): # lint-amnesty, pylint: disable=missing-function-docstring
|
|
assert hasattr(self.runtime, 'modulestore') and hasattr(self.runtime.modulestore, 'get_course'), \
|
|
"modulestore has to be available"
|
|
|
|
course_block = self.runtime.modulestore.get_course(self.location.course_key)
|
|
group_configuration_url = None
|
|
if 'split_test' in course_block.advanced_modules:
|
|
user_partition = self.get_selected_partition()
|
|
if user_partition:
|
|
group_configuration_url = "{url}#{configuration_id}".format(
|
|
url='/group_configurations/' + str(self.location.course_key),
|
|
configuration_id=str(user_partition.id)
|
|
)
|
|
|
|
return group_configuration_url
|
|
|
|
def _create_vertical_for_group(self, group, user_id):
|
|
"""
|
|
Creates a vertical to associate with the group.
|
|
|
|
This appends the new vertical to the end of children, and updates group_id_to_child.
|
|
A mutable modulestore is needed to call this method (will need to update after mixed
|
|
modulestore work, currently relies on mongo's create_item method).
|
|
"""
|
|
assert hasattr(self.runtime, 'modulestore') and hasattr(self.runtime.modulestore, 'create_item'), \
|
|
"editor_saved should only be called when a mutable modulestore is available"
|
|
modulestore = self.runtime.modulestore
|
|
dest_usage_key = self.location.replace(category="vertical", name=uuid4().hex)
|
|
metadata = {'display_name': DEFAULT_GROUP_NAME.format(group_id=group.id)}
|
|
modulestore.create_item(
|
|
user_id,
|
|
self.location.course_key,
|
|
dest_usage_key.block_type,
|
|
block_id=dest_usage_key.block_id,
|
|
definition_data=None,
|
|
metadata=metadata,
|
|
runtime=self.runtime,
|
|
)
|
|
self.children.append(dest_usage_key) # pylint: disable=no-member
|
|
self.group_id_to_child[str(group.id)] = dest_usage_key
|