Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-27 18:12

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('modulestore_migrator', '0004_alter_modulestoreblockmigration_target_squashed_0005_modulestoreblockmigration_unsupported_reason'),
]

operations = [
migrations.AddField(
model_name='modulestoremigration',
name='migration_summary',
field=models.JSONField(default=dict, help_text='Summary that contains number of sections, subsections, units and components migrated'),
),
]
4 changes: 4 additions & 0 deletions cms/djangoapps/modulestore_migrator/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ class ModulestoreMigration(models.Model):
"is the migration failed?"
),
)
migration_summary = models.JSONField(
default=dict,
help_text=_("Summary that contains number of sections, subsections, units and components migrated"),
)

def __str__(self):
return (
Expand Down
51 changes: 42 additions & 9 deletions cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,36 @@
)


class LibraryMigrationCollectionSerializer(serializers.ModelSerializer):
"""
Serializer for the target collection of a library migration.
"""
class Meta:
model = Collection
fields = ["key", "title"]


class MigrationSummarySerializer(serializers.Serializer):
"""
Serializer for a migration summary
"""
total_blocks = serializers.IntegerField(required=False)
sections = serializers.IntegerField(required=False)
subsections = serializers.IntegerField(required=False)
units = serializers.IntegerField(required=False)
components = serializers.IntegerField(required=False)
unsupported = serializers.IntegerField(required=False)


class MigrationBlockUnsupportedReasonSerializer(serializers.Serializer):
"""
Serializer for an unsupported block reason of a migration
"""
block_name = serializers.CharField(required=False)
block_type = serializers.CharField(required=False)
reason = serializers.CharField(required=False)


