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
2 changes: 1 addition & 1 deletion cms/djangoapps/modulestore_migrator/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
92 changes: 92 additions & 0 deletions cms/djangoapps/modulestore_migrator/rest_api/v0/serializers.py
Original file line number Diff line number Diff line change
@@ -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']
4 changes: 2 additions & 2 deletions cms/djangoapps/modulestore_migrator/rest_api/v0/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
118 changes: 118 additions & 0 deletions cms/djangoapps/modulestore_migrator/rest_api/v0/views.py
Original file line number Diff line number Diff line change
@@ -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": "<source_course_key>",
"target": "<target_library>",
"composition_level": "<composition_level>", # Optional, defaults to "component"
"target_collection_slug": "<target_collection_slug>", # Optional
"replace_existing": "<boolean>" # 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/<uuid>/
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)
Loading