Skip to content

Commit 3da1cdf

Browse files
fix: handle paste of library content blocks correctly (#34274) (backport)
1 parent fb05745 commit 3da1cdf

File tree

3 files changed

+103
-6
lines changed

3 files changed

+103
-6
lines changed

cms/djangoapps/contentstore/helpers.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from xmodule.contentstore.content import StaticContent
1818
from xmodule.contentstore.django import contentstore
1919
from xmodule.exceptions import NotFoundError
20+
from xmodule.library_content_block import LibraryContentBlock
2021
from xmodule.modulestore.django import modulestore
2122
from xmodule.xml_block import XmlMixin
2223

@@ -336,8 +337,14 @@ def _import_xml_node_to_parent(
336337
new_xblock = store.update_item(temp_xblock, user_id, allow_not_found=True)
337338
parent_xblock.children.append(new_xblock.location)
338339
store.update_item(parent_xblock, user_id)
339-
for child_node in child_nodes:
340-
_import_xml_node_to_parent(child_node, new_xblock, store, user_id=user_id)
340+
if isinstance(new_xblock, LibraryContentBlock):
341+
# Special case handling for library content. If we need this for other blocks in the future, it can be made into
342+
# an API, and we'd call new_block.studio_post_paste() instead of this code.
343+
# In this case, we want to pull the children from the library and let library_tools assign their IDs.
344+
new_xblock.tools.update_children(new_xblock, version=new_xblock.source_library_version)
345+
else:
346+
for child_node in child_nodes:
347+
_import_xml_node_to_parent(child_node, new_xblock, store, user_id=user_id)
341348
return new_xblock
342349

343350

cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44
APIs.
55
"""
66
import ddt
7+
from django.test import LiveServerTestCase
78
from opaque_keys.edx.keys import UsageKey
89
from rest_framework.test import APIClient
9-
from xmodule.modulestore.django import contentstore
10+
from xmodule.modulestore.django import contentstore, modulestore
1011
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course
11-
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory
12+
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, LibraryFactory, ToyCourseFactory
13+
14+
from cms.djangoapps.contentstore.utils import reverse_usage_url
15+
from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient
1216

1317
CLIPBOARD_ENDPOINT = "/api/content-staging/v1/clipboard/"
1418
XBLOCK_ENDPOINT = "/xblock/"
@@ -109,7 +113,7 @@ def test_copy_and_paste_component(self, block_args):
109113
"""
110114
Test copying a component (XBlock) from one course into another
111115
"""
112-
source_course = CourseFactory.create(display_name='Destination Course')
116+
source_course = CourseFactory.create(display_name='Source Course')
113117
source_block = BlockFactory.create(parent_location=source_course.location, **block_args)
114118

115119
dest_course = CourseFactory.create(display_name='Destination Course')
@@ -204,3 +208,79 @@ def test_paste_with_assets(self):
204208
source_pic2_hash = contentstore().find(source_course.id.make_asset_key("asset", "picture2.jpg")).content_digest
205209
dest_pic2_hash = contentstore().find(dest_course_key.make_asset_key("asset", "picture2.jpg")).content_digest
206210
assert source_pic2_hash != dest_pic2_hash # Because there was a conflict, this file was unchanged.
211+
212+
213+
class ClipboardLibraryContentPasteTestCase(LiveServerTestCase, ModuleStoreTestCase):
214+
"""
215+
Test Clipboard Paste functionality with library content
216+
"""
217+
218+
def setUp(self):
219+
"""
220+
Set up a v2 Content Library and a library content block
221+
"""
222+
super().setUp()
223+
self.client = AjaxEnabledTestClient()
224+
self.client.login(username=self.user.username, password=self.user_password)
225+
self.store = modulestore()
226+
227+
def test_paste_library_content_block_v1(self):
228+
"""
229+
Same as the above test, but uses modulestore (v1) content library
230+
"""
231+
library = LibraryFactory.create()
232+
data = {
233+
'parent_locator': str(library.location),
234+
'category': 'html',
235+
'display_name': 'HTML Content',
236+
}
237+
response = self.client.ajax_post(XBLOCK_ENDPOINT, data)
238+
self.assertEqual(response.status_code, 200)
239+
course = CourseFactory.create(display_name='Course')
240+
orig_lc_block = BlockFactory.create(
241+
parent=course,
242+
category="library_content",
243+
source_library_id=str(library.location.library_key),
244+
display_name="LC Block",
245+
publish_item=False,
246+
)
247+
orig_lc_block.refresh_children()
248+
orig_child = self.store.get_item(orig_lc_block.children[0])
249+
assert orig_child.display_name == "HTML Content"
250+
# Copy a library content block that has children:
251+
copy_response = self.client.post(CLIPBOARD_ENDPOINT, {
252+
"usage_key": str(orig_lc_block.location)
253+
}, format="json")
254+
assert copy_response.status_code == 200
255+
256+
# Paste the Library content block:
257+
paste_response = self.client.ajax_post(XBLOCK_ENDPOINT, {
258+
"parent_locator": str(course.location),
259+
"staged_content": "clipboard",
260+
})
261+
assert paste_response.status_code == 200
262+
dest_lc_block_key = UsageKey.from_string(paste_response.json()["locator"])
263+
264+
# Get the ID of the new child:
265+
dest_lc_block = self.store.get_item(dest_lc_block_key)
266+
dest_child = self.store.get_item(dest_lc_block.children[0])
267+
assert dest_child.display_name == "HTML Content"
268+
269+
# Importantly, the ID of the child must not changed when the library content is synced.
270+
# Otherwise, user state saved against this child will be lost when it syncs.
271+
dest_lc_block.refresh_children()
272+
updated_dest_child = self.store.get_item(dest_lc_block.children[0])
273+
assert dest_child.location == updated_dest_child.location
274+
275+
def _sync_lc_block_from_library(self, attr_name):
276+
"""
277+
Helper method to "sync" a Library Content Block by [re-]fetching its
278+
children from the library.
279+
"""
280+
usage_key = getattr(self, attr_name).location
281+
# It's easiest to do this via the REST API:
282+
handler_url = reverse_usage_url('preview_handler', usage_key, kwargs={'handler': 'upgrade_and_sync'})
283+
response = self.client.post(handler_url)
284+
assert response.status_code == 200
285+
# Now reload the block and make sure the child is in place
286+
setattr(self, attr_name, self.store.get_item(usage_key)) # we must reload after upgrade_and_sync

openedx/core/djangoapps/content_libraries/api.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,15 @@
9191
from xblock.exceptions import XBlockNotFoundError
9292
from edx_rest_api_client.client import OAuthAPIClient
9393
from openedx.core.djangoapps.content_libraries import permissions
94-
from openedx.core.djangoapps.content_libraries.constants import DRAFT_NAME, COMPLEX
94+
# pylint: disable=unused-import
95+
from openedx.core.djangoapps.content_libraries.constants import (
96+
ALL_RIGHTS_RESERVED,
97+
CC_4_BY,
98+
COMPLEX,
99+
DRAFT_NAME,
100+
PROBLEM,
101+
VIDEO,
102+
)
95103
from openedx.core.djangoapps.content_libraries.library_bundle import LibraryBundle
96104
from openedx.core.djangoapps.content_libraries.libraries_index import ContentLibraryIndexer, LibraryBlockIndexer
97105
from openedx.core.djangoapps.content_libraries.models import (
@@ -425,6 +433,8 @@ def create_library(
425433
426434
allow_public_read: Allow anyone to view blocks (including source) in Studio?
427435
436+
library_type: Deprecated parameter, not really used. Set to COMPLEX.
437+
428438
Returns a ContentLibraryMetadata instance.
429439
"""
430440
assert isinstance(collection_uuid, UUID)

0 commit comments

Comments
 (0)