Skip to content

Commit

Permalink
refactor: Duplicate and update primitives made available.
Browse files Browse the repository at this point in the history
  • Loading branch information
Kelketek committed Apr 20, 2023
1 parent 1bd5b0d commit a91b495
Show file tree
Hide file tree
Showing 6 changed files with 287 additions and 56 deletions.
6 changes: 3 additions & 3 deletions cms/djangoapps/contentstore/tests/test_libraries.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient, parse_json
from cms.djangoapps.contentstore.utils import reverse_library_url, reverse_url, reverse_usage_url
from cms.djangoapps.contentstore.views.item import _duplicate_item
from cms.djangoapps.contentstore.views.item import duplicate_block
from cms.djangoapps.contentstore.views.preview import _load_preview_module
from cms.djangoapps.contentstore.views.tests.test_library import LIBRARY_REST_URL
from cms.djangoapps.course_creators.views import add_user_with_status_granted
Expand Down Expand Up @@ -947,7 +947,7 @@ def test_persistent_overrides(self, duplicate):
if duplicate:
# Check that this also works when the RCB is duplicated.
self.lc_block = modulestore().get_item(
_duplicate_item(self.course.location, self.lc_block.location, self.user)
duplicate_block(self.course.location, self.lc_block.location, self.user)
)
self.problem_in_course = modulestore().get_item(self.lc_block.children[0])
else:
Expand Down Expand Up @@ -1006,7 +1006,7 @@ def test_duplicated_version(self):

# Duplicate self.lc_block:
duplicate = store.get_item(
_duplicate_item(self.course.location, self.lc_block.location, self.user)
duplicate_block(self.course.location, self.lc_block.location, self.user)
)
# The duplicate should have identical children to the original:
self.assertEqual(len(duplicate.children), 1)
Expand Down
117 changes: 79 additions & 38 deletions cms/djangoapps/contentstore/views/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,11 +239,11 @@ def xblock_handler(request, usage_key_string=None):
status=400
)

