Skip to content

Commit 2644032

Browse files
authored
feat: support collection restore in backup/restore process (openedx#398)
1 parent ce359a2 commit 2644032

3 files changed

Lines changed: 78 additions & 2 deletions

File tree

openedx_learning/apps/authoring/backup_restore/serializers.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,18 @@ def validate(self, attrs):
120120
attrs["children"] = children
121121
attrs.pop("container") # Remove the container field after processing
122122
return attrs
123+
124+
125+
class CollectionSerializer(serializers.Serializer): # pylint: disable=abstract-method
126+
"""
127+
Serializer for collections.
128+
"""
129+
title = serializers.CharField(required=True)
130+
key = serializers.CharField(required=True)
131+
description = serializers.CharField(required=True, allow_blank=True)
132+
created_by = serializers.IntegerField(required=True, allow_null=True)
133+
entities = serializers.ListField(
134+
child=serializers.CharField(),
135+
required=True,
136+
allow_empty=True,
137+
)

openedx_learning/apps/authoring/backup_restore/toml.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,3 +245,13 @@ def parse_publishable_entity_toml(content: str) -> tuple[Dict[str, Any], list]:
245245
if "version" not in pe_data:
246246
raise ValueError("Invalid publishable entity TOML: missing 'version' section")
247247
return pe_data["entity"], pe_data.get("version", [])
248+
249+
250+
def parse_collection_toml(content: str) -> dict:
251+
"""
252+
Parse the collection TOML content and return a dict of its fields.
253+
"""
254+
collection_data: Dict[str, Any] = tomlkit.parse(content)
255+
if "collection" not in collection_data:
256+
raise ValueError("Invalid collection TOML: missing 'collection' section")
257+
return collection_data["collection"]

openedx_learning/apps/authoring/backup_restore/zipper.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@
2525
PublishableEntityVersion,
2626
)
2727
from openedx_learning.apps.authoring.backup_restore.serializers import (
28+
CollectionSerializer,
2829
ComponentSerializer,
2930
ComponentVersionSerializer,
3031
ContainerSerializer,
3132
ContainerVersionSerializer,
3233
)
3334
from openedx_learning.apps.authoring.backup_restore.toml import (
35+
parse_collection_toml,
3436
parse_learning_package_toml,
3537
parse_publishable_entity_toml,
3638
toml_collection,
@@ -413,6 +415,7 @@ def __init__(self) -> None:
413415
self.units_map_by_key: dict[str, Any] = {}
414416
self.subsections_map_by_key: dict[str, Any] = {}
415417
self.sections_map_by_key: dict[str, Any] = {}
418+
self.all_publishable_entities_keys: set[str] = set()
416419

417420
# --------------------------
418421
# Public API
@@ -434,9 +437,13 @@ def load(self, zipf: zipfile.ZipFile) -> dict[str, Any]:
434437
zipf, organized_files["containers"], ContainerSerializer, ContainerVersionSerializer
435438
)
436439

440+
collections_validated = self._extract_collections(
441+
zipf, organized_files["collections"]
442+
)
443+
437444
self._write_errors()
438445
if not self.errors:
439-
self._save(learning_package, components_validated, containers_validated)
446+
self._save(learning_package, components_validated, containers_validated, collections_validated)
440447

441448
return {
442449
"learning_package": learning_package.key,
@@ -474,6 +481,7 @@ def _extract_entities(
474481
continue
475482

476483
entity_data = serializer.validated_data
484+
self.all_publishable_entities_keys.add(entity_data["key"])
477485
entity_type = entity_data.pop("container_type", "components")
478486
results[entity_type].append(entity_data)
479487

@@ -491,6 +499,36 @@ def _extract_entities(
491499

492500
return results
493501

502+
def _extract_collections(
503+
self,
504+
zipf: zipfile.ZipFile,
505+
collection_files: list[str],
506+
) -> dict[str, Any]:
507+
"""Extraction + validation pipeline for collections."""
508+
results: dict[str, list[Any]] = defaultdict(list)
509+
510+
for file in collection_files:
511+
if not file.endswith(".toml"):
512+
# Skip non-TOML files
513+
continue
514+
toml_content = self._read_file_from_zip(zipf, file)
515+
collection_data = parse_collection_toml(toml_content)
516+
serializer = CollectionSerializer(data={"created_by": None, **collection_data})
517+
if not serializer.is_valid():
518+
self.errors.append({"file": file, "errors": serializer.errors})
519+
continue
520+
collection_validated = serializer.validated_data
521+
entities_list = collection_validated["entities"]
522+
for entity_key in entities_list:
523+
if entity_key not in self.all_publishable_entities_keys:
524+
self.errors.append({
525+
"file": file,
526+
"errors": f"Entity key {entity_key} not found for collection {collection_validated.get('key')}"
527+
})
528+
results["collections"].append(collection_validated)
529+
530+
return results
531+
494532
# --------------------------
495533
# Save Logic
496534
# --------------------------
@@ -499,7 +537,8 @@ def _save(
499537
self,
500538
learning_package: LearningPackage,
501539
components: dict[str, Any],
502-
containers: dict[str, Any]
540+
containers: dict[str, Any],
541+
collections: dict[str, Any]
503542
) -> None:
504543
"""Persist all validated entities in two phases: published then drafts."""
505544

@@ -508,11 +547,23 @@ def _save(
508547
self._save_units(learning_package, containers)
509548
self._save_subsections(learning_package, containers)
510549
self._save_sections(learning_package, containers)
550+
self._save_collections(learning_package, collections)
511551
publishing_api.publish_all_drafts(learning_package.id)
512552

513553
with publishing_api.bulk_draft_changes_for(learning_package.id):
514554
self._save_draft_versions(components, containers)
515555

556+
def _save_collections(self, learning_package, collections):
557+
"""Save collections and their entities."""
558+
for valid_collection in collections.get("collections", []):
559+
entities = valid_collection.pop("entities", [])
560+
collection = collections_api.create_collection(learning_package.id, **valid_collection)
561+
collection = collections_api.add_to_collection(
562+
learning_package_id=learning_package.id,
563+
key=collection.key,
564+
entities_qset=publishing_api.get_publishable_entities(learning_package.id).filter(key__in=entities)
565+
)
566+
516567
def _save_components(self, learning_package, components):
517568
"""Save components and published component versions."""
518569
for valid_component in components.get("components", []):

0 commit comments

Comments
 (0)