Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions openedx/core/djangoapps/content_libraries/api/libraries.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
CONTENT_LIBRARY_UPDATED
)
from openedx_learning.api import authoring as authoring_api
from openedx_learning.api.authoring_models import Component
from openedx_learning.api.authoring_models import Component, LearningPackage
from organizations.models import Organization
from user_tasks.models import UserTaskArtifact, UserTaskStatus
from xblock.core import XBlock
Expand Down Expand Up @@ -384,6 +384,7 @@ def create_library(
allow_public_learning: bool = False,
allow_public_read: bool = False,
library_license: str = ALL_RIGHTS_RESERVED,
learning_package: LearningPackage | None = None,
Comment thread
wgu-taylor-payne marked this conversation as resolved.
) -> ContentLibraryMetadata:
"""
Create a new content library.
Expand All @@ -400,6 +401,8 @@ def create_library(

allow_public_read: Allow anyone to view blocks (including source) in Studio?

learning_package: A learning package to associate with this library.

Returns a ContentLibraryMetadata instance.
"""
assert isinstance(org, Organization)
Expand All @@ -413,14 +416,25 @@ def create_library(
allow_public_read=allow_public_read,
license=library_license,
)
learning_package = authoring_api.create_learning_package(
key=str(ref.library_key),
title=title,
description=description,
)

if learning_package:
# A temporary LearningPackage was passed in, so update its key to match the library,
# and also update its title/description in case they differ.
authoring_api.update_learning_package(
learning_package.id,
key=str(ref.library_key),
title=title,
description=description,
)
else:
# We have to generate a new LearningPackage for this library.
learning_package = authoring_api.create_learning_package(
key=str(ref.library_key),
title=title,
description=description,
)
ref.learning_package = learning_package
ref.save()

except IntegrityError:
raise LibraryAlreadyExists(slug) # lint-amnesty, pylint: disable=raise-missing-from

Expand All @@ -431,6 +445,7 @@ def create_library(
library_key=ref.library_key
)
)

return ContentLibraryMetadata(
key=ref.library_key,
title=title,
Expand Down
84 changes: 84 additions & 0 deletions openedx/core/djangoapps/content_libraries/rest_api/libraries.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@
from django.views.decorators.csrf import csrf_exempt
from django.views.generic.base import TemplateResponseMixin, View
from drf_yasg.utils import swagger_auto_schema
from user_tasks.models import UserTaskStatus

from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
from organizations.api import ensure_organization
from organizations.exceptions import InvalidOrganizationException
Expand All @@ -97,6 +99,9 @@
get_allowed_organizations_for_libraries,
user_can_create_organizations
)
from cms.djangoapps.contentstore.storage import course_import_export_storage
from openedx.core.djangoapps.content_libraries.tasks import restore_library

from openedx.core.djangoapps.content_libraries import api, permissions
from openedx.core.djangoapps.content_libraries.api.libraries import get_backup_task_status
from openedx.core.djangoapps.content_libraries.rest_api.serializers import (
Expand All @@ -110,6 +115,9 @@
ContentLibraryUpdateSerializer,
LibraryBackupResponseSerializer,
LibraryBackupTaskStatusSerializer,
LibraryRestoreFileSerializer,
LibraryRestoreTaskRequestSerializer,
LibraryRestoreTaskResultSerializer,
LibraryXBlockCreationSerializer,
LibraryXBlockMetadataSerializer,
LibraryXBlockTypeSerializer,
Expand Down Expand Up @@ -790,6 +798,82 @@ def get(self, request, lib_key_str):
return Response(LibraryBackupTaskStatusSerializer(result, context={'request': request}).data)


@method_decorator(non_atomic_requests, name="dispatch")
@view_auth_classes()
class LibraryRestoreView(APIView):
"""
Restore a library from a backup file.

After the file is uploaded, a background task will be started to process the
file and restore the library contents. You can use the returned `task_id` to
check the status of the restore task.

The result of the restore task will be a "staged" learning package that can
then be saved into a content library.

**POST Parameters**

A POST request must include the following parameters.

* file: (required) The backup file to restore the library from. Must be a
.zip file.

**GET Parameters**

A GET request must include the following parameters.

* task_id: (required) The UUID of a restore task.
"""
@apidocs.schema(
body=LibraryRestoreFileSerializer,
responses={200: LibraryRestoreFileSerializer}
)
def post(self, request):
"""
Restore a library from a backup file.
"""
if not request.user.has_perm(permissions.CAN_CREATE_CONTENT_LIBRARY):
raise PermissionDenied

serializer = LibraryRestoreFileSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
upload = serializer.validated_data['file']

storage_path = course_import_export_storage.save(f'library_restore/{upload.name}', upload)
Comment thread
wgu-taylor-payne marked this conversation as resolved.

log.info("Learning package archive upload %s: Upload complete", upload.name)

async_result = restore_library.delay(request.user.id, storage_path)

return Response(LibraryRestoreFileSerializer({'task_id': async_result.task_id}).data)

@apidocs.schema(
parameters=[
apidocs.query_parameter(
'task_id',
str,
description="The ID of the restore library task to retrieve."
),
],
responses={200: LibraryRestoreTaskResultSerializer}
)
def get(self, request):
"""
Check the status of a library restore task.
"""
# validate input
serializer = LibraryRestoreTaskRequestSerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
task_id = serializer.validated_data.get('task_id')

# get task status and related artifact
task_status = get_object_or_404(UserTaskStatus, task_id=task_id, user=request.user)

# serialize and return result
result_serializer = LibraryRestoreTaskResultSerializer.from_task_status(task_status, request)
return Response(result_serializer.data)


# LTI 1.3 Views
# =============

Expand Down
118 changes: 117 additions & 1 deletion openedx/core/djangoapps/content_libraries/rest_api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@
Serializers for the content libraries REST API
"""
# pylint: disable=abstract-method
import json
import logging

from django.core.validators import validate_unicode_slug
from opaque_keys import InvalidKeyError, OpaqueKey
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryUsageLocatorV2
from openedx_learning.api.authoring_models import Collection
from openedx_learning.api.authoring_models import Collection, LearningPackage
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from user_tasks.models import UserTaskStatus

from openedx.core.djangoapps.content_libraries.tasks import LibraryRestoreTask
from openedx.core.djangoapps.content_libraries.api.containers import ContainerType
from openedx.core.djangoapps.content_libraries.constants import ALL_RIGHTS_RESERVED, LICENSE_OPTIONS
from openedx.core.djangoapps.content_libraries.models import (
Expand All @@ -22,6 +27,8 @@

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

log = logging.getLogger(__name__)


class ContentLibraryMetadataSerializer(serializers.Serializer):
"""
Expand All @@ -37,6 +44,7 @@ class ContentLibraryMetadataSerializer(serializers.Serializer):
slug = serializers.CharField(source="key.slug", validators=(validate_unicode_slug, ))
title = serializers.CharField()
description = serializers.CharField(allow_blank=True)
learning_package = serializers.PrimaryKeyRelatedField(queryset=LearningPackage.objects.all(), required=False)
num_blocks = serializers.IntegerField(read_only=True)
last_published = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True)
published_by = serializers.CharField(read_only=True)
Expand Down Expand Up @@ -426,3 +434,111 @@ class LibraryBackupTaskStatusSerializer(serializers.Serializer):
"""
state = serializers.CharField()
url = serializers.FileField(source='file', allow_null=True, use_url=True)


class LibraryRestoreFileSerializer(serializers.Serializer):
"""
Serializer for restoring a library from a backup file.
"""
# input only fields
file = serializers.FileField(write_only=True, help_text="A ZIP file containing a library backup.")

# output only fields
task_id = serializers.UUIDField(read_only=True)

def validate_file(self, value):
"""
Validate that the uploaded file is a ZIP file.
"""
if value.content_type != 'application/zip':
raise serializers.ValidationError("Only ZIP files are allowed.")
return value


class LibraryRestoreTaskRequestSerializer(serializers.Serializer):
"""
Serializer for requesting the status of a library restore task.
"""
task_id = serializers.UUIDField(write_only=True, help_text="The ID of the restore task to check.")


class RestoreSuccessDataSerializer(serializers.Serializer):
"""
Serializer for the data returned upon successful restoration of a library.
"""
learning_package_id = serializers.IntegerField(source="lp_restored_data.id")
title = serializers.CharField(source="lp_restored_data.title")
org = serializers.CharField(source="lp_restored_data.archive_org_key")
slug = serializers.CharField(source="lp_restored_data.archive_slug")

# The `key` is a unique temporary key assigned to the learning package during the restore process,
# whereas the `archive_key` is the original key of the learning package from the backup.
# The temporary learning package key is replaced with a standard key once it is added to a content library.
key = serializers.CharField(source="lp_restored_data.key")
archive_key = serializers.CharField(source="lp_restored_data.archive_lp_key")
Comment thread
ormsbee marked this conversation as resolved.

containers = serializers.IntegerField(source="lp_restored_data.num_containers")
components = serializers.IntegerField(source="lp_restored_data.num_components")
collections = serializers.IntegerField(source="lp_restored_data.num_collections")
sections = serializers.IntegerField(source="lp_restored_data.num_sections")
subsections = serializers.IntegerField(source="lp_restored_data.num_subsections")
units = serializers.IntegerField(source="lp_restored_data.num_units")

created_on_server = serializers.CharField(source="backup_metadata.original_server", required=False)
created_at = serializers.DateTimeField(source="backup_metadata.created_at", format=DATETIME_FORMAT)
created_by = serializers.SerializerMethodField()

def get_created_by(self, obj):
Comment thread
ormsbee marked this conversation as resolved.
"""
Get the user information of the archive creator, if available.

The information is stored in the backup metadata of the archive and references
a user that may not exist in the system where the restore is being performed.
"""
username = obj["backup_metadata"].get("created_by")
email = obj["backup_metadata"].get("created_by_email")
return {"username": username, "email": email}


class LibraryRestoreTaskResultSerializer(serializers.Serializer):
"""
Serializer for the result of a library restore task.
"""
state = serializers.CharField()
result = RestoreSuccessDataSerializer(required=False, allow_null=True, default=None)
error = serializers.CharField(required=False, allow_blank=True, default=None)
error_log = serializers.FileField(source='error_log_url', allow_null=True, use_url=True, default=None)

@classmethod
def from_task_status(cls, task_status, request):
"""Build serializer input from task status object."""

# If the task did not complete, just return the state.
if task_status.state not in {UserTaskStatus.SUCCEEDED, UserTaskStatus.FAILED}:
return cls({
"state": task_status.state,
})

artifact_name = LibraryRestoreTask.ARTIFACT_NAMES.get(task_status.state, '')
artifact = task_status.artifacts.filter(name=artifact_name).first()

# If the task failed, include the log artifact if it exists
if task_status.state == UserTaskStatus.FAILED:
return cls({
"state": UserTaskStatus.FAILED,
"error": "Library restore failed. See error log for details.",
"error_log_url": artifact.file if artifact else None,
}, context={'request': request})

if task_status.state == UserTaskStatus.SUCCEEDED:
input_data = {
"state": UserTaskStatus.SUCCEEDED,
}
try:
result = json.loads(artifact.text) if artifact else {}
input_data["result"] = result
except json.JSONDecodeError:
log.error("Failed to decode JSON from artifact (%s): %s", artifact.id, artifact.text)
input_data["error"] = f'Could not decode artifact JSON. Artifact Text: {artifact.text}'

return cls(input_data)
Loading
Loading