Commit a94e9424 by Oleg Marshev Committed by jmclaus

BLD-1125: Validation of content experiments after managing a group.

parent 6dd9d9ec
...@@ -106,7 +106,7 @@ def course_notifications_handler(request, course_key_string=None, action_state_i ...@@ -106,7 +106,7 @@ def course_notifications_handler(request, course_key_string=None, action_state_i
Handle incoming requests for notifications in a RESTful way. Handle incoming requests for notifications in a RESTful way.
course_key_string and action_state_id must both be set; else a HttpBadResponseRequest is returned. course_key_string and action_state_id must both be set; else a HttpBadResponseRequest is returned.
For each of these operations, the requesting user must have access to the course; For each of these operations, the requesting user must have access to the course;
else a PermissionDenied error is returned. else a PermissionDenied error is returned.
...@@ -1123,20 +1123,49 @@ class GroupConfiguration(object): ...@@ -1123,20 +1123,49 @@ class GroupConfiguration(object):
@staticmethod @staticmethod
def get_usage_info(course, store): def get_usage_info(course, store):
""" """
Get all units names and their urls that have experiments and associated Get usage information for all Group Configurations.
with configurations. """
split_tests = store.get_items(course.id, qualifiers={'category': 'split_test'})
return GroupConfiguration._get_usage_info(store, course, split_tests)
@staticmethod
def add_usage_info(course, store):
"""
Add usage information to group configurations jsons in course.
Returns json of group configurations updated with usage information.
"""
usage_info = GroupConfiguration.get_usage_info(course, store)
configurations = []
for partition in course.user_partitions:
configuration = partition.to_json()
configuration['usage'] = usage_info.get(partition.id, [])
configurations.append(configuration)
return configurations
@staticmethod
def _get_usage_info(store, course, split_tests):
"""
Returns all units names, their urls and validation messages.
Returns: Returns:
{'user_partition_id': {'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'} 'label': 'Unit 1 / Experiment 1',
'url': 'url_to_unit_1',
'validation': {'message': 'a validation message', 'type': 'warning'}
},
{
'label': 'Unit 2 / Experiment 2',
'url': 'url_to_unit_2',
'validation': {'message': 'another validation message', 'type': 'error'}
}
], ],
} }
""" """
usage_info = {} usage_info = {}
descriptors = store.get_items(course.id, qualifiers={'category': 'split_test'}) for split_test in split_tests:
for split_test in descriptors:
if split_test.user_partition_id not in usage_info: if split_test.user_partition_id not in usage_info:
usage_info[split_test.user_partition_id] = [] usage_info[split_test.user_partition_id] = []
...@@ -1157,24 +1186,28 @@ class GroupConfiguration(object): ...@@ -1157,24 +1186,28 @@ class GroupConfiguration(object):
) )
usage_info[split_test.user_partition_id].append({ usage_info[split_test.user_partition_id].append({
'label': '{} / {}'.format(unit.display_name, split_test.display_name), 'label': '{} / {}'.format(unit.display_name, split_test.display_name),
'url': unit_url 'url': unit_url,
'validation': split_test.general_validation_message,
}) })
return usage_info return usage_info
@staticmethod @staticmethod
def add_usage_info(course, store): def update_usage_info(store, course, configuration):
""" """
Add usage information to group configurations json. Update usage information for particular Group Configuration.
Returns json of group configurations updated with usage information. Returns json of particular group configuration updated with usage information.
""" """
usage_info = GroupConfiguration.get_usage_info(course, store) # Get all Experiments that use particular Group Configuration in course.
configurations = [] split_tests = store.get_items(
for partition in course.user_partitions: course.id,
configuration = partition.to_json() category='split_test',
configuration['usage'] = usage_info.get(partition.id, []) content={'user_partition_id': configuration.id}
configurations.append(configuration) )
return configurations configuration_json = configuration.to_json()
usage_information = GroupConfiguration._get_usage_info(store, course, split_tests)
configuration_json['usage'] = usage_information.get(configuration.id, [])
return configuration_json
@require_http_methods(("GET", "POST")) @require_http_methods(("GET", "POST"))
...@@ -1262,7 +1295,8 @@ def group_configurations_detail_handler(request, course_key_string, group_config ...@@ -1262,7 +1295,8 @@ def group_configurations_detail_handler(request, course_key_string, group_config
else: else:
course.user_partitions.append(new_configuration) course.user_partitions.append(new_configuration)
store.update_item(course, request.user.id) store.update_item(course, request.user.id)
return JsonResponse(new_configuration.to_json(), status=201) configuration = GroupConfiguration.update_usage_info(store, course, new_configuration)
return JsonResponse(configuration, status=201)
elif request.method == "DELETE": elif request.method == "DELETE":
if not configuration: if not configuration:
return JsonResponse(status=404) return JsonResponse(status=404)
......
...@@ -3,14 +3,14 @@ Group Configuration Tests. ...@@ -3,14 +3,14 @@ Group Configuration Tests.
""" """
import json import json
from mock import patch from mock import patch
from contentstore.utils import reverse_course_url from contentstore.utils import reverse_course_url, reverse_usage_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.views.course import GroupConfiguration
from contentstore.tests.utils import CourseTestCase from contentstore.tests.utils import CourseTestCase
from util.testing import UrlResetMixin from util.testing import UrlResetMixin
from xmodule.partitions.partitions import Group, UserPartition from xmodule.partitions.partitions import Group, UserPartition
from xmodule.modulestore.tests.factories import ItemFactory from xmodule.modulestore.tests.factories import ItemFactory
from xmodule.split_test_module import ValidationMessage, ValidationMessageType
GROUP_CONFIGURATION_JSON = { GROUP_CONFIGURATION_JSON = {
u'name': u'Test name', u'name': u'Test name',
...@@ -27,7 +27,7 @@ class HelperMethods(object): ...@@ -27,7 +27,7 @@ class HelperMethods(object):
""" """
Mixin that provides useful methods for Group Configuration tests. Mixin that provides useful methods for Group Configuration tests.
""" """
def _create_content_experiment(self, cid=None, name_suffix=''): def _create_content_experiment(self, cid=-1, name_suffix=''):
""" """
Create content experiment. Create content experiment.
...@@ -38,12 +38,42 @@ class HelperMethods(object): ...@@ -38,12 +38,42 @@ class HelperMethods(object):
parent_location=self.course.location, parent_location=self.course.location,
display_name='Test Unit {}'.format(name_suffix) display_name='Test Unit {}'.format(name_suffix)
) )
c0_url = self.course.id.make_usage_key("vertical", "split_test_cond0")
c1_url = self.course.id.make_usage_key("vertical", "split_test_cond1")
c2_url = self.course.id.make_usage_key("vertical", "split_test_cond2")
split_test = ItemFactory.create( split_test = ItemFactory.create(
category='split_test', category='split_test',
parent_location=vertical.location, parent_location=vertical.location,
user_partition_id=cid, user_partition_id=cid,
display_name='Test Content Experiment {}'.format(name_suffix) display_name='Test Content Experiment {}'.format(name_suffix),
group_id_to_child={"0": c0_url, "1": c1_url, "2": c2_url}
)
ItemFactory.create(
parent_location=split_test.location,
category="vertical",
display_name="Condition 0 vertical",
location=c0_url,
) )
ItemFactory.create(
parent_location=split_test.location,
category="vertical",
display_name="Condition 1 vertical",
location=c1_url,
)
ItemFactory.create(
parent_location=split_test.location,
category="vertical",
display_name="Condition 2 vertical",
location=c2_url,
)
partitions_json = [p.to_json() for p in self.course.user_partitions]
self.client.ajax_post(
reverse_usage_url("xblock_handler", split_test.location),
data={'metadata': {'user_partitions': partitions_json}}
)
self.save_course() self.save_course()
return (vertical, split_test) return (vertical, split_test)
...@@ -54,7 +84,7 @@ class HelperMethods(object): ...@@ -54,7 +84,7 @@ class HelperMethods(object):
partitions = [ partitions = [
UserPartition( UserPartition(
i, 'Name ' + str(i), 'Description ' + str(i), [Group(0, 'Group A'), Group(1, 'Group B'), Group(2, 'Group C')] i, 'Name ' + str(i), 'Description ' + str(i), [Group(0, 'Group A'), Group(1, 'Group B'), Group(2, 'Group C')]
) for i in xrange(count) ) for i in xrange(0, count)
] ]
self.course.user_partitions = partitions self.course.user_partitions = partitions
...@@ -238,7 +268,7 @@ class GroupConfigurationsDetailHandlerTestCase(UrlResetMixin, CourseTestCase, Gr ...@@ -238,7 +268,7 @@ class GroupConfigurationsDetailHandlerTestCase(UrlResetMixin, CourseTestCase, Gr
Test cases for group_configurations_detail_handler. Test cases for group_configurations_detail_handler.
""" """
ID = 000000000000 ID = 0
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_GROUP_CONFIGURATIONS": True}) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_GROUP_CONFIGURATIONS": True})
def setUp(self): def setUp(self):
...@@ -247,11 +277,11 @@ class GroupConfigurationsDetailHandlerTestCase(UrlResetMixin, CourseTestCase, Gr ...@@ -247,11 +277,11 @@ class GroupConfigurationsDetailHandlerTestCase(UrlResetMixin, CourseTestCase, Gr
""" """
super(GroupConfigurationsDetailHandlerTestCase, self).setUp() super(GroupConfigurationsDetailHandlerTestCase, self).setUp()
def _url(self, cid=None): def _url(self, cid=-1):
""" """
Return url for the handler. Return url for the handler.
""" """
cid = cid if cid is not None else self.ID cid = cid if cid > 0 else self.ID
return reverse_course_url( return reverse_course_url(
'group_configurations_detail_handler', 'group_configurations_detail_handler',
self.course.id, self.course.id,
...@@ -271,6 +301,7 @@ class GroupConfigurationsDetailHandlerTestCase(UrlResetMixin, CourseTestCase, Gr ...@@ -271,6 +301,7 @@ class GroupConfigurationsDetailHandlerTestCase(UrlResetMixin, CourseTestCase, Gr
{u'id': 0, u'name': u'Group A', u'version': 1}, {u'id': 0, u'name': u'Group A', u'version': 1},
{u'id': 1, u'name': u'Group B', u'version': 1}, {u'id': 1, u'name': u'Group B', u'version': 1},
], ],
u'usage': [],
} }
response = self.client.put( response = self.client.put(
...@@ -307,7 +338,9 @@ class GroupConfigurationsDetailHandlerTestCase(UrlResetMixin, CourseTestCase, Gr ...@@ -307,7 +338,9 @@ class GroupConfigurationsDetailHandlerTestCase(UrlResetMixin, CourseTestCase, Gr
{u'id': 0, u'name': u'New Group Name', u'version': 1}, {u'id': 0, u'name': u'New Group Name', u'version': 1},
{u'id': 2, u'name': u'Group C', u'version': 1}, {u'id': 2, u'name': u'Group C', u'version': 1},
], ],
u'usage': [],
} }
response = self.client.put( response = self.client.put(
self._url(), self._url(),
data=json.dumps(expected), data=json.dumps(expected),
...@@ -318,8 +351,10 @@ class GroupConfigurationsDetailHandlerTestCase(UrlResetMixin, CourseTestCase, Gr ...@@ -318,8 +351,10 @@ class GroupConfigurationsDetailHandlerTestCase(UrlResetMixin, CourseTestCase, Gr
content = json.loads(response.content) content = json.loads(response.content)
self.assertEqual(content, expected) self.assertEqual(content, expected)
self.reload_course() self.reload_course()
# Verify that user_partitions is properly updated in the course. # Verify that user_partitions is properly updated in the course.
user_partititons = self.course.user_partitions user_partititons = self.course.user_partitions
self.assertEqual(len(user_partititons), 1) self.assertEqual(len(user_partititons), 1)
self.assertEqual(user_partititons[0].name, u'New Test name') self.assertEqual(user_partititons[0].name, u'New Test name')
self.assertEqual(len(user_partititons[0].groups), 2) self.assertEqual(len(user_partititons[0].groups), 2)
...@@ -344,7 +379,7 @@ class GroupConfigurationsDetailHandlerTestCase(UrlResetMixin, CourseTestCase, Gr ...@@ -344,7 +379,7 @@ class GroupConfigurationsDetailHandlerTestCase(UrlResetMixin, CourseTestCase, Gr
# Verify that user_partitions is properly updated in the course. # Verify that user_partitions is properly updated in the course.
user_partititons = self.course.user_partitions user_partititons = self.course.user_partitions
self.assertEqual(len(user_partititons), 1) self.assertEqual(len(user_partititons), 1)
self.assertEqual(user_partititons[0].name, u'Name 1') self.assertEqual(user_partititons[0].name, 'Name 1')
def test_cannot_delete_used_group_configuration(self): def test_cannot_delete_used_group_configuration(self):
""" """
...@@ -366,7 +401,7 @@ class GroupConfigurationsDetailHandlerTestCase(UrlResetMixin, CourseTestCase, Gr ...@@ -366,7 +401,7 @@ class GroupConfigurationsDetailHandlerTestCase(UrlResetMixin, CourseTestCase, Gr
# Verify that user_partitions is still the same. # Verify that user_partitions is still the same.
user_partititons = self.course.user_partitions user_partititons = self.course.user_partitions
self.assertEqual(len(user_partititons), 2) self.assertEqual(len(user_partititons), 2)
self.assertEqual(user_partititons[0].name, u'Name 0') self.assertEqual(user_partititons[0].name, 'Name 0')
def test_cannot_delete_non_existent_group_configuration(self): def test_cannot_delete_non_existent_group_configuration(self):
""" """
...@@ -383,7 +418,7 @@ class GroupConfigurationsDetailHandlerTestCase(UrlResetMixin, CourseTestCase, Gr ...@@ -383,7 +418,7 @@ class GroupConfigurationsDetailHandlerTestCase(UrlResetMixin, CourseTestCase, Gr
# Verify that user_partitions is still the same. # Verify that user_partitions is still the same.
user_partititons = self.course.user_partitions user_partititons = self.course.user_partitions
self.assertEqual(len(user_partititons), 2) self.assertEqual(len(user_partititons), 2)
self.assertEqual(user_partititons[0].name, u'Name 0') self.assertEqual(user_partititons[0].name, 'Name 0')
# pylint: disable=no-member # pylint: disable=no-member
...@@ -391,11 +426,9 @@ class GroupConfigurationsUsageInfoTestCase(UrlResetMixin, CourseTestCase, Helper ...@@ -391,11 +426,9 @@ class GroupConfigurationsUsageInfoTestCase(UrlResetMixin, CourseTestCase, Helper
""" """
Tests for usage information of configurations. Tests for usage information of configurations.
""" """
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_GROUP_CONFIGURATIONS": True}) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_GROUP_CONFIGURATIONS": True})
def setUp(self): def setUp(self):
"""
Set up group configurations and split test module.
"""
super(GroupConfigurationsUsageInfoTestCase, self).setUp() super(GroupConfigurationsUsageInfoTestCase, self).setUp()
def test_group_configuration_not_used(self): def test_group_configuration_not_used(self):
...@@ -405,16 +438,16 @@ class GroupConfigurationsUsageInfoTestCase(UrlResetMixin, CourseTestCase, Helper ...@@ -405,16 +438,16 @@ class GroupConfigurationsUsageInfoTestCase(UrlResetMixin, CourseTestCase, Helper
self._add_user_partitions() self._add_user_partitions()
actual = GroupConfiguration.add_usage_info(self.course, self.store) actual = GroupConfiguration.add_usage_info(self.course, self.store)
expected = [{ expected = [{
u'id': 0, 'id': 0,
u'name': u'Name 0', 'name': 'Name 0',
u'description': u'Description 0', 'description': 'Description 0',
u'version': 1, 'version': 1,
u'groups': [ 'groups': [
{u'id': 0, u'name': u'Group A', u'version': 1}, {'id': 0, 'name': 'Group A', 'version': 1},
{u'id': 1, u'name': u'Group B', u'version': 1}, {'id': 1, 'name': 'Group B', 'version': 1},
{u'id': 2, u'name': u'Group C', u'version': 1}, {'id': 2, 'name': 'Group C', 'version': 1},
], ],
u'usage': [], 'usage': [],
}] }]
self.assertEqual(actual, expected) self.assertEqual(actual, expected)
...@@ -429,30 +462,31 @@ class GroupConfigurationsUsageInfoTestCase(UrlResetMixin, CourseTestCase, Helper ...@@ -429,30 +462,31 @@ class GroupConfigurationsUsageInfoTestCase(UrlResetMixin, CourseTestCase, Helper
actual = GroupConfiguration.add_usage_info(self.course, self.store) actual = GroupConfiguration.add_usage_info(self.course, self.store)
expected = [{ expected = [{
u'id': 0, 'id': 0,
u'name': u'Name 0', 'name': 'Name 0',
u'description': u'Description 0', 'description': 'Description 0',
u'version': 1, 'version': 1,
u'groups': [ 'groups': [
{u'id': 0, u'name': u'Group A', u'version': 1}, {'id': 0, 'name': 'Group A', 'version': 1},
{u'id': 1, u'name': u'Group B', u'version': 1}, {'id': 1, 'name': 'Group B', 'version': 1},
{u'id': 2, u'name': u'Group C', u'version': 1}, {'id': 2, 'name': 'Group C', 'version': 1},
], ],
u'usage': [{ 'usage': [{
'url': '/container/i4x://MITx/999/vertical/Test_Unit_0', 'url': '/container/i4x://MITx/999/vertical/Test_Unit_0',
'label': 'Test Unit 0 / Test Content Experiment 0', 'label': 'Test Unit 0 / Test Content Experiment 0',
'validation': None,
}], }],
}, { }, {
u'id': 1, 'id': 1,
u'name': u'Name 1', 'name': 'Name 1',
u'description': u'Description 1', 'description': 'Description 1',
u'version': 1, 'version': 1,
u'groups': [ 'groups': [
{u'id': 0, u'name': u'Group A', u'version': 1}, {'id': 0, 'name': 'Group A', 'version': 1},
{u'id': 1, u'name': u'Group B', u'version': 1}, {'id': 1, 'name': 'Group B', 'version': 1},
{u'id': 2, u'name': u'Group C', u'version': 1}, {'id': 2, 'name': 'Group C', 'version': 1},
], ],
u'usage': [], 'usage': [],
}] }]
self.assertEqual(actual, expected) self.assertEqual(actual, expected)
...@@ -469,21 +503,23 @@ class GroupConfigurationsUsageInfoTestCase(UrlResetMixin, CourseTestCase, Helper ...@@ -469,21 +503,23 @@ class GroupConfigurationsUsageInfoTestCase(UrlResetMixin, CourseTestCase, Helper
actual = GroupConfiguration.add_usage_info(self.course, self.store) actual = GroupConfiguration.add_usage_info(self.course, self.store)
expected = [{ expected = [{
u'id': 0, 'id': 0,
u'name': u'Name 0', 'name': 'Name 0',
u'description': u'Description 0', 'description': 'Description 0',
u'version': 1, 'version': 1,
u'groups': [ 'groups': [
{u'id': 0, u'name': u'Group A', u'version': 1}, {'id': 0, 'name': 'Group A', 'version': 1},
{u'id': 1, u'name': u'Group B', u'version': 1}, {'id': 1, 'name': 'Group B', 'version': 1},
{u'id': 2, u'name': u'Group C', u'version': 1}, {'id': 2, 'name': 'Group C', 'version': 1},
], ],
u'usage': [{ 'usage': [{
'url': '/container/i4x://MITx/999/vertical/Test_Unit_0', 'url': '/container/i4x://MITx/999/vertical/Test_Unit_0',
'label': 'Test Unit 0 / Test Content Experiment 0', 'label': 'Test Unit 0 / Test Content Experiment 0',
'validation': None,
}, { }, {
'url': '/container/i4x://MITx/999/vertical/Test_Unit_1', 'url': '/container/i4x://MITx/999/vertical/Test_Unit_1',
'label': 'Test Unit 1 / Test Content Experiment 1', 'label': 'Test Unit 1 / Test Content Experiment 1',
'validation': None,
}], }],
}] }]
self.assertEqual(actual, expected) self.assertEqual(actual, expected)
...@@ -502,3 +538,97 @@ class GroupConfigurationsUsageInfoTestCase(UrlResetMixin, CourseTestCase, Helper ...@@ -502,3 +538,97 @@ class GroupConfigurationsUsageInfoTestCase(UrlResetMixin, CourseTestCase, Helper
self.save_course() self.save_course()
actual = GroupConfiguration.get_usage_info(self.course, self.store) actual = GroupConfiguration.get_usage_info(self.course, self.store)
self.assertEqual(actual, {0: []}) self.assertEqual(actual, {0: []})
class GroupConfigurationsValidationTestCase(CourseTestCase, HelperMethods):
"""
Tests for validation in Group Configurations.
"""
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_GROUP_CONFIGURATIONS": True})
def setUp(self):
super(GroupConfigurationsValidationTestCase, self).setUp()
@patch('xmodule.split_test_module.SplitTestDescriptor.validation_messages')
def test_error_message_present(self, mocked_validation_messages):
"""
Tests if validation message is present.
"""
self._add_user_partitions()
split_test = self._create_content_experiment(cid=0, name_suffix='0')[1]
mocked_validation_messages.return_value = [
ValidationMessage(
split_test,
u"Validation message",
ValidationMessageType.error
)
]
group_configuration = GroupConfiguration.add_usage_info(self.course, self.store)[0]
self.assertEqual(
group_configuration['usage'][0]['validation'],
{
'message': u'This content experiment has issues that affect content visibility.',
'type': 'error'
}
)
@patch('xmodule.split_test_module.SplitTestDescriptor.validation_messages')
def test_warning_message_present(self, mocked_validation_messages):
"""
Tests if validation message is present.
"""
self._add_user_partitions()
split_test = self._create_content_experiment(cid=0, name_suffix='0')[1]
mocked_validation_messages.return_value = [
ValidationMessage(
split_test,
u"Validation message",
ValidationMessageType.warning
)
]
group_configuration = GroupConfiguration.add_usage_info(self.course, self.store)[0]
self.assertEqual(
group_configuration['usage'][0]['validation'],
{
'message': u'This content experiment has issues that affect content visibility.',
'type': 'warning'
}
)
@patch('xmodule.split_test_module.SplitTestDescriptor.validation_messages')
def test_update_usage_info(self, mocked_validation_messages):
"""
Tests if validation message is present when updating usage info.
"""
self._add_user_partitions()
split_test = self._create_content_experiment(cid=0, name_suffix='0')[1]
mocked_validation_messages.return_value = [
ValidationMessage(
split_test,
u"Validation message",
ValidationMessageType.warning
)
]
group_configuration = GroupConfiguration.update_usage_info(self.store, self.course, self.course.user_partitions[0])
self.assertEqual(
group_configuration['usage'][0]['validation'],
{
'message': u'This content experiment has issues that affect content visibility.',
'type': 'warning'
}
)
@patch('xmodule.split_test_module.SplitTestDescriptor.validation_messages')
def test_update_usage_info_no_message(self, mocked_validation_messages):
"""
Tests if validation message is not present when updating usage info.
"""
self._add_user_partitions()
self._create_content_experiment(cid=0, name_suffix='0')
mocked_validation_messages.return_value = []
group_configuration = GroupConfiguration.update_usage_info(self.store, self.course, self.course.user_partitions[0])
self.assertEqual(group_configuration['usage'][0]['validation'], None)
...@@ -33,7 +33,12 @@ define([ ...@@ -33,7 +33,12 @@ define([
usageText: '.group-configuration-usage-text', usageText: '.group-configuration-usage-text',
usageTextAnchor: '.group-configuration-usage-text > a', usageTextAnchor: '.group-configuration-usage-text > a',
usageUnit: '.group-configuration-usage-unit', usageUnit: '.group-configuration-usage-unit',
usageUnitAnchor: '.group-configuration-usage-unit > a', usageUnitAnchor: '.group-configuration-usage-unit a',
usageUnitMessage: '.group-configuration-validation-message',
usageUnitWarningIcon: '.group-configuration-usage-unit i.icon-warning-sign',
usageUnitErrorIcon: '.group-configuration-usage-unit i.icon-exclamation-sign',
warningMessage: '.group-configuration-validation-text',
warningIcon: '.wrapper-group-configuration-validation > i',
note: '.wrapper-delete-button' note: '.wrapper-delete-button'
}; };
...@@ -162,12 +167,10 @@ define([ ...@@ -162,12 +167,10 @@ define([
it('should show non-empty usage appropriately', function() { it('should show non-empty usage appropriately', function() {
var usageUnitAnchors; var usageUnitAnchors;
this.model.set('usage', this.model.set('usage', [
[ {'label': 'label1', 'url': 'url1'},
{'label': 'label1', 'url': 'url1'}, {'label': 'label2', 'url': 'url2'}
{'label': 'label2', 'url': 'url2'} ]);
]
);
this.model.set('showGroups', false); this.model.set('showGroups', false);
this.view.$('.show-groups').click(); this.view.$('.show-groups').click();
...@@ -205,6 +208,55 @@ define([ ...@@ -205,6 +208,55 @@ define([
expect(this.view.$(SELECTORS.usageCount)) expect(this.view.$(SELECTORS.usageCount))
.toContainText('Used in 2 units'); .toContainText('Used in 2 units');
}); });
it('should show validation warning icon and message appropriately', function() {
this.model.set('usage', [
{
'label': 'label1',
'url': 'url1',
'validation': {
'message': "Warning message",
'type': 'warning'
}
}
]);
this.model.set('showGroups', false);
this.view.$('.show-groups').click();
expect(this.view.$(SELECTORS.usageUnitMessage)).toContainText('Warning message');
expect(this.view.$(SELECTORS.usageUnitWarningIcon)).toExist();
});
it('should show validation error icon and message appropriately', function() {
this.model.set('usage', [
{
'label': 'label1',
'url': 'url1',
'validation': {
'message': "Error message",
'type': 'error'
}
}
]);
this.model.set('showGroups', false);
this.view.$('.show-groups').click();
expect(this.view.$(SELECTORS.usageUnitMessage)).toContainText('Error message');
expect(this.view.$(SELECTORS.usageUnitErrorIcon)).toExist();
});
it('should hide validation icons and messages 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.usageUnitMessage)).not.toExist();
expect(this.view.$(SELECTORS.usageUnitWarningIcon)).not.toExist();
expect(this.view.$(SELECTORS.usageUnitErrorIcon)).not.toExist();
});
}); });
describe('GroupConfigurationEdit', function() { describe('GroupConfigurationEdit', function() {
...@@ -418,6 +470,24 @@ define([ ...@@ -418,6 +470,24 @@ define([
); );
expect(this.view.$('.delete')).toHaveClass('is-disabled'); expect(this.view.$('.delete')).toHaveClass('is-disabled');
}); });
it('contains warning message if it is in use', function () {
this.model.set('usage', [ {'label': 'label1', 'url': 'url1'} ]);
this.view.render();
expect(this.view.$(SELECTORS.warningMessage)).toContainText(
'This configuration is currently used in content ' +
'experiments. If you make changes to the groups, you may ' +
'need to edit those experiments.'
);
expect(this.view.$(SELECTORS.warningIcon)).toExist();
});
it('does not contain warning message if it is not in use', function () {
this.model.set('usage', []);
this.view.render();
expect(this.view.$(SELECTORS.warningMessage)).not.toExist();
expect(this.view.$(SELECTORS.warningIcon)).not.toExist();
});
}); });
describe('GroupConfigurationsList', function() { describe('GroupConfigurationsList', function() {
......
...@@ -190,21 +190,51 @@ ...@@ -190,21 +190,51 @@
.wrapper-group-configuration-usages { .wrapper-group-configuration-usages {
@extend %t-copy-sub1; @extend %t-copy-sub1;
background-color: #f8f8f8;
box-shadow: 0 2px 2px 0 $shadow inset; box-shadow: 0 2px 2px 0 $shadow inset;
padding: $baseline ($baseline*1.5) $baseline ($baseline*2.5); padding: $baseline ($baseline*1.5) $baseline ($baseline*2.5);
color: $gray-l1;
.group-configuration-usage { .group-configuration-usage {
color: $gray-l1;
margin-left: $baseline; margin-left: $baseline;
.group-configuration-usage-unit { .group-configuration-usage-unit {
padding: ($baseline/4) 0; padding: ($baseline/4) 0;
a {
font-weight: 600;
}
.icon-warning-sign {
margin: ($baseline/4) ($baseline/2) 0 ($baseline*1.5);
color: $orange;
}
.icon-exclamation-sign {
margin: ($baseline/4) ($baseline/2) 0 ($baseline*1.5);
color: $red-l2;
}
} }
} }
} }
} }
.wrapper-group-configuration-validation {
@extend %t-copy-sub1;
background-color: #f8f8f8;
margin-top: $baseline;
padding: $baseline ($baseline*1.5) $baseline ($baseline*1.5);
.icon-warning-sign {
margin: ($baseline/2) $baseline 0 0;
color: $orange;
float: left;
}
.group-configuration-validation-text {
overflow: auto;
}
}
.group-configuration-edit { .group-configuration-edit {
@include box-sizing(border-box); @include box-sizing(border-box);
border-radius: 2px; border-radius: 2px;
......
...@@ -62,7 +62,19 @@ ...@@ -62,7 +62,19 @@
<ol class="group-configuration-usage"> <ol class="group-configuration-usage">
<% _.each(usage, function(unit) { %> <% _.each(usage, function(unit) { %>
<li class="group-configuration-usage-unit"> <li class="group-configuration-usage-unit">
<a href=<%= unit.url %> ><%= unit.label %></a> <p><a href=<%= unit.url %> ><%= unit.label %></a></p>
<% if (unit.validation) { %>
<p>
<% if (unit.validation.type === 'warning') { %>
<i class="icon-warning-sign"></i>
<% } else if (unit.validation.type === 'error') { %>
<i class="icon-exclamation-sign"></i>
<% } %>
<span class="group-configuration-validation-message">
<%= unit.validation.message %>
</span>
</p>
<% } %>
</li> </li>
<% }) %> <% }) %>
</ol> </ol>
......
...@@ -32,6 +32,14 @@ ...@@ -32,6 +32,14 @@
<ol class="groups list-input enum"></ol> <ol class="groups list-input enum"></ol>
<button class="action action-add-group"><i class="icon-plus"></i> <%= gettext("Add another group") %></button> <button class="action action-add-group"><i class="icon-plus"></i> <%= gettext("Add another group") %></button>
</fieldset> </fieldset>
<% if (!_.isEmpty(usage)) { %>
<div class="wrapper-group-configuration-validation">
<i class="icon-warning-sign"></i>
<p class="group-configuration-validation-text">
<%= gettext('This configuration is currently used in content experiments. If you make changes to the groups, you may need to edit those experiments.') %>
</p>
</div>
<% } %>
</div> </div>
<div class="actions"> <div class="actions">
<button class="action action-primary" type="submit"><% if (isNew) { print(gettext("Create")) } else { print(gettext("Save")) } %></button> <button class="action action-primary" type="submit"><% if (isNew) { print(gettext("Create")) } else { print(gettext("Save")) } %></button>
......
...@@ -607,3 +607,19 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor, StudioEditableDes ...@@ -607,3 +607,19 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor, StudioEditableDes
) )
self.children.append(dest_usage_key) # pylint: disable=no-member self.children.append(dest_usage_key) # pylint: disable=no-member
self.group_id_to_child[unicode(group.id)] = dest_usage_key self.group_id_to_child[unicode(group.id)] = dest_usage_key
@property
def general_validation_message(self):
"""
Message for either error or warning validation message/s.
Returns message and type. Priority given to error type message.
"""
validation_messages = self.validation_messages()
if validation_messages:
has_error = any(message.message_type == ValidationMessageType.error for message in validation_messages)
return {
'message': _(u"This content experiment has issues that affect content visibility."),
'type': ValidationMessageType.error if has_error else ValidationMessageType.warning,
}
return None
...@@ -344,6 +344,13 @@ class SplitTestModuleStudioTest(SplitTestModuleTest): ...@@ -344,6 +344,13 @@ class SplitTestModuleStudioTest(SplitTestModuleTest):
self.assertEqual(message.action_class, expected_action_class) self.assertEqual(message.action_class, expected_action_class)
self.assertEqual(message.action_label, expected_action_label) self.assertEqual(message.action_label, expected_action_label)
def verify_general_validation_message(general_validation, expected_message, expected_message_type):
"""
Verify that the general validation message has the expected validation message and type.
"""
self.assertEqual(unicode(general_validation['message']), expected_message)
self.assertEqual(general_validation['type'], expected_message_type)
# Verify the messages for an unconfigured user partition # Verify the messages for an unconfigured user partition
split_test_module.user_partition_id = -1 split_test_module.user_partition_id = -1
messages = split_test_module.validation_messages() messages = split_test_module.validation_messages()
...@@ -355,6 +362,11 @@ class SplitTestModuleStudioTest(SplitTestModuleTest): ...@@ -355,6 +362,11 @@ class SplitTestModuleStudioTest(SplitTestModuleTest):
'edit-button', 'edit-button',
u"Select a Group Configuration", u"Select a Group Configuration",
) )
verify_general_validation_message(
split_test_module.general_validation_message,
u"This content experiment has issues that affect content visibility.",
ValidationMessageType.warning
)
# Verify the messages for a correctly configured split_test # Verify the messages for a correctly configured split_test
split_test_module.user_partition_id = 0 split_test_module.user_partition_id = 0
...@@ -363,6 +375,7 @@ class SplitTestModuleStudioTest(SplitTestModuleTest): ...@@ -363,6 +375,7 @@ class SplitTestModuleStudioTest(SplitTestModuleTest):
] ]
messages = split_test_module.validation_messages() messages = split_test_module.validation_messages()
self.assertEqual(len(messages), 0) self.assertEqual(len(messages), 0)
self.assertIsNone(split_test_module.general_validation_message, None)
# Verify the messages for a split test with too few groups # Verify the messages for a split test with too few groups
split_test_module.user_partitions = [ split_test_module.user_partitions = [
...@@ -378,7 +391,11 @@ class SplitTestModuleStudioTest(SplitTestModuleTest): ...@@ -378,7 +391,11 @@ class SplitTestModuleStudioTest(SplitTestModuleTest):
'add-missing-groups-button', 'add-missing-groups-button',
u"Add Missing Groups" u"Add Missing Groups"
) )
verify_general_validation_message(
split_test_module.general_validation_message,
u"This content experiment has issues that affect content visibility.",
ValidationMessageType.error
)
# Verify the messages for a split test with children that are not associated with any group # Verify the messages for a split test with children that are not associated with any group
split_test_module.user_partitions = [ split_test_module.user_partitions = [
UserPartition(0, 'first_partition', 'First Partition', UserPartition(0, 'first_partition', 'First Partition',
...@@ -391,7 +408,11 @@ class SplitTestModuleStudioTest(SplitTestModuleTest): ...@@ -391,7 +408,11 @@ class SplitTestModuleStudioTest(SplitTestModuleTest):
u"The experiment has an inactive group. Move content into active groups, then delete the inactive group.", u"The experiment has an inactive group. Move content into active groups, then delete the inactive group.",
ValidationMessageType.warning ValidationMessageType.warning
) )
verify_general_validation_message(
split_test_module.general_validation_message,
u"This content experiment has issues that affect content visibility.",
ValidationMessageType.warning
)
# Verify the messages for a split test with both missing and inactive children # Verify the messages for a split test with both missing and inactive children
split_test_module.user_partitions = [ split_test_module.user_partitions = [
UserPartition(0, 'first_partition', 'First Partition', UserPartition(0, 'first_partition', 'First Partition',
...@@ -411,6 +432,12 @@ class SplitTestModuleStudioTest(SplitTestModuleTest): ...@@ -411,6 +432,12 @@ class SplitTestModuleStudioTest(SplitTestModuleTest):
u"The experiment has an inactive group. Move content into active groups, then delete the inactive group.", u"The experiment has an inactive group. Move content into active groups, then delete the inactive group.",
ValidationMessageType.warning ValidationMessageType.warning
) )
# With two messages of type error and warning priority given to error.
verify_general_validation_message(
split_test_module.general_validation_message,
u"This content experiment has issues that affect content visibility.",
ValidationMessageType.error
)
# Verify the messages for a split test referring to a non-existent user partition # Verify the messages for a split test referring to a non-existent user partition
split_test_module.user_partition_id = 2 split_test_module.user_partition_id = 2
...@@ -422,3 +449,8 @@ class SplitTestModuleStudioTest(SplitTestModuleTest): ...@@ -422,3 +449,8 @@ class SplitTestModuleStudioTest(SplitTestModuleTest):
u"Select a valid group configuration or delete this experiment.", u"Select a valid group configuration or delete this experiment.",
ValidationMessageType.error ValidationMessageType.error
) )
verify_general_validation_message(
split_test_module.general_validation_message,
u"This content experiment has issues that affect content visibility.",
ValidationMessageType.error
)
...@@ -30,6 +30,14 @@ class GroupConfigurationsPage(CoursePage): ...@@ -30,6 +30,14 @@ class GroupConfigurationsPage(CoursePage):
""" """
self.q(css=".new-button").first.click() self.q(css=".new-button").first.click()
@property
def no_group_configuration_message_is_present(self):
return self.q(css='.wrapper-content .no-group-configurations-content').present
@property
def no_group_configuration_message_text(self):
return self.q(css='.wrapper-content .no-group-configurations-content').text[0]
class GroupConfiguration(object): class GroupConfiguration(object):
""" """
...@@ -198,6 +206,34 @@ class GroupConfiguration(object): ...@@ -198,6 +206,34 @@ class GroupConfiguration(object):
""" """
return self.find_css('.wrapper-delete-button').first.attrs('data-tooltip')[0] return self.find_css('.wrapper-delete-button').first.attrs('data-tooltip')[0]
@property
def details_error_icon_is_present(self):
return self.find_css('.wrapper-group-configuration-usages .icon-exclamation-sign').present
@property
def details_warning_icon_is_present(self):
return self.find_css('.wrapper-group-configuration-usages .icon-warning-sign').present
@property
def details_message_is_present(self):
return self.find_css('.wrapper-group-configuration-usages .group-configuration-validation-message').present
@property
def details_message_text(self):
return self.find_css('.wrapper-group-configuration-usages .group-configuration-validation-message').text[0]
@property
def edit_warning_icon_is_present(self):
return self.find_css('.wrapper-group-configuration-validation .icon-warning-sign').present
@property
def edit_warning_message_is_present(self):
return self.find_css('.wrapper-group-configuration-validation .group-configuration-validation-text').present
@property
def edit_warning_message_text(self):
return self.find_css('.wrapper-group-configuration-validation .group-configuration-validation-text').text[0]
def __repr__(self): def __repr__(self):
return "<{}:{}>".format(self.__class__.__name__, self.name) return "<{}:{}>".format(self.__class__.__name__, self.name)
......
...@@ -301,6 +301,33 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -301,6 +301,33 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
) )
) )
def create_group_configuration_experiment(self, groups, associate_experiment):
"""
Creates a Group Configuration containing a list of groups.
Optionally creates a Content Experiment and associates it with previous Group Configuration.
"""
# Create a new group configurations
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
UserPartition(0, "Name", "Description.", groups).to_json(),
],
},
})
if associate_experiment:
# Assign newly created group configuration to experiment
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})
)
# Go to the Group Configuration Page
self.page.visit()
config = self.page.group_configurations[0]
return config
def test_no_group_configurations_added(self): def test_no_group_configurations_added(self):
""" """
Scenario: Ensure that message telling me to create a new group configuration is Scenario: Ensure that message telling me to create a new group configuration is
...@@ -311,11 +338,10 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -311,11 +338,10 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
And "Create new Group Configuration" button is available And "Create new Group Configuration" button is available
""" """
self.page.visit() self.page.visit()
css = ".wrapper-content .no-group-configurations-content" self.assertTrue(self.page.no_group_configuration_message_is_present)
self.assertTrue(self.page.q(css=css).present)
self.assertIn( self.assertIn(
"You haven't created any group configurations yet.", "You haven't created any group configurations yet.",
self.page.q(css=css).text[0] self.page.no_group_configuration_message_text
) )
def test_group_configurations_have_correct_data(self): def test_group_configurations_have_correct_data(self):
...@@ -784,3 +810,115 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): ...@@ -784,3 +810,115 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
group_configuration_link_name, group_configuration_link_name,
self.page.group_configurations[1].name self.page.group_configurations[1].name
) )
def test_details_error_validation_message(self):
"""
Scenario: When a Content Experiment uses a Group Configuration, ensure
that an error validation message appears if necessary.
Given I have a course with a Group Configuration containing two Groups
And a Content Experiment is assigned to that Group Configuration
When I go to the Group Configuration Page
Then I do not see a error icon and message in the Group Configuration details view.
When I add a Group
Then I see an error icon and message in the Group Configuration details view
"""
# Create group configuration and associated experiment
config = self.create_group_configuration_experiment([Group("0", "Group A"), Group("1", "Group B")], True)
# Display details view
config.toggle()
# Check that error icon and message are not present
self.assertFalse(config.details_error_icon_is_present)
self.assertFalse(config.details_message_is_present)
# Add a group
config.toggle()
config.edit()
config.add_group()
config.save()
# Display details view
config.toggle()
# Check that error icon and message are present
self.assertTrue(config.details_error_icon_is_present)
self.assertTrue(config.details_message_is_present)
self.assertIn(
"This content experiment has issues that affect content visibility.",
config.details_message_text
)
def test_details_warning_validation_message(self):
"""
Scenario: When a Content Experiment uses a Group Configuration, ensure
that a warning validation message appears if necessary.
Given I have a course with a Group Configuration containing three Groups
And a Content Experiment is assigned to that Group Configuration
When I go to the Group Configuration Page
Then I do not see a warning icon and message in the Group Configuration details view.
When I remove a Group
Then I see a warning icon and message in the Group Configuration details view
"""
# Create group configuration and associated experiment
config = self.create_group_configuration_experiment([Group("0", "Group A"), Group("1", "Group B"), Group("2", "Group C")], True)
# Display details view
config.toggle()
# Check that warning icon and message are not present
self.assertFalse(config.details_warning_icon_is_present)
self.assertFalse(config.details_message_is_present)
# Remove a group
config.toggle()
config.edit()
config.groups[2].remove()
config.save()
# Display details view
config.toggle()
# Check that warning icon and message are present
self.assertTrue(config.details_warning_icon_is_present)
self.assertTrue(config.details_message_is_present)
self.assertIn(
"This content experiment has issues that affect content visibility.",
config.details_message_text
)
def test_edit_warning_message_empty_usage(self):
"""
Scenario: When a Group Configuration is not used, ensure that there are no warning icon and message.
Given I have a course with a Group Configuration containing two Groups
When I edit the Group Configuration
Then I do not see a warning icon and message
"""
# Create a group configuration with no associated experiment and display edit view
config = self.create_group_configuration_experiment([Group("0", "Group A"), Group("1", "Group B")], False)
config.edit()
# Check that warning icon and message are not present
self.assertFalse(config.edit_warning_icon_is_present)
self.assertFalse(config.edit_warning_message_is_present)
def test_edit_warning_message_non_empty_usage(self):
"""
Scenario: When a Group Configuration is used, ensure that there are a warning icon and message.
Given I have a course with a Group Configuration containing two Groups
When I edit the Group Configuration
Then I see a warning icon and message
"""
# Create a group configuration with an associated experiment and display edit view
config = self.create_group_configuration_experiment([Group("0", "Group A"), Group("1", "Group B")], True)
config.edit()
# Check that warning icon and message are present
self.assertTrue(config.edit_warning_icon_is_present)
self.assertTrue(config.edit_warning_message_is_present)
self.assertIn(
"This configuration is currently used in content experiments. If you make changes to the groups, you may need to edit those experiments.",
config.edit_warning_message_text
)
...@@ -27,22 +27,20 @@ show_link = settings.FEATURES.get('ENABLE_GROUP_CONFIGURATIONS') and group_confi ...@@ -27,22 +27,20 @@ show_link = settings.FEATURES.get('ENABLE_GROUP_CONFIGURATIONS') and group_confi
% endif % endif
% if len(messages) > 0: % if len(messages) > 0:
<% <%
general_validation = split_test.descriptor.general_validation_message
def get_validation_icon(validation_type): def get_validation_icon(validation_type):
if validation_type == 'error': if validation_type == ValidationMessageType.error:
return 'icon-exclamation-sign' return 'icon-exclamation-sign'
elif validation_type == 'warning': elif validation_type == ValidationMessageType.warning:
return 'icon-warning-sign' return 'icon-warning-sign'
return None return None
error_messages = (message for message in messages if message.message_type==ValidationMessageType.error) aggregate_validation_class = 'has-errors' if general_validation['type']==ValidationMessageType.error else ' has-warnings'
has_errors = next(error_messages, False)
aggregate_validation_class = 'has-errors' if has_errors else 'has-warnings'
aggregate_validation_type = 'error' if has_errors else 'warning'
%> %>
<div class="xblock-message validation ${aggregate_validation_class}"> <div class="xblock-message validation ${aggregate_validation_class}">
% if is_configured: % if is_configured:
<p class="${aggregate_validation_type}"><i class="${get_validation_icon(aggregate_validation_type)}"></i> <p class="${general_validation['type']}"><i class="${get_validation_icon(general_validation['type'])}"></i>
${_("This content experiment has issues that affect content visibility.")} ${general_validation['message']}
</p> </p>
% endif % endif
% if is_root or not is_configured: % if is_root or not is_configured:
......
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