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
import json
import random
import string # pylint: disable=W0402
import logging
from django.utils.translation import ugettext as _
import django.utils
......@@ -32,7 +33,8 @@ from contentstore.utils import (
get_lms_link_for_item,
add_extra_panel_tab,
remove_extra_panel_tab,
reverse_course_url
reverse_course_url,
reverse_usage_url,
)
from models.settings.course_details import CourseDetails, CourseSettingsEncoder
......@@ -70,6 +72,8 @@ __all__ = ['course_info_handler', 'course_handler', 'course_info_update_handler'
'textbooks_list_handler', 'textbooks_detail_handler',
'group_configurations_list_handler', 'group_configurations_detail_handler']
log = logging.getLogger(__name__)
class AccessListFallback(Exception):
"""
......@@ -949,6 +953,62 @@ class GroupConfiguration(object):
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"))
@login_required
......@@ -968,12 +1028,16 @@ def group_configurations_list_handler(request, course_key_string):
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
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
configurations = GroupConfiguration.add_usage_info(course, store)
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 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'):
if request.method == 'POST':
......
......@@ -6,8 +6,10 @@ from unittest import skipUnless
from django.conf import settings
from contentstore.utils import reverse_course_url
from contentstore.views.component import SPLIT_TEST_COMPONENT_TYPE
from contentstore.views.course import GroupConfiguration
from contentstore.tests.utils import CourseTestCase
from xmodule.partitions.partitions import Group, UserPartition
from xmodule.modulestore.tests.factories import ItemFactory
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
class GroupConfigurationsBaseTestCase(object):
"""
......@@ -286,3 +326,118 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
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[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) {
}
]),
showGroups: false,
editing: false
editing: false,
usage: []
};
},
......
......@@ -9,6 +9,9 @@ define([
this.addMatchers({
toBeInstanceOf: function(expected) {
return this.actual instanceof expected;
},
toBeEmpty: function() {
return this.actual.length === 0;
}
});
});
......@@ -40,6 +43,10 @@ define([
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() {
this.model.set('name', 'foobar');
this.model.reset();
......@@ -120,7 +127,8 @@ define([
'order': 1,
'name': 'Group 2'
}
]
],
'usage': []
},
model = new GroupConfigurationModel(
serverModelSpec, { parse: true }
......
......@@ -28,6 +28,12 @@ define([
inputGroupName: '.group-name',
inputName: '.group-configuration-name-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() {
......@@ -89,6 +95,7 @@ define([
});
this.collection = new GroupConfigurationCollection([ this.model ]);
this.collection.outlineUrl = '/outline';
this.view = new GroupConfigurationDetails({
model: this.model
});
......@@ -126,6 +133,70 @@ define([
expect(this.view.$(SELECTORS.description)).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() {
......@@ -418,5 +489,3 @@ define([
});
});
});
define([
'js/views/baseview', 'underscore', 'gettext'
'js/views/baseview', 'underscore', 'gettext', 'underscore.string'
],
function(BaseView, _, gettext) {
function(BaseView, _, gettext, str) {
'use strict';
var GroupConfigurationDetails = BaseView.extend({
tagName: 'div',
......@@ -30,6 +30,8 @@ function(BaseView, _, gettext) {
render: function() {
var attrs = $.extend({}, this.model.attributes, {
groupsCountMessage: this.getGroupsCountTitle(),
usageCountMessage: this.getUsageCountTitle(),
outlineAnchorMessage: this.getOutlineAnchorMessage(),
index: this.model.collection.indexOf(this.model)
});
......@@ -64,6 +66,44 @@ function(BaseView, _, gettext) {
);
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 @@
outline: none;
.group-configuration-details {
padding: $baseline ($baseline*1.5);
.wrapper-group-configuration {
padding: $baseline ($baseline*1.5);
.group-configuration-header {
margin-bottom: 0;
border-bottom: 0;
}
.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-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;
.group-toggle {
display: inline-block;
padding-left: $baseline;
color: $black;
&:hover, &:focus {
color: $blue;
&:hover, &:focus {
color: $blue;
}
}
}
}
.group-configuration-info {
@extend %t-copy-sub1;
color: $gray-l1;
margin-left: $baseline;
.group-configuration-info {
@extend %t-copy-sub1;
color: $gray-l1;
margin-left: $baseline;
&.group-configuration-info-inline {
display: table;
width: 70%;
margin: ($baseline/4) 0 ($baseline/2) $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%;
li {
@include box-sizing(border-box);
display: table-cell;
margin-right: 1%;
&.group-configuration-usage-count {
font-style: italic;
}
}
}
}
&.group-configuration-info-block {
li {
padding: ($baseline/4) 0;
&.group-configuration-info-block {
li {
padding: ($baseline/4) 0;
}
}
}
.group-configuration-label {
text-transform: uppercase;
}
.group-configuration-label {
text-transform: uppercase;
}
.group-configuration-description {
overflow: hidden;
text-overflow: ellipsis;
.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;
.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;
}
&:hover {
color: $blue;
&.is-selectable {
cursor: pointer;
.ui-toggle-expansion {
&:hover {
color: $blue;
.ui-toggle-expansion {
color: $blue;
}
}
}
}
.groups {
margin-left: $baseline;
margin-bottom: ($baseline*0.75);
.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;
.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;
}
&:first-child {
border-top: none;
}
.group-name {
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
vertical-align: middle;
width: 75%;
margin-right: 5%;
.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;
}
}
}
.group-allocation {
.actions {
@include transition(opacity .15s .25s ease-in-out);
opacity: 0.0;
position: absolute;
top: $baseline;
right: $baseline;
.action {
display: inline-block;
vertical-align: middle;
width: 20%;
color: $gray-l1;
text-align: right;
margin-right: ($baseline/4);
.edit {
@include blue-button;
@extend %t-action4;
}
}
}
}
.actions {
@include transition(opacity .15s .25s ease-in-out);
opacity: 0.0;
position: absolute;
top: $baseline;
right: $baseline;
.wrapper-group-configuration-usages {
@include font-size(14);
background-color: #f8f8f8;
box-shadow: 0 2px 2px 0 $shadow inset;
padding: $baseline ($baseline*1.5) $baseline ($baseline*2.5);
.action {
display: inline-block;
margin-right: ($baseline/4);
.group-configuration-usage {
color: $gray-l1;
margin-left: $baseline;
.edit {
@include blue-button;
@extend %t-action4;
.group-configuration-usage-unit {
padding: ($baseline/4) 0;
}
}
}
}
&:hover .actions {
&:hover .wrapper-group-configuration .actions {
opacity: 1.0;
}
}
......
......@@ -26,6 +26,7 @@ function(doc, GroupConfigurationCollection, GroupConfigurationsPage) {
var collection = new GroupConfigurationCollection(${json.dumps(configurations)}, { parse: true });
collection.url = "${group_configuration_url}";
collection.outlineUrl = "${course_outline_url}";
new GroupConfigurationsPage({
el: $('#content'),
collection: collection
......
......@@ -23,19 +23,22 @@
<li class="group-configuration-groups-count">
<%= groupsCountMessage %>
</li>
<li class="group-configuration-usage-count">
<%= usageCountMessage %>
</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>
<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>
<% } %>
<ul class="actions group-configuration-actions">
<li class="action action-edit">
......@@ -43,3 +46,21 @@
</li>
</ul>
</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):
self.grader_type = grader_type
self.publish = publish
self.children = []
self.locator = None
def add_children(self, *args):
"""
......@@ -137,11 +138,12 @@ class XBlockFixtureDesc(object):
metadata={2},
grader_type={3},
publish={4},
children={5}
children={5},
locator={6},
>
""").strip().format(
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):
self._updates = []
self._handouts = []
self._children = []
self.children = []
self._assets = []
self._advanced_settings = {}
......@@ -216,7 +218,7 @@ class CourseFixture(StudioApiFixture):
Returns the course fixture to allow chaining.
"""
self._children.extend(args)
self.children.extend(args)
return self
def add_update(self, update):
......@@ -257,7 +259,7 @@ class CourseFixture(StudioApiFixture):
self._configure_course()
self._upload_assets()
self._add_advanced_settings()
self._create_xblock_children(self._course_location, self._children)
self._create_xblock_children(self._course_location, self.children)
return self
......@@ -362,7 +364,7 @@ class CourseFixture(StudioApiFixture):
# Construct HTML with each of the handout links
handouts_li = [
'<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))
......@@ -446,12 +448,31 @@ class CourseFixture(StudioApiFixture):
Recursively create XBlock children.
"""
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._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)
and `xblock_desc` (an `XBlockFixtureDesc` instance).
......@@ -477,6 +498,7 @@ class CourseFixture(StudioApiFixture):
try:
loc = response.json().get('locator')
xblock_desc.locator = loc
except ValueError:
raise CourseFixtureError("Could not decode JSON from '{0}'".format(response.content))
......
......@@ -89,6 +89,9 @@ class CourseOutlineUnit(CourseOutlineChild):
"""
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):
"""
......@@ -197,4 +200,3 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
Open release date edit modal of first section in course outline
"""
self.q(css='div.section-published-date a.edit-release-date').first.click()
......@@ -15,6 +15,7 @@ class GroupConfigurationsPage(CoursePage):
def is_browser_on_page(self):
return self.q(css='body.view-group-configurations').present
@property
def group_configurations(self):
"""
Return list of the group configurations for the course.
......@@ -68,6 +69,20 @@ class GroupConfiguration(object):
"""
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):
"""
Open editing view for the group configuration.
......@@ -115,6 +130,14 @@ class GroupConfiguration(object):
return self.get_text('.message-status.error')
@property
def usages(self):
"""
Return list of usages.
"""
css = '.group-configuration-usage-unit'
return self.find_css(css).text
@property
def name(self):
"""
Return group configuration name.
......
......@@ -14,6 +14,8 @@ class UnitPage(PageObject):
Unit page in Studio
"""
NAME_SELECTOR = '#unit-display-name-input'
def __init__(self, browser, unit_locator):
super(UnitPage, self).__init__(browser)
self.unit_locator = unit_locator
......@@ -39,6 +41,10 @@ class UnitPage(PageObject):
)
@property
def name(self):
return self.q(css=self.NAME_SELECTOR).attrs('value')[0]
@property
def components(self):
"""
Return a list of components loaded on the unit page.
......@@ -87,6 +93,7 @@ COMPONENT_BUTTONS = {
'save_settings': '.action-save',
}
class Component(PageObject):
"""
A PageObject representing an XBlock child on the Studio UnitPage (including
......
......@@ -8,13 +8,15 @@ import math
from unittest import skip, skipUnless
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 ..pages.studio.component_editor import ComponentEditorView
from ..pages.studio.overview import CourseOutlinePage
from ..pages.studio.settings_advanced import AdvancedSettingsPage
from ..pages.studio.settings_group_configurations import GroupConfigurationsPage
from ..pages.studio.utils import add_advanced_component
from ..pages.studio.unit import UnitPage
from ..pages.xblock.utils import wait_for_xblock_initialization
from acceptance.tests.base_studio_test import StudioCourseTest
......@@ -238,6 +240,13 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
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):
self.assertEqual(config.mode, 'details')
......@@ -317,7 +326,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
})
self.page.visit()
config = self.page.group_configurations()[0]
config = self.page.group_configurations[0]
# no groups when the the configuration is collapsed
self.assertEqual(len(config.groups), 0)
self._assert_fields(
......@@ -327,7 +336,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
groups=["Group 0", "Group 1"]
)
config = self.page.group_configurations()[1]
config = self.page.group_configurations[1]
self._assert_fields(
config,
......@@ -350,10 +359,10 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
Then I see the group configuration is saved successfully and has the new data
"""
self.page.visit()
self.assertEqual(len(self.page.group_configurations()), 0)
self.assertEqual(len(self.page.group_configurations), 0)
# Create new group configuration
self.page.create()
config = self.page.group_configurations()[0]
config = self.page.group_configurations[0]
config.name = "New Group Configuration Name"
config.description = "New Description of the group configuration."
config.groups[1].name = "New Group Name"
......@@ -418,7 +427,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.page.visit()
# Create new group configuration
self.page.create()
config = self.page.group_configurations()[0]
config = self.page.group_configurations[0]
config.name = "New Group Configuration Name"
# Add new group
config.add_group()
......@@ -435,7 +444,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.verify_groups(container, ['Group A', 'Group B', 'New group'], [])
self.page.visit()
config = self.page.group_configurations()[0]
config = self.page.group_configurations[0]
config.edit()
config.name = "Second Group Configuration Name"
# Add new group
......@@ -476,11 +485,11 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
"""
self.page.visit()
self.assertEqual(len(self.page.group_configurations()), 0)
self.assertEqual(len(self.page.group_configurations), 0)
# Create new group configuration
self.page.create()
config = self.page.group_configurations()[0]
config = self.page.group_configurations[0]
config.name = "Name of the Group Configuration"
config.description = "Description of the group configuration."
# Add new group
......@@ -488,7 +497,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
# Cancel the configuration
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):
"""
......@@ -508,8 +517,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
},
})
self.page.visit()
config = self.page.group_configurations()[0]
config = self.page.group_configurations[0]
config.name = "New Group Configuration Name"
config.description = "New Description of the group configuration."
# Add 2 new groups
......@@ -552,7 +560,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
# Create new group configuration
self.page.create()
# Leave empty required field
config = self.page.group_configurations()[0]
config = self.page.group_configurations[0]
config.description = "Description of the group configuration."
try_to_save_and_verify_error_message("Group Configuration name is required")
......@@ -574,3 +582,76 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
description="Description of the group configuration.",
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