Skip to content

Commit 326f2d2

Browse files
authored
fix: set upstream link for re-copied block from course originally from library (#35784) (#35801)
Sets upstream link to library block for blocks that were copied from a course block which were originally copied/imported from a library. (cherry picked from commit d82aada)
1 parent db0b890 commit 326f2d2

File tree

2 files changed

+81
-44
lines changed

2 files changed

+81
-44
lines changed

cms/djangoapps/contentstore/helpers.py

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from xmodule.xml_block import XmlMixin
2525

2626
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
27-
from cms.lib.xblock.upstream_sync import UpstreamLink, BadUpstream, BadDownstream, fetch_customizable_fields
27+
from cms.lib.xblock.upstream_sync import UpstreamLink, UpstreamLinkException, fetch_customizable_fields
2828
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
2929
import openedx.core.djangoapps.content_staging.api as content_staging_api
3030
import openedx.core.djangoapps.content_tagging.api as content_tagging_api
@@ -323,6 +323,56 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
323323
return new_xblock, notices
324324

325325

326+
def _fetch_and_set_upstream_link(
327+
copied_from_block: str,
328+
copied_from_version_num: int,
329+
temp_xblock: XBlock,
330+
user: User
331+
):
332+
"""
333+
Fetch and set upstream link for the given xblock. This function handles following cases:
334+
* the xblock is copied from a v2 library; the library block is set as upstream.
335+
* the xblock is copied from a course; no upstream is set, only copied_from_block is set.
336+
* the xblock is copied from a course where the source block was imported from a library; the original libary block
337+
is set as upstream.
338+
"""
339+
# Try to link the pasted block (downstream) to the copied block (upstream).
340+
temp_xblock.upstream = copied_from_block
341+
try:
342+
UpstreamLink.get_for_block(temp_xblock)
343+
except UpstreamLinkException:
344+
# Usually this will fail. For example, if the copied block is a modulestore course block, it can't be an
345+
# upstream. That's fine! Instead, we store a reference to where this block was copied from, in the
346+
# 'copied_from_block' field (from AuthoringMixin).
347+
348+
# In case if the source block was imported from a library, we need to check its upstream
349+
# and set the same upstream link for the new block.
350+
source_descriptor = modulestore().get_item(UsageKey.from_string(copied_from_block))
351+
if source_descriptor.upstream:
352+
_fetch_and_set_upstream_link(
353+
source_descriptor.upstream,
354+
source_descriptor.upstream_version,
355+
temp_xblock,
356+
user,
357+
)
358+
else:
359+
# else we store a reference to where this block was copied from, in the 'copied_from_block'
360+
# field (from AuthoringMixin).
361+
temp_xblock.upstream = None
362+
temp_xblock.copied_from_block = copied_from_block
363+
else:
364+
# But if it doesn't fail, then populate the `upstream_version` field based on what was copied. Note that
365+
# this could be the latest published version, or it could be an an even newer draft version.
366+
temp_xblock.upstream_version = copied_from_version_num
367+
# Also, fetch upstream values (`upstream_display_name`, etc.).
368+
# Recall that the copied block could be a draft. So, rather than fetching from the published upstream (which
369+
# could be older), fetch from the copied block itself. That way, if an author customizes a field, but then
370+
# later wants to restore it, it will restore to the value that the field had when the block was pasted. Of
371+
# course, if the author later syncs updates from a *future* published upstream version, then that will fetch
372+
# new values from the published upstream content.
373+
fetch_customizable_fields(upstream=temp_xblock, downstream=temp_xblock, user=user)
374+
375+
326376
def _import_xml_node_to_parent(
327377
node,
328378
parent_xblock: XBlock,
@@ -404,28 +454,7 @@ def _import_xml_node_to_parent(
404454
raise NotImplementedError("We don't yet support pasting XBlocks with children")
405455
temp_xblock.parent = parent_key
406456
if copied_from_block:
407-
# Try to link the pasted block (downstream) to the copied block (upstream).
408-
temp_xblock.upstream = copied_from_block
409-
try:
410-
UpstreamLink.get_for_block(temp_xblock)
411-
except (BadDownstream, BadUpstream):
412-
# Usually this will fail. For example, if the copied block is a modulestore course block, it can't be an
413-
# upstream. That's fine! Instead, we store a reference to where this block was copied from, in the
414-
# 'copied_from_block' field (from AuthoringMixin).
415-
temp_xblock.upstream = None
416-
temp_xblock.copied_from_block = copied_from_block
417-
else:
418-
# But if it doesn't fail, then populate the `upstream_version` field based on what was copied. Note that
419-
# this could be the latest published version, or it could be an an even newer draft version.
420-
temp_xblock.upstream_version = copied_from_version_num
421-
# Also, fetch upstream values (`upstream_display_name`, etc.).
422-
# Recall that the copied block could be a draft. So, rather than fetching from the published upstream (which
423-
# could be older), fetch from the copied block itself. That way, if an author customizes a field, but then
424-
# later wants to restore it, it will restore to the value that the field had when the block was pasted. Of
425-
# course, if the author later syncs updates from a *future* published upstream version, then that will fetch
426-
# new values from the published upstream content.
427-
fetch_customizable_fields(upstream=temp_xblock, downstream=temp_xblock, user=user)
428-
457+
_fetch_and_set_upstream_link(copied_from_block, copied_from_version_num, temp_xblock, user)
429458
# Save the XBlock into modulestore. We need to save the block and its parent for this to work:
430459
new_xblock = store.update_item(temp_xblock, user.id, allow_not_found=True)
431460
parent_xblock.children.append(new_xblock.location)
@@ -436,7 +465,7 @@ def _import_xml_node_to_parent(
436465
# Allow an XBlock to do anything fancy it may need to when pasted from the clipboard.
437466
# These blocks may handle their own children or parenting if needed. Let them return booleans to
438467
# let us know if we need to handle these or not.
439-
children_handed = new_xblock.studio_post_paste(store, node)
468+
children_handled = new_xblock.studio_post_paste(store, node)
440469

441470
if not children_handled:
442471
for child_node in child_nodes:

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

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -456,26 +456,6 @@ def setUp(self):
456456
self.lib_block_tags = ['tag_1', 'tag_5']
457457
tagging_api.tag_object(str(self.lib_block_key), taxonomy_all_org, self.lib_block_tags)
458458

459-
def test_paste_from_library_creates_link(self):
460-
"""
461-
When we copy a v2 lib block into a course, the dest block should be linked up to the lib block.
462-
"""
463-
copy_response = self.client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(self.lib_block_key)}, format="json")
464-
assert copy_response.status_code == 200
465-
466-
paste_response = self.client.post(XBLOCK_ENDPOINT, {
467-
"parent_locator": str(self.course.usage_key),
468-
"staged_content": "clipboard",
469-
}, format="json")
470-
assert paste_response.status_code == 200
471-
472-
new_block_key = UsageKey.from_string(paste_response.json()["locator"])
473-
new_block = modulestore().get_item(new_block_key)
474-
assert new_block.upstream == str(self.lib_block_key)
475-
assert new_block.upstream_version == 3
476-
assert new_block.upstream_display_name == "MCQ-draft"
477-
assert new_block.upstream_max_attempts == 5
478-
479459
def test_paste_from_library_read_only_tags(self):
480460
"""
481461
When we copy a v2 lib block into a course, the dest block should have read-only copied tags.
@@ -556,6 +536,34 @@ def test_paste_from_library_copies_asset(self):
556536
assert image_asset.name == "1px.webp"
557537
assert image_asset.length == len(webp_raw_data)
558538

539+
def test_paste_from_course_block_imported_from_library_creates_link(self):
540+
"""
541+
When we copy a course xblock which was imported or copied from v2 lib block into a course,
542+
the dest block should be linked up to the original lib block.
543+
"""
544+
def _copy_paste_and_assert_link(key_to_copy):
545+
copy_response = self.client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(key_to_copy)}, format="json")
546+
assert copy_response.status_code == 200
547+
548+
paste_response = self.client.post(XBLOCK_ENDPOINT, {
549+
"parent_locator": str(self.course.usage_key),
550+
"staged_content": "clipboard",
551+
}, format="json")
552+
assert paste_response.status_code == 200
553+
554+
new_block_key = UsageKey.from_string(paste_response.json()["locator"])
555+
new_block = modulestore().get_item(new_block_key)
556+
assert new_block.upstream == str(self.lib_block_key)
557+
assert new_block.upstream_version == 3
558+
assert new_block.upstream_display_name == "MCQ-draft"
559+
assert new_block.upstream_max_attempts == 5
560+
return new_block_key
561+
562+
# first verify link for copied block from library
563+
new_block_key = _copy_paste_and_assert_link(self.lib_block_key)
564+
# next verify link for copied block from the pasted block
565+
_copy_paste_and_assert_link(new_block_key)
566+
559567

560568
class ClipboardPasteFromV1LibraryTestCase(ModuleStoreTestCase):
561569
"""

0 commit comments

Comments
 (0)