Skip to content

Commit 8e71a58

Browse files
authored
fix: improvements to backup and restore (openedx#406)
A number of fixes and adjustments based on feedback: * include staged key generation in the load process * extend load process with updated input and output fields * remove uuid from TOML files (these are going to be site-specific) * remove unnecessary [container] table in components TOML files * add success and error responses to the restore process * improve validation for restore process inputs * handle case when draft and published versions are the same * introduce dataclass for load result and refactor version check - Added dataclass to represent load process result - Improved docstrings - Simplified _is_version_already_exists logic
1 parent ae58aaf commit 8e71a58

20 files changed

Lines changed: 581 additions & 158 deletions

File tree

openedx_learning/apps/authoring/backup_restore/api.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,28 @@
33
"""
44
import zipfile
55

6+
from django.contrib.auth.models import User as UserType # pylint: disable=imported-auth-user
7+
68
from openedx_learning.apps.authoring.backup_restore.zipper import LearningPackageUnzipper, LearningPackageZipper
79
from openedx_learning.apps.authoring.publishing.api import get_learning_package_by_key
810

911

1012
def create_zip_file(lp_key: str, path: str) -> None:
1113
"""
1214
Creates a dump zip file for the given learning package key at the given path.
15+
The zip file contains a TOML representation of the learning package and its contents.
1316
1417
Can throw a NotFoundError at get_learning_package_by_key
1518
"""
1619
learning_package = get_learning_package_by_key(lp_key)
1720
LearningPackageZipper(learning_package).create_zip(path)
1821

1922

20-
def load_dump_zip_file(path: str) -> None:
23+
def load_learning_package(path: str, key: str | None = None, user: UserType | None = None) -> dict:
2124
"""
22-
Loads a zip file derived from create_zip_file
25+
Loads a learning package from a zip file at the given path.
26+
Restores the learning package and its contents to the database.
27+
Returns a dictionary with the status of the operation and any errors encountered.
2328
"""
2429
with zipfile.ZipFile(path, "r") as zipf:
25-
LearningPackageUnzipper(zipf).load()
30+
return LearningPackageUnzipper(zipf, key, user).load()

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Django management commands to handle backup learning packages (WIP)
33
"""
44
import logging
5+
import time
56

67
from django.core.management import CommandError
78
from django.core.management.base import BaseCommand
@@ -28,8 +29,10 @@ def handle(self, *args, **options):
2829
if not file_name.lower().endswith(".zip"):
2930
raise CommandError("Output file name must end with .zip")
3031
try:
32+
start_time = time.time()
3133
create_zip_file(lp_key, file_name)
32-
message = f'{lp_key} written to {file_name}'
34+
elapsed = time.time() - start_time
35+
message = f'{lp_key} written to {file_name} (create_zip_file: {elapsed:.2f} seconds)'
3336
self.stdout.write(self.style.SUCCESS(message))
3437
except LearningPackage.DoesNotExist as exc:
3538
message = f"Learning package with key {lp_key} not found"

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

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
Django management commands to handle restore learning packages (WIP)
33
"""
44
import logging
5+
import time
56

7+
from django.contrib.auth.models import User as UserType # pylint: disable=imported-auth-user
68
from django.core.management import CommandError
79
from django.core.management.base import BaseCommand
810

9-
from openedx_learning.apps.authoring.backup_restore.api import load_dump_zip_file
11+
from openedx_learning.apps.authoring.backup_restore.api import load_learning_package
1012

1113
logger = logging.getLogger(__name__)
1214

@@ -18,15 +20,28 @@ class Command(BaseCommand):
1820
help = 'Load a learning package from a zip file.'
1921

2022
def add_arguments(self, parser):
21-
parser.add_argument('file_name', type=str, help='The name of the input zip file to load.')
23+
parser.add_argument('file_name', type=str, help='The path of the input zip file to load.')
24+
parser.add_argument('username', type=str, help='The username of the user performing the load operation.')
2225

2326
def handle(self, *args, **options):
2427
file_name = options['file_name']
28+
username = options['username']
2529
if not file_name.lower().endswith(".zip"):
2630
raise CommandError("Input file name must end with .zip")
2731
try:
28-
load_dump_zip_file(file_name)
29-
message = f'{file_name} loaded successfully'
32+
start_time = time.time()
33+
# Create a tmp user to pass to the load function
34+
user = UserType.objects.get(username=username)
35+
36+
result = load_learning_package(file_name, user=user)
37+
duration = time.time() - start_time
38+
if result["status"] == "error":
39+
message = "Errors encountered during restore:\n"
40+
log_buffer = result.get("log_file_error")
41+
if log_buffer:
42+
message += log_buffer.getvalue()
43+
raise CommandError(message)
44+
message = f'{file_name} loaded successfully (duration: {duration:.2f} seconds)'
3045
self.stdout.write(self.style.SUCCESS(message))
3146
except FileNotFoundError as exc:
3247
message = f"Learning package file {file_name} not found: {exc}"

openedx_learning/apps/authoring/backup_restore/serializers.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,34 @@
88
from openedx_learning.apps.authoring.components import api as components_api
99

1010

11+
class LearningPackageSerializer(serializers.Serializer): # pylint: disable=abstract-method
12+
"""
13+
Serializer for learning packages.
14+
15+
Note:
16+
The `key` field is serialized, but it is generally not trustworthy for restoration.
17+
During restore, a new key may be generated or overridden.
18+
"""
19+
title = serializers.CharField(required=True)
20+
key = serializers.CharField(required=True)
21+
description = serializers.CharField(required=True, allow_blank=True)
22+
created = serializers.DateTimeField(required=True, default_timezone=timezone.utc)
23+
24+
25+
class LearningPackageMetadataSerializer(serializers.Serializer): # pylint: disable=abstract-method
26+
"""
27+
Serializer for learning package metadata.
28+
29+
Note:
30+
This serializer handles data exported to an archive (e.g., during backup),
31+
but the metadata is not restored to the database and is meant solely for inspection.
32+
"""
33+
format_version = serializers.IntegerField(required=True)
34+
created_by = serializers.CharField(required=False, allow_null=True)
35+
created_at = serializers.DateTimeField(required=True, default_timezone=timezone.utc)
36+
origin_server = serializers.CharField(required=False, allow_null=True)
37+
38+
1139
class EntitySerializer(serializers.Serializer): # pylint: disable=abstract-method
1240
"""
1341
Serializer for publishable entities.

openedx_learning/apps/authoring/backup_restore/toml.py

Lines changed: 14 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ def _get_toml_publishable_entity_table(
7272
7373
The resulting content looks like:
7474
[entity]
75-
uuid = "f8ea9bae-b4ed-4a84-ab4f-2b9850b59cd6"
7675
can_stand_alone = true
7776
key = "xblock.v1:problem:my_published_example"
7877
@@ -88,7 +87,6 @@ def _get_toml_publishable_entity_table(
8887
a string-like TOML fragment rather than a complete TOML document.
8988
"""
9089
entity_table = tomlkit.table()
91-
entity_table.add("uuid", str(entity.uuid))
9290
entity_table.add("can_stand_alone", entity.can_stand_alone)
9391
# Add key since the toml filename doesn't show the real key
9492
entity_table.add("key", entity.key)
@@ -133,7 +131,6 @@ def toml_publishable_entity(
133131
134132
The resulting content looks like:
135133
[entity]
136-
uuid = "f8ea9bae-b4ed-4a84-ab4f-2b9850b59cd6"
137134
can_stand_alone = true
138135
key = "xblock.v1:problem:my_published_example"
139136
@@ -143,17 +140,16 @@ def toml_publishable_entity(
143140
[entity.published]
144141
version_num = 1
145142
143+
[entity.container.section] (if applicable)
144+
146145
# ### Versions
147146
148147
[[version]]
149148
title = "My published problem"
150-
uuid = "2e07511f-daa7-428a-9032-17fe12a77d06"
151149
version_num = 1
152150
153-
[version.container]
151+
[version.container] (if applicable)
154152
children = []
155-
156-
[version.container.unit]
157153
"""
158154
# Create the TOML representation for the entity itself
159155
entity_table = _get_toml_publishable_entity_table(entity, draft_version, published_version)
@@ -179,32 +175,25 @@ def toml_publishable_entity_version(version: PublishableEntityVersion) -> tomlki
179175
The resulting content looks like:
180176
[[version]]
181177
title = "My published problem"
182-
uuid = "2e07511f-daa7-428a-9032-17fe12a77d06"
183178
version_num = 1
184179
185-
[version.container]
180+
[version.container] (if applicable)
186181
children = []
187182
188-
[version.container.unit]
189-
graded = true
190-
191-
Note: This function returns a tomlkit.items.Table, which represents
183+
Note: This function returns a tomlkit.items.Table, which represents
192184
a string-like TOML fragment rather than a complete TOML document.
193185
"""
194186
version_table = tomlkit.table()
195187
version_table.add("title", version.title)
196-
version_table.add("uuid", str(version.uuid))
197188
version_table.add("version_num", version.version_num)
198189

199-
container_table = tomlkit.table()
200-
201-
children = []
202190
if hasattr(version, 'containerversion'):
191+
# If the version has a container version, add its children
192+
container_table = tomlkit.table()
203193
children = publishing_api.get_container_children_entities_keys(version.containerversion)
204-
container_table.add("children", children)
205-
206-
version_table.add("container", container_table)
207-
return version_table # For use in AoT
194+
container_table.add("children", children)
195+
version_table.add("container", container_table)
196+
return version_table
208197

209198

210199
def toml_collection(collection: Collection, entity_keys: list[str]) -> str:
@@ -245,36 +234,20 @@ def parse_learning_package_toml(content: str) -> dict:
245234
Parse the learning package TOML content and return a dict of its fields.
246235
"""
247236
lp_data: Dict[str, Any] = tomlkit.parse(content)
237+
return lp_data
248238

249-
# Validate the minimum required fields
250-
if "learning_package" not in lp_data:
251-
raise ValueError("Invalid learning package TOML: missing 'learning_package' section")
252-
if "title" not in lp_data["learning_package"]:
253-
raise ValueError("Invalid learning package TOML: missing 'title' in 'learning_package' section")
254-
if "key" not in lp_data["learning_package"]:
255-
raise ValueError("Invalid learning package TOML: missing 'key' in 'learning_package' section")
256-
return lp_data["learning_package"]
257239

258-
259-
def parse_publishable_entity_toml(content: str) -> tuple[Dict[str, Any], list]:
240+
def parse_publishable_entity_toml(content: str) -> dict:
260241
"""
261242
Parse the publishable entity TOML file and return a dict of its fields.
262243
"""
263244
pe_data: Dict[str, Any] = tomlkit.parse(content)
264-
265-
# Validate the minimum required fields
266-
if "entity" not in pe_data:
267-
raise ValueError("Invalid publishable entity TOML: missing 'entity' section")
268-
if "version" not in pe_data:
269-
raise ValueError("Invalid publishable entity TOML: missing 'version' section")
270-
return pe_data["entity"], pe_data.get("version", [])
245+
return pe_data
271246

272247

273248
def parse_collection_toml(content: str) -> dict:
274249
"""
275250
Parse the collection TOML content and return a dict of its fields.
276251
"""
277252
collection_data: Dict[str, Any] = tomlkit.parse(content)
278-
if "collection" not in collection_data:
279-
raise ValueError("Invalid collection TOML: missing 'collection' section")
280-
return collection_data["collection"]
253+
return collection_data

0 commit comments

Comments
 (0)