Commit 34ac6abe by Tim Babych

Merge pull request #4316 from edx/anton/group-experiments

BLD-1117, BLD-1217: Add read-only list of Group Configurations, Create feature flag.
parents 1bc48af7 453c5837
......@@ -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']
......@@ -65,7 +66,7 @@ else:
'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'
......
......@@ -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,
......
......@@ -6,15 +6,20 @@ import ddt
from mock import patch
from pytz import UTC
from unittest import skipUnless
from webob import Response
from django.conf import settings
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
......@@ -773,6 +778,7 @@ class TestEditItem(ItemTest):
self.verify_publish_state(html_usage_key, PublishState.draft)
@skipUnless(settings.FEATURES.get('ENABLE_GROUP_CONFIGURATIONS'), 'Tests Group Configurations feature')
class TestEditSplitModule(ItemTest):
"""
Tests around editing instances of the split_test module.
......@@ -827,7 +833,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)
......@@ -854,15 +860,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))
......@@ -884,12 +891,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)
......@@ -900,12 +907,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)
......@@ -916,7 +923,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]
......@@ -984,6 +991,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):
......
......@@ -25,6 +25,7 @@ Longer TODO:
# pylint: disable=W0401, W0611, W0614
import imp
import os
import sys
import lms.envs.common
# Although this module itself may not use these imported variables, other dependent modules may.
......@@ -101,6 +102,9 @@ FEATURES = {
# Turn off Advanced Security by default
'ADVANCED_SECURITY': False,
# Toggles Group Configuration editing functionality
'ENABLE_GROUP_CONFIGURATIONS': os.environ.get('FEATURE_GROUP_CONFIGURATIONS'),
}
ENABLE_JASMINE = False
......
......@@ -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
......
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;
});
define([
'backbone', 'js/models/group_configuration'
],
function(Backbone, GroupConfigurationModel) {
'use strict';
var GroupConfigurationCollection = Backbone.Collection.extend({
model: GroupConfigurationModel
});
return GroupConfigurationCollection;
});
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;
});
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;
});
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();
});
});
});
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($('<script>', {
id: 'group-configuration-details-tpl',
type: 'text/template'
}).text(tpl));
this.model = new GroupConfigurationModel({
name: 'Configuration',
description: 'Configuration Description',
id: 0
});
spyOn(this.model, 'destroy').andCallThrough();
this.collection = new GroupConfigurationSet([ this.model ]);
this.view = new GroupConfigurationDetails({
model: this.model
});
});
describe('Basic', function() {
it('should render properly', function() {
this.view.render();
expect(this.view.$el).toContainText('Configuration');
expect(this.view.$el).toContainText('ID: 0');
});
it('should show groups appropriately', function() {
this.model.get('groups').add([{}, {}, {}]);
this.model.set('showGroups', false);
this.view.render().$('.show-groups').click();
expect(this.model.get('showGroups')).toBeTruthy();
expect(this.view.$el.find('.group').length).toBe(5);
expect(this.view.$el.find('.group-configuration-groups-count'))
.not.toExist();
expect(this.view.$el.find('.group-configuration-description'))
.toContainText('Configuration Description');
expect(this.view.$el.find('.group-allocation'))
.toContainText('20%');
});
it('should hide groups appropriately', function() {
this.model.get('groups').add([{}, {}, {}]);
this.model.set('showGroups', true);
this.view.render().$('.hide-groups').click();
expect(this.model.get('showGroups')).toBeFalsy();
expect(this.view.$el.find('.group').length).toBe(0);
expect(this.view.$el.find('.group-configuration-groups-count'))
.toContainText('Contains 5 groups');
expect(this.view.$el.find('.group-configuration-description'))
.not.toExist();
expect(this.view.$el.find('.group-allocation'))
.not.toExist();
});
});
});
describe('GroupConfigurationsList', function() {
var noGroupConfigurationsTpl = readFixtures(
'no-group-configurations.underscore'
);
beforeEach(function() {
var showEl = $('<li>');
setFixtures($('<script>', {
id: 'no-group-configurations-tpl',
type: 'text/template'
}).text(noGroupConfigurationsTpl));
this.showSpies = spyOnConstructor(
window, 'GroupConfigurationDetails', [ 'render' ]
);
this.showSpies.render.andReturn(this.showSpies);
this.showSpies.$el = showEl;
this.showSpies.el = showEl.get(0);
this.collection = new GroupConfigurationSet();
this.view = new GroupConfigurationsList({
collection: this.collection
});
this.view.render();
});
var message = 'should render the empty template if there are no group ' +
'configurations';
it(message, function() {
expect(this.view.$el).toContainText(
'You haven\'t created any group configurations yet.'
);
expect(this.view.$el).not.toContain('.new-button');
expect(this.showSpies.constructor).not.toHaveBeenCalled();
});
it('should render GroupConfigurationDetails views by default', function() {
this.collection.add([{}, {}, {}]);
this.view.render();
expect(this.view.$el).not.toContainText(
'You haven\'t created any group configurations yet.'
);
expect(this.view.$el.find('.group-configuration').length).toBe(3);
});
});
});
define([
'jquery', 'underscore', 'js/views/pages/group_configurations',
'js/collections/group_configuration'
], function ($, _, GroupConfigurationsPage, GroupConfigurationCollection) {
'use strict';
describe('GroupConfigurationsPage', function() {
var mockGroupConfigurationsPage = readFixtures(
'mock/mock-group-configuration-page.underscore'
),
noGroupConfigurationsTpl = readFixtures(
'no-group-configurations.underscore'
), view;
var initializePage = function (disableSpy) {
view = new GroupConfigurationsPage({
el: $('.content-primary'),
collection: new GroupConfigurationCollection({
name: 'Configuration 1'
})
});
if (!disableSpy) {
spyOn(view, 'addGlobalActions');
}
};
beforeEach(function () {
setFixtures($('<script>', {
id: 'no-group-configurations-tpl',
type: 'text/template'
}).text(noGroupConfigurationsTpl));
appendSetFixtures(mockGroupConfigurationsPage);
});
describe('Initial display', function() {
it('can render itself', function() {
initializePage();
expect(view.$('.ui-loading')).toBeVisible();
view.render();
expect(view.$('.no-group-configurations-content')).toBeTruthy();
expect(view.$('.ui-loading')).toBeHidden();
});
});
describe('on page close/change', function() {
it('I see notification message if the model is changed',
function() {
var message;
initializePage(true);
view.render();
message = view.onBeforeUnload();
expect(message).toBeUndefined();
});
it('I do not see notification message if the model is not changed',
function() {
var expectedMessage = [
'You have unsaved changes. Do you really want to ',
'leave this page?'
].join(''), message;
initializePage();
view.render();
view.collection.at(0).set('name', 'Configuration 2');
message = view.onBeforeUnload();
expect(message).toBe(expectedMessage);
});
});
});
});
define([
'js/views/baseview', 'underscore', 'gettext'
],
function(BaseView, _, gettext) {
'use strict';
var GroupConfigurationDetails = BaseView.extend({
tagName: 'section',
className: 'group-configuration',
events: {
'click .show-groups': 'showGroups',
'click .hide-groups': 'hideGroups'
},
initialize: function() {
this.template = _.template(
$('#group-configuration-details-tpl').text()
);
this.listenTo(this.model, 'change', this.render);
},
render: function() {
var attrs = $.extend({}, this.model.attributes, {
groupsCountMessage: this.getGroupsCountTitle(),
index: this.model.collection.indexOf(this.model)
});
this.$el.html(this.template(attrs));
return this;
},
showGroups: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set('showGroups', true);
},
hideGroups: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set('showGroups', false);
},
getGroupsCountTitle: function () {
var count = this.model.get('groups').length,
message = ngettext(
// Translators: 'count' is number of groups that the group configuration contains.
'Contains %(count)s group', 'Contains %(count)s groups',
count
);
return interpolate(message, { count: count }, true);
}
});
return GroupConfigurationDetails;
});
define(['js/views/baseview', 'jquery', 'js/views/group_configuration_details'],
function(BaseView, $, GroupConfigurationDetailsView) {
'use strict';
var GroupConfigurationsList = BaseView.extend({
tagName: 'div',
className: 'group-configurations-list',
events: { },
initialize: function() {
this.emptyTemplate = this.loadTemplate('no-group-configurations');
this.listenTo(this.collection, 'all', this.render);
},
render: function() {
var configurations = this.collection;
if(configurations.length === 0) {
this.$el.html(this.emptyTemplate());
} else {
var frag = document.createDocumentFragment();
configurations.each(function(configuration) {
var view = new GroupConfigurationDetailsView({
model: configuration
});
frag.appendChild(view.render().el);
});
this.$el.html([frag]);
}
return this;
}
});
return GroupConfigurationsList;
});
define([
'jquery', 'underscore', 'gettext', 'js/views/baseview',
'js/views/group_configurations_list'
],
function ($, _, gettext, BaseView, ConfigurationsListView) {
'use strict';
var GroupConfigurationsPage = BaseView.extend({
initialize: function() {
BaseView.prototype.initialize.call(this);
this.listView = new ConfigurationsListView({
collection: this.collection
});
},
render: function() {
this.hideLoadingIndicator();
this.$el.append(this.listView.render().el);
this.addGlobalActions();
},
addGlobalActions: function () {
$(window).on('beforeunload', this.onBeforeUnload.bind(this));
},
onBeforeUnload: function () {
var dirty = this.collection.find(function(configuration) {
return configuration.isDirty();
});
if(dirty) {
return gettext(
'You have unsaved changes. Do you really want to ' +
'leave this page?'
);
}
}
});
return GroupConfigurationsPage;
}); // end define();
......@@ -55,6 +55,12 @@ nav {
&:hover {
}
&.nav-course-settings {
.wrapper-nav-sub {
width: ($baseline*9);
}
}
}
.wrapper-nav-sub {
......
......@@ -47,6 +47,7 @@
@import 'views/checklists';
@import 'views/textbooks';
@import 'views/export-git';
@import 'views/group-configuration';
// base - contexts
@import 'contexts/ie'; // ie-specific rules (mostly for known/older bugs)
......
// studio - views - group-configurations
// ====================
.view-group-configurations {
.content-primary, .content-supplementary {
@include box-sizing(border-box);
float: left;
}
.content-primary {
width: flex-grid(9, 12);
margin-right: flex-gutter();
.notice-moduledisabled {
@extend %ui-well;
@extend %t-copy-base;
background-color: $white;
padding: ($baseline*1.5) $baseline;
text-align: center;
}
.no-group-configurations-content {
@extend %ui-well;
padding: ($baseline*2);
background-color: $gray-l4;
text-align: center;
color: $gray;
}
.group-configuration {
@extend %ui-window;
position: relative;
.view-group-configuration {
padding: $baseline ($baseline*1.5);
.group-configuration-header {
margin-bottom: 0;
border-bottom: 0;
}
.group-configuration-title {
@extend %t-title;
@include font-size(22);
@include line-height(22);
overflow: hidden;
text-overflow: ellipsis;
margin-right: ($baseline*14);
font-weight: bold;
.group-toggle {
display: inline-block;
padding-left: $baseline;
color: $black;
&:hover, &:focus {
color: $blue;
}
}
}
.group-configuration-info {
color: $gray-l1;
margin-left: $baseline;
&.group-configuration-info-inline {
display: table;
width: 70%;
margin: ($baseline/4) 0 ($baseline/2) $baseline;
li {
@include box-sizing(border-box);
display: table-cell;
margin-right: 1%;
}
}
&.group-configuration-info-block {
li {
padding: ($baseline/4) 0;
}
}
.group-configuration-label {
text-transform: uppercase;
}
.group-configuration-description {
overflow: hidden;
text-overflow: ellipsis;
}
}
.ui-toggle-expansion {
@include transition(rotate .15s ease-in-out .25s);
@include font-size(21);
display: inline-block;
width: ($baseline*0.75);
vertical-align: baseline;
margin-left: -$baseline;
}
&.is-selectable {
cursor: pointer;
&:hover {
color: $blue;
.ui-toggle-expansion {
color: $blue;
}
}
}
.groups {
margin-left: $baseline;
margin-bottom: ($baseline*0.75);
.group {
@extend %t-copy-sub2;
@include font-size(18);
@include line-height(16);
padding: ($baseline/7) 0 ($baseline/4);
border-top: 1px solid $gray-l4;
white-space: nowrap;
&:first-child {
border-top: none;
}
.group-name {
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
vertical-align: middle;
width: 75%;
margin-right: 5%;
}
.group-allocation {
display: inline-block;
vertical-align: middle;
width: 20%;
color: $gray-l1;
text-align: right;
}
}
}
}
&:hover .actions {
opacity: 1.0;
}
}
}
.content-supplementary {
width: flex-grid(3, 12);
}
}
<%inherit file="base.html" />
<%def name="online_help_token()"><% return "group_configurations" %></%def>
<%namespace name='static' file='static_content.html'/>
<%! import json %>
<%!
from contentstore import utils
from django.utils.translation import ugettext as _
%>
<%block name="title">${_("Group Configurations")}</%block>
<%block name="bodyclass">is-signedin course view-group-configurations</%block>
<%block name="header_extras">
% for template_name in ["group-configuration-details", "no-group-configurations", "basic-modal", "modal-button"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
% endfor
</%block>
<%block name="jsextra">
<script type="text/javascript">
require(["!domReady", "js/collections/group_configuration", "js/views/pages/group_configurations"],
function(doc, GroupConfigurationCollection, GroupConfigurationsPage, xmoduleLoader) {
% if configurations is not None:
var view = new GroupConfigurationsPage({
el: $('.content-primary'),
collection: new GroupConfigurationCollection(${json.dumps(configurations)}, { url: "${group_configuration_url}" })
});
view.render();
% endif
});
</script>
</%block>
<%block name="content">
<div class="wrapper-mast wrapper">
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">${_("Settings")}</small>
<span class="sr">&gt; </span>${_("Group Configurations")}
</h1>
</header>
</div>
<div class="wrapper-content wrapper">
<section class="content">
<article class="content-primary" role="main">
% if configurations is None:
<div class="notice notice-incontext notice-moduledisabled">
<p class="copy">
${_("This module is disabled at the moment.")}
</p>
</div>
% else:
<div class="ui-loading">
<p><span class="spin"><i class="icon-refresh"></i></span> <span class="copy">${_("Loading...")}</span></p>
</div>
% endif
</article>
<aside class="content-supplementary" role="complimentary">
<div class="bit">
% if context_course:
<%
details_url = utils.reverse_course_url('settings_handler', context_course.id)
grading_url = utils.reverse_course_url('grading_handler', context_course.id)
course_team_url = utils.reverse_course_url('course_team_handler', context_course.id)
advanced_settings_url = utils.reverse_course_url('advanced_settings_handler', context_course.id)
%>
<h3 class="title-3">${_("Other Course Settings")}</h3>
<nav class="nav-related">
<ul>
<li class="nav-item"><a href="${details_url}">${_("Details &amp; Schedule")}</a></li>
<li class="nav-item"><a href="${grading_url}">${_("Grading")}</a></li>
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
<li class="nav-item"><a href="${advanced_settings_url}">${_("Advanced Settings")}</a></li>
</ul>
</nav>
% endif
</div>
</aside>
</section>
</div>
</%block>
<div class="view-group-configuration view-group-configuration-<%= index %>">
<div class="wrapper-group-configuration">
<header class="group-configuration-header">
<h3 class="group-configuration-title">
<a href="#" class="group-toggle <% if(showGroups){ print('hide'); } else { print('show'); } %>-groups">
<i class="ui-toggle-expansion icon-caret-<% if(showGroups){ print('down'); } else { print('right'); } %>"></i>
<%= name %>
</a>
</h3>
</header>
<ol class="group-configuration-info group-configuration-info-<% if(showGroups){ print('block'); } else { print('inline'); } %>">
<% if (_.isNumber(id)) { %>
<li class="group-configuration-id"
><span class="group-configuration-label"><%= gettext('ID') %>: </span
><span class="group-configuration-value"><%= id %></span
></li>
<% } %>
<% if (showGroups) { %>
<li class="group-configuration-description">
<%= description %>
</li>
<% } else { %>
<li class="group-configuration-groups-count">
<%= groupsCountMessage %>
</li>
<% } %>
</ol>
<% if(showGroups) { %>
<% allocation = Math.floor(100 / groups.length) %>
<ol class="groups groups-<%= index %>">
<% groups.each(function(group, groupIndex) { %>
<li class="group group-<%= groupIndex %>"
><span class="group-name"><%= group.get('name') %></span
><span class="group-allocation"><%= allocation %>%</span
></li>
<% }) %>
</ol>
<% } %>
</div>
</div>
<div id="content">
<div class="wrapper-mast wrapper">
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">${_("Settings")}</small>
<span class="sr">&gt; </span>${_("Group Configurations")}
</h1>
</header>
</div>
<div class="wrapper-content wrapper">
<section class="content">
<article class="content-primary" role="main">
<div class="ui-loading">
<p><span class="spin"><i class="icon-refresh"></i></span> <span class="copy">${_("Loading...")}</span></p>
</div>
</article>
<aside class="content-supplementary" role="complimentary"></aside>
</section>
</div>
</div>
<div class="no-group-configurations-content">
<p><%= gettext("You haven't created any group configurations yet.") %></p>
</div>
......@@ -316,6 +316,9 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
<li class="nav-item"><a href="${grading_config_url}">${_("Grading")}</a></li>
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
<li class="nav-item"><a href="${advanced_config_url}">${_("Advanced Settings")}</a></li>
% if settings.FEATURES.get('ENABLE_GROUP_CONFIGURATIONS') and "split_test" in context_course.advanced_modules:
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
% endif
</ul>
</nav>
% endif
......
......@@ -125,6 +125,9 @@ require(["domReady!", "jquery", "gettext", "js/models/settings/advanced", "js/vi
<li class="nav-item"><a href="${details_url}">${_("Details &amp; Schedule")}</a></li>
<li class="nav-item"><a href="${grading_url}">${_("Grading")}</a></li>
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
% if settings.FEATURES.get('ENABLE_GROUP_CONFIGURATIONS') and "split_test" in context_course.advanced_modules:
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
% endif
</ul>
</nav>
% endif
......
......@@ -148,6 +148,9 @@ require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings
<li class="nav-item"><a href="${detailed_settings_url}">${_("Details &amp; Schedule")}</a></li>
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
<li class="nav-item"><a href="${advanced_settings_url}">${_("Advanced Settings")}</a></li>
% if settings.FEATURES.get('ENABLE_GROUP_CONFIGURATIONS') and "split_test" in context_course.advanced_modules:
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
% endif
</ul>
</nav>
% endif
......
......@@ -84,6 +84,11 @@
<li class="nav-item nav-course-settings-advanced">
<a href="${advanced_settings_url}">${_("Advanced Settings")}</a>
</li>
% if settings.FEATURES.get('ENABLE_GROUP_CONFIGURATIONS') and "split_test" in context_course.advanced_modules:
<li class="nav-item nav-course-settings-group-configurations">
<a href="${reverse('contentstore.views.group_configurations_list_handler', kwargs={'course_key_string': unicode(course_key)})}">${_("Group Configurations")}</a>
</li>
% endif
</ul>
</div>
</div>
......
......@@ -96,6 +96,10 @@ urlpatterns += patterns(
url(r'^textbooks/{}/(?P<textbook_id>\d[^/]*)$'.format(COURSE_KEY_PATTERN), 'textbooks_detail_handler'),
)
if settings.FEATURES.get('ENABLE_GROUP_CONFIGURATIONS'):
urlpatterns += (url(r'^group_configurations/(?P<course_key_string>[^/]+)$',
'contentstore.views.group_configurations_list_handler'),)
js_info_dict = {
'domain': 'djangojs',
# We need to explicitly include external Django apps that are not in LOCALE_PATHS.
......
......@@ -3,6 +3,10 @@ Course Advanced Settings page
"""
from .course_page import CoursePage
from .utils import press_the_notification_button, type_in_codemirror, get_codemirror_value
KEY_CSS = '.key h3.title'
class AdvancedSettingsPage(CoursePage):
......@@ -14,3 +18,27 @@ class AdvancedSettingsPage(CoursePage):
def is_browser_on_page(self):
return self.q(css='body.advanced').present
def _get_index_of(self, expected_key):
for i, element in enumerate(self.q(css=KEY_CSS)):
# Sometimes get stale reference if I hold on to the array of elements
key = self.q(css=KEY_CSS).nth(i).text[0]
if key == expected_key:
return i
return -1
def save(self):
press_the_notification_button(self, "Save")
def cancel(self):
press_the_notification_button(self, "Cancel")
def set(self, key, new_value):
index = self._get_index_of(key)
type_in_codemirror(self, index, new_value)
self.save()
def get(self, key):
index = self._get_index_of(key)
return get_codemirror_value(self, index)
"""
Course Group Configurations page.
"""
from .course_page import CoursePage
class GroupConfigurationsPage(CoursePage):
"""
Course Group Configurations page.
"""
url_path = "group_configurations"
def is_browser_on_page(self):
return self.q(css='body.view-group-configurations').present
def group_configurations(self):
"""
Returns list of the group configurations for the course.
"""
css = '.wrapper-group-configuration'
return [GroupConfiguration(self, index) for index in xrange(len(self.q(css=css)))]
class GroupConfiguration(object):
"""
Group Configuration wrapper.
"""
def __init__(self, page, index):
self.page = page
self.SELECTOR = '.view-group-configuration-{}'.format(index)
self.index = index
def get_selector(self, css=''):
return ' '.join([self.SELECTOR, css])
def find_css(self, selector):
"""
Find elements as defined by css locator.
"""
return self.page.q(css=self.get_selector(css=selector))
def toggle(self):
"""
Expand/collapse group configuration.
"""
css = 'a.group-toggle'
self.find_css(css).first.click()
@property
def id(self):
"""
Returns group configuration id.
"""
css = '.group-configuration-id .group-configuration-value'
return self.find_css(css).first.text[0]
@property
def name(self):
"""
Returns group configuration name.
"""
css = '.group-configuration-title'
return self.find_css(css).first.text[0]
@property
def description(self):
"""
Returns group configuration description.
"""
css = '.group-configuration-description'
return self.find_css(css).first.text[0]
@property
def groups(self):
"""
Returns list of groups.
"""
css = '.group'
def group_selector(config_index, group_index):
return self.get_selector('.groups-{} .group-{} '.format(config_index, group_index))
return [Group(self.page, group_selector(self.index, index)) for index, element in enumerate(self.find_css(css))]
def __repr__(self):
return "<{}:{}>".format(self.__class__.__name__, self.name)
class Group(object):
"""
Group wrapper.
"""
def __init__(self, page, prefix_selector):
self.page = page
self.prefix = prefix_selector
def find_css(self, selector):
"""
Find elements as defined by css locator.
"""
return self.page.q(css=self.prefix + selector)
@property
def name(self):
"""
Returns group name.
"""
css = '.group-name'
return self.find_css(css).first.text[0]
@property
def allocation(self):
"""
Returns allocation for the group.
"""
css = '.group-allocation'
return self.find_css(css).first.text[0]
def __repr__(self):
return "<{}:{}>".format(self.__class__.__name__, self.name)
......@@ -37,6 +37,18 @@ def wait_for_notification(page):
Promise(_is_saving_done, 'Notification should have been hidden.', timeout=60).fulfill()
def press_the_notification_button(page, name):
# Because the notification uses a CSS transition,
# Selenium will always report it as being visible.
# This makes it very difficult to successfully click
# the "Save" button at the UI level.
# Instead, we use JavaScript to reliably click
# the button.
btn_css = 'div#page-notification a.action-%s' % name.lower()
page.browser.execute_script("$('{}').focus().click()".format(btn_css))
page.wait_for_ajax()
def add_discussion(page, menu_index):
"""
Add a new instance of the discussion category.
......@@ -66,3 +78,20 @@ def add_advanced_component(page, menu_index, name):
Promise(is_advanced_components_showing, "Advanced component menu not showing").fulfill()
click_css(page, 'a[data-category={}]'.format(name))
def type_in_codemirror(page, index, text, find_prefix="$"):
script = """
var cm = {find_prefix}('div.CodeMirror:eq({index})').get(0).CodeMirror;
CodeMirror.signal(cm, "focus", cm);
cm.setValue(arguments[0]);
CodeMirror.signal(cm, "blur", cm);""".format(index=index, find_prefix=find_prefix)
page.browser.execute_script(script, str(text))
def get_codemirror_value(page, index=0, find_prefix="$"):
return page.browser.execute_script(
"""
return {find_prefix}('div.CodeMirror:eq({index})').get(0).CodeMirror.getValue();
""".format(index=index, find_prefix=find_prefix)
)
......@@ -2,16 +2,23 @@
Acceptance tests for Studio related to the split_test module.
"""
from unittest import skip
import json
import os
from unittest import skip, skipUnless
from ..fixtures.course import CourseFixture, XBlockFixtureDesc
from ..pages.studio.component_editor import ComponentEditorView
from ..pages.studio.settings_advanced import AdvancedSettingsPage
from ..pages.studio.settings_group_configurations import GroupConfigurationsPage
from ..pages.studio.auto_auth import AutoAuthPage
from test_studio_container import ContainerBase
from ..pages.studio.utils import add_advanced_component
from xmodule.partitions.partitions import Group, UserPartition
from bok_choy.promise import Promise
from .helpers import UniqueCourseTest
class SplitTest(ContainerBase):
"""
......@@ -154,3 +161,149 @@ class SplitTest(ContainerBase):
container = self.create_poorly_configured_split_instance()
container.delete(0)
self.verify_groups(container, ['alpha'], [], verify_missing_groups_not_present=False)
@skipUnless(os.environ.get('FEATURE_GROUP_CONFIGURATIONS'), 'Tests Group Configurations feature')
class SettingsMenuTest(UniqueCourseTest):
"""
Tests that Setting menu is rendered correctly in Studio
"""
def setUp(self):
super(SettingsMenuTest, self).setUp()
course_fix = CourseFixture(**self.course_info)
course_fix.install()
self.auth_page = AutoAuthPage(
self.browser,
staff=False,
username=course_fix.user.get('username'),
email=course_fix.user.get('email'),
password=course_fix.user.get('password')
)
self.auth_page.visit()
self.advanced_settings = AdvancedSettingsPage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
self.advanced_settings.visit()
def test_link_exist_if_split_test_enabled(self):
"""
Ensure that the link to the "Group Configurations" page is shown in the
Settings menu.
"""
link_css = 'li.nav-course-settings-group-configurations a'
self.assertFalse(self.advanced_settings.q(css=link_css).present)
self.advanced_settings.set('Advanced Module List', '["split_test"]')
self.browser.refresh()
self.advanced_settings.wait_for_page()
self.assertIn(
"split_test",
json.loads(self.advanced_settings.get('Advanced Module List')),
)
self.assertTrue(self.advanced_settings.q(css=link_css).present)
def test_link_does_not_exist_if_split_test_disabled(self):
"""
Ensure that the link to the "Group Configurations" page does not exist
in the Settings menu.
"""
link_css = 'li.nav-course-settings-group-configurations a'
self.advanced_settings.set('Advanced Module List', '[]')
self.browser.refresh()
self.advanced_settings.wait_for_page()
self.assertFalse(self.advanced_settings.q(css=link_css).present)
@skipUnless(os.environ.get('FEATURE_GROUP_CONFIGURATIONS'), 'Tests Group Configurations feature')
class GroupConfigurationsTest(UniqueCourseTest):
"""
Tests that Group Configurations page works correctly with previously
added configurations in Studio
"""
def setUp(self):
super(GroupConfigurationsTest, self).setUp()
course_fix = CourseFixture(**self.course_info)
course_fix.add_advanced_settings({
u"advanced_modules": {"value": ["split_test"]},
})
course_fix.install()
self.course_fix = course_fix
self.user = course_fix.user
self.auth_page = AutoAuthPage(
self.browser,
staff=False,
username=course_fix.user.get('username'),
email=course_fix.user.get('email'),
password=course_fix.user.get('password')
)
self.auth_page.visit()
self.page = GroupConfigurationsPage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
def test_no_group_configurations_added(self):
"""
Ensure that message telling me to create a new group configuration is
shown when group configurations were not added.
"""
self.page.visit()
css = ".wrapper-content .no-group-configurations-content"
self.assertTrue(self.page.q(css=css).present)
self.assertIn(
"You haven't created any group configurations yet.",
self.page.q(css=css).text[0]
)
def test_group_configurations_have_correct_data(self):
"""
Ensure that the group configuration is rendered correctly in
expanded/collapsed mode.
"""
self.course_fix.add_advanced_settings({
u"user_partitions": {
"value": [
UserPartition(0, 'Name of the Group Configuration', 'Description of the group configuration.', [Group("0", 'Group 0'), Group("1", 'Group 1')]).to_json(),
UserPartition(1, 'Name of second Group Configuration', 'Second group configuration.', [Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]).to_json()
],
},
})
self.course_fix._add_advanced_settings()
self.page.visit()
config = self.page.group_configurations()[0]
self.assertIn("Name of the Group Configuration", config.name)
self.assertEqual(config.id, '0')
config.toggle()
self.assertIn("Description of the group configuration.", config.description)
self.assertEqual(len(config.groups), 2)
self.assertEqual("Group 0", config.groups[0].name)
self.assertEqual("50%", config.groups[0].allocation)
config = self.page.group_configurations()[1]
self.assertIn("Name of second Group Configuration", config.name)
self.assertEqual(len(config.groups), 0) # no groups when the partition is collapsed
config.toggle()
self.assertEqual(len(config.groups), 3)
self.assertEqual("Beta", config.groups[1].name)
self.assertEqual("33%", config.groups[1].allocation)
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