From 5ab61fc4b2604e6988220af45d3b2a6ad4661c66 Mon Sep 17 00:00:00 2001 From: cahrens Date: Wed, 28 May 2014 11:33:44 -0400 Subject: [PATCH] Implement a custom editor for the split_module. STUD-1529 --- .../contentstore/tests/test_contentstore.py | 2 +- cms/djangoapps/contentstore/tests/utils.py | 1 - .../contentstore/views/component.py | 7 +- cms/djangoapps/contentstore/views/item.py | 8 +- .../contentstore/views/tests/test_item.py | 138 ++++++++++++- .../coffee/src/views/module_edit.coffee | 4 +- .../js/spec/views/pages/container_spec.js | 10 - cms/static/js/views/pages/container.js | 13 +- cms/static/sass/elements/_xblocks.scss | 53 +++++ cms/static/sass/views/_container.scss | 28 ++- cms/static/sass/views/_unit.scss | 38 +++- cms/templates/container.html | 3 - cms/templates/container_xblock_component.html | 3 + cms/templates/studio_xblock_wrapper.html | 6 +- cms/templates/widgets/split-edit.html | 12 ++ common/lib/xmodule/xmodule/course_module.py | 19 -- .../xmodule/xmodule/css/split_test/edit.scss | 11 ++ .../xmodule/modulestore/inheritance.py | 20 +- .../lib/xmodule/xmodule/split_test_module.py | 181 +++++++++++++++++- ...it_module.py => test_split_test_module.py} | 120 +++++++++++- .../xmodule/video_module/video_module.py | 12 ++ common/lib/xmodule/xmodule/x_module.py | 103 ++++++---- common/test/acceptance/pages/studio/unit.py | 3 +- lms/templates/split_test_studio_header.html | 47 +++++ 24 files changed, 717 insertions(+), 125 deletions(-) create mode 100644 cms/templates/widgets/split-edit.html create mode 100644 common/lib/xmodule/xmodule/css/split_test/edit.scss rename common/lib/xmodule/xmodule/tests/{test_split_module.py => test_split_test_module.py} (56%) create mode 100644 lms/templates/split_test_studio_header.html diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 79a4a4178d..b4326954ba 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -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): diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py index d648942023..3d81ca6c7b 100644 --- a/cms/djangoapps/contentstore/tests/utils.py +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -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 diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 6de17e20fe..38b45f785a 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -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' diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 714a77bb9a..f6ef0ab61c 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -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) diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index dd11ef0d06..443960359c 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -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): diff --git a/cms/static/coffee/src/views/module_edit.coffee b/cms/static/coffee/src/views/module_edit.coffee index b4fc7b1787..b839ade446 100644 --- a/cms/static/coffee/src/views/module_edit.coffee +++ b/cms/static/coffee/src/views/module_edit.coffee @@ -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 diff --git a/cms/static/js/spec/views/pages/container_spec.js b/cms/static/js/spec/views/pages/container_spec.js index 130b945ee4..d30521c4d5 100644 --- a/cms/static/js/spec/views/pages/container_spec.js +++ b/cms/static/js/spec/views/pages/container_spec.js @@ -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", diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 4a1d7fb54a..414a040e9b 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -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(); diff --git a/cms/static/sass/elements/_xblocks.scss b/cms/static/sass/elements/_xblocks.scss index 5fbb582658..2602bcca61 100644 --- a/cms/static/sass/elements/_xblocks.scss +++ b/cms/static/sass/elements/_xblocks.scss @@ -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; + } + } + } +} diff --git a/cms/static/sass/views/_container.scss b/cms/static/sass/views/_container.scss index e2f6a8ae29..299c5ff731 100644 --- a/cms/static/sass/views/_container.scss +++ b/cms/static/sass/views/_container.scss @@ -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 diff --git a/cms/static/sass/views/_unit.scss b/cms/static/sass/views/_unit.scss index 2f8b3d664b..ce28a6d263 100644 --- a/cms/static/sass/views/_unit.scss +++ b/cms/static/sass/views/_unit.scss @@ -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; } } } diff --git a/cms/templates/container.html b/cms/templates/container.html index c19dbefc74..0062d91628 100644 --- a/cms/templates/container.html +++ b/cms/templates/container.html @@ -94,9 +94,6 @@ main_xblock_info = {
-

