diff --git a/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py b/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py index c5b48b861c..7318079119 100644 --- a/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py +++ b/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py @@ -22,7 +22,7 @@ class ModulestoreMigrationSerializer(serializers.Serializer): required=True, ) target = serializers.CharField( - help_text="The target library V2 key to import into.", + help_text="The target content library V2 key to import into.", required=True, ) composition_level = serializers.ChoiceField( @@ -155,9 +155,9 @@ class BulkModulestoreMigrationSerializer(ModulestoreMigrationSerializer): return super().to_representation(instance) -class StatusWithModulestoreMigrationSerializer(StatusSerializer): +class StatusWithModulestoreMigrationsSerializer(StatusSerializer): """ - Serializer for the import task status. + Serializer for the import task status, including 1+ migration objects. """ parameters = ModulestoreMigrationSerializer(source='migrations', many=True) diff --git a/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py b/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py index b7bdc0b0d6..f2b231c5c1 100644 --- a/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py +++ b/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py @@ -16,7 +16,7 @@ from cms.djangoapps.modulestore_migrator.api import start_migration_to_library, from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser from .serializers import ( - StatusWithModulestoreMigrationSerializer, + StatusWithModulestoreMigrationsSerializer, ModulestoreMigrationSerializer, BulkModulestoreMigrationSerializer, ) @@ -25,79 +25,44 @@ from .serializers import ( log = logging.getLogger(__name__) +_error_responses = { + 400: "Request malformed.", + 401: "Requester is not authenticated.", + 403: "Permission denied", +} + + +@apidocs.schema_for( + "list", + """ + List all migration and bulk-migration tasks started by the current user. + + The response is a paginated series of migration task status objects, ordered + by the time at which the migration was started, newest first. + See `POST /api/modulestore_migrator/v1/migrations` for details of each object's schema. + """, +) +@apidocs.schema_for( + "retrieve", + """ + Get the status of particular migration or bulk-migration task by its UUID. + + The response is a migration task status object. + See `POST /api/modulestore_migrator/v1/migrations` for details on its schema. + """, +) +@apidocs.schema_for( + "cancel", + """ + Cancel a particular migration or bulk-migration task. + + The response is a migration task status object. + See `POST /api/modulestore_migrator/v1/migrations` for details on its schema. + """, +) class MigrationViewSet(StatusViewSet): """ - Import course content or legacy library 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/v1/migrations/ - Start the import process. - - Request body: - { - "source": "", - "target": "", - "composition_level": "", # Optional, defaults to "component" - "target_collection_slug": "", # Optional - "repeat_handling_strategy": "" # Optional, defaults to Skip - "preserve_url_slugs": "" # Optional, defaults to true - } - - Example request: - { - "source": "course-v1:edX+DemoX+2014_T1", - "target": "library-v1:org1+lib_1", - "composition_level": "unit", - "repeat_handling_strategy": "update", - "preserve_url_slugs": true - } - - Example response: - { - "state": "Succeeded", - "state_text": "Succeeded", # Translation into the current language of the current state - "completed_steps": 11, - "total_steps": 11, - "attempts": 1, - "created": "2025-05-14T22:24:37.048539Z", - "modified": "2025-05-14T22:24:59.128068Z", - "artifacts": [], - "uuid": "3de23e5d-fd34-4a6f-bf02-b183374120f0", - "parameters": { - "source": "course-v1:OpenedX+DemoX+DemoCourse", - "composition_level": "unit", - "repeat_handling_strategy": "update", - "preserve_url_slugs": true - } - } - - GET /api/modulestore_migrator/v1/migrations// - Get the status of the import task. - - Example response: - { - "state": "Importing staged content structure", - "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": [], - "uuid": "3de23e5d-fd34-4a6f-bf02-b183374120f0", - "parameters": [ - { - "source": "course-v1:OpenedX+DemoX+DemoCourse2", - "composition_level": "component", - "repeat_handling_strategy": "skip", - "preserve_url_slugs": false - } - ] - } + JSON HTTP API to create and check on ModuleStore-to-Learning-Core migration tasks. """ permission_classes = (IsAdminUser,) @@ -106,41 +71,119 @@ class MigrationViewSet(StatusViewSet): JwtAuthentication, SessionAuthenticationAllowInactiveUser, ) - serializer_class = StatusWithModulestoreMigrationSerializer + serializer_class = StatusWithModulestoreMigrationsSerializer + + # DELETE is not allowed, as we want to preserve all task status objects. + # Instead, users can POST to /cancel to cancel running tasks. + http_method_names = ["get", "post"] def get_queryset(self): """ Override the default queryset to filter by the migration event and user. """ - return StatusViewSet.queryset.filter(migrations__isnull=False, user=self.request.user).distinct() + return StatusViewSet.queryset.filter( + migrations__isnull=False, user=self.request.user + ).distinct().order_by("-created") @apidocs.schema( body=ModulestoreMigrationSerializer, responses={ - 201: StatusWithModulestoreMigrationSerializer, - 401: "The requester is not authenticated.", + 201: StatusWithModulestoreMigrationsSerializer, + **_error_responses, }, - summary="Start a modulestore to content library migration", - description=( - "Create a migration task to import course or legacy library content into " - "a content library.\n\n" - "**Request example**:\n\n" - "```json\n" - "{\n" - ' "source": "course-v1:edX+DemoX+2014_T1",\n' - ' "target": "library-v1:org1+lib_1",\n' - ' "composition_level": "unit",\n' - ' "repeat_handling_strategy": "update",\n' - ' "preserve_url_slugs": true\n' - "}\n" - "```" - ), ) def create(self, request, *args, **kwargs): """ - Handle the migration task creation. - """ + Transfer content from course or legacy library into a content library. + This begins a migration task to copy content from a ModuleStore-based **source** context + to Learning Core-based **target** context. The valid **source** contexts are: + + * A course. + * A legacy content library. + + The valid **target** contexts are: + + * A content library. + * A collection within a content library. + + Other options: + + * The **composition_level** (*component*, *unit*, *subsection*, *section*) indicates the highest level of + hierarchy to be transferred. Default is *component*. To maximally preserve the source structure, + specify *section*. + * The **repeat_handling_strategy** specifies how the system should handle source items which have + previously been migrated to the target. Specify *skip* to prefer the existing target item, specify + *update* to update the existing target item with the latest source content, or specify *fork* to create + a new target item with the source content. Default is *skip*. + * Specify **preserve_url_slugs** as *true* in order to use the source-provided block IDs + (a.k.a. "URL slugs", "url_names"). Otherwise, the system will use each source item's title + to auto-generate an ID in the target context. + * Specify **forward_source_to_target** as *true* in order to establish a mapping from the source items to the + target items (specifically, the mapping is stored by the *forwarded* field of the + *modulestore_migrator_modulestoresource* and *modulestore_migrator_modulestoreblocksource* database tables). + * **Example**: Specify *true* if you are permanently migrating legacy library content into a content + library, and want course references to the legacy library content to automatically be mapped to new + content library. + * **Example**: Specify *false* if you are migrating legacy library content into a content + library, but do *not* want course references to the legacy library content to be mapped to new + content library. + * **Example**: Specify *false* if you are copying course content into a content library, but do not + want to persist a link between the source source content and destination library contenet. + + Example request: + ```json + { + "source": "course-v1:MyOrganization+MyCourse+MyRun", + "target": "lib:MyOrganization:MyUlmoLibrary", + "composition_level": "unit", + "repeat_handling_strategy": "update", + "preserve_url_slugs": true + } + ``` + + The migration task will take anywhere from seconds to minutes, depending on the size of the source + content. This API's response will tell the initial status of the migration task, including: + + * The **state**, whose values include: + * _Pending_: The migration task is waiting to be picked up by a Studio worker process. + * _Succeeded_: The migration task finished without fatal errors. + * _Failed_: A fatal error occured during the migration task. + * _Canceled_: An administrator canceled the migration task. + * Any other **state** value indicates the migration is actively in progress. + * The **state_text**, the localized version of **state**. + * The **artifacts**, a list of URLs pointing to additional diagnostic information created + during that task. In the current version of this API, successful migrations will have zero + artifacts and failed migrations will have one artifact. + * The **uuid**, which be used to retrieve an updated status on this migration later, via + *GET /api/modulestore_migrator/v1/migrations/$uuid* + * The **parameters**, a singleton list whose elemenet indicates the parameters which were + used to start this migration task. + + Example response: + ```json + { + "state": "Parsing staged OLX", + "state_text": "Parsing staged OLX", + "completed_steps": 4, + "total_steps": 12, + "attempts": 1, + "created": "2025-05-14T22:24:37.048539Z", + "modified": "2025-05-14T22:24:59.128068Z", + "artifacts": [], + "uuid": "3de23e5d-fd34-4a6f-bf02-b183374120f0", + "parameters": [ + { + "source": "course-v1:MyOrganization+MyCourse+MyRun", + "target": "lib:MyOrganization:MyUlmoLibrary", + "composition_level": "unit", + "repeat_handling_strategy": "update", + "preserve_url_slugs": true + } + ] + } + ``` + """ serializer_data = ModulestoreMigrationSerializer(data=request.data) serializer_data.is_valid(raise_exception=True) validated_data = serializer_data.validated_data @@ -164,65 +207,7 @@ class MigrationViewSet(StatusViewSet): class BulkMigrationViewSet(StatusViewSet): """ - Import content of a list of courses or legacy libraries 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/v1/bulk-migration/ - Start the bulk import process. - - Request body: - { - "sources": ["", ""], - "target": "", - "composition_level": "", # Optional, defaults to "component" - "target_collection_slugs": ["", ""], # Optional - "create_collections": "" # Optional, defaults to false - "repeat_handling_strategy": "" # Optional, defaults to Skip - "preserve_url_slugs": "" # Optional, defaults to true - } - - Example request: - { - "sources": ["course-v1:edX+DemoX+2014_T1", "course-v1:edX+DemoX+2014_T2"], - "target": "library-v1:org1+lib_1", - "composition_level": "unit", - "repeat_handling_strategy": "update", - "preserve_url_slugs": true, - "create_collections": true - } - - Example response: - { - "state": "Succeeded", - "state_text": "Succeeded", # Translation into the current language of the current state - "completed_steps": 11, - "total_steps": 11, - "attempts": 1, - "created": "2025-05-14T22:24:37.048539Z", - "modified": "2025-05-14T22:24:59.128068Z", - "artifacts": [], - "uuid": "3de23e5d-fd34-4a6f-bf02-b183374120f0", - "parameters": [ - { - "source": "course-v1:edX+DemoX+2014_T1", - "composition_level": "unit", - "repeat_handling_strategy": "update", - "preserve_url_slugs": true - }, - { - "source": "course-v1:edX+DemoX+2014_T2", - "composition_level": "unit", - "repeat_handling_strategy": "update", - "preserve_url_slugs": true - }, - ] - } - - GET Not Alowed + JSON HTTP API to bulk-create ModuleStore-to-Learning-Core migration tasks. """ permission_classes = (IsAdminUser,) @@ -231,35 +216,85 @@ class BulkMigrationViewSet(StatusViewSet): JwtAuthentication, SessionAuthenticationAllowInactiveUser, ) - serializer_class = StatusWithModulestoreMigrationSerializer + serializer_class = StatusWithModulestoreMigrationsSerializer + + # Because bulk-migration tasks are just special migration tasks, they are listed as part of: + # GET .../migrations/ + # To avoid having 2 APIs that do (mostly) the same thing, we nix this endpoint: + # GET .../bulk_migration//cancel + # which we inherited from StatusViewSet. + # Furthermore, DELETE is not allowed, as we want to preserve all task status objects. + # That just leaves us with POST. http_method_names = ["post"] @apidocs.schema( body=BulkModulestoreMigrationSerializer, responses={ - 201: StatusWithModulestoreMigrationSerializer, - 401: "The requester is not authenticated.", + 201: StatusWithModulestoreMigrationsSerializer, + **_error_responses, }, - summary="Start a bulk modulestore to content library migration", - description=( - "Create a migration task to import multiple courses or legacy libraries " - "into a single content library.\n\n" - "**Request example**:\n\n" - "```json\n" - "{\n" - ' "sources": ["course-v1:edX+DemoX+2014_T1", "course-v1:edX+DemoX+2014_T2"],\n' - ' "target": "library-v1:org1+lib_1",\n' - ' "composition_level": "unit",\n' - ' "repeat_handling_strategy": "update",\n' - ' "preserve_url_slugs": true,\n' - ' "create_collections": true\n' - "}\n" - "```" - ), ) def create(self, request, *args, **kwargs): """ - Handle the bulk migration task creation. + Transfer content from multiple courses or legacy libraries into a content library. + + Create a migration task to import multiple courses or legacy libraries into a single content library. + This is bulk version of `POST /api/modulestore_migrator/v1/migrations`. See that endpoint's documentation + for details on the meanings of the request and response values. + + **Request body**: + ```json + { + "sources": ["", ""], + "target": "", + "composition_level": "", # Optional, defaults to "component" + "target_collection_slugs": ["", ""], # Optional + "create_collections": "" # Optional, defaults to false + "repeat_handling_strategy": "" # Optional, defaults to Skip + "preserve_url_slugs": "" # Optional, defaults to true + } + ``` + + **Example request:** + ```json + { + "sources": ["course-v1:edX+DemoX+2014_T1", "course-v1:edX+DemoX+2014_T2"], + "target": "library-v1:org1+lib_1", + "composition_level": "unit", + "repeat_handling_strategy": "update", + "preserve_url_slugs": true, + "create_collections": true + } + ``` + + **Example response:** + ```json + { + "state": "Succeeded", + "state_text": "Succeeded", # Translation into the current language of the current state + "completed_steps": 11, + "total_steps": 11, + "attempts": 1, + "created": "2025-05-14T22:24:37.048539Z", + "modified": "2025-05-14T22:24:59.128068Z", + "artifacts": [], + "uuid": "3de23e5d-fd34-4a6f-bf02-b183374120f0", + "parameters": [ + { + "source": "course-v1:edX+DemoX+2014_T1", + "composition_level": "unit", + "repeat_handling_strategy": "update", + "preserve_url_slugs": true + }, + { + "source": "course-v1:edX+DemoX+2014_T2", + "composition_level": "unit", + "repeat_handling_strategy": "update", + "preserve_url_slugs": true + }, + ] + } + ``` """ serializer_data = BulkModulestoreMigrationSerializer(data=request.data) serializer_data.is_valid(raise_exception=True) @@ -281,3 +316,15 @@ class BulkMigrationViewSet(StatusViewSet): serializer = self.get_serializer(task_status) return Response(serializer.data, status=status.HTTP_201_CREATED) + + def cancel(self, request, *args, **kwargs): + """ + Remove the `POST .../cancel` endpoint, which was inherited from StatusViewSet. + + Bulk-migration tasks and migration tasks, under the hood, are the same thing. + So, bulk-migration tasks can be cancelled with: + POST .../migrations//cancel + + We disable this endpoint to avoid confusion. + """ + raise NotImplementedError