Merge pull request #15289 from edx/jlajoie/EDUCATOR-434
EDUCATOR-434: Unit Group Access
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -38,15 +38,22 @@ class HelperMethods(object):
|
||||
"""
|
||||
Mixin that provides useful methods for Group Configuration tests.
|
||||
"""
|
||||
def _create_content_experiment(self, cid=-1, name_suffix='', special_characters=''):
|
||||
def _create_content_experiment(self, cid=-1, group_id=None, cid_for_problem=None,
|
||||
name_suffix='', special_characters=''):
|
||||
"""
|
||||
Create content experiment.
|
||||
|
||||
Assign Group Configuration to the experiment if cid is provided.
|
||||
Assigns a problem to the first group in the split test if group_id and cid_for_problem is provided.
|
||||
"""
|
||||
sequential = ItemFactory.create(
|
||||
category='sequential',
|
||||
parent_location=self.course.location,
|
||||
display_name='Test Subsection {}'.format(name_suffix)
|
||||
)
|
||||
vertical = ItemFactory.create(
|
||||
category='vertical',
|
||||
parent_location=self.course.location,
|
||||
parent_location=sequential.location,
|
||||
display_name='Test Unit {}'.format(name_suffix)
|
||||
)
|
||||
c0_url = self.course.id.make_usage_key("vertical", "split_test_cond0")
|
||||
@@ -65,7 +72,7 @@ class HelperMethods(object):
|
||||
display_name="Condition 0 vertical",
|
||||
location=c0_url,
|
||||
)
|
||||
ItemFactory.create(
|
||||
c1_vertical = ItemFactory.create(
|
||||
parent_location=split_test.location,
|
||||
category="vertical",
|
||||
display_name="Condition 1 vertical",
|
||||
@@ -78,6 +85,19 @@ class HelperMethods(object):
|
||||
location=c2_url,
|
||||
)
|
||||
|
||||
problem = None
|
||||
if group_id and cid_for_problem:
|
||||
problem = ItemFactory.create(
|
||||
category='problem',
|
||||
parent_location=c1_vertical.location,
|
||||
display_name=u"Test Problem"
|
||||
)
|
||||
self.client.ajax_post(
|
||||
reverse_usage_url("xblock_handler", problem.location),
|
||||
data={'metadata': {'group_access': {cid_for_problem: [group_id]}}}
|
||||
)
|
||||
c1_vertical.children.append(problem.location)
|
||||
|
||||
partitions_json = [p.to_json() for p in self.course.user_partitions]
|
||||
|
||||
self.client.ajax_post(
|
||||
@@ -86,16 +106,25 @@ class HelperMethods(object):
|
||||
)
|
||||
|
||||
self.save_course()
|
||||
return (vertical, split_test)
|
||||
return vertical, split_test, problem
|
||||
|
||||
def _create_problem_with_content_group(self, cid, group_id, name_suffix='', special_characters='', orphan=False):
|
||||
"""
|
||||
Create a problem
|
||||
Assign content group to the problem.
|
||||
"""
|
||||
vertical_parent_location = self.course.location
|
||||
if not orphan:
|
||||
subsection = ItemFactory.create(
|
||||
category='sequential',
|
||||
parent_location=self.course.location,
|
||||
display_name="Test Subsection {}".format(name_suffix)
|
||||
)
|
||||
vertical_parent_location = subsection.location
|
||||
|
||||
vertical = ItemFactory.create(
|
||||
category='vertical',
|
||||
parent_location=self.course.location,
|
||||
parent_location=vertical_parent_location,
|
||||
display_name="Test Unit {}".format(name_suffix)
|
||||
)
|
||||
|
||||
@@ -113,7 +142,7 @@ class HelperMethods(object):
|
||||
)
|
||||
|
||||
if not orphan:
|
||||
self.course.children.append(vertical.location)
|
||||
self.course.children.append(subsection.location)
|
||||
self.save_course()
|
||||
|
||||
return vertical, problem
|
||||
@@ -757,12 +786,108 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
|
||||
}]
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
def test_can_get_correct_usage_info_for_split_test(self):
|
||||
"""
|
||||
When a split test is created and content group access is set for a problem within a group,
|
||||
the usage info should return a url to the split test, not to the group.
|
||||
"""
|
||||
# Create user partition for groups in the split test,
|
||||
# and another partition to set group access for the problem within the split test.
|
||||
self._add_user_partitions(count=1)
|
||||
self.course.user_partitions += [
|
||||
UserPartition(
|
||||
id=1,
|
||||
name='Cohort User Partition',
|
||||
scheme=UserPartition.get_scheme('cohort'),
|
||||
description='Cohort User Partition',
|
||||
groups=[
|
||||
Group(id=3, name="Problem Group")
|
||||
],
|
||||
),
|
||||
]
|
||||
self.store.update_item(self.course, ModuleStoreEnum.UserID.test)
|
||||
|
||||
__, split_test, problem = self._create_content_experiment(cid=0, name_suffix='0', group_id=3, cid_for_problem=1)
|
||||
|
||||
expected = {
|
||||
'id': 1,
|
||||
'name': 'Cohort User Partition',
|
||||
'scheme': 'cohort',
|
||||
'description': 'Cohort User Partition',
|
||||
'version': UserPartition.VERSION,
|
||||
'groups': [
|
||||
{'id': 3, 'name': 'Problem Group', 'version': 1, 'usage': [
|
||||
{
|
||||
'url': '/container/{}'.format(split_test.location),
|
||||
'label': 'Condition 1 vertical / Test Problem'
|
||||
}
|
||||
]},
|
||||
],
|
||||
u'parameters': {},
|
||||
u'active': True,
|
||||
}
|
||||
actual = self._get_user_partition('cohort')
|
||||
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
def test_can_get_correct_usage_info_for_unit(self):
|
||||
"""
|
||||
When group access is set on the unit level, the usage info should return a url to the unit, not
|
||||
the sequential parent of the unit.
|
||||
"""
|
||||
self.course.user_partitions = [
|
||||
UserPartition(
|
||||
id=0,
|
||||
name='User Partition',
|
||||
scheme=UserPartition.get_scheme('cohort'),
|
||||
description='User Partition',
|
||||
groups=[
|
||||
Group(id=0, name="Group")
|
||||
],
|
||||
),
|
||||
]
|
||||
vertical, __ = self._create_problem_with_content_group(
|
||||
cid=0, group_id=0, name_suffix='0'
|
||||
)
|
||||
|
||||
self.client.ajax_post(
|
||||
reverse_usage_url("xblock_handler", vertical.location),
|
||||
data={'metadata': {'group_access': {0: [0]}}}
|
||||
)
|
||||
|
||||
actual = self._get_user_partition('cohort')
|
||||
expected = {
|
||||
'id': 0,
|
||||
'name': 'User Partition',
|
||||
'scheme': 'cohort',
|
||||
'description': 'User Partition',
|
||||
'version': UserPartition.VERSION,
|
||||
'groups': [
|
||||
{'id': 0, 'name': 'Group', 'version': 1, 'usage': [
|
||||
{
|
||||
'url': u"/container/{}".format(vertical.location),
|
||||
'label': u"Test Subsection 0 / Test Unit 0"
|
||||
},
|
||||
{
|
||||
'url': u"/container/{}".format(vertical.location),
|
||||
'label': u"Test Unit 0 / Test Problem 0"
|
||||
}
|
||||
]},
|
||||
],
|
||||
u'parameters': {},
|
||||
u'active': True,
|
||||
}
|
||||
|
||||
self.maxDiff = None
|
||||
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
def test_can_get_correct_usage_info(self):
|
||||
"""
|
||||
Test if group configurations json updated successfully with usage information.
|
||||
"""
|
||||
self._add_user_partitions(count=2)
|
||||
vertical, __ = self._create_content_experiment(cid=0, name_suffix='0')
|
||||
__, split_test, __ = self._create_content_experiment(cid=0, name_suffix='0')
|
||||
self._create_content_experiment(name_suffix='1')
|
||||
|
||||
actual = GroupConfiguration.get_split_test_partitions_with_usage(self.store, self.course)
|
||||
@@ -779,7 +904,7 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
|
||||
{'id': 2, 'name': 'Group C', 'version': 1},
|
||||
],
|
||||
'usage': [{
|
||||
'url': '/container/{}'.format(vertical.location),
|
||||
'url': '/container/{}'.format(split_test.location),
|
||||
'label': 'Test Unit 0 / Test Content Experiment 0',
|
||||
'validation': None,
|
||||
}],
|
||||
@@ -809,7 +934,7 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
|
||||
characters are being used in content experiment
|
||||
"""
|
||||
self._add_user_partitions(count=1)
|
||||
vertical, __ = self._create_content_experiment(cid=0, name_suffix='0', special_characters=u"JOSÉ ANDRÉS")
|
||||
__, split_test, __ = self._create_content_experiment(cid=0, name_suffix='0', special_characters=u"JOSÉ ANDRÉS")
|
||||
|
||||
actual = GroupConfiguration.get_split_test_partitions_with_usage(self.store, self.course, )
|
||||
|
||||
@@ -825,7 +950,7 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
|
||||
{'id': 2, 'name': 'Group C', 'version': 1},
|
||||
],
|
||||
'usage': [{
|
||||
'url': '/container/{}'.format(vertical.location),
|
||||
'url': reverse_usage_url("container_handler", split_test.location),
|
||||
'label': u"Test Unit 0 / Test Content Experiment 0JOSÉ ANDRÉS",
|
||||
'validation': None,
|
||||
}],
|
||||
@@ -841,8 +966,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
|
||||
group configuration.
|
||||
"""
|
||||
self._add_user_partitions()
|
||||
vertical, __ = self._create_content_experiment(cid=0, name_suffix='0')
|
||||
vertical1, __ = self._create_content_experiment(cid=0, name_suffix='1')
|
||||
__, split_test, __ = self._create_content_experiment(cid=0, name_suffix='0')
|
||||
__, split_test1, __ = self._create_content_experiment(cid=0, name_suffix='1')
|
||||
|
||||
actual = GroupConfiguration.get_split_test_partitions_with_usage(self.store, self.course)
|
||||
|
||||
@@ -858,11 +983,11 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
|
||||
{'id': 2, 'name': 'Group C', 'version': 1},
|
||||
],
|
||||
'usage': [{
|
||||
'url': '/container/{}'.format(vertical.location),
|
||||
'url': '/container/{}'.format(split_test.location),
|
||||
'label': 'Test Unit 0 / Test Content Experiment 0',
|
||||
'validation': None,
|
||||
}, {
|
||||
'url': '/container/{}'.format(vertical1.location),
|
||||
'url': '/container/{}'.format(split_test1.location),
|
||||
'label': 'Test Unit 1 / Test Content Experiment 1',
|
||||
'validation': None,
|
||||
}],
|
||||
|
||||
@@ -1,53 +1,60 @@
|
||||
"""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
|
||||
|
||||
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 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
|
||||
)
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from student.tests.factories import UserFactory
|
||||
from xblock_django.models import XBlockConfiguration, XBlockStudioConfiguration, XBlockStudioConfigurationFlag
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
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 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.fields import Scope, String, ScopeIds
|
||||
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.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.partitions.partitions import (
|
||||
Group, UserPartition, ENROLLMENT_TRACK_PARTITION_ID, MINIMUM_STATIC_PARTITION_ID
|
||||
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 (
|
||||
ALWAYS,
|
||||
VisibilityState,
|
||||
_get_module_info,
|
||||
_get_source_index,
|
||||
_xblock_type_and_display_name,
|
||||
add_container_page_publishing_info,
|
||||
create_xblock_info
|
||||
)
|
||||
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 TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, LibraryFactory, check_mongo_calls
|
||||
from xmodule.partitions.partitions import (
|
||||
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}
|
||||
|
||||
22
cms/static/js/views/xblock_access_editor.js
Normal file
22
cms/static/js/views/xblock_access_editor.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -194,7 +197,7 @@
|
||||
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>
|
||||
|
||||
31
cms/templates/js/container-access.underscore
Normal file
31
cms/templates/js/container-access.underscore
Normal file
@@ -0,0 +1,31 @@
|
||||
<%
|
||||
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>
|
||||
|
||||
46
cms/templates/js/unit-access-editor.underscore
Normal file
46
cms/templates/js/unit-access-editor.underscore
Normal file
@@ -0,0 +1,46 @@
|
||||
<%
|
||||
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>
|
||||
5
cms/templates/js/xblock-access-editor.underscore
Normal file
5
cms/templates/js/xblock-access-editor.underscore
Normal file
@@ -0,0 +1,5 @@
|
||||
<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>
|
||||
@@ -1,14 +1,14 @@
|
||||
<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):
|
||||
"""
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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')
|
||||
@@ -88,6 +88,11 @@ class CourseOutlineItem(object):
|
||||
""" Returns True if the 'Contains staff only content' message is visible """
|
||||
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) """
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ from common.test.acceptance.fixtures.course import XBlockFixtureDesc
|
||||
from common.test.acceptance.pages.lms.courseware import CoursewarePage
|
||||
from common.test.acceptance.pages.lms.create_mode import ModeCreationPage
|
||||
from common.test.acceptance.pages.lms.staff_view import StaffCoursewarePage
|
||||
from common.test.acceptance.pages.studio.component_editor import ComponentEditorView, ComponentVisibilityEditorView
|
||||
from common.test.acceptance.pages.studio.xblock_editor import XBlockEditorView, XBlockVisibilityEditorView
|
||||
from common.test.acceptance.pages.studio.container import ContainerPage
|
||||
from common.test.acceptance.pages.studio.html_component_editor import HtmlComponentEditorView
|
||||
from common.test.acceptance.pages.studio.html_component_editor import HtmlXBlockEditorView
|
||||
from common.test.acceptance.pages.studio.move_xblock import MoveModalView
|
||||
from common.test.acceptance.pages.studio.utils import add_discussion, drag
|
||||
from common.test.acceptance.tests.helpers import create_user_partition_json
|
||||
@@ -276,7 +276,7 @@ class EditContainerTest(NestedVerticalTest):
|
||||
modified_name = 'modified'
|
||||
self.assertNotEqual(component.name, modified_name)
|
||||
component.edit()
|
||||
component_editor = ComponentEditorView(self.browser, component.locator)
|
||||
component_editor = XBlockEditorView(self.browser, component.locator)
|
||||
component_editor.set_field_value_and_save('Display Name', modified_name)
|
||||
self.assertEqual(component.name, modified_name)
|
||||
|
||||
@@ -307,7 +307,7 @@ class EditContainerTest(NestedVerticalTest):
|
||||
component = container.xblocks[1].children[0]
|
||||
component.edit()
|
||||
|
||||
html_editor = HtmlComponentEditorView(self.browser, component.locator)
|
||||
html_editor = HtmlXBlockEditorView(self.browser, component.locator)
|
||||
html_editor.set_content_and_save(modified_content, raw=True)
|
||||
|
||||
#note we're expecting the <p> tags to have been removed
|
||||
@@ -315,14 +315,15 @@ class EditContainerTest(NestedVerticalTest):
|
||||
|
||||
|
||||
class BaseGroupConfigurationsTest(ContainerBase):
|
||||
ALL_LEARNERS_AND_STAFF = ComponentVisibilityEditorView.ALL_LEARNERS_AND_STAFF
|
||||
ALL_LEARNERS_AND_STAFF = XBlockVisibilityEditorView.ALL_LEARNERS_AND_STAFF
|
||||
CHOOSE_ONE = "Select a group type"
|
||||
CONTENT_GROUP_PARTITION = ComponentVisibilityEditorView.CONTENT_GROUP_PARTITION
|
||||
ENROLLMENT_TRACK_PARTITION = ComponentVisibilityEditorView.ENROLLMENT_TRACK_PARTITION
|
||||
CONTENT_GROUP_PARTITION = XBlockVisibilityEditorView.CONTENT_GROUP_PARTITION
|
||||
ENROLLMENT_TRACK_PARTITION = XBlockVisibilityEditorView.ENROLLMENT_TRACK_PARTITION
|
||||
MISSING_GROUP_LABEL = 'Deleted Group\nThis group no longer exists. Choose another group or do not restrict access to this component.'
|
||||
VALIDATION_ERROR_LABEL = 'This component has validation issues.'
|
||||
VALIDATION_ERROR_MESSAGE = "Error:\nThis component's access settings refer to deleted or invalid groups."
|
||||
GROUP_VISIBILITY_MESSAGE = 'Access to some content in this unit is restricted to specific groups of learners.'
|
||||
MODAL_NOT_RESTRICTED_MESSAGE = "Access is not restricted"
|
||||
|
||||
def setUp(self):
|
||||
super(BaseGroupConfigurationsTest, self).setUp()
|
||||
@@ -365,10 +366,17 @@ class BaseGroupConfigurationsTest(ContainerBase):
|
||||
|
||||
def edit_component_visibility(self, component):
|
||||
"""
|
||||
Edit the visibility of an xblock on the container page.
|
||||
Edit the visibility of an xblock on the container page and returns an XBlockVisibilityEditorView.
|
||||
"""
|
||||
component.edit_visibility()
|
||||
return ComponentVisibilityEditorView(self.browser, component.locator)
|
||||
return XBlockVisibilityEditorView(self.browser, component.locator)
|
||||
|
||||
def edit_unit_visibility(self, unit):
|
||||
"""
|
||||
Edit the visibility of a unit on the container page and returns an XBlockVisibilityEditorView.
|
||||
"""
|
||||
unit.edit_visibility()
|
||||
return XBlockVisibilityEditorView(self.browser, unit.locator)
|
||||
|
||||
def verify_current_groups_message(self, visibility_editor, expected_current_groups):
|
||||
"""
|
||||
@@ -402,6 +410,7 @@ class BaseGroupConfigurationsTest(ContainerBase):
|
||||
"""
|
||||
# Make initial edit(s) and save
|
||||
visibility_editor = self.edit_component_visibility(component)
|
||||
|
||||
visibility_editor.select_groups_in_partition_scheme(partition_label, groups)
|
||||
|
||||
# Re-open the modal and inspect its selected inputs. If no groups were selected,
|
||||
@@ -413,6 +422,22 @@ class BaseGroupConfigurationsTest(ContainerBase):
|
||||
self.verify_selected_groups(visibility_editor, groups)
|
||||
visibility_editor.save()
|
||||
|
||||
def select_and_verify_unit_group_access(self, unit, partition_label, groups=[]):
|
||||
"""
|
||||
Edit the visibility of an xblock on the unit page and
|
||||
verify that the edit persists. Note that `groups`
|
||||
are labels which should be clicked, but are not necessarily checked.
|
||||
"""
|
||||
unit_access_editor = self.edit_unit_visibility(unit)
|
||||
unit_access_editor.select_groups_in_partition_scheme(partition_label, groups)
|
||||
|
||||
if not groups:
|
||||
partition_label = self.CHOOSE_ONE
|
||||
unit_access_editor = self.edit_unit_visibility(unit)
|
||||
self.verify_selected_partition_scheme(unit_access_editor, partition_label)
|
||||
self.verify_selected_groups(unit_access_editor, groups)
|
||||
unit_access_editor.save()
|
||||
|
||||
def verify_component_validation_error(self, component):
|
||||
"""
|
||||
Verify that we see validation errors for the given component.
|
||||
@@ -434,6 +459,19 @@ class BaseGroupConfigurationsTest(ContainerBase):
|
||||
self.assertNotIn(self.GROUP_VISIBILITY_MESSAGE, self.container_page.sidebar_visibility_message)
|
||||
self.assertFalse(component.has_group_visibility_set)
|
||||
|
||||
def verify_unit_visibility_set(self, unit, set_groups=[]):
|
||||
"""
|
||||
Verify that the container visibility modal shows that unit visibility
|
||||
settings have been edited if there are `set_groups`. Otherwise verify
|
||||
that the modal shows no such information.
|
||||
"""
|
||||
unit_access_editor = self.edit_unit_visibility(unit)
|
||||
if set_groups:
|
||||
self.assertIn(", ".join(set_groups), unit_access_editor.current_groups_message)
|
||||
else:
|
||||
self.assertEqual(self.MODAL_NOT_RESTRICTED_MESSAGE, unit_access_editor.current_groups_message)
|
||||
unit_access_editor.cancel()
|
||||
|
||||
def update_component(self, component, metadata):
|
||||
"""
|
||||
Update a component's metadata and refresh the page.
|
||||
@@ -458,6 +496,59 @@ class BaseGroupConfigurationsTest(ContainerBase):
|
||||
self.assertFalse(component.has_validation_error)
|
||||
|
||||
|
||||
class UnitAccessContainerTest(BaseGroupConfigurationsTest):
|
||||
GROUP_RESTRICTED_MESSAGE = 'Access to this unit is restricted to: Dogs'
|
||||
|
||||
def _toggle_container_unit_access(self, group_ids, unit):
|
||||
"""
|
||||
Toggle the unit level access on the course outline page
|
||||
"""
|
||||
unit.toggle_unit_access('Content Groups', group_ids)
|
||||
|
||||
def _verify_container_unit_access_message(self, group_ids, expected_message):
|
||||
"""
|
||||
Check that the container page displays the correct unit
|
||||
access message.
|
||||
"""
|
||||
self.outline.visit()
|
||||
self.outline.expand_all_subsections()
|
||||
unit = self.outline.section_at(0).subsection_at(0).unit_at(0)
|
||||
self._toggle_container_unit_access(group_ids, unit)
|
||||
|
||||
container_page = self.go_to_unit_page()
|
||||
self.assertEqual(str(container_page.get_xblock_access_message()), expected_message)
|
||||
|
||||
def test_default_selection(self):
|
||||
"""
|
||||
Tests that no message is displayed when there are no
|
||||
restrictions on the unit or components.
|
||||
"""
|
||||
self._verify_container_unit_access_message([], '')
|
||||
|
||||
def test_restricted_components_message(self):
|
||||
"""
|
||||
Test that the proper message is displayed when access to
|
||||
some components is restricted.
|
||||
"""
|
||||
container_page = self.go_to_unit_page()
|
||||
html_component = container_page.xblocks[1]
|
||||
|
||||
# Initially set visibility to Dog group.
|
||||
self.update_component(
|
||||
html_component,
|
||||
{'group_access': {self.id_base: [self.id_base + 1]}}
|
||||
)
|
||||
|
||||
self._verify_container_unit_access_message([], self.GROUP_VISIBILITY_MESSAGE)
|
||||
|
||||
def test_restricted_access_message(self):
|
||||
"""
|
||||
Test that the proper message is displayed when access to the
|
||||
unit is restricted to a particular group.
|
||||
"""
|
||||
self._verify_container_unit_access_message([self.id_base + 1], self.GROUP_RESTRICTED_MESSAGE)
|
||||
|
||||
|
||||
@attr(shard=3)
|
||||
class ContentGroupVisibilityModalTest(BaseGroupConfigurationsTest):
|
||||
"""
|
||||
@@ -484,21 +575,36 @@ class ContentGroupVisibilityModalTest(BaseGroupConfigurationsTest):
|
||||
Scenario: The component visibility modal can be set to be visible to all students and staff.
|
||||
Given I have a unit with one component
|
||||
When I go to the container page for that unit
|
||||
And I open the visibility editor modal for that unit's component
|
||||
And I select 'Dogs'
|
||||
And I save the modal
|
||||
Then the container page should display the content visibility warning
|
||||
And I re-open the visibility editor modal for that unit's component
|
||||
Then the container page should not display the content visibility warning by default.
|
||||
If I then restrict access and save, and then I open the visibility editor modal for that unit's component
|
||||
And I select 'All Students and Staff'
|
||||
And I save the modal
|
||||
Then the visibility selection should be 'All Students and Staff'
|
||||
And the container page should not display the content visibility warning
|
||||
And the container page should still not display the content visibility warning
|
||||
"""
|
||||
self.select_and_verify_saved(self.html_component, self.CONTENT_GROUP_PARTITION, ['Dogs'])
|
||||
self.verify_visibility_set(self.html_component, True)
|
||||
self.select_and_verify_saved(self.html_component, self.ALL_LEARNERS_AND_STAFF)
|
||||
self.verify_visibility_set(self.html_component, False)
|
||||
|
||||
def test_reset_unit_access_to_all_students_and_staff(self):
|
||||
"""
|
||||
Scenario: The unit visibility modal can be set to be visible to all students and staff.
|
||||
Given I have a unit
|
||||
When I go to the container page for that unit
|
||||
And I open the visibility editor modal for that unit
|
||||
And I select 'Dogs'
|
||||
And I save the modal
|
||||
Then I re-open the modal, the unit access modal should display the content visibility settings
|
||||
Then after re-opening the modal again
|
||||
And I select 'All Learners and Staff'
|
||||
And I save the modal
|
||||
And I re-open the modal, the unit access modal should display that no content is restricted
|
||||
"""
|
||||
self.select_and_verify_unit_group_access(self.container_page, self.CONTENT_GROUP_PARTITION, ['Dogs'])
|
||||
self.verify_unit_visibility_set(self.container_page, set_groups=["Dogs"])
|
||||
self.select_and_verify_unit_group_access(self.container_page, self.ALL_LEARNERS_AND_STAFF)
|
||||
self.verify_unit_visibility_set(self.container_page)
|
||||
|
||||
def test_select_single_content_group(self):
|
||||
"""
|
||||
Scenario: The component visibility modal can be set to be visible to one content group.
|
||||
@@ -508,10 +614,8 @@ class ContentGroupVisibilityModalTest(BaseGroupConfigurationsTest):
|
||||
And I select 'Dogs'
|
||||
And I save the modal
|
||||
Then the visibility selection should be 'Dogs' and 'Specific Content Groups'
|
||||
And the container page should display the content visibility warning
|
||||
"""
|
||||
self.select_and_verify_saved(self.html_component, self.CONTENT_GROUP_PARTITION, ['Dogs'])
|
||||
self.verify_visibility_set(self.html_component, True)
|
||||
|
||||
def test_select_multiple_content_groups(self):
|
||||
"""
|
||||
@@ -522,10 +626,8 @@ class ContentGroupVisibilityModalTest(BaseGroupConfigurationsTest):
|
||||
And I select 'Dogs' and 'Cats'
|
||||
And I save the modal
|
||||
Then the visibility selection should be 'Dogs', 'Cats', and 'Specific Content Groups'
|
||||
And the container page should display the content visibility warning
|
||||
"""
|
||||
self.select_and_verify_saved(self.html_component, self.CONTENT_GROUP_PARTITION, ['Dogs', 'Cats'])
|
||||
self.verify_visibility_set(self.html_component, True)
|
||||
|
||||
def test_select_zero_content_groups(self):
|
||||
"""
|
||||
@@ -581,12 +683,10 @@ class ContentGroupVisibilityModalTest(BaseGroupConfigurationsTest):
|
||||
Then I should see a validation error message on that unit's component
|
||||
And I open the visibility editor modal for that unit's component
|
||||
Then I should see that I have selected multiple deleted groups
|
||||
And the container page should display the content visibility warning
|
||||
And I de-select the missing groups
|
||||
And then if I de-select the missing groups
|
||||
And I save the modal
|
||||
Then the visibility selection should be the names of the valid groups.
|
||||
And I should not see any validation errors on the component
|
||||
And the container page should display the content visibility warning
|
||||
"""
|
||||
self.update_component(
|
||||
self.html_component,
|
||||
@@ -603,7 +703,6 @@ class ContentGroupVisibilityModalTest(BaseGroupConfigurationsTest):
|
||||
expected_groups = ['Dogs', 'Cats']
|
||||
self.verify_current_groups_message(visibility_editor, ", ".join(expected_groups))
|
||||
self.verify_selected_groups(visibility_editor, expected_groups)
|
||||
self.verify_visibility_set(self.html_component, True)
|
||||
|
||||
def _verify_and_remove_missing_content_groups(self, current_groups_message, all_group_labels):
|
||||
self.verify_component_validation_error(self.html_component)
|
||||
@@ -1000,7 +1099,7 @@ class UnitPublishingTest(ContainerBase):
|
||||
unit = self.go_to_unit_page()
|
||||
component = unit.xblocks[1]
|
||||
component.edit()
|
||||
HtmlComponentEditorView(self.browser, component.locator).set_content_and_save(modified_content)
|
||||
HtmlXBlockEditorView(self.browser, component.locator).set_content_and_save(modified_content)
|
||||
self.assertEqual(component.student_content, modified_content)
|
||||
unit.verify_publish_title(self.DRAFT_STATUS)
|
||||
unit.publish_action.click()
|
||||
@@ -1023,7 +1122,7 @@ class UnitPublishingTest(ContainerBase):
|
||||
unit = self.go_to_unit_page()
|
||||
component = unit.xblocks[1]
|
||||
component.edit()
|
||||
HtmlComponentEditorView(self.browser, component.locator).set_content_and_cancel("modified content")
|
||||
HtmlXBlockEditorView(self.browser, component.locator).set_content_and_cancel("modified content")
|
||||
self.assertEqual(component.student_content, "Body of HTML Unit.")
|
||||
unit.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
|
||||
self.browser.refresh()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -491,6 +494,152 @@ class EditingSectionsTest(CourseOutlineTest):
|
||||
self.assertIn(release_text, self.course_outline_page.section_at(0).subsection_at(0).release_date)
|
||||
|
||||
|
||||
@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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
59
lms/lib/tests/test_utils.py
Normal file
59
lms/lib/tests/test_utils.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
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))
|
||||
30
lms/lib/utils.py
Normal file
30
lms/lib/utils.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user