feat: get migrations info REST-API added [FC-0112] (#37558)

- Adds the get migrations info REST-API.
- Add missing title to CourseDetails population.
This commit is contained in:
Chris Chávez
2025-11-14 12:38:40 -05:00
committed by GitHub
parent f32f8e8ac3
commit fcf03cc710
6 changed files with 232 additions and 9 deletions

View File

@@ -1,6 +1,7 @@
"""
API for migration from modulestore to learning core
"""
from collections import defaultdict
from celery.result import AsyncResult
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, LearningContextKey, UsageKey
@@ -20,6 +21,7 @@ __all__ = (
"start_bulk_migration_to_library",
"is_successfully_migrated",
"get_migration_info",
"get_all_migrations_info",
"get_target_block_usage_keys",
)
@@ -120,7 +122,7 @@ def is_successfully_migrated(
def get_migration_info(source_keys: list[CourseKey | LibraryLocator]) -> dict:
"""
Check if the source course/library has been migrated successfully and return target info
Check if the source course/library has been migrated successfully and return the last target info
"""
return {
info.key: info
@@ -140,6 +142,26 @@ def get_migration_info(source_keys: list[CourseKey | LibraryLocator]) -> dict:
}
def get_all_migrations_info(source_keys: list[CourseKey | LibraryLocator]) -> dict:
"""
Get all target info of all successful migrations of the source keys
"""
results = defaultdict(list)
for info in ModulestoreSource.objects.filter(
migrations__task_status__state=UserTaskStatus.SUCCEEDED,
migrations__is_failed=False,
key__in=source_keys,
).values(
'migrations__target__key',
'migrations__target__title',
'migrations__target_collection__key',
'migrations__target_collection__title',
'key',
):
results[info['key']].append(info)
return dict(results)
def get_target_block_usage_keys(source_key: CourseKey | LibraryLocator) -> dict[UsageKey, LibraryUsageLocatorV2 | None]:
"""
For given source_key, get a map of legacy block key and its new location in migrated v2 library.

View File

@@ -177,6 +177,35 @@ class StatusWithModulestoreMigrationsSerializer(StatusSerializer):
return fields
class MigrationInfoSerializer(serializers.Serializer):
"""
Serializer for the migration info
"""
source_key = serializers.CharField(source="key")
target_key = serializers.CharField(source="migrations__target__key")
target_title = serializers.CharField(source="migrations__target__title")
target_collection_key = serializers.CharField(
source="migrations__target_collection__key",
allow_null=True
)
target_collection_title = serializers.CharField(
source="migrations__target_collection__title",
allow_null=True
)
class MigrationInfoResponseSerializer(serializers.Serializer):
"""
Serializer for the migrations info view response
"""
def to_representation(self, instance):
return {
str(key): MigrationInfoSerializer(value, many=True).data
for key, value in instance.items()
}
class LibraryMigrationCourseSourceSerializer(serializers.ModelSerializer):
"""
Serializer for the source course of a library migration.

View File

@@ -1,10 +1,9 @@
"""
Course to Library Import API v1 URLs.
"""
from django.urls import path, include
from rest_framework.routers import SimpleRouter
from .views import BulkMigrationViewSet, LibraryCourseMigrationViewSet, MigrationViewSet
from .views import MigrationViewSet, BulkMigrationViewSet, MigrationInfoViewSet, LibraryCourseMigrationViewSet
ROUTER = SimpleRouter()
ROUTER.register(r'migrations', MigrationViewSet, basename='migrations')
@@ -15,5 +14,7 @@ ROUTER.register(
basename='library-migrations',
)
urlpatterns = ROUTER.urls
urlpatterns = [
path('', include(ROUTER.urls)),
path('migration_info/', MigrationInfoViewSet.as_view(), name='migration-info'),
]

View File

@@ -11,20 +11,28 @@ from opaque_keys.edx.locator import LibraryLocatorV2
from rest_framework import status
from rest_framework.exceptions import ParseError
from rest_framework.mixins import ListModelMixin
from rest_framework.permissions import IsAdminUser
from rest_framework.permissions import IsAdminUser, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import GenericViewSet
from user_tasks.models import UserTaskStatus
from user_tasks.views import StatusViewSet
from opaque_keys.edx.keys import CourseKey
from cms.djangoapps.modulestore_migrator.api import start_bulk_migration_to_library, start_migration_to_library
from cms.djangoapps.modulestore_migrator.api import (
start_migration_to_library,
start_bulk_migration_to_library,
get_all_migrations_info,
)
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.content_libraries import api as lib_api
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
from common.djangoapps.student.auth import has_studio_write_access
from ...models import ModulestoreMigration
from .serializers import (
BulkModulestoreMigrationSerializer,
MigrationInfoResponseSerializer,
LibraryMigrationCourseSerializer,
ModulestoreMigrationSerializer,
StatusWithModulestoreMigrationsSerializer,
@@ -338,6 +346,103 @@ class BulkMigrationViewSet(StatusViewSet):
raise NotImplementedError
class MigrationInfoViewSet(APIView):
"""
Retrieve migration information for a list of source courses or libraries.
It returns the target library information associated with each successfully migrated source.
API Endpoints
-------------
GET /api/modulestore_migrator/v1/migration-info/
Retrieve migration details for one or more sources.
Query parameters:
source_keys (list[str]): List of course or library keys to check.
Example: ?source_keys=course-v1:edX+DemoX+2024_T1&source_keys=library-v1:orgX+lib_2
Example request:
GET /api/modulestore_migrator/v1/migration-info/?source_keys=course-v1:edX+DemoX+2024_T1
Example response:
{
"course-v1:edX+DemoX+2024_T1": [
{
"target_key": "library-v1:orgX+lib_2",
"target_title": "Demo Library",
"target_collection_key": "col-v2:1234abcd",
"target_collection_title": "Default Collection",
"source_key": "course-v1:edX+DemoX+2024_T1"
}
],
"library-v1:orgX+lib_2": [
{
"target_key": "library-v1:orgX+lib_2",
"target_title": "Demo Library",
"target_collection_key": "col-v2:1234abcd",
"target_collection_title": "Default Collection",
"source_key": "course-v1:edX+DemoX+2024_T1"
},
{
"target_key": "library-v1:orgX+lib_2",
"target_title": "Demo Library",
"target_collection_key": "col-v2:1234abcd",
"target_collection_title": "Default Collection",
"source_key": "course-v1:edX+DemoX+2024_T1"
}
]
}
"""
permission_classes = (IsAuthenticated,)
authentication_classes = (
BearerAuthenticationAllowInactiveUser,
JwtAuthentication,
SessionAuthenticationAllowInactiveUser,
)
@apidocs.schema(
parameters=[
apidocs.string_parameter(
"source_keys",
apidocs.ParameterLocation.QUERY,
description="List of source keys to consult",
),
],
responses={
200: MigrationInfoResponseSerializer,
400: "Missing required parameter: source_keys",
401: "The requester is not authenticated.",
},
)
def get(self, request):
"""
Handle the migration info `GET` request
"""
source_keys = request.query_params.getlist("source_keys")
if not source_keys:
return Response(
{"detail": "Missing required parameter: source_keys"},
status=status.HTTP_400_BAD_REQUEST
)
# Check permissions for each source_key:
# Skip the source if the key is invalid or if the user doesn't have permissions
source_keys_validated = []
for source_key in source_keys:
try:
key = CourseKey.from_string(source_key)
if has_studio_write_access(request.user, key):
source_keys_validated.append(key)
except InvalidKeyError:
continue
data = get_all_migrations_info(source_keys_validated)
serializer = MigrationInfoResponseSerializer(data)
return Response(serializer.data)
@apidocs.schema_for(
"list",
"List all course migrations to a library.",

View File

@@ -31,13 +31,23 @@ class TestModulestoreMigratorAPI(LibraryTestCase):
self.lib_key_v2 = LibraryLocatorV2.from_string(
f"lib:{self.organization.short_name}:test-key"
)
self.lib_key_v2_2 = LibraryLocatorV2.from_string(
f"lib:{self.organization.short_name}:test-key-2"
)
lib_api.create_library(
org=self.organization,
slug=self.lib_key_v2.slug,
title="Test Library",
)
lib_api.create_library(
org=self.organization,
slug=self.lib_key_v2_2.slug,
title="Test Library 2",
)
self.library_v2 = lib_api.ContentLibrary.objects.get(slug=self.lib_key_v2.slug)
self.library_v2_2 = lib_api.ContentLibrary.objects.get(slug=self.lib_key_v2_2.slug)
self.learning_package = self.library_v2.learning_package
self.learning_package_2 = self.library_v2_2.learning_package
self.blocks = []
for _ in range(3):
self.blocks.append(self._add_simple_content_block().usage_key)
@@ -386,7 +396,62 @@ class TestModulestoreMigratorAPI(LibraryTestCase):
assert row.migrations__target__key == str(self.lib_key_v2)
assert row.migrations__target__title == "Test Library"
assert row.migrations__target_collection__key == collection_key
assert row.migrations__target_collection__title == "Test Collection"
assert row.migrations__target_collection__title == "Test Collection"
def test_get_all_migrations_info(self):
"""
Test that the API can retrieve all migrations info for source keys.
"""
user = UserFactory()
collection_key = "test-collection"
collection_key_2 = "test-collection"
authoring_api.create_collection(
learning_package_id=self.learning_package.id,
key=collection_key,
title="Test Collection",
created_by=user.id,
)
authoring_api.create_collection(
learning_package_id=self.learning_package_2.id,
key=collection_key_2,
title="Test Collection 2",
created_by=user.id,
)
api.start_migration_to_library(
user=user,
source_key=self.lib_key,
target_library_key=self.library_v2.library_key,
target_collection_slug=collection_key,
composition_level=CompositionLevel.Component.value,
repeat_handling_strategy=RepeatHandlingStrategy.Skip.value,
preserve_url_slugs=True,
forward_source_to_target=True,
)
api.start_migration_to_library(
user=user,
source_key=self.lib_key,
target_library_key=self.library_v2_2.library_key,
target_collection_slug=collection_key_2,
composition_level=CompositionLevel.Component.value,
repeat_handling_strategy=RepeatHandlingStrategy.Skip.value,
preserve_url_slugs=True,
forward_source_to_target=True,
)
with self.assertNumQueries(1):
result = api.get_all_migrations_info([self.lib_key])
row = result.get(self.lib_key)
assert row is not None
assert row[0].get('migrations__target__key') == str(self.lib_key_v2)
assert row[0].get('migrations__target__title') == "Test Library"
assert row[0].get('migrations__target_collection__key') == collection_key
assert row[0].get('migrations__target_collection__title') == "Test Collection"
assert row[1].get('migrations__target__key') == str(self.lib_key_v2_2)
assert row[1].get('migrations__target__title') == "Test Library 2"
assert row[1].get('migrations__target_collection__key') == collection_key_2
assert row[1].get('migrations__target_collection__title') == "Test Collection 2"
def test_get_target_block_usage_keys(self):
"""

View File

@@ -129,6 +129,7 @@ class CourseDetails:
course_details.self_paced = block.self_paced
course_details.learning_info = block.learning_info
course_details.instructor_info = block.instructor_info
course_details.title = block.display_name
# Default course license is "All Rights Reserved"
course_details.license = getattr(block, "license", "all-rights-reserved")