Skip to content

Commit fcf03cc

Browse files
authored
feat: get migrations info REST-API added [FC-0112] (#37558)
- Adds the get migrations info REST-API. - Add missing title to CourseDetails population.
1 parent f32f8e8 commit fcf03cc

6 files changed

Lines changed: 232 additions & 9 deletions

File tree

cms/djangoapps/modulestore_migrator/api.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
API for migration from modulestore to learning core
33
"""
4+
from collections import defaultdict
45
from celery.result import AsyncResult
56
from opaque_keys import InvalidKeyError
67
from opaque_keys.edx.keys import CourseKey, LearningContextKey, UsageKey
@@ -20,6 +21,7 @@
2021
"start_bulk_migration_to_library",
2122
"is_successfully_migrated",
2223
"get_migration_info",
24+
"get_all_migrations_info",
2325
"get_target_block_usage_keys",
2426
)
2527

@@ -120,7 +122,7 @@ def is_successfully_migrated(
120122

121123
def get_migration_info(source_keys: list[CourseKey | LibraryLocator]) -> dict:
122124
"""
123-
Check if the source course/library has been migrated successfully and return target info
125+
Check if the source course/library has been migrated successfully and return the last target info
124126
"""
125127
return {
126128
info.key: info
@@ -140,6 +142,26 @@ def get_migration_info(source_keys: list[CourseKey | LibraryLocator]) -> dict:
140142
}
141143

142144

145+
def get_all_migrations_info(source_keys: list[CourseKey | LibraryLocator]) -> dict:
146+
"""
147+
Get all target info of all successful migrations of the source keys
148+
"""
149+
results = defaultdict(list)
150+
for info in ModulestoreSource.objects.filter(
151+
migrations__task_status__state=UserTaskStatus.SUCCEEDED,
152+
migrations__is_failed=False,
153+
key__in=source_keys,
154+
).values(
155+
'migrations__target__key',
156+
'migrations__target__title',
157+
'migrations__target_collection__key',
158+
'migrations__target_collection__title',
159+
'key',
160+
):
161+
results[info['key']].append(info)
162+
return dict(results)
163+
164+
143165
def get_target_block_usage_keys(source_key: CourseKey | LibraryLocator) -> dict[UsageKey, LibraryUsageLocatorV2 | None]:
144166
"""
145167
For given source_key, get a map of legacy block key and its new location in migrated v2 library.

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,35 @@ def get_fields(self):
177177
return fields
178178

179179

180+
class MigrationInfoSerializer(serializers.Serializer):
181+
"""
182+
Serializer for the migration info
183+
"""
184+
185+
source_key = serializers.CharField(source="key")
186+
target_key = serializers.CharField(source="migrations__target__key")
187+
target_title = serializers.CharField(source="migrations__target__title")
188+
target_collection_key = serializers.CharField(
189+
source="migrations__target_collection__key",
190+
allow_null=True
191+
)
192+
target_collection_title = serializers.CharField(
193+
source="migrations__target_collection__title",
194+
allow_null=True
195+
)
196+
197+
198+
class MigrationInfoResponseSerializer(serializers.Serializer):
199+
"""
200+
Serializer for the migrations info view response
201+
"""
202+
def to_representation(self, instance):
203+
return {
204+
str(key): MigrationInfoSerializer(value, many=True).data
205+
for key, value in instance.items()
206+
}
207+
208+
180209
class LibraryMigrationCourseSourceSerializer(serializers.ModelSerializer):
181210
"""
182211
Serializer for the source course of a library migration.
Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
"""
22
Course to Library Import API v1 URLs.
33
"""
4-
4+
from django.urls import path, include
55
from rest_framework.routers import SimpleRouter
6-
7-
from .views import BulkMigrationViewSet, LibraryCourseMigrationViewSet, MigrationViewSet
6+
from .views import MigrationViewSet, BulkMigrationViewSet, MigrationInfoViewSet, LibraryCourseMigrationViewSet
87

98
ROUTER = SimpleRouter()
109
ROUTER.register(r'migrations', MigrationViewSet, basename='migrations')
@@ -15,5 +14,7 @@
1514
basename='library-migrations',
1615
)
1716

18-
19-
urlpatterns = ROUTER.urls
17+
urlpatterns = [
18+
path('', include(ROUTER.urls)),
19+
path('migration_info/', MigrationInfoViewSet.as_view(), name='migration-info'),
20+
]

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

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,28 @@
1111
from rest_framework import status
1212
from rest_framework.exceptions import ParseError
1313
from rest_framework.mixins import ListModelMixin
14-
from rest_framework.permissions import IsAdminUser
14+
from rest_framework.permissions import IsAdminUser, IsAuthenticated
1515
from rest_framework.response import Response
16+
from rest_framework.views import APIView
1617
from rest_framework.viewsets import GenericViewSet
1718
from user_tasks.models import UserTaskStatus
1819
from user_tasks.views import StatusViewSet
20+
from opaque_keys.edx.keys import CourseKey
1921

20-
from cms.djangoapps.modulestore_migrator.api import start_bulk_migration_to_library, start_migration_to_library
22+
from cms.djangoapps.modulestore_migrator.api import (
23+
start_migration_to_library,
24+
start_bulk_migration_to_library,
25+
get_all_migrations_info,
26+
)
2127
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
2228
from openedx.core.djangoapps.content_libraries import api as lib_api
2329
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
30+
from common.djangoapps.student.auth import has_studio_write_access
2431

2532
from ...models import ModulestoreMigration
2633
from .serializers import (
2734
BulkModulestoreMigrationSerializer,
35+
MigrationInfoResponseSerializer,
2836
LibraryMigrationCourseSerializer,
2937
ModulestoreMigrationSerializer,
3038
StatusWithModulestoreMigrationsSerializer,
@@ -338,6 +346,103 @@ def cancel(self, request, *args, **kwargs):
338346
raise NotImplementedError
339347

340348

349+
class MigrationInfoViewSet(APIView):
350+
"""
351+
Retrieve migration information for a list of source courses or libraries.
352+
353+
It returns the target library information associated with each successfully migrated source.
354+
355+
API Endpoints
356+
-------------
357+
GET /api/modulestore_migrator/v1/migration-info/
358+
Retrieve migration details for one or more sources.
359+
360+
Query parameters:
361+
source_keys (list[str]): List of course or library keys to check.
362+
Example: ?source_keys=course-v1:edX+DemoX+2024_T1&source_keys=library-v1:orgX+lib_2
363+
364+
Example request:
365+
GET /api/modulestore_migrator/v1/migration-info/?source_keys=course-v1:edX+DemoX+2024_T1
366+
367+
Example response:
368+
{
369+
"course-v1:edX+DemoX+2024_T1": [
370+
{
371+
"target_key": "library-v1:orgX+lib_2",
372+
"target_title": "Demo Library",
373+
"target_collection_key": "col-v2:1234abcd",
374+
"target_collection_title": "Default Collection",
375+
"source_key": "course-v1:edX+DemoX+2024_T1"
376+
}
377+
],
378+
"library-v1:orgX+lib_2": [
379+
{
380+
"target_key": "library-v1:orgX+lib_2",
381+
"target_title": "Demo Library",
382+
"target_collection_key": "col-v2:1234abcd",
383+
"target_collection_title": "Default Collection",
384+
"source_key": "course-v1:edX+DemoX+2024_T1"
385+
},
386+
{
387+
"target_key": "library-v1:orgX+lib_2",
388+
"target_title": "Demo Library",
389+
"target_collection_key": "col-v2:1234abcd",
390+
"target_collection_title": "Default Collection",
391+
"source_key": "course-v1:edX+DemoX+2024_T1"
392+
}
393+
]
394+
}
395+
"""
396+
397+
permission_classes = (IsAuthenticated,)
398+
authentication_classes = (
399+
BearerAuthenticationAllowInactiveUser,
400+
JwtAuthentication,
401+
SessionAuthenticationAllowInactiveUser,
402+
)
403+
404+
@apidocs.schema(
405+
parameters=[
406+
apidocs.string_parameter(
407+
"source_keys",
408+
apidocs.ParameterLocation.QUERY,
409+
description="List of source keys to consult",
410+
),
411+
],
412+
responses={
413+
200: MigrationInfoResponseSerializer,
414+
400: "Missing required parameter: source_keys",
415+
401: "The requester is not authenticated.",
416+
},
417+
)
418+
def get(self, request):
419+
"""
420+
Handle the migration info `GET` request
421+
"""
422+
source_keys = request.query_params.getlist("source_keys")
423+
424+
if not source_keys:
425+
return Response(
426+
{"detail": "Missing required parameter: source_keys"},
427+
status=status.HTTP_400_BAD_REQUEST
428+
)
429+
430+
# Check permissions for each source_key:
431+
# Skip the source if the key is invalid or if the user doesn't have permissions
432+
source_keys_validated = []
433+
for source_key in source_keys:
434+
try:
435+
key = CourseKey.from_string(source_key)
436+
if has_studio_write_access(request.user, key):
437+
source_keys_validated.append(key)
438+
except InvalidKeyError:
439+
continue
440+
441+
data = get_all_migrations_info(source_keys_validated)
442+
serializer = MigrationInfoResponseSerializer(data)
443+
return Response(serializer.data)
444+
445+
341446
@apidocs.schema_for(
342447
"list",
343448
"List all course migrations to a library.",

cms/djangoapps/modulestore_migrator/tests/test_api.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,23 @@ def setUp(self):
3131
self.lib_key_v2 = LibraryLocatorV2.from_string(
3232
f"lib:{self.organization.short_name}:test-key"
3333
)
34+
self.lib_key_v2_2 = LibraryLocatorV2.from_string(
35+
f"lib:{self.organization.short_name}:test-key-2"
36+
)
3437
lib_api.create_library(
3538
org=self.organization,
3639
slug=self.lib_key_v2.slug,
3740
title="Test Library",
3841
)
42+
lib_api.create_library(
43+
org=self.organization,
44+
slug=self.lib_key_v2_2.slug,
45+
title="Test Library 2",
46+
)
3947
self.library_v2 = lib_api.ContentLibrary.objects.get(slug=self.lib_key_v2.slug)
48+
self.library_v2_2 = lib_api.ContentLibrary.objects.get(slug=self.lib_key_v2_2.slug)
4049
self.learning_package = self.library_v2.learning_package
50+
self.learning_package_2 = self.library_v2_2.learning_package
4151
self.blocks = []
4252
for _ in range(3):
4353
self.blocks.append(self._add_simple_content_block().usage_key)
@@ -386,7 +396,62 @@ def test_get_migration_info(self):
386396
assert row.migrations__target__key == str(self.lib_key_v2)
387397
assert row.migrations__target__title == "Test Library"
388398
assert row.migrations__target_collection__key == collection_key
389-
assert row.migrations__target_collection__title == "Test Collection"
399+
assert row.migrations__target_collection__title == "Test Collection"
400+
401+
def test_get_all_migrations_info(self):
402+
"""
403+
Test that the API can retrieve all migrations info for source keys.
404+
"""
405+
user = UserFactory()
406+
407+
collection_key = "test-collection"
408+
collection_key_2 = "test-collection"
409+
authoring_api.create_collection(
410+
learning_package_id=self.learning_package.id,
411+
key=collection_key,
412+
title="Test Collection",
413+
created_by=user.id,
414+
)
415+
authoring_api.create_collection(
416+
learning_package_id=self.learning_package_2.id,
417+
key=collection_key_2,
418+
title="Test Collection 2",
419+
created_by=user.id,
420+
)
421+
422+
api.start_migration_to_library(
423+
user=user,
424+
source_key=self.lib_key,
425+
target_library_key=self.library_v2.library_key,
426+
target_collection_slug=collection_key,
427+
composition_level=CompositionLevel.Component.value,
428+
repeat_handling_strategy=RepeatHandlingStrategy.Skip.value,
429+
preserve_url_slugs=True,
430+
forward_source_to_target=True,
431+
)
432+
api.start_migration_to_library(
433+
user=user,
434+
source_key=self.lib_key,
435+
target_library_key=self.library_v2_2.library_key,
436+
target_collection_slug=collection_key_2,
437+
composition_level=CompositionLevel.Component.value,
438+
repeat_handling_strategy=RepeatHandlingStrategy.Skip.value,
439+
preserve_url_slugs=True,
440+
forward_source_to_target=True,
441+
)
442+
with self.assertNumQueries(1):
443+
result = api.get_all_migrations_info([self.lib_key])
444+
row = result.get(self.lib_key)
445+
assert row is not None
446+
assert row[0].get('migrations__target__key') == str(self.lib_key_v2)
447+
assert row[0].get('migrations__target__title') == "Test Library"
448+
assert row[0].get('migrations__target_collection__key') == collection_key
449+
assert row[0].get('migrations__target_collection__title') == "Test Collection"
450+
451+
assert row[1].get('migrations__target__key') == str(self.lib_key_v2_2)
452+
assert row[1].get('migrations__target__title') == "Test Library 2"
453+
assert row[1].get('migrations__target_collection__key') == collection_key_2
454+
assert row[1].get('migrations__target_collection__title') == "Test Collection 2"
390455

391456
def test_get_target_block_usage_keys(self):
392457
"""

openedx/core/djangoapps/models/course_details.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ def populate(cls, block):
129129
course_details.self_paced = block.self_paced
130130
course_details.learning_info = block.learning_info
131131
course_details.instructor_info = block.instructor_info
132+
course_details.title = block.display_name
132133

133134
# Default course license is "All Rights Reserved"
134135
course_details.license = getattr(block, "license", "all-rights-reserved")

0 commit comments

Comments
 (0)