Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
6078bbb
feat: redesign import task + api/helpers tests adaptation
May 14, 2025
3ebf29b
feat: add simple REST API
May 14, 2025
34d4322
refactor: add API endpoint secs to view docs, updated tests & fix var…
May 15, 2025
71199bb
refactor: simplify staged content in helpers tests & fix import order…
May 15, 2025
0ae07fd
fix: add default value for composition_level to not break old instances
May 15, 2025
2beecd0
feat!: modulestore_migrator (WIP)
kdmccormick May 20, 2025
f61df27
feat: add admin interface
kdmccormick Jun 11, 2025
e843b10
feat: better task debug info on the admin interface
kdmccormick Jun 11, 2025
f5549fe
fix: fixes to tasks and admin -- happy path works now
kdmccormick Jun 11, 2025
f12f22b
fix: Create ContainerVersion subclassses too
kdmccormick Jun 17, 2025
1a4429b
feat: add REST API for task creation & status retrieve
Jun 13, 2025
4a748e5
fix: drop all the models to un-bork modulestoreblocksource
kdmccormick Jun 19, 2025
ff00943
fix: recreate models with missing db constraint
kdmccormick Jun 19, 2025
71d4007
refactor: remove unnecessary non-Noneness check after get_library
kdmccormick Jun 25, 2025
bbce8fa
test: cover new changes with tests
Jun 16, 2025
ccd08fe
test: add collection_slug case
Jun 16, 2025
7c77cc5
refactor: remove ContentLibrary & LearningPackage factories
Jun 16, 2025
6fdb33a
refactor: fix docstring style, remove redundant comments & clean a li…
Jun 16, 2025
f034ce7
feat: implement TODOs & fix tests
Jul 4, 2025
e7287e6
feat: implement _create_migration_artifacts_in_bulk
cmltaWt0 Jul 25, 2025
280b326
feat: implement Handling duplicates in modulestore migrator
cmltaWt0 Aug 1, 2025
c560d60
feat: implement Containers restoring
cmltaWt0 Aug 3, 2025
d252ce6
feat: implement Containers deduplication
cmltaWt0 Aug 4, 2025
fe3163e
test: fix modulestore_migrator test
cmltaWt0 Aug 4, 2025
5b8289e
feat: implement MigrationContext
cmltaWt0 Aug 10, 2025
a5ff3fd
refactor: avoid redundant DB queries
cmltaWt0 Aug 10, 2025
dcf3b9a
fix: Rest API create action returns incorrect value
cmltaWt0 Aug 10, 2025
c1132de
refactor: update Rest API contract
cmltaWt0 Aug 10, 2025
68ae017
fix: remove unnecessary imports left over from rebase
kdmccormick Aug 15, 2025
603d203
feat: implement RepeatHandlingStrategy
cmltaWt0 Aug 21, 2025
e77f6b8
refactor: address code review comments, clean code
cmltaWt0 Aug 26, 2025
2189b4c
refactor: remove _create_migration_artifacts_in_bulk
cmltaWt0 Sep 2, 2025
b3300c9
fix: imports after rebase
cmltaWt0 Sep 2, 2025
cd35bfe
style: improve code style quality
cmltaWt0 Sep 2, 2025
f04df27
feat: add source_version field for ModulestoreMigration model
cmltaWt0 Sep 3, 2025
a88c5ff
feat: use forward_source_to_target from request data for REST API
cmltaWt0 Sep 4, 2025
642d2b4
fix: resolve import linting complains
cmltaWt0 Sep 10, 2025
f52e171
refactor: use content_libraries.api.get_library to get a library in a…
cmltaWt0 Sep 11, 2025
e991a67
fix: add missed uuid field to migrator REST API response
cmltaWt0 Sep 16, 2025
f122096
style: update return type hint for start_migration_to_library
cmltaWt0 Sep 24, 2025
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
1 change: 1 addition & 0 deletions .github/workflows/unit-test-shards.json
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@
"cms/djangoapps/cms_user_tasks/",
"cms/djangoapps/course_creators/",
"cms/djangoapps/export_course_metadata/",
"cms/djangoapps/modulestore_migrator/",
"cms/djangoapps/maintenance/",
"cms/djangoapps/models/",
"cms/djangoapps/pipeline_js/",
Expand Down
Empty file.
192 changes: 192 additions & 0 deletions cms/djangoapps/modulestore_migrator/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
"""
A nice little admin interface for migrating courses and libraries from modulstore to Learning Core.
"""
import logging

from django import forms
from django.contrib import admin, messages
from django.contrib.admin.helpers import ActionForm
from django.db import models


from opaque_keys import InvalidKeyError
from opaque_keys.edx.locator import LibraryCollectionLocator, LibraryLocatorV2
from user_tasks.models import UserTaskStatus

from openedx.core.types.http import AuthenticatedHttpRequest

from . import api
from .data import CompositionLevel, RepeatHandlingStrategy
from .models import ModulestoreSource, ModulestoreMigration, ModulestoreBlockSource, ModulestoreBlockMigration


log = logging.getLogger(__name__)


