Skip to content

Commit bf8ffe4

Browse files
author
Taylor Payne
authored
feat: add library restore endpoint (#37439)
Adds a library restore endpoint to restore a learning package from a backup zip archive (/api/libraries/v2/restore/). The learning package can then be used to create a content library.
1 parent 5d01a40 commit bf8ffe4

File tree

12 files changed

+725
-18
lines changed

12 files changed

+725
-18
lines changed

openedx/core/djangoapps/content_libraries/api/libraries.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
CONTENT_LIBRARY_UPDATED
6262
)
6363
from openedx_learning.api import authoring as authoring_api
64-
from openedx_learning.api.authoring_models import Component
64+
from openedx_learning.api.authoring_models import Component, LearningPackage
6565
from organizations.models import Organization
6666
from user_tasks.models import UserTaskArtifact, UserTaskStatus
6767
from xblock.core import XBlock
@@ -384,6 +384,7 @@ def create_library(
384384
allow_public_learning: bool = False,
385385
allow_public_read: bool = False,
386386
library_license: str = ALL_RIGHTS_RESERVED,
387+
learning_package: LearningPackage | None = None,
387388
) -> ContentLibraryMetadata:
388389
"""
389390
Create a new content library.
@@ -400,6 +401,8 @@ def create_library(
400401
401402
allow_public_read: Allow anyone to view blocks (including source) in Studio?
402403
404+
learning_package: A learning package to associate with this library.
405+
403406
Returns a ContentLibraryMetadata instance.
404407
"""
405408
assert isinstance(org, Organization)
@@ -413,14 +416,25 @@ def create_library(
413416
allow_public_read=allow_public_read,
414417
license=library_license,
415418
)
416-
learning_package = authoring_api.create_learning_package(
417-
key=str(ref.library_key),
418-
title=title,
419-
description=description,
420-
)
419+
420+
if learning_package:
421+
# A temporary LearningPackage was passed in, so update its key to match the library,
422+
# and also update its title/description in case they differ.
423+
authoring_api.update_learning_package(
424+
learning_package.id,
425+
key=str(ref.library_key),
426+
title=title,
427+
description=description,
428+
)
429+
else:
430+
# We have to generate a new LearningPackage for this library.
431+
learning_package = authoring_api.create_learning_package(
432+
key=str(ref.library_key),
433+
title=title,
434+
description=description,
435+
)
421436
ref.learning_package = learning_package
422437
ref.save()
423-
424438
except IntegrityError:
425439
raise LibraryAlreadyExists(slug) # lint-amnesty, pylint: disable=raise-missing-from
426440

