diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index b4bd61f34b..4f76a6b8bd 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -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': diff --git a/cms/djangoapps/contentstore/views/tests/test_group_configurations.py b/cms/djangoapps/contentstore/views/tests/test_group_configurations.py index 318806dbff..34e9466762 100644 --- a/cms/djangoapps/contentstore/views/tests/test_group_configurations.py +++ b/cms/djangoapps/contentstore/views/tests/test_group_configurations.py @@ -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: []}) diff --git a/cms/static/js/models/group_configuration.js b/cms/static/js/models/group_configuration.js index 54c209dd4f..f2e42a6531 100644 --- a/cms/static/js/models/group_configuration.js +++ b/cms/static/js/models/group_configuration.js @@ -22,7 +22,8 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) { } ]), showGroups: false, - editing: false + editing: false, + usage: [] }; }, diff --git a/cms/static/js/spec/models/group_configuration_spec.js b/cms/static/js/spec/models/group_configuration_spec.js index e57a12d363..550d1426bc 100644 --- a/cms/static/js/spec/models/group_configuration_spec.js +++ b/cms/static/js/spec/models/group_configuration_spec.js @@ -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 } diff --git a/cms/static/js/spec/views/group_configuration_spec.js b/cms/static/js/spec/views/group_configuration_spec.js index 4aaa6f335d..100ae9890c 100644 --- a/cms/static/js/spec/views/group_configuration_spec.js +++ b/cms/static/js/spec/views/group_configuration_spec.js @@ -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([ }); }); }); - - diff --git a/cms/static/js/views/group_configuration_details.js b/cms/static/js/views/group_configuration_details.js index f00e7d00b6..ac73a52610 100644 --- a/cms/static/js/views/group_configuration_details.js +++ b/cms/static/js/views/group_configuration_details.js @@ -1,7 +1,7 @@ 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( + '%(text)s', + { + url: this.model.collection.outlineUrl, + text: gettext('Course Outline') + } + ); + + return str.sprintf(message, {outlineAnchor: anchor}); } }); diff --git a/cms/static/sass/views/_group-configuration.scss b/cms/static/sass/views/_group-configuration.scss index 9382690ae1..311750c570 100644 --- a/cms/static/sass/views/_group-configuration.scss +++ b/cms/static/sass/views/_group-configuration.scss @@ -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-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 { - @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; - - li { - @include box-sizing(border-box); - display: table-cell; - margin-right: 1%; - } + .group-configuration-header { + margin-bottom: 0; + border-bottom: 0; } - &.group-configuration-info-block { - li { - padding: ($baseline/4) 0; - } - } - - .group-configuration-label { - text-transform: uppercase; - } - - .group-configuration-description { + .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; - .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; - } + .group-toggle { + display: inline-block; + padding-left: $baseline; + color: $black; - &.is-selectable { - cursor: pointer; - - &:hover { - color: $blue; - - .ui-toggle-expansion { - color: $blue; + &:hover, &:focus { + color: $blue; + } } } - } - .groups { - margin-left: $baseline; - margin-bottom: ($baseline*0.75); + .group-configuration-info { + @extend %t-copy-sub1; + color: $gray-l1; + margin-left: $baseline; - .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-configuration-info-inline { + display: table; + width: 70%; + margin: ($baseline/4) 0 ($baseline/2) $baseline; - &:first-child { - border-top: none; + li { + @include box-sizing(border-box); + display: table-cell; + margin-right: 1%; + + &.group-configuration-usage-count { + font-style: italic; + } + } } - .group-name { + &.group-configuration-info-block { + li { + padding: ($baseline/4) 0; + } + } + + .group-configuration-label { + text-transform: uppercase; + } + + .group-configuration-description { overflow: hidden; text-overflow: ellipsis; - display: inline-block; - vertical-align: middle; - width: 75%; - margin-right: 5%; } + } - .group-allocation { + .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; + } + } + } + + .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; } } diff --git a/cms/templates/group_configurations.html b/cms/templates/group_configurations.html index 80aef06cd1..537688573e 100644 --- a/cms/templates/group_configurations.html +++ b/cms/templates/group_configurations.html @@ -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 diff --git a/cms/templates/js/group-configuration-details.underscore b/cms/templates/js/group-configuration-details.underscore index ebaa20a1ad..e96f2a24bf 100644 --- a/cms/templates/js/group-configuration-details.underscore +++ b/cms/templates/js/group-configuration-details.underscore @@ -23,19 +23,22 @@
+ <%= outlineAnchorMessage %> +
+ <% } %> +