diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py index acfa4b491c..f6f31af5be 100644 --- a/cms/djangoapps/contentstore/tests/test_item.py +++ b/cms/djangoapps/contentstore/tests/test_item.py @@ -4,7 +4,7 @@ import json from datetime import datetime import ddt -from mock import Mock, patch +from mock import patch from pytz import UTC from webob import Response @@ -28,9 +28,10 @@ class ItemTest(CourseTestCase): def setUp(self): 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.unicode_locator = unicode(self.course_locator) def get_old_id(self, locator): """ @@ -157,6 +158,124 @@ class TestCreateItem(ItemTest): 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): """ Test xblock update. diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index d6a8ff3abb..fa42f40366 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -33,6 +33,7 @@ from .helpers import _xmodule_recurse from preview import handler_prefix, get_preview_html from edxmako.shortcuts import render_to_response, render_to_string from models.settings.course_grading import CourseGradingModel +from django.utils.translation import ugettext as _ __all__ = ['orphan_handler', 'xblock_handler'] @@ -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' 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: - :parent_locator: parent for new xblock, required - :category: type of xblock, required + :parent_locator: parent for new xblock, required for both duplicate and create new instance + :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 - :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. """ if package_id is not None: @@ -131,7 +135,17 @@ def xblock_handler(request, tag=None, package_id=None, branch=None, version_guid publish=request.json.get('publish'), ) 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: return HttpResponseBadRequest( "Only instance creation is supported without a package_id.", @@ -286,6 +300,52 @@ def _create_item(request): 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): """ Deletes the item at with the given Location.