Commit 8834bbad by Adam Palay

delete_item should not delete any child that has a parent we do not intend to delete

parent 99c410dc
"""Tests running the delete_orphan command""" """Tests running the delete_orphan command"""
import ddt
from django.core.management import call_command from django.core.management import call_command
from contentstore.tests.test_orphan import TestOrphanBase from contentstore.tests.test_orphan import TestOrphanBase
from xmodule.modulestore import ModuleStoreEnum
@ddt.ddt
class TestDeleteOrphan(TestOrphanBase): class TestDeleteOrphan(TestOrphanBase):
""" """
Tests for running the delete_orphan management command. Tests for running the delete_orphan management command.
Inherits from TestOrphan in order to use its setUp method. Inherits from TestOrphan in order to use its setUp method.
""" """
def setUp(self): @ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
super(TestDeleteOrphan, self).setUp() def test_delete_orphans_no_commit(self, default_store):
self.course_id = self.course.id.to_deprecated_string()
def test_delete_orphans_no_commit(self):
""" """
Tests that running the command without a 'commit' argument Tests that running the command without a 'commit' argument
results in no orphans being deleted results in no orphans being deleted
""" """
call_command('delete_orphans', self.course_id) course = self.create_course_with_orphans(default_store)
self.assertTrue(self.store.has_item(self.course.id.make_usage_key('html', 'multi_parent_html'))) call_command('delete_orphans', unicode(course.id))
self.assertTrue(self.store.has_item(self.course.id.make_usage_key('vertical', 'OrphanVert'))) self.assertTrue(self.store.has_item(course.id.make_usage_key('html', 'multi_parent_html')))
self.assertTrue(self.store.has_item(self.course.id.make_usage_key('chapter', 'OrphanChapter'))) self.assertTrue(self.store.has_item(course.id.make_usage_key('vertical', 'OrphanVert')))
self.assertTrue(self.store.has_item(self.course.id.make_usage_key('html', 'OrphanHtml'))) self.assertTrue(self.store.has_item(course.id.make_usage_key('chapter', 'OrphanChapter')))
self.assertTrue(self.store.has_item(course.id.make_usage_key('html', 'OrphanHtml')))
def test_delete_orphans_commit(self): @ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
def test_delete_orphans_commit(self, default_store):
""" """
Tests that running the command WITH the 'commit' argument Tests that running the command WITH the 'commit' argument
results in the orphans being deleted results in the orphans being deleted
""" """
call_command('delete_orphans', self.course_id, 'commit') course = self.create_course_with_orphans(default_store)
call_command('delete_orphans', unicode(course.id), 'commit')
# make sure this module wasn't deleted # make sure this module wasn't deleted
self.assertTrue(self.store.has_item(self.course.id.make_usage_key('html', 'multi_parent_html'))) self.assertTrue(self.store.has_item(course.id.make_usage_key('html', 'multi_parent_html')))
# and make sure that these were # and make sure that these were
self.assertFalse(self.store.has_item(self.course.id.make_usage_key('vertical', 'OrphanVert'))) self.assertFalse(self.store.has_item(course.id.make_usage_key('vertical', 'OrphanVert')))
self.assertFalse(self.store.has_item(self.course.id.make_usage_key('chapter', 'OrphanChapter'))) self.assertFalse(self.store.has_item(course.id.make_usage_key('chapter', 'OrphanChapter')))
self.assertFalse(self.store.has_item(self.course.id.make_usage_key('html', 'OrphanHtml'))) self.assertFalse(self.store.has_item(course.id.make_usage_key('html', 'OrphanHtml')))
...@@ -2,27 +2,34 @@ ...@@ -2,27 +2,34 @@
Test finding orphans via the view and django config Test finding orphans via the view and django config
""" """
import json import json
import ddt
from contentstore.tests.utils import CourseTestCase from contentstore.tests.utils import CourseTestCase
from student.models import CourseEnrollment from student.models import CourseEnrollment
from contentstore.utils import reverse_course_url from contentstore.utils import reverse_course_url
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.factories import CourseFactory
class TestOrphanBase(CourseTestCase): class TestOrphanBase(CourseTestCase):
""" """
Base class for Studio tests that require orphaned modules Base class for Studio tests that require orphaned modules
""" """
def setUp(self): def create_course_with_orphans(self, default_store):
super(TestOrphanBase, self).setUp() """
Creates a course with 3 orphan modules, one of which
has a child that's also in the course tree.
"""
course = CourseFactory.create(default_store=default_store)
# create chapters and add them to course tree # create chapters and add them to course tree
chapter1 = self.store.create_child(self.user.id, self.course.location, 'chapter', "Chapter1") chapter1 = self.store.create_child(self.user.id, course.location, 'chapter', "Chapter1")
self.store.publish(chapter1.location, self.user.id) self.store.publish(chapter1.location, self.user.id)
chapter2 = self.store.create_child(self.user.id, self.course.location, 'chapter', "Chapter2") chapter2 = self.store.create_child(self.user.id, course.location, 'chapter', "Chapter2")
self.store.publish(chapter2.location, self.user.id) self.store.publish(chapter2.location, self.user.id)
# orphan chapter # orphan chapter
orphan_chapter = self.store.create_item(self.user.id, self.course.id, 'chapter', "OrphanChapter") orphan_chapter = self.store.create_item(self.user.id, course.id, 'chapter', "OrphanChapter")
self.store.publish(orphan_chapter.location, self.user.id) self.store.publish(orphan_chapter.location, self.user.id)
# create vertical and add it as child to chapter1 # create vertical and add it as child to chapter1
...@@ -30,7 +37,7 @@ class TestOrphanBase(CourseTestCase): ...@@ -30,7 +37,7 @@ class TestOrphanBase(CourseTestCase):
self.store.publish(vertical1.location, self.user.id) self.store.publish(vertical1.location, self.user.id)
# create orphan vertical # create orphan vertical
orphan_vertical = self.store.create_item(self.user.id, self.course.id, 'vertical', "OrphanVert") orphan_vertical = self.store.create_item(self.user.id, course.id, 'vertical', "OrphanVert")
self.store.publish(orphan_vertical.location, self.user.id) self.store.publish(orphan_vertical.location, self.user.id)
# create component and add it to vertical1 # create component and add it to vertical1
...@@ -45,61 +52,72 @@ class TestOrphanBase(CourseTestCase): ...@@ -45,61 +52,72 @@ class TestOrphanBase(CourseTestCase):
self.store.update_item(orphan_vertical, self.user.id) self.store.update_item(orphan_vertical, self.user.id)
# create an orphaned html module # create an orphaned html module
orphan_html = self.store.create_item(self.user.id, self.course.id, 'html', "OrphanHtml") orphan_html = self.store.create_item(self.user.id, course.id, 'html', "OrphanHtml")
self.store.publish(orphan_html.location, self.user.id) self.store.publish(orphan_html.location, self.user.id)
self.store.create_child(self.user.id, self.course.location, 'static_tab', "staticuno") self.store.create_child(self.user.id, course.location, 'static_tab', "staticuno")
self.store.create_child(self.user.id, self.course.location, 'about', "overview") self.store.create_child(self.user.id, course.location, 'course_info', "updates")
self.store.create_child(self.user.id, self.course.location, 'course_info', "updates")
return course
@ddt.ddt
class TestOrphan(TestOrphanBase): class TestOrphan(TestOrphanBase):
""" """
Test finding orphans via view and django config Test finding orphans via view and django config
""" """
def setUp(self):
super(TestOrphan, self).setUp()
self.orphan_url = reverse_course_url('orphan_handler', self.course.id)
def test_mongo_orphan(self): @ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
def test_get_orphans(self, default_store):
""" """
Test that old mongo finds the orphans Test that the orphan handler finds the orphans
""" """
course = self.create_course_with_orphans(default_store)
orphan_url = reverse_course_url('orphan_handler', course.id)
orphans = json.loads( orphans = json.loads(
self.client.get( self.client.get(
self.orphan_url, orphan_url,
HTTP_ACCEPT='application/json' HTTP_ACCEPT='application/json'
).content ).content
) )
self.assertEqual(len(orphans), 3, "Wrong # {}".format(orphans)) self.assertEqual(len(orphans), 3, "Wrong # {}".format(orphans))
location = self.course.location.replace(category='chapter', name='OrphanChapter') location = course.location.replace(category='chapter', name='OrphanChapter')
self.assertIn(location.to_deprecated_string(), orphans) self.assertIn(location.to_deprecated_string(), orphans)
location = self.course.location.replace(category='vertical', name='OrphanVert') location = course.location.replace(category='vertical', name='OrphanVert')
self.assertIn(location.to_deprecated_string(), orphans) self.assertIn(location.to_deprecated_string(), orphans)
location = self.course.location.replace(category='html', name='OrphanHtml') location = course.location.replace(category='html', name='OrphanHtml')
self.assertIn(location.to_deprecated_string(), orphans) self.assertIn(location.to_deprecated_string(), orphans)
def test_mongo_orphan_delete(self): @ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
def test_delete_orphans(self, default_store):
""" """
Test that old mongo deletes the orphans Test that the orphan handler deletes the orphans
""" """
self.client.delete(self.orphan_url) course = self.create_course_with_orphans(default_store)
orphan_url = reverse_course_url('orphan_handler', course.id)
self.client.delete(orphan_url)
orphans = json.loads( orphans = json.loads(
self.client.get(self.orphan_url, HTTP_ACCEPT='application/json').content self.client.get(orphan_url, HTTP_ACCEPT='application/json').content
) )
self.assertEqual(len(orphans), 0, "Orphans not deleted {}".format(orphans)) self.assertEqual(len(orphans), 0, "Orphans not deleted {}".format(orphans))
# make sure that any children with one orphan parent and one non-orphan # make sure that any children with one orphan parent and one non-orphan
# parent are not deleted # parent are not deleted
self.assertTrue(self.store.has_item(self.course.id.make_usage_key('html', "multi_parent_html"))) self.assertTrue(self.store.has_item(course.id.make_usage_key('html', "multi_parent_html")))
def test_not_permitted(self): @ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
def test_not_permitted(self, default_store):
""" """
Test that auth restricts get and delete appropriately Test that auth restricts get and delete appropriately
""" """
course = self.create_course_with_orphans(default_store)
orphan_url = reverse_course_url('orphan_handler', course.id)
test_user_client, test_user = self.create_non_staff_authed_user_client() test_user_client, test_user = self.create_non_staff_authed_user_client()
CourseEnrollment.enroll(test_user, self.course.id) CourseEnrollment.enroll(test_user, course.id)
response = test_user_client.get(self.orphan_url) response = test_user_client.get(orphan_url)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = test_user_client.delete(self.orphan_url) response = test_user_client.delete(orphan_url)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
...@@ -2399,7 +2399,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): ...@@ -2399,7 +2399,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
parent_block.edit_info.source_version = None parent_block.edit_info.source_version = None
self.decache_block(usage_locator.course_key, new_id, parent_block_key) self.decache_block(usage_locator.course_key, new_id, parent_block_key)
self._remove_subtree(BlockKey.from_usage_key(usage_locator), new_blocks) self._remove_subtree(BlockKey.from_usage_key(usage_locator), new_structure)
# update index if appropriate and structures # update index if appropriate and structures
self.update_structure(usage_locator.course_key, new_structure) self.update_structure(usage_locator.course_key, new_structure)
...@@ -2416,14 +2416,30 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): ...@@ -2416,14 +2416,30 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
return result return result
@contract(block_key=BlockKey, blocks='dict(BlockKey: BlockData)') @contract(block_key=BlockKey, structure='dict')
def _remove_subtree(self, block_key, blocks): def _remove_subtree(self, block_key, structure):
""" """
Remove the subtree rooted at block_key Remove the subtree rooted at block_key
""" We do this breadth-first to make sure that we don't remove
for child in blocks[block_key].fields.get('children', []): any children that may have parents that we don't want to delete.
self._remove_subtree(BlockKey(*child), blocks) """
del blocks[block_key] to_delete = {block_key}
tier = {block_key}
while tier:
new_tier = set()
for block_key in tier:
for child in structure['blocks'][block_key].fields.get('children', []):
child_block_key = BlockKey(*child)
parents = self._get_parents_from_structure(child_block_key, structure)
# Make sure we want to delete all of the child's parents
# before slating it for deletion
if all(parent in to_delete for parent in parents):
new_tier.add(child_block_key)
tier = new_tier
to_delete.update(tier)
for block_key in to_delete:
del structure['blocks'][block_key]
def delete_course(self, course_key, user_id): def delete_course(self, course_key, user_id):
""" """
......
...@@ -410,7 +410,7 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli ...@@ -410,7 +410,7 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
new_structure = self.version_structure(draft_course_key, draft_course_structure, user_id) new_structure = self.version_structure(draft_course_key, draft_course_structure, user_id)
# remove the block and its descendants from the new structure # remove the block and its descendants from the new structure
self._remove_subtree(BlockKey.from_usage_key(location), new_structure['blocks']) self._remove_subtree(BlockKey.from_usage_key(location), new_structure)
# copy over the block and its descendants from the published branch # copy over the block and its descendants from the published branch
def copy_from_published(root_block_id): def copy_from_published(root_block_id):
......
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