Skip to content

Commit 3db4399

Browse files
authored
feat: bulk modulestore migration [FC-0097] (#37381)
- Adds the task, python api, and rest api view for bulk migration. - Refactor the code to share code between single migration and bulk migration.
1 parent 19c9a34 commit 3db4399

9 files changed

Lines changed: 1468 additions & 170 deletions

File tree

cms/djangoapps/modulestore_migrator/api.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
__all__ = (
1717
"start_migration_to_library",
18+
"start_bulk_migration_to_library",
1819
"is_successfully_migrated",
1920
"get_migration_info",
2021
)
@@ -46,7 +47,6 @@ def start_migration_to_library(
4647
return tasks.migrate_from_modulestore.delay(
4748
user_id=user.id,
4849
source_pk=source.id,
49-
target_package_pk=target_package_id,
5050
target_library_key=str(target_library_key),
5151
target_collection_pk=target_collection_id,
5252
composition_level=composition_level,
@@ -56,6 +56,52 @@ def start_migration_to_library(
5656
)
5757

5858

59+
def start_bulk_migration_to_library(
60+
*,
61+
user: AuthUser,
62+
source_key_list: list[LearningContextKey],
63+
target_library_key: LibraryLocatorV2,
64+
target_collection_slug_list: list[str | None] | None = None,
65+
create_collections: bool = False,
66+
composition_level: str,
67+
repeat_handling_strategy: str,
68+
preserve_url_slugs: bool,
69+
forward_source_to_target: bool,
70+
) -> AsyncResult:
71+
"""
72+
Import a list of courses or legacy libraries into a V2 library (or, a collections within a V2 library).
73+
"""
74+
target_library = get_library(target_library_key)
75+
# get_library ensures that the library is connected to a learning package.
76+
target_package_id: int = target_library.learning_package_id # type: ignore[assignment]
77+
78+
sources_pks: list[int] = []
79+
for source_key in source_key_list:
80+
source, _ = ModulestoreSource.objects.get_or_create(key=source_key)
81+
sources_pks.append(source.id)
82+
83+
target_collection_pks: list[int | None] = []
84+
if target_collection_slug_list:
85+
for target_collection_slug in target_collection_slug_list:
86+
if target_collection_slug:
87+
target_collection_id = get_collection(target_package_id, target_collection_slug).id
88+
target_collection_pks.append(target_collection_id)
89+
else:
90+
target_collection_pks.append(None)
91+
92+
return tasks.bulk_migrate_from_modulestore.delay(
93+
user_id=user.id,
94+
sources_pks=sources_pks,
95+
target_library_key=str(target_library_key),
96+
target_collection_pks=target_collection_pks,
97+
create_collections=create_collections,
98+
composition_level=composition_level,
99+
repeat_handling_strategy=repeat_handling_strategy,
100+
preserve_url_slugs=preserve_url_slugs,
101+
forward_source_to_target=forward_source_to_target,
102+
)
103+
104+
59105
def is_successfully_migrated(source_key: CourseKey | LibraryLocator) -> bool:
60106
"""
61107
Check if the source course/library has been migrated successfully.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 4.2.24 on 2025-09-29 20:28
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('user_tasks', '0004_url_textfield'),
11+
('modulestore_migrator', '0001_initial'),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name='modulestoremigration',
17+
name='task_status',
18+
field=models.ForeignKey(help_text='Tracks the status of the task which is executing this migration. In a bulk migration, the same task can be multiple migrations', on_delete=django.db.models.deletion.RESTRICT, related_name='migrations', to='user_tasks.usertaskstatus'),
19+
),
20+
]

cms/djangoapps/modulestore_migrator/models.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,14 @@ class ModulestoreMigration(models.Model):
107107
)
108108

109109
## MIGRATION ARTIFACTS
110-
task_status = models.OneToOneField(
110+
task_status = models.ForeignKey(
111111
UserTaskStatus,
112112
on_delete=models.RESTRICT,
113-
help_text=_("Tracks the status of the task which is executing this migration"),
113+
help_text=_(
114+
"Tracks the status of the task which is executing this migration. "
115+
"In a bulk migration, the same task can be multiple migrations"
116+
),
117+
related_name="migrations",
114118
)
115119
change_log = models.ForeignKey(
116120
DraftChangeLog,

cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,17 @@
1212
from cms.djangoapps.modulestore_migrator.models import ModulestoreMigration
1313

1414

15-
class ModulestoreMigrationSerializer(serializers.ModelSerializer):
15+
class ModulestoreMigrationSerializer(serializers.Serializer):
1616
"""
17-
Serializer for the course to library import creation API.
17+
Serializer for the course or legacylibrary to library V2 import creation API.
1818
"""
1919

2020
source = serializers.CharField( # type: ignore[assignment]
2121
help_text="The source course or legacy library key to import from.",
2222
required=True,
2323
)
2424
target = serializers.CharField(
25-
help_text="The target library key to import into.",
25+
help_text="The target library V2 key to import into.",
2626
required=True,
2727
)
2828
composition_level = serializers.ChoiceField(
@@ -54,18 +54,6 @@ class ModulestoreMigrationSerializer(serializers.ModelSerializer):
5454
default=False,
5555
)
5656

57-
class Meta:
58-
model = ModulestoreMigration
59-
fields = [
60-
'source',
61-
'target',
62-
'target_collection_slug',
63-
'composition_level',
64-
'repeat_handling_strategy',
65-
'preserve_url_slugs',
66-
'forward_source_to_target',
67-
]
68-
6957
def get_fields(self):
7058
fields = super().get_fields()
7159
request = self.context.get('request')
@@ -100,19 +88,74 @@ def get_forward_source_to_target(self, obj: ModulestoreMigration):
10088

10189
def to_representation(self, instance):
10290
"""
103-
Override to customize the serialized representation."""
91+
Override to customize the serialized representation.
92+
"""
10493
data = super().to_representation(instance)
10594
# Custom logic for forward_source_to_target during serialization
10695
data['forward_source_to_target'] = self.get_forward_source_to_target(instance)
10796
return data
10897

10998

99+
class BulkModulestoreMigrationSerializer(ModulestoreMigrationSerializer):
100+
"""
101+
Serializer for a bulk migration (of several courses or legacy libraries) to a V2 library.
102+
"""
103+
sources = serializers.ListField(
104+
child=serializers.CharField(),
105+
help_text="The list of sources course or legacy library keys to import from.",
106+
required=True,
107+
)
108+
109+
target_collection_slug_list = serializers.ListField(
110+
child=serializers.CharField(),
111+
help_text="The list of target collection slugs within the library to import into. Optional.",
112+
required=False,
113+
allow_empty=True,
114+
default=None,
115+
)
116+
117+
create_collections = serializers.BooleanField(
118+
help_text=(
119+
"If true and `target_collection_slug_list` is not set, "
120+
"create the collections in the library where the import will be made"
121+
),
122+
required=False,
123+
default=False,
124+
)
125+
126+
def get_fields(self):
127+
fields = super().get_fields()
128+
fields.pop("source", None)
129+
fields.pop("target_collection_slug", None)
130+
return fields
131+
132+
def validate_sources(self, value):
133+
"""
134+
Validate all the source key format
135+
"""
136+
validated_sources = []
137+
for v in value:
138+
try:
139+
validated_sources.append(LearningContextKey.from_string(v))
140+
except InvalidKeyError as exc:
141+
raise serializers.ValidationError(f"Invalid source key: {str(exc)}") from exc
142+
return validated_sources
143+
144+
def to_representation(self, instance):
145+
"""
146+
Override to customize the serialized representation.
147+
"""
148+
if isinstance(instance, list):
149+
return [super().to_representation(obj) for obj in instance]
150+
return super().to_representation(instance)
151+
152+
110153
class StatusWithModulestoreMigrationSerializer(StatusSerializer):
111154
"""
112155
Serializer for the import task status.
113156
"""
114157

115-
parameters = ModulestoreMigrationSerializer(source='modulestoremigration')
158+
parameters = ModulestoreMigrationSerializer(source='migrations', many=True)
116159

117160
class Meta:
118161
model = StatusSerializer.Meta.model

cms/djangoapps/modulestore_migrator/rest_api/v1/urls.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
"""
44

55
from rest_framework.routers import SimpleRouter
6-
from .views import MigrationViewSet
6+
from .views import MigrationViewSet, BulkMigrationViewSet
77

88
ROUTER = SimpleRouter()
9-
ROUTER.register(r'migrations', MigrationViewSet)
9+
ROUTER.register(r'migrations', MigrationViewSet, basename='migrations')
10+
ROUTER.register(r'bulk_migration', BulkMigrationViewSet, basename='bulk-migration')
1011

1112
urlpatterns = ROUTER.urls

0 commit comments

Comments
 (0)