From ab0f98234030d625f1f215d9a1b3d990928a7a95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Mon, 27 Oct 2025 19:04:06 -0300 Subject: [PATCH 1/2] feat: add library migration list endpoint --- .../rest_api/v1/serializers.py | 64 ++++++++++++++++- .../modulestore_migrator/rest_api/v1/urls.py | 9 ++- .../modulestore_migrator/rest_api/v1/views.py | 70 +++++++++++++++++-- 3 files changed, 136 insertions(+), 7 deletions(-) diff --git a/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py b/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py index 73180791191f..5674cce2d969 100644 --- a/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py +++ b/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py @@ -5,11 +5,13 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import LearningContextKey from opaque_keys.edx.locator import LibraryLocatorV2 +from openedx_learning.api.authoring_models import Collection from rest_framework import serializers +from user_tasks.models import UserTaskStatus from user_tasks.serializers import StatusSerializer from cms.djangoapps.modulestore_migrator.data import CompositionLevel, RepeatHandlingStrategy -from cms.djangoapps.modulestore_migrator.models import ModulestoreMigration +from cms.djangoapps.modulestore_migrator.models import ModulestoreMigration, ModulestoreSource class ModulestoreMigrationSerializer(serializers.Serializer): @@ -173,3 +175,63 @@ def get_fields(self): fields = super().get_fields() fields.pop('name', None) return fields + + +class LibraryMigrationCourseSourceSerializer(serializers.ModelSerializer): + """ + Serializer for the source course of a library migration. + """ + display_name = serializers.SerializerMethodField() + + class Meta: + model = ModulestoreSource + fields = ['key', 'display_name'] + + def get_display_name(self, obj): + """ + Return the display name of the source course + """ + return self.context["course_names"].get(str(obj.key), None) + + +class LibraryMigrationCollectionSerializer(serializers.ModelSerializer): + """ + Serializer for the target collection of a library migration. + """ + class Meta: + model = Collection + fields = ["key", "title"] + + +class LibraryMigrationCourseSerializer(serializers.ModelSerializer): + """ + Serializer for the course or legacylibrary migrations to V2 library. + """ + source = LibraryMigrationCourseSourceSerializer() # type: ignore[assignment] + target_collection = LibraryMigrationCollectionSerializer(required=False) + state = serializers.SerializerMethodField() + progress = serializers.SerializerMethodField() + + class Meta: + model = ModulestoreMigration + fields = [ + 'source', + 'target_collection', + 'state', + 'progress', + ] + + def get_state(self, obj: ModulestoreMigration): + """ + Return the state of the migration. + """ + if obj.is_failed: + return UserTaskStatus.FAILED + + return obj.task_status.state + + def get_progress(self, obj: ModulestoreMigration): + """ + Return the progress of the migration. + """ + return obj.task_status.completed_steps / obj.task_status.total_steps diff --git a/cms/djangoapps/modulestore_migrator/rest_api/v1/urls.py b/cms/djangoapps/modulestore_migrator/rest_api/v1/urls.py index 7f66dc5f6dd6..596f519f5386 100644 --- a/cms/djangoapps/modulestore_migrator/rest_api/v1/urls.py +++ b/cms/djangoapps/modulestore_migrator/rest_api/v1/urls.py @@ -3,10 +3,17 @@ """ from rest_framework.routers import SimpleRouter -from .views import MigrationViewSet, BulkMigrationViewSet + +from .views import BulkMigrationViewSet, LibraryCourseMigrationViewSet, MigrationViewSet ROUTER = SimpleRouter() ROUTER.register(r'migrations', MigrationViewSet, basename='migrations') ROUTER.register(r'bulk_migration', BulkMigrationViewSet, basename='bulk-migration') +ROUTER.register( + r'library/(?P[^/.]+)/migrations/courses', + LibraryCourseMigrationViewSet, + basename='library-migrations', +) + urlpatterns = ROUTER.urls diff --git a/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py b/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py index f2b231c5c1b2..826312b138db 100644 --- a/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py +++ b/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py @@ -6,22 +6,30 @@ import edx_api_doc_tools as apidocs from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from opaque_keys import InvalidKeyError +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.response import Response -from rest_framework import status +from rest_framework.viewsets import GenericViewSet from user_tasks.models import UserTaskStatus from user_tasks.views import StatusViewSet -from cms.djangoapps.modulestore_migrator.api import start_migration_to_library, start_bulk_migration_to_library +from cms.djangoapps.modulestore_migrator.api import start_bulk_migration_to_library, start_migration_to_library +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 ...models import ModulestoreMigration from .serializers import ( - StatusWithModulestoreMigrationsSerializer, - ModulestoreMigrationSerializer, BulkModulestoreMigrationSerializer, + LibraryMigrationCourseSerializer, + ModulestoreMigrationSerializer, + StatusWithModulestoreMigrationsSerializer, ) - log = logging.getLogger(__name__) @@ -328,3 +336,55 @@ def cancel(self, request, *args, **kwargs): We disable this endpoint to avoid confusion. """ raise NotImplementedError + + +@apidocs.schema_for( + "list", + "List all course migrations to a library.", + responses={ + 201: LibraryMigrationCourseSerializer, + 401: "The requester is not authenticated.", + 403: "The requester does not have permission to access the library.", + }, +) +class LibraryCourseMigrationViewSet(GenericViewSet, ListModelMixin): + """ + Show infomation about migrations related to a destination library. + """ + + serializer_class = LibraryMigrationCourseSerializer + pagination_class = None + queryset = ModulestoreMigration.objects.all().select_related('target_collection', 'target', 'task_status') + + def get_serializer_context(self): + """ + Add course name list to the serializer context. + + We need to display the course names in the migration view, and we get all of + them here to avoid futher queries. + """ + context = super().get_serializer_context() + queryset = self.get_queryset() + course_keys = queryset.values_list('source__key', flat=True) + courses = CourseOverview.get_all_courses(course_keys=course_keys) + context['course_names'] = dict((str(course.id), course.display_name) for course in courses) + return context + + def get_queryset(self): + """ + Override the default queryset to filter by the library key and check permissions. + """ + queryset = super().get_queryset() + lib_key_str = self.kwargs['lib_key_str'] + try: + library_key = LibraryLocatorV2.from_string(lib_key_str) + except InvalidKeyError as exc: + raise ParseError(detail=f"Malformed library key: {lib_key_str}") from exc + lib_api.require_permission_for_library_key( + library_key, + self.request.user, + lib_api.permissions.CAN_VIEW_THIS_CONTENT_LIBRARY + ) + queryset = queryset.filter(target__key=library_key, source__key__startswith='course-v1') + + return queryset From f1311d9cfd5aab602e5b31de38457a1bbf661c90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 6 Nov 2025 17:40:13 -0300 Subject: [PATCH 2/2] fix: migration status return --- .../modulestore_migrator/rest_api/v1/serializers.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py b/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py index 5674cce2d969..ac273f89ac1d 100644 --- a/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py +++ b/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py @@ -225,10 +225,12 @@ def get_state(self, obj: ModulestoreMigration): """ Return the state of the migration. """ - if obj.is_failed: + if obj.is_failed or obj.task_status.state in [UserTaskStatus.FAILED, UserTaskStatus.CANCELED]: return UserTaskStatus.FAILED + elif obj.task_status.state == UserTaskStatus.SUCCEEDED: + return UserTaskStatus.SUCCEEDED - return obj.task_status.state + return UserTaskStatus.IN_PROGRESS def get_progress(self, obj: ModulestoreMigration): """