Skip to content
6 changes: 3 additions & 3 deletions .importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions openedx_learning/api/authoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand Down
14 changes: 2 additions & 12 deletions openedx_learning/apps/authoring/backup_restore/api.py
Original file line number Diff line number Diff line change
@@ -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:
"""
Expand All @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
134 changes: 71 additions & 63 deletions openedx_learning/apps/authoring/backup_restore/toml.py
Original file line number Diff line number Diff line change
@@ -1,72 +1,80 @@
"""
Utilities for backup and restore app
TOML serialization for learning packages and publishable entities.
"""

from datetime import datetime
from typing import Any, Dict

from tomlkit import comment, document, dumps, nl, table
from tomlkit.items import Table
import tomlkit

from openedx_learning.apps.authoring.publishing.models.learning_package import LearningPackage
from openedx_learning.apps.authoring.publishing.models import (
PublishableEntity,
PublishableEntityVersion,
Draft,
Published,
)


class TOMLLearningPackageFile():
"""
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):
self.doc = document()
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.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
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 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 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."""

current_draft_version: Draft = getattr(entity, "draft", None)
current_published_version: Published = getattr(entity, "published", None)

doc = tomlkit.document()
entity_table = tomlkit.table()
entity_table.add("uuid", str(entity.uuid))
entity_table.add("can_stand_alone", entity.can_stand_alone)

if current_draft_version:
draft = tomlkit.table()
draft.add("version_num", current_draft_version.version.version_num)
entity_table.add("draft", draft)

published = tomlkit.table()
if current_published_version:
published.add("version_num", current_published_version.version.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.nl())
doc.add(tomlkit.comment("### Versions"))

for entity_version in entity.versions.all():
version = tomlkit.aot()
version_table = toml_publishable_entity_version(entity_version)
version.append(version_table)
doc.add("version", version)

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
55 changes: 55 additions & 0 deletions openedx_learning/apps/authoring/backup_restore/zipper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""
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 pathlib import Path

from openedx_learning.apps.authoring.backup_restore.toml import toml_learning_package, toml_publishable_entity
from openedx_learning.apps.authoring.publishing import api as publishing_api
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_content: str = toml_learning_package(self.learning_package)

with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as zipf:
# Add the package.toml string
zipf.writestr(TOML_PACKAGE_NAME, package_toml_content)

# Add the entities directory
entities_folder = Path("entities")
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 publishing_api.get_entities(self.learning_package.pk):
# Entity it is a PublishableEntity type

# Create a TOML representation of the entity
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)
8 changes: 8 additions & 0 deletions openedx_learning/apps/authoring/publishing/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"get_publishable_entity_by_key",
"get_last_publish",
"get_all_drafts",
"get_entities",
"get_entities_with_unpublished_changes",
"get_entities_with_unpublished_deletes",
"publish_all_drafts",
Expand Down Expand Up @@ -261,6 +262,13 @@ def get_all_drafts(learning_package_id: int, /) -> QuerySet[Draft]:
)


def get_entities(learning_package_id: int, /) -> QuerySet[PublishableEntity]:
"""
Get all entities in a learning package.
"""
return PublishableEntity.objects.filter(learning_package_id=learning_package_id)


def get_entities_with_unpublished_changes(
learning_package_id: int,
/,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
Empty file.
Loading
Loading