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/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py new file mode 100644 index 0000000000..13f6189cc5 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -0,0 +1,18 @@ +from django.test.testcases import TestCase +from cms.djangoapps.contentstore import utils +import mock + +class LMSLinksTestCase(TestCase): + def about_page_test(self): + location = 'i4x','mitX','101','course', 'test' + utils.get_course_id = mock.Mock(return_value="mitX/101/test") + link = utils.get_lms_link_for_about_page(location) + self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/about") + + def ls_link_test(self): + location = 'i4x','mitX','101','vertical', 'contacting_us' + utils.get_course_id = mock.Mock(return_value="mitX/101/test") + link = utils.get_lms_link_for_item(location, False) + self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us") + link = utils.get_lms_link_for_item(location, True) + self.assertEquals(link, "//preview.localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us") diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 6343098554..da2993e463 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -73,20 +73,38 @@ def get_course_for_item(location): def get_lms_link_for_item(location, preview=False): - location = Location(location) if settings.LMS_BASE is not None: lms_link = "//{preview}{lms_base}/courses/{course_id}/jump_to/{location}".format( preview='preview.' if preview else '', lms_base=settings.LMS_BASE, - # TODO: These will need to be changed to point to the particular instance of this problem in the particular course - course_id=modulestore().get_containing_courses(location)[0].id, - location=location, + course_id=get_course_id(location), + location=Location(location) ) else: lms_link = None return lms_link +def get_lms_link_for_about_page(location): + """ + Returns the url to the course about page from the location tuple. + """ + if settings.LMS_BASE is not None: + lms_link = "//{lms_base}/courses/{course_id}/about".format( + lms_base=settings.LMS_BASE, + course_id=get_course_id(location) + ) + else: + lms_link = None + + return lms_link + +def get_course_id(location): + """ + Returns the course_id from a given the location tuple. + """ + # TODO: These will need to be changed to point to the particular instance of this problem in the particular course + return modulestore().get_containing_courses(Location(location))[0].id class UnitState(object): draft = 'draft' diff --git a/cms/envs/test.py b/cms/envs/test.py index cb84f32ff1..a32f088582 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -77,6 +77,8 @@ DATABASES = { } } +LMS_BASE = "localhost:8000" + CACHES = { # This is the cache used for most things. Askbot will not work without a # functioning cache -- it relies on caching to load its settings in places. diff --git a/cms/templates/settings.html b/cms/templates/settings.html index 1266213906..1fa5b0acce 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -73,7 +73,7 @@ from contentstore import utils
- This is used in your course URL, and cannot be changed + This is used in your course URL, and cannot be changed
@@ -83,7 +83,7 @@ from contentstore import utils
- This is used in your course URL, and cannot be changed + This is used in your course URL, and cannot be changed
@@ -93,7 +93,7 @@ from contentstore import utils
- This is used in your course URL, and cannot be changed + This is used in your course URL, and cannot be changed
@@ -213,7 +213,7 @@ from contentstore import utils
- Introductions, prerequisites, FAQs that are used on your course summary page + Introductions, prerequisites, FAQs that are used on your course summary page
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/common/lib/xmodule/xmodule/templates/problem/forumularesponse.yaml b/common/lib/xmodule/xmodule/templates/problem/forumularesponse.yaml index c4872ca479..231530b9f3 100644 --- a/common/lib/xmodule/xmodule/templates/problem/forumularesponse.yaml +++ b/common/lib/xmodule/xmodule/templates/problem/forumularesponse.yaml @@ -1,6 +1,6 @@ --- metadata: - display_name: Formula Repsonse + display_name: Formula Response rerandomize: never showanswer: always data: | 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