+ ${_("This module is disabled at the moment.")} +
+${_("Loading...")}
+diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 2499ff9123..cd1405bed2 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -43,6 +43,7 @@ log = logging.getLogger(__name__) # NOTE: unit_handler assumes this list is disjoint from ADVANCED_COMPONENT_TYPES COMPONENT_TYPES = ['discussion', 'html', 'problem', 'video'] +SPLIT_TEST_COMPONENT_TYPE = 'split_test' OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"] NOTE_COMPONENT_TYPES = ['notes'] @@ -61,11 +62,11 @@ else: # XBlocks from pmitros repos are prototypes. They should not be used # except for edX Learning Sciences experiments on edge.edx.org without # further work to make them robust, maintainable, finalize data formats, - # etc. + # 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 - 'split_test' + SPLIT_TEST_COMPONENT_TYPE, # Adds A/B test support ] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES ADVANCED_COMPONENT_CATEGORY = 'advanced' diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 8935013e97..4ff410fecb 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -44,7 +44,8 @@ from .access import has_course_access from .component import ( OPEN_ENDED_COMPONENT_TYPES, NOTE_COMPONENT_TYPES, - ADVANCED_COMPONENT_POLICY_KEY + ADVANCED_COMPONENT_POLICY_KEY, + SPLIT_TEST_COMPONENT_TYPE, ) from django_comment_common.models import assign_default_role @@ -65,7 +66,8 @@ __all__ = ['course_info_handler', 'course_handler', 'course_info_update_handler' 'settings_handler', 'grading_handler', 'advanced_settings_handler', - 'textbooks_list_handler', 'textbooks_detail_handler'] + 'textbooks_list_handler', 'textbooks_detail_handler', + 'group_configurations_list_handler'] class AccessListFallback(Exception): @@ -267,7 +269,6 @@ def course_index(request, course_key): lms_link = get_lms_link_for_item(course_module.location) sections = course_module.get_children() - return render_to_response('overview.html', { 'context_course': course_module, 'lms_link': lms_link, @@ -604,7 +605,7 @@ def _config_course_advanced_components(request, course_module): # Indicate that tabs should not be filtered out of # the metadata filter_tabs = False # Set this flag to avoid the tab removal code below. - found_ac_type = True #break + found_ac_type = True # break # If we did not find a module type in the advanced settings, # we may need to remove the tab from the course. @@ -854,6 +855,28 @@ def textbooks_detail_handler(request, course_key_string, textbook_id): return JsonResponse() +@require_http_methods(("GET")) +@login_required +@ensure_csrf_cookie +def group_configurations_list_handler(request, course_key_string): + """ + A RESTful handler for Group Configurations + + GET + html: return Group Configurations list page (Backbone application) + """ + course_key = CourseKey.from_string(course_key_string) + course = _get_course_module(course_key, request.user) + group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key) + splite_test_enabled = SPLIT_TEST_COMPONENT_TYPE in course.advanced_modules + + return render_to_response('group_configurations.html', { + 'context_course': course, + 'group_configuration_url': group_configuration_url, + 'configurations': [u.to_json() for u in course.user_partitions] if splite_test_enabled else None, + }) + + def _get_course_creator_status(user): """ Helper method for returning the course creator status for a particular user, diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index 7bb31fcd10..4c4e2134ab 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -12,9 +12,12 @@ from django.http import Http404 from django.test import TestCase from django.test.client import RequestFactory from django.core.urlresolvers import reverse -from contentstore.utils import reverse_usage_url +from contentstore.utils import reverse_usage_url, reverse_course_url -from contentstore.views.component import component_handler, get_component_templates +from contentstore.views.component import ( + component_handler, get_component_templates, + SPLIT_TEST_COMPONENT_TYPE +) from contentstore.tests.utils import CourseTestCase from student.tests.factories import UserFactory @@ -824,7 +827,7 @@ class TestEditSplitModule(ItemTest): def test_create_groups(self): """ - Test that verticals are created for the experiment groups when + Test that verticals are created for the configuration groups when a spit test module is edited. """ split_test = self.get_item_from_modulestore(self.split_test_usage_key, verify_is_draft=True) @@ -851,15 +854,16 @@ class TestEditSplitModule(ItemTest): def test_change_user_partition_id(self): """ - Test what happens when the user_partition_id is changed to a different experiment. + Test what happens when the user_partition_id is changed to a different groups + group configuration. """ - # Set to first experiment. + # Set to first group configuration. 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 + # Set to second group configuration split_test = self._update_partition_id(1) # We don't remove existing children. self.assertEqual(5, len(split_test.children)) @@ -881,12 +885,12 @@ class TestEditSplitModule(ItemTest): """ Test that nothing happens when the user_partition_id is set to the same value twice. """ - # Set to first experiment. + # Set to first group configuration. 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. + # Set again to first group configuration. 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) @@ -897,12 +901,12 @@ class TestEditSplitModule(ItemTest): The user_partition_id will be updated, but children and group_id_to_child map will not change. """ - # Set to first experiment. + # Set to first group configuration. 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. + # Set to an group configuration 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) @@ -913,7 +917,7 @@ class TestEditSplitModule(ItemTest): Also test that deleting a child not in the group_id_to_child_map behaves properly. """ - # Set to first experiment. + # Set to first group configuration. self._update_partition_id(0) split_test = self._assert_children(2) vertical_1_usage_key = split_test.children[1] @@ -981,6 +985,34 @@ class TestEditSplitModule(ItemTest): split_test = self._assert_children(3) self.assertEqual(group_id_to_child, split_test.group_id_to_child) + def test_view_index_ok(self): + """ + Basic check that the groups configuration page responds correctly. + """ + if SPLIT_TEST_COMPONENT_TYPE not in self.course.advanced_modules: + self.course.advanced_modules.append(SPLIT_TEST_COMPONENT_TYPE) + self.store.update_item(self.course, self.user.id) + + url = reverse_course_url('group_configurations_list_handler', self.course.id) + resp = self.client.get(url) + self.assertContains(resp, self.course.display_name) + self.assertContains(resp, 'First Partition') + self.assertContains(resp, 'alpha') + self.assertContains(resp, 'Second Partition') + self.assertContains(resp, 'Group 1') + + def test_view_index_disabled(self): + """ + Check that group configuration page is not displayed when turned off. + """ + if SPLIT_TEST_COMPONENT_TYPE in self.course.advanced_modules: + self.course.advanced_modules.remove(SPLIT_TEST_COMPONENT_TYPE) + self.store.update_item(self.course, self.user.id) + + url = reverse_course_url('group_configurations_list_handler', self.course.id) + resp = self.client.get(url) + self.assertContains(resp, "module is disabled") + @ddt.ddt class TestComponentHandler(TestCase): diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee index e55a28dda3..4ed431b066 100644 --- a/cms/static/coffee/spec/main.coffee +++ b/cms/static/coffee/spec/main.coffee @@ -214,6 +214,7 @@ define([ "js/spec/models/component_template_spec", "js/spec/models/explicit_url_spec", + "js/spec/models/group_configuration_spec", "js/spec/utils/drag_and_drop_spec", "js/spec/utils/handle_iframe_binding_spec", @@ -229,10 +230,13 @@ define([ "js/spec/views/xblock_editor_spec", "js/spec/views/pages/container_spec", + "js/spec/views/pages/group_configurations_spec", "js/spec/views/modals/base_modal_spec", "js/spec/views/modals/edit_xblock_spec", + "js/spec/views/group_configuration_spec", + "js/spec/xblock/cms.runtime.v1_spec", # these tests are run separately in the cms-squire suite, due to process diff --git a/cms/static/js/collections/group.js b/cms/static/js/collections/group.js new file mode 100644 index 0000000000..5ebcdc6edb --- /dev/null +++ b/cms/static/js/collections/group.js @@ -0,0 +1,20 @@ +define([ + 'backbone', 'js/models/group' +], +function (Backbone, GroupModel) { + 'use strict'; + var GroupCollection = Backbone.Collection.extend({ + model: GroupModel, + /** + * Indicates if the collection is empty when all the models are empty + * or the collection does not include any models. + **/ + isEmpty: function() { + return this.length === 0 || this.every(function(m) { + return m.isEmpty(); + }); + } + }); + + return GroupCollection; +}); diff --git a/cms/static/js/collections/group_configuration.js b/cms/static/js/collections/group_configuration.js new file mode 100644 index 0000000000..e322995d76 --- /dev/null +++ b/cms/static/js/collections/group_configuration.js @@ -0,0 +1,11 @@ +define([ + 'backbone', 'js/models/group_configuration' +], +function(Backbone, GroupConfigurationModel) { + 'use strict'; + var GroupConfigurationCollection = Backbone.Collection.extend({ + model: GroupConfigurationModel + }); + + return GroupConfigurationCollection; +}); diff --git a/cms/static/js/models/group.js b/cms/static/js/models/group.js new file mode 100644 index 0000000000..d87c414873 --- /dev/null +++ b/cms/static/js/models/group.js @@ -0,0 +1,29 @@ +define([ + 'backbone', 'gettext', 'backbone.associations' +], function(Backbone, gettext) { + 'use strict'; + var Group = Backbone.AssociatedModel.extend({ + defaults: function() { + return { name: '' }; + }, + + isEmpty: function() { + return !this.get('name'); + }, + + toJSON: function() { + return { name: this.get('name') }; + }, + + validate: function(attrs) { + if (!attrs.name) { + return { + message: gettext('Group name is required'), + attributes: { name: true } + }; + } + } + }); + + return Group; +}); diff --git a/cms/static/js/models/group_configuration.js b/cms/static/js/models/group_configuration.js new file mode 100644 index 0000000000..178c0a432f --- /dev/null +++ b/cms/static/js/models/group_configuration.js @@ -0,0 +1,87 @@ +define([ + 'backbone', 'underscore', 'gettext', 'js/models/group', + 'js/collections/group', 'backbone.associations', 'coffee/src/main' +], +function(Backbone, _, gettext, GroupModel, GroupCollection) { + 'use strict'; + var GroupConfiguration = Backbone.AssociatedModel.extend({ + defaults: function() { + return { + id: null, + name: '', + description: '', + groups: new GroupCollection([{}, {}]), + showGroups: false + }; + }, + + relations: [{ + type: Backbone.Many, + key: 'groups', + relatedModel: GroupModel, + collectionType: GroupCollection + }], + + initialize: function() { + this.setOriginalAttributes(); + return this; + }, + + setOriginalAttributes: function() { + this._originalAttributes = this.toJSON(); + }, + + reset: function() { + this.set(this._originalAttributes); + }, + + isDirty: function() { + return !_.isEqual( + this._originalAttributes, this.toJSON() + ); + }, + + isEmpty: function() { + return !this.get('name') && this.get('groups').isEmpty(); + }, + + toJSON: function() { + return { + id: this.get('id'), + name: this.get('name'), + description: this.get('description'), + groups: this.get('groups').toJSON() + }; + }, + + validate: function(attrs) { + if (!attrs.name) { + return { + message: gettext('Group Configuration name is required'), + attributes: {name: true} + }; + } + if (attrs.groups.length === 0) { + return { + message: gettext('Please add at least one group'), + attributes: {groups: true} + }; + } else { + // validate all groups + var invalidGroups = []; + attrs.groups.each(function(group) { + if(!group.isValid()) { + invalidGroups.push(group); + } + }); + if (!_.isEmpty(invalidGroups)) { + return { + message: gettext('All groups must have a name'), + attributes: {groups: invalidGroups} + }; + } + } + } + }); + return GroupConfiguration; +}); diff --git a/cms/static/js/spec/models/group_configuration_spec.js b/cms/static/js/spec/models/group_configuration_spec.js new file mode 100644 index 0000000000..6ca7e8f6d1 --- /dev/null +++ b/cms/static/js/spec/models/group_configuration_spec.js @@ -0,0 +1,228 @@ +define([ + 'backbone', 'js/models/group_configuration', + 'js/collections/group_configuration', 'js/models/group', + 'js/collections/group', 'coffee/src/main' +], function( + Backbone, GroupConfiguration, GroupConfigurationSet, Group, GroupSet, main +) { + 'use strict'; + beforeEach(function() { + this.addMatchers({ + toBeInstanceOf: function(expected) { + return this.actual instanceof expected; + } + }); + }); + + describe('GroupConfiguration model', function() { + beforeEach(function() { + main(); + this.model = new GroupConfiguration(); + }); + + describe('Basic', function() { + it('should have an empty name by default', function() { + expect(this.model.get('name')).toEqual(''); + }); + + it('should have an empty description by default', function() { + expect(this.model.get('description')).toEqual(''); + }); + + it('should not show groups by default', function() { + expect(this.model.get('showGroups')).toBeFalsy(); + }); + + it('should have a GroupSet with two groups by default', function() { + var groups = this.model.get('groups'); + + expect(groups).toBeInstanceOf(GroupSet); + expect(groups.length).toEqual(2); + expect(groups.at(0).isEmpty()).toBeTruthy(); + expect(groups.at(1).isEmpty()).toBeTruthy(); + }); + + it('should be empty by default', function() { + expect(this.model.isEmpty()).toBeTruthy(); + }); + + it('should be able to reset itself', function() { + this.model.set('name', 'foobar'); + this.model.reset(); + + expect(this.model.get('name')).toEqual(''); + }); + + it('should not be dirty by default', function() { + expect(this.model.isDirty()).toBeFalsy(); + }); + + it('should be dirty after it\'s been changed', function() { + this.model.set('name', 'foobar'); + + expect(this.model.isDirty()).toBeTruthy(); + }); + + it('should not be dirty after calling setOriginalAttributes', function() { + this.model.set('name', 'foobar'); + this.model.setOriginalAttributes(); + + expect(this.model.isDirty()).toBeFalsy(); + }); + }); + + describe('Input/Output', function() { + var deepAttributes = function(obj) { + if (obj instanceof Backbone.Model) { + return deepAttributes(obj.attributes); + } else if (obj instanceof Backbone.Collection) { + return obj.map(deepAttributes); + } else if (_.isArray(obj)) { + return _.map(obj, deepAttributes); + } else if (_.isObject(obj)) { + var attributes = {}; + + for (var prop in obj) { + if (obj.hasOwnProperty(prop)) { + attributes[prop] = deepAttributes(obj[prop]); + } + } + return attributes; + } else { + return obj; + } + }; + + it('should match server model to client model', function() { + var serverModelSpec = { + 'id': 10, + 'name': 'My GroupConfiguration', + 'description': 'Some description', + 'groups': [ + { + 'name': 'Group 1' + }, { + 'name': 'Group 2' + } + ] + }, + clientModelSpec = { + 'id': 10, + 'name': 'My GroupConfiguration', + 'description': 'Some description', + 'showGroups': false, + 'groups': [ + { + 'name': 'Group 1' + }, { + 'name': 'Group 2' + } + ] + }, + model = new GroupConfiguration(serverModelSpec); + + expect(deepAttributes(model)).toEqual(clientModelSpec); + expect(model.toJSON()).toEqual(serverModelSpec); + }); + }); + + describe('Validation', function() { + it('requires a name', function() { + var model = new GroupConfiguration({ name: '' }); + + expect(model.isValid()).toBeFalsy(); + }); + + it('requires at least one group', function() { + var model = new GroupConfiguration({ name: 'foo' }); + model.get('groups').reset(); + + expect(model.isValid()).toBeFalsy(); + }); + + it('requires a valid group', function() { + var group = new Group(), + model = new GroupConfiguration({ name: 'foo' }); + + group.isValid = function() { return false; }; + model.get('groups').reset([group]); + + expect(model.isValid()).toBeFalsy(); + }); + + it('requires all groups to be valid', function() { + var group1 = new Group(), + group2 = new Group(), + model = new GroupConfiguration({ name: 'foo' }); + + group1.isValid = function() { return true; }; + group2.isValid = function() { return false; }; + model.get('groups').reset([group1, group2]); + + expect(model.isValid()).toBeFalsy(); + }); + + it('can pass validation', function() { + var group = new Group(), + model = new GroupConfiguration({ name: 'foo' }); + + group.isValid = function() { return true; }; + model.get('groups').reset([group]); + + expect(model.isValid()).toBeTruthy(); + }); + }); + }); + + describe('Group model', function() { + beforeEach(function() { + this.model = new Group(); + }); + + describe('Basic', function() { + it('should have a name by default', function() { + expect(this.model.get('name')).toEqual(''); + }); + + it('should be empty by default', function() { + expect(this.model.isEmpty()).toBeTruthy(); + }); + }); + + describe('Validation', function() { + it('requires a name', function() { + var model = new Group({ name: '' }); + + expect(model.isValid()).toBeFalsy(); + }); + + it('can pass validation', function() { + var model = new Group({ name: 'a' }); + + expect(model.isValid()).toBeTruthy(); + }); + }); + }); + + describe('Group collection', function() { + beforeEach(function() { + this.collection = new GroupSet(); + }); + + it('is empty by default', function() { + expect(this.collection.isEmpty()).toBeTruthy(); + }); + + it('is empty if all groups are empty', function() { + this.collection.add([{}, {}, {}]); + + expect(this.collection.isEmpty()).toBeTruthy(); + }); + + it('is not empty if a group is not empty', function() { + this.collection.add([{}, { name: 'full' }, {} ]); + + expect(this.collection.isEmpty()).toBeFalsy(); + }); + }); +}); diff --git a/cms/static/js/spec/views/group_configuration_spec.js b/cms/static/js/spec/views/group_configuration_spec.js new file mode 100644 index 0000000000..ade63adaf6 --- /dev/null +++ b/cms/static/js/spec/views/group_configuration_spec.js @@ -0,0 +1,145 @@ +define([ + 'js/models/group_configuration', 'js/models/course', + 'js/collections/group_configuration', 'js/views/group_configuration_details', + 'js/views/group_configurations_list', 'jasmine-stealth' +], function( + GroupConfigurationModel, Course, GroupConfigurationSet, + GroupConfigurationDetails, GroupConfigurationsList +) { + 'use strict'; + beforeEach(function() { + window.course = new Course({ + id: '5', + name: 'Course Name', + url_name: 'course_name', + org: 'course_org', + num: 'course_num', + revision: 'course_rev' + }); + + this.addMatchers({ + toContainText: function(text) { + var trimmedText = $.trim(this.actual.text()); + + if (text && $.isFunction(text.test)) { + return text.test(trimmedText); + } else { + return trimmedText.indexOf(text) !== -1; + } + } + }); + }); + + afterEach(function() { + delete window.course; + }); + + describe('GroupConfigurationDetails', function() { + var tpl = readFixtures('group-configuration-details.underscore'); + + beforeEach(function() { + setFixtures($(' +% endfor +%block> + +<%block name="jsextra"> + +%block> + +<%block name="content"> +
+ ${_("This module is disabled at the moment.")} +
+${_("Loading...")}
+${_("Loading...")}
+<%= gettext("You haven't created any group configurations yet.") %>
+