@@ -431,6 +445,7 @@ def create_library(
431445
library_key=ref.library_key
432446
)
433447
)
448+
434449
return ContentLibraryMetadata(
435450
key=ref.library_key,
436451
title=title,

openedx/core/djangoapps/content_libraries/rest_api/libraries.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@
7979
from django.views.decorators.csrf import csrf_exempt
8080
from django.views.generic.base import TemplateResponseMixin, View
8181
from drf_yasg.utils import swagger_auto_schema
82+
from user_tasks.models import UserTaskStatus
83+
8284
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
8385
from organizations.api import ensure_organization
8486
from organizations.exceptions import InvalidOrganizationException
@@ -97,6 +99,9 @@
9799
get_allowed_organizations_for_libraries,
98100
user_can_create_organizations
99101
)
102+
from cms.djangoapps.contentstore.storage import course_import_export_storage
103+
from openedx.core.djangoapps.content_libraries.tasks import restore_library
104+
100105
from openedx.core.djangoapps.content_libraries import api, permissions
101106
from openedx.core.djangoapps.content_libraries.api.libraries import get_backup_task_status
102107
from openedx.core.djangoapps.content_libraries.rest_api.serializers import (
@@ -110,6 +115,9 @@
110115
ContentLibraryUpdateSerializer,
111116
LibraryBackupResponseSerializer,
112117
LibraryBackupTaskStatusSerializer,
118+
LibraryRestoreFileSerializer,
119+
LibraryRestoreTaskRequestSerializer,
120+
LibraryRestoreTaskResultSerializer,
113121
LibraryXBlockCreationSerializer,
114122
LibraryXBlockMetadataSerializer,
115123
LibraryXBlockTypeSerializer,
@@ -790,6 +798,82 @@ def get(self, request, lib_key_str):
790798
return Response(LibraryBackupTaskStatusSerializer(result, context={'request': request}).data)
791799

792800

801+
@method_decorator(non_atomic_requests, name="dispatch")
802+
@view_auth_classes()
803+
class LibraryRestoreView(APIView):
804+
"""
805+
Restore a library from a backup file.
806+
807+
After the file is uploaded, a background task will be started to process the
808+
file and restore the library contents. You can use the returned `task_id` to
809+
check the status of the restore task.
810+
811+
The result of the restore task will be a "staged" learning package that can
812+
then be saved into a content library.
813+
814+
**POST Parameters**
815+
816+
A POST request must include the following parameters.
817+
818+
* file: (required) The backup file to restore the library from. Must be a
819+
.zip file.
820+
821+
**GET Parameters**
822+
823+
A GET request must include the following parameters.
824+
825+
* task_id: (required) The UUID of a restore task.
826+
"""
827+
@apidocs.schema(
828+
body=LibraryRestoreFileSerializer,
829+
responses={200: LibraryRestoreFileSerializer}
830+
)
831+
def post(self, request):
832+
"""
833+
Restore a library from a backup file.
834+
"""
835+
if not request.user.has_perm(permissions.CAN_CREATE_CONTENT_LIBRARY):
836+
raise PermissionDenied
837+
838+
serializer = LibraryRestoreFileSerializer(data=request.data)
839+
serializer.is_valid(raise_exception=True)
840+
upload = serializer.validated_data['file']
841+
842+
storage_path = course_import_export_storage.save(f'library_restore/{upload.name}', upload)
843+
844+
log.info("Learning package archive upload %s: Upload complete", upload.name)
845+
846+
async_result = restore_library.delay(request.user.id, storage_path)
847+
848+
return Response(LibraryRestoreFileSerializer({'task_id': async_result.task_id}).data)
849+
850+
@apidocs.schema(
851+
parameters=[
852+
apidocs.query_parameter(
853+
'task_id',
854+
str,
855+
description="The ID of the restore library task to retrieve."
856+
),
857+
],
858+
responses={200: LibraryRestoreTaskResultSerializer}
859+
)
860+
def get(self, request):
861+
"""
862+
Check the status of a library restore task.
863+
"""
864+
# validate input
865+
serializer = LibraryRestoreTaskRequestSerializer(data=request.query_params)
866+
serializer.is_valid(raise_exception=True)
867+
task_id = serializer.validated_data.get('task_id')
868+
869+
# get task status and related artifact
870+
task_status = get_object_or_404(UserTaskStatus, task_id=task_id, user=request.user)
871+
872+
# serialize and return result
873+
result_serializer = LibraryRestoreTaskResultSerializer.from_task_status(task_status, request)
874+
return Response(result_serializer.data)
875+
876+
793877
# LTI 1.3 Views
794878
# =============
795879

openedx/core/djangoapps/content_libraries/rest_api/serializers.py

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@
22
Serializers for the content libraries REST API
33
"""
44
# pylint: disable=abstract-method
5+
import json
6+
import logging
7+
58
from django.core.validators import validate_unicode_slug
69
from opaque_keys import InvalidKeyError, OpaqueKey
710
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryUsageLocatorV2
8-
from openedx_learning.api.authoring_models import Collection
11+
from openedx_learning.api.authoring_models import Collection, LearningPackage
912
from rest_framework import serializers
1013
from rest_framework.exceptions import ValidationError
14+
from user_tasks.models import UserTaskStatus
1115

16+
from openedx.core.djangoapps.content_libraries.tasks import LibraryRestoreTask
1217
from openedx.core.djangoapps.content_libraries.api.containers import ContainerType
1318
from openedx.core.djangoapps.content_libraries.constants import ALL_RIGHTS_RESERVED, LICENSE_OPTIONS
1419
from openedx.core.djangoapps.content_libraries.models import (
@@ -22,6 +27,8 @@
2227

2328
DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
2429

30+
log = logging.getLogger(__name__)
31+
2532

2633
class ContentLibraryMetadataSerializer(serializers.Serializer):
2734
"""
@@ -37,6 +44,7 @@ class ContentLibraryMetadataSerializer(serializers.Serializer):
3744
slug = serializers.CharField(source="key.slug", validators=(validate_unicode_slug, ))
3845
title = serializers.CharField()
3946
description = serializers.CharField(allow_blank=True)
47+
learning_package = serializers.PrimaryKeyRelatedField(queryset=LearningPackage.objects.all(), required=False)
4048
num_blocks = serializers.IntegerField(read_only=True)
4149
last_published = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True)
4250
published_by = serializers.CharField(read_only=True)
@@ -426,3 +434,111 @@ class LibraryBackupTaskStatusSerializer(serializers.Serializer):
426434
"""
427435
state = serializers.CharField()
428436
url = serializers.FileField(source='file', allow_null=True, use_url=True)
437+
438+
439+
class LibraryRestoreFileSerializer(serializers.Serializer):
440+
"""
441+
Serializer for restoring a library from a backup file.
442+
"""
443+
# input only fields
444+
file = serializers.FileField(write_only=True, help_text="A ZIP file containing a library backup.")
445+
446+
# output only fields
447+
task_id = serializers.UUIDField(read_only=True)
448+
449+
def validate_file(self, value):
450+
"""
451+
Validate that the uploaded file is a ZIP file.
452+
"""
453+
if value.content_type != 'application/zip':
454+
raise serializers.ValidationError("Only ZIP files are allowed.")
455+
return value
456+
457+
458+
class LibraryRestoreTaskRequestSerializer(serializers.Serializer):
459+
"""
460+
Serializer for requesting the status of a library restore task.
461+
"""
462+
task_id = serializers.UUIDField(write_only=True, help_text="The ID of the restore task to check.")
463+
464+
465+
class RestoreSuccessDataSerializer(serializers.Serializer):
466+
"""
467+
Serializer for the data returned upon successful restoration of a library.
468+
"""
469+
learning_package_id = serializers.IntegerField(source="lp_restored_data.id")
470+
title = serializers.CharField(source="lp_restored_data.title")
471+
org = serializers.CharField(source="lp_restored_data.archive_org_key")
472+
slug = serializers.CharField(source="lp_restored_data.archive_slug")
473+
474+
# The `key` is a unique temporary key assigned to the learning package during the restore process,
475+
# whereas the `archive_key` is the original key of the learning package from the backup.
476+
# The temporary learning package key is replaced with a standard key once it is added to a content library.
477+
key = serializers.CharField(source="lp_restored_data.key")
478+
archive_key = serializers.CharField(source="lp_restored_data.archive_lp_key")
479+
480+
containers = serializers.IntegerField(source="lp_restored_data.num_containers")
481+
components = serializers.IntegerField(source="lp_restored_data.num_components")
482+
collections = serializers.IntegerField(source="lp_restored_data.num_collections")
483+
sections = serializers.IntegerField(source="lp_restored_data.num_sections")
484+
subsections = serializers.IntegerField(source="lp_restored_data.num_subsections")
485+
units = serializers.IntegerField(source="lp_restored_data.num_units")
486+
487+
created_on_server = serializers.CharField(source="backup_metadata.original_server", required=False)
488+
created_at = serializers.DateTimeField(source="backup_metadata.created_at", format=DATETIME_FORMAT)
489+
created_by = serializers.SerializerMethodField()
490+
491+
def get_created_by(self, obj):
492+
"""
493+
Get the user information of the archive creator, if available.
494+
495+
The information is stored in the backup metadata of the archive and references
496+
a user that may not exist in the system where the restore is being performed.
497+
"""
498+
username = obj["backup_metadata"].get("created_by")
499+
email = obj["backup_metadata"].get("created_by_email")
500+
return {"username": username, "email": email}
501+
502+
503+
class LibraryRestoreTaskResultSerializer(serializers.Serializer):
504+
"""
505+
Serializer for the result of a library restore task.
506+
"""
507+
state = serializers.CharField()
508+
result = RestoreSuccessDataSerializer(required=False, allow_null=True, default=None)
509+
error = serializers.CharField(required=False, allow_blank=True, default=None)
510+
error_log = serializers.FileField(source='error_log_url', allow_null=True, use_url=True, default=None)
511+
512+
@classmethod
513+
def from_task_status(cls, task_status, request):
514+
"""Build serializer input from task status object."""
515+
516+
# If the task did not complete, just return the state.
517+
if task_status.state not in {UserTaskStatus.SUCCEEDED, UserTaskStatus.FAILED}:
518+
return cls({
519+
"state": task_status.state,
520+
})
521+
522+
artifact_name = LibraryRestoreTask.ARTIFACT_NAMES.get(task_status.state, '')
523+
artifact = task_status.artifacts.filter(name=artifact_name).first()
524+
525+
# If the task failed, include the log artifact if it exists
526+
if task_status.state == UserTaskStatus.FAILED:
527+
return cls({
528+
"state": UserTaskStatus.FAILED,
529+
"error": "Library restore failed. See error log for details.",
530+
"error_log_url": artifact.file if artifact else None,
531+
}, context={'request': request})
532+
533+
if task_status.state == UserTaskStatus.SUCCEEDED:
534+
input_data = {
535+
"state": UserTaskStatus.SUCCEEDED,
536+
}
537+
try:
538+
result = json.loads(artifact.text) if artifact else {}
539+
input_data["result"] = result
540+
except json.JSONDecodeError:
541+
log.error("Failed to decode JSON from artifact (%s): %s", artifact.id, artifact.text)
542+
input_data["error"] = f'Could not decode artifact JSON. Artifact Text: {artifact.text}'
543+
544+
return cls(input_data)

0 commit comments

Comments
 (0)