99from dataclasses import dataclass
1010from datetime import datetime , timezone
1111from enum import Enum
12+ from itertools import groupby
1213
1314from celery import shared_task
1415from celery .utils .log import get_task_logger
2021from opaque_keys import InvalidKeyError
2122from opaque_keys .edx .keys import CourseKey , UsageKey
2223from opaque_keys .edx .locator import (
23- CourseLocator , LibraryLocator ,
24- LibraryLocatorV2 , LibraryUsageLocatorV2 , LibraryContainerLocator
24+ CourseLocator ,
25+ LibraryContainerLocator ,
26+ LibraryLocator ,
27+ LibraryLocatorV2 ,
28+ LibraryUsageLocatorV2
2529)
2630from openedx_learning .api import authoring as authoring_api
2731from openedx_learning .api .authoring_models import (
3034 ComponentType ,
3135 LearningPackage ,
3236 PublishableEntity ,
33- PublishableEntityVersion ,
37+ PublishableEntityVersion
3438)
3539from user_tasks .tasks import UserTask , UserTaskStatus
3640
37- from openedx . core . djangoapps .content_libraries . api import ContainerType , get_library
41+ from common . djangoapps .split_modulestore_django . models import SplitModulestoreCourseIndex
3842from openedx .core .djangoapps .content_libraries import api as libraries_api
43+ from openedx .core .djangoapps .content_libraries .api import ContainerType , get_library
3944from openedx .core .djangoapps .content_staging import api as staging_api
4045from xmodule .modulestore import exceptions as modulestore_exceptions
4146from xmodule .modulestore .django import modulestore
42- from common .djangoapps .split_modulestore_django .models import SplitModulestoreCourseIndex
4347
4448from .constants import CONTENT_STAGING_PURPOSE_TEMPLATE
4549from .data import CompositionLevel , RepeatHandlingStrategy
46- from .models import ModulestoreSource , ModulestoreMigration , ModulestoreBlockSource , ModulestoreBlockMigration
47-
50+ from .models import ModulestoreBlockMigration , ModulestoreBlockSource , ModulestoreMigration , ModulestoreSource
4851
4952log = get_task_logger (__name__ )
5053
@@ -89,7 +92,7 @@ class _MigrationContext:
8992 Context for the migration process.
9093 """
9194 existing_source_to_target_keys : dict [ # Note: It's intended to be mutable to reflect changes during migration.
92- UsageKey , PublishableEntity
95+ UsageKey , list [ PublishableEntity ]
9396 ]
9497 target_package_id : int
9598 target_library_key : LibraryLocatorV2
@@ -105,16 +108,30 @@ def is_already_migrated(self, source_key: UsageKey) -> bool:
105108 return source_key in self .existing_source_to_target_keys
106109
107110 def get_existing_target (self , source_key : UsageKey ) -> PublishableEntity :
108- return self .existing_source_to_target_keys [source_key ]
111+ """
112+ Get the target entity for a given source key.
113+
114+ If the source key is already migrated, return the FIRST target entity.
115+ If the source key is not found, raise a KeyError.
116+ """
117+ if source_key not in self .existing_source_to_target_keys :
118+ raise KeyError (f"Source key { source_key } not found in existing source to target keys" )
119+
120+ # NOTE: This is a list of PublishableEntities, but we always return the first one.
121+ return self .existing_source_to_target_keys [source_key ][0 ]
109122
110123 def add_migration (self , source_key : UsageKey , target : PublishableEntity ) -> None :
111124 """Update the context with a new migration (keeps it current)"""
112- self .existing_source_to_target_keys [source_key ] = target
125+ if source_key not in self .existing_source_to_target_keys :
126+ self .existing_source_to_target_keys [source_key ] = [target ]
127+ else :
128+ self .existing_source_to_target_keys [source_key ].append (target )
113129
114130 def get_existing_target_entity_keys (self , base_key : str ) -> set [str ]:
115131 return set (
116- publishable_entity .key for _ , publishable_entity in
117- self .existing_source_to_target_keys .items ()
132+ publishable_entity .key
133+ for publishable_entity_list in self .existing_source_to_target_keys .values ()
134+ for publishable_entity in publishable_entity_list
118135 if publishable_entity .key .startswith (base_key )
119136 )
120137
@@ -285,10 +302,13 @@ def migrate_from_modulestore(
285302 # a given LearningPackage.
286303 # We use this mapping to ensure that we don't create duplicate
287304 # PublishableEntities during the migration process for a given LearningPackage.
305+ existing_source_to_target_keys : dict [UsageKey , list [PublishableEntity ]] = {}
306+ modulestore_blocks = (
307+ ModulestoreBlockMigration .objects .filter (overall_migration__target = migration .target .id ).order_by ("source__key" )
308+ )
288309 existing_source_to_target_keys = {
289- block .source .key : block .target for block in ModulestoreBlockMigration .objects .filter (
290- overall_migration__target = migration .target .id
291- )
310+ source_key : list (block .target for block in group ) for source_key , group in groupby (
311+ modulestore_blocks , key = lambda x : x .source .key )
292312 }
293313
294314 migration_context = _MigrationContext (
@@ -657,7 +677,7 @@ def _get_distinct_target_usage_key(
657677 # Check if we already processed this block and we are not forking. If we are forking, we will
658678 # want a new target key.
659679 if context .is_already_migrated (source_key ) and not context .should_fork_strategy :
660- log .debug (f"Block { source_key } already exists, reusing existing target" )
680+ log .debug (f"Block { source_key } already exists, reusing first existing target" )
661681 existing_target = context .get_existing_target (source_key )
662682 block_id = existing_target .component .local_key
663683
0 commit comments