From 8befde8cda27a5cc69c751668eaa47cc467ca188 Mon Sep 17 00:00:00 2001 From: Kenny Sun Date: Mon, 7 Oct 2024 16:37:29 -0400 Subject: [PATCH 01/14] feat: --cc-lti flag and basic sequental iterator implemented Added --cc-lti flag that redirects from xml_exporter to imscc_exporter in export_olx.py, new file created to start implementing imscc function, starting with basic iterator for sequentials --- .../management/commands/export_olx.py | 22 +- .../xmodule/modulestore/imscc_exporter.py | 439 ++++++++++++++++++ 2 files changed, 456 insertions(+), 5 deletions(-) create mode 100644 common/lib/xmodule/xmodule/modulestore/imscc_exporter.py diff --git a/cms/djangoapps/contentstore/management/commands/export_olx.py b/cms/djangoapps/contentstore/management/commands/export_olx.py index 7561ebdc9b48..3e94e2529acc 100644 --- a/cms/djangoapps/contentstore/management/commands/export_olx.py +++ b/cms/djangoapps/contentstore/management/commands/export_olx.py @@ -29,6 +29,8 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.xml_exporter import export_course_to_xml +from xmodule.modulestore.imscc_exporter import export_course_to_imscc + class Command(BaseCommand): @@ -40,6 +42,7 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('course_id') parser.add_argument('--output') + parser.add_argument('--cc-lti', action = 'store_true', help = 'Run the command with Common Cartridge format') def handle(self, *args, **options): course_id = options['course_id'] @@ -58,7 +61,8 @@ def handle(self, *args, **options): filename = mktemp() pipe_results = True - export_course_to_tarfile(course_key, filename) + cc_lti = options.get('cc_lti', False) + export_course_to_tarfile(course_key, filename, cc_lti) results = self._get_results(filename) if pipe_results else b'' @@ -78,17 +82,22 @@ def _get_results(self, filename): return results -def export_course_to_tarfile(course_key, filename): +def export_course_to_tarfile(course_key, filename, cc_lti): + # test for --cc-lti flag functionality it works + if cc_lti: + print("CC_LTI") + else: + print("NO CC_LTI") """Exports a course into a tar.gz file""" tmp_dir = mkdtemp() try: - course_dir = export_course_to_directory(course_key, tmp_dir) + course_dir = export_course_to_directory(course_key, tmp_dir, cc_lti) compress_directory(course_dir, filename) finally: shutil.rmtree(tmp_dir, ignore_errors=True) -def export_course_to_directory(course_key, root_dir): +def export_course_to_directory(course_key, root_dir, cc_lti): """Export course into a directory""" store = modulestore() course = store.get_course(course_key) @@ -102,7 +111,10 @@ def export_course_to_directory(course_key, root_dir): course_dir = replacement_char.join([course.id.org, course.id.course, course.id.run]) course_dir = re.sub(r'[^\w\.\-]', replacement_char, course_dir) - export_course_to_xml(store, None, course.id, root_dir, course_dir) + if cc_lti: + export_course_to_imscc(store, None, course.id, root_dir, course_dir) + else: + export_course_to_xml(store, None, course.id, root_dir, course_dir) export_dir = path(root_dir) / course_dir return export_dir diff --git a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py new file mode 100644 index 000000000000..fb7cba931e67 --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py @@ -0,0 +1,439 @@ +""" +Methods for exporting course data to IMSCC +""" + + +import logging +import os +from abc import abstractmethod +from json import dumps + +import lxml.etree +from fs.osfs import OSFS +from opaque_keys.edx.locator import CourseLocator, LibraryLocator +from xblock.fields import Reference, ReferenceList, ReferenceValueDict, Scope + +from xmodule.assetstore import AssetMetadata +from xmodule.contentstore.content import StaticContent +from xmodule.exceptions import NotFoundError +from xmodule.modulestore import LIBRARY_ROOT, EdxJSONEncoder, ModuleStoreEnum +from xmodule.modulestore.draft_and_published import DIRECT_ONLY_CATEGORIES +from xmodule.modulestore.inheritance import own_metadata +from xmodule.modulestore.store_utilities import draft_node_constructor, get_draft_subtree_roots + +DRAFT_DIR = "drafts" +PUBLISHED_DIR = "published" + +DEFAULT_CONTENT_FIELDS = ['metadata', 'data'] + + +def _export_drafts(modulestore, course_key, export_fs, xml_centric_course_key): + """ + Exports course drafts. + """ + # NOTE: we need to explicitly implement the logic for setting the vertical's parent + # and index here since the XML modulestore cannot load draft modules + with modulestore.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course_key): + draft_modules = modulestore.get_items( + course_key, + qualifiers={'category': {'$nin': DIRECT_ONLY_CATEGORIES}}, + revision=ModuleStoreEnum.RevisionOption.draft_only + ) + # Check to see if the returned draft modules have changes w.r.t. the published module. + # Only modules with changes will be exported into the /drafts directory. + draft_modules = [module for module in draft_modules if modulestore.has_changes(module)] + if draft_modules: + draft_course_dir = export_fs.makedir(DRAFT_DIR, recreate=True) + + # accumulate tuples of draft_modules and their parents in + # this list: + draft_node_list = [] + + for draft_module in draft_modules: + parent_loc = modulestore.get_parent_location( + draft_module.location, + revision=ModuleStoreEnum.RevisionOption.draft_preferred + ) + + # if module has no parent, set its parent_url to `None` + parent_url = None + if parent_loc is not None: + parent_url = str(parent_loc) + + draft_node = draft_node_constructor( + draft_module, + location=draft_module.location, + url=str(draft_module.location), + parent_location=parent_loc, + parent_url=parent_url, + ) + + draft_node_list.append(draft_node) + + for draft_node in get_draft_subtree_roots(draft_node_list): + # only export the roots of the draft subtrees + # since export_from_xml (called by `add_xml_to_node`) + # exports a whole tree + + # ensure module has "xml_attributes" attr + if not hasattr(draft_node.module, 'xml_attributes'): + draft_node.module.xml_attributes = {} + + # Don't try to export orphaned items + # and their descendents + if draft_node.parent_location is None: + continue + + logging.debug('parent_loc = %s', draft_node.parent_location) + draft_node.module.xml_attributes['parent_url'] = draft_node.parent_url + parent = modulestore.get_item(draft_node.parent_location) + + # Don't try to export orphaned items + if draft_node.module.location not in parent.children: + continue + index = parent.children.index(draft_node.module.location) + draft_node.module.xml_attributes['index_in_children_list'] = str(index) + + draft_node.module.runtime.export_fs = draft_course_dir + adapt_references(draft_node.module, xml_centric_course_key, draft_course_dir) + node = lxml.etree.Element('unknown') + + draft_node.module.add_xml_to_node(node) + + +class ExportManager: + """ + Manages XML exporting for courselike objects. + """ + def __init__(self, modulestore, contentstore, courselike_key, root_dir, target_dir): + """ + Export all modules from `modulestore` and content from `contentstore` as xml to `root_dir`. + + `modulestore`: A `ModuleStore` object that is the source of the modules to export + `contentstore`: A `ContentStore` object that is the source of the content to export, can be None + `courselike_key`: The Locator of the Descriptor to export + `root_dir`: The directory to write the exported xml to + `target_dir`: The name of the directory inside `root_dir` to write the content to + """ + self.modulestore = modulestore + self.contentstore = contentstore + self.courselike_key = courselike_key + self.root_dir = root_dir + self.target_dir = str(target_dir) + + @abstractmethod + def get_key(self): + """ + Get the courselike locator key + """ + raise NotImplementedError + + def process_root(self, root, export_fs): + """ + Perform any additional tasks to the root XML node. + """ + + def process_extra(self, root, courselike, root_courselike_dir, xml_centric_courselike_key, export_fs): + """ + Process additional content, like static assets. + """ + + def post_process(self, root, export_fs): + """ + Perform any final processing after the other export tasks are done. + """ + + @abstractmethod + def get_courselike(self): + """ + Get the target courselike object for this export. + """ + + def export(self): + """ + Perform the export given the parameters handed to this class at init. + """ + with self.modulestore.bulk_operations(self.courselike_key): + + # attempt to collect metadata + fsm = OSFS(self.root_dir) + root = lxml.etree.Element('unknown') + + # export only the published content + with self.modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, self.courselike_key): + courselike = self.get_courselike() + export_fs = courselike.runtime.export_fs = fsm.makedir(self.target_dir, recreate=True) + + # change all of the references inside the course to use the xml expected key type w/o version & branch + xml_centric_courselike_key = self.get_key() + adapt_references(courselike, xml_centric_courselike_key, export_fs) + root.set('url_name', self.courselike_key.run) + courselike.add_xml_to_node(root) + + # Make any needed adjustments to the root node. + self.process_root(root, export_fs) + + # Process extra items-- drafts, assets, etc + root_courselike_dir = self.root_dir + '/' + self.target_dir + self.process_extra(root, courselike, root_courselike_dir, xml_centric_courselike_key, export_fs) + + # Any last pass adjustments + self.post_process(root, export_fs) + + +class CourseExportManager(ExportManager): + """ + Export manager for courses. + """ + def get_key(self): + return CourseLocator( + self.courselike_key.org, self.courselike_key.course, self.courselike_key.run, deprecated=True + ) + + def get_courselike(self): + # depth = None: Traverses down the entire course structure. + # lazy = False: Loads and caches all block definitions during traversal for fast access later + # -and- to eliminate many round-trips to read individual definitions. + # Why these parameters? Because a course export needs to access all the course block information + # eventually. Accessing it all now at the beginning increases performance of the export. + return self.modulestore.get_course(self.courselike_key, depth=None, lazy=False) + + def get_sequential_modules(self, modulestore, course_key): + """ + Retrieve all sequential modules from the course. + """ + + with modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, course_key): + # Get all top-level modules (e.g., chapters, sections) + top_level_modules = modulestore.get_items(course_key) + + sequentials = [] + for module in top_level_modules: + if module.category == 'sequential': + sequentials.append(module) + # Recursively check children if necessary + if hasattr(module, 'children'): + for child in module.children: + child_module = modulestore.get_item(child) + if child_module.category == 'sequential': + sequentials.append(child_module) + return sequentials + + def process_root(self, root, export_fs): + with export_fs.open('course.xml', 'wb') as course_xml: + lxml.etree.ElementTree(root).write(course_xml, encoding='utf-8') + + def process_extra(self, root, courselike, root_courselike_dir, xml_centric_courselike_key, export_fs): + + # Retrieve all sequential modules + sequentials = self.get_sequential_modules(self.modulestore, self.courselike_key) + + for sequential in sequentials: + print(f"Sequential Title: {sequential.display_name}") + print(f"Sequential ID: {sequential.location.block_id}") + + # Export the modulestore's asset metadata. + asset_dir = root_courselike_dir + '/' + AssetMetadata.EXPORTED_ASSET_DIR + '/' + if not os.path.isdir(asset_dir): + os.makedirs(asset_dir) + asset_root = lxml.etree.Element(AssetMetadata.ALL_ASSETS_XML_TAG) + course_assets = self.modulestore.get_all_asset_metadata(self.courselike_key, None) + for asset_md in course_assets: + # All asset types are exported using the "asset" tag - but their asset type is specified in each asset key. + asset = lxml.etree.SubElement(asset_root, AssetMetadata.ASSET_XML_TAG) + asset_md.to_xml(asset) + with OSFS(asset_dir).open(AssetMetadata.EXPORTED_ASSET_FILENAME, 'wb') as asset_xml_file: + lxml.etree.ElementTree(asset_root).write(asset_xml_file, encoding='utf-8') + + # export the static assets + policies_dir = export_fs.makedir('policies', recreate=True) + if self.contentstore: + self.contentstore.export_all_for_course( + self.courselike_key, + root_courselike_dir + '/static/', + root_courselike_dir + '/policies/assets.json', + ) + + # If we are using the default course image, export it to the + # legacy location to support backwards compatibility. + if courselike.course_image == courselike.fields['course_image'].default: + try: + course_image = self.contentstore.find( + StaticContent.compute_location( + courselike.id, + courselike.course_image + ), + ) + except NotFoundError: + pass + else: + output_dir = root_courselike_dir + '/static/images/' + if not os.path.isdir(output_dir): + os.makedirs(output_dir) + with OSFS(output_dir).open('course_image.jpg', 'wb') as course_image_file: + course_image_file.write(course_image.data) + + # export the static tabs + export_extra_content( + export_fs, self.modulestore, self.courselike_key, xml_centric_courselike_key, + 'static_tab', 'tabs', '.html' + ) + + # export the custom tags + export_extra_content( + export_fs, self.modulestore, self.courselike_key, xml_centric_courselike_key, + 'custom_tag_template', 'custom_tags' + ) + + # export the course updates + export_extra_content( + export_fs, self.modulestore, self.courselike_key, xml_centric_courselike_key, + 'course_info', 'info', '.html' + ) + + # export the 'about' data (e.g. overview, etc.) + export_extra_content( + export_fs, self.modulestore, self.courselike_key, xml_centric_courselike_key, + 'about', 'about', '.html' + ) + + course_policy_dir_name = courselike.location.run + course_run_policy_dir = policies_dir.makedir(course_policy_dir_name, recreate=True) + + # export the grading policy + with course_run_policy_dir.open('grading_policy.json', 'wb') as grading_policy: + grading_policy.write(dumps(courselike.grading_policy, cls=EdxJSONEncoder, + sort_keys=True, indent=4).encode('utf-8')) + + # export all of the course metadata in policy.json + with course_run_policy_dir.open('policy.json', 'wb') as course_policy: + policy = {'course/' + courselike.location.run: own_metadata(courselike)} + course_policy.write(dumps(policy, cls=EdxJSONEncoder, sort_keys=True, indent=4).encode('utf-8')) + + _export_drafts(self.modulestore, self.courselike_key, export_fs, xml_centric_courselike_key) + + +class LibraryExportManager(ExportManager): + """ + Export manager for Libraries + """ + def get_key(self): + """ + Get the library locator for the current library key. + """ + return LibraryLocator( + self.courselike_key.org, self.courselike_key.library + ) + + def get_courselike(self): + """ + Get the library from the modulestore. + """ + return self.modulestore.get_library(self.courselike_key, depth=None, lazy=False) + + def process_root(self, root, export_fs): + """ + Add extra attributes to the root XML file. + """ + root.set('org', self.courselike_key.org) + root.set('library', self.courselike_key.library) + + def process_extra(self, root, courselike, root_courselike_dir, xml_centric_courselike_key, export_fs): + """ + Notionally, libraries may have assets. This is currently unsupported, but the structure is here + to ease in duck typing during import. This may be expanded as a useful feature eventually. + """ + # export the static assets + export_fs.makedir('policies', recreate=True) + + if self.contentstore: + self.contentstore.export_all_for_course( + self.courselike_key, + self.root_dir + '/' + self.target_dir + '/static/', + self.root_dir + '/' + self.target_dir + '/policies/assets.json', + ) + + def post_process(self, root, export_fs): + """ + Because Libraries are XBlocks, they aren't exported in the same way Course Modules + are, but instead use the standard XBlock serializers. Accordingly, we need to + create our own index file to act as the equivalent to the root course.xml file, + called library.xml. + """ + # Create the Library.xml file, which acts as the index of all library contents. + xml_file = export_fs.open(LIBRARY_ROOT, 'wb') + xml_file.write(lxml.etree.tostring(root, pretty_print=True, encoding='utf-8')) + xml_file.close() + +""" +Functions "export_course_to_imscc" and "export_library_to_xml" below get called by the django management comman from export_olx.py +""" + +def export_course_to_imscc(modulestore, contentstore, course_key, root_dir, course_dir): + """ + Thin wrapper for the Course Export Manager. See ExportManager for details. + """ + CourseExportManager(modulestore, contentstore, course_key, root_dir, course_dir).export() + + +def export_library_to_xml(modulestore, contentstore, library_key, root_dir, library_dir): + """ + Thin wrapper for the Library Export Manager. See ExportManager for details. + """ + LibraryExportManager(modulestore, contentstore, library_key, root_dir, library_dir).export() + + +def adapt_references(subtree, destination_course_key, export_fs): + """ + Map every reference in the subtree into destination_course_key and set it back into the xblock fields + """ + subtree.runtime.export_fs = export_fs # ensure everything knows where it's going! + for field_name, field in subtree.fields.items(): + if field.is_set_on(subtree): + if isinstance(field, Reference): + value = field.read_from(subtree) + if value is not None: + field.write_to(subtree, field.read_from(subtree).map_into_course(destination_course_key)) + elif field_name == 'children': + # don't change the children field but do recurse over the children + [adapt_references(child, destination_course_key, export_fs) for child in subtree.get_children()] # lint-amnesty, pylint: disable=expression-not-assigned + elif isinstance(field, ReferenceList): + field.write_to( + subtree, + [ele.map_into_course(destination_course_key) for ele in field.read_from(subtree)] + ) + elif isinstance(field, ReferenceValueDict): + field.write_to( + subtree, { + key: ele.map_into_course(destination_course_key) for key, ele in field.read_from(subtree).items() # lint-amnesty, pylint: disable=line-too-long + } + ) + + +def _export_field_content(xblock_item, item_dir): + """ + Export all fields related to 'xblock_item' other than 'metadata' and 'data' to json file in provided directory + """ + module_data = xblock_item.get_explicitly_set_fields_by_scope(Scope.content) + if isinstance(module_data, dict): + for field_name in module_data: + if field_name not in DEFAULT_CONTENT_FIELDS: + # filename format: {dirname}.{field_name}.json + with item_dir.open('{}.{}.{}'.format(xblock_item.location.block_id, field_name, 'json'), + 'wb') as field_content_file: + field_content_file.write(dumps(module_data.get(field_name, {}), cls=EdxJSONEncoder, + sort_keys=True, indent=4).encode('utf-8')) + + +def export_extra_content(export_fs, modulestore, source_course_key, dest_course_key, category_type, dirname, file_suffix=''): # lint-amnesty, pylint: disable=line-too-long, missing-function-docstring + items = modulestore.get_items(source_course_key, qualifiers={'category': category_type}) + + if len(items) > 0: + item_dir = export_fs.makedir(dirname, recreate=True) + for item in items: + adapt_references(item, dest_course_key, export_fs) + with item_dir.open(item.location.block_id + file_suffix, 'wb') as item_file: + item_file.write(item.data.encode('utf8')) + + # export content fields other then metadata and data in json format in current directory + _export_field_content(item, item_dir) From b35615c3fd3ec4409f9ddfab2cab14e84c4a2505 Mon Sep 17 00:00:00 2001 From: Kenny Sun Date: Fri, 11 Oct 2024 14:49:38 -0400 Subject: [PATCH 02/14] feat: lti links successfully building out, started formatting assignment_settings.xml file next steps, work on creating identifiers and linking them, look at trying to extract points, clean up code, remove unecessary functions for original openedx exporter in imscc_exporter.py --- .../xmodule/modulestore/imscc_exporter.py | 207 ++++++++++++++---- test.xml | 47 ++++ 2 files changed, 206 insertions(+), 48 deletions(-) create mode 100644 test.xml diff --git a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py index fb7cba931e67..3e6675d42afd 100644 --- a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py @@ -21,6 +21,9 @@ from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.store_utilities import draft_node_constructor, get_draft_subtree_roots +import lxml.etree +import uuid + DRAFT_DIR = "drafts" PUBLISHED_DIR = "published" @@ -100,6 +103,26 @@ def _export_drafts(modulestore, course_key, export_fs, xml_centric_course_key): draft_node.module.add_xml_to_node(node) +def get_sequential_modules(modulestore, course_key): + """ + Retrieve all sequential modules from the course. + """ + + with modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, course_key): + # Get all top-level modules (e.g., chapters, sections) + top_level_modules = modulestore.get_items(course_key) + + sequentials = [] + for module in top_level_modules: + if module.category == 'sequential': + sequentials.append(module) + # Recursively check children if necessary + if hasattr(module, 'children'): + for child in module.children: + child_module = modulestore.get_item(child) + if child_module.category == 'sequential': + sequentials.append(child_module) + return sequentials class ExportManager: """ @@ -121,6 +144,14 @@ def __init__(self, modulestore, contentstore, courselike_key, root_dir, target_d self.root_dir = root_dir self.target_dir = str(target_dir) + print(self.modulestore) + print(self.contentstore) + print(self.courselike_key) + print(self.root_dir) + + # Name of the folder in the tar.gz + print(self.target_dir) + @abstractmethod def get_key(self): """ @@ -153,32 +184,141 @@ def export(self): """ Perform the export given the parameters handed to this class at init. """ + + # contains all the default metadata values used in exporting CUCWD's OpenEdX courses to Canvas + metadata_template = { + 'identifier': '', # custom + 'title': '', # custom + 'due_at': '', + 'lock_at': '', + 'unlock_at': '', + 'module_locked': 'false', + 'assignment_group_identifierref': '', # custom + 'workflow_state': 'published', + 'assignment_overrides': '', + 'allowed_extensions': '', + 'has_group_category': 'false', + 'points_possible': '', # custom + 'grading_type': 'points', + 'all_day': 'false', + 'submission_types': 'external_tool', + 'position': '100', + 'turnitin_enabled': 'false', + 'vericite_enabled': 'false', + 'peer_review_count': '0', + 'peer_reviews': 'false', + 'automatic_peer_reviews': 'false', + 'anonymous_peer_reviews': 'false', + 'grade_group_students_individually': 'false', + 'freeze_on_copy': 'false', + 'omit_from_final_grade': 'false', + 'hide_in_gradebook': 'false', + 'intra_group_peer_reviews': 'false', + 'only_visible_to_overrides': 'false', + 'post_to_sis': 'false', + 'moderated_grading': 'false', + 'grader_count': '0', + 'grader_comments_visible_to_graders': 'true', + 'anonymous_grading': 'false', + 'graders_anonymous_to_graders': 'false', + 'grader_names_visible_to_final_grader': 'true', + 'anonymous_instructor_annotations': 'false', + 'external_tool_identifierref': '', # custom + 'external_tool_url': '', # custom + 'external_tool_data_json': '\"\"', + 'external_tool_link_settings_json': '{\"selection_width\": \"\", "selection_height": \"\"}', + 'external_tool_new_tab': 'false', + 'post_policy': '' + } + + sequential_modules = get_sequential_modules(self.modulestore, self.courselike_key) + all_sequential_metadata = [] + for sequential in sequential_modules: + sequential_metadata = metadata_template + sequential_metadata['title'] = str(getattr(sequential, 'display_name')) + # will need to build out all identifiers, unsure how they are created or what convention they follow + # also need to find where the point values are coming from + # build out the lti link + lti_link = 'https://courses.educateworkforce.com/lti_provider/courses/' + str(self.courselike_key) + "/" + (str(self.courselike_key)).replace('course', 'block') + 'type@' + str(getattr(sequential, 'url_name')) + print(lti_link) + sequential_metadata['external_tool_url'] = lti_link + all_sequential_metadata.append(sequential_metadata) + + print("course_id") + print(dir(sequential_modules[0])) + print(sequential_modules[0].scope_ids) + print(all_sequential_metadata[0]) + + + + for sequential_metadata in all_sequential_metadata: + # 3 types of identifiers that need to be made + # assignment_group_identifier - links to type of assignment and grading system + # identifier - in root, links folder, file, manifest + # external_tool_identifier - the same across all xml files, helps with usage of lti + # Generate a UUID following Canvas export standards to create identifiers + identifier = 'g' + (str(uuid.uuid4())).replace('-', '') + + print(identifier) + + # Create the root element with proper namespaces + root = lxml.etree.Element( + 'assignment', + { + 'identifier': identifier, + }, + nsmap={ + None: 'http://canvas.instructure.com/xsd/cccv1p0', # Default namespace + 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', + } + ) + root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', + 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') + root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', + 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') + + for key in sequential_metadata.keys(): + sub_element = lxml.etree.SubElement(root, key) + if key == 'post_policy': + post_sub = lxml.etree.SubElement(sub_element, 'post_manually') + post_sub.text = 'false' + else: + sub_element.text = sequential_metadata[key] + print(key + ': ' + sequential_metadata[key]) + + # Convert the t ree to a string + tree = lxml.etree.ElementTree(root) + tree.write('test.xml', xml_declaration=True, encoding='UTF-8', pretty_print=True) + with self.modulestore.bulk_operations(self.courselike_key): - # attempt to collect metadata - fsm = OSFS(self.root_dir) - root = lxml.etree.Element('unknown') + fsm = OSFS(self.root_dir) + root = lxml.etree.Element('unknown') + + # export only the published content + with self.modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, self.courselike_key): + + # stores metadata for the course + courselike = self.get_courselike() - # export only the published content - with self.modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, self.courselike_key): - courselike = self.get_courselike() - export_fs = courselike.runtime.export_fs = fsm.makedir(self.target_dir, recreate=True) + # make the directory to export to + export_fs = courselike.runtime.export_fs = fsm.makedir(self.target_dir, recreate=True) - # change all of the references inside the course to use the xml expected key type w/o version & branch - xml_centric_courselike_key = self.get_key() - adapt_references(courselike, xml_centric_courselike_key, export_fs) - root.set('url_name', self.courselike_key.run) - courselike.add_xml_to_node(root) + # # change all of the references inside the course to use the xml expected key type w/o version & branch + # xml_centric_courselike_key = self.get_key() + # adapt_references(courselike, xml_centric_courselike_key, export_fs) + # root.set('url_name', self.courselike_key.run) + # courselike.add_xml_to_node(root) - # Make any needed adjustments to the root node. - self.process_root(root, export_fs) + # # Make any needed adjustments to the root node. + # self.process_root(root, export_fs) - # Process extra items-- drafts, assets, etc - root_courselike_dir = self.root_dir + '/' + self.target_dir - self.process_extra(root, courselike, root_courselike_dir, xml_centric_courselike_key, export_fs) + # # Process extra items-- drafts, assets, etc + # root_courselike_dir = self.root_dir + '/' + self.target_dir + # self.process_extra(root, courselike, root_courselike_dir, xml_centric_courselike_key, export_fs) - # Any last pass adjustments - self.post_process(root, export_fs) + # # Any last pass adjustments + # self.post_process(root, export_fs) class CourseExportManager(ExportManager): @@ -198,40 +338,11 @@ def get_courselike(self): # eventually. Accessing it all now at the beginning increases performance of the export. return self.modulestore.get_course(self.courselike_key, depth=None, lazy=False) - def get_sequential_modules(self, modulestore, course_key): - """ - Retrieve all sequential modules from the course. - """ - - with modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, course_key): - # Get all top-level modules (e.g., chapters, sections) - top_level_modules = modulestore.get_items(course_key) - - sequentials = [] - for module in top_level_modules: - if module.category == 'sequential': - sequentials.append(module) - # Recursively check children if necessary - if hasattr(module, 'children'): - for child in module.children: - child_module = modulestore.get_item(child) - if child_module.category == 'sequential': - sequentials.append(child_module) - return sequentials - def process_root(self, root, export_fs): with export_fs.open('course.xml', 'wb') as course_xml: lxml.etree.ElementTree(root).write(course_xml, encoding='utf-8') def process_extra(self, root, courselike, root_courselike_dir, xml_centric_courselike_key, export_fs): - - # Retrieve all sequential modules - sequentials = self.get_sequential_modules(self.modulestore, self.courselike_key) - - for sequential in sequentials: - print(f"Sequential Title: {sequential.display_name}") - print(f"Sequential ID: {sequential.location.block_id}") - # Export the modulestore's asset metadata. asset_dir = root_courselike_dir + '/' + AssetMetadata.EXPORTED_ASSET_DIR + '/' if not os.path.isdir(asset_dir): diff --git a/test.xml b/test.xml new file mode 100644 index 000000000000..22d9993e33f8 --- /dev/null +++ b/test.xml @@ -0,0 +1,47 @@ + + + + Course Survey + + + + false + + published + + + false + + points + false + external_tool + 100 + false + false + 0 + false + false + false + false + false + false + false + false + false + false + false + 0 + true + false + false + true + false + + https://courses.educateworkforce.com/lti_provider/courses/course-v1:CA+FAA-ACS-AM-ID-FLF+DEVELOPMENT/block-v1:CA+FAA-ACS-AM-ID-FLF+DEVELOPMENTtype@478b8d00cfa04aab8010999f7e5a02f4 + "" + {"selection_width": "", "selection_height": ""} + false + + false + + From 7d07617e311171dec9e0ecb79ee7959a86804eb4 Mon Sep 17 00:00:00 2001 From: Kenny Sun Date: Wed, 16 Oct 2024 15:19:37 -0400 Subject: [PATCH 03/14] fix: removed ExportManger classes and export_drafts, combined into one new TestExportManager class All the necessary work for imscc was merged together into one class to keep things organized and concise, additionally, moved code to create assignment_setting.xml files into a function separate from the primary export command --- .../xmodule/modulestore/imscc_exporter.py | 406 ++---------------- 1 file changed, 45 insertions(+), 361 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py index 3e6675d42afd..196824029bb5 100644 --- a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py @@ -30,101 +30,7 @@ DEFAULT_CONTENT_FIELDS = ['metadata', 'data'] -def _export_drafts(modulestore, course_key, export_fs, xml_centric_course_key): - """ - Exports course drafts. - """ - # NOTE: we need to explicitly implement the logic for setting the vertical's parent - # and index here since the XML modulestore cannot load draft modules - with modulestore.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course_key): - draft_modules = modulestore.get_items( - course_key, - qualifiers={'category': {'$nin': DIRECT_ONLY_CATEGORIES}}, - revision=ModuleStoreEnum.RevisionOption.draft_only - ) - # Check to see if the returned draft modules have changes w.r.t. the published module. - # Only modules with changes will be exported into the /drafts directory. - draft_modules = [module for module in draft_modules if modulestore.has_changes(module)] - if draft_modules: - draft_course_dir = export_fs.makedir(DRAFT_DIR, recreate=True) - - # accumulate tuples of draft_modules and their parents in - # this list: - draft_node_list = [] - - for draft_module in draft_modules: - parent_loc = modulestore.get_parent_location( - draft_module.location, - revision=ModuleStoreEnum.RevisionOption.draft_preferred - ) - - # if module has no parent, set its parent_url to `None` - parent_url = None - if parent_loc is not None: - parent_url = str(parent_loc) - - draft_node = draft_node_constructor( - draft_module, - location=draft_module.location, - url=str(draft_module.location), - parent_location=parent_loc, - parent_url=parent_url, - ) - - draft_node_list.append(draft_node) - - for draft_node in get_draft_subtree_roots(draft_node_list): - # only export the roots of the draft subtrees - # since export_from_xml (called by `add_xml_to_node`) - # exports a whole tree - - # ensure module has "xml_attributes" attr - if not hasattr(draft_node.module, 'xml_attributes'): - draft_node.module.xml_attributes = {} - - # Don't try to export orphaned items - # and their descendents - if draft_node.parent_location is None: - continue - - logging.debug('parent_loc = %s', draft_node.parent_location) - draft_node.module.xml_attributes['parent_url'] = draft_node.parent_url - parent = modulestore.get_item(draft_node.parent_location) - - # Don't try to export orphaned items - if draft_node.module.location not in parent.children: - continue - index = parent.children.index(draft_node.module.location) - draft_node.module.xml_attributes['index_in_children_list'] = str(index) - - draft_node.module.runtime.export_fs = draft_course_dir - adapt_references(draft_node.module, xml_centric_course_key, draft_course_dir) - node = lxml.etree.Element('unknown') - - draft_node.module.add_xml_to_node(node) - -def get_sequential_modules(modulestore, course_key): - """ - Retrieve all sequential modules from the course. - """ - - with modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, course_key): - # Get all top-level modules (e.g., chapters, sections) - top_level_modules = modulestore.get_items(course_key) - - sequentials = [] - for module in top_level_modules: - if module.category == 'sequential': - sequentials.append(module) - # Recursively check children if necessary - if hasattr(module, 'children'): - for child in module.children: - child_module = modulestore.get_item(child) - if child_module.category == 'sequential': - sequentials.append(child_module) - return sequentials - -class ExportManager: +class TestExportManager: """ Manages XML exporting for courselike objects. """ @@ -148,43 +54,48 @@ def __init__(self, modulestore, contentstore, courselike_key, root_dir, target_d print(self.contentstore) print(self.courselike_key) print(self.root_dir) - - # Name of the folder in the tar.gz print(self.target_dir) - @abstractmethod def get_key(self): """ Get the courselike locator key """ - raise NotImplementedError - - def process_root(self, root, export_fs): - """ - Perform any additional tasks to the root XML node. - """ - - def process_extra(self, root, courselike, root_courselike_dir, xml_centric_courselike_key, export_fs): - """ - Process additional content, like static assets. - """ - - def post_process(self, root, export_fs): - """ - Perform any final processing after the other export tasks are done. - """ + return CourseLocator( + self.courselike_key.org, self.courselike_key.course, self.courselike_key.run, deprecated=True + ) - @abstractmethod def get_courselike(self): """ Get the target courselike object for this export. """ - - def export(self): - """ - Perform the export given the parameters handed to this class at init. - """ - + # depth = None: Traverses down the entire course structure. + # lazy = False: Loads and caches all block definitions during traversal for fast access later + # -and- to eliminate many round-trips to read individual definitions. + # Why these parameters? Because a course export needs to access all the course block information + # eventually. Accessing it all now at the beginning increases performance of the export. + return self.modulestore.get_course(self.courselike_key, depth=None, lazy=False) + + def get_sequential_modules(self, modulestore, course_key): + """ + Retrieve all sequential modules from the course. + """ + with modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, course_key): + # Get all top-level modules (e.g., chapters, sections) + top_level_modules = modulestore.get_items(course_key) + + sequentials = [] + for module in top_level_modules: + if module.category == 'sequential': + sequentials.append(module) + # Recursively check children if necessary + if hasattr(module, 'children'): + for child in module.children: + child_module = modulestore.get_item(child) + if child_module.category == 'sequential': + sequentials.append(child_module) + return sequentials + + def get_assignment_xml(self, modulestore, course_key): # contains all the default metadata values used in exporting CUCWD's OpenEdX courses to Canvas metadata_template = { 'identifier': '', # custom @@ -231,7 +142,7 @@ def export(self): 'post_policy': '' } - sequential_modules = get_sequential_modules(self.modulestore, self.courselike_key) + sequential_modules = self.get_sequential_modules(modulestore, course_key) all_sequential_metadata = [] for sequential in sequential_modules: sequential_metadata = metadata_template @@ -239,7 +150,7 @@ def export(self): # will need to build out all identifiers, unsure how they are created or what convention they follow # also need to find where the point values are coming from # build out the lti link - lti_link = 'https://courses.educateworkforce.com/lti_provider/courses/' + str(self.courselike_key) + "/" + (str(self.courselike_key)).replace('course', 'block') + 'type@' + str(getattr(sequential, 'url_name')) + lti_link = 'https://courses.educateworkforce.com/lti_provider/courses/' + str(course_key) + "/" + (str(course_key)).replace('course', 'block') + 'type@' + str(getattr(sequential, 'url_name')) print(lti_link) sequential_metadata['external_tool_url'] = lti_link all_sequential_metadata.append(sequential_metadata) @@ -290,6 +201,13 @@ def export(self): tree = lxml.etree.ElementTree(root) tree.write('test.xml', xml_declaration=True, encoding='UTF-8', pretty_print=True) + def export(self): + + self.get_assignment_xml(self.modulestore, self.courselike_key) + + """ + Perform the export given the parameters handed to this class at init. + """ with self.modulestore.bulk_operations(self.courselike_key): fsm = OSFS(self.root_dir) @@ -303,248 +221,14 @@ def export(self): # make the directory to export to export_fs = courselike.runtime.export_fs = fsm.makedir(self.target_dir, recreate=True) - - # # change all of the references inside the course to use the xml expected key type w/o version & branch - # xml_centric_courselike_key = self.get_key() - # adapt_references(courselike, xml_centric_courselike_key, export_fs) - # root.set('url_name', self.courselike_key.run) - # courselike.add_xml_to_node(root) - - # # Make any needed adjustments to the root node. - # self.process_root(root, export_fs) - - # # Process extra items-- drafts, assets, etc - # root_courselike_dir = self.root_dir + '/' + self.target_dir - # self.process_extra(root, courselike, root_courselike_dir, xml_centric_courselike_key, export_fs) - - # # Any last pass adjustments - # self.post_process(root, export_fs) - - -class CourseExportManager(ExportManager): - """ - Export manager for courses. - """ - def get_key(self): - return CourseLocator( - self.courselike_key.org, self.courselike_key.course, self.courselike_key.run, deprecated=True - ) - - def get_courselike(self): - # depth = None: Traverses down the entire course structure. - # lazy = False: Loads and caches all block definitions during traversal for fast access later - # -and- to eliminate many round-trips to read individual definitions. - # Why these parameters? Because a course export needs to access all the course block information - # eventually. Accessing it all now at the beginning increases performance of the export. - return self.modulestore.get_course(self.courselike_key, depth=None, lazy=False) - - def process_root(self, root, export_fs): - with export_fs.open('course.xml', 'wb') as course_xml: - lxml.etree.ElementTree(root).write(course_xml, encoding='utf-8') - - def process_extra(self, root, courselike, root_courselike_dir, xml_centric_courselike_key, export_fs): - # Export the modulestore's asset metadata. - asset_dir = root_courselike_dir + '/' + AssetMetadata.EXPORTED_ASSET_DIR + '/' - if not os.path.isdir(asset_dir): - os.makedirs(asset_dir) - asset_root = lxml.etree.Element(AssetMetadata.ALL_ASSETS_XML_TAG) - course_assets = self.modulestore.get_all_asset_metadata(self.courselike_key, None) - for asset_md in course_assets: - # All asset types are exported using the "asset" tag - but their asset type is specified in each asset key. - asset = lxml.etree.SubElement(asset_root, AssetMetadata.ASSET_XML_TAG) - asset_md.to_xml(asset) - with OSFS(asset_dir).open(AssetMetadata.EXPORTED_ASSET_FILENAME, 'wb') as asset_xml_file: - lxml.etree.ElementTree(asset_root).write(asset_xml_file, encoding='utf-8') - - # export the static assets - policies_dir = export_fs.makedir('policies', recreate=True) - if self.contentstore: - self.contentstore.export_all_for_course( - self.courselike_key, - root_courselike_dir + '/static/', - root_courselike_dir + '/policies/assets.json', - ) - - # If we are using the default course image, export it to the - # legacy location to support backwards compatibility. - if courselike.course_image == courselike.fields['course_image'].default: - try: - course_image = self.contentstore.find( - StaticContent.compute_location( - courselike.id, - courselike.course_image - ), - ) - except NotFoundError: - pass - else: - output_dir = root_courselike_dir + '/static/images/' - if not os.path.isdir(output_dir): - os.makedirs(output_dir) - with OSFS(output_dir).open('course_image.jpg', 'wb') as course_image_file: - course_image_file.write(course_image.data) - - # export the static tabs - export_extra_content( - export_fs, self.modulestore, self.courselike_key, xml_centric_courselike_key, - 'static_tab', 'tabs', '.html' - ) - - # export the custom tags - export_extra_content( - export_fs, self.modulestore, self.courselike_key, xml_centric_courselike_key, - 'custom_tag_template', 'custom_tags' - ) - - # export the course updates - export_extra_content( - export_fs, self.modulestore, self.courselike_key, xml_centric_courselike_key, - 'course_info', 'info', '.html' - ) - - # export the 'about' data (e.g. overview, etc.) - export_extra_content( - export_fs, self.modulestore, self.courselike_key, xml_centric_courselike_key, - 'about', 'about', '.html' - ) - - course_policy_dir_name = courselike.location.run - course_run_policy_dir = policies_dir.makedir(course_policy_dir_name, recreate=True) - - # export the grading policy - with course_run_policy_dir.open('grading_policy.json', 'wb') as grading_policy: - grading_policy.write(dumps(courselike.grading_policy, cls=EdxJSONEncoder, - sort_keys=True, indent=4).encode('utf-8')) - - # export all of the course metadata in policy.json - with course_run_policy_dir.open('policy.json', 'wb') as course_policy: - policy = {'course/' + courselike.location.run: own_metadata(courselike)} - course_policy.write(dumps(policy, cls=EdxJSONEncoder, sort_keys=True, indent=4).encode('utf-8')) - - _export_drafts(self.modulestore, self.courselike_key, export_fs, xml_centric_courselike_key) - - -class LibraryExportManager(ExportManager): - """ - Export manager for Libraries - """ - def get_key(self): - """ - Get the library locator for the current library key. - """ - return LibraryLocator( - self.courselike_key.org, self.courselike_key.library - ) - - def get_courselike(self): - """ - Get the library from the modulestore. - """ - return self.modulestore.get_library(self.courselike_key, depth=None, lazy=False) - - def process_root(self, root, export_fs): - """ - Add extra attributes to the root XML file. - """ - root.set('org', self.courselike_key.org) - root.set('library', self.courselike_key.library) - - def process_extra(self, root, courselike, root_courselike_dir, xml_centric_courselike_key, export_fs): - """ - Notionally, libraries may have assets. This is currently unsupported, but the structure is here - to ease in duck typing during import. This may be expanded as a useful feature eventually. - """ - # export the static assets - export_fs.makedir('policies', recreate=True) - - if self.contentstore: - self.contentstore.export_all_for_course( - self.courselike_key, - self.root_dir + '/' + self.target_dir + '/static/', - self.root_dir + '/' + self.target_dir + '/policies/assets.json', - ) - - def post_process(self, root, export_fs): - """ - Because Libraries are XBlocks, they aren't exported in the same way Course Modules - are, but instead use the standard XBlock serializers. Accordingly, we need to - create our own index file to act as the equivalent to the root course.xml file, - called library.xml. - """ - # Create the Library.xml file, which acts as the index of all library contents. - xml_file = export_fs.open(LIBRARY_ROOT, 'wb') - xml_file.write(lxml.etree.tostring(root, pretty_print=True, encoding='utf-8')) - xml_file.close() + print("redirected!!!") """ -Functions "export_course_to_imscc" and "export_library_to_xml" below get called by the django management comman from export_olx.py +Function "export_course_to_imscc" below get called by the django management comman from export_olx.py """ def export_course_to_imscc(modulestore, contentstore, course_key, root_dir, course_dir): """ - Thin wrapper for the Course Export Manager. See ExportManager for details. + Thin wrapper for the Export Manager. See ExportManager for details. """ - CourseExportManager(modulestore, contentstore, course_key, root_dir, course_dir).export() - - -def export_library_to_xml(modulestore, contentstore, library_key, root_dir, library_dir): - """ - Thin wrapper for the Library Export Manager. See ExportManager for details. - """ - LibraryExportManager(modulestore, contentstore, library_key, root_dir, library_dir).export() - - -def adapt_references(subtree, destination_course_key, export_fs): - """ - Map every reference in the subtree into destination_course_key and set it back into the xblock fields - """ - subtree.runtime.export_fs = export_fs # ensure everything knows where it's going! - for field_name, field in subtree.fields.items(): - if field.is_set_on(subtree): - if isinstance(field, Reference): - value = field.read_from(subtree) - if value is not None: - field.write_to(subtree, field.read_from(subtree).map_into_course(destination_course_key)) - elif field_name == 'children': - # don't change the children field but do recurse over the children - [adapt_references(child, destination_course_key, export_fs) for child in subtree.get_children()] # lint-amnesty, pylint: disable=expression-not-assigned - elif isinstance(field, ReferenceList): - field.write_to( - subtree, - [ele.map_into_course(destination_course_key) for ele in field.read_from(subtree)] - ) - elif isinstance(field, ReferenceValueDict): - field.write_to( - subtree, { - key: ele.map_into_course(destination_course_key) for key, ele in field.read_from(subtree).items() # lint-amnesty, pylint: disable=line-too-long - } - ) - - -def _export_field_content(xblock_item, item_dir): - """ - Export all fields related to 'xblock_item' other than 'metadata' and 'data' to json file in provided directory - """ - module_data = xblock_item.get_explicitly_set_fields_by_scope(Scope.content) - if isinstance(module_data, dict): - for field_name in module_data: - if field_name not in DEFAULT_CONTENT_FIELDS: - # filename format: {dirname}.{field_name}.json - with item_dir.open('{}.{}.{}'.format(xblock_item.location.block_id, field_name, 'json'), - 'wb') as field_content_file: - field_content_file.write(dumps(module_data.get(field_name, {}), cls=EdxJSONEncoder, - sort_keys=True, indent=4).encode('utf-8')) - - -def export_extra_content(export_fs, modulestore, source_course_key, dest_course_key, category_type, dirname, file_suffix=''): # lint-amnesty, pylint: disable=line-too-long, missing-function-docstring - items = modulestore.get_items(source_course_key, qualifiers={'category': category_type}) - - if len(items) > 0: - item_dir = export_fs.makedir(dirname, recreate=True) - for item in items: - adapt_references(item, dest_course_key, export_fs) - with item_dir.open(item.location.block_id + file_suffix, 'wb') as item_file: - item_file.write(item.data.encode('utf8')) - - # export content fields other then metadata and data in json format in current directory - _export_field_content(item, item_dir) + TestExportManager(modulestore, contentstore, course_key, root_dir, course_dir).export() \ No newline at end of file From 61e08662bccea2d8a5696f6c2d230910b0509b0e Mon Sep 17 00:00:00 2001 From: Kenny Sun Date: Mon, 21 Oct 2024 16:44:15 -0400 Subject: [PATCH 04/14] feat: cleaned up code, added functions to export different files in course_settings folder Removed a lot of the hard coded assignment parameters for the xml, found most were not necessary --- .../xmodule/modulestore/imscc_exporter.py | 325 +++++++++++------- test.xml | 47 --- 2 files changed, 200 insertions(+), 172 deletions(-) delete mode 100644 test.xml diff --git a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py index 196824029bb5..3ff96f240942 100644 --- a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py @@ -21,14 +21,16 @@ from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.store_utilities import draft_node_constructor, get_draft_subtree_roots -import lxml.etree import uuid +from datetime import datetime DRAFT_DIR = "drafts" PUBLISHED_DIR = "published" DEFAULT_CONTENT_FIELDS = ['metadata', 'data'] +def create_uuid(): + return 'g' + (str(uuid.uuid4())).replace('-', '') class TestExportManager: """ @@ -50,11 +52,7 @@ def __init__(self, modulestore, contentstore, courselike_key, root_dir, target_d self.root_dir = root_dir self.target_dir = str(target_dir) - print(self.modulestore) - print(self.contentstore) - print(self.courselike_key) - print(self.root_dir) - print(self.target_dir) + self.course_settings_identifier = None def get_key(self): """ @@ -74,137 +72,213 @@ def get_courselike(self): # Why these parameters? Because a course export needs to access all the course block information # eventually. Accessing it all now at the beginning increases performance of the export. return self.modulestore.get_course(self.courselike_key, depth=None, lazy=False) - - def get_sequential_modules(self, modulestore, course_key): + + def get_sequential_modules(self, modulestore, courselike_key): """ Retrieve all sequential modules from the course. """ - with modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, course_key): + with modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, courselike_key): # Get all top-level modules (e.g., chapters, sections) - top_level_modules = modulestore.get_items(course_key) + top_level_modules = modulestore.get_items(courselike_key) sequentials = [] for module in top_level_modules: if module.category == 'sequential': sequentials.append(module) - # Recursively check children if necessary - if hasattr(module, 'children'): - for child in module.children: - child_module = modulestore.get_item(child) - if child_module.category == 'sequential': - sequentials.append(child_module) return sequentials - def get_assignment_xml(self, modulestore, course_key): - # contains all the default metadata values used in exporting CUCWD's OpenEdX courses to Canvas - metadata_template = { - 'identifier': '', # custom - 'title': '', # custom - 'due_at': '', - 'lock_at': '', - 'unlock_at': '', - 'module_locked': 'false', - 'assignment_group_identifierref': '', # custom - 'workflow_state': 'published', - 'assignment_overrides': '', - 'allowed_extensions': '', - 'has_group_category': 'false', - 'points_possible': '', # custom - 'grading_type': 'points', - 'all_day': 'false', - 'submission_types': 'external_tool', - 'position': '100', - 'turnitin_enabled': 'false', - 'vericite_enabled': 'false', - 'peer_review_count': '0', - 'peer_reviews': 'false', - 'automatic_peer_reviews': 'false', - 'anonymous_peer_reviews': 'false', - 'grade_group_students_individually': 'false', - 'freeze_on_copy': 'false', - 'omit_from_final_grade': 'false', - 'hide_in_gradebook': 'false', - 'intra_group_peer_reviews': 'false', - 'only_visible_to_overrides': 'false', - 'post_to_sis': 'false', - 'moderated_grading': 'false', - 'grader_count': '0', - 'grader_comments_visible_to_graders': 'true', - 'anonymous_grading': 'false', - 'graders_anonymous_to_graders': 'false', - 'grader_names_visible_to_final_grader': 'true', - 'anonymous_instructor_annotations': 'false', - 'external_tool_identifierref': '', # custom - 'external_tool_url': '', # custom - 'external_tool_data_json': '\"\"', - 'external_tool_link_settings_json': '{\"selection_width\": \"\", "selection_height": \"\"}', - 'external_tool_new_tab': 'false', - 'post_policy': '' - } - - sequential_modules = self.get_sequential_modules(modulestore, course_key) - all_sequential_metadata = [] - for sequential in sequential_modules: - sequential_metadata = metadata_template - sequential_metadata['title'] = str(getattr(sequential, 'display_name')) - # will need to build out all identifiers, unsure how they are created or what convention they follow - # also need to find where the point values are coming from - # build out the lti link - lti_link = 'https://courses.educateworkforce.com/lti_provider/courses/' + str(course_key) + "/" + (str(course_key)).replace('course', 'block') + 'type@' + str(getattr(sequential, 'url_name')) - print(lti_link) - sequential_metadata['external_tool_url'] = lti_link - all_sequential_metadata.append(sequential_metadata) - - print("course_id") - print(dir(sequential_modules[0])) - print(sequential_modules[0].scope_ids) - print(all_sequential_metadata[0]) + def export_assignment_groups(self, modulestore, courselike, export_fs): + # Create root + root = lxml.etree.Element( + 'assignmentGroups', + nsmap = { + None: 'http://canvas.instructure.com/xsd/cccv1p0', + 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', + } + ) - + root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', + 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') + + position = 1 + # Accessing each asignment type with their weight + for grade in courselike.grading_policy['GRADER']: + grade_name = grade['type'] + grade_weight = grade['weight'] * 100 # openedx uses 0-1 grading weight, imscc uses 0-100 + assignment_group = lxml.etree.SubElement(root, 'assignmentGroup', {'identifier': create_uuid()}) + lxml.etree.SubElement(assignment_group, 'title').text = 'EW - ' + grade_name + lxml.etree.SubElement(assignment_group, 'position').text = str(position) + position += 1 + lxml.etree.SubElement(assignment_group, 'group_weight').text = str(grade_weight) - for sequential_metadata in all_sequential_metadata: - # 3 types of identifiers that need to be made - # assignment_group_identifier - links to type of assignment and grading system - # identifier - in root, links folder, file, manifest - # external_tool_identifier - the same across all xml files, helps with usage of lti - # Generate a UUID following Canvas export standards to create identifiers - identifier = 'g' + (str(uuid.uuid4())).replace('-', '') - - print(identifier) - - # Create the root element with proper namespaces - root = lxml.etree.Element( - 'assignment', - { - 'identifier': identifier, - }, - nsmap={ - None: 'http://canvas.instructure.com/xsd/cccv1p0', # Default namespace - 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', - } - ) - root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', - 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') - root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', - 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') - - for key in sequential_metadata.keys(): - sub_element = lxml.etree.SubElement(root, key) - if key == 'post_policy': - post_sub = lxml.etree.SubElement(sub_element, 'post_manually') - post_sub.text = 'false' - else: - sub_element.text = sequential_metadata[key] - print(key + ': ' + sequential_metadata[key]) - - # Convert the t ree to a string + with export_fs.open('course_settings/assignment_groups.xml', 'wb') as assignment_groups_xml: tree = lxml.etree.ElementTree(root) - tree.write('test.xml', xml_declaration=True, encoding='UTF-8', pretty_print=True) + tree.write(assignment_groups_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) - def export(self): + def export_media_tracks(self, export_fs): + # Create root + root = lxml.etree.Element( + 'media_tracks', + nsmap = { + None: 'http://canvas.instructure.com/xsd/cccv1p0', + 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', + } + ) + + root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', + 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') - self.get_assignment_xml(self.modulestore, self.courselike_key) + with export_fs.open('course_settings/media_tracks.xml', 'wb') as media_tracks_xml: + tree = lxml.etree.ElementTree(root) + tree.write(media_tracks_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) + + def export_files_meta(self, export_fs): + # Create root + root = lxml.etree.Element( + 'fileMeta', + nsmap = { + None: 'http://canvas.instructure.com/xsd/cccv1p0', + 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', + } + ) + + root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', + 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') + + with export_fs.open('course_settings/files_meta.xml', 'wb') as files_meta_xml: + tree = lxml.etree.ElementTree(root) + tree.write(files_meta_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) + + def export_course_settings(self, modulestore, courselike_key, export_fs): + # Create root + root = lxml.etree.Element( + 'course', + nsmap = { + None: 'http://canvas.instructure.com/xsd/cccv1p0', + 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', + } + ) + + root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', + 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') + + lxml.etree.SubElement(root, 'title').text = str(courselike_key) + lxml.etree.SubElement(root, 'course_code').text = str(courselike_key) + lxml.etree.SubElement(root, 'group_weighting_scheme').text = "percent" + + # Below are additional settings that can be enabled to be the default for openedx -> canvas + # or they can be set manually after the creation of the canvas class + # lxml.etree.SubElement(root, 'is_public').text = false + # lxml.etree.SubElement(root, 'is_public_to_auth_users').text = false + # lxml.etree.SubElement(root, 'allow_student_wiki_edits').text = false + # lxml.etree.SubElement(root, 'allow_student_forum_attachments').text = true + # lxml.etree.SubElement(root, 'lock_all_announcements').text = false + # lxml.etree.SubElement(root, 'default_wiki_editing_roles').text = teachers + # lxml.etree.SubElement(root, 'allow_student_organized_groups').text = true + # lxml.etree.SubElement(root, 'default_view').text = modules + # lxml.etree.SubElement(root, 'show_total_grade_as_points').text = false + # lxml.etree.SubElement(root, 'allow_final_grade_overrides').text = false + # lxml.etree.SubElement(root, 'open_enrollment').text = false + # lxml.etree.SubElement(root, 'filter_speed_grader_by_student_group').text = false + # lxml.etree.SubElement(root, 'self_enrollment').text = false + # lxml.etree.SubElement(root, 'license').text = private + # lxml.etree.SubElement(root, 'indexed').text = false + # lxml.etree.SubElement(root, 'hide_final_grade').text = false + # lxml.etree.SubElement(root, 'hide_distribution_graphs').text = false + # lxml.etree.SubElement(root, 'allow_student_discussion_topics').text = true + # lxml.etree.SubElement(root, 'allow_student_editing').text = true + # lxml.etree.SubElement(root, 'show_announcements_on_home_page').text = false + # lxml.etree.SubElement(root, 'home_page_announcement_limit').text = 3 + # lxml.etree.SubElement(root, 'usage_rights_required').text = false + # lxml.etree.SubElement(root, 'restrict_student_future_view').text = false + # lxml.etree.SubElement(root, 'restrict_student_past_view').text = true + # lxml.etree.SubElement(root, 'homeroom_course').text = false + # lxml.etree.SubElement(root, 'grading_standard_enabled').text = false + with export_fs.open('course_settings/course_settings.xml', 'wb') as media_tracks_xml: + tree = lxml.etree.ElementTree(root) + tree.write(media_tracks_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) + + def write_imsmanifest_xml(self, export_fs, modulestore, courselike_key): + ############### Metadata section of imsmanifeset.xml #################### + # Create the root element with proper namespaces + root = lxml.etree.Element( + 'manifest', + { + 'identifier': create_uuid() + }, + nsmap={ + None: 'http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1', # Default namespace + 'lom': 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource', + 'lomimscc': 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest', + 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', + } + ) + + # Set the schemaLocation attribute + root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', + 'http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1 http://www.imsglobal.org/profile/cc/ccv1p1/ccv1p1_imscp_v1p2_v1p0.xsd ' + 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource http://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lomresource_v1p0.xsd ' + 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest http://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lommanifest_v1p0.xsd') + + # Create the metadata element + metadata = lxml.etree.SubElement(root, 'metadata') + lxml.etree.SubElement(metadata, 'schema').text = 'IMS Common Cartridge' + lxml.etree.SubElement(metadata, 'schemaversion').text = '1.1.0' + + # Create the LOM element + lom = lxml.etree.SubElement(metadata, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}lom') + + # Build the general element + general = lxml.etree.SubElement(lom, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}general') + title = lxml.etree.SubElement(general, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}title') + lxml.etree.SubElement(title, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}string').text = 'TEMP-TITlE' # Need to extract some general title + + # Create the lifecycle element + lifecycle = lxml.etree.SubElement(lom, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}lifeCycle') + contribute = lxml.etree.SubElement(lifecycle, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}contribute') + date = lxml.etree.SubElement(contribute, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}date') + lxml.etree.SubElement(date, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}dateTime').text = (datetime.now()).strftime('%Y-%m-%d') # extract current date + + # Create the rights element + rights = lxml.etree.SubElement(lom, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}rights') + copyright = lxml.etree.SubElement(rights, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}copyrightAndOtherRestrictions') + lxml.etree.SubElement(copyright, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}value').text = 'yes' + description = lxml.etree.SubElement(rights, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}description') + lxml.etree.SubElement(description, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}string').text = 'Private (Copyrighted) - http://en.wikipedia.org/wiki/Copyright' + + ######################## Organizations section of imsmanifest.xml ########################## + + # Create organizations and organization element + organizations = lxml.etree.SubElement(root, 'organizations') + organization = lxml.etree.SubElement(organizations, 'organization', {'identifier': 'org_1', 'structure': 'rooted-hierarchy'}) + + # Create outer learning_module, for first iteration of imscc_exporter, this will be hard coded for the one course, later implementations will need to incorporate multiple courses + learning_modules = lxml.etree.SubElement(organization, 'item', {'identifier': 'uuid'}) + module = lxml.etree.SubElement(learning_modules, 'item', {'identifier': 'uuid'}) + lxml.etree.SubElement(module, 'title').text = (self.get_key()).course + + uuids = [] + + # Build out all the sequentials underneath the one learning module (course) + sequential_modules = self.get_sequential_modules(modulestore, courselike_key) + for sequential in sequential_modules: + identifier = create_uuid() + uuids.append(identifier) + sequential_xml = lxml.etree.SubElement(module, 'item', {'identifier': identifier}) + lxml.etree.SubElement(sequential_xml, 'title').text = str(getattr(sequential, 'display_name')) + + tree = lxml.etree.ElementTree(root) + tree.write('test2.xml', xml_declaration=True, encoding='UTF-8', pretty_print=True) + + def export_all_course_settings(self, courselike, modulestore, courselike_key, export_fs): + export_fs.makedirs('course_settings', recreate=True) + self.export_assignment_groups(modulestore, courselike, export_fs) + self.export_media_tracks(export_fs) + self.export_files_meta(export_fs) + self.export_course_settings(modulestore, courselike_key, export_fs) + + def export(self): """ Perform the export given the parameters handed to this class at init. """ @@ -216,13 +290,14 @@ def export(self): # export only the published content with self.modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, self.courselike_key): - # stores metadata for the course - courselike = self.get_courselike() + # stores metadata for the course + courselike = self.get_courselike() - # make the directory to export to - export_fs = courselike.runtime.export_fs = fsm.makedir(self.target_dir, recreate=True) - print("redirected!!!") + # make the directory to export to + export_fs = courselike.runtime.export_fs = fsm.makedir(self.target_dir, recreate=True) + self.export_all_course_settings(courselike, self.modulestore, self.courselike_key, export_fs) + """ Function "export_course_to_imscc" below get called by the django management comman from export_olx.py """ diff --git a/test.xml b/test.xml deleted file mode 100644 index 22d9993e33f8..000000000000 --- a/test.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - Course Survey - - - - false - - published - - - false - - points - false - external_tool - 100 - false - false - 0 - false - false - false - false - false - false - false - false - false - false - false - 0 - true - false - false - true - false - - https://courses.educateworkforce.com/lti_provider/courses/course-v1:CA+FAA-ACS-AM-ID-FLF+DEVELOPMENT/block-v1:CA+FAA-ACS-AM-ID-FLF+DEVELOPMENTtype@478b8d00cfa04aab8010999f7e5a02f4 - "" - {"selection_width": "", "selection_height": ""} - false - - false - - From d71e30f2ba700b19badc8887b6e68a98fcbfe301 Mon Sep 17 00:00:00 2001 From: Kenny Sun Date: Wed, 23 Oct 2024 15:42:41 -0400 Subject: [PATCH 05/14] feat: assignment_folders builds out, having issues with possible points Need to update imsmanifest_xml to take the new identifiers as assignment folders are built out first --- .../xmodule/modulestore/imscc_exporter.py | 116 ++++++++++++++---- 1 file changed, 95 insertions(+), 21 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py index 3ff96f240942..542685d77866 100644 --- a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py @@ -32,6 +32,31 @@ def create_uuid(): return 'g' + (str(uuid.uuid4())).replace('-', '') +def get_total_score(sequential): + total_score = 0 + + # Get the direct children of the sequential + children = sequential.get_children() + + for child in children: + try: + # Check if the child has a score and add it + score = child.get_score() + print("max score rtest") + print(child.max_score()) + if score is not None: + print(getattr(child, 'display_name')) + print(score) + print(getattr(child, 'max_score')) + total_score += score[1] + except NotImplementedError: + # Handle the case where get_score is not implemented + print(f"Score not implemented for child: {child}") + + # Recursively get the score from the child's children + total_score += get_total_score(child) + + return total_score class TestExportManager: """ Manages XML exporting for courselike objects. @@ -52,7 +77,10 @@ def __init__(self, modulestore, contentstore, courselike_key, root_dir, target_d self.root_dir = root_dir self.target_dir = str(target_dir) - self.course_settings_identifier = None + self.sequential_to_identifier = {} + self.sequential_to_identifierref = {} + self.assignment_group_to_identifier = {} + self.external_tool_identifierref = create_uuid() def get_key(self): """ @@ -99,13 +127,14 @@ def export_assignment_groups(self, modulestore, courselike, export_fs): root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') - + position = 1 # Accessing each asignment type with their weight for grade in courselike.grading_policy['GRADER']: grade_name = grade['type'] grade_weight = grade['weight'] * 100 # openedx uses 0-1 grading weight, imscc uses 0-100 - assignment_group = lxml.etree.SubElement(root, 'assignmentGroup', {'identifier': create_uuid()}) + self.assignment_group_to_identifier[grade_name] = create_uuid() + assignment_group = lxml.etree.SubElement(root, 'assignmentGroup', {'identifier': str(self.assignment_group_to_identifier[grade_name])}) lxml.etree.SubElement(assignment_group, 'title').text = 'EW - ' + grade_name lxml.etree.SubElement(assignment_group, 'position').text = str(position) position += 1 @@ -199,7 +228,46 @@ def export_course_settings(self, modulestore, courselike_key, export_fs): tree = lxml.etree.ElementTree(root) tree.write(media_tracks_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) - def write_imsmanifest_xml(self, export_fs, modulestore, courselike_key): + def export_assignment_folders(self, modulestore, courselike_key, courselike, export_fs): + sequential_modules = self.get_sequential_modules(modulestore, courselike_key) + + # parse out non assignments + assignment_types = {assignment_type['type'] for assignment_type in courselike.grading_policy['GRADER']} + only_assignments = (sequential for sequential in sequential_modules if getattr(sequential, 'format') in assignment_types) + + for sequential in only_assignments: + self.sequential_to_identifier[sequential] = create_uuid() + self.sequential_to_identifierref[sequential] = create_uuid() + + root = lxml.etree.Element( + 'assignment', + { + 'identifier': self.sequential_to_identifier[sequential] + }, + nsmap={ + None: 'http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1', + 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', + } + ) + root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', + 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') + + lxml.etree.SubElement(root, 'title').text = str(getattr(sequential, 'display_name')) + lxml.etree.SubElement(root, 'assignment_group_identifierref').text = str(self.assignment_group_to_identifier[(str(getattr(sequential, 'format')))]) + lxml.etree.SubElement(root, 'points_possible').text = str(getattr(sequential, 'max_score')) + lxml.etree.SubElement(root, 'submission_types').text = 'external_tool' + lxml.etree.SubElement(root, 'external_tool_identifierref').text = self.external_tool_identifierref + lti_link = 'https://courses.educateworkforce.com/lti_provider/courses/' + str(courselike_key) + "/" + (str(courselike_key)).replace('course', 'block') + 'type@' + str(getattr(sequential, 'url_name')) + lxml.etree.SubElement(root, 'external_tool_url').text = lti_link + + export_fs.makedirs(str(self.sequential_to_identifier[sequential]), recreate=True) + + with export_fs.open(str(self.sequential_to_identifier[sequential]) + '/assignment_settings.xml', 'wb') as assignment_settings_xml: + tree = lxml.etree.ElementTree(root) + tree.write(assignment_settings_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) + + + def write_imsmanifest_xml(self, modulestore, courselike_key, courselike, export_fs): ############### Metadata section of imsmanifeset.xml #################### # Create the root element with proper namespaces root = lxml.etree.Element( @@ -208,7 +276,7 @@ def write_imsmanifest_xml(self, export_fs, modulestore, courselike_key): 'identifier': create_uuid() }, nsmap={ - None: 'http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1', # Default namespace + None: 'http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1', 'lom': 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource', 'lomimscc': 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest', 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', @@ -221,26 +289,21 @@ def write_imsmanifest_xml(self, export_fs, modulestore, courselike_key): 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource http://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lomresource_v1p0.xsd ' 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest http://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lommanifest_v1p0.xsd') - # Create the metadata element metadata = lxml.etree.SubElement(root, 'metadata') lxml.etree.SubElement(metadata, 'schema').text = 'IMS Common Cartridge' lxml.etree.SubElement(metadata, 'schemaversion').text = '1.1.0' - # Create the LOM element lom = lxml.etree.SubElement(metadata, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}lom') - # Build the general element general = lxml.etree.SubElement(lom, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}general') title = lxml.etree.SubElement(general, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}title') - lxml.etree.SubElement(title, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}string').text = 'TEMP-TITlE' # Need to extract some general title + lxml.etree.SubElement(title, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}string').text = str(courselike_key) - # Create the lifecycle element lifecycle = lxml.etree.SubElement(lom, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}lifeCycle') contribute = lxml.etree.SubElement(lifecycle, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}contribute') date = lxml.etree.SubElement(contribute, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}date') lxml.etree.SubElement(date, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}dateTime').text = (datetime.now()).strftime('%Y-%m-%d') # extract current date - # Create the rights element rights = lxml.etree.SubElement(lom, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}rights') copyright = lxml.etree.SubElement(rights, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}copyrightAndOtherRestrictions') lxml.etree.SubElement(copyright, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}value').text = 'yes' @@ -253,20 +316,30 @@ def write_imsmanifest_xml(self, export_fs, modulestore, courselike_key): organizations = lxml.etree.SubElement(root, 'organizations') organization = lxml.etree.SubElement(organizations, 'organization', {'identifier': 'org_1', 'structure': 'rooted-hierarchy'}) - # Create outer learning_module, for first iteration of imscc_exporter, this will be hard coded for the one course, later implementations will need to incorporate multiple courses - learning_modules = lxml.etree.SubElement(organization, 'item', {'identifier': 'uuid'}) - module = lxml.etree.SubElement(learning_modules, 'item', {'identifier': 'uuid'}) - lxml.etree.SubElement(module, 'title').text = (self.get_key()).course + learning_module = lxml.etree.SubElement(organization, 'item', {'identifier': 'LearningModules'}) - uuids = [] + # single module is created here for the one course, will need to be updated later to incorporate multiple courses + module = lxml.etree.SubElement(learning_module, 'item', {'identifier': create_uuid()}) + lxml.etree.SubElement(module, 'title').text = (self.get_key()).course # Build out all the sequentials underneath the one learning module (course) sequential_modules = self.get_sequential_modules(modulestore, courselike_key) for sequential in sequential_modules: - identifier = create_uuid() - uuids.append(identifier) - sequential_xml = lxml.etree.SubElement(module, 'item', {'identifier': identifier}) - lxml.etree.SubElement(sequential_xml, 'title').text = str(getattr(sequential, 'display_name')) + self.sequential_to_identifier[sequential] = create_uuid() + self.sequential_to_identifierref[sequential] = create_uuid() + + print(str(getattr(sequential, 'display_name'))) + print(str(getattr(sequential, 'format'))) + + assignment_types = {assignment_type['type'] for assignment_type in courselike.grading_policy['GRADER']} + + if str(getattr(sequential, 'format')) in assignment_types: + sequential_xml = lxml.etree.SubElement(module, 'item', {'identifier': self.sequential_to_identifier[sequential], 'identifierref': self.sequential_to_identifierref[sequential]}) + lxml.etree.SubElement(sequential_xml, 'title').text = str(getattr(sequential, 'display_name')) + else: + sequential_xml = lxml.etree.SubElement(module, 'item', {'identifier': self.sequential_to_identifier[sequential]}) + lxml.etree.SubElement(sequential_xml, 'title').text = str(getattr(sequential, 'display_name')) + self.sequential_to_identifierref[sequential] = None tree = lxml.etree.ElementTree(root) tree.write('test2.xml', xml_declaration=True, encoding='UTF-8', pretty_print=True) @@ -295,8 +368,9 @@ def export(self): # make the directory to export to export_fs = courselike.runtime.export_fs = fsm.makedir(self.target_dir, recreate=True) - + #self.write_imsmanifest_xml(self.modulestore, self.courselike_key, courselike, export_fs) self.export_all_course_settings(courselike, self.modulestore, self.courselike_key, export_fs) + self.export_assignment_folders(self.modulestore, self.courselike_key, courselike, export_fs) """ Function "export_course_to_imscc" below get called by the django management comman from export_olx.py From cd4f8e3d6e7280ebd925c002ba78922a66c38315 Mon Sep 17 00:00:00 2001 From: Kenny Sun Date: Fri, 25 Oct 2024 13:16:06 -0400 Subject: [PATCH 06/14] feat: added html for assignment folders, imsmanifest resources, and external tool xml file export Next steps include finishing out module_meta.xml file, probably one of the more challenging files to create --- .../xmodule/modulestore/imscc_exporter.py | 219 +++++++++++++++--- 1 file changed, 191 insertions(+), 28 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py index 542685d77866..cec97d511331 100644 --- a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py @@ -23,6 +23,7 @@ import uuid from datetime import datetime +import re DRAFT_DIR = "drafts" PUBLISHED_DIR = "published" @@ -30,6 +31,9 @@ DEFAULT_CONTENT_FIELDS = ['metadata', 'data'] def create_uuid(): + """ + Returns an essentially unique identifier following canvas's default format + """ return 'g' + (str(uuid.uuid4())).replace('-', '') def get_total_score(sequential): @@ -43,7 +47,7 @@ def get_total_score(sequential): # Check if the child has a score and add it score = child.get_score() print("max score rtest") - print(child.max_score()) + if score is not None: print(getattr(child, 'display_name')) print(score) @@ -57,6 +61,7 @@ def get_total_score(sequential): total_score += get_total_score(child) return total_score + class TestExportManager: """ Manages XML exporting for courselike objects. @@ -77,10 +82,41 @@ def __init__(self, modulestore, contentstore, courselike_key, root_dir, target_d self.root_dir = root_dir self.target_dir = str(target_dir) + """ + Sets up some information to share between export functions + + 'sequential_to_identifier': A dictionary mapping each sequential to an unique identifier + An 'identifier' attribute of the 'item' element containing sequential information in the 'imsmanifest.xml' file + An 'identifier' attribute of the 'item' element containing sequential information in the 'course_settings/module_meta.xml' file + Links sequentials with all the information above + + 'sequential_to_identifierref': A dictionary mapping each sequential to an unique identifier + An 'identifierref' attribute of the 'item' element containing sequential information in the 'imsmanifest.xml file' + An 'identifierref' attribute of the 'item' element containing sequential information in the 'course_settings/module_meta.xml' file + An 'identifier' attribute of the assignment object in each sequential's individual assignment_settings.xml file + The name of the folder that stores a sequential's assignment_settings.xml file and html file + Links sequentials with all the information above + + 'assignment_group_to_identifier': A dictionary mapping the names of each assignment group to an unique identifier + An 'identifier' attribute of the 'assignmentGroup' element in 'assignment_groups.xml' + An 'assignment_group_identifierref' child element of the 'assignment' element in an assignment's 'assignment_settings.xml' file + Links assignments to their assignment types + + 'external_tool_identifierref': A pre-made identifier to link all external tool references + The name of the file + '.xml' in the root directory containing LTI metadata and information + An 'identifierref' attrubute of the 'item' element containing LTI data + An 'external_tool_identifierref' element of the 'assignment' element in an assignment's 'assignment_settings.xml' file + + 'course_settings_identifier': A pre-made identifier to link course_settings references + An 'identifier' attrubute of the 'course' element in course_settings/course_settings.xml file containing course settings + An 'identifier' attribute of the 'resource' element in 'imsmanifest.xml' file containing course settings information + """ + self.sequential_to_identifier = {} self.sequential_to_identifierref = {} self.assignment_group_to_identifier = {} self.external_tool_identifierref = create_uuid() + self.course_settings_identifier = create_uuid() def get_key(self): """ @@ -116,6 +152,9 @@ def get_sequential_modules(self, modulestore, courselike_key): return sequentials def export_assignment_groups(self, modulestore, courselike, export_fs): + """ + Exports the 'assignment_groups.xml' file in course_settings + """ # Create root root = lxml.etree.Element( 'assignmentGroups', @@ -128,8 +167,8 @@ def export_assignment_groups(self, modulestore, courselike, export_fs): root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') + # Accessing each asignment type with their weight and adding it to the xml position = 1 - # Accessing each asignment type with their weight for grade in courselike.grading_policy['GRADER']: grade_name = grade['type'] grade_weight = grade['weight'] * 100 # openedx uses 0-1 grading weight, imscc uses 0-100 @@ -140,11 +179,16 @@ def export_assignment_groups(self, modulestore, courselike, export_fs): position += 1 lxml.etree.SubElement(assignment_group, 'group_weight').text = str(grade_weight) + # Write to file with export_fs.open('course_settings/assignment_groups.xml', 'wb') as assignment_groups_xml: tree = lxml.etree.ElementTree(root) tree.write(assignment_groups_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) def export_media_tracks(self, export_fs): + """ + Exports the mostly empty 'media_tracks.xml' file in course_settings + """ + # Create root root = lxml.etree.Element( 'media_tracks', @@ -157,11 +201,15 @@ def export_media_tracks(self, export_fs): root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') + # Write to file with export_fs.open('course_settings/media_tracks.xml', 'wb') as media_tracks_xml: tree = lxml.etree.ElementTree(root) tree.write(media_tracks_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) def export_files_meta(self, export_fs): + """ + Exports the mostly empty 'files_meta.xml' file in course_settings + """ # Create root root = lxml.etree.Element( 'fileMeta', @@ -174,14 +222,21 @@ def export_files_meta(self, export_fs): root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') + # Write to file with export_fs.open('course_settings/files_meta.xml', 'wb') as files_meta_xml: tree = lxml.etree.ElementTree(root) tree.write(files_meta_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) def export_course_settings(self, modulestore, courselike_key, export_fs): + """ + Exports the 'course_settings.xml' file in course_settings + """ # Create root root = lxml.etree.Element( 'course', + { + 'identifier': self.course_settings_identifier + }, nsmap = { None: 'http://canvas.instructure.com/xsd/cccv1p0', 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', @@ -224,34 +279,47 @@ def export_course_settings(self, modulestore, courselike_key, export_fs): # lxml.etree.SubElement(root, 'homeroom_course').text = false # lxml.etree.SubElement(root, 'grading_standard_enabled').text = false + # Write to file with export_fs.open('course_settings/course_settings.xml', 'wb') as media_tracks_xml: tree = lxml.etree.ElementTree(root) tree.write(media_tracks_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) + + # There's this file called canvas_export.txt that contains nothing but a pun... + # It's referenced in the ims_manifest file for some reason so we're adding it + with export_fs.open('course_settings/canvas_export.txt', 'w') as canvas_export_txt: + canvas_export_txt.write('Q: What did the panda say when he was forced out of his natural habitat?\nA: This is un-BEAR-able\n') def export_assignment_folders(self, modulestore, courselike_key, courselike, export_fs): + """ + Exports all the individual folders for each sequential that is an assignment + """ sequential_modules = self.get_sequential_modules(modulestore, courselike_key) - # parse out non assignments + # Parse out non assignments assignment_types = {assignment_type['type'] for assignment_type in courselike.grading_policy['GRADER']} only_assignments = (sequential for sequential in sequential_modules if getattr(sequential, 'format') in assignment_types) - for sequential in only_assignments: + # Set all the identifiers + for sequential in sequential_modules: self.sequential_to_identifier[sequential] = create_uuid() self.sequential_to_identifierref[sequential] = create_uuid() - + + for sequential in only_assignments: + # Create root root = lxml.etree.Element( 'assignment', { - 'identifier': self.sequential_to_identifier[sequential] + 'identifier': self.sequential_to_identifierref[sequential] }, nsmap={ - None: 'http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1', + None: 'http://canvas.instructure.com/xsd/cccv1p0', 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', } ) root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') - + + # Add assignment data like points, assignemtn type, lti, etc. lxml.etree.SubElement(root, 'title').text = str(getattr(sequential, 'display_name')) lxml.etree.SubElement(root, 'assignment_group_identifierref').text = str(self.assignment_group_to_identifier[(str(getattr(sequential, 'format')))]) lxml.etree.SubElement(root, 'points_possible').text = str(getattr(sequential, 'max_score')) @@ -260,15 +328,44 @@ def export_assignment_folders(self, modulestore, courselike_key, courselike, exp lti_link = 'https://courses.educateworkforce.com/lti_provider/courses/' + str(courselike_key) + "/" + (str(courselike_key)).replace('course', 'block') + 'type@' + str(getattr(sequential, 'url_name')) lxml.etree.SubElement(root, 'external_tool_url').text = lti_link - export_fs.makedirs(str(self.sequential_to_identifier[sequential]), recreate=True) + # Create corresponding HTML file + # HTML content is hard coded for now, seems that every assignment has the exact same html structure and not much too it + html_content =''' + + + Assignment: ''' + + html_content_pt2 =''' + + + + + ''' + + html_file_name = re.sub(r'[^a-zA-Z0-9\s-]', '', str(getattr(sequential, 'display_name'))) + html_file_name = html_file_name.lower() + html_file_name = html_file_name.replace(' ', '-') + html_file_name = html_file_name + '.html' + + # Write to file + export_fs.makedirs(str(self.sequential_to_identifierref[sequential]), recreate=True) - with export_fs.open(str(self.sequential_to_identifier[sequential]) + '/assignment_settings.xml', 'wb') as assignment_settings_xml: + with export_fs.open(str(self.sequential_to_identifierref[sequential]) + '/assignment_settings.xml', 'wb') as assignment_settings_xml: tree = lxml.etree.ElementTree(root) tree.write(assignment_settings_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) + with export_fs.open(str(self.sequential_to_identifierref[sequential]) + '/' + html_file_name, 'w') as html_file: + html_file.write(html_content) + html_file.write(str(getattr(sequential, 'display_name'))) + html_file.write(html_content_pt2) def write_imsmanifest_xml(self, modulestore, courselike_key, courselike, export_fs): + """ + Exports the imsmanifest.xml file + """ + ############### Metadata section of imsmanifeset.xml #################### + # Create the root element with proper namespaces root = lxml.etree.Element( 'manifest', @@ -283,12 +380,12 @@ def write_imsmanifest_xml(self, modulestore, courselike_key, courselike, export_ } ) - # Set the schemaLocation attribute root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', 'http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1 http://www.imsglobal.org/profile/cc/ccv1p1/ccv1p1_imscp_v1p2_v1p0.xsd ' 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource http://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lomresource_v1p0.xsd ' 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest http://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lommanifest_v1p0.xsd') + # Set all the metadata values metadata = lxml.etree.SubElement(root, 'metadata') lxml.etree.SubElement(metadata, 'schema').text = 'IMS Common Cartridge' lxml.etree.SubElement(metadata, 'schemaversion').text = '1.1.0' @@ -318,33 +415,95 @@ def write_imsmanifest_xml(self, modulestore, courselike_key, courselike, export_ learning_module = lxml.etree.SubElement(organization, 'item', {'identifier': 'LearningModules'}) - # single module is created here for the one course, will need to be updated later to incorporate multiple courses + # Single module is created here for the one course, will need to be updated later to incorporate multiple courses module = lxml.etree.SubElement(learning_module, 'item', {'identifier': create_uuid()}) lxml.etree.SubElement(module, 'title').text = (self.get_key()).course # Build out all the sequentials underneath the one learning module (course) sequential_modules = self.get_sequential_modules(modulestore, courselike_key) for sequential in sequential_modules: - self.sequential_to_identifier[sequential] = create_uuid() - self.sequential_to_identifierref[sequential] = create_uuid() - - print(str(getattr(sequential, 'display_name'))) - print(str(getattr(sequential, 'format'))) assignment_types = {assignment_type['type'] for assignment_type in courselike.grading_policy['GRADER']} + sequential_xml = lxml.etree.SubElement(module, 'item', {'identifier': self.sequential_to_identifier[sequential], 'identifierref': self.sequential_to_identifierref[sequential]}) + lxml.etree.SubElement(sequential_xml, 'title').text = str(getattr(sequential, 'display_name')) + + ############################# Resources section of imsmanifest.xml ############################# + + # Create resources element + resources = lxml.etree.SubElement(root, 'resources') + type_string = 'associatedcontent/imscc_xmlv1p1/learning-application-resource' + + # Course settings + course_settings_resource = lxml.etree.SubElement(resources, 'resource', {'identifier': self.course_settings_identifier, 'type': type_string, 'href': 'course_settings/canvas_export.txt'}) + + course_settings_path = 'course_settings' + for filename in export_fs.listdir(course_settings_path): + lxml.etree.SubElement(course_settings_resource, 'file', {'href': 'course_settings/' + filename}) + + # Create resources for sequentials + for sequential in sequential_modules: if str(getattr(sequential, 'format')) in assignment_types: - sequential_xml = lxml.etree.SubElement(module, 'item', {'identifier': self.sequential_to_identifier[sequential], 'identifierref': self.sequential_to_identifierref[sequential]}) - lxml.etree.SubElement(sequential_xml, 'title').text = str(getattr(sequential, 'display_name')) - else: - sequential_xml = lxml.etree.SubElement(module, 'item', {'identifier': self.sequential_to_identifier[sequential]}) - lxml.etree.SubElement(sequential_xml, 'title').text = str(getattr(sequential, 'display_name')) - self.sequential_to_identifierref[sequential] = None + html_file_path = self.sequential_to_identifierref[sequential] + xml_file_path = self.sequential_to_identifierref[sequential] + for filename in export_fs.listdir(self.sequential_to_identifierref[sequential]): + if filename.endswith('.html'): + html_file_path = html_file_path + '/' + filename + if filename.endswith('xml'): + xml_file_path = xml_file_path + '/' + filename + resource = lxml.etree.SubElement(resources , 'resource', {'identifier': self.sequential_to_identifierref[sequential], 'type': type_string, 'href': html_file_path}) + lxml.etree.SubElement(resource, 'file', {'href': html_file_path}) + lxml.etree.SubElement(resource, 'file', {'href': xml_file_path}) + + # Write to file + with export_fs.open('imsmanifest.xml', 'wb') as imsmanifest_xml: + tree = lxml.etree.ElementTree(root) + tree.write(imsmanifest_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) - tree = lxml.etree.ElementTree(root) - tree.write('test2.xml', xml_declaration=True, encoding='UTF-8', pretty_print=True) + def export_external_tool(self, export_fs): + # Create the root element + root = lxml.etree.Element( + 'cartridge_basiclti_link', + nsmap={ + None: "http://www.imsglobal.org/xsd/imslticc_v1p0", + 'blti': "http://www.imsglobal.org/xsd/imsbasiclti_v1p0", + 'lticm': "http://www.imsglobal.org/xsd/imslticm_v1p0", + 'lticp': "http://www.imsglobal.org/xsd/imslticp_v1p0", + 'xsi': "http://www.w3.org/2001/XMLSchema-instance" + }, + xsi_schemaLocation="http://www.imsglobal.org/xsd/imslticc_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticc_v1p0.xsd\n" + "http://www.imsglobal.org/xsd/imsbasiclti_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imsbasiclti_v1p0p1.xsd\n" + "http://www.imsglobal.org/xsd/imslticm_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticm_v1p0.xsd\n" + "http://www.imsglobal.org/xsd/imslticp_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticp_v1p0.xsd" + ) + + # Create child elements + lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}title', nsmap={'blti': "http://www.imsglobal.org/xsd/imsbasiclti_v1p0"}).text = "EducateWorkforce (courses.educateworkforce.com)" + lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}description').text = "" + lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}secure_launch_url').text = "https://courses.educateworkforce.com/lti_provider/" + + # Vendor information + vendor = lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}vendor') + lxml.etree.SubElement(vendor, '{http://www.imsglobal.org/xsd/imslticp_v1p0}code').text = "unknown" + lxml.etree.SubElement(vendor, '{http://www.imsglobal.org/xsd/imslticp_v1p0}name').text = "unknown" + + # Custom element + lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}custom') - def export_all_course_settings(self, courselike, modulestore, courselike_key, export_fs): + # Extensions + extensions = lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}extensions', platform="canvas.instructure.com") + lxml.etree.SubElement(extensions, '{http://www.imsglobal.org/xsd/imslticm_v1p0}property', name="privacy_level").text = "public" + lxml.etree.SubElement(extensions, '{http://www.imsglobal.org/xsd/imslticm_v1p0}property', name="domain").text = "courses.educateworkforce.com" + lxml.etree.SubElement(extensions, '{http://www.imsglobal.org/xsd/imslticm_v1p0}property', name="lti_version").text = "1.1" + + with export_fs.open(self.external_tool_identifierref + '.xml', 'wb') as external_tool_identifierref_xml: + tree = lxml.etree.ElementTree(root) + tree.write(external_tool_identifierref_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) + + def export_all_course_settings(self, modulestore, courselike_key, courselike, export_fs): + """ + Function to export all course_settings at once + """ export_fs.makedirs('course_settings', recreate=True) self.export_assignment_groups(modulestore, courselike, export_fs) self.export_media_tracks(export_fs) @@ -368,9 +527,13 @@ def export(self): # make the directory to export to export_fs = courselike.runtime.export_fs = fsm.makedir(self.target_dir, recreate=True) - #self.write_imsmanifest_xml(self.modulestore, self.courselike_key, courselike, export_fs) - self.export_all_course_settings(courselike, self.modulestore, self.courselike_key, export_fs) + + # Call export functions + self.export_external_tool(export_fs) + self.export_all_course_settings(self.modulestore, self.courselike_key, courselike, export_fs) self.export_assignment_folders(self.modulestore, self.courselike_key, courselike, export_fs) + self.write_imsmanifest_xml(self.modulestore, self.courselike_key, courselike, export_fs) + """ Function "export_course_to_imscc" below get called by the django management comman from export_olx.py From 9788cfacf2383f5846f9634544e5b37146cdee6b Mon Sep 17 00:00:00 2001 From: Kenny Sun Date: Wed, 30 Oct 2024 15:25:12 -0400 Subject: [PATCH 07/14] feat: added export for the external tools xml file, fixed bugs with incorrect schema values Next, need to incorporate module headers Also fixed a lot of typos and added more detailed documentation --- .../xmodule/modulestore/imscc_exporter.py | 249 +++++++++++------- 1 file changed, 160 insertions(+), 89 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py index cec97d511331..60a85bcc4239 100644 --- a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py @@ -2,7 +2,6 @@ Methods for exporting course data to IMSCC """ - import logging import os from abc import abstractmethod @@ -30,41 +29,39 @@ DEFAULT_CONTENT_FIELDS = ['metadata', 'data'] +# Returns an essentially 'unique' uuid for identifying and linking data up def create_uuid(): """ Returns an essentially unique identifier following canvas's default format """ return 'g' + (str(uuid.uuid4())).replace('-', '') +########## THIS FUNCTION DOESN'T WORK ###################################### +########## ALL IT DOES IS RETURNS ZERO, NEED TO FIX TO GET ACTUAL SCORE #### def get_total_score(sequential): - total_score = 0 + total_score = 0.0 # Get the direct children of the sequential children = sequential.get_children() for child in children: try: - # Check if the child has a score and add it - score = child.get_score() - print("max score rtest") - - if score is not None: - print(getattr(child, 'display_name')) - print(score) - print(getattr(child, 'max_score')) - total_score += score[1] - except NotImplementedError: - # Handle the case where get_score is not implemented - print(f"Score not implemented for child: {child}") - + # Call get_max_score() on the individual child + score = child.get_max_score() # Change this line to use child + total_score += score + except Exception as e: + pass # Handle the exception if necessary + # Recursively get the score from the child's children total_score += get_total_score(child) + if total_score != 0: + print(total_score) return total_score class TestExportManager: """ - Manages XML exporting for courselike objects. + Manages IMSCC exporting for courselike objects. """ def __init__(self, modulestore, contentstore, courselike_key, root_dir, target_dir): """ @@ -97,6 +94,11 @@ def __init__(self, modulestore, contentstore, courselike_key, root_dir, target_d The name of the folder that stores a sequential's assignment_settings.xml file and html file Links sequentials with all the information above + 'chapter_to_identifier': A dictionary mapping each chapter to an unique identifier + An 'identifier' attribute of the 'item' element containing chapter information in the 'imsmanifest.xml' file + An 'identifier' attribute of the 'item' element containing chapter information in the 'course_settings/module_meta.xml' file + Links sequentials with all the information above + 'assignment_group_to_identifier': A dictionary mapping the names of each assignment group to an unique identifier An 'identifier' attribute of the 'assignmentGroup' element in 'assignment_groups.xml' An 'assignment_group_identifierref' child element of the 'assignment' element in an assignment's 'assignment_settings.xml' file @@ -108,15 +110,21 @@ def __init__(self, modulestore, contentstore, courselike_key, root_dir, target_d An 'external_tool_identifierref' element of the 'assignment' element in an assignment's 'assignment_settings.xml' file 'course_settings_identifier': A pre-made identifier to link course_settings references - An 'identifier' attrubute of the 'course' element in course_settings/course_settings.xml file containing course settings - An 'identifier' attribute of the 'resource' element in 'imsmanifest.xml' file containing course settings information - """ + An 'identifier' attrubute of the 'course' element in 'course_settings/course_settings.xml' file containing course settings + An 'identifier' attribute of the 'resource' element in 'imsmanifest.xml' file containing course settings information + 'module_identifier': A pre-made identifier to link the only module that is created + An 'identifier' attribute of the 'item' element under the 'item' element with the identifier 'LearningModules' in 'imsmanfiest.xml' + An 'identifier' attribute of the 'module' element in the 'course_settings/module_meta.xml' file + """ + self.sequential_to_identifier = {} self.sequential_to_identifierref = {} + self.chapter_to_identifier = {} self.assignment_group_to_identifier = {} self.external_tool_identifierref = create_uuid() self.course_settings_identifier = create_uuid() + self.module_identifier = create_uuid() def get_key(self): """ @@ -139,10 +147,10 @@ def get_courselike(self): def get_sequential_modules(self, modulestore, courselike_key): """ - Retrieve all sequential modules from the course. + Retrieve all sequential modules from the course """ with modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, courselike_key): - # Get all top-level modules (e.g., chapters, sections) + # Get all top-level modules (e.g., chapters, sequentials) top_level_modules = modulestore.get_items(courselike_key) sequentials = [] @@ -150,6 +158,34 @@ def get_sequential_modules(self, modulestore, courselike_key): if module.category == 'sequential': sequentials.append(module) return sequentials + + def get_chapter_modules(self, modulestore, courselike_key): + """ + Retrieve all chapter modules from the course + """ + with modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, courselike_key): + # Get all top-level modules (e.g., chapters, sequentials) + top_level_modules = modulestore.get_items(courselike_key) + + chapter = [] + for module in top_level_modules: + if module.category == 'chapter': + chapter.append(module) + return chapter + + def get_chapter_sequential_modules(self, modulestore, courselike_key): + """ + Retrieve all chapter and sequential modules from the course + """ + with modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, courselike_key): + # Get all top-level modules (e.g., chapters, sequentials) + top_level_modules = modulestore.get_items(courselike_key) + + sequentials_chapters = [] + for module in top_level_modules: + if module.category == 'sequential' or module.category == 'chapter': + sequentials_chapters.append(module) + return sequentials_chapters def export_assignment_groups(self, modulestore, courselike, export_fs): """ @@ -168,15 +204,12 @@ def export_assignment_groups(self, modulestore, courselike, export_fs): 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') # Accessing each asignment type with their weight and adding it to the xml - position = 1 for grade in courselike.grading_policy['GRADER']: grade_name = grade['type'] grade_weight = grade['weight'] * 100 # openedx uses 0-1 grading weight, imscc uses 0-100 self.assignment_group_to_identifier[grade_name] = create_uuid() assignment_group = lxml.etree.SubElement(root, 'assignmentGroup', {'identifier': str(self.assignment_group_to_identifier[grade_name])}) lxml.etree.SubElement(assignment_group, 'title').text = 'EW - ' + grade_name - lxml.etree.SubElement(assignment_group, 'position').text = str(position) - position += 1 lxml.etree.SubElement(assignment_group, 'group_weight').text = str(grade_weight) # Write to file @@ -250,35 +283,6 @@ def export_course_settings(self, modulestore, courselike_key, export_fs): lxml.etree.SubElement(root, 'course_code').text = str(courselike_key) lxml.etree.SubElement(root, 'group_weighting_scheme').text = "percent" - # Below are additional settings that can be enabled to be the default for openedx -> canvas - # or they can be set manually after the creation of the canvas class - # lxml.etree.SubElement(root, 'is_public').text = false - # lxml.etree.SubElement(root, 'is_public_to_auth_users').text = false - # lxml.etree.SubElement(root, 'allow_student_wiki_edits').text = false - # lxml.etree.SubElement(root, 'allow_student_forum_attachments').text = true - # lxml.etree.SubElement(root, 'lock_all_announcements').text = false - # lxml.etree.SubElement(root, 'default_wiki_editing_roles').text = teachers - # lxml.etree.SubElement(root, 'allow_student_organized_groups').text = true - # lxml.etree.SubElement(root, 'default_view').text = modules - # lxml.etree.SubElement(root, 'show_total_grade_as_points').text = false - # lxml.etree.SubElement(root, 'allow_final_grade_overrides').text = false - # lxml.etree.SubElement(root, 'open_enrollment').text = false - # lxml.etree.SubElement(root, 'filter_speed_grader_by_student_group').text = false - # lxml.etree.SubElement(root, 'self_enrollment').text = false - # lxml.etree.SubElement(root, 'license').text = private - # lxml.etree.SubElement(root, 'indexed').text = false - # lxml.etree.SubElement(root, 'hide_final_grade').text = false - # lxml.etree.SubElement(root, 'hide_distribution_graphs').text = false - # lxml.etree.SubElement(root, 'allow_student_discussion_topics').text = true - # lxml.etree.SubElement(root, 'allow_student_editing').text = true - # lxml.etree.SubElement(root, 'show_announcements_on_home_page').text = false - # lxml.etree.SubElement(root, 'home_page_announcement_limit').text = 3 - # lxml.etree.SubElement(root, 'usage_rights_required').text = false - # lxml.etree.SubElement(root, 'restrict_student_future_view').text = false - # lxml.etree.SubElement(root, 'restrict_student_past_view').text = true - # lxml.etree.SubElement(root, 'homeroom_course').text = false - # lxml.etree.SubElement(root, 'grading_standard_enabled').text = false - # Write to file with export_fs.open('course_settings/course_settings.xml', 'wb') as media_tracks_xml: tree = lxml.etree.ElementTree(root) @@ -297,7 +301,8 @@ def export_assignment_folders(self, modulestore, courselike_key, courselike, exp # Parse out non assignments assignment_types = {assignment_type['type'] for assignment_type in courselike.grading_policy['GRADER']} - only_assignments = (sequential for sequential in sequential_modules if getattr(sequential, 'format') in assignment_types) + print(assignment_types) + only_assignments = (sequential for sequential in sequential_modules if sequential.format in assignment_types) # Set all the identifiers for sequential in sequential_modules: @@ -319,17 +324,24 @@ def export_assignment_folders(self, modulestore, courselike_key, courselike, exp root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') - # Add assignment data like points, assignemtn type, lti, etc. - lxml.etree.SubElement(root, 'title').text = str(getattr(sequential, 'display_name')) - lxml.etree.SubElement(root, 'assignment_group_identifierref').text = str(self.assignment_group_to_identifier[(str(getattr(sequential, 'format')))]) - lxml.etree.SubElement(root, 'points_possible').text = str(getattr(sequential, 'max_score')) + # Add assignment data like points, assignment type, lti, etc. + lxml.etree.SubElement(root, 'title').text = sequential.display_name + lxml.etree.SubElement(root, 'assignment_group_identifierref').text = str(self.assignment_group_to_identifier[sequential.format]) + + ###### NEED TO FIX ####### + lxml.etree.SubElement(root, 'points_possible').text = str(get_total_score(sequential)) + ########################## + lxml.etree.SubElement(root, 'submission_types').text = 'external_tool' lxml.etree.SubElement(root, 'external_tool_identifierref').text = self.external_tool_identifierref - lti_link = 'https://courses.educateworkforce.com/lti_provider/courses/' + str(courselike_key) + "/" + (str(courselike_key)).replace('course', 'block') + 'type@' + str(getattr(sequential, 'url_name')) + lti_link = 'https://courses.educateworkforce.com/lti_provider/courses/' + str(courselike_key) + "/" + (str(courselike_key)).replace('course', 'block') + '+type@sequential+block@' + sequential.url_name lxml.etree.SubElement(root, 'external_tool_url').text = lti_link + lxml.etree.SubElement(root, 'external_tool_data_json').text = '\"\"' + lxml.etree.SubElement(root, 'external_tool_link_settings_json').text = '{\"selection_width\":\"\",\"selection_height":\"\"}' + lxml.etree.SubElement(root, 'external_tool_new_tab').text = 'false' # Create corresponding HTML file - # HTML content is hard coded for now, seems that every assignment has the exact same html structure and not much too it + # HTML files follow this same cookie cutter format with the only thing changing is the title html_content =''' @@ -342,7 +354,8 @@ def export_assignment_folders(self, modulestore, courselike_key, courselike, exp ''' - html_file_name = re.sub(r'[^a-zA-Z0-9\s-]', '', str(getattr(sequential, 'display_name'))) + # Make the name of the file match conventions with all lower cases and no spaces, and dashes replacing spaces + html_file_name = re.sub(r'[^a-zA-Z0-9\s-]', '', sequential.display_name) html_file_name = html_file_name.lower() html_file_name = html_file_name.replace(' ', '-') html_file_name = html_file_name + '.html' @@ -356,7 +369,7 @@ def export_assignment_folders(self, modulestore, courselike_key, courselike, exp with export_fs.open(str(self.sequential_to_identifierref[sequential]) + '/' + html_file_name, 'w') as html_file: html_file.write(html_content) - html_file.write(str(getattr(sequential, 'display_name'))) + html_file.write(sequential.display_name) html_file.write(html_content_pt2) def write_imsmanifest_xml(self, modulestore, courselike_key, courselike, export_fs): @@ -416,17 +429,20 @@ def write_imsmanifest_xml(self, modulestore, courselike_key, courselike, export_ learning_module = lxml.etree.SubElement(organization, 'item', {'identifier': 'LearningModules'}) # Single module is created here for the one course, will need to be updated later to incorporate multiple courses - module = lxml.etree.SubElement(learning_module, 'item', {'identifier': create_uuid()}) + module = lxml.etree.SubElement(learning_module, 'item', {'identifier': self.module_identifier}) lxml.etree.SubElement(module, 'title').text = (self.get_key()).course - # Build out all the sequentials underneath the one learning module (course) - sequential_modules = self.get_sequential_modules(modulestore, courselike_key) - for sequential in sequential_modules: - - assignment_types = {assignment_type['type'] for assignment_type in courselike.grading_policy['GRADER']} - - sequential_xml = lxml.etree.SubElement(module, 'item', {'identifier': self.sequential_to_identifier[sequential], 'identifierref': self.sequential_to_identifierref[sequential]}) - lxml.etree.SubElement(sequential_xml, 'title').text = str(getattr(sequential, 'display_name')) + # Build out all the chapters and sequentials underneath the one learning module (course) + chapter_and_sequential_modules = self.get_chapter_sequential_modules(modulestore, courselike_key) + for chapter_sequential_module in chapter_and_sequential_modules: + if chapter_sequential_module.category == 'sequential': + sequential = lxml.etree.SubElement(module, 'item', {'identifier': self.sequential_to_identifier[chapter_sequential_module], 'identifierref': self.sequential_to_identifierref[chapter_sequential_module]}) + lxml.etree.SubElement(sequential, 'title').text = chapter_sequential_module.display_name + else: + self.chapter_to_identifier[chapter_sequential_module] = create_uuid() + chapter = lxml.etree.SubElement(module, 'item', {'identifier': self.chapter_to_identifier[chapter_sequential_module]}) + print(chapter_sequential_module.display_name) + lxml.etree.SubElement(chapter, 'title').text = chapter_sequential_module.display_name ############################# Resources section of imsmanifest.xml ############################# @@ -436,14 +452,16 @@ def write_imsmanifest_xml(self, modulestore, courselike_key, courselike, export_ # Course settings course_settings_resource = lxml.etree.SubElement(resources, 'resource', {'identifier': self.course_settings_identifier, 'type': type_string, 'href': 'course_settings/canvas_export.txt'}) - course_settings_path = 'course_settings' for filename in export_fs.listdir(course_settings_path): lxml.etree.SubElement(course_settings_resource, 'file', {'href': 'course_settings/' + filename}) - # Create resources for sequentials + # Create resources for assignment sequentials + sequential_modules = self.get_sequential_modules(modulestore, courselike_key) + + assignment_types = {assignment_type['type'] for assignment_type in courselike.grading_policy['GRADER']} for sequential in sequential_modules: - if str(getattr(sequential, 'format')) in assignment_types: + if sequential.format in assignment_types: html_file_path = self.sequential_to_identifierref[sequential] xml_file_path = self.sequential_to_identifierref[sequential] for filename in export_fs.listdir(self.sequential_to_identifierref[sequential]): @@ -455,6 +473,10 @@ def write_imsmanifest_xml(self, modulestore, courselike_key, courselike, export_ lxml.etree.SubElement(resource, 'file', {'href': html_file_path}) lxml.etree.SubElement(resource, 'file', {'href': xml_file_path}) + # Additional last resource for the external tool xml + external_tool_resource = lxml.etree.SubElement(resources, 'resource', {'identifier': self.external_tool_identifierref, 'type': 'imsbasiclti_xmlv1p0'}) + lxml.etree.SubElement(external_tool_resource, 'file', {'href': self.external_tool_identifierref + '.xml'}) + # Write to file with export_fs.open('imsmanifest.xml', 'wb') as imsmanifest_xml: tree = lxml.etree.ElementTree(root) @@ -470,35 +492,84 @@ def export_external_tool(self, export_fs): 'lticm': "http://www.imsglobal.org/xsd/imslticm_v1p0", 'lticp': "http://www.imsglobal.org/xsd/imslticp_v1p0", 'xsi': "http://www.w3.org/2001/XMLSchema-instance" - }, - xsi_schemaLocation="http://www.imsglobal.org/xsd/imslticc_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticc_v1p0.xsd\n" - "http://www.imsglobal.org/xsd/imsbasiclti_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imsbasiclti_v1p0p1.xsd\n" - "http://www.imsglobal.org/xsd/imslticm_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticm_v1p0.xsd\n" - "http://www.imsglobal.org/xsd/imslticp_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticp_v1p0.xsd" + } ) - # Create child elements + root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', + "http://www.imsglobal.org/xsd/imslticc_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticc_v1p0.xsd" + "http://www.imsglobal.org/xsd/imsbasiclti_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imsbasiclti_v1p0p1.xsd" + "http://www.imsglobal.org/xsd/imslticm_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticm_v1p0.xsd" + "http://www.imsglobal.org/xsd/imslticp_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticp_v1p0.xsd") + + # Basic metadata content lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}title', nsmap={'blti': "http://www.imsglobal.org/xsd/imsbasiclti_v1p0"}).text = "EducateWorkforce (courses.educateworkforce.com)" lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}description').text = "" lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}secure_launch_url').text = "https://courses.educateworkforce.com/lti_provider/" - - # Vendor information vendor = lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}vendor') lxml.etree.SubElement(vendor, '{http://www.imsglobal.org/xsd/imslticp_v1p0}code').text = "unknown" lxml.etree.SubElement(vendor, '{http://www.imsglobal.org/xsd/imslticp_v1p0}name').text = "unknown" - - # Custom element lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}custom') - - # Extensions extensions = lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}extensions', platform="canvas.instructure.com") lxml.etree.SubElement(extensions, '{http://www.imsglobal.org/xsd/imslticm_v1p0}property', name="privacy_level").text = "public" lxml.etree.SubElement(extensions, '{http://www.imsglobal.org/xsd/imslticm_v1p0}property', name="domain").text = "courses.educateworkforce.com" lxml.etree.SubElement(extensions, '{http://www.imsglobal.org/xsd/imslticm_v1p0}property', name="lti_version").text = "1.1" with export_fs.open(self.external_tool_identifierref + '.xml', 'wb') as external_tool_identifierref_xml: - tree = lxml.etree.ElementTree(root) - tree.write(external_tool_identifierref_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) + tree = lxml.etree.ElementTree(root) + tree.write(external_tool_identifierref_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) + + def export_module_meta_xml(self, modulestore, courselike_key, courselike, export_fs): + """ + Exports the module_meta.xml file in course_settings + """ + + # Get all the chapter and sequential modules to appear under the modules page + chapter_sequential_modules = self.get_chapter_sequential_modules(modulestore, courselike_key) + # Parse out assignments (assignments are sequentials with a 'format' in the grading policy) + assignment_types = {assignment_type['type'] for assignment_type in courselike.grading_policy['GRADER']} + + # Create the root element + root = lxml.etree.Element( + 'modules', + nsmap = { + None: 'http://canvas.instructure.com/xsd/cccv1p0', + 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', + } + ) + + root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', + 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') + + module = lxml.etree.SubElement(root, 'module', {'identifier': self.module_identifier}) + lxml.etree.SubElement(module, 'title').text = (self.get_key()).course + lxml.etree.SubElement(module, 'workflow_state').text = 'active' + + items = lxml.etree.SubElement(module, 'items') + + # Iterate through chapter_sequential_modules and assign their type as they would appear in the modules page + # Example types: Header that just has text, external tool, assignment, etc. + for chapter_sequential in chapter_sequential_modules: + if chapter_sequential.format in assignment_types: + item = lxml.etree.SubElement(items, 'item', {'identifier': self.sequential_to_identifier[chapter_sequential]}) + lxml.etree.SubElement(item, 'content_type').text = 'Assignment' + lxml.etree.SubElement(item, 'title').text = chapter_sequential.display_name + lxml.etree.SubElement(item, 'identifierref').text = self.sequential_to_identifierref[chapter_sequential] + elif chapter_sequential.category == 'sequential': + item = lxml.etree.SubElement(items, 'item', {'identifier': self.sequential_to_identifierref[chapter_sequential]}) + lxml.etree.SubElement(item, 'content_type').text = 'ContextExternalTool' + lxml.etree.SubElement(item, 'title').text = chapter_sequential.display_name + lxml.etree.SubElement(item, 'identifierref').text = self.external_tool_identifierref + lti_link = 'https://courses.educateworkforce.com/lti_provider/courses/' + str(courselike_key) + "/" + (str(courselike_key)).replace('course', 'block') + '+type@sequential+block@' + chapter_sequential.url_name + lxml.etree.SubElement(item, 'url').text = lti_link + else: + item = lxml.etree.SubElement(items, 'item', {'identifier': self.chapter_to_identifier[chapter_sequential]}) + lxml.etree.SubElement(item, 'content_type').text = 'ContextModuleSubHeader' + lxml.etree.SubElement(item, 'title').text = chapter_sequential.display_name + + # Write to file + with export_fs.open('course_settings/module_meta.xml', 'wb') as module_meta_xml: + tree = lxml.etree.ElementTree(root) + tree.write(module_meta_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) def export_all_course_settings(self, modulestore, courselike_key, courselike, export_fs): """ @@ -533,7 +604,7 @@ def export(self): self.export_all_course_settings(self.modulestore, self.courselike_key, courselike, export_fs) self.export_assignment_folders(self.modulestore, self.courselike_key, courselike, export_fs) self.write_imsmanifest_xml(self.modulestore, self.courselike_key, courselike, export_fs) - + self.export_module_meta_xml(self.modulestore, self.courselike_key, courselike, export_fs) """ Function "export_course_to_imscc" below get called by the django management comman from export_olx.py From b4226005ab957907d551330afc6f2c4129c0e759 Mon Sep 17 00:00:00 2001 From: Kenny Sun Date: Thu, 31 Oct 2024 15:12:46 -0400 Subject: [PATCH 08/14] fix: chapter modules/module subheaders werent published by default, now fixed --- common/lib/xmodule/xmodule/modulestore/imscc_exporter.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py index 60a85bcc4239..113649110091 100644 --- a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py @@ -553,18 +553,21 @@ def export_module_meta_xml(self, modulestore, courselike_key, courselike, export item = lxml.etree.SubElement(items, 'item', {'identifier': self.sequential_to_identifier[chapter_sequential]}) lxml.etree.SubElement(item, 'content_type').text = 'Assignment' lxml.etree.SubElement(item, 'title').text = chapter_sequential.display_name + lxml.etree.SubElement(item, 'workflow_state').text= 'active' lxml.etree.SubElement(item, 'identifierref').text = self.sequential_to_identifierref[chapter_sequential] elif chapter_sequential.category == 'sequential': item = lxml.etree.SubElement(items, 'item', {'identifier': self.sequential_to_identifierref[chapter_sequential]}) lxml.etree.SubElement(item, 'content_type').text = 'ContextExternalTool' lxml.etree.SubElement(item, 'title').text = chapter_sequential.display_name + lxml.etree.SubElement(item, 'workflow_state').text= 'active' lxml.etree.SubElement(item, 'identifierref').text = self.external_tool_identifierref lti_link = 'https://courses.educateworkforce.com/lti_provider/courses/' + str(courselike_key) + "/" + (str(courselike_key)).replace('course', 'block') + '+type@sequential+block@' + chapter_sequential.url_name lxml.etree.SubElement(item, 'url').text = lti_link else: item = lxml.etree.SubElement(items, 'item', {'identifier': self.chapter_to_identifier[chapter_sequential]}) lxml.etree.SubElement(item, 'content_type').text = 'ContextModuleSubHeader' - lxml.etree.SubElement(item, 'title').text = chapter_sequential.display_name + lxml.etree.SubElement(item, 'title').text = chapter_sequential.display_name + lxml.etree.SubElement(item, 'workflow_state').text= 'active' # Write to file with export_fs.open('course_settings/module_meta.xml', 'wb') as module_meta_xml: From adcd2444d0d3861009da65684dc1e191caa18abc Mon Sep 17 00:00:00 2001 From: Kenny Sun Date: Mon, 4 Nov 2024 15:24:34 -0500 Subject: [PATCH 09/14] feat: able to pass multiple courses as arguments, export_assignment_groups is iterable Added safeguard that prevents passing multiple courses without cc-lti flag --- .../management/commands/export_olx.py | 58 ++++++++++-------- .../xmodule/modulestore/imscc_exporter.py | 61 ++++++++++++------- 2 files changed, 72 insertions(+), 47 deletions(-) diff --git a/cms/djangoapps/contentstore/management/commands/export_olx.py b/cms/djangoapps/contentstore/management/commands/export_olx.py index 3e94e2529acc..e045e92480d6 100644 --- a/cms/djangoapps/contentstore/management/commands/export_olx.py +++ b/cms/djangoapps/contentstore/management/commands/export_olx.py @@ -40,19 +40,27 @@ class Command(BaseCommand): help = dedent(__doc__).strip() def add_arguments(self, parser): - parser.add_argument('course_id') + parser.add_argument('course_id', nargs="+") #nargs = "+" allows parsing of unlimited course ids parser.add_argument('--output') parser.add_argument('--cc-lti', action = 'store_true', help = 'Run the command with Common Cartridge format') def handle(self, *args, **options): - course_id = options['course_id'] - - try: - course_key = CourseKey.from_string(course_id) - except InvalidKeyError: - raise CommandError("Unparsable course_id") # lint-amnesty, pylint: disable=raise-missing-from - except IndexError: - raise CommandError("Insufficient arguments") # lint-amnesty, pylint: disable=raise-missing-from + cc_lti = options.get('cc_lti', False) + course_ids = options['course_id'] + + # Raise an error only allowing courses to be exported 1 at a time when not using Common Cartridge packaging standards + if not cc_lti and len(course_ids) > 1: + raise CommandError("Can only export 1 OpenEdX course at at time in default OpenEdX packaging standards") + + # stores all the different course keys based on the inputted course ids + course_keys = [] + for course_id in course_ids: + try: + course_keys.append(CourseKey.from_string(course_id)) + except InvalidKeyError: + raise CommandError("Unparsable course_id") # lint-amnesty, pylint: disable=raise-missing-from + except IndexError: + raise CommandError("Insufficient arguments") # lint-amnesty, pylint: disable=raise-missing-from filename = options['output'] pipe_results = False @@ -61,8 +69,7 @@ def handle(self, *args, **options): filename = mktemp() pipe_results = True - cc_lti = options.get('cc_lti', False) - export_course_to_tarfile(course_key, filename, cc_lti) + export_course_to_tarfile(course_keys, filename, cc_lti) results = self._get_results(filename) if pipe_results else b'' @@ -82,39 +89,40 @@ def _get_results(self, filename): return results -def export_course_to_tarfile(course_key, filename, cc_lti): - # test for --cc-lti flag functionality it works - if cc_lti: - print("CC_LTI") - else: - print("NO CC_LTI") +def export_course_to_tarfile(course_keys, filename, cc_lti): """Exports a course into a tar.gz file""" tmp_dir = mkdtemp() try: - course_dir = export_course_to_directory(course_key, tmp_dir, cc_lti) + course_dir = export_course_to_directory(course_keys, tmp_dir, cc_lti) compress_directory(course_dir, filename) finally: shutil.rmtree(tmp_dir, ignore_errors=True) -def export_course_to_directory(course_key, root_dir, cc_lti): +def export_course_to_directory(course_keys, root_dir, cc_lti): """Export course into a directory""" + # attempt to get all the courses based on the course_keys store = modulestore() - course = store.get_course(course_key) - if course is None: - raise CommandError("Invalid course_id") + courses = [] + for course_key in course_keys: + course = store.get_course(course_key) + if course is None: + raise CommandError("Invalid course_id") + courses.append(course) # The safest characters are A-Z, a-z, 0-9, , and . # We represent the first four with \w. # TODO: Once we support courses with unicode characters, we will need to revisit this. replacement_char = '-' - course_dir = replacement_char.join([course.id.org, course.id.course, course.id.run]) + course_dir = replacement_char.join([courses[0].id.org, courses[0].id.course, courses[0].id.run]) course_dir = re.sub(r'[^\w\.\-]', replacement_char, course_dir) if cc_lti: - export_course_to_imscc(store, None, course.id, root_dir, course_dir) + if len(courses) > 1: + course_dir = "MULTI-COURSE-EXPORT" + export_course_to_imscc(store, None, courses[0].id, root_dir, course_dir) else: - export_course_to_xml(store, None, course.id, root_dir, course_dir) + export_course_to_xml(store, None, courses[0].id, root_dir, course_dir) export_dir = path(root_dir) / course_dir return export_dir diff --git a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py index 113649110091..dc302e2eb878 100644 --- a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py @@ -126,6 +126,13 @@ def __init__(self, modulestore, contentstore, courselike_key, root_dir, target_d self.course_settings_identifier = create_uuid() self.module_identifier = create_uuid() + """ + Sets up xml roots to access for multi course exporting + 'assignment_groups_root': xml root for assignment_groups + """ + # Declare variable for all the xml tree roots + self.assignment_groups_root = None + def get_key(self): """ Get the courselike locator key @@ -187,12 +194,12 @@ def get_chapter_sequential_modules(self, modulestore, courselike_key): sequentials_chapters.append(module) return sequentials_chapters - def export_assignment_groups(self, modulestore, courselike, export_fs): + def prepare_roots(self): """ - Exports the 'assignment_groups.xml' file in course_settings + Prepares roots with basic metadata that is the same across all courses for multi-course exporting to build on """ - # Create root - root = lxml.etree.Element( + ################### Assignment groups root ################### + self.assignment_groups_root = lxml.etree.Element( 'assignmentGroups', nsmap = { None: 'http://canvas.instructure.com/xsd/cccv1p0', @@ -200,22 +207,26 @@ def export_assignment_groups(self, modulestore, courselike, export_fs): } ) - root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', + assignment_groups_root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') + + def export_assignment_groups(self, modulestore, courselike, export_fs): + """ + Exports the 'assignment_groups.xml' file in course_settings + """ # Accessing each asignment type with their weight and adding it to the xml for grade in courselike.grading_policy['GRADER']: - grade_name = grade['type'] - grade_weight = grade['weight'] * 100 # openedx uses 0-1 grading weight, imscc uses 0-100 - self.assignment_group_to_identifier[grade_name] = create_uuid() - assignment_group = lxml.etree.SubElement(root, 'assignmentGroup', {'identifier': str(self.assignment_group_to_identifier[grade_name])}) - lxml.etree.SubElement(assignment_group, 'title').text = 'EW - ' + grade_name - lxml.etree.SubElement(assignment_group, 'group_weight').text = str(grade_weight) - - # Write to file - with export_fs.open('course_settings/assignment_groups.xml', 'wb') as assignment_groups_xml: - tree = lxml.etree.ElementTree(root) - tree.write(assignment_groups_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) + # Only add the assignmeent group if it doesn't already exist in the xml + # For CUCWD, this should only add the 4 primary assignment groups (pre-test, activities, module reinforcement, post-tests) + # For other platforms, all the assignment groups may not add up correctly but can easily be fixed in post on Canvas + if assignment_group_to_identifier[grade_name] == None: + grade_name = grade['type'] + grade_weight = grade['weight'] * 100 # openedx uses 0-1 grading weight, imscc uses 0-100 + self.assignment_group_to_identifier[grade_name] = create_uuid() + assignment_group = lxml.etree.SubElement(root, 'assignmentGroup', {'identifier': str(self.assignment_group_to_identifier[grade_name])}) + lxml.etree.SubElement(assignment_group, 'title').text = 'EW - ' + grade_name + lxml.etree.SubElement(assignment_group, 'group_weight').text = str(grade_weight) def export_media_tracks(self, export_fs): """ @@ -539,7 +550,8 @@ def export_module_meta_xml(self, modulestore, courselike_key, courselike, export root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') - + + # Single module is created here, need to iterate module = lxml.etree.SubElement(root, 'module', {'identifier': self.module_identifier}) lxml.etree.SubElement(module, 'title').text = (self.get_key()).course lxml.etree.SubElement(module, 'workflow_state').text = 'active' @@ -580,8 +592,13 @@ def export_all_course_settings(self, modulestore, courselike_key, courselike, ex """ export_fs.makedirs('course_settings', recreate=True) self.export_assignment_groups(modulestore, courselike, export_fs) + # Write assignment_groups to a file + with export_fs.open('course_settings/assignment_groups.xml', 'wb') as assignment_groups_xml: + tree = lxml.etree.ElementTree(self.assignment_groups_root) + tree.write(assignment_groups_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) + self.export_media_tracks(export_fs) - self.export_files_meta(export_fs) + self.export_files_meta(export_fs)ß self.export_course_settings(modulestore, courselike_key, export_fs) def export(self): @@ -590,11 +607,11 @@ def export(self): """ with self.modulestore.bulk_operations(self.courselike_key): - fsm = OSFS(self.root_dir) - root = lxml.etree.Element('unknown') + fsm = OSFS(self.root_dir) + root = lxml.etree.Element('unknown') - # export only the published content - with self.modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, self.courselike_key): + # export only the published content + with self.modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, self.courselike_key): # stores metadata for the course courselike = self.get_courselike() From aea9aba4b7c30b233cd7e53abddacc4b8fdba68e Mon Sep 17 00:00:00 2001 From: Kenny Sun Date: Wed, 13 Nov 2024 16:29:12 -0500 Subject: [PATCH 10/14] feat: finished multi-course export assignment folders imsmanifest and module_meta now generates using multiple courses, changed identifier dictionary keys of sequentials and chapters to use a serialized object with custom hash function Update for use of a new serialized object for chapters and sequentials due a weird issue where iterating through the same list of courselike keys in a different function led to slightly different chapters and sequentials causing for non-consistent keys, maybe due to memory of the objects? Naming conventions were also added such as adding the tag at the end of the course key to the beginning of modules, etc. --- .../management/commands/export_olx.py | 7 +- .../xmodule/modulestore/imscc_exporter.py | 615 +++++++++++------- 2 files changed, 371 insertions(+), 251 deletions(-) diff --git a/cms/djangoapps/contentstore/management/commands/export_olx.py b/cms/djangoapps/contentstore/management/commands/export_olx.py index e045e92480d6..6125d1bfc208 100644 --- a/cms/djangoapps/contentstore/management/commands/export_olx.py +++ b/cms/djangoapps/contentstore/management/commands/export_olx.py @@ -110,6 +110,9 @@ def export_course_to_directory(course_keys, root_dir, cc_lti): raise CommandError("Invalid course_id") courses.append(course) + course_ids = [] + for course in courses: + course_ids.append(course.id) # The safest characters are A-Z, a-z, 0-9, , and . # We represent the first four with \w. # TODO: Once we support courses with unicode characters, we will need to revisit this. @@ -120,9 +123,9 @@ def export_course_to_directory(course_keys, root_dir, cc_lti): if cc_lti: if len(courses) > 1: course_dir = "MULTI-COURSE-EXPORT" - export_course_to_imscc(store, None, courses[0].id, root_dir, course_dir) + export_course_to_imscc(store, None, course_ids, root_dir, course_dir) else: - export_course_to_xml(store, None, courses[0].id, root_dir, course_dir) + export_course_to_xml(store, None, course_ids[0], root_dir, course_dir) export_dir = path(root_dir) / course_dir return export_dir diff --git a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py index dc302e2eb878..64ce681846a0 100644 --- a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py @@ -38,32 +38,73 @@ def create_uuid(): ########## THIS FUNCTION DOESN'T WORK ###################################### ########## ALL IT DOES IS RETURNS ZERO, NEED TO FIX TO GET ACTUAL SCORE #### -def get_total_score(sequential): - total_score = 0.0 +# def get_total_score(sequential): +# total_score = 0.0 - # Get the direct children of the sequential - children = sequential.get_children() +# # Get the direct children of the sequential +# children = sequential.get_children() - for child in children: - try: - # Call get_max_score() on the individual child - score = child.get_max_score() # Change this line to use child - total_score += score - except Exception as e: - pass # Handle the exception if necessary +# for child in children: +# try: +# # Call get_max_score() on the individual child +# score = child.get_max_score() # Change this line to use child +# total_score += score +# except Exception as e: +# pass # Handle the exception if necessary - # Recursively get the score from the child's children - total_score += get_total_score(child) +# # Recursively get the score from the child's children +# total_score += get_total_score(child) - if total_score != 0: - print(total_score) - return total_score +# if total_score != 0: +# print(total_score) +# return total_score + + +class SerializableChapterSequential: + """ + A serialized chapter or sequential object which allows for access of only the important attributes + as well as hashing without objet memory related issues + """ + def __init__(self, display_name, format, url_name, category, course_id): + """ + 'display_name': The display name of the chapter or sequential + 'format': The format of the chapter or sequential (commonly used with assignment types) + 'url_name': The ending url of the chapter or sequential (used with creating LTI links) + 'category': The category of the module (chapter or sequential) + 'course_id': The course id in which the chapter or sequential is from + """ + self.display_name = display_name + self.format = format + self.url_name = url_name + self.category = category + self.course_id = course_id + + def __hash__(self): + """ + Provides an overriden hash function to be used for dictionaries to prevent memory related issues + when hashing objects + """ + # Use a combination of the attributes to generate a hash value + return hash((self.display_name, self.format, self.url_name, self.category, self.course_id)) + + def __eq__(self, other): + """ + Provides an equals function for comparing 2 serialized chapter or sequential objects + """ + # Define equality based on the attributes + if isinstance(other, SerializableChapterSequential): + return (self.display_name == other.display_name and + self.format == other.format and + self.url_name == other.url_name and + self.category == other.category and + self.course_id == other.course_id) + return False class TestExportManager: """ Manages IMSCC exporting for courselike objects. """ - def __init__(self, modulestore, contentstore, courselike_key, root_dir, target_dir): + def __init__(self, modulestore, contentstore, courselike_keys, root_dir, target_dir): """ Export all modules from `modulestore` and content from `contentstore` as xml to `root_dir`. @@ -75,7 +116,7 @@ def __init__(self, modulestore, contentstore, courselike_key, root_dir, target_d """ self.modulestore = modulestore self.contentstore = contentstore - self.courselike_key = courselike_key + self.courselike_keys = courselike_keys self.root_dir = root_dir self.target_dir = str(target_dir) @@ -117,40 +158,51 @@ def __init__(self, modulestore, contentstore, courselike_key, root_dir, target_d An 'identifier' attribute of the 'item' element under the 'item' element with the identifier 'LearningModules' in 'imsmanfiest.xml' An 'identifier' attribute of the 'module' element in the 'course_settings/module_meta.xml' file """ - + self.sequential_to_identifier = {} self.sequential_to_identifierref = {} self.chapter_to_identifier = {} + self.module_identifiers = {} + + # Fill out the sequential, chapter, and module dictionaries with identifiers + for courselike_key in self.courselike_keys: + sequential_modules = self.get_sequential_modules(self.modulestore, courselike_key) + for sequential in sequential_modules: + sequential = self.serialize_chapter_sequential(sequential) + self.sequential_to_identifier[sequential] = create_uuid() + self.sequential_to_identifierref[sequential] = create_uuid() + + for courselike_key in self.courselike_keys: + chapter_modules = self.get_chapter_modules(self.modulestore, courselike_key) + for chapter in chapter_modules: + chapter = self.serialize_chapter_sequential(chapter) + self.chapter_to_identifier[chapter] = create_uuid() + + for courselike_key in self.courselike_keys: + self.module_identifiers[courselike_key] = create_uuid() + self.assignment_group_to_identifier = {} self.external_tool_identifierref = create_uuid() self.course_settings_identifier = create_uuid() - self.module_identifier = create_uuid() + def get_key(self, courselike_key): """ - Sets up xml roots to access for multi course exporting - 'assignment_groups_root': xml root for assignment_groups - """ - # Declare variable for all the xml tree roots - self.assignment_groups_root = None - - def get_key(self): - """ - Get the courselike locator key + Get the courselike locator key based on the input courselike_key """ return CourseLocator( - self.courselike_key.org, self.courselike_key.course, self.courselike_key.run, deprecated=True + courselike_key.org, courselike_key.course, courselike_key.run, deprecated=True ) - def get_courselike(self): + def get_courselike(self, courselike_key): """ - Get the target courselike object for this export. + Get the target courselike object based on the input courselike_key """ # depth = None: Traverses down the entire course structure. # lazy = False: Loads and caches all block definitions during traversal for fast access later # -and- to eliminate many round-trips to read individual definitions. # Why these parameters? Because a course export needs to access all the course block information # eventually. Accessing it all now at the beginning increases performance of the export. - return self.modulestore.get_course(self.courselike_key, depth=None, lazy=False) + return self.modulestore.get_course(courselike_key, depth=None, lazy=False) def get_sequential_modules(self, modulestore, courselike_key): """ @@ -193,13 +245,52 @@ def get_chapter_sequential_modules(self, modulestore, courselike_key): if module.category == 'sequential' or module.category == 'chapter': sequentials_chapters.append(module) return sequentials_chapters + + def serialize_chapter_sequential(self, chapter_sequential): + """ + Return a serialized object of an inputted chpater or sequential in order to bypass dictionary key issues + """ + return SerializableChapterSequential( + chapter_sequential.display_name, + chapter_sequential.format, + chapter_sequential.url_name, + chapter_sequential.category, + chapter_sequential.course_id + ) + + def get_course_abbreviation(self, courselike_key): + """ + Returns a course abbreviation to append to the start of module and assignment names + Returns nothing if the courselike_key provided doesn't match the expected pattern in most courses + """ + # re pattern for extracting the values between the plus signs + between_pluses = r'(?<=\+)(.*?)(?=\+)' + # re pattern for the 'FAA-ACS-AM-IA-ACE' that all courses seem to have + course_type = r'([A-Za-z]{3}-[A-Za-z]{3}-[A-Za-z]{2}-[A-Za-z]{2}-[A-Za-z]{3})' + + courselike_key = str(courselike_key) + + match = re.search(between_pluses, courselike_key) + + if match: + extracted_value = match.group(1) + + # Check if it matches the course pattern + if re.match(course_type, extracted_value): + # If it matches, extract the last 2 letters dash 3 letters + last_part = extracted_value.split('-')[-2] + '-' + extracted_value.split('-')[-1] + return last_part + ' ' + else: + return '' + else: + return '' - def prepare_roots(self): + def export_assignment_groups(self, modulestore, courselikes, export_fs): """ - Prepares roots with basic metadata that is the same across all courses for multi-course exporting to build on + Exports the 'assignment_groups.xml' file in course_settings """ ################### Assignment groups root ################### - self.assignment_groups_root = lxml.etree.Element( + root = lxml.etree.Element( 'assignmentGroups', nsmap = { None: 'http://canvas.instructure.com/xsd/cccv1p0', @@ -207,26 +298,27 @@ def prepare_roots(self): } ) - assignment_groups_root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', + root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') - def export_assignment_groups(self, modulestore, courselike, export_fs): - """ - Exports the 'assignment_groups.xml' file in course_settings - """ - - # Accessing each asignment type with their weight and adding it to the xml - for grade in courselike.grading_policy['GRADER']: - # Only add the assignmeent group if it doesn't already exist in the xml - # For CUCWD, this should only add the 4 primary assignment groups (pre-test, activities, module reinforcement, post-tests) - # For other platforms, all the assignment groups may not add up correctly but can easily be fixed in post on Canvas - if assignment_group_to_identifier[grade_name] == None: + for courselike in courselikes: + # Accessing each asignment type with their weight and adding it to the xml + for grade in courselike.grading_policy['GRADER']: + # Only add the assignmeent group if it doesn't already exist in the xml + # For CUCWD, this should only add the 4 primary assignment groups (pre-test, activities, module reinforcement, post-tests) + # For other platforms, all the assignment groups may not add up correctly but can easily be fixed in post on Canvas grade_name = grade['type'] grade_weight = grade['weight'] * 100 # openedx uses 0-1 grading weight, imscc uses 0-100 - self.assignment_group_to_identifier[grade_name] = create_uuid() - assignment_group = lxml.etree.SubElement(root, 'assignmentGroup', {'identifier': str(self.assignment_group_to_identifier[grade_name])}) - lxml.etree.SubElement(assignment_group, 'title').text = 'EW - ' + grade_name - lxml.etree.SubElement(assignment_group, 'group_weight').text = str(grade_weight) + if grade_name not in self.assignment_group_to_identifier: + self.assignment_group_to_identifier[grade_name] = create_uuid() + assignment_group = lxml.etree.SubElement(root, 'assignmentGroup', {'identifier': str(self.assignment_group_to_identifier[grade_name])}) + lxml.etree.SubElement(assignment_group, 'title').text = 'EW - ' + grade_name + lxml.etree.SubElement(assignment_group, 'group_weight').text = str(grade_weight) + + # Write assignment_groups to a file + with export_fs.open('course_settings/assignment_groups.xml', 'wb') as assignment_groups_xml: + tree = lxml.etree.ElementTree(root) + tree.write(assignment_groups_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) def export_media_tracks(self, export_fs): """ @@ -271,7 +363,7 @@ def export_files_meta(self, export_fs): tree = lxml.etree.ElementTree(root) tree.write(files_meta_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) - def export_course_settings(self, modulestore, courselike_key, export_fs): + def export_course_settings(self, modulestore, courselike_keys, export_fs): """ Exports the 'course_settings.xml' file in course_settings """ @@ -290,100 +382,108 @@ def export_course_settings(self, modulestore, courselike_key, export_fs): root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') - lxml.etree.SubElement(root, 'title').text = str(courselike_key) - lxml.etree.SubElement(root, 'course_code').text = str(courselike_key) - lxml.etree.SubElement(root, 'group_weighting_scheme').text = "percent" + # Sets the title of the course to MULTI-COURSE-EXPORT if it's multi course, the default courselike key otherwise + if len(courselike_keys) > 1: + lxml.etree.SubElement(root, 'title').text = 'MULTI-COURSE-EXPORT' + lxml.etree.SubElement(root, 'course_code').text = 'MULTI-COURSE-EXPORT' + else: + lxml.etree.SubElement(root, 'title').text = str(courselike_keys[0]) + lxml.etree.SubElement(root, 'course_code').text = str(courselike_keys[0]) + lxml.etree.SubElement(root, 'group_weighting_scheme').text = 'percent' # Write to file - with export_fs.open('course_settings/course_settings.xml', 'wb') as media_tracks_xml: + with export_fs.open('course_settings/course_settings.xml', 'wb') as course_settings_xml: tree = lxml.etree.ElementTree(root) - tree.write(media_tracks_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) + tree.write(course_settings_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) # There's this file called canvas_export.txt that contains nothing but a pun... # It's referenced in the ims_manifest file for some reason so we're adding it with export_fs.open('course_settings/canvas_export.txt', 'w') as canvas_export_txt: canvas_export_txt.write('Q: What did the panda say when he was forced out of his natural habitat?\nA: This is un-BEAR-able\n') - def export_assignment_folders(self, modulestore, courselike_key, courselike, export_fs): + def export_assignment_folders(self, modulestore, courselike_keys, courselikes, export_fs): """ Exports all the individual folders for each sequential that is an assignment """ - sequential_modules = self.get_sequential_modules(modulestore, courselike_key) - - # Parse out non assignments - assignment_types = {assignment_type['type'] for assignment_type in courselike.grading_policy['GRADER']} - print(assignment_types) - only_assignments = (sequential for sequential in sequential_modules if sequential.format in assignment_types) - - # Set all the identifiers - for sequential in sequential_modules: - self.sequential_to_identifier[sequential] = create_uuid() - self.sequential_to_identifierref[sequential] = create_uuid() - - for sequential in only_assignments: - # Create root - root = lxml.etree.Element( - 'assignment', - { - 'identifier': self.sequential_to_identifierref[sequential] - }, - nsmap={ - None: 'http://canvas.instructure.com/xsd/cccv1p0', - 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', - } - ) - root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', - 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') - - # Add assignment data like points, assignment type, lti, etc. - lxml.etree.SubElement(root, 'title').text = sequential.display_name - lxml.etree.SubElement(root, 'assignment_group_identifierref').text = str(self.assignment_group_to_identifier[sequential.format]) - - ###### NEED TO FIX ####### - lxml.etree.SubElement(root, 'points_possible').text = str(get_total_score(sequential)) - ########################## - - lxml.etree.SubElement(root, 'submission_types').text = 'external_tool' - lxml.etree.SubElement(root, 'external_tool_identifierref').text = self.external_tool_identifierref - lti_link = 'https://courses.educateworkforce.com/lti_provider/courses/' + str(courselike_key) + "/" + (str(courselike_key)).replace('course', 'block') + '+type@sequential+block@' + sequential.url_name - lxml.etree.SubElement(root, 'external_tool_url').text = lti_link - lxml.etree.SubElement(root, 'external_tool_data_json').text = '\"\"' - lxml.etree.SubElement(root, 'external_tool_link_settings_json').text = '{\"selection_width\":\"\",\"selection_height":\"\"}' - lxml.etree.SubElement(root, 'external_tool_new_tab').text = 'false' - - # Create corresponding HTML file - # HTML files follow this same cookie cutter format with the only thing changing is the title - html_content =''' - - - Assignment: ''' - - html_content_pt2 =''' - - - - - ''' - - # Make the name of the file match conventions with all lower cases and no spaces, and dashes replacing spaces - html_file_name = re.sub(r'[^a-zA-Z0-9\s-]', '', sequential.display_name) - html_file_name = html_file_name.lower() - html_file_name = html_file_name.replace(' ', '-') - html_file_name = html_file_name + '.html' - - # Write to file - export_fs.makedirs(str(self.sequential_to_identifierref[sequential]), recreate=True) - - with export_fs.open(str(self.sequential_to_identifierref[sequential]) + '/assignment_settings.xml', 'wb') as assignment_settings_xml: - tree = lxml.etree.ElementTree(root) - tree.write(assignment_settings_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) - - with export_fs.open(str(self.sequential_to_identifierref[sequential]) + '/' + html_file_name, 'w') as html_file: - html_file.write(html_content) - html_file.write(sequential.display_name) - html_file.write(html_content_pt2) - - def write_imsmanifest_xml(self, modulestore, courselike_key, courselike, export_fs): + # Iterate through both courselike_keys and courselikes at the same time + for courselike_key, courselike in zip(courselike_keys, courselikes): + # Bulk operations and only operate on published content + with self.modulestore.bulk_operations(courselike_key): + with self.modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, courselike_key): + sequential_modules = self.get_sequential_modules(modulestore, courselike_key) + + # Parse out non assignments + assignment_types = {assignment_type['type'] for assignment_type in courselike.grading_policy['GRADER']} + only_assignments = (sequential for sequential in sequential_modules if sequential.format in assignment_types) + + # Course abbreviation to append to the start of the module names + course_abbreviation = self.get_course_abbreviation(courselike_key) + + for sequential in only_assignments: + sequential = self.serialize_chapter_sequential(sequential) + # Create root + root = lxml.etree.Element( + 'assignment', + { + 'identifier': self.sequential_to_identifierref[sequential] + }, + nsmap={ + None: 'http://canvas.instructure.com/xsd/cccv1p0', + 'xsi': 'http://www.w3.org/2001/XMLSchema-instance', + } + ) + root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', + 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') + + # Add assignment data like points, assignment type, lti, etc. + lxml.etree.SubElement(root, 'title').text = course_abbreviation + sequential.display_name + lxml.etree.SubElement(root, 'assignment_group_identifierref').text = str(self.assignment_group_to_identifier[sequential.format]) + + ###### NEED TO FIX ####### + lxml.etree.SubElement(root, 'points_possible').text = '0'#str(get_total_score(sequential)) + ########################## + + lxml.etree.SubElement(root, 'submission_types').text = 'external_tool' + lxml.etree.SubElement(root, 'external_tool_identifierref').text = self.external_tool_identifierref + lti_link = 'https://courses.educateworkforce.com/lti_provider/courses/' + str(courselike_key) + "/" + (str(courselike_key)).replace('course', 'block') + '+type@sequential+block@' + sequential.url_name + lxml.etree.SubElement(root, 'external_tool_url').text = lti_link + lxml.etree.SubElement(root, 'external_tool_data_json').text = '\"\"' + lxml.etree.SubElement(root, 'external_tool_link_settings_json').text = '{\"selection_width\":\"\",\"selection_height":\"\"}' + lxml.etree.SubElement(root, 'external_tool_new_tab').text = 'false' + + # Create corresponding HTML file + # HTML files follow this same cookie cutter format with the only thing changing is the title + html_content =''' + + + Assignment: ''' + + html_content_pt2 =''' + + + + + ''' + + # Make the name of the file match conventions with all lower cases and no spaces, and dashes replacing spaces + html_file_name = re.sub(r'[^a-zA-Z0-9\s-]', '', course_abbreviation + sequential.display_name) + html_file_name = html_file_name.lower() + html_file_name = html_file_name.replace(' ', '-') + html_file_name = html_file_name + '.html' + + # Write to file + export_fs.makedirs(str(self.sequential_to_identifierref[sequential]), recreate=True) + + with export_fs.open(str(self.sequential_to_identifierref[sequential]) + '/assignment_settings.xml', 'wb') as assignment_settings_xml: + tree = lxml.etree.ElementTree(root) + tree.write(assignment_settings_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) + + with export_fs.open(str(self.sequential_to_identifierref[sequential]) + '/' + html_file_name, 'w') as html_file: + html_file.write(html_content) + html_file.write(sequential.display_name) + html_file.write(html_content_pt2) + + def export_imsmanifest_xml(self, modulestore, courselike_keys, courselikes, export_fs): """ Exports the imsmanifest.xml file """ @@ -418,8 +518,11 @@ def write_imsmanifest_xml(self, modulestore, courselike_key, courselike, export_ general = lxml.etree.SubElement(lom, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}general') title = lxml.etree.SubElement(general, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}title') - lxml.etree.SubElement(title, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}string').text = str(courselike_key) - + # Sets the title of the imsmanifest to MULTI-COURSE-EXPORT if it's multi course, the default courselike key otherwise + if len(courselike_keys) > 1: + lxml.etree.SubElement(title, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}string').text = 'MULTI-COURSE-EXPORT' + else: + lxml.etree.SubElement(title, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}string').text = str(courselike_keys[0]) lifecycle = lxml.etree.SubElement(lom, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}lifeCycle') contribute = lxml.etree.SubElement(lifecycle, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}contribute') date = lxml.etree.SubElement(contribute, '{http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest}date') @@ -439,22 +542,29 @@ def write_imsmanifest_xml(self, modulestore, courselike_key, courselike, export_ learning_module = lxml.etree.SubElement(organization, 'item', {'identifier': 'LearningModules'}) - # Single module is created here for the one course, will need to be updated later to incorporate multiple courses - module = lxml.etree.SubElement(learning_module, 'item', {'identifier': self.module_identifier}) - lxml.etree.SubElement(module, 'title').text = (self.get_key()).course - - # Build out all the chapters and sequentials underneath the one learning module (course) - chapter_and_sequential_modules = self.get_chapter_sequential_modules(modulestore, courselike_key) - for chapter_sequential_module in chapter_and_sequential_modules: - if chapter_sequential_module.category == 'sequential': - sequential = lxml.etree.SubElement(module, 'item', {'identifier': self.sequential_to_identifier[chapter_sequential_module], 'identifierref': self.sequential_to_identifierref[chapter_sequential_module]}) - lxml.etree.SubElement(sequential, 'title').text = chapter_sequential_module.display_name - else: - self.chapter_to_identifier[chapter_sequential_module] = create_uuid() - chapter = lxml.etree.SubElement(module, 'item', {'identifier': self.chapter_to_identifier[chapter_sequential_module]}) - print(chapter_sequential_module.display_name) - lxml.etree.SubElement(chapter, 'title').text = chapter_sequential_module.display_name - + # Iterate through all the courselikes keys + for courselike_key in courselike_keys: + # Bulk operations and only operate on published content + with self.modulestore.bulk_operations(courselike_key): + with self.modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, courselike_key): + module = lxml.etree.SubElement(learning_module, 'item', {'identifier': self.module_identifiers[courselike_key]}) + lxml.etree.SubElement(module, 'title').text = (self.get_courselike(courselike_key)).display_name + ' ' + (self.get_key(courselike_key)).course + + # Build out all the chapters and sequentials underneath the one learning module (course) + chapter_and_sequential_modules = self.get_chapter_sequential_modules(modulestore, courselike_key) + + # Course abbreviation to append to the start of the module names + course_abbreviation = self.get_course_abbreviation(courselike_key) + + for chapter_sequential in chapter_and_sequential_modules: + chapter_sequential = self.serialize_chapter_sequential(chapter_sequential) + if chapter_sequential.category == 'sequential': + sequential = lxml.etree.SubElement(module, 'item', {'identifier': self.sequential_to_identifier[chapter_sequential], 'identifierref': self.sequential_to_identifierref[chapter_sequential]}) + lxml.etree.SubElement(sequential, 'title').text = course_abbreviation + chapter_sequential.display_name + else: + chapter = lxml.etree.SubElement(module, 'item', {'identifier': self.chapter_to_identifier[chapter_sequential]}) + lxml.etree.SubElement(chapter, 'title').text = course_abbreviation + chapter_sequential.display_name + ############################# Resources section of imsmanifest.xml ############################# # Create resources element @@ -467,22 +577,28 @@ def write_imsmanifest_xml(self, modulestore, courselike_key, courselike, export_ for filename in export_fs.listdir(course_settings_path): lxml.etree.SubElement(course_settings_resource, 'file', {'href': 'course_settings/' + filename}) - # Create resources for assignment sequentials - sequential_modules = self.get_sequential_modules(modulestore, courselike_key) - - assignment_types = {assignment_type['type'] for assignment_type in courselike.grading_policy['GRADER']} - for sequential in sequential_modules: - if sequential.format in assignment_types: - html_file_path = self.sequential_to_identifierref[sequential] - xml_file_path = self.sequential_to_identifierref[sequential] - for filename in export_fs.listdir(self.sequential_to_identifierref[sequential]): - if filename.endswith('.html'): - html_file_path = html_file_path + '/' + filename - if filename.endswith('xml'): - xml_file_path = xml_file_path + '/' + filename - resource = lxml.etree.SubElement(resources , 'resource', {'identifier': self.sequential_to_identifierref[sequential], 'type': type_string, 'href': html_file_path}) - lxml.etree.SubElement(resource, 'file', {'href': html_file_path}) - lxml.etree.SubElement(resource, 'file', {'href': xml_file_path}) + # Iterate through all the courselike keys and courselikes + for courselike_key, courselike in zip(courselike_keys, courselikes): + # Bulk operations and only operate on published content + with self.modulestore.bulk_operations(courselike_key): + with self.modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, courselike_key): + # Create resources for assignment sequentials + sequential_modules = self.get_sequential_modules(modulestore, courselike_key) + + assignment_types = {assignment_type['type'] for assignment_type in courselike.grading_policy['GRADER']} + for sequential in sequential_modules: + sequential = self.serialize_chapter_sequential(sequential) + if sequential.format in assignment_types: + html_file_path = self.sequential_to_identifierref[sequential] + xml_file_path = self.sequential_to_identifierref[sequential] + for filename in export_fs.listdir(self.sequential_to_identifierref[sequential]): + if filename.endswith('.html'): + html_file_path = html_file_path + '/' + filename + if filename.endswith('xml'): + xml_file_path = xml_file_path + '/' + filename + resource = lxml.etree.SubElement(resources , 'resource', {'identifier': self.sequential_to_identifierref[sequential], 'type': type_string, 'href': html_file_path}) + lxml.etree.SubElement(resource, 'file', {'href': html_file_path}) + lxml.etree.SubElement(resource, 'file', {'href': xml_file_path}) # Additional last resource for the external tool xml external_tool_resource = lxml.etree.SubElement(resources, 'resource', {'identifier': self.external_tool_identifierref, 'type': 'imsbasiclti_xmlv1p0'}) @@ -498,47 +614,42 @@ def export_external_tool(self, export_fs): root = lxml.etree.Element( 'cartridge_basiclti_link', nsmap={ - None: "http://www.imsglobal.org/xsd/imslticc_v1p0", - 'blti': "http://www.imsglobal.org/xsd/imsbasiclti_v1p0", - 'lticm': "http://www.imsglobal.org/xsd/imslticm_v1p0", - 'lticp': "http://www.imsglobal.org/xsd/imslticp_v1p0", - 'xsi': "http://www.w3.org/2001/XMLSchema-instance" + None: 'http://www.imsglobal.org/xsd/imslticc_v1p0', + 'blti': 'http://www.imsglobal.org/xsd/imsbasiclti_v1p0', + 'lticm': 'http://www.imsglobal.org/xsd/imslticm_v1p0', + 'lticp': 'http://www.imsglobal.org/xsd/imslticp_v1p0', + 'xsi': 'http://www.w3.org/2001/XMLSchema-instance' } ) root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', - "http://www.imsglobal.org/xsd/imslticc_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticc_v1p0.xsd" - "http://www.imsglobal.org/xsd/imsbasiclti_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imsbasiclti_v1p0p1.xsd" - "http://www.imsglobal.org/xsd/imslticm_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticm_v1p0.xsd" - "http://www.imsglobal.org/xsd/imslticp_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticp_v1p0.xsd") + 'http://www.imsglobal.org/xsd/imslticc_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticc_v1p0.xsd' + 'http://www.imsglobal.org/xsd/imsbasiclti_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imsbasiclti_v1p0p1.xsd' + 'http://www.imsglobal.org/xsd/imslticm_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticm_v1p0.xsd' + 'http://www.imsglobal.org/xsd/imslticp_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticp_v1p0.xsd') # Basic metadata content - lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}title', nsmap={'blti': "http://www.imsglobal.org/xsd/imsbasiclti_v1p0"}).text = "EducateWorkforce (courses.educateworkforce.com)" - lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}description').text = "" - lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}secure_launch_url').text = "https://courses.educateworkforce.com/lti_provider/" + lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}title', nsmap={'blti': 'http://www.imsglobal.org/xsd/imsbasiclti_v1p0'}).text = 'EducateWorkforce (courses.educateworkforce.com)' + lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}description').text = '' + lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}secure_launch_url').text = 'https://courses.educateworkforce.com/lti_provider/' vendor = lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}vendor') - lxml.etree.SubElement(vendor, '{http://www.imsglobal.org/xsd/imslticp_v1p0}code').text = "unknown" - lxml.etree.SubElement(vendor, '{http://www.imsglobal.org/xsd/imslticp_v1p0}name').text = "unknown" + lxml.etree.SubElement(vendor, '{http://www.imsglobal.org/xsd/imslticp_v1p0}code').text = 'unknown' + lxml.etree.SubElement(vendor, '{http://www.imsglobal.org/xsd/imslticp_v1p0}name').text = 'unknown' lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}custom') - extensions = lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}extensions', platform="canvas.instructure.com") - lxml.etree.SubElement(extensions, '{http://www.imsglobal.org/xsd/imslticm_v1p0}property', name="privacy_level").text = "public" - lxml.etree.SubElement(extensions, '{http://www.imsglobal.org/xsd/imslticm_v1p0}property', name="domain").text = "courses.educateworkforce.com" - lxml.etree.SubElement(extensions, '{http://www.imsglobal.org/xsd/imslticm_v1p0}property', name="lti_version").text = "1.1" + extensions = lxml.etree.SubElement(root, '{http://www.imsglobal.org/xsd/imsbasiclti_v1p0}extensions', platform='canvas.instructure.com') + lxml.etree.SubElement(extensions, '{http://www.imsglobal.org/xsd/imslticm_v1p0}property', name='privacy_level').text = 'public' + lxml.etree.SubElement(extensions, '{http://www.imsglobal.org/xsd/imslticm_v1p0}property', name='domain').text = 'courses.educateworkforce.com' + lxml.etree.SubElement(extensions, '{http://www.imsglobal.org/xsd/imslticm_v1p0}property', name='lti_version').text = '1.1' with export_fs.open(self.external_tool_identifierref + '.xml', 'wb') as external_tool_identifierref_xml: tree = lxml.etree.ElementTree(root) tree.write(external_tool_identifierref_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) - def export_module_meta_xml(self, modulestore, courselike_key, courselike, export_fs): + def export_module_meta_xml(self, modulestore, courselike_keys, courselikes, export_fs): """ Exports the module_meta.xml file in course_settings """ - # Get all the chapter and sequential modules to appear under the modules page - chapter_sequential_modules = self.get_chapter_sequential_modules(modulestore, courselike_key) - # Parse out assignments (assignments are sequentials with a 'format' in the grading policy) - assignment_types = {assignment_type['type'] for assignment_type in courselike.grading_policy['GRADER']} - # Create the root element root = lxml.etree.Element( 'modules', @@ -550,81 +661,87 @@ def export_module_meta_xml(self, modulestore, courselike_key, courselike, export root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') - - # Single module is created here, need to iterate - module = lxml.etree.SubElement(root, 'module', {'identifier': self.module_identifier}) - lxml.etree.SubElement(module, 'title').text = (self.get_key()).course - lxml.etree.SubElement(module, 'workflow_state').text = 'active' - - items = lxml.etree.SubElement(module, 'items') - - # Iterate through chapter_sequential_modules and assign their type as they would appear in the modules page - # Example types: Header that just has text, external tool, assignment, etc. - for chapter_sequential in chapter_sequential_modules: - if chapter_sequential.format in assignment_types: - item = lxml.etree.SubElement(items, 'item', {'identifier': self.sequential_to_identifier[chapter_sequential]}) - lxml.etree.SubElement(item, 'content_type').text = 'Assignment' - lxml.etree.SubElement(item, 'title').text = chapter_sequential.display_name - lxml.etree.SubElement(item, 'workflow_state').text= 'active' - lxml.etree.SubElement(item, 'identifierref').text = self.sequential_to_identifierref[chapter_sequential] - elif chapter_sequential.category == 'sequential': - item = lxml.etree.SubElement(items, 'item', {'identifier': self.sequential_to_identifierref[chapter_sequential]}) - lxml.etree.SubElement(item, 'content_type').text = 'ContextExternalTool' - lxml.etree.SubElement(item, 'title').text = chapter_sequential.display_name - lxml.etree.SubElement(item, 'workflow_state').text= 'active' - lxml.etree.SubElement(item, 'identifierref').text = self.external_tool_identifierref - lti_link = 'https://courses.educateworkforce.com/lti_provider/courses/' + str(courselike_key) + "/" + (str(courselike_key)).replace('course', 'block') + '+type@sequential+block@' + chapter_sequential.url_name - lxml.etree.SubElement(item, 'url').text = lti_link - else: - item = lxml.etree.SubElement(items, 'item', {'identifier': self.chapter_to_identifier[chapter_sequential]}) - lxml.etree.SubElement(item, 'content_type').text = 'ContextModuleSubHeader' - lxml.etree.SubElement(item, 'title').text = chapter_sequential.display_name - lxml.etree.SubElement(item, 'workflow_state').text= 'active' + + # Iterate through all the courselikes keys + for courselike_key, courselike in zip(courselike_keys, courselikes): + # Bulk operations and only operate on published content + with self.modulestore.bulk_operations(courselike_key): + with self.modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, courselike_key): + # Get all the chapter and sequential modules to appear under the modules page + chapter_sequential_modules = self.get_chapter_sequential_modules(modulestore, courselike_key) + # Parse out assignments (assignments are sequentials with a 'format' in the grading policy) + assignment_types = {assignment_type['type'] for assignment_type in courselike.grading_policy['GRADER']} + + module = lxml.etree.SubElement(root, 'module', {'identifier': self.module_identifiers[courselike_key]}) + lxml.etree.SubElement(module, 'title').text = (self.get_courselike(courselike_key)).display_name + ' ' + (self.get_key(courselike_key)).course + lxml.etree.SubElement(module, 'workflow_state').text = 'active' + + items = lxml.etree.SubElement(module, 'items') + + # Course abbreviation to append to the start of the module names + course_abbreviation = self.get_course_abbreviation(courselike_key) + + # Iterate through chapter_sequential_modules and assign their type as they would appear in the modules page + # Example types: Header that just has text, external tool, assignment, etc. + for chapter_sequential in chapter_sequential_modules: + chapter_sequential = self.serialize_chapter_sequential(chapter_sequential) + if chapter_sequential.format in assignment_types: + item = lxml.etree.SubElement(items, 'item', {'identifier': self.sequential_to_identifier[chapter_sequential]}) + lxml.etree.SubElement(item, 'content_type').text = 'Assignment' + lxml.etree.SubElement(item, 'title').text = course_abbreviation + chapter_sequential.display_name + lxml.etree.SubElement(item, 'workflow_state').text= 'active' + lxml.etree.SubElement(item, 'identifierref').text = self.sequential_to_identifierref[chapter_sequential] + elif chapter_sequential.category == 'sequential': + item = lxml.etree.SubElement(items, 'item', {'identifier': self.sequential_to_identifierref[chapter_sequential]}) + lxml.etree.SubElement(item, 'content_type').text = 'ContextExternalTool' + lxml.etree.SubElement(item, 'title').text = course_abbreviation + chapter_sequential.display_name + lxml.etree.SubElement(item, 'workflow_state').text= 'active' + lxml.etree.SubElement(item, 'identifierref').text = self.external_tool_identifierref + lti_link = 'https://courses.educateworkforce.com/lti_provider/courses/' + str(courselike_key) + "/" + (str(courselike_key)).replace('course', 'block') + '+type@sequential+block@' + chapter_sequential.url_name + lxml.etree.SubElement(item, 'url').text = lti_link + else: + item = lxml.etree.SubElement(items, 'item', {'identifier': self.chapter_to_identifier[chapter_sequential]}) + lxml.etree.SubElement(item, 'content_type').text = 'ContextModuleSubHeader' + lxml.etree.SubElement(item, 'title').text = chapter_sequential.display_name + lxml.etree.SubElement(item, 'workflow_state').text= 'active' # Write to file with export_fs.open('course_settings/module_meta.xml', 'wb') as module_meta_xml: tree = lxml.etree.ElementTree(root) tree.write(module_meta_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) - def export_all_course_settings(self, modulestore, courselike_key, courselike, export_fs): + def export_all_course_settings(self, modulestore, courselike_keys, courselikes, export_fs): """ Function to export all course_settings at once """ export_fs.makedirs('course_settings', recreate=True) - self.export_assignment_groups(modulestore, courselike, export_fs) - # Write assignment_groups to a file - with export_fs.open('course_settings/assignment_groups.xml', 'wb') as assignment_groups_xml: - tree = lxml.etree.ElementTree(self.assignment_groups_root) - tree.write(assignment_groups_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) - + self.export_assignment_groups(modulestore, courselikes, export_fs) self.export_media_tracks(export_fs) - self.export_files_meta(export_fs)ß - self.export_course_settings(modulestore, courselike_key, export_fs) + self.export_files_meta(export_fs) + self.export_course_settings(modulestore, courselike_keys, export_fs) def export(self): """ Perform the export given the parameters handed to this class at init. """ - with self.modulestore.bulk_operations(self.courselike_key): - - fsm = OSFS(self.root_dir) - root = lxml.etree.Element('unknown') - - # export only the published content - with self.modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, self.courselike_key): + # Create a list of all the different courselikes + courselikes = [] + for courselike_key in self.courselike_keys: + courselikes.append(self.get_courselike(courselike_key)) + + fsm = OSFS(self.root_dir) - # stores metadata for the course - courselike = self.get_courselike() + # Make the directory to export to + export_fs = fsm.makedir(self.target_dir, recreate=True) + + # Call export functions + self.export_external_tool(export_fs) + self.export_all_course_settings(self.modulestore, self.courselike_keys, courselikes, export_fs) + self.export_assignment_folders(self.modulestore, self.courselike_keys, courselikes, export_fs) + self.export_imsmanifest_xml(self.modulestore, self.courselike_keys, courselikes, export_fs) + self.export_module_meta_xml(self.modulestore, self.courselike_keys, courselikes, export_fs) - # make the directory to export to - export_fs = courselike.runtime.export_fs = fsm.makedir(self.target_dir, recreate=True) - # Call export functions - self.export_external_tool(export_fs) - self.export_all_course_settings(self.modulestore, self.courselike_key, courselike, export_fs) - self.export_assignment_folders(self.modulestore, self.courselike_key, courselike, export_fs) - self.write_imsmanifest_xml(self.modulestore, self.courselike_key, courselike, export_fs) - self.export_module_meta_xml(self.modulestore, self.courselike_key, courselike, export_fs) """ Function "export_course_to_imscc" below get called by the django management comman from export_olx.py From 2f0367dc46de0e0f7ac98d75447416ccd1dad702 Mon Sep 17 00:00:00 2001 From: Kenny Sun Date: Wed, 13 Nov 2024 16:52:02 -0500 Subject: [PATCH 11/14] feat: added external-tool-only flag to only contain external tools and no assignments --- .../management/commands/export_olx.py | 14 +- .../xmodule/modulestore/imscc_exporter.py | 135 +++++++++++------- 2 files changed, 89 insertions(+), 60 deletions(-) diff --git a/cms/djangoapps/contentstore/management/commands/export_olx.py b/cms/djangoapps/contentstore/management/commands/export_olx.py index 6125d1bfc208..97bfd06bcf72 100644 --- a/cms/djangoapps/contentstore/management/commands/export_olx.py +++ b/cms/djangoapps/contentstore/management/commands/export_olx.py @@ -43,14 +43,18 @@ def add_arguments(self, parser): parser.add_argument('course_id', nargs="+") #nargs = "+" allows parsing of unlimited course ids parser.add_argument('--output') parser.add_argument('--cc-lti', action = 'store_true', help = 'Run the command with Common Cartridge format') + parser.add_argument('--external-tool-only', action = 'store_true', help = 'Export Common Cartridge file using only external tools and no assignment types') def handle(self, *args, **options): cc_lti = options.get('cc_lti', False) + external_tool_only = options.get('external_tool_only', False) course_ids = options['course_id'] # Raise an error only allowing courses to be exported 1 at a time when not using Common Cartridge packaging standards if not cc_lti and len(course_ids) > 1: raise CommandError("Can only export 1 OpenEdX course at at time in default OpenEdX packaging standards") + if external_tool_only and not cc_lti: + raise CommandError("Cannot export with the --external_tool_only option in default OpenEdX packaging standards") # stores all the different course keys based on the inputted course ids course_keys = [] @@ -69,7 +73,7 @@ def handle(self, *args, **options): filename = mktemp() pipe_results = True - export_course_to_tarfile(course_keys, filename, cc_lti) + export_course_to_tarfile(course_keys, filename, cc_lti, external_tool_only) results = self._get_results(filename) if pipe_results else b'' @@ -89,17 +93,17 @@ def _get_results(self, filename): return results -def export_course_to_tarfile(course_keys, filename, cc_lti): +def export_course_to_tarfile(course_keys, filename, cc_lti, external_tool_only): """Exports a course into a tar.gz file""" tmp_dir = mkdtemp() try: - course_dir = export_course_to_directory(course_keys, tmp_dir, cc_lti) + course_dir = export_course_to_directory(course_keys, tmp_dir, cc_lti, external_tool_only) compress_directory(course_dir, filename) finally: shutil.rmtree(tmp_dir, ignore_errors=True) -def export_course_to_directory(course_keys, root_dir, cc_lti): +def export_course_to_directory(course_keys, root_dir, cc_lti, external_tool_only): """Export course into a directory""" # attempt to get all the courses based on the course_keys store = modulestore() @@ -123,7 +127,7 @@ def export_course_to_directory(course_keys, root_dir, cc_lti): if cc_lti: if len(courses) > 1: course_dir = "MULTI-COURSE-EXPORT" - export_course_to_imscc(store, None, course_ids, root_dir, course_dir) + export_course_to_imscc(store, None, course_ids, root_dir, course_dir, external_tool_only) else: export_course_to_xml(store, None, course_ids[0], root_dir, course_dir) diff --git a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py index 64ce681846a0..fd554de6e579 100644 --- a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py @@ -104,21 +104,23 @@ class TestExportManager: """ Manages IMSCC exporting for courselike objects. """ - def __init__(self, modulestore, contentstore, courselike_keys, root_dir, target_dir): + def __init__(self, modulestore, contentstore, courselike_keys, root_dir, target_dir, external_tool_only): """ Export all modules from `modulestore` and content from `contentstore` as xml to `root_dir`. `modulestore`: A `ModuleStore` object that is the source of the modules to export `contentstore`: A `ContentStore` object that is the source of the content to export, can be None - `courselike_key`: The Locator of the Descriptor to export + `courselike_keys`: The Locators of the Descriptor to export `root_dir`: The directory to write the exported xml to `target_dir`: The name of the directory inside `root_dir` to write the content to + 'external_tool_only': Option to export the course without any assignments and have everything be set as an external tool """ self.modulestore = modulestore self.contentstore = contentstore self.courselike_keys = courselike_keys self.root_dir = root_dir self.target_dir = str(target_dir) + self.external_tool_only = external_tool_only """ Sets up some information to share between export functions @@ -483,7 +485,7 @@ def export_assignment_folders(self, modulestore, courselike_keys, courselikes, e html_file.write(sequential.display_name) html_file.write(html_content_pt2) - def export_imsmanifest_xml(self, modulestore, courselike_keys, courselikes, export_fs): + def export_imsmanifest_xml(self, modulestore, courselike_keys, courselikes, export_fs, external_tool_only): """ Exports the imsmanifest.xml file """ @@ -577,28 +579,30 @@ def export_imsmanifest_xml(self, modulestore, courselike_keys, courselikes, expo for filename in export_fs.listdir(course_settings_path): lxml.etree.SubElement(course_settings_resource, 'file', {'href': 'course_settings/' + filename}) - # Iterate through all the courselike keys and courselikes - for courselike_key, courselike in zip(courselike_keys, courselikes): - # Bulk operations and only operate on published content - with self.modulestore.bulk_operations(courselike_key): - with self.modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, courselike_key): - # Create resources for assignment sequentials - sequential_modules = self.get_sequential_modules(modulestore, courselike_key) - - assignment_types = {assignment_type['type'] for assignment_type in courselike.grading_policy['GRADER']} - for sequential in sequential_modules: - sequential = self.serialize_chapter_sequential(sequential) - if sequential.format in assignment_types: - html_file_path = self.sequential_to_identifierref[sequential] - xml_file_path = self.sequential_to_identifierref[sequential] - for filename in export_fs.listdir(self.sequential_to_identifierref[sequential]): - if filename.endswith('.html'): - html_file_path = html_file_path + '/' + filename - if filename.endswith('xml'): - xml_file_path = xml_file_path + '/' + filename - resource = lxml.etree.SubElement(resources , 'resource', {'identifier': self.sequential_to_identifierref[sequential], 'type': type_string, 'href': html_file_path}) - lxml.etree.SubElement(resource, 'file', {'href': html_file_path}) - lxml.etree.SubElement(resource, 'file', {'href': xml_file_path}) + # Only export assignment resources if we're not doing external_tool_only + if not external_tool_only: + # Iterate through all the courselike keys and courselikes + for courselike_key, courselike in zip(courselike_keys, courselikes): + # Bulk operations and only operate on published content + with self.modulestore.bulk_operations(courselike_key): + with self.modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, courselike_key): + # Create resources for assignment sequentials + sequential_modules = self.get_sequential_modules(modulestore, courselike_key) + + assignment_types = {assignment_type['type'] for assignment_type in courselike.grading_policy['GRADER']} + for sequential in sequential_modules: + sequential = self.serialize_chapter_sequential(sequential) + if sequential.format in assignment_types: + html_file_path = self.sequential_to_identifierref[sequential] + xml_file_path = self.sequential_to_identifierref[sequential] + for filename in export_fs.listdir(self.sequential_to_identifierref[sequential]): + if filename.endswith('.html'): + html_file_path = html_file_path + '/' + filename + if filename.endswith('xml'): + xml_file_path = xml_file_path + '/' + filename + resource = lxml.etree.SubElement(resources , 'resource', {'identifier': self.sequential_to_identifierref[sequential], 'type': type_string, 'href': html_file_path}) + lxml.etree.SubElement(resource, 'file', {'href': html_file_path}) + lxml.etree.SubElement(resource, 'file', {'href': xml_file_path}) # Additional last resource for the external tool xml external_tool_resource = lxml.etree.SubElement(resources, 'resource', {'identifier': self.external_tool_identifierref, 'type': 'imsbasiclti_xmlv1p0'}) @@ -645,7 +649,7 @@ def export_external_tool(self, export_fs): tree = lxml.etree.ElementTree(root) tree.write(external_tool_identifierref_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) - def export_module_meta_xml(self, modulestore, courselike_keys, courselikes, export_fs): + def export_module_meta_xml(self, modulestore, courselike_keys, courselikes, export_fs, external_tool_only): """ Exports the module_meta.xml file in course_settings """ @@ -683,39 +687,59 @@ def export_module_meta_xml(self, modulestore, courselike_keys, courselikes, expo # Iterate through chapter_sequential_modules and assign their type as they would appear in the modules page # Example types: Header that just has text, external tool, assignment, etc. - for chapter_sequential in chapter_sequential_modules: - chapter_sequential = self.serialize_chapter_sequential(chapter_sequential) - if chapter_sequential.format in assignment_types: - item = lxml.etree.SubElement(items, 'item', {'identifier': self.sequential_to_identifier[chapter_sequential]}) - lxml.etree.SubElement(item, 'content_type').text = 'Assignment' - lxml.etree.SubElement(item, 'title').text = course_abbreviation + chapter_sequential.display_name - lxml.etree.SubElement(item, 'workflow_state').text= 'active' - lxml.etree.SubElement(item, 'identifierref').text = self.sequential_to_identifierref[chapter_sequential] - elif chapter_sequential.category == 'sequential': - item = lxml.etree.SubElement(items, 'item', {'identifier': self.sequential_to_identifierref[chapter_sequential]}) - lxml.etree.SubElement(item, 'content_type').text = 'ContextExternalTool' - lxml.etree.SubElement(item, 'title').text = course_abbreviation + chapter_sequential.display_name - lxml.etree.SubElement(item, 'workflow_state').text= 'active' - lxml.etree.SubElement(item, 'identifierref').text = self.external_tool_identifierref - lti_link = 'https://courses.educateworkforce.com/lti_provider/courses/' + str(courselike_key) + "/" + (str(courselike_key)).replace('course', 'block') + '+type@sequential+block@' + chapter_sequential.url_name - lxml.etree.SubElement(item, 'url').text = lti_link - else: - item = lxml.etree.SubElement(items, 'item', {'identifier': self.chapter_to_identifier[chapter_sequential]}) - lxml.etree.SubElement(item, 'content_type').text = 'ContextModuleSubHeader' - lxml.etree.SubElement(item, 'title').text = chapter_sequential.display_name - lxml.etree.SubElement(item, 'workflow_state').text= 'active' + + # Check for external_tool_only + if not external_tool_only: + for chapter_sequential in chapter_sequential_modules: + chapter_sequential = self.serialize_chapter_sequential(chapter_sequential) + if chapter_sequential.format in assignment_types: + item = lxml.etree.SubElement(items, 'item', {'identifier': self.sequential_to_identifier[chapter_sequential]}) + lxml.etree.SubElement(item, 'content_type').text = 'Assignment' + lxml.etree.SubElement(item, 'title').text = course_abbreviation + chapter_sequential.display_name + lxml.etree.SubElement(item, 'workflow_state').text= 'active' + lxml.etree.SubElement(item, 'identifierref').text = self.sequential_to_identifierref[chapter_sequential] + elif chapter_sequential.category == 'sequential': + item = lxml.etree.SubElement(items, 'item', {'identifier': self.sequential_to_identifierref[chapter_sequential]}) + lxml.etree.SubElement(item, 'content_type').text = 'ContextExternalTool' + lxml.etree.SubElement(item, 'title').text = course_abbreviation + chapter_sequential.display_name + lxml.etree.SubElement(item, 'workflow_state').text= 'active' + lxml.etree.SubElement(item, 'identifierref').text = self.external_tool_identifierref + lti_link = 'https://courses.educateworkforce.com/lti_provider/courses/' + str(courselike_key) + "/" + (str(courselike_key)).replace('course', 'block') + '+type@sequential+block@' + chapter_sequential.url_name + lxml.etree.SubElement(item, 'url').text = lti_link + else: + item = lxml.etree.SubElement(items, 'item', {'identifier': self.chapter_to_identifier[chapter_sequential]}) + lxml.etree.SubElement(item, 'content_type').text = 'ContextModuleSubHeader' + lxml.etree.SubElement(item, 'title').text = chapter_sequential.display_name + lxml.etree.SubElement(item, 'workflow_state').text= 'active' + else: + for chapter_sequential in chapter_sequential_modules: + chapter_sequential = self.serialize_chapter_sequential(chapter_sequential) + if chapter_sequential.category == 'sequential': + item = lxml.etree.SubElement(items, 'item', {'identifier': self.sequential_to_identifierref[chapter_sequential]}) + lxml.etree.SubElement(item, 'content_type').text = 'ContextExternalTool' + lxml.etree.SubElement(item, 'title').text = course_abbreviation + chapter_sequential.display_name + lxml.etree.SubElement(item, 'workflow_state').text= 'active' + lxml.etree.SubElement(item, 'identifierref').text = self.external_tool_identifierref + lti_link = 'https://courses.educateworkforce.com/lti_provider/courses/' + str(courselike_key) + "/" + (str(courselike_key)).replace('course', 'block') + '+type@sequential+block@' + chapter_sequential.url_name + lxml.etree.SubElement(item, 'url').text = lti_link + else: + item = lxml.etree.SubElement(items, 'item', {'identifier': self.chapter_to_identifier[chapter_sequential]}) + lxml.etree.SubElement(item, 'content_type').text = 'ContextModuleSubHeader' + lxml.etree.SubElement(item, 'title').text = chapter_sequential.display_name + lxml.etree.SubElement(item, 'workflow_state').text= 'active' # Write to file with export_fs.open('course_settings/module_meta.xml', 'wb') as module_meta_xml: tree = lxml.etree.ElementTree(root) tree.write(module_meta_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) - def export_all_course_settings(self, modulestore, courselike_keys, courselikes, export_fs): + def export_all_course_settings(self, modulestore, courselike_keys, courselikes, export_fs, external_tool_only): """ Function to export all course_settings at once """ export_fs.makedirs('course_settings', recreate=True) - self.export_assignment_groups(modulestore, courselikes, export_fs) + if not external_tool_only: + self.export_assignment_groups(modulestore, courselikes, export_fs) self.export_media_tracks(export_fs) self.export_files_meta(export_fs) self.export_course_settings(modulestore, courselike_keys, export_fs) @@ -736,10 +760,11 @@ def export(self): # Call export functions self.export_external_tool(export_fs) - self.export_all_course_settings(self.modulestore, self.courselike_keys, courselikes, export_fs) - self.export_assignment_folders(self.modulestore, self.courselike_keys, courselikes, export_fs) - self.export_imsmanifest_xml(self.modulestore, self.courselike_keys, courselikes, export_fs) - self.export_module_meta_xml(self.modulestore, self.courselike_keys, courselikes, export_fs) + self.export_all_course_settings(self.modulestore, self.courselike_keys, courselikes, export_fs, self.external_tool_only) + if not self.external_tool_only: + self.export_assignment_folders(self.modulestore, self.courselike_keys, courselikes, export_fs) + self.export_imsmanifest_xml(self.modulestore, self.courselike_keys, courselikes, export_fs, self.external_tool_only) + self.export_module_meta_xml(self.modulestore, self.courselike_keys, courselikes, export_fs, self.external_tool_only) @@ -747,8 +772,8 @@ def export(self): Function "export_course_to_imscc" below get called by the django management comman from export_olx.py """ -def export_course_to_imscc(modulestore, contentstore, course_key, root_dir, course_dir): +def export_course_to_imscc(modulestore, contentstore, course_key, root_dir, course_dir, external_tool_only): """ Thin wrapper for the Export Manager. See ExportManager for details. """ - TestExportManager(modulestore, contentstore, course_key, root_dir, course_dir).export() \ No newline at end of file + TestExportManager(modulestore, contentstore, course_key, root_dir, course_dir, external_tool_only).export() \ No newline at end of file From f487286e6886595950b875236fc5006cc5eb1638 Mon Sep 17 00:00:00 2001 From: Kenny Sun Date: Fri, 22 Nov 2024 12:58:11 -0500 Subject: [PATCH 12/14] feat: added point totals to the common cartridge export Need to check if this system also works with qualtrics surveys Update: Works with qualtrics surveys --- .../xmodule/modulestore/imscc_exporter.py | 95 +++++++++++-------- 1 file changed, 53 insertions(+), 42 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py index fd554de6e579..66453bf8cac3 100644 --- a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py @@ -29,37 +29,6 @@ DEFAULT_CONTENT_FIELDS = ['metadata', 'data'] -# Returns an essentially 'unique' uuid for identifying and linking data up -def create_uuid(): - """ - Returns an essentially unique identifier following canvas's default format - """ - return 'g' + (str(uuid.uuid4())).replace('-', '') - -########## THIS FUNCTION DOESN'T WORK ###################################### -########## ALL IT DOES IS RETURNS ZERO, NEED TO FIX TO GET ACTUAL SCORE #### -# def get_total_score(sequential): -# total_score = 0.0 - -# # Get the direct children of the sequential -# children = sequential.get_children() - -# for child in children: -# try: -# # Call get_max_score() on the individual child -# score = child.get_max_score() # Change this line to use child -# total_score += score -# except Exception as e: -# pass # Handle the exception if necessary - -# # Recursively get the score from the child's children -# total_score += get_total_score(child) - -# if total_score != 0: -# print(total_score) -# return total_score - - class SerializableChapterSequential: """ A serialized chapter or sequential object which allows for access of only the important attributes @@ -171,21 +140,21 @@ def __init__(self, modulestore, contentstore, courselike_keys, root_dir, target_ sequential_modules = self.get_sequential_modules(self.modulestore, courselike_key) for sequential in sequential_modules: sequential = self.serialize_chapter_sequential(sequential) - self.sequential_to_identifier[sequential] = create_uuid() - self.sequential_to_identifierref[sequential] = create_uuid() + self.sequential_to_identifier[sequential] = self.create_uuid() + self.sequential_to_identifierref[sequential] = self.create_uuid() for courselike_key in self.courselike_keys: chapter_modules = self.get_chapter_modules(self.modulestore, courselike_key) for chapter in chapter_modules: chapter = self.serialize_chapter_sequential(chapter) - self.chapter_to_identifier[chapter] = create_uuid() + self.chapter_to_identifier[chapter] = self.create_uuid() for courselike_key in self.courselike_keys: - self.module_identifiers[courselike_key] = create_uuid() + self.module_identifiers[courselike_key] = self.create_uuid() self.assignment_group_to_identifier = {} - self.external_tool_identifierref = create_uuid() - self.course_settings_identifier = create_uuid() + self.external_tool_identifierref = self.create_uuid() + self.course_settings_identifier = self.create_uuid() def get_key(self, courselike_key): """ @@ -287,6 +256,46 @@ def get_course_abbreviation(self, courselike_key): else: return '' + def get_total_score(self, sequential): + """ + Returns the total amount of points that can be earned for an assignment sequential + """ + total_score = 0.0 + + # Get the direct children of the sequential + children = sequential.get_children() + + # Recursively iterate through the children to get access to the max_score of individual problem components + for child in children: + # This try statement will attempt to access max_count, a variable of quiz types with a bank of x amount of questions + # These quizzes randomly choose max_count number of questions to display from the bank + # Unable to recurse through the quizzes because otherwise, the total_score returned will include every single question in the bank, + # not just how many are displayed to the student + + try: + max_count = child.max_count + # If max_count is 0 or set to None, keep recursing through it's children + if max_count == 0 or max_count == None: + raise Exception() + total_score += max_count + except Exception as e: + # Attempt to grab max_score value + try: + score = child.max_score() + total_score += score + except Exception as e: + pass + # Only recursively get the score if no max_score or max_count is present + total_score += self.get_total_score(child) + + return total_score + + def create_uuid(self): + """ + Returns an essentially unique identifier following canvas's default format + """ + return 'g' + (str(uuid.uuid4())).replace('-', '') + def export_assignment_groups(self, modulestore, courselikes, export_fs): """ Exports the 'assignment_groups.xml' file in course_settings @@ -312,7 +321,7 @@ def export_assignment_groups(self, modulestore, courselikes, export_fs): grade_name = grade['type'] grade_weight = grade['weight'] * 100 # openedx uses 0-1 grading weight, imscc uses 0-100 if grade_name not in self.assignment_group_to_identifier: - self.assignment_group_to_identifier[grade_name] = create_uuid() + self.assignment_group_to_identifier[grade_name] = self.create_uuid() assignment_group = lxml.etree.SubElement(root, 'assignmentGroup', {'identifier': str(self.assignment_group_to_identifier[grade_name])}) lxml.etree.SubElement(assignment_group, 'title').text = 'EW - ' + grade_name lxml.etree.SubElement(assignment_group, 'group_weight').text = str(grade_weight) @@ -422,6 +431,8 @@ def export_assignment_folders(self, modulestore, courselike_keys, courselikes, e course_abbreviation = self.get_course_abbreviation(courselike_key) for sequential in only_assignments: + # Store a non serialized version of the sequential in order to get access to its children for points_possible + non_serialized_sequential = sequential sequential = self.serialize_chapter_sequential(sequential) # Create root root = lxml.etree.Element( @@ -441,9 +452,9 @@ def export_assignment_folders(self, modulestore, courselike_keys, courselikes, e lxml.etree.SubElement(root, 'title').text = course_abbreviation + sequential.display_name lxml.etree.SubElement(root, 'assignment_group_identifierref').text = str(self.assignment_group_to_identifier[sequential.format]) - ###### NEED TO FIX ####### - lxml.etree.SubElement(root, 'points_possible').text = '0'#str(get_total_score(sequential)) - ########################## + # Adds the maximum number of points to the assignment information + lxml.etree.SubElement(root, 'points_possible').text = str(self.get_total_score(non_serialized_sequential)) + lxml.etree.SubElement(root, 'grading_type').text = 'points' lxml.etree.SubElement(root, 'submission_types').text = 'external_tool' lxml.etree.SubElement(root, 'external_tool_identifierref').text = self.external_tool_identifierref @@ -496,7 +507,7 @@ def export_imsmanifest_xml(self, modulestore, courselike_keys, courselikes, expo root = lxml.etree.Element( 'manifest', { - 'identifier': create_uuid() + 'identifier': self.create_uuid() }, nsmap={ None: 'http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1', From 7975d0d5f41d7a9060bf557ec3d683c28ecf6136 Mon Sep 17 00:00:00 2001 From: Kenny Sun Date: Thu, 5 Dec 2024 16:18:13 -0500 Subject: [PATCH 13/14] feat: Cleaned up imports, reverted export_olx to original state, created new export_imscc file --- .../management/commands/export_imscc.py | 121 ++++++++++++++++++ .../management/commands/export_olx.py | 65 +++------- .../xmodule/modulestore/imscc_exporter.py | 25 +--- 3 files changed, 144 insertions(+), 67 deletions(-) create mode 100644 cms/djangoapps/contentstore/management/commands/export_imscc.py diff --git a/cms/djangoapps/contentstore/management/commands/export_imscc.py b/cms/djangoapps/contentstore/management/commands/export_imscc.py new file mode 100644 index 000000000000..23ca4a829bfe --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/export_imscc.py @@ -0,0 +1,121 @@ +""" +A Django command that exports a course to a tar.gz file using IMSCC protocol + +At present, it differs from Studio exports in several ways: + +* It does not include static content. +* It only supports the export of courses. It does not export libraries. +""" + +import os +import re +import shutil +import tarfile +from tempfile import mkdtemp, mktemp +from textwrap import dedent + +from django.core.management.base import BaseCommand, CommandError +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from path import Path as path + +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.imscc_exporter import export_course_to_imscc + +class Command(BaseCommand): + """ + Export a course to IMSCC. The output is compressed as a tar.gz file. + """ + help = dedent(__doc__).strip() + + def add_arguments(self, parser): + parser.add_argument('course_id', nargs="+") #nargs = "+" allows parsing of unlimited course ids + parser.add_argument('--output') + parser.add_argument('--external-tool-only', action = 'store_true', help = 'Export Common Cartridge file using only external tools and no assignment types') + + def handle(self, *args, **options): + external_tool_only = options.get('external_tool_only', False) + course_ids = options['course_id'] + + # stores all the different course keys based on the inputted course ids + course_keys = [] + for course_id in course_ids: + try: + course_keys.append(CourseKey.from_string(course_id)) + except InvalidKeyError: + raise CommandError("Unparsable course_id") # lint-amnesty, pylint: disable=raise-missing-from + except IndexError: + raise CommandError("Insufficient arguments") # lint-amnesty, pylint: disable=raise-missing-from + + filename = options['output'] + pipe_results = False + + if filename is None: + filename = mktemp() + pipe_results = True + + export_course_to_tarfile(course_keys, filename, external_tool_only) + + results = self._get_results(filename) if pipe_results else b'' + + # results is of type bytes, so we must write the underlying buffer directly. + self.stdout.buffer.write(results) + + def _get_results(self, filename): + """ + Load results from file. + + Returns: + bytes: bytestring of file contents. + """ + with open(filename, 'rb') as f: + results = f.read() + os.remove(filename) + return results + + +def export_course_to_tarfile(course_keys, filename, external_tool_only): + """Exports a course into a tar.gz file""" + tmp_dir = mkdtemp() + try: + course_dir = export_course_to_directory(course_keys, tmp_dir, external_tool_only) + compress_directory(course_dir, filename) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + +def export_course_to_directory(course_keys, root_dir, external_tool_only): + """Export course into a directory""" + # attempt to get all the courses based on the course_keys + store = modulestore() + courses = [] + for course_key in course_keys: + course = store.get_course(course_key) + if course is None: + raise CommandError("Invalid course_id") + courses.append(course) + + course_ids = [] + for course in courses: + course_ids.append(course.id) + # The safest characters are A-Z, a-z, 0-9, , and . + # We represent the first four with \w. + # TODO: Once we support courses with unicode characters, we will need to revisit this. + replacement_char = '-' + course_dir = replacement_char.join([courses[0].id.org, courses[0].id.course, courses[0].id.run]) + course_dir = re.sub(r'[^\w\.\-]', replacement_char, course_dir) + + if len(courses) > 1: + course_dir = "MULTI-COURSE-EXPORT" + export_course_to_imscc(store, None, course_ids, root_dir, course_dir, external_tool_only) + + export_dir = path(root_dir) / course_dir + return export_dir + + +def compress_directory(directory, filename): + """Compress a directory into a tar.gz file""" + mode = 'w:gz' + name = path(directory).name + with tarfile.open(filename, mode) as tar_file: + tar_file.add(directory, arcname=name) diff --git a/cms/djangoapps/contentstore/management/commands/export_olx.py b/cms/djangoapps/contentstore/management/commands/export_olx.py index 97bfd06bcf72..7561ebdc9b48 100644 --- a/cms/djangoapps/contentstore/management/commands/export_olx.py +++ b/cms/djangoapps/contentstore/management/commands/export_olx.py @@ -29,8 +29,6 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.xml_exporter import export_course_to_xml -from xmodule.modulestore.imscc_exporter import export_course_to_imscc - class Command(BaseCommand): @@ -40,31 +38,18 @@ class Command(BaseCommand): help = dedent(__doc__).strip() def add_arguments(self, parser): - parser.add_argument('course_id', nargs="+") #nargs = "+" allows parsing of unlimited course ids + parser.add_argument('course_id') parser.add_argument('--output') - parser.add_argument('--cc-lti', action = 'store_true', help = 'Run the command with Common Cartridge format') - parser.add_argument('--external-tool-only', action = 'store_true', help = 'Export Common Cartridge file using only external tools and no assignment types') def handle(self, *args, **options): - cc_lti = options.get('cc_lti', False) - external_tool_only = options.get('external_tool_only', False) - course_ids = options['course_id'] - - # Raise an error only allowing courses to be exported 1 at a time when not using Common Cartridge packaging standards - if not cc_lti and len(course_ids) > 1: - raise CommandError("Can only export 1 OpenEdX course at at time in default OpenEdX packaging standards") - if external_tool_only and not cc_lti: - raise CommandError("Cannot export with the --external_tool_only option in default OpenEdX packaging standards") - - # stores all the different course keys based on the inputted course ids - course_keys = [] - for course_id in course_ids: - try: - course_keys.append(CourseKey.from_string(course_id)) - except InvalidKeyError: - raise CommandError("Unparsable course_id") # lint-amnesty, pylint: disable=raise-missing-from - except IndexError: - raise CommandError("Insufficient arguments") # lint-amnesty, pylint: disable=raise-missing-from + course_id = options['course_id'] + + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError: + raise CommandError("Unparsable course_id") # lint-amnesty, pylint: disable=raise-missing-from + except IndexError: + raise CommandError("Insufficient arguments") # lint-amnesty, pylint: disable=raise-missing-from filename = options['output'] pipe_results = False @@ -73,7 +58,7 @@ def handle(self, *args, **options): filename = mktemp() pipe_results = True - export_course_to_tarfile(course_keys, filename, cc_lti, external_tool_only) + export_course_to_tarfile(course_key, filename) results = self._get_results(filename) if pipe_results else b'' @@ -93,43 +78,31 @@ def _get_results(self, filename): return results -def export_course_to_tarfile(course_keys, filename, cc_lti, external_tool_only): +def export_course_to_tarfile(course_key, filename): """Exports a course into a tar.gz file""" tmp_dir = mkdtemp() try: - course_dir = export_course_to_directory(course_keys, tmp_dir, cc_lti, external_tool_only) + course_dir = export_course_to_directory(course_key, tmp_dir) compress_directory(course_dir, filename) finally: shutil.rmtree(tmp_dir, ignore_errors=True) -def export_course_to_directory(course_keys, root_dir, cc_lti, external_tool_only): +def export_course_to_directory(course_key, root_dir): """Export course into a directory""" - # attempt to get all the courses based on the course_keys store = modulestore() - courses = [] - for course_key in course_keys: - course = store.get_course(course_key) - if course is None: - raise CommandError("Invalid course_id") - courses.append(course) - - course_ids = [] - for course in courses: - course_ids.append(course.id) + course = store.get_course(course_key) + if course is None: + raise CommandError("Invalid course_id") + # The safest characters are A-Z, a-z, 0-9, , and . # We represent the first four with \w. # TODO: Once we support courses with unicode characters, we will need to revisit this. replacement_char = '-' - course_dir = replacement_char.join([courses[0].id.org, courses[0].id.course, courses[0].id.run]) + course_dir = replacement_char.join([course.id.org, course.id.course, course.id.run]) course_dir = re.sub(r'[^\w\.\-]', replacement_char, course_dir) - if cc_lti: - if len(courses) > 1: - course_dir = "MULTI-COURSE-EXPORT" - export_course_to_imscc(store, None, course_ids, root_dir, course_dir, external_tool_only) - else: - export_course_to_xml(store, None, course_ids[0], root_dir, course_dir) + export_course_to_xml(store, None, course.id, root_dir, course_dir) export_dir = path(root_dir) / course_dir return export_dir diff --git a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py index 66453bf8cac3..547a6d34f2e3 100644 --- a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py @@ -2,32 +2,15 @@ Methods for exporting course data to IMSCC """ -import logging -import os -from abc import abstractmethod -from json import dumps - import lxml.etree from fs.osfs import OSFS -from opaque_keys.edx.locator import CourseLocator, LibraryLocator -from xblock.fields import Reference, ReferenceList, ReferenceValueDict, Scope - -from xmodule.assetstore import AssetMetadata -from xmodule.contentstore.content import StaticContent -from xmodule.exceptions import NotFoundError -from xmodule.modulestore import LIBRARY_ROOT, EdxJSONEncoder, ModuleStoreEnum -from xmodule.modulestore.draft_and_published import DIRECT_ONLY_CATEGORIES -from xmodule.modulestore.inheritance import own_metadata -from xmodule.modulestore.store_utilities import draft_node_constructor, get_draft_subtree_roots +from opaque_keys.edx.locator import CourseLocator +from xmodule.modulestore import ModuleStoreEnum import uuid from datetime import datetime import re -DRAFT_DIR = "drafts" -PUBLISHED_DIR = "published" - -DEFAULT_CONTENT_FIELDS = ['metadata', 'data'] class SerializableChapterSequential: """ @@ -69,7 +52,7 @@ def __eq__(self, other): self.course_id == other.course_id) return False -class TestExportManager: +class CourseExportManager: """ Manages IMSCC exporting for courselike objects. """ @@ -787,4 +770,4 @@ def export_course_to_imscc(modulestore, contentstore, course_key, root_dir, cour """ Thin wrapper for the Export Manager. See ExportManager for details. """ - TestExportManager(modulestore, contentstore, course_key, root_dir, course_dir, external_tool_only).export() \ No newline at end of file + CourseExportManager(modulestore, contentstore, course_key, root_dir, course_dir, external_tool_only).export() \ No newline at end of file From 2f95a01c3db7126a4df484b226eedbc05dd357a4 Mon Sep 17 00:00:00 2001 From: Kenny Sun Date: Fri, 6 Dec 2024 14:07:06 -0500 Subject: [PATCH 14/14] fix: removed trailing whitespace and too long lines --- .../management/commands/export_imscc.py | 5 +- .../xmodule/modulestore/imscc_exporter.py | 71 ++++++++++--------- 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/cms/djangoapps/contentstore/management/commands/export_imscc.py b/cms/djangoapps/contentstore/management/commands/export_imscc.py index 23ca4a829bfe..d61b863a7c42 100644 --- a/cms/djangoapps/contentstore/management/commands/export_imscc.py +++ b/cms/djangoapps/contentstore/management/commands/export_imscc.py @@ -31,7 +31,10 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('course_id', nargs="+") #nargs = "+" allows parsing of unlimited course ids parser.add_argument('--output') - parser.add_argument('--external-tool-only', action = 'store_true', help = 'Export Common Cartridge file using only external tools and no assignment types') + parser.add_argument( + '--external-tool-only', + action = 'store_true', + help = 'Export Common Cartridge file using only external tools and no assignment types') def handle(self, *args, **options): external_tool_only = options.get('external_tool_only', False) diff --git a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py index 547a6d34f2e3..93f2b6c154e9 100644 --- a/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/imscc_exporter.py @@ -79,7 +79,7 @@ def __init__(self, modulestore, contentstore, courselike_keys, root_dir, target_ 'sequential_to_identifier': A dictionary mapping each sequential to an unique identifier An 'identifier' attribute of the 'item' element containing sequential information in the 'imsmanifest.xml' file - An 'identifier' attribute of the 'item' element containing sequential information in the 'course_settings/module_meta.xml' file + An 'identifier' attribute of the 'item' element containing sequential information in the 'course_settings/module_meta.xml' file Links sequentials with all the information above 'sequential_to_identifierref': A dictionary mapping each sequential to an unique identifier @@ -88,12 +88,12 @@ def __init__(self, modulestore, contentstore, courselike_keys, root_dir, target_ An 'identifier' attribute of the assignment object in each sequential's individual assignment_settings.xml file The name of the folder that stores a sequential's assignment_settings.xml file and html file Links sequentials with all the information above - + 'chapter_to_identifier': A dictionary mapping each chapter to an unique identifier An 'identifier' attribute of the 'item' element containing chapter information in the 'imsmanifest.xml' file - An 'identifier' attribute of the 'item' element containing chapter information in the 'course_settings/module_meta.xml' file + An 'identifier' attribute of the 'item' element containing chapter information in the 'course_settings/module_meta.xml' file Links sequentials with all the information above - + 'assignment_group_to_identifier': A dictionary mapping the names of each assignment group to an unique identifier An 'identifier' attribute of the 'assignmentGroup' element in 'assignment_groups.xml' An 'assignment_group_identifierref' child element of the 'assignment' element in an assignment's 'assignment_settings.xml' file @@ -106,7 +106,7 @@ def __init__(self, modulestore, contentstore, courselike_keys, root_dir, target_ 'course_settings_identifier': A pre-made identifier to link course_settings references An 'identifier' attrubute of the 'course' element in 'course_settings/course_settings.xml' file containing course settings - An 'identifier' attribute of the 'resource' element in 'imsmanifest.xml' file containing course settings information + An 'identifier' attribute of the 'resource' element in 'imsmanifest.xml' file containing course settings information 'module_identifier': A pre-made identifier to link the only module that is created An 'identifier' attribute of the 'item' element under the 'item' element with the identifier 'LearningModules' in 'imsmanfiest.xml' @@ -125,13 +125,13 @@ def __init__(self, modulestore, contentstore, courselike_keys, root_dir, target_ sequential = self.serialize_chapter_sequential(sequential) self.sequential_to_identifier[sequential] = self.create_uuid() self.sequential_to_identifierref[sequential] = self.create_uuid() - + for courselike_key in self.courselike_keys: chapter_modules = self.get_chapter_modules(self.modulestore, courselike_key) for chapter in chapter_modules: chapter = self.serialize_chapter_sequential(chapter) self.chapter_to_identifier[chapter] = self.create_uuid() - + for courselike_key in self.courselike_keys: self.module_identifiers[courselike_key] = self.create_uuid() @@ -171,7 +171,7 @@ def get_sequential_modules(self, modulestore, courselike_key): if module.category == 'sequential': sequentials.append(module) return sequentials - + def get_chapter_modules(self, modulestore, courselike_key): """ Retrieve all chapter modules from the course @@ -185,7 +185,7 @@ def get_chapter_modules(self, modulestore, courselike_key): if module.category == 'chapter': chapter.append(module) return chapter - + def get_chapter_sequential_modules(self, modulestore, courselike_key): """ Retrieve all chapter and sequential modules from the course @@ -199,7 +199,7 @@ def get_chapter_sequential_modules(self, modulestore, courselike_key): if module.category == 'sequential' or module.category == 'chapter': sequentials_chapters.append(module) return sequentials_chapters - + def serialize_chapter_sequential(self, chapter_sequential): """ Return a serialized object of an inputted chpater or sequential in order to bypass dictionary key issues @@ -221,14 +221,14 @@ def get_course_abbreviation(self, courselike_key): between_pluses = r'(?<=\+)(.*?)(?=\+)' # re pattern for the 'FAA-ACS-AM-IA-ACE' that all courses seem to have course_type = r'([A-Za-z]{3}-[A-Za-z]{3}-[A-Za-z]{2}-[A-Za-z]{2}-[A-Za-z]{3})' - + courselike_key = str(courselike_key) - + match = re.search(between_pluses, courselike_key) - + if match: extracted_value = match.group(1) - + # Check if it matches the course pattern if re.match(course_type, extracted_value): # If it matches, extract the last 2 letters dash 3 letters @@ -244,17 +244,17 @@ def get_total_score(self, sequential): Returns the total amount of points that can be earned for an assignment sequential """ total_score = 0.0 - + # Get the direct children of the sequential children = sequential.get_children() - + # Recursively iterate through the children to get access to the max_score of individual problem components for child in children: # This try statement will attempt to access max_count, a variable of quiz types with a bank of x amount of questions # These quizzes randomly choose max_count number of questions to display from the bank # Unable to recurse through the quizzes because otherwise, the total_score returned will include every single question in the bank, # not just how many are displayed to the student - + try: max_count = child.max_count # If max_count is 0 or set to None, keep recursing through it's children @@ -308,7 +308,7 @@ def export_assignment_groups(self, modulestore, courselikes, export_fs): assignment_group = lxml.etree.SubElement(root, 'assignmentGroup', {'identifier': str(self.assignment_group_to_identifier[grade_name])}) lxml.etree.SubElement(assignment_group, 'title').text = 'EW - ' + grade_name lxml.etree.SubElement(assignment_group, 'group_weight').text = str(grade_weight) - + # Write assignment_groups to a file with export_fs.open('course_settings/assignment_groups.xml', 'wb') as assignment_groups_xml: tree = lxml.etree.ElementTree(root) @@ -330,7 +330,7 @@ def export_media_tracks(self, export_fs): root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') - + # Write to file with export_fs.open('course_settings/media_tracks.xml', 'wb') as media_tracks_xml: tree = lxml.etree.ElementTree(root) @@ -351,7 +351,7 @@ def export_files_meta(self, export_fs): root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') - + # Write to file with export_fs.open('course_settings/files_meta.xml', 'wb') as files_meta_xml: tree = lxml.etree.ElementTree(root) @@ -389,7 +389,7 @@ def export_course_settings(self, modulestore, courselike_keys, export_fs): with export_fs.open('course_settings/course_settings.xml', 'wb') as course_settings_xml: tree = lxml.etree.ElementTree(root) tree.write(course_settings_xml, xml_declaration=True, encoding='UTF-8', pretty_print=True) - + # There's this file called canvas_export.txt that contains nothing but a pun... # It's referenced in the ims_manifest file for some reason so we're adding it with export_fs.open('course_settings/canvas_export.txt', 'w') as canvas_export_txt: @@ -405,7 +405,7 @@ def export_assignment_folders(self, modulestore, courselike_keys, courselikes, e with self.modulestore.bulk_operations(courselike_key): with self.modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, courselike_key): sequential_modules = self.get_sequential_modules(modulestore, courselike_key) - + # Parse out non assignments assignment_types = {assignment_type['type'] for assignment_type in courselike.grading_policy['GRADER']} only_assignments = (sequential for sequential in sequential_modules if sequential.format in assignment_types) @@ -430,7 +430,7 @@ def export_assignment_folders(self, modulestore, courselike_keys, courselikes, e ) root.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation', 'http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd') - + # Add assignment data like points, assignment type, lti, etc. lxml.etree.SubElement(root, 'title').text = course_abbreviation + sequential.display_name lxml.etree.SubElement(root, 'assignment_group_identifierref').text = str(self.assignment_group_to_identifier[sequential.format]) @@ -446,7 +446,7 @@ def export_assignment_folders(self, modulestore, courselike_keys, courselikes, e lxml.etree.SubElement(root, 'external_tool_data_json').text = '\"\"' lxml.etree.SubElement(root, 'external_tool_link_settings_json').text = '{\"selection_width\":\"\",\"selection_height":\"\"}' lxml.etree.SubElement(root, 'external_tool_new_tab').text = 'false' - + # Create corresponding HTML file # HTML files follow this same cookie cutter format with the only thing changing is the title html_content =''' @@ -466,7 +466,7 @@ def export_assignment_folders(self, modulestore, courselike_keys, courselikes, e html_file_name = html_file_name.lower() html_file_name = html_file_name.replace(' ', '-') html_file_name = html_file_name + '.html' - + # Write to file export_fs.makedirs(str(self.sequential_to_identifierref[sequential]), recreate=True) @@ -560,13 +560,13 @@ def export_imsmanifest_xml(self, modulestore, courselike_keys, courselikes, expo else: chapter = lxml.etree.SubElement(module, 'item', {'identifier': self.chapter_to_identifier[chapter_sequential]}) lxml.etree.SubElement(chapter, 'title').text = course_abbreviation + chapter_sequential.display_name - + ############################# Resources section of imsmanifest.xml ############################# - + # Create resources element resources = lxml.etree.SubElement(root, 'resources') type_string = 'associatedcontent/imscc_xmlv1p1/learning-application-resource' - + # Course settings course_settings_resource = lxml.etree.SubElement(resources, 'resource', {'identifier': self.course_settings_identifier, 'type': type_string, 'href': 'course_settings/canvas_export.txt'}) course_settings_path = 'course_settings' @@ -582,7 +582,7 @@ def export_imsmanifest_xml(self, modulestore, courselike_keys, courselikes, expo with self.modulestore.branch_setting(ModuleStoreEnum.Branch.published_only, courselike_key): # Create resources for assignment sequentials sequential_modules = self.get_sequential_modules(modulestore, courselike_key) - + assignment_types = {assignment_type['type'] for assignment_type in courselike.grading_policy['GRADER']} for sequential in sequential_modules: sequential = self.serialize_chapter_sequential(sequential) @@ -701,7 +701,7 @@ def export_module_meta_xml(self, modulestore, courselike_keys, courselikes, expo lti_link = 'https://courses.educateworkforce.com/lti_provider/courses/' + str(courselike_key) + "/" + (str(courselike_key)).replace('course', 'block') + '+type@sequential+block@' + chapter_sequential.url_name lxml.etree.SubElement(item, 'url').text = lti_link else: - item = lxml.etree.SubElement(items, 'item', {'identifier': self.chapter_to_identifier[chapter_sequential]}) + item = lxml.etree.SubElement(items, 'item', {'identifier': self.chapter_to_identifier[chapter_sequential]}) lxml.etree.SubElement(item, 'content_type').text = 'ContextModuleSubHeader' lxml.etree.SubElement(item, 'title').text = chapter_sequential.display_name lxml.etree.SubElement(item, 'workflow_state').text= 'active' @@ -717,7 +717,7 @@ def export_module_meta_xml(self, modulestore, courselike_keys, courselikes, expo lti_link = 'https://courses.educateworkforce.com/lti_provider/courses/' + str(courselike_key) + "/" + (str(courselike_key)).replace('course', 'block') + '+type@sequential+block@' + chapter_sequential.url_name lxml.etree.SubElement(item, 'url').text = lti_link else: - item = lxml.etree.SubElement(items, 'item', {'identifier': self.chapter_to_identifier[chapter_sequential]}) + item = lxml.etree.SubElement(items, 'item', {'identifier': self.chapter_to_identifier[chapter_sequential]}) lxml.etree.SubElement(item, 'content_type').text = 'ContextModuleSubHeader' lxml.etree.SubElement(item, 'title').text = chapter_sequential.display_name lxml.etree.SubElement(item, 'workflow_state').text= 'active' @@ -746,12 +746,12 @@ def export(self): courselikes = [] for courselike_key in self.courselike_keys: courselikes.append(self.get_courselike(courselike_key)) - + fsm = OSFS(self.root_dir) # Make the directory to export to export_fs = fsm.makedir(self.target_dir, recreate=True) - + # Call export functions self.export_external_tool(export_fs) self.export_all_course_settings(self.modulestore, self.courselike_keys, courselikes, export_fs, self.external_tool_only) @@ -761,7 +761,7 @@ def export(self): self.export_module_meta_xml(self.modulestore, self.courselike_keys, courselikes, export_fs, self.external_tool_only) - + """ Function "export_course_to_imscc" below get called by the django management comman from export_olx.py """ @@ -770,4 +770,5 @@ def export_course_to_imscc(modulestore, contentstore, course_key, root_dir, cour """ Thin wrapper for the Export Manager. See ExportManager for details. """ - CourseExportManager(modulestore, contentstore, course_key, root_dir, course_dir, external_tool_only).export() \ No newline at end of file + CourseExportManager(modulestore, contentstore, course_key, root_dir, course_dir, external_tool_only).export() + \ No newline at end of file