class ModulestoreMigrationSerializer(serializers.Serializer):
"""
Serializer for the course or legacylibrary to library V2 import creation API.
Expand Down Expand Up @@ -53,6 +83,7 @@ class ModulestoreMigrationSerializer(serializers.Serializer):
allow_blank=True,
default=None,
)
target_collection = LibraryMigrationCollectionSerializer(required=False)
forward_source_to_target = serializers.BooleanField(
help_text="Forward references of this block source over to the target of this block migration.",
required=False,
Expand All @@ -63,6 +94,15 @@ class ModulestoreMigrationSerializer(serializers.Serializer):
required=False,
default=False,
)
migration_summary = MigrationSummarySerializer(
help_text="Summary of the finished migration",
required=False
)
unsupported_reasons = MigrationBlockUnsupportedReasonSerializer(
help_text="List of unsupported blocks with the reason",
required=False,
many=True
)

def get_fields(self):
fields = super().get_fields()
Expand Down Expand Up @@ -226,19 +266,11 @@ def get_display_name(self, obj):
return self.context["course_names"].get(str(obj.key), None)


class LibraryMigrationCollectionSerializer(serializers.ModelSerializer):
"""
Serializer for the target collection of a library migration.
"""
class Meta:
model = Collection
fields = ["key", "title"]


class LibraryMigrationCourseSerializer(serializers.ModelSerializer):
"""
Serializer for the course or legacylibrary migrations to V2 library.
"""
task_uuid = serializers.UUIDField(source='task_status.uuid', read_only=True)
source = LibraryMigrationCourseSourceSerializer() # type: ignore[assignment]
target_collection = LibraryMigrationCollectionSerializer(required=False)
state = serializers.SerializerMethodField()
Expand All @@ -247,6 +279,7 @@ class LibraryMigrationCourseSerializer(serializers.ModelSerializer):
class Meta:
model = ModulestoreMigration
fields = [
'task_uuid',
'source',
'target_collection',
'state',
Expand Down
66 changes: 55 additions & 11 deletions cms/djangoapps/modulestore_migrator/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ class _MigrationContext:
composition_level: CompositionLevel
repeat_handling_strategy: RepeatHandlingStrategy
preserve_url_slugs: bool
migration_summary: dict[str, int]
created_by: int
created_at: datetime

Expand Down Expand Up @@ -168,6 +169,20 @@ def add_migration(self, source_key: UsageKey, target: PublishableEntity | None)
else:
self.existing_source_to_target_keys[source_key].append(target)

def add_block_to_summary(self, container_type: ContainerType | None, is_unsupported=False):
"""Add a block to the migration summary using the container_type"""
self.migration_summary["total_blocks"] += 1
if is_unsupported:
self.migration_summary["unsupported"] += 1
elif container_type is None:
self.migration_summary["components"] += 1
elif container_type is ContainerType.Unit:
self.migration_summary["units"] += 1
elif container_type is ContainerType.Subsection:
self.migration_summary["subsections"] += 1
elif container_type is ContainerType.Section:
self.migration_summary["sections"] += 1

def get_existing_target_entity_keys(self, base_key: str) -> set[str]:
return set(
publishable_entity.key
Expand Down Expand Up @@ -388,6 +403,15 @@ def _import_structure(
modulestore_blocks, key=lambda x: x.source.key)
}

migration_summary = {
"total_blocks": 0,
"sections": 0,
"subsections": 0,
"units": 0,
"components": 0,
"unsupported": 0,
}

migration_context = _MigrationContext(
existing_source_to_target_keys=existing_source_to_target_keys,
target_package_id=migration.target.pk,
Expand All @@ -397,6 +421,7 @@ def _import_structure(
composition_level=CompositionLevel(migration.composition_level),
repeat_handling_strategy=RepeatHandlingStrategy(migration.repeat_handling_strategy),
preserve_url_slugs=migration.preserve_url_slugs,
migration_summary=migration_summary,
created_by=status.user_id,
created_at=datetime.now(timezone.utc),
)
Expand All @@ -407,6 +432,7 @@ def _import_structure(
source_node=root_node,
)
change_log.save()
migration.migration_summary = migration_context.migration_summary
return change_log, root_migrated_node


Expand Down Expand Up @@ -638,6 +664,7 @@ def migrate_from_modulestore(

status.set_state(MigrationStep.UNSTAGING.value)
staged_content.delete()
migration.staged_content = None
status.increment_completed_steps()

_create_migration_artifacts_incrementally(
Expand All @@ -659,6 +686,11 @@ def migrate_from_modulestore(
if target_collection:
_populate_collection(user_id, migration)
status.increment_completed_steps()

migration.save(update_fields=[
"target_collection",
"migration_summary",
])
except Exception as exc: # pylint: disable=broad-exception-caught
_set_migrations_to_fail([source_data])
status.fail(str(exc))
Expand Down Expand Up @@ -923,7 +955,11 @@ def bulk_migrate_from_modulestore(

ModulestoreMigration.objects.bulk_update(
[x.migration for x in source_data_list],
["target_collection", "is_failed"],
[
"target_collection",
"is_failed",
"migration_summary",
],
)
status.increment_completed_steps()
except Exception as exc: # pylint: disable=broad-exception-caught
Expand Down Expand Up @@ -1031,20 +1067,28 @@ def _migrate_node(
title=title,
)
)
if container_type is None and target_entity_version is None and reason is not None:
# Currently, components with children are not supported

context.add_block_to_summary(container_type, is_unsupported=target_entity_version is None)
if container_type is None and target_entity_version is None:
# Currently, components with children are not supported, but they appear as unsupported in the summary.
children_length = len(source_node.getchildren())
if children_length:
reason += (
ngettext(
' It has {count} children block.',
' It has {count} children blocks.',
children_length,
)
).format(count=children_length)
for _ in range(children_length):
context.add_block_to_summary(None, is_unsupported=True)

# And add the children count to the reason.
if reason is not None:
if children_length:
reason += (
ngettext(
' It has {count} children block.',
' It has {count} children blocks.',
children_length,
)
).format(count=children_length)
source_to_target = (source_key, target_entity_version, reason)
context.add_migration(source_key, target_entity_version.entity if target_entity_version else None)
else:
context.add_block_to_summary(None, is_unsupported=True)
log.warning(
f"Cannot migrate node from {context.source_context_key} to {context.target_library_key} "
f"because it lacks an url_name and thus has no identity: {source_olx}"
Expand Down
Loading
Loading