From 11de2a4055d3157eb636b9a8019b61a8bc6e98df Mon Sep 17 00:00:00 2001 From: Yusuf Musleh Date: Thu, 15 Aug 2024 19:58:06 +0300 Subject: [PATCH] feat: REST API to allow pasting clipboard (staged) content into a library (#35199) --- .../core/djangoapps/content_libraries/api.py | 87 +++++++++++++++---- .../content_libraries/serializers.py | 11 +++ .../content_libraries/tests/base.py | 7 ++ .../tests/test_content_libraries.py | 48 ++++++++++ .../core/djangoapps/content_libraries/urls.py | 2 + .../djangoapps/content_libraries/views.py | 29 +++++++ 6 files changed, 169 insertions(+), 15 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index d2c6eeeeab..17bea80b3a 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -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. diff --git a/openedx/core/djangoapps/content_libraries/serializers.py b/openedx/core/djangoapps/content_libraries/serializers.py index e714718a77..497eda8147 100644 --- a/openedx/core/djangoapps/content_libraries/serializers.py +++ b/openedx/core/djangoapps/content_libraries/serializers.py @@ -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): """ diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index 40fe3ba949..987133255f 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -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. diff --git a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py index 576d506f9d..95b7309b3c 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -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): diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index 022c288e36..6e450df635 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -43,6 +43,8 @@ urlpatterns = [ path('team/group//', 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//', include([ # Get metadata about a specific XBlock in this library, or delete the block: diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/views.py index 502150b47d..bde8142d3f 100644 --- a/openedx/core/djangoapps/content_libraries/views.py +++ b/openedx/core/djangoapps/content_libraries/views.py @@ -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):