feat: REST API to allow pasting clipboard (staged) content into a library (#35199)
This commit is contained in:
@@ -751,27 +751,30 @@ def set_library_block_olx(usage_key, new_olx_str):
|
||||
)
|
||||
|
||||
|
||||
def create_library_block(library_key, block_type, definition_id, user_id=None):
|
||||
def validate_can_add_block_to_library(
|
||||
library_key: LibraryLocatorV2,
|
||||
block_type: str,
|
||||
block_id: str,
|
||||
) -> tuple[ContentLibrary, LibraryUsageLocatorV2]:
|
||||
"""
|
||||
Create a new XBlock in this library of the specified type (e.g. "html").
|
||||
"""
|
||||
# It's in the serializer as ``definition_id``, but for our purposes, it's
|
||||
# the block_id. See the comments in ``LibraryXBlockCreationSerializer`` for
|
||||
# more details. TODO: Change the param name once we change the serializer.
|
||||
block_id = definition_id
|
||||
Perform checks to validate whether a new block with `block_id` and type `block_type` can be added to
|
||||
the library with key `library_key`.
|
||||
|
||||
Returns the ContentLibrary that has the passed in `library_key` and newly created LibraryUsageLocatorV2 if
|
||||
validation successful, otherwise raises errors.
|
||||
"""
|
||||
assert isinstance(library_key, LibraryLocatorV2)
|
||||
ref = ContentLibrary.objects.get_by_key(library_key)
|
||||
if ref.type != COMPLEX:
|
||||
if block_type != ref.type:
|
||||
content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined]
|
||||
if content_library.type != COMPLEX:
|
||||
if block_type != content_library.type:
|
||||
raise IncompatibleTypesError(
|
||||
_('Block type "{block_type}" is not compatible with library type "{library_type}".').format(
|
||||
block_type=block_type, library_type=ref.type,
|
||||
block_type=block_type, library_type=content_library.type,
|
||||
)
|
||||
)
|
||||
|
||||
# If adding a component would take us over our max, return an error.
|
||||
component_count = authoring_api.get_all_drafts(ref.learning_package.id).count()
|
||||
component_count = authoring_api.get_all_drafts(content_library.learning_package.id).count()
|
||||
if component_count + 1 > settings.MAX_BLOCKS_PER_CONTENT_LIBRARY:
|
||||
raise BlockLimitReachedError(
|
||||
_("Library cannot have more than {} Components").format(
|
||||
@@ -784,7 +787,7 @@ def create_library_block(library_key, block_type, definition_id, user_id=None):
|
||||
# Ensure the XBlock type is valid and installed:
|
||||
XBlock.load_class(block_type) # Will raise an exception if invalid
|
||||
# Make sure the new ID is not taken already:
|
||||
usage_key = LibraryUsageLocatorV2(
|
||||
usage_key = LibraryUsageLocatorV2( # type: ignore[abstract]
|
||||
lib_key=library_key,
|
||||
block_type=block_type,
|
||||
usage_id=block_id,
|
||||
@@ -793,12 +796,26 @@ def create_library_block(library_key, block_type, definition_id, user_id=None):
|
||||
if _component_exists(usage_key):
|
||||
raise LibraryBlockAlreadyExists(f"An XBlock with ID '{usage_key}' already exists")
|
||||
|
||||
_create_component_for_block(ref, usage_key, user_id=user_id)
|
||||
return content_library, usage_key
|
||||
|
||||
|
||||
def create_library_block(library_key, block_type, definition_id, user_id=None):
|
||||
"""
|
||||
Create a new XBlock in this library of the specified type (e.g. "html").
|
||||
"""
|
||||
# It's in the serializer as ``definition_id``, but for our purposes, it's
|
||||
# the block_id. See the comments in ``LibraryXBlockCreationSerializer`` for
|
||||
# more details. TODO: Change the param name once we change the serializer.
|
||||
block_id = definition_id
|
||||
|
||||
content_library, usage_key = validate_can_add_block_to_library(library_key, block_type, block_id)
|
||||
|
||||
_create_component_for_block(content_library, usage_key, user_id)
|
||||
|
||||
# Now return the metadata about the new block:
|
||||
LIBRARY_BLOCK_CREATED.send_event(
|
||||
library_block=LibraryBlockData(
|
||||
library_key=ref.library_key,
|
||||
library_key=content_library.library_key,
|
||||
usage_key=usage_key
|
||||
)
|
||||
)
|
||||
@@ -820,6 +837,46 @@ def _component_exists(usage_key: UsageKeyV2) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def import_staged_content_from_user_clipboard(library_key: LibraryLocatorV2, user, block_id) -> XBlock:
|
||||
"""
|
||||
Create a new library block and populate it with staged content from clipboard
|
||||
|
||||
Returns the newly created library block
|
||||
"""
|
||||
from openedx.core.djangoapps.content_staging import api as content_staging_api
|
||||
if not content_staging_api:
|
||||
raise RuntimeError("The required content_staging app is not installed")
|
||||
|
||||
user_clipboard = content_staging_api.get_user_clipboard(user)
|
||||
if not user_clipboard:
|
||||
return None
|
||||
|
||||
olx_str = content_staging_api.get_staged_content_olx(user_clipboard.content.id)
|
||||
|
||||
# TODO: Handle importing over static assets
|
||||
|
||||
content_library, usage_key = validate_can_add_block_to_library(
|
||||
library_key,
|
||||
user_clipboard.content.block_type,
|
||||
block_id
|
||||
)
|
||||
|
||||
# Create component for block then populate it with clipboard data
|
||||
_create_component_for_block(content_library, usage_key, user.id)
|
||||
set_library_block_olx(usage_key, olx_str)
|
||||
|
||||
# Emit library block created event
|
||||
LIBRARY_BLOCK_CREATED.send_event(
|
||||
library_block=LibraryBlockData(
|
||||
library_key=content_library.library_key,
|
||||
usage_key=usage_key
|
||||
)
|
||||
)
|
||||
|
||||
# Now return the metadata about the new block
|
||||
return get_library_block(usage_key)
|
||||
|
||||
|
||||
def get_or_create_olx_media_type(block_type: str) -> MediaType:
|
||||
"""
|
||||
Get or create a MediaType for the block type.
|
||||
|
||||
@@ -179,6 +179,17 @@ class LibraryXBlockCreationSerializer(serializers.Serializer):
|
||||
# slugs at the moment, but hopefully we can change this soon.
|
||||
definition_id = serializers.CharField(validators=(validate_unicode_slug, ))
|
||||
|
||||
# Optional param specified when pasting data from clipboard instead of
|
||||
# creating new block from scratch
|
||||
staged_content = serializers.CharField(required=False)
|
||||
|
||||
|
||||
class LibraryPasteClipboardSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for pasting clipboard data into library
|
||||
"""
|
||||
block_id = serializers.CharField(validators=(validate_unicode_slug, ))
|
||||
|
||||
|
||||
class LibraryXBlockOlxSerializer(serializers.Serializer):
|
||||
"""
|
||||
|
||||
@@ -25,6 +25,7 @@ URL_LIB_BLOCKS = URL_LIB_DETAIL + 'blocks/' # Get the list of XBlocks in this l
|
||||
URL_LIB_TEAM = URL_LIB_DETAIL + 'team/' # Get the list of users/groups authorized to use this library
|
||||
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_BLOCK = URL_PREFIX + 'blocks/{block_key}/' # Get data about a block, or delete it
|
||||
URL_LIB_BLOCK_OLX = URL_LIB_BLOCK + 'olx/' # Get or set the OLX of the specified XBlock
|
||||
URL_LIB_BLOCK_ASSETS = URL_LIB_BLOCK + 'assets/' # List the static asset files of the specified XBlock
|
||||
@@ -284,6 +285,12 @@ class ContentLibrariesRestApiTest(APITransactionTestCase):
|
||||
url = URL_LIB_BLOCK_ASSET_FILE.format(block_key=block_key, file_name=file_name)
|
||||
return self._api('delete', url, None, expect_response)
|
||||
|
||||
def _paste_clipboard_content_in_library(self, lib_key, block_id, expect_response=200):
|
||||
""" Paste's the users clipboard content into Library """
|
||||
url = URL_LIB_PASTE_CLIPBOARD.format(lib_key=lib_key)
|
||||
data = {"block_id": block_id}
|
||||
return self._api('post', url, data, expect_response)
|
||||
|
||||
def _render_block_view(self, block_key, view_name, expect_response=200):
|
||||
"""
|
||||
Render an XBlock's view in the active application's runtime.
|
||||
|
||||
@@ -5,6 +5,7 @@ from unittest.mock import Mock, patch
|
||||
from unittest import skip
|
||||
|
||||
import ddt
|
||||
from uuid import uuid4
|
||||
from django.contrib.auth.models import Group
|
||||
from django.test.client import Client
|
||||
from organizations.models import Organization
|
||||
@@ -29,6 +30,7 @@ from openedx.core.djangoapps.content_libraries.tests.base import (
|
||||
URL_BLOCK_XBLOCK_HANDLER,
|
||||
)
|
||||
from openedx.core.djangoapps.content_libraries.constants import VIDEO, COMPLEX, PROBLEM, CC_4_BY
|
||||
from openedx.core.djangoapps.xblock import api as xblock_api
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_cms
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
|
||||
@@ -974,6 +976,52 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix
|
||||
event_receiver.call_args.kwargs
|
||||
)
|
||||
|
||||
def test_library_paste_clipboard(self):
|
||||
"""
|
||||
Check the a new block is created in the library after pasting from clipboard.
|
||||
The content of the new block should match the content of the block in the clipboard.
|
||||
"""
|
||||
# Importing here since this was failing when tests ran in the LMS
|
||||
from openedx.core.djangoapps.content_staging.api import save_xblock_to_user_clipboard
|
||||
|
||||
# Create user to perform tests on
|
||||
author = UserFactory.create(username="Author", email="author@example.com")
|
||||
with self.as_user(author):
|
||||
lib = self._create_library(
|
||||
slug="test_lib_paste_clipboard",
|
||||
title="Paste Clipboard Test Library",
|
||||
description="Testing pasting clipboard in library"
|
||||
)
|
||||
lib_id = lib["id"]
|
||||
|
||||
# Add a 'problem' XBlock to the library:
|
||||
block_data = self._add_block_to_library(lib_id, "problem", "problem1")
|
||||
|
||||
# Get the usage_key of the created block
|
||||
library_key = LibraryLocatorV2.from_string(lib_id)
|
||||
usage_key = LibraryUsageLocatorV2(
|
||||
lib_key=library_key,
|
||||
block_type="problem",
|
||||
usage_id="problem1"
|
||||
)
|
||||
|
||||
# Get the XBlock created in the previous step
|
||||
block = xblock_api.load_block(usage_key, user=author)
|
||||
|
||||
# Copy the block to the user's clipboard
|
||||
save_xblock_to_user_clipboard(block, author.id)
|
||||
|
||||
# Paste the content of the clipboard into the library
|
||||
pasted_block_id = str(uuid4())
|
||||
paste_data = self._paste_clipboard_content_in_library(lib_id, pasted_block_id)
|
||||
|
||||
# Check that the new block was created after the paste and it's content matches
|
||||
# the the block in the clipboard
|
||||
self.assertDictContainsEntries(self._get_library_block(paste_data["id"]), {
|
||||
**block_data,
|
||||
"id": f"lb:CL-TEST:test_lib_paste_clipboard:problem:{pasted_block_id}",
|
||||
})
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class ContentLibraryXBlockValidationTest(APITestCase):
|
||||
|
||||
@@ -43,6 +43,8 @@ urlpatterns = [
|
||||
path('team/group/<str:group_name>/', views.LibraryTeamGroupView.as_view()),
|
||||
# Import blocks into this library.
|
||||
path('import_blocks/', include(import_blocks_router.urls)),
|
||||
# Paste contents of clipboard into library
|
||||
path('paste_clipboard/', views.LibraryPasteClipboardView.as_view()),
|
||||
])),
|
||||
path('blocks/<str:usage_key_str>/', include([
|
||||
# Get metadata about a specific XBlock in this library, or delete the block:
|
||||
|
||||
@@ -112,6 +112,7 @@ from openedx.core.djangoapps.content_libraries.serializers import (
|
||||
LibraryXBlockStaticFileSerializer,
|
||||
LibraryXBlockStaticFilesSerializer,
|
||||
ContentLibraryAddPermissionByEmailSerializer,
|
||||
LibraryPasteClipboardSerializer,
|
||||
)
|
||||
import openedx.core.djangoapps.site_configuration.helpers as configuration_helpers
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes
|
||||
@@ -502,6 +503,34 @@ class LibraryCommitView(APIView):
|
||||
return Response({})
|
||||
|
||||
|
||||
@method_decorator(non_atomic_requests, name="dispatch")
|
||||
@view_auth_classes()
|
||||
class LibraryPasteClipboardView(GenericAPIView):
|
||||
"""
|
||||
Paste content of clipboard into Library.
|
||||
"""
|
||||
@convert_exceptions
|
||||
def post(self, request, lib_key_str):
|
||||
"""
|
||||
Import the contents of the user's clipboard and paste them into the Library
|
||||
"""
|
||||
library_key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
api.require_permission_for_library_key(library_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
|
||||
serializer = LibraryPasteClipboardSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
result = api.import_staged_content_from_user_clipboard(
|
||||
library_key, request.user, **serializer.validated_data
|
||||
)
|
||||
except api.IncompatibleTypesError as err:
|
||||
raise ValidationError( # lint-amnesty, pylint: disable=raise-missing-from
|
||||
detail={'block_type': str(err)},
|
||||
)
|
||||
|
||||
return Response(LibraryXBlockMetadataSerializer(result).data)
|
||||
|
||||
|
||||
@method_decorator(non_atomic_requests, name="dispatch")
|
||||
@view_auth_classes()
|
||||
class LibraryBlocksView(GenericAPIView):
|
||||
|
||||
Reference in New Issue
Block a user