Commit d65e887d by Andy Armstrong Committed by cahrens

New Publishing controls on Unit page.

STUD-1707
parent b1eccdf2
...@@ -152,6 +152,7 @@ def xml_only_video(step): ...@@ -152,6 +152,7 @@ def xml_only_video(step):
category='video', category='video',
data='<video youtube="1.00:%s"></video>' % youtube_id, data='<video youtube="1.00:%s"></video>' % youtube_id,
modulestore=store, modulestore=store,
user_id=world.scenario_dict["USER"].id
) )
......
...@@ -23,6 +23,7 @@ from xblock.runtime import Mixologist ...@@ -23,6 +23,7 @@ from xblock.runtime import Mixologist
from contentstore.utils import get_lms_link_for_item, compute_publish_state 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.helpers import get_parent_xblock, is_unit, xblock_type_display_name
from contentstore.views.item import create_xblock_info
from models.settings.course_grading import CourseGradingModel from models.settings.course_grading import CourseGradingModel
from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.keys import UsageKey
...@@ -148,7 +149,7 @@ def container_handler(request, usage_key_string): ...@@ -148,7 +149,7 @@ def container_handler(request, usage_key_string):
usage_key = UsageKey.from_string(usage_key_string) usage_key = UsageKey.from_string(usage_key_string)
try: try:
course, xblock, __ = _get_item_in_course(request, usage_key) course, xblock, lms_link = _get_item_in_course(request, usage_key)
except ItemNotFoundError: except ItemNotFoundError:
return HttpResponseBadRequest() return HttpResponseBadRequest()
...@@ -166,15 +167,38 @@ def container_handler(request, usage_key_string): ...@@ -166,15 +167,38 @@ def container_handler(request, usage_key_string):
parent = get_parent_xblock(parent) parent = get_parent_xblock(parent)
ancestor_xblocks.reverse() ancestor_xblocks.reverse()
subsection = get_parent_xblock(unit) if unit else None assert unit is not None, "Could not determine unit page"
section = get_parent_xblock(subsection) if subsection else None subsection = get_parent_xblock(unit)
# TODO: correct with publishing story. assert subsection is not None, "Could not determine parent subsection from unit " + unicode(unit.location)
unit_publish_state = 'draft' section = get_parent_xblock(subsection)
assert section is not None, "Could not determine ancestor section from unit " + unicode(unit.location)
xblock_info = create_xblock_info(usage_key, xblock)
# Create the link for preview.
preview_lms_base = settings.FEATURES.get('PREVIEW_LMS_BASE')
# need to figure out where this item is in the list of children as the
# preview will need this
index = 1
for child in subsection.get_children():
if child.location == unit.location:
break
index += 1
preview_lms_link = (
u'//{preview_lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'
).format(
preview_lms_base=preview_lms_base,
lms_base=settings.LMS_BASE,
org=course.location.org,
course=course.location.course,
course_name=course.location.name,
section=section.location.name,
subsection=subsection.location.name,
index=index
)
return render_to_response('container.html', { return render_to_response('container.html', {
'context_course': course, # Needed only for display of menus at top of page. 'context_course': course, # Needed only for display of menus at top of page.
'xblock': xblock, 'xblock': xblock,
'unit_publish_state': unit_publish_state,
'xblock_locator': xblock.location, 'xblock_locator': xblock.location,
'unit': unit, 'unit': unit,
'is_unit_page': is_unit_page, 'is_unit_page': is_unit_page,
...@@ -183,6 +207,9 @@ def container_handler(request, usage_key_string): ...@@ -183,6 +207,9 @@ def container_handler(request, usage_key_string):
'new_unit_category': 'vertical', 'new_unit_category': 'vertical',
'ancestor_xblocks': ancestor_xblocks, 'ancestor_xblocks': ancestor_xblocks,
'component_templates': json.dumps(component_templates), 'component_templates': json.dumps(component_templates),
'xblock_info': xblock_info,
'draft_preview_link': preview_lms_link,
'published_preview_link': lms_link,
}) })
else: else:
return HttpResponseBadRequest("Only supports HTML requests") return HttpResponseBadRequest("Only supports HTML requests")
......
...@@ -23,9 +23,13 @@ import xmodule ...@@ -23,9 +23,13 @@ import xmodule
from xmodule.tabs import StaticTab, CourseTabList from xmodule.tabs import StaticTab, CourseTabList
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError, DuplicateItemError from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
from xmodule.x_module import PREVIEW_VIEWS, STUDIO_VIEW, STUDENT_VIEW from xmodule.x_module import PREVIEW_VIEWS, STUDIO_VIEW, STUDENT_VIEW
from contentstore.utils import compute_publish_state
from xmodule.modulestore import PublishState
from django.contrib.auth.models import User
from util.date_utils import get_default_time_display
from util.json_request import expect_json, JsonResponse from util.json_request import expect_json, JsonResponse
...@@ -92,7 +96,7 @@ def xblock_handler(request, usage_key_string): ...@@ -92,7 +96,7 @@ def xblock_handler(request, usage_key_string):
to None! Absent ones will be left alone. to None! Absent ones will be left alone.
:nullout: which metadata fields to set to None :nullout: which metadata fields to set to None
:graderType: change how this unit is graded :graderType: change how this unit is graded
:publish: can be one of three values, 'make_public, 'make_private', or 'create_draft' :publish: can be only one value-- 'make_public'
The JSON representation on the updated xblock (minus children) is returned. 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 if usage_key_string is not specified, create a new xblock instance, either by duplicating
...@@ -183,7 +187,6 @@ def xblock_view_handler(request, usage_key_string, view_name): ...@@ -183,7 +187,6 @@ def xblock_view_handler(request, usage_key_string, view_name):
if 'application/json' in accept_header: if 'application/json' in accept_header:
store = modulestore() store = modulestore()
xblock = store.get_item(usage_key) xblock = store.get_item(usage_key)
is_read_only = _is_xblock_read_only(xblock)
container_views = ['container_preview', 'reorderable_container_child_preview'] container_views = ['container_preview', 'reorderable_container_child_preview']
# wrap the generated fragment in the xmodule_editor div so that the javascript # wrap the generated fragment in the xmodule_editor div so that the javascript
...@@ -216,7 +219,6 @@ def xblock_view_handler(request, usage_key_string, view_name): ...@@ -216,7 +219,6 @@ def xblock_view_handler(request, usage_key_string, view_name):
context = { context = {
'is_pages_view': is_pages_view, # This setting disables the recursive wrapping of xblocks 'is_pages_view': is_pages_view, # This setting disables the recursive wrapping of xblocks
'is_unit_page': is_unit(xblock), 'is_unit_page': is_unit(xblock),
'read_only': is_read_only,
'root_xblock': xblock if (view_name == 'container_preview') else None, 'root_xblock': xblock if (view_name == 'container_preview') else None,
'reorderable_items': reorderable_items 'reorderable_items': reorderable_items
} }
...@@ -249,19 +251,6 @@ def xblock_view_handler(request, usage_key_string, view_name): ...@@ -249,19 +251,6 @@ def xblock_view_handler(request, usage_key_string, view_name):
return HttpResponse(status=406) return HttpResponse(status=406)
def _is_xblock_read_only(xblock):
"""
Returns true if the specified xblock is read-only, meaning that it cannot be edited.
"""
# We allow direct editing of xblocks in DIRECT_ONLY_CATEGORIES (for example, static pages).
# if xblock.category in DIRECT_ONLY_CATEGORIES:
# return False
# component_publish_state = compute_publish_state(xblock)
# return component_publish_state == PublishState.public
# TODO: correct with publishing story.
return False
def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout=None, def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout=None,
grader_type=None, publish=None): grader_type=None, publish=None):
""" """
...@@ -287,19 +276,6 @@ def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout ...@@ -287,19 +276,6 @@ def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout
old_metadata = own_metadata(existing_item) old_metadata = own_metadata(existing_item)
old_content = existing_item.get_explicitly_set_fields_by_scope(Scope.content) old_content = existing_item.get_explicitly_set_fields_by_scope(Scope.content)
if publish:
if publish == 'make_private':
try:
store.unpublish(existing_item.location, user.id),
except ItemNotFoundError:
pass
elif publish == 'create_draft':
try:
store.convert_to_draft(existing_item.location, user.id)
except DuplicateItemError:
pass
if data: if data:
# TODO Allow any scope.content fields not just "data" (exactly like the get below this) # TODO Allow any scope.content fields not just "data" (exactly like the get below this)
existing_item.data = data existing_item.data = data
...@@ -555,8 +531,30 @@ def _get_module_info(usage_key, user, rewrite_static_links=True): ...@@ -555,8 +531,30 @@ def _get_module_info(usage_key, user, rewrite_static_links=True):
) )
# Note that children aren't being returned until we have a use case. # Note that children aren't being returned until we have a use case.
return { return create_xblock_info(usage_key, module, data, own_metadata(module))
'id': unicode(module.location),
'data': data,
'metadata': own_metadata(module) def create_xblock_info(usage_key, xblock, data=None, metadata=None):
"""
Creates the information needed for client-side XBlockInfo.
If data or metadata are not specified, their information will not be added
(regardless of whether or not the xblock actually has data or metadata).
"""
publish_state = compute_publish_state(xblock) if xblock else None
xblock_info = {
"id": unicode(xblock.location),
"display_name": xblock.display_name_with_default,
"category": xblock.category,
"has_changes": modulestore().has_changes(usage_key),
"published": publish_state in (PublishState.public, PublishState.draft),
"edited_on": get_default_time_display(xblock.edited_on) if xblock.edited_on else None,
"edited_by": User.objects.get(id=xblock.edited_by).username if xblock.edited_by else None
} }
if data is not None:
xblock_info["data"] = data
if metadata is not None:
xblock_info["metadata"] = metadata
return xblock_info
...@@ -549,22 +549,6 @@ class TestEditItem(ItemTest): ...@@ -549,22 +549,6 @@ class TestEditItem(ItemTest):
) )
self.verify_publish_state(self.problem_usage_key, PublishState.public) self.verify_publish_state(self.problem_usage_key, PublishState.public)
def test_make_private(self):
""" Test making a public problem private (un-publishing it). """
# Make problem public.
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_public'}
)
self.verify_publish_state(self.problem_usage_key, PublishState.public)
# Now make it private
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_private'}
)
self.verify_publish_state(self.problem_usage_key, PublishState.private)
def test_make_draft(self): def test_make_draft(self):
""" Test creating a draft version of a public problem. """ """ Test creating a draft version of a public problem. """
# Make problem public. # Make problem public.
...@@ -574,13 +558,6 @@ class TestEditItem(ItemTest): ...@@ -574,13 +558,6 @@ class TestEditItem(ItemTest):
) )
published = self.verify_publish_state(self.problem_usage_key, PublishState.public) published = self.verify_publish_state(self.problem_usage_key, PublishState.public)
# Now make it draft, which means both versions will exist.
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'create_draft'}
)
self.verify_publish_state(self.problem_usage_key, PublishState.draft)
# Update the draft version and check that published is different. # Update the draft version and check that published is different.
self.client.ajax_post( self.client.ajax_post(
self.problem_update_url, self.problem_update_url,
...@@ -589,6 +566,9 @@ class TestEditItem(ItemTest): ...@@ -589,6 +566,9 @@ class TestEditItem(ItemTest):
updated_draft = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True) updated_draft = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True)
self.assertEqual(updated_draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) self.assertEqual(updated_draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
self.assertIsNone(published.due) self.assertIsNone(published.due)
# Fetch the published version again to make sure the due date is still unset.
published = modulestore().get_item(published.location, revision=REVISION_OPTION_PUBLISHED_ONLY)
self.assertIsNone(published.due)
def test_make_public_with_update(self): def test_make_public_with_update(self):
""" Update a problem and make it public at the same time. """ """ Update a problem and make it public at the same time. """
...@@ -602,112 +582,6 @@ class TestEditItem(ItemTest): ...@@ -602,112 +582,6 @@ class TestEditItem(ItemTest):
published = self.get_item_from_modulestore(self.problem_usage_key) published = self.get_item_from_modulestore(self.problem_usage_key)
self.assertEqual(published.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) self.assertEqual(published.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
def test_make_private_with_update(self):
""" Make a problem private and update it at the same time. """
# Make problem public.
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_public'}
)
self.verify_publish_state(self.problem_usage_key, PublishState.public)
# Make problem private and update.
self.client.ajax_post(
self.problem_update_url,
data={
'metadata': {'due': '2077-10-10T04:00Z'},
'publish': 'make_private'
}
)
draft = self.verify_publish_state(self.problem_usage_key, PublishState.private)
self.assertEqual(draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
def test_create_draft_with_update(self):
""" Create a draft and update it at the same time. """
# Make problem public.
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_public'}
)
published = self.verify_publish_state(self.problem_usage_key, PublishState.public)
# Now make it draft, which means both versions will exist.
self.client.ajax_post(
self.problem_update_url,
data={
'metadata': {'due': '2077-10-10T04:00Z'},
'publish': 'create_draft'
}
)
draft = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True)
self.assertEqual(draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
self.assertIsNone(published.due)
def test_create_draft_with_multiple_requests(self):
"""
Create a draft request returns already created version if it exists.
"""
# Make problem public.
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_public'}
)
self.verify_publish_state(self.problem_usage_key, PublishState.public)
# Now make it draft, which means both versions will exist.
self.client.ajax_post(
self.problem_update_url,
data={
'publish': 'create_draft'
}
)
draft_1 = self.verify_publish_state(self.problem_usage_key, PublishState.draft)
# Now check that when a user sends request to create a draft when there is already a draft version then
# user gets that already created draft instead of getting 'DuplicateItemError' exception.
self.client.ajax_post(
self.problem_update_url,
data={
'publish': 'create_draft'
}
)
draft_2 = self.verify_publish_state(self.problem_usage_key, PublishState.draft)
self.assertIsNotNone(draft_2)
self.assertEqual(draft_1, draft_2)
def test_make_private_with_multiple_requests(self):
"""
Make private requests gets proper response even if xmodule is already made private.
"""
# Make problem public.
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_public'}
)
self.assertIsNotNone(self.get_item_from_modulestore(self.problem_usage_key))
# Now make it private, and check that its version is private
resp = self.client.ajax_post(
self.problem_update_url,
data={
'publish': 'make_private'
}
)
self.assertEqual(resp.status_code, 200)
draft_1 = self.verify_publish_state(self.problem_usage_key, PublishState.private)
# Now check that when a user sends request to make it private when it already is private then
# user gets that private version instead of getting 'ItemNotFoundError' exception.
self.client.ajax_post(
self.problem_update_url,
data={
'publish': 'make_private'
}
)
self.assertEqual(resp.status_code, 200)
draft_2 = self.verify_publish_state(self.problem_usage_key, PublishState.private)
self.assertEqual(draft_1, draft_2)
def test_published_and_draft_contents_with_update(self): def test_published_and_draft_contents_with_update(self):
""" Create a draft and publish it then modify the draft and check that published content is not modified """ """ Create a draft and publish it then modify the draft and check that published content is not modified """
...@@ -724,8 +598,7 @@ class TestEditItem(ItemTest): ...@@ -724,8 +598,7 @@ class TestEditItem(ItemTest):
data={ data={
'id': unicode(self.problem_usage_key), 'id': unicode(self.problem_usage_key),
'metadata': {}, 'metadata': {},
'data': "<p>Problem content draft.</p>", 'data': "<p>Problem content draft.</p>"
'publish': 'create_draft'
} }
) )
...@@ -746,6 +619,9 @@ class TestEditItem(ItemTest): ...@@ -746,6 +619,9 @@ class TestEditItem(ItemTest):
# Both published and draft content should still be different # Both published and draft content should still be different
draft = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True) draft = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True)
self.assertNotEqual(draft.data, published.data) self.assertNotEqual(draft.data, published.data)
# Fetch the published version again to make sure the data is correct.
published = modulestore().get_item(published.location, revision=REVISION_OPTION_PUBLISHED_ONLY)
self.assertNotEqual(draft.data, published.data)
def test_publish_states_of_nested_xblocks(self): def test_publish_states_of_nested_xblocks(self):
""" Test publishing of a unit page containing a nested xblock """ """ Test publishing of a unit page containing a nested xblock """
...@@ -777,7 +653,6 @@ class TestEditItem(ItemTest): ...@@ -777,7 +653,6 @@ class TestEditItem(ItemTest):
data={ data={
'id': unicode(unit_usage_key), 'id': unicode(unit_usage_key),
'metadata': {}, 'metadata': {},
'publish': 'create_draft'
} }
) )
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
......
...@@ -229,6 +229,7 @@ define([ ...@@ -229,6 +229,7 @@ define([
"js/spec/views/xblock_editor_spec", "js/spec/views/xblock_editor_spec",
"js/spec/views/pages/container_spec", "js/spec/views/pages/container_spec",
"js/spec/views/pages/container_subviews_spec",
"js/spec/views/pages/group_configurations_spec", "js/spec/views/pages/group_configurations_spec",
"js/spec/views/modals/base_modal_spec", "js/spec/views/modals/base_modal_spec",
......
...@@ -7,12 +7,52 @@ define(["backbone", "js/utils/module"], function(Backbone, ModuleUtils) { ...@@ -7,12 +7,52 @@ define(["backbone", "js/utils/module"], function(Backbone, ModuleUtils) {
"id": null, "id": null,
"display_name": null, "display_name": null,
"category": null, "category": null,
"is_draft": null,
"is_container": null, "is_container": null,
"data": null, "data": null,
"metadata" : null, "metadata" : null,
"children": null "children": null,
/**
* True iff:
* 1) Edits have been made to the xblock and no published version exists.
* 2) Edits have been made to the xblock since the last published version.
*/
"has_changes": null,
/**
* True iff a published version of the xblock exists with a release date in the past,
* and the xblock is not locked.
*/
"released_to_students": null,
/**
* True iff a published version of the xblock exists.
*/
"published": null,
/**
* If true, only course staff can see the xblock regardless of publish status or
* release date status.
*/
"locked": null,
/**
* Date of last edit to this xblock. Will be the latest change to either the draft
* or the published version.
*/
"edited_on":null,
/**
* User who last edited the xblock.
*/
"edited_by":null,
/**
* If the xblock is published, the date on which it will be released to students.
*/
"release_date": null,
/**
* The xblock which is determining the release date. For instance, for a unit,
* this will either be the parent subsection or the grandparent section.
*/
"release_date_from":null
} }
// NOTE: 'publish' is not an attribute on XBlockInfo, but it used to signal the publish
// and discard changes actions. Therefore 'publish' cannot be introduced as an attribute.
}); });
return XBlockInfo; return XBlockInfo;
}); });
...@@ -143,7 +143,10 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -143,7 +143,10 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(mockContainerXBlockHtml, this);
inlineEditDisplayName(updatedDisplayName); inlineEditDisplayName(updatedDisplayName);
displayNameInput.change(); displayNameInput.change();
// This is the response for the change operation.
create_sinon.respondWithJson(requests, { }); create_sinon.respondWithJson(requests, { });
// This is the response for the subsequent fetch operation.
create_sinon.respondWithJson(requests, {"display_name": updatedDisplayName});
expect(displayNameInput).toHaveClass('is-hidden'); expect(displayNameInput).toHaveClass('is-hidden');
expect(displayNameElement).not.toHaveClass('is-hidden'); expect(displayNameElement).not.toHaveClass('is-hidden');
expect(displayNameElement.text().trim()).toBe(updatedDisplayName); expect(displayNameElement.text().trim()).toBe(updatedDisplayName);
...@@ -153,8 +156,11 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -153,8 +156,11 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
it('does not change the title when a display name update fails', function() { it('does not change the title when a display name update fails', function() {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(mockContainerXBlockHtml, this);
inlineEditDisplayName(updatedDisplayName); inlineEditDisplayName(updatedDisplayName);
var initialRequests = requests.length;
displayNameInput.change(); displayNameInput.change();
create_sinon.respondWithError(requests); create_sinon.respondWithError(requests);
// No fetch operation should occur.
expect(initialRequests + 1).toBe(requests.length);
expect(displayNameElement).toHaveClass('is-hidden'); expect(displayNameElement).toHaveClass('is-hidden');
expect(displayNameInput).not.toHaveClass('is-hidden'); expect(displayNameInput).not.toHaveClass('is-hidden');
expect(displayNameInput.val().trim()).toBe(updatedDisplayName); expect(displayNameInput.val().trim()).toBe(updatedDisplayName);
...@@ -305,14 +311,19 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin ...@@ -305,14 +311,19 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
create_sinon.respondWithJson(requests, {}); create_sinon.respondWithJson(requests, {});
// first request contains given component's id (to delete the component) // first request contains given component's id (to delete the component)
expect(requests[requests.length - 2].url).toMatch( expect(requests[requests.length - 3].url).toMatch(
new RegExp("locator-component-" + GROUP_TO_TEST + (componentIndex + 1)) new RegExp("locator-component-" + GROUP_TO_TEST + (componentIndex + 1))
); );
// second request contains parent's id (to remove as child) // second request contains parent's id (to remove as child)
expect(lastRequest().url).toMatch( expect(requests[requests.length - 2].url).toMatch(
new RegExp("locator-group-" + GROUP_TO_TEST) new RegExp("locator-group-" + GROUP_TO_TEST)
); );
// third request if a fetch of the container.
expect(lastRequest().url).toMatch(
new RegExp("locator-container")
);
}; };
deleteComponentWithSuccess = function(componentIndex) { deleteComponentWithSuccess = function(componentIndex) {
......
...@@ -82,7 +82,7 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", ...@@ -82,7 +82,7 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
}, },
updateChildren: function (targetParent, successCallback) { updateChildren: function (targetParent, successCallback) {
var children, childLocators; var children, childLocators, xblockInfo=this.model;
// Find descendants with class "studio-xblock-wrapper" whose parent === targetParent. // Find descendants with class "studio-xblock-wrapper" whose parent === targetParent.
// This is necessary to filter our grandchildren, great-grandchildren, etc. // This is necessary to filter our grandchildren, great-grandchildren, etc.
...@@ -110,6 +110,8 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", ...@@ -110,6 +110,8 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
if (successCallback) { if (successCallback) {
successCallback(); successCallback();
} }
// Update publish and last modified information from the server.
xblockInfo.fetch();
} }
}); });
}, },
......
...@@ -4,15 +4,15 @@ ...@@ -4,15 +4,15 @@
*/ */
define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/container", define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/container",
"js/views/xblock", "js/views/components/add_xblock", "js/views/modals/edit_xblock", "js/models/xblock_info", "js/views/xblock", "js/views/components/add_xblock", "js/views/modals/edit_xblock", "js/models/xblock_info",
"js/views/xblock_string_field_editor"], "js/views/xblock_string_field_editor", "js/views/pages/container_subviews"],
function ($, _, gettext, BaseView, ContainerView, XBlockView, AddXBlockComponent, EditXBlockModal, XBlockInfo, function ($, _, gettext, BaseView, ContainerView, XBlockView, AddXBlockComponent, EditXBlockModal, XBlockInfo,
XBlockStringFieldEditor) { XBlockStringFieldEditor, ContainerSubviews) {
var XBlockContainerPage = BaseView.extend({ var XBlockContainerPage = BaseView.extend({
// takes XBlockInfo as a model // takes XBlockInfo as a model
view: 'container_preview', view: 'container_preview',
initialize: function() { initialize: function(options) {
BaseView.prototype.initialize.call(this); BaseView.prototype.initialize.call(this);
this.nameEditor = new XBlockStringFieldEditor({ this.nameEditor = new XBlockStringFieldEditor({
el: this.$('.wrapper-xblock-field'), el: this.$('.wrapper-xblock-field'),
...@@ -24,16 +24,39 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai ...@@ -24,16 +24,39 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai
model: this.model, model: this.model,
view: this.view view: this.view
}); });
this.isUnitPage = this.options.isUnitPage;
if (this.isUnitPage) {
this.xblockPublisher = new ContainerSubviews.Publisher({
el: this.$('#publish-unit'),
model: this.model
});
this.xblockPublisher.render();
// No need to render initially. This is only used for updating state
// when the unit changes visibility.
this.visibilityState = new ContainerSubviews.VisibilityStateController({
el: this.$('.section-item.editing a'),
model: this.model
});
this.previewActions = new ContainerSubviews.PreviewActionController({
el: this.$('.nav-actions'),
model: this.model
});
this.previewActions.render();
}
}, },
render: function(options) { render: function(options) {
var self = this, var self = this,
xblockView = this.xblockView, xblockView = this.xblockView,
loadingElement = this.$('.ui-loading'); loadingElement = this.$('.ui-loading'),
loadingElement.removeClass('is-hidden'); unitLocationTree = this.$('.unit-location'),
hiddenCss='is-hidden';
loadingElement.removeClass(hiddenCss);
// Hide both blocks until we know which one to show // Hide both blocks until we know which one to show
xblockView.$el.addClass('is-hidden'); xblockView.$el.addClass(hiddenCss);
if (!options || !options.refresh) { if (!options || !options.refresh) {
// Add actions to any top level buttons, e.g. "Edit" of the container itself. // Add actions to any top level buttons, e.g. "Edit" of the container itself.
...@@ -45,11 +68,12 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai ...@@ -45,11 +68,12 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai
xblockView.render({ xblockView.render({
success: function() { success: function() {
xblockView.xblock.runtime.notify("page-shown", self); xblockView.xblock.runtime.notify("page-shown", self);
xblockView.$el.removeClass('is-hidden'); xblockView.$el.removeClass(hiddenCss);
self.renderAddXBlockComponents(); self.renderAddXBlockComponents();
self.onXBlockRefresh(xblockView); self.onXBlockRefresh(xblockView);
self.refreshDisplayName(); self.refreshDisplayName();
loadingElement.addClass('is-hidden'); loadingElement.addClass(hiddenCss);
unitLocationTree.removeClass(hiddenCss);
self.delegateEvents(); self.delegateEvents();
} }
}); });
...@@ -71,6 +95,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai ...@@ -71,6 +95,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai
onXBlockRefresh: function(xblockView) { onXBlockRefresh: function(xblockView) {
this.addButtonActions(xblockView.$el); this.addButtonActions(xblockView.$el);
this.xblockView.refresh(); this.xblockView.refresh();
// Update publish and last modified information from the server.
this.model.fetch();
}, },
renderAddXBlockComponents: function() { renderAddXBlockComponents: function() {
...@@ -181,6 +207,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai ...@@ -181,6 +207,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai
xblockElement.remove(); xblockElement.remove();
xblockView.updateChildren(parent); xblockView.updateChildren(parent);
xblock.runtime.notify('deleted-child', parent.data('locator')); xblock.runtime.notify('deleted-child', parent.data('locator'));
// Update publish and last modified information from the server.
this.model.fetch();
}, },
onNewXBlock: function(xblockElement, scrollOffset, data) { onNewXBlock: function(xblockElement, scrollOffset, data) {
......
/**
* Subviews (usually small side panels) for XBlockContainerPage.
*/
define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/feedback_prompt"],
function ($, _, gettext, BaseView, PromptView) {
var disabledCss = "is-disabled";
/**
* A view that calls render when "has_changes" or "published" values in XBlockInfo have changed
* after a server sync operation.
*/
var UnitStateListenerView = BaseView.extend({
// takes XBlockInfo as a model
initialize: function() {
this.model.on('sync', this.onSync, this);
},
onSync: function(e) {
if (e.changedAttributes() &&
(('has_changes' in e.changedAttributes()) || ('published' in e.changedAttributes()))) {
this.render();
}
},
render: function() {}
});
/**
* A controller for updating the visibility status of the unit on the RHS navigation tree.
*/
var VisibilityStateController = UnitStateListenerView.extend({
render: function() {
var computeState = function(published, has_changes) {
if (!published) {
return "private";
}
else if (has_changes) {
return "draft";
}
else {
return "public";
}
};
var state = computeState(this.model.get('published'), this.model.get('has_changes'));
this.$el.removeClass("private-item public-item draft-item");
this.$el.addClass(state + "-item");
}
});
/**
* A controller for updating the "View Live" and "Preview" buttons.
*/
var PreviewActionController = UnitStateListenerView.extend({
render: function() {
var previewAction = this.$el.find('.preview-button'),
viewLiveAction = this.$el.find('.view-button');
if (this.model.get('published')) {
viewLiveAction.removeClass(disabledCss);
}
else {
viewLiveAction.addClass(disabledCss);
}
if (this.model.get('has_changes') || !this.model.get('published')) {
previewAction.removeClass(disabledCss);
}
else {
previewAction.addClass(disabledCss);
}
}
});
/**
* Publisher is a view that supports the following:
* 1) Publishing of a draft version of an xblock.
* 2) Discarding of edits in a draft version.
* 3) Display of who last edited the xblock, and when.
* 4) Display of publish status (published, published with changes, changes with no published version).
*/
var Publisher = BaseView.extend({
events: {
'click .action-publish': 'publish',
'click .action-discard': 'discardChanges'
},
// takes XBlockInfo as a model
initialize: function () {
BaseView.prototype.initialize.call(this);
this.template = this.loadTemplate('publish-xblock');
this.model.on('sync', this.onSync, this);
},
onSync: function(e) {
if (e.changedAttributes() &&
(('has_changes' in e.changedAttributes()) || ('published' in e.changedAttributes()) ||
('edited_on' in e.changedAttributes()) || ('edited_by' in e.changedAttributes()))) {
this.render();
}
},
render: function () {
this.$el.html(this.template({
has_changes: this.model.get('has_changes'),
published: this.model.get('published'),
edited_on: this.model.get('edited_on'),
edited_by: this.model.get('edited_by')
}));
return this;
},
publish: function (e) {
var xblockInfo = this.model;
if (e && e.preventDefault) {
e.preventDefault();
}
this.runOperationShowingMessage(gettext('Publishing&hellip;'),
function () {
return xblockInfo.save({publish: 'make_public'});
}).done(function () {
xblockInfo.fetch();
});
},
discardChanges: function (e) {
if (e && e.preventDefault) {
e.preventDefault();
}
var xblockInfo = this.model, view;
view = new PromptView.Warning({
title: gettext("Discard Changes"),
message: gettext("Are you sure you want to discard changes and revert to the last published version?"),
actions: {
primary: {
text: gettext("Discard Changes"),
click: function (view) {
view.hide();
$.ajax({
type: 'DELETE',
url: xblockInfo.url()
}).success(function () {
return window.location.reload();
});
}
},
secondary: {
text: gettext("Cancel"),
click: function (view) {
view.hide();
}
}
}
}).show();
}
});
return {
'VisibilityStateController': VisibilityStateController,
'PreviewActionController': PreviewActionController,
'Publisher': Publisher
};
}); // end define();
...@@ -77,7 +77,8 @@ define(["jquery", "gettext", "js/views/baseview"], ...@@ -77,7 +77,8 @@ define(["jquery", "gettext", "js/views/baseview"],
function() { function() {
return xblockInfo.save(requestData); return xblockInfo.save(requestData);
}).done(function() { }).done(function() {
xblockInfo.set(fieldName, newValue); // Update publish and last modified information from the server.
xblockInfo.fetch();
}); });
}, },
......
...@@ -26,30 +26,30 @@ from django.utils.translation import ugettext as _ ...@@ -26,30 +26,30 @@ from django.utils.translation import ugettext as _
<script type="text/template" id="${template_name}-tpl"> <script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" /> <%static:include path="js/${template_name}.underscore" />
</script> </script>
<script type="text/template" id="publish-xblock-tpl">
<%static:include path="js/publish-xblock.underscore" />
</script>
% endfor % endfor
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
</%block> </%block>
<%block name="jsextra"> <%block name="jsextra">
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
<%
main_xblock_info = {
'id': str(xblock_locator),
'display_name': xblock.display_name_with_default,
'category': xblock.category,
};
%>
<script type='text/javascript'> <script type='text/javascript'>
require(["domReady!", "jquery", "js/models/xblock_info", "js/views/pages/container", require(["domReady!", "jquery", "js/models/xblock_info", "js/views/pages/container",
"js/collections/component_template", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"], "js/collections/component_template", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function(doc, $, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) { function(doc, $, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
var templates = new ComponentTemplates(${component_templates | n}, {parse: true}); var templates = new ComponentTemplates(${component_templates | n}, {parse: true});
var mainXBlockInfo = new XBlockInfo(${json.dumps(main_xblock_info) | n}); // TODO: can go back to dumping on server side if easier.
var mainXBlockInfo = new XBlockInfo(${json.dumps(xblock_info) | n});
var isUnitPage = ${json.dumps(is_unit_page)}
xmoduleLoader.done(function () { xmoduleLoader.done(function () {
var view = new ContainerPage({ var view = new ContainerPage({
el: $('#content'), el: $('#content'),
model: mainXBlockInfo, model: mainXBlockInfo,
templates: templates templates: templates,
isUnitPage: isUnitPage
}); });
view.render(); view.render();
}); });
...@@ -86,13 +86,24 @@ main_xblock_info = { ...@@ -86,13 +86,24 @@ main_xblock_info = {
<nav class="nav-actions"> <nav class="nav-actions">
<h3 class="sr">${_("Page Actions")}</h3> <h3 class="sr">${_("Page Actions")}</h3>
<ul> <ul>
% if not is_unit_page and not unit_publish_state == 'public': % if is_unit_page:
<li class="action-item action-edit nav-item"> <li class="action-item action-view nav-item">
<a href="#" class="button edit-button action-button"> <a href="${published_preview_link}" class="button view-button action-button is-disabled">
<i class="icon-pencil"></i> <span class="action-button-text">${_("View Published Version")}</span>
<span class="action-button-text">${_("Edit")}</span> </a>
</a> </li>
</li> <li class="action-item action-preview nav-item">
<a href="${draft_preview_link}" class="button preview-button action-button is-disabled">
<span class="action-button-text">${_("Preview Changes")}</span>
</a>
</li>
% else:
<li class="action-item action-edit nav-item">
<a href="#" class="button edit-button action-button">
<i class="icon-pencil"></i>
<span class="action-button-text">${_("Edit")}</span>
</a>
</li>
% endif % endif
</ul> </ul>
</nav> </nav>
...@@ -103,7 +114,7 @@ main_xblock_info = { ...@@ -103,7 +114,7 @@ main_xblock_info = {
<div class="inner-wrapper"> <div class="inner-wrapper">
<section class="content-area"> <section class="content-area">
<article class="content-primary window"> <article class="content-primary">
<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" data-locator="${xblock_locator}" data-course-key="${xblock_locator.course_key}"> <section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" data-locator="${xblock_locator}" data-course-key="${xblock_locator.course_key}">
</section> </section>
<div class="ui-loading"> <div class="ui-loading">
...@@ -120,16 +131,17 @@ main_xblock_info = { ...@@ -120,16 +131,17 @@ main_xblock_info = {
</div> </div>
% endif % endif
% if is_unit_page: % if is_unit_page:
<div class="unit-location"> <div id="publish-unit"></div>
<h4 class="header">${_("Unit Location")}</h4> <div class="unit-location is-hidden">
<div class="wrapper-unit-id content-bit"> <h4 class="bar-mod-title">${_("Unit Location")}</h4>
<div class="wrapper-unit-id bar-mod-content">
<h5 class="title">Unit Location ID</h5> <h5 class="title">Unit Location ID</h5>
<p class="unit-id"> <p class="unit-id">
<span class="unit-id-value" id="unit-location-id-input">${unit.location.name}</span> <span class="unit-id-value" id="unit-location-id-input">${unit.location.name}</span>
<span class="tip"><span class="sr">Tip: </span>${_("Use this ID to link to this unit from other places in your course")}</span> <span class="tip"><span class="sr">Tip: </span>${_("Use this ID to link to this unit from other places in your course")}</span>
</p> </p>
</div> </div>
<div class="wrapper-unit-tree-location content-bit"> <div class="wrapper-unit-tree-location bar-mod-content">
<h5 class="title">Unit Tree Location</h5> <h5 class="title">Unit Tree Location</h5>
<ol> <ol>
<li class="section"> <li class="section">
......
...@@ -14,12 +14,25 @@ ...@@ -14,12 +14,25 @@
<nav class="nav-actions"> <nav class="nav-actions">
<h3 class="sr">Page Actions</h3> <h3 class="sr">Page Actions</h3>
<ul> <ul>
<li class="action-item action-edit nav-item"> % if is_unit_page:
<a href="#" class="button edit-button action-button"> <li class="action-item action-view nav-item">
<i class="icon-pencil"></i> <a href="${published_preview_link}" class="button view-button action-button is-disabled">
<span class="action-button-text">${_("Edit")}</span> <span class="action-button-text">${_("View Published Version")}</span>
</a> </a>
</li> </li>
<li class="action-item action-preview nav-item">
<a href="${draft_preview_link}" class="button preview-button action-button is-disabled">
<span class="action-button-text">${_("Preview Changes")}</span>
</a>
</li>
% else:
<li class="action-item action-edit nav-item">
<a href="#" class="button edit-button action-button">
<i class="icon-pencil"></i>
<span class="action-button-text">${_("Edit")}</span>
</a>
</li>
% endif
</ul> </ul>
</nav> </nav>
</header> </header>
...@@ -37,7 +50,44 @@ ...@@ -37,7 +50,44 @@
</div> </div>
</article> </article>
<aside class="content-supplementary" role="complimentary"> <aside class="content-supplementary" role="complimentary">
<div id="publish-unit" class="window"></div>
</aside> </aside>
<div class="unit-location">
<h4 class="header">${_("Unit Location")}</h4>
<div class="wrapper-unit-id content-bit">
<h5 class="title">Unit Location ID</h5>
<p class="unit-id">
<span class="unit-id-value" id="unit-location-id-input">${unit.location.name}</span>
<span class="tip"><span class="sr">Tip: </span>${_("Use this ID to link to this unit from other places in your course")}</span>
</p>
</div>
<div class="wrapper-unit-tree-location content-bit">
<h5 class="title">Unit Tree Location</h5>
<ol>
<li class="section">
<a href="course-overview-url" class="section-item section-name">
<span class="section-name">Test Section</span>
</a>
<ol>
<li class="subsection">
<div class="section-item">
<span class="subsection-name"><span class="subsection-name-value">Test Subsection</span></span>
</div>
<ol class="sortable-unit-list">
<li class="courseware-unit unit is-draggable" data-locator="locator-container"
data-parent="" data-course-key="">
<div class="section-item editing">
<a href="unit-url" class="private-item">
<span class="unit-name">Test Container</span>
</a>
</div>
</ol>
</li>
</ol>
</li>
</ol>
</div>
</div>
</section> </section>
</div> </div>
</div> </div>
......
<div class="bit-publishing <% if (published && !has_changes) { %>published<% } else { %>draft<%} %>">
<h3 class="bar-mod-title pub-status"><span class="sr"><%= gettext("Publishing Status") %></span>
<% if (published && !has_changes) { %>
<%= gettext("Published") %>
<% } else { %>
<%= gettext("Draft (Unpublished changes)") %>
<% } %>
</h3>
<!--To be added in STUDIO-1708-->
<!--<div class="wrapper-last-draft bar-mod-content">-->
<!--<p class="copy meta">-->
<!--Draft saved on 6/15/2014 at 12:45pm by amako-->
<!--</p>-->
<!--</div>-->
<!--To be added in STUD-1712-->
<!--<div class="wrapper-release bar-mod-content">-->
<!--<h5 class="title">Will Release:</h5>-->
<!--<p class="copy">-->
<!--<span class="release-date">July 25, 2014</span> with-->
<!--<span class="release-with">Section "Week 1"</span>-->
<!--</p>-->
<!--</div>-->
<!--To be added in STUD-1713-->
<!--<div class="wrapper-visibility bar-mod-content">-->
<!--<h5 class="title">Will be Visible to:</h5>-->
<!--<p class="copy">Staff and Students</p>-->
<!--<p class="action-inline">-->
<!--<a href="">-->
<!--<i class="icon-unlock is-disabled"></i> Hide from Students-->
<!--</a>-->
<!--</p>-->
<!--</div>-->
<div class="wrapper-pub-actions bar-mod-actions">
<ul class="action-list">
<li class="action-item">
<a class="action-publish action-primary <% if (published && !has_changes) { %>is-disabled<% } %>"
href=""><%= gettext("Publish") %>
</a>
</li>
<li class="action-item">
<a class="action-discard action-secondary <% if (!published || !has_changes) { %>is-disabled<% } %>"
href=""><%= gettext("Discard Changes") %>
</a>
</li>
</ul>
</div>
</div>
...@@ -34,7 +34,7 @@ label = xblock.display_name or xblock.scope_ids.block_type ...@@ -34,7 +34,7 @@ label = xblock.display_name or xblock.scope_ids.block_type
</div> </div>
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
% if not xblock_context['read_only'] and not is_root: % if not is_root:
% if not show_inline: % if not show_inline:
<li class="action-item action-edit"> <li class="action-item action-edit">
<a href="#" class="edit-button action-button"> <a href="#" class="edit-button action-button">
......
...@@ -469,7 +469,7 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): ...@@ -469,7 +469,7 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
Create a copy of the source and mark its revision as draft. Create a copy of the source and mark its revision as draft.
Note: This method is to support the Mongo Modulestore and may be deprecated. Note: This method is to support the Mongo Modulestore and may be deprecated.
:param source: the location of the source (its revision must be None) :param location: the location of the source (its revision must be None)
""" """
store = self._verify_modulestore_support(location.course_key, 'convert_to_draft') store = self._verify_modulestore_support(location.course_key, 'convert_to_draft')
return store.convert_to_draft(location, user_id) return store.convert_to_draft(location, user_id)
......
...@@ -530,6 +530,30 @@ class TestMixedModuleStore(unittest.TestCase): ...@@ -530,6 +530,30 @@ class TestMixedModuleStore(unittest.TestCase):
self.assertIn(self.course_locations[self.XML_COURSEID1], course_ids) self.assertIn(self.course_locations[self.XML_COURSEID1], course_ids)
self.assertIn(self.course_locations[self.XML_COURSEID2], course_ids) self.assertIn(self.course_locations[self.XML_COURSEID2], course_ids)
@ddt.data('draft')
def test_has_changes_draft_mongo(self, default_ms):
"""
Smoke test for has_changes with draft mongo modulestore.
Tests already exist for both split and draft in their own test files.
"""
self.initdb(default_ms)
item = self.store.create_item(self.course_locations[self.MONGO_COURSEID], 'problem', block_id='orphan')
self.assertTrue(self.store.has_changes(item.location))
self.store.publish(item.location, self.user_id)
self.assertFalse(self.store.has_changes(item.location))
@ddt.data('split')
def test_has_changes_split(self, default_ms):
"""
Smoke test for has_changes with split modulestore.
Tests already exist for both split and draft in their own test files.
"""
self.initdb(default_ms)
self.assertTrue(self.store.has_changes(self.writable_chapter_location))
# split modulestore's "publish" method is currently called "xblock_publish"
def test_xml_get_courses(self): def test_xml_get_courses(self):
""" """
Test that the xml modulestore only loaded the courses from the maps. Test that the xml modulestore only loaded the courses from the maps.
......
...@@ -7,6 +7,6 @@ ...@@ -7,6 +7,6 @@
% if can_reorder: % if can_reorder:
</ol> </ol>
% endif % endif
% if can_add and not xblock_context['read_only']: % if can_add:
<div class="add-xblock-component new-component-item adding"></div> <div class="add-xblock-component new-component-item adding"></div>
% endif % endif
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment