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:
@@ -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.")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user