refactor: Duplicate and update primitives made available.

This makes a couple of changes to the xblock handler in the CMS. These changes 
add a handful of utility functions and modify the existing ones to make reuse 
of existing blocks easier. With these changes, it is possible to copy an 
entire section from one course to another, and then later refresh that section, 
and all of its children, without destroying the blocks next to it.

The existing _duplicate_block function was modified to have a shallow keyword 
to avoid copying children, and the update_from_source function was added to 
make it easy to copy attributes over from one block to another. These functions 
can be used alongside copy_from_template in the modulestore to copy over blocks 
and their children without requiring them to be within any particular container 
(other than a library or course root)-- thus allowing library-like inclusion 
without the library content block. This is especially useful for cases like 
copying sections rather than unit content.
This commit is contained in:
Fox Piacenti
2023-05-25 08:58:28 -05:00
committed by GitHub
parent fcb4c4d098
commit aa7370c773
8 changed files with 435 additions and 172 deletions

View File

@@ -57,7 +57,6 @@ Representation:
import copy
import datetime
import hashlib
import logging
from collections import defaultdict
from importlib import import_module
@@ -102,7 +101,7 @@ from xmodule.modulestore.exceptions import (
)
from xmodule.modulestore.split_mongo import BlockKey, CourseEnvelope
from xmodule.modulestore.split_mongo.mongo_connection import DuplicateKeyError, DjangoFlexPersistenceBackend
from xmodule.modulestore.store_utilities import DETACHED_XBLOCK_TYPES
from xmodule.modulestore.store_utilities import DETACHED_XBLOCK_TYPES, derived_key
from xmodule.partitions.partitions_service import PartitionService
from xmodule.util.misc import get_library_or_course_attribute
@@ -2369,6 +2368,9 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
time this method is called for the same source block and dest_usage, the same resulting
block id will be generated.
Note also that this function does not override any of the attributes on the destination
block-- it only replaces the destination block's children.
:param source_keys: a list of BlockUsageLocators. Order is preserved.
:param dest_usage: The BlockUsageLocator that will become the parent of an inherited copy
@@ -2442,7 +2444,6 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
for usage_key in source_keys:
src_course_key = usage_key.course_key
hashable_source_id = src_course_key.for_version(None)
block_key = BlockKey(usage_key.block_type, usage_key.block_id)
source_structure = source_structures[src_course_key]
@@ -2450,15 +2451,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
raise ItemNotFoundError(usage_key)
source_block_info = source_structure['blocks'][block_key]
# Compute a new block ID. This new block ID must be consistent when this
# method is called with the same (source_key, dest_structure) pair
unique_data = "{}:{}:{}".format(
str(hashable_source_id).encode("utf-8"),
block_key.id,
new_parent_block_key.id,
)
new_block_id = hashlib.sha1(unique_data.encode('utf-8')).hexdigest()[:20]
new_block_key = BlockKey(block_key.type, new_block_id)
new_block_key = derived_key(src_course_key, block_key, new_parent_block_key)
# Now clone block_key to new_block_key:
new_block_info = copy.deepcopy(source_block_info)

View File

@@ -1,5 +1,5 @@
# lint-amnesty, pylint: disable=missing-module-docstring
import hashlib
import logging
import re
import uuid
@@ -7,6 +7,8 @@ from collections import namedtuple
from xblock.core import XBlock
from xmodule.modulestore.split_mongo import BlockKey
DETACHED_XBLOCK_TYPES = {name for name, __ in XBlock.load_tagged_classes("detached")}
@@ -104,3 +106,31 @@ def get_draft_subtree_roots(draft_nodes):
for draft_node in draft_nodes:
if draft_node.parent_url not in urls:
yield draft_node
def derived_key(courselike_source_key, block_key, dest_parent: BlockKey):
"""
Return a new reproducible block ID for a given root, source block, and destination parent.
When recursively copying a block structure, we need to generate new block IDs for the
blocks. We don't want to use the exact same IDs as we might copy blocks multiple times.
However, we do want to be able to reproduce the same IDs when copying the same block
so that if we ever need to re-copy the block from its source (that is, to update it with
upstream changes) we don't affect any data tied to the ID, such as grades.
This is used by the copy_from_template function of the modulestore, and can be used by
pluggable django apps that need to copy blocks from one course to another in an
idempotent way. In the future, this should be created into a proper API function
in the spirit of OEP-49.
"""
hashable_source_id = courselike_source_key.for_version(None)
# Compute a new block ID. This new block ID must be consistent when this
# method is called with the same (source_key, dest_structure) pair
unique_data = "{}:{}:{}".format(
str(hashable_source_id).encode("utf-8"),
block_key.id,
dest_parent.id,
)
new_block_id = hashlib.sha1(unique_data.encode('utf-8')).hexdigest()[:20]
return BlockKey(block_key.type, new_block_id)

View File

@@ -4,11 +4,15 @@ Tests for store_utilities.py
import unittest
from unittest import TestCase
from unittest.mock import Mock
import ddt
from xmodule.modulestore.store_utilities import draft_node_constructor, get_draft_subtree_roots
from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.split_mongo import BlockKey
from xmodule.modulestore.store_utilities import draft_node_constructor, get_draft_subtree_roots, derived_key
@ddt.ddt
@@ -82,3 +86,43 @@ class TestUtils(unittest.TestCase):
subtree_roots_urls = [root.url for root in get_draft_subtree_roots(block_nodes)]
# check that we return the expected urls
assert set(subtree_roots_urls) == set(expected_roots_urls)
mock_block = Mock()
mock_block.id = CourseKey.from_string('course-v1:Beeper+B33P+BOOP')
derived_key_scenarios = [
{
'courselike_source_key': CourseKey.from_string('course-v1:edX+DemoX+Demo_Course'),
'block_key': BlockKey('chapter', 'interactive_demonstrations'),
'parent': mock_block,
'expected': BlockKey(
'chapter', '5793ec64e25ed870a7dd',
),
},
{
'courselike_source_key': CourseKey.from_string('course-v1:edX+DemoX+Demo_Course'),
'block_key': BlockKey('chapter', 'interactive_demonstrations'),
'parent': BlockKey(
'chapter', 'thingy',
),
'expected': BlockKey(
'chapter', '599792a5622d85aa41e6',
),
}
]
@ddt.ddt
class TestDerivedKey(TestCase):
"""
Test reproducible block ID generation.
"""
@ddt.data(*derived_key_scenarios)
@ddt.unpack
def test_derived_key(self, courselike_source_key, block_key, parent, expected):
"""
Test that derived_key returns the expected value.
"""
self.assertEqual(derived_key(courselike_source_key, block_key, parent), expected)