class StartMigrationTaskForm(ActionForm):
"""
Params for start_migration_task admin adtion, displayed next the "Go" button.
"""
target_key = forms.CharField(label="Target library or collection key →", required=False)
repeat_handling_strategy = forms.ChoiceField(
label="How to handle existing content? →",
choices=RepeatHandlingStrategy.supported_choices,
required=False,
)
preserve_url_slugs = forms.BooleanField(label="Preserve current slugs? →", required=False, initial=True)
forward_to_target = forms.BooleanField(label="Forward references? →", required=False)
composition_level = forms.ChoiceField(
label="Aggregate up to →", choices=CompositionLevel.supported_choices, required=False
)


def task_status_details(obj: ModulestoreMigration) -> str:
"""
Return the state and, if available, details of the status of the migration.
"""
details: str | None = None
if obj.task_status.state == UserTaskStatus.FAILED:
# Calling fail(msg) from a task should automatically generates an "Error" artifact with that msg.
# https://django-user-tasks.readthedocs.io/en/latest/user_tasks.html#user_tasks.models.UserTaskStatus.fail
if error_artifacts := obj.task_status.artifacts.filter(name="Error"):
if error_text := error_artifacts.order_by("-created").first().text:
details = error_text
elif obj.task_status.state == UserTaskStatus.SUCCEEDED:
details = f"Migrated {obj.block_migrations.count()} blocks"
return f"{obj.task_status.state}: {details}" if details else obj.task_status.state


migration_admin_fields = (
"target",
"target_collection",
"task_status",
# The next line works, but django-stubs incorrectly thinks that these should all be strings,
# so we will need to use type:ignore below.
task_status_details,
"composition_level",
"repeat_handling_strategy",
"preserve_url_slugs",
"change_log",
"staged_content",
)


class ModulestoreMigrationInline(admin.TabularInline):
"""
Readonly table within the ModulestoreSource page; each row is a Migration from this Source.
"""
model = ModulestoreMigration
fk_name = "source"
show_change_link = True
readonly_fields = migration_admin_fields # type: ignore[assignment]
ordering = ("-task_status__created",)

def has_add_permission(self, _request, _obj):
return False


class ModulestoreBlockSourceInline(admin.TabularInline):
"""
Readonly table within the ModulestoreSource page; each row is a BlockSource.
"""
model = ModulestoreBlockSource
fk_name = "overall_source"
readonly_fields = (
"key",
"forwarded"
)

def has_add_permission(self, _request, _obj):
return False


@admin.register(ModulestoreSource)
class ModulestoreSourceAdmin(admin.ModelAdmin):
"""
Admin interface for source legacy libraries and courses.
"""
readonly_fields = ("forwarded",)
list_display = ("id", "key", "forwarded")
actions = ["start_migration_task"]
action_form = StartMigrationTaskForm
inlines = [ModulestoreMigrationInline, ModulestoreBlockSourceInline]

@admin.action(description="Start migration for selected sources")
def start_migration_task(
self,
request: AuthenticatedHttpRequest,
queryset: models.QuerySet[ModulestoreSource],
) -> None:
"""
Start a migration for each selected source
"""
form = StartMigrationTaskForm(request.POST)
form.is_valid()
target_key_string = form.cleaned_data['target_key']
if not target_key_string:
messages.add_message(request, messages.ERROR, "Target key is required")
return
try:
target_library_key = LibraryLocatorV2.from_string(target_key_string)
target_collection_slug = None
except InvalidKeyError:
try:
target_collection_key = LibraryCollectionLocator.from_string(target_key_string)
target_library_key = target_collection_key.lib_key
target_collection_slug = target_collection_key.collection_id
except InvalidKeyError:
messages.add_message(request, messages.ERROR, f"Invalid target key: {target_key_string}")
return
started = 0
total = 0
for source in queryset:
total += 1
try:
api.start_migration_to_library(
user=request.user,
source_key=source.key,
target_library_key=target_library_key,
target_collection_slug=target_collection_slug,
composition_level=form.cleaned_data['composition_level'],
repeat_handling_strategy=form.cleaned_data['repeat_handling_strategy'],
preserve_url_slugs=form.cleaned_data['preserve_url_slugs'],
forward_source_to_target=form.cleaned_data['forward_to_target'],
)
except Exception as exc: # pylint: disable=broad-except
message = f"Failed to start migration {source.key} -> {target_key_string}"
messages.add_message(request, messages.ERROR, f"{message}: {exc}")
log.exception(message)
continue
started += 1
click_in = "Click into the source objects to see migration details."

if not started:
messages.add_message(request, messages.WARNING, f"Failed to start {total} migration(s).")
if started < total:
messages.add_message(request, messages.WARNING, f"Started {started} of {total} migration(s). {click_in}")
else:
messages.add_message(request, messages.INFO, f"Started {started} migration(s). {click_in}")


class ModulestoreBlockMigrationInline(admin.TabularInline):
"""
Readonly table witin the Migration admin; each row is a block
"""
model = ModulestoreBlockMigration
fk_name = "overall_migration"
readonly_fields = (
"source",
"target",
"change_log_record",
)
list_display = ("id", *readonly_fields)


