Commit 52f8c976 by Mushtaq Ali

Move an item - TNL-6064

parent 3f355241
...@@ -19,6 +19,8 @@ from django.views.decorators.http import require_http_methods ...@@ -19,6 +19,8 @@ from django.views.decorators.http import require_http_methods
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import LibraryUsageLocator from opaque_keys.edx.locator import LibraryUsageLocator
from pytz import UTC from pytz import UTC
from xblock.core import XBlock
from xblock.fields import Scope from xblock.fields import Scope
from xblock.fragment import Fragment from xblock.fragment import Fragment
from xblock_django.user_service import DjangoXBlockUserService from xblock_django.user_service import DjangoXBlockUserService
...@@ -127,8 +129,11 @@ def xblock_handler(request, usage_key_string): ...@@ -127,8 +129,11 @@ def xblock_handler(request, usage_key_string):
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
an existing xblock, or creating an entirely new one. The json playload can contain 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 for both duplicate and create new instance :parent_locator: parent for new xblock, required for duplicate, move and create new instance
:duplicate_source_locator: if present, use this as the source for creating a duplicate copy :duplicate_source_locator: if present, use this as the source for creating a duplicate copy
:move_source_locator: if present, use this as the source item for moving
:target_index: if present, use this as the target index for moving an item to a particular index
otherwise target_index is calculated. It is sent back in the response.
:category: type of xblock, required if duplicate_source_locator is not present. :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 and only used :boilerplate: template name for populating fields, optional and only used
...@@ -198,14 +203,26 @@ def xblock_handler(request, usage_key_string): ...@@ -198,14 +203,26 @@ def xblock_handler(request, usage_key_string):
request.user, request.user,
request.json.get('display_name'), request.json.get('display_name'),
) )
return JsonResponse({'locator': unicode(dest_usage_key), 'courseKey': unicode(dest_usage_key.course_key)})
return JsonResponse({"locator": unicode(dest_usage_key), "courseKey": unicode(dest_usage_key.course_key)})
else: else:
return _create_item(request) return _create_item(request)
elif request.method == 'PATCH':
if 'move_source_locator' in request.json:
move_source_usage_key = usage_key_with_run(request.json.get('move_source_locator'))
target_parent_usage_key = usage_key_with_run(request.json.get('parent_locator'))
target_index = request.json.get('target_index')
if (
not has_studio_write_access(request.user, target_parent_usage_key.course_key) or
not has_studio_read_access(request.user, target_parent_usage_key.course_key)
):
raise PermissionDenied()
return _move_item(move_source_usage_key, target_parent_usage_key, request.user, target_index)
return JsonResponse({'error': 'Patch request did not recognise any parameters to handle.'}, status=400)
else: else:
return HttpResponseBadRequest( return HttpResponseBadRequest(
"Only instance creation is supported without a usage key.", 'Only instance creation is supported without a usage key.',
content_type="text/plain" content_type='text/plain'
) )
...@@ -636,8 +653,107 @@ def _create_item(request): ...@@ -636,8 +653,107 @@ def _create_item(request):
) )
return JsonResponse( return JsonResponse(
{"locator": unicode(created_block.location), "courseKey": unicode(created_block.location.course_key)} {'locator': unicode(created_block.location), 'courseKey': unicode(created_block.location.course_key)}
)
def _get_source_index(source_usage_key, source_parent):
"""
Get source index position of the XBlock.
Arguments:
source_usage_key (BlockUsageLocator): Locator of source item.
source_parent (XBlock): A parent of the source XBlock.
Returns:
source_index (int): Index position of the xblock in a parent.
"""
try:
source_index = source_parent.children.index(source_usage_key)
return source_index
except ValueError:
return None
def _move_item(source_usage_key, target_parent_usage_key, user, target_index=None):
"""
Move an existing xblock as a child of the supplied target_parent_usage_key.
Arguments:
source_usage_key (BlockUsageLocator): Locator of source item.
target_parent_usage_key (BlockUsageLocator): Locator of target parent.
target_index (int): If provided, insert source item at provided index location in target_parent_usage_key item.
Returns:
JsonResponse: Information regarding move operation. It may contains error info if an invalid move operation
is performed.
"""
# Get the list of all component type XBlocks
component_types = sorted(set(name for name, class_ in XBlock.load_classes()) - set(DIRECT_ONLY_CATEGORIES))
store = modulestore()
with store.bulk_operations(source_usage_key.course_key):
source_item = store.get_item(source_usage_key)
source_parent = source_item.get_parent()
target_parent = store.get_item(target_parent_usage_key)
source_type = source_item.category
target_parent_type = target_parent.category
error = None
# Store actual/initial index of the source item. This would be sent back with response,
# so that with Undo operation, it would easier to move back item to it's original/old index.
source_index = _get_source_index(source_usage_key, source_parent)
valid_move_type = {
'vertical': source_type if source_type in component_types else 'component',
'sequential': 'vertical',
'chapter': 'sequential',
}
if valid_move_type.get(target_parent_type, '') != source_type:
error = 'You can not move {source_type} into {target_parent_type}.'.format(
source_type=source_type,
target_parent_type=target_parent_type,
) )
elif source_parent.location == target_parent.location:
error = 'You can not move an item into the same parent.'
elif source_index is None:
error = '{source_usage_key} not found in {parent_usage_key}.'.format(
source_usage_key=unicode(source_usage_key),
parent_usage_key=unicode(source_parent.location)
)
else:
try:
target_index = int(target_index) if target_index is not None else None
if len(target_parent.children) < target_index:
error = 'You can not move {source_usage_key} at an invalid index ({target_index}).'.format(
source_usage_key=unicode(source_usage_key),
target_index=target_index
)
except ValueError:
error = 'You must provide target_index ({target_index}) as an integer.'.format(
target_index=target_index
)
if error:
return JsonResponse({'error': error}, status=400)
# Remove reference from old parent.
source_parent.children.remove(source_item.location)
store.update_item(source_parent, user.id)
# When target_index is provided, insert xblock at target_index position, otherwise insert at the end.
insert_at = target_index if target_index is not None else len(target_parent.children)
# Add to new parent at particular location.
target_parent.children.insert(insert_at, source_item.location)
store.update_item(target_parent, user.id)
context = {
'move_source_locator': unicode(source_usage_key),
'parent_locator': unicode(target_parent_usage_key),
'source_index': target_index if target_index is not None else source_index
}
return JsonResponse(context)
def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_name=None, is_child=False): def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_name=None, is_child=False):
......
...@@ -14,13 +14,14 @@ from django.test.client import RequestFactory ...@@ -14,13 +14,14 @@ from django.test.client import RequestFactory
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from contentstore.utils import reverse_usage_url, reverse_course_url from contentstore.utils import reverse_usage_url, reverse_course_url
from opaque_keys import InvalidKeyError
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from contentstore.views.component import ( from contentstore.views.component import (
component_handler, get_component_templates component_handler, get_component_templates
) )
from contentstore.views.item import ( from contentstore.views.item import (
create_xblock_info, _get_module_info, ALWAYS, VisibilityState, _xblock_type_and_display_name, create_xblock_info, _get_source_index, _get_module_info, ALWAYS, VisibilityState, _xblock_type_and_display_name,
add_container_page_publishing_info add_container_page_publishing_info
) )
from contentstore.tests.utils import CourseTestCase from contentstore.tests.utils import CourseTestCase
...@@ -734,6 +735,235 @@ class TestDuplicateItem(ItemTest, DuplicateHelper): ...@@ -734,6 +735,235 @@ class TestDuplicateItem(ItemTest, DuplicateHelper):
verify_name(self.seq_usage_key, self.chapter_usage_key, "customized name", display_name="customized name") verify_name(self.seq_usage_key, self.chapter_usage_key, "customized name", display_name="customized name")
class TestMoveItem(ItemTest):
"""
Tests for move item.
"""
def setUp(self):
"""
Creates the test course structure to build course outline tree.
"""
super(TestMoveItem, self).setUp()
# Create a parent chapter
chap1 = self.create_xblock(parent_usage_key=self.course.location, display_name='chapter1', category='chapter')
self.chapter_usage_key = self.response_usage_key(chap1)
chap2 = self.create_xblock(parent_usage_key=self.course.location, display_name='chapter2', category='chapter')
self.chapter2_usage_key = self.response_usage_key(chap2)
# create a sequential
seq1 = self.create_xblock(parent_usage_key=self.chapter_usage_key, display_name='seq1', category='sequential')
self.seq_usage_key = self.response_usage_key(seq1)
seq2 = self.create_xblock(parent_usage_key=self.chapter_usage_key, display_name='seq2', category='sequential')
self.seq2_usage_key = self.response_usage_key(seq2)
# create a vertical
vert1 = self.create_xblock(parent_usage_key=self.seq_usage_key, display_name='vertical1', category='vertical')
self.vert_usage_key = self.response_usage_key(vert1)
vert2 = self.create_xblock(parent_usage_key=self.seq_usage_key, display_name='vertical2', category='vertical')
self.vert2_usage_key = self.response_usage_key(vert2)
# create problem and an html component
problem1 = self.create_xblock(parent_usage_key=self.vert_usage_key, display_name='problem1', category='problem')
self.problem_usage_key = self.response_usage_key(problem1)
html1 = self.create_xblock(parent_usage_key=self.vert_usage_key, display_name='html1', category='html')
self.html_usage_key = self.response_usage_key(html1)
def _move_component(self, source_usage_key, target_usage_key, target_index=None):
"""
Helper method to send move request and returns the response.
Arguments:
source_usage_key (BlockUsageLocator): Locator of source item.
target_usage_key (BlockUsageLocator): Locator of target parent.
target_index (int): If provided, insert source item at the provided index location in target_usage_key item.
Returns:
resp (JsonResponse): Response after the move operation is complete.
"""
data = {
'move_source_locator': unicode(source_usage_key),
'parent_locator': unicode(target_usage_key)
}
if target_index is not None:
data['target_index'] = target_index
return self.client.patch(
reverse('contentstore.views.xblock_handler'),
json.dumps(data),
content_type='application/json'
)
def assert_move_item(self, source_usage_key, target_usage_key, target_index=None):
"""
Assert move component.
Arguments:
source_usage_key (BlockUsageLocator): Locator of source item.
target_usage_key (BlockUsageLocator): Locator of target parent.
target_index (int): If provided, insert source item at the provided index location in target_usage_key item.
"""
parent_loc = self.store.get_parent_location(source_usage_key)
parent = self.get_item_from_modulestore(parent_loc)
source_index = _get_source_index(source_usage_key, parent)
expected_index = target_index if target_index is not None else source_index
response = self._move_component(source_usage_key, target_usage_key, target_index)
self.assertEqual(response.status_code, 200)
response = json.loads(response.content)
self.assertEqual(response['move_source_locator'], unicode(source_usage_key))
self.assertEqual(response['parent_locator'], unicode(target_usage_key))
self.assertEqual(response['source_index'], expected_index)
new_parent_loc = self.store.get_parent_location(source_usage_key)
self.assertEqual(new_parent_loc, target_usage_key)
self.assertNotEqual(parent_loc, new_parent_loc)
def test_move_component(self):
"""
Test move component with different xblock types.
"""
for source_usage_key, target_usage_key in [
(self.html_usage_key, self.vert2_usage_key),
(self.vert_usage_key, self.seq2_usage_key),
(self.seq_usage_key, self.chapter2_usage_key)
]:
self.assert_move_item(source_usage_key, target_usage_key)
def test_move_source_index(self):
"""
Test moving an item to a particular index.
"""
parent = self.get_item_from_modulestore(self.vert_usage_key)
children = parent.get_children()
self.assertEqual(len(children), 2)
# Create a component within vert2.
resp = self.create_xblock(parent_usage_key=self.vert2_usage_key, display_name='html2', category='html')
html2_usage_key = self.response_usage_key(resp)
# Move html2_usage_key inside vert_usage_key at second position.
self.assert_move_item(html2_usage_key, self.vert_usage_key, 1)
parent = self.get_item_from_modulestore(self.vert_usage_key)
children = parent.get_children()
self.assertEqual(len(children), 3)
self.assertEqual(children[1].location, html2_usage_key)
def test_move_undo(self):
"""
Test move a component and move it back (undo).
"""
# Get the initial index of the component
parent = self.get_item_from_modulestore(self.vert_usage_key)
original_index = _get_source_index(self.html_usage_key, parent)
# Move component and verify that response contains initial index
response = self._move_component(self.html_usage_key, self.vert2_usage_key)
response = json.loads(response.content)
self.assertEquals(original_index, response['source_index'])
# Verify that new parent has the moved component at the last index.
parent = self.get_item_from_modulestore(self.vert2_usage_key)
self.assertEqual(self.html_usage_key, parent.children[-1])
# Verify original and new index is different now.
source_index = _get_source_index(self.html_usage_key, parent)
self.assertNotEquals(original_index, source_index)
# Undo Move to the original index, use the source index fetched from the response.
response = self._move_component(self.html_usage_key, self.vert_usage_key, response['source_index'])
response = json.loads(response.content)
self.assertEquals(original_index, response['source_index'])
def test_move_large_target_index(self):
"""
Test moving an item at a large index would generate an error message.
"""
parent = self.get_item_from_modulestore(self.vert2_usage_key)
parent_children_length = len(parent.children)
response = self._move_component(self.html_usage_key, self.vert2_usage_key, parent_children_length + 10)
self.assertEqual(response.status_code, 400)
response = json.loads(response.content)
expected_error = 'You can not move {usage_key} at an invalid index ({target_index}).'.format(
usage_key=self.html_usage_key,
target_index=parent_children_length + 10
)
self.assertEqual(expected_error, response['error'])
new_parent_loc = self.store.get_parent_location(self.html_usage_key)
self.assertEqual(new_parent_loc, self.vert_usage_key)
def test_invalid_move(self):
"""
Test invalid move.
"""
parent_loc = self.store.get_parent_location(self.chapter_usage_key)
response = self._move_component(self.chapter_usage_key, self.usage_key)
self.assertEqual(response.status_code, 400)
response = json.loads(response.content)
expected_error = 'You can not move {source_type} into {target_type}.'.format(
source_type=self.chapter_usage_key.block_type,
target_type=self.usage_key.block_type
)
self.assertEqual(expected_error, response['error'])
new_parent_loc = self.store.get_parent_location(self.chapter_usage_key)
self.assertEqual(new_parent_loc, parent_loc)
def test_move_current_parent(self):
"""
Test that a component can not be moved to it's current parent.
"""
parent_loc = self.store.get_parent_location(self.html_usage_key)
self.assertEqual(parent_loc, self.vert_usage_key)
response = self._move_component(self.html_usage_key, self.vert_usage_key)
self.assertEqual(response.status_code, 400)
response = json.loads(response.content)
self.assertEqual(response['error'], 'You can not move an item into the same parent.')
self.assertEqual(self.store.get_parent_location(self.html_usage_key), parent_loc)
def test_move_invalid_source_index(self):
"""
Test moving an item to an invalid index.
"""
target_index = 'test_index'
parent_loc = self.store.get_parent_location(self.html_usage_key)
response = self._move_component(self.html_usage_key, self.vert2_usage_key, target_index)
self.assertEqual(response.status_code, 400)
response = json.loads(response.content)
error = 'You must provide target_index ({target_index}) as an integer.'.format(target_index=target_index)
self.assertEqual(response['error'], error)
new_parent_loc = self.store.get_parent_location(self.html_usage_key)
self.assertEqual(new_parent_loc, parent_loc)
def test_move_no_target_locator(self):
"""
Test move an item without specifying the target location.
"""
data = {'move_source_locator': unicode(self.html_usage_key)}
with self.assertRaises(InvalidKeyError):
self.client.patch(
reverse('contentstore.views.xblock_handler'),
json.dumps(data),
content_type='application/json'
)
def test_no_move_source_locator(self):
"""
Test patch request without providing a move source locator.
"""
response = self.client.patch(
reverse('contentstore.views.xblock_handler')
)
self.assertEqual(response.status_code, 400)
response = json.loads(response.content)
self.assertEqual(response['error'], 'Patch request did not recognise any parameters to handle.')
class TestDuplicateItemWithAsides(ItemTest, DuplicateHelper): class TestDuplicateItemWithAsides(ItemTest, DuplicateHelper):
""" """
Test the duplicate method for blocks with asides. Test the duplicate method for blocks with asides.
......
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