Merge pull request #3928 from edx/christina/custom-split-editor
Implement a custom editor for the split_module.
This commit is contained in:
@@ -140,7 +140,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.check_components_on_page(
|
||||
ADVANCED_COMPONENT_TYPES,
|
||||
['Word cloud', 'Annotation', 'Text Annotation', 'Video Annotation', 'Image Annotation',
|
||||
'Open Response Assessment', 'Peer Grading Interface', 'openassessment'],
|
||||
'Open Response Assessment', 'Peer Grading Interface', 'openassessment', 'split_test'],
|
||||
)
|
||||
|
||||
def test_advanced_components_require_two_clicks(self):
|
||||
|
||||
@@ -8,7 +8,6 @@ from django.contrib.auth.models import User
|
||||
from django.test.client import Client
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from contentstore.tests.modulestore_config import TEST_MODULESTORE
|
||||
|
||||
@@ -62,10 +62,11 @@ else:
|
||||
# except for edX Learning Sciences experiments on edge.edx.org without
|
||||
# further work to make them robust, maintainable, finalize data formats,
|
||||
# etc.
|
||||
'concept', # Concept mapper. See https://github.com/pmitros/ConceptXBlock
|
||||
'done', # Lets students mark things as done. See https://github.com/pmitros/DoneXBlock
|
||||
'audio', # Embed an audio file. See https://github.com/pmitros/AudioXBlock
|
||||
'concept', # Concept mapper. See https://github.com/pmitros/ConceptXBlock
|
||||
'done', # Lets students mark things as done. See https://github.com/pmitros/DoneXBlock
|
||||
'audio', # Embed an audio file. See https://github.com/pmitros/AudioXBlock
|
||||
'openassessment', # edx-ora2
|
||||
'split_test'
|
||||
] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES
|
||||
|
||||
ADVANCED_COMPONENT_CATEGORY = 'advanced'
|
||||
|
||||
@@ -23,7 +23,6 @@ import xmodule
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError, DuplicateItemError
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.video_module import manage_video_subtitles_save
|
||||
|
||||
from util.json_request import expect_json, JsonResponse
|
||||
from util.string_utils import str_to_bool
|
||||
@@ -289,6 +288,7 @@ def _save_item(request, usage_key, data=None, children=None, metadata=None, null
|
||||
return JsonResponse({"error": "Can't find item by location: " + unicode(usage_key)}, 404)
|
||||
|
||||
old_metadata = own_metadata(existing_item)
|
||||
old_content = existing_item.get_explicitly_set_fields_by_scope(Scope.content)
|
||||
|
||||
if publish:
|
||||
if publish == 'make_private':
|
||||
@@ -310,7 +310,7 @@ def _save_item(request, usage_key, data=None, children=None, metadata=None, null
|
||||
# TODO Allow any scope.content fields not just "data" (exactly like the get below this)
|
||||
existing_item.data = data
|
||||
else:
|
||||
data = existing_item.get_explicitly_set_fields_by_scope(Scope.content)
|
||||
data = old_content['data'] if 'data' in old_content else None
|
||||
|
||||
if children is not None:
|
||||
children_usage_keys = [
|
||||
@@ -345,8 +345,8 @@ def _save_item(request, usage_key, data=None, children=None, metadata=None, null
|
||||
return JsonResponse({"error": "Invalid data"}, 400)
|
||||
field.write_to(existing_item, value)
|
||||
|
||||
if existing_item.category == 'video':
|
||||
manage_video_subtitles_save(existing_item, request.user, old_metadata, generate_translation=True)
|
||||
if callable(getattr(existing_item, "editor_saved", None)):
|
||||
existing_item.editor_saved(request.user, old_metadata, old_content)
|
||||
|
||||
# commit to datastore
|
||||
store.update_item(existing_item, request.user.id)
|
||||
|
||||
@@ -24,6 +24,7 @@ from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from opaque_keys.edx.locations import Location
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
|
||||
|
||||
class ItemTest(CourseTestCase):
|
||||
@@ -62,10 +63,6 @@ class ItemTest(CourseTestCase):
|
||||
data['boilerplate'] = boilerplate
|
||||
return self.client.ajax_post(reverse('contentstore.views.xblock_handler'), json.dumps(data))
|
||||
|
||||
|
||||
class GetItem(ItemTest):
|
||||
"""Tests for '/xblock' GET url."""
|
||||
|
||||
def _create_vertical(self, parent_usage_key=None):
|
||||
"""
|
||||
Creates a vertical, returning its UsageKey.
|
||||
@@ -74,6 +71,10 @@ class GetItem(ItemTest):
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
return self.response_usage_key(resp)
|
||||
|
||||
|
||||
class GetItem(ItemTest):
|
||||
"""Tests for '/xblock' GET url."""
|
||||
|
||||
def _get_container_preview(self, usage_key):
|
||||
"""
|
||||
Returns the HTML and resources required for the xblock at the specified UsageKey
|
||||
@@ -645,7 +646,6 @@ class TestEditItem(ItemTest):
|
||||
self.assertIsNotNone(draft_2)
|
||||
self.assertEqual(draft_1, draft_2)
|
||||
|
||||
|
||||
def test_make_private_with_multiple_requests(self):
|
||||
"""
|
||||
Make private requests gets proper response even if xmodule is already made private.
|
||||
@@ -685,7 +685,6 @@ class TestEditItem(ItemTest):
|
||||
self.assertIsNotNone(draft_2)
|
||||
self.assertEqual(draft_1, draft_2)
|
||||
|
||||
|
||||
def test_published_and_draft_contents_with_update(self):
|
||||
""" Create a draft and publish it then modify the draft and check that published content is not modified """
|
||||
|
||||
@@ -771,6 +770,133 @@ class TestEditItem(ItemTest):
|
||||
self.assertEqual(compute_publish_state(html), PublishState.draft)
|
||||
|
||||
|
||||
class TestEditSplitModule(ItemTest):
|
||||
"""
|
||||
Tests around editing instances of the split_test module.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestEditSplitModule, self).setUp()
|
||||
self.course.user_partitions = [
|
||||
UserPartition(
|
||||
0, 'first_partition', 'First Partition',
|
||||
[Group("0", 'alpha'), Group("1", 'beta')]
|
||||
),
|
||||
UserPartition(
|
||||
1, 'second_partition', 'Second Partition',
|
||||
[Group("0", 'Group 0'), Group("1", 'Group 1'), Group("2", 'Group 2')]
|
||||
)
|
||||
]
|
||||
self.store.update_item(self.course, self.user.id)
|
||||
root_usage_key = self._create_vertical()
|
||||
resp = self.create_xblock(category='split_test', parent_usage_key=root_usage_key)
|
||||
self.split_test_usage_key = self.response_usage_key(resp)
|
||||
self.split_test_update_url = reverse_usage_url("xblock_handler", self.split_test_usage_key)
|
||||
|
||||
def _update_partition_id(self, partition_id):
|
||||
"""
|
||||
Helper method that sets the user_partition_id to the supplied value.
|
||||
|
||||
The updated split_test instance is returned.
|
||||
"""
|
||||
self.client.ajax_post(
|
||||
self.split_test_update_url,
|
||||
# Even though user_partition_id is Scope.content, it will get saved by the Studio editor as
|
||||
# metadata. The code in item.py will update the field correctly, even though it is not the
|
||||
# expected scope.
|
||||
data={'metadata': {'user_partition_id': str(partition_id)}}
|
||||
)
|
||||
|
||||
# Verify the partition_id was saved.
|
||||
split_test = self.get_item_from_modulestore(self.split_test_usage_key, True)
|
||||
self.assertEqual(partition_id, split_test.user_partition_id)
|
||||
return split_test
|
||||
|
||||
def test_split_create_groups(self):
|
||||
"""
|
||||
Test that verticals are created for the experiment groups when
|
||||
a spit test module is edited.
|
||||
"""
|
||||
split_test = self.get_item_from_modulestore(self.split_test_usage_key, True)
|
||||
# Initially, no user_partition_id is set, and the split_test has no children.
|
||||
self.assertEqual(-1, split_test.user_partition_id)
|
||||
self.assertEqual(0, len(split_test.children))
|
||||
|
||||
# Set the user_partition_id to 0.
|
||||
split_test = self._update_partition_id(0)
|
||||
|
||||
# Verify that child verticals have been set to match the groups
|
||||
self.assertEqual(2, len(split_test.children))
|
||||
vertical_0 = self.get_item_from_modulestore(split_test.children[0], True)
|
||||
vertical_1 = self.get_item_from_modulestore(split_test.children[1], True)
|
||||
self.assertEqual("vertical", vertical_0.category)
|
||||
self.assertEqual("vertical", vertical_1.category)
|
||||
self.assertEqual("alpha", vertical_0.display_name)
|
||||
self.assertEqual("beta", vertical_1.display_name)
|
||||
|
||||
# Verify that the group_id_to child mapping is correct.
|
||||
self.assertEqual(2, len(split_test.group_id_to_child))
|
||||
split_test.group_id_to_child['0'] = vertical_0.location
|
||||
split_test.group_id_to_child['1'] = vertical_1.location
|
||||
|
||||
def test_split_change_user_partition_id(self):
|
||||
"""
|
||||
Test what happens when the user_partition_id is changed to a different experiment.
|
||||
|
||||
This is not currently supported by the Studio UI.
|
||||
"""
|
||||
# Set to first experiment.
|
||||
split_test = self._update_partition_id(0)
|
||||
self.assertEqual(2, len(split_test.children))
|
||||
initial_vertical_0_location = split_test.children[0]
|
||||
initial_vertical_1_location = split_test.children[1]
|
||||
|
||||
# Set to second experiment
|
||||
split_test = self._update_partition_id(1)
|
||||
# We don't currently remove existing children.
|
||||
self.assertEqual(5, len(split_test.children))
|
||||
vertical_0 = self.get_item_from_modulestore(split_test.children[2], True)
|
||||
vertical_1 = self.get_item_from_modulestore(split_test.children[3], True)
|
||||
vertical_2 = self.get_item_from_modulestore(split_test.children[4], True)
|
||||
|
||||
# Verify that the group_id_to child mapping is correct.
|
||||
self.assertEqual(3, len(split_test.group_id_to_child))
|
||||
split_test.group_id_to_child['0'] = vertical_0.location
|
||||
split_test.group_id_to_child['1'] = vertical_1.location
|
||||
split_test.group_id_to_child['2'] = vertical_2.location
|
||||
self.assertNotEqual(initial_vertical_0_location, vertical_0.location)
|
||||
self.assertNotEqual(initial_vertical_1_location, vertical_1.location)
|
||||
|
||||
def test_split_same_user_partition_id(self):
|
||||
"""
|
||||
Test that nothing happens when the user_partition_id is set to the same value twice.
|
||||
"""
|
||||
# Set to first experiment.
|
||||
split_test = self._update_partition_id(0)
|
||||
self.assertEqual(2, len(split_test.children))
|
||||
initial_group_id_to_child = split_test.group_id_to_child
|
||||
|
||||
# Set again to first experiment.
|
||||
split_test = self._update_partition_id(0)
|
||||
self.assertEqual(2, len(split_test.children))
|
||||
self.assertEqual(initial_group_id_to_child, split_test.group_id_to_child)
|
||||
|
||||
def test_split_non_existent_user_partition_id(self):
|
||||
"""
|
||||
Test that nothing happens when the user_partition_id is set to a value that doesn't exist.
|
||||
|
||||
The user_partition_id will be updated, but children and group_id_to_child map will not change.
|
||||
"""
|
||||
# Set to first experiment.
|
||||
split_test = self._update_partition_id(0)
|
||||
self.assertEqual(2, len(split_test.children))
|
||||
initial_group_id_to_child = split_test.group_id_to_child
|
||||
|
||||
# Set to an experiment that doesn't exist.
|
||||
split_test = self._update_partition_id(-50)
|
||||
self.assertEqual(2, len(split_test.children))
|
||||
self.assertEqual(initial_group_id_to_child, split_test.group_id_to_child)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestComponentHandler(TestCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -7,8 +7,8 @@ define ["jquery", "underscore", "gettext", "xblock/runtime.v1",
|
||||
editorMode: 'editor-mode'
|
||||
|
||||
events:
|
||||
"click .component-actions .edit-button": 'clickEditButton'
|
||||
"click .component-actions .delete-button": 'onDelete'
|
||||
"click .edit-button": 'clickEditButton'
|
||||
"click .delete-button": 'onDelete'
|
||||
|
||||
initialize: ->
|
||||
@onDelete = @options.onDelete
|
||||
|
||||
@@ -209,16 +209,6 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
|
||||
});
|
||||
});
|
||||
|
||||
describe("Empty container", function() {
|
||||
var mockEmptyContainerXBlockHtml = readFixtures('mock/mock-empty-container-xblock.underscore');
|
||||
|
||||
it('shows the "no children" message', function() {
|
||||
renderContainerPage(mockEmptyContainerXBlockHtml, this);
|
||||
expect(containerPage.$('.no-container-content')).not.toHaveClass('is-hidden');
|
||||
expect(containerPage.$('.wrapper-xblock')).toHaveClass('is-hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe("xblock operations", function() {
|
||||
var getGroupElement, expectNumComponents,
|
||||
NUM_GROUPS = 2, NUM_COMPONENTS_PER_GROUP = 3, GROUP_TO_TEST = "A",
|
||||
|
||||
@@ -14,7 +14,6 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
|
||||
|
||||
initialize: function() {
|
||||
BaseView.prototype.initialize.call(this);
|
||||
this.noContentElement = this.$('.no-container-content');
|
||||
this.xblockView = new ContainerView({
|
||||
el: this.$('.wrapper-xblock'),
|
||||
model: this.model,
|
||||
@@ -24,13 +23,11 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
|
||||
|
||||
render: function(options) {
|
||||
var self = this,
|
||||
noContentElement = this.noContentElement,
|
||||
xblockView = this.xblockView,
|
||||
loadingElement = this.$('.ui-loading');
|
||||
loadingElement.removeClass('is-hidden');
|
||||
|
||||
// Hide both blocks until we know which one to show
|
||||
noContentElement.addClass('is-hidden');
|
||||
xblockView.$el.addClass('is-hidden');
|
||||
|
||||
// Add actions to any top level buttons, e.g. "Edit" of the container itself
|
||||
@@ -39,13 +36,9 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
|
||||
// Render the xblock
|
||||
xblockView.render({
|
||||
success: function(xblock) {
|
||||
if (xblockView.hasChildXBlocks()) {
|
||||
xblockView.$el.removeClass('is-hidden');
|
||||
self.renderAddXBlockComponents();
|
||||
self.onXBlockRefresh(xblockView);
|
||||
} else {
|
||||
noContentElement.removeClass('is-hidden');
|
||||
}
|
||||
xblockView.$el.removeClass('is-hidden');
|
||||
self.renderAddXBlockComponents();
|
||||
self.onXBlockRefresh(xblockView);
|
||||
self.refreshTitle();
|
||||
loadingElement.addClass('is-hidden');
|
||||
self.delegateEvents();
|
||||
|
||||
@@ -138,3 +138,56 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper-xblock-message {
|
||||
|
||||
.xblock-message {
|
||||
@extend %t-copy-sub1;
|
||||
background-color: $gray-d2;
|
||||
padding: ($baseline/2) ($baseline*.75);
|
||||
color: $white;
|
||||
|
||||
.message-text {
|
||||
display: inline-block;
|
||||
width: 93%;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
[class^="icon-"] {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
&.information {
|
||||
background-color: $gray-l5;
|
||||
color: $gray-d2;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background-color: $gray-d2;
|
||||
padding: ($baseline/2) $baseline;
|
||||
color: $white;
|
||||
|
||||
.icon-warning-sign {
|
||||
margin-right: ($baseline/2);
|
||||
color: $orange;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
display: inline-block;
|
||||
width: 93%;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
&.error {
|
||||
background-color: $gray-d2;
|
||||
padding: ($baseline/2) $baseline;
|
||||
color: $white;
|
||||
|
||||
.icon-exclamation-sign {
|
||||
margin-right: ($baseline/2);
|
||||
color: $red-l2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,12 +40,12 @@
|
||||
|
||||
.action-button-text {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[class^="icon-"] {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,8 +69,15 @@
|
||||
text-align: center;
|
||||
color: $gray;
|
||||
|
||||
.new-button {
|
||||
@include font-size(14);
|
||||
.icon-warning-sign {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
@include green-button;
|
||||
@extend %t-action4;
|
||||
padding: 8px 20px 10px;
|
||||
text-align: center;
|
||||
margin-left: $baseline;
|
||||
|
||||
[class^="icon-"] {
|
||||
@@ -155,6 +162,19 @@ body.view-container .content-primary {
|
||||
.xblock-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.xblock-message {
|
||||
border-radius: 3px 3px 0 0;
|
||||
|
||||
&.information {
|
||||
@extend %t-copy-base;
|
||||
margin-bottom: $baseline;
|
||||
border-bottom: 1px solid $gray-l4;
|
||||
padding: ($baseline/2) ($baseline*.75);
|
||||
background-color: $gray-l5;
|
||||
color: $gray-d1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: nesting level xblock rendering
|
||||
|
||||
@@ -176,6 +176,36 @@ body.course.unit,
|
||||
}
|
||||
}
|
||||
|
||||
.xblock-message-area {
|
||||
.xblock-student_view {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.xmodule_DiscussionModule,
|
||||
.xmodule_HtmlModule,
|
||||
.xblock {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.xblock-message {
|
||||
border-radius: 0 0 0 2px;
|
||||
|
||||
.edit-button {
|
||||
color: $orange-l1;
|
||||
|
||||
.icon-pencil {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.information {
|
||||
@extend %t-copy-sub2;
|
||||
padding-top: 0;
|
||||
color: $gray-l1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UI: DnD - specific elems/cases - unit
|
||||
.courseware-unit {
|
||||
|
||||
@@ -760,9 +790,9 @@ body.unit {
|
||||
.action-button {
|
||||
@include transition(all $tmg-f3 linear 0s);
|
||||
display: block;
|
||||
padding: 0 $baseline/2;
|
||||
padding: ($baseline/5) ($baseline/2);
|
||||
width: auto;
|
||||
height: ($baseline*1.5);
|
||||
height: auto;
|
||||
border-radius: 3px;
|
||||
color: $gray-l1;
|
||||
|
||||
@@ -773,7 +803,7 @@ body.unit {
|
||||
|
||||
.action-button-text {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
vertical-align: baseline;
|
||||
padding: 0 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -785,7 +815,7 @@ body.unit {
|
||||
|
||||
[class^="icon-"] {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,9 +94,6 @@ main_xblock_info = {
|
||||
<article class="content-primary window">
|
||||
<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" data-locator="${xblock_locator}" data-course-key="${xblock_locator.course_key}">
|
||||
</section>
|
||||
<div class="no-container-content is-hidden">
|
||||
<p>${_("This page has no content yet.")}</p>
|
||||
</div>
|
||||
<div class="ui-loading">
|
||||
<p><span class="spin"><i class="icon-refresh"></i></span> <span class="copy">${_("Loading...")}</span></p>
|
||||
</div>
|
||||
|
||||
@@ -42,3 +42,6 @@ from contentstore.views.helpers import xblock_studio_url
|
||||
</ul>
|
||||
</div>
|
||||
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
|
||||
<div class="xblock-message-area">
|
||||
${preview}
|
||||
</div>
|
||||
|
||||
@@ -80,8 +80,12 @@ collapsible_class = "is-collapsible" if xblock.has_children else ""
|
||||
</header>
|
||||
% if is_root or not xblock_url:
|
||||
<article class="xblock-render">
|
||||
${content}
|
||||
${content}
|
||||
</article>
|
||||
% else:
|
||||
<div class="xblock-message-area">
|
||||
${content}
|
||||
</div>
|
||||
% endif
|
||||
|
||||
% if not is_root:
|
||||
|
||||
12
cms/templates/widgets/split-edit.html
Normal file
12
cms/templates/widgets/split-edit.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%include file="metadata-edit.html" />
|
||||
% if disable_user_partition_editing:
|
||||
<div class="message setting-message">
|
||||
% if not selected_partition:
|
||||
<p>${_("This content experiment refers to a group configuration that has been deleted.")}</p>
|
||||
% else:
|
||||
<p>${_("This content experiment uses group configuration '{0}'.".format("<strong>"+str(selected_partition.name)+"</strong>"))}</p>
|
||||
% endif
|
||||
<p class="tip setting-help">${_("After you select the group configuration and save the content experiment, you cannot change this setting.")}</p>
|
||||
</div>
|
||||
% endif
|
||||
@@ -9,7 +9,6 @@ import dateutil.parser
|
||||
from lazy import lazy
|
||||
|
||||
from opaque_keys.edx.locations import Location
|
||||
from xmodule.partitions.partitions import UserPartition
|
||||
from xmodule.seq_module import SequenceDescriptor, SequenceModule
|
||||
from xmodule.graders import grader_from_conf
|
||||
from xmodule.tabs import CourseTabList
|
||||
@@ -160,29 +159,11 @@ class TextbookList(List):
|
||||
return json_data
|
||||
|
||||
|
||||
class UserPartitionList(List):
|
||||
"""Special List class for listing UserPartitions"""
|
||||
def from_json(self, values):
|
||||
return [UserPartition.from_json(v) for v in values]
|
||||
|
||||
def to_json(self, values):
|
||||
return [user_partition.to_json()
|
||||
for user_partition in values]
|
||||
|
||||
|
||||
class CourseFields(object):
|
||||
lti_passports = List(help="LTI tools passports as id:client_key:client_secret", scope=Scope.settings)
|
||||
textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course",
|
||||
default=[], scope=Scope.content)
|
||||
|
||||
# This is should be scoped to content, but since it's defined in the policy
|
||||
# file, it is currently scoped to settings.
|
||||
user_partitions = UserPartitionList(
|
||||
help="List of user partitions of this course into groups, used e.g. for experiments",
|
||||
default=[],
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content)
|
||||
enrollment_start = Date(help="Date that enrollment for this class is opened", scope=Scope.settings)
|
||||
enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings)
|
||||
|
||||
11
common/lib/xmodule/xmodule/css/split_test/edit.scss
Normal file
11
common/lib/xmodule/xmodule/css/split_test/edit.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
.setting-message {
|
||||
margin: ($baseline/2) $baseline;
|
||||
border-top: 3px solid $gray-l2;
|
||||
background-color: $gray-l5;
|
||||
padding: $baseline;
|
||||
}
|
||||
|
||||
.setting-help {
|
||||
@include font-size(12);
|
||||
font-color: $gray-l6;
|
||||
}
|
||||
@@ -5,12 +5,23 @@ Support for inheritance of fields down an XBlock hierarchy.
|
||||
from datetime import datetime
|
||||
from pytz import UTC
|
||||
|
||||
from xblock.fields import Scope, Boolean, String, Float, XBlockMixin, Dict, Integer
|
||||
from xmodule.partitions.partitions import UserPartition
|
||||
from xblock.fields import Scope, Boolean, String, Float, XBlockMixin, Dict, Integer, List
|
||||
from xblock.runtime import KeyValueStore, KvsFieldData
|
||||
|
||||
from xmodule.fields import Date, Timedelta
|
||||
|
||||
|
||||
class UserPartitionList(List):
|
||||
"""Special List class for listing UserPartitions"""
|
||||
def from_json(self, values):
|
||||
return [UserPartition.from_json(v) for v in values]
|
||||
|
||||
def to_json(self, values):
|
||||
return [user_partition.to_json()
|
||||
for user_partition in values]
|
||||
|
||||
|
||||
class InheritanceMixin(XBlockMixin):
|
||||
"""Field definitions for inheritable fields."""
|
||||
|
||||
@@ -95,6 +106,13 @@ class InheritanceMixin(XBlockMixin):
|
||||
"or to report and issue, please contact moocsupport@mathworks.com",
|
||||
scope=Scope.settings
|
||||
)
|
||||
# This is should be scoped to content, but since it's defined in the policy
|
||||
# file, it is currently scoped to settings.
|
||||
user_partitions = UserPartitionList(
|
||||
help="The list of group configurations for partitioning students in content experiments.",
|
||||
default=[],
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
|
||||
def compute_inherited_metadata(descriptor):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,7 +9,7 @@ from fs.memoryfs import MemoryFS
|
||||
from xmodule.tests.xml import factories as xml
|
||||
from xmodule.tests.xml import XModuleXmlImportTest
|
||||
from xmodule.tests import get_test_system
|
||||
from xmodule.split_test_module import SplitTestDescriptor
|
||||
from xmodule.split_test_module import SplitTestDescriptor, SplitTestFields, ValidationMessageType
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
from xmodule.partitions.test_partitions import StaticPartitionService, MemoryUserTagsService
|
||||
|
||||
@@ -21,12 +21,10 @@ class SplitTestModuleFactory(xml.XmlImportFactory):
|
||||
tag = 'split_test'
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class SplitTestModuleTest(XModuleXmlImportTest):
|
||||
"""
|
||||
Test the split test module
|
||||
Base class for all split_module tests.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.course_id = 'test_org/test_course_number/test_run'
|
||||
# construct module
|
||||
@@ -74,6 +72,13 @@ class SplitTestModuleTest(XModuleXmlImportTest):
|
||||
self.split_test_module = self.course_sequence.get_children()[0]
|
||||
self.split_test_module.bind_for_student(self.module_system, self.split_test_module._field_data) # pylint: disable=protected-access
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class SplitTestModuleLMSTest(SplitTestModuleTest):
|
||||
"""
|
||||
Test the split test module
|
||||
"""
|
||||
|
||||
@ddt.data(('0', 'split_test_cond0'), ('1', 'split_test_cond1'))
|
||||
@ddt.unpack
|
||||
def test_child(self, user_tag, child_url_name):
|
||||
@@ -148,6 +153,12 @@ class SplitTestModuleTest(XModuleXmlImportTest):
|
||||
self.assertIsNotNone(fields.get('group_id_to_child'))
|
||||
self.assertEquals(len(children), 2)
|
||||
|
||||
|
||||
class SplitTestModuleStudioTest(SplitTestModuleTest):
|
||||
"""
|
||||
Unit tests for how split test interacts with Studio.
|
||||
"""
|
||||
|
||||
def test_render_studio_view(self):
|
||||
"""
|
||||
Test the rendering of the Studio view.
|
||||
@@ -177,10 +188,107 @@ class SplitTestModuleTest(XModuleXmlImportTest):
|
||||
self.assertNotIn('HTML FOR GROUP 0', html)
|
||||
self.assertNotIn('HTML FOR GROUP 1', html)
|
||||
|
||||
def test_settings(self):
|
||||
def test_editable_settings(self):
|
||||
"""
|
||||
Test the settings configuration.
|
||||
Test the setting information passed back from editable_metadata_fields.
|
||||
"""
|
||||
editable_metadata_fields = self.split_test_module.editable_metadata_fields
|
||||
self.assertIn(SplitTestDescriptor.display_name.name, editable_metadata_fields)
|
||||
self.assertNotIn(SplitTestDescriptor.due.name, editable_metadata_fields)
|
||||
self.assertNotIn(SplitTestDescriptor.user_partitions.name, editable_metadata_fields)
|
||||
|
||||
# user_partition_id will only appear in the editable settings if the value is the
|
||||
# default "unselected" value. This split instance has user_partition_id = 0, so
|
||||
# user_partition_id will not be editable.
|
||||
self.assertNotIn(SplitTestDescriptor.user_partition_id.name, editable_metadata_fields)
|
||||
|
||||
# Explicitly set user_partition_id to the default value. Now user_partition_id will be editable.
|
||||
self.split_test_module.user_partition_id = SplitTestFields.no_partition_selected['value']
|
||||
editable_metadata_fields = self.split_test_module.editable_metadata_fields
|
||||
self.assertIn(SplitTestDescriptor.user_partition_id.name, editable_metadata_fields)
|
||||
|
||||
def test_non_editable_settings(self):
|
||||
"""
|
||||
Test the settings that are marked as "non-editable".
|
||||
"""
|
||||
non_editable_metadata_fields = self.split_test_module.non_editable_metadata_fields
|
||||
self.assertIn(SplitTestDescriptor.due, non_editable_metadata_fields)
|
||||
self.assertIn(SplitTestDescriptor.user_partitions, non_editable_metadata_fields)
|
||||
self.assertNotIn(SplitTestDescriptor.display_name, non_editable_metadata_fields)
|
||||
|
||||
def test_available_partitions(self):
|
||||
"""
|
||||
Tests that the available partitions are populated correctly when editable_metadata_fields are called
|
||||
"""
|
||||
self.assertEqual([], SplitTestDescriptor.user_partition_id.values)
|
||||
|
||||
# user_partitions is empty, only the "Not Selected" item will appear.
|
||||
self.split_test_module.editable_metadata_fields # pylint: disable=pointless-statement
|
||||
partitions = SplitTestDescriptor.user_partition_id.values
|
||||
self.assertEqual(1, len(partitions))
|
||||
self.assertEqual(SplitTestFields.no_partition_selected['value'], partitions[0]['value'])
|
||||
|
||||
# Populate user_partitions and call editable_metadata_fields again
|
||||
self.split_test_module.user_partitions = [
|
||||
UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("1", 'beta')])
|
||||
]
|
||||
self.split_test_module.editable_metadata_fields # pylint: disable=pointless-statement
|
||||
partitions = SplitTestDescriptor.user_partition_id.values
|
||||
self.assertEqual(2, len(partitions))
|
||||
self.assertEqual(SplitTestFields.no_partition_selected['value'], partitions[0]['value'])
|
||||
self.assertEqual(0, partitions[1]['value'])
|
||||
self.assertEqual("first_partition", partitions[1]['display_name'])
|
||||
|
||||
def test_validation_message_types(self):
|
||||
"""
|
||||
Test the behavior of validation message types.
|
||||
"""
|
||||
self.assertEqual(ValidationMessageType.display_name(ValidationMessageType.error), u"Error")
|
||||
self.assertEqual(ValidationMessageType.display_name(ValidationMessageType.warning), u"Warning")
|
||||
self.assertIsNone(ValidationMessageType.display_name(ValidationMessageType.information))
|
||||
|
||||
def test_validation_messages(self):
|
||||
"""
|
||||
Test the validation messages produced for different split_test configurations.
|
||||
"""
|
||||
|
||||
def verify_validation_message(split_test_module, expected_message, expected_message_type):
|
||||
"""
|
||||
Verify that the module has the expected validation message and type.
|
||||
"""
|
||||
(message, message_type) = split_test_module.validation_message()
|
||||
self.assertEqual(message, expected_message)
|
||||
self.assertEqual(message_type, expected_message_type)
|
||||
|
||||
# Verify the message for an unconfigured experiment
|
||||
self.split_test_module.user_partition_id = -1
|
||||
verify_validation_message(self.split_test_module,
|
||||
u"You must select a group configuration for this content experiment.",
|
||||
ValidationMessageType.warning)
|
||||
|
||||
# Verify the message for a correctly configured experiment
|
||||
self.split_test_module.user_partition_id = 0
|
||||
self.split_test_module.user_partitions = [
|
||||
UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("1", 'beta')])
|
||||
]
|
||||
verify_validation_message(self.split_test_module,
|
||||
u"This content experiment uses group configuration 'first_partition'.",
|
||||
ValidationMessageType.information)
|
||||
|
||||
# Verify the message for a block with the wrong number of groups
|
||||
self.split_test_module.user_partitions = [
|
||||
UserPartition(0, 'first_partition', 'First Partition',
|
||||
[Group("0", 'alpha'), Group("1", 'beta'), Group("2", 'gamma')])
|
||||
]
|
||||
verify_validation_message(self.split_test_module,
|
||||
u"This content experiment is in an invalid state and cannot be repaired. "
|
||||
u"Please delete and recreate.",
|
||||
ValidationMessageType.error)
|
||||
|
||||
# Verify the message for a block referring to a non-existent experiment
|
||||
self.split_test_module.user_partition_id = 2
|
||||
verify_validation_message(self.split_test_module,
|
||||
u"This content experiment will not be shown to students because it refers "
|
||||
u"to a group configuration that has been deleted. "
|
||||
u"You can delete this experiment or reinstate the group configuration to repair it.",
|
||||
ValidationMessageType.error)
|
||||
@@ -35,6 +35,7 @@ from .video_utils import create_youtube_string
|
||||
from .video_xfields import VideoFields
|
||||
from .video_handlers import VideoStudentViewHandlers, VideoStudioViewHandlers
|
||||
|
||||
from xmodule.video_module import manage_video_subtitles_save
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
_ = lambda text: text
|
||||
@@ -224,6 +225,17 @@ class VideoDescriptor(VideoFields, VideoStudioViewHandlers, TabsEditingDescripto
|
||||
if not download_track['explicitly_set'] and self.track:
|
||||
self.download_track = True
|
||||
|
||||
def editor_saved(self, user, old_metadata, old_content):
|
||||
"""
|
||||
Used to update video subtitles.
|
||||
"""
|
||||
manage_video_subtitles_save(
|
||||
self,
|
||||
user,
|
||||
old_metadata if old_metadata else None,
|
||||
generate_translation=True
|
||||
)
|
||||
|
||||
def save_with_metadata(self, user):
|
||||
"""
|
||||
Save module with updated metadata to database."
|
||||
|
||||
@@ -771,6 +771,24 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock):
|
||||
"""
|
||||
raise NotImplementedError('Modules must implement export_to_xml to enable xml export')
|
||||
|
||||
def editor_saved(self, user, old_metadata, old_content):
|
||||
"""
|
||||
This method is called when "Save" is pressed on the Studio editor.
|
||||
|
||||
Note that after this method is called, the modulestore update_item method will
|
||||
be called on this xmodule. Therefore, any modifications to the xmodule that are
|
||||
performed in editor_saved will automatically be persisted (implementors of this method
|
||||
should not call update_item themselves).
|
||||
|
||||
Args:
|
||||
user: the user who requested the save (as obtained from the request)
|
||||
old_metadata (dict): the values of the fields with Scope.settings before the save was performed
|
||||
old_content (dict): the values of the fields with Scope.content before the save was performed.
|
||||
This will include 'data'.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
# =============================== BUILTIN METHODS ==========================
|
||||
def __eq__(self, other):
|
||||
return (self.scope_ids == other.scope_ids and
|
||||
@@ -797,7 +815,6 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock):
|
||||
# We are not allowing editing of xblock tag and name fields at this time (for any component).
|
||||
return [XBlock.tags, XBlock.name]
|
||||
|
||||
|
||||
@property
|
||||
def editable_metadata_fields(self):
|
||||
"""
|
||||
@@ -805,6 +822,24 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock):
|
||||
|
||||
Can be limited by extending `non_editable_metadata_fields`.
|
||||
"""
|
||||
metadata_fields = {}
|
||||
|
||||
# Only use the fields from this class, not mixins
|
||||
fields = getattr(self, 'unmixed_class', self.__class__).fields
|
||||
|
||||
for field in fields.values():
|
||||
|
||||
if field.scope != Scope.settings or field in self.non_editable_metadata_fields:
|
||||
continue
|
||||
|
||||
metadata_fields[field.name] = self._create_metadata_editor_info(field)
|
||||
|
||||
return metadata_fields
|
||||
|
||||
def _create_metadata_editor_info(self, field):
|
||||
"""
|
||||
Creates the information needed by the metadata editor for a specific field.
|
||||
"""
|
||||
def jsonify_value(field, json_choice):
|
||||
if isinstance(json_choice, dict):
|
||||
json_choice = dict(json_choice) # make a copy so below doesn't change the original
|
||||
@@ -823,46 +858,36 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock):
|
||||
else:
|
||||
return self.runtime.service(self, "i18n").ugettext(value)
|
||||
|
||||
metadata_fields = {}
|
||||
# gets the 'default_value' and 'explicitly_set' attrs
|
||||
metadata_field_editor_info = self.runtime.get_field_provenance(self, field)
|
||||
metadata_field_editor_info['field_name'] = field.name
|
||||
metadata_field_editor_info['display_name'] = get_text(field.display_name)
|
||||
metadata_field_editor_info['help'] = get_text(field.help)
|
||||
metadata_field_editor_info['value'] = field.read_json(self)
|
||||
|
||||
# Only use the fields from this class, not mixins
|
||||
fields = getattr(self, 'unmixed_class', self.__class__).fields
|
||||
# We support the following editors:
|
||||
# 1. A select editor for fields with a list of possible values (includes Booleans).
|
||||
# 2. Number editors for integers and floats.
|
||||
# 3. A generic string editor for anything else (editing JSON representation of the value).
|
||||
editor_type = "Generic"
|
||||
values = field.values
|
||||
if isinstance(values, (tuple, list)) and len(values) > 0:
|
||||
editor_type = "Select"
|
||||
values = [jsonify_value(field, json_choice) for json_choice in values]
|
||||
elif isinstance(field, Integer):
|
||||
editor_type = "Integer"
|
||||
elif isinstance(field, Float):
|
||||
editor_type = "Float"
|
||||
elif isinstance(field, List):
|
||||
editor_type = "List"
|
||||
elif isinstance(field, Dict):
|
||||
editor_type = "Dict"
|
||||
elif isinstance(field, RelativeTime):
|
||||
editor_type = "RelativeTime"
|
||||
metadata_field_editor_info['type'] = editor_type
|
||||
metadata_field_editor_info['options'] = [] if values is None else values
|
||||
|
||||
for field in fields.values():
|
||||
|
||||
if field.scope != Scope.settings or field in self.non_editable_metadata_fields:
|
||||
continue
|
||||
|
||||
# gets the 'default_value' and 'explicitly_set' attrs
|
||||
metadata_fields[field.name] = self.runtime.get_field_provenance(self, field)
|
||||
metadata_fields[field.name]['field_name'] = field.name
|
||||
metadata_fields[field.name]['display_name'] = get_text(field.display_name)
|
||||
metadata_fields[field.name]['help'] = get_text(field.help)
|
||||
metadata_fields[field.name]['value'] = field.read_json(self)
|
||||
|
||||
# We support the following editors:
|
||||
# 1. A select editor for fields with a list of possible values (includes Booleans).
|
||||
# 2. Number editors for integers and floats.
|
||||
# 3. A generic string editor for anything else (editing JSON representation of the value).
|
||||
editor_type = "Generic"
|
||||
values = field.values
|
||||
if isinstance(values, (tuple, list)) and len(values) > 0:
|
||||
editor_type = "Select"
|
||||
values = [jsonify_value(field, json_choice) for json_choice in values]
|
||||
elif isinstance(field, Integer):
|
||||
editor_type = "Integer"
|
||||
elif isinstance(field, Float):
|
||||
editor_type = "Float"
|
||||
elif isinstance(field, List):
|
||||
editor_type = "List"
|
||||
elif isinstance(field, Dict):
|
||||
editor_type = "Dict"
|
||||
elif isinstance(field, RelativeTime):
|
||||
editor_type = "RelativeTime"
|
||||
metadata_fields[field.name]['type'] = editor_type
|
||||
metadata_fields[field.name]['options'] = [] if values is None else values
|
||||
|
||||
return metadata_fields
|
||||
return metadata_field_editor_info
|
||||
|
||||
# ~~~~~~~~~~~~~~~ XModule Indirection ~~~~~~~~~~~~~~~~
|
||||
@property
|
||||
|
||||
@@ -28,8 +28,7 @@ class UnitPage(PageObject):
|
||||
def _is_finished_loading():
|
||||
# Wait until all components have been loaded
|
||||
number_of_leaf_xblocks = len(self.q(css='{} .xblock-student_view'.format(Component.BODY_SELECTOR)).results)
|
||||
number_of_container_xblocks = len(self.q(css='{} .wrapper-xblock'.format(Component.BODY_SELECTOR)).results)
|
||||
is_done = len(self.q(css=Component.BODY_SELECTOR).results) == number_of_leaf_xblocks + number_of_container_xblocks
|
||||
is_done = len(self.q(css=Component.BODY_SELECTOR).results) == number_of_leaf_xblocks
|
||||
return (is_done, is_done)
|
||||
|
||||
# First make sure that an element with the view-unit class is present on the page,
|
||||
|
||||
47
lms/templates/split_test_studio_header.html
Normal file
47
lms/templates/split_test_studio_header.html
Normal file
@@ -0,0 +1,47 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%! from xmodule.split_test_module import ValidationMessageType %>
|
||||
|
||||
<%
|
||||
split_test = context.get('split_test')
|
||||
(message, message_type) = split_test.descriptor.validation_message()
|
||||
message_type_display_name = ValidationMessageType.display_name(message_type) if message_type else None
|
||||
is_configured = split_test.user_partition_id >= 0
|
||||
%>
|
||||
|
||||
% if message or not is_configured:
|
||||
% if is_root and not is_configured:
|
||||
<div class="no-container-content">
|
||||
% else:
|
||||
<div class="wrapper-xblock-message">
|
||||
<div class="xblock-message ${message_type}">
|
||||
% endif
|
||||
|
||||
% if not is_configured:
|
||||
<p><i class="icon-warning-sign"></i> ${_("You must select a group configuration for this content experiment.")}
|
||||
<a href="#" class="button edit-button action-button">
|
||||
<i class="icon-pencil"></i> <span class="action-button-text">${_("Select a Group Configuration")}</span>
|
||||
</a>
|
||||
</p>
|
||||
% else:
|
||||
<p>
|
||||
% if message_type == 'warning':
|
||||
<i class='icon-warning-sign'></i>
|
||||
% elif message_type == 'error':
|
||||
<i class='icon-exclamation-sign'></i>
|
||||
% endif
|
||||
<span class='message-text'>
|
||||
% if message_type_display_name:
|
||||
<span class='sr'>${message_type_display_name}:</span>
|
||||
% endif
|
||||
${message}
|
||||
</span>
|
||||
</p>
|
||||
% endif
|
||||
|
||||
% if is_root and not is_configured:
|
||||
</div>
|
||||
% else:
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
% endif
|
||||
Reference in New Issue
Block a user