diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py
index c87db7e076..d6bbd503f1 100644
--- a/cms/djangoapps/contentstore/tests/test_utils.py
+++ b/cms/djangoapps/contentstore/tests/test_utils.py
@@ -231,7 +231,7 @@ class XBlockVisibilityTestCase(TestCase):
vertical.start = self.future
modulestore().update_item(vertical, self.dummy_user)
- self.assertTrue(utils.is_xblock_visible_to_students(vertical))
+ self.assertTrue(utils.is_currently_visible_to_students(vertical))
def _test_visible_to_students(self, expected_visible_without_lock, name, start_date, publish=False):
"""
@@ -239,13 +239,13 @@ class XBlockVisibilityTestCase(TestCase):
with and without visible_to_staff_only set.
"""
no_staff_lock = self._create_xblock_with_start_date(name, start_date, publish, visible_to_staff_only=False)
- self.assertEqual(expected_visible_without_lock, utils.is_xblock_visible_to_students(no_staff_lock))
+ self.assertEqual(expected_visible_without_lock, utils.is_currently_visible_to_students(no_staff_lock))
# any xblock with visible_to_staff_only set to True should not be visible to students.
staff_lock = self._create_xblock_with_start_date(
name + "_locked", start_date, publish, visible_to_staff_only=True
)
- self.assertFalse(utils.is_xblock_visible_to_students(staff_lock))
+ self.assertFalse(utils.is_currently_visible_to_students(staff_lock))
def _create_xblock_with_start_date(self, name, start_date, publish=False, visible_to_staff_only=False):
"""Helper to create an xblock with a start date, optionally publishing it"""
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index 9b1b935cee..167ad9e81d 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -164,9 +164,10 @@ def compute_publish_state(xblock):
return modulestore().compute_publish_state(xblock)
-def is_xblock_visible_to_students(xblock):
+def is_currently_visible_to_students(xblock):
"""
- Returns true if there is a published version of the xblock that has been released.
+ Returns true if there is a published version of the xblock that is currently visible to students.
+ This means that it has a release date in the past, and the xblock has not been set to staff only.
"""
try:
diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py
index 193b0e088d..f9a657addf 100644
--- a/cms/djangoapps/contentstore/views/component.py
+++ b/cms/djangoapps/contentstore/views/component.py
@@ -21,7 +21,7 @@ from xblock.fields import Scope
from xblock.plugin import PluginMissingError
from xblock.runtime import Mixologist
-from contentstore.utils import get_lms_link_for_item, compute_publish_state, is_xblock_visible_to_students
+from contentstore.utils import get_lms_link_for_item, compute_publish_state
from contentstore.views.helpers import get_parent_xblock, is_unit, xblock_type_display_name
from contentstore.views.item import create_xblock_info
@@ -207,7 +207,6 @@ def container_handler(request, usage_key_string):
'xblock_locator': xblock.location,
'unit': unit,
'is_unit_page': is_unit_page,
- 'is_visible_to_students': is_xblock_visible_to_students(xblock),
'subsection': subsection,
'section': section,
'new_unit_category': 'vertical',
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index efd6ef6526..1cef9d0e09 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -37,6 +37,7 @@ from util.date_utils import get_default_time_display
from util.json_request import expect_json, JsonResponse
from .access import has_course_access
+from contentstore.utils import is_currently_visible_to_students
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
@@ -104,10 +105,12 @@ def xblock_handler(request, usage_key_string):
to None! Absent ones will be left alone.
:nullout: which metadata fields to set to None
:graderType: change how this unit is graded
- :publish: can be either -- 'make_public' (which publishes the content) or 'discard_changes'
- (which reverts to the last published version). If 'discard_changes', the other fields
- will not be used; that is, it is not possible to update and discard changes
- in a single operation.
+ :publish: can be:
+ 'make_public': publish the content
+ 'republish': publish this item *only* if it was previously published
+ 'discard_changes' - reverts to the last published version
+ Note: If 'discard_changes', the other fields will not be used; that is, it is not possible
+ to update and discard changes in a single operation.
The JSON representation on the updated xblock (minus children) is returned.
if usage_key_string is not specified, create a new xblock instance, either by duplicating
@@ -136,7 +139,7 @@ def xblock_handler(request, usage_key_string):
# right now can't combine output of this w/ output of _get_module_info, but worthy goal
return JsonResponse(CourseGradingModel.get_section_grader_type(usage_key))
# TODO: pass fields to _get_module_info and only return those
- rsp = _get_module_info(usage_key, request.user)
+ rsp = _get_module_info(_get_xblock(usage_key, request.user))
return JsonResponse(rsp)
else:
return HttpResponse(status=406)
@@ -145,9 +148,9 @@ def xblock_handler(request, usage_key_string):
_delete_item(usage_key, request.user)
return JsonResponse()
else: # Since we have a usage_key, we are updating an existing xblock.
- return _save_item(
+ return _save_xblock(
request.user,
- usage_key,
+ _get_xblock(usage_key, request.user),
data=request.json.get('data'),
children=request.json.get('children'),
metadata=request.json.get('metadata'),
@@ -289,8 +292,8 @@ def xblock_outline_handler(request, usage_key_string):
return Http404
-def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout=None,
- grader_type=None, publish=None):
+def _save_xblock(user, xblock, data=None, children=None, metadata=None, nullout=None,
+ grader_type=None, publish=None):
"""
Saves xblock w/ its fields. Has special processing for grader_type, publish, and nullout and Nones in metadata.
nullout means to truly set the field to None whereas nones in metadata mean to unset them (so they revert
@@ -298,32 +301,19 @@ def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout
"""
store = modulestore()
- try:
- existing_item = store.get_item(usage_key)
- except ItemNotFoundError:
- if usage_key.category in CREATE_IF_NOT_FOUND:
- # New module at this location, for pages that are not pre-created.
- # Used for course info handouts.
- existing_item = store.create_item(user.id, usage_key.course_key, usage_key.block_type, usage_key.block_id)
- else:
- raise
- except InvalidLocationError:
- log.error("Can't find item by location.")
- return JsonResponse({"error": "Can't find item by location: " + unicode(usage_key)}, 404)
-
# Don't allow updating an xblock and discarding changes in a single operation (unsupported by UI).
if publish == "discard_changes":
- store.revert_to_published(usage_key, user.id)
+ store.revert_to_published(xblock.location, user.id)
# Returning the same sort of result that we do for other save operations. In the future,
# we may want to return the full XBlockInfo.
- return JsonResponse({'id': unicode(usage_key)})
+ return JsonResponse({'id': unicode(xblock.location)})
- old_metadata = own_metadata(existing_item)
- old_content = existing_item.get_explicitly_set_fields_by_scope(Scope.content)
+ old_metadata = own_metadata(xblock)
+ old_content = xblock.get_explicitly_set_fields_by_scope(Scope.content)
if data:
# TODO Allow any scope.content fields not just "data" (exactly like the get below this)
- existing_item.data = data
+ xblock.data = data
else:
data = old_content['data'] if 'data' in old_content else None
@@ -332,7 +322,7 @@ def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout
for child in children:
child_usage_key = usage_key_with_run(child)
children_usage_keys.append(child_usage_key)
- existing_item.children = children_usage_keys
+ xblock.children = children_usage_keys
# also commit any metadata which might have been passed along
if nullout is not None or metadata is not None:
@@ -341,53 +331,61 @@ def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout
# 'apply' the submitted metadata, so we don't end up deleting system metadata.
if nullout is not None:
for metadata_key in nullout:
- setattr(existing_item, metadata_key, None)
+ setattr(xblock, metadata_key, None)
# update existing metadata with submitted metadata (which can be partial)
# IMPORTANT NOTE: if the client passed 'null' (None) for a piece of metadata that means 'remove it'. If
# the intent is to make it None, use the nullout field
if metadata is not None:
for metadata_key, value in metadata.items():
- field = existing_item.fields[metadata_key]
+ field = xblock.fields[metadata_key]
if value is None:
- field.delete_from(existing_item)
+ field.delete_from(xblock)
else:
try:
value = field.from_json(value)
except ValueError:
return JsonResponse({"error": "Invalid data"}, 400)
- field.write_to(existing_item, value)
+ field.write_to(xblock, value)
- if callable(getattr(existing_item, "editor_saved", None)):
- existing_item.editor_saved(user, old_metadata, old_content)
+ if callable(getattr(xblock, "editor_saved", None)):
+ xblock.editor_saved(user, old_metadata, old_content)
# commit to datastore
- store.update_item(existing_item, user.id)
+ store.update_item(xblock, user.id)
# for static tabs, their containing course also records their display name
- if usage_key.category == 'static_tab':
- course = store.get_course(usage_key.course_key)
+ if xblock.location.category == 'static_tab':
+ course = store.get_course(xblock.location.course_key)
# find the course's reference to this tab and update the name.
- static_tab = CourseTabList.get_tab_by_slug(course.tabs, usage_key.name)
+ static_tab = CourseTabList.get_tab_by_slug(course.tabs, xblock.location.name)
# only update if changed
- if static_tab and static_tab['name'] != existing_item.display_name:
- static_tab['name'] = existing_item.display_name
+ if static_tab and static_tab['name'] != xblock.display_name:
+ static_tab['name'] = xblock.display_name
store.update_item(course, user.id)
result = {
- 'id': unicode(usage_key),
+ 'id': unicode(xblock.location),
'data': data,
- 'metadata': own_metadata(existing_item)
+ 'metadata': own_metadata(xblock)
}
if grader_type is not None:
- result.update(CourseGradingModel.update_section_grader_type(existing_item, grader_type, user))
+ result.update(CourseGradingModel.update_section_grader_type(xblock, grader_type, user))
+
+ # If publish is set to 'republish' and this item has previously been published, then this
+ # new item should be republished. This is used by staff locking to ensure that changing the draft
+ # value of the staff lock will also update the published version.
+ if publish == 'republish':
+ published = modulestore().has_item(xblock.location, revision=ModuleStoreEnum.RevisionOption.published_only)
+ if published:
+ publish = 'make_public'
# Make public after updating the xblock, in case the caller asked for both an update and a publish.
- # Although not supported in the UI, Bok Choy tests use this.
+ # Used by Bok Choy tests and by republishing of staff locks.
if publish == 'make_public':
- modulestore().publish(existing_item.location, user.id)
+ modulestore().publish(xblock.location, user.id)
# Note that children aren't being returned until we have a use case.
return JsonResponse(result)
@@ -552,31 +550,40 @@ def orphan_handler(request, course_key_string):
raise PermissionDenied()
-def _get_module_info(usage_key, user, rewrite_static_links=True):
+def _get_xblock(usage_key, user):
+ """
+ Returns the xblock for the specified usage key. Note: if failing to find a key with a category
+ in the CREATE_IF_NOT_FOUND list, an xblock will be created and saved automatically.
+ """
+ store = modulestore()
+ try:
+ return store.get_item(usage_key)
+ except ItemNotFoundError:
+ if usage_key.category in CREATE_IF_NOT_FOUND:
+ # Create a new one for certain categories only. Used for course info handouts.
+ return store.create_item(user.id, usage_key.course_key, usage_key.block_type, block_id=usage_key.block_id)
+ else:
+ raise
+ except InvalidLocationError:
+ log.error("Can't find item by location.")
+ return JsonResponse({"error": "Can't find item by location: " + unicode(usage_key)}, 404)
+
+
+def _get_module_info(xblock, rewrite_static_links=True):
"""
metadata, data, id representation of a leaf module fetcher.
:param usage_key: A UsageKey
"""
- store = modulestore()
- try:
- module = store.get_item(usage_key)
- except ItemNotFoundError:
- if usage_key.category in CREATE_IF_NOT_FOUND:
- # Create a new one for certain categories only. Used for course info handouts.
- module = store.create_item(user.id, usage_key.course_key, usage_key.block_type, block_id=usage_key.block_id)
- else:
- raise
-
- data = getattr(module, 'data', '')
+ data = getattr(xblock, 'data', '')
if rewrite_static_links:
data = replace_static_urls(
data,
None,
- course_id=module.location.course_key
+ course_id=xblock.location.course_key
)
# Note that children aren't being returned until we have a use case.
- return create_xblock_info(module, data=data, metadata=own_metadata(module), include_ancestor_info=True)
+ return create_xblock_info(xblock, data=data, metadata=own_metadata(xblock), include_ancestor_info=True)
def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=False, include_child_info=False,
@@ -630,6 +637,8 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
"released_to_students": datetime.now(UTC) > xblock.start,
"release_date": release_date,
"release_date_from": _get_release_date_from(xblock) if release_date else None,
+ "visible_to_staff_only": xblock.visible_to_staff_only,
+ "currently_visible_to_students": is_currently_visible_to_students(xblock),
}
if data is not None:
xblock_info["data"] = data
diff --git a/cms/djangoapps/contentstore/views/tests/test_container_page.py b/cms/djangoapps/contentstore/views/tests/test_container_page.py
index e3fd69d609..d79464ae09 100644
--- a/cms/djangoapps/contentstore/views/tests/test_container_page.py
+++ b/cms/djangoapps/contentstore/views/tests/test_container_page.py
@@ -157,39 +157,3 @@ class ContainerPageTestCase(StudioPageTestCase):
"""
empty_child_container = self._create_item(self.vertical.location, 'split_test', 'Split Test')
self.validate_preview_html(empty_child_container, self.reorderable_child_view, can_add=False)
-
- def test_unreleased_private_container_messages(self):
- """
- Verify that an unreleased private container does not display messages.
- """
- self.validate_html_for_messages(self.unreleased_private_vertical, False)
-
- def test_unreleased_public_container_messages(self):
- """
- Verify that an unreleased public container does not display messages.
- """
- self.validate_html_for_messages(self.unreleased_public_vertical, False)
-
- def test_released_private_container_message(self):
- """
- Verify that a released private container does not display messages.
- """
- self.validate_html_for_messages(self.released_private_vertical, False)
-
- def test_released_public_container_message(self):
- """
- Verify that a released public container does display messages.
- """
- self.validate_html_for_messages(self.released_public_vertical, True)
-
- def validate_html_for_messages(self, xblock, has_messages):
- """
- Validate that the specified HTML has the appropriate messages for the current student visibility state.
- """
- # Verify that there are no warning messages for blocks that are not visible to students
- html = self.get_page_html(xblock)
- messages_html = '
'
- if has_messages:
- self.assertIn(messages_html, html)
- else:
- self.assertNotIn(messages_html, html)
diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py
index 0f052ea321..d14211da92 100644
--- a/cms/djangoapps/contentstore/views/tests/test_item.py
+++ b/cms/djangoapps/contentstore/views/tests/test_item.py
@@ -567,6 +567,55 @@ class TestEditItem(ItemTest):
published = self.verify_publish_state(self.problem_usage_key, PublishState.public)
self.assertIsNone(published.due)
+ def test_republish(self):
+ """ Test republishing an item. """
+ new_display_name = 'New Display Name'
+ republish_data = {
+ 'publish': 'republish',
+ 'display_name': new_display_name
+ }
+
+ # When the problem is first created, it is only in draft (because of its category).
+ self.verify_publish_state(self.problem_usage_key, PublishState.private)
+
+ # Republishing when only in draft will update the draft but not cause a public item to be created.
+ self.client.ajax_post(
+ self.problem_update_url,
+ data={
+ 'publish': 'republish',
+ 'metadata': {
+ 'display_name': new_display_name
+ }
+ }
+ )
+ self.verify_publish_state(self.problem_usage_key, PublishState.private)
+ draft = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True)
+ self.assertEqual(draft.display_name, new_display_name)
+
+ # Publish the item
+ self.client.ajax_post(
+ self.problem_update_url,
+ data={'publish': 'make_public'}
+ )
+
+ # Now republishing should update the published version
+ new_display_name_2 = 'New Display Name 2'
+ self.client.ajax_post(
+ self.problem_update_url,
+ data={
+ 'publish': 'republish',
+ 'metadata': {
+ 'display_name': new_display_name_2
+ }
+ }
+ )
+ self.verify_publish_state(self.problem_usage_key, PublishState.public)
+ published = modulestore().get_item(
+ self.problem_usage_key,
+ revision=ModuleStoreEnum.RevisionOption.published_only
+ )
+ self.assertEqual(published.display_name, new_display_name_2)
+
def _make_draft_content_different_from_published(self):
"""
Helper method to create different draft and published versions of a problem.
diff --git a/cms/static/js/models/xblock_info.js b/cms/static/js/models/xblock_info.js
index 46fccebf73..8cee477c0e 100644
--- a/cms/static/js/models/xblock_info.js
+++ b/cms/static/js/models/xblock_info.js
@@ -38,7 +38,7 @@ define(["backbone", "underscore", "js/utils/module"], function(Backbone, _, Modu
* If true, only course staff can see the xblock regardless of publish status or
* release date status.
*/
- "locked": null,
+ "visible_to_staff_only": null,
/**
* Date of the last edit to this xblock or any of its descendants.
*/
@@ -69,7 +69,12 @@ define(["backbone", "underscore", "js/utils/module"], function(Backbone, _, Modu
* this will either be the parent subsection or the grandparent section.
* This can be null if the release date is unscheduled.
*/
- "release_date_from":null
+ "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.
+ */
+ "currently_visible_to_students": null
},
parse: function(response) {
diff --git a/cms/static/js/spec/views/pages/container_spec.js b/cms/static/js/spec/views/pages/container_spec.js
index a6e5a3c02c..c1db0bb0e1 100644
--- a/cms/static/js/spec/views/pages/container_spec.js
+++ b/cms/static/js/spec/views/pages/container_spec.js
@@ -48,7 +48,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
);
};
- renderContainerPage = function(html, test, options) {
+ renderContainerPage = function(test, html, options) {
requests = create_sinon.requests(test);
containerPage = new ContainerPage(_.extend(options || {}, {
model: model,
@@ -70,7 +70,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
describe("Initial display", function() {
it('can render itself', function() {
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
expect(containerPage.$('.xblock-header').length).toBe(9);
expect(containerPage.$('.wrapper-xblock .level-nesting')).not.toHaveClass('is-hidden');
});
@@ -84,7 +84,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
});
it('inline edits the display name when performing a new action', function() {
- renderContainerPage(mockContainerXBlockHtml, this, {
+ renderContainerPage(this, mockContainerXBlockHtml, {
action: 'new'
});
expect(containerPage.$('.xblock-header').length).toBe(9);
@@ -106,8 +106,8 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
};
expectEditCanceled = function(test, options) {
- var initialRequests, displayNameWrapper;
- renderContainerPage(mockContainerXBlockHtml, test);
+ var initialRequests, displayNameWrapper, displayNameInput;
+ renderContainerPage(test, mockContainerXBlockHtml);
displayNameWrapper = getDisplayNameWrapper();
initialRequests = requests.length;
displayNameInput = edit_helpers.inlineEdit(displayNameWrapper, options.newTitle);
@@ -125,7 +125,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('can edit itself', function() {
var editButtons, displayNameElement;
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
displayNameElement = containerPage.$('.page-header-title');
// Click the root edit button
@@ -162,7 +162,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('can inline edit the display name', function() {
var displayNameInput, displayNameWrapper;
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
displayNameWrapper = getDisplayNameWrapper();
displayNameInput = edit_helpers.inlineEdit(displayNameWrapper, updatedDisplayName);
displayNameInput.change();
@@ -176,7 +176,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('does not change the title when a display name update fails', function() {
var initialRequests, displayNameInput, displayNameWrapper;
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
displayNameWrapper = getDisplayNameWrapper();
displayNameInput = edit_helpers.inlineEdit(displayNameWrapper, updatedDisplayName);
initialRequests = requests.length;
@@ -190,7 +190,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('trims whitespace from the display name', function() {
var displayNameInput, displayNameWrapper;
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
displayNameWrapper = getDisplayNameWrapper();
displayNameInput = edit_helpers.inlineEdit(displayNameWrapper, updatedDisplayName + ' ');
displayNameInput.change();
@@ -222,7 +222,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('can show an edit modal for a child xblock', function() {
var editButtons;
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
editButtons = containerPage.$('.wrapper-xblock .edit-button');
// The container should have rendered six mock xblocks
expect(editButtons.length).toBe(6);
@@ -258,7 +258,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('can save changes to settings', function() {
var editButtons, modal, mockUpdatedXBlockHtml;
mockUpdatedXBlockHtml = readFixtures('mock/mock-updated-xblock.underscore');
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
editButtons = containerPage.$('.wrapper-xblock .edit-button');
// The container should have rendered six mock xblocks
expect(editButtons.length).toBe(6);
@@ -346,24 +346,24 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
};
it("can delete the first xblock", function() {
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
deleteComponentWithSuccess(0);
});
it("can delete a middle xblock", function() {
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
deleteComponentWithSuccess(1);
});
it("can delete the last xblock", function() {
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
deleteComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1);
});
it('does not delete when clicking No in prompt', function () {
var numRequests;
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
numRequests = requests.length;
// click delete on the first component but press no
@@ -378,7 +378,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('shows a notification during the delete operation', function() {
var notificationSpy = edit_helpers.createNotificationSpy();
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
clickDelete(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Deleting/);
create_sinon.respondWithJson(requests, {});
@@ -387,7 +387,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('does not delete an xblock upon failure', function () {
var notificationSpy = edit_helpers.createNotificationSpy();
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
clickDelete(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Deleting/);
create_sinon.respondWithError(requests);
@@ -431,23 +431,23 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
};
it("can duplicate the first xblock", function() {
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
duplicateComponentWithSuccess(0);
});
it("can duplicate a middle xblock", function() {
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
duplicateComponentWithSuccess(1);
});
it("can duplicate the last xblock", function() {
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
duplicateComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1);
});
it('shows a notification when duplicating', function () {
var notificationSpy = edit_helpers.createNotificationSpy();
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
clickDuplicate(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
create_sinon.respondWithJson(requests, {"locator": "new_item"});
@@ -456,7 +456,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('does not duplicate an xblock upon failure', function () {
var notificationSpy = edit_helpers.createNotificationSpy();
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
refreshXBlockSpies = spyOn(containerPage, "refreshXBlock");
clickDuplicate(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
@@ -475,7 +475,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
};
it('sends the correct JSON to the server', function () {
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
clickNewComponent(0);
edit_helpers.verifyXBlockRequest(requests, {
"category": "discussion",
@@ -486,7 +486,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('shows a notification while creating', function () {
var notificationSpy = edit_helpers.createNotificationSpy();
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
clickNewComponent(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Adding/);
create_sinon.respondWithJson(requests, { });
@@ -495,7 +495,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('does not insert component upon failure', function () {
var requestCount;
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
clickNewComponent(0);
requestCount = requests.length;
create_sinon.respondWithError(requests);
@@ -514,7 +514,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
verifyCreateHtmlComponent = function(test, templateIndex, expectedRequest) {
var xblockCount;
- renderContainerPage(mockContainerXBlockHtml, test);
+ renderContainerPage(test, mockContainerXBlockHtml);
showTemplatePicker();
xblockCount = containerPage.$('.studio-xblock-wrapper').length;
containerPage.$('.new-component-html a')[templateIndex].click();
diff --git a/cms/static/js/spec/views/pages/container_subviews_spec.js b/cms/static/js/spec/views/pages/container_subviews_spec.js
index b7f427787d..6aaaedc6ef 100644
--- a/cms/static/js/spec/views/pages/container_subviews_spec.js
+++ b/cms/static/js/spec/views/pages/container_subviews_spec.js
@@ -1,11 +1,12 @@
define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers",
- "js/views/feedback_prompt", "js/views/pages/container", "js/views/pages/container_subviews",
- "js/models/xblock_info"],
+ "js/views/feedback_prompt", "js/views/pages/container", "js/views/pages/container_subviews",
+ "js/models/xblock_info"],
function ($, _, str, create_sinon, edit_helpers, Prompt, ContainerPage, ContainerSubviews, XBlockInfo) {
describe("Container Subviews", function() {
- var model, containerPage, requests, renderContainerPage, respondWithHtml, respondWithJson, fetch,
- disabledCss = "is-disabled",
+ var model, containerPage, requests, createContainerPage, renderContainerPage,
+ respondWithHtml, respondWithJson, fetch,
+ disabledCss = "is-disabled", defaultXBlockInfo, createXBlockInfo,
mockContainerPage = readFixtures('mock/mock-container-page.underscore'),
mockContainerXBlockHtml = readFixtures('mock/mock-empty-container-xblock.underscore');
@@ -14,27 +15,39 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
edit_helpers.installTemplate('publish-xblock');
edit_helpers.installTemplate('publish-history');
edit_helpers.installTemplate('unit-outline');
+ edit_helpers.installTemplate('container-message');
appendSetFixtures(mockContainerPage);
+ });
- model = new XBlockInfo({
- id: 'locator-container',
- display_name: 'Test Container',
- category: 'vertical',
- published: false,
- has_changes: false
- }, {
- parse: true
- });
+ defaultXBlockInfo = {
+ id: 'locator-container',
+ display_name: 'Test Container',
+ category: 'vertical',
+ published: false,
+ has_changes: false,
+ edited_on: "Jul 02, 2014 at 14:20 UTC", edited_by: "joe",
+ published_on: "Jul 01, 2014 at 12:45 UTC", published_by: "amako",
+ visible_to_staff_only: false,
+ currently_visible_to_students: false
+ };
+
+ createXBlockInfo = function(options) {
+ return _.extend(_.extend({}, defaultXBlockInfo), options || {});
+ };
+
+ createContainerPage = function (test, options) {
+ requests = create_sinon.requests(test);
+ model = new XBlockInfo(createXBlockInfo(options), { parse: true });
containerPage = new ContainerPage({
model: model,
templates: edit_helpers.mockComponentTemplates,
el: $('#content'),
isUnitPage: true
});
- });
+ };
- renderContainerPage = function(html, that) {
- requests = create_sinon.requests(that);
+ renderContainerPage = function (test, html, options) {
+ createContainerPage(test, options);
containerPage.render();
respondWithHtml(html);
};
@@ -57,6 +70,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
};
fetch = function (json) {
+ json = createXBlockInfo(json);
model.fetch();
respondWithJson(json);
};
@@ -66,30 +80,30 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
previewCss = '.button-preview';
it('renders correctly for private unit', function () {
- renderContainerPage(mockContainerXBlockHtml, this);
+ renderContainerPage(this, mockContainerXBlockHtml);
expect(containerPage.$(viewPublishedCss)).toHaveClass(disabledCss);
expect(containerPage.$(previewCss)).not.toHaveClass(disabledCss);
});
it('updates when published attribute changes', function () {
- renderContainerPage(mockContainerXBlockHtml, this);
- fetch({"id": "locator-container", "published": true});
+ renderContainerPage(this, mockContainerXBlockHtml);
+ fetch({"published": true});
expect(containerPage.$(viewPublishedCss)).not.toHaveClass(disabledCss);
- fetch({"id": "locator-container", "published": false});
+ fetch({"published": false});
expect(containerPage.$(viewPublishedCss)).toHaveClass(disabledCss);
});
it('updates when has_changes attribute changes', function () {
- renderContainerPage(mockContainerXBlockHtml, this);
- fetch({"id": "locator-container", "has_changes": true});
+ renderContainerPage(this, mockContainerXBlockHtml);
+ fetch({"has_changes": true});
expect(containerPage.$(previewCss)).not.toHaveClass(disabledCss);
- fetch({"id": "locator-container", "published": true, "has_changes": false});
+ fetch({"published": true, "has_changes": false});
expect(containerPage.$(previewCss)).toHaveClass(disabledCss);
// If published is false, preview is always enabled.
- fetch({"id": "locator-container", "published": false, "has_changes": false});
+ fetch({"published": false, "has_changes": false});
expect(containerPage.$(previewCss)).not.toHaveClass(disabledCss);
});
});
@@ -97,21 +111,21 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
describe("Publisher", function () {
var headerCss = '.pub-status',
bitPublishingCss = "div.bit-publishing",
- publishedBit = "published",
- draftBit = "draft",
+ publishedBit = "is-published",
+ draftBit = "is-draft",
+ staffOnlyBit = "is-staff-only",
publishButtonCss = ".action-publish",
discardChangesButtonCss = ".action-discard",
lastDraftCss = ".wrapper-last-draft",
releaseDateTitleCss = ".wrapper-release .title",
releaseDateContentCss = ".wrapper-release .copy",
- lastRequest, promptSpies, sendDiscardChangesToServer;
+ promptSpies, sendDiscardChangesToServer;
- lastRequest = function() { return requests[requests.length - 1]; };
-
- sendDiscardChangesToServer = function(test) {
+ sendDiscardChangesToServer = function() {
// Helper function to do the discard operation, up until the server response.
- renderContainerPage(mockContainerXBlockHtml, test);
- fetch({"id": "locator-container", "published": true, "has_changes": true});
+ containerPage.render();
+ respondWithHtml(mockContainerXBlockHtml);
+ fetch({"published": true, "has_changes": true});
expect(containerPage.$(discardChangesButtonCss)).not.toHaveClass('is-disabled');
expect(containerPage.$(bitPublishingCss)).toHaveClass(draftBit);
// Click discard changes
@@ -132,30 +146,30 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
});
it('renders correctly with private content', function () {
- var verifyPrivateState = function(){
- // State is the same regardless of "has_changes" value.
- expect(containerPage.$(headerCss).text()).toContain('Draft (Unpublished changes)');
+ var verifyPrivateState = function() {
+ expect(containerPage.$(headerCss).text()).toContain('Draft (Never published)');
expect(containerPage.$(publishButtonCss)).not.toHaveClass(disabledCss);
expect(containerPage.$(discardChangesButtonCss)).toHaveClass(disabledCss);
- expect(containerPage.$(bitPublishingCss)).toHaveClass(draftBit);
+ expect(containerPage.$(bitPublishingCss)).not.toHaveClass(draftBit);
+ expect(containerPage.$(bitPublishingCss)).not.toHaveClass(publishedBit);
};
- renderContainerPage(mockContainerXBlockHtml, this);
- fetch({"id": "locator-container", "published": false, "has_changes": false});
+ renderContainerPage(this, mockContainerXBlockHtml);
+ fetch({"published": false, "has_changes": false});
verifyPrivateState();
- fetch({"id": "locator-container", "published": false, "has_changes": true});
+ fetch({"published": false, "has_changes": true});
verifyPrivateState();
});
it('renders correctly with public content', function () {
- renderContainerPage(mockContainerXBlockHtml, this);
- fetch({"id": "locator-container", "published": true, "has_changes": false});
+ renderContainerPage(this, mockContainerXBlockHtml);
+ fetch({"published": true, "has_changes": false});
expect(containerPage.$(headerCss).text()).toContain('Published');
expect(containerPage.$(publishButtonCss)).toHaveClass(disabledCss);
expect(containerPage.$(discardChangesButtonCss)).toHaveClass(disabledCss);
expect(containerPage.$(bitPublishingCss)).toHaveClass(publishedBit);
- fetch({"id": "locator-container", "published": true, "has_changes": true});
+ fetch({"published": true, "has_changes": true});
expect(containerPage.$(headerCss).text()).toContain('Draft (Unpublished changes)');
expect(containerPage.$(publishButtonCss)).not.toHaveClass(disabledCss);
expect(containerPage.$(discardChangesButtonCss)).not.toHaveClass(disabledCss);
@@ -164,9 +178,10 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('can publish private content', function () {
var notificationSpy = edit_helpers.createNotificationSpy();
- renderContainerPage(mockContainerXBlockHtml, this);
- fetch({"id": "locator-container", "published": false, "has_changes": false});
- expect(containerPage.$(bitPublishingCss)).toHaveClass(draftBit);
+ renderContainerPage(this, mockContainerXBlockHtml);
+ fetch({"published": false, "has_changes": false});
+ expect(containerPage.$(bitPublishingCss)).not.toHaveClass(draftBit);
+ expect(containerPage.$(bitPublishingCss)).not.toHaveClass(publishedBit);
// Click publish
containerPage.$(publishButtonCss).click();
@@ -191,9 +206,9 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
});
it('can does not fetch if publish fails', function () {
- renderContainerPage(mockContainerXBlockHtml, this);
- fetch({"id": "locator-container", "published": false, "has_changes": false});
- expect(containerPage.$(bitPublishingCss)).toHaveClass(draftBit);
+ renderContainerPage(this, mockContainerXBlockHtml);
+ fetch({"published": false});
+ expect(containerPage.$(bitPublishingCss)).not.toHaveClass(publishedBit);
// Click publish
containerPage.$(publishButtonCss).click();
@@ -205,17 +220,18 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
expect(requests.length).toEqual(numRequests);
// Verify still in draft state.
- expect(containerPage.$(bitPublishingCss)).toHaveClass(draftBit);
+ expect(containerPage.$(bitPublishingCss)).not.toHaveClass(publishedBit);
// Verify that the "published" value has been cleared out of the model.
expect(containerPage.model.get("publish")).toBeNull();
});
it('can discard changes', function () {
- var notificationSpy = edit_helpers.createNotificationSpy(),
- renderPageSpy = spyOn(containerPage.xblockPublisher, 'renderPage').andCallThrough(),
- numRequests;
+ var notificationSpy, renderPageSpy, numRequests;
+ createContainerPage(this);
+ notificationSpy = edit_helpers.createNotificationSpy();
+ renderPageSpy = spyOn(containerPage.xblockPublisher, 'renderPage').andCallThrough();
- sendDiscardChangesToServer(this);
+ sendDiscardChangesToServer();
numRequests = requests.length;
// Respond with success.
@@ -230,10 +246,11 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
});
it('does not fetch if discard changes fails', function () {
- var renderPageSpy = spyOn(containerPage.xblockPublisher, 'renderPage').andCallThrough(),
- numRequests;
+ var renderPageSpy, numRequests;
+ createContainerPage(this);
+ renderPageSpy = spyOn(containerPage.xblockPublisher, 'renderPage').andCallThrough();
- sendDiscardChangesToServer(this);
+ sendDiscardChangesToServer();
numRequests = requests.length;
// Respond with failure
@@ -246,8 +263,8 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
});
it('does not discard changes on cancel', function () {
- renderContainerPage(mockContainerXBlockHtml, this);
- fetch({"id": "locator-container", "published": true, "has_changes": true});
+ renderContainerPage(this, mockContainerXBlockHtml);
+ fetch({"published": true, "has_changes": true});
var numRequests = requests.length;
// Click discard changes
@@ -262,84 +279,213 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
});
it('renders the last published date and user when there are no changes', function () {
- renderContainerPage(mockContainerXBlockHtml, this);
- fetch({ "id": "locator-container", "has_changes": false,
- "edited_on": "Jun 30, 2014 at 14:20 UTC", "edited_by": "joe",
- "published_on": "Jul 01, 2014 at 12:45 UTC", "published_by": "amako"});
+ renderContainerPage(this, mockContainerXBlockHtml);
+ fetch({"published_on": "Jul 01, 2014 at 12:45 UTC", "published_by": "amako"});
expect(containerPage.$(lastDraftCss).text()).
toContain("Last published Jul 01, 2014 at 12:45 UTC by amako");
});
it('renders the last saved date and user when there are changes', function () {
- renderContainerPage(mockContainerXBlockHtml, this);
- fetch({ "id": "locator-container", "has_changes": true,
- "edited_on": "Jul 02, 2014 at 14:20 UTC", "edited_by": "joe",
- "published_on": "Jul 01, 2014 at 12:45 UTC", "published_by": "amako"});
+ renderContainerPage(this, mockContainerXBlockHtml);
+ fetch({"has_changes": true, "edited_on": "Jul 02, 2014 at 14:20 UTC", "edited_by": "joe"});
expect(containerPage.$(lastDraftCss).text()).
toContain("Draft saved on Jul 02, 2014 at 14:20 UTC by joe");
});
- it('renders the release date correctly when unreleased', function () {
- renderContainerPage(mockContainerXBlockHtml, this);
- fetch({ "id": "locator-container", "published": true, "released_to_students": false,
- "release_date": "Jul 02, 2014 at 14:20 UTC", "release_date_from": 'Section "Week 1"'});
- expect(containerPage.$(releaseDateTitleCss).text()).toContain("Scheduled:");
- expect(containerPage.$(releaseDateContentCss).text()).
- toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"');
+ describe("Release Date", function() {
+ it('renders correctly when unreleased', function () {
+ renderContainerPage(this, mockContainerXBlockHtml);
+ fetch({"published": true, "released_to_students": false,
+ "release_date": "Jul 02, 2014 at 14:20 UTC", "release_date_from": 'Section "Week 1"'});
+ expect(containerPage.$(releaseDateTitleCss).text()).toContain("Scheduled:");
+ expect(containerPage.$(releaseDateContentCss).text()).
+ toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"');
+ });
+
+ it('renders correctly when released', function () {
+ renderContainerPage(this, mockContainerXBlockHtml);
+ fetch({"published": true, "released_to_students": true,
+ "release_date": "Jul 02, 2014 at 14:20 UTC", "release_date_from": 'Section "Week 1"' });
+ expect(containerPage.$(releaseDateTitleCss).text()).toContain("Released:");
+ expect(containerPage.$(releaseDateContentCss).text()).
+ toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"');
+ });
+
+ it('renders correctly when the release date is not set', function () {
+ renderContainerPage(this, mockContainerXBlockHtml);
+ fetch({"published": true, "released_to_students": false,
+ "release_date": null, "release_date_from": null });
+ expect(containerPage.$(releaseDateTitleCss).text()).toContain("Release:");
+ expect(containerPage.$(releaseDateContentCss).text()).toContain("Unscheduled");
+ });
+
+ it('renders correctly when the unit is not published', function () {
+ renderContainerPage(this, mockContainerXBlockHtml);
+ fetch({"published": false, "released_to_students": true,
+ "release_date": "Jul 02, 2014 at 14:20 UTC", "release_date_from": 'Section "Week 1"' });
+ // Force a render because none of the fetched fields will trigger a render
+ containerPage.xblockPublisher.render();
+ expect(containerPage.$(releaseDateTitleCss).text()).toContain("Release:");
+ expect(containerPage.$(releaseDateContentCss).text()).
+ toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"');
+ });
});
- it('renders the release date correctly when released', function () {
- renderContainerPage(mockContainerXBlockHtml, this);
- fetch({ "id": "locator-container", "published": true, "released_to_students": true,
- "release_date": "Jul 02, 2014 at 14:20 UTC", "release_date_from": 'Section "Week 1"' });
- expect(containerPage.$(releaseDateTitleCss).text()).toContain("Released:");
- expect(containerPage.$(releaseDateContentCss).text()).
- toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"');
- });
+ describe("Content Visibility", function () {
+ var requestStaffOnly, verifyStaffOnly, promptSpy;
- it('renders the release date correctly when the release date is not set', function () {
- renderContainerPage(mockContainerXBlockHtml, this);
- fetch({ "id": "locator-container", "published": true, "released_to_students": false,
- "release_date": null, "release_date_from": null });
- expect(containerPage.$(releaseDateTitleCss).text()).toContain("Release:");
- expect(containerPage.$(releaseDateContentCss).text()).toContain("Unscheduled");
- });
+ requestStaffOnly = function(isStaffOnly) {
+ containerPage.$('.action-staff-lock').click();
- it('renders the release date correctly when the unit is not published', function () {
- renderContainerPage(mockContainerXBlockHtml, this);
- fetch({ "id": "locator-container", "published": false, "released_to_students": true,
- "release_date": "Jul 02, 2014 at 14:20 UTC", "release_date_from": 'Section "Week 1"' });
- // Force a render because none of the fetched fields will trigger a render
- containerPage.xblockPublisher.render();
- expect(containerPage.$(releaseDateTitleCss).text()).toContain("Release:");
- expect(containerPage.$(releaseDateContentCss).text()).
- toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"');
+ // If removing the staff lock, click 'Yes' to confirm
+ if (!isStaffOnly) {
+ edit_helpers.confirmPrompt(promptSpy);
+ }
+
+ create_sinon.expectJsonRequest(requests, 'POST', '/xblock/locator-container', {
+ publish: 'republish',
+ metadata: { visible_to_staff_only: isStaffOnly }
+ });
+ create_sinon.respondWithJson(requests, {
+ data: null,
+ id: "locator-container",
+ metadata: {
+ visible_to_staff_only: isStaffOnly
+ }
+ });
+ create_sinon.expectJsonRequest(requests, 'GET', '/xblock/locator-container');
+ create_sinon.respondWithJson(requests, createXBlockInfo({
+ published: containerPage.model.get('published'),
+ visible_to_staff_only: isStaffOnly
+ }));
+ };
+
+ verifyStaffOnly = function(isStaffOnly) {
+ if (isStaffOnly) {
+ expect(containerPage.$('.action-staff-lock i')).toHaveClass('icon-check');
+ expect(containerPage.$('.wrapper-visibility .copy').text()).toBe('Staff Only');
+ expect(containerPage.$(bitPublishingCss)).toHaveClass(staffOnlyBit);
+ } else {
+ expect(containerPage.$('.action-staff-lock i')).toHaveClass('icon-check-empty');
+ expect(containerPage.$('.wrapper-visibility .copy').text()).toBe('Staff and Students');
+ expect(containerPage.$(bitPublishingCss)).not.toHaveClass(staffOnlyBit);
+ }
+ };
+
+ it("is initially shown to all", function() {
+ renderContainerPage(this, mockContainerXBlockHtml);
+ verifyStaffOnly(false);
+ });
+
+ it("can be set to staff only", function() {
+ renderContainerPage(this, mockContainerXBlockHtml);
+ containerPage.$('.action-staff-lock').click();
+ requestStaffOnly(true);
+ verifyStaffOnly(true);
+ });
+
+ it("can remove staff only setting", function() {
+ promptSpy = edit_helpers.createPromptSpy();
+ renderContainerPage(this, mockContainerXBlockHtml);
+ requestStaffOnly(true);
+ requestStaffOnly(false);
+ verifyStaffOnly(false);
+ expect(containerPage.$(bitPublishingCss)).not.toHaveClass(publishedBit);
+ });
+
+ it("can remove staff only setting from published unit", function() {
+ promptSpy = edit_helpers.createPromptSpy();
+ renderContainerPage(this, mockContainerXBlockHtml, { published: true });
+ requestStaffOnly(true);
+ requestStaffOnly(false);
+ verifyStaffOnly(false);
+ expect(containerPage.$(bitPublishingCss)).toHaveClass(publishedBit);
+ });
+
+ it("does not refresh if removing staff only is canceled", function() {
+ var requestCount;
+ promptSpy = edit_helpers.createPromptSpy();
+ renderContainerPage(this, mockContainerXBlockHtml);
+ requestStaffOnly(true);
+ requestCount = requests.length;
+ containerPage.$('.action-staff-lock').click();
+ edit_helpers.confirmPrompt(promptSpy, true); // Click 'No' to cancel
+ expect(requests.length).toBe(requestCount);
+ verifyStaffOnly(true);
+ });
+
+ it("does not refresh when failing to set staff only", function() {
+ var requestCount;
+ renderContainerPage(this, mockContainerXBlockHtml);
+ containerPage.$('.lock-checkbox').click();
+ requestCount = requests.length;
+ create_sinon.respondWithError(requests);
+ expect(requests.length).toBe(requestCount);
+ verifyStaffOnly(false);
+ });
});
});
describe("PublishHistory", function () {
var lastPublishCss = ".wrapper-last-publish";
- it('renders the last published date and user when the block is published', function () {
- renderContainerPage(mockContainerXBlockHtml, this);
- fetch({ "id": "locator-container", "published": true,
- "published_on": "Jul 01, 2014 at 12:45 UTC", "published_by": "amako" });
+ it('renders the last published date and user when the block is published', function() {
+ renderContainerPage(this, mockContainerXBlockHtml);
+ fetch({
+ "published": true, "published_on": "Jul 01, 2014 at 12:45 UTC", "published_by": "amako"
+ });
expect(containerPage.$(lastPublishCss).text()).
toContain("Last published Jul 01, 2014 at 12:45 UTC by amako");
});
it('renders never published when the block is unpublished', function () {
- renderContainerPage(mockContainerXBlockHtml, this);
- fetch({ "id": "locator-container", "published": false,
- "published_on": "Jul 01, 2014 at 12:45 UTC", "published_by": "amako" });
+ renderContainerPage(this, mockContainerXBlockHtml);
+ fetch({ "published": false });
expect(containerPage.$(lastPublishCss).text()).toContain("Never published");
});
it('renders correctly when the block is published without publish info', function () {
- renderContainerPage(mockContainerXBlockHtml, this);
- fetch({ "id": "locator-container", "published": true, "published_on": null, "published_by": null});
+ renderContainerPage(this, mockContainerXBlockHtml);
+ fetch({
+ "published": true, "published_on": null, "published_by": null
+ });
expect(containerPage.$(lastPublishCss).text()).toContain("Previously published");
});
});
+
+ describe("Message Area", function() {
+ var messageSelector = '.container-message .warning',
+ warningMessage = 'This content is live for students. Edit with caution.';
+
+ it('is empty for a unit that is not currently visible to students', function() {
+ renderContainerPage(this, mockContainerXBlockHtml, {
+ currently_visible_to_students: false
+ });
+ expect(containerPage.$(messageSelector).text().trim()).toBe('');
+ });
+
+ it('shows a message for a unit that is currently visible to students', function() {
+ renderContainerPage(this, mockContainerXBlockHtml, {
+ currently_visible_to_students: true
+ });
+ expect(containerPage.$(messageSelector).text().trim()).toBe(warningMessage);
+ });
+
+ it('hides the message when the unit is hidden from students', function() {
+ renderContainerPage(this, mockContainerXBlockHtml, {
+ currently_visible_to_students: true
+ });
+ fetch({ currently_visible_to_students: false });
+ expect(containerPage.$(messageSelector).text().trim()).toBe('');
+ });
+
+ it('shows a message when a unit is made visible', function() {
+ renderContainerPage(this, mockContainerXBlockHtml, {
+ currently_visible_to_students: false
+ });
+ fetch({ currently_visible_to_students: true });
+ expect(containerPage.$(messageSelector).text().trim()).toBe(warningMessage);
+ });
+ });
});
});
diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js
index 5d43ff6ed0..25806b1fb1 100644
--- a/cms/static/js/views/pages/container.js
+++ b/cms/static/js/views/pages/container.js
@@ -29,6 +29,11 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
model: this.model,
view: this.view
});
+ this.messageView = new ContainerSubviews.MessageView({
+ el: this.$('.container-message'),
+ model: this.model
+ });
+ this.messageView.render();
this.isUnitPage = this.options.isUnitPage;
if (this.isUnitPage) {
this.xblockPublisher = new ContainerSubviews.Publisher({
diff --git a/cms/static/js/views/pages/container_subviews.js b/cms/static/js/views/pages/container_subviews.js
index 2ae82ccaaa..7bf27078a8 100644
--- a/cms/static/js/views/pages/container_subviews.js
+++ b/cms/static/js/views/pages/container_subviews.js
@@ -10,7 +10,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/
* A view that calls render when "has_changes" or "published" values in XBlockInfo have changed
* after a server sync operation.
*/
- var UnitStateListenerView = BaseView.extend({
+ var ContainerStateListenerView = BaseView.extend({
// takes XBlockInfo as a model
initialize: function() {
@@ -18,18 +18,43 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/
},
onSync: function(model) {
- if (ViewUtils.hasChangedAttributes(model, ['has_changes', 'published'])) {
+ if (this.shouldRefresh(model)) {
this.render();
}
},
+ shouldRefresh: function(model) {
+ return false;
+ },
+
render: function() {}
});
+ var MessageView = ContainerStateListenerView.extend({
+ initialize: function () {
+ ContainerStateListenerView.prototype.initialize.call(this);
+ this.template = this.loadTemplate('container-message');
+ },
+
+ shouldRefresh: function(model) {
+ return ViewUtils.hasChangedAttributes(model, ['currently_visible_to_students']);
+ },
+
+ render: function() {
+ this.$el.html(this.template({
+ currentlyVisibleToStudents: this.model.get('currently_visible_to_students')
+ }));
+ return this;
+ }
+ });
+
/**
* A controller for updating the "View Live" and "Preview" buttons.
*/
- var PreviewActionController = UnitStateListenerView.extend({
+ var PreviewActionController = ContainerStateListenerView.extend({
+ shouldRefresh: function(model) {
+ return ViewUtils.hasChangedAttributes(model, ['has_changes', 'published']);
+ },
render: function() {
var previewAction = this.$el.find('.button-preview'),
@@ -59,7 +84,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/
var Publisher = BaseView.extend({
events: {
'click .action-publish': 'publish',
- 'click .action-discard': 'discardChanges'
+ 'click .action-discard': 'discardChanges',
+ 'click .action-staff-lock': 'toggleStaffLock'
},
// takes XBlockInfo as a model
@@ -72,22 +98,25 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/
},
onSync: function(model) {
- if (ViewUtils.hasChangedAttributes(model, ['has_changes', 'published', 'edited_on', 'edited_by'])) {
+ if (ViewUtils.hasChangedAttributes(model, [
+ 'has_changes', 'published', 'edited_on', 'edited_by', 'visible_to_staff_only'
+ ])) {
this.render();
}
},
render: function () {
this.$el.html(this.template({
- has_changes: this.model.get('has_changes'),
+ hasChanges: this.model.get('has_changes'),
published: this.model.get('published'),
- edited_on: this.model.get('edited_on'),
- edited_by: this.model.get('edited_by'),
- published_on: this.model.get('published_on'),
- published_by: this.model.get('published_by'),
- released_to_students: this.model.get('released_to_students'),
- release_date: this.model.get('release_date'),
- release_date_from: this.model.get('release_date_from')
+ editedOn: this.model.get('edited_on'),
+ editedBy: this.model.get('edited_by'),
+ publishedOn: this.model.get('published_on'),
+ publishedBy: this.model.get('published_by'),
+ releasedToStudents: this.model.get('released_to_students'),
+ releaseDate: this.model.get('release_date'),
+ releaseDateFrom: this.model.get('release_date_from'),
+ visibleToStaffOnly: this.model.get('visible_to_staff_only')
}));
return this;
@@ -127,10 +156,60 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/
});
}
);
+ },
+
+ toggleStaffLock: function (e) {
+ var xblockInfo = this.model, self=this, enableStaffLock,
+ saveAndPublishStaffLock, revertCheckBox;
+ if (e && e.preventDefault) {
+ e.preventDefault();
+ }
+ enableStaffLock = !xblockInfo.get('visible_to_staff_only');
+
+ revertCheckBox = function() {
+ self.checkStaffLock(!enableStaffLock);
+ };
+
+ saveAndPublishStaffLock = function() {
+ return xblockInfo.save({
+ publish: 'republish',
+ metadata: {visible_to_staff_only: enableStaffLock}},
+ {patch: true}
+ ).always(function() {
+ xblockInfo.set("publish", null);
+ }).done(function () {
+ xblockInfo.fetch();
+ }).fail(function() {
+ revertCheckBox();
+ });
+ };
+
+ this.checkStaffLock(enableStaffLock);
+ if (enableStaffLock) {
+ ViewUtils.runOperationShowingMessage(gettext('Setting Staff Lock…'),
+ _.bind(saveAndPublishStaffLock, self));
+ } else {
+ ViewUtils.confirmThenRunOperation(gettext("Remove Staff Lock"),
+ gettext("Are you sure you want to remove the staff lock? Once you publish this unit, it will be released to students on the release date."),
+ gettext("Remove Staff Lock"),
+ function() {
+ ViewUtils.runOperationShowingMessage(gettext('Removing Staff Lock…'),
+ _.bind(saveAndPublishStaffLock, self));
+ },
+ function() {
+ // On cancel, revert the check in the check box
+ revertCheckBox();
+ }
+ );
+ }
+ },
+
+ checkStaffLock: function(check) {
+ this.$('.action-staff-lock i').removeClass('icon-check icon-check-empty');
+ this.$('.action-staff-lock i').addClass(check ? 'icon-check' : 'icon-check-empty');
}
});
-
/**
* PublishHistory displays when and by whom the xblock was last published, if it ever was.
*/
@@ -161,6 +240,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/
});
return {
+ 'MessageView': MessageView,
'PreviewActionController': PreviewActionController,
'Publisher': Publisher,
'PublishHistory': PublishHistory
diff --git a/cms/static/js/views/utils/view_utils.js b/cms/static/js/views/utils/view_utils.js
index 52007c3628..f4f44126a8 100644
--- a/cms/static/js/views/utils/view_utils.js
+++ b/cms/static/js/views/utils/view_utils.js
@@ -33,7 +33,7 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
/**
* Confirms with the user whether to run an operation or not, and then runs it if desired.
*/
- confirmThenRunOperation = function(title, message, actionLabel, operation) {
+ confirmThenRunOperation = function(title, message, actionLabel, operation, onCancelCallback) {
return new PromptView.Warning({
title: title,
message: message,
@@ -48,6 +48,9 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
secondary: {
text: gettext('Cancel'),
click: function(prompt) {
+ if (onCancelCallback) {
+ onCancelCallback();
+ }
return prompt.hide();
}
}
diff --git a/cms/static/sass/_developer.scss b/cms/static/sass/_developer.scss
index dd92d653d4..30859b4094 100644
--- a/cms/static/sass/_developer.scss
+++ b/cms/static/sass/_developer.scss
@@ -11,8 +11,7 @@
//.wrapper-xblock-header {
-.view-outline,
-.view-container {
+.view-outline {
.add-xblock-component {
text-align: center;
diff --git a/cms/static/sass/views/_container.scss b/cms/static/sass/views/_container.scss
index 2a015c3f23..a6957810a8 100644
--- a/cms/static/sass/views/_container.scss
+++ b/cms/static/sass/views/_container.scss
@@ -111,11 +111,15 @@
&.staff-only,
&.is-staff-only {
@extend %bar-module-black;
+
+ &.is-scheduled .wrapper-release .copy {
+ text-decoration: line-through;
+ }
}
.bar-mod-content {
border: 0;
- padding: ($baseline/2) ($baseline*.75) ($baseline*.75) ($baseline*.75);
+ padding: ($baseline/2) ($baseline*.75) ($baseline/4) ($baseline*.75);
.title {
margin-bottom: ($baseline/10);
@@ -123,7 +127,6 @@
}
.wrapper-last-draft {
- padding: ($baseline*.75) ($baseline*.75) ($baseline/4) ($baseline*.75);
.date,
.user {
@@ -145,9 +148,9 @@
font-weight: 600;
}
- .action-inline [class^="icon-"] {
- margin: 0 ($baseline/4);
-
+ [class^="icon-"] {
+ margin-left: ($baseline/4);
+ color: $gray-d1;
}
}
@@ -215,107 +218,7 @@
}
.wrapper-unit-tree-location {
-
- .draggable-drop-indicator {
- display: none;
- }
-
- // need to explicitly set this since the html structure is different than the others
- .section-name:hover {
- background: $blue-l5;
- color: $blue;
- }
-
- .subsection,
- .courseware-unit {
- margin: ($baseline/4) 0 0 ($baseline*.75);
- }
-
- .courseware-unit .section-item {
- background-color: transparent;
- }
-
- .section-item {
- @include transition(background $tmg-avg ease-in-out 0);
- @include box-sizing(border-box);
- @extend %t-copy-sub2;
- width: 100%;
- display: inline-block;
- vertical-align: top;
- overflow: hidden;
- padding: 6px 8px 8px 16px;
- background: $gray-l5;
- white-space: nowrap;
- text-overflow: ellipsis;
- color: $gray;
-
- &:hover {
- background: $blue-l5;
- color: $blue;
- }
-
- &.editing {
- background-color: $orange-l3;
- }
-
- // TODO: update these once we have different pub states
- .public-item {
- color: $black;
- }
-
- .private-item {
- color: $gray-l1;
- }
-
- .draft-item {
- color: $yellow-d1;
- }
-
- .public-item:hover,
- .private-item:hover,
- .draft-item:hover {
- color: $blue;
- }
-
- .draft-item:after,
- .public-item:after,
- .private-item:after {
- @include font-size(9);
- margin-left: 3px;
- font-weight: 600;
- text-transform: uppercase;
- }
-
- .draft-item:after {
- content: "- draft";
- }
-
- .private-item:after {
- content: "- private";
- }
- }
-
- .subsection > .section-item:hover {
- background-color: $gray-l5;
- color: inherit;
- }
-
- .new-unit-item {
- @extend %ui-btn-flat-outline;
- @extend %t-action4;
- width: 90%;
- margin: 0 0 ($baseline/2) ($baseline/4);
- border: 1px solid transparent;
- padding: ($baseline/4) ($baseline/2);
- font-weight: normal;
- color: $gray-l2;
- text-align: left;
-
- &:hover {
- box-shadow: none;
- background-image: none;
- }
- }
+ // tree location-specific styles should go here
}
}
}
diff --git a/cms/templates/container.html b/cms/templates/container.html
index 6cd6391a13..bf611c0a27 100644
--- a/cms/templates/container.html
+++ b/cms/templates/container.html
@@ -25,7 +25,7 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal",
"editor-mode-button", "upload-dialog", "image-modal",
"add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu",
"add-xblock-component-menu-problem", "xblock-string-field-editor", "publish-xblock", "publish-history",
- "unit-outline"]
+ "unit-outline", "container-message"]
%>
<%block name="header_extras">
% for template_name in templates:
@@ -116,16 +116,7 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal",
- % if is_visible_to_students:
-
-
-
-
- ${_("This content is live for students. Edit with caution.")}
-
-
-
- % endif
+
diff --git a/cms/templates/js/container-message.underscore b/cms/templates/js/container-message.underscore
new file mode 100644
index 0000000000..b47fa39578
--- /dev/null
+++ b/cms/templates/js/container-message.underscore
@@ -0,0 +1,8 @@
+<% if (currentlyVisibleToStudents) { %>
+
+
+
+ <%= gettext("This content is live for students. Edit with caution.") %>
+
+
+<% } %>
diff --git a/cms/templates/js/course-outline.underscore b/cms/templates/js/course-outline.underscore
index 1034cf2860..a9d52cfddb 100644
--- a/cms/templates/js/course-outline.underscore
+++ b/cms/templates/js/course-outline.underscore
@@ -14,8 +14,8 @@
<% if (xblockInfo.get('category') === 'vertical') { %>
<%= xblockInfo.get('display_name') %>
<% } else { %>
-
">
- <%= xblockInfo.get('display_name') %>
+ ">
+ <%= xblockInfo.get('display_name') %>
<% } %>
diff --git a/cms/templates/js/mock/mock-container-page.underscore b/cms/templates/js/mock/mock-container-page.underscore
index 87a36330a8..2c39ab5081 100644
--- a/cms/templates/js/mock/mock-container-page.underscore
+++ b/cms/templates/js/mock/mock-container-page.underscore
@@ -43,6 +43,7 @@
+
diff --git a/cms/templates/js/publish-xblock.underscore b/cms/templates/js/publish-xblock.underscore
index 70352ad3b7..f9b30bcb38 100644
--- a/cms/templates/js/publish-xblock.underscore
+++ b/cms/templates/js/publish-xblock.underscore
@@ -1,35 +1,50 @@
-
+<%
+var publishClasses = "";
+var title = gettext("Draft (Never published)");
+if (published) {
+ if (published && hasChanges) {
+ publishClasses = publishClasses + " is-draft";
+ title = gettext("Draft (Unpublished changes)");
+ } else {
+ publishClasses = publishClasses + " is-published";
+ title = gettext("Published");
+ }
+}
+if (releaseDate) {
+ publishClasses = publishClasses + " is-scheduled";
+}
+if (visibleToStaffOnly) {
+ publishClasses = publishClasses + " is-staff-only";
+ title = gettext("Unpublished (Staff only)");
+}
+%>
+
<%= gettext("Publishing Status") %>
- <% if (published && !has_changes) { %>
- <%= gettext("Published") %>
- <% } else { %>
- <%= gettext("Draft (Unpublished changes)") %>
- <% } %>
+ <%= title %>
- <% if (has_changes && edited_on && edited_by) {
+ <% if (hasChanges && editedOn && editedBy) {
var message = gettext("Draft saved on %(last_saved_date)s by %(edit_username)s") %>
<%= interpolate(message, {
- last_saved_date: '' + edited_on + '',
- edit_username: '' + edited_by + '' }, true) %>
- <% } else if (published_on && published_by) {
+ last_saved_date: '' + editedOn + '',
+ edit_username: '' + editedBy + '' }, true) %>
+ <% } else if (publishedOn && publishedBy) {
var message = gettext("Last published %(last_published_date)s by %(publish_username)s"); %>
<%= interpolate(message, {
- last_published_date: '' + published_on + '',
- publish_username: '' + published_by + '' }, true) %>
+ last_published_date: '' + publishedOn + '',
+ publish_username: '' + publishedBy + '' }, true) %>
<% } else { %>
<%= gettext("Previously published") %>
<% } %>
-
- <% if (published && release_date) {
- if (released_to_students) { %>
+ <% if (published && releaseDate) {
+ if (releasedToStudents) { %>
<%= gettext("Released:") %>
<% } else { %>
<%= gettext("Scheduled:") %>
@@ -39,37 +54,45 @@
<% } %>
- <% if (release_date) { %>
+ <% if (releaseDate) { %>
<% var message = gettext("%(release_date)s with %(section_or_subsection)s") %>
<%= interpolate(message, {
- release_date: '' + release_date + '',
- section_or_subsection: '' + release_date_from + '' }, true) %>
+ release_date: '' + releaseDate + '',
+ section_or_subsection: '' + releaseDateFrom + '' }, true) %>
<% } else { %>
<%= gettext("Unscheduled") %>
<% } %>
-
-
-
-
-
-
-
-
-
-
+
+
<%= gettext("Will Be Visible To:") %>
+ <% if (visibleToStaffOnly) { %>
+
<%= gettext("Staff Only") %>
+ <% } else { %>
+
<%= gettext("Staff and Students") %>
+ <% } %>
+
+
+ <% if (visibleToStaffOnly) { %>
+
+ <% } else { %>
+
+ <% } %>
+ <%= gettext('Hide from students') %>
+
+
+
-
- <%= gettext("Publish") %>
-
- <%= gettext("Discard Changes") %>
diff --git a/common/test/acceptance/pages/lms/staff_view.py b/common/test/acceptance/pages/lms/staff_view.py
index 46d814e86c..48dfe7a3d6 100644
--- a/common/test/acceptance/pages/lms/staff_view.py
+++ b/common/test/acceptance/pages/lms/staff_view.py
@@ -10,16 +10,24 @@ class StaffPage(PageObject):
"""
url = None
+ STAFF_STATUS_CSS = '#staffstatus'
def is_browser_on_page(self):
- return self.q(css='#staffstatus').present
+ return self.q(css=self.STAFF_STATUS_CSS).present
@property
def staff_status(self):
"""
Return the current status, either Staff view or Student view
"""
- return self.q(css='#staffstatus').text[0]
+ return self.q(css=self.STAFF_STATUS_CSS).text[0]
+
+ def toggle_staff_view(self):
+ """
+ Toggle between staff view and student view.
+ """
+ self.q(css=self.STAFF_STATUS_CSS).first.click()
+ self.wait_for_ajax()
def open_staff_debug_info(self):
"""
diff --git a/common/test/acceptance/pages/studio/container.py b/common/test/acceptance/pages/studio/container.py
index fc9ff3628f..9cee0ffbbe 100644
--- a/common/test/acceptance/pages/studio/container.py
+++ b/common/test/acceptance/pages/studio/container.py
@@ -81,6 +81,33 @@ class ContainerPage(PageObject):
"""
return self.q(css='.pub-status').first.text[0]
+ @property
+ def release_title(self):
+ """
+ Returns the title before the release date in the publishing sidebar component.
+ """
+ return self.q(css='.wrapper-release .title').first.text[0]
+
+ @property
+ def release_date(self):
+ """
+ Returns the release date of the unit (with ancestor inherited from), as displayed
+ in the publishing sidebar component.
+ """
+ return self.q(css='.wrapper-release .copy').first.text[0]
+
+ @property
+ def currently_visible_to_students(self):
+ """
+ Returns True if the unit is marked as currently visible to students
+ (meaning that a warning is being displayed).
+ """
+ warnings = self.q(css='.container-message .warning')
+ if not warnings.is_present():
+ return False
+ warning_text = warnings.first.text[0]
+ return warning_text == "This content is live for students. Edit with caution."
+
@property
def publish_action(self):
"""
@@ -96,6 +123,22 @@ class ContainerPage(PageObject):
self.q(css='a.button.action-primary').first.click()
self.wait_for_ajax()
+ def toggle_staff_lock(self):
+ """
+ Toggles "hide from students" which enables or disables a staff-only lock.
+
+ Returns True if the lock is now enabled, else False.
+ """
+ class_attribute_values = self.q(css='a.action-staff-lock>i').attrs('class')
+ was_locked_initially = 'icon-check' in class_attribute_values
+ if not was_locked_initially:
+ self.q(css='a.action-staff-lock').first.click()
+ else:
+ click_css(self, 'a.action-staff-lock', 0, require_notification=False)
+ self.q(css='a.button.action-primary').first.click()
+ self.wait_for_ajax()
+ return not was_locked_initially
+
def view_published_version(self):
"""
Clicks "View Published Version", which will open the published version of the unit page in the LMS.
diff --git a/common/test/acceptance/tests/test_lms.py b/common/test/acceptance/tests/test_lms.py
index c39b658e99..eabb2beeda 100644
--- a/common/test/acceptance/tests/test_lms.py
+++ b/common/test/acceptance/tests/test_lms.py
@@ -16,6 +16,7 @@ from ..pages.lms.progress import ProgressPage
from ..pages.lms.dashboard import DashboardPage
from ..pages.lms.video.video import VideoPage
from ..pages.xblock.acid import AcidView
+from ..pages.lms.courseware import CoursewarePage
from ..fixtures.course import CourseFixture, XBlockFixtureDesc, CourseUpdateDesc
@@ -421,3 +422,87 @@ class XBlockAcidChildTest(XBlockAcidBase):
@skip('This will fail until we fix support of children in pure XBlocks')
def test_acid_block(self):
super(XBlockAcidChildTest, self).test_acid_block()
+
+
+class VisibleToStaffOnlyTest(UniqueCourseTest):
+ """
+ Tests that content with visible_to_staff_only set to True cannot be viewed by students.
+ """
+ def setUp(self):
+ super(VisibleToStaffOnlyTest, self).setUp()
+
+ course_fix = CourseFixture(
+ self.course_info['org'],
+ self.course_info['number'],
+ self.course_info['run'],
+ self.course_info['display_name']
+ )
+
+ course_fix.add_children(
+ XBlockFixtureDesc('chapter', 'Test Section').add_children(
+ XBlockFixtureDesc('sequential', 'Subsection With Locked Unit').add_children(
+ XBlockFixtureDesc('vertical', 'Locked Unit', metadata={'visible_to_staff_only': True}).add_children(
+ XBlockFixtureDesc('html', 'Html Child in locked unit', data="Visible only to staff"),
+ ),
+ XBlockFixtureDesc('vertical', 'Unlocked Unit').add_children(
+ XBlockFixtureDesc('html', 'Html Child in unlocked unit', data="Visible only to all"),
+ )
+ ),
+ XBlockFixtureDesc('sequential', 'Unlocked Subsection').add_children(
+ XBlockFixtureDesc('vertical', 'Test Unit').add_children(
+ XBlockFixtureDesc('html', 'Html Child in visible unit', data="Visible to all"),
+ )
+ ),
+ XBlockFixtureDesc('sequential', 'Locked Subsection', metadata={'visible_to_staff_only': True}).add_children(
+ XBlockFixtureDesc('vertical', 'Test Unit').add_children(
+ XBlockFixtureDesc(
+ 'html', 'Html Child in locked subsection', data="Visible only to staff"
+ )
+ )
+ )
+ )
+ ).install()
+
+ self.courseware_page = CoursewarePage(self.browser, self.course_id)
+ self.course_nav = CourseNavPage(self.browser)
+
+ def test_visible_to_staff(self):
+ """
+ Scenario: All content is visible for a user marked is_staff (different from course staff)
+ Given some of the course content has been marked 'visible_to_staff_only'
+ And I am logged on with an account marked 'is_staff'
+ Then I can see all course content
+ """
+ AutoAuthPage(self.browser, username="STAFF_TESTER", email="johndoe_staff@example.com",
+ course_id=self.course_id, staff=True).visit()
+
+ self.courseware_page.visit()
+ self.assertEqual(3, len(self.course_nav.sections['Test Section']))
+
+ self.course_nav.go_to_section("Test Section", "Subsection With Locked Unit")
+ self.assertEqual(["Html Child in locked unit", "Html Child in unlocked unit"], self.course_nav.sequence_items)
+
+ self.course_nav.go_to_section("Test Section", "Unlocked Subsection")
+ self.assertEqual(["Html Child in visible unit"], self.course_nav.sequence_items)
+
+ self.course_nav.go_to_section("Test Section", "Locked Subsection")
+ self.assertEqual(["Html Child in locked subsection"], self.course_nav.sequence_items)
+
+ def test_visible_to_student(self):
+ """
+ Scenario: Content marked 'visible_to_staff_only' is not visible for students in the course
+ Given some of the course content has been marked 'visible_to_staff_only'
+ And I am logged on with an authorized student account
+ Then I can only see content without 'visible_to_staff_only' set to True
+ """
+ AutoAuthPage(self.browser, username="STUDENT_TESTER", email="johndoe_student@example.com",
+ course_id=self.course_id, staff=False).visit()
+
+ self.courseware_page.visit()
+ self.assertEqual(2, len(self.course_nav.sections['Test Section']))
+
+ self.course_nav.go_to_section("Test Section", "Subsection With Locked Unit")
+ self.assertEqual(["Html Child in unlocked unit"], self.course_nav.sequence_items)
+
+ self.course_nav.go_to_section("Test Section", "Unlocked Subsection")
+ self.assertEqual(["Html Child in visible unit"], self.course_nav.sequence_items)
diff --git a/common/test/acceptance/tests/test_studio_container.py b/common/test/acceptance/tests/test_studio_container.py
index 4448d56d63..398fd90e11 100644
--- a/common/test/acceptance/tests/test_studio_container.py
+++ b/common/test/acceptance/tests/test_studio_container.py
@@ -11,9 +11,12 @@ from ..fixtures.course import XBlockFixtureDesc
from ..pages.studio.component_editor import ComponentEditorView
from ..pages.studio.utils import add_discussion
from ..pages.lms.courseware import CoursewarePage
+from ..pages.lms.staff_view import StaffPage
from unittest import skip
from acceptance.tests.base_studio_test import StudioCourseTest
+import datetime
+from bok_choy.promise import Promise
@attr('shard_1')
@@ -46,15 +49,15 @@ class ContainerBase(StudioCourseTest):
container = unit.xblocks[1].go_to_container()
return container
- def go_to_unit_page(self):
+ def go_to_unit_page(self, section_name='Test Section', subsection_name='Test Subsection', unit_name='Test Unit'):
"""
Go to the test unit page.
If make_draft is true, the unit page will be put into draft mode.
"""
self.outline.visit()
- subsection = self.outline.section('Test Section').subsection('Test Subsection')
- return subsection.toggle_expand().unit('Test Unit').go_to()
+ subsection = self.outline.section(section_name).subsection(subsection_name)
+ return subsection.toggle_expand().unit(unit_name).go_to()
def verify_ordering(self, container, expected_orderings):
"""
@@ -379,6 +382,8 @@ class UnitPublishingTest(ContainerBase):
PUBLISHED_STATUS = "Publishing Status\nPublished"
DRAFT_STATUS = "Publishing Status\nDraft (Unpublished changes)"
+ LOCKED_STATUS = "Publishing Status\nUnpublished (Staff only)"
+ RELEASE_TITLE_RELEASED = "RELEASED:"
def setup_fixtures(self):
"""
@@ -393,6 +398,8 @@ class UnitPublishingTest(ContainerBase):
self.course_info['run'],
self.course_info['display_name']
)
+ past_start_date = datetime.datetime(1974, 6, 22)
+ self.past_start_date_text = "Jun 22, 1974 at 00:00 UTC"
course_fix.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children(
@@ -401,6 +408,20 @@ class UnitPublishingTest(ContainerBase):
XBlockFixtureDesc('html', 'Test html', data=self.html_content)
)
)
+ ),
+ XBlockFixtureDesc('chapter', 'Unlocked Section', metadata={'start': past_start_date.isoformat()}).add_children(
+ XBlockFixtureDesc('sequential', 'Unlocked Subsection').add_children(
+ XBlockFixtureDesc('vertical', 'Unlocked Unit').add_children(
+ XBlockFixtureDesc('problem', '', data=self.html_content)
+ )
+ )
+ ),
+ XBlockFixtureDesc('chapter', 'Section With Locked Unit').add_children(
+ XBlockFixtureDesc('sequential', 'Subsection With Locked Unit', metadata={'start': past_start_date.isoformat()}).add_children(
+ XBlockFixtureDesc('vertical', 'Locked Unit', metadata={'visible_to_staff_only': True}).add_children(
+ XBlockFixtureDesc('discussion', '', data=self.html_content)
+ )
+ )
)
).install()
@@ -408,61 +429,220 @@ class UnitPublishingTest(ContainerBase):
def test_publishing(self):
"""
- Test the state changes when a published unit has draft changes.
+ Scenario: The publish title changes based on whether or not draft content exists
+ Given I have a published unit with no unpublished changes
+ When I go to the unit page in Studio
+ Then the title in the Publish information box is "Published"
+ And the Publish button is disabled
+ And when I add a component to the unit
+ Then the title in the Publish information box is "Draft (Unpublished changes)"
+ And the Publish button is enabled
+ And when I click the Publish button
+ Then the title in the Publish information box is "Published"
"""
unit = self.go_to_unit_page()
- self.assertEqual(self.PUBLISHED_STATUS, unit.publish_title)
+ self._verify_publish_title(unit, self.PUBLISHED_STATUS)
+ # Start date set in course fixture to 1970.
+ self._verify_release_date_info(
+ unit, self.RELEASE_TITLE_RELEASED, 'Jan 01, 1970 at 00:00 UTC with Section "Test Section"'
+ )
# Should not be able to click on Publish action -- but I don't know how to test that it is not clickable.
# TODO: continue discussion with Muhammad and Jay about this.
# Add a component to the page so it will have unpublished changes.
add_discussion(unit)
- self.assertEqual(self.DRAFT_STATUS, unit.publish_title)
+ self._verify_publish_title(unit, self.DRAFT_STATUS)
unit.publish_action.click()
unit.wait_for_ajax()
- self.assertEqual(self.PUBLISHED_STATUS, unit.publish_title)
+ self._verify_publish_title(unit, self.PUBLISHED_STATUS)
def test_discard_changes(self):
"""
- Test the state after discard changes.
+ Scenario: The publish title changes after "Discard Changes" is clicked
+ Given I have a published unit with no unpublished changes
+ When I go to the unit page in Studio
+ Then the Discard Changes button is disabled
+ And I add a component to the unit
+ Then the title in the Publish information box is "Draft (Unpublished changes)"
+ And the Discard Changes button is enabled
+ And when I click the Discard Changes button
+ Then the title in the Publish information box is "Published"
"""
unit = self.go_to_unit_page()
add_discussion(unit)
- self.assertEqual(self.DRAFT_STATUS, unit.publish_title)
+ self._verify_publish_title(unit, self.DRAFT_STATUS)
unit.discard_changes()
- self.assertEqual(self.PUBLISHED_STATUS, unit.publish_title)
+ self._verify_publish_title(unit, self.PUBLISHED_STATUS)
def test_view_live_no_changes(self):
"""
- Tests viewing of live with initial published content.
+ Scenario: "View Live" shows published content in LMS
+ Given I have a published unit with no unpublished changes
+ When I go to the unit page in Studio
+ Then the View Live button is enabled
+ And when I click on the View Live button
+ Then I see the published content in LMS
"""
unit = self.go_to_unit_page()
unit.view_published_version()
- self.assertEqual(1, self.courseware.num_xblock_components)
- self.assertEqual('html', self.courseware.xblock_component_type(0))
+ self._verify_components_visible(['html'])
def test_view_live_changes(self):
"""
- Tests that viewing of live with draft content does not show the draft content.
+ Scenario: "View Live" does not show draft content in LMS
+ Given I have a published unit with no unpublished changes
+ When I go to the unit page in Studio
+ And when I add a component to the unit
+ And when I click on the View Live button
+ Then I see the published content in LMS
+ And I do not see the unpublished component
"""
unit = self.go_to_unit_page()
add_discussion(unit)
unit.view_published_version()
- self.assertEqual(1, self.courseware.num_xblock_components)
- self.assertEqual('html', self.courseware.xblock_component_type(0))
+ self._verify_components_visible(['html'])
self.assertEqual(self.html_content, self.courseware.xblock_component_html_content(0))
def test_view_live_after_publish(self):
"""
- Tests viewing of live after creating draft and publishing it.
+ Scenario: "View Live" shows newly published content
+ Given I have a published unit with no unpublished changes
+ When I go to the unit page in Studio
+ And when I add a component to the unit
+ And when I click the Publish button
+ And when I click on the View Live button
+ Then I see the newly published component
"""
unit = self.go_to_unit_page()
add_discussion(unit)
unit.publish_action.click()
unit.view_published_version()
- self.assertEqual(2, self.courseware.num_xblock_components)
- self.assertEqual('html', self.courseware.xblock_component_type(0))
- self.assertEqual('discussion', self.courseware.xblock_component_type(1))
+ self._verify_components_visible(['html', 'discussion'])
+
+ def test_initially_unlocked_visible_to_students(self):
+ """
+ Scenario: An unlocked unit with release date in the past is visible to students
+ Given I have a published unlocked unit with release date in the past
+ When I go to the unit page in Studio
+ Then the unit has a warning that it is visible to students
+ And it is marked as "RELEASED" with release date in the past visible
+ And when I click on the View Live Button
+ And when I view the course as a student
+ Then I see the content in the unit
+ """
+ unit = self.go_to_unit_page("Unlocked Section", "Unlocked Subsection", "Unlocked Unit")
+ self._verify_publish_title(unit, self.PUBLISHED_STATUS)
+ self.assertTrue(unit.currently_visible_to_students)
+ self._verify_release_date_info(
+ unit, self.RELEASE_TITLE_RELEASED, self.past_start_date_text + ' with Section "Unlocked Section"'
+ )
+ unit.view_published_version()
+ self._verify_student_view_visible(['problem'])
+
+ def test_locked_visible_to_staff_only(self):
+ """
+ Scenario: After locking a unit with release date in the past, it is only visible to staff
+ Given I have a published unlocked unit with release date in the past
+ When I go to the unit page in Studio
+ And when I select "Hide from students"
+ Then the unit does not have a warning that it is visible to students
+ And when I click on the View Live Button
+ Then I see the content in the unit when logged in as staff
+ And when I view the course as a student
+ Then I do not see any content in the unit
+ """
+ unit = self.go_to_unit_page("Unlocked Section", "Unlocked Subsection", "Unlocked Unit")
+ checked = unit.toggle_staff_lock()
+ self.assertTrue(checked)
+ self.assertFalse(unit.currently_visible_to_students)
+ self._verify_publish_title(unit, self.LOCKED_STATUS)
+ unit.view_published_version()
+ # Will initially be in staff view, locked component should be visible.
+ self._verify_components_visible(['problem'])
+ # Switch to student view and verify not visible
+ self._verify_student_view_locked()
+
+ def test_initially_locked_not_visible_to_students(self):
+ """
+ Scenario: A locked unit with release date in the past is not visible to students
+ Given I have a published locked unit with release date in the past
+ When I go to the unit page in Studio
+ Then the unit does not have a warning that it is visible to students
+ And it is marked as "RELEASED" with release date in the past visible
+ And when I click on the View Live Button
+ And when I view the course as a student
+ Then I do not see any content in the unit
+ """
+ unit = self.go_to_unit_page("Section With Locked Unit", "Subsection With Locked Unit", "Locked Unit")
+ self._verify_publish_title(unit, self.LOCKED_STATUS)
+ self.assertFalse(unit.currently_visible_to_students)
+ self._verify_release_date_info(
+ unit, self.RELEASE_TITLE_RELEASED,
+ self.past_start_date_text + ' with Subsection "Subsection With Locked Unit"'
+ )
+ unit.view_published_version()
+ self._verify_student_view_locked()
+
+ def test_unlocked_visible_to_all(self):
+ """
+ Scenario: After unlocking a unit with release date in the past, it is visible to both students and staff
+ Given I have a published unlocked unit with release date in the past
+ When I go to the unit page in Studio
+ And when I deselect "Hide from students"
+ Then the unit does have a warning that it is visible to students
+ And when I click on the View Live Button
+ Then I see the content in the unit when logged in as staff
+ And when I view the course as a student
+ Then I see the content in the unit
+ """
+ unit = self.go_to_unit_page("Section With Locked Unit", "Subsection With Locked Unit", "Locked Unit")
+ checked = unit.toggle_staff_lock()
+ self.assertFalse(checked)
+ self._verify_publish_title(unit, self.PUBLISHED_STATUS)
+ self.assertTrue(unit.currently_visible_to_students)
+ unit.view_published_version()
+ # Will initially be in staff view, components always visible.
+ self._verify_components_visible(['discussion'])
+ # Switch to student view and verify visible.
+ self._verify_student_view_visible(['discussion'])
+
+ def _verify_student_view_locked(self):
+ """
+ Verifies no component is visible when viewing as a student.
+ """
+ StaffPage(self.browser).toggle_staff_view()
+ self.assertEqual(0, self.courseware.num_xblock_components)
+
+ def _verify_student_view_visible(self, expected_components):
+ """
+ Verifies expected components are visible when viewing as a student.
+ """
+ StaffPage(self.browser).toggle_staff_view()
+ self._verify_components_visible(expected_components)
+
+ def _verify_components_visible(self, expected_components):
+ """
+ Verifies the expected components are visible (and there are no extras).
+ """
+ self.assertEqual(len(expected_components), self.courseware.num_xblock_components)
+ for index, component in enumerate(expected_components):
+ self.assertEqual(component, self.courseware.xblock_component_type(index))
+
+ def _verify_release_date_info(self, unit, expected_title, expected_date):
+ """
+ Verifies how the release date is displayed in the publishing sidebar.
+ """
+ self.assertEqual(expected_title, unit.release_title)
+ self.assertEqual(expected_date, unit.release_date)
+
+ def _verify_publish_title(self, unit, expected_title):
+ """
+ Waits for the publish title to change to the expected value.
+ """
+ def wait_for_title_change():
+ return (unit.publish_title == expected_title, unit.publish_title)
+
+ Promise(wait_for_title_change, "Publish title incorrect. Found '" + unit.publish_title + "'").fulfill()
# TODO: need to work with Jay/Christine to get testing of "Preview" working.
# def test_preview(self):