From 9f239ffe8fee5e0a3a113b01599199047afce1f0 Mon Sep 17 00:00:00 2001 From: Kyle McCormick <kmccormick@edx.org> Date: Mon, 23 Nov 2020 15:14:10 -0500 Subject: [PATCH] Add reset_course_content Studio management command (#25653) Given a course key and a split-mongo version GUID, it resets the course run's draft branch to a specified version and publishes. The purpose of this is to allow us to restore overwritten course content without having to write to Mongo via a dbshell. Adds `reset_course_to_version` method to modulestore API. TNL-7705 --- .../commands/reset_course_content.py | 62 +++++++++++++ .../tests/test_reset_course_content.py | 42 +++++++++ .../lib/xmodule/xmodule/modulestore/mixed.py | 13 +++ .../modulestore/split_mongo/split_draft.py | 14 +++ .../tests/test_mixed_modulestore.py | 91 +++++++++++++++++++ 5 files changed, 222 insertions(+) create mode 100644 cms/djangoapps/contentstore/management/commands/reset_course_content.py create mode 100644 cms/djangoapps/contentstore/management/commands/tests/test_reset_course_content.py diff --git a/cms/djangoapps/contentstore/management/commands/reset_course_content.py b/cms/djangoapps/contentstore/management/commands/reset_course_content.py new file mode 100644 index 00000000000..747acba8bf8 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/reset_course_content.py @@ -0,0 +1,62 @@ +""" +Django management command to reset the content of a course to a a different +version, as specified by an ObjectId from the DraftVersioningModulestore (aka Split). +""" +from textwrap import dedent + +from django.core.management import BaseCommand, CommandError +from opaque_keys.edx.keys import CourseKey + +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore + + +class Command(BaseCommand): + """ + Reset the content of a course run to a different version, and publish. + + This is a powerful command; use with care. + It's analogous to `git reset --hard VERSION && git push -f`. + + The intent of this is to restore overwritten course content that has not yet been + pruned from the modulestore. I guess you could use it to change a course's content + to any structure in Split you wanted, though. + + Make sure you have validated the value of `course_id` and `version_guid`. + There is no confirmation prompt. + + Example: + + ./manage.py reset_course_content "course-v1:my+cool+course" "5fb5772e2fe4c7c76493c241" + """ + help = dedent(__doc__) + + def add_arguments(self, parser): + parser.add_argument( + 'course_id', + help="A split-modulestore course key string (ie, course-v1:ORG+COURSE+RUN)", + ) + parser.add_argument( + 'version_guid', + help="A split-modulestore structure ObjectId (a 24-digit hex string)", + ) + + def handle(self, *args, **options): + course_key = CourseKey.from_string(options["course_id"]) + + version_guid = options["version_guid"] + unparseable_guid = False + try: + int(version_guid, 16) + except ValueError: + unparseable_guid = True + if unparseable_guid or len(version_guid) != 24: + raise CommandError("version_guid should be a 24-digit hexadecimal number") + + print("Resetting '{}' to version '{}'...".format(course_key, version_guid)) + modulestore().reset_course_to_version( + course_key, + version_guid, + ModuleStoreEnum.UserID.mgmt_command, + ) + print("Done.") diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_reset_course_content.py b/cms/djangoapps/contentstore/management/commands/tests/test_reset_course_content.py new file mode 100644 index 00000000000..e731984c4df --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/tests/test_reset_course_content.py @@ -0,0 +1,42 @@ +""" +Shallow tests for `./manage.py cms reset_course_content COURSE_KEY VERSION_GUID` +""" +import mock + +from django.test import TestCase +from django.core.management import CommandError, call_command +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey + +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.mixed import MixedModuleStore + + +class TestCommand(TestCase): + """ + Shallow test for CMS `reset_course_content` management command. + + The underlying implementation (`DraftVersioningModulestore.reset_course_to_version`) + is tested within the modulestore. + """ + + def test_bad_course_id(self): + with self.assertRaises(InvalidKeyError): + call_command("reset_course_content", "not_a_course_id", "0123456789abcdef01234567") + + def test_wrong_length_version_guid(self): + with self.assertRaises(CommandError): + call_command("reset_course_content", "course-v1:a+b+c", "0123456789abcdef") + + def test_non_hex_version_guid(self): + with self.assertRaises(CommandError): + call_command("reset_course_content", "course-v1:a+b+c", "0123456789abcdefghijklmn") + + @mock.patch.object(MixedModuleStore, "reset_course_to_version") + def test_good_arguments(self, mock_reset_course_to_version): + call_command("reset_course_content", "course-v1:a+b+c", "0123456789abcdef01234567") + mock_reset_course_to_version.assert_called_once_with( + CourseKey.from_string("course-v1:a+b+c"), + "0123456789abcdef01234567", + ModuleStoreEnum.UserID.mgmt_command, + ) diff --git a/common/lib/xmodule/xmodule/modulestore/mixed.py b/common/lib/xmodule/xmodule/modulestore/mixed.py index 6297888c6d3..0abfc1f251e 100644 --- a/common/lib/xmodule/xmodule/modulestore/mixed.py +++ b/common/lib/xmodule/xmodule/modulestore/mixed.py @@ -826,6 +826,19 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): store = self._verify_modulestore_support(location.course_key, 'revert_to_published') return store.revert_to_published(location, user_id) + def reset_course_to_version(self, course_key, version_guid, user_id): + """ + Resets the content of a course at `course_key` to a version specified by `version_guid`. + + :raises NotImplementedError: if not supported by store. + """ + store = self._verify_modulestore_support(course_key, 'reset_course_to_version') + return store.reset_course_to_version( + course_key=course_key, + version_guid=version_guid, + user_id=user_id, + ) + def close_all_connections(self): """ Close all db connections diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py index cfa85d27c96..1a273059263 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py @@ -461,6 +461,20 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli if index_entry is not None: self._update_head(draft_course_key, index_entry, ModuleStoreEnum.BranchName.draft, new_structure['_id']) + def reset_course_to_version(self, course_key, version_guid, user_id): + """ + Resets a course to a version specified by the string `version_guid`. + + The `version_guid` refers to the Mongo-level id ("_id") + of the structure we want to revert to. It should be a 24-digit hex string. + """ + draft_course_key = course_key.for_branch(ModuleStoreEnum.BranchName.draft) + version_object_id = course_key.as_object_id(version_guid) + with self.bulk_operations(draft_course_key): + index_entry = self._get_index_if_valid(draft_course_key) + self._update_head(draft_course_key, index_entry, ModuleStoreEnum.BranchName.draft, version_object_id) + self.force_publish_course(draft_course_key, user_id, commit=True) + def update_parent_if_moved(self, item_location, original_parent_location, course_structure, user_id): """ Update parent of an item if it has moved. diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py index d023e5c99a2..1e32991e137 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py @@ -47,6 +47,7 @@ from xmodule.modulestore.exceptions import ( from xmodule.modulestore.inheritance import InheritanceMixin from xmodule.modulestore.mixed import MixedModuleStore from xmodule.modulestore.search import navigation_index, path_to_location +from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore from xmodule.modulestore.store_utilities import DETACHED_XBLOCK_TYPES from xmodule.modulestore.tests.factories import check_exact_number_of_calls, check_mongo_calls, mongo_uses_error_check from xmodule.modulestore.tests.mongo_connection import MONGO_HOST, MONGO_PORT_NUM @@ -1796,6 +1797,96 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): # It does not discard the child vertical, even though that child is a draft (with no published version) self.assertEqual(num_children, len(reverted_parent.children)) + def test_reset_course_to_version(self): + """ + Test calling `DraftVersioningModuleStore.test_reset_course_to_version`. + """ + # Set up test course. + self.initdb(ModuleStoreEnum.Type.split) # Old Mongo does not support this operation. + self._create_block_hierarchy() + self.store.publish(self.course.location, self.user_id) + + # Get children of a vertical as a set. + # We will use this set as a basis for content comparision in this test. + original_vertical = self.store.get_item(self.vertical_x1a) + original_vertical_children = set(original_vertical.children) + + # Find the version_guid of our course by diving into Split Mongo. + split = self._get_split_modulestore() + course_index = split.get_course_index(self.course.location.course_key) + original_version_guid = course_index["versions"]["published-branch"] + + # Reset course to currently-published version. + # This should be a no-op. + self.store.reset_course_to_version( + self.course.location.course_key, + original_version_guid, + self.user_id, + ) + noop_reset_vertical = self.store.get_item(self.vertical_x1a) + assert set(noop_reset_vertical.children) == original_vertical_children + + # Delete a problem from the vertical and publish. + # Vertical should have one less problem than before. + self.store.delete_item(self.problem_x1a_1, self.user_id) + self.store.publish(self.course.location, self.user_id) + modified_vertical = self.store.get_item(self.vertical_x1a) + assert set(modified_vertical.children) == ( + original_vertical_children - {self.problem_x1a_1} + ) + + # Add a couple more children to the vertical. + # and publish a couple more times. + # We want to make sure we can restore from something a few versions back. + self.store.create_child( + self.user_id, + self.vertical_x1a, + 'problem', + block_id='new_child1', + ) + self.store.publish(self.course.location, self.user_id) + self.store.create_child( + self.user_id, + self.vertical_x1a, + 'problem', + block_id='new_child2', + ) + self.store.publish(self.course.location, self.user_id) + + # Add another child, but don't publish. + # We want to make sure that this works with a dirty draft branch. + self.store.create_child( + self.user_id, + self.vertical_x1a, + 'problem', + block_id='new_child3', + ) + + # Reset course to original version. + # The restored vertical should have the same children as it did originally. + self.store.reset_course_to_version( + self.course.location.course_key, + original_version_guid, + self.user_id, + ) + restored_vertical = self.store.get_item(self.vertical_x1a) + assert set(restored_vertical.children) == original_vertical_children + + def _get_split_modulestore(self): + """ + Grab the SplitMongo modulestore instance from within the Mixed modulestore. + + Assumption: There is a SplitMongo modulestore within the Mixed modulestore. + This assumpion is hacky, but it seems OK because we're removing the + Old (non-Split) Mongo modulestores soon. + + Returns: SplitMongoModuleStore + """ + for store in self.store.modulestores: + if isinstance(store, SplitMongoModuleStore): + return store + assert False, "SplitMongoModuleStore was not found in MixedModuleStore" + # Draft: get all items which can be or should have parents # Split: active_versions, structure @ddt.data((ModuleStoreEnum.Type.mongo, 1, 0), (ModuleStoreEnum.Type.split, 2, 0)) -- GitLab