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
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import LibraryUsageLocator
from pytz import UTC
from xblock.core import XBlock
from xblock.fields import Scope
from xblock.fragment import Fragment
from xblock_django.user_service import DjangoXBlockUserService
......@@ -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
an existing xblock, or creating an entirely new one. The json playload can contain
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
: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.
:display_name: name for new xblock, optional
:boilerplate: template name for populating fields, optional and only used
......@@ -198,14 +203,26 @@ def xblock_handler(request, usage_key_string):
request.user,
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:
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:
return HttpResponseBadRequest(
"Only instance creation is supported without a usage key.",
content_type="text/plain"
'Only instance creation is supported without a usage key.',
content_type='text/plain'
)
......@@ -636,10 +653,109 @@ def _create_item(request):
)
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):
"""
Duplicate an existing xblock as a child of the supplied parent_usage_key.
......
......@@ -14,13 +14,14 @@ from django.test.client import RequestFactory
from django.core.urlresolvers import reverse
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 contentstore.views.component import (
component_handler, get_component_templates
)
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
)
from contentstore.tests.utils import CourseTestCase
......@@ -734,6 +735,235 @@ class TestDuplicateItem(ItemTest, DuplicateHelper):
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):
"""
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