feat: Implementation of library v2 backup endpoints
This commit is contained in:
committed by
David Ormsbee
parent
913598076c
commit
0c9997ce92
@@ -41,9 +41,10 @@ could be promoted to the core XBlock API and made generic.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field as dataclass_field
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field as dataclass_field
|
||||
from datetime import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser, AnonymousUser, Group
|
||||
@@ -53,29 +54,24 @@ from django.db import IntegrityError, transaction
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.utils.translation import gettext as _
|
||||
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
|
||||
from openedx_events.content_authoring.data import (
|
||||
ContentLibraryData,
|
||||
)
|
||||
from openedx_events.content_authoring.data import ContentLibraryData
|
||||
from openedx_events.content_authoring.signals import (
|
||||
CONTENT_LIBRARY_CREATED,
|
||||
CONTENT_LIBRARY_DELETED,
|
||||
CONTENT_LIBRARY_UPDATED,
|
||||
CONTENT_LIBRARY_UPDATED
|
||||
)
|
||||
from openedx_learning.api import authoring as authoring_api
|
||||
from openedx_learning.api.authoring_models import Component
|
||||
from organizations.models import Organization
|
||||
from user_tasks.models import UserTaskArtifact, UserTaskStatus
|
||||
from xblock.core import XBlock
|
||||
|
||||
from openedx.core.types import User as UserType
|
||||
|
||||
from .. import permissions
|
||||
from .. import permissions, tasks
|
||||
from ..constants import ALL_RIGHTS_RESERVED
|
||||
from ..models import ContentLibrary, ContentLibraryPermission
|
||||
from .. import tasks
|
||||
from .exceptions import (
|
||||
LibraryAlreadyExists,
|
||||
LibraryPermissionIntegrityError,
|
||||
)
|
||||
from .exceptions import LibraryAlreadyExists, LibraryPermissionIntegrityError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -105,6 +101,7 @@ __all__ = [
|
||||
"get_allowed_block_types",
|
||||
"publish_changes",
|
||||
"revert_changes",
|
||||
"get_backup_task_status",
|
||||
]
|
||||
|
||||
|
||||
@@ -692,3 +689,30 @@ def revert_changes(library_key: LibraryLocatorV2, user_id: int | None = None) ->
|
||||
|
||||
# Call the event handlers as needed.
|
||||
tasks.wait_for_post_revert_events(draft_change_log, library_key)
|
||||
|
||||
|
||||
def get_backup_task_status(
|
||||
user_id: int,
|
||||
task_id: str
|
||||
) -> dict | None:
|
||||
"""
|
||||
Get the status of a library backup task.
|
||||
|
||||
Returns a dictionary with the following keys:
|
||||
- state: One of "Pending", "Exporting", "Succeeded", "Failed"
|
||||
- url: If state is "Succeeded", the URL where the exported .zip file can be downloaded. Otherwise, None.
|
||||
If no task is found, returns None.
|
||||
"""
|
||||
|
||||
try:
|
||||
task_status = UserTaskStatus.objects.get(task_id=task_id, user_id=user_id)
|
||||
except UserTaskStatus.DoesNotExist:
|
||||
return None
|
||||
|
||||
result = {'state': task_status.state, 'url': None}
|
||||
|
||||
if task_status.state == UserTaskStatus.SUCCEEDED:
|
||||
artifact = UserTaskArtifact.objects.get(status=task_status, name='Output')
|
||||
result['url'] = artifact.file.storage.url(artifact.file.name)
|
||||
|
||||
return result
|
||||
|
||||
@@ -66,6 +66,7 @@ import itertools
|
||||
import json
|
||||
import logging
|
||||
|
||||
import edx_api_doc_tools as apidocs
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate, get_user_model, login
|
||||
from django.contrib.auth.models import Group
|
||||
@@ -78,14 +79,12 @@ from django.views.decorators.clickjacking import xframe_options_exempt
|
||||
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 pylti1p3.contrib.django import DjangoCacheDataStorage, DjangoDbToolConf, DjangoMessageLaunch, DjangoOIDCLogin
|
||||
from pylti1p3.exception import LtiException, OIDCException
|
||||
|
||||
import edx_api_doc_tools as apidocs
|
||||
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
|
||||
from organizations.api import ensure_organization
|
||||
from organizations.exceptions import InvalidOrganizationException
|
||||
from organizations.models import Organization
|
||||
from pylti1p3.contrib.django import DjangoCacheDataStorage, DjangoDbToolConf, DjangoMessageLaunch, DjangoOIDCLogin
|
||||
from pylti1p3.exception import LtiException, OIDCException
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
|
||||
from rest_framework.generics import GenericAPIView
|
||||
@@ -93,12 +92,15 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
import openedx.core.djangoapps.site_configuration.helpers as configuration_helpers
|
||||
from cms.djangoapps.contentstore.views.course import (
|
||||
get_allowed_organizations_for_libraries,
|
||||
user_can_create_organizations,
|
||||
user_can_create_organizations
|
||||
)
|
||||
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 (
|
||||
ContentLibraryAddPermissionByEmailSerializer,
|
||||
ContentLibraryBlockImportTaskCreateSerializer,
|
||||
ContentLibraryBlockImportTaskSerializer,
|
||||
ContentLibraryFilterSerializer,
|
||||
@@ -106,20 +108,20 @@ from openedx.core.djangoapps.content_libraries.rest_api.serializers import (
|
||||
ContentLibraryPermissionLevelSerializer,
|
||||
ContentLibraryPermissionSerializer,
|
||||
ContentLibraryUpdateSerializer,
|
||||
LibraryBackupResponseSerializer,
|
||||
LibraryBackupTaskStatusSerializer,
|
||||
LibraryXBlockCreationSerializer,
|
||||
LibraryXBlockMetadataSerializer,
|
||||
LibraryXBlockTypeSerializer,
|
||||
ContentLibraryAddPermissionByEmailSerializer,
|
||||
PublishableItemSerializer,
|
||||
PublishableItemSerializer
|
||||
)
|
||||
import openedx.core.djangoapps.site_configuration.helpers as configuration_helpers
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes
|
||||
from openedx.core.djangoapps.content_libraries.tasks import backup_library
|
||||
from openedx.core.djangoapps.safe_sessions.middleware import mark_user_change_as_expected
|
||||
from openedx.core.djangoapps.xblock import api as xblock_api
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes
|
||||
|
||||
from .utils import convert_exceptions
|
||||
from ..models import ContentLibrary, LtiGradedResource, LtiProfile
|
||||
|
||||
from .utils import convert_exceptions
|
||||
|
||||
User = get_user_model()
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -685,6 +687,109 @@ class LibraryImportTaskViewSet(GenericViewSet):
|
||||
return Response(ContentLibraryBlockImportTaskSerializer(import_task).data)
|
||||
|
||||
|
||||
# Library Backup Views
|
||||
# ====================
|
||||
|
||||
@method_decorator(non_atomic_requests, name="dispatch")
|
||||
@view_auth_classes()
|
||||
class LibraryBackupView(APIView):
|
||||
"""
|
||||
**Use Case**
|
||||
* Start an asynchronous task to back up the content of a library to a .zip file
|
||||
* Get a status on an asynchronous export task
|
||||
|
||||
**Example Requests**
|
||||
POST /api/libraries/v2/{library_id}/backup/
|
||||
GET /api/libraries/v2/{library_id}/backup/?task_id={task_id}
|
||||
|
||||
**POST Response Values**
|
||||
|
||||
If the import task is started successfully, an HTTP 200 "OK" response is
|
||||
returned.
|
||||
|
||||
The HTTP 200 response has the following values:
|
||||
|
||||
* task_id: UUID of the created task, usable for checking status
|
||||
|
||||
**Example POST Response**
|
||||
|
||||
{
|
||||
"task_id": "7069b95b-ccea-4214-b6db-e00f27065bf7"
|
||||
}
|
||||
|
||||
**GET Parameters**
|
||||
|
||||
A GET request must include the following parameters:
|
||||
|
||||
* task_id: (required) The UUID of the task to check.
|
||||
|
||||
**GET Response Values**
|
||||
|
||||
If the import task is found successfully by the UUID provided, an HTTP
|
||||
200 "OK" response is returned.
|
||||
|
||||
The HTTP 200 response has the following values:
|
||||
|
||||
* state: String description of the state of the task.
|
||||
Possible states: "Pending", "Exporting", "Succeeded", "Failed".
|
||||
* url: (may be null) If the task is complete, a URL to download the .zip file
|
||||
|
||||
**Example GET Response**
|
||||
{
|
||||
"state": "Succeeded",
|
||||
"url": "/media/user_tasks/2025/10/03/lib-wgu-csprob-2025-10-03-153633.zip"
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
@apidocs.schema(
|
||||
body=None,
|
||||
responses={200: LibraryBackupResponseSerializer}
|
||||
)
|
||||
@convert_exceptions
|
||||
def post(self, request, lib_key_str):
|
||||
"""
|
||||
Start backup task for the specified library.
|
||||
"""
|
||||
library_key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
# Using CAN_EDIT_THIS_CONTENT_LIBRARY permission for now. This should eventually become its own permission
|
||||
api.require_permission_for_library_key(library_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
|
||||
|
||||
async_result = backup_library.delay(request.user.id, str(library_key))
|
||||
result = {'task_id': async_result.task_id}
|
||||
|
||||
return Response(LibraryBackupResponseSerializer(result).data)
|
||||
|
||||
@apidocs.schema(
|
||||
parameters=[
|
||||
apidocs.query_parameter(
|
||||
'task_id',
|
||||
str,
|
||||
description="The ID of the backup task to retrieve."
|
||||
),
|
||||
],
|
||||
responses={200: LibraryBackupTaskStatusSerializer}
|
||||
)
|
||||
@convert_exceptions
|
||||
def get(self, request, lib_key_str):
|
||||
"""
|
||||
Get the status of the specified backup task for the specified library.
|
||||
"""
|
||||
library_key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
# Using CAN_EDIT_THIS_CONTENT_LIBRARY permission for now. This should eventually become its own permission
|
||||
api.require_permission_for_library_key(library_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
|
||||
|
||||
task_id = request.query_params.get('task_id', None)
|
||||
if not task_id:
|
||||
raise ValidationError(detail={'task_id': _('This field is required.')})
|
||||
result = get_backup_task_status(request.user.id, task_id)
|
||||
|
||||
if not result:
|
||||
raise NotFound(detail="No backup found for this library.")
|
||||
|
||||
return Response(LibraryBackupTaskStatusSerializer(result).data)
|
||||
|
||||
|
||||
# LTI 1.3 Views
|
||||
# =============
|
||||
|
||||
|
||||
@@ -3,26 +3,22 @@ Serializers for the content libraries REST API
|
||||
"""
|
||||
# pylint: disable=abstract-method
|
||||
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 rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from opaque_keys import OpaqueKey
|
||||
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryUsageLocatorV2
|
||||
from opaque_keys import InvalidKeyError
|
||||
|
||||
from openedx_learning.api.authoring_models import Collection
|
||||
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.constants import ALL_RIGHTS_RESERVED, LICENSE_OPTIONS
|
||||
from openedx.core.djangoapps.content_libraries.models import (
|
||||
ContentLibraryPermission, ContentLibraryBlockImportTask,
|
||||
ContentLibrary
|
||||
ContentLibrary,
|
||||
ContentLibraryBlockImportTask,
|
||||
ContentLibraryPermission
|
||||
)
|
||||
from openedx.core.lib.api.serializers import CourseKeyField
|
||||
from .. import permissions
|
||||
|
||||
from .. import permissions
|
||||
|
||||
DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
|
||||
|
||||
@@ -416,3 +412,18 @@ class ContainerHierarchySerializer(serializers.Serializer):
|
||||
units = serializers.ListField(child=ContainerHierarchyMemberSerializer(), allow_empty=True)
|
||||
components = serializers.ListField(child=ContainerHierarchyMemberSerializer(), allow_empty=True)
|
||||
object_key = OpaqueKeySerializer()
|
||||
|
||||
|
||||
class LibraryBackupResponseSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for the response after requesting a backup of a content library.
|
||||
"""
|
||||
task_id = serializers.CharField()
|
||||
|
||||
|
||||
class LibraryBackupTaskStatusSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for checking the status of a library backup task.
|
||||
"""
|
||||
state = serializers.CharField()
|
||||
url = serializers.URLField(allow_null=True)
|
||||
|
||||
@@ -17,37 +17,44 @@ Architecture note:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from tempfile import mkdtemp
|
||||
|
||||
from celery import shared_task
|
||||
from celery_utils.logged_task import LoggedTask
|
||||
from celery.utils.log import get_task_logger
|
||||
from edx_django_utils.monitoring import set_code_owner_attribute, set_code_owner_attribute_from_module
|
||||
from celery_utils.logged_task import LoggedTask
|
||||
from django.core.files import File
|
||||
from django.utils.text import slugify
|
||||
from edx_django_utils.monitoring import (
|
||||
set_code_owner_attribute,
|
||||
set_code_owner_attribute_from_module,
|
||||
set_custom_attribute
|
||||
)
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys.edx.locator import (
|
||||
BlockUsageLocator,
|
||||
LibraryCollectionLocator,
|
||||
LibraryContainerLocator,
|
||||
LibraryLocatorV2,
|
||||
)
|
||||
from openedx_learning.api import authoring as authoring_api
|
||||
from openedx_learning.api.authoring_models import DraftChangeLog, PublishLog
|
||||
from openedx_events.content_authoring.data import (
|
||||
LibraryBlockData,
|
||||
LibraryCollectionData,
|
||||
LibraryContainerData,
|
||||
LibraryLocatorV2
|
||||
)
|
||||
from openedx_events.content_authoring.data import LibraryBlockData, LibraryCollectionData, LibraryContainerData
|
||||
from openedx_events.content_authoring.signals import (
|
||||
LIBRARY_BLOCK_CREATED,
|
||||
LIBRARY_BLOCK_DELETED,
|
||||
LIBRARY_BLOCK_UPDATED,
|
||||
LIBRARY_BLOCK_PUBLISHED,
|
||||
LIBRARY_BLOCK_UPDATED,
|
||||
LIBRARY_COLLECTION_UPDATED,
|
||||
LIBRARY_CONTAINER_CREATED,
|
||||
LIBRARY_CONTAINER_DELETED,
|
||||
LIBRARY_CONTAINER_UPDATED,
|
||||
LIBRARY_CONTAINER_PUBLISHED,
|
||||
LIBRARY_CONTAINER_UPDATED
|
||||
)
|
||||
|
||||
from openedx_learning.api import authoring as authoring_api
|
||||
from openedx_learning.api.authoring import create_zip_file as create_lib_zip_file
|
||||
from openedx_learning.api.authoring_models import DraftChangeLog, PublishLog
|
||||
from path import Path
|
||||
from user_tasks.models import UserTaskArtifact
|
||||
from user_tasks.tasks import UserTask, UserTaskStatus
|
||||
from xblock.fields import Scope
|
||||
|
||||
@@ -477,3 +484,66 @@ def _copy_overrides(
|
||||
dest_block=store.get_item(dest_child_key),
|
||||
)
|
||||
store.update_item(dest_block, user_id)
|
||||
|
||||
|
||||
class LibraryBackupTask(UserTask): # pylint: disable=abstract-method
|
||||
"""
|
||||
Base class for tasks related with Library backup functionality.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def generate_name(cls, arguments_dict) -> str:
|
||||
"""
|
||||
Create a name for this particular backup task instance.
|
||||
|
||||
Should be both:
|
||||
a. semi human-friendly
|
||||
b. something we can query in order to determine whether the library has a task in progress
|
||||
|
||||
Arguments:
|
||||
arguments_dict (dict): The arguments given to the task function
|
||||
|
||||
Returns:
|
||||
str: The generated name
|
||||
"""
|
||||
key = arguments_dict['library_key_str']
|
||||
return f'Backup of {key}'
|
||||
|
||||
|
||||
@shared_task(base=LibraryBackupTask, bind=True)
|
||||
# Note: The decorator @set_code_owner_attribute cannot be used here because the UserTaskMixin
|
||||
# does stack inspection and can't handle additional decorators.
|
||||
def backup_library(self, user_id: int, library_key_str: str) -> None:
|
||||
"""
|
||||
Export a library to a .zip archive and prepare it for download.
|
||||
Possible Task states:
|
||||
- Pending: Task is created but not started yet.
|
||||
- Exporting: Task is running and the library is being exported.
|
||||
- Succeeded: Task completed successfully and the exported file is available for download.
|
||||
- Failed: Task failed and the export did not complete.
|
||||
"""
|
||||
ensure_cms("backup_library may only be executed in a CMS context")
|
||||
set_code_owner_attribute_from_module(__name__)
|
||||
library_key = LibraryLocatorV2.from_string(library_key_str)
|
||||
|
||||
try:
|
||||
self.status.set_state('Exporting')
|
||||
set_custom_attribute("exporting_started", str(library_key))
|
||||
|
||||
root_dir = Path(mkdtemp())
|
||||
sanitized_lib_key = str(library_key).replace(":", "-")
|
||||
sanitized_lib_key = slugify(sanitized_lib_key, allow_unicode=True)
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
||||
filename = f'{sanitized_lib_key}-{timestamp}.zip'
|
||||
file_path = os.path.join(root_dir, filename)
|
||||
create_lib_zip_file(lp_key=str(library_key), path=file_path)
|
||||
set_custom_attribute("exporting_completed", str(library_key))
|
||||
|
||||
with open(file_path, 'rb') as zipfile:
|
||||
artifact = UserTaskArtifact(status=self.status, name='Output')
|
||||
artifact.file.save(name=os.path.basename(zipfile.name), content=File(zipfile))
|
||||
artifact.save()
|
||||
except Exception as exception: # pylint: disable=broad-except
|
||||
TASK_LOGGER.exception('Error exporting library %s', library_key, exc_info=True)
|
||||
if self.status.state != UserTaskStatus.FAILED:
|
||||
self.status.fail({'raw_error_msg': str(exception)})
|
||||
|
||||
@@ -32,6 +32,8 @@ URL_LIB_TEAM = URL_LIB_DETAIL + 'team/' # Get the list of users/groups authoriz
|
||||
URL_LIB_TEAM_USER = URL_LIB_TEAM + 'user/{username}/' # Add/edit/remove a user's permission to use this library
|
||||
URL_LIB_TEAM_GROUP = URL_LIB_TEAM + 'group/{group_name}/' # Add/edit/remove a group's permission to use this library
|
||||
URL_LIB_PASTE_CLIPBOARD = URL_LIB_DETAIL + 'paste_clipboard/' # Paste user clipboard (POST) containing Xblock data
|
||||
URL_LIB_BACKUP = URL_LIB_DETAIL + 'backup/' # Start a backup task for this library
|
||||
URL_LIB_BACKUP_GET = URL_LIB_BACKUP + '?{query_params}' # Get status on a backup task for this library
|
||||
URL_LIB_BLOCK = URL_PREFIX + 'blocks/{block_key}/' # Get data about a block, or delete it
|
||||
URL_LIB_BLOCK_PUBLISH = URL_LIB_BLOCK + 'publish/' # Publish changes from a specified XBlock
|
||||
URL_LIB_BLOCK_OLX = URL_LIB_BLOCK + 'olx/' # Get or set the OLX of the specified XBlock
|
||||
@@ -319,6 +321,17 @@ class ContentLibrariesRestApiTest(APITransactionTestCase):
|
||||
url = URL_LIB_PASTE_CLIPBOARD.format(lib_key=lib_key)
|
||||
return self._api('post', url, {}, expect_response)
|
||||
|
||||
def _start_library_backup_task(self, lib_key, expect_response=200):
|
||||
""" Start a backup task for this library """
|
||||
url = URL_LIB_BACKUP.format(lib_key=lib_key)
|
||||
return self._api('post', url, {}, expect_response)
|
||||
|
||||
def _get_library_backup_task(self, lib_key, task_id, expect_response=200):
|
||||
""" Get the status of a backup task for this library """
|
||||
query_params = urlencode({"task_id": task_id})
|
||||
url = URL_LIB_BACKUP_GET.format(lib_key=lib_key, query_params=query_params)
|
||||
return self._api('get', url, None, expect_response)
|
||||
|
||||
def _render_block_view(self, block_key, view_name, version=None, expect_response=200):
|
||||
"""
|
||||
Render an XBlock's view in the active application's runtime.
|
||||
|
||||
@@ -4,9 +4,11 @@ Tests for Content Library internal api.
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
from user_tasks.models import UserTaskStatus
|
||||
|
||||
from opaque_keys.edx.keys import (
|
||||
CourseKey,
|
||||
@@ -1309,3 +1311,95 @@ class ContentLibraryContainersTest(ContentLibrariesRestApiTest):
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ContentLibraryExportTest(ContentLibrariesRestApiTest):
|
||||
"""
|
||||
Tests for Content Library API export methods.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
# Create Content Libraries
|
||||
self._create_library("test-lib-exp-1", "Test Library Export 1")
|
||||
|
||||
# Fetch the created ContentLibrary objects so we can access their learning_package.id
|
||||
self.lib1 = ContentLibrary.objects.get(slug="test-lib-exp-1")
|
||||
self.wrong_task_id = '11111111-1111-1111-1111-111111111111'
|
||||
|
||||
def test_get_backup_task_status_no_task(self) -> None:
|
||||
status = api.get_backup_task_status(self.user.id, "")
|
||||
assert status is None
|
||||
|
||||
def test_get_backup_task_status_wrong_task_id(self) -> None:
|
||||
status = api.get_backup_task_status(self.user.id, task_id=self.wrong_task_id)
|
||||
assert status is None
|
||||
|
||||
def test_get_backup_task_status_in_progress(self) -> None:
|
||||
# Create a mock UserTaskStatus in IN_PROGRESS state
|
||||
task_id = str(uuid.uuid4())
|
||||
mock_task = UserTaskStatus(
|
||||
task_id=task_id,
|
||||
user_id=self.user.id,
|
||||
name=f"Export of {self.lib1.library_key}",
|
||||
state=UserTaskStatus.IN_PROGRESS
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
'openedx.core.djangoapps.content_libraries.api.libraries.UserTaskStatus.objects.get'
|
||||
) as mock_get:
|
||||
mock_get.return_value = mock_task
|
||||
|
||||
status = api.get_backup_task_status(self.user.id, task_id=task_id)
|
||||
assert status is not None
|
||||
assert status['state'] == UserTaskStatus.IN_PROGRESS
|
||||
assert status['url'] is None
|
||||
|
||||
def test_get_backup_task_status_succeeded(self) -> None:
|
||||
# Create a mock UserTaskStatus in SUCCEEDED state
|
||||
task_id = str(uuid.uuid4())
|
||||
mock_task = UserTaskStatus(
|
||||
task_id=task_id,
|
||||
user_id=self.user.id,
|
||||
name=f"Export of {self.lib1.library_key}",
|
||||
state=UserTaskStatus.SUCCEEDED
|
||||
)
|
||||
|
||||
# Create a mock UserTaskArtifact
|
||||
mock_artifact = mock.Mock()
|
||||
mock_artifact.file.storage.url.return_value = "/media/user_tasks/2025/10/01/library-libOEXCSPROB_mOw1rPL.zip"
|
||||
|
||||
with mock.patch(
|
||||
'openedx.core.djangoapps.content_libraries.api.libraries.UserTaskStatus.objects.get'
|
||||
) as mock_get, mock.patch(
|
||||
'openedx.core.djangoapps.content_libraries.api.libraries.UserTaskArtifact.objects.get'
|
||||
) as mock_artifact_get:
|
||||
|
||||
mock_get.return_value = mock_task
|
||||
mock_artifact_get.return_value = mock_artifact
|
||||
|
||||
status = api.get_backup_task_status(self.user.id, task_id=task_id)
|
||||
assert status is not None
|
||||
assert status['state'] == UserTaskStatus.SUCCEEDED
|
||||
assert status['url'] == "/media/user_tasks/2025/10/01/library-libOEXCSPROB_mOw1rPL.zip"
|
||||
|
||||
def test_get_backup_task_status_failed(self) -> None:
|
||||
# Create a mock UserTaskStatus in FAILED state
|
||||
task_id = str(uuid.uuid4())
|
||||
mock_task = UserTaskStatus(
|
||||
task_id=task_id,
|
||||
user_id=self.user.id,
|
||||
name=f"Export of {self.lib1.library_key}",
|
||||
state=UserTaskStatus.FAILED
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
'openedx.core.djangoapps.content_libraries.api.libraries.UserTaskStatus.objects.get'
|
||||
) as mock_get:
|
||||
mock_get.return_value = mock_task
|
||||
|
||||
status = api.get_backup_task_status(self.user.id, task_id=task_id)
|
||||
assert status is not None
|
||||
assert status['state'] == UserTaskStatus.FAILED
|
||||
assert status['url'] is None
|
||||
|
||||
@@ -823,6 +823,40 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest):
|
||||
"id": f"lb:CL-TEST:test_lib_paste_clipboard:problem:{pasted_usage_key.block_id}",
|
||||
})
|
||||
|
||||
def test_start_library_backup(self):
|
||||
"""
|
||||
Test starting a backup operation on a content library.
|
||||
"""
|
||||
author = UserFactory.create(username="Author", email="author@example.com", is_staff=True)
|
||||
with self.as_user(author):
|
||||
lib = self._create_library(
|
||||
slug="test_lib_backup",
|
||||
title="Backup Test Library",
|
||||
description="Testing backup for library"
|
||||
)
|
||||
lib_id = lib["id"]
|
||||
response = self._start_library_backup_task(lib_id)
|
||||
assert response["task_id"] is not None
|
||||
|
||||
def test_get_library_backup_status(self):
|
||||
"""
|
||||
Test getting the status of a backup operation on a content library.
|
||||
"""
|
||||
author = UserFactory.create(username="Author", email="author@example.com", is_staff=True)
|
||||
with self.as_user(author):
|
||||
lib = self._create_library(
|
||||
slug="test_lib_backup_status",
|
||||
title="Backup Status Test Library",
|
||||
description="Testing backup status for library"
|
||||
)
|
||||
lib_id = lib["id"]
|
||||
response = self._start_library_backup_task(lib_id)
|
||||
task_id = response["task_id"]
|
||||
|
||||
# Now check the status of the backup task
|
||||
status_response = self._get_library_backup_task(lib_id, task_id)
|
||||
assert status_response["state"] in ["Pending", "Exporting", "Succeeded", "Failed"]
|
||||
|
||||
@override_settings(LIBRARY_ENABLED_BLOCKS=['problem', 'video', 'html'])
|
||||
def test_library_get_enabled_blocks(self):
|
||||
expected = [
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Unit tests for content libraries Celery tasks
|
||||
"""
|
||||
|
||||
from ..models import ContentLibrary
|
||||
from .base import ContentLibrariesRestApiTest
|
||||
|
||||
from openedx.core.djangoapps.content_libraries.tasks import backup_library
|
||||
from user_tasks.models import UserTaskArtifact
|
||||
|
||||
|
||||
class ContentLibraryBackupTaskTest(ContentLibrariesRestApiTest):
|
||||
"""
|
||||
Tests for Content Library export task.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
# Create Content Libraries
|
||||
self._create_library("test-lib-task-1", "Test Library Task 1")
|
||||
|
||||
# Fetch the created ContentLibrary objects so we can access their learning_package.id
|
||||
self.lib1 = ContentLibrary.objects.get(slug="test-lib-task-1")
|
||||
self.wrong_task_id = '11111111-1111-1111-1111-111111111111'
|
||||
|
||||
def test_backup_task_returns_task_id(self):
|
||||
result = backup_library.delay(self.user.id, str(self.lib1.library_key))
|
||||
assert result.task_id is not None
|
||||
|
||||
def test_backup_task_success(self):
|
||||
result = backup_library.delay(self.user.id, str(self.lib1.library_key))
|
||||
assert result.state == 'SUCCESS'
|
||||
# Ensure an artifact was created with the output file
|
||||
artifact = UserTaskArtifact.objects.filter(status__task_id=result.task_id, name='Output').first()
|
||||
assert artifact is not None
|
||||
assert artifact.file.name.endswith('.zip')
|
||||
|
||||
def test_backup_task_failure(self):
|
||||
result = backup_library.delay(self.user.id, self.wrong_task_id)
|
||||
assert result.state == 'FAILURE'
|
||||
# Ensure an error artifact was created
|
||||
artifact = UserTaskArtifact.objects.filter(status__task_id=result.task_id, name='Error').first()
|
||||
assert artifact is not None
|
||||
assert artifact.text is not None
|
||||
@@ -54,6 +54,8 @@ urlpatterns = [
|
||||
path('import_blocks/', include(import_blocks_router.urls)),
|
||||
# Paste contents of clipboard into library
|
||||
path('paste_clipboard/', libraries.LibraryPasteClipboardView.as_view()),
|
||||
# Start a backup task for this library
|
||||
path('backup/', libraries.LibraryBackupView.as_view()),
|
||||
# Library Collections
|
||||
path('', include(library_collections_router.urls)),
|
||||
])),
|
||||
|
||||
Reference in New Issue
Block a user