Skip to content

Commit c8ebb89

Browse files
authored
feat: add initial code to load a learning package (#379)
- Implement restore of the learning package - Add initial logic to handle ZIP file contents - Include placeholders for restoring other entities (containers, components, collections)
1 parent be6b569 commit c8ebb89

File tree

5 files changed

+235
-5
lines changed

5 files changed

+235
-5
lines changed
Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
11
"""
22
Backup Restore API
33
"""
4-
from openedx_learning.apps.authoring.backup_restore.zipper import LearningPackageZipper
4+
import zipfile
5+
6+
from openedx_learning.apps.authoring.backup_restore.zipper import LearningPackageUnzipper, LearningPackageZipper
57
from openedx_learning.apps.authoring.publishing.api import get_learning_package_by_key
68

79

810
def create_zip_file(lp_key: str, path: str) -> None:
911
"""
10-
Creates a zip file with a toml file so far (WIP)
12+
Creates a dump zip file for the given learning package key at the given path.
1113
1214
Can throw a NotFoundError at get_learning_package_by_key
1315
"""
1416
learning_package = get_learning_package_by_key(lp_key)
1517
LearningPackageZipper(learning_package).create_zip(path)
18+
19+
20+
def load_dump_zip_file(path: str) -> None:
21+
"""
22+
Loads a zip file derived from create_zip_file
23+
"""
24+
with zipfile.ZipFile(path, "r") as zipf:
25+
LearningPackageUnzipper().load(zipf)

openedx_learning/apps/authoring/backup_restore/management/commands/lp_dump.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
Django management commands to handle backup and restore learning packages (WIP)
2+
Django management commands to handle backup learning packages (WIP)
33
"""
44
import logging
55

@@ -25,7 +25,7 @@ def add_arguments(self, parser):
2525
def handle(self, *args, **options):
2626
lp_key = options['lp_key']
2727
file_name = options['file_name']
28-
if not file_name.endswith(".zip"):
28+
if not file_name.lower().endswith(".zip"):
2929
raise CommandError("Output file name must end with .zip")
3030
try:
3131
create_zip_file(lp_key, file_name)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""
2+
Django management commands to handle restore learning packages (WIP)
3+
"""
4+
import logging
5+
6+
from django.core.management import CommandError
7+
from django.core.management.base import BaseCommand
8+
9+
from openedx_learning.apps.authoring.backup_restore.api import load_dump_zip_file
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
class Command(BaseCommand):
15+
"""
16+
Django management command to load a learning package from a zip file.
17+
"""
18+
help = 'Load a learning package from a zip file.'
19+
20+
def add_arguments(self, parser):
21+
parser.add_argument('file_name', type=str, help='The name of the input zip file to load.')
22+
23+
def handle(self, *args, **options):
24+
file_name = options['file_name']
25+
if not file_name.lower().endswith(".zip"):
26+
raise CommandError("Input file name must end with .zip")
27+
try:
28+
load_dump_zip_file(file_name)
29+
message = f'{file_name} loaded successfully'
30+
self.stdout.write(self.style.SUCCESS(message))
31+
except FileNotFoundError as exc:
32+
message = f"Learning package file {file_name} not found"
33+
raise CommandError(message) from exc
34+
except Exception as e:
35+
message = f"Failed to load '{file_name}': {e}"
36+
logger.exception(
37+
"Failed to load zip file %s ",
38+
file_name,
39+
)
40+
raise CommandError(message) from e

openedx_learning/apps/authoring/backup_restore/toml.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
from datetime import datetime
6+
from typing import Any, Dict
67

78
import tomlkit
89

@@ -202,3 +203,19 @@ def toml_collection(collection: Collection, entity_keys: list[str]) -> str:
202203
doc.add("collection", collection_table)
203204

204205
return tomlkit.dumps(doc)
206+
207+
208+
def parse_learning_package_toml(content: str) -> dict:
209+
"""
210+
Parse the learning package TOML content and return a dict of its fields.
211+
"""
212+
lp_data: Dict[str, Any] = tomlkit.parse(content)
213+
214+
# Validate the minimum required fields
215+
if "learning_package" not in lp_data:
216+
raise ValueError("Invalid learning package TOML: missing 'learning_package' section")
217+
if "title" not in lp_data["learning_package"]:
218+
raise ValueError("Invalid learning package TOML: missing 'title' in 'learning_package' section")
219+
if "key" not in lp_data["learning_package"]:
220+
raise ValueError("Invalid learning package TOML: missing 'key' in 'learning_package' section")
221+
return lp_data["learning_package"]

openedx_learning/apps/authoring/backup_restore/zipper.py

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
import zipfile
77
from datetime import datetime, timezone
88
from pathlib import Path
9-
from typing import List, Optional, Tuple
9+
from typing import Any, List, Optional, Tuple
1010

11+
from django.db import transaction
1112
from django.db.models import Prefetch, QuerySet
1213
from django.utils.text import slugify
1314

@@ -21,6 +22,7 @@
2122
PublishableEntityVersion,
2223
)
2324
from openedx_learning.apps.authoring.backup_restore.toml import (
25+
parse_learning_package_toml,
2426
toml_collection,
2527
toml_learning_package,
2628
toml_publishable_entity,
@@ -366,3 +368,164 @@ def create_zip(self, path: str) -> None:
366368
toml_collection(collection, list(entity_keys_related)),
367369
timestamp=collection.modified,
368370
)
371+
372+
373+
class LearningPackageUnzipper:
374+
"""
375+
Handles extraction and restoration of learning package data from a zip archive.
376+
377+
Main responsibilities:
378+
- Parse and organize files from the zip structure.
379+
- Restore learning package, containers, components, and collections to the database.
380+
- Ensure atomicity of the restore process.
381+
382+
Usage:
383+
unzipper = LearningPackageUnzipper()
384+
summary = unzipper.load("/path/to/backup.zip")
385+
"""
386+
387+
def __init__(self) -> None:
388+
self.utc_now: datetime = datetime.now(tz=timezone.utc)
389+
390+
# --------------------------
391+
# Public API
392+
# --------------------------
393+
394+
@transaction.atomic
395+
def load(self, zipf: zipfile.ZipFile) -> dict[str, Any]:
396+
"""
397+
Extracts and restores all objects from the ZIP archive in an atomic transaction.
398+
399+
Args:
400+
zipf (ZipFile): An open ZipFile instance.
401+
402+
Returns:
403+
dict: Summary of restored objects (keys, counts, etc.).
404+
405+
Raises:
406+
FileNotFoundError: If required files are missing.
407+
ValueError: If TOML parsing fails.
408+
Exception: For any database errors (transaction will rollback).
409+
"""
410+
organized_files = self._get_organized_file_list(zipf.namelist())
411+
412+
# Validate required files
413+
if not organized_files["learning_package"]:
414+
raise FileNotFoundError(f"Missing required {TOML_PACKAGE_NAME} in archive.")
415+
416+
# Restore objects
417+
learning_package = self._load_learning_package(zipf, organized_files["learning_package"])
418+
self._restore_components(zipf, organized_files["components"], learning_package)
419+
self._restore_containers(zipf, organized_files["containers"], learning_package)
420+
self._restore_collections(zipf, organized_files["collections"], learning_package)
421+
422+
return {
423+
"learning_package": learning_package.key,
424+
"containers": len(organized_files["containers"]),
425+
"components": len(organized_files["components"]),
426+
"collections": len(organized_files["collections"]),
427+
}
428+
429+
# --------------------------
430+
# Loading methods
431+
# --------------------------
432+
433+
def _load_learning_package(self, zipf: zipfile.ZipFile, package_file: str) -> LearningPackage:
434+
"""Load and persist the learning package TOML file."""
435+
toml_content = self._read_file_from_zip(zipf, package_file)
436+
data = parse_learning_package_toml(toml_content)
437+
438+
return publishing_api.create_learning_package(
439+
key=data["key"],
440+
title=data["title"],
441+
description=data["description"],
442+
)
443+
444+
def _restore_containers(
445+
self, zipf: zipfile.ZipFile, container_files: List[str], learning_package: LearningPackage
446+
) -> None:
447+
"""Restore containers from the zip archive."""
448+
for container_file in container_files:
449+
self._load_container(zipf, container_file, learning_package)
450+
451+
def _restore_components(
452+
self, zipf: zipfile.ZipFile, component_files: List[str], learning_package: LearningPackage
453+
) -> None:
454+
"""Restore components from the zip archive."""
455+
for component_file in component_files:
456+
self._load_component(zipf, component_file, learning_package)
457+
458+
def _restore_collections(
459+
self, zipf: zipfile.ZipFile, collection_files: List[str], learning_package: LearningPackage
460+
) -> None:
461+
"""Restore collections from the zip archive (future extension)."""
462+
# pylint: disable=W0613
463+
for collection_file in collection_files: # pylint: disable=W0612
464+
# Placeholder for collection restore logic
465+
pass
466+
467+
# --------------------------
468+
# Individual object loaders
469+
# --------------------------
470+
471+
def _load_container(
472+
self, zipf: zipfile.ZipFile, container_file: str, learning_package: LearningPackage
473+
): # pylint: disable=W0613
474+
"""Load and persist a container (placeholder)."""
475+
# TODO: parse TOML here
476+
# pylint: disable=W0105
477+
"""
478+
container = publishing_api.create_container(
479+
learning_package_id=learning_package.id,
480+
key="container_key_placeholder",
481+
title="Container Title Placeholder",
482+
description="Container Description Placeholder",
483+
)
484+
publishing_api.create_container_version(
485+
container_id=container.id,
486+
title="Container Version Title Placeholder",
487+
created_by=None,
488+
)
489+
"""
490+
491+
def _load_component(
492+
self, zipf: zipfile.ZipFile, component_file: str, learning_package: LearningPackage
493+
): # pylint: disable=W0613
494+
"""Load and persist a component (placeholder)."""
495+
# TODO: implement actual parsing
496+
return None
497+
498+
# --------------------------
499+
# Utilities
500+
# --------------------------
501+
502+
def _read_file_from_zip(self, zipf: zipfile.ZipFile, filename: str) -> str:
503+
"""Read and decode a UTF-8 file from the zip archive."""
504+
with zipf.open(filename) as f:
505+
return f.read().decode("utf-8")
506+
507+
def _get_organized_file_list(self, file_paths: List[str]) -> dict[str, Any]:
508+
"""
509+
Organize file paths into categories: learning_package, containers, components, collections.
510+
"""
511+
organized: dict[str, Any] = {
512+
"learning_package": None,
513+
"containers": [],
514+
"components": [],
515+
"collections": [],
516+
}
517+
518+
for path in file_paths:
519+
if path.endswith("/"): # skip directories
520+
continue
521+
522+
if path == TOML_PACKAGE_NAME:
523+
organized["learning_package"] = path
524+
elif path.startswith("entities/") and str(Path(path).parent) == "entities":
525+
organized["containers"].append(path)
526+
elif path.startswith("entities/"):
527+
organized["components"].append(path)
528+
elif path.startswith("collections/"):
529+
organized["collections"].append(path)
530+
531+
return organized

0 commit comments

Comments
 (0)