From 7975d0d5f41d7a9060bf557ec3d683c28ecf6136 Mon Sep 17 00:00:00 2001 From: Kenny Sun Date: Thu, 5 Dec 2024 16:18:13 -0500 Subject: [PATCH] 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