Skip to content

Commit 7275ce1

Browse files
kdmccormickAndriicmltaWt0
authored
feat!: modulestore_migrator (#36873)
This introduces the modulestore_migrator app, which can be used to copy content (courses and libraries) from modulestore into Learning Core. It is currently aimed to work on the legacy library -> v2 library migration, but it will be used in the future for course->library and course->course migrations. This includes an initial REST API, Django admin interface, and Python API. Closes: #37211 Requires some follow-up work before this is production-ready: #37259 Co-authored-by: Andrii <[email protected]> Co-authored-by: Maksim Sokolskiy <[email protected]>
1 parent 5d9fc24 commit 7275ce1

27 files changed

Lines changed: 3324 additions & 1 deletion

File tree

.github/workflows/unit-test-shards.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@
238238
"cms/djangoapps/cms_user_tasks/",
239239
"cms/djangoapps/course_creators/",
240240
"cms/djangoapps/export_course_metadata/",
241+
"cms/djangoapps/modulestore_migrator/",
241242
"cms/djangoapps/maintenance/",
242243
"cms/djangoapps/models/",
243244
"cms/djangoapps/pipeline_js/",

cms/djangoapps/modulestore_migrator/__init__.py

Whitespace-only changes.
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""
2+
A nice little admin interface for migrating courses and libraries from modulstore to Learning Core.
3+
"""
4+
import logging
5+
6+
from django import forms
7+
from django.contrib import admin, messages
8+
from django.contrib.admin.helpers import ActionForm
9+
from django.db import models
10+
11+
12+
from opaque_keys import InvalidKeyError
13+
from opaque_keys.edx.locator import LibraryCollectionLocator, LibraryLocatorV2
14+
from user_tasks.models import UserTaskStatus
15+
16+
from openedx.core.types.http import AuthenticatedHttpRequest
17+
18+
from . import api
19+
from .data import CompositionLevel, RepeatHandlingStrategy
20+
from .models import ModulestoreSource, ModulestoreMigration, ModulestoreBlockSource, ModulestoreBlockMigration
21+
22+
23+
log = logging.getLogger(__name__)
24+
25+
26+
class StartMigrationTaskForm(ActionForm):
27+
"""
28+
Params for start_migration_task admin adtion, displayed next the "Go" button.
29+
"""
30+
target_key = forms.CharField(label="Target library or collection key →", required=False)
31+
repeat_handling_strategy = forms.ChoiceField(
32+
label="How to handle existing content? →",
33+
choices=RepeatHandlingStrategy.supported_choices,
34+
required=False,
35+
)
36+
preserve_url_slugs = forms.BooleanField(label="Preserve current slugs? →", required=False, initial=True)
37+
forward_to_target = forms.BooleanField(label="Forward references? →", required=False)
38+
composition_level = forms.ChoiceField(
39+
label="Aggregate up to →", choices=CompositionLevel.supported_choices, required=False
40+
)
41+
42+
43+
def task_status_details(obj: ModulestoreMigration) -> str:
44+
"""
45+
Return the state and, if available, details of the status of the migration.
46+
"""
47+
details: str | None = None
48+
if obj.task_status.state == UserTaskStatus.FAILED:
49+
# Calling fail(msg) from a task should automatically generates an "Error" artifact with that msg.
50+
# https://django-user-tasks.readthedocs.io/en/latest/user_tasks.html#user_tasks.models.UserTaskStatus.fail
51+
if error_artifacts := obj.task_status.artifacts.filter(name="Error"):
52+
if error_text := error_artifacts.order_by("-created").first().text:
53+
details = error_text
54+
elif obj.task_status.state == UserTaskStatus.SUCCEEDED:
55+
details = f"Migrated {obj.block_migrations.count()} blocks"
56+
return f"{obj.task_status.state}: {details}" if details else obj.task_status.state
57+
58+
59+
migration_admin_fields = (
60+
"target",
61+
"target_collection",
62+
"task_status",
63+
# The next line works, but django-stubs incorrectly thinks that these should all be strings,
64+
# so we will need to use type:ignore below.
65+
task_status_details,
66+
"composition_level",
67+
"repeat_handling_strategy",
68+
"preserve_url_slugs",
69+
"change_log",
70+
"staged_content",
71+
)
72+
73+
74+
class ModulestoreMigrationInline(admin.TabularInline):
75+
"""
76+
Readonly table within the ModulestoreSource page; each row is a Migration from this Source.
77+
"""
78+
model = ModulestoreMigration
79+
fk_name = "source"
80+
show_change_link = True
81+
readonly_fields = migration_admin_fields # type: ignore[assignment]
82+
ordering = ("-task_status__created",)
83+
84+
def has_add_permission(self, _request, _obj):
85+
return False
86+
87+
88+
class ModulestoreBlockSourceInline(admin.TabularInline):
89+
"""
90+
Readonly table within the ModulestoreSource page; each row is a BlockSource.
91+
"""
92+
model = ModulestoreBlockSource
93+
fk_name = "overall_source"
94+
readonly_fields = (
95+
"key",
96+
"forwarded"
97+
)
98+
99+
def has_add_permission(self, _request, _obj):
100+
return False
101+
102+
103+
@admin.register(ModulestoreSource)
104+
class ModulestoreSourceAdmin(admin.ModelAdmin):
105+
"""
106+
Admin interface for source legacy libraries and courses.
107+
"""
108+
readonly_fields = ("forwarded",)
109+
list_display = ("id", "key", "forwarded")
110+
actions = ["start_migration_task"]
111+
action_form = StartMigrationTaskForm
112+
inlines = [ModulestoreMigrationInline, ModulestoreBlockSourceInline]
113+
114+
@admin.action(description="Start migration for selected sources")
115+
def start_migration_task(
116+
self,
117+
request: AuthenticatedHttpRequest,
118+
queryset: models.QuerySet[ModulestoreSource],
119+
) -> None:
120+
"""
121+
Start a migration for each selected source
122+
"""
123+
form = StartMigrationTaskForm(request.POST)
124+
form.is_valid()
125+
target_key_string = form.cleaned_data['target_key']
126+
if not target_key_string:
127+
messages.add_message(request, messages.ERROR, "Target key is required")
128+
return
129+
try:
130+
target_library_key = LibraryLocatorV2.from_string(target_key_string)
131+
target_collection_slug = None
132+
except InvalidKeyError:
133+
try:
134+
target_collection_key = LibraryCollectionLocator.from_string(target_key_string)
135+
target_library_key = target_collection_key.lib_key
136+
target_collection_slug = target_collection_key.collection_id
137+
except InvalidKeyError:
138+
messages.add_message(request, messages.ERROR, f"Invalid target key: {target_key_string}")
139+
return
140+
started = 0
141+
total = 0
142+
for source in queryset:
143+
total += 1
144+
try:
145+
api.start_migration_to_library(
146+
user=request.user,
147+
source_key=source.key,
148+
target_library_key=target_library_key,
149+
target_collection_slug=target_collection_slug,
150+
composition_level=form.cleaned_data['composition_level'],
151+
repeat_handling_strategy=form.cleaned_data['repeat_handling_strategy'],
152+
preserve_url_slugs=form.cleaned_data['preserve_url_slugs'],
153+
forward_source_to_target=form.cleaned_data['forward_to_target'],
154+
)
155+
except Exception as exc: # pylint: disable=broad-except
156+
message = f"Failed to start migration {source.key} -> {target_key_string}"
157+
messages.add_message(request, messages.ERROR, f"{message}: {exc}")
158+
log.exception(message)
159+
continue
160+
started += 1
161+
click_in = "Click into the source objects to see migration details."
162+
163+
if not started:
164+
messages.add_message(request, messages.WARNING, f"Failed to start {total} migration(s).")
165+
if started < total:
166+
messages.add_message(request, messages.WARNING, f"Started {started} of {total} migration(s). {click_in}")
167+
else:
168+
messages.add_message(request, messages.INFO, f"Started {started} migration(s). {click_in}")
169+
170+
171+
class ModulestoreBlockMigrationInline(admin.TabularInline):
172+
"""
173+
Readonly table witin the Migration admin; each row is a block
174+
"""
175+
model = ModulestoreBlockMigration
176+
fk_name = "overall_migration"
177+
readonly_fields = (
178+
"source",
179+
"target",
180+
"change_log_record",
181+
)
182+
list_display = ("id", *readonly_fields)
183+
184+
185+
@admin.register(ModulestoreMigration)
186+
class ModulestoreMigrationAdmin(admin.ModelAdmin):
187+
"""
188+
Readonly admin page for viewing Migrations
189+
"""
190+
readonly_fields = ("source", *migration_admin_fields) # type: ignore[assignment]
191+
list_display = ("id", "source", *migration_admin_fields) # type: ignore[assignment]
192+
inlines = [ModulestoreBlockMigrationInline]
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""
2+
API for migration from modulestore to learning core
3+
"""
4+
from opaque_keys.edx.locator import LibraryLocatorV2
5+
from opaque_keys.edx.keys import LearningContextKey
6+
from openedx_learning.api.authoring import get_collection
7+
from celery.result import AsyncResult
8+
9+
from openedx.core.djangoapps.content_libraries.api import get_library
10+
from openedx.core.types.user import AuthUser
11+
12+
from . import tasks
13+
from .data import RepeatHandlingStrategy
14+
from .models import ModulestoreSource
15+
16+
17+
__all__ = (
18+
"start_migration_to_library",
19+
)
20+
21+
22+
def start_migration_to_library(
23+
*,
24+
user: AuthUser,
25+
source_key: LearningContextKey,
26+
target_library_key: LibraryLocatorV2,
27+
target_collection_slug: str | None = None,
28+
composition_level: str,
29+
repeat_handling_strategy: str,
30+
preserve_url_slugs: bool,
31+
forward_source_to_target: bool,
32+
) -> AsyncResult:
33+
"""
34+
Import a course or legacy library into a V2 library (or, a collection within a V2 library).
35+
"""
36+
# Can raise NotImplementedError for the Fork strategy
37+
assert RepeatHandlingStrategy(repeat_handling_strategy).is_implemented()
38+
39+
source, _ = ModulestoreSource.objects.get_or_create(key=source_key)
40+
target_library = get_library(target_library_key)
41+
# get_library ensures that the library is connected to a learning package.
42+
target_package_id: int = target_library.learning_package_id # type: ignore[assignment]
43+
target_collection_id = None
44+
45+
if target_collection_slug:
46+
target_collection_id = get_collection(target_package_id, target_collection_slug).id
47+
48+
return tasks.migrate_from_modulestore.delay(
49+
user_id=user.id,
50+
source_pk=source.id,
51+
target_package_pk=target_package_id,
52+
target_library_key=str(target_library_key),
53+
target_collection_pk=target_collection_id,
54+
composition_level=composition_level,
55+
repeat_handling_strategy=repeat_handling_strategy,
56+
preserve_url_slugs=preserve_url_slugs,
57+
forward_source_to_target=forward_source_to_target,
58+
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""
2+
App configurations
3+
"""
4+
5+
from django.apps import AppConfig
6+
7+
8+
class ModulestoreMigratorConfig(AppConfig):
9+
"""
10+
App for importing legacy content from the modulestore.
11+
"""
12+
13+
name = 'cms.djangoapps.modulestore_migrator'
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""
2+
Constants
3+
"""
4+
5+
CONTENT_STAGING_PURPOSE_PREFIX = "modulestore_migrator"
6+
CONTENT_STAGING_PURPOSE_TEMPLATE = CONTENT_STAGING_PURPOSE_PREFIX + "({source_key})"
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""
2+
Value objects
3+
"""
4+
from __future__ import annotations
5+
6+
from enum import Enum
7+
8+
from openedx.core.djangoapps.content_libraries.api import ContainerType
9+
10+
11+
class CompositionLevel(Enum):
12+
"""
13+
Enumeration of composition levels for legacy content.
14+
15+
Defined in increasing order of complexity so that `is_higher_than` works correctly.
16+
"""
17+
# Components are individual XBlocks, e.g. Problem
18+
Component = 'component'
19+
20+
# Container types currently supported by Content Libraries
21+
Unit = ContainerType.Unit.value
22+
Subsection = ContainerType.Subsection.value
23+
Section = ContainerType.Section.value
24+
25+
@property
26+
def is_container(self) -> bool:
27+
return self is not self.Component
28+
29+
def is_higher_than(self, other: 'CompositionLevel') -> bool:
30+
"""
31+
Is this composition level 'above' (more complex than) the other?
32+
"""
33+
levels: list[CompositionLevel] = list(self.__class__)
34+
return levels.index(self) > levels.index(other)
35+
36+
@classmethod
37+
def supported_choices(cls) -> list[tuple[str, str]]:
38+
"""
39+
Returns all supported composition levels as a list of tuples,
40+
for use in a Django Models ChoiceField.
41+
"""
42+
return [
43+
(composition_level.value, composition_level.name)
44+
for composition_level in cls
45+
]
46+
47+
48+
class RepeatHandlingStrategy(Enum):
49+
"""
50+
Enumeration of repeat handling strategies for imported content.
51+
"""
52+
Skip = 'skip'
53+
Fork = 'fork'
54+
Update = 'update'
55+
56+
@classmethod
57+
def supported_choices(cls) -> list[tuple[str, str]]:
58+
"""
59+
Returns all supported repeat handling strategies as a list of tuples,
60+
for use in a Django Models ChoiceField.
61+
"""
62+
return [
63+
(strategy.value, strategy.name)
64+
for strategy in cls
65+
]
66+
67+
@classmethod
68+
def default(cls) -> RepeatHandlingStrategy:
69+
"""
70+
Returns the default repeat handling strategy.
71+
"""
72+
return cls.Skip
73+
74+
def is_implemented(self) -> bool:
75+
"""
76+
Returns True if the repeat handling strategy is implemented.
77+
"""
78+
if self == self.Fork:
79+
raise NotImplementedError("Forking is not implemented yet.")
80+
81+
return True

0 commit comments

Comments
 (0)