Skip to content

Commit c410212

Browse files
Kelketekxitij2000
authored andcommitted
refactor: Duplicate and update primitives made available.
(cherry picked from commit a91b495)
1 parent 64d2883 commit c410212

File tree

6 files changed

+286
-55
lines changed

6 files changed

+286
-55
lines changed

cms/djangoapps/contentstore/tests/test_libraries.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient, parse_json
1818
from cms.djangoapps.contentstore.utils import reverse_library_url, reverse_url, reverse_usage_url
19-
from cms.djangoapps.contentstore.views.block import _duplicate_block
19+
from cms.djangoapps.contentstore.views.block import duplicate_block
2020
from cms.djangoapps.contentstore.views.preview import _load_preview_block
2121
from cms.djangoapps.contentstore.views.tests.test_library import LIBRARY_REST_URL
2222
from cms.djangoapps.course_creators.views import add_user_with_status_granted
@@ -947,7 +947,7 @@ def test_persistent_overrides(self, duplicate):
947947
if duplicate:
948948
# Check that this also works when the RCB is duplicated.
949949
self.lc_block = modulestore().get_item(
950-
_duplicate_block(self.course.location, self.lc_block.location, self.user)
950+
duplicate_block(self.course.location, self.lc_block.location, self.user)
951951
)
952952
self.problem_in_course = modulestore().get_item(self.lc_block.children[0])
953953
else:
@@ -1006,7 +1006,7 @@ def test_duplicated_version(self):
10061006

10071007
# Duplicate self.lc_block:
10081008
duplicate = store.get_item(
1009-
_duplicate_block(self.course.location, self.lc_block.location, self.user)
1009+
duplicate_block(self.course.location, self.lc_block.location, self.user)
10101010
)
10111011
# The duplicate should have identical children to the original:
10121012
self.assertEqual(len(duplicate.children), 1)

cms/djangoapps/contentstore/views/block.py

Lines changed: 78 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -242,11 +242,11 @@ def xblock_handler(request, usage_key_string=None):
242242
status=400
243243
)
244244

