From 209b8bdf9a4b5e6e8537b52fabe207c8b2b1c734 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Mon, 14 Jul 2025 16:41:26 -0600 Subject: [PATCH 1/8] feat: Add PublishableEntities to lp_dump file --- .../apps/authoring/backup_restore/api.py | 14 +-- .../management/commands/lp_dump.py | 1 - .../apps/authoring/backup_restore/toml.py | 102 +++++++++++++----- .../apps/authoring/backup_restore/zipper.py | 49 +++++++++ .../publishing/models/publishable_entity.py | 4 + 5 files changed, 132 insertions(+), 38 deletions(-) create mode 100644 openedx_learning/apps/authoring/backup_restore/zipper.py diff --git a/openedx_learning/apps/authoring/backup_restore/api.py b/openedx_learning/apps/authoring/backup_restore/api.py index b26fc207a..5dc66b98d 100644 --- a/openedx_learning/apps/authoring/backup_restore/api.py +++ b/openedx_learning/apps/authoring/backup_restore/api.py @@ -1,14 +1,9 @@ """ Backup Restore API """ -import zipfile - +from openedx_learning.apps.authoring.backup_restore.zipper import LearningPackageZipper from openedx_learning.apps.authoring.publishing.api import get_learning_package_by_key -from .toml import TOMLLearningPackageFile - -TOML_PACKAGE_NAME = "package.toml" - def create_zip_file(lp_key: str, path: str) -> None: """ @@ -17,9 +12,4 @@ def create_zip_file(lp_key: str, path: str) -> None: Can throw a NotFoundError at get_learning_package_by_key """ learning_package = get_learning_package_by_key(lp_key) - toml_file = TOMLLearningPackageFile(learning_package) - toml_file.create() - toml_content: str = toml_file.get() - with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as zipf: - # Add the TOML string as a file in the ZIP - zipf.writestr(TOML_PACKAGE_NAME, toml_content) + LearningPackageZipper(learning_package).create_zip(path) diff --git a/openedx_learning/apps/authoring/backup_restore/management/commands/lp_dump.py b/openedx_learning/apps/authoring/backup_restore/management/commands/lp_dump.py index 12acf2f04..f1b9ddfe9 100644 --- a/openedx_learning/apps/authoring/backup_restore/management/commands/lp_dump.py +++ b/openedx_learning/apps/authoring/backup_restore/management/commands/lp_dump.py @@ -33,7 +33,6 @@ def handle(self, *args, **options): self.stdout.write(self.style.SUCCESS(message)) except LearningPackage.DoesNotExist as exc: message = f"Learning package with key {lp_key} not found" - logger.exception(message) raise CommandError(message) from exc except Exception as e: message = f"Failed to export learning package '{lp_key}': {e}" diff --git a/openedx_learning/apps/authoring/backup_restore/toml.py b/openedx_learning/apps/authoring/backup_restore/toml.py index fd3854204..a0ecea8e4 100644 --- a/openedx_learning/apps/authoring/backup_restore/toml.py +++ b/openedx_learning/apps/authoring/backup_restore/toml.py @@ -9,9 +9,84 @@ from tomlkit.items import Table from openedx_learning.apps.authoring.publishing.models.learning_package import LearningPackage +from openedx_learning.apps.authoring.publishing.models.publishable_entity import PublishableEntityMixin -class TOMLLearningPackageFile(): +class TOMLMixin: + """ + Mixin class to provide common functionality for TOML file generation. + This class can be extended by other classes that need to generate TOML files. + """ + + def __init__(self): + self.doc = document() + + def _create_table(self, params: Dict[str, Any]) -> Table: + """ + Builds a TOML table section from a dictionary of key-value pairs. + """ + section = table() + for key, value in params.items(): + section.add(key, value) + return section + + def get(self) -> str: + """ + Returns: + str: The string representation of the generated TOML document. + Ensure `create()` has been called beforehand to get meaningful output. + """ + return dumps(self.doc) + + +class TOMLPublishableEntityFile(TOMLMixin): + """ + Class to create a .toml representation of a PublishableEntity instance. + + This class builds a structured TOML document using `tomlkit` with metadata and fields + extracted from a `PublishableEntity` object. The output can later be saved to a file or used elsewhere. + """ + + def __init__(self, publishable_entity: PublishableEntityMixin): + super().__init__() + self.publishable_entity = publishable_entity + self.draft_version = publishable_entity.versioning.draft + self.published_version = publishable_entity.versioning.published + + def create(self) -> None: + """ + Populates the TOML document with a header and a table containing + metadata from the PublishableEntity instance. + + This method must be called before calling `get()`, otherwise the document will be empty. + """ + # PublishableEntity metadata + entity = self._create_table({ + "uuid": str(self.publishable_entity.uuid), + "can_stand_alone": self.publishable_entity.can_stand_alone, + "created": self.publishable_entity.created, + }) + self.doc.add("entity", entity) + + # PublishableEntity draft metadata + entity_draft = self._create_table({ + "version_num": self.publishable_entity.versioning.draft.version_num, + }) + self.doc.add("entity_draft", entity_draft) + + # PublishableEntity published metadata + if self.published_version is None: + self.doc.add("entity_published", self._create_table({})) + self.doc.add(comment("unpublished: no published_version_num")) + self.doc.add(nl()) + else: + entity_published = self._create_table({ + "version_num": self.publishable_entity.versioning.published.version_num, + }) + self.doc.add("entity_published", entity_published) + + +class TOMLLearningPackageFile(TOMLMixin): """ Class to create a .toml representation of a LearningPackage instance. @@ -20,7 +95,7 @@ class TOMLLearningPackageFile(): """ def __init__(self, learning_package: LearningPackage): - self.doc = document() + super().__init__() self.learning_package = learning_package def _create_header(self) -> None: @@ -31,21 +106,6 @@ def _create_header(self) -> None: self.doc.add(comment(f"Datetime of the export: {datetime.now()}")) self.doc.add(nl()) - def _create_table(self, params: Dict[str, Any]) -> Table: - """ - Builds a TOML table section from a dictionary of key-value pairs. - - Args: - params (Dict[str, Any]): A dictionary containing keys and values to include in the TOML table. - - Returns: - Table: A TOML table populated with the provided keys and values. - """ - section = table() - for key, value in params.items(): - section.add(key, value) - return section - def create(self) -> None: """ Populates the TOML document with a header and a table containing @@ -62,11 +122,3 @@ def create(self) -> None: "updated": self.learning_package.updated }) self.doc.add("learning_package", section) - - def get(self) -> str: - """ - Returns: - str: The string representation of the generated TOML document. - Ensure `create()` has been called beforehand to get meaningful output. - """ - return dumps(self.doc) diff --git a/openedx_learning/apps/authoring/backup_restore/zipper.py b/openedx_learning/apps/authoring/backup_restore/zipper.py new file mode 100644 index 000000000..58937a1be --- /dev/null +++ b/openedx_learning/apps/authoring/backup_restore/zipper.py @@ -0,0 +1,49 @@ +""" +This module provides functionality to create a zip file containing the learning package data, +including a TOML representation of the learning package and its entities. +""" +import zipfile + +from openedx_learning.apps.authoring.backup_restore.toml import TOMLLearningPackageFile, TOMLPublishableEntityFile +from openedx_learning.apps.authoring.publishing.models.learning_package import LearningPackage + +TOML_PACKAGE_NAME = "package.toml" + + +class LearningPackageZipper: + """ + A class to handle the zipping of learning content for backup and restore. + """ + + def __init__(self, learning_package: LearningPackage): + self.learning_package = learning_package + + def create_zip(self, path: str) -> None: + """ + Creates a zip file containing the learning package data. + Args: + path (str): The path where the zip file will be created. + Raises: + Exception: If the learning package cannot be found or if the zip creation fails. + """ + package_toml_file = TOMLLearningPackageFile(self.learning_package) + package_toml_file.create() + packafe_toml_content: str = package_toml_file.get() + + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as zipf: + # Add the package.toml string as a file in the ZIP + zipf.writestr(TOML_PACKAGE_NAME, packafe_toml_content) + + # Add entities folder + target_folder = "entities" + zip_info = zipfile.ZipInfo(target_folder + '/') + zipf.writestr(zip_info, '') + + # Add the entities + for entity in self.learning_package.component_set.all(): + entity_toml = TOMLPublishableEntityFile(entity) + entity_toml.create() + entity_toml_content = entity_toml.get() + filename = f"{entity.key}.toml" + arcname = f"{target_folder.rstrip('/')}/{filename}" + zipf.writestr(arcname, entity_toml_content) diff --git a/openedx_learning/apps/authoring/publishing/models/publishable_entity.py b/openedx_learning/apps/authoring/publishing/models/publishable_entity.py index 0a95e0a9e..d967a343b 100644 --- a/openedx_learning/apps/authoring/publishing/models/publishable_entity.py +++ b/openedx_learning/apps/authoring/publishing/models/publishable_entity.py @@ -294,6 +294,10 @@ def versioning(self): def uuid(self) -> str: return self.publishable_entity.uuid + @property + def can_stand_alone(self) -> bool: + return self.publishable_entity.can_stand_alone + @property def key(self) -> str: return self.publishable_entity.key From 3e8c3525ef61650ae9947419811b026701242db4 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Fri, 18 Jul 2025 17:47:14 -0600 Subject: [PATCH 2/8] test: add test to validate lp_dump output structure --- openedx_learning/api/authoring.py | 1 + test_settings.py | 1 + .../apps/authoring/backup_restore/__init__.py | 0 .../authoring/backup_restore/test_backup.py | 128 ++++++++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 tests/openedx_learning/apps/authoring/backup_restore/__init__.py create mode 100644 tests/openedx_learning/apps/authoring/backup_restore/test_backup.py diff --git a/openedx_learning/api/authoring.py b/openedx_learning/api/authoring.py index 5db14ceff..9082b33fb 100644 --- a/openedx_learning/api/authoring.py +++ b/openedx_learning/api/authoring.py @@ -9,6 +9,7 @@ """ # These wildcard imports are okay because these api modules declare __all__. # pylint: disable=wildcard-import +from ..apps.authoring.backup_restore.api import * from ..apps.authoring.collections.api import * from ..apps.authoring.components.api import * from ..apps.authoring.contents.api import * diff --git a/test_settings.py b/test_settings.py index e1e4d79ab..a50efb17c 100644 --- a/test_settings.py +++ b/test_settings.py @@ -48,6 +48,7 @@ def root(*args): "openedx_learning.apps.authoring.sections.apps.SectionsConfig", "openedx_learning.apps.authoring.subsections.apps.SubsectionsConfig", "openedx_learning.apps.authoring.units.apps.UnitsConfig", + "openedx_learning.apps.authoring.backup_restore.apps.BackupRestoreConfig", ] AUTHENTICATION_BACKENDS = [ diff --git a/tests/openedx_learning/apps/authoring/backup_restore/__init__.py b/tests/openedx_learning/apps/authoring/backup_restore/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/openedx_learning/apps/authoring/backup_restore/test_backup.py b/tests/openedx_learning/apps/authoring/backup_restore/test_backup.py new file mode 100644 index 000000000..b5ef43f49 --- /dev/null +++ b/tests/openedx_learning/apps/authoring/backup_restore/test_backup.py @@ -0,0 +1,128 @@ +""" +Tests relating to dumping learning packages to disk +""" +import zipfile +from datetime import datetime, timezone +from io import StringIO +from pathlib import Path + +from django.contrib.auth import get_user_model +from django.core.management import CommandError, call_command + +from openedx_learning.api import authoring as api +from openedx_learning.api.authoring_models import LearningPackage +from openedx_learning.lib.test_utils import TestCase + +User = get_user_model() + + +class LpDumpCommandTestCase(TestCase): + """ + Test serving static assets (Content files, via Component lookup). + """ + + learning_package: LearningPackage + + @classmethod + def setUpTestData(cls): + """ + Initialize our content data + """ + cls.user = User.objects.create( + username="user", + email="user@example.com", + ) + + cls.learning_package = api.create_learning_package( + key="ComponentTestCase-test-key", + title="Components Test Case Learning Package", + ) + cls.learning_package_2 = api.create_learning_package( + key="ComponentTestCase-test-key-2", + title="Components Test Case another Learning Package", + ) + cls.now = datetime(2024, 8, 5, tzinfo=timezone.utc) + + cls.html_type = api.get_or_create_component_type("xblock.v1", "html") + cls.problem_type = api.get_or_create_component_type("xblock.v1", "problem") + created_time = datetime(2025, 4, 1, tzinfo=timezone.utc) + cls.draft_unit = api.create_unit( + learning_package_id=cls.learning_package.id, + key="unit-1", + created=created_time, + created_by=cls.user.id, + ) + + # Make and publish one Component + cls.published_component, _ = api.create_component_and_version( + cls.learning_package.id, + cls.problem_type, + local_key="my_published_example", + title="My published problem", + created=cls.now, + created_by=cls.user.id, + ) + api.publish_all_drafts( + cls.learning_package.id, + message="Publish from CollectionTestCase.setUpTestData", + published_at=cls.now, + ) + + # Create a Draft component, one in each learning package + cls.draft_component, _ = api.create_component_and_version( + cls.learning_package.id, + cls.html_type, + local_key="my_draft_example", + title="My draft html", + created=cls.now, + created_by=cls.user.id, + ) + + def check_zip_file_structure(self, zip_path: Path): + """ + Check that the zip file has the expected structure. + """ + + with zipfile.ZipFile(zip_path, 'r') as zip_file: + # Check that the zip file contains the expected files + expected_files = [ + "package.toml", + "entities/", + "entities/xblock.v1:problem:my_published_example.toml", + "entities/xblock.v1:html:my_draft_example.toml", + ] + for expected_file in expected_files: + self.assertIn(expected_file, zip_file.namelist()) + + def test_lp_dump_command(self): + lp_key = self.learning_package.key + file_name = f"{lp_key}.zip" + try: + out = StringIO() + + # Call the management command to dump the learning package + call_command("lp_dump", lp_key, file_name, stdout=out) + + # Check that the zip file was created + self.assertTrue(Path(file_name).exists()) + # Check the structure of the zip file + self.check_zip_file_structure(Path(file_name)) + + # Check the output message + message = f'{lp_key} written to {file_name}' + self.assertIn(message, out.getvalue()) + except Exception as e: # pylint: disable=broad-exception-caught + self.fail(f"lp_dump command failed with error: {e}") + finally: + # Clean up the created zip file + if Path(file_name).exists(): + Path(file_name).unlink(missing_ok=True) + + def test_dump_nonexistent_learning_package(self): + out = StringIO() + lp_key = "nonexistent_lp" + file_name = f"{lp_key}.zip" + with self.assertRaises(CommandError): + # Attempt to dump a learning package that does not exist + call_command("lp_dump", lp_key, file_name, stdout=out) + self.assertIn("Learning package 'nonexistent_lp' does not exist", out.getvalue()) From 53a27ba32c8b5e12332d6fe75b23721fa478330f Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Mon, 21 Jul 2025 12:08:35 -0600 Subject: [PATCH 3/8] feat: improve ZIP file generation and folder structure --- .../apps/authoring/backup_restore/zipper.py | 19 ++++++++----------- .../authoring/backup_restore/test_backup.py | 3 +-- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/openedx_learning/apps/authoring/backup_restore/zipper.py b/openedx_learning/apps/authoring/backup_restore/zipper.py index 58937a1be..807f476ee 100644 --- a/openedx_learning/apps/authoring/backup_restore/zipper.py +++ b/openedx_learning/apps/authoring/backup_restore/zipper.py @@ -3,6 +3,7 @@ including a TOML representation of the learning package and its entities. """ import zipfile +from pathlib import Path from openedx_learning.apps.authoring.backup_restore.toml import TOMLLearningPackageFile, TOMLPublishableEntityFile from openedx_learning.apps.authoring.publishing.models.learning_package import LearningPackage @@ -28,22 +29,18 @@ def create_zip(self, path: str) -> None: """ package_toml_file = TOMLLearningPackageFile(self.learning_package) package_toml_file.create() - packafe_toml_content: str = package_toml_file.get() + package_toml_content: str = package_toml_file.get() with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as zipf: - # Add the package.toml string as a file in the ZIP - zipf.writestr(TOML_PACKAGE_NAME, packafe_toml_content) - - # Add entities folder - target_folder = "entities" - zip_info = zipfile.ZipInfo(target_folder + '/') - zipf.writestr(zip_info, '') + # Add the package.toml string + zipf.writestr(TOML_PACKAGE_NAME, package_toml_content) # Add the entities + entities_folder = Path("entities") for entity in self.learning_package.component_set.all(): entity_toml = TOMLPublishableEntityFile(entity) entity_toml.create() entity_toml_content = entity_toml.get() - filename = f"{entity.key}.toml" - arcname = f"{target_folder.rstrip('/')}/{filename}" - zipf.writestr(arcname, entity_toml_content) + entity_toml_filename = f"{entity.key}.toml" + entity_toml_path = entities_folder / entity_toml_filename + zipf.writestr(str(entity_toml_path), entity_toml_content) diff --git a/tests/openedx_learning/apps/authoring/backup_restore/test_backup.py b/tests/openedx_learning/apps/authoring/backup_restore/test_backup.py index b5ef43f49..54a48aea4 100644 --- a/tests/openedx_learning/apps/authoring/backup_restore/test_backup.py +++ b/tests/openedx_learning/apps/authoring/backup_restore/test_backup.py @@ -83,11 +83,10 @@ def check_zip_file_structure(self, zip_path: Path): Check that the zip file has the expected structure. """ - with zipfile.ZipFile(zip_path, 'r') as zip_file: + with zipfile.ZipFile(zip_path, "r") as zip_file: # Check that the zip file contains the expected files expected_files = [ "package.toml", - "entities/", "entities/xblock.v1:problem:my_published_example.toml", "entities/xblock.v1:html:my_draft_example.toml", ] From ef8561641c0c77639837159d11b51ef2fad98864 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Mon, 21 Jul 2025 17:21:47 -0600 Subject: [PATCH 4/8] test: improve test to verify generated TOML files --- .importlinter | 6 +- .../apps/authoring/backup_restore/toml.py | 1 - .../apps/authoring/backup_restore/zipper.py | 14 ++- .../authoring/backup_restore/test_backup.py | 106 +++++++++++++++--- 4 files changed, 105 insertions(+), 22 deletions(-) diff --git a/.importlinter b/.importlinter index 0317245a6..6c278d0dd 100644 --- a/.importlinter +++ b/.importlinter @@ -35,6 +35,9 @@ layers= # The public authoring API is at the top–none of the apps should call to it. openedx_learning.api.authoring + # The "backup_restore" app handle the new export and import mechanism. + openedx_learning.apps.authoring.backup_restore + # The "components" app is responsible for storing versioned Components, # which is Open edX Studio terminology maps to things like individual # Problems, Videos, and blocks of HTML text. This is also the type we would @@ -50,9 +53,6 @@ layers= # Its only dependency should be the publishing app. openedx_learning.apps.authoring.collections - # The "backup_restore" app handle the new export and import mechanism. - openedx_learning.apps.authoring.backup_restore - # The lowest layer is "publishing", which holds the basic primitives needed # to create Learning Packages and manage the draft and publish states for # various types of content. diff --git a/openedx_learning/apps/authoring/backup_restore/toml.py b/openedx_learning/apps/authoring/backup_restore/toml.py index a0ecea8e4..8b119e4ef 100644 --- a/openedx_learning/apps/authoring/backup_restore/toml.py +++ b/openedx_learning/apps/authoring/backup_restore/toml.py @@ -64,7 +64,6 @@ def create(self) -> None: entity = self._create_table({ "uuid": str(self.publishable_entity.uuid), "can_stand_alone": self.publishable_entity.can_stand_alone, - "created": self.publishable_entity.created, }) self.doc.add("entity", entity) diff --git a/openedx_learning/apps/authoring/backup_restore/zipper.py b/openedx_learning/apps/authoring/backup_restore/zipper.py index 807f476ee..c078ccdff 100644 --- a/openedx_learning/apps/authoring/backup_restore/zipper.py +++ b/openedx_learning/apps/authoring/backup_restore/zipper.py @@ -6,6 +6,7 @@ from pathlib import Path from openedx_learning.apps.authoring.backup_restore.toml import TOMLLearningPackageFile, TOMLPublishableEntityFile +from openedx_learning.apps.authoring.components import api as components_api from openedx_learning.apps.authoring.publishing.models.learning_package import LearningPackage TOML_PACKAGE_NAME = "package.toml" @@ -35,9 +36,18 @@ def create_zip(self, path: str) -> None: # Add the package.toml string zipf.writestr(TOML_PACKAGE_NAME, package_toml_content) - # Add the entities + # Add the entities directory entities_folder = Path("entities") - for entity in self.learning_package.component_set.all(): + zip_info = zipfile.ZipInfo(str(entities_folder) + "/") # Ensure trailing slash + zipf.writestr(zip_info, "") # Add explicit empty directory entry + + # Add the collections directory + collections_folder = Path("collections") + collections_info = zipfile.ZipInfo(str(collections_folder) + "/") # Ensure trailing slash + zipf.writestr(collections_info, "") # Add explicit empty directory + + # Add each entity's TOML file + for entity in components_api.get_components(self.learning_package.pk): entity_toml = TOMLPublishableEntityFile(entity) entity_toml.create() entity_toml_content = entity_toml.get() diff --git a/tests/openedx_learning/apps/authoring/backup_restore/test_backup.py b/tests/openedx_learning/apps/authoring/backup_restore/test_backup.py index 54a48aea4..0b756928f 100644 --- a/tests/openedx_learning/apps/authoring/backup_restore/test_backup.py +++ b/tests/openedx_learning/apps/authoring/backup_restore/test_backup.py @@ -8,9 +8,10 @@ from django.contrib.auth import get_user_model from django.core.management import CommandError, call_command +from django.db.models import QuerySet from openedx_learning.api import authoring as api -from openedx_learning.api.authoring_models import LearningPackage +from openedx_learning.api.authoring_models import Component, LearningPackage from openedx_learning.lib.test_utils import TestCase User = get_user_model() @@ -22,36 +23,33 @@ class LpDumpCommandTestCase(TestCase): """ learning_package: LearningPackage + all_components: QuerySet[Component] @classmethod def setUpTestData(cls): """ Initialize our content data """ + + # Create a user for the test cls.user = User.objects.create( username="user", email="user@example.com", ) + # Create a Learning Package for the test cls.learning_package = api.create_learning_package( key="ComponentTestCase-test-key", title="Components Test Case Learning Package", - ) - cls.learning_package_2 = api.create_learning_package( - key="ComponentTestCase-test-key-2", - title="Components Test Case another Learning Package", + description="This is a test learning package for components.", ) cls.now = datetime(2024, 8, 5, tzinfo=timezone.utc) - cls.html_type = api.get_or_create_component_type("xblock.v1", "html") - cls.problem_type = api.get_or_create_component_type("xblock.v1", "problem") - created_time = datetime(2025, 4, 1, tzinfo=timezone.utc) - cls.draft_unit = api.create_unit( - learning_package_id=cls.learning_package.id, - key="unit-1", - created=created_time, - created_by=cls.user.id, - ) + cls.xblock_v1_namespace = "xblock.v1" + + # Create component types + cls.html_type = api.get_or_create_component_type(cls.xblock_v1_namespace, "html") + cls.problem_type = api.get_or_create_component_type(cls.xblock_v1_namespace, "problem") # Make and publish one Component cls.published_component, _ = api.create_component_and_version( @@ -62,6 +60,8 @@ def setUpTestData(cls): created=cls.now, created_by=cls.user.id, ) + + # Create a Content entry for the published Component api.publish_all_drafts( cls.learning_package.id, message="Publish from CollectionTestCase.setUpTestData", @@ -78,6 +78,28 @@ def setUpTestData(cls): created_by=cls.user.id, ) + api.create_component_version( + cls.draft_component.pk, + version_num=cls.draft_component.versioning.draft.version_num + 1, + title="My draft html v2", + created=cls.now, + created_by=cls.user.id, + ) + + # components = self.learning_package.publishable_entities.all() + components = api.get_components(cls.learning_package) + cls.all_components = components + + def check_toml_file(self, zip_path: Path, zip_member_name: Path, content_to_check: list): + """ + Check that a specific entity TOML file in the zip matches the expected content. + """ + with zipfile.ZipFile(zip_path, "r") as zip_file: + with zip_file.open(str(zip_member_name)) as toml_file: + toml_content = toml_file.read().decode("utf-8") + for value in content_to_check: + self.assertIn(value, toml_content) + def check_zip_file_structure(self, zip_path: Path): """ Check that the zip file has the expected structure. @@ -87,12 +109,28 @@ def check_zip_file_structure(self, zip_path: Path): # Check that the zip file contains the expected files expected_files = [ "package.toml", - "entities/xblock.v1:problem:my_published_example.toml", - "entities/xblock.v1:html:my_draft_example.toml", + "entities/", + "collections/", ] + + # Add expected entity files + for entity in self.all_components: + expected_files.append(f"entities/{entity.key}.toml") + + # Check that all expected files are present for expected_file in expected_files: self.assertIn(expected_file, zip_file.namelist()) + def check_content_in_zip(self, zip_path: Path, expected_file_path: Path, zip_member_name: str): + """ + Compare a file inside the zip with an expected file. + """ + with zipfile.ZipFile(zip_path, "r") as zip_file: + with zip_file.open(zip_member_name) as content_file: + actual_content = content_file.read().decode("utf-8") + expected_content = expected_file_path.read_text() + self.assertEqual(actual_content, expected_content) + def test_lp_dump_command(self): lp_key = self.learning_package.key file_name = f"{lp_key}.zip" @@ -104,9 +142,45 @@ def test_lp_dump_command(self): # Check that the zip file was created self.assertTrue(Path(file_name).exists()) + # Check the structure of the zip file self.check_zip_file_structure(Path(file_name)) + zip_path = Path(file_name) + + # Check the content of the package.toml file + self.check_toml_file( + zip_path, + Path("package.toml"), + [ + '[learning_package]', + f'key = "{self.learning_package.key}"', + f'title = "{self.learning_package.title}"', + f'description = "{self.learning_package.description}"', + ] + ) + + # Check the content of the entity TOML files + for entity in self.all_components: + expected_content = [ + '[entity]', + f'uuid = "{entity.uuid}"', + 'can_stand_alone = true', + '[entity_draft]', + f'version_num = {entity.versioning.draft.version_num}', + '[entity_published]', + ] + if entity.versioning.published: + expected_content.append(f'version_num = {entity.versioning.published.version_num}') + else: + expected_content.append('# unpublished: no published_version_num') + + self.check_toml_file( + zip_path, + Path(f"entities/{entity.key}.toml"), + expected_content + ) + # Check the output message message = f'{lp_key} written to {file_name}' self.assertIn(message, out.getvalue()) From 5d5cb3b2f5c296b4c29295506219ba178089c0f9 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Wed, 23 Jul 2025 12:14:18 -0600 Subject: [PATCH 5/8] feat: improve TOML files to meet requirements --- .../apps/authoring/backup_restore/toml.py | 109 +++++++++++++++--- .../apps/authoring/backup_restore/zipper.py | 16 ++- .../authoring/backup_restore/test_backup.py | 11 +- 3 files changed, 120 insertions(+), 16 deletions(-) diff --git a/openedx_learning/apps/authoring/backup_restore/toml.py b/openedx_learning/apps/authoring/backup_restore/toml.py index 8b119e4ef..81ead8e1d 100644 --- a/openedx_learning/apps/authoring/backup_restore/toml.py +++ b/openedx_learning/apps/authoring/backup_restore/toml.py @@ -5,11 +5,14 @@ from datetime import datetime from typing import Any, Dict -from tomlkit import comment, document, dumps, nl, table +from tomlkit import aot, comment, document, dumps, nl, table from tomlkit.items import Table from openedx_learning.apps.authoring.publishing.models.learning_package import LearningPackage -from openedx_learning.apps.authoring.publishing.models.publishable_entity import PublishableEntityMixin +from openedx_learning.apps.authoring.publishing.models.publishable_entity import ( + PublishableEntityMixin, + PublishableEntityVersionMixin, +) class TOMLMixin: @@ -18,14 +21,32 @@ class TOMLMixin: This class can be extended by other classes that need to generate TOML files. """ - def __init__(self): - self.doc = document() + def __init__(self, existing_doc: document = None): + self.doc = document() if existing_doc is None else existing_doc - def _create_table(self, params: Dict[str, Any]) -> Table: + def add_nl(self) -> None: + """ + Adds a newline to the TOML document. + This is useful for formatting the output. + """ + self.doc.add(nl()) + + def add_comment(self, text: str) -> None: + """ + Adds a comment to the TOML document. + Args: + text (str): The comment text to add. + """ + self.doc.add(comment(text)) + self.add_nl() + + def _create_table(self, params: Dict[str, Any], comment_text: str = None) -> Table: """ Builds a TOML table section from a dictionary of key-value pairs. """ section = table() + if comment_text: + section.add(comment(comment_text)) for key, value in params.items(): section.add(key, value) return section @@ -38,6 +59,59 @@ def get(self) -> str: """ return dumps(self.doc) + def get_document(self) -> document: + """ + Returns: + document: The TOML document object. + Ensure `create()` has been called beforehand to get meaningful output. + """ + return self.doc + + +class TOMLPublishableEntityVersionFile(TOMLMixin): + """ + Class to create a .toml representation of a PublishableEntityVersion instance. + + This class builds a structured TOML document using `tomlkit` with metadata and fields + extracted from a `PublishableEntityVersion` object. The output can later be saved to a file or used elsewhere. + """ + + def __init__( + self, + publishable_entity_version: PublishableEntityVersionMixin, + versions: aot, + existing_doc: document = None + ): + super().__init__(existing_doc) + self.publishable_entity_version = publishable_entity_version + self.versions = versions # Array Of Tables + + def create(self) -> None: + """ + Populates the TOML document with a header and a table containing + metadata from the PublishableEntityVersion instance. + + This method must be called before calling `get()`, otherwise the document will be empty. + """ + version_table = self._create_table({ + "title": self.publishable_entity_version.title, + "uuid": str(self.publishable_entity_version.uuid), + "version_num": self.publishable_entity_version.version_num, + }) + + container_table = self._create_table({ + "children": [], + }) + version_table.add("container", container_table) + + unit_table = self._create_table({ + "graded": True, + }) + container_table.add("unit", unit_table) + + # Add version information to array of tables + self.versions.append(version_table) + class TOMLPublishableEntityFile(TOMLMixin): """ @@ -49,6 +123,7 @@ class TOMLPublishableEntityFile(TOMLMixin): def __init__(self, publishable_entity: PublishableEntityMixin): super().__init__() + self.aot = aot() # Array Of Tables self.publishable_entity = publishable_entity self.draft_version = publishable_entity.versioning.draft self.published_version = publishable_entity.versioning.published @@ -65,24 +140,33 @@ def create(self) -> None: "uuid": str(self.publishable_entity.uuid), "can_stand_alone": self.publishable_entity.can_stand_alone, }) - self.doc.add("entity", entity) # PublishableEntity draft metadata entity_draft = self._create_table({ "version_num": self.publishable_entity.versioning.draft.version_num, }) - self.doc.add("entity_draft", entity_draft) + entity.add("draft", entity_draft) # PublishableEntity published metadata if self.published_version is None: - self.doc.add("entity_published", self._create_table({})) - self.doc.add(comment("unpublished: no published_version_num")) - self.doc.add(nl()) + draft_table = self._create_table({}) + draft_table.add(comment("unpublished: no published_version_num")) + entity.add("published", draft_table) else: entity_published = self._create_table({ "version_num": self.publishable_entity.versioning.published.version_num, }) - self.doc.add("entity_published", entity_published) + entity.add("published", entity_published) + self.doc.add("entity", entity) + self.add_nl() + self.add_comment("### Versions") + + def add_versions_to_document(self) -> None: + """ + Adds the version information to the document. + This method should be called after `create()` to ensure the document is populated. + """ + self.doc.add("version", self.aot) class TOMLLearningPackageFile(TOMLMixin): @@ -102,8 +186,7 @@ def _create_header(self) -> None: Adds a comment with the current datetime to indicate when the export occurred. This helps with traceability and file versioning. """ - self.doc.add(comment(f"Datetime of the export: {datetime.now()}")) - self.doc.add(nl()) + self.add_comment(f"Datetime of the export: {datetime.now()}") def create(self) -> None: """ diff --git a/openedx_learning/apps/authoring/backup_restore/zipper.py b/openedx_learning/apps/authoring/backup_restore/zipper.py index c078ccdff..dbff2cc64 100644 --- a/openedx_learning/apps/authoring/backup_restore/zipper.py +++ b/openedx_learning/apps/authoring/backup_restore/zipper.py @@ -5,7 +5,11 @@ import zipfile from pathlib import Path -from openedx_learning.apps.authoring.backup_restore.toml import TOMLLearningPackageFile, TOMLPublishableEntityFile +from openedx_learning.apps.authoring.backup_restore.toml import ( + TOMLLearningPackageFile, + TOMLPublishableEntityFile, + TOMLPublishableEntityVersionFile, +) from openedx_learning.apps.authoring.components import api as components_api from openedx_learning.apps.authoring.publishing.models.learning_package import LearningPackage @@ -48,8 +52,18 @@ def create_zip(self, path: str) -> None: # Add each entity's TOML file for entity in components_api.get_components(self.learning_package.pk): + # Create a TOML representation of the entity entity_toml = TOMLPublishableEntityFile(entity) entity_toml.create() + + for entity_version in entity.versioning.versions.all(): + # Create a TOML representation of the entity version + entity_version_toml = TOMLPublishableEntityVersionFile( + entity_version, entity_toml.aot, entity_toml.get_document() + ) + entity_version_toml.create() + + entity_toml.add_versions_to_document() entity_toml_content = entity_toml.get() entity_toml_filename = f"{entity.key}.toml" entity_toml_path = entities_folder / entity_toml_filename diff --git a/tests/openedx_learning/apps/authoring/backup_restore/test_backup.py b/tests/openedx_learning/apps/authoring/backup_restore/test_backup.py index 0b756928f..2ffe3253c 100644 --- a/tests/openedx_learning/apps/authoring/backup_restore/test_backup.py +++ b/tests/openedx_learning/apps/authoring/backup_restore/test_backup.py @@ -166,15 +166,22 @@ def test_lp_dump_command(self): '[entity]', f'uuid = "{entity.uuid}"', 'can_stand_alone = true', - '[entity_draft]', + '[entity.draft]', f'version_num = {entity.versioning.draft.version_num}', - '[entity_published]', + '[entity.published]', ] if entity.versioning.published: expected_content.append(f'version_num = {entity.versioning.published.version_num}') else: expected_content.append('# unpublished: no published_version_num') + for entity_version in entity.versioning.versions.all(): + expected_content.append(f'title = "{entity_version.title}"') + expected_content.append(f'uuid = "{entity_version.uuid}"') + expected_content.append(f'version_num = {entity_version.version_num}') + expected_content.append('[version.container]') + expected_content.append('[version.container.unit]') + self.check_toml_file( zip_path, Path(f"entities/{entity.key}.toml"), From f816acbe04653b3a2485f9584ca91b3008ad9fd2 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Wed, 23 Jul 2025 13:04:34 -0600 Subject: [PATCH 6/8] fix: improve typing in TOML module --- .../apps/authoring/backup_restore/toml.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/openedx_learning/apps/authoring/backup_restore/toml.py b/openedx_learning/apps/authoring/backup_restore/toml.py index 81ead8e1d..2f43421cb 100644 --- a/openedx_learning/apps/authoring/backup_restore/toml.py +++ b/openedx_learning/apps/authoring/backup_restore/toml.py @@ -3,10 +3,10 @@ """ from datetime import datetime -from typing import Any, Dict +from typing import Any, Dict, Optional -from tomlkit import aot, comment, document, dumps, nl, table -from tomlkit.items import Table +from tomlkit import TOMLDocument, aot, comment, document, dumps, nl, table +from tomlkit.items import AoT, Table from openedx_learning.apps.authoring.publishing.models.learning_package import LearningPackage from openedx_learning.apps.authoring.publishing.models.publishable_entity import ( @@ -21,7 +21,7 @@ class TOMLMixin: This class can be extended by other classes that need to generate TOML files. """ - def __init__(self, existing_doc: document = None): + def __init__(self, existing_doc: Optional[TOMLDocument] = None): self.doc = document() if existing_doc is None else existing_doc def add_nl(self) -> None: @@ -40,7 +40,7 @@ def add_comment(self, text: str) -> None: self.doc.add(comment(text)) self.add_nl() - def _create_table(self, params: Dict[str, Any], comment_text: str = None) -> Table: + def _create_table(self, params: Dict[str, Any], comment_text: Optional[str] = None) -> Table: """ Builds a TOML table section from a dictionary of key-value pairs. """ @@ -59,7 +59,7 @@ def get(self) -> str: """ return dumps(self.doc) - def get_document(self) -> document: + def get_document(self) -> TOMLDocument: """ Returns: document: The TOML document object. @@ -79,8 +79,8 @@ class TOMLPublishableEntityVersionFile(TOMLMixin): def __init__( self, publishable_entity_version: PublishableEntityVersionMixin, - versions: aot, - existing_doc: document = None + versions: AoT, + existing_doc: Optional[TOMLDocument] = None ): super().__init__(existing_doc) self.publishable_entity_version = publishable_entity_version From c396fcbf765c831b399fdaf1f9b59c97162fbc5e Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Wed, 30 Jul 2025 08:41:43 -0600 Subject: [PATCH 7/8] refactor: use functions instead of classes for TOML file serialization --- .../apps/authoring/backup_restore/toml.py | 255 +++++------------- .../apps/authoring/backup_restore/zipper.py | 23 +- 2 files changed, 63 insertions(+), 215 deletions(-) diff --git a/openedx_learning/apps/authoring/backup_restore/toml.py b/openedx_learning/apps/authoring/backup_restore/toml.py index 2f43421cb..5877583c8 100644 --- a/openedx_learning/apps/authoring/backup_restore/toml.py +++ b/openedx_learning/apps/authoring/backup_restore/toml.py @@ -1,206 +1,71 @@ """ -Utilities for backup and restore app +TOML serialization for learning packages and publishable entities. """ from datetime import datetime -from typing import Any, Dict, Optional -from tomlkit import TOMLDocument, aot, comment, document, dumps, nl, table -from tomlkit.items import AoT, Table +import tomlkit from openedx_learning.apps.authoring.publishing.models.learning_package import LearningPackage from openedx_learning.apps.authoring.publishing.models.publishable_entity import ( - PublishableEntityMixin, - PublishableEntityVersionMixin, + PublishableEntity, + PublishableEntityVersion, ) -class TOMLMixin: - """ - Mixin class to provide common functionality for TOML file generation. - This class can be extended by other classes that need to generate TOML files. - """ - - def __init__(self, existing_doc: Optional[TOMLDocument] = None): - self.doc = document() if existing_doc is None else existing_doc - - def add_nl(self) -> None: - """ - Adds a newline to the TOML document. - This is useful for formatting the output. - """ - self.doc.add(nl()) - - def add_comment(self, text: str) -> None: - """ - Adds a comment to the TOML document. - Args: - text (str): The comment text to add. - """ - self.doc.add(comment(text)) - self.add_nl() - - def _create_table(self, params: Dict[str, Any], comment_text: Optional[str] = None) -> Table: - """ - Builds a TOML table section from a dictionary of key-value pairs. - """ - section = table() - if comment_text: - section.add(comment(comment_text)) - for key, value in params.items(): - section.add(key, value) - return section - - def get(self) -> str: - """ - Returns: - str: The string representation of the generated TOML document. - Ensure `create()` has been called beforehand to get meaningful output. - """ - return dumps(self.doc) - - def get_document(self) -> TOMLDocument: - """ - Returns: - document: The TOML document object. - Ensure `create()` has been called beforehand to get meaningful output. - """ - return self.doc - - -class TOMLPublishableEntityVersionFile(TOMLMixin): - """ - Class to create a .toml representation of a PublishableEntityVersion instance. - - This class builds a structured TOML document using `tomlkit` with metadata and fields - extracted from a `PublishableEntityVersion` object. The output can later be saved to a file or used elsewhere. - """ - - def __init__( - self, - publishable_entity_version: PublishableEntityVersionMixin, - versions: AoT, - existing_doc: Optional[TOMLDocument] = None - ): - super().__init__(existing_doc) - self.publishable_entity_version = publishable_entity_version - self.versions = versions # Array Of Tables - - def create(self) -> None: - """ - Populates the TOML document with a header and a table containing - metadata from the PublishableEntityVersion instance. - - This method must be called before calling `get()`, otherwise the document will be empty. - """ - version_table = self._create_table({ - "title": self.publishable_entity_version.title, - "uuid": str(self.publishable_entity_version.uuid), - "version_num": self.publishable_entity_version.version_num, - }) - - container_table = self._create_table({ - "children": [], - }) - version_table.add("container", container_table) - - unit_table = self._create_table({ - "graded": True, - }) - container_table.add("unit", unit_table) - - # Add version information to array of tables - self.versions.append(version_table) - - -class TOMLPublishableEntityFile(TOMLMixin): - """ - Class to create a .toml representation of a PublishableEntity instance. - - This class builds a structured TOML document using `tomlkit` with metadata and fields - extracted from a `PublishableEntity` object. The output can later be saved to a file or used elsewhere. - """ - - def __init__(self, publishable_entity: PublishableEntityMixin): - super().__init__() - self.aot = aot() # Array Of Tables - self.publishable_entity = publishable_entity - self.draft_version = publishable_entity.versioning.draft - self.published_version = publishable_entity.versioning.published - - def create(self) -> None: - """ - Populates the TOML document with a header and a table containing - metadata from the PublishableEntity instance. - - This method must be called before calling `get()`, otherwise the document will be empty. - """ - # PublishableEntity metadata - entity = self._create_table({ - "uuid": str(self.publishable_entity.uuid), - "can_stand_alone": self.publishable_entity.can_stand_alone, - }) - - # PublishableEntity draft metadata - entity_draft = self._create_table({ - "version_num": self.publishable_entity.versioning.draft.version_num, - }) - entity.add("draft", entity_draft) - - # PublishableEntity published metadata - if self.published_version is None: - draft_table = self._create_table({}) - draft_table.add(comment("unpublished: no published_version_num")) - entity.add("published", draft_table) - else: - entity_published = self._create_table({ - "version_num": self.publishable_entity.versioning.published.version_num, - }) - entity.add("published", entity_published) - self.doc.add("entity", entity) - self.add_nl() - self.add_comment("### Versions") - - def add_versions_to_document(self) -> None: - """ - Adds the version information to the document. - This method should be called after `create()` to ensure the document is populated. - """ - self.doc.add("version", self.aot) - - -class TOMLLearningPackageFile(TOMLMixin): - """ - Class to create a .toml representation of a LearningPackage instance. - - This class builds a structured TOML document using `tomlkit` with metadata and fields - extracted from a `LearningPackage` object. The output can later be saved to a file or used elsewhere. - """ - - def __init__(self, learning_package: LearningPackage): - super().__init__() - self.learning_package = learning_package - - def _create_header(self) -> None: - """ - Adds a comment with the current datetime to indicate when the export occurred. - This helps with traceability and file versioning. - """ - self.add_comment(f"Datetime of the export: {datetime.now()}") - - def create(self) -> None: - """ - Populates the TOML document with a header and a table containing - metadata from the LearningPackage instance. - - This method must be called before calling `get()`, otherwise the document will be empty. - """ - self._create_header() - section = self._create_table({ - "title": self.learning_package.title, - "key": self.learning_package.key, - "description": self.learning_package.description, - "created": self.learning_package.created, - "updated": self.learning_package.updated - }) - self.doc.add("learning_package", section) +def toml_learning_package(learning_package: LearningPackage) -> str: + """Create a TOML representation of the learning package.""" + doc = tomlkit.document() + doc.add(tomlkit.comment(f"Datetime of the export: {datetime.now()}")) + section = tomlkit.table() + section.add("title", learning_package.title) + section.add("key", learning_package.key) + section.add("description", learning_package.description) + section.add("created", learning_package.created) + section.add("updated", learning_package.updated) + doc.add("learning_package", section) + return tomlkit.dumps(doc) + + +def toml_publishable_entity(entity: PublishableEntity) -> str: + """Create a TOML representation of a publishable entity.""" + doc = tomlkit.document() + entity_table = tomlkit.table() + entity_table.add("uuid", str(entity.uuid)) + entity_table.add("can_stand_alone", entity.can_stand_alone) + + draft = tomlkit.table() + draft.add("version_num", entity.versioning.draft.version_num) + entity_table.add("draft", draft) + + published = tomlkit.table() + if entity.versioning.published: + published.add("version_num", entity.versioning.published.version_num) + else: + published.add(tomlkit.comment("unpublished: no published_version_num")) + entity_table.add("published", published) + + doc.add("entity", entity_table) + doc.add(tomlkit.comment("### Versions")) + doc.add("version", tomlkit.aot()) # Add empty AoT or fill as needed + + for entity_version in entity.versioning.versions.all(): + version_table = toml_publishable_entity_version(entity_version) + doc["version"].append(version_table) + + return tomlkit.dumps(doc) + + +def toml_publishable_entity_version(version: PublishableEntityVersion) -> tomlkit.items.Table: + """Create a TOML representation of a publishable entity version.""" + version_table = tomlkit.table() + version_table.add("title", version.title) + version_table.add("uuid", str(version.uuid)) + version_table.add("version_num", version.version_num) + container_table = tomlkit.table() + container_table.add("children", []) + unit_table = tomlkit.table() + unit_table.add("graded", True) + container_table.add("unit", unit_table) + version_table.add("container", container_table) + return version_table # For use in AoT diff --git a/openedx_learning/apps/authoring/backup_restore/zipper.py b/openedx_learning/apps/authoring/backup_restore/zipper.py index dbff2cc64..2c07401e5 100644 --- a/openedx_learning/apps/authoring/backup_restore/zipper.py +++ b/openedx_learning/apps/authoring/backup_restore/zipper.py @@ -5,11 +5,7 @@ import zipfile from pathlib import Path -from openedx_learning.apps.authoring.backup_restore.toml import ( - TOMLLearningPackageFile, - TOMLPublishableEntityFile, - TOMLPublishableEntityVersionFile, -) +from openedx_learning.apps.authoring.backup_restore.toml import toml_learning_package, toml_publishable_entity from openedx_learning.apps.authoring.components import api as components_api from openedx_learning.apps.authoring.publishing.models.learning_package import LearningPackage @@ -32,9 +28,7 @@ def create_zip(self, path: str) -> None: Raises: Exception: If the learning package cannot be found or if the zip creation fails. """ - package_toml_file = TOMLLearningPackageFile(self.learning_package) - package_toml_file.create() - package_toml_content: str = package_toml_file.get() + package_toml_content: str = toml_learning_package(self.learning_package) with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as zipf: # Add the package.toml string @@ -53,18 +47,7 @@ def create_zip(self, path: str) -> None: # Add each entity's TOML file for entity in components_api.get_components(self.learning_package.pk): # Create a TOML representation of the entity - entity_toml = TOMLPublishableEntityFile(entity) - entity_toml.create() - - for entity_version in entity.versioning.versions.all(): - # Create a TOML representation of the entity version - entity_version_toml = TOMLPublishableEntityVersionFile( - entity_version, entity_toml.aot, entity_toml.get_document() - ) - entity_version_toml.create() - - entity_toml.add_versions_to_document() - entity_toml_content = entity_toml.get() + entity_toml_content: str = toml_publishable_entity(entity) entity_toml_filename = f"{entity.key}.toml" entity_toml_path = entities_folder / entity_toml_filename zipf.writestr(str(entity_toml_path), entity_toml_content) From d4b31f0ec49a32f06fa41798afa4a2c8095b6fc2 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Wed, 30 Jul 2025 10:32:14 -0600 Subject: [PATCH 8/8] fix: minor adjustments to TOML serialization --- .../apps/authoring/backup_restore/toml.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/openedx_learning/apps/authoring/backup_restore/toml.py b/openedx_learning/apps/authoring/backup_restore/toml.py index 5877583c8..05ede1df3 100644 --- a/openedx_learning/apps/authoring/backup_restore/toml.py +++ b/openedx_learning/apps/authoring/backup_restore/toml.py @@ -8,8 +8,8 @@ from openedx_learning.apps.authoring.publishing.models.learning_package import LearningPackage from openedx_learning.apps.authoring.publishing.models.publishable_entity import ( - PublishableEntity, - PublishableEntityVersion, + PublishableEntityMixin, + PublishableEntityVersionMixin, ) @@ -27,7 +27,7 @@ def toml_learning_package(learning_package: LearningPackage) -> str: return tomlkit.dumps(doc) -def toml_publishable_entity(entity: PublishableEntity) -> str: +def toml_publishable_entity(entity: PublishableEntityMixin) -> str: """Create a TOML representation of a publishable entity.""" doc = tomlkit.document() entity_table = tomlkit.table() @@ -46,17 +46,19 @@ def toml_publishable_entity(entity: PublishableEntity) -> str: entity_table.add("published", published) doc.add("entity", entity_table) + doc.add(tomlkit.nl()) doc.add(tomlkit.comment("### Versions")) - doc.add("version", tomlkit.aot()) # Add empty AoT or fill as needed for entity_version in entity.versioning.versions.all(): + version = tomlkit.aot() version_table = toml_publishable_entity_version(entity_version) - doc["version"].append(version_table) + version.append(version_table) + doc.add("version", version) return tomlkit.dumps(doc) -def toml_publishable_entity_version(version: PublishableEntityVersion) -> tomlkit.items.Table: +def toml_publishable_entity_version(version: PublishableEntityVersionMixin) -> tomlkit.items.Table: """Create a TOML representation of a publishable entity version.""" version_table = tomlkit.table() version_table.add("title", version.title)