Commit 1c902204 by Jeff LaJoie

EDUCATOR-434: Extends Course Outline Unit settings to allow Group Access configuration

parent 626f015a
......@@ -7,6 +7,7 @@ import logging
from django.utils.translation import ugettext as _
from contentstore.utils import reverse_usage_url
from lms.lib.utils import get_parent_unit
from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition
from util.db import MYSQL_MAX_INT, generate_int_id
from xmodule.partitions.partitions import MINIMUM_STATIC_PARTITION_ID, UserPartition
......@@ -111,9 +112,30 @@ class GroupConfiguration(object):
"""
Get usage info for unit/module.
"""
parent_unit = get_parent_unit(item)
if unit == parent_unit and not item.has_children:
# Display the topmost unit page if
# the item is a child of the topmost unit and doesn't have its own children.
unit_for_url = unit
elif (not parent_unit and unit.get_parent()) or (unit == parent_unit and item.has_children):
# Display the item's page rather than the unit page if
# the item is one level below the topmost unit and has children, or
# the item itself *is* the topmost unit (and thus does not have a parent unit, but is not an orphan).
unit_for_url = item
else:
# If the item is nested deeper than two levels (the topmost unit > vertical > ... > item)
# display the page for the nested vertical element.
parent = item.get_parent()
nested_vertical = item
while parent != parent_unit:
nested_vertical = parent
parent = parent.get_parent()
unit_for_url = nested_vertical
unit_url = reverse_usage_url(
'container_handler',
course.location.course_key.make_usage_key(unit.location.block_type, unit.location.name)
course.location.course_key.make_usage_key(unit_for_url.location.block_type, unit_for_url.location.name)
)
usage_dict = {'label': u"{} / {}".format(unit.display_name, item.display_name), 'url': unit_url}
......
......@@ -431,7 +431,7 @@ def get_user_partition_info(xblock, schemes=None, course=None):
return partitions
def get_visibility_partition_info(xblock):
def get_visibility_partition_info(xblock, course=None):
"""
Retrieve user partition information for the component visibility editor.
......@@ -440,12 +440,16 @@ def get_visibility_partition_info(xblock):
Arguments:
xblock (XBlock): The component being edited.
course (XBlock): The course descriptor. If provided, uses this to look up the user partitions
instead of loading the course. This is useful if we're calling this function multiple
times for the same course want to minimize queries to the modulestore.
Returns: dict
"""
selectable_partitions = []
# We wish to display enrollment partitions before cohort partitions.
enrollment_user_partitions = get_user_partition_info(xblock, schemes=["enrollment_track"])
enrollment_user_partitions = get_user_partition_info(xblock, schemes=["enrollment_track"], course=course)
# For enrollment partitions, we only show them if there is a selected group or
# or if the number of groups > 1.
......@@ -454,7 +458,7 @@ def get_visibility_partition_info(xblock):
selectable_partitions.append(partition)
# Now add the cohort user partitions.
selectable_partitions = selectable_partitions + get_user_partition_info(xblock, schemes=["cohort"])
selectable_partitions = selectable_partitions + get_user_partition_info(xblock, schemes=["cohort"], course=course)
# Find the first partition with a selected group. That will be the one initially enabled in the dialog
# (if the course has only been added in Studio, only one partition should have a selected group).
......
......@@ -46,8 +46,8 @@ CONTAINER_TEMPLATES = [
"editor-mode-button", "upload-dialog",
"add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu",
"add-xblock-component-support-legend", "add-xblock-component-support-level", "add-xblock-component-menu-problem",
"xblock-string-field-editor", "publish-xblock", "publish-history",
"unit-outline", "container-message", "license-selector",
"xblock-string-field-editor", "xblock-access-editor", "publish-xblock", "publish-history",
"unit-outline", "container-message", "container-access", "license-selector",
]
......
......@@ -30,6 +30,7 @@ from contentstore.utils import (
find_staff_lock_source,
get_split_group_display_name,
get_user_partition_info,
get_visibility_partition_info,
has_children_visible_to_specific_partition_groups,
is_currently_visible_to_students,
is_self_paced
......@@ -1231,9 +1232,11 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
else:
xblock_info['staff_only_message'] = False
xblock_info["has_partition_group_components"] = has_children_visible_to_specific_partition_groups(
xblock_info['has_partition_group_components'] = has_children_visible_to_specific_partition_groups(
xblock
)
xblock_info['user_partition_info'] = get_visibility_partition_info(xblock, course=course)
return xblock_info
......
"""Tests for items views."""
import json
from datetime import datetime, timedelta
import ddt
from mock import patch, Mock, PropertyMock
from pytz import UTC
from pyquery import PyQuery
from webob import Response
import ddt
from django.conf import settings
from django.core.urlresolvers import reverse
from django.http import Http404
from django.test import TestCase
from django.test.client import RequestFactory
from django.core.urlresolvers import reverse
from contentstore.utils import reverse_usage_url, reverse_course_url
from mock import Mock, PropertyMock, patch
from opaque_keys import InvalidKeyError
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from contentstore.views.component import (
component_handler, get_component_templates
)
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locations import Location
from pyquery import PyQuery
from pytz import UTC
from webob import Response
from xblock.core import XBlockAside
from xblock.exceptions import NoSuchHandlerError
from xblock.fields import Scope, ScopeIds, String
from xblock.fragment import Fragment
from xblock.runtime import DictKeyValueStore, KvsFieldData
from xblock.test.tools import TestRuntime
from xblock.validation import ValidationMessage
from contentstore.tests.utils import CourseTestCase
from contentstore.utils import reverse_course_url, reverse_usage_url
from contentstore.views.component import component_handler, get_component_templates
from contentstore.views.item import (
create_xblock_info, _get_source_index, _get_module_info, ALWAYS, VisibilityState, _xblock_type_and_display_name,
add_container_page_publishing_info
ALWAYS,
VisibilityState,
_get_module_info,
_get_source_index,
_xblock_type_and_display_name,
add_container_page_publishing_info,
create_xblock_info
)
from contentstore.tests.utils import CourseTestCase
from lms_xblock.mixin import NONSENSICAL_ACCESS_RESTRICTION
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from student.tests.factories import UserFactory
from xblock_django.models import XBlockConfiguration, XBlockStudioConfiguration, XBlockStudioConfigurationFlag
from xblock_django.user_service import DjangoXBlockUserService
from xmodule.capa_module import CapaDescriptor
from xmodule.course_module import DEFAULT_START_DATE
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_SPLIT_MODULESTORE
from xmodule.modulestore.tests.factories import ItemFactory, LibraryFactory, check_mongo_calls, CourseFactory
from xmodule.x_module import STUDIO_VIEW, STUDENT_VIEW
from xmodule.course_module import DEFAULT_START_DATE
from xblock.core import XBlockAside
from xblock.fields import Scope, String, ScopeIds
from xblock.fragment import Fragment
from xblock.runtime import DictKeyValueStore, KvsFieldData
from xblock.test.tools import TestRuntime
from xblock.exceptions import NoSuchHandlerError
from xblock_django.user_service import DjangoXBlockUserService
from opaque_keys.edx.keys import UsageKey, CourseKey
from opaque_keys.edx.locations import Location
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, LibraryFactory, check_mongo_calls
from xmodule.partitions.partitions import (
Group, UserPartition, ENROLLMENT_TRACK_PARTITION_ID, MINIMUM_STATIC_PARTITION_ID
ENROLLMENT_TRACK_PARTITION_ID,
MINIMUM_STATIC_PARTITION_ID,
Group,
UserPartition
)
from xmodule.partitions.tests.test_partitions import MockPartitionService
from xmodule.x_module import STUDENT_VIEW, STUDIO_VIEW
class AsideTest(XBlockAside):
......@@ -1155,6 +1162,64 @@ class TestMoveItem(ItemTest):
response = json.loads(response.content)
self.assertEqual(response['error'], 'Patch request did not recognise any parameters to handle.')
def _verify_validation_message(self, message, expected_message, expected_message_type):
"""
Verify that the validation message has the expected validation message and type.
"""
self.assertEqual(message.text, expected_message)
self.assertEqual(message.type, expected_message_type)
def test_move_component_nonsensical_access_restriction_validation(self):
"""
Test that moving a component with non-contradicting access
restrictions into a unit that has contradicting access
restrictions brings up the nonsensical access validation
message and that the message does not show up when moved
into a unit where the component's access settings do not
contradict the unit's access settings.
"""
group1 = self.course.user_partitions[0].groups[0]
group2 = self.course.user_partitions[0].groups[1]
vert2 = self.store.get_item(self.vert2_usage_key)
html = self.store.get_item(self.html_usage_key)
# Inject mock partition service as obtaining the course from the draft modulestore
# (which is the default for these tests) does not work.
partitions_service = MockPartitionService(
self.course,
course_id=self.course.id,
)
html.runtime._services['partitions'] = partitions_service
# Set access settings so html will contradict vert2 when moved into that unit
vert2.group_access = {self.course.user_partitions[0].id: [group1.id]}
html.group_access = {self.course.user_partitions[0].id: [group2.id]}
self.store.update_item(html, self.user.id)
self.store.update_item(vert2, self.user.id)
# Verify that there is no warning when html is in a non contradicting unit
validation = html.validate()
self.assertEqual(len(validation.messages), 0)
# Now move it and confirm that the html component has been moved into vertical 2
self.assert_move_item(self.html_usage_key, self.vert2_usage_key)
html.parent = self.vert2_usage_key
self.store.update_item(html, self.user.id)
validation = html.validate()
self.assertEqual(len(validation.messages), 1)
self._verify_validation_message(
validation.messages[0],
NONSENSICAL_ACCESS_RESTRICTION,
ValidationMessage.ERROR,
)
# Move the html component back and confirm that the warning is gone again
self.assert_move_item(self.html_usage_key, self.vert_usage_key)
html.parent = self.vert_usage_key
self.store.update_item(html, self.user.id)
validation = html.validate()
self.assertEqual(len(validation.messages), 0)
@patch('contentstore.views.item.log')
def test_move_logging(self, mock_logger):
"""
......
......@@ -76,7 +76,7 @@ define([
expect(view.$('.delete')).toHaveClass('is-disabled');
expect(view.$(SELECTORS.usageText)).not.toExist();
expect(view.$(SELECTORS.usageUnit)).not.toExist();
expect(view.$(SELECTORS.usageCount)).toContainText('Used in 2 units');
expect(view.$(SELECTORS.usageCount)).toContainText('Used in 2 locations');
};
var setUsageInfo = function(model) {
model.set('usage', [
......
......@@ -235,12 +235,12 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
});
it('can show a visibility modal for a child xblock if supported for the page', function() {
var visibilityButtons, request;
var accessButtons, request;
renderContainerPage(this, mockContainerXBlockHtml);
visibilityButtons = containerPage.$('.wrapper-xblock .visibility-button');
accessButtons = containerPage.$('.wrapper-xblock .access-button');
if (hasVisibilityEditor) {
expect(visibilityButtons.length).toBe(6);
visibilityButtons[0].click();
expect(accessButtons.length).toBe(6);
accessButtons[0].click();
request = AjaxHelpers.currentRequest(requests);
expect(str.startsWith(request.url, '/xblock/locator-component-A1/visibility_view'))
.toBeTruthy();
......@@ -251,7 +251,7 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
expect(EditHelpers.isShowingModal()).toBeTruthy();
}
else {
expect(visibilityButtons.length).toBe(0);
expect(accessButtons.length).toBe(0);
}
});
......
......@@ -108,18 +108,6 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
fetch({published: false});
expect(containerPage.$(viewPublishedCss)).toHaveClass(disabledCss);
});
it('updates when has_partition_group_components attribute changes', function() {
renderContainerPage(this, mockContainerXBlockHtml);
fetch({has_partition_group_components: false});
expect(containerPage.$(visibilityNoteCss).length).toBe(0);
fetch({has_partition_group_components: true});
expect(containerPage.$(visibilityNoteCss).length).toBe(1);
fetch({has_partition_group_components: false});
expect(containerPage.$(visibilityNoteCss).length).toBe(0);
});
});
describe('Publisher', function() {
......
......@@ -30,7 +30,9 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/j
category: 'chapter',
display_name: 'Section',
children: []
}
},
user_partitions: [],
user_partition_info: {}
}, options, {child_info: {children: children}});
};
......@@ -50,7 +52,10 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/j
category: 'sequential',
display_name: 'Subsection',
children: []
}
},
user_partitions: [],
group_access: {},
user_partition_info: {}
}, options, {child_info: {children: children}});
};
......@@ -76,7 +81,10 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/j
category: 'vertical',
display_name: 'Unit',
children: []
}
},
user_partitions: [],
group_access: {},
user_partition_info: {}
}, options, {child_info: {children: children}});
};
......@@ -91,7 +99,10 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/j
published: true,
visibility_state: 'unscheduled',
edited_on: 'Jul 02, 2014 at 20:56 UTC',
edited_by: 'MockUser'
edited_by: 'MockUser',
user_partitions: [],
group_access: {},
user_partition_info: {}
}, options);
};
......@@ -242,8 +253,9 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/j
'course-outline', 'xblock-string-field-editor', 'modal-button',
'basic-modal', 'course-outline-modal', 'release-date-editor',
'due-date-editor', 'grading-editor', 'publish-editor',
'staff-lock-editor', 'content-visibility-editor', 'settings-modal-tabs',
'timed-examination-preference-editor', 'access-editor', 'show-correctness-editor'
'staff-lock-editor', 'unit-access-editor', 'content-visibility-editor',
'settings-modal-tabs', 'timed-examination-preference-editor', 'access-editor',
'show-correctness-editor'
]);
appendSetFixtures(mockOutlinePage);
mockCourseJSON = createMockCourseJSON({}, [
......@@ -1607,6 +1619,44 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/j
);
});
it('shows partition group information with group_access set', function() {
var partitions = [
{
scheme: 'cohort',
id: 1,
groups: [
{
deleted: false,
selected: true,
id: 2,
name: 'Group 2'
},
{
deleted: false,
selected: true,
id: 3,
name: 'Group 3'
}
],
name: 'Content Group Configuration'
}
];
var messages = getUnitStatus({
has_partition_group_components: true,
user_partitions: partitions,
group_access: {1: [2, 3]},
user_partition_info: {
selected_partition_index: 1,
selected_groups_label: '1, 2',
selectable_partitions: partitions
}
});
expect(messages.length).toBe(1);
expect(messages).toContainText(
'Access to this unit is restricted to'
);
});
it('does not show partition group information if visible to all', function() {
var messages = getUnitStatus({});
expect(messages.length).toBe(0);
......
......@@ -86,7 +86,7 @@ function(BaseView, _, gettext, str, StringUtils, HtmlUtils) {
Translators: 'count' is number of units that the group
configuration is used in.
*/
'Used in {count} unit', 'Used in {count} units',
'Used in {count} location', 'Used in {count} locations',
count
),
{count: count}
......
......@@ -14,8 +14,9 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
) {
'use strict';
var CourseOutlineXBlockModal, SettingsXBlockModal, PublishXBlockModal, AbstractEditor, BaseDateEditor,
ReleaseDateEditor, DueDateEditor, GradingEditor, PublishEditor, AbstractVisibilityEditor, StaffLockEditor,
ContentVisibilityEditor, TimedExaminationPreferenceEditor, AccessEditor, ShowCorrectnessEditor;
ReleaseDateEditor, DueDateEditor, GradingEditor, PublishEditor, AbstractVisibilityEditor,
StaffLockEditor, UnitAccessEditor, ContentVisibilityEditor, TimedExaminationPreferenceEditor,
AccessEditor, ShowCorrectnessEditor;
CourseOutlineXBlockModal = BaseModal.extend({
events: _.extend({}, BaseModal.prototype.events, {
......@@ -112,18 +113,6 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
);
},
getIntroductionMessage: function() {
var message = '';
var tabs = this.options.tabs;
if (!tabs || tabs.length < 2) {
message = StringUtils.interpolate(
gettext('Change the settings for {display_name}'),
{display_name: this.model.get('display_name')}
);
}
return message;
},
initializeEditors: function() {
var tabs = this.options.tabs;
if (tabs && tabs.length > 0) {
......@@ -579,6 +568,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
});
AbstractVisibilityEditor = AbstractEditor.extend({
afterRender: function() {
AbstractEditor.prototype.afterRender.call(this);
},
......@@ -633,6 +623,96 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
}
});
UnitAccessEditor = AbstractVisibilityEditor.extend({
templateName: 'unit-access-editor',
className: 'edit-unit-access',
events: {
'change .user-partition-select': function() {
this.hideCheckboxDivs();
this.showSelectedDiv(this.getSelectedEnrollmentTrackId());
}
},
afterRender: function() {
var groupAccess,
keys;
AbstractVisibilityEditor.prototype.afterRender.call(this);
this.hideCheckboxDivs();
if (this.model.attributes.group_access) {
groupAccess = this.model.attributes.group_access;
keys = Object.keys(groupAccess);
if (keys.length === 1) { // should be only one partition key
if (groupAccess.hasOwnProperty(keys[0]) && groupAccess[keys[0]].length > 0) {
// Select the option that has group access, provided there is a specific group within the scheme
this.$('.user-partition-select option[value=' + keys[0] + ']').prop('selected', true);
this.showSelectedDiv(keys[0]);
// Change default option to 'All Learners and Staff' if unit is currently restricted
this.$('#partition-select option:first').text(gettext('All Learners and Staff'));
}
}
}
},
getSelectedEnrollmentTrackId: function() {
return parseInt(this.$('.user-partition-select').val(), 10);
},
getCheckboxDivs: function() {
return $('.user-partition-group-checkboxes').children('div');
},
getSelectedCheckboxesByDivId: function(contentGroupId) {
var $checkboxes = $('#' + contentGroupId + '-checkboxes input:checked'),
selectedCheckboxValues = [],
i;
for (i = 0; i < $checkboxes.length; i++) {
selectedCheckboxValues.push(parseInt($($checkboxes[i]).val(), 10));
}
return selectedCheckboxValues;
},
showSelectedDiv: function(contentGroupId) {
$('#' + contentGroupId + '-checkboxes').show();
},
hideCheckboxDivs: function() {
this.getCheckboxDivs().hide();
},
hasChanges: function() {
// compare the group access object retrieved vs the current selection
return (JSON.stringify(this.model.get('group_access')) !== JSON.stringify(this.getGroupAccessData()));
},
getGroupAccessData: function() {
var userPartitionId = this.getSelectedEnrollmentTrackId(),
groupAccess = {};
if (userPartitionId !== -1 && !isNaN(userPartitionId)) {
groupAccess[userPartitionId] = this.getSelectedCheckboxesByDivId(userPartitionId);
return groupAccess;
} else {
return {};
}
},
getRequestData: function() {
var metadata = {},
groupAccessData = this.getGroupAccessData();
if (this.hasChanges()) {
if (groupAccessData) {
metadata.group_access = groupAccessData;
}
return {
publish: 'republish',
metadata: metadata
};
} else {
return {};
}
}
});
ContentVisibilityEditor = AbstractVisibilityEditor.extend({
templateName: 'content-visibility-editor',
className: 'edit-content-visibility',
......@@ -782,7 +862,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
editors: []
};
if (xblockInfo.isVertical()) {
editors = [StaffLockEditor];
editors = [StaffLockEditor, UnitAccessEditor];
} else {
tabs = [
{
......
......@@ -119,7 +119,11 @@ define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common
getTitle: function() {
var displayName = this.xblockInfo.get('display_name');
if (!displayName) {
displayName = gettext('Component');
if (this.xblockInfo.isVertical()) {
displayName = gettext('Unit');
} else {
displayName = gettext('Component');
}
}
return interpolate(this.options.titleFormat, {title: displayName}, true);
},
......
......@@ -3,20 +3,20 @@
* This page allows the user to understand and manipulate the xblock and its children.
*/
define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/pages/base_page',
'common/js/components/utils/view_utils', 'js/views/container', 'js/views/xblock',
'js/views/components/add_xblock', 'js/views/modals/edit_xblock', 'js/views/modals/move_xblock_modal',
'js/models/xblock_info', 'js/views/xblock_string_field_editor', 'js/views/pages/container_subviews',
'js/views/unit_outline', 'js/views/utils/xblock_utils'],
'common/js/components/utils/view_utils', 'js/views/container', 'js/views/xblock',
'js/views/components/add_xblock', 'js/views/modals/edit_xblock', 'js/views/modals/move_xblock_modal',
'js/models/xblock_info', 'js/views/xblock_string_field_editor', 'js/views/xblock_access_editor',
'js/views/pages/container_subviews', 'js/views/unit_outline', 'js/views/utils/xblock_utils'],
function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView, AddXBlockComponent,
EditXBlockModal, MoveXBlockModal, XBlockInfo, XBlockStringFieldEditor, ContainerSubviews,
UnitOutlineView, XBlockUtils) {
EditXBlockModal, MoveXBlockModal, XBlockInfo, XBlockStringFieldEditor, XBlockAccessEditor,
ContainerSubviews, UnitOutlineView, XBlockUtils) {
'use strict';
var XBlockContainerPage = BasePage.extend({
// takes XBlockInfo as a model
events: {
'click .edit-button': 'editXBlock',
'click .visibility-button': 'editVisibilitySettings',
'click .access-button': 'editVisibilitySettings',
'click .duplicate-button': 'duplicateXBlock',
'click .move-button': 'showMoveXBlockModal',
'click .delete-button': 'deleteXBlock',
......@@ -40,11 +40,18 @@ define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/pages/base_page
initialize: function(options) {
BasePage.prototype.initialize.call(this, options);
this.viewClass = options.viewClass || this.defaultViewClass;
this.isLibraryPage = (this.model.attributes.category === 'library');
this.nameEditor = new XBlockStringFieldEditor({
el: this.$('.wrapper-xblock-field'),
model: this.model
});
this.nameEditor.render();
if (!this.isLibraryPage) {
this.accessEditor = new XBlockAccessEditor({
el: this.$('.wrapper-xblock-field')
});
this.accessEditor.render();
}
if (this.options.action === 'new') {
this.nameEditor.$('.xblock-field-value-edit').click();
}
......@@ -54,8 +61,14 @@ define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/pages/base_page
model: this.model
});
this.messageView.render();
this.isUnitPage = this.options.isUnitPage;
if (this.isUnitPage) {
// Display access message on units and split test components
if (!this.isLibraryPage) {
this.containerAccessView = new ContainerSubviews.ContainerAccess({
el: this.$('.container-access'),
model: this.model
});
this.containerAccessView.render();
this.xblockPublisher = new ContainerSubviews.Publisher({
el: this.$('#publish-unit'),
model: this.model,
......@@ -183,7 +196,7 @@ define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/pages/base_page
editVisibilitySettings: function(event) {
this.editXBlock(event, {
view: 'visibility_view',
// Translators: "title" is the name of the current component being edited.
// Translators: "title" is the name of the current component or unit being edited.
titleFormat: gettext('Editing access for: %(title)s'),
viewSpecificClasses: '',
modalSize: 'med'
......
......@@ -32,6 +32,30 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/compo
render: function() {}
});
var ContainerAccess = ContainerStateListenerView.extend({
initialize: function() {
ContainerStateListenerView.prototype.initialize.call(this);
this.template = this.loadTemplate('container-access');
},
shouldRefresh: function(model) {
return ViewUtils.hasChangedAttributes(model, ['has_partition_group_components', 'user_partitions']);
},
render: function() {
HtmlUtils.setHtml(
this.$el,
HtmlUtils.HTML(
this.template({
hasPartitionGroupComponents: this.model.get('has_partition_group_components'),
userPartitionInfo: this.model.get('user_partition_info')
})
)
);
return this;
}
});
var MessageView = ContainerStateListenerView.extend({
initialize: function() {
ContainerStateListenerView.prototype.initialize.call(this);
......@@ -98,7 +122,7 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/compo
onSync: function(model) {
if (ViewUtils.hasChangedAttributes(model, [
'has_changes', 'published', 'edited_on', 'edited_by', 'visibility_state',
'has_explicit_staff_lock', 'has_partition_group_components'
'has_explicit_staff_lock'
])) {
this.render();
}
......@@ -124,7 +148,6 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/compo
releaseDateFrom: this.model.get('release_date_from'),
hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'),
staffLockFrom: this.model.get('staff_lock_from'),
hasPartitionGroupComponents: this.model.get('has_partition_group_components'),
course: window.course,
HtmlUtils: HtmlUtils
})
......@@ -270,9 +293,10 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/compo
});
return {
'MessageView': MessageView,
'ViewLiveButtonController': ViewLiveButtonController,
'Publisher': Publisher,
'PublishHistory': PublishHistory
MessageView: MessageView,
ViewLiveButtonController: ViewLiveButtonController,
Publisher: Publisher,
PublishHistory: PublishHistory,
ContainerAccess: ContainerAccess
};
}); // end define();
......@@ -68,10 +68,10 @@ define([
/* globals ngettext */
return StringUtils.interpolate(ngettext(
/*
Translators: 'count' is number of units that the group
Translators: 'count' is number of locations that the group
configuration is used in.
*/
'Used in {count} unit', 'Used in {count} units',
'Used in {count} location', 'Used in {count} locations',
count
),
{count: count}
......
/**
* XBlockAccessEditor is a view that allows the user to restrict access at the unit level on the container page.
* This view renders the button to restrict unit access into the appropriate place in the unit page.
*/
define(['js/views/baseview'],
function(BaseView) {
'use strict';
var XBlockAccessEditor = BaseView.extend({
// takes XBlockInfo as a model
initialize: function() {
BaseView.prototype.initialize.call(this);
this.template = this.loadTemplate('xblock-access-editor');
},
render: function() {
this.$el.append(this.template({}));
return this;
}
});
return XBlockAccessEditor;
}); // end define();
......@@ -396,6 +396,15 @@ form {
// TODO: abstract this out into a Sass placeholder
.incontext-editor.is-editable {
.access-editor-action-wrapper {
display: inline-block;
vertical-align: middle;
max-width: 80%;
.icon.icon {
vertical-align: baseline;
}
}
.incontext-editor-value,
.incontext-editor-action-wrapper {
@extend %cont-truncated;
......@@ -404,7 +413,7 @@ form {
max-width: 80%;
}
.incontext-editor-open-action {
.incontext-editor-open-action, .access-button {
@extend %ui-btn-non-blue;
@extend %t-copy-base;
padding-top: ($baseline/10);
......
......@@ -151,6 +151,9 @@
}
.modal-section-content {
.user-partition-group-checkboxes {
min-height: 95px;
}
.list-fields, .list-actions {
display: inline-block;
......@@ -191,10 +194,10 @@
}
a {
color: $blue;
color: $blue-d2;
&:hover {
color: $blue-s2;
color: $blue-d4;
}
}
}
......@@ -700,7 +703,7 @@
}
}
.edit-staff-lock, .edit-content-visibility {
.edit-staff-lock, .edit-content-visibility, .edit-unit-access {
margin-bottom: $baseline;
.tip {
......@@ -710,7 +713,7 @@
}
// UI: staff lock section
.edit-staff-lock, .edit-settings-timed-examination {
.edit-staff-lock, .edit-settings-timed-examination, .edit-unit-access {
.checkbox-cosmetic .input-checkbox {
@extend %cont-text-sr;
......@@ -777,4 +780,84 @@
}
}
}
.edit-unit-access, .edit-staff-lock {
.modal-section-content {
@include font-size(16);
.group-select-title {
font-weight: font-weight(semi-bold);
font-size: inherit;
margin-bottom: ($baseline/4);
.user-partition-select {
font-size: inherit;
}
}
.partition-group-directions {
padding-top: ($baseline/2);
}
.label {
&.deleted {
color: $red;
}
font-size: inherit;
margin-left: ($baseline/4);
}
.deleted-group-message {
display: block;
color: $red;
@include font-size(14);
}
.field {
margin-top: ($baseline/4);
}
}
}
.edit-unit-access, .edit-staff-lock {
.modal-section-content {
@include font-size(16);
.group-select-title {
font-weight: font-weight(semi-bold);
font-size: inherit;
margin-bottom: ($baseline/4);
.user-partition-select {
font-size: inherit;
}
}
.partition-group-directions {
padding-top: ($baseline/2);
}
.label {
&.deleted {
color: $red;
}
font-size: inherit;
@include margin-left($baseline/4);
}
.deleted-group-message {
display: block;
color: $red;
@include font-size(14);
}
.field {
margin-top: ($baseline/4);
}
}
}
}
......@@ -55,6 +55,14 @@
}
}
}
.container-access {
@include font-size(14);
line-height: 1.5;
white-space: normal;
color: #707070;
font-weight: font-weight(semi-bold);
}
}
&.has-actions {
......
......@@ -72,6 +72,8 @@ from openedx.core.djangolib.markup import HTML, Text
data-field="display_name" data-field-display-name="${_("Display Name")}">
<h1 class="page-header-title xblock-field-value incontext-editor-value"><span class="title-value">${xblock.display_name_with_default}</span></h1>
</div>
<div class="container-access">
</div>
</div>
<nav class="nav-actions" aria-label="${_('Page Actions')}">
......
......@@ -26,7 +26,7 @@ from openedx.core.djangolib.markup import HTML, Text
<%block name="header_extras">
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'content-visibility-editor', 'verification-access-editor', 'timed-examination-preference-editor', 'access-editor', 'settings-modal-tabs', 'show-correctness-editor']:
% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'unit-access-editor', 'content-visibility-editor', 'verification-access-editor', 'timed-examination-preference-editor', 'access-editor', 'settings-modal-tabs', 'show-correctness-editor']:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
......
<%
var selectedGroupsLabel = userPartitionInfo['selected_groups_label'];
var selectedPartitionIndex = userPartitionInfo['selected_partition_index'];
var category = this.model.attributes.category;
var blockType = "";
if (category == "vertical") {
blockType = gettext("unit")
} else {
blockType = gettext("component")
}
%>
<% if (selectedGroupsLabel) { %>
<span class="access-message"><%-
edx.StringUtils.interpolate(
// Translators: blockType refers to the type of the xblock that access is restricted to.
gettext('Access to this {blockType} is restricted to: {selectedGroupsLabel}'),
{
selectedGroupsLabel: selectedGroupsLabel,
blockType: blockType
}
) %></span>
<% } else if (hasPartitionGroupComponents) { %>
<span class="access-message"><%-
edx.StringUtils.interpolate(
// Translators: blockType refers to the type of the xblock that access is restricted to.
gettext('Access to some content in this {blockType} is restricted to specific groups of learners.'),
{
blockType: blockType
}
) %></span>
<% } %>
......@@ -4,6 +4,9 @@ var visibilityState = xblockInfo.get('visibility_state');
var published = xblockInfo.get('published');
var prereq = xblockInfo.get('prereq');
var hasPartitionGroups = xblockInfo.get('has_partition_group_components');
var userPartitionInfo = xblockInfo.get('user_partition_info');
var selectedGroupsLabel = userPartitionInfo['selected_groups_label'];
var selectedPartitionIndex = userPartitionInfo['selected_partition_index'];
var statusMessages = [];
var messageType;
......@@ -57,7 +60,16 @@ if (staffOnlyMessage) {
addStatusMessage(messageType, messageText);
}
if (hasPartitionGroups) {
if (selectedPartitionIndex !== -1 && !isNaN(selectedPartitionIndex) && xblockInfo.isVertical()) {
messageType = 'partition-groups';
messageText = edx.StringUtils.interpolate(
gettext('Access to this unit is restricted to: {selectedGroupsLabel}'),
{
selectedGroupsLabel: selectedGroupsLabel
}
)
addStatusMessage(messageType, messageText);
} else if (hasPartitionGroups && xblockInfo.isVertical()) {
addStatusMessage(
'partition-groups',
gettext('Access to some content in this unit is restricted to specific groups of learners')
......
......@@ -47,7 +47,7 @@
<button class="edit-button action-button"></button>
</li>
<li class="action-item action-visibility">
<button class="visibility-button action-button"></button>
<button class="access-button action-button"></button>
</li>
<li class="action-item action-duplicate">
<button class="duplicate-button action-button"></button>
......@@ -78,7 +78,7 @@
<button class="edit-button action-button"></button>
</li>
<li class="action-item action-visibility">
<button class="visibility-button action-button"></button>
<button class="access-button action-button"></button>
</li>
<li class="action-item action-duplicate">
<button class="duplicate-button action-button"></button>
......@@ -109,7 +109,7 @@
<button class="edit-button action-button"></button>
</li>
<li class="action-item action-visibility">
<button class="visibility-button action-button"></button>
<button class="access-button action-button"></button>
</li>
<li class="action-item action-duplicate">
<button class="duplicate-button action-button"></button>
......@@ -170,7 +170,7 @@
<button class="edit-button action-button"></button>
</li>
<li class="action-item action-visibility">
<button class="visibility-button action-button"></button>
<button class="access-button action-button"></button>
</li>
<li class="action-item action-duplicate">
<button class="duplicate-button action-button"></button>
......@@ -201,7 +201,7 @@
<button class="edit-button action-button"></button>
</li>
<li class="action-item action-visibility">
<button class="visibility-button action-button"></button>
<button class="access-button action-button"></button>
</li>
<li class="action-item action-duplicate">
<button class="duplicate-button action-button"></button>
......@@ -232,7 +232,7 @@
<button class="edit-button action-button"></button>
</li>
<li class="action-item action-visibility">
<button class="visibility-button action-button"></button>
<button class="access-button action-button"></button>
</li>
<li class="action-item action-duplicate">
<button class="duplicate-button action-button"></button>
......
......@@ -97,12 +97,6 @@ var visibleToStaffOnly = visibilityState === 'staff_only';
<% } else { %>
<p class="visbility-copy copy"><%- gettext("Staff and Learners") %></p>
<% } %>
<% if (hasPartitionGroupComponents) { %>
<p class="note-visibility">
<span class="icon fa fa-eye" aria-hidden="true"></span>
<span class="note-copy"><%- gettext("Access to some content in this unit is restricted to specific groups of learners.") %></span>
</p>
<% } %>
<ul class="actions-inline">
<li class="action-inline">
<a href="" class="action-staff-lock" role="button" aria-pressed="<%- hasExplicitStaffLock %>">
......
......@@ -7,26 +7,19 @@
<% } %>
</h3>
<div class="modal-section-content staff-lock">
<ul class="list-fields list-input">
<li class="field field-checkbox checkbox-cosmetic">
<input type="checkbox" id="staff_lock" name="staff_lock" class="input input-checkbox" />
<label for="staff_lock" class="label">
<span class="icon fa fa-check-square-o input-checkbox-checked" aria-hidden="true"></span>
<span class="icon fa fa-square-o input-checkbox-unchecked" aria-hidden="true"></span>
<%- gettext('Hide from learners') %>
</label>
<% if (hasExplicitStaffLock && !ancestorLocked) { %>
<p class="tip tip-warning">
<% if (xblockInfo.isVertical()) { %>
<%- gettext('If the unit was previously published and released to learners, any changes you made to the unit when it was hidden will now be visible to learners.') %>
<% } else { %>
<% var message = gettext('If you make this %(xblockType)s visible to learners, learners will be able to see its content after the release date has passed and you have published the unit. Only units that are explicitly hidden from learners will remain hidden after you clear this option for the %(xblockType)s.') %>
<%- interpolate(message, { xblockType: xblockType }, true) %>
<% } %>
</p>
<% } %>
</li>
</ul>
<label class="label">
<input type="checkbox" id="staff_lock" name="staff_lock" class="input input-checkbox" />
<%- gettext('Hide from learners') %>
</label>
<% if (hasExplicitStaffLock && !ancestorLocked) { %>
<p class="tip tip-warning">
<% if (xblockInfo.isVertical()) { %>
<%- gettext('If the unit was previously published and released to learners, any changes you made to the unit when it was hidden will now be visible to learners.') %>
<% } else { %>
<% var message = gettext('If you make this %(xblockType)s visible to learners, learners will be able to see its content after the release date has passed and you have published the unit. Only units that are explicitly hidden from learners will remain hidden after you clear this option for the %(xblockType)s.') %>
<%- interpolate(message, { xblockType: xblockType }, true) %>
<% } %>
</p>
<% } %>
</div>
</form>
<%
var userPartitionInfo = xblockInfo.get('user_partition_info');
var selectablePartitions = userPartitionInfo['selectable_partitions'];
%>
<form>
<% if (selectablePartitions.length > 0) { %>
<h3 class="modal-section-title access-change">
<%- gettext('Unit Access') %>
</h3>
<div class="modal-section-content access-change">
<label class="group-select-title"><%- gettext('Restrict access to:') %>
<select class="user-partition-select" id="partition-select">
<option value="-1" selected ="selected"><%- gettext('Select a group type') %></option>
<% for (var i=0; i < selectablePartitions.length; i++) { %>
<option id="<%- selectablePartitions[i].id %>-option" value="<%- selectablePartitions[i].id %>"><%- selectablePartitions[i].name %></option>
<% } %>
</select>
</label>
<br>
<div class="user-partition-group-checkboxes">
<% for (var i=0; i < selectablePartitions.length; i++) { %>
<div role="group" aria-labelledby="partition-group-directions-<%- selectablePartitions[i].id %>" id="<%- selectablePartitions[i].id %>-checkboxes">
<div class="partition-group-directions" id="partition-group-directions-<%- selectablePartitions[i].id %>">
<%- gettext('Select one or more groups:') %>
<% for (var j = 0; j < selectablePartitions[i].groups.length; j++) { %>
<div class="field partition-group-control">
<input type="checkbox" id="content-group-<%- selectablePartitions[i].groups[j].id %>" value="<%- selectablePartitions[i].groups[j].id %>" class="input input-checkbox"
<% if (selectablePartitions[i].groups[j].selected) { %> checked="checked" <% } %> />
<% if (selectablePartitions[i].groups[j].deleted) { %>
<label for="content-group-<%- selectablePartitions[i].groups[j].id %>" class="label deleted">
<%- gettext('Deleted Group') %>
<span class="deleted-group-message"><%- gettext('This group no longer exists. Choose another group or do not restrict access to this unit.') %></span>
<% } else { %>
<label for="content-group-<%-selectablePartitions[i].groups[j].id %>" class="label">
<%- selectablePartitions[i].groups[j].name %>
</label>
<% } %>
</div>
<% } %>
</div>
</div>
<% } %>
</div>
</div>
<% } %>
</form>
<div class="access-editor-action-wrapper">
<button class="unit-container access-button">
<span class="icon fa fa-gear" aria-hidden="true"></span><span class="sr"> <%- gettext('Set Access') %></span>
</button>
</div>
<div class="incontext-editor-action-wrapper">
<a href="" class="action-edit action-inline xblock-field-value-edit incontext-editor-open-action" title="<%- gettext('Edit the name') %>">
<span class="icon fa fa-pencil" aria-hidden="true"></span><span class="sr"> <%- gettext("Edit") %></span>
</a>
<button class="action-edit action-inline xblock-field-value-edit incontext-editor-open-action">
<span class="icon fa fa-pencil" aria-hidden="true"></span><span class="sr"> <%- gettext('Edit') %></span>
</button>
</div>
<div class="xblock-string-field-editor incontext-editor-form">
<form>
<% var formLabel = gettext("Edit %(display_name)s (required)"); %>
<label><span class="sr"><%= interpolate(formLabel, {display_name: fieldDisplayName}, true) %></span>
<input type="text" value="<%= value %>" class="xblock-field-input incontext-editor-input" data-metadata-name="<%= fieldName %>" title="<%= gettext('Edit the name') %>">
<input type="text" value="<%= value %>" class="xblock-field-input incontext-editor-input" data-metadata-name="<%= fieldName %>">
</label>
<button class="sr action action-primary" name="submit" type="submit"><%= gettext("Save") %></button>
<button class="sr action action-secondary" name="cancel" type="button"><%= gettext("Cancel") %></button>
......
......@@ -82,7 +82,7 @@ messages = xblock.validate().to_json()
</li>
% if can_edit_visibility:
<li class="action-item action-visibility">
<button data-tooltip="${_("Access Settings")}" class="btn-default visibility-button action-button">
<button data-tooltip="${_("Access Settings")}" class="btn-default access-button action-button">
<span class="icon fa fa-gear" aria-hidden="true"></span>
<span class="sr">${_("Set Access")}</span>
</button>
......
......@@ -131,6 +131,13 @@ class CourseOutlinePage(PageObject):
return len(self.q(css=self.SUBSECTION_TITLES_SELECTOR.format(section_index)))
@property
def num_units(self):
"""
Return the number of units in the first subsection
"""
return len(self.q(css='.sequence-list-wrapper ol li'))
def go_to_section(self, section_title, subsection_title):
"""
Go to the section/subsection in the courseware.
......
......@@ -284,6 +284,12 @@ class ContainerPage(PageObject, HelpMixin):
"""
return _click_edit(self, '.edit-button', '.xblock-studio_view')
def edit_visibility(self):
"""
Clicks the edit visibility button for this container.
"""
return _click_edit(self, '.access-button', '.xblock-visibility_view')
def verify_confirmation_message(self, message, verify_hidden=False):
"""
Verify for confirmation message is present or hidden.
......@@ -332,6 +338,16 @@ class ContainerPage(PageObject, HelpMixin):
"""
return self.q(css=".xblock-message.information").first.text[0]
def get_xblock_access_message(self):
"""
Returns a message detailing the access to the specified unit
"""
access_message = self.q(css=".access-message").first
if access_message:
return access_message.text[0]
else:
return ""
def is_inline_editing_display_name(self):
"""
Return whether this container's display name is in its editable form.
......@@ -513,7 +529,7 @@ class XBlockWrapper(PageObject):
Returns true if this xblock has an 'edit visibility' button
:return:
"""
return self.q(css=self._bounded_selector('.visibility-button')).is_present()
return self.q(css=self._bounded_selector('.access-button')).is_present()
@property
def has_move_modal_button(self):
......@@ -548,7 +564,7 @@ class XBlockWrapper(PageObject):
"""
Clicks the edit visibility button for this xblock.
"""
return _click_edit(self, '.visibility-button', '.xblock-visibility_view', self._bounded_selector)
return _click_edit(self, '.access-button', '.xblock-visibility_view', self._bounded_selector)
def open_advanced_tab(self):
"""
......
from common.test.acceptance.pages.studio.utils import type_in_codemirror
from component_editor import ComponentEditorView
from xblock_editor import XBlockEditorView
class HtmlComponentEditorView(ComponentEditorView):
class HtmlXBlockEditorView(XBlockEditorView):
"""
Represents the rendered view of an HTML component editor.
"""
......
......@@ -9,7 +9,7 @@ from selenium.webdriver.support.select import Select
from common.test.acceptance.pages.common.utils import confirm_prompt, sync_on_notification
from common.test.acceptance.pages.studio import BASE_URL
from common.test.acceptance.pages.studio.component_editor import ComponentEditorView
from common.test.acceptance.pages.studio.xblock_editor import XBlockEditorView
from common.test.acceptance.pages.studio.container import XBlockWrapper
from common.test.acceptance.pages.studio.pagination import PaginatedMixin
from common.test.acceptance.pages.studio.users import UsersPageMixin
......@@ -133,7 +133,7 @@ class LibraryEditPage(LibraryPage, PaginatedMixin, UsersPageMixin):
)
class StudioLibraryContentEditor(ComponentEditorView):
class StudioLibraryContentEditor(XBlockEditorView):
"""
Library Content XBlock Modal edit window
"""
......
......@@ -14,7 +14,7 @@ from common.test.acceptance.pages.common.utils import click_css, confirm_prompt
from common.test.acceptance.pages.studio.container import ContainerPage
from common.test.acceptance.pages.studio.course_page import CoursePage
from common.test.acceptance.pages.studio.utils import set_input_value, set_input_value_and_save
from common.test.acceptance.tests.helpers import disable_animations, enable_animations
from common.test.acceptance.tests.helpers import disable_animations, enable_animations, select_option_by_text
@js_defined('jQuery')
......@@ -89,6 +89,11 @@ class CourseOutlineItem(object):
return self.status_message == 'Contains staff only content' if self.has_status_message else False
@property
def has_restricted_warning(self):
""" Returns True if the 'Access to this unit is restricted to' message is visible """
return 'Access to this unit is restricted to' in self.status_message if self.has_status_message else False
@property
def is_staff_only(self):
""" Returns True if the visiblity state of this item is staff only (has a black sidebar) """
return "is-staff-only" in self.q(css=self._bounded_selector(''))[0].get_attribute("class") # pylint: disable=no-member
......@@ -129,6 +134,29 @@ class CourseOutlineItem(object):
modal.is_explicitly_locked = is_locked
modal.save()
def get_enrollment_select_options(self):
"""
Gets the option names available for unit group access
"""
modal = self.edit()
group_options = self.q(css='.group-select-title option').text
modal.cancel()
return group_options
def toggle_unit_access(self, partition_name, group_ids):
"""
Toggles unit access to the groups in group_ids
"""
if group_ids:
modal = self.edit()
groups_select = self.q(css='.group-select-title select')
select_option_by_text(groups_select, partition_name)
for group_id in group_ids:
checkbox = self.q(css='#content-group-{group_id}'.format(group_id=group_id))
checkbox.click()
modal.save()
def in_editable_form(self):
"""
Return whether this outline item's display name is in its editable form.
......@@ -1082,7 +1110,7 @@ class CourseOutlineModal(object):
"""
self.ensure_staff_lock_visible()
if value != self.is_explicitly_locked:
self.find_css('label[for="staff_lock"]').click()
self.find_css('#staff_lock').click()
EmptyPromise(lambda: value == self.is_explicitly_locked, "Explicit staff lock is updated").fulfill()
def shows_staff_lock_warning(self):
......
......@@ -240,7 +240,7 @@ class GroupConfiguration(object):
"""
Set group configuration name.
"""
self.find_css('.collection-name-input').first.fill(value)
return self.find_css('.collection-name-input').first.fill(value)
@property
def description(self):
......
......@@ -6,9 +6,9 @@ from common.test.acceptance.pages.common.utils import click_css
from common.test.acceptance.tests.helpers import get_selected_option_text, select_option_by_text
class BaseComponentEditorView(PageObject):
class BaseXBlockEditorView(PageObject):
"""
A base :class:`.PageObject` for the component and visibility editors.
A base :class:`.PageObject` for the xblock and visibility editors.
This class assumes that the editor is our default editor as displayed for xmodules.
"""
......@@ -20,7 +20,7 @@ class BaseComponentEditorView(PageObject):
browser (selenium.webdriver): The Selenium-controlled browser that this page is loaded in.
locator (str): The locator that identifies which xblock this :class:`.xblock-editor` relates to.
"""
super(BaseComponentEditorView, self).__init__(browser)
super(BaseXBlockEditorView, self).__init__(browser)
self.locator = locator
def is_browser_on_page(self):
......@@ -28,7 +28,7 @@ class BaseComponentEditorView(PageObject):
def _bounded_selector(self, selector):
"""
Return `selector`, but limited to this particular `ComponentEditorView` context
Return `selector`, but limited to this particular `XBlockEditorView` context
"""
return '{}[data-locator="{}"] {}'.format(
self.BODY_SELECTOR,
......@@ -55,9 +55,9 @@ class BaseComponentEditorView(PageObject):
click_css(self, 'a.action-cancel', require_notification=False)
class ComponentEditorView(BaseComponentEditorView):
class XBlockEditorView(BaseXBlockEditorView):
"""
A :class:`.PageObject` representing the rendered view of a component editor.
A :class:`.PageObject` representing the rendered view of an xblock editor.
"""
def get_setting_element(self, label):
"""
......@@ -106,9 +106,9 @@ class ComponentEditorView(BaseComponentEditorView):
return None
class ComponentVisibilityEditorView(BaseComponentEditorView):
class XBlockVisibilityEditorView(BaseXBlockEditorView):
"""
A :class:`.PageObject` representing the rendered view of a component visibility editor.
A :class:`.PageObject` representing the rendered view of an xblock visibility editor.
"""
OPTION_SELECTOR = '.partition-group-control .field'
ALL_LEARNERS_AND_STAFF = 'All Learners and Staff'
......
......@@ -13,7 +13,7 @@ from common.test.acceptance.pages.common.logout import LogoutPage
from common.test.acceptance.pages.lms.course_home import CourseHomePage
from common.test.acceptance.pages.lms.instructor_dashboard import InstructorDashboardPage
from common.test.acceptance.pages.lms.staff_view import StaffCoursewarePage
from common.test.acceptance.pages.studio.component_editor import ComponentVisibilityEditorView
from common.test.acceptance.pages.studio.xblock_editor import XBlockVisibilityEditorView
from common.test.acceptance.pages.studio.overview import CourseOutlinePage as StudioCourseOutlinePage
from common.test.acceptance.pages.studio.settings_group_configurations import GroupConfigurationsPage
from common.test.acceptance.tests.discussion.helpers import CohortTestMixin
......@@ -177,7 +177,7 @@ class CoursewareSearchCohortTest(ContainerBase, CohortTestMixin):
"""
html_block = container_page.xblocks[html_block_index]
html_block.edit_visibility()
visibility_dialog = ComponentVisibilityEditorView(self.browser, html_block.locator)
visibility_dialog = XBlockVisibilityEditorView(self.browser, html_block.locator)
visibility_dialog.select_groups_in_partition_scheme(visibility_dialog.CONTENT_GROUP_PARTITION, groups)
set_visibility(1, [self.content_group_a])
......
......@@ -14,7 +14,7 @@ from ...pages.lms.courseware import CoursewarePage
from ...pages.lms.instructor_dashboard import InstructorDashboardPage, StudentSpecificAdmin
from ...pages.lms.problem import ProblemPage
from ...pages.lms.progress import ProgressPage
from ...pages.studio.component_editor import ComponentEditorView
from ...pages.studio.xblock_editor import XBlockEditorView
from ...pages.studio.overview import CourseOutlinePage as StudioCourseOutlinePage
from ...pages.studio.utils import type_in_codemirror
from ..helpers import (
......@@ -179,7 +179,7 @@ class PersistentGradesTest(ProgressPageBaseTest):
"""
unit, component = self._get_problem_in_studio()
component.edit()
component_editor = ComponentEditorView(self.browser, component.locator)
component_editor = XBlockEditorView(self.browser, component.locator)
component_editor.set_field_value_and_save('Problem Weight', 5)
unit.publish()
......
......@@ -12,11 +12,14 @@ from pytz import UTC
from base_studio_test import StudioCourseTest
from common.test.acceptance.fixtures.config import ConfigModelFixture
from common.test.acceptance.fixtures.course import XBlockFixtureDesc
from common.test.acceptance.pages.common.utils import add_enrollment_course_modes
from common.test.acceptance.pages.lms.course_home import CourseHomePage
from common.test.acceptance.pages.lms.courseware import CoursewarePage
from common.test.acceptance.pages.lms.progress import ProgressPage
from common.test.acceptance.pages.lms.staff_view import StaffCoursewarePage
from common.test.acceptance.pages.studio.overview import ContainerPage, CourseOutlinePage, ExpandCollapseLinkState
from common.test.acceptance.pages.studio.settings_advanced import AdvancedSettingsPage
from common.test.acceptance.pages.studio.settings_group_configurations import GroupConfigurationsPage
from common.test.acceptance.pages.studio.utils import add_discussion, drag, verify_ordering
from common.test.acceptance.tests.helpers import disable_animations, load_data_str
......@@ -492,6 +495,152 @@ class EditingSectionsTest(CourseOutlineTest):
@attr(shard=3)
class UnitAccessTest(CourseOutlineTest):
"""
Feature: Units can be restricted and unrestricted to certain groups from the course outline.
"""
__test__ = True
def setUp(self):
super(UnitAccessTest, self).setUp()
self.group_configurations_page = GroupConfigurationsPage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
self.content_group_a = "Test Group A"
self.content_group_b = "Test Group B"
self.group_configurations_page.visit()
self.group_configurations_page.create_first_content_group()
config_a = self.group_configurations_page.content_groups[0]
config_a.name = self.content_group_a
config_a.save()
self.content_group_a_id = config_a.id
self.group_configurations_page.add_content_group()
config_b = self.group_configurations_page.content_groups[1]
config_b.name = self.content_group_b
config_b.save()
self.content_group_b_id = config_b.id
def populate_course_fixture(self, course_fixture):
"""
Create a course with one section, one subsection, and two units
"""
# with collapsed outline
self.chap_1_handle = 0
self.chap_1_seq_1_handle = 1
# with first sequential expanded
self.seq_1_vert_1_handle = 2
self.seq_1_vert_2_handle = 3
self.chap_1_seq_2_handle = 4
course_fixture.add_children(
XBlockFixtureDesc('chapter', "1").add_children(
XBlockFixtureDesc('sequential', '1.1').add_children(
XBlockFixtureDesc('vertical', '1.1.1'),
XBlockFixtureDesc('vertical', '1.1.2')
)
)
)
def _set_restriction_on_unrestricted_unit(self, unit):
"""
Restrict unit access to a certain group and confirm that a
warning is displayed. Then, remove the access restriction
and verify that the warning no longer appears.
"""
self.assertFalse(unit.has_restricted_warning)
unit.toggle_unit_access('Content Groups', [self.content_group_a_id])
self.assertTrue(unit.has_restricted_warning)
unit.toggle_unit_access('Content Groups', [self.content_group_a_id])
self.assertFalse(unit.has_restricted_warning)
def test_units_can_be_restricted(self):
"""
Visit the course outline page, restrict access to a unit.
Verify that there is a restricted group warning.
Remove the group access restriction and verify that there
is no longer a warning.
"""
self.course_outline_page.visit()
self.course_outline_page.expand_all_subsections()
unit = self.course_outline_page.section_at(0).subsection_at(0).unit_at(0)
self._set_restriction_on_unrestricted_unit(unit)
def test_restricted_sections_for_content_group_users_in_lms(self):
"""
Verify that those who are in an content track with access to a restricted unit are able
to see that unit in lms, and those who are in an enrollment track without access to a restricted
unit are not able to see that unit in lms
"""
self.course_outline_page.visit()
self.course_outline_page.expand_all_subsections()
unit = self.course_outline_page.section_at(0).subsection_at(0).unit_at(0)
unit.toggle_unit_access('Content Groups', [self.content_group_a_id])
self.course_outline_page.view_live()
course_home_page = CourseHomePage(self.browser, self.course_id)
course_home_page.visit()
course_home_page.resume_course_from_header()
self.assertEqual(course_home_page.outline.num_units, 2)
# Test for a user without additional content available
staff_page = StaffCoursewarePage(self.browser, self.course_id)
staff_page.set_staff_view_mode('Learner in Test Group B')
staff_page.wait_for_page()
self.assertEqual(course_home_page.outline.num_units, 1)
# Test for a user with additional content available
staff_page.set_staff_view_mode('Learner in Test Group A')
staff_page.wait_for_page()
self.assertEqual(course_home_page.outline.num_units, 2)
def test_restricted_sections_for_enrollment_track_users_in_lms(self):
"""
Verify that those who are in an enrollment track with access to a restricted unit are able
to see that unit in lms, and those who are in an enrollment track without access to a restricted
unit are not able to see that unit in lms
"""
# Add just 1 enrollment track to verify the enrollment option isn't available in the modal
add_enrollment_course_modes(self.browser, self.course_id, ["audit"])
self.course_outline_page.visit()
self.course_outline_page.expand_all_subsections()
unit = self.course_outline_page.section_at(0).subsection_at(0).unit_at(0)
enrollment_select_options = unit.get_enrollment_select_options()
self.assertFalse('Enrollment Track Groups' in enrollment_select_options)
# Add the additional enrollment track so the unit access toggles should now be available
add_enrollment_course_modes(self.browser, self.course_id, ["verified"])
self.course_outline_page.visit()
self.course_outline_page.expand_all_subsections()
unit = self.course_outline_page.section_at(0).subsection_at(0).unit_at(0)
unit.toggle_unit_access('Enrollment Track Groups', [1]) # Hard coded 1 for audit ID
self.course_outline_page.view_live()
course_home_page = CourseHomePage(self.browser, self.course_id)
course_home_page.visit()
course_home_page.resume_course_from_header()
self.assertEqual(course_home_page.outline.num_units, 2)
# Test for a user without additional content available
staff_page = StaffCoursewarePage(self.browser, self.course_id)
staff_page.set_staff_view_mode('Learner in Verified')
staff_page.wait_for_page()
self.assertEqual(course_home_page.outline.num_units, 1)
# Test for a user with additional content available
staff_page = StaffCoursewarePage(self.browser, self.course_id)
staff_page.set_staff_view_mode('Learner in Audit')
staff_page.wait_for_page()
self.assertEqual(course_home_page.outline.num_units, 2)
@attr(shard=3)
class StaffLockTest(CourseOutlineTest):
"""
Feature: Sections, subsections, and units can be locked and unlocked from the course outline.
......
......@@ -12,11 +12,11 @@ from selenium.webdriver.support.ui import Select
from base_studio_test import StudioCourseTest
from common.test.acceptance.fixtures.course import XBlockFixtureDesc
from common.test.acceptance.pages.lms.courseware import CoursewarePage
from common.test.acceptance.pages.studio.component_editor import ComponentEditorView
from common.test.acceptance.pages.studio.container import ContainerPage
from common.test.acceptance.pages.studio.overview import CourseOutlinePage, CourseOutlineUnit
from common.test.acceptance.pages.studio.settings_group_configurations import GroupConfigurationsPage
from common.test.acceptance.pages.studio.utils import add_advanced_component
from common.test.acceptance.pages.studio.xblock_editor import XBlockEditorView
from common.test.acceptance.pages.xblock.utils import wait_for_xblock_initialization
from common.test.acceptance.tests.helpers import create_user_partition_json
from test_studio_container import ContainerBase
......@@ -126,7 +126,7 @@ class SplitTest(ContainerBase, SplitTestMixin):
add_advanced_component(unit, 0, 'split_test')
container = self.go_to_nested_container_page()
container.edit()
component_editor = ComponentEditorView(self.browser, container.locator)
component_editor = XBlockEditorView(self.browser, container.locator)
component_editor.set_select_value_and_save('Group Configuration', 'Configuration alpha,beta')
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
......@@ -151,7 +151,7 @@ class SplitTest(ContainerBase, SplitTestMixin):
add_advanced_component(unit, 0, 'split_test')
container = self.go_to_nested_container_page()
container.edit()
component_editor = ComponentEditorView(self.browser, container.locator)
component_editor = XBlockEditorView(self.browser, container.locator)
component_editor.set_select_value_and_save('Group Configuration', 'Configuration alpha,beta')
self.verify_groups(container, ['alpha', 'beta'], [])
......@@ -159,7 +159,7 @@ class SplitTest(ContainerBase, SplitTestMixin):
# that there is only a single "editor" on the page.
container = self.go_to_nested_container_page()
container.edit()
component_editor = ComponentEditorView(self.browser, container.locator)
component_editor = XBlockEditorView(self.browser, container.locator)
component_editor.set_select_value_and_save('Group Configuration', 'Configuration 0,1,2')
self.verify_groups(container, ['Group 0', 'Group 1', 'Group 2'], ['Group ID 0', 'Group ID 1'])
......@@ -537,7 +537,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
container = ContainerPage(self.browser, split_test.locator)
container.visit()
container.edit()
component_editor = ComponentEditorView(self.browser, container.locator)
component_editor = XBlockEditorView(self.browser, container.locator)
component_editor.set_select_value_and_save('Group Configuration', 'New Group Configuration Name')
self.verify_groups(container, ['Group A', 'Group B', 'New group'], [])
......@@ -589,7 +589,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
container = ContainerPage(self.browser, split_test.locator)
container.visit()
container.edit()
component_editor = ComponentEditorView(self.browser, container.locator)
component_editor = XBlockEditorView(self.browser, container.locator)
self.assertEqual(
"Second Group Configuration Name",
component_editor.get_selected_option_text('Group Configuration')
......
......@@ -10,8 +10,8 @@ from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
from common.test.acceptance.pages.common.utils import add_enrollment_course_modes, enroll_user_track
from common.test.acceptance.pages.lms.courseware import CoursewarePage
from common.test.acceptance.pages.lms.instructor_dashboard import InstructorDashboardPage
from common.test.acceptance.pages.studio.component_editor import ComponentVisibilityEditorView
from common.test.acceptance.pages.studio.settings_group_configurations import GroupConfigurationsPage
from common.test.acceptance.pages.studio.xblock_editor import XBlockVisibilityEditorView
from common.test.acceptance.tests.discussion.helpers import CohortTestMixin
from common.test.acceptance.tests.lms.test_lms_user_preview import verify_expected_problem_visibility
from studio.base_studio_test import ContainerBase
......@@ -144,7 +144,7 @@ class EndToEndCohortedCoursewareTest(ContainerBase, CohortTestMixin):
def set_visibility(problem_index, groups, group_partition='content_group'):
problem = container_page.xblocks[problem_index]
problem.edit_visibility()
visibility_dialog = ComponentVisibilityEditorView(self.browser, problem.locator)
visibility_dialog = XBlockVisibilityEditorView(self.browser, problem.locator)
partition_name = (visibility_dialog.ENROLLMENT_TRACK_PARTITION
if group_partition == enrollment_group
else visibility_dialog.CONTENT_GROUP_PARTITION)
......
......@@ -23,6 +23,7 @@ from courseware.access import has_access
from courseware.courses import get_current_child
from edxnotes.exceptions import EdxNotesParseError, EdxNotesServiceUnavailable
from edxnotes.plugins import EdxNotesTab
from lms.lib.utils import get_parent_unit
from openedx.core.lib.token_utils import JwtBuilder
from student.models import anonymous_id_for_user
from util.date_utils import get_default_time_display
......@@ -120,21 +121,6 @@ def send_request(user, course_id, page, page_size, path="", text=None):
return response
def get_parent_unit(xblock):
"""
Find vertical that is a unit, not just some container.
"""
while xblock:
xblock = xblock.get_parent()
if xblock is None:
return None
parent = xblock.get_parent()
if parent is None:
return None
if parent.category == 'sequential':
return xblock
def preprocess_collection(user, course, collection):
"""
Prepare `collection(notes_list)` provided by edx-notes-api
......
......@@ -691,21 +691,6 @@ class EdxNotesHelpersTest(ModuleStoreTestCase):
helpers.preprocess_collection(self.user, self.course, initial_collection)
)
def test_get_parent_unit(self):
"""
Tests `get_parent_unit` method for the successful result.
"""
parent = helpers.get_parent_unit(self.html_module_1)
self.assertEqual(parent.location, self.vertical.location)
parent = helpers.get_parent_unit(self.child_html_module)
self.assertEqual(parent.location, self.vertical_with_container.location)
self.assertIsNone(helpers.get_parent_unit(None))
self.assertIsNone(helpers.get_parent_unit(self.course))
self.assertIsNone(helpers.get_parent_unit(self.chapter))
self.assertIsNone(helpers.get_parent_unit(self.sequential))
def test_get_module_context_sequential(self):
"""
Tests `get_module_context` method for the sequential.
......
......@@ -17,6 +17,7 @@ _ = lambda text: text
INVALID_USER_PARTITION_VALIDATION = _(u"This component's access settings refer to deleted or invalid group configurations.")
INVALID_USER_PARTITION_GROUP_VALIDATION = _(u"This component's access settings refer to deleted or invalid groups.")
NONSENSICAL_ACCESS_RESTRICTION = _(u"This component's access settings contradict its parent's access settings.")
class GroupAccessDict(Dict):
......@@ -142,6 +143,40 @@ class LmsBlockMixin(XBlockMixin):
raise NoSuchUserPartitionError("could not find a UserPartition with ID [{}]".format(user_partition_id))
def _has_nonsensical_access_settings(self):
"""
Checks if a block's group access settings do not make sense.
By nonsensical access settings, we mean a component's access
settings which contradict its parent's access in that they
restrict access to the component to a group that already
will not be able to see that content.
Note: This contradiction can occur when a component
restricts access to the same partition but a different group
than its parent, or when there is a parent access
restriction but the component attempts to allow access to
all learners.
Returns:
bool: True if the block's access settings contradict its
parent's access settings.
"""
parent = self.get_parent()
if not parent:
return False
parent_group_access = parent.group_access
component_group_access = self.group_access
for user_partition_id, parent_group_ids in parent_group_access.iteritems():
component_group_ids = component_group_access.get(user_partition_id)
if component_group_ids:
return parent_group_ids and not set(component_group_ids).issubset(set(parent_group_ids))
else:
return not component_group_access
else:
return False
def validate(self):
"""
Validates the state of this xblock instance.
......@@ -150,6 +185,7 @@ class LmsBlockMixin(XBlockMixin):
validation = super(LmsBlockMixin, self).validate()
has_invalid_user_partitions = False
has_invalid_groups = False
for user_partition_id, group_ids in self.group_access.iteritems():
try:
user_partition = self._get_user_partition(user_partition_id)
......@@ -171,6 +207,7 @@ class LmsBlockMixin(XBlockMixin):
INVALID_USER_PARTITION_VALIDATION
)
)
if has_invalid_groups:
validation.add(
ValidationMessage(
......@@ -178,4 +215,13 @@ class LmsBlockMixin(XBlockMixin):
INVALID_USER_PARTITION_GROUP_VALIDATION
)
)
if self._has_nonsensical_access_settings():
validation.add(
ValidationMessage(
ValidationMessage.ERROR,
NONSENSICAL_ACCESS_RESTRICTION
)
)
return validation
"""
Tests for the LMS/lib utils
"""
from lms.lib import utils
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
class LmsUtilsTest(ModuleStoreTestCase):
"""
Tests for the LMS utility functions
"""
def setUp(self):
"""
Setup a dummy course content.
"""
super(LmsUtilsTest, self).setUp()
with self.store.default_store(ModuleStoreEnum.Type.mongo):
self.course = CourseFactory.create()
self.chapter = ItemFactory.create(category="chapter", parent_location=self.course.location)
self.sequential = ItemFactory.create(category="sequential", parent_location=self.chapter.location)
self.vertical = ItemFactory.create(category="vertical", parent_location=self.sequential.location)
self.html_module_1 = ItemFactory.create(category="html", parent_location=self.vertical.location)
self.vertical_with_container = ItemFactory.create(
category="vertical", parent_location=self.sequential.location
)
self.child_container = ItemFactory.create(
category="split_test", parent_location=self.vertical_with_container.location)
self.child_vertical = ItemFactory.create(category="vertical", parent_location=self.child_container.location)
self.child_html_module = ItemFactory.create(category="html", parent_location=self.child_vertical.location)
# Read again so that children lists are accurate
self.course = self.store.get_item(self.course.location)
self.chapter = self.store.get_item(self.chapter.location)
self.sequential = self.store.get_item(self.sequential.location)
self.vertical = self.store.get_item(self.vertical.location)
self.vertical_with_container = self.store.get_item(self.vertical_with_container.location)
self.child_container = self.store.get_item(self.child_container.location)
self.child_vertical = self.store.get_item(self.child_vertical.location)
self.child_html_module = self.store.get_item(self.child_html_module.location)
def test_get_parent_unit(self):
"""
Tests `get_parent_unit` method for the successful result.
"""
parent = utils.get_parent_unit(self.html_module_1)
self.assertEqual(parent.location, self.vertical.location)
parent = utils.get_parent_unit(self.child_html_module)
self.assertEqual(parent.location, self.vertical_with_container.location)
self.assertIsNone(utils.get_parent_unit(None))
self.assertIsNone(utils.get_parent_unit(self.vertical))
self.assertIsNone(utils.get_parent_unit(self.course))
self.assertIsNone(utils.get_parent_unit(self.chapter))
self.assertIsNone(utils.get_parent_unit(self.sequential))
"""
Helper methods for the LMS.
"""
def get_parent_unit(xblock):
"""
Finds xblock's parent unit if it exists.
To find an xblock's parent unit, we traverse up the xblock's
family tree until we find an xblock whose parent is a
sequential xblock, which guarantees that the xblock is a unit.
The `get_parent()` call on both the xblock and the parent block
ensure that we don't accidentally return that a unit is its own
parent unit.
Returns:
xblock: Returns the parent unit xblock if it exists.
If no parent unit exists, returns None
"""
while xblock:
parent = xblock.get_parent()
if parent is None:
return None
grandparent = parent.get_parent()
if grandparent is None:
return None
if parent.category == "vertical" and grandparent.category == "sequential":
return parent
xblock = parent
......@@ -4,7 +4,9 @@ Tests of the LMS XBlock Mixin
import ddt
from nose.plugins.attrib import attr
from lms_xblock.mixin import INVALID_USER_PARTITION_VALIDATION, INVALID_USER_PARTITION_GROUP_VALIDATION
from lms_xblock.mixin import (
INVALID_USER_PARTITION_VALIDATION, INVALID_USER_PARTITION_GROUP_VALIDATION, NONSENSICAL_ACCESS_RESTRICTION
)
from xblock.validation import ValidationMessage
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.factories import CourseFactory, ToyCourseFactory, ItemFactory
......@@ -38,10 +40,16 @@ class LmsXBlockMixinTestCase(ModuleStoreTestCase):
subsection = ItemFactory.create(parent=section, category='sequential', display_name='Test Subsection')
vertical = ItemFactory.create(parent=subsection, category='vertical', display_name='Test Unit')
video = ItemFactory.create(parent=vertical, category='video', display_name='Test Video 1')
split_test = ItemFactory.create(parent=vertical, category='split_test', display_name='Test Content Experiment')
child_vertical = ItemFactory.create(parent=split_test, category='vertical')
child_html_module = ItemFactory.create(parent=child_vertical, category='html')
self.section_location = section.location
self.subsection_location = subsection.location
self.vertical_location = vertical.location
self.video_location = video.location
self.split_test_location = split_test.location
self.child_vertical_location = child_vertical.location
self.child_html_module_location = child_html_module.location
def set_group_access(self, block_location, access_dict):
"""
......@@ -130,6 +138,98 @@ class XBlockValidationTest(LmsXBlockMixinTestCase):
ValidationMessage.ERROR,
)
def test_validate_nonsensical_access_for_split_test_children(self):
"""
Test the validation messages produced for components within
a content group experiment (also known as a split_test).
Ensures that children of split_test xblocks only validate
their access settings off the parent, rather than any
grandparent.
"""
# Test that no validation message is displayed on split_test child when child agrees with parent
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id]})
self.set_group_access(self.split_test_location, {self.user_partition.id: [self.group2.id]})
self.set_group_access(self.child_vertical_location, {self.user_partition.id: [self.group2.id]})
self.set_group_access(self.child_html_module_location, {self.user_partition.id: [self.group2.id]})
validation = self.store.get_item(self.child_html_module_location).validate()
self.assertEqual(len(validation.messages), 0)
# Test that a validation message is displayed on split_test child when the child contradicts the parent,
# even though the child agrees with the grandparent unit.
self.set_group_access(self.child_html_module_location, {self.user_partition.id: [self.group1.id]})
validation = self.store.get_item(self.child_html_module_location).validate()
self.assertEqual(len(validation.messages), 1)
self.verify_validation_message(
validation.messages[0],
NONSENSICAL_ACCESS_RESTRICTION,
ValidationMessage.ERROR,
)
def test_validate_nonsensical_access_restriction(self):
"""
Test the validation messages produced for a component whose
access settings contradict the unit level access.
"""
# Test that there is no validation message for non-contradicting access restrictions
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id]})
self.set_group_access(self.video_location, {self.user_partition.id: [self.group1.id]})
validation = self.store.get_item(self.video_location).validate()
self.assertEqual(len(validation.messages), 0)
# Now try again with opposing access restrictions
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id]})
self.set_group_access(self.video_location, {self.user_partition.id: [self.group2.id]})
validation = self.store.get_item(self.video_location).validate()
self.assertEqual(len(validation.messages), 1)
self.verify_validation_message(
validation.messages[0],
NONSENSICAL_ACCESS_RESTRICTION,
ValidationMessage.ERROR,
)
# Now try again when the component restricts access to additional groups that the unit does not
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id]})
self.set_group_access(self.video_location, {self.user_partition.id: [self.group1.id, self.group2.id]})
validation = self.store.get_item(self.video_location).validate()
self.assertEqual(len(validation.messages), 1)
self.verify_validation_message(
validation.messages[0],
NONSENSICAL_ACCESS_RESTRICTION,
ValidationMessage.ERROR,
)
# Now try again when the component tries to allow access to all learners and staff
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id]})
self.set_group_access(self.video_location, {})
validation = self.store.get_item(self.video_location).validate()
self.assertEqual(len(validation.messages), 1)
self.verify_validation_message(
validation.messages[0],
NONSENSICAL_ACCESS_RESTRICTION,
ValidationMessage.ERROR,
)
def test_nonsensical_access_restriction_does_not_override(self):
"""
Test that the validation message produced for a component
whose access settings contradict the unit level access don't
override other messages but add on to them.
"""
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id]})
self.set_group_access(self.video_location, {self.user_partition.id: [self.group2.id, 999]})
validation = self.store.get_item(self.video_location).validate()
self.assertEqual(len(validation.messages), 2)
self.verify_validation_message(
validation.messages[0],
INVALID_USER_PARTITION_GROUP_VALIDATION,
ValidationMessage.ERROR,
)
self.verify_validation_message(
validation.messages[1],
NONSENSICAL_ACCESS_RESTRICTION,
ValidationMessage.ERROR,
)
class OpenAssessmentBlockMixinTestCase(ModuleStoreTestCase):
"""
......
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