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
48 changes: 36 additions & 12 deletions openedx/core/djangoapps/content_libraries/api/libraries.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,10 @@
"""
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
Expand All @@ -53,29 +54,24 @@
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__)

Expand Down Expand Up @@ -105,6 +101,7 @@
"get_allowed_block_types",
"publish_changes",
"revert_changes",
"get_backup_task_status",
]


Expand Down Expand Up @@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rodmgwgu CC @holaontiveros Do you think we can update this to return an absolute URL, before Ulmo? REST APIs should generally only return absolute URLs, doubly so if the field is called "url", and triply so if we're talking about file storage which may be on a totally separate server/domain like S3. But this API is currently returning just the path part.

I noticed this here in the corresponding frontend PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bradenmacdonald I modeled this one around the existing course export code here, which is doing the same, it seems that if it's S3, it will give us an absolute url, but for filestorage it returns a relative path.

But what you mention makes sense, I'll review this to always return an absoulte URL.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bradenmacdonald here is the PR addressing this issue: #37508


return result
127 changes: 116 additions & 11 deletions openedx/core/djangoapps/content_libraries/rest_api/libraries.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
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
Expand All @@ -78,48 +79,49 @@
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
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,
ContentLibraryMetadataSerializer,
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
Comment thread
rodmgwgu marked this conversation as resolved.
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__)
Expand Down Expand Up @@ -685,6 +687,109 @@ def retrieve(self, request, lib_key_str, pk=None):
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)
Comment thread
rodmgwgu marked this conversation as resolved.

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
# =============

Expand Down
35 changes: 23 additions & 12 deletions openedx/core/djangoapps/content_libraries/rest_api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,22 @@
"""
# 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'

Expand Down Expand Up @@ -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)
Loading
Loading