diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py
index 8698feb6d9..76b809a96d 100644
--- a/cms/djangoapps/contentstore/tests/test_utils.py
+++ b/cms/djangoapps/contentstore/tests/test_utils.py
@@ -423,3 +423,63 @@ class InheritedStaffLockTest(StaffLockTest):
def test_no_inheritance_for_orphan(self):
"""Tests that an orphaned xblock does not inherit staff lock"""
self.assertFalse(utils.ancestor_has_staff_lock(self.orphan))
+
+
+class GroupVisibilityTest(CourseTestCase):
+ """
+ Test content group access rules.
+ """
+ def setUp(self):
+ super(GroupVisibilityTest, self).setUp()
+
+ chapter = ItemFactory.create(category='chapter', parent_location=self.course.location)
+ sequential = ItemFactory.create(category='sequential', parent_location=chapter.location)
+ vertical = ItemFactory.create(category='vertical', parent_location=sequential.location)
+ html = ItemFactory.create(category='html', parent_location=vertical.location)
+ problem = ItemFactory.create(
+ category='problem', parent_location=vertical.location, data=""
+ )
+ self.sequential = self.store.get_item(sequential.location)
+ self.vertical = self.store.get_item(vertical.location)
+ self.html = self.store.get_item(html.location)
+ self.problem = self.store.get_item(problem.location)
+
+ def set_group_access(self, xblock, value):
+ """ Sets group_access to specified value and calls update_item to persist the change. """
+ xblock.group_access = value
+ self.store.update_item(xblock, self.user.id)
+
+ def test_no_visibility_set(self):
+ """ Tests when group_access has not been set on anything. """
+
+ def verify_all_components_visible_to_all(): # pylint: disable=invalid-name
+ """ Verifies when group_access has not been set on anything. """
+ for item in (self.sequential, self.vertical, self.html, self.problem):
+ self.assertFalse(utils.has_children_visible_to_specific_content_groups(item))
+ self.assertFalse(utils.is_visible_to_specific_content_groups(item))
+
+ verify_all_components_visible_to_all()
+
+ # Test with group_access set to Falsey values.
+ self.set_group_access(self.vertical, {1: []})
+ self.set_group_access(self.html, {2: None})
+
+ verify_all_components_visible_to_all()
+
+ def test_sequential_and_problem_have_group_access(self):
+ """ Tests when group_access is set on a few different components. """
+ self.set_group_access(self.sequential, {1: [0]})
+ # This is a no-op.
+ self.set_group_access(self.vertical, {1: []})
+ self.set_group_access(self.problem, {2: [3, 4]})
+
+ # Note that "has_children_visible_to_specific_content_groups" only checks immediate children.
+ self.assertFalse(utils.has_children_visible_to_specific_content_groups(self.sequential))
+ self.assertTrue(utils.has_children_visible_to_specific_content_groups(self.vertical))
+ self.assertFalse(utils.has_children_visible_to_specific_content_groups(self.html))
+ self.assertFalse(utils.has_children_visible_to_specific_content_groups(self.problem))
+
+ self.assertTrue(utils.is_visible_to_specific_content_groups(self.sequential))
+ self.assertFalse(utils.is_visible_to_specific_content_groups(self.vertical))
+ self.assertFalse(utils.is_visible_to_specific_content_groups(self.html))
+ self.assertTrue(utils.is_visible_to_specific_content_groups(self.problem))
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index 07a8a8cac1..41fca796f4 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -179,6 +179,36 @@ def is_currently_visible_to_students(xblock):
return True
+def has_children_visible_to_specific_content_groups(xblock):
+ """
+ Returns True if this xblock has children that are limited to specific content groups.
+ Note that this method is not recursive (it does not check grandchildren).
+ """
+ if not xblock.has_children:
+ return False
+
+ for child in xblock.get_children():
+ if is_visible_to_specific_content_groups(child):
+ return True
+
+ return False
+
+
+def is_visible_to_specific_content_groups(xblock):
+ """
+ Returns True if this xblock has visibility limited to specific content groups.
+ """
+ if not xblock.group_access:
+ return False
+ for __, value in xblock.group_access.iteritems():
+ # value should be a list of group IDs. If it is an empty list or None, the xblock is visible
+ # to all groups in that particular partition. So if value is a truthy value, the xblock is
+ # restricted in some way.
+ if value:
+ return True
+ return False
+
+
def find_release_date_source(xblock):
"""
Finds the ancestor of xblock that set its release date.
diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py
index 90f1dde267..ea4412e7b7 100644
--- a/cms/djangoapps/contentstore/views/component.py
+++ b/cms/djangoapps/contentstore/views/component.py
@@ -21,7 +21,7 @@ from xblock.runtime import Mixologist
from contentstore.utils import get_lms_link_for_item
from contentstore.views.helpers import get_parent_xblock, is_unit, xblock_type_display_name
-from contentstore.views.item import create_xblock_info
+from contentstore.views.item import create_xblock_info, add_container_page_publishing_info
from opaque_keys.edx.keys import UsageKey
@@ -186,8 +186,9 @@ def container_handler(request, usage_key_string):
# about the block's ancestors and siblings for use by the Unit Outline.
xblock_info = create_xblock_info(xblock, include_ancestor_info=is_unit_page)
- # Create the link for preview.
- preview_lms_base = settings.FEATURES.get('PREVIEW_LMS_BASE')
+ if is_unit_page:
+ add_container_page_publishing_info(xblock, xblock_info)
+
# need to figure out where this item is in the list of children as the
# preview will need this
index = 1
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index f8b4de4781..7133a5571d 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -1410,7 +1410,7 @@ def group_configurations_list_handler(request, course_key_string):
'context_course': course,
'group_configuration_url': group_configuration_url,
'course_outline_url': course_outline_url,
- 'configurations': configurations if should_show_group_configurations_page(course) else None,
+ 'configurations': configurations,
})
elif "application/json" in request.META.get('HTTP_ACCEPT'):
if request.method == 'POST':
@@ -1489,16 +1489,6 @@ def group_configurations_detail_handler(request, course_key_string, group_config
return JsonResponse(status=204)
-def should_show_group_configurations_page(course):
- """
- Returns true if Studio should show the "Group Configurations" page for the specified course.
- """
- return (
- SPLIT_TEST_COMPONENT_TYPE in ADVANCED_COMPONENT_TYPES and
- SPLIT_TEST_COMPONENT_TYPE in course.advanced_modules
- )
-
-
def _get_course_creator_status(user):
"""
Helper method for returning the course creator status for a particular user,
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index bdb5a8d248..96c1b6db85 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -39,7 +39,7 @@ from util.json_request import expect_json, JsonResponse
from student.auth import has_studio_write_access, has_studio_read_access
from contentstore.utils import find_release_date_source, find_staff_lock_source, is_currently_visible_to_students, \
- ancestor_has_staff_lock
+ ancestor_has_staff_lock, has_children_visible_to_specific_content_groups
from contentstore.views.helpers import is_unit, xblock_studio_url, xblock_primary_child_category, \
xblock_type_display_name, get_parent_xblock
from contentstore.views.preview import get_preview_fragment
@@ -48,8 +48,11 @@ from models.settings.course_grading import CourseGradingModel
from cms.lib.xblock.runtime import handler_url, local_resource_url
from opaque_keys.edx.keys import UsageKey, CourseKey
from opaque_keys.edx.locator import LibraryUsageLocator
+from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW
-__all__ = ['orphan_handler', 'xblock_handler', 'xblock_view_handler', 'xblock_outline_handler']
+__all__ = [
+ 'orphan_handler', 'xblock_handler', 'xblock_view_handler', 'xblock_outline_handler', 'xblock_container_handler'
+]
log = logging.getLogger(__name__)
@@ -59,7 +62,6 @@ CREATE_IF_NOT_FOUND = ['course_info']
NEVER = lambda x: False
ALWAYS = lambda x: True
-
# In order to allow descriptors to use a handler url, we need to
# monkey-patch the x_module library.
# TODO: Remove this code when Runtimes are no longer created by modulestores
@@ -144,8 +146,8 @@ def xblock_handler(request, usage_key_string):
return JsonResponse(CourseGradingModel.get_section_grader_type(usage_key))
# TODO: pass fields to _get_module_info and only return those
with modulestore().bulk_operations(usage_key.course_key):
- rsp = _get_module_info(_get_xblock(usage_key, request.user))
- return JsonResponse(rsp)
+ response = _get_module_info(_get_xblock(usage_key, request.user))
+ return JsonResponse(response)
else:
return HttpResponse(status=406)
@@ -226,14 +228,14 @@ def xblock_view_handler(request, usage_key_string, view_name):
request_token=request_token(request),
))
- if view_name == STUDIO_VIEW:
+ if view_name in (STUDIO_VIEW, VISIBILITY_VIEW):
try:
- fragment = xblock.render(STUDIO_VIEW)
+ fragment = xblock.render(view_name)
# catch exceptions indiscriminately, since after this point they escape the
# dungeon and surface as uneditable, unsaveable, and undeletable
# component-goblins.
except Exception as exc: # pylint: disable=broad-except
- log.debug("unable to render studio_view for %r", xblock, exc_info=True)
+ log.debug("Unable to render %s for %r", view_name, xblock, exc_info=True)
fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)}))
elif view_name in (PREVIEW_VIEWS + container_views):
@@ -334,6 +336,32 @@ def xblock_outline_handler(request, usage_key_string):
return Http404
+# pylint: disable=unused-argument
+@require_http_methods(("GET"))
+@login_required
+@expect_json
+def xblock_container_handler(request, usage_key_string):
+ """
+ The restful handler for requests for XBlock information about the block and its children.
+ This is used by the container page in particular to get additional information about publish state
+ and ancestor state.
+ """
+ usage_key = usage_key_with_run(usage_key_string)
+
+ if not has_course_author_access(request.user, usage_key.course_key):
+ raise PermissionDenied()
+
+ response_format = request.REQUEST.get('format', 'html')
+ if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
+ with modulestore().bulk_operations(usage_key.course_key):
+ response = _get_module_info(
+ _get_xblock(usage_key, request.user), include_ancestor_info=True, include_publishing_info=True
+ )
+ return JsonResponse(response)
+ else:
+ return Http404
+
+
def _update_with_callback(xblock, user, old_metadata=None, old_content=None):
"""
Updates the xblock in the modulestore.
@@ -696,7 +724,7 @@ def _get_xblock(usage_key, user):
return JsonResponse({"error": "Can't find item by location: " + unicode(usage_key)}, 404)
-def _get_module_info(xblock, rewrite_static_links=True):
+def _get_module_info(xblock, rewrite_static_links=True, include_ancestor_info=False, include_publishing_info=False):
"""
metadata, data, id representation of a leaf module fetcher.
:param usage_key: A UsageKey
@@ -716,7 +744,12 @@ def _get_module_info(xblock, rewrite_static_links=True):
modulestore().has_changes(modulestore().get_course(xblock.location.course_key, depth=None))
# Note that children aren't being returned until we have a use case.
- return create_xblock_info(xblock, data=data, metadata=own_metadata(xblock), include_ancestor_info=True)
+ xblock_info = create_xblock_info(
+ xblock, data=data, metadata=own_metadata(xblock), include_ancestor_info=include_ancestor_info
+ )
+ if include_publishing_info:
+ add_container_page_publishing_info(xblock, xblock_info)
+ return xblock_info
def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=False, include_child_info=False,
@@ -736,24 +769,6 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
In addition, an optional include_children_predicate argument can be provided to define whether or
not a particular xblock should have its children included.
"""
-
- def safe_get_username(user_id):
- """
- Guard against bad user_ids, like the infamous "**replace_user**".
- Note that this will ignore our special known IDs (ModuleStoreEnum.UserID).
- We should consider adding special handling for those values.
-
- :param user_id: the user id to get the username of
- :return: username, or None if the user does not exist or user_id is None
- """
- if user_id:
- try:
- return User.objects.get(id=user_id).username
- except: # pylint: disable=bare-except
- pass
-
- return None
-
is_library_block = isinstance(xblock.location, LibraryUsageLocator)
is_xblock_unit = is_unit(xblock, parent_xblock)
# this should not be calculated for Sections and Subsections on Unit page or for library blocks
@@ -779,8 +794,6 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
else:
child_info = None
- # Treat DEFAULT_START_DATE as a magic number that means the release date has not been set
- release_date = get_default_time_display(xblock.start) if xblock.start != DEFAULT_START_DATE else None
if xblock.category != 'course':
visibility_state = _compute_visibility_state(xblock, child_info, is_xblock_unit and has_changes)
else:
@@ -796,7 +809,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
"published_on": get_default_time_display(xblock.published_on) if published and xblock.published_on else None,
"studio_url": xblock_studio_url(xblock, parent_xblock),
"released_to_students": datetime.now(UTC) > xblock.start,
- "release_date": release_date,
+ "release_date": _get_release_date(xblock),
"visibility_state": visibility_state,
"has_explicit_staff_lock": xblock.fields['visible_to_staff_only'].is_set_on(xblock),
"start": xblock.fields['start'].to_json(xblock.start),
@@ -820,19 +833,6 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
else:
xblock_info["ancestor_has_staff_lock"] = False
- # Currently, 'edited_by', 'published_by', and 'release_date_from' are only used by the
- # container page when rendering a unit. Since they are expensive to compute, only include them for units
- # that are not being rendered on the course outline.
- if is_xblock_unit and not course_outline:
- xblock_info["edited_by"] = safe_get_username(xblock.subtree_edited_by)
- xblock_info["published_by"] = safe_get_username(xblock.published_by)
- xblock_info["currently_visible_to_students"] = is_currently_visible_to_students(xblock)
- if release_date:
- xblock_info["release_date_from"] = _get_release_date_from(xblock)
- if visibility_state == VisibilityState.staff_only:
- xblock_info["staff_lock_from"] = _get_staff_lock_from(xblock)
- else:
- xblock_info["staff_lock_from"] = None
if course_outline:
if xblock_info["has_explicit_staff_lock"]:
xblock_info["staff_only_message"] = True
@@ -844,6 +844,40 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
return xblock_info
+def add_container_page_publishing_info(xblock, xblock_info): # pylint: disable=invalid-name
+ """
+ Adds information about the xblock's publish state to the supplied
+ xblock_info for the container page.
+ """
+ def safe_get_username(user_id):
+ """
+ Guard against bad user_ids, like the infamous "**replace_user**".
+ Note that this will ignore our special known IDs (ModuleStoreEnum.UserID).
+ We should consider adding special handling for those values.
+
+ :param user_id: the user id to get the username of
+ :return: username, or None if the user does not exist or user_id is None
+ """
+ if user_id:
+ try:
+ return User.objects.get(id=user_id).username
+ except: # pylint: disable=bare-except
+ pass
+
+ return None
+
+ xblock_info["edited_by"] = safe_get_username(xblock.subtree_edited_by)
+ xblock_info["published_by"] = safe_get_username(xblock.published_by)
+ xblock_info["currently_visible_to_students"] = is_currently_visible_to_students(xblock)
+ xblock_info["has_content_group_components"] = has_children_visible_to_specific_content_groups(xblock)
+ if xblock_info["release_date"]:
+ xblock_info["release_date_from"] = _get_release_date_from(xblock)
+ if xblock_info["visibility_state"] == VisibilityState.staff_only:
+ xblock_info["staff_lock_from"] = _get_staff_lock_from(xblock)
+ else:
+ xblock_info["staff_lock_from"] = None
+
+
class VisibilityState(object):
"""
Represents the possible visibility states for an xblock:
@@ -963,6 +997,14 @@ def _create_xblock_child_info(xblock, course_outline, graders, include_children_
return child_info
+def _get_release_date(xblock):
+ """
+ Returns the release date for the xblock, or None if the release date has never been set.
+ """
+ # Treat DEFAULT_START_DATE as a magic number that means the release date has not been set
+ return get_default_time_display(xblock.start) if xblock.start != DEFAULT_START_DATE else None
+
+
def _get_release_date_from(xblock):
"""
Returns a string representation of the section or subsection that sets the xblock's release date
diff --git a/cms/djangoapps/contentstore/views/tests/test_group_configurations.py b/cms/djangoapps/contentstore/views/tests/test_group_configurations.py
index 5c0e5130d1..411c17625a 100644
--- a/cms/djangoapps/contentstore/views/tests/test_group_configurations.py
+++ b/cms/djangoapps/contentstore/views/tests/test_group_configurations.py
@@ -208,17 +208,6 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
self.assertContains(response, 'First name')
self.assertContains(response, 'Group C')
- def test_view_index_disabled(self):
- """
- Check that group configuration page is not displayed when turned off.
- """
- if SPLIT_TEST_COMPONENT_TYPE in self.course.advanced_modules:
- self.course.advanced_modules.remove(SPLIT_TEST_COMPONENT_TYPE)
- self.store.update_item(self.course, self.user.id)
-
- resp = self.client.get(self._url())
- self.assertContains(resp, "module is disabled")
-
def test_unsupported_http_accept_header(self):
"""
Test if not allowed header present in request.
diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py
index d7f8d165ec..01a84dea5c 100644
--- a/cms/djangoapps/contentstore/views/tests/test_item.py
+++ b/cms/djangoapps/contentstore/views/tests/test_item.py
@@ -18,8 +18,9 @@ from contentstore.views.component import (
component_handler, get_component_templates
)
-
-from contentstore.views.item import create_xblock_info, ALWAYS, VisibilityState, _xblock_type_and_display_name
+from contentstore.views.item import (
+ create_xblock_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 xmodule.capa_module import CapaDescriptor
@@ -116,20 +117,9 @@ class GetItemTest(ItemTest):
return resp
@ddt.data(
- # chapter explanation:
- # 1-3. get course, chapter, chapter's children,
- # 4-7. chapter's published grandchildren, chapter's draft grandchildren, published & then draft greatgrand
- # 8 compute chapter's parent
- # 9 get chapter's parent
- # 10-16. run queries 2-8 again
- # 17-19. compute seq, vert, and problem's parents (odd since it's going down; so, it knows)
- # 20-22. get course 3 times
- # 23. get chapter
- # 24. compute chapter's parent (course)
- # 25. compute course's parent (None)
- (1, 20, 20, 26, 26),
- (2, 21, 21, 29, 28),
- (3, 22, 22, 32, 30),
+ (1, 16, 14, 15, 11),
+ (2, 16, 14, 15, 11),
+ (3, 16, 14, 15, 11),
)
@ddt.unpack
def test_get_query_count(self, branching_factor, chapter_queries, section_queries, unit_queries, problem_queries):
@@ -144,6 +134,17 @@ class GetItemTest(ItemTest):
with check_mongo_calls(problem_queries):
self.client.get(reverse_usage_url('xblock_handler', self.populated_usage_keys['problem'][-1]))
+ @ddt.data(
+ (1, 26),
+ (2, 28),
+ (3, 30),
+ )
+ @ddt.unpack
+ def test_container_get_query_count(self, branching_factor, unit_queries,):
+ self.populate_course(branching_factor)
+ with check_mongo_calls(unit_queries):
+ self.client.get(reverse_usage_url('xblock_container_handler', self.populated_usage_keys['vertical'][-1]))
+
def test_get_vertical(self):
# Add a vertical
resp = self.create_xblock(category='vertical')
@@ -1403,6 +1404,7 @@ class TestXBlockInfo(ItemTest):
include_children_predicate=ALWAYS,
include_ancestor_info=True
)
+ add_container_page_publishing_info(vertical, xblock_info)
self.validate_vertical_xblock_info(xblock_info)
def test_component_xblock_info(self):
@@ -1523,10 +1525,6 @@ class TestXBlockInfo(ItemTest):
)
else:
self.assertIsNone(xblock_info.get('child_info', None))
- if xblock_info['category'] == 'vertical' and not course_outline:
- self.assertEqual(xblock_info['edited_by'], 'testuser')
- else:
- self.assertIsNone(xblock_info.get('edited_by', None))
class TestLibraryXBlockInfo(ModuleStoreTestCase):
@@ -1631,7 +1629,8 @@ class TestXBlockPublishingInfo(ItemTest):
)
if staff_only:
self._enable_staff_only(child.location)
- return child
+ # In case the staff_only state was set, return the updated xblock.
+ return modulestore().get_item(child.location)
def _get_child_xblock_info(self, xblock_info, index):
"""
@@ -1720,12 +1719,6 @@ class TestXBlockPublishingInfo(ItemTest):
"""
self._verify_xblock_info_state(xblock_info, 'has_explicit_staff_lock', expected_state, path, should_equal)
- def _verify_staff_lock_from_state(self, xblock_info, expected_state, path=None, should_equal=True):
- """
- Verify the staff_lock_from state of an item in the xblock_info.
- """
- self._verify_xblock_info_state(xblock_info, 'staff_lock_from', expected_state, path, should_equal)
-
def test_empty_chapter(self):
empty_chapter = self._create_child(self.course, 'chapter', "Empty Chapter")
xblock_info = self._get_xblock_info(empty_chapter.location)
@@ -1815,7 +1808,7 @@ class TestXBlockPublishingInfo(ItemTest):
"""
chapter = self._create_child(self.course, 'chapter', "Test Chapter", staff_only=True)
sequential = self._create_child(chapter, 'sequential', "Test Sequential")
- self._create_child(sequential, 'vertical', "Unit")
+ vertical = self._create_child(sequential, 'vertical', "Unit")
xblock_info = self._get_xblock_info(chapter.location)
self._verify_visibility_state(xblock_info, VisibilityState.staff_only)
self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.FIRST_SUBSECTION_PATH)
@@ -1825,7 +1818,9 @@ class TestXBlockPublishingInfo(ItemTest):
self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_SUBSECTION_PATH)
self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_UNIT_PATH)
- self._verify_staff_lock_from_state(xblock_info, _xblock_type_and_display_name(chapter), path=self.FIRST_UNIT_PATH)
+ vertical_info = self._get_xblock_info(vertical.location)
+ add_container_page_publishing_info(vertical, vertical_info)
+ self.assertEqual(_xblock_type_and_display_name(chapter), vertical_info["staff_lock_from"])
def test_no_staff_only_section(self):
"""
@@ -1846,7 +1841,7 @@ class TestXBlockPublishingInfo(ItemTest):
"""
chapter = self._create_child(self.course, 'chapter', "Test Chapter")
sequential = self._create_child(chapter, 'sequential', "Test Sequential", staff_only=True)
- self._create_child(sequential, 'vertical', "Unit")
+ vertical = self._create_child(sequential, 'vertical', "Unit")
xblock_info = self._get_xblock_info(chapter.location)
self._verify_visibility_state(xblock_info, VisibilityState.staff_only)
self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.FIRST_SUBSECTION_PATH)
@@ -1856,7 +1851,9 @@ class TestXBlockPublishingInfo(ItemTest):
self._verify_explicit_staff_lock_state(xblock_info, True, path=self.FIRST_SUBSECTION_PATH)
self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_UNIT_PATH)
- self._verify_staff_lock_from_state(xblock_info, _xblock_type_and_display_name(sequential), path=self.FIRST_UNIT_PATH)
+ vertical_info = self._get_xblock_info(vertical.location)
+ add_container_page_publishing_info(vertical, vertical_info)
+ self.assertEqual(_xblock_type_and_display_name(sequential), vertical_info["staff_lock_from"])
def test_no_staff_only_subsection(self):
"""
@@ -1874,7 +1871,7 @@ class TestXBlockPublishingInfo(ItemTest):
def test_staff_only_unit(self):
chapter = self._create_child(self.course, 'chapter', "Test Chapter")
sequential = self._create_child(chapter, 'sequential', "Test Sequential")
- unit = self._create_child(sequential, 'vertical', "Unit", staff_only=True)
+ vertical = self._create_child(sequential, 'vertical', "Unit", staff_only=True)
xblock_info = self._get_xblock_info(chapter.location)
self._verify_visibility_state(xblock_info, VisibilityState.staff_only)
self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.FIRST_SUBSECTION_PATH)
@@ -1884,7 +1881,9 @@ class TestXBlockPublishingInfo(ItemTest):
self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_SUBSECTION_PATH)
self._verify_explicit_staff_lock_state(xblock_info, True, path=self.FIRST_UNIT_PATH)
- self._verify_staff_lock_from_state(xblock_info, _xblock_type_and_display_name(unit), path=self.FIRST_UNIT_PATH)
+ vertical_info = self._get_xblock_info(vertical.location)
+ add_container_page_publishing_info(vertical, vertical_info)
+ self.assertEqual(_xblock_type_and_display_name(vertical), vertical_info["staff_lock_from"])
def test_unscheduled_section_with_live_subsection(self):
chapter = self._create_child(self.course, 'chapter', "Test Chapter")
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 04dc2b699a..30657f0a5a 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -37,6 +37,7 @@ from path import path
from warnings import simplefilter
from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin
+from cms.lib.xblock.authoring_mixin import AuthoringMixin
import dealer.git
from xmodule.modulestore.edit_info import EditInfoMixin
@@ -269,7 +270,13 @@ from xmodule.x_module import XModuleMixin
# This should be moved into an XBlock Runtime/Application object
# once the responsibility of XBlock creation is moved out of modulestore - cpennington
-XBLOCK_MIXINS = (LmsBlockMixin, InheritanceMixin, XModuleMixin, EditInfoMixin)
+XBLOCK_MIXINS = (
+ LmsBlockMixin,
+ InheritanceMixin,
+ XModuleMixin,
+ EditInfoMixin,
+ AuthoringMixin,
+)
# Allow any XBlock in Studio
# You should also enable the ALLOW_ALL_ADVANCED_COMPONENTS feature flag, so that
diff --git a/cms/lib/xblock/authoring_mixin.py b/cms/lib/xblock/authoring_mixin.py
new file mode 100644
index 0000000000..a4bbf472e8
--- /dev/null
+++ b/cms/lib/xblock/authoring_mixin.py
@@ -0,0 +1,49 @@
+"""
+Mixin class that provides authoring capabilities for XBlocks.
+"""
+
+import logging
+
+from xblock.core import XBlock
+from xblock.fields import XBlockMixin
+from xblock.fragment import Fragment
+
+log = logging.getLogger(__name__)
+
+VISIBILITY_VIEW = 'visibility_view'
+
+
+@XBlock.needs("i18n")
+class AuthoringMixin(XBlockMixin):
+ """
+ Mixin class that provides authoring capabilities for XBlocks.
+ """
+ _services_requested = {
+ 'i18n': 'need',
+ }
+
+ def _get_studio_resource_url(self, relative_url):
+ """
+ Returns the Studio URL to a static resource.
+ """
+ # TODO: is there a cleaner way to do this?
+ from cms.envs.common import STATIC_URL
+ return STATIC_URL + relative_url
+
+ def visibility_view(self, _context=None):
+ """
+ Render the view to manage an xblock's visibility settings in Studio.
+ Args:
+ _context: Not actively used for this view.
+ Returns:
+ (Fragment): An HTML fragment for editing the visibility of this XBlock.
+ """
+ fragment = Fragment()
+ from contentstore.utils import reverse_course_url
+ fragment.add_content(self.system.render_template('visibility_editor.html', {
+ 'xblock': self,
+ 'manage_groups_url': reverse_course_url('group_configurations_list_handler', self.location.course_key),
+ }))
+ fragment.add_javascript_url(self._get_studio_resource_url('/js/xblock/authoring.js'))
+ fragment.initialize_js('VisibilityEditorInit')
+ return fragment
diff --git a/cms/lib/xblock/test/test_authoring_mixin.py b/cms/lib/xblock/test/test_authoring_mixin.py
new file mode 100644
index 0000000000..574c5c761f
--- /dev/null
+++ b/cms/lib/xblock/test/test_authoring_mixin.py
@@ -0,0 +1,121 @@
+from openedx.core.djangoapps.course_groups.partition_scheme import CohortPartitionScheme
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
+from xmodule.partitions.partitions import Group, UserPartition
+
+
+class AuthoringMixinTestCase(ModuleStoreTestCase):
+ """
+ Tests the studio authoring XBlock mixin.
+ """
+ def setUp(self):
+ """
+ Create a simple course with a video component.
+ """
+ super(AuthoringMixinTestCase, self).setUp()
+ self.course = CourseFactory.create()
+ chapter = ItemFactory.create(
+ category='chapter',
+ parent_location=self.course.location,
+ display_name='Test Chapter'
+ )
+ sequential = ItemFactory.create(
+ category='sequential',
+ parent_location=chapter.location,
+ display_name='Test Sequential'
+ )
+ self.vertical = ItemFactory.create(
+ category='vertical',
+ parent_location=sequential.location,
+ display_name='Test Vertical'
+ )
+ self.video = ItemFactory.create(
+ category='video',
+ parent_location=self.vertical.location,
+ display_name='Test Vertical'
+ )
+ self.pet_groups = [Group(1, 'Cat Lovers'), Group(2, 'Dog Lovers')]
+
+ def create_cohorted_content_groups(self, groups):
+ """
+ Create a cohorted content partition with specified groups.
+ """
+ self.content_partition = UserPartition(
+ 1,
+ 'Content Groups',
+ 'Contains Groups for Cohorted Courseware',
+ groups,
+ scheme_id='cohort'
+ )
+ self.course.user_partitions = [self.content_partition]
+ self.store.update_item(self.course, self.user.id)
+
+ def set_staff_only(self, item):
+ """Make an item visible to staff only."""
+ item.visible_to_staff_only = True
+ self.store.update_item(item, self.user.id)
+
+ def set_group_access(self, item, group_ids):
+ """
+ Set group_access for the specified item to the specified group
+ ids within the content partition.
+ """
+ item.group_access[self.content_partition.id] = group_ids
+ self.store.update_item(item, self.user.id)
+
+ def verify_visibility_view_contains(self, item, substrings):
+ """
+ Verify that an item's visibility view returns an html string
+ containing all the expected substrings.
+ """
+ html = item.visibility_view().body_html()
+ for string in substrings:
+ self.assertIn(string, html)
+
+ def test_html_no_partition(self):
+ self.verify_visibility_view_contains(self.video, 'You have not set up any groups to manage visibility with.')
+
+ def test_html_empty_partition(self):
+ self.create_cohorted_content_groups([])
+ self.verify_visibility_view_contains(self.video, 'You have not set up any groups to manage visibility with.')
+
+ def test_html_populated_partition(self):
+ self.create_cohorted_content_groups(self.pet_groups)
+ self.verify_visibility_view_contains(self.video, ['Cat Lovers', 'Dog Lovers'])
+
+ def test_html_no_partition_staff_locked(self):
+ self.set_staff_only(self.vertical)
+ self.verify_visibility_view_contains(self.video, ['You have not set up any groups to manage visibility with.'])
+
+ def test_html_empty_partition_staff_locked(self):
+ self.create_cohorted_content_groups([])
+ self.set_staff_only(self.vertical)
+ self.verify_visibility_view_contains(self.video, 'You have not set up any groups to manage visibility with.')
+
+ def test_html_populated_partition_staff_locked(self):
+ self.create_cohorted_content_groups(self.pet_groups)
+ self.set_staff_only(self.vertical)
+ self.verify_visibility_view_contains(
+ self.video, ['The Unit this component is contained in is hidden from students.', 'Cat Lovers', 'Dog Lovers']
+ )
+
+ def test_html_false_content_group(self):
+ self.create_cohorted_content_groups(self.pet_groups)
+ self.set_group_access(self.video, ['false_group_id'])
+ self.verify_visibility_view_contains(
+ self.video, ['Cat Lovers', 'Dog Lovers', 'Content group no longer exists.']
+ )
+
+ def test_html_false_content_group_staff_locked(self):
+ self.create_cohorted_content_groups(self.pet_groups)
+ self.set_staff_only(self.vertical)
+ self.set_group_access(self.video, ['false_group_id'])
+ self.verify_visibility_view_contains(
+ self.video,
+ [
+ 'Cat Lovers',
+ 'Dog Lovers',
+ 'The Unit this component is contained in is hidden from students.',
+ 'Content group no longer exists.'
+ ]
+ )
diff --git a/cms/static/coffee/src/views/module_edit.coffee b/cms/static/coffee/src/views/module_edit.coffee
index b839ade446..b04495f8c5 100644
--- a/cms/static/coffee/src/views/module_edit.coffee
+++ b/cms/static/coffee/src/views/module_edit.coffee
@@ -52,7 +52,5 @@ define ["jquery", "underscore", "gettext", "xblock/runtime.v1",
clickEditButton: (event) ->
event.preventDefault()
- modal = new EditXBlockModal({
- view: 'student_view'
- });
+ modal = new EditXBlockModal();
modal.edit(this.$el, self.model, { refresh: _.bind(@render, this) })
diff --git a/cms/static/js/factories/container.js b/cms/static/js/factories/container.js
index 429ae58f51..6593c25fb3 100644
--- a/cms/static/js/factories/container.js
+++ b/cms/static/js/factories/container.js
@@ -1,14 +1,14 @@
define([
- 'jquery', 'underscore', 'js/models/xblock_info', 'js/views/pages/container',
+ 'jquery', 'underscore', 'js/models/xblock_container_info', 'js/views/pages/container',
'js/collections/component_template', 'xmodule', 'coffee/src/main',
'xblock/cms.runtime.v1'
],
-function($, _, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
+function($, _, XBlockContainerInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
'use strict';
return function (componentTemplates, XBlockInfoJson, action, options) {
var main_options = {
el: $('#content'),
- model: new XBlockInfo(XBlockInfoJson, {parse: true}),
+ model: new XBlockContainerInfo(XBlockInfoJson, {parse: true}),
action: action,
templates: new ComponentTemplates(componentTemplates, {parse: true})
};
diff --git a/cms/static/js/models/custom_sync_xblock_info.js b/cms/static/js/models/custom_sync_xblock_info.js
new file mode 100644
index 0000000000..da3b6b0c7a
--- /dev/null
+++ b/cms/static/js/models/custom_sync_xblock_info.js
@@ -0,0 +1,10 @@
+define(["js/models/xblock_info"],
+ function(XBlockInfo) {
+ var CustomSyncXBlockInfo = XBlockInfo.extend({
+ sync: function(method, model, options) {
+ options.url = (this.urlRoots[method] || this.urlRoot) + '/' + this.get('id');
+ return XBlockInfo.prototype.sync.call(this, method, model, options);
+ }
+ });
+ return CustomSyncXBlockInfo;
+ });
diff --git a/cms/static/js/models/xblock_container_info.js b/cms/static/js/models/xblock_container_info.js
new file mode 100644
index 0000000000..f60f491d38
--- /dev/null
+++ b/cms/static/js/models/xblock_container_info.js
@@ -0,0 +1,9 @@
+define(["js/models/custom_sync_xblock_info"],
+ function(CustomSyncXBlockInfo) {
+ var XBlockContainerInfo = CustomSyncXBlockInfo.extend({
+ urlRoots: {
+ 'read': '/xblock/container'
+ }
+ });
+ return XBlockContainerInfo;
+ });
diff --git a/cms/static/js/models/xblock_info.js b/cms/static/js/models/xblock_info.js
index d2324313b5..4e643f7d5f 100644
--- a/cms/static/js/models/xblock_info.js
+++ b/cms/static/js/models/xblock_info.js
@@ -32,7 +32,8 @@ function(Backbone, _, str, ModuleUtils) {
*/
'edited_on':null,
/**
- * User who last edited the xblock or any of its descendants.
+ * User who last edited the xblock or any of its descendants. Will only be present if
+ * publishing info was explicitly requested.
*/
'edited_by':null,
/**
@@ -44,7 +45,8 @@ function(Backbone, _, str, ModuleUtils) {
*/
'published_on': null,
/**
- * User who last published the xblock, or null if never published.
+ * User who last published the xblock, or null if never published. Will only be present if
+ * publishing info was explicitly requested.
*/
'published_by': null,
/**
@@ -70,12 +72,14 @@ function(Backbone, _, str, ModuleUtils) {
/**
* The xblock which is determining the release date. For instance, for a unit,
* this will either be the parent subsection or the grandparent section.
- * This can be null if the release date is unscheduled.
+ * This can be null if the release date is unscheduled. Will only be present if
+ * publishing info was explicitly requested.
*/
'release_date_from':null,
/**
* True if this xblock is currently visible to students. This is computed server-side
- * so that the logic isn't duplicated on the client.
+ * so that the logic isn't duplicated on the client. Will only be present if
+ * publishing info was explicitly requested.
*/
'currently_visible_to_students': null,
/**
@@ -114,13 +118,20 @@ function(Backbone, _, str, ModuleUtils) {
/**
* The xblock which is determining the staff lock value. For instance, for a unit,
* this will either be the parent subsection or the grandparent section.
- * This can be null if the xblock has no inherited staff lock.
+ * This can be null if the xblock has no inherited staff lock. Will only be present if
+ * publishing info was explicitly requested.
*/
'staff_lock_from': null,
/**
* True iff this xblock should display a "Contains staff only content" message.
*/
- 'staff_only_message': null
+ 'staff_only_message': null,
+ /**
+ * True iff this xblock is a unit, and it has children that are only visible to certain
+ * content groups. Note that this is not a recursive property. Will only be present if
+ * publishing info was explicitly requested.
+ */
+ 'has_content_group_components': null
},
initialize: function () {
diff --git a/cms/static/js/models/xblock_outline_info.js b/cms/static/js/models/xblock_outline_info.js
index da34adb219..b90001c98e 100644
--- a/cms/static/js/models/xblock_outline_info.js
+++ b/cms/static/js/models/xblock_outline_info.js
@@ -1,6 +1,6 @@
-define(["js/models/xblock_info"],
- function(XBlockInfo) {
- var XBlockOutlineInfo = XBlockInfo.extend({
+define(["js/models/custom_sync_xblock_info"],
+ function(CustomSyncXBlockInfo) {
+ var XBlockOutlineInfo = CustomSyncXBlockInfo.extend({
urlRoots: {
'read': '/xblock/outline'
@@ -8,15 +8,6 @@ define(["js/models/xblock_info"],
createChild: function(response) {
return new XBlockOutlineInfo(response, { parse: true });
- },
-
- sync: function(method, model, options) {
- var urlRoot = this.urlRoots[method];
- if (!urlRoot) {
- urlRoot = this.urlRoot;
- }
- options.url = urlRoot + '/' + this.get('id');
- return XBlockInfo.prototype.sync.call(this, method, model, options);
}
});
return XBlockOutlineInfo;
diff --git a/cms/static/js/spec/views/pages/container_spec.js b/cms/static/js/spec/views/pages/container_spec.js
index 7ef32bb7b7..d311bc32b7 100644
--- a/cms/static/js/spec/views/pages/container_spec.js
+++ b/cms/static/js/spec/views/pages/container_spec.js
@@ -1,6 +1,6 @@
define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_helpers",
"js/common_helpers/template_helpers", "js/spec_helpers/edit_helpers",
- "js/views/pages/container", "js/views/pages/paged_container", "js/models/xblock_info"],
+ "js/views/pages/container", "js/views/pages/paged_container", "js/models/xblock_info", "jquery.simulate"],
function ($, _, str, AjaxHelpers, TemplateHelpers, EditHelpers, ContainerPage, PagedContainerPage, XBlockInfo) {
function parameterized_suite(label, global_page_options, fixtures) {
@@ -14,6 +14,7 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
mockBadXBlockContainerXBlockHtml = readFixtures('mock/mock-bad-xblock-container-xblock.underscore'),
mockUpdatedContainerXBlockHtml = readFixtures('mock/mock-updated-container-xblock.underscore'),
mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore'),
+ mockXBlockVisibilityEditorHtml = readFixtures('mock/mock-xblock-visibility-editor.underscore'),
PageClass = fixtures.page;
beforeEach(function () {
@@ -219,6 +220,21 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
});
expect(EditHelpers.isShowingModal()).toBeTruthy();
});
+
+ it('can show a visibility modal for a child xblock', function() {
+ var visibilityButtons;
+ renderContainerPage(this, mockContainerXBlockHtml);
+ visibilityButtons = containerPage.$('.wrapper-xblock .visibility-button');
+ expect(visibilityButtons.length).toBe(6);
+ visibilityButtons[0].click();
+ expect(str.startsWith(lastRequest().url, '/xblock/locator-component-A1/visibility_view'))
+ .toBeTruthy();
+ AjaxHelpers.respondWithJson(requests, {
+ html: mockXBlockVisibilityEditorHtml,
+ resources: []
+ });
+ expect(EditHelpers.isShowingModal()).toBeTruthy();
+ });
});
describe("Editing an xmodule", function () {
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 2809c779a4..8ac10470ed 100644
--- a/cms/static/js/spec/views/pages/container_subviews_spec.js
+++ b/cms/static/js/spec/views/pages/container_subviews_spec.js
@@ -80,7 +80,8 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
describe("PreviewActionController", function () {
var viewPublishedCss = '.button-view',
- previewCss = '.button-preview';
+ previewCss = '.button-preview',
+ visibilityNoteCss = '.note-visibility';
it('renders correctly for unscheduled unit', function () {
renderContainerPage(this, mockContainerXBlockHtml);
@@ -109,6 +110,18 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
fetch({published: false, has_changes: false});
expect(containerPage.$(previewCss)).not.toHaveClass(disabledCss);
});
+
+ it('updates when has_content_group_components attribute changes', function () {
+ renderContainerPage(this, mockContainerXBlockHtml);
+ fetch({has_content_group_components: false});
+ expect(containerPage.$(visibilityNoteCss).length).toBe(0);
+
+ fetch({has_content_group_components: true});
+ expect(containerPage.$(visibilityNoteCss).length).toBe(1);
+
+ fetch({has_content_group_components: false});
+ expect(containerPage.$(visibilityNoteCss).length).toBe(0);
+ });
});
describe("Publisher", function () {
diff --git a/cms/static/js/spec/views/xblock_editor_spec.js b/cms/static/js/spec/views/xblock_editor_spec.js
index 59116c603b..d681db4b5e 100644
--- a/cms/static/js/spec/views/xblock_editor_spec.js
+++ b/cms/static/js/spec/views/xblock_editor_spec.js
@@ -85,7 +85,7 @@ define([ "jquery", "underscore", "js/common_helpers/ajax_helpers", "js/spec_help
});
// Give the mock xblock a save method...
editor.xblock.save = window.MockDescriptor.save;
- editor.model.save(editor.getXModuleData());
+ editor.model.save(editor.getXBlockFieldData());
request = requests[requests.length - 1];
response = JSON.parse(request.requestBody);
expect(response.metadata.display_name).toBe(testDisplayName);
diff --git a/cms/static/js/views/metadata.js b/cms/static/js/views/metadata.js
index f70f77b745..3159ada436 100644
--- a/cms/static/js/views/metadata.js
+++ b/cms/static/js/views/metadata.js
@@ -49,7 +49,7 @@ function(BaseView, _, MetadataModel, AbstractEditor, FileUpload, UploadDialog, V
},
/**
- * Returns the just the modified metadata values, in the format used to persist to the server.
+ * Returns just the modified metadata values, in the format used to persist to the server.
*/
getModifiedMetadataValues: function () {
var modified_values = {};
diff --git a/cms/static/js/views/modals/base_modal.js b/cms/static/js/views/modals/base_modal.js
index eb543295ed..fb02299484 100644
--- a/cms/static/js/views/modals/base_modal.js
+++ b/cms/static/js/views/modals/base_modal.js
@@ -1,5 +1,23 @@
/**
* This is a base modal implementation that provides common utilities.
+ *
+ * A modal implementation should override the following methods:
+ *
+ * getTitle():
+ * returns the title for the modal.
+ * getHTMLContent():
+ * returns the HTML content to be shown inside the modal.
+ *
+ * A modal implementation should also provide the following options:
+ *
+ * modalName: A string identifying the modal.
+ * modalType: A string identifying the type of the modal.
+ * modalSize: A string, either 'sm', 'med', or 'lg' indicating the
+ * size of the modal.
+ * viewSpecificClasses: A string of CSS classes to be attached to
+ * the modal window.
+ * addSaveButton: A boolean indicating whether to include a save
+ * button on the modal.
*/
define(["jquery", "underscore", "gettext", "js/views/baseview"],
function($, _, gettext, BaseView) {
@@ -41,7 +59,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
name: this.options.modalName,
type: this.options.modalType,
size: this.options.modalSize,
- title: this.options.title,
+ title: this.getTitle(),
viewSpecificClasses: this.options.viewSpecificClasses
}));
this.addActionButtons();
@@ -49,6 +67,10 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
this.parentElement.append(this.$el);
},
+ getTitle: function() {
+ return this.options.title;
+ },
+
renderContents: function() {
var contentHtml = this.getContentHtml();
this.$('.modal-content').html(contentHtml);
diff --git a/cms/static/js/views/modals/edit_xblock.js b/cms/static/js/views/modals/edit_xblock.js
index 65d97ad636..104ef571cb 100644
--- a/cms/static/js/views/modals/edit_xblock.js
+++ b/cms/static/js/views/modals/edit_xblock.js
@@ -6,6 +6,8 @@
define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/views/utils/view_utils",
"js/models/xblock_info", "js/views/xblock_editor"],
function($, _, gettext, BaseModal, ViewUtils, XBlockInfo, XBlockEditorView) {
+ "strict mode";
+
var EditXBlockModal = BaseModal.extend({
events : {
"click .action-save": "save",
@@ -15,7 +17,10 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
options: $.extend({}, BaseModal.prototype.options, {
modalName: 'edit-xblock',
addSaveButton: true,
- viewSpecificClasses: 'modal-editor confirm'
+ view: 'studio_view',
+ viewSpecificClasses: 'modal-editor confirm',
+ // Translators: "title" is the name of the current component being edited.
+ titleFormat: gettext("Editing: %(title)s")
}),
initialize: function() {
@@ -56,7 +61,8 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
displayXBlock: function() {
this.editorView = new XBlockEditorView({
el: this.$('.xblock-editor'),
- model: this.xblockInfo
+ model: this.xblockInfo,
+ view: this.options.view
});
this.editorView.render({
success: _.bind(this.onDisplayXBlock, this)
@@ -66,7 +72,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
onDisplayXBlock: function() {
var editorView = this.editorView,
title = this.getTitle(),
- readOnlyView = (this.editOptions && this.editOptions.readOnlyView) || !editorView.xblock.save;
+ readOnlyView = (this.editOptions && this.editOptions.readOnlyView) || !this.canSave();
// Notify the runtime that the modal has been shown
editorView.notifyRuntime('modal-shown', this);
@@ -99,6 +105,10 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
this.resize();
},
+ canSave: function() {
+ return this.editorView.xblock.save || this.editorView.xblock.collectFieldData;
+ },
+
disableSave: function() {
var saveButton = this.getActionButton('save'),
cancelButton = this.getActionButton('cancel');
@@ -112,7 +122,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
if (!displayName) {
displayName = gettext('Component');
}
- return interpolate(gettext("Editing: %(title)s"), { title: displayName }, true);
+ return interpolate(this.options.titleFormat, { title: displayName }, true);
},
addDefaultModes: function() {
@@ -147,7 +157,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
var self = this,
editorView = this.editorView,
xblockInfo = this.xblockInfo,
- data = editorView.getXModuleData();
+ data = editorView.getXBlockFieldData();
event.preventDefault();
if (data) {
ViewUtils.runOperationShowingMessage(gettext('Saving'),
diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js
index 71619551cd..45ef51a75d 100644
--- a/cms/static/js/views/pages/container.js
+++ b/cms/static/js/views/pages/container.js
@@ -15,6 +15,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
events: {
"click .edit-button": "editXBlock",
+ "click .visibility-button": "editVisibilitySettings",
"click .duplicate-button": "duplicateXBlock",
"click .delete-button": "deleteXBlock",
"click .new-component-button": "scrollToNewComponentButtons"
@@ -161,10 +162,10 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
}
},
- editXBlock: function(event) {
+ editXBlock: function(event, options) {
var xblockElement = this.findXBlockElement(event.target),
self = this,
- modal = new EditXBlockModal({ });
+ modal = new EditXBlockModal(options);
event.preventDefault();
modal.edit(xblockElement, this.model, {
@@ -175,6 +176,16 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
});
},
+ editVisibilitySettings: function(event) {
+ this.editXBlock(event, {
+ view: 'visibility_view',
+ // Translators: "title" is the name of the current component being edited.
+ titleFormat: gettext("Editing visibility for: %(title)s"),
+ viewSpecificClasses: '',
+ modalSize: 'med'
+ });
+ },
+
duplicateXBlock: function(event) {
event.preventDefault();
this.duplicateComponent(this.findXBlockElement(event.target));
diff --git a/cms/static/js/views/pages/container_subviews.js b/cms/static/js/views/pages/container_subviews.js
index df77759876..441a4d6860 100644
--- a/cms/static/js/views/pages/container_subviews.js
+++ b/cms/static/js/views/pages/container_subviews.js
@@ -100,7 +100,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/
onSync: function(model) {
if (ViewUtils.hasChangedAttributes(model, [
- 'has_changes', 'published', 'edited_on', 'edited_by', 'visibility_state', 'has_explicit_staff_lock'
+ 'has_changes', 'published', 'edited_on', 'edited_by', 'visibility_state',
+ 'has_explicit_staff_lock', 'has_content_group_components'
])) {
this.render();
}
@@ -120,7 +121,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/
releaseDate: this.model.get('release_date'),
releaseDateFrom: this.model.get('release_date_from'),
hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'),
- staffLockFrom: this.model.get('staff_lock_from')
+ staffLockFrom: this.model.get('staff_lock_from'),
+ hasContentGroupComponents: this.model.get('has_content_group_components')
}));
return this;
diff --git a/cms/static/js/views/pages/course_outline.js b/cms/static/js/views/pages/course_outline.js
index a812eb7b02..4e815fd08b 100644
--- a/cms/static/js/views/pages/course_outline.js
+++ b/cms/static/js/views/pages/course_outline.js
@@ -26,7 +26,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
});
this.model.on('change', this.setCollapseExpandVisibility, this);
$('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () {
- $('.wrapper-alert-announcement').removeClass('is-shown').addClass('is-hidden')
+ $('.wrapper-alert-announcement').removeClass('is-shown').addClass('is-hidden');
}));
},
diff --git a/cms/static/js/views/xblock_editor.js b/cms/static/js/views/xblock_editor.js
index 7cdf2de34a..e549fa6b54 100644
--- a/cms/static/js/views/xblock_editor.js
+++ b/cms/static/js/views/xblock_editor.js
@@ -89,17 +89,23 @@ define(["jquery", "underscore", "gettext", "js/views/xblock", "js/views/metadata
},
/**
- * Returns the data saved for the xmodule. Note that this *does not* work for XBlocks.
+ * Returns the updated field data for the xblock. Note that this works for all
+ * XModules as well as for XBlocks that provide a 'collectFieldData' API.
*/
- getXModuleData: function() {
+ getXBlockFieldData: function() {
var xblock = this.xblock,
metadataEditor = this.getMetadataEditor(),
data = null;
- if (xblock.save) {
+ // If the xblock supports returning its field data then collect it
+ if (xblock.collectFieldData) {
+ data = xblock.collectFieldData();
+ // ... else if this is an XModule then call its save method
+ } else if (xblock.save) {
data = xblock.save();
if (metadataEditor) {
data.metadata = _.extend(data.metadata || {}, this.getChangedMetadata());
}
+ // ... else log an error
} else {
console.error('Cannot save xblock as it has no save method');
}
diff --git a/cms/static/js/xblock/authoring.js b/cms/static/js/xblock/authoring.js
new file mode 100644
index 0000000000..dfc8e7cc0d
--- /dev/null
+++ b/cms/static/js/xblock/authoring.js
@@ -0,0 +1,48 @@
+/**
+ * Client-side logic to support XBlock authoring.
+ */
+(function($) {
+ 'use strict';
+
+ function VisibilityEditorView(runtime, element) {
+ this.getGroupAccess = function() {
+ var groupAccess, userPartitionId, selectedGroupIds;
+ if (element.find('.visibility-level-all').prop('checked')) {
+ return {};
+ }
+ userPartitionId = element.find('.wrapper-visibility-specific').data('user-partition-id').toString();
+ selectedGroupIds = [];
+ element.find('.field-visibility-content-group input:checked').each(function(index, input) {
+ selectedGroupIds.push(parseInt($(input).val()));
+ });
+ groupAccess = {};
+ groupAccess[userPartitionId] = selectedGroupIds;
+ return groupAccess;
+ };
+
+ element.find('.field-visibility-level input').change(function(event) {
+ if ($(event.target).hasClass('visibility-level-all')) {
+ element.find('.field-visibility-content-group input').prop('checked', false);
+ }
+ });
+ element.find('.field-visibility-content-group input').change(function(event) {
+ element.find('.visibility-level-all').prop('checked', false);
+ element.find('.visibility-level-specific').prop('checked', true);
+ });
+ }
+
+ VisibilityEditorView.prototype.collectFieldData = function collectFieldData() {
+ return {
+ metadata: {
+ "group_access": this.getGroupAccess()
+ }
+ };
+ };
+
+ function initializeVisibilityEditor(runtime, element) {
+ return new VisibilityEditorView(runtime, element);
+ }
+
+ // XBlock initialization functions must be global
+ window.VisibilityEditorInit = initializeVisibilityEditor;
+})($);
diff --git a/cms/static/sass/elements/_xblocks.scss b/cms/static/sass/elements/_xblocks.scss
index 823694201c..186b78a9e6 100644
--- a/cms/static/sass/elements/_xblocks.scss
+++ b/cms/static/sass/elements/_xblocks.scss
@@ -189,8 +189,8 @@
}
}
- // CASE: xblock has specific visibility set
- &.has-visibility-set {
+ // CASE: xblock has specific visibility based on content groups set
+ &.has-group-visibility-set {
.action-visibility .visibility-button.visibility-button { // needed to cascade in front of overscoped header-actions CSS rule
color: $color-visibility-set;
diff --git a/cms/templates/container.html b/cms/templates/container.html
index fe8b2952b7..2bcd978ba3 100644
--- a/cms/templates/container.html
+++ b/cms/templates/container.html
@@ -144,8 +144,3 @@ from django.utils.translation import ugettext as _
%block>
-
-
-<%block name="modal_placeholder">
- <%include file="ux/reference/modal_access-component.html" />
-%block>
diff --git a/cms/templates/group_configurations.html b/cms/templates/group_configurations.html
index 460565d94e..3645463379 100644
--- a/cms/templates/group_configurations.html
+++ b/cms/templates/group_configurations.html
@@ -47,17 +47,9 @@
- % if configurations is None:
-
-
- ${_("This module is disabled at the moment.")}
-
-
- % else:
- % endif