Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 127 additions & 8 deletions openedx_learning/apps/authoring/backup_restore/toml.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,26 @@

import tomlkit

from openedx_learning.apps.authoring.collections.models import Collection
from openedx_learning.apps.authoring.publishing import api as publishing_api
from openedx_learning.apps.authoring.publishing.models import PublishableEntity, PublishableEntityVersion
from openedx_learning.apps.authoring.publishing.models.learning_package import LearningPackage


def toml_learning_package(learning_package: LearningPackage) -> str:
"""Create a TOML representation of the learning package."""
"""
Create a TOML representation of the learning package.

The resulting content looks like:
# Datetime of the export: 2025-09-03 12:50:59.573253

[learning_package]
title = "Components Test Case Learning Package"
key = "ComponentTestCase-test-key"
description = "This is a test learning package for components."
created = 2025-09-03T17:50:59.536190Z
updated = 2025-09-03T17:50:59.536190Z
"""
doc = tomlkit.document()
doc.add(tomlkit.comment(f"Datetime of the export: {datetime.now()}"))
section = tomlkit.table()
Expand All @@ -25,16 +38,38 @@ def toml_learning_package(learning_package: LearningPackage) -> str:
return tomlkit.dumps(doc)


def toml_publishable_entity(entity: PublishableEntity) -> str:
"""Create a TOML representation of a publishable entity."""
def _get_toml_publishable_entity_table(
entity: PublishableEntity,
include_versions: bool = True) -> tomlkit.items.Table:
"""
Create a TOML representation of a publishable entity.

current_draft_version = publishing_api.get_draft_version(entity)
current_published_version = publishing_api.get_published_version(entity)
The resulting content looks like:
[entity]
uuid = "f8ea9bae-b4ed-4a84-ab4f-2b9850b59cd6"
can_stand_alone = true
key = "xblock.v1:problem:my_published_example"

doc = tomlkit.document()
[entity.draft]
version_num = 2

[entity.published]
version_num = 1

Note: This function returns a tomlkit.items.Table, which represents
a string-like TOML fragment rather than a complete TOML document.
"""
entity_table = tomlkit.table()
entity_table.add("uuid", str(entity.uuid))
entity_table.add("can_stand_alone", entity.can_stand_alone)
# Add key since the toml filename doesn't show the real key
entity_table.add("key", entity.key)

if not include_versions:
return entity_table

current_draft_version = publishing_api.get_draft_version(entity)
current_published_version = publishing_api.get_published_version(entity)

if current_draft_version:
draft_table = tomlkit.table()
Expand All @@ -47,12 +82,45 @@ def toml_publishable_entity(entity: PublishableEntity) -> str:
else:
published_table.add(tomlkit.comment("unpublished: no published_version_num"))
entity_table.add("published", published_table)
return entity_table


def toml_publishable_entity(entity: PublishableEntity, versions_to_write: list[PublishableEntityVersion]) -> str:
"""
Create a TOML representation of a publishable entity and its versions.

The resulting content looks like:
[entity]
uuid = "f8ea9bae-b4ed-4a84-ab4f-2b9850b59cd6"
can_stand_alone = true
key = "xblock.v1:problem:my_published_example"

[entity.draft]
version_num = 2

[entity.published]
version_num = 1

# ### Versions

[[version]]
title = "My published problem"
uuid = "2e07511f-daa7-428a-9032-17fe12a77d06"
version_num = 1

[version.container]
children = []

[version.container.unit]
graded = true
"""
entity_table = _get_toml_publishable_entity_table(entity)
doc = tomlkit.document()
doc.add("entity", entity_table)
doc.add(tomlkit.nl())
doc.add(tomlkit.comment("### Versions"))

for entity_version in entity.versions.all():
for entity_version in versions_to_write:
version = tomlkit.aot()
version_table = toml_publishable_entity_version(entity_version)
version.append(version_table)
Expand All @@ -62,7 +130,24 @@ def toml_publishable_entity(entity: PublishableEntity) -> str:


def toml_publishable_entity_version(version: PublishableEntityVersion) -> tomlkit.items.Table:
"""Create a TOML representation of a publishable entity version."""
"""
Create a TOML representation of a publishable entity version.

The resulting content looks like:
[[version]]
title = "My published problem"
uuid = "2e07511f-daa7-428a-9032-17fe12a77d06"
version_num = 1

[version.container]
children = []

[version.container.unit]
graded = true

Note: This function returns a tomlkit.items.Table, which represents
a string-like TOML fragment rather than a complete TOML document.
"""
version_table = tomlkit.table()
version_table.add("title", version.title)
version_table.add("uuid", str(version.uuid))
Expand All @@ -74,3 +159,37 @@ def toml_publishable_entity_version(version: PublishableEntityVersion) -> tomlki
container_table.add("unit", unit_table)
version_table.add("container", container_table)
return version_table # For use in AoT


def toml_collection(collection: Collection) -> str:
"""
Create a TOML representation of a collection.

The resulting content looks like:
[collection]
title = "Collection 1"
key = "COL1"
description = "Description of Collection 1"
created = 2025-09-03T22:28:53.839362Z
entities = [
"xblock.v1:problem:my_published_example",
"xblock.v1:html:my_draft_example",
]
"""
doc = tomlkit.document()

