Commit 5ab61fc4 by cahrens Committed by Andy Armstrong

Implement a custom editor for the split_module.

STUD-1529
parent 2eae8b83
......@@ -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:
......
<%! 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)
......
.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):
......
......@@ -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 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 configuration.
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 = {}
# 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
# 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
# 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)
# 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
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,
......
<%! 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
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment