Skip to content

Commit ca4c169

Browse files
author
Taylor Payne
committed
fixup! feat: add library restore endpoint
1 parent 13b68ff commit ca4c169

4 files changed

Lines changed: 117 additions & 73 deletions

File tree

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

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@
100100
user_can_create_organizations
101101
)
102102
from cms.djangoapps.contentstore.storage import course_import_export_storage
103-
from openedx.core.djangoapps.content_libraries.tasks import restore_library, LibraryRestoreTask
103+
from openedx.core.djangoapps.content_libraries.tasks import restore_library
104104

105105
from openedx.core.djangoapps.content_libraries import api, permissions
106106
from openedx.core.djangoapps.content_libraries.api.libraries import get_backup_task_status
@@ -117,6 +117,7 @@
117117
LibraryBackupTaskStatusSerializer,
118118
LibraryRestoreFileSerializer,
119119
LibraryRestoreTaskSerializer,
120+
LibraryRestoreTaskResultSerializer,
120121
LibraryXBlockCreationSerializer,
121122
LibraryXBlockMetadataSerializer,
122123
LibraryXBlockTypeSerializer,
@@ -854,7 +855,7 @@ def post(self, request):
854855
description="The ID of the restore library task to retrieve."
855856
),
856857
],
857-
responses={200: LibraryRestoreTaskSerializer}
858+
responses={200: LibraryRestoreTaskResultSerializer}
858859
)
859860
def get(self, request):
860861
"""
@@ -867,18 +868,10 @@ def get(self, request):
867868

868869
# get task status and related artifact
869870
task_status = get_object_or_404(UserTaskStatus, task_id=task_id, user=request.user)
870-
artifact_name = LibraryRestoreTask.ARTIFACT_NAMES.get(task_status.state, '')
871-
artifact = task_status.artifacts.filter(name=artifact_name).first()
872871

873-
try:
874-
result = json.loads(artifact.text) if artifact else {'message': 'No artifact for the current task status.'}
875-
except json.JSONDecodeError:
876-
result = {'error': f'Could not decode artifact JSON. Artifact Text: {artifact.text}'}
877-
878-
return Response(LibraryRestoreTaskSerializer({
879-
'status': task_status.state,
880-
'result': result,
881-
}).data)
872+
# serialize and return result
873+
result_serializer = LibraryRestoreTaskResultSerializer.from_task_status(task_status, request)
874+
return Response(result_serializer.data)
882875

883876

884877
# LTI 1.3 Views

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

Lines changed: 79 additions & 5 deletions
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
811
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
"""
@@ -453,9 +460,76 @@ class LibraryRestoreTaskSerializer(serializers.Serializer):
453460
"""
454461
Serializer for result of a library restore task.
455462
"""
456-
# input only fields
457-
task_id = serializers.CharField(write_only=True, help_text="The ID of the restore task to check.")
463+
task_id = serializers.UUIDField(write_only=True, help_text="The ID of the restore task to check.")
458464

459-
# output only fields
460-
status = serializers.UUIDField(read_only=True)
461-
result = serializers.JSONField(read_only=True)
465+
466+
class RestoreSuccessDataSerializer(serializers.Serializer):
467+
"""
468+
Serializer for the data returned upon successful restoration of a library.
469+
"""
470+
learning_package_id = serializers.IntegerField(source="lp_restored_data.id")
471+
title = serializers.CharField(source="lp_restored_data.title")
472+
org = serializers.CharField(source="lp_restored_data.archive_org_key")
473+
slug = serializers.CharField(source="lp_restored_data.archive_slug")
474+
key = serializers.CharField(source="lp_restored_data.key")
475+
archive_key = serializers.CharField(source="lp_restored_data.archive_lp_key")
476+
477+
containers = serializers.IntegerField(source="lp_restored_data.num_containers")
478+
components = serializers.IntegerField(source="lp_restored_data.num_components")
479+
collections = serializers.IntegerField(source="lp_restored_data.num_collections")
480+
sections = serializers.IntegerField(source="lp_restored_data.num_sections")
481+
subsections = serializers.IntegerField(source="lp_restored_data.num_subsections")
482+
units = serializers.IntegerField(source="lp_restored_data.num_units")
483+
484+
created_on_server = serializers.CharField(source="backup_metadata.original_server", required=False)
485+
created_at = serializers.DateTimeField(source="backup_metadata.created_at", format=DATETIME_FORMAT)
486+
created_by = serializers.SerializerMethodField()
487+
488+
def get_created_by(self, obj):
489+
username = obj["backup_metadata"].get("created_by")
490+
email = obj["backup_metadata"].get("created_by_email")
491+
return {"username": username, "email": email}
492+
493+
494+
class LibraryRestoreTaskResultSerializer(serializers.Serializer):
495+
"""
496+
Serializer for the result of a library restore task.
497+
"""
498+
state = serializers.CharField()
499+
result = RestoreSuccessDataSerializer(required=False, allow_null=True, default=None)
500+
error = serializers.CharField(required=False, allow_blank=True, default=None)
501+
error_log = serializers.FileField(source='error_log_url', allow_null=True, use_url=True, default=None)
502+
503+
@classmethod
504+
def from_task_status(cls, task_status, request):
505+
"""Build serializer input from task status object."""
506+
507+
# If the task did not complete, just return the state.
508+
if task_status.state not in {UserTaskStatus.SUCCEEDED, UserTaskStatus.FAILED}:
509+
return cls({
510+
"state": task_status.state,
511+
})
512+
513+
artifact_name = LibraryRestoreTask.ARTIFACT_NAMES.get(task_status.state, '')
514+
artifact = task_status.artifacts.filter(name=artifact_name).first()
515+
516+
# If the task failed, include the log artifact if it exists
517+
if task_status.state == UserTaskStatus.FAILED:
518+
return cls({
519+
"state": UserTaskStatus.FAILED,
520+
"error": "Library restore failed. See error log for details.",
521+
"error_log_url": artifact.file if artifact else None,
522+
}, context={'request': request})
523+
524+
if task_status.state == UserTaskStatus.SUCCEEDED:
525+
input_data = {
526+
"state": UserTaskStatus.SUCCEEDED,
527+
}
528+
try:
529+
result = json.loads(artifact.text) if artifact else {}
530+
input_data["result"] = result
531+
except json.JSONDecodeError:
532+
log.error("Failed to decode JSON from artifact (%s): %s", artifact.id, artifact.text)
533+
input_data["error"] = f'Could not decode artifact JSON. Artifact Text: {artifact.text}'
534+
535+
return cls(input_data)

openedx/core/djangoapps/content_libraries/tasks.py

Lines changed: 7 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
from django.core.files.base import ContentFile
2828
from django.contrib.auth import get_user_model
29+
from django.core.serializers.json import DjangoJSONEncoder
2930
from celery import shared_task
3031
from celery.utils.log import get_task_logger
3132
from celery_utils.logged_task import LoggedTask
@@ -572,10 +573,12 @@ class LibraryRestoreTask(UserTask):
572573
"""
573574

574575
ARTIFACT_NAMES = {
575-
UserTaskStatus.FAILED: 'Error',
576+
UserTaskStatus.FAILED: 'Error log',
576577
UserTaskStatus.SUCCEEDED: 'Library Restore',
577578
}
578579

580+
ERROR_LOG_ARTIFACT_NAME = 'Error log'
581+
579582
@classmethod
580583
def generate_name(cls, arguments_dict):
581584
storage_path = arguments_dict['storage_path']
@@ -592,13 +595,11 @@ def fail_with_error_log(self, logfile) -> None:
592595
error_log_file = ContentFile(logfile.getvalue().encode("utf-8"))
593596

594597
# Save the error log as an artifact
595-
artifact = UserTaskArtifact(status=self.status, name='Error log')
598+
artifact = UserTaskArtifact(status=self.status, name=self.ERROR_LOG_ARTIFACT_NAME)
596599
artifact.file.save(name=f'{self.status.task_id}-error.log', content=error_log_file)
597600
artifact.save()
598601

599-
# Fail the task with a reference to the error log
600-
url = artifact.file.storage.url(artifact.file.name)
601-
self.status.fail(json.dumps({'error': 'Error(s) restoring learning package', 'log_file': url}))
602+
self.status.fail(json.dumps({'error': 'Error(s) restoring learning package'}))
602603

603604
def load_learning_package(self, storage_path, user):
604605
"""
@@ -651,39 +652,15 @@ def restore_library(self, user_id, storage_path):
651652
user = User.objects.get(id=user_id)
652653
result = self.load_learning_package(storage_path, user=user)
653654
learning_package_data = result.get("lp_restored_data", {})
654-
backup_metadata = result.get("backup_metadata", {})
655655

656656
TASK_LOGGER.info(
657657
'Restored learning package (id: %s) with key %s',
658658
learning_package_data.get('id'),
659659
learning_package_data.get('key')
660660
)
661661

662-
# Ensure any datetime value is formatted correctly
663-
if backup_created_at := backup_metadata.get("created_at"):
664-
backup_created_at = backup_created_at.strftime(DATETIME_FORMAT)
665-
666662
# Save the restore details as an artifact in JSON format
667-
restore_data = json.dumps({
668-
"learning_package_id": learning_package_data.get("id"),
669-
"title": learning_package_data.get("title"),
670-
"org": learning_package_data.get("archive_org_key"),
671-
"slug": learning_package_data.get("archive_slug"),
672-
"key": learning_package_data.get("key"),
673-
"archive_key": learning_package_data.get("archive_lp_key"),
674-
"containers": learning_package_data.get("num_containers", -1),
675-
"components": learning_package_data.get("num_components", -1),
676-
"collections": learning_package_data.get("num_collections", -1),
677-
"sections": learning_package_data.get("num_sections", -1),
678-
"subsections": learning_package_data.get("num_subsections", -1),
679-
"units": learning_package_data.get("num_units", -1),
680-
"created_on_server": backup_metadata.get("original_server"),
681-
"created_at": backup_created_at,
682-
"created_by": {
683-
"username": backup_metadata.get("created_by"),
684-
"email": backup_metadata.get("created_by_email"),
685-
},
686-
})
663+
restore_data = json.dumps(result, cls=DjangoJSONEncoder)
687664

