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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user