diff --git a/cms/djangoapps/modulestore_migrator/api.py b/cms/djangoapps/modulestore_migrator/api.py index e16d3061c9d8..498d18d8b1fd 100644 --- a/cms/djangoapps/modulestore_migrator/api.py +++ b/cms/djangoapps/modulestore_migrator/api.py @@ -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 @@ "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. diff --git a/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py b/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py index ac273f89ac1d..8c9f15387647 100644 --- a/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py +++ b/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py @@ -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. diff --git a/cms/djangoapps/modulestore_migrator/rest_api/v1/urls.py b/cms/djangoapps/modulestore_migrator/rest_api/v1/urls.py index 596f519f5386..44c872c6657a 100644 --- a/cms/djangoapps/modulestore_migrator/rest_api/v1/urls.py +++ b/cms/djangoapps/modulestore_migrator/rest_api/v1/urls.py @@ -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 @@ basename='library-migrations', ) - -urlpatterns = ROUTER.urls +urlpatterns = [ + path('', include(ROUTER.urls)), + path('migration_info/', MigrationInfoViewSet.as_view(), name='migration-info'), +] diff --git a/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py b/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py index 826312b138db..3d887fff15f7 100644 --- a/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py +++ b/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py @@ -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, @@ -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.", diff --git a/cms/djangoapps/modulestore_migrator/tests/test_api.py b/cms/djangoapps/modulestore_migrator/tests/test_api.py index d208ff85e375..83757eadf98b 100644 --- a/cms/djangoapps/modulestore_migrator/tests/test_api.py +++ b/cms/djangoapps/modulestore_migrator/tests/test_api.py @@ -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) @@ -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): """ diff --git a/openedx/core/djangoapps/models/course_details.py b/openedx/core/djangoapps/models/course_details.py index 344be4ea75da..dbb0fdfaec4d 100644 --- a/openedx/core/djangoapps/models/course_details.py +++ b/openedx/core/djangoapps/models/course_details.py @@ -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")