688665
UserTaskArtifact.objects.create(
689666
status=self.status,

openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -992,8 +992,8 @@ def test_restore_library_success(self):
992992
with self.as_user(self.admin_user):
993993
response_data = self._get_library_restore_task(response_data['task_id'])
994994

995-
self.assertIn('status', response_data)
996-
self.assertEqual(response_data['status'], 'Succeeded')
995+
self.assertIn('state', response_data)
996+
self.assertEqual(response_data['state'], 'Succeeded')
997997

998998
self.assertIn('result', response_data)
999999
task_result = response_data.get('result', {})
@@ -1039,8 +1039,8 @@ def test_create_content_library_from_restore(self):
10391039
with self.as_user(self.admin_user):
10401040
response_data = self._get_library_restore_task(response_data['task_id'])
10411041

1042-
self.assertIn('status', response_data)
1043-
self.assertEqual(response_data['status'], 'Succeeded')
1042+
self.assertIn('state', response_data)
1043+
self.assertEqual(response_data['state'], 'Succeeded')
10441044

10451045
task_result = response_data.get('result', {})
10461046
self.assertIn('learning_package_id', task_result)
@@ -1094,10 +1094,10 @@ def test_get_restore_task_unfinished(self):
10941094
response_data = self._get_library_restore_task(pending_task_status.task_id)
10951095

10961096
expected = {
1097-
"status": UserTaskStatus.PENDING,
1098-
"result": {
1099-
"message": "No artifact for the current task status."
1100-
}
1097+
"state": UserTaskStatus.PENDING,
1098+
"result": None,
1099+
"error": None,
1100+
"error_log": None,
11011101
}
11021102

11031103
self.assertEqual(response_data, expected)
@@ -1110,7 +1110,7 @@ def test_get_restore_task_unfinished(self):
11101110
):
11111111
response_data = self._get_library_restore_task(in_progress_task_status.task_id)
11121112

1113-
expected["status"] = UserTaskStatus.IN_PROGRESS
1113+
expected["state"] = UserTaskStatus.IN_PROGRESS
11141114
self.assertEqual(response_data, expected)
11151115

11161116
def test_task_user_mismatch(self):
@@ -1136,7 +1136,7 @@ def test_task_artifact_text_not_json(self):
11361136
UserTaskArtifact.objects.create(
11371137
status=task_status,
11381138
text=artifact_text,
1139-
name=LibraryRestoreTask.ARTIFACT_NAMES.get(task_status.state, '')
1139+
name=LibraryRestoreTask.ARTIFACT_NAMES[task_status.state],
11401140
)
11411141

11421142
with patch(
@@ -1146,10 +1146,10 @@ def test_task_artifact_text_not_json(self):
11461146
response_data = self._get_library_restore_task(task_status.task_id)
11471147

11481148
expected = {
1149-
"status": UserTaskStatus.SUCCEEDED,
1150-
"result": {
1151-
'error': f'Could not decode artifact JSON. Artifact Text: {artifact_text}'
1152-
}
1149+
"state": UserTaskStatus.SUCCEEDED,
1150+
"result": None,
1151+
"error": ANY,
1152+
"error_log": None,
11531153
}
11541154

11551155
self.assertEqual(response_data, expected)
@@ -1159,8 +1159,10 @@ def test_failed_task_with_error_log(self):
11591159
If a task fails with an error log, include the url to the log
11601160
"""
11611161
error_result = {
1162-
'log_file_error': StringIO("Library restore failed: An unexpected error occurred during processing."),
11631162
'status': 'error',
1163+
'log_file_error': StringIO("Library restore failed: An unexpected error occurred during processing."),
1164+
'lp_restore_data': None,
1165+
'backup_metadata': None,
11641166
}
11651167

11661168
with self.as_user(self.admin_user):
@@ -1174,11 +1176,10 @@ def test_failed_task_with_error_log(self):
11741176
task_data = self._get_library_restore_task(response['task_id'])
11751177

11761178
expected = {
1177-
'status': 'Failed',
1178-
'result': {
1179-
'error': ANY,
1180-
'log_file': ANY,
1181-
}
1179+
'state': 'Failed',
1180+
'error': ANY,
1181+
'error_log': ANY,
1182+
'result': None,
11821183
}
11831184

11841185
self.assertEqual(task_data, expected)
@@ -1198,11 +1199,10 @@ def test_uncaught_error_creates_error_log(self):
11981199
task_data = self._get_library_restore_task(response['task_id'])
11991200

12001201
expected = {
1201-
'status': 'Failed',
1202-
'result': {
1203-
'error': ANY,
1204-
'log_file': ANY,
1205-
}
1202+
'state': 'Failed',
1203+
'error': ANY,
1204+
'error_log': ANY,
1205+
'result': None,
12061206
}
12071207

12081208
self.assertEqual(task_data, expected)

0 commit comments

Comments
 (0)