[FC-0009] Studio backend APIs to support copying and pasting of entire Units (#32812)
* feat: tests for copying units in content_staging app * chore: convert old tests to pytest style * feat: make pasting units work (in Studio backend API) * refactor: make XmlMixin.parse_xml more like standard XBlock behavior
This commit is contained in:
@@ -18,6 +18,7 @@ from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.xml_block import XmlMixin
|
||||
|
||||
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
|
||||
import openedx.core.djangoapps.content_staging.api as content_staging_api
|
||||
@@ -238,7 +239,6 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
|
||||
if not user_clipboard:
|
||||
# Clipboard is empty or expired/error/loading
|
||||
return None, StaticFileNotices()
|
||||
block_type = user_clipboard.content.block_type
|
||||
olx_str = content_staging_api.get_staged_content_olx(user_clipboard.content.id)
|
||||
static_files = content_staging_api.get_staged_content_static_files(user_clipboard.content.id)
|
||||
node = etree.fromstring(olx_str)
|
||||
@@ -246,35 +246,15 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
|
||||
with store.bulk_operations(parent_key.course_key):
|
||||
parent_descriptor = store.get_item(parent_key)
|
||||
# Some blocks like drag-and-drop only work here with the full XBlock runtime loaded:
|
||||
|
||||
parent_xblock = _load_preview_block(request, parent_descriptor)
|
||||
runtime = parent_xblock.runtime
|
||||
# Generate the new ID:
|
||||
id_generator = ImportIdGenerator(parent_key.context_key)
|
||||
def_id = id_generator.create_definition(block_type, user_clipboard.source_usage_key.block_id)
|
||||
usage_id = id_generator.create_usage(def_id)
|
||||
keys = ScopeIds(None, block_type, def_id, usage_id)
|
||||
# parse_xml is a really messy API. We pass both 'keys' and 'id_generator' and, depending on the XBlock, either
|
||||
# one may be used to determine the new XBlock's usage key, and the other will be ignored. e.g. video ignores
|
||||
# 'keys' and uses 'id_generator', but the default XBlock parse_xml ignores 'id_generator' and uses 'keys'.
|
||||
# For children of this block, obviously only id_generator is used.
|
||||
xblock_class = runtime.load_block_type(block_type)
|
||||
# Note: if we find a case where any XBlock needs access to the block-specific static files that were saved to
|
||||
# export_fs during copying, we could make them available here via runtime.resources_fs before calling parse_xml.
|
||||
# However, currently the only known case for that is video block's transcript files, and those will
|
||||
# automatically be "carried over" to the new XBlock even in a different course because the video ID is the same,
|
||||
# and VAL will thus make the transcript available.
|
||||
temp_xblock = xblock_class.parse_xml(node, runtime, keys, id_generator)
|
||||
if xblock_class.has_children and temp_xblock.children:
|
||||
raise NotImplementedError("We don't yet support pasting XBlocks with children")
|
||||
temp_xblock.parent = parent_key
|
||||
# Store a reference to where this block was copied from, in the 'copied_from_block' field (AuthoringMixin)
|
||||
temp_xblock.copied_from_block = str(user_clipboard.source_usage_key)
|
||||
# Save the XBlock into modulestore. We need to save the block and its parent for this to work:
|
||||
new_xblock = store.update_item(temp_xblock, request.user.id, allow_not_found=True)
|
||||
parent_xblock.children.append(new_xblock.location)
|
||||
store.update_item(parent_xblock, request.user.id)
|
||||
|
||||
new_xblock = _import_xml_node_to_parent(
|
||||
node,
|
||||
parent_xblock,
|
||||
store,
|
||||
user_id=request.user.id,
|
||||
slug_hint=user_clipboard.source_usage_key.block_id,
|
||||
copied_from_block=str(user_clipboard.source_usage_key),
|
||||
)
|
||||
# Now handle static files that need to go into Files & Uploads:
|
||||
notices = _import_files_into_course(
|
||||
course_key=parent_key.context_key,
|
||||
@@ -285,6 +265,80 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
|
||||
return new_xblock, notices
|
||||
|
||||
|
||||
def _import_xml_node_to_parent(
|
||||
node,
|
||||
parent_xblock: XBlock,
|
||||
# The modulestore we're using
|
||||
store,
|
||||
# The ID of the user who is performing this operation
|
||||
user_id: int,
|
||||
# Hint to use as usage ID (block_id) for the new XBlock
|
||||
slug_hint: str | None = None,
|
||||
# UsageKey of the XBlock that this one is a copy of
|
||||
copied_from_block: str | None = None,
|
||||
) -> XBlock:
|
||||
"""
|
||||
Given an XML node representing a serialized XBlock (OLX), import it into modulestore 'store' as a child of the
|
||||
specified parent block. Recursively copy children as needed.
|
||||
"""
|
||||
runtime = parent_xblock.runtime
|
||||
parent_key = parent_xblock.scope_ids.usage_id
|
||||
block_type = node.tag
|
||||
|
||||
# Generate the new ID:
|
||||
id_generator = ImportIdGenerator(parent_key.context_key)
|
||||
def_id = id_generator.create_definition(block_type, slug_hint)
|
||||
usage_id = id_generator.create_usage(def_id)
|
||||
keys = ScopeIds(None, block_type, def_id, usage_id)
|
||||
# parse_xml is a really messy API. We pass both 'keys' and 'id_generator' and, depending on the XBlock, either
|
||||
# one may be used to determine the new XBlock's usage key, and the other will be ignored. e.g. video ignores
|
||||
# 'keys' and uses 'id_generator', but the default XBlock parse_xml ignores 'id_generator' and uses 'keys'.
|
||||
# For children of this block, obviously only id_generator is used.
|
||||
xblock_class = runtime.load_block_type(block_type)
|
||||
# Note: if we find a case where any XBlock needs access to the block-specific static files that were saved to
|
||||
# export_fs during copying, we could make them available here via runtime.resources_fs before calling parse_xml.
|
||||
# However, currently the only known case for that is video block's transcript files, and those will
|
||||
# automatically be "carried over" to the new XBlock even in a different course because the video ID is the same,
|
||||
# and VAL will thus make the transcript available.
|
||||
|
||||
child_nodes = []
|
||||
if not xblock_class.has_children:
|
||||
# No children to worry about. The XML may contain child nodes, but they're not XBlocks.
|
||||
temp_xblock = xblock_class.parse_xml(node, runtime, keys, id_generator)
|
||||
else:
|
||||
# We have to handle the children ourselves, because there are lots of complex interactions between
|
||||
# * the vanilla XBlock parse_xml() method, and its lack of API for "create and save a new XBlock"
|
||||
# * the XmlMixin version of parse_xml() which only works with ImportSystem, not modulestore or the v2 runtime
|
||||
# * the modulestore APIs for creating and saving a new XBlock, which work but don't support XML parsing.
|
||||
# We can safely assume that if the XBLock class supports children, every child node will be the XML
|
||||
# serialization of a child block, in order. For blocks that don't support children, their XML content/nodes
|
||||
# could be anything (e.g. HTML, capa)
|
||||
node_without_children = etree.Element(node.tag, **node.attrib)
|
||||
if issubclass(xblock_class, XmlMixin):
|
||||
# Hack: XBlocks that use "XmlMixin" have their own XML parsing behavior, and in particular if they encounter
|
||||
# an XML node that has no children and has only a "url_name" attribute, they'll try to load the XML data
|
||||
# from an XML file in runtime.resources_fs. But that file doesn't exist here. So we set at least one
|
||||
# additional attribute here to make sure that url_name is not the only attribute; otherwise in some cases,
|
||||
# XmlMixin.parse_xml will try to load an XML file that doesn't exist, giving an error. The name and value
|
||||
# of this attribute don't matter and should be ignored.
|
||||
node_without_children.attrib["x-is-pointer-node"] = "no"
|
||||
temp_xblock = xblock_class.parse_xml(node_without_children, runtime, keys, id_generator)
|
||||
child_nodes = list(node)
|
||||
if xblock_class.has_children and temp_xblock.children:
|
||||
raise NotImplementedError("We don't yet support pasting XBlocks with children")
|
||||
temp_xblock.parent = parent_key
|
||||
if copied_from_block:
|
||||
# Store a reference to where this block was copied from, in the 'copied_from_block' field (AuthoringMixin)
|
||||
temp_xblock.copied_from_block = copied_from_block
|
||||
# Save the XBlock into modulestore. We need to save the block and its parent for this to work:
|
||||
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)
|
||||
return new_xblock
|
||||
|
||||
|
||||
def _import_files_into_course(
|
||||
course_key: CourseKey,
|
||||
staged_content_id: int,
|
||||
|
||||
@@ -5,7 +5,7 @@ APIs.
|
||||
"""
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from rest_framework.test import APIClient
|
||||
from xmodule.modulestore.django import contentstore, modulestore
|
||||
from xmodule.modulestore.django import contentstore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course
|
||||
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory
|
||||
|
||||
@@ -34,7 +34,7 @@ class ClipboardPasteTestCase(ModuleStoreTestCase):
|
||||
|
||||
# Check how many blocks are in the vertical currently
|
||||
parent_key = course_key.make_usage_key("vertical", "vertical_test") # This is the vertical that holds the video
|
||||
orig_vertical = modulestore().get_item(parent_key)
|
||||
orig_vertical = self.store.get_item(parent_key)
|
||||
assert len(orig_vertical.children) == 4
|
||||
|
||||
# Copy the video
|
||||
@@ -51,16 +51,54 @@ class ClipboardPasteTestCase(ModuleStoreTestCase):
|
||||
new_block_key = UsageKey.from_string(paste_response.json()["locator"])
|
||||
|
||||
# Now there should be an extra block in the vertical:
|
||||
updated_vertical = modulestore().get_item(parent_key)
|
||||
updated_vertical = self.store.get_item(parent_key)
|
||||
assert len(updated_vertical.children) == 5
|
||||
assert updated_vertical.children[-1] == new_block_key
|
||||
# And it should match the original:
|
||||
orig_video = modulestore().get_item(video_key)
|
||||
new_video = modulestore().get_item(new_block_key)
|
||||
orig_video = self.store.get_item(video_key)
|
||||
new_video = self.store.get_item(new_block_key)
|
||||
assert new_video.youtube_id_1_0 == orig_video.youtube_id_1_0
|
||||
# The new block should store a reference to where it was copied from
|
||||
assert new_video.copied_from_block == str(video_key)
|
||||
|
||||
def test_copy_and_paste_unit(self):
|
||||
"""
|
||||
Test copying a unit (vertical) from one course into another
|
||||
"""
|
||||
course_key, client = self._setup_course()
|
||||
dest_course = CourseFactory.create(display_name='Destination Course')
|
||||
with self.store.bulk_operations(dest_course.id):
|
||||
dest_chapter = BlockFactory.create(parent=dest_course, category='chapter', display_name='Section')
|
||||
dest_sequential = BlockFactory.create(parent=dest_chapter, category='sequential', display_name='Subsection')
|
||||
|
||||
# Copy the unit
|
||||
unit_key = course_key.make_usage_key("vertical", "vertical_test")
|
||||
copy_response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(unit_key)}, format="json")
|
||||
assert copy_response.status_code == 200
|
||||
|
||||
# Paste the unit
|
||||
paste_response = client.post(XBLOCK_ENDPOINT, {
|
||||
"parent_locator": str(dest_sequential.location),
|
||||
"staged_content": "clipboard",
|
||||
}, format="json")
|
||||
assert paste_response.status_code == 200
|
||||
dest_unit_key = UsageKey.from_string(paste_response.json()["locator"])
|
||||
|
||||
# Now there should be a one unit/vertical as a child of the destination sequential/subsection:
|
||||
updated_sequential = self.store.get_item(dest_sequential.location)
|
||||
assert updated_sequential.children == [dest_unit_key]
|
||||
# And it should match the original:
|
||||
orig_unit = self.store.get_item(unit_key)
|
||||
dest_unit = self.store.get_item(dest_unit_key)
|
||||
assert len(orig_unit.children) == len(dest_unit.children)
|
||||
# Check details of the fourth child (a poll)
|
||||
orig_poll = self.store.get_item(orig_unit.children[3])
|
||||
dest_poll = self.store.get_item(dest_unit.children[3])
|
||||
assert dest_poll.display_name == orig_poll.display_name
|
||||
assert dest_poll.question == orig_poll.question
|
||||
# The new block should store a reference to where it was copied from
|
||||
assert dest_unit.copied_from_block == str(unit_key)
|
||||
|
||||
def test_paste_with_assets(self):
|
||||
"""
|
||||
When pasting into a different course, any required static assets should
|
||||
|
||||
Reference in New Issue
Block a user