Studio support for cohorted courseware
TNL-652
This commit is contained in:
@@ -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="<problem></problem>"
|
||||
)
|
||||
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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
49
cms/lib/xblock/authoring_mixin.py
Normal file
49
cms/lib/xblock/authoring_mixin.py
Normal file
@@ -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
|
||||
121
cms/lib/xblock/test/test_authoring_mixin.py
Normal file
121
cms/lib/xblock/test/test_authoring_mixin.py
Normal file
@@ -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.'
|
||||
]
|
||||
)
|
||||
@@ -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) })
|
||||
|
||||
@@ -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})
|
||||
};
|
||||
|
||||
10
cms/static/js/models/custom_sync_xblock_info.js
Normal file
10
cms/static/js/models/custom_sync_xblock_info.js
Normal file
@@ -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;
|
||||
});
|
||||
9
cms/static/js/models/xblock_container_info.js
Normal file
9
cms/static/js/models/xblock_container_info.js
Normal file
@@ -0,0 +1,9 @@
|
||||
define(["js/models/custom_sync_xblock_info"],
|
||||
function(CustomSyncXBlockInfo) {
|
||||
var XBlockContainerInfo = CustomSyncXBlockInfo.extend({
|
||||
urlRoots: {
|
||||
'read': '/xblock/container'
|
||||
}
|
||||
});
|
||||
return XBlockContainerInfo;
|
||||
});
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}));
|
||||
},
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
48
cms/static/js/xblock/authoring.js
Normal file
48
cms/static/js/xblock/authoring.js
Normal file
@@ -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;
|
||||
})($);
|
||||
@@ -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;
|
||||
|
||||
@@ -144,8 +144,3 @@ from django.utils.translation import ugettext as _
|
||||
</div>
|
||||
</div>
|
||||
</%block>
|
||||
|
||||
<!-- NOTE: remove this HTML if you want to not see the fake visibility modal -->
|
||||
<%block name="modal_placeholder">
|
||||
<%include file="ux/reference/modal_access-component.html" />
|
||||
</%block>
|
||||
|
||||
@@ -47,17 +47,9 @@
|
||||
<div class="wrapper-content wrapper">
|
||||
<section class="content">
|
||||
<article class="content-primary" role="main">
|
||||
% if configurations is None:
|
||||
<div class="notice notice-incontext notice-moduledisabled">
|
||||
<p class="copy">
|
||||
${_("This module is disabled at the moment.")}
|
||||
</p>
|
||||
</div>
|
||||
% else:
|
||||
<div class="ui-loading">
|
||||
<p><span class="spin"><i class="icon fa fa-refresh"></i></span> <span class="copy">${_("Loading")}</span></p>
|
||||
</div>
|
||||
% endif
|
||||
</article>
|
||||
<aside class="content-supplementary" role="complementary">
|
||||
<div class="bit">
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-visibility">
|
||||
<a href="#" class="visibility-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
@@ -71,6 +74,9 @@
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-visibility">
|
||||
<a href="#" class="visibility-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
@@ -96,6 +102,9 @@
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-visibility">
|
||||
<a href="#" class="visibility-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
@@ -151,6 +160,9 @@
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-visibility">
|
||||
<a href="#" class="visibility-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
@@ -176,6 +188,9 @@
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-visibility">
|
||||
<a href="#" class="visibility-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
@@ -201,6 +216,9 @@
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-visibility">
|
||||
<a href="#" class="visibility-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<div class="xblock xblock-visibility_view" data-runtime-version="1" data-usage-id="i4x:;_;_edX;_mock"
|
||||
data-init="MockXBlock" data-runtime-class="StudioRuntime" tabindex="0">
|
||||
</div>
|
||||
@@ -77,13 +77,12 @@ var visibleToStaffOnly = visibilityState === 'staff_only';
|
||||
<% } else { %>
|
||||
<p class="visbility-copy copy"><%= gettext("Staff and Students") %></p>
|
||||
<% } %>
|
||||
|
||||
<!-- NOTE: @andyarmstrong, here's the new copy we're adding to the visibility summary UI to make sure we're as accurate as we can be at the final state of the unit/container's visibility -->
|
||||
<p class="note-visibility">
|
||||
<i class="icon icon-eye-open"></i>
|
||||
<span class="note-copy"><%= gettext("Some content in this unit is only visible to particular groups") %></span>
|
||||
</p>
|
||||
|
||||
<% if (hasContentGroupComponents) { %>
|
||||
<p class="note-visibility">
|
||||
<i class="icon icon-eye-open"></i>
|
||||
<span class="note-copy"><%= gettext("Some content in this unit is only visible to particular groups") %></span>
|
||||
</p>
|
||||
<% } %>
|
||||
<ul class="actions-inline">
|
||||
<li class="action-inline">
|
||||
<a href="" class="action-staff-lock" role="button" aria-pressed="<%= hasExplicitStaffLock %>">
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from contentstore import utils
|
||||
from contentstore.views.course import should_show_group_configurations_page
|
||||
import urllib
|
||||
%>
|
||||
|
||||
@@ -333,9 +332,7 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
|
||||
<ul>
|
||||
<li class="nav-item"><a href="${grading_config_url}">${_("Grading")}</a></li>
|
||||
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
|
||||
% if should_show_group_configurations_page(context_course):
|
||||
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
|
||||
% endif
|
||||
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
|
||||
<li class="nav-item"><a href="${advanced_config_url}">${_("Advanced Settings")}</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from contentstore import utils
|
||||
from contentstore.views.course import should_show_group_configurations_page
|
||||
from django.utils.html import escapejs
|
||||
%>
|
||||
<%block name="title">${_("Advanced Settings")}</%block>
|
||||
@@ -92,9 +91,7 @@
|
||||
<li class="nav-item"><a href="${details_url}">${_("Details & Schedule")}</a></li>
|
||||
<li class="nav-item"><a href="${grading_url}">${_("Grading")}</a></li>
|
||||
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
|
||||
% if should_show_group_configurations_page(context_course):
|
||||
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
|
||||
% endif
|
||||
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
% endif
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%!
|
||||
from contentstore import utils
|
||||
from contentstore.views.course import should_show_group_configurations_page
|
||||
from django.utils.translation import ugettext as _
|
||||
%>
|
||||
|
||||
@@ -135,9 +134,7 @@
|
||||
<ul>
|
||||
<li class="nav-item"><a href="${detailed_settings_url}">${_("Details & Schedule")}</a></li>
|
||||
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
|
||||
% if should_show_group_configurations_page(context_course):
|
||||
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
|
||||
% endif
|
||||
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
|
||||
<li class="nav-item"><a href="${advanced_settings_url}">${_("Advanced Settings")}</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from contentstore.views.helpers import xblock_studio_url
|
||||
from contentstore.utils import is_visible_to_specific_content_groups
|
||||
import json
|
||||
%>
|
||||
<%
|
||||
@@ -38,8 +39,11 @@ messages = json.dumps(xblock.validate().to_json())
|
||||
<div class="studio-xblock-wrapper" data-locator="${xblock.location | h}" data-course-key="${xblock.location.course_key | h}">
|
||||
% endif
|
||||
|
||||
<!-- NOTE: @andyarmstrong, in order to style the case when an access level is set, we need to add a class to each xblock's wrapper. How does .has-visiblity-set sound? -->
|
||||
<section class="wrapper-xblock ${section_class} ${collapsible_class} has-visibility-set">
|
||||
<section class="wrapper-xblock ${section_class} ${collapsible_class}
|
||||
% if is_visible_to_specific_content_groups(xblock):
|
||||
has-group-visibility-set
|
||||
% endif
|
||||
">
|
||||
% endif
|
||||
|
||||
<header class="xblock-header xblock-header-${xblock.category}">
|
||||
@@ -77,7 +81,6 @@ messages = json.dumps(xblock.validate().to_json())
|
||||
<span class="sr">${_("Delete")}</span>
|
||||
</a>
|
||||
</li>
|
||||
<!-- NOTE: @andyarmstrong, here's a static version of the new access control we're adding. Remember we wanted to update the tooltip if particular groups were set on a component -->
|
||||
<li class="action-item action-visibility">
|
||||
<a href="#" data-tooltip="${_("Visibility Settings")}" class="visibility-button action-button">
|
||||
<i class="icon-eye-open"></i>
|
||||
|
||||
94
cms/templates/visibility_editor.html
Normal file
94
cms/templates/visibility_editor.html
Normal file
@@ -0,0 +1,94 @@
|
||||
<%
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition
|
||||
from contentstore.utils import ancestor_has_staff_lock
|
||||
|
||||
cohorted_user_partition = get_cohorted_user_partition(xblock.location.course_key)
|
||||
unsorted_groups = cohorted_user_partition.groups if cohorted_user_partition else []
|
||||
groups = sorted(unsorted_groups, key=lambda group: group.name)
|
||||
selected_group_ids = xblock.group_access.get(cohorted_user_partition.id, []) if cohorted_user_partition else []
|
||||
has_selected_groups = len(selected_group_ids) > 0
|
||||
is_staff_locked = ancestor_has_staff_lock(xblock)
|
||||
%>
|
||||
|
||||
<div class="modal-section visibility-summary">
|
||||
% if len(groups) == 0:
|
||||
<div class="is-not-configured has-actions">
|
||||
<h4 class="title">${_('You have not set up any groups to manage visibility with.')}</h4>
|
||||
|
||||
<div class="copy">
|
||||
<p>${_('Groups are a way for you to organize content in your course with a particular student experience in mind. They are commonly used to facilitate content and pedagogical experiments as well as provide different tracks of content.')}</p>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a href="${manage_groups_url}" class="action action-primary action-settings">${_('Manage groups in this course')}</a>
|
||||
</div>
|
||||
</div>
|
||||
% elif is_staff_locked:
|
||||
<div class="summary-message summary-message-warning visibility-summary-message">
|
||||
<i class="icon icon-warning-sign"></i>
|
||||
<p class="copy">${_('The Unit this component is contained in is hidden from students. Visibility settings here will be trumped by this.')}</p>
|
||||
</div>
|
||||
% endif
|
||||
</div>
|
||||
|
||||
% if len(groups) > 0:
|
||||
<form class="visibility-controls-form" method="post" action="">
|
||||
|
||||
<div class="modal-section visibility-controls">
|
||||
<h3 class="modal-section-title">${_('Set visibility to:')}</h3>
|
||||
|
||||
<div class="modal-section-content">
|
||||
|
||||
<section class="visibility-controls-primary">
|
||||
<ul class="list-fields list-radio">
|
||||
<li class="field field-radio field-visibility-level">
|
||||
<input type="radio" id="visibility-level-all" name="visibility-level" value="" class="input input-radio visibility-level-all" ${'checked="checked"' if not has_selected_groups else ''} />
|
||||
<label for="visibility-level-all" class="label">${_('All Students and Staff')}</label>
|
||||
</li>
|
||||
|
||||
<li class="field field-radio field-visibility-level">
|
||||
<input type="radio" id="visibility-level-specific" name="visibility-level" value="" class="input input-radio visibility-level-specific" ${'checked="checked"' if has_selected_groups else ''} />
|
||||
<label for="visibility-level-specific" class="label">${_('Specific Content Groups')}</label>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="wrapper-visibility-specific" data-user-partition-id="${cohorted_user_partition.id}">
|
||||
<section class="visibility-controls-secondary">
|
||||
<div class="visibility-controls-group">
|
||||
<h4 class="visibility-controls-title modal-subsection-title sr">${_('Content Groups')}</h4>
|
||||
<ul class="list-fields list-checkbox">
|
||||
<%
|
||||
missing_group_ids = set(selected_group_ids)
|
||||
%>
|
||||
% for group in groups:
|
||||
<%
|
||||
is_group_selected = group.id in selected_group_ids
|
||||
if is_group_selected:
|
||||
missing_group_ids.remove(group.id)
|
||||
%>
|
||||
<li class="field field-checkbox field-visibility-content-group">
|
||||
<input type="checkbox" id="visibility-content-group-${group.id}" name="visibility-content-group" value="${group.id}" class="input input-checkbox" ${'checked="checked"' if group.id in selected_group_ids else ''}/>
|
||||
<label for="visibility-content-group-${group.id}" class="label">${group.name | h}</label>
|
||||
</li>
|
||||
% endfor
|
||||
|
||||
% for group_id in missing_group_ids:
|
||||
<li class="field field-checkbox field-visibility-content-group was-removed">
|
||||
<input type="checkbox" id="visibility-content-group-${group_id}" name="visibility-content-group" value="${group_id}" class="input input-checkbox" checked="checked" />
|
||||
<label for="visibility-content-group-${group_id}" class="label">
|
||||
${_('Deleted Content Group')}
|
||||
<span class="note">${_('Content group no longer exists. Please choose another or allow access to All Students and staff')}</span>
|
||||
</label>
|
||||
</li>
|
||||
% endfor
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
% endif
|
||||
@@ -3,7 +3,6 @@
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
from contentstore.context_processors import doc_url
|
||||
from contentstore.views.course import should_show_group_configurations_page
|
||||
%>
|
||||
<%page args="online_help_token"/>
|
||||
|
||||
@@ -93,11 +92,9 @@
|
||||
<li class="nav-item nav-course-settings-team">
|
||||
<a href="${course_team_url}">${_("Course Team")}</a>
|
||||
</li>
|
||||
% if should_show_group_configurations_page(context_course):
|
||||
<li class="nav-item nav-course-settings-group-configurations">
|
||||
<a href="${reverse('contentstore.views.group_configurations_list_handler', kwargs={'course_key_string': unicode(course_key)})}">${_("Group Configurations")}</a>
|
||||
</li>
|
||||
% endif
|
||||
<li class="nav-item nav-course-settings-group-configurations">
|
||||
<a href="${reverse('contentstore.views.group_configurations_list_handler', kwargs={'course_key_string': unicode(course_key)})}">${_("Group Configurations")}</a>
|
||||
</li>
|
||||
<li class="nav-item nav-course-settings-advanced">
|
||||
<a href="${advanced_settings_url}">${_("Advanced Settings")}</a>
|
||||
</li>
|
||||
|
||||
@@ -91,6 +91,7 @@ urlpatterns += patterns(
|
||||
url(r'^import_status/{}/(?P<filename>.+)$'.format(settings.COURSE_KEY_PATTERN), 'import_status_handler'),
|
||||
url(r'^export/{}$'.format(settings.COURSE_KEY_PATTERN), 'export_handler'),
|
||||
url(r'^xblock/outline/{}$'.format(settings.USAGE_KEY_PATTERN), 'xblock_outline_handler'),
|
||||
url(r'^xblock/container/{}$'.format(settings.USAGE_KEY_PATTERN), 'xblock_container_handler'),
|
||||
url(r'^xblock/{}/(?P<view_name>[^/]+)$'.format(settings.USAGE_KEY_PATTERN), 'xblock_view_handler'),
|
||||
url(r'^xblock/{}?$'.format(settings.USAGE_KEY_PATTERN), 'xblock_handler'),
|
||||
url(r'^tabs/{}$'.format(settings.COURSE_KEY_PATTERN), 'tabs_handler'),
|
||||
|
||||
@@ -4,9 +4,9 @@ from utils import click_css
|
||||
from selenium.webdriver.support.ui import Select
|
||||
|
||||
|
||||
class ComponentEditorView(PageObject):
|
||||
class BaseComponentEditorView(PageObject):
|
||||
"""
|
||||
A :class:`.PageObject` representing the rendered view of a component editor.
|
||||
A base :class:`.PageObject` for the component and visibility editors.
|
||||
|
||||
This class assumes that the editor is our default editor as displayed for xmodules.
|
||||
"""
|
||||
@@ -18,7 +18,7 @@ class ComponentEditorView(PageObject):
|
||||
browser (selenium.webdriver): The Selenium-controlled browser that this page is loaded in.
|
||||
locator (str): The locator that identifies which xblock this :class:`.xblock-editor` relates to.
|
||||
"""
|
||||
super(ComponentEditorView, self).__init__(browser)
|
||||
super(BaseComponentEditorView, self).__init__(browser)
|
||||
self.locator = locator
|
||||
|
||||
def is_browser_on_page(self):
|
||||
@@ -40,6 +40,23 @@ class ComponentEditorView(PageObject):
|
||||
"""
|
||||
return None
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Clicks save button.
|
||||
"""
|
||||
click_css(self, 'a.action-save')
|
||||
|
||||
def cancel(self):
|
||||
"""
|
||||
Clicks cancel button.
|
||||
"""
|
||||
click_css(self, 'a.action-cancel', require_notification=False)
|
||||
|
||||
|
||||
class ComponentEditorView(BaseComponentEditorView):
|
||||
"""
|
||||
A :class:`.PageObject` representing the rendered view of a component editor.
|
||||
"""
|
||||
def get_setting_element(self, label):
|
||||
"""
|
||||
Returns the index of the setting entry with given label (display name) within the Settings modal.
|
||||
@@ -86,14 +103,48 @@ class ComponentEditorView(PageObject):
|
||||
else:
|
||||
return None
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Clicks save button.
|
||||
"""
|
||||
click_css(self, 'a.action-save')
|
||||
|
||||
def cancel(self):
|
||||
class ComponentVisibilityEditorView(BaseComponentEditorView):
|
||||
"""
|
||||
A :class:`.PageObject` representing the rendered view of a component visibility editor.
|
||||
"""
|
||||
OPTION_SELECTOR = '.modal-section-content li.field'
|
||||
|
||||
@property
|
||||
def all_options(self):
|
||||
"""
|
||||
Clicks cancel button.
|
||||
Return all visibility 'li' options.
|
||||
"""
|
||||
click_css(self, 'a.action-cancel', require_notification=False)
|
||||
return self.q(css=self._bounded_selector(self.OPTION_SELECTOR)).results
|
||||
|
||||
@property
|
||||
def selected_options(self):
|
||||
"""
|
||||
Return all selected visibility 'li' options.
|
||||
"""
|
||||
results = []
|
||||
for option in self.all_options:
|
||||
button = option.find_element_by_css_selector('input.input')
|
||||
if button.is_selected():
|
||||
results.append(option)
|
||||
return results
|
||||
|
||||
def select_option(self, label_text, save=True):
|
||||
"""
|
||||
Click the first li which has a label matching `label_text`.
|
||||
|
||||
Arguments:
|
||||
label_text (str): Text of a label accompanying the input
|
||||
which should be clicked.
|
||||
save (boolean): Whether the "save" button should be clicked
|
||||
afterwards.
|
||||
Returns:
|
||||
bool: Whether the label was found and clicked.
|
||||
"""
|
||||
for option in self.all_options:
|
||||
if label_text in option.text:
|
||||
option.click()
|
||||
if save:
|
||||
self.save()
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -152,6 +152,13 @@ class ContainerPage(PageObject):
|
||||
"""
|
||||
return self.q(css='.bit-publishing .wrapper-visibility .copy .inherited-from').visible
|
||||
|
||||
@property
|
||||
def sidebar_visibility_message(self):
|
||||
"""
|
||||
Returns the text within the sidebar visibility section.
|
||||
"""
|
||||
return self.q(css='.bit-publishing .wrapper-visibility').first.text[0]
|
||||
|
||||
@property
|
||||
def publish_action(self):
|
||||
"""
|
||||
@@ -243,7 +250,7 @@ class ContainerPage(PageObject):
|
||||
"""
|
||||
Clicks the "edit" button for the first component on the page.
|
||||
"""
|
||||
return _click_edit(self)
|
||||
return _click_edit(self, '.edit-button', '.xblock-studio_view')
|
||||
|
||||
def add_missing_groups(self):
|
||||
"""
|
||||
@@ -282,6 +289,7 @@ class XBlockWrapper(PageObject):
|
||||
url = None
|
||||
BODY_SELECTOR = '.studio-xblock-wrapper'
|
||||
NAME_SELECTOR = '.xblock-display-name'
|
||||
VALIDATION_SELECTOR = '.xblock-message.validation'
|
||||
COMPONENT_BUTTONS = {
|
||||
'basic_tab': '.editor-tabs li.inner_tab_wrap:nth-child(1) > a',
|
||||
'advanced_tab': '.editor-tabs li.inner_tab_wrap:nth-child(2) > a',
|
||||
@@ -348,11 +356,11 @@ class XBlockWrapper(PageObject):
|
||||
@property
|
||||
def has_validation_message(self):
|
||||
""" Is a validation warning/error/message shown? """
|
||||
return self.q(css=self._bounded_selector('.xblock-message.validation')).present
|
||||
return self.q(css=self._bounded_selector(self.VALIDATION_SELECTOR)).present
|
||||
|
||||
def _validation_paragraph(self, css_class):
|
||||
""" Helper method to return the <p> element of a validation warning """
|
||||
return self.q(css=self._bounded_selector('.xblock-message.validation p.{}'.format(css_class)))
|
||||
return self.q(css=self._bounded_selector('{} p.{}'.format(self.VALIDATION_SELECTOR, css_class)))
|
||||
|
||||
@property
|
||||
def has_validation_warning(self):
|
||||
@@ -380,6 +388,10 @@ class XBlockWrapper(PageObject):
|
||||
""" Get the text of the validation error. """
|
||||
return self._validation_paragraph('error').text[0]
|
||||
|
||||
@property
|
||||
def validation_error_messages(self):
|
||||
return self.q(css=self._bounded_selector('{} .xblock-message-item.error'.format(self.VALIDATION_SELECTOR))).text
|
||||
|
||||
@property
|
||||
# pylint: disable=invalid-name
|
||||
def validation_not_configured_warning_text(self):
|
||||
@@ -390,6 +402,10 @@ class XBlockWrapper(PageObject):
|
||||
def preview_selector(self):
|
||||
return self._bounded_selector('.xblock-student_view,.xblock-author_view')
|
||||
|
||||
@property
|
||||
def has_group_visibility_set(self):
|
||||
return self.q(css=self._bounded_selector('.wrapper-xblock.has-group-visibility-set')).is_present()
|
||||
|
||||
def go_to_container(self):
|
||||
"""
|
||||
Open the container page linked to by this xblock, and return
|
||||
@@ -401,7 +417,13 @@ class XBlockWrapper(PageObject):
|
||||
"""
|
||||
Clicks the "edit" button for this xblock.
|
||||
"""
|
||||
return _click_edit(self, self._bounded_selector)
|
||||
return _click_edit(self, '.edit-button', '.xblock-studio_view', self._bounded_selector)
|
||||
|
||||
def edit_visibility(self):
|
||||
"""
|
||||
Clicks the edit visibility button for this xblock.
|
||||
"""
|
||||
return _click_edit(self, '.visibility-button', '.xblock-visibility_view', self._bounded_selector)
|
||||
|
||||
def open_advanced_tab(self):
|
||||
"""
|
||||
@@ -478,13 +500,13 @@ class XBlockWrapper(PageObject):
|
||||
return self.q(css=self._bounded_selector('span.message-text a')).first.text[0]
|
||||
|
||||
|
||||
def _click_edit(page_object, bounded_selector=lambda(x): x):
|
||||
def _click_edit(page_object, button_css, view_css, bounded_selector=lambda(x): x):
|
||||
"""
|
||||
Click on the first edit button found and wait for the Studio editor to be present.
|
||||
Click on the first editing button found and wait for the Studio editor to be present.
|
||||
"""
|
||||
page_object.q(css=bounded_selector('.edit-button')).first.click()
|
||||
page_object.q(css=bounded_selector(button_css)).first.click()
|
||||
EmptyPromise(
|
||||
lambda: page_object.q(css='.xblock-studio_view').present,
|
||||
lambda: page_object.q(css=view_css).present,
|
||||
'Wait for the Studio editor to be present'
|
||||
).fulfill()
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ from path import path
|
||||
from bok_choy.javascript import js_defined
|
||||
from bok_choy.web_app_test import WebAppTest
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
from xmodule.partitions.tests.test_partitions import MockUserPartitionScheme
|
||||
from xmodule.partitions.partitions import UserPartition
|
||||
from xmodule.partitions.tests.test_partitions import MockUserPartitionScheme
|
||||
|
||||
|
||||
def skip_if_browser(browser):
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
"""
|
||||
Acceptance tests for Studio related to the container page.
|
||||
The container page is used both for display units, and for
|
||||
displaying containers within units.
|
||||
The container page is used both for displaying units, and
|
||||
for displaying containers within units.
|
||||
"""
|
||||
from nose.plugins.attrib import attr
|
||||
|
||||
from ...fixtures.course import XBlockFixtureDesc
|
||||
from ...pages.studio.component_editor import ComponentEditorView
|
||||
from ...pages.studio.component_editor import ComponentEditorView, ComponentVisibilityEditorView
|
||||
from ...pages.studio.html_component_editor import HtmlComponentEditorView
|
||||
from ...pages.studio.utils import add_discussion, drag
|
||||
from ...pages.lms.courseware import CoursewarePage
|
||||
from ...pages.lms.staff_view import StaffPage
|
||||
from ...tests.helpers import create_user_partition_json
|
||||
|
||||
import datetime
|
||||
from bok_choy.promise import Promise, EmptyPromise
|
||||
from base_studio_test import ContainerBase
|
||||
from xmodule.partitions.partitions import Group
|
||||
|
||||
|
||||
class NestedVerticalTest(ContainerBase):
|
||||
@@ -289,6 +291,265 @@ class EditContainerTest(NestedVerticalTest):
|
||||
self.modify_display_name_and_verify(container)
|
||||
|
||||
|
||||
class EditVisibilityModalTest(ContainerBase):
|
||||
"""
|
||||
Tests of the visibility settings modal for components on the unit
|
||||
page.
|
||||
"""
|
||||
VISIBILITY_LABEL_ALL = 'All Students and Staff'
|
||||
VISIBILITY_LABEL_SPECIFIC = 'Specific Content Groups'
|
||||
MISSING_GROUP_LABEL = 'Deleted Content Group\nContent group no longer exists. Please choose another or allow access to All Students and staff'
|
||||
VALIDATION_ERROR_LABEL = 'This component has validation issues.'
|
||||
VALIDATION_ERROR_MESSAGE = 'Error:\nThis component refers to deleted or invalid content groups.'
|
||||
GROUP_VISIBILITY_MESSAGE = 'Some content in this unit is only visible to particular groups'
|
||||
|
||||
def setUp(self):
|
||||
super(EditVisibilityModalTest, self).setUp()
|
||||
|
||||
# Set up a cohort-schemed user partition
|
||||
self.course_fixture._update_xblock(self.course_fixture._course_location, {
|
||||
"metadata": {
|
||||
u"user_partitions": [
|
||||
create_user_partition_json(
|
||||
0,
|
||||
'Configuration Dogs, Cats',
|
||||
'Content Group Partition',
|
||||
[Group("0", 'Dogs'), Group("1", 'Cats')],
|
||||
scheme="cohort"
|
||||
)
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
self.container_page = self.go_to_unit_page()
|
||||
self.html_component = self.container_page.xblocks[1]
|
||||
|
||||
def populate_course_fixture(self, course_fixture):
|
||||
"""
|
||||
Populate a simple course a section, subsection, and unit, and HTML component.
|
||||
"""
|
||||
course_fixture.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
|
||||
XBlockFixtureDesc('html', 'Html Component')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def edit_component_visibility(self, component):
|
||||
"""
|
||||
Edit the visibility of an xblock on the container page.
|
||||
"""
|
||||
component.edit_visibility()
|
||||
return ComponentVisibilityEditorView(self.browser, component.locator)
|
||||
|
||||
def verify_selected_labels(self, visibility_editor, expected_labels):
|
||||
"""
|
||||
Verify that a visibility editor's selected labels match the
|
||||
expected ones.
|
||||
"""
|
||||
# If anything other than 'All Students and Staff', is selected,
|
||||
# 'Specific Content Groups' should be selected as well.
|
||||
if expected_labels != [self.VISIBILITY_LABEL_ALL]:
|
||||
expected_labels.append(self.VISIBILITY_LABEL_SPECIFIC)
|
||||
self.assertItemsEqual(expected_labels, [option.text for option in visibility_editor.selected_options])
|
||||
|
||||
def select_and_verify_saved(self, component, labels, expected_labels=None):
|
||||
"""
|
||||
Edit the visibility of an xblock on the container page and
|
||||
verify that the edit persists. If provided, verify that
|
||||
`expected_labels` are selected after save, otherwise expect
|
||||
that `labels` are selected after save. Note that `labels`
|
||||
are labels which should be clicked, but not necessarily checked.
|
||||
"""
|
||||
if expected_labels is None:
|
||||
expected_labels = labels
|
||||
|
||||
# Make initial edit(s) and save
|
||||
visibility_editor = self.edit_component_visibility(component)
|
||||
for label in labels:
|
||||
visibility_editor.select_option(label, save=False)
|
||||
visibility_editor.save()
|
||||
|
||||
# Re-open the modal and inspect its selected inputs
|
||||
visibility_editor = self.edit_component_visibility(component)
|
||||
self.verify_selected_labels(visibility_editor, expected_labels)
|
||||
|
||||
def verify_component_validation_error(self, component):
|
||||
"""
|
||||
Verify that we see validation errors for the given component.
|
||||
"""
|
||||
self.assertTrue(component.has_validation_error)
|
||||
self.assertEqual(component.validation_error_text, self.VALIDATION_ERROR_LABEL)
|
||||
self.assertEqual([self.VALIDATION_ERROR_MESSAGE], component.validation_error_messages)
|
||||
|
||||
def verify_visibility_set(self, component, is_set):
|
||||
"""
|
||||
Verify that the container page shows that component visibility
|
||||
settings have been edited if `is_set` is True; otherwise
|
||||
verify that the container page shows no such information.
|
||||
"""
|
||||
if is_set:
|
||||
self.assertIn(self.GROUP_VISIBILITY_MESSAGE, self.container_page.sidebar_visibility_message)
|
||||
self.assertTrue(component.has_group_visibility_set)
|
||||
else:
|
||||
self.assertNotIn(self.GROUP_VISIBILITY_MESSAGE, self.container_page.sidebar_visibility_message)
|
||||
self.assertFalse(component.has_group_visibility_set)
|
||||
|
||||
def update_component(self, component, metadata):
|
||||
"""
|
||||
Update a component's metadata and refresh the page.
|
||||
"""
|
||||
self.course_fixture._update_xblock(component.locator, {'metadata': metadata})
|
||||
self.browser.refresh()
|
||||
self.container_page.wait_for_page()
|
||||
|
||||
def remove_missing_groups(self, component):
|
||||
"""
|
||||
Deselect the missing groups for a component. After save,
|
||||
verify that there are no missing group messages in the modal
|
||||
and that there is no validation error on the component.
|
||||
"""
|
||||
visibility_editor = self.edit_component_visibility(component)
|
||||
for option in self.edit_component_visibility(component).selected_options:
|
||||
if option.text == self.MISSING_GROUP_LABEL:
|
||||
option.click()
|
||||
visibility_editor.save()
|
||||
visibility_editor = self.edit_component_visibility(component)
|
||||
self.assertNotIn(self.MISSING_GROUP_LABEL, [item.text for item in visibility_editor.all_options])
|
||||
visibility_editor.cancel()
|
||||
self.assertFalse(component.has_validation_error)
|
||||
|
||||
def test_default_selection(self):
|
||||
"""
|
||||
Scenario: The component visibility modal selects visible to all by default.
|
||||
Given I have a unit with one component
|
||||
When I go to the container page for that unit
|
||||
And I open the visibility editor modal for that unit's component
|
||||
Then the default visibility selection should be 'All Students and Staff'
|
||||
And the container page should not display 'Some content in this unit is only visible to particular groups'
|
||||
"""
|
||||
self.verify_selected_labels(self.edit_component_visibility(self.html_component), [self.VISIBILITY_LABEL_ALL])
|
||||
self.verify_visibility_set(self.html_component, False)
|
||||
|
||||
def test_reset_to_all_students_and_staff(self):
|
||||
"""
|
||||
Scenario: The component visibility modal can be set to be visible to all students and staff.
|
||||
Given I have a unit with one component
|
||||
When I go to the container page for that unit
|
||||
And I open the visibility editor modal for that unit's component
|
||||
And I select 'Dogs'
|
||||
And I save the modal
|
||||
Then the container page should display 'Some content in this unit is only visible to particular groups'
|
||||
And I re-open the visibility editor modal for that unit's component
|
||||
And I select 'All Students and Staff'
|
||||
And I save the modal
|
||||
Then the visibility selection should be 'All Students and Staff'
|
||||
And the container page should not display 'Some content in this unit is only visible to particular groups'
|
||||
"""
|
||||
self.select_and_verify_saved(self.html_component, ['Dogs'])
|
||||
self.verify_visibility_set(self.html_component, True)
|
||||
self.select_and_verify_saved(self.html_component, [self.VISIBILITY_LABEL_ALL])
|
||||
self.verify_visibility_set(self.html_component, False)
|
||||
|
||||
def test_select_single_content_group(self):
|
||||
"""
|
||||
Scenario: The component visibility modal can be set to be visible to one content group.
|
||||
Given I have a unit with one component
|
||||
When I go to the container page for that unit
|
||||
And I open the visibility editor modal for that unit's component
|
||||
And I select 'Dogs'
|
||||
And I save the modal
|
||||
Then the visibility selection should be 'Dogs' and 'Specific Content Groups'
|
||||
And the container page should display 'Some content in this unit is only visible to particular groups'
|
||||
"""
|
||||
self.select_and_verify_saved(self.html_component, ['Dogs'])
|
||||
self.verify_visibility_set(self.html_component, True)
|
||||
|
||||
def test_select_multiple_content_groups(self):
|
||||
"""
|
||||
Scenario: The component visibility modal can be set to be visible to multiple content groups.
|
||||
Given I have a unit with one component
|
||||
When I go to the container page for that unit
|
||||
And I open the visibility editor modal for that unit's component
|
||||
And I select 'Dogs' and 'Cats'
|
||||
And I save the modal
|
||||
Then the visibility selection should be 'Dogs', 'Cats', and 'Specific Content Groups'
|
||||
And the container page should display 'Some content in this unit is only visible to particular groups'
|
||||
"""
|
||||
self.select_and_verify_saved(self.html_component, ['Dogs', 'Cats'])
|
||||
self.verify_visibility_set(self.html_component, True)
|
||||
|
||||
def test_select_zero_content_groups(self):
|
||||
"""
|
||||
Scenario: The component visibility modal can not be set to be visible to 'Specific Content Groups' without
|
||||
selecting those specific groups.
|
||||
Given I have a unit with one component
|
||||
When I go to the container page for that unit
|
||||
And I open the visibility editor modal for that unit's component
|
||||
And I select 'Specific Content Groups'
|
||||
And I save the modal
|
||||
Then the visibility selection should be 'All Students and Staff'
|
||||
And the container page should not display 'Some content in this unit is only visible to particular groups'
|
||||
"""
|
||||
self.select_and_verify_saved(
|
||||
self.html_component, [self.VISIBILITY_LABEL_SPECIFIC], expected_labels=[self.VISIBILITY_LABEL_ALL]
|
||||
)
|
||||
self.verify_visibility_set(self.html_component, False)
|
||||
|
||||
def test_missing_groups(self):
|
||||
"""
|
||||
Scenario: The component visibility modal shows a validation error when visibility is set to multiple unknown
|
||||
group ids.
|
||||
Given I have a unit with one component
|
||||
And that component's group access specifies multiple invalid group ids
|
||||
When I go to the container page for that unit
|
||||
Then I should see a validation error message on that unit's component
|
||||
And I open the visibility editor modal for that unit's component
|
||||
Then I should see that I have selected multiple deleted groups
|
||||
And the container page should display 'Some content in this unit is only visible to particular groups'
|
||||
And I de-select the missing groups
|
||||
And I save the modal
|
||||
Then the visibility selection should be 'All Students and Staff'
|
||||
And I should not see any validation errors on the component
|
||||
And the container page should not display 'Some content in this unit is only visible to particular groups'
|
||||
"""
|
||||
self.update_component(self.html_component, {'group_access': {0: [2, 3]}})
|
||||
self.verify_component_validation_error(self.html_component)
|
||||
visibility_editor = self.edit_component_visibility(self.html_component)
|
||||
self.verify_selected_labels(visibility_editor, [self.MISSING_GROUP_LABEL] * 2)
|
||||
self.remove_missing_groups(self.html_component)
|
||||
self.verify_visibility_set(self.html_component, False)
|
||||
|
||||
def test_found_and_missing_groups(self):
|
||||
"""
|
||||
Scenario: The component visibility modal shows a validation error when visibility is set to multiple unknown
|
||||
group ids and multiple known group ids.
|
||||
Given I have a unit with one component
|
||||
And that component's group access specifies multiple invalid and valid group ids
|
||||
When I go to the container page for that unit
|
||||
Then I should see a validation error message on that unit's component
|
||||
And I open the visibility editor modal for that unit's component
|
||||
Then I should see that I have selected multiple deleted groups
|
||||
And the container page should display 'Some content in this unit is only visible to particular groups'
|
||||
And I de-select the missing groups
|
||||
And I save the modal
|
||||
Then the visibility selection should be the names of the valid groups.
|
||||
And I should not see any validation errors on the component
|
||||
And the container page should display 'Some content in this unit is only visible to particular groups'
|
||||
"""
|
||||
self.update_component(self.html_component, {'group_access': {0: [0, 1, 2, 3]}})
|
||||
self.verify_component_validation_error(self.html_component)
|
||||
visibility_editor = self.edit_component_visibility(self.html_component)
|
||||
self.verify_selected_labels(visibility_editor, ['Dogs', 'Cats'] + [self.MISSING_GROUP_LABEL] * 2)
|
||||
self.remove_missing_groups(self.html_component)
|
||||
visibility_editor = self.edit_component_visibility(self.html_component)
|
||||
self.verify_selected_labels(visibility_editor, ['Dogs', 'Cats'])
|
||||
self.verify_visibility_set(self.html_component, True)
|
||||
|
||||
|
||||
@attr('shard_1')
|
||||
class UnitPublishingTest(ContainerBase):
|
||||
"""
|
||||
|
||||
@@ -8,7 +8,7 @@ from unittest import skip
|
||||
from nose.plugins.attrib import attr
|
||||
from selenium.webdriver.support.ui import Select
|
||||
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
from xmodule.partitions.partitions import Group
|
||||
from bok_choy.promise import Promise, EmptyPromise
|
||||
|
||||
from ...fixtures.course import XBlockFixtureDesc
|
||||
@@ -217,37 +217,14 @@ class SettingsMenuTest(StudioCourseTest):
|
||||
)
|
||||
self.advanced_settings.visit()
|
||||
|
||||
def test_link_exist_if_split_test_enabled(self):
|
||||
def test_link_exist(self):
|
||||
"""
|
||||
Ensure that the link to the "Group Configurations" page is shown in the
|
||||
Settings menu.
|
||||
"""
|
||||
link_css = 'li.nav-course-settings-group-configurations a'
|
||||
self.assertFalse(self.advanced_settings.q(css=link_css).present)
|
||||
|
||||
self.advanced_settings.set('Advanced Module List', '["split_test"]')
|
||||
|
||||
self.browser.refresh()
|
||||
self.advanced_settings.wait_for_page()
|
||||
|
||||
self.assertIn(
|
||||
"split_test",
|
||||
json.loads(self.advanced_settings.get('Advanced Module List')),
|
||||
)
|
||||
|
||||
self.assertTrue(self.advanced_settings.q(css=link_css).present)
|
||||
|
||||
def test_link_does_not_exist_if_split_test_disabled(self):
|
||||
"""
|
||||
Ensure that the link to the "Group Configurations" page does not exist
|
||||
in the Settings menu.
|
||||
"""
|
||||
link_css = 'li.nav-course-settings-group-configurations a'
|
||||
self.advanced_settings.set('Advanced Module List', '[]')
|
||||
self.browser.refresh()
|
||||
self.advanced_settings.wait_for_page()
|
||||
self.assertFalse(self.advanced_settings.q(css=link_css).present)
|
||||
|
||||
|
||||
@attr('shard_1')
|
||||
class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
|
||||
|
||||
@@ -69,7 +69,6 @@ class LmsBlockMixin(XBlockMixin):
|
||||
default=False,
|
||||
scope=Scope.settings,
|
||||
)
|
||||
|
||||
group_access = GroupAccessDict(
|
||||
help=_(
|
||||
"A dictionary that maps which groups can be shown this block. The keys "
|
||||
@@ -143,26 +142,32 @@ class LmsBlockMixin(XBlockMixin):
|
||||
"""
|
||||
_ = self.runtime.service(self, "i18n").ugettext # pylint: disable=redefined-outer-name
|
||||
validation = super(LmsBlockMixin, self).validate()
|
||||
has_invalid_user_partitions = False
|
||||
has_invalid_groups = False
|
||||
for user_partition_id, group_ids in self.group_access.iteritems():
|
||||
try:
|
||||
user_partition = self._get_user_partition(user_partition_id)
|
||||
except NoSuchUserPartitionError:
|
||||
validation.add(
|
||||
ValidationMessage(
|
||||
ValidationMessage.ERROR,
|
||||
_(u"This xblock refers to a deleted or invalid content group configuration.")
|
||||
)
|
||||
)
|
||||
has_invalid_user_partitions = True
|
||||
else:
|
||||
for group_id in group_ids:
|
||||
try:
|
||||
user_partition.get_group(group_id)
|
||||
except NoSuchUserPartitionGroupError:
|
||||
validation.add(
|
||||
ValidationMessage(
|
||||
ValidationMessage.ERROR,
|
||||
_(u"This xblock refers to a deleted or invalid content group.")
|
||||
)
|
||||
)
|
||||
has_invalid_groups = True
|
||||
|
||||
if has_invalid_user_partitions:
|
||||
validation.add(
|
||||
ValidationMessage(
|
||||
ValidationMessage.ERROR,
|
||||
_(u"This component refers to deleted or invalid content group configurations.")
|
||||
)
|
||||
)
|
||||
if has_invalid_groups:
|
||||
validation.add(
|
||||
ValidationMessage(
|
||||
ValidationMessage.ERROR,
|
||||
_(u"This component refers to deleted or invalid content groups.")
|
||||
)
|
||||
)
|
||||
return validation
|
||||
|
||||
@@ -70,29 +70,51 @@ class XBlockValidationTest(LmsXBlockMixinTestCase):
|
||||
validation = self.video.validate()
|
||||
self.assertEqual(len(validation.messages), 0)
|
||||
|
||||
def test_validate_invalid_user_partition(self):
|
||||
def test_validate_invalid_user_partitions(self):
|
||||
"""
|
||||
Test the validation messages produced for an xblock referring to a non-existent user partition.
|
||||
Test the validation messages produced for an xblock referring to non-existent user partitions.
|
||||
"""
|
||||
self.video.group_access[999] = [self.group1.id]
|
||||
validation = self.video.validate()
|
||||
self.assertEqual(len(validation.messages), 1)
|
||||
self.verify_validation_message(
|
||||
validation.messages[0],
|
||||
u"This xblock refers to a deleted or invalid content group configuration.",
|
||||
u"This component refers to deleted or invalid content group configurations.",
|
||||
ValidationMessage.ERROR,
|
||||
)
|
||||
|
||||
def test_validate_invalid_group(self):
|
||||
# Now add a second invalid user partition and validate again.
|
||||
# Note that even though there are two invalid configurations,
|
||||
# only a single error message will be returned.
|
||||
self.video.group_access[998] = [self.group2.id]
|
||||
validation = self.video.validate()
|
||||
self.assertEqual(len(validation.messages), 1)
|
||||
self.verify_validation_message(
|
||||
validation.messages[0],
|
||||
u"This component refers to deleted or invalid content group configurations.",
|
||||
ValidationMessage.ERROR,
|
||||
)
|
||||
|
||||
def test_validate_invalid_groups(self):
|
||||
"""
|
||||
Test the validation messages produced for an xblock referring to a non-existent group.
|
||||
Test the validation messages produced for an xblock referring to non-existent groups.
|
||||
"""
|
||||
self.video.group_access[self.user_partition.id] = [self.group1.id, 999] # pylint: disable=no-member
|
||||
validation = self.video.validate()
|
||||
self.assertEqual(len(validation.messages), 1)
|
||||
self.verify_validation_message(
|
||||
validation.messages[0],
|
||||
u"This xblock refers to a deleted or invalid content group.",
|
||||
u"This component refers to deleted or invalid content groups.",
|
||||
ValidationMessage.ERROR,
|
||||
)
|
||||
|
||||
# Now try again with two invalid group ids
|
||||
self.video.group_access[self.user_partition.id] = [self.group1.id, 998, 999] # pylint: disable=no-member
|
||||
validation = self.video.validate()
|
||||
self.assertEqual(len(validation.messages), 1)
|
||||
self.verify_validation_message(
|
||||
validation.messages[0],
|
||||
u"This component refers to deleted or invalid content groups.",
|
||||
ValidationMessage.ERROR,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user