diff --git a/cms/djangoapps/modulestore_migrator/api.py b/cms/djangoapps/modulestore_migrator/api.py index cada06824887..c11a1031d3bf 100644 --- a/cms/djangoapps/modulestore_migrator/api.py +++ b/cms/djangoapps/modulestore_migrator/api.py @@ -41,7 +41,7 @@ def start_migration_to_library( target_collection_id = None if target_collection_slug: target_collection_id = get_collection(target_package_id, target_collection_slug).id - tasks.migrate_from_modulestore.delay( + return tasks.migrate_from_modulestore.delay( user_id=user.id, source_pk=source.id, target_package_pk=target_package_id, diff --git a/cms/djangoapps/modulestore_migrator/rest_api/v0/serializers.py b/cms/djangoapps/modulestore_migrator/rest_api/v0/serializers.py new file mode 100644 index 000000000000..2d959411b01f --- /dev/null +++ b/cms/djangoapps/modulestore_migrator/rest_api/v0/serializers.py @@ -0,0 +1,92 @@ +""" +Serializers for the Course to Library Import API. +""" + +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import LearningContextKey +from opaque_keys.edx.locator import LibraryLocatorV2 +from rest_framework import serializers +from user_tasks.serializers import StatusSerializer + +from cms.djangoapps.modulestore_migrator.data import CompositionLevel +from cms.djangoapps.modulestore_migrator.models import ModulestoreMigration + + +class ModulestoreMigrationSerializer(serializers.ModelSerializer): + """ + Serializer for the course to library import creation API. + """ + + source = serializers.CharField( + help_text="The source course or legacy library key to import from.", + required=True, + source='source.key', + ) + target = serializers.CharField( + help_text="The target library key to import into.", + required=True, + ) + composition_level = serializers.ChoiceField( + help_text="The composition level to import the content at.", + choices=CompositionLevel.supported_choices(), + required=False, + default=CompositionLevel.Component.value, + ) + replace_existing = serializers.BooleanField( + help_text="If true, replace existing content in the target library.", + required=False, + default=False, + ) + target_collection_slug = serializers.CharField( + help_text="The target collection slug within the library to import into. Optional.", + required=False, + allow_blank=True, + ) + + class Meta: + model = ModulestoreMigration + fields = [ + 'source', + 'target', + 'target_collection_slug', + 'composition_level', + 'replace_existing', + ] + + def get_fields(self): + fields = super().get_fields() + request = self.context.get('request') + if request and request.method != 'POST': + fields.pop('target', None) + fields.pop('target_collection_slug', None) + return fields + + def validate_source(self, value): + """ + Validate the source key format. + """ + try: + return LearningContextKey.from_string(value) + except InvalidKeyError as exc: + raise serializers.ValidationError(f"Invalid source key: {str(exc)}") from exc + + def validate_target(self, value): + """ + Validate the target library key format. + """ + try: + return LibraryLocatorV2.from_string(value) + except InvalidKeyError as exc: + raise serializers.ValidationError(f"Invalid target library key: {str(exc)}") from exc + + +class StatusWithModulestoreMigrationSerializer(StatusSerializer): + """ + Serializer for the import task status. + """ + + modulestoremigration = ModulestoreMigrationSerializer() + + class Meta: + model = StatusSerializer.Meta.model + fields = [*StatusSerializer.Meta.fields, 'modulestoremigration'] diff --git a/cms/djangoapps/modulestore_migrator/rest_api/v0/urls.py b/cms/djangoapps/modulestore_migrator/rest_api/v0/urls.py index eda8e0e3d43e..9f50f2a27e96 100644 --- a/cms/djangoapps/modulestore_migrator/rest_api/v0/urls.py +++ b/cms/djangoapps/modulestore_migrator/rest_api/v0/urls.py @@ -3,9 +3,9 @@ """ from rest_framework.routers import SimpleRouter -# from .views import ImportViewSet # @@TODO - re-anble this once API is fixed +from .views import ImportViewSet ROUTER = SimpleRouter() -# ROUTER.register(r'imports', ImportViewSet) +ROUTER.register(r'imports', ImportViewSet) urlpatterns = ROUTER.urls diff --git a/cms/djangoapps/modulestore_migrator/rest_api/v0/views.py b/cms/djangoapps/modulestore_migrator/rest_api/v0/views.py new file mode 100644 index 000000000000..1c5cfdd89f3d --- /dev/null +++ b/cms/djangoapps/modulestore_migrator/rest_api/v0/views.py @@ -0,0 +1,118 @@ +""" +API v0 views. +""" + +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from rest_framework.permissions import IsAdminUser +from user_tasks.models import UserTaskStatus +from user_tasks.views import StatusViewSet + +from cms.djangoapps.modulestore_migrator.api import start_migration_to_library +from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser + +from .serializers import ModulestoreMigrationSerializer, StatusWithModulestoreMigrationSerializer + + +class ImportViewSet(StatusViewSet): + """ + Import course content from modulestore into a content library. + + This viewset handles the import process, including creating the import task and + retrieving the status of the import task. Meant to be used by admin users only. + + API Endpoints + ------------ + POST /api/modulestore_migrator/v0/imports/ + Start the import process. + + Request body: + { + "source": "", + "target": "", + "composition_level": "", # Optional, defaults to "component" + "target_collection_slug": "", # Optional + "replace_existing": "" # Optional, defaults to false + } + + Example request: + { + "source": "course-v1:edX+DemoX+2014_T1", + "target": "library-v1:org1+lib_1", + "composition_level": "unit", + "replace_existing": true + } + + Example response: + { + "name": migrate_from_modulestore", + "state": "Succeeded", + "state_text": "Succeeded", + "completed_steps": 11, + "total_steps": 11, + "attempts": 1, + "created": "2025-05-14T22:24:37.048539Z", + "modified": "2025-05-14T22:24:59.128068Z", + "artifacts": [], + "modulestoremigration": { + "source": "course-v1:OpenedX+DemoX+DemoCourse", + "composition_level": "unit", + "replace_existing": true + } + } + + GET /api/modulestore_migrator/v0/imports// + Get the status of the import task. + + Example response: + { + "name": "migrate_from_modulestore", + "state": "Importing staged files and resources", + "state_text": "Importing staged content structure", + "completed_steps": 6, + "total_steps": 11, + "attempts": 1, + "created": "2025-05-14T22:24:37.048539Z", + "modified": "2025-05-14T22:24:59.128068Z", + "artifacts": [], + "modulestoremigration": { + "source": "course-v1:OpenedX+DemoX+DemoCourse2", + "composition_level": "component", + "replace_existing": false + } + } + """ + + permission_classes = (IsAdminUser,) + authentication_classes = ( + BearerAuthenticationAllowInactiveUser, + JwtAuthentication, + SessionAuthenticationAllowInactiveUser, + ) + serializer_class = StatusWithModulestoreMigrationSerializer + + def get_queryset(self): + """ + Override the default queryset to filter by the import event and user. + """ + return StatusViewSet.queryset.filter(modulestoremigration__isnull=False, user=self.request.user) + + def create(self, request, *args, **kwargs): + """ + Handle the import task creation. + """ + + serializer_data = ModulestoreMigrationSerializer(data=request.data) + serializer_data.is_valid(raise_exception=True) + validated_data = serializer_data.validated_data + + task = start_migration_to_library( + user=request.user, + source_key=validated_data['source'], + target_library_key=validated_data['target'], + target_collection_slug=validated_data.get('target_collection_slug'), + composition_level=validated_data['composition_level'], + replace_existing=validated_data['replace_existing'], + forward_source_to_target=False, # @@TODO - Set to False for now. Explain this better. + ) + return UserTaskStatus.objects.get(task_id=task.id)