Commit 69ee9d92 by jmclaus

BLD-1104: List units that use group configuration.

parent df71afd4
...@@ -4,6 +4,7 @@ Views related to operations on course objects ...@@ -4,6 +4,7 @@ Views related to operations on course objects
import json import json
import random import random
import string # pylint: disable=W0402 import string # pylint: disable=W0402
import logging
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
import django.utils import django.utils
...@@ -32,7 +33,8 @@ from contentstore.utils import ( ...@@ -32,7 +33,8 @@ from contentstore.utils import (
get_lms_link_for_item, get_lms_link_for_item,
add_extra_panel_tab, add_extra_panel_tab,
remove_extra_panel_tab, remove_extra_panel_tab,
reverse_course_url reverse_course_url,
reverse_usage_url,
) )
from models.settings.course_details import CourseDetails, CourseSettingsEncoder from models.settings.course_details import CourseDetails, CourseSettingsEncoder
...@@ -70,6 +72,8 @@ __all__ = ['course_info_handler', 'course_handler', 'course_info_update_handler' ...@@ -70,6 +72,8 @@ __all__ = ['course_info_handler', 'course_handler', 'course_info_update_handler'
'textbooks_list_handler', 'textbooks_detail_handler', 'textbooks_list_handler', 'textbooks_detail_handler',
'group_configurations_list_handler', 'group_configurations_detail_handler'] 'group_configurations_list_handler', 'group_configurations_detail_handler']
log = logging.getLogger(__name__)
class AccessListFallback(Exception): class AccessListFallback(Exception):
""" """
...@@ -949,6 +953,62 @@ class GroupConfiguration(object): ...@@ -949,6 +953,62 @@ class GroupConfiguration(object):
groups groups
) )
@staticmethod
def _get_usage_info(course, modulestore):
"""
Get all units names and their urls that have experiments and associated
with configurations.
Returns:
{'user_partition_id':
[
{'label': 'Unit Name / Experiment Name', 'url': 'url_to_unit_1'},
{'label': 'Another Unit Name / Another Experiment Name', 'url': 'url_to_unit_1'}
],
}
"""
usage_info = {}
descriptors = modulestore.get_items(course.id, category='split_test')
for split_test in descriptors:
if split_test.user_partition_id not in usage_info:
usage_info[split_test.user_partition_id] = []
unit_location = modulestore.get_parent_location(split_test.location)
if not unit_location:
log.warning("Parent location of split_test module not found: %s", split_test.location)
continue
try:
unit = modulestore.get_item(unit_location)
except ItemNotFoundError:
log.warning("Unit not found: %s", unit_location)
continue
unit_url = reverse_usage_url(
'unit_handler',
course.location.course_key.make_usage_key(unit.location.block_type, unit.location.name)
)
usage_info[split_test.user_partition_id].append({
'label': '{} / {}'.format(unit.display_name, split_test.display_name),
'url': unit_url
})
return usage_info
@staticmethod
def add_usage_info(course, modulestore):
"""
Add usage information to group configurations json.
Returns json of group configurations updated with usage information.
"""
usage_info = GroupConfiguration._get_usage_info(course, modulestore)
configurations = []
for partition in course.user_partitions:
configuration = partition.to_json()
configuration['usage'] = usage_info.get(partition.id, [])
configurations.append(configuration)
return configurations
@require_http_methods(("GET", "POST")) @require_http_methods(("GET", "POST"))
@login_required @login_required
...@@ -968,12 +1028,16 @@ def group_configurations_list_handler(request, course_key_string): ...@@ -968,12 +1028,16 @@ def group_configurations_list_handler(request, course_key_string):
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'): if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key) group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key)
course_outline_url = reverse_course_url('course_handler', course_key)
split_test_enabled = SPLIT_TEST_COMPONENT_TYPE in course.advanced_modules split_test_enabled = SPLIT_TEST_COMPONENT_TYPE in course.advanced_modules
configurations = GroupConfiguration.add_usage_info(course, store)
return render_to_response('group_configurations.html', { return render_to_response('group_configurations.html', {
'context_course': course, 'context_course': course,
'group_configuration_url': group_configuration_url, 'group_configuration_url': group_configuration_url,
'configurations': [u.to_json() for u in course.user_partitions] if split_test_enabled else None, 'course_outline_url': course_outline_url,
'configurations': configurations if split_test_enabled else None,
}) })
elif "application/json" in request.META.get('HTTP_ACCEPT'): elif "application/json" in request.META.get('HTTP_ACCEPT'):
if request.method == 'POST': if request.method == 'POST':
......
...@@ -6,8 +6,10 @@ from unittest import skipUnless ...@@ -6,8 +6,10 @@ from unittest import skipUnless
from django.conf import settings from django.conf import settings
from contentstore.utils import reverse_course_url from contentstore.utils import reverse_course_url
from contentstore.views.component import SPLIT_TEST_COMPONENT_TYPE from contentstore.views.component import SPLIT_TEST_COMPONENT_TYPE
from contentstore.views.course import GroupConfiguration
from contentstore.tests.utils import CourseTestCase from contentstore.tests.utils import CourseTestCase
from xmodule.partitions.partitions import Group, UserPartition from xmodule.partitions.partitions import Group, UserPartition
from xmodule.modulestore.tests.factories import ItemFactory
GROUP_CONFIGURATION_JSON = { GROUP_CONFIGURATION_JSON = {
...@@ -20,6 +22,44 @@ GROUP_CONFIGURATION_JSON = { ...@@ -20,6 +22,44 @@ GROUP_CONFIGURATION_JSON = {
} }
class HelperMethods(object):
"""
Mixin that provides useful methods for Group Configuration tests.
"""
def _create_content_experiment(self, cid=None, name_suffix=''):
"""
Create content experiment.
Assign Group Configuration to the experiment if cid is provided.
"""
vertical = ItemFactory.create(
category='vertical',
parent_location=self.course.location,
display_name='Test Unit {}'.format(name_suffix)
)
split_test = ItemFactory.create(
category='split_test',
parent_location=vertical.location,
user_partition_id=cid,
display_name='Test Content Experiment {}'.format(name_suffix)
)
self.save_course()
return (vertical, split_test)
def _add_user_partitions(self, count=1):
"""
Create user partitions for the course.
"""
partitions = [
UserPartition(
i, 'Name ' + str(i), 'Description ' + str(i), [Group(0, 'Group A'), Group(1, 'Group B'), Group(2, 'Group C')]
) for i in xrange(count)
]
self.course.user_partitions = partitions
self.save_course()
# pylint: disable=no-member # pylint: disable=no-member
class GroupConfigurationsBaseTestCase(object): class GroupConfigurationsBaseTestCase(object):
""" """
...@@ -286,3 +326,118 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio ...@@ -286,3 +326,118 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
self.assertEqual(len(user_partititons[0].groups), 2) self.assertEqual(len(user_partititons[0].groups), 2)
self.assertEqual(user_partititons[0].groups[0].name, u'New Group Name') self.assertEqual(user_partititons[0].groups[0].name, u'New Group Name')
self.assertEqual(user_partititons[0].groups[1].name, u'Group C') self.assertEqual(user_partititons[0].groups[1].name, u'Group C')
# pylint: disable=no-member
@skipUnless(settings.FEATURES.get('ENABLE_GROUP_CONFIGURATIONS'), 'Tests Group Configurations feature')
class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
"""
Tests for usage information of configurations.
"""
def setUp(self):
super(GroupConfigurationsUsageInfoTestCase, self).setUp()
def test_group_configuration_not_used(self):
"""
Test that right data structure will be created if group configuration is not used.
"""
self._add_user_partitions()
actual = GroupConfiguration.add_usage_info(self.course, self.store)
expected = [{
u'id': 0,
u'name': u'Name 0',
u'description': u'Description 0',
u'version': 1,
u'groups': [
{u'id': 0, u'name': u'Group A', u'version': 1},
{u'id': 1, u'name': u'Group B', u'version': 1},
{u'id': 2, u'name': u'Group C', u'version': 1},
],
u'usage': [],
}]
self.assertEqual(actual, expected)
def test_can_get_correct_usage_info(self):
"""
Test if group configurations json updated successfully with usage information.
"""
self._add_user_partitions(count=2)
self._create_content_experiment(cid=0, name_suffix='0')
self._create_content_experiment(name_suffix='1')
actual = GroupConfiguration.add_usage_info(self.course, self.store)
expected = [{
u'id': 0,
u'name': u'Name 0',
u'description': u'Description 0',
u'version': 1,
u'groups': [
{u'id': 0, u'name': u'Group A', u'version': 1},
{u'id': 1, u'name': u'Group B', u'version': 1},
{u'id': 2, u'name': u'Group C', u'version': 1},
],
u'usage': [{
'url': '/unit/i4x://MITx/999/vertical/Test_Unit_0',
'label': 'Test Unit 0 / Test Content Experiment 0',
}],
}, {
u'id': 1,
u'name': u'Name 1',
u'description': u'Description 1',
u'version': 1,
u'groups': [
{u'id': 0, u'name': u'Group A', u'version': 1},
{u'id': 1, u'name': u'Group B', u'version': 1},
{u'id': 2, u'name': u'Group C', u'version': 1},
],
u'usage': [],
}]
self.assertEqual(actual, expected)
def test_can_use_one_configuration_in_multiple_experiments(self):
"""
Test if multiple experiments are present in usage info when they use same
group configuration.
"""
self._add_user_partitions()
self._create_content_experiment(cid=0, name_suffix='0')
self._create_content_experiment(cid=0, name_suffix='1')
actual = GroupConfiguration.add_usage_info(self.course, self.store)
expected = [{
u'id': 0,
u'name': u'Name 0',
u'description': u'Description 0',
u'version': 1,
u'groups': [
{u'id': 0, u'name': u'Group A', u'version': 1},
{u'id': 1, u'name': u'Group B', u'version': 1},
{u'id': 2, u'name': u'Group C', u'version': 1},
],
u'usage': [{
'url': '/unit/i4x://MITx/999/vertical/Test_Unit_0',
'label': 'Test Unit 0 / Test Content Experiment 0',
}, {
'url': '/unit/i4x://MITx/999/vertical/Test_Unit_1',
'label': 'Test Unit 1 / Test Content Experiment 1',
}],
}]
self.assertEqual(actual, expected)
def test_can_handle_without_parent(self):
"""
Test if it possible to handle case when split_test has no parent.
"""
self._add_user_partitions()
# Create split test without parent.
ItemFactory.create(
category='split_test',
user_partition_id=0,
display_name='Test Content Experiment'
)
self.save_course()
actual = GroupConfiguration._get_usage_info(self.course, self.store)
self.assertEqual(actual, {0: []})
...@@ -22,7 +22,8 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) { ...@@ -22,7 +22,8 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) {
} }
]), ]),
showGroups: false, showGroups: false,
editing: false editing: false,
usage: []
}; };
}, },
......
...@@ -9,6 +9,9 @@ define([ ...@@ -9,6 +9,9 @@ define([
this.addMatchers({ this.addMatchers({
toBeInstanceOf: function(expected) { toBeInstanceOf: function(expected) {
return this.actual instanceof expected; return this.actual instanceof expected;
},
toBeEmpty: function() {
return this.actual.length === 0;
} }
}); });
}); });
...@@ -40,6 +43,10 @@ define([ ...@@ -40,6 +43,10 @@ define([
expect(groups.at(1).get('name')).toBe('Group B'); expect(groups.at(1).get('name')).toBe('Group B');
}); });
it('should have an empty usage by default', function() {
expect(this.model.get('usage')).toBeEmpty();
});
it('should be able to reset itself', function() { it('should be able to reset itself', function() {
this.model.set('name', 'foobar'); this.model.set('name', 'foobar');
this.model.reset(); this.model.reset();
...@@ -120,7 +127,8 @@ define([ ...@@ -120,7 +127,8 @@ define([
'order': 1, 'order': 1,
'name': 'Group 2' 'name': 'Group 2'
} }
] ],
'usage': []
}, },
model = new GroupConfigurationModel( model = new GroupConfigurationModel(
serverModelSpec, { parse: true } serverModelSpec, { parse: true }
......
...@@ -28,6 +28,12 @@ define([ ...@@ -28,6 +28,12 @@ define([
inputGroupName: '.group-name', inputGroupName: '.group-name',
inputName: '.group-configuration-name-input', inputName: '.group-configuration-name-input',
inputDescription: '.group-configuration-description-input', inputDescription: '.group-configuration-description-input',
usageCount: '.group-configuration-usage-count',
usage: '.group-configuration-usage',
usageText: '.group-configuration-usage-text',
usageTextAnchor: '.group-configuration-usage-text > a',
usageUnit: '.group-configuration-usage-unit',
usageUnitAnchor: '.group-configuration-usage-unit > a'
}; };
beforeEach(function() { beforeEach(function() {
...@@ -89,6 +95,7 @@ define([ ...@@ -89,6 +95,7 @@ define([
}); });
this.collection = new GroupConfigurationCollection([ this.model ]); this.collection = new GroupConfigurationCollection([ this.model ]);
this.collection.outlineUrl = '/outline';
this.view = new GroupConfigurationDetails({ this.view = new GroupConfigurationDetails({
model: this.model model: this.model
}); });
...@@ -126,6 +133,70 @@ define([ ...@@ -126,6 +133,70 @@ define([
expect(this.view.$(SELECTORS.description)).not.toExist(); expect(this.view.$(SELECTORS.description)).not.toExist();
expect(this.view.$(SELECTORS.groupsAllocation)).not.toExist(); expect(this.view.$(SELECTORS.groupsAllocation)).not.toExist();
}); });
it('should show empty usage appropriately', function() {
this.model.set('showGroups', false);
this.view.$('.show-groups').click();
expect(this.view.$(SELECTORS.usageCount)).not.toExist();
expect(this.view.$(SELECTORS.usageText))
.toContainText('This Group Configuration is not in use. ' +
'Start by adding a content experiment to any ' +
'Unit via the');
expect(this.view.$(SELECTORS.usageTextAnchor)).toExist();
expect(this.view.$(SELECTORS.usageUnit)).not.toExist();
});
it('should hide empty usage appropriately', function() {
this.model.set('showGroups', true);
this.view.$('.hide-groups').click();
expect(this.view.$(SELECTORS.usageText)).not.toExist();
expect(this.view.$(SELECTORS.usageUnit)).not.toExist();
expect(this.view.$(SELECTORS.usageCount))
.toContainText('Not in Use');
});
it('should show non-empty usage appropriately', function() {
var usageUnitAnchors;
this.model.set('usage',
[
{'label': 'label1', 'url': 'url1'},
{'label': 'label2', 'url': 'url2'}
]
);
this.model.set('showGroups', false);
this.view.$('.show-groups').click();
usageUnitAnchors = this.view.$(SELECTORS.usageUnitAnchor);
expect(this.view.$(SELECTORS.usageCount)).not.toExist();
expect(this.view.$(SELECTORS.usageText))
.toContainText('This Group Configuration is used in:');
expect(this.view.$(SELECTORS.usageUnit).length).toBe(2);
expect(usageUnitAnchors.length).toBe(2);
expect(usageUnitAnchors.eq(0)).toContainText('label1');
expect(usageUnitAnchors.eq(0).attr('href')).toBe('url1');
expect(usageUnitAnchors.eq(1)).toContainText('label2');
expect(usageUnitAnchors.eq(1).attr('href')).toBe('url2');
});
it('should hide non-empty usage appropriately', function() {
this.model.set('usage',
[
{'label': 'label1', 'url': 'url1'},
{'label': 'label2', 'url': 'url2'}
]
);
this.model.set('showGroups', true);
this.view.$('.hide-groups').click();
expect(this.view.$(SELECTORS.usageText)).not.toExist();
expect(this.view.$(SELECTORS.usageUnit)).not.toExist();
expect(this.view.$(SELECTORS.usageCount))
.toContainText('Used in 2 units');
});
}); });
describe('GroupConfigurationEdit', function() { describe('GroupConfigurationEdit', function() {
...@@ -418,5 +489,3 @@ define([ ...@@ -418,5 +489,3 @@ define([
}); });
}); });
}); });
define([ define([
'js/views/baseview', 'underscore', 'gettext' 'js/views/baseview', 'underscore', 'gettext', 'underscore.string'
], ],
function(BaseView, _, gettext) { function(BaseView, _, gettext, str) {
'use strict'; 'use strict';
var GroupConfigurationDetails = BaseView.extend({ var GroupConfigurationDetails = BaseView.extend({
tagName: 'div', tagName: 'div',
...@@ -30,6 +30,8 @@ function(BaseView, _, gettext) { ...@@ -30,6 +30,8 @@ function(BaseView, _, gettext) {
render: function() { render: function() {
var attrs = $.extend({}, this.model.attributes, { var attrs = $.extend({}, this.model.attributes, {
groupsCountMessage: this.getGroupsCountTitle(), groupsCountMessage: this.getGroupsCountTitle(),
usageCountMessage: this.getUsageCountTitle(),
outlineAnchorMessage: this.getOutlineAnchorMessage(),
index: this.model.collection.indexOf(this.model) index: this.model.collection.indexOf(this.model)
}); });
...@@ -64,6 +66,44 @@ function(BaseView, _, gettext) { ...@@ -64,6 +66,44 @@ function(BaseView, _, gettext) {
); );
return interpolate(message, { count: count }, true); return interpolate(message, { count: count }, true);
},
getUsageCountTitle: function () {
var count = this.model.get('usage').length, message;
if (count === 0) {
message = gettext('Not in Use');
} else {
message = ngettext(
/*
Translators: 'count' is number of units that the group
configuration is used in.
*/
'Used in %(count)s unit', 'Used in %(count)s units',
count
);
}
return interpolate(message, { count: count }, true);
},
getOutlineAnchorMessage: function () {
var message = gettext(
/*
Translators: 'outlineAnchor' is an anchor pointing to
the course outline page.
*/
'This Group Configuration is not in use. Start by adding a content experiment to any Unit via the %(outlineAnchor)s.'
),
anchor = str.sprintf(
'<a href="%(url)s" title="%(text)s">%(text)s</a>',
{
url: this.model.collection.outlineUrl,
text: gettext('Course Outline')
}
);
return str.sprintf(message, {outlineAnchor: anchor});
} }
}); });
......
...@@ -42,142 +42,164 @@ ...@@ -42,142 +42,164 @@
outline: none; outline: none;
.group-configuration-details { .group-configuration-details {
padding: $baseline ($baseline*1.5); .wrapper-group-configuration {
padding: $baseline ($baseline*1.5);
.group-configuration-header { .group-configuration-header {
margin-bottom: 0; margin-bottom: 0;
border-bottom: 0; border-bottom: 0;
} }
.group-configuration-title { .group-configuration-title {
@extend %t-title; @extend %t-title;
@include font-size(22); @include font-size(22);
@include line-height(22); @include line-height(22);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
margin-right: ($baseline*14); margin-right: ($baseline*14);
font-weight: bold; font-weight: bold;
.group-toggle { .group-toggle {
display: inline-block; display: inline-block;
padding-left: $baseline; padding-left: $baseline;
color: $black; color: $black;
&:hover, &:focus { &:hover, &:focus {
color: $blue; color: $blue;
}
} }
} }
}
.group-configuration-info { .group-configuration-info {
@extend %t-copy-sub1; @extend %t-copy-sub1;
color: $gray-l1; color: $gray-l1;
margin-left: $baseline; margin-left: $baseline;
&.group-configuration-info-inline { &.group-configuration-info-inline {
display: table; display: table;
width: 70%; width: 70%;
margin: ($baseline/4) 0 ($baseline/2) $baseline; margin: ($baseline/4) 0 ($baseline/2) $baseline;
li { li {
@include box-sizing(border-box); @include box-sizing(border-box);
display: table-cell; display: table-cell;
margin-right: 1%; margin-right: 1%;
&.group-configuration-usage-count {
font-style: italic;
}
}
} }
}
&.group-configuration-info-block { &.group-configuration-info-block {
li { li {
padding: ($baseline/4) 0; padding: ($baseline/4) 0;
}
} }
}
.group-configuration-label { .group-configuration-label {
text-transform: uppercase; text-transform: uppercase;
} }
.group-configuration-description { .group-configuration-description {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; 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 { .ui-toggle-expansion {
cursor: pointer; @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;
}
&:hover { &.is-selectable {
color: $blue; cursor: pointer;
.ui-toggle-expansion { &:hover {
color: $blue; color: $blue;
.ui-toggle-expansion {
color: $blue;
}
} }
} }
}
.groups { .groups {
margin-left: $baseline; margin-left: $baseline;
margin-bottom: ($baseline*0.75); margin-bottom: ($baseline*0.75);
.group { .group {
@extend %t-copy-sub2; @extend %t-copy-sub2;
@include font-size(18); @include font-size(18);
@include line-height(16); @include line-height(16);
padding: ($baseline/7) 0 ($baseline/4); padding: ($baseline/7) 0 ($baseline/4);
border-top: 1px solid $gray-l4; border-top: 1px solid $gray-l4;
white-space: nowrap; white-space: nowrap;
&:first-child { &:first-child {
border-top: none; border-top: none;
} }
.group-name { .group-name {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
width: 75%; width: 75%;
margin-right: 5%; margin-right: 5%;
}
.group-allocation {
display: inline-block;
vertical-align: middle;
width: 20%;
color: $gray-l1;
text-align: right;
}
} }
}
.group-allocation { .actions {
@include transition(opacity .15s .25s ease-in-out);
opacity: 0.0;
position: absolute;
top: $baseline;
right: $baseline;
.action {
display: inline-block; display: inline-block;
vertical-align: middle; margin-right: ($baseline/4);
width: 20%;
color: $gray-l1; .edit {
text-align: right; @include blue-button;
@extend %t-action4;
}
} }
} }
} }
.actions { .wrapper-group-configuration-usages {
@include transition(opacity .15s .25s ease-in-out); @include font-size(14);
opacity: 0.0; background-color: #f8f8f8;
position: absolute; box-shadow: 0 2px 2px 0 $shadow inset;
top: $baseline; padding: $baseline ($baseline*1.5) $baseline ($baseline*2.5);
right: $baseline;
.action { .group-configuration-usage {
display: inline-block; color: $gray-l1;
margin-right: ($baseline/4); margin-left: $baseline;
.edit { .group-configuration-usage-unit {
@include blue-button; padding: ($baseline/4) 0;
@extend %t-action4;
} }
} }
} }
} }
&:hover .actions { &:hover .wrapper-group-configuration .actions {
opacity: 1.0; opacity: 1.0;
} }
} }
......
...@@ -26,6 +26,7 @@ function(doc, GroupConfigurationCollection, GroupConfigurationsPage) { ...@@ -26,6 +26,7 @@ function(doc, GroupConfigurationCollection, GroupConfigurationsPage) {
var collection = new GroupConfigurationCollection(${json.dumps(configurations)}, { parse: true }); var collection = new GroupConfigurationCollection(${json.dumps(configurations)}, { parse: true });
collection.url = "${group_configuration_url}"; collection.url = "${group_configuration_url}";
collection.outlineUrl = "${course_outline_url}";
new GroupConfigurationsPage({ new GroupConfigurationsPage({
el: $('#content'), el: $('#content'),
collection: collection collection: collection
......
...@@ -23,19 +23,22 @@ ...@@ -23,19 +23,22 @@
<li class="group-configuration-groups-count"> <li class="group-configuration-groups-count">
<%= groupsCountMessage %> <%= groupsCountMessage %>
</li> </li>
<li class="group-configuration-usage-count">
<%= usageCountMessage %>
</li>
<% } %> <% } %>
</ol> </ol>
<% if(showGroups) { %> <% if(showGroups) { %>
<% allocation = Math.floor(100 / groups.length) %> <% allocation = Math.floor(100 / groups.length) %>
<ol class="groups groups-<%= index %>"> <ol class="groups groups-<%= index %>">
<% groups.each(function(group, groupIndex) { %> <% groups.each(function(group, groupIndex) { %>
<li class="group group-<%= groupIndex %>" <li class="group group-<%= groupIndex %>">
><span class="group-name"><%= group.get('name') %></span <span class="group-name"><%= group.get('name') %></span>
><span class="group-allocation"><%= allocation %>%</span <span class="group-allocation"><%= allocation %>%</span>
></li> </li>
<% }) %> <% }) %>
</ol> </ol>
<% } %> <% } %>
<ul class="actions group-configuration-actions"> <ul class="actions group-configuration-actions">
<li class="action action-edit"> <li class="action action-edit">
...@@ -43,3 +46,21 @@ ...@@ -43,3 +46,21 @@
</li> </li>
</ul> </ul>
</div> </div>
<% if(showGroups) { %>
<div class="wrapper-group-configuration-usages">
<% if (!_.isEmpty(usage)) { %>
<h4 class="group-configuration-usage-text"><%= gettext('This Group Configuration is used in:') %></h4>
<ol class="group-configuration-usage">
<% _.each(usage, function(unit) { %>
<li class="group-configuration-usage-unit">
<a href=<%= unit.url %> ><%= unit.label %></a>
</li>
<% }) %>
</ol>
<% } else { %>
<p class="group-configuration-usage-text">
<%= outlineAnchorMessage %>
</p>
<% } %>
</div>
<% } %>
...@@ -99,6 +99,7 @@ class XBlockFixtureDesc(object): ...@@ -99,6 +99,7 @@ class XBlockFixtureDesc(object):
self.grader_type = grader_type self.grader_type = grader_type
self.publish = publish self.publish = publish
self.children = [] self.children = []
self.locator = None
def add_children(self, *args): def add_children(self, *args):
""" """
...@@ -137,11 +138,12 @@ class XBlockFixtureDesc(object): ...@@ -137,11 +138,12 @@ class XBlockFixtureDesc(object):
metadata={2}, metadata={2},
grader_type={3}, grader_type={3},
publish={4}, publish={4},
children={5} children={5},
locator={6},
> >
""").strip().format( """).strip().format(
self.category, self.data, self.metadata, self.category, self.data, self.metadata,
self.grader_type, self.publish, self.children self.grader_type, self.publish, self.children, self.locator
) )
...@@ -199,7 +201,7 @@ class CourseFixture(StudioApiFixture): ...@@ -199,7 +201,7 @@ class CourseFixture(StudioApiFixture):
self._updates = [] self._updates = []
self._handouts = [] self._handouts = []
self._children = [] self.children = []
self._assets = [] self._assets = []
self._advanced_settings = {} self._advanced_settings = {}
...@@ -216,7 +218,7 @@ class CourseFixture(StudioApiFixture): ...@@ -216,7 +218,7 @@ class CourseFixture(StudioApiFixture):
Returns the course fixture to allow chaining. Returns the course fixture to allow chaining.
""" """
self._children.extend(args) self.children.extend(args)
return self return self
def add_update(self, update): def add_update(self, update):
...@@ -257,7 +259,7 @@ class CourseFixture(StudioApiFixture): ...@@ -257,7 +259,7 @@ class CourseFixture(StudioApiFixture):
self._configure_course() self._configure_course()
self._upload_assets() self._upload_assets()
self._add_advanced_settings() self._add_advanced_settings()
self._create_xblock_children(self._course_location, self._children) self._create_xblock_children(self._course_location, self.children)
return self return self
...@@ -362,7 +364,7 @@ class CourseFixture(StudioApiFixture): ...@@ -362,7 +364,7 @@ class CourseFixture(StudioApiFixture):
# Construct HTML with each of the handout links # Construct HTML with each of the handout links
handouts_li = [ handouts_li = [
'<li><a href="/static/{handout}">Example Handout</a></li>'.format(handout=handout) '<li><a href="/static/{handout}">Example Handout</a></li>'.format(handout=handout)
for handout in self._handouts for handout in self._handouts
] ]
handouts_html = '<ol class="treeview-handoutsnav">{}</ol>'.format("".join(handouts_li)) handouts_html = '<ol class="treeview-handoutsnav">{}</ol>'.format("".join(handouts_li))
...@@ -446,12 +448,31 @@ class CourseFixture(StudioApiFixture): ...@@ -446,12 +448,31 @@ class CourseFixture(StudioApiFixture):
Recursively create XBlock children. Recursively create XBlock children.
""" """
for desc in xblock_descriptions: for desc in xblock_descriptions:
loc = self._create_xblock(parent_loc, desc) loc = self.create_xblock(parent_loc, desc)
self._create_xblock_children(loc, desc.children) self._create_xblock_children(loc, desc.children)
self._publish_xblock(parent_loc) self._publish_xblock(parent_loc)
def _create_xblock(self, parent_loc, xblock_desc): def get_nested_xblocks(self, category=None):
"""
Return a list of nested XBlocks for the course that can be filtered by
category.
"""
xblocks = self._get_nested_xblocks(self)
if category:
xblocks = filter(lambda x: x.category == category, xblocks)
return xblocks
def _get_nested_xblocks(self, xblock_descriptor):
"""
Return a list of nested XBlocks for the course.
"""
xblocks = list(xblock_descriptor.children)
for child in xblock_descriptor.children:
xblocks.extend(self._get_nested_xblocks(child))
return xblocks
def create_xblock(self, parent_loc, xblock_desc):
""" """
Create an XBlock with `parent_loc` (the location of the parent block) Create an XBlock with `parent_loc` (the location of the parent block)
and `xblock_desc` (an `XBlockFixtureDesc` instance). and `xblock_desc` (an `XBlockFixtureDesc` instance).
...@@ -477,6 +498,7 @@ class CourseFixture(StudioApiFixture): ...@@ -477,6 +498,7 @@ class CourseFixture(StudioApiFixture):
try: try:
loc = response.json().get('locator') loc = response.json().get('locator')
xblock_desc.locator = loc
except ValueError: except ValueError:
raise CourseFixtureError("Could not decode JSON from '{0}'".format(response.content)) raise CourseFixtureError("Could not decode JSON from '{0}'".format(response.content))
......
...@@ -89,6 +89,9 @@ class CourseOutlineUnit(CourseOutlineChild): ...@@ -89,6 +89,9 @@ class CourseOutlineUnit(CourseOutlineChild):
""" """
return UnitPage(self.browser, self.locator).visit() return UnitPage(self.browser, self.locator).visit()
def is_browser_on_page(self):
return self.q(css=self.BODY_SELECTOR).present
class CourseOutlineSubsection(CourseOutlineChild, CourseOutlineContainer): class CourseOutlineSubsection(CourseOutlineChild, CourseOutlineContainer):
""" """
...@@ -197,4 +200,3 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer): ...@@ -197,4 +200,3 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
Open release date edit modal of first section in course outline Open release date edit modal of first section in course outline
""" """
self.q(css='div.section-published-date a.edit-release-date').first.click() self.q(css='div.section-published-date a.edit-release-date').first.click()
...@@ -15,6 +15,7 @@ class GroupConfigurationsPage(CoursePage): ...@@ -15,6 +15,7 @@ class GroupConfigurationsPage(CoursePage):
def is_browser_on_page(self): def is_browser_on_page(self):
return self.q(css='body.view-group-configurations').present return self.q(css='body.view-group-configurations').present
@property
def group_configurations(self): def group_configurations(self):
""" """
Return list of the group configurations for the course. Return list of the group configurations for the course.
...@@ -68,6 +69,20 @@ class GroupConfiguration(object): ...@@ -68,6 +69,20 @@ class GroupConfiguration(object):
""" """
return self.find_css(css).first.text[0] return self.find_css(css).first.text[0]
def click_outline_anchor(self):
"""
Click on the `Course Outline` link.
"""
css = 'p.group-configuration-usage-text a'
self.find_css(css).first.click()
def click_unit_anchor(self, index=0):
"""
Click on the link to the unit.
"""
css = 'li.group-configuration-usage-unit a'
self.find_css(css).nth(index).click()
def edit(self): def edit(self):
""" """
Open editing view for the group configuration. Open editing view for the group configuration.
...@@ -115,6 +130,14 @@ class GroupConfiguration(object): ...@@ -115,6 +130,14 @@ class GroupConfiguration(object):
return self.get_text('.message-status.error') return self.get_text('.message-status.error')
@property @property
def usages(self):
"""
Return list of usages.
"""
css = '.group-configuration-usage-unit'
return self.find_css(css).text
@property
def name(self): def name(self):
""" """
Return group configuration name. Return group configuration name.
......
...@@ -14,6 +14,8 @@ class UnitPage(PageObject): ...@@ -14,6 +14,8 @@ class UnitPage(PageObject):
Unit page in Studio Unit page in Studio
""" """
NAME_SELECTOR = '#unit-display-name-input'
def __init__(self, browser, unit_locator): def __init__(self, browser, unit_locator):
super(UnitPage, self).__init__(browser) super(UnitPage, self).__init__(browser)
self.unit_locator = unit_locator self.unit_locator = unit_locator
...@@ -39,6 +41,10 @@ class UnitPage(PageObject): ...@@ -39,6 +41,10 @@ class UnitPage(PageObject):
) )
@property @property
def name(self):
return self.q(css=self.NAME_SELECTOR).attrs('value')[0]
@property
def components(self): def components(self):
""" """
Return a list of components loaded on the unit page. Return a list of components loaded on the unit page.
...@@ -87,6 +93,7 @@ COMPONENT_BUTTONS = { ...@@ -87,6 +93,7 @@ COMPONENT_BUTTONS = {
'save_settings': '.action-save', 'save_settings': '.action-save',
} }
class Component(PageObject): class Component(PageObject):
""" """
A PageObject representing an XBlock child on the Studio UnitPage (including A PageObject representing an XBlock child on the Studio UnitPage (including
......
...@@ -8,13 +8,15 @@ import math ...@@ -8,13 +8,15 @@ import math
from unittest import skip, skipUnless from unittest import skip, skipUnless
from xmodule.partitions.partitions import Group, UserPartition from xmodule.partitions.partitions import Group, UserPartition
from bok_choy.promise import Promise from bok_choy.promise import Promise, EmptyPromise
from ..fixtures.course import XBlockFixtureDesc from ..fixtures.course import XBlockFixtureDesc
from ..pages.studio.component_editor import ComponentEditorView from ..pages.studio.component_editor import ComponentEditorView
from ..pages.studio.overview import CourseOutlinePage
from ..pages.studio.settings_advanced import AdvancedSettingsPage from ..pages.studio.settings_advanced import AdvancedSettingsPage
from ..pages.studio.settings_group_configurations import GroupConfigurationsPage from ..pages.studio.settings_group_configurations import GroupConfigurationsPage
from ..pages.studio.utils import add_advanced_component from ..pages.studio.utils import add_advanced_component
from ..pages.studio.unit import UnitPage
from ..pages.xblock.utils import wait_for_xblock_initialization from ..pages.xblock.utils import wait_for_xblock_initialization
from acceptance.tests.base_studio_test import StudioCourseTest from acceptance.tests.base_studio_test import StudioCourseTest
...@@ -238,6 +240,13 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -238,6 +240,13 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_info['run'] self.course_info['run']
) )
self.outline_page = CourseOutlinePage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
def _assert_fields(self, config, cid=None, name='', description='', groups=None): def _assert_fields(self, config, cid=None, name='', description='', groups=None):
self.assertEqual(config.mode, 'details') self.assertEqual(config.mode, 'details')
...@@ -317,7 +326,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -317,7 +326,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
}) })
self.page.visit() self.page.visit()
config = self.page.group_configurations()[0] config = self.page.group_configurations[0]
# no groups when the the configuration is collapsed # no groups when the the configuration is collapsed
self.assertEqual(len(config.groups), 0) self.assertEqual(len(config.groups), 0)
self._assert_fields( self._assert_fields(
...@@ -327,7 +336,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -327,7 +336,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
groups=["Group 0", "Group 1"] groups=["Group 0", "Group 1"]
) )
config = self.page.group_configurations()[1] config = self.page.group_configurations[1]
self._assert_fields( self._assert_fields(
config, config,
...@@ -350,10 +359,10 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -350,10 +359,10 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
Then I see the group configuration is saved successfully and has the new data Then I see the group configuration is saved successfully and has the new data
""" """
self.page.visit() self.page.visit()
self.assertEqual(len(self.page.group_configurations()), 0) self.assertEqual(len(self.page.group_configurations), 0)
# Create new group configuration # Create new group configuration
self.page.create() self.page.create()
config = self.page.group_configurations()[0] config = self.page.group_configurations[0]
config.name = "New Group Configuration Name" config.name = "New Group Configuration Name"
config.description = "New Description of the group configuration." config.description = "New Description of the group configuration."
config.groups[1].name = "New Group Name" config.groups[1].name = "New Group Name"
...@@ -418,7 +427,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -418,7 +427,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.page.visit() self.page.visit()
# Create new group configuration # Create new group configuration
self.page.create() self.page.create()
config = self.page.group_configurations()[0] config = self.page.group_configurations[0]
config.name = "New Group Configuration Name" config.name = "New Group Configuration Name"
# Add new group # Add new group
config.add_group() config.add_group()
...@@ -435,7 +444,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -435,7 +444,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.verify_groups(container, ['Group A', 'Group B', 'New group'], []) self.verify_groups(container, ['Group A', 'Group B', 'New group'], [])
self.page.visit() self.page.visit()
config = self.page.group_configurations()[0] config = self.page.group_configurations[0]
config.edit() config.edit()
config.name = "Second Group Configuration Name" config.name = "Second Group Configuration Name"
# Add new group # Add new group
...@@ -476,11 +485,11 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -476,11 +485,11 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
""" """
self.page.visit() self.page.visit()
self.assertEqual(len(self.page.group_configurations()), 0) self.assertEqual(len(self.page.group_configurations), 0)
# Create new group configuration # Create new group configuration
self.page.create() self.page.create()
config = self.page.group_configurations()[0] config = self.page.group_configurations[0]
config.name = "Name of the Group Configuration" config.name = "Name of the Group Configuration"
config.description = "Description of the group configuration." config.description = "Description of the group configuration."
# Add new group # Add new group
...@@ -488,7 +497,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -488,7 +497,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
# Cancel the configuration # Cancel the configuration
config.cancel() config.cancel()
self.assertEqual(len(self.page.group_configurations()), 0) self.assertEqual(len(self.page.group_configurations), 0)
def test_can_cancel_editing_of_group_configuration(self): def test_can_cancel_editing_of_group_configuration(self):
""" """
...@@ -508,8 +517,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -508,8 +517,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
}, },
}) })
self.page.visit() self.page.visit()
config = self.page.group_configurations[0]
config = self.page.group_configurations()[0]
config.name = "New Group Configuration Name" config.name = "New Group Configuration Name"
config.description = "New Description of the group configuration." config.description = "New Description of the group configuration."
# Add 2 new groups # Add 2 new groups
...@@ -552,7 +560,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -552,7 +560,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
# Create new group configuration # Create new group configuration
self.page.create() self.page.create()
# Leave empty required field # Leave empty required field
config = self.page.group_configurations()[0] config = self.page.group_configurations[0]
config.description = "Description of the group configuration." config.description = "Description of the group configuration."
try_to_save_and_verify_error_message("Group Configuration name is required") try_to_save_and_verify_error_message("Group Configuration name is required")
...@@ -574,3 +582,76 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -574,3 +582,76 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
description="Description of the group configuration.", description="Description of the group configuration.",
groups=["Group A", "Group B"] groups=["Group A", "Group B"]
) )
def test_group_configuration_empty_usage(self):
"""
Scenario: When group configuration is not used, ensure that the link to outline page works correctly.
Given I have a course without group configurations
And I create new group configuration with 2 default groups
Then I see a link to the outline page
When I click on the outline link
Then I see the outline page
"""
# Create a new group configurations
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
UserPartition(0, "Name", "Description.", [Group("0", "Group A"), Group("1", "Group B")]).to_json(),
],
},
})
# Go to the Group Configuration Page and click on outline anchor
self.page.visit()
config = self.page.group_configurations[0]
config.toggle()
config.click_outline_anchor()
# Waiting for the page load and verify that we've landed on course outline page
EmptyPromise(
lambda: self.outline_page.is_browser_on_page(), "loaded page {!r}".format(self.outline_page),
timeout=30
).fulfill()
def test_group_configuration_non_empty_usage(self):
"""
Scenario: When group configuration is used, ensure that the links to units using a group configuration work correctly.
Given I have a course without group configurations
And I create new group configuration with 2 default groups
And I create a unit and assign the newly created group configuration
And open the Group Configuration page
Then I see a link to the newly created unit
When I click on the unit link
Then I see correct unit page
"""
# Create a new group configurations
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
UserPartition(0, "Name", "Description.", [Group("0", "Group A"), Group("1", "Group B")]).to_json(),
],
},
})
# Assign newly created group configuration to unit
vertical = self.course_fixture.get_nested_xblocks(category="vertical")[0]
self.course_fixture.create_xblock(
vertical.locator,
XBlockFixtureDesc('split_test', 'Test Content Experiment', metadata={'user_partition_id': 0})
)
unit = UnitPage(self.browser, vertical.locator)
# Go to the Group Configuration Page and click unit anchor
self.page.visit()
config = self.page.group_configurations[0]
config.toggle()
usage = config.usages[0]
config.click_unit_anchor()
# Waiting for the page load and verify that we've landed on the unit page
EmptyPromise(
lambda: unit.is_browser_on_page(), "loaded page {!r}".format(unit),
timeout=30
).fulfill()
self.assertIn(unit.name, usage)
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