Skip to content

Commit 0c9997c

Browse files
rodmgwguormsbee
authored andcommitted
feat: Implementation of library v2 backup endpoints
1 parent 9135980 commit 0c9997c

File tree

9 files changed

+446
-48
lines changed

9 files changed

+446
-48
lines changed

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

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,10 @@
4141
"""
4242
from __future__ import annotations
4343

44-
from dataclasses import dataclass, field as dataclass_field
45-
from datetime import datetime
4644
import logging
45+
from dataclasses import dataclass
46+
from dataclasses import field as dataclass_field
47+
from datetime import datetime
4748

4849
from django.conf import settings
4950
from django.contrib.auth.models import AbstractUser, AnonymousUser, Group
@@ -53,29 +54,24 @@
5354
from django.db.models import Q, QuerySet
5455
from django.utils.translation import gettext as _
5556
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
56-
from openedx_events.content_authoring.data import (
57-
ContentLibraryData,
58-
)
57+
from openedx_events.content_authoring.data import ContentLibraryData
5958
from openedx_events.content_authoring.signals import (
6059
CONTENT_LIBRARY_CREATED,
6160
CONTENT_LIBRARY_DELETED,
62-
CONTENT_LIBRARY_UPDATED,
61+
CONTENT_LIBRARY_UPDATED
6362
)
6463
from openedx_learning.api import authoring as authoring_api
6564
from openedx_learning.api.authoring_models import Component
6665
from organizations.models import Organization
66+
from user_tasks.models import UserTaskArtifact, UserTaskStatus
6767
from xblock.core import XBlock
6868

6969
from openedx.core.types import User as UserType
7070

71-
from .. import permissions
71+
from .. import permissions, tasks
7272
from ..constants import ALL_RIGHTS_RESERVED
7373
from ..models import ContentLibrary, ContentLibraryPermission
74-
from .. import tasks
75-
from .exceptions import (
76-
LibraryAlreadyExists,
77-
LibraryPermissionIntegrityError,
78-
)
74+
from .exceptions import LibraryAlreadyExists, LibraryPermissionIntegrityError
7975

8076
log = logging.getLogger(__name__)
8177

@@ -105,6 +101,7 @@
105101
"get_allowed_block_types",
106102
"publish_changes",
107103
"revert_changes",
104+
"get_backup_task_status",
108105
]
109106

110107

@@ -692,3 +689,30 @@ def revert_changes(library_key: LibraryLocatorV2, user_id: int | None = None) ->
692689

693690
# Call the event handlers as needed.
694691
tasks.wait_for_post_revert_events(draft_change_log, library_key)
692+
693+
694+
def get_backup_task_status(
695+
user_id: int,
696+
task_id: str
697+
) -> dict | None:
698+
"""
699+
Get the status of a library backup task.
700+
701+
Returns a dictionary with the following keys:
702+
- state: One of "Pending", "Exporting", "Succeeded", "Failed"
703+
- url: If state is "Succeeded", the URL where the exported .zip file can be downloaded. Otherwise, None.
704+
If no task is found, returns None.
705+
"""
706+
707+
try:
708+
task_status = UserTaskStatus.objects.get(task_id=task_id, user_id=user_id)
709+
except UserTaskStatus.DoesNotExist:
710+
return None
711+
712+
result = {'state': task_status.state, 'url': None}
713+
714+
if task_status.state == UserTaskStatus.SUCCEEDED:
715+
artifact = UserTaskArtifact.objects.get(status=task_status, name='Output')
716+
result['url'] = artifact.file.storage.url(artifact.file.name)
717+
718+
return result

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

Lines changed: 116 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
import json
6767
import logging
6868

69+
import edx_api_doc_tools as apidocs
6970
from django.conf import settings
7071
from django.contrib.auth import authenticate, get_user_model, login
7172
from django.contrib.auth.models import Group
@@ -78,48 +79,49 @@
7879
from django.views.decorators.csrf import csrf_exempt
7980
from django.views.generic.base import TemplateResponseMixin, View
8081
from drf_yasg.utils import swagger_auto_schema
81-
from pylti1p3.contrib.django import DjangoCacheDataStorage, DjangoDbToolConf, DjangoMessageLaunch, DjangoOIDCLogin
82-
from pylti1p3.exception import LtiException, OIDCException
83-
84-
import edx_api_doc_tools as apidocs
8582
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
8683
from organizations.api import ensure_organization
8784
from organizations.exceptions import InvalidOrganizationException
8885
from organizations.models import Organization
86+
from pylti1p3.contrib.django import DjangoCacheDataStorage, DjangoDbToolConf, DjangoMessageLaunch, DjangoOIDCLogin
87+
from pylti1p3.exception import LtiException, OIDCException
8988
from rest_framework import status
9089
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
9190
from rest_framework.generics import GenericAPIView
9291
from rest_framework.response import Response
9392
from rest_framework.views import APIView
9493
from rest_framework.viewsets import GenericViewSet
9594

95+
import openedx.core.djangoapps.site_configuration.helpers as configuration_helpers
9696
from cms.djangoapps.contentstore.views.course import (
9797
get_allowed_organizations_for_libraries,
98-
user_can_create_organizations,
98+
user_can_create_organizations
9999
)
100100
from openedx.core.djangoapps.content_libraries import api, permissions
101+
from openedx.core.djangoapps.content_libraries.api.libraries import get_backup_task_status
101102
from openedx.core.djangoapps.content_libraries.rest_api.serializers import (
103+
ContentLibraryAddPermissionByEmailSerializer,
102104
ContentLibraryBlockImportTaskCreateSerializer,
103105
ContentLibraryBlockImportTaskSerializer,
104106
ContentLibraryFilterSerializer,
105107
ContentLibraryMetadataSerializer,
106108
ContentLibraryPermissionLevelSerializer,
107109
ContentLibraryPermissionSerializer,
108110
ContentLibraryUpdateSerializer,
111+
LibraryBackupResponseSerializer,
112+
LibraryBackupTaskStatusSerializer,
109113
LibraryXBlockCreationSerializer,
110114
LibraryXBlockMetadataSerializer,
111115
LibraryXBlockTypeSerializer,
112-
ContentLibraryAddPermissionByEmailSerializer,
113-
PublishableItemSerializer,
116+
PublishableItemSerializer
114117
)
115-
import openedx.core.djangoapps.site_configuration.helpers as configuration_helpers
116-
from openedx.core.lib.api.view_utils import view_auth_classes
118+
from openedx.core.djangoapps.content_libraries.tasks import backup_library
117119
from openedx.core.djangoapps.safe_sessions.middleware import mark_user_change_as_expected
118120
from openedx.core.djangoapps.xblock import api as xblock_api
121+
from openedx.core.lib.api.view_utils import view_auth_classes
119122

120-
from .utils import convert_exceptions
121123
from ..models import ContentLibrary, LtiGradedResource, LtiProfile
122-
124+
from .utils import convert_exceptions
123125

124126
User = get_user_model()
125127
log = logging.getLogger(__name__)
@@ -685,6 +687,109 @@ def retrieve(self, request, lib_key_str, pk=None):
685687
return Response(ContentLibraryBlockImportTaskSerializer(import_task).data)
686688

687689

690+
# Library Backup Views
691+
# ====================
692+
693+
@method_decorator(non_atomic_requests, name="dispatch")
694+
@view_auth_classes()
695+
class LibraryBackupView(APIView):
696+
"""
697+
**Use Case**
698+
* Start an asynchronous task to back up the content of a library to a .zip file
699+
* Get a status on an asynchronous export task
700+
701+
**Example Requests**
702+
POST /api/libraries/v2/{library_id}/backup/
703+
GET /api/libraries/v2/{library_id}/backup/?task_id={task_id}
704+
705+
**POST Response Values**
706+
707+
If the import task is started successfully, an HTTP 200 "OK" response is
708+
returned.
709+
710+
The HTTP 200 response has the following values:
711+
712+
* task_id: UUID of the created task, usable for checking status
713+
714+
**Example POST Response**
715+
716+
{
717+
"task_id": "7069b95b-ccea-4214-b6db-e00f27065bf7"
718+
}
719+
720+
**GET Parameters**
721+
722+
A GET request must include the following parameters:
723+
724+
* task_id: (required) The UUID of the task to check.
725+
726+
**GET Response Values**
727+
728+
If the import task is found successfully by the UUID provided, an HTTP
729+
200 "OK" response is returned.
730+
731+
The HTTP 200 response has the following values:
732+
733+
* state: String description of the state of the task.
734+
Possible states: "Pending", "Exporting", "Succeeded", "Failed".
735+
* url: (may be null) If the task is complete, a URL to download the .zip file
736+
737+
**Example GET Response**
738+
{
739+
"state": "Succeeded",
740+
"url": "/media/user_tasks/2025/10/03/lib-wgu-csprob-2025-10-03-153633.zip"
741+
}
742+
743+
"""
744+
745+
@apidocs.schema(
746+
body=None,
747+
responses={200: LibraryBackupResponseSerializer}
748+
)
749+
@convert_exceptions
750+
def post(self, request, lib_key_str):
751+
"""
752+
Start backup task for the specified library.
753+
"""
754+
library_key = LibraryLocatorV2.from_string(lib_key_str)
755+
# Using CAN_EDIT_THIS_CONTENT_LIBRARY permission for now. This should eventually become its own permission
756+
api.require_permission_for_library_key(library_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
757+
758+
async_result = backup_library.delay(request.user.id, str(library_key))
759+
result = {'task_id': async_result.task_id}
760+
761+
return Response(LibraryBackupResponseSerializer(result).data)
762+
763+
@apidocs.schema(
764+
parameters=[
765+
apidocs.query_parameter(
766+
'task_id',
767+
str,
768+
description="The ID of the backup task to retrieve."
769+
),
770+
],
771+
responses={200: LibraryBackupTaskStatusSerializer}
772+
)
773+
@convert_exceptions
774+
def get(self, request, lib_key_str):
775+
"""
776+
Get the status of the specified backup task for the specified library.
777+
"""
778+
library_key = LibraryLocatorV2.from_string(lib_key_str)
779+
# Using CAN_EDIT_THIS_CONTENT_LIBRARY permission for now. This should eventually become its own permission
780+
api.require_permission_for_library_key(library_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
781+
782+
task_id = request.query_params.get('task_id', None)
783+
if not task_id:
784+
raise ValidationError(detail={'task_id': _('This field is required.')})
785+
result = get_backup_task_status(request.user.id, task_id)
786+
787+
if not result:
788+
raise NotFound(detail="No backup found for this library.")
789+
790+
return Response(LibraryBackupTaskStatusSerializer(result).data)
791+
792+
688793
# LTI 1.3 Views
689794
# =============
690795

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

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,22 @@
33
"""
44
# pylint: disable=abstract-method
55
from django.core.validators import validate_unicode_slug
6+
from opaque_keys import InvalidKeyError, OpaqueKey
7+
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryUsageLocatorV2
8+
from openedx_learning.api.authoring_models import Collection
69
from rest_framework import serializers
710
from rest_framework.exceptions import ValidationError
811

