fix: handle paste of library content blocks correctly (#33926)
This commit is contained in:
@@ -17,6 +17,7 @@ from xblock.runtime import IdGenerator
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from xmodule.library_content_block import LibraryContentBlock
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.xml_block import XmlMixin
|
||||
|
||||
@@ -336,8 +337,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 isinstance(new_xblock, LibraryContentBlock):
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
@@ -4,12 +4,19 @@ allow users to paste XBlocks that were copied using the staged_content/clipboard
|
||||
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 @@ class ClipboardPasteTestCase(ModuleStoreTestCase):
|
||||
"""
|
||||
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 @@ class ClipboardPasteTestCase(ModuleStoreTestCase):
|
||||
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, """
|
||||
<problem display_name="MCQ" max_attempts="1">
|
||||
<multiplechoiceresponse>
|
||||
<label>Q</label>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false">Wrong</choice>
|
||||
<choice correct="true">Right</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</problem>
|
||||
""")
|
||||
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
|
||||
|
||||
@@ -91,7 +91,15 @@ from xblock.exceptions import XBlockNotFoundError
|
||||
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)
|
||||
|
||||
@@ -31,7 +31,6 @@ from common.djangoapps.student.roles import (
|
||||
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 @@ class TestTaxonomyObjectsMixin:
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user