diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py index 0d66070b1120..3ca67b0cb031 100644 --- a/cms/djangoapps/contentstore/helpers.py +++ b/cms/djangoapps/contentstore/helpers.py @@ -336,8 +336,14 @@ def _import_xml_node_to_parent( new_xblock = store.update_item(temp_xblock, user_id, allow_not_found=True) parent_xblock.children.append(new_xblock.location) store.update_item(parent_xblock, user_id) - for child_node in child_nodes: - _import_xml_node_to_parent(child_node, new_xblock, store, user_id=user_id) + if new_xblock.scope_ids.block_type == "library_content": + # Special case handling for library content. If we need this for other blocks in the future, it can be made into + # an API, and we'd call new_block.studio_post_paste() instead of this code. + # In this case, we want to pull the children from the library and let library_tools assign their IDs. + new_xblock.sync_from_library(upgrade_to_latest=False) + else: + for child_node in child_nodes: + _import_xml_node_to_parent(child_node, new_xblock, store, user_id=user_id) return new_xblock diff --git a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py index 170c98d1eb09..011c9a10e561 100644 --- a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py +++ b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py @@ -4,12 +4,19 @@ APIs. """ import ddt +from django.test import LiveServerTestCase from opaque_keys.edx.keys import UsageKey from rest_framework.test import APIClient -from xmodule.modulestore.django import contentstore +from organizations.models import Organization +from xmodule.modulestore.django import contentstore, modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory +from cms.djangoapps.contentstore.utils import reverse_usage_url +from openedx.core.lib.blockstore_api.tests.base import BlockstoreAppTestMixin +from openedx.core.djangoapps.content_libraries import api as library_api +from blockstore.apps import api as blockstore_api + CLIPBOARD_ENDPOINT = "/api/content-staging/v1/clipboard/" XBLOCK_ENDPOINT = "/xblock/" @@ -110,7 +117,7 @@ def test_copy_and_paste_component(self, block_args): """ Test copying a component (XBlock) from one course into another """ - source_course = CourseFactory.create(display_name='Destination Course') + source_course = CourseFactory.create(display_name='Source Course') source_block = BlockFactory.create(parent_location=source_course.location, **block_args) dest_course = CourseFactory.create(display_name='Destination Course') @@ -205,3 +212,97 @@ def test_paste_with_assets(self): source_pic2_hash = contentstore().find(source_course.id.make_asset_key("asset", "picture2.jpg")).content_digest dest_pic2_hash = contentstore().find(dest_course_key.make_asset_key("asset", "picture2.jpg")).content_digest assert source_pic2_hash != dest_pic2_hash # Because there was a conflict, this file was unchanged. + + +class ClipboardLibraryContentPasteTestCase(BlockstoreAppTestMixin, LiveServerTestCase, ModuleStoreTestCase): + """ + Test Clipboard Paste functionality with library content + """ + + def setUp(self): + """ + Set up a v2 Content Library and a library content block + """ + super().setUp() + self.client = APIClient() + self.client.login(username=self.user.username, password=self.user_password) + self.store = modulestore() + # Create a content library: + library = library_api.create_library( + collection_uuid=blockstore_api.create_collection("Collection").uuid, + library_type=library_api.COMPLEX, + org=Organization.objects.create(name="Test Org", short_name="CL-TEST"), + slug="lib", + title="Library", + ) + # Populate it with a problem: + problem_key = library_api.create_library_block(library.key, "problem", "p1").usage_key + library_api.set_library_block_olx(problem_key, """ + + + + + Wrong + Right + + + + """) + library_api.publish_changes(library.key) + + # Create a library content block (lc), point it out our library, and sync it. + self.course = CourseFactory.create(display_name='Course') + self.orig_lc_block = BlockFactory.create( + parent=self.course, + category="library_content", + source_library_id=str(library.key), + display_name="LC Block", + publish_item=False, + ) + self.dest_lc_block = None + + self._sync_lc_block_from_library('orig_lc_block') + orig_child = self.store.get_item(self.orig_lc_block.children[0]) + assert orig_child.display_name == "MCQ" + + def test_paste_library_content_block(self): + """ + Test the special handling of copying and pasting library content + """ + # Copy a library content block that has children: + copy_response = self.client.post(CLIPBOARD_ENDPOINT, { + "usage_key": str(self.orig_lc_block.location) + }, format="json") + assert copy_response.status_code == 200 + + # Paste the Library content block: + paste_response = self.client.post(XBLOCK_ENDPOINT, { + "parent_locator": str(self.course.location), + "staged_content": "clipboard", + }, format="json") + assert paste_response.status_code == 200 + dest_lc_block_key = UsageKey.from_string(paste_response.json()["locator"]) + + # Get the ID of the new child: + self.dest_lc_block = self.store.get_item(dest_lc_block_key) + dest_child = self.store.get_item(self.dest_lc_block.children[0]) + assert dest_child.display_name == "MCQ" + + # Importantly, the ID of the child must not changed when the library content is synced. + # Otherwise, user state saved against this child will be lost when it syncs. + self._sync_lc_block_from_library('dest_lc_block') + updated_dest_child = self.store.get_item(self.dest_lc_block.children[0]) + assert dest_child.location == updated_dest_child.location + + def _sync_lc_block_from_library(self, attr_name): + """ + Helper method to "sync" a Library Content Block by [re-]fetching its + children from the library. + """ + usage_key = getattr(self, attr_name).location + # It's easiest to do this via the REST API: + handler_url = reverse_usage_url('preview_handler', usage_key, kwargs={'handler': 'upgrade_and_sync'}) + response = self.client.post(handler_url) + assert response.status_code == 200 + # Now reload the block and make sure the child is in place + setattr(self, attr_name, self.store.get_item(usage_key)) # we must reload after upgrade_and_sync diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index 10383de336e5..76cbde89082e 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -91,7 +91,15 @@ from edx_rest_api_client.client import OAuthAPIClient from openedx.core.djangoapps.content_libraries import permissions -from openedx.core.djangoapps.content_libraries.constants import DRAFT_NAME, COMPLEX +# pylint: disable=unused-import +from openedx.core.djangoapps.content_libraries.constants import ( + ALL_RIGHTS_RESERVED, + CC_4_BY, + COMPLEX, + DRAFT_NAME, + PROBLEM, + VIDEO, +) from openedx.core.djangoapps.content_libraries.library_bundle import LibraryBundle from openedx.core.djangoapps.content_libraries.models import ( ContentLibrary, @@ -387,8 +395,15 @@ def get_library(library_key): def create_library( - collection_uuid, library_type, org, slug, title, description, allow_public_learning, allow_public_read, - library_license, + collection_uuid, + org, + slug, + title, + description="", + allow_public_learning=False, + allow_public_read=False, + library_license=ALL_RIGHTS_RESERVED, + library_type=COMPLEX, ): """ Create a new content library. @@ -405,6 +420,8 @@ def create_library( allow_public_read: Allow anyone to view blocks (including source) in Studio? + library_type: Deprecated parameter, not really used. Set to COMPLEX. + Returns a ContentLibraryMetadata instance. """ assert isinstance(collection_uuid, UUID) diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py index bfaf9760c4a1..c9026dbdbddb 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py @@ -31,7 +31,6 @@ from openedx.core.djangoapps.content_libraries.api import ( AccessLevel, create_library, - COMPLEX, set_library_user_permissions, ) from openedx.core.djangoapps.content_tagging.models import TaxonomyOrg @@ -104,12 +103,8 @@ def _setUp_library(self): collection_uuid=self.collection.uuid, org=self.orgA, slug="lib_a", - library_type=COMPLEX, title="Library Org A", description="This is a library from Org A", - allow_public_learning=False, - allow_public_read=False, - library_license="", ) def _setUp_users(self):