9-
from opaque_keys import OpaqueKey
10-
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryUsageLocatorV2
11-
from opaque_keys import InvalidKeyError
12-
13-
from openedx_learning.api.authoring_models import Collection
1412
from openedx.core.djangoapps.content_libraries.api.containers import ContainerType
15-
from openedx.core.djangoapps.content_libraries.constants import (
16-
ALL_RIGHTS_RESERVED,
17-
LICENSE_OPTIONS,
18-
)
13+
from openedx.core.djangoapps.content_libraries.constants import ALL_RIGHTS_RESERVED, LICENSE_OPTIONS
1914
from openedx.core.djangoapps.content_libraries.models import (
20-
ContentLibraryPermission, ContentLibraryBlockImportTask,
21-
ContentLibrary
15+
ContentLibrary,
16+
ContentLibraryBlockImportTask,
17+
ContentLibraryPermission
2218
)
2319
from openedx.core.lib.api.serializers import CourseKeyField
24-
from .. import permissions
2520

21+
from .. import permissions
2622

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

@@ -416,3 +412,18 @@ class ContainerHierarchySerializer(serializers.Serializer):
416412
units = serializers.ListField(child=ContainerHierarchyMemberSerializer(), allow_empty=True)
417413
components = serializers.ListField(child=ContainerHierarchyMemberSerializer(), allow_empty=True)
418414
object_key = OpaqueKeySerializer()
415+
416+
417+
class LibraryBackupResponseSerializer(serializers.Serializer):
418+
"""
419+
Serializer for the response after requesting a backup of a content library.
420+
"""
421+
task_id = serializers.CharField()
422+
423+
424+
class LibraryBackupTaskStatusSerializer(serializers.Serializer):
425+
"""
426+
Serializer for checking the status of a library backup task.
427+
"""
428+
state = serializers.CharField()
429+
url = serializers.URLField(allow_null=True)

0 commit comments

Comments
 (0)