feat: REST API to allow pasting clipboard (staged) content into a library (#35199)

This commit is contained in:
Yusuf Musleh
2024-08-15 19:58:06 +03:00
committed by GitHub
parent e4855536fd
commit 11de2a4055
6 changed files with 169 additions and 15 deletions

View File

@@ -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.

View File

@@ -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):
"""

View File

@@ -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.

View File

@@ -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):

View File

@@ -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:

View File

@@ -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):