Commit 39f1f009 by Anton Stupak

Merge pull request #4424 from edx/anton/edit-group-configurations

Edit group configurations.
parents 1c595998 2a6a874a
...@@ -869,43 +869,90 @@ class GroupConfiguration(object): ...@@ -869,43 +869,90 @@ class GroupConfiguration(object):
""" """
Prepare Group Configuration for the course. Prepare Group Configuration for the course.
""" """
def __init__(self, json_string, course, configuration_id=None):
"""
Receive group configuration as a json (`json_string`), deserialize it
and validate.
"""
self.configuration = GroupConfiguration.parse(json_string)
self.course = course
self.assign_id(configuration_id)
self.assign_group_ids()
self.validate()
@staticmethod @staticmethod
def parse(configuration_json): def parse(json_string):
""" """
Parse given string that represents group configuration. Deserialize given json that represents group configuration.
""" """
try: try:
group_configuration = json.loads(configuration_json) configuration = json.loads(json_string)
except ValueError: except ValueError:
raise GroupConfigurationsValidationError(_("invalid JSON")) raise GroupConfigurationsValidationError(_("invalid JSON"))
if not group_configuration.get('version'): return configuration
group_configuration['version'] = UserPartition.VERSION
def validate(self):
"""
Validate group configuration representation.
"""
if not self.configuration.get("name"):
raise GroupConfigurationsValidationError(_("must have name of the configuration"))
if len(self.configuration.get('groups', [])) < 2:
raise GroupConfigurationsValidationError(_("must have at least two groups"))
def generate_id(self):
"""
Generate unique id for the group configuration.
If this id is already used, we generate new one.
"""
used_ids = self.get_used_ids()
cid = random.randint(100, 10 ** 12)
while cid in used_ids:
cid = random.randint(100, 10 ** 12)
return cid
def assign_id(self, configuration_id=None):
"""
Assign id for the json representation of group configuration.
"""
self.configuration['id'] = int(configuration_id) if configuration_id else self.generate_id()
def assign_group_ids(self):
"""
Assign ids for the group_configuration's groups.
"""
# this is temporary logic, we are going to build default groups on front-end # this is temporary logic, we are going to build default groups on front-end
if not group_configuration.get('groups'): if not self.configuration.get('groups'):
group_configuration['groups'] = [ self.configuration['groups'] = [
{'name': 'Group A'}, {'name': 'Group B'}, {'name': 'Group A'}, {'name': 'Group B'},
] ]
for group in group_configuration['groups']: # Assign ids to every group in configuration.
group['version'] = Group.VERSION for index, group in enumerate(self.configuration.get('groups', [])):
return group_configuration group['id'] = index
@staticmethod def get_used_ids(self):
def validate(group_configuration):
""" """
Validate group configuration representation. Return a list of IDs that already in use.
""" """
if not group_configuration.get("name"): return set([p.id for p in self.course.user_partitions])
raise GroupConfigurationsValidationError(_("must have name of the configuration"))
if not isinstance(group_configuration.get("description"), basestring): def get_user_partition(self):
raise GroupConfigurationsValidationError(_("must have description of the configuration")) """
if len(group_configuration.get('groups')) < 2: Get user partition for saving in course.
raise GroupConfigurationsValidationError(_("must have at least two groups")) """
group_id = unicode(group_configuration.get("id", "")) groups = [Group(g["id"], g["name"]) for g in self.configuration["groups"]]
if group_id and not group_id.isdigit():
raise GroupConfigurationsValidationError(_("group configuration ID must be numeric")) return UserPartition(
self.configuration["id"],
self.configuration["name"],
self.configuration["description"],
groups
)
@require_http_methods(("GET", "POST")) @require_http_methods(("GET", "POST"))
@login_required @login_required
...@@ -932,40 +979,63 @@ def group_configurations_list_handler(request, course_key_string): ...@@ -932,40 +979,63 @@ def group_configurations_list_handler(request, course_key_string):
'group_configuration_url': group_configuration_url, 'group_configuration_url': group_configuration_url,
'configurations': [u.to_json() for u in course.user_partitions] if split_test_enabled else None, 'configurations': [u.to_json() for u in course.user_partitions] if split_test_enabled else None,
}) })
elif "application/json" in request.META.get('HTTP_ACCEPT') and request.method == 'POST': elif "application/json" in request.META.get('HTTP_ACCEPT'):
if request.method == 'POST':
# create a new group configuration for the course # create a new group configuration for the course
try: try:
configuration = GroupConfiguration.parse(request.body) new_configuration = GroupConfiguration(request.body, course).get_user_partition()
GroupConfiguration.validate(configuration)
except GroupConfigurationsValidationError as err: except GroupConfigurationsValidationError as err:
return JsonResponse({"error": err.message}, status=400) return JsonResponse({"error": err.message}, status=400)
if not configuration.get("id"): course.user_partitions.append(new_configuration)
configuration["id"] = random.randint(100, 10**12) response = JsonResponse(new_configuration.to_json(), status=201)
# Assign ids to every group in configuration.
for index, group in enumerate(configuration.get('groups', [])):
group["id"] = index
course.user_partitions.append(UserPartition.from_json(configuration))
store.update_item(course, request.user.id)
response = JsonResponse(configuration, status=201)
response["Location"] = reverse_course_url( response["Location"] = reverse_course_url(
'group_configurations_detail_handler', 'group_configurations_detail_handler',
course.id, course.id,
kwargs={'group_configuration_id': configuration["id"]} kwargs={'group_configuration_id': new_configuration.id} # pylint: disable=no-member
) )
store.update_item(course, request.user.id)
return response return response
else: else:
return HttpResponse(status=406) return HttpResponse(status=406)
@require_http_methods(("GET", "POST"))
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
@require_http_methods(("POST", "PUT"))
def group_configurations_detail_handler(request, course_key_string, group_configuration_id): def group_configurations_detail_handler(request, course_key_string, group_configuration_id):
return JsonResponse(status=404) """
JSON API endpoint for manipulating a group configuration via its internal ID.
Used by the Backbone application.
POST or PUT
json: update group configuration based on provided information
"""
course_key = CourseKey.from_string(course_key_string)
course = _get_course_module(course_key, request.user)
store = modulestore()
matching_id = [p for p in course.user_partitions
if unicode(p.id) == unicode(group_configuration_id)]
if matching_id:
configuration = matching_id[0]
else:
configuration = None
if request.method in ('POST', 'PUT'): # can be either and sometimes
# django is rewriting one to the other
try:
new_configuration = GroupConfiguration(request.body, course, group_configuration_id).get_user_partition()
except GroupConfigurationsValidationError as err:
return JsonResponse({"error": err.message}, status=400)
if configuration:
index = course.user_partitions.index(configuration)
course.user_partitions[index] = new_configuration
else:
course.user_partitions.append(new_configuration)
store.update_item(course, request.user.id)
return JsonResponse(new_configuration.to_json(), status=201)
def _get_course_creator_status(user): def _get_course_creator_status(user):
......
"""
Group Configuration Tests.
"""
import json import json
from unittest import skipUnless from unittest import skipUnless
from django.conf import settings from django.conf import settings
from contentstore.utils import reverse_course_url from contentstore.utils import reverse_course_url
from contentstore.tests.utils import CourseTestCase from contentstore.tests.utils import CourseTestCase
from xmodule.partitions.partitions import Group, UserPartition
@skipUnless(settings.FEATURES.get('ENABLE_GROUP_CONFIGURATIONS'), 'Tests Group Configurations feature') GROUP_CONFIGURATION_JSON = {
class GroupConfigurationsCreateTestCase(CourseTestCase): u'name': u'Test name',
""" u'description': u'Test description',
Test cases for creating a new group configurations. }
"""
def setUp(self):
# pylint: disable=no-member
class GroupConfigurationsBaseTestCase(object):
""" """
Set up a url and group configuration content for tests. Mixin with base test cases for the group configurations.
""" """
super(GroupConfigurationsCreateTestCase, self).setUp() def _remove_ids(self, content):
self.url = reverse_course_url('group_configurations_list_handler', self.course.id)
self.group_configuration_json = {
u'description': u'Test description',
u'name': u'Test name'
}
def test_index_page(self):
""" """
Check that the group configuration index page responds correctly. Remove ids from the response. We cannot predict IDs, because they're
generated randomly.
We use this method to clean up response when creating new group configurations.
Returns a tuple that contains removed group configuration ID and group IDs.
""" """
response = self.client.get(self.url) configuration_id = content.pop("id")
self.assertEqual(response.status_code, 200) group_ids = [group.pop("id") for group in content["groups"]]
self.assertIn('New Group Configuration', response.content)
return (configuration_id, group_ids)
def test_group_success(self): def test_required_fields_are_absent(self):
""" """
Test that you can create a group configuration. Test required fields are absent.
""" """
expected_group_configuration = { bad_jsons = [
u'description': u'Test description', # must have name of the configuration
{
u'description': 'Test description',
u'groups': [
{u'name': u'Group A'},
{u'name': u'Group B'},
],
},
# must have at least two groups
{
u'name': u'Test name', u'name': u'Test name',
u'version': 1, u'description': u'Test description',
u'groups': [ u'groups': [
{u'id': 0, u'name': u'Group A', u'version': 1}, {u'name': u'Group A'},
{u'id': 1, u'name': u'Group B', u'version': 1} ],
},
# an empty json
{},
] ]
}
for bad_json in bad_jsons:
response = self.client.post( response = self.client.post(
self.url, self._url(),
data=json.dumps(self.group_configuration_json), data=json.dumps(bad_json),
content_type="application/json", content_type="application/json",
HTTP_ACCEPT="application/json", HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest" HTTP_X_REQUESTED_WITH="XMLHttpRequest",
) )
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 400)
self.assertIn("Location", response) self.assertNotIn("Location", response)
group_configuration = json.loads(response.content) content = json.loads(response.content)
del group_configuration['id'] # do not check for id, it is unique self.assertIn("error", content)
self.assertEqual(expected_group_configuration, group_configuration)
def test_bad_group(self): def test_invalid_json(self):
""" """
Test if only one group in configuration exist. Test invalid json handling.
""" """
# Only one group in group configuration here. # No property name.
bad_group_configuration = { invalid_json = "{u'name': 'Test Name', []}"
u'description': u'Test description',
u'id': 1,
u'name': u'Test name',
u'version': 1,
u'groups': [
{u'id': 0, u'name': u'Group A', u'version': 1},
]
}
response = self.client.post( response = self.client.post(
self.url, self._url(),
data=json.dumps(bad_group_configuration), data=invalid_json,
content_type="application/json", content_type="application/json",
HTTP_ACCEPT="application/json", HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest" HTTP_X_REQUESTED_WITH="XMLHttpRequest",
) )
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertNotIn("Location", response) self.assertNotIn("Location", response)
content = json.loads(response.content) content = json.loads(response.content)
self.assertIn("error", content) self.assertIn("error", content)
def test_bad_configuration_id(self):
# pylint: disable=no-member
@skipUnless(settings.FEATURES.get('ENABLE_GROUP_CONFIGURATIONS'), 'Tests Group Configurations feature')
class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurationsBaseTestCase):
"""
Test cases for group_configurations_list_handler.
"""
def setUp(self):
"""
Set up GroupConfigurationsListHandlerTestCase.
"""
super(GroupConfigurationsListHandlerTestCase, self).setUp()
def _url(self):
"""
Return url for the handler.
"""
return reverse_course_url('group_configurations_list_handler', self.course.id)
def test_can_retrieve_html(self):
"""
Check that the group configuration index page responds correctly.
"""
response = self.client.get(self._url())
self.assertEqual(response.status_code, 200)
self.assertIn('New Group Configuration', response.content)
def test_unsupported_http_accept_header(self):
""" """
Test if configuration id is not numeric. Test if not allowed header present in request.
""" """
# Configuration id is string here. response = self.client.get(
bad_group_configuration = { self._url(),
HTTP_ACCEPT="text/plain",
)
self.assertEqual(response.status_code, 406)
def test_can_create_group_configuration(self):
"""
Test that you can create a group configuration.
"""
expected = {
u'description': u'Test description', u'description': u'Test description',
u'id': 'bad_id',
u'name': u'Test name', u'name': u'Test name',
u'version': 1, u'version': 1,
u'groups': [ u'groups': [
{u'id': 0, u'name': u'Group A', u'version': 1}, {u'name': u'Group A', u'version': 1},
{u'id': 1, u'name': u'Group B', u'version': 1} {u'name': u'Group B', u'version': 1},
] ],
} }
response = self.client.post( response = self.client.post(
self.url, self._url(),
data=json.dumps(bad_group_configuration), data=json.dumps(GROUP_CONFIGURATION_JSON),
content_type="application/json", content_type="application/json",
HTTP_ACCEPT="application/json", HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest" HTTP_X_REQUESTED_WITH="XMLHttpRequest",
) )
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 201)
self.assertNotIn("Location", response) self.assertIn("Location", response)
content = json.loads(response.content) content = json.loads(response.content)
self.assertIn("error", content) configuration_id, group_ids = self._remove_ids(content) # pylint: disable=unused-variable
self.assertEqual(content, expected)
# IDs are unique
self.assertEqual(len(group_ids), len(set(group_ids)))
self.assertEqual(len(group_ids), 2)
self.reload_course()
# Verify that user_partitions in the course contains the new group configuration.
self.assertEqual(len(self.course.user_partitions), 1)
self.assertEqual(self.course.user_partitions[0].name, u'Test name')
def test_bad_json(self):
# pylint: disable=no-member
@skipUnless(settings.FEATURES.get('ENABLE_GROUP_CONFIGURATIONS'), 'Tests Group Configurations feature')
class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfigurationsBaseTestCase):
""" """
Test bad json handling. Test cases for group_configurations_detail_handler.
""" """
bad_jsons = [
{u'name': 'Test Name'}, ID = 000000000000
{u'description': 'Test description'},
{} def setUp(self):
] """
for bad_json in bad_jsons: Set up GroupConfigurationsDetailHandlerTestCase.
response = self.client.post( """
self.url, super(GroupConfigurationsDetailHandlerTestCase, self).setUp()
data=json.dumps(bad_json),
def _url(self, cid=None):
"""
Return url for the handler.
"""
cid = cid if cid is not None else self.ID
return reverse_course_url(
'group_configurations_detail_handler',
self.course.id,
kwargs={'group_configuration_id': cid},
)
def test_can_create_new_group_configuration_if_it_is_not_exist(self):
"""
PUT new group configuration when no configurations exist in the course.
"""
expected = {
u'id': 999,
u'name': u'Test name',
u'description': u'Test description',
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},
],
}
response = self.client.put(
self._url(cid=999),
data=json.dumps(GROUP_CONFIGURATION_JSON),
content_type="application/json", content_type="application/json",
HTTP_ACCEPT="application/json", HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest", HTTP_X_REQUESTED_WITH="XMLHttpRequest",
) )
self.assertEqual(response.status_code, 400)
self.assertNotIn("Location", response)
content = json.loads(response.content) content = json.loads(response.content)
self.assertIn("error", content) self.assertEqual(content, expected)
self.reload_course()
# Verify that user_partitions in the course contains the new group configuration.
self.assertEqual(len(self.course.user_partitions), 1)
self.assertEqual(self.course.user_partitions[0].name, u'Test name')
def test_invalid_json(self): def test_can_edit_group_configuration(self):
""" """
Test invalid json handling. Edit group configuration and check its id and modified fields.
""" """
# No property name. self.course.user_partitions = [
invalid_json = "{u'name': 'Test Name', []}" UserPartition(self.ID, 'First name', 'First description', [Group(0, 'Group A'), Group(1, 'Group B'), Group(2, 'Group C')]),
]
self.save_course()
response = self.client.post( expected = {
self.url, u'id': self.ID,
data=invalid_json, u'name': u'New Test name',
u'description': u'New Test description',
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},
],
}
response = self.client.put(
self._url(),
data=json.dumps(expected),
content_type="application/json", content_type="application/json",
HTTP_ACCEPT="application/json", HTTP_ACCEPT="application/json",
HTTP_X_REQUESTED_WITH="XMLHttpRequest", HTTP_X_REQUESTED_WITH="XMLHttpRequest",
) )
self.assertEqual(response.status_code, 400)
self.assertNotIn("Location", response)
content = json.loads(response.content) content = json.loads(response.content)
self.assertIn("error", content) self.assertEqual(content, expected)
self.reload_course()
# Verify that user_partitions is properly updated in the course.
self.assertEqual(len(self.course.user_partitions), 1)
self.assertEqual(self.course.user_partitions[0].name, u'New Test name')
...@@ -65,6 +65,7 @@ class TextbookIndexTestCase(CourseTestCase): ...@@ -65,6 +65,7 @@ class TextbookIndexTestCase(CourseTestCase):
) )
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
obj = json.loads(resp.content) obj = json.loads(resp.content)
self.assertEqual(content, obj) self.assertEqual(content, obj)
def test_view_index_xhr_put(self): def test_view_index_xhr_put(self):
......
...@@ -4,7 +4,10 @@ define([ ...@@ -4,7 +4,10 @@ define([
'use strict'; 'use strict';
var Group = Backbone.AssociatedModel.extend({ var Group = Backbone.AssociatedModel.extend({
defaults: function() { defaults: function() {
return { name: '' }; return {
name: '',
version: null
};
}, },
isEmpty: function() { isEmpty: function() {
...@@ -12,7 +15,10 @@ define([ ...@@ -12,7 +15,10 @@ define([
}, },
toJSON: function() { toJSON: function() {
return { name: this.get('name') }; return {
name: this.get('name'),
version: this.get('version')
};
}, },
validate: function(attrs) { validate: function(attrs) {
......
...@@ -10,6 +10,7 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) { ...@@ -10,6 +10,7 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) {
return { return {
name: '', name: '',
description: '', description: '',
version: null,
groups: new GroupCollection([]), groups: new GroupCollection([]),
showGroups: false, showGroups: false,
editing: false editing: false
...@@ -51,6 +52,7 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) { ...@@ -51,6 +52,7 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) {
id: this.get('id'), id: this.get('id'),
name: this.get('name'), name: this.get('name'),
description: this.get('description'), description: this.get('description'),
version: this.get('version'),
groups: this.get('groups').toJSON() groups: this.get('groups').toJSON()
}; };
}, },
......
...@@ -93,10 +93,13 @@ define([ ...@@ -93,10 +93,13 @@ define([
'id': 10, 'id': 10,
'name': 'My Group Configuration', 'name': 'My Group Configuration',
'description': 'Some description', 'description': 'Some description',
'version': 1,
'groups': [ 'groups': [
{ {
'version': 1,
'name': 'Group 1' 'name': 'Group 1'
}, { }, {
'version': 1,
'name': 'Group 2' 'name': 'Group 2'
} }
] ]
...@@ -107,10 +110,13 @@ define([ ...@@ -107,10 +110,13 @@ define([
'description': 'Some description', 'description': 'Some description',
'showGroups': false, 'showGroups': false,
'editing': false, 'editing': false,
'version': 1,
'groups': [ 'groups': [
{ {
'version': 1,
'name': 'Group 1' 'name': 'Group 1'
}, { }, {
'version': 1,
'name': 'Group 2' 'name': 'Group 2'
} }
] ]
......
...@@ -273,7 +273,9 @@ define([ ...@@ -273,7 +273,9 @@ define([
describe('GroupConfigurationItem', function() { describe('GroupConfigurationItem', function() {
beforeEach(function() { beforeEach(function() {
this.model = new GroupConfigurationModel({ }); view_helpers.installTemplate('group-configuration-edit', true);
view_helpers.installTemplate('group-configuration-details');
this.model = new GroupConfigurationModel({ id: 0 });
this.collection = new GroupConfigurationCollection([ this.model ]); this.collection = new GroupConfigurationCollection([ this.model ]);
this.view = new GroupConfigurationItem({ this.view = new GroupConfigurationItem({
model: this.model model: this.model
...@@ -284,10 +286,10 @@ define([ ...@@ -284,10 +286,10 @@ define([
it('should render properly', function() { it('should render properly', function() {
// Details view by default // Details view by default
expect(this.view.$(SELECTORS.detailsView)).toExist(); expect(this.view.$(SELECTORS.detailsView)).toExist();
this.model.set('editing', true); this.view.$('.action-edit .edit').click();
expect(this.view.$(SELECTORS.editView)).toExist(); expect(this.view.$(SELECTORS.editView)).toExist();
expect(this.view.$(SELECTORS.detailsView)).not.toExist(); expect(this.view.$(SELECTORS.detailsView)).not.toExist();
this.model.set('editing', false); this.view.$('.action-cancel').click();
expect(this.view.$(SELECTORS.detailsView)).toExist(); expect(this.view.$(SELECTORS.detailsView)).toExist();
expect(this.view.$(SELECTORS.editView)).not.toExist(); expect(this.view.$(SELECTORS.editView)).not.toExist();
}); });
......
...@@ -6,6 +6,7 @@ function(BaseView, _, gettext) { ...@@ -6,6 +6,7 @@ function(BaseView, _, gettext) {
var GroupConfigurationDetails = BaseView.extend({ var GroupConfigurationDetails = BaseView.extend({
tagName: 'div', tagName: 'div',
events: { events: {
'click .edit': 'editConfiguration',
'click .show-groups': 'showGroups', 'click .show-groups': 'showGroups',
'click .hide-groups': 'hideGroups' 'click .hide-groups': 'hideGroups'
}, },
...@@ -36,6 +37,11 @@ function(BaseView, _, gettext) { ...@@ -36,6 +37,11 @@ function(BaseView, _, gettext) {
return this; return this;
}, },
editConfiguration: function(event) {
if(event && event.preventDefault) { event.preventDefault(); }
this.model.set('editing', true);
},
showGroups: function(e) { showGroups: function(e) {
if(e && e.preventDefault) { e.preventDefault(); } if(e && e.preventDefault) { e.preventDefault(); }
this.model.set('showGroups', true); this.model.set('showGroups', true);
......
...@@ -44,6 +44,7 @@ define([ ...@@ -44,6 +44,7 @@ define([
} }
this.$el.html(this.view.render().el); this.$el.html(this.view.render().el);
this.$el.focus();
return this; return this;
} }
......
...@@ -34,6 +34,7 @@ define([ ...@@ -34,6 +34,7 @@ define([
this.$el.html([frag]); this.$el.html([frag]);
} }
return this; return this;
}, },
......
...@@ -157,6 +157,24 @@ ...@@ -157,6 +157,24 @@
} }
} }
} }
.actions {
@include transition(opacity .15s .25s ease-in-out);
opacity: 0.0;
position: absolute;
top: $baseline;
right: $baseline;
.action {
display: inline-block;
margin-right: ($baseline/4);
.edit {
@include blue-button;
@extend %t-action4;
}
}
}
} }
&:hover .actions { &:hover .actions {
...@@ -322,6 +340,26 @@ ...@@ -322,6 +340,26 @@
&.add-group-configuration-name label { &.add-group-configuration-name label {
@extend %t-title5; @extend %t-title5;
display: inline-block;
width: 50%;
padding-right: 5%;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
}
.group-configuration-id {
display: inline-block;
width: 45%;
text-align: right;
vertical-align: top;
color: $gray-l1;
.group-configuration-value {
font-weight: 600;
white-space: nowrap;
margin-left: ($baseline*0.5);
}
} }
} }
......
...@@ -70,6 +70,16 @@ function(doc, GroupConfigurationCollection, GroupConfigurationsPage) { ...@@ -70,6 +70,16 @@ function(doc, GroupConfigurationCollection, GroupConfigurationsPage) {
</article> </article>
<aside class="content-supplementary" role="complimentary"> <aside class="content-supplementary" role="complimentary">
<div class="bit"> <div class="bit">
<h3 class="title-3">${_("What can I do on this page?")}</h3>
<p>${_("You can create and edit group configurations.")}</p>
<p>${_("A group configuration defines how many groups of students are in an experiment. When you create a content experiment, you select the group configuration to use.")}</p>
<p>${_("Click {em_start}New Group Configuration{em_end} to add a new configuration. To edit an existing configuration, hover over its box and click {em_start}Edit{em_end}.").format(em_start='<strong>', em_end="</strong>")}</p>
<p><a href="${get_online_help_info(online_help_token())['doc_url']}" target="_blank">${_("Learn More")}</a></p>
</div>
<div class="bit">
% if context_course: % if context_course:
<% <%
details_url = utils.reverse_course_url('settings_handler', context_course.id) details_url = utils.reverse_course_url('settings_handler', context_course.id)
......
...@@ -37,4 +37,9 @@ ...@@ -37,4 +37,9 @@
<% }) %> <% }) %>
</ol> </ol>
<% } %> <% } %>
<ul class="actions group-configuration-actions">
<li class="action action-edit">
<button class="edit"><%= gettext("Edit") %></button>
</li>
</ul>
</div> </div>
...@@ -8,7 +8,14 @@ ...@@ -8,7 +8,14 @@
<fieldset class="group-configuration-fields"> <fieldset class="group-configuration-fields">
<legend class="sr"><%= gettext("Group Configuration information") %></legend> <legend class="sr"><%= gettext("Group Configuration information") %></legend>
<div class="input-wrap field text required add-group-configuration-name <% if(error && error.attributes && error.attributes.name) { print('error'); } %>"> <div class="input-wrap field text required add-group-configuration-name <% if(error && error.attributes && error.attributes.name) { print('error'); } %>">
<label for="group-configuration-name-<%= uniqueId %>"><%= gettext("Group Configuration Name") %></label> <label for="group-configuration-name-<%= uniqueId %>"><%= gettext("Group Configuration Name") %></label><%
if (!_.isUndefined(id)) {
%><span class="group-configuration-id">
<span class="group-configuration-label"><%= gettext('Group Configuration ID') %></span>
<span class="group-configuration-value"><%= id %></span>
</span><%
}
%>
<input id="group-configuration-name-<%= uniqueId %>" class="group-configuration-name-input input-text" name="group-configuration-name" type="text" placeholder="<%= gettext("This is the Name of the Group Configuration") %>" value="<%= name %>"> <input id="group-configuration-name-<%= uniqueId %>" class="group-configuration-name-input input-text" name="group-configuration-name" type="text" placeholder="<%= gettext("This is the Name of the Group Configuration") %>" value="<%= name %>">
<span class="tip tip-stacked"><%= gettext("Name or short description of the configuration") %></span> <span class="tip tip-stacked"><%= gettext("Name or short description of the configuration") %></span>
</div> </div>
......
...@@ -63,7 +63,7 @@ class ComponentEditorView(PageObject): ...@@ -63,7 +63,7 @@ class ComponentEditorView(PageObject):
action = action.send_keys(Keys.BACKSPACE) action = action.send_keys(Keys.BACKSPACE)
# Send the new text, then Tab to move to the next field (so change event is triggered). # Send the new text, then Tab to move to the next field (so change event is triggered).
action.send_keys(value).send_keys(Keys.TAB).perform() action.send_keys(value).send_keys(Keys.TAB).perform()
click_css(self, 'a.action-save') self.save()
def set_select_value_and_save(self, label, value): def set_select_value_and_save(self, label, value):
""" """
...@@ -72,4 +72,27 @@ class ComponentEditorView(PageObject): ...@@ -72,4 +72,27 @@ class ComponentEditorView(PageObject):
elem = self.get_setting_element(label) elem = self.get_setting_element(label)
select = Select(elem) select = Select(elem)
select.select_by_value(value) select.select_by_value(value)
self.save()
def get_selected_option_text(self, label):
"""
Returns the text of the first selected option for the select with given label (display name).
"""
elem = self.get_setting_element(label)
if elem:
select = Select(elem)
return select.first_selected_option.text
else:
return None
def save(self):
"""
Clicks save button.
"""
click_css(self, 'a.action-save') click_css(self, 'a.action-save')
def cancel(self):
"""
Clicks cancel button.
"""
click_css(self, 'a.action-cancel', require_notification=False)
...@@ -134,6 +134,12 @@ class ContainerPage(PageObject): ...@@ -134,6 +134,12 @@ class ContainerPage(PageObject):
""" """
return self.q(css='.add-missing-groups-button').present return self.q(css='.add-missing-groups-button').present
def get_xblock_information_message(self):
"""
Returns an information message for the container page.
"""
return self.q(css=".xblock-message.information").first.text[0]
class XBlockWrapper(PageObject): class XBlockWrapper(PageObject):
""" """
......
...@@ -17,7 +17,7 @@ class GroupConfigurationsPage(CoursePage): ...@@ -17,7 +17,7 @@ class GroupConfigurationsPage(CoursePage):
def group_configurations(self): def group_configurations(self):
""" """
Returns list of the group configurations for the course. Return list of the group configurations for the course.
""" """
css = '.group-configurations-list-item' css = '.group-configurations-list-item'
return [GroupConfiguration(self, index) for index in xrange(len(self.q(css=css)))] return [GroupConfiguration(self, index) for index in xrange(len(self.q(css=css)))]
...@@ -55,6 +55,19 @@ class GroupConfiguration(object): ...@@ -55,6 +55,19 @@ class GroupConfiguration(object):
css = 'a.group-toggle' css = 'a.group-toggle'
self.find_css(css).first.click() self.find_css(css).first.click()
def get_text(self, css):
"""
Return text for the defined by css locator.
"""
return self.find_css(css).first.text[0]
def edit(self):
"""
Open editing view for the group configuration.
"""
css = '.action-edit .edit'
self.find_css(css).first.click()
def save(self): def save(self):
""" """
Save group configuration. Save group configuration.
...@@ -73,7 +86,7 @@ class GroupConfiguration(object): ...@@ -73,7 +86,7 @@ class GroupConfiguration(object):
@property @property
def mode(self): def mode(self):
""" """
Returns group configuration mode. Return group configuration mode.
""" """
if self.find_css('.group-configuration-edit').present: if self.find_css('.group-configuration-edit').present:
return 'edit' return 'edit'
...@@ -83,31 +96,28 @@ class GroupConfiguration(object): ...@@ -83,31 +96,28 @@ class GroupConfiguration(object):
@property @property
def id(self): def id(self):
""" """
Returns group configuration id. Return group configuration id.
""" """
css = '.group-configuration-id .group-configuration-value' return self.get_text('.group-configuration-id .group-configuration-value')
return self.find_css(css).first.text[0]
@property @property
def validation_message(self): def validation_message(self):
""" """
Returns validation message. Return validation message.
""" """
css = '.message-status.error' return self.get_text('.message-status.error')
return self.find_css(css).first.text[0]
@property @property
def name(self): def name(self):
""" """
Returns group configuration name. Return group configuration name.
""" """
css = '.group-configuration-title' return self.get_text('.group-configuration-title')
return self.find_css(css).first.text[0]
@name.setter @name.setter
def name(self, value): def name(self, value):
""" """
Sets group configuration name. Set group configuration name.
""" """
css = '.group-configuration-name-input' css = '.group-configuration-name-input'
self.find_css(css).first.fill(value) self.find_css(css).first.fill(value)
...@@ -115,15 +125,14 @@ class GroupConfiguration(object): ...@@ -115,15 +125,14 @@ class GroupConfiguration(object):
@property @property
def description(self): def description(self):
""" """
Returns group configuration description. Return group configuration description.
""" """
css = '.group-configuration-description' return self.get_text('.group-configuration-description')
return self.find_css(css).first.text[0]
@description.setter @description.setter
def description(self, value): def description(self, value):
""" """
Sets group configuration description. Set group configuration description.
""" """
css = '.group-configuration-description-input' css = '.group-configuration-description-input'
self.find_css(css).first.fill(value) self.find_css(css).first.fill(value)
...@@ -131,7 +140,7 @@ class GroupConfiguration(object): ...@@ -131,7 +140,7 @@ class GroupConfiguration(object):
@property @property
def groups(self): def groups(self):
""" """
Returns list of groups. Return list of groups.
""" """
css = '.group' css = '.group'
...@@ -161,7 +170,7 @@ class Group(object): ...@@ -161,7 +170,7 @@ class Group(object):
@property @property
def name(self): def name(self):
""" """
Returns group name. Return group name.
""" """
css = '.group-name' css = '.group-name'
return self.find_css(css).first.text[0] return self.find_css(css).first.text[0]
...@@ -169,7 +178,7 @@ class Group(object): ...@@ -169,7 +178,7 @@ class Group(object):
@property @property
def allocation(self): def allocation(self):
""" """
Returns allocation for the group. Return allocation for the group.
""" """
css = '.group-allocation' css = '.group-allocation'
return self.find_css(css).first.text[0] return self.find_css(css).first.text[0]
......
...@@ -4,6 +4,7 @@ Acceptance tests for Studio related to the split_test module. ...@@ -4,6 +4,7 @@ Acceptance tests for Studio related to the split_test module.
import json import json
import os import os
import math
from unittest import skip, skipUnless from unittest import skip, skipUnless
from xmodule.partitions.partitions import Group, UserPartition from xmodule.partitions.partitions import Group, UserPartition
...@@ -243,10 +244,42 @@ class GroupConfigurationsTest(ContainerBase): ...@@ -243,10 +244,42 @@ class GroupConfigurationsTest(ContainerBase):
self.course_info['run'] self.course_info['run']
) )
def _assert_fields(self, config, cid=None, name='', description='', groups=None):
self.assertEqual(config.mode, 'details')
if name:
self.assertIn(name, config.name)
if cid:
self.assertEqual(cid, config.id)
else:
# To make sure that id is present on the page and it is not an empty.
# We do not check the value of the id, because it's generated randomly and we cannot
# predict this value
self.assertTrue(config.id)
# Expand the configuration
config.toggle()
if description:
self.assertIn(description, config.description)
if groups:
allocation = int(math.floor(100 / len(groups)))
for index, group in enumerate(groups):
self.assertEqual(group, config.groups[index].name)
self.assertEqual(str(allocation) + "%", config.groups[index].allocation)
# Collapse the configuration
config.toggle()
def test_no_group_configurations_added(self): def test_no_group_configurations_added(self):
""" """
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
shown when group configurations were not added. shown when group configurations were not added.
Given I have a course without group configurations
When I go to the Group Configuration page in Studio
Then I see "You haven't created any group configurations yet." message
And "Create new Group Configuration" button is available
""" """
self.page.visit() self.page.visit()
css = ".wrapper-content .no-group-configurations-content" css = ".wrapper-content .no-group-configurations-content"
...@@ -258,8 +291,14 @@ class GroupConfigurationsTest(ContainerBase): ...@@ -258,8 +291,14 @@ class GroupConfigurationsTest(ContainerBase):
def test_group_configurations_have_correct_data(self): def test_group_configurations_have_correct_data(self):
""" """
Ensure that the group configuration is rendered correctly in Scenario: Ensure that the group configuration is rendered correctly in expanded/collapsed mode.
expanded/collapsed mode. Given I have a course with 2 group configurations
And I go to the Group Configuration page in Studio
And I work with the first group configuration
And I see `name`, `id` are visible and have correct values
When I expand the first group configuration
Then I see `description` and `groups` appear and also have correct values
And I do the same checks for the second group configuration
""" """
self.course_fix.add_advanced_settings({ self.course_fix.add_advanced_settings({
u"user_partitions": { u"user_partitions": {
...@@ -274,66 +313,93 @@ class GroupConfigurationsTest(ContainerBase): ...@@ -274,66 +313,93 @@ class GroupConfigurationsTest(ContainerBase):
self.page.visit() self.page.visit()
config = self.page.group_configurations()[0] config = self.page.group_configurations()[0]
self.assertIn("Name of the Group Configuration", config.name) # no groups when the the configuration is collapsed
self.assertEqual(config.id, '0') self.assertEqual(len(config.groups), 0)
# Expand the configuration self._assert_fields(
config.toggle() config,
self.assertIn("Description of the group configuration.", config.description) cid="0", name="Name of the Group Configuration",
self.assertEqual(len(config.groups), 2) description="Description of the group configuration.",
groups=["Group 0", "Group 1"]
self.assertEqual("Group 0", config.groups[0].name) )
self.assertEqual("50%", config.groups[0].allocation)
config = self.page.group_configurations()[1] config = self.page.group_configurations()[1]
self.assertIn("Name of second Group Configuration", config.name)
self.assertEqual(len(config.groups), 0) # no groups when the partition is collapsed
# Expand the configuration
config.toggle()
self.assertEqual(len(config.groups), 3)
self.assertEqual("Beta", config.groups[1].name) self._assert_fields(
self.assertEqual("33%", config.groups[1].allocation) config,
name="Name of second Group Configuration",
description="Second group configuration.",
groups=["Alpha", "Beta", "Gamma"]
)
def test_can_create_group_configuration(self): def test_can_create_and_edit_group_configuration(self):
""" """
Ensure that the group configuration can be created correctly. Scenario: Ensure that the group configuration can be created and edited correctly.
Given I have a course without group configurations
When I click button 'Create new Group Configuration'
And I set new name and description
And I click button 'Create'
Then I see the new group configuration is added
When I edit the group group_configuration
And I change the name and description
And I click button 'Save'
Then I see the group configuration is saved successfully and has the new data
""" """
self.page.visit() self.page.visit()
self.assertEqual(len(self.page.group_configurations()), 0) self.assertEqual(len(self.page.group_configurations()), 0)
# Create new group configuration # Create new group configuration
self.page.create() self.page.create()
config = self.page.group_configurations()[0] config = self.page.group_configurations()[0]
config.name = "New Group Configuration Name" config.name = "New Group Configuration Name"
config.description = "New Description of the group configuration." config.description = "New Description of the group configuration."
self.assertEqual(config.get_text('.action-primary'), "CREATE")
# Save the configuration # Save the configuration
config.save() config.save()
self.assertEqual(config.mode, 'details') self._assert_fields(
self.assertIn("New Group Configuration Name", config.name) config,
name="New Group Configuration Name",
description="New Description of the group configuration.",
groups=["Group A", "Group B"]
)
# Edit the group configuration
config.edit()
# Update fields
self.assertTrue(config.id) self.assertTrue(config.id)
# Expand the configuration config.name = "Second Group Configuration Name"
config.toggle() config.description = "Second Description of the group configuration."
self.assertIn("New Description of the group configuration.", config.description) self.assertEqual(config.get_text('.action-primary'), "SAVE")
self.assertEqual(len(config.groups), 2) # Save the configuration
config.save()
self.assertEqual("Group A", config.groups[0].name) self._assert_fields(
self.assertEqual("Group B", config.groups[1].name) config,
self.assertEqual("50%", config.groups[0].allocation) name="Second Group Configuration Name",
description="Second Description of the group configuration."
)
def test_use_group_configuration(self): def test_use_group_configuration(self):
""" """
Create and use group configuration Scenario: Ensure that the group configuration can be used by split_module correctly
Given I have a course without group configurations
When I create new group configuration
And I set new name, save the group configuration
And I go to the unit page in Studio
And I add new advanced module "Content Experiment"
When I assign created group configuration to the module
Then I see the module has correct groups
And I go to the Group Configuration page in Studio
And I edit the name of the group configuration
And I go to the unit page in Studio
And I edit the unit
Then I see the group configuration name is changed in `Group Configuration` dropdown
And the group configuration name is changed on container page
""" """
self.page.visit() self.page.visit()
self.assertEqual(len(self.page.group_configurations()), 0)
# Create new group configuration # Create new group configuration
self.page.create() self.page.create()
config = self.page.group_configurations()[0] config = self.page.group_configurations()[0]
config.name = "New Group Configuration Name" config.name = "New Group Configuration Name"
config.description = "New Description of the group configuration."
# Save the configuration # Save the configuration
config.save() config.save()
...@@ -345,9 +411,34 @@ class GroupConfigurationsTest(ContainerBase): ...@@ -345,9 +411,34 @@ class GroupConfigurationsTest(ContainerBase):
component_editor.set_select_value_and_save('Group Configuration', 'New Group Configuration Name') component_editor.set_select_value_and_save('Group Configuration', 'New Group Configuration Name')
self.verify_groups(container, ['Group A', 'Group B'], []) self.verify_groups(container, ['Group A', 'Group B'], [])
self.page.visit()
config = self.page.group_configurations()[0]
config.edit()
config.name = "Second Group Configuration Name"
# Save the configuration
config.save()
container = self.go_to_container_page()
container.edit()
component_editor = ComponentEditorView(self.browser, container.locator)
self.assertEqual(
"Second Group Configuration Name",
component_editor.get_selected_option_text('Group Configuration')
)
component_editor.cancel()
self.assertIn(
"Second Group Configuration Name",
container.get_xblock_information_message()
)
def test_can_cancel_creation_of_group_configuration(self): def test_can_cancel_creation_of_group_configuration(self):
""" """
Ensure that creation of the group configuration can be canceled correctly. Scenario: Ensure that creation of the group configuration can be canceled correctly.
Given I have a course without group configurations
When I click button 'Create new Group Configuration'
And I set new name and description
And I click button 'Cancel'
Then I see that there is no new group configurations in the course
""" """
self.page.visit() self.page.visit()
...@@ -363,9 +454,49 @@ class GroupConfigurationsTest(ContainerBase): ...@@ -363,9 +454,49 @@ class GroupConfigurationsTest(ContainerBase):
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):
"""
Scenario: Ensure that editing of the group configuration can be canceled correctly.
Given I have a course with group configuration
When I go to the edit mode of the group configuration
And I set new name and description
And I click button 'Cancel'
Then I see that new changes were discarded
"""
self.course_fix.add_advanced_settings({
u"user_partitions": {
"value": [
UserPartition(0, 'Name of the Group Configuration', 'Description of the group configuration.', [Group("0", 'Group 0'), Group("1", 'Group 1')]).to_json(),
UserPartition(1, 'Name of second Group Configuration', 'Second group configuration.', [Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]).to_json()
],
},
})
self.course_fix._add_advanced_settings()
self.page.visit()
config = self.page.group_configurations()[0]
config.name = "New Group Configuration Name"
config.description = "New Description of the group configuration."
# Cancel the configuration
config.cancel()
self._assert_fields(
config,
name="Name of the Group Configuration",
description="Description of the group configuration.",
groups=["Group 0", "Group 1"]
)
def test_group_configuration_validation(self): def test_group_configuration_validation(self):
""" """
Ensure that validation of the group configuration works correctly. Scenario: Ensure that validation of the group configuration works correctly.
Given I have a course without group configurations
And I create new group configuration with 2 default groups
When I set only description and try to save
Then I see error message "Group Configuration name is required"
When I set new name and try to save
Then I see the group configuration is saved successfully
""" """
self.page.visit() self.page.visit()
...@@ -387,11 +518,10 @@ class GroupConfigurationsTest(ContainerBase): ...@@ -387,11 +518,10 @@ class GroupConfigurationsTest(ContainerBase):
config.name = "Name of the Group Configuration" config.name = "Name of the Group Configuration"
# Save the configuration # Save the configuration
config.save() config.save()
# Verify the configuration is saved and it is shown in `details` mode.
self.assertEqual(config.mode, 'details') self._assert_fields(
# Verify the configuration for the data correctness config,
self.assertIn("Name of the Group Configuration", config.name) name="Name of the Group Configuration",
self.assertTrue(config.id) description="Description of the group configuration.",
# Expand the configuration groups=["Group A", "Group B"]
config.toggle() )
self.assertIn("Description of the group configuration.", config.description)
...@@ -33,6 +33,7 @@ export = building_course/export_import_course.html#export-a-course ...@@ -33,6 +33,7 @@ export = building_course/export_import_course.html#export-a-course
welcome = getting_started/get_started.html welcome = getting_started/get_started.html
login = getting_started/get_started.html login = getting_started/get_started.html
register = getting_started/get_started.html register = getting_started/get_started.html
group_configurations = content_experiments/content_experiments_configure.html#set-up-group-configurations-in-edx-studio
# below are the language directory names for the different locales # below are the language directory names for the different locales
[locales] [locales]
......
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