dest_usage_key = _duplicate_item(
dest_usage_key = duplicate_block(
parent_usage_key,
duplicate_source_usage_key,
request.user,
request.json.get('display_name'),
display_name=request.json.get('display_name'),
)
return JsonResponse({
'locator': str(dest_usage_key),
Expand Down Expand Up @@ -873,47 +873,88 @@ def _move_item(source_usage_key, target_parent_usage_key, user, target_index=Non
return JsonResponse(context)


def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_name=None, is_child=False):
def gather_block_attributes(source_item, display_name=None, is_child=False):
"""
Gather all the attributes of the source block that need to be copied over to a new or updated block.
"""
# Update the display name to indicate this is a duplicate (unless display name provided).
# Can't use own_metadata(), b/c it converts data for JSON serialization -
# not suitable for setting metadata of the new block
duplicate_metadata = {}
for field in source_item.fields.values():
if field.scope == Scope.settings and field.is_set_on(source_item):
duplicate_metadata[field.name] = field.read_from(source_item)

if is_child:
display_name = display_name or source_item.display_name or source_item.category

if display_name is not None:
duplicate_metadata['display_name'] = display_name
else:
if source_item.display_name is None:
duplicate_metadata['display_name'] = _("Duplicate of {0}").format(source_item.category)
else:
duplicate_metadata['display_name'] = _("Duplicate of '{0}'").format(source_item.display_name)

asides_to_create = []
for aside in source_item.runtime.get_asides(source_item):
for field in aside.fields.values():
if field.scope in (Scope.settings, Scope.content,) and field.is_set_on(aside):
asides_to_create.append(aside)
break

for aside in asides_to_create:
for field in aside.fields.values():
if field.scope not in (Scope.settings, Scope.content,):
field.delete_from(aside)
return duplicate_metadata, asides_to_create


def update_from_source(*, source_block, destination_block, user_id):
"""
Update a block to have all the settings and attributes of another source.
Copies over all attributes and settings of a source block to a destination
block. Blocks must be the same type. This function does not modify or duplicate
children.
"""
duplicate_metadata, asides = gather_block_attributes(source_block, display_name=source_block.display_name)
for key, value in duplicate_metadata.items():
setattr(destination_block, key, value)
for key, value in source_block.get_explicitly_set_fields_by_scope(Scope.content).items():
setattr(destination_block, key, value)
modulestore().update_item(
destination_block,
user_id,
metadata=duplicate_metadata,
asides=asides,
)


def duplicate_block(
parent_usage_key,
duplicate_source_usage_key,
user,
dest_usage_key=None,
display_name=None,
shallow=False,
is_child=False
):
"""
Duplicate an existing xblock as a child of the supplied parent_usage_key.
"""
store = modulestore()
with store.bulk_operations(duplicate_source_usage_key.course_key):
source_item = store.get_item(duplicate_source_usage_key)
# Change the blockID to be unique.
dest_usage_key = source_item.location.replace(name=uuid4().hex)
category = dest_usage_key.block_type

# Update the display name to indicate this is a duplicate (unless display name provided).
# Can't use own_metadata(), b/c it converts data for JSON serialization -
# not suitable for setting metadata of the new block
duplicate_metadata = {}
for field in source_item.fields.values():
if field.scope == Scope.settings and field.is_set_on(source_item):
duplicate_metadata[field.name] = field.read_from(source_item)

if is_child:
display_name = display_name or source_item.display_name or source_item.category

if display_name is not None:
duplicate_metadata['display_name'] = display_name
else:
if source_item.display_name is None:
duplicate_metadata['display_name'] = _("Duplicate of {0}").format(source_item.category)
else:
duplicate_metadata['display_name'] = _("Duplicate of '{0}'").format(source_item.display_name)
if not dest_usage_key:
# Change the blockID to be unique.
dest_usage_key = source_item.location.replace(name=uuid4().hex)

asides_to_create = []
for aside in source_item.runtime.get_asides(source_item):
for field in aside.fields.values():
if field.scope in (Scope.settings, Scope.content,) and field.is_set_on(aside):
asides_to_create.append(aside)
break
category = dest_usage_key.block_type

for aside in asides_to_create:
for field in aside.fields.values():
if field.scope not in (Scope.settings, Scope.content,):
field.delete_from(aside)
duplicate_metadata, asides_to_create = gather_block_attributes(
source_item, display_name=display_name, is_child=is_child,
)

dest_module = store.create_item(
user.id,
Expand All @@ -937,11 +978,11 @@ def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_

# Children are not automatically copied over (and not all xblocks have a 'children' attribute).
# Because DAGs are not fully supported, we need to actually duplicate each child as well.
if source_item.has_children and not children_handled:
if source_item.has_children and not shallow and not children_handled:
dest_module.children = dest_module.children or []
for child in source_item.children:
dupe = _duplicate_item(dest_module.location, child, user=user, is_child=True)
if dupe not in dest_module.children: # _duplicate_item may add the child for us.
dupe = duplicate_block(dest_module.location, child, user=user, is_child=True)
if dupe not in dest_module.children: # _duplicate_block may add the child for us.
dest_module.children.append(dupe)
store.update_item(dest_module, user.id)

Expand Down
133 changes: 132 additions & 1 deletion cms/djangoapps/contentstore/views/tests/test_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
_get_source_index,
_xblock_type_and_display_name,
add_container_page_publishing_info,
create_xblock_info,
create_xblock_info, duplicate_block, update_from_source,
)


Expand Down Expand Up @@ -780,6 +780,29 @@ def verify_name(source_usage_key, parent_usage_key, expected_name, display_name=
# Now send a custom display name for the duplicate.
verify_name(self.seq_usage_key, self.chapter_usage_key, "customized name", display_name="customized name")

def test_shallow_duplicate(self):
"""
Test that shallow_duplicate creates a new block.
"""
source_course = CourseFactory()
user = UserFactory.create()
source_chapter = ItemFactory(parent=source_course, category='chapter', display_name='Source Chapter')
ItemFactory(parent=source_chapter, category='html', display_name='Child')
# Refresh.
source_chapter = self.store.get_item(source_chapter.location)
self.assertEqual(len(source_chapter.get_children()), 1)
destination_course = CourseFactory()
destination_location = duplicate_block(
parent_usage_key=destination_course.location, duplicate_source_usage_key=source_chapter.location,
user=user,
display_name=source_chapter.display_name,
shallow=True,
)
# Refresh here, too, just to be sure.
destination_chapter = self.store.get_item(destination_location)
self.assertEqual(len(destination_chapter.get_children()), 0)
self.assertEqual(destination_chapter.display_name, 'Source Chapter')


@ddt.ddt
class TestMoveItem(ItemTest):
Expand Down Expand Up @@ -3587,3 +3610,111 @@ def test_creator_show_delete_button_with_waffle(self):
)

self.assertFalse(xblock_info['show_delete_button'])


@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types',
lambda self, block: ['test_aside'])
class TestUpdateFromSource(ModuleStoreTestCase):
"""
Test update_from_source.
"""

def setUp(self):
"""
Set up the runtime for tests.
"""
super().setUp()
key_store = DictKeyValueStore()
field_data = KvsFieldData(key_store)
self.runtime = TestRuntime(services={'field-data': field_data})

def create_source_block(self, course):
"""
Create a chapter with all the fixings.
"""
source_block = ItemFactory(
parent=course,
category='course_info',
display_name='Source Block',
metadata={'due': datetime(2010, 11, 22, 4, 0, tzinfo=UTC)},
)

def_id = self.runtime.id_generator.create_definition('html')
usage_id = self.runtime.id_generator.create_usage(def_id)

aside = AsideTest(scope_ids=ScopeIds('user', 'html', def_id, usage_id), runtime=self.runtime)
aside.field11 = 'html_new_value1'

# The data attribute is handled in a special manner and should be updated.
source_block.data = '<div>test</div>'
# This field is set on the content scope (definition_data), which should be updated.
source_block.items = ['test', 'beep']

self.store.update_item(source_block, self.user.id, asides=[aside])

# quick sanity checks
source_block = self.store.get_item(source_block.location)
self.assertEqual(source_block.due, datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
self.assertEqual(source_block.display_name, 'Source Block')
self.assertEqual(source_block.runtime.get_asides(source_block)[0].field11, 'html_new_value1')
self.assertEqual(source_block.data, '<div>test</div>')
self.assertEqual(source_block.items, ['test', 'beep'])

return source_block

def check_updated(self, source_block, destination_key):
"""
Check that the destination block has been updated to match our source block.
"""
revised = self.store.get_item(destination_key)
self.assertEqual(source_block.display_name, revised.display_name)
self.assertEqual(source_block.due, revised.due)
self.assertEqual(revised.data, source_block.data)
self.assertEqual(revised.items, source_block.items)

self.assertEqual(
revised.runtime.get_asides(revised)[0].field11,
source_block.runtime.get_asides(source_block)[0].field11,
)

@XBlockAside.register_temp_plugin(AsideTest, 'test_aside')
def test_update_from_source(self):
"""
Test that update_from_source updates the destination block.
"""
course = CourseFactory()
user = UserFactory.create()

source_block = self.create_source_block(course)

destination_block = ItemFactory(parent=course, category='course_info', display_name='Destination Problem')
update_from_source(source_block=source_block, destination_block=destination_block, user_id=user.id)
self.check_updated(source_block, destination_block.location)

@XBlockAside.register_temp_plugin(AsideTest, 'test_aside')
def test_update_clobbers(self):
"""
Verify that our update clobbers everything.
"""
course = CourseFactory()
user = UserFactory.create()

source_block = self.create_source_block(course)

destination_block = ItemFactory(
parent=course,
category='course_info',
display_name='Destination Chapter',
metadata={'due': datetime(2025, 10, 21, 6, 5, tzinfo=UTC)},
)

def_id = self.runtime.id_generator.create_definition('html')
usage_id = self.runtime.id_generator.create_usage(def_id)
aside = AsideTest(scope_ids=ScopeIds('user', 'html', def_id, usage_id), runtime=self.runtime)
aside.field11 = 'Other stuff'
destination_block.data = '<div>other stuff</div>'
destination_block.items = ['other stuff', 'boop']
self.store.update_item(destination_block, user.id, asides=[aside])

update_from_source(source_block=source_block, destination_block=destination_block, user_id=user.id)
self.check_updated(source_block, destination_block.location)
14 changes: 2 additions & 12 deletions common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@

import copy
import datetime
import hashlib
import logging
from collections import defaultdict
from importlib import import_module
Expand Down Expand Up @@ -102,7 +101,7 @@
)
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 ..exceptions import ItemNotFoundError
Expand Down Expand Up @@ -2428,23 +2427,14 @@ def _copy_from_template(

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]

if block_key not in source_structure['blocks']:
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)
Expand Down
Loading

0 comments on commit a91b495

Please sign in to comment.