Skip to content

Commit 4252351

Browse files
committed
feat: Backend for Migrating Legacy Libraries (wip)
1 parent 5d1566c commit 4252351

File tree

9 files changed

+546
-23
lines changed

9 files changed

+546
-23
lines changed

cms/djangoapps/contentstore/views/course.py

+29-4
Original file line numberDiff line numberDiff line change
@@ -673,15 +673,40 @@ def library_listing(request):
673673
def _format_library_for_view(library, request):
674674
"""
675675
Return a dict of the data which the view requires for each library
676-
"""
677676
677+
@@TODO This is a hacky prototype implementation. In a real implementation, we'd probably want to change the schema
678+
of this API to include both the old an (if migrate) new library metadata, and then leave the messaging and
679+
URL-building logic to frontend-app-authoring.
680+
"""
681+
# @@TODO: Either put ContentLibraryMigration behind a Python API, or move it to the contentstore app.
682+
# That way, this app doesn't need to import from another app's models.
683+
from openedx.core.djangoapps.content_libraries.models import ContentLibraryMigration
684+
try:
685+
migration = ContentLibraryMigration.objects.select_related(
686+
"target", "target__learning_package", "target_collection"
687+
).get(source_key=library.id)
688+
except ContentLibraryMigration.DoesNotExist:
689+
# Library is not yet migrated. Point to legacy legacyy.
690+
display_name = library.display_name
691+
url = reverse_library_url('library_handler', str(library.location.library_key))
692+
key_for_access_check = library.context_key
693+
else:
694+
url = f"{settings.COURSE_AUTHORING_MICROFRONTEND_URL}/library/{migration.target.library_key}"
695+
new_library_title = migration.target.learning_package.title
696+
if migration.target_collection:
697+
url = f"{url}/collection/{migration.target_collection.key}"
698+
collection_title = migration.target_collection.title
699+
display_name = f"{library.display_name} (migrated to '{collection_title}' in '{new_library_title}')"
700+
else:
701+
display_name = f"{library.display_name} (migrated to '{new_library_title}')"
702+
key_for_access_check = migration.target.library_key
678703
return {
679-
'display_name': library.display_name,
704+
'display_name': display_name,
705+
'url': url,
680706
'library_key': str(library.location.library_key),
681-
'url': reverse_library_url('library_handler', str(library.location.library_key)),
682707
'org': library.display_org_with_default,
683708
'number': library.display_number_with_default,
684-
'can_edit': has_studio_write_access(request.user, library.location.library_key),
709+
'can_edit': has_studio_write_access(request.user, key_for_access_check),
685710
}
686711

687712

cms/lib/xblock/upstream_sync.py

+56-13
Original file line numberDiff line numberDiff line change
@@ -146,16 +146,47 @@ def get_for_block(cls, downstream: XBlock) -> t.Self:
146146
If link exists, is supported, and is followable, returns UpstreamLink.
147147
Otherwise, raises an UpstreamLinkException.
148148
"""
149-
if not downstream.upstream:
149+
if downstream.upstream:
150+
if not isinstance(downstream.usage_key.context_key, CourseKey):
151+
raise BadDownstream(_("Cannot update content because it does not belong to a course."))
152+
if downstream.has_children:
153+
raise BadDownstream(_("Updating content with children is not yet supported."))
154+
155+
# We need to determine the usage key of this block's upstream.
156+
upstream_key: LibraryUsageLocatorV2
157+
version_synced: int | None
158+
version_available: int | None
159+
# A few different scenarios...
160+
161+
# Do we have an upstream explicitly defined on the block? If so, use that.
162+
if downstream.upstream:
163+
try:
164+
upstream_key = LibraryUsageLocatorV2.from_string(downstream.upstream)
165+
except InvalidKeyError as exc:
166+
raise BadUpstream(_("Reference to linked library item is malformed")) from exc
167+
version_synced = downstream.upstream_version
168+
version_declined = downstream.upstream_version_declined
169+
170+
# Otherwise, is this the child of a LegacyLibraryContentBlock?
171+
# If so, then we know that this block was derived from block in a legacy (v1) content library.
172+
# Try to get that block's migrated (v2) content library equivalent and use it as our upstream.
173+
elif downstream.parent and downstream.parent.block_type == "library_content":
174+
from xmodule.library_content_block import LegacyLibraryContentBlock
175+
parent: LegacyLibraryContentBlock = downstream.get_parent()
176+
# Next line will raise UpstreamLinkException if no matching V2 library block.
177+
upstream_key = parent.get_migrated_upstream_for_child(downstream.usage_key.block_id)
178+
# If we are here, then there is indeed a migrated V2 library block, but we have not yet synced from it
179+
# (otherwise `.upstream` would have been explicitly set). So, it is fair to set the version information
180+
# to "None". That way, as soon as an updated version of the migrated upstream is published, it will be
181+
# available to the course author.
182+
version_synced = None
183+
version_declined = None
184+
185+
# Otherwise, we don't have an upstream. Raise.
186+
else:
150187
raise NoUpstream()
151-
if not isinstance(downstream.usage_key.context_key, CourseKey):
152-
raise BadDownstream(_("Cannot update content because it does not belong to a course."))
153-
if downstream.has_children:
154-
raise BadDownstream(_("Updating content with children is not yet supported."))
155-
try:
156-
upstream_key = LibraryUsageLocatorV2.from_string(downstream.upstream)
157-
except InvalidKeyError as exc:
158-
raise BadUpstream(_("Reference to linked library item is malformed")) from exc
188+
189+
# Ensure that the upstream block is of a compatible type.
159190
downstream_type = downstream.usage_key.block_type
160191
if upstream_key.block_type != downstream_type:
161192
# Note: Currently, we strictly enforce that the downstream and upstream block_types must exactly match.
@@ -178,8 +209,8 @@ def get_for_block(cls, downstream: XBlock) -> t.Self:
178209
except XBlockNotFoundError as exc:
179210
raise BadUpstream(_("Linked library item was not found in the system")) from exc
180211
return cls(
181-
upstream_ref=downstream.upstream,
182-
version_synced=downstream.upstream_version,
212+
upstream_ref=str(upstream_key),
213+
version_synced=downstream.upstream_version if downstream.upstream else 0,
183214
version_available=(lib_meta.published_version_num if lib_meta else None),
184215
version_declined=downstream.upstream_version_declined,
185216
error_message=None,
@@ -201,6 +232,13 @@ def sync_from_upstream(downstream: XBlock, user: User) -> None:
201232
_update_tags(upstream=upstream, downstream=downstream)
202233
downstream.upstream_version = link.version_available
203234

235+
# Explicitly set the `upstream` setting of the downstream block from the upstream's usage key.
236+
# In most cases, this is a no-op, since that is normally how we'd spefically an upstream.
237+
# However, it is also possible for a block to have implicitly-defined upstream-- particularly, if it is the child of
238+
# a LegacyLibraryContentBlock, whose source library was recently migrated from a V1 library to a V2 library.
239+
# In that case, we want to "migrate" the downstream to the new schema by explicitly setting its `upstream` setting.
240+
downstream.upstream = str(upstream.usage_key)
241+
204242

205243
def fetch_customizable_fields(*, downstream: XBlock, user: User, upstream: XBlock | None = None) -> None:
206244
"""
@@ -213,6 +251,9 @@ def fetch_customizable_fields(*, downstream: XBlock, user: User, upstream: XBloc
213251
_link, upstream = _load_upstream_link_and_block(downstream, user)
214252
_update_customizable_fields(upstream=upstream, downstream=downstream, only_fetch=True)
215253

254+
# (see comment in sync_from_upstream)
255+
downstream.upstream = str(upstream.usage_key)
256+
216257

217258
def _load_upstream_link_and_block(downstream: XBlock, user: User) -> tuple[UpstreamLink, XBlock]:
218259
"""
@@ -227,14 +268,16 @@ def _load_upstream_link_and_block(downstream: XBlock, user: User) -> tuple[Upstr
227268
# We import load_block here b/c UpstreamSyncMixin is used by cms/envs, which loads before the djangoapps are ready.
228269
from openedx.core.djangoapps.xblock.api import load_block, CheckPerm, LatestVersion # pylint: disable=wrong-import-order
229270
try:
271+
# We know that upstream_ref cannot be None, since get_for_block returned successfully.
272+
upstream_ref: str = link.upstream_ref # type: ignore[assignment]
230273
lib_block: XBlock = load_block(
231-
LibraryUsageLocatorV2.from_string(downstream.upstream),
274+
LibraryUsageLocatorV2.from_string(upstream_ref),
232275
user,
233276
check_permission=CheckPerm.CAN_READ_AS_AUTHOR,
234277
version=LatestVersion.PUBLISHED,
235278
)
236279
except (NotFound, PermissionDenied) as exc:
237-
raise BadUpstream(_("Linked library item could not be loaded: {}").format(downstream.upstream)) from exc
280+
raise BadUpstream(_("Linked library item could not be loaded: {}").format(link.upstream_ref)) from exc
238281
return link, lib_block
239282

240283

Original file line numberDiff line numberDiff line change
@@ -1,12 +1,90 @@
11
"""
22
Admin registration for Split Modulestore Django Backend
33
"""
4-
from django.contrib import admin
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 opaque_keys import InvalidKeyError
10+
from opaque_keys.edx.locator import LibraryLocator as LegacyLibraryLocator, LibraryLocatorV2, LibraryCollectionLocator
511
from simple_history.admin import SimpleHistoryAdmin
612

13+
from openedx.core.djangoapps.content_libraries.migration_api import migrate_legacy_library
14+
715
from .models import SplitModulestoreCourseIndex
816

917

18+
logger = logging.getLogger(__name__)
19+
20+
21+
@admin.action(description="Migrate Legacy Library to new Library or Collection")
22+
def migrate(modeladmin, request, queryset):
23+
"""
24+
Migrate legacy modulestore index entries to Learning Core, based on `migration_target_key`.
25+
26+
Currently, this only works for LEGACY LIBRARY (library-v1:...) index entries.
27+
Will fail if used on any other course entry.
28+
29+
The only valid targets are currently V2 Libraries and their Collections.
30+
Will fail on any other type of target key.
31+
32+
WARNING: This does not delete the remaining legacy index item! It's up to Studio to recognize that an item has been
33+
migrated, and that the legacy entry should be ignored.
34+
"""
35+
target_key_string = request.POST['migration_target_key']
36+
target_library_key: LibraryLocatorV2
37+
target_collection_slug: str | None
38+
try:
39+
target_library_key = LibraryLocatorV2.from_string(target_key_string)
40+
target_collection_slug = None
41+
except InvalidKeyError:
42+
try:
43+
target_collection_key = LibraryCollectionLocator.from_string(target_key_string)
44+
target_library_key = target_collection_key.library_key
45+
target_collection_slug = target_collection_key.collection_id
46+
except InvalidKeyError:
47+
modeladmin.message_user(
48+
request,
49+
f"Migration target key is not a valid V2 Library or Collection key: {target_key_string}",
50+
level=messages.ERROR,
51+
)
52+
return
53+
for obj in queryset:
54+
if not isinstance(obj.course_id, LegacyLibraryLocator):
55+
modeladmin.message_user(
56+
request,
57+
f"Selected entry is not a Legacy Library: {obj.course_id}. Skipping.",
58+
level=messages.WARNING,
59+
)
60+
continue
61+
try:
62+
migrate_legacy_library(
63+
source_key=obj.course_id,
64+
target_key=target_library_key,
65+
collection_slug=target_collection_slug,
66+
user=request.user,
67+
)
68+
except Exception as exc: # pylint: disable=broad-except
69+
modeladmin.message_user(
70+
request,
71+
f"Failed to migrate {obj.course_id} to {target_key_string}: {exc}. See logs for details.",
72+
level=messages.ERROR,
73+
)
74+
logger.exception(exc)
75+
continue
76+
else:
77+
modeladmin.message_user(
78+
request,
79+
f"Migrated {obj.course_id} to {target_key_string}",
80+
level=messages.SUCCESS,
81+
)
82+
83+
84+
class MigrationTargetForm(ActionForm):
85+
migration_target_key = forms.CharField()
86+
87+
1088
@admin.register(SplitModulestoreCourseIndex)
1189
class SplitModulestoreCourseIndexAdmin(SimpleHistoryAdmin):
1290
"""
@@ -15,4 +93,5 @@ class SplitModulestoreCourseIndexAdmin(SimpleHistoryAdmin):
1593
list_display = ('course_id', 'draft_version', 'published_version', 'library_version', 'wiki_slug', 'last_update')
1694
search_fields = ('course_id', 'wiki_slug')
1795
ordering = ('course_id', )
18-
readonly_fields = ('id', 'objectid', 'course_id', 'org', )
96+
actions = [migrate]
97+
action_form = MigrationTargetForm

openedx/core/djangoapps/content_libraries/admin.py

+20-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
Admin site for content libraries
33
"""
44
from django.contrib import admin
5-
from .models import ContentLibrary, ContentLibraryPermission
5+
from .models import (
6+
ContentLibrary, ContentLibraryPermission, ContentLibraryMigration, ContentLibraryBlockMigration
7+
)
68

79

810
class ContentLibraryPermissionInline(admin.TabularInline):
@@ -39,3 +41,20 @@ def get_readonly_fields(self, request, obj=None):
3941
return ["library_key", "org", "slug"]
4042
else:
4143
return ["library_key", ]
44+
45+
46+
class ContentLibraryBlockMigrationInline(admin.TabularInline):
47+
"""
48+
Django admin UI for content library block migrations
49+
"""
50+
model = ContentLibraryBlockMigration
51+
list_display = ("library_migration", "block_type", "source_block_id", "target_block_id")
52+
53+
54+
@admin.register(ContentLibraryMigration)
55+
class ContentLibraryMigrationAdmin(admin.ModelAdmin):
56+
"""
57+
Django admin UI for content library migrations
58+
"""
59+
list_display = ("source_key", "target", "target_collection")
60+
inlines = (ContentLibraryBlockMigrationInline,)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""
2+
Implements ./manage.py cms migrate_legacy_library
3+
"""
4+
import logging
5+
6+
from django.contrib.auth.models import User # pylint: disable=imported-auth-user
7+
from django.core.management import BaseCommand
8+
9+
from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2
10+
from openedx.core.djangoapps.content_libraries.migration_api import migrate_legacy_library
11+
12+
13+
log = logging.getLogger(__name__)
14+
15+
16+
class Command(BaseCommand):
17+
"""
18+
@TODO
19+
"""
20+
21+
def add_arguments(self, parser):
22+
"""
23+
Add arguments to the argument parser.
24+
"""
25+
parser.add_argument(
26+
'legacy_library',
27+
type=LibraryLocator.from_string,
28+
)
29+
parser.add_argument(
30+
'new_library',
31+
type=LibraryLocatorV2.from_string,
32+
)
33+
parser.add_argument(
34+
'collection',
35+
type=str,
36+
)
37+
38+
def handle( # pylint: disable=arguments-differ
39+
self,
40+
legacy_library: LibraryLocator,
41+
new_library: LibraryLocatorV2,
42+
collection: str | None,
43+
**kwargs,
44+
) -> None:
45+
"""
46+
Handle the command.
47+
"""
48+
user = User.objects.filter(is_superuser=True)[0]
49+
migrate_legacy_library(legacy_library, new_library, collection_slug=collection, user=user)

0 commit comments

Comments
 (0)