diff --git a/cms/djangoapps/contentstore/management/commands/force_publish.py b/cms/djangoapps/contentstore/management/commands/force_publish.py new file mode 100644 index 0000000000..4795e10560 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/force_publish.py @@ -0,0 +1,73 @@ +""" +Script for force publishing a course +""" +from django.core.management.base import BaseCommand, CommandError +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from .prompt import query_yes_no +from .utils import get_course_versions + +# To run from command line: ./manage.py cms force_publish course-v1:org+course+run + + +class Command(BaseCommand): + """Force publish a course""" + help = ''' + Force publish a course. Takes two arguments: + : the course id of the course you want to publish forcefully + commit: do the force publish + + If you do not specify 'commit', the command will print out what changes would be made. + ''' + + def handle(self, *args, **options): + """Execute the command""" + if len(args) not in {1, 2}: + raise CommandError("force_publish requires 1 or more argument: |commit|") + + try: + course_key = CourseKey.from_string(args[0]) + except InvalidKeyError: + raise CommandError("Invalid course key.") + + if not modulestore().get_course(course_key): + raise CommandError("Course not found.") + + commit = False + if len(args) == 2: + commit = args[1] == 'commit' + + # for now only support on split mongo + owning_store = modulestore()._get_modulestore_for_courselike(course_key) # pylint: disable=protected-access + if hasattr(owning_store, 'force_publish_course'): + versions = get_course_versions(args[0]) + print "Course versions : {0}".format(versions) + + if commit: + if query_yes_no("Are you sure to publish the {0} course forcefully?".format(course_key), default="no"): + # publish course forcefully + updated_versions = owning_store.force_publish_course( + course_key, ModuleStoreEnum.UserID.mgmt_command, commit + ) + if updated_versions: + # if publish and draft were different + if versions['published-branch'] != versions['draft-branch']: + print "Success! Published the course '{0}' forcefully.".format(course_key) + print "Updated course versions : \n{0}".format(updated_versions) + else: + print "Course '{0}' is already in published state.".format(course_key) + else: + print "Error! Could not publish course {0}.".format(course_key) + else: + # if publish and draft were different + if versions['published-branch'] != versions['draft-branch']: + print "Dry run. Following would have been changed : " + print "Published branch version {0} changed to draft branch version {1}".format( + versions['published-branch'], versions['draft-branch'] + ) + else: + print "Dry run. Course '{0}' is already in published state.".format(course_key) + else: + raise CommandError("The owning modulestore does not support this command.") diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_force_publish.py b/cms/djangoapps/contentstore/management/commands/tests/test_force_publish.py new file mode 100644 index 0000000000..f4766ddfd9 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/tests/test_force_publish.py @@ -0,0 +1,109 @@ +""" +Tests for the force_publish management command +""" +import mock +from django.core.management.base import CommandError +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from contentstore.management.commands.force_publish import Command +from contentstore.management.commands.utils import get_course_versions + + +class TestForcePublish(SharedModuleStoreTestCase): + """ + Tests for the force_publish management command + """ + @classmethod + def setUpClass(cls): + super(TestForcePublish, cls).setUpClass() + cls.course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split) + cls.test_user_id = ModuleStoreEnum.UserID.test + cls.command = Command() + + def test_no_args(self): + """ + Test 'force_publish' command with no arguments + """ + errstring = "force_publish requires 1 or more argument: |commit" + with self.assertRaisesRegexp(CommandError, errstring): + self.command.handle() + + def test_invalid_course_key(self): + """ + Test 'force_publish' command with invalid course key + """ + errstring = "Invalid course key." + with self.assertRaisesRegexp(CommandError, errstring): + self.command.handle('TestX/TS01') + + def test_too_many_arguments(self): + """ + Test 'force_publish' command with more than 2 arguments + """ + errstring = "force_publish requires 1 or more argument: |commit" + with self.assertRaisesRegexp(CommandError, errstring): + self.command.handle(unicode(self.course.id), 'commit', 'invalid-arg') + + def test_course_key_not_found(self): + """ + Test 'force_publish' command with non-existing course key + """ + errstring = "Course not found." + with self.assertRaisesRegexp(CommandError, errstring): + self.command.handle(unicode('course-v1:org+course+run')) + + def test_force_publish_non_split(self): + """ + Test 'force_publish' command doesn't work on non split courses + """ + course = CourseFactory.create(default_store=ModuleStoreEnum.Type.mongo) + errstring = 'The owning modulestore does not support this command.' + with self.assertRaisesRegexp(CommandError, errstring): + self.command.handle(unicode(course.id)) + + @SharedModuleStoreTestCase.modifies_courseware + def test_force_publish(self): + """ + Test 'force_publish' command + """ + # Add some changes to course + chapter = ItemFactory.create(category='chapter', parent_location=self.course.location) + self.store.create_child( + self.test_user_id, + chapter.location, + 'html', + block_id='html_component' + ) + + # verify that course has changes. + self.assertTrue(self.store.has_changes(self.store.get_item(self.course.location))) + + # get draft and publish branch versions + versions = get_course_versions(unicode(self.course.id)) + draft_version = versions['draft-branch'] + published_version = versions['published-branch'] + + # verify that draft and publish point to different versions + self.assertNotEqual(draft_version, published_version) + + with mock.patch('contentstore.management.commands.force_publish.query_yes_no') as patched_yes_no: + patched_yes_no.return_value = True + + # force publish course + self.command.handle(unicode(self.course.id), 'commit') + + # verify that course has no changes + self.assertFalse(self.store.has_changes(self.store.get_item(self.course.location))) + + # get new draft and publish branch versions + versions = get_course_versions(unicode(self.course.id)) + new_draft_version = versions['draft-branch'] + new_published_version = versions['published-branch'] + + # verify that the draft branch didn't change while the published branch did + self.assertEqual(draft_version, new_draft_version) + self.assertNotEqual(published_version, new_published_version) + + # verify that draft and publish point to same versions now + self.assertEqual(new_draft_version, new_published_version) diff --git a/cms/djangoapps/contentstore/management/commands/utils.py b/cms/djangoapps/contentstore/management/commands/utils.py index e2e13a3263..03502f8441 100644 --- a/cms/djangoapps/contentstore/management/commands/utils.py +++ b/cms/djangoapps/contentstore/management/commands/utils.py @@ -2,6 +2,8 @@ Common methods for cms commands to use """ from django.contrib.auth.models import User +from opaque_keys.edx.keys import CourseKey +from xmodule.modulestore.django import modulestore def user_from_str(identifier): @@ -17,3 +19,21 @@ def user_from_str(identifier): return User.objects.get(email=identifier) return User.objects.get(id=user_id) + + +def get_course_versions(course_key): + """ + Fetches the latest course versions + :param course_key: + :return: { 'draft-branch' : value1, 'published-branch' : value2} + """ + course_locator = CourseKey.from_string(course_key) + store = modulestore()._get_modulestore_for_courselike(course_locator) # pylint: disable=protected-access + index_entry = store.get_course_index(course_locator) + if index_entry is not None: + return { + 'draft-branch': index_entry['versions']['draft-branch'], + 'published-branch': index_entry['versions']['published-branch'] + } + + return None 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 fbc32d9a09..d8b491a130 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py @@ -445,6 +445,28 @@ 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 force_publish_course(self, course_locator, user_id, commit=False): + """ + Helper method to forcefully publish a course, + making the published branch point to the same structure as the draft branch. + """ + versions = None + index_entry = self.get_course_index(course_locator) + if index_entry is not None: + versions = index_entry['versions'] + if commit: + # update published branch version only if publish and draft point to different versions + if versions['published-branch'] != versions['draft-branch']: + self._update_head( + course_locator, + index_entry, + 'published-branch', + index_entry['versions']['draft-branch'] + ) + self._flag_publish_event(course_locator) + return self.get_course_index(course_locator)['versions'] + return versions + def get_course_history_info(self, course_locator): """ See :py:meth `xmodule.modulestore.split_mongo.split.SplitMongoModuleStore.get_course_history_info`