From b40a8a3e9ec7813b2a66448ecb3d93e8dee1573a Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 20 Dec 2012 13:55:15 -0500 Subject: [PATCH] implement course clone and delete functionality from the command line --- cms/djangoapps/auth/authz.py | 16 +++ .../contentstore/management/commands/clone.py | 31 +++++ .../management/commands/delete_course.py | 36 +++++ .../management/commands/prompt.py | 33 +++++ .../lib/xmodule/xmodule/contentstore/mongo.py | 8 +- .../xmodule/modulestore/store_utilities.py | 129 ++++++++++++++++++ rakefile | 22 +++ 7 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 cms/djangoapps/contentstore/management/commands/clone.py create mode 100644 cms/djangoapps/contentstore/management/commands/delete_course.py create mode 100644 cms/djangoapps/contentstore/management/commands/prompt.py create mode 100644 common/lib/xmodule/xmodule/modulestore/store_utilities.py diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index 460c9d6467..e3d2e352a1 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -55,6 +55,22 @@ def create_new_course_group(creator, location, role): return +''' +This is to be called only by either a command line code path or through a app which has already +asserted permissions +''' +def _delete_course_group(location): + # remove all memberships + instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME)) + for user in instructors.user_set.all(): + user.groups.remove(instructors) + user.save() + + staff = Group.objects.get(name=get_course_groupname_for_role(location, STAFF_ROLE_NAME)) + for user in staff.user_set.all(): + user.groups.remove(staff) + user.save() + def add_user_to_course_group(caller, user, location, role): # only admins can add/remove other users diff --git a/cms/djangoapps/contentstore/management/commands/clone.py b/cms/djangoapps/contentstore/management/commands/clone.py new file mode 100644 index 0000000000..64cf4d4263 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/clone.py @@ -0,0 +1,31 @@ +### +### Script for cloning a course +### +from django.core.management.base import BaseCommand, CommandError +from xmodule.modulestore.store_utilities import clone_course +from xmodule.modulestore.django import modulestore +from xmodule.contentstore.django import contentstore +from xmodule.modulestore import Location +from xmodule.course_module import CourseDescriptor + +# +# To run from command line: rake cms:clone SOURCE_LOC=MITx/111/Foo1 DEST_LOC=MITx/135/Foo3 +# + +class Command(BaseCommand): + help = \ +'''Clone a MongoDB backed course to another location''' + + def handle(self, *args, **options): + if len(args) != 2: + raise CommandError("clone requires two arguments: ") + + source_location_str = args[0] + dest_location_str = args[1] + + print "Cloning course {0} to {1}".format(source_location_str, dest_location_str) + + source_location = CourseDescriptor.id_to_location(source_location_str) + dest_location = CourseDescriptor.id_to_location(dest_location_str) + + clone_course(modulestore('direct'), contentstore(), source_location, dest_location) diff --git a/cms/djangoapps/contentstore/management/commands/delete_course.py b/cms/djangoapps/contentstore/management/commands/delete_course.py new file mode 100644 index 0000000000..55c04bf5ea --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/delete_course.py @@ -0,0 +1,36 @@ +### +### Script for cloning a course +### +from django.core.management.base import BaseCommand, CommandError +from xmodule.modulestore.store_utilities import delete_course +from xmodule.modulestore.django import modulestore +from xmodule.contentstore.django import contentstore +from xmodule.modulestore import Location +from xmodule.course_module import CourseDescriptor +from prompt import query_yes_no + +from auth.authz import _delete_course_group + +# +# To run from command line: rake cms:delete_course LOC=MITx/111/Foo1 +# + +class Command(BaseCommand): + help = \ +'''Delete a MongoDB backed course''' + + def handle(self, *args, **options): + if len(args) != 1: + raise CommandError("delete_course requires one arguments: ") + + loc_str = args[0] + + if query_yes_no("Deleting course {0}. Confirm?".format(loc_str), default="no"): + if query_yes_no("Are you sure. This action cannot be undone!", default="no"): + loc = CourseDescriptor.id_to_location(loc_str) + if delete_course(modulestore('direct'), contentstore(), loc) == True: + # in the django layer, we need to remove all the user permissions groups associated with this course + _delete_course_group(loc) + + + diff --git a/cms/djangoapps/contentstore/management/commands/prompt.py b/cms/djangoapps/contentstore/management/commands/prompt.py new file mode 100644 index 0000000000..9c8fd81d45 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/prompt.py @@ -0,0 +1,33 @@ +import sys + +def query_yes_no(question, default="yes"): + """Ask a yes/no question via raw_input() and return their answer. + + "question" is a string that is presented to the user. + "default" is the presumed answer if the user just hits . + It must be "yes" (the default), "no" or None (meaning + an answer is required of the user). + + The "answer" return value is one of "yes" or "no". + """ + valid = {"yes":True, "y":True, "ye":True, + "no":False, "n":False} + if default == None: + prompt = " [y/n] " + elif default == "yes": + prompt = " [Y/n] " + elif default == "no": + prompt = " [y/N] " + else: + raise ValueError("invalid default answer: '%s'" % default) + + while True: + sys.stdout.write(question + prompt) + choice = raw_input().lower() + if default is not None and choice == '': + return valid[default] + elif choice in valid: + return valid[choice] + else: + sys.stdout.write("Please respond with 'yes' or 'no' "\ + "(or 'y' or 'n').\n") \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/contentstore/mongo.py b/common/lib/xmodule/xmodule/contentstore/mongo.py index bd8e9dc3ca..213beb140a 100644 --- a/common/lib/xmodule/xmodule/contentstore/mongo.py +++ b/common/lib/xmodule/xmodule/contentstore/mongo.py @@ -29,8 +29,7 @@ class MongoContentStore(ContentStore): id = content.get_id() # Seems like with the GridFS we can't update existing ID's we have to do a delete/add pair - if self.fs.exists({"_id" : id}): - self.fs.delete(id) + self.delete(id) with self.fs.new_file(_id = id, filename=content.get_url_path(), content_type=content.content_type, displayname=content.name, thumbnail_location=content.thumbnail_location) as fp: @@ -39,7 +38,10 @@ class MongoContentStore(ContentStore): return content - + def delete(self, id): + if self.fs.exists({"_id" : id}): + self.fs.delete(id) + def find(self, location): id = StaticContent.get_id_from_location(location) try: diff --git a/common/lib/xmodule/xmodule/modulestore/store_utilities.py b/common/lib/xmodule/xmodule/modulestore/store_utilities.py new file mode 100644 index 0000000000..df89cbf41c --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/store_utilities.py @@ -0,0 +1,129 @@ +import logging +from xmodule.contentstore.content import StaticContent +from xmodule.modulestore import Location +from xmodule.modulestore.mongo import MongoModuleStore + +def clone_course(modulestore, contentstore, source_location, dest_location, delete_original=False): + # first check to see if the modulestore is Mongo backed + if not isinstance(modulestore, MongoModuleStore): + raise Exception("Expected a MongoModuleStore in the runtime. Aborting....") + + # check to see if the dest_location exists as an empty course + # we need an empty course because the app layers manage the permissions and users + if not modulestore.has_item(dest_location): + raise Exception("An empty course at {0} must have already been created. Aborting...".format(dest_location)) + + # verify that the dest_location really is an empty course, which means only one + dest_modules = modulestore.get_items([dest_location.tag, dest_location.org, dest_location.course, None, None, None]) + + if len(dest_modules) != 1: + raise Exception("Course at destination {0} is not an empty course. You can only clone into an empty course. Aborting...".format(dest_location)) + + # check to see if the source course is actually there + if not modulestore.has_item(source_location): + raise Exception("Cannot find a course at {0}. Aborting".format(source_location)) + + # Get all modules under this namespace which is (tag, org, course) tuple + + modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, None]) + + for module in modules: + original_loc = Location(module.location) + + if original_loc.category != 'course': + module.location = module.location._replace(tag = dest_location.tag, org = dest_location.org, + course = dest_location.course) + else: + # on the course module we also have to update the module name + module.location = module.location._replace(tag = dest_location.tag, org = dest_location.org, + course = dest_location.course, name=dest_location.name) + + print "Cloning module {0} to {1}....".format(original_loc, module.location) + + if 'data' in module.definition: + modulestore.update_item(module.location, module.definition['data']) + + # repoint children + if 'children' in module.definition: + new_children = [] + for child_loc_url in module.definition['children']: + child_loc = Location(child_loc_url) + child_loc = child_loc._replace(tag = dest_location.tag, org = dest_location.org, + course = dest_location.course) + new_children = new_children + [child_loc.url()] + + modulestore.update_children(module.location, new_children) + + # save metadata + modulestore.update_metadata(module.location, module.metadata) + + # now iterate through all of the assets and clone them + # first the thumbnails + thumbs = contentstore.get_all_content_thumbnails_for_course(source_location) + for thumb in thumbs: + thumb_loc = Location(thumb["_id"]) + content = contentstore.find(thumb_loc) + content.location = content.location._replace(org = dest_location.org, + course = dest_location.course) + + print "Cloning thumbnail {0} to {1}".format(thumb_loc, content.location) + + contentstore.save(content) + + # now iterate through all of the assets, also updating the thumbnail pointer + + assets = contentstore.get_all_content_for_course(source_location) + for asset in assets: + asset_loc = Location(asset["_id"]) + content = contentstore.find(asset_loc) + content.location = content.location._replace(org = dest_location.org, + course = dest_location.course) + + # be sure to update the pointer to the thumbnail + if content.thumbnail_location is not None: + content.thumbnail_location._replace(tag = dest_location.tag, org = dest_location.org, + course = dest_location.course) + + + print "Cloning asset {0} to {1}".format(asset_loc, content.location) + + contentstore.save(content) + +def delete_course(modulestore, contentstore, source_location): + # first check to see if the modulestore is Mongo backed + if not isinstance(modulestore, MongoModuleStore): + raise Exception("Expected a MongoModuleStore in the runtime. Aborting....") + + # check to see if the source course is actually there + if not modulestore.has_item(source_location): + raise Exception("Cannot find a course at {0}. Aborting".format(source_location)) + + # first delete all of the thumbnails + thumbs = contentstore.get_all_content_thumbnails_for_course(source_location) + for thumb in thumbs: + thumb_loc = Location(thumb["_id"]) + id = StaticContent.get_id_from_location(thumb_loc) + print "Deleting {0}...".format(id) + contentstore.delete(id) + + # then delete all of the assets + assets = contentstore.get_all_content_for_course(source_location) + for asset in assets: + asset_loc = Location(asset["_id"]) + id = StaticContent.get_id_from_location(asset_loc) + print "Deleting {0}...".format(id) + contentstore.delete(id) + + # then delete all course modules + modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, None]) + + for module in modules: + if module.category != 'course': # save deleting the course module for last + print "Deleting {0}...".format(module.location) + modulestore.delete_item(module.location) + + # finally delete the top-level course module itself + print "Deleting {0}...".format(source_location) + modulestore.delete_item(source_location) + + return True \ No newline at end of file diff --git a/rakefile b/rakefile index fa8bb4f4ab..9d3540ee8a 100644 --- a/rakefile +++ b/rakefile @@ -396,6 +396,28 @@ task :publish => :package do sh("scp #{BUILD_DIR}/#{NORMALIZED_DEPLOY_NAME}_#{PKG_VERSION}*.deb #{PACKAGE_REPO}") end +namespace :cms do + desc "Clone existing MongoDB based course" + task :clone do + if ENV['SOURCE_LOC'] and ENV['DEST_LOC'] + sh(django_admin(:cms, :dev, :clone, ENV['SOURCE_LOC'], ENV['DEST_LOC'])) + else + raise "You must pass in a SOURCE_LOC and DEST_LOC parameters" + end + end +end + +namespace :cms do + desc "Delete existing MongoDB based course" + task :delete_course do + if ENV['LOC'] + sh(django_admin(:cms, :dev, :delete_course, ENV['LOC'])) + else + raise "You must pass in a LOC parameter" + end + end +end + namespace :cms do desc "Import course data within the given DATA_DIR variable" task :import do