Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion cms/djangoapps/modulestore_migrator/api.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,6 +21,7 @@
"start_bulk_migration_to_library",
"is_successfully_migrated",
"get_migration_info",
"get_all_migrations_info",
"get_target_block_usage_keys",
)

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,35 @@ def get_fields(self):
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.
Expand Down
11 changes: 6 additions & 5 deletions cms/djangoapps/modulestore_migrator/rest_api/v1/urls.py
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -15,5 +14,7 @@
basename='library-migrations',
)


urlpatterns = ROUTER.urls
urlpatterns = [
path('', include(ROUTER.urls)),
path('migration_info/', MigrationInfoViewSet.as_view(), name='migration-info'),
]
109 changes: 107 additions & 2 deletions cms/djangoapps/modulestore_migrator/rest_api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,28 @@
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,
Expand Down Expand Up @@ -338,6 +346,103 @@ def cancel(self, request, *args, **kwargs):
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.",
Expand Down
67 changes: 66 additions & 1 deletion cms/djangoapps/modulestore_migrator/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,23 @@ def setUp(self):
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)
Expand Down Expand Up @@ -386,7 +396,62 @@ def test_get_migration_info(self):
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):
"""
Expand Down
1 change: 1 addition & 0 deletions openedx/core/djangoapps/models/course_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ def populate(cls, block):
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")
Expand Down
Loading