diff --git a/cms/djangoapps/contentstore/management/commands/clean_cert_name.py b/cms/djangoapps/contentstore/management/commands/clean_cert_name.py new file mode 100644 index 0000000000..8914069b7c --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/clean_cert_name.py @@ -0,0 +1,205 @@ +""" +A single-use management command that provides an interactive way to remove +erroneous certificate names. +""" + +from collections import namedtuple + +from django.core.management.base import BaseCommand + +from xmodule.modulestore.django import modulestore +from xmodule.modulestore import ModuleStoreEnum + +Result = namedtuple("Result", ["course_key", "cert_name_short", "cert_name_long", "should_clean"]) + + +class Command(BaseCommand): + """ + A management command that provides an interactive way to remove erroneous cert_name_long and + cert_name_short course attributes across both the Split and Mongo modulestores. + """ + help = 'Allows manual clean-up of invalid cert_name_short and cert_name_long entries on CourseModules' + + def _mongo_results(self): + """ + Return Result objects for any mongo-modulestore backend course that has + cert_name_short or cert_name_long set. + """ + # N.B. This code breaks many abstraction barriers. That's ok, because + # it's a one-time cleanup command. + # pylint: disable=protected-access + mongo_modulestore = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo) + old_mongo_courses = mongo_modulestore.collection.find({ + "_id.category": "course", + "$or": [ + {"metadata.cert_name_short": {"$exists": 1}}, + {"metadata.cert_name_long": {"$exists": 1}}, + ] + }, { + "_id": True, + "metadata.cert_name_short": True, + "metadata.cert_name_long": True, + }) + + return [ + Result( + mongo_modulestore.make_course_key( + course['_id']['org'], + course['_id']['course'], + course['_id']['name'], + ), + course['metadata'].get('cert_name_short'), + course['metadata'].get('cert_name_long'), + True + ) for course in old_mongo_courses + ] + + def _split_results(self): + """ + Return Result objects for any split-modulestore backend course that has + cert_name_short or cert_name_long set. + """ + # N.B. This code breaks many abstraction barriers. That's ok, because + # it's a one-time cleanup command. + # pylint: disable=protected-access + split_modulestore = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.split) + active_version_collection = split_modulestore.db_connection.course_index + structure_collection = split_modulestore.db_connection.structures + + branches = active_version_collection.aggregate([{ + '$group': { + '_id': 1, + 'draft': {'$push': '$versions.draft-branch'}, + 'published': {'$push': '$versions.published-branch'} + } + }, { + '$project': { + '_id': 1, + 'branches': {'$setUnion': ['$draft', '$published']} + } + }])['result'][0]['branches'] + + structures = list( + structure_collection.find({ + '_id': {'$in': branches}, + 'blocks': {'$elemMatch': { + '$and': [ + {"block_type": "course"}, + {'$or': [ + {'fields.cert_name_long': {'$exists': True}}, + {'fields.cert_name_short': {'$exists': True}} + ]} + ] + }} + }, { + '_id': True, + 'blocks.fields.cert_name_long': True, + 'blocks.fields.cert_name_short': True, + }) + ) + + structure_map = {struct['_id']: struct for struct in structures} + structure_ids = [struct['_id'] for struct in structures] + + split_mongo_courses = list(active_version_collection.find({ + '$or': [ + {"versions.draft-branch": {'$in': structure_ids}}, + {"versions.published": {'$in': structure_ids}}, + ] + }, { + 'org': True, + 'course': True, + 'run': True, + 'versions': True, + })) + + for course in split_mongo_courses: + draft = course['versions'].get('draft-branch') + if draft in structure_map: + draft_fields = structure_map[draft]['blocks'][0].get('fields', {}) + else: + draft_fields = {} + + published = course['versions'].get('published') + if published in structure_map: + published_fields = structure_map[published]['blocks'][0].get('fields', {}) + else: + published_fields = {} + + for fields in (draft_fields, published_fields): + for field in ('cert_name_short', 'cert_name_long'): + if field in fields: + course[field] = fields[field] + + return [ + Result( + split_modulestore.make_course_key( + course['org'], + course['course'], + course['run'], + ), + course.get('cert_name_short'), + course.get('cert_name_long'), + True + ) for course in split_mongo_courses + ] + + def _display(self, results): + """ + Render a list of Result objects as a nicely formatted table. + """ + headers = ["Course Key", "cert_name_short", "cert_name_short", "Should clean?"] + col_widths = [ + max(len(unicode(result[col])) for result in results + [headers]) + for col in range(len(results[0])) + ] + id_format = "{{:>{}}} |".format(len(unicode(len(results)))) + col_format = "| {{:>{}}} |" + + self.stdout.write(id_format.format(""), ending='') + for header, width in zip(headers, col_widths): + self.stdout.write(col_format.format(width).format(header), ending='') + + self.stdout.write('') + + for idx, result in enumerate(results): + self.stdout.write(id_format.format(idx), ending='') + for col, width in zip(result, col_widths): + self.stdout.write(col_format.format(width).format(unicode(col)), ending='') + self.stdout.write("") + + def _commit(self, results): + """ + For each Result in ``results``, if ``should_clean`` is True, remove cert_name_long + and cert_name_short from the course and save in the backing modulestore. + """ + for result in results: + if not result.should_clean: + continue + course = modulestore().get_course(result.course_key) + del course.cert_name_short + del course.cert_name_long + modulestore().update_item(course, ModuleStoreEnum.UserID.mgmt_command) + + def handle(self, *args, **options): + + results = self._mongo_results() + self._split_results() + + self.stdout.write("Type the index of a row to toggle whether it will be cleaned, " + "'commit' to remove all cert_name_short and cert_name_long values " + "from any rows marked for cleaning, or 'quit' to quit.") + + while True: + self._display(results) + command = raw_input("|commit|quit: ").strip() + + if command == 'quit': + return + elif command == 'commit': + self._commit(results) + return + elif command == '': + continue + else: + index = int(command) + results[index] = results[index]._replace(should_clean=not results[index].should_clean) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 4a47af8949..d2e7b12b42 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -495,9 +495,9 @@ class CourseFields(object): ## Course level Certificate Name overrides. cert_name_short = String( help=_( - "Use this setting only when generating PDF certificates. " - "Between quotation marks, enter the short name of the course to use on the certificate that " - "students receive when they complete the course." + 'Use this setting only when generating PDF certificates. ' + 'Between quotation marks, enter the short name of the type of certificate that ' + 'students receive when they complete the course. For instance, "Certificate".' ), display_name=_("Certificate Name (Short)"), scope=Scope.settings, @@ -505,9 +505,9 @@ class CourseFields(object): ) cert_name_long = String( help=_( - "Use this setting only when generating PDF certificates. " - "Between quotation marks, enter the long name of the course to use on the certificate that students " - "receive when they complete the course." + 'Use this setting only when generating PDF certificates. ' + 'Between quotation marks, enter the long name of the type of certificate that students ' + 'receive when they complete the course. For instance, "Certificate of Achievement".' ), display_name=_("Certificate Name (Long)"), scope=Scope.settings,