${_("Loading...")}

diff --git a/cms/templates/container_xblock_component.html b/cms/templates/container_xblock_component.html index 13ee1c0721..b63da494ff 100644 --- a/cms/templates/container_xblock_component.html +++ b/cms/templates/container_xblock_component.html @@ -42,3 +42,6 @@ from contentstore.views.helpers import xblock_studio_url +
+${preview} +
diff --git a/cms/templates/studio_xblock_wrapper.html b/cms/templates/studio_xblock_wrapper.html index 5e2d6a9364..e9c9450bc1 100644 --- a/cms/templates/studio_xblock_wrapper.html +++ b/cms/templates/studio_xblock_wrapper.html @@ -80,8 +80,12 @@ collapsible_class = "is-collapsible" if xblock.has_children else "" % if is_root or not xblock_url:
-${content} + ${content}
+% else: +
+ ${content} +
% endif % if not is_root: diff --git a/cms/templates/widgets/split-edit.html b/cms/templates/widgets/split-edit.html new file mode 100644 index 0000000000..3ac72ac12f --- /dev/null +++ b/cms/templates/widgets/split-edit.html @@ -0,0 +1,12 @@ +<%! from django.utils.translation import ugettext as _ %> +<%include file="metadata-edit.html" /> +% if disable_user_partition_editing: +
+ % if not selected_partition: +

${_("This content experiment refers to a group configuration that has been deleted.")}

+ % else: +

${_("This content experiment uses group configuration '{0}'.".format(""+str(selected_partition.name)+""))}

+ % endif +

${_("After you select the group configuration and save the content experiment, you cannot change this setting.")}

+
+% endif diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 478b69a194..736faefe59 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -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) diff --git a/common/lib/xmodule/xmodule/css/split_test/edit.scss b/common/lib/xmodule/xmodule/css/split_test/edit.scss new file mode 100644 index 0000000000..adbf3ad7c6 --- /dev/null +++ b/common/lib/xmodule/xmodule/css/split_test/edit.scss @@ -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; +} diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index 459d008ebd..54f102a939 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -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): diff --git a/common/lib/xmodule/xmodule/split_test_module.py b/common/lib/xmodule/xmodule/split_test_module.py index f0c7d67240..90969c1742 100644 --- a/common/lib/xmodule/xmodule/split_test_module.py +++ b/common/lib/xmodule/xmodule/split_test_module.py @@ -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 diff --git a/common/lib/xmodule/xmodule/tests/test_split_module.py b/common/lib/xmodule/xmodule/tests/test_split_test_module.py similarity index 56% rename from common/lib/xmodule/xmodule/tests/test_split_module.py rename to common/lib/xmodule/xmodule/tests/test_split_test_module.py index 35a7f7b595..e9eee2cef6 100644 --- a/common/lib/xmodule/xmodule/tests/test_split_module.py +++ b/common/lib/xmodule/xmodule/tests/test_split_test_module.py @@ -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) diff --git a/common/lib/xmodule/xmodule/video_module/video_module.py b/common/lib/xmodule/xmodule/video_module/video_module.py index 954ab09747..433a43ed41 100644 --- a/common/lib/xmodule/xmodule/video_module/video_module.py +++ b/common/lib/xmodule/xmodule/video_module/video_module.py @@ -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." diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 97aa615b8e..cc4a2e37b8 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -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 diff --git a/common/test/acceptance/pages/studio/unit.py b/common/test/acceptance/pages/studio/unit.py index 94af355980..8e89a0d556 100644 --- a/common/test/acceptance/pages/studio/unit.py +++ b/common/test/acceptance/pages/studio/unit.py @@ -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, diff --git a/lms/templates/split_test_studio_header.html b/lms/templates/split_test_studio_header.html new file mode 100644 index 0000000000..a63182640c --- /dev/null +++ b/lms/templates/split_test_studio_header.html @@ -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: +
+ % else: +
+
+ % endif + + % if not is_configured: +

${_("You must select a group configuration for this content experiment.")} + + ${_("Select a Group Configuration")} + +

+ % else: +

+ % if message_type == 'warning': + + % elif message_type == 'error': + + % endif + + % if message_type_display_name: + ${message_type_display_name}: + % endif + ${message} + +

+ % endif + + % if is_root and not is_configured: +
+ % else: +
+
+ % endif +% endif