Commit 0d8d7cb3 by cahrens

Support duplicating an existing xblock to a supplied parent location.

STUD-1190
parent e475f835
...@@ -4,7 +4,7 @@ import json ...@@ -4,7 +4,7 @@ import json
from datetime import datetime from datetime import datetime
import ddt import ddt
from mock import Mock, patch from mock import patch
from pytz import UTC from pytz import UTC
from webob import Response from webob import Response
...@@ -28,9 +28,10 @@ class ItemTest(CourseTestCase): ...@@ -28,9 +28,10 @@ class ItemTest(CourseTestCase):
def setUp(self): def setUp(self):
super(ItemTest, self).setUp() super(ItemTest, self).setUp()
self.unicode_locator = unicode(loc_mapper().translate_location( self.course_locator = loc_mapper().translate_location(
self.course.location.course_id, self.course.location, False, True self.course.location.course_id, self.course.location, False, True
)) )
self.unicode_locator = unicode(self.course_locator)
def get_old_id(self, locator): def get_old_id(self, locator):
""" """
...@@ -157,6 +158,124 @@ class TestCreateItem(ItemTest): ...@@ -157,6 +158,124 @@ class TestCreateItem(ItemTest):
self.assertEqual(obj.start, datetime(2030, 1, 1, tzinfo=UTC)) self.assertEqual(obj.start, datetime(2030, 1, 1, tzinfo=UTC))
class TestDuplicateItem(ItemTest):
"""
Test the duplicate method.
"""
def setUp(self):
""" Creates the test course structure and a few components to 'duplicate'. """
super(TestDuplicateItem, self).setUp()
# create a parent sequential
resp = self.create_xblock(parent_locator=self.unicode_locator, category='sequential')
self.seq_locator = self.response_locator(resp)
# create problem and an html component
resp = self.create_xblock(parent_locator=self.seq_locator, category='problem', boilerplate='multiplechoice.yaml')
self.problem_locator = self.response_locator(resp)
resp = self.create_xblock(parent_locator=self.seq_locator, category='html')
self.html_locator = self.response_locator(resp)
def test_duplicate_equality(self):
"""
Tests that a duplicated xblock is identical to the original,
except for location and display name.
"""
def verify_duplicate(source_locator, parent_locator):
locator = self._duplicate_item(parent_locator, source_locator)
original_item = self.get_item_from_modulestore(source_locator, draft=True)
duplicated_item = self.get_item_from_modulestore(locator, draft=True)
self.assertNotEqual(
original_item.location,
duplicated_item.location,
"Location of duplicate should be different from original"
)
# Set the location and display name to be the same so we can make sure the rest of the duplicate is equal.
duplicated_item.location = original_item.location
duplicated_item.display_name = original_item.display_name
self.assertEqual(original_item, duplicated_item, "Duplicated item differs from original")
verify_duplicate(self.problem_locator, self.seq_locator)
verify_duplicate(self.html_locator, self.seq_locator)
verify_duplicate(self.seq_locator, self.unicode_locator)
def test_ordering(self):
"""
Tests the a duplicated xblock appears immediately after its source
(if duplicate and source share the same parent), else at the
end of the children of the parent.
"""
def verify_order(source_locator, parent_locator, source_position=None):
locator = self._duplicate_item(parent_locator, source_locator)
parent = self.get_item_from_modulestore(parent_locator)
children = parent.children
if source_position is None:
self.assertFalse(source_locator in children, 'source item not expected in children array')
self.assertEqual(
children[len(children) - 1],
self.get_old_id(locator).url(),
"duplicated item not at end"
)
else:
self.assertEqual(
children[source_position],
self.get_old_id(source_locator).url(),
"source item at wrong position"
)
self.assertEqual(
children[source_position+1],
self.get_old_id(locator).url(),
"duplicated item not ordered after source item"
)
verify_order(self.problem_locator, self.seq_locator, 0)
# 2 because duplicate of problem should be located before.
verify_order(self.html_locator, self.seq_locator, 2)
verify_order(self.seq_locator, self.unicode_locator, 0)
# Test duplicating something into a location that is not the parent of the original item.
# Duplicated item should appear at the end.
verify_order(self.html_locator, self.unicode_locator)
def test_display_name(self):
"""
Tests the expected display name for the duplicated xblock.
"""
def verify_name(source_locator, parent_locator, expected_name, display_name=None):
locator = self._duplicate_item(parent_locator, source_locator, display_name)
duplicated_item = self.get_item_from_modulestore(locator, draft=True)
self.assertEqual(duplicated_item.display_name, expected_name)
return locator
# Display name comes from template.
dupe_locator = verify_name(self.problem_locator, self.seq_locator, "Duplicate of 'Multiple Choice'")
# Test dupe of dupe.
verify_name(dupe_locator, self.seq_locator, "Duplicate of 'Duplicate of 'Multiple Choice''")
# Uses default display_name of 'Text' from HTML component.
verify_name(self.html_locator, self.seq_locator, "Duplicate of 'Text'")
# The sequence does not have a display_name set, so None gets included as the string 'None'.
verify_name(self.seq_locator, self.unicode_locator, "Duplicate of 'None'")
# Now send a custom display name for the duplicate.
verify_name(self.seq_locator, self.unicode_locator, "customized name", display_name="customized name")
def _duplicate_item(self, parent_locator, source_locator, display_name=None):
data = {
'parent_locator': parent_locator,
'duplicate_source_locator': source_locator
}
if display_name is not None:
data['display_name'] = display_name
resp = self.client.ajax_post('/xblock', json.dumps(data))
resp_content = json.loads(resp.content)
self.assertEqual(resp.status_code, 200)
return resp_content['locator']
class TestEditItem(ItemTest): class TestEditItem(ItemTest):
""" """
Test xblock update. Test xblock update.
......
...@@ -33,6 +33,7 @@ from .helpers import _xmodule_recurse ...@@ -33,6 +33,7 @@ from .helpers import _xmodule_recurse
from preview import handler_prefix, get_preview_html from preview import handler_prefix, get_preview_html
from edxmako.shortcuts import render_to_response, render_to_string from edxmako.shortcuts import render_to_response, render_to_string
from models.settings.course_grading import CourseGradingModel from models.settings.course_grading import CourseGradingModel
from django.utils.translation import ugettext as _
__all__ = ['orphan_handler', 'xblock_handler'] __all__ = ['orphan_handler', 'xblock_handler']
...@@ -71,12 +72,15 @@ def xblock_handler(request, tag=None, package_id=None, branch=None, version_guid ...@@ -71,12 +72,15 @@ def xblock_handler(request, tag=None, package_id=None, branch=None, version_guid
:publish: can be one of three values, 'make_public, 'make_private', or 'create_draft' :publish: can be one of three values, 'make_public, 'make_private', or 'create_draft'
The JSON representation on the updated xblock (minus children) is returned. The JSON representation on the updated xblock (minus children) is returned.
if xblock locator is not specified, create a new xblock instance. The json playload can contain if xblock locator is not specified, create a new xblock instance, either by duplicating
an existing xblock, or creating an entirely new one. The json playload can contain
these fields: these fields:
:parent_locator: parent for new xblock, required :parent_locator: parent for new xblock, required for both duplicate and create new instance
:category: type of xblock, required :duplicate_source_locator: if present, use this as the source for creating a duplicate copy
:category: type of xblock, required if duplicate_source_locator is not present.
:display_name: name for new xblock, optional :display_name: name for new xblock, optional
:boilerplate: template name for populating fields, optional :boilerplate: template name for populating fields, optional and only used
if duplicate_source_locator is not present
The locator (and old-style id) for the created xblock (minus children) is returned. The locator (and old-style id) for the created xblock (minus children) is returned.
""" """
if package_id is not None: if package_id is not None:
...@@ -131,7 +135,17 @@ def xblock_handler(request, tag=None, package_id=None, branch=None, version_guid ...@@ -131,7 +135,17 @@ def xblock_handler(request, tag=None, package_id=None, branch=None, version_guid
publish=request.json.get('publish'), publish=request.json.get('publish'),
) )
elif request.method in ('PUT', 'POST'): elif request.method in ('PUT', 'POST'):
return _create_item(request) if 'duplicate_source_locator' in request.json:
parent_locator = BlockUsageLocator(request.json['parent_locator'])
duplicate_source_locator = BlockUsageLocator(request.json['duplicate_source_locator'])
new_locator = _duplicate_item(
parent_locator,
duplicate_source_locator,
request.json.get('display_name')
)
return JsonResponse({"locator": unicode(new_locator)})
else:
return _create_item(request)
else: else:
return HttpResponseBadRequest( return HttpResponseBadRequest(
"Only instance creation is supported without a package_id.", "Only instance creation is supported without a package_id.",
...@@ -286,6 +300,52 @@ def _create_item(request): ...@@ -286,6 +300,52 @@ def _create_item(request):
return JsonResponse({"locator": unicode(locator)}) return JsonResponse({"locator": unicode(locator)})
def _duplicate_item(parent_locator, duplicate_source_locator, display_name):
"""
Duplicate an existing xblock as a child of the supplied parent_locator.
"""
parent_location = loc_mapper().translate_locator_to_location(parent_locator)
duplicate_source_location = loc_mapper().translate_locator_to_location(duplicate_source_locator)
store = get_modulestore(duplicate_source_location)
source_item = store.get_item(duplicate_source_location)
# Change the blockID to be unique.
dest_location = duplicate_source_location.replace(name=uuid4().hex)
category = dest_location.category
# Update the display name to indicate this is a duplicate (unless display name provided).
duplicate_metadata = own_metadata(source_item)
if display_name is not None:
duplicate_metadata['display_name'] = display_name
else:
duplicate_metadata['display_name'] = _("Duplicate of '{0}'").format(source_item.display_name)
get_modulestore(category).create_and_save_xmodule(
dest_location,
definition_data=source_item.data if hasattr(source_item, 'data') else None,
metadata=duplicate_metadata,
system=source_item.system if hasattr(source_item, 'system') else None,
)
# Children are not automatically copied over. Not all xblocks have a 'children' attribute.
if hasattr(source_item, 'children'):
get_modulestore(dest_location).update_children(dest_location, source_item.children)
if category not in DETACHED_CATEGORIES:
parent = get_modulestore(parent_location).get_item(parent_location)
# If source was already a child of the parent, add duplicate immediately afterward.
# Otherwise, add child to end.
if duplicate_source_location.url() in parent.children:
source_index = parent.children.index(duplicate_source_location.url())
parent.children.insert(source_index+1, dest_location.url())
else:
parent.children.append(dest_location.url())
get_modulestore(parent_location).update_children(parent_location, parent.children)
course_location = loc_mapper().translate_locator_to_location(BlockUsageLocator(parent_locator), get_course=True)
return loc_mapper().translate_location(course_location.course_id, dest_location, False, True)
def _delete_item_at_location(item_location, delete_children=False, delete_all_versions=False): def _delete_item_at_location(item_location, delete_children=False, delete_all_versions=False):
""" """
Deletes the item at with the given Location. Deletes the item at with the given Location.
......
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