diff --git a/cms/djangoapps/contentstore/course_group_config.py b/cms/djangoapps/contentstore/course_group_config.py index 9e8285a505..07378c5197 100644 --- a/cms/djangoapps/contentstore/course_group_config.py +++ b/cms/djangoapps/contentstore/course_group_config.py @@ -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} diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index a32686736b..b4e6e8d1c9 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -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). diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 48d5011593..c6a6ed84a3 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -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", ] diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index b4452f6f98..330eb0ee35 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -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 diff --git a/cms/djangoapps/contentstore/views/tests/test_group_configurations.py b/cms/djangoapps/contentstore/views/tests/test_group_configurations.py index ff2710c3ef..ac0b234c68 100644 --- a/cms/djangoapps/contentstore/views/tests/test_group_configurations.py +++ b/cms/djangoapps/contentstore/views/tests/test_group_configurations.py @@ -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, }], diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index 11c675b719..7d125edb46 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -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): """ diff --git a/cms/static/js/spec/views/group_configuration_spec.js b/cms/static/js/spec/views/group_configuration_spec.js index f418d7173a..612e2ac37d 100644 --- a/cms/static/js/spec/views/group_configuration_spec.js +++ b/cms/static/js/spec/views/group_configuration_spec.js @@ -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', [ diff --git a/cms/static/js/spec/views/pages/container_spec.js b/cms/static/js/spec/views/pages/container_spec.js index 4b56b837b6..e6da95bdcc 100644 --- a/cms/static/js/spec/views/pages/container_spec.js +++ b/cms/static/js/spec/views/pages/container_spec.js @@ -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); } }); diff --git a/cms/static/js/spec/views/pages/container_subviews_spec.js b/cms/static/js/spec/views/pages/container_subviews_spec.js index cc1d6cf8c5..67138a06b3 100644 --- a/cms/static/js/spec/views/pages/container_subviews_spec.js +++ b/cms/static/js/spec/views/pages/container_subviews_spec.js @@ -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() { diff --git a/cms/static/js/spec/views/pages/course_outline_spec.js b/cms/static/js/spec/views/pages/course_outline_spec.js index 4e0781542b..1b943f744e 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -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); diff --git a/cms/static/js/views/group_configuration_details.js b/cms/static/js/views/group_configuration_details.js index 9111ab159e..e2b6425de9 100644 --- a/cms/static/js/views/group_configuration_details.js +++ b/cms/static/js/views/group_configuration_details.js @@ -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} diff --git a/cms/static/js/views/modals/course_outline_modals.js b/cms/static/js/views/modals/course_outline_modals.js index 3c22e00ca5..90a0b5a028 100644 --- a/cms/static/js/views/modals/course_outline_modals.js +++ b/cms/static/js/views/modals/course_outline_modals.js @@ -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 = [ { diff --git a/cms/static/js/views/modals/edit_xblock.js b/cms/static/js/views/modals/edit_xblock.js index 2576b8dbbd..d61ecae86d 100644 --- a/cms/static/js/views/modals/edit_xblock.js +++ b/cms/static/js/views/modals/edit_xblock.js @@ -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); }, diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index ed9957b10a..1fc77092ba 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -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' diff --git a/cms/static/js/views/pages/container_subviews.js b/cms/static/js/views/pages/container_subviews.js index 78a9670c59..4f3474a93f 100644 --- a/cms/static/js/views/pages/container_subviews.js +++ b/cms/static/js/views/pages/container_subviews.js @@ -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(); diff --git a/cms/static/js/views/partition_group_details.js b/cms/static/js/views/partition_group_details.js index bfd96808b7..d0d2a2813a 100644 --- a/cms/static/js/views/partition_group_details.js +++ b/cms/static/js/views/partition_group_details.js @@ -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} diff --git a/cms/static/js/views/xblock_access_editor.js b/cms/static/js/views/xblock_access_editor.js new file mode 100644 index 0000000000..acd7c38984 --- /dev/null +++ b/cms/static/js/views/xblock_access_editor.js @@ -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(); diff --git a/cms/static/sass/elements/_forms.scss b/cms/static/sass/elements/_forms.scss index 3336412b08..720413275b 100644 --- a/cms/static/sass/elements/_forms.scss +++ b/cms/static/sass/elements/_forms.scss @@ -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); diff --git a/cms/static/sass/elements/_modal-window.scss b/cms/static/sass/elements/_modal-window.scss index aa01fd92e9..bf1352689e 100644 --- a/cms/static/sass/elements/_modal-window.scss +++ b/cms/static/sass/elements/_modal-window.scss @@ -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); + } + } + } } diff --git a/cms/static/sass/views/_container.scss b/cms/static/sass/views/_container.scss index 5520d15b9c..0632ce4469 100644 --- a/cms/static/sass/views/_container.scss +++ b/cms/static/sass/views/_container.scss @@ -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 { diff --git a/cms/templates/container.html b/cms/templates/container.html index a313733c3b..0ee9d6d45a 100644 --- a/cms/templates/container.html +++ b/cms/templates/container.html @@ -72,6 +72,8 @@ from openedx.core.djangolib.markup import HTML, Text data-field="display_name" data-field-display-name="${_("Display Name")}">

${xblock.display_name_with_default}

+
+