@admin.register(ModulestoreMigration)
class ModulestoreMigrationAdmin(admin.ModelAdmin):
"""
Readonly admin page for viewing Migrations
"""
readonly_fields = ("source", *migration_admin_fields) # type: ignore[assignment]
list_display = ("id", "source", *migration_admin_fields) # type: ignore[assignment]
inlines = [ModulestoreBlockMigrationInline]
58 changes: 58 additions & 0 deletions cms/djangoapps/modulestore_migrator/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""
API for migration from modulestore to learning core
"""
from opaque_keys.edx.locator import LibraryLocatorV2
from opaque_keys.edx.keys import LearningContextKey
from openedx_learning.api.authoring import get_collection
from celery.result import AsyncResult

from openedx.core.djangoapps.content_libraries.api import get_library
from openedx.core.types.user import AuthUser

from . import tasks
from .data import RepeatHandlingStrategy
from .models import ModulestoreSource


__all__ = (
"start_migration_to_library",
)


def start_migration_to_library(
*,
user: AuthUser,
source_key: LearningContextKey,
target_library_key: LibraryLocatorV2,
target_collection_slug: str | None = None,
composition_level: str,
repeat_handling_strategy: str,
preserve_url_slugs: bool,
forward_source_to_target: bool,
) -> AsyncResult:
"""
Import a course or legacy library into a V2 library (or, a collection within a V2 library).
"""
# Can raise NotImplementedError for the Fork strategy
assert RepeatHandlingStrategy(repeat_handling_strategy).is_implemented()

source, _ = ModulestoreSource.objects.get_or_create(key=source_key)
target_library = get_library(target_library_key)
# get_library ensures that the library is connected to a learning package.
target_package_id: int = target_library.learning_package_id # type: ignore[assignment]
target_collection_id = None

if target_collection_slug:
target_collection_id = get_collection(target_package_id, target_collection_slug).id

return tasks.migrate_from_modulestore.delay(
user_id=user.id,
source_pk=source.id,
target_package_pk=target_package_id,
target_library_key=str(target_library_key),
target_collection_pk=target_collection_id,
composition_level=composition_level,
repeat_handling_strategy=repeat_handling_strategy,
preserve_url_slugs=preserve_url_slugs,
forward_source_to_target=forward_source_to_target,
)
13 changes: 13 additions & 0 deletions cms/djangoapps/modulestore_migrator/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""
App configurations
"""

from django.apps import AppConfig


class ModulestoreMigratorConfig(AppConfig):
"""
App for importing legacy content from the modulestore.
"""

name = 'cms.djangoapps.modulestore_migrator'
6 changes: 6 additions & 0 deletions cms/djangoapps/modulestore_migrator/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""
Constants
"""

CONTENT_STAGING_PURPOSE_PREFIX = "modulestore_migrator"
CONTENT_STAGING_PURPOSE_TEMPLATE = CONTENT_STAGING_PURPOSE_PREFIX + "({source_key})"
81 changes: 81 additions & 0 deletions cms/djangoapps/modulestore_migrator/data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""
Value objects
"""
from __future__ import annotations

from enum import Enum

from openedx.core.djangoapps.content_libraries.api import ContainerType


class CompositionLevel(Enum):
"""
Enumeration of composition levels for legacy content.

Defined in increasing order of complexity so that `is_higher_than` works correctly.
"""
# Components are individual XBlocks, e.g. Problem
Component = 'component'

# Container types currently supported by Content Libraries
Unit = ContainerType.Unit.value
Subsection = ContainerType.Subsection.value
Section = ContainerType.Section.value

@property
def is_container(self) -> bool:
return self is not self.Component

def is_higher_than(self, other: 'CompositionLevel') -> bool:
"""
Is this composition level 'above' (more complex than) the other?
"""
levels: list[CompositionLevel] = list(self.__class__)
return levels.index(self) > levels.index(other)

@classmethod
def supported_choices(cls) -> list[tuple[str, str]]:
"""
Returns all supported composition levels as a list of tuples,
for use in a Django Models ChoiceField.
"""
return [
(composition_level.value, composition_level.name)
for composition_level in cls
]


class RepeatHandlingStrategy(Enum):
"""
Enumeration of repeat handling strategies for imported content.
"""
Skip = 'skip'
Fork = 'fork'
Update = 'update'

@classmethod
def supported_choices(cls) -> list[tuple[str, str]]:
"""
Returns all supported repeat handling strategies as a list of tuples,
for use in a Django Models ChoiceField.
"""
return [
(strategy.value, strategy.name)
for strategy in cls
]

@classmethod
def default(cls) -> RepeatHandlingStrategy:
"""
Returns the default repeat handling strategy.
"""
return cls.Skip

def is_implemented(self) -> bool:
"""
Returns True if the repeat handling strategy is implemented.
"""
if self == self.Fork:
raise NotImplementedError("Forking is not implemented yet.")

return True
Loading
Loading