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
This commit is contained in:
Kyle McCormick
2020-11-23 15:14:10 -05:00
committed by GitHub
parent 341c1c98e5
commit 9f239ffe8f
5 changed files with 222 additions and 0 deletions

View File

@@ -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.")

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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.

View File

@@ -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))