245-
dest_usage_key = _duplicate_block(
245+
dest_usage_key = duplicate_block(
246246
parent_usage_key,
247247
duplicate_source_usage_key,
248248
request.user,
249-
request.json.get('display_name'),
249+
display_name=request.json.get('display_name'),
250250
)
251251
return JsonResponse({
252252
'locator': str(dest_usage_key),
@@ -879,47 +879,88 @@ def _move_item(source_usage_key, target_parent_usage_key, user, target_index=Non
879879
return JsonResponse(context)
880880

881881

882-
def _duplicate_block(parent_usage_key, duplicate_source_usage_key, user, display_name=None, is_child=False):
882+
def gather_block_attributes(source_item, display_name=None, is_child=False):
883+
"""
884+
Gather all the attributes of the source block that need to be copied over to a new or updated block.
885+
"""
886+
# Update the display name to indicate this is a duplicate (unless display name provided).
887+
# Can't use own_metadata(), b/c it converts data for JSON serialization -
888+
# not suitable for setting metadata of the new block
889+
duplicate_metadata = {}
890+
for field in source_item.fields.values():
891+
if field.scope == Scope.settings and field.is_set_on(source_item):
892+
duplicate_metadata[field.name] = field.read_from(source_item)
893+
894+
if is_child:
895+
display_name = display_name or source_item.display_name or source_item.category
896+
897+
if display_name is not None:
898+
duplicate_metadata['display_name'] = display_name
899+
else:
900+
if source_item.display_name is None:
901+
duplicate_metadata['display_name'] = _("Duplicate of {0}").format(source_item.category)
902+
else:
903+
duplicate_metadata['display_name'] = _("Duplicate of '{0}'").format(source_item.display_name)
904+
905+
asides_to_create = []
906+
for aside in source_item.runtime.get_asides(source_item):
907+
for field in aside.fields.values():
908+
if field.scope in (Scope.settings, Scope.content,) and field.is_set_on(aside):
909+
asides_to_create.append(aside)
910+
break
911+
912+
for aside in asides_to_create:
913+
for field in aside.fields.values():
914+
if field.scope not in (Scope.settings, Scope.content,):
915+
field.delete_from(aside)
916+
return duplicate_metadata, asides_to_create
917+
918+
919+
def update_from_source(*, source_block, destination_block, user_id):
920+
"""
921+
Update a block to have all the settings and attributes of another source.
922+
923+
Copies over all attributes and settings of a source block to a destination
924+
block. Blocks must be the same type. This function does not modify or duplicate
925+
children.
926+
"""
927+
duplicate_metadata, asides = gather_block_attributes(source_block, display_name=source_block.display_name)
928+
for key, value in duplicate_metadata.items():
929+
setattr(destination_block, key, value)
930+
for key, value in source_block.get_explicitly_set_fields_by_scope(Scope.content).items():
931+
setattr(destination_block, key, value)
932+
modulestore().update_item(
933+
destination_block,
934+
user_id,
935+
metadata=duplicate_metadata,
936+
asides=asides,
937+
)
938+
939+
940+
def duplicate_block(
941+
parent_usage_key,
942+
duplicate_source_usage_key,
943+
user,
944+
dest_usage_key=None,
945+
display_name=None,
946+
shallow=False,
947+
is_child=False
948+
):
883949
"""
884950
Duplicate an existing xblock as a child of the supplied parent_usage_key.
885951
"""
886952
store = modulestore()
887953
with store.bulk_operations(duplicate_source_usage_key.course_key):
888954
source_item = store.get_item(duplicate_source_usage_key)
889-
# Change the blockID to be unique.
890-
dest_usage_key = source_item.location.replace(name=uuid4().hex)
891-
category = dest_usage_key.block_type
892-
893-
# Update the display name to indicate this is a duplicate (unless display name provided).
894-
# Can't use own_metadata(), b/c it converts data for JSON serialization -
895-
# not suitable for setting metadata of the new block
896-
duplicate_metadata = {}
897-
for field in source_item.fields.values():
898-
if field.scope == Scope.settings and field.is_set_on(source_item):
899-
duplicate_metadata[field.name] = field.read_from(source_item)
900-
901-
if is_child:
902-
display_name = display_name or source_item.display_name or source_item.category
903-
904-
if display_name is not None:
905-
duplicate_metadata['display_name'] = display_name
906-
else:
907-
if source_item.display_name is None:
908-
duplicate_metadata['display_name'] = _("Duplicate of {0}").format(source_item.category)
909-
else:
910-
duplicate_metadata['display_name'] = _("Duplicate of '{0}'").format(source_item.display_name)
955+
if not dest_usage_key:
956+
# Change the blockID to be unique.
957+
dest_usage_key = source_item.location.replace(name=uuid4().hex)
911958

912-
asides_to_create = []
913-
for aside in source_item.runtime.get_asides(source_item):
914-
for field in aside.fields.values():
915-
if field.scope in (Scope.settings, Scope.content,) and field.is_set_on(aside):
916-
asides_to_create.append(aside)
917-
break
959+
category = dest_usage_key.block_type
918960

919-
for aside in asides_to_create:
920-
for field in aside.fields.values():
921-
if field.scope not in (Scope.settings, Scope.content,):
922-
field.delete_from(aside)
961+
duplicate_metadata, asides_to_create = gather_block_attributes(
962+
source_item, display_name=display_name, is_child=is_child,
963+
)
923964

924965
dest_block = store.create_item(
925966
user.id,
@@ -943,10 +984,10 @@ def _duplicate_block(parent_usage_key, duplicate_source_usage_key, user, display
943984

944985
# Children are not automatically copied over (and not all xblocks have a 'children' attribute).
945986
# Because DAGs are not fully supported, we need to actually duplicate each child as well.
946-
if source_item.has_children and not children_handled:
987+
if source_item.has_children and not shallow and not children_handled:
947988
dest_block.children = dest_block.children or []
948989
for child in source_item.children:
949-
dupe = _duplicate_block(dest_block.location, child, user=user, is_child=True)
990+
dupe = duplicate_block(dest_block.location, child, user=user, is_child=True)
950991
if dupe not in dest_block.children: # _duplicate_block may add the child for us.
951992
dest_block.children.append(dupe)
952993
store.update_item(dest_block, user.id)

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

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
_get_source_index,
7171
_xblock_type_and_display_name,
7272
add_container_page_publishing_info,
73-
create_xblock_info,
73+
create_xblock_info, duplicate_block, update_from_source,
7474
)
7575

7676

@@ -789,6 +789,29 @@ def verify_name(source_usage_key, parent_usage_key, expected_name, display_name=
789789
# Now send a custom display name for the duplicate.
790790
verify_name(self.seq_usage_key, self.chapter_usage_key, "customized name", display_name="customized name")
791791

792+
def test_shallow_duplicate(self):
793+
"""
794+
Test that shallow_duplicate creates a new block.
795+
"""
796+
source_course = CourseFactory()
797+
user = UserFactory.create()
798+
source_chapter = BlockFactory(parent=source_course, category='chapter', display_name='Source Chapter')
799+
BlockFactory(parent=source_chapter, category='html', display_name='Child')
800+
# Refresh.
801+
source_chapter = self.store.get_item(source_chapter.location)
802+
self.assertEqual(len(source_chapter.get_children()), 1)
803+
destination_course = CourseFactory()
804+
destination_location = duplicate_block(
805+
parent_usage_key=destination_course.location, duplicate_source_usage_key=source_chapter.location,
806+
user=user,
807+
display_name=source_chapter.display_name,
808+
shallow=True,
809+
)
810+
# Refresh here, too, just to be sure.
811+
destination_chapter = self.store.get_item(destination_location)
812+
self.assertEqual(len(destination_chapter.get_children()), 0)
813+
self.assertEqual(destination_chapter.display_name, 'Source Chapter')
814+
792815

793816
@ddt.ddt
794817
class TestMoveItem(ItemTest):
@@ -3620,3 +3643,111 @@ def test_creator_show_delete_button_with_waffle(self):
36203643
)
36213644

36223645
self.assertFalse(xblock_info['show_delete_button'])
3646+
3647+
3648+
@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types',
3649+
lambda self, block: ['test_aside'])
3650+
class TestUpdateFromSource(ModuleStoreTestCase):
3651+
"""
3652+
Test update_from_source.
3653+
"""
3654+
3655+
def setUp(self):
3656+
"""
3657+
Set up the runtime for tests.
3658+
"""
3659+
super().setUp()
3660+
key_store = DictKeyValueStore()
3661+
field_data = KvsFieldData(key_store)
3662+
self.runtime = TestRuntime(services={'field-data': field_data})
3663+
3664+
def create_source_block(self, course):
3665+
"""
3666+
Create a chapter with all the fixings.
3667+
"""
3668+
source_block = BlockFactory(
3669+
parent=course,
3670+
category='course_info',
3671+
display_name='Source Block',
3672+
metadata={'due': datetime(2010, 11, 22, 4, 0, tzinfo=UTC)},
3673+
)
3674+
3675+
def_id = self.runtime.id_generator.create_definition('html')
3676+
usage_id = self.runtime.id_generator.create_usage(def_id)
3677+
3678+
aside = AsideTest(scope_ids=ScopeIds('user', 'html', def_id, usage_id), runtime=self.runtime)
3679+
aside.field11 = 'html_new_value1'
3680+
3681+
# The data attribute is handled in a special manner and should be updated.
3682+
source_block.data = '<div>test</div>'
3683+
# This field is set on the content scope (definition_data), which should be updated.
3684+
source_block.items = ['test', 'beep']
3685+
3686+
self.store.update_item(source_block, self.user.id, asides=[aside])
3687+
3688+
# quick sanity checks
3689+
source_block = self.store.get_item(source_block.location)
3690+
self.assertEqual(source_block.due, datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
3691+
self.assertEqual(source_block.display_name, 'Source Block')
3692+
self.assertEqual(source_block.runtime.get_asides(source_block)[0].field11, 'html_new_value1')
3693+
self.assertEqual(source_block.data, '<div>test</div>')
3694+
self.assertEqual(source_block.items, ['test', 'beep'])
3695+
3696+
return source_block
3697+
3698+
def check_updated(self, source_block, destination_key):
3699+
"""
3700+
Check that the destination block has been updated to match our source block.
3701+
"""
3702+
revised = self.store.get_item(destination_key)
3703+
self.assertEqual(source_block.display_name, revised.display_name)
3704+
self.assertEqual(source_block.due, revised.due)
3705+
self.assertEqual(revised.data, source_block.data)
3706+
self.assertEqual(revised.items, source_block.items)
3707+
3708+
self.assertEqual(
3709+
revised.runtime.get_asides(revised)[0].field11,
3710+
source_block.runtime.get_asides(source_block)[0].field11,
3711+
)
3712+
3713+
@XBlockAside.register_temp_plugin(AsideTest, 'test_aside')
3714+
def test_update_from_source(self):
3715+
"""
3716+
Test that update_from_source updates the destination block.
3717+
"""
3718+
course = CourseFactory()
3719+
user = UserFactory.create()
3720+
3721+
source_block = self.create_source_block(course)
3722+
3723+
destination_block = BlockFactory(parent=course, category='course_info', display_name='Destination Problem')
3724+
update_from_source(source_block=source_block, destination_block=destination_block, user_id=user.id)
3725+
self.check_updated(source_block, destination_block.location)
3726+
3727+
@XBlockAside.register_temp_plugin(AsideTest, 'test_aside')
3728+
def test_update_clobbers(self):
3729+
"""
3730+
Verify that our update clobbers everything.
3731+
"""
3732+
course = CourseFactory()
3733+
user = UserFactory.create()
3734+
3735+
source_block = self.create_source_block(course)
3736+
3737+
destination_block = BlockFactory(
3738+
parent=course,
3739+
category='course_info',
3740+
display_name='Destination Chapter',
3741+
metadata={'due': datetime(2025, 10, 21, 6, 5, tzinfo=UTC)},
3742+
)
3743+
3744+
def_id = self.runtime.id_generator.create_definition('html')
3745+
usage_id = self.runtime.id_generator.create_usage(def_id)
3746+
aside = AsideTest(scope_ids=ScopeIds('user', 'html', def_id, usage_id), runtime=self.runtime)
3747+
aside.field11 = 'Other stuff'
3748+
destination_block.data = '<div>other stuff</div>'
3749+
destination_block.items = ['other stuff', 'boop']
3750+
self.store.update_item(destination_block, user.id, asides=[aside])
3751+
3752+
update_from_source(source_block=source_block, destination_block=destination_block, user_id=user.id)
3753+
self.check_updated(source_block, destination_block.location)

xmodule/modulestore/split_mongo/split.py

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@
5757

5858
import copy
5959
import datetime
60-
import hashlib
6160
import logging
6261
from collections import defaultdict
6362
from importlib import import_module
@@ -102,7 +101,7 @@
102101
)
103102
from xmodule.modulestore.split_mongo import BlockKey, CourseEnvelope
104103
from xmodule.modulestore.split_mongo.mongo_connection import DuplicateKeyError, DjangoFlexPersistenceBackend
105-
from xmodule.modulestore.store_utilities import DETACHED_XBLOCK_TYPES
104+
from xmodule.modulestore.store_utilities import DETACHED_XBLOCK_TYPES, derived_key
106105
from xmodule.partitions.partitions_service import PartitionService
107106
from xmodule.util.misc import get_library_or_course_attribute
108107

@@ -2438,23 +2437,14 @@ def _copy_from_template(
24382437

24392438
for usage_key in source_keys:
24402439
src_course_key = usage_key.course_key
2441-
hashable_source_id = src_course_key.for_version(None)
24422440
block_key = BlockKey(usage_key.block_type, usage_key.block_id)
24432441
source_structure = source_structures[src_course_key]
24442442

24452443
if block_key not in source_structure['blocks']:
24462444
raise ItemNotFoundError(usage_key)
24472445
source_block_info = source_structure['blocks'][block_key]
24482446

2449-
# Compute a new block ID. This new block ID must be consistent when this
2450-
# method is called with the same (source_key, dest_structure) pair
2451-
unique_data = "{}:{}:{}".format(
2452-
str(hashable_source_id).encode("utf-8"),
2453-
block_key.id,
2454-
new_parent_block_key.id,
2455-
)
2456-
new_block_id = hashlib.sha1(unique_data.encode('utf-8')).hexdigest()[:20]
2457-
new_block_key = BlockKey(block_key.type, new_block_id)
2447+
new_block_key = derived_key(src_course_key, block_key, new_parent_block_key)
24582448

24592449
# Now clone block_key to new_block_key:
24602450
new_block_info = copy.deepcopy(source_block_info)

0 commit comments

Comments
 (0)