entity_keys = collection.entities.order_by("key").values_list("key", flat=True)
entities_array = tomlkit.array()
entities_array.extend(entity_keys)
entities_array.multiline(True)

collection_table = tomlkit.table()
collection_table.add("title", collection.title)
collection_table.add("key", collection.key)
collection_table.add("description", collection.description)
collection_table.add("created", collection.created)
collection_table.add("entities", entities_array)

doc.add("collection", collection_table)

return tomlkit.dumps(doc)
36 changes: 29 additions & 7 deletions openedx_learning/apps/authoring/backup_restore/zipper.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,20 @@
from django.utils.text import slugify

from openedx_learning.api.authoring_models import (
Collection,
ComponentVersion,
ComponentVersionContent,
Content,
LearningPackage,
PublishableEntity,
PublishableEntityVersion,
)
from openedx_learning.apps.authoring.backup_restore.toml import toml_learning_package, toml_publishable_entity
from openedx_learning.apps.authoring.backup_restore.toml import (
toml_collection,
toml_learning_package,
toml_publishable_entity,
)
from openedx_learning.apps.authoring.collections import api as collections_api
from openedx_learning.apps.authoring.publishing import api as publishing_api

TOML_PACKAGE_NAME = "package.toml"
Expand Down Expand Up @@ -104,6 +110,15 @@ def get_publishable_entities(self) -> QuerySet[PublishableEntity]:
)
)

def get_collections(self) -> QuerySet[Collection]:
"""
Get the collections associated with the learning package.
"""
return (
collections_api.get_collections(self.learning_package.pk)
.prefetch_related("entities")
)

def get_versions_to_write(self, entity: PublishableEntity):
"""
Get the versions of a publishable entity that should be written to the zip file.
Expand Down Expand Up @@ -148,8 +163,11 @@ def create_zip(self, path: str) -> None:
for entity in publishable_entities:
# entity: PublishableEntity = entity # Type hint for clarity

# Get the draft and published versions
versions_to_write: List[PublishableEntityVersion] = self.get_versions_to_write(entity)

# Create a TOML representation of the entity
entity_toml_content: str = toml_publishable_entity(entity)
entity_toml_content: str = toml_publishable_entity(entity, versions_to_write)

if hasattr(entity, 'container'):
entity_slugify_hash = slugify_hashed_filename(entity.key)
Expand Down Expand Up @@ -199,11 +217,7 @@ def create_zip(self, path: str) -> None:
self.create_folder(component_version_folder, zipf)

# ------ COMPONENT VERSIONING -------------
# Focusing on draft and published versions

# Get the draft and published versions
versions_to_write: List[PublishableEntityVersion] = self.get_versions_to_write(entity)

# Focusing on draft and published versions only
for version in versions_to_write:
# Create a folder for the version
version_number = f"v{version.version_num}"
Expand Down Expand Up @@ -240,3 +254,11 @@ def create_zip(self, path: str) -> None:
# If no file and no text, we skip this content
continue
zipf.writestr(str(file_path), file_data)

# ------ COLLECTION SERIALIZATION -------------
collections = self.get_collections()

for collection in collections:
collection_hash_slug = slugify_hashed_filename(collection.key)
collection_toml_file_path = collections_folder / f"{collection_hash_slug}.toml"
zipf.writestr(str(collection_toml_file_path), toml_collection(collection))
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from django.db.models import QuerySet

from openedx_learning.api import authoring as api
from openedx_learning.api.authoring_models import Component, LearningPackage
from openedx_learning.api.authoring_models import Collection, Component, Content, LearningPackage, PublishableEntity
from openedx_learning.apps.authoring.backup_restore.zipper import LearningPackageZipper
from openedx_learning.lib.test_utils import TestCase

Expand All @@ -24,7 +24,15 @@ class LpDumpCommandTestCase(TestCase):
"""

learning_package: LearningPackage
all_components: QuerySet[Component]
all_components: QuerySet[PublishableEntity]
now: datetime
xblock_v1_namespace: str
html_type: str
problem_type: str
published_component: Component
draft_component: Component
html_asset_content: Content
collection: Collection

@classmethod
def setUpTestData(cls):
Expand Down Expand Up @@ -122,6 +130,20 @@ def setUpTestData(cls):
components = api.get_publishable_entities(cls.learning_package)
cls.all_components = components

cls.collection = api.create_collection(
cls.learning_package.id,
key="COL1",
created_by=cls.user.id,
title="Collection 1",
description="Description of Collection 1",
)

api.add_to_collection(
cls.learning_package.id,
cls.collection.key,
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.
Expand Down Expand Up @@ -157,6 +179,9 @@ def check_zip_file_structure(self, zip_path: Path):
# Entity static content files
"entities/xblock.v1/html/my_draft_example_af06e1/component_versions/v2/static/hello.html",
"entities/xblock.v1/problem/my_published_example_386dce/component_versions/v2/hello.txt",

# Collections
"collections/col1_06bb25.toml",
]

expected_paths = expected_directories + expected_files
Expand Down