Skip to content

Commit 664021b

Browse files
committed
feat: Backend for Migrating Legacy Libraries (wip)
1 parent f730276 commit 664021b

File tree

8 files changed

+457
-28
lines changed

8 files changed

+457
-28
lines changed

cms/djangoapps/contentstore/views/library.py

+26-14
Original file line numberDiff line numberDiff line change
@@ -177,29 +177,41 @@ def _list_libraries(request):
177177
org - The organization used to filter libraries
178178
text_search - The string used to filter libraries by searching in title, id or org
179179
"""
180+
from openedx.core.djangoapps.content_libraries.models import ContentLibraryMigration
180181
org = request.GET.get('org', '')
181182
text_search = request.GET.get('text_search', '').lower()
182183

183184
if org:
184185
libraries = modulestore().get_libraries(org=org)
185186
else:
186187
libraries = modulestore().get_libraries()
187-
188-
lib_info = [
189-
{
188+
lib_info = {}
189+
for lib in libraries:
190+
if not (
191+
text_search in lib.display_name.lower() or
192+
text_search in lib.context_key.org.lower() or
193+
text_search in lib.context_key.library.lower()
194+
):
195+
continue
196+
if not has_studio_read_access(request.user, lib.context_key):
197+
continue
198+
lib_info[lib.context_key] = {
190199
"display_name": lib.display_name,
191-
"library_key": str(lib.location.library_key),
200+
"library_key": str(lib.context_key),
201+
"migrated_to": None,
202+
}
203+
try:
204+
migration = ContentLibraryMigration.objects.select_related(
205+
"target", "target__learning_package", "target_collection"
206+
).get(source_key=lib.context_key)
207+
except ContentLibraryMigration.DoesNotExist:
208+
continue
209+
lib_info["migrated_to"] = {
210+
"library_key": str(migration.target.library_key),
211+
"display_name": str(migration.target.learning_packge.title),
212+
"collection_key": str(migration.target_collection.key) if migration.target_collection else None,
213+
"collection_display_name": str(migration.target_collection.key) if migration.target_collection else None,
192214
}
193-
for lib in libraries
194-
if (
195-
(
196-
text_search in lib.display_name.lower() or
197-
text_search in lib.location.library_key.org.lower() or
198-
text_search in lib.location.library_key.library.lower()
199-
) and
200-
has_studio_read_access(request.user, lib.location.library_key)
201-
)
202-
]
203215
return JsonResponse(lib_info)
204216

205217

cms/lib/xblock/upstream_sync.py

+51-10
Original file line numberDiff line numberDiff line change
@@ -146,16 +146,46 @@ 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:
150-
raise NoUpstream()
151149
if not isinstance(downstream.usage_key.context_key, CourseKey):
152150
raise BadDownstream(_("Cannot update content because it does not belong to a course."))
153151
if downstream.has_children:
154152
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
153+
154+
# We need to determine the usage key of this block's upstream.
155+
upstream_key: LibraryUsageLocatorV2
156+
version_synced: int | None
157+
version_available: int | None
158+
# A few different scenarios...
159+
160+
# Do we have an upstream explicitly defined on the block? If so, use that.
161+
if downstream.upstream:
162+
try:
163+
upstream_key = LibraryUsageLocatorV2.from_string(downstream.upstream)
164+
except InvalidKeyError as exc:
165+
raise BadUpstream(_("Reference to linked library item is malformed")) from exc
166+
version_synced = downstream.upstream_version
167+
version_declined = downstream.upstream_version_declined
168+
169+
# Otherwise, is this the child of a LegacyLibraryContentBlock?
170+
# If so, then we know that this block was derived from block in a legacy (v1) content library.
171+
# Try to get that block's migrated (v2) content library equivalent and use it as our upstream.
172+
elif downstream.parent.block_type == "library_content":
173+
from xmodule.library_content_block import LegacyLibraryContentBlock
174+
parent: LegacyLibraryContentBlock = downstream.get_parent()
175+
# Next line will raise UpstreamLinkException if no matching V2 library block.
176+
upstream_key = parent.get_migrated_upstream_for_child(downstream.usage_key.block_id)
177+
# If we are here, then there is indeed a migrated V2 library block, but we have not yet synced from it
178+
# (otherwise `.upstream` would have been explicitly set). So, it is fair to set the version information
179+
# to "None". That way, as soon as an updated version of the migrated upstream is published, it will be
180+
# available to the course author.
181+
version_synced = None
182+
version_declined = None
183+
184+
# Otherwise, we don't have an upstream. Raise.
185+
else:
186+
raise NoUpstream()
187+
188+
# Ensure that the upstream block is of a compatible type.
159189
downstream_type = downstream.usage_key.block_type
160190
if upstream_key.block_type != downstream_type:
161191
# Note: Currently, we strictly enforce that the downstream and upstream block_types must exactly match.
@@ -178,8 +208,8 @@ def get_for_block(cls, downstream: XBlock) -> t.Self:
178208
except XBlockNotFoundError as exc:
179209
raise BadUpstream(_("Linked library item was not found in the system")) from exc
180210
return cls(
181-
upstream_ref=downstream.upstream,
182-
version_synced=downstream.upstream_version,
211+
upstream_ref=str(upstream_key),
212+
version_synced=downstream.upstream_version if downstream.upstream else 0,
183213
version_available=(lib_meta.published_version_num if lib_meta else None),
184214
version_declined=downstream.upstream_version_declined,
185215
error_message=None,
@@ -201,6 +231,13 @@ def sync_from_upstream(downstream: XBlock, user: User) -> None:
201231
_update_tags(upstream=upstream, downstream=downstream)
202232
downstream.upstream_version = link.version_available
203233

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

205242
def fetch_customizable_fields(*, downstream: XBlock, user: User, upstream: XBlock | None = None) -> None:
206243
"""
@@ -213,6 +250,9 @@ def fetch_customizable_fields(*, downstream: XBlock, user: User, upstream: XBloc
213250
_link, upstream = _load_upstream_link_and_block(downstream, user)
214251
_update_customizable_fields(upstream=upstream, downstream=downstream, only_fetch=True)
215252

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

217257
def _load_upstream_link_and_block(downstream: XBlock, user: User) -> tuple[UpstreamLink, XBlock]:
218258
"""
@@ -227,14 +267,15 @@ def _load_upstream_link_and_block(downstream: XBlock, user: User) -> tuple[Upstr
227267
# We import load_block here b/c UpstreamSyncMixin is used by cms/envs, which loads before the djangoapps are ready.
228268
from openedx.core.djangoapps.xblock.api import load_block, CheckPerm, LatestVersion # pylint: disable=wrong-import-order
229269
try:
270+
upstream_ref: str = link.upstream_ref # We know this isn't None, since get_for_block returned successfully.
230271
lib_block: XBlock = load_block(
231-
LibraryUsageLocatorV2.from_string(downstream.upstream),
272+
LibraryUsageLocatorV2.from_string(upstream_ref),
232273
user,
233274
check_permission=CheckPerm.CAN_READ_AS_AUTHOR,
234275
version=LatestVersion.PUBLISHED,
235276
)
236277
except (NotFound, PermissionDenied) as exc:
237-
raise BadUpstream(_("Linked library item could not be loaded: {}").format(downstream.upstream)) from exc
278+
raise BadUpstream(_("Linked library item could not be loaded: {}").format(link.upstream_ref)) from exc
238279
return link, lib_block
239280

240281

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)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""
2+
@@TODO
3+
"""
4+
from __future__ import annotations
5+
6+
import logging
7+
from django.contrib.auth.models import User # pylint: disable=imported-auth-user
8+
from django.db import transaction
9+
from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2
10+
from openedx_learning.api.authoring import add_to_collection, get_collection
11+
from openedx_learning.api.authoring_models import PublishableEntity, Component
12+
from openedx_tagging.core.tagging.api import tag_object
13+
from openedx_tagging.core.tagging.models import Taxonomy
14+
from organizations.models import Organization
15+
from xblock.fields import Scope
16+
17+
from openedx.core.djangoapps.xblock.api import load_block
18+
from openedx.core.djangoapps.content_libraries.api import create_library_block
19+
from xmodule.util.keys import BlockKey
20+
from xmodule.modulestore.django import modulestore
21+
22+
from .models import ContentLibrary, ContentLibraryMigration, ContentLibraryBlockMigration
23+
24+
25+
log = logging.getLogger(__name__)
26+
27+
28+
def migrate_legacy_library(
29+
source_key: LibraryLocator,
30+
target_key: LibraryLocatorV2,
31+
*,
32+
collection_slug: str | None,
33+
user: User,
34+
tags_to_add: dict[Taxonomy, list[str]] | None = None,
35+
) -> None:
36+
"""
37+
Migrate a v1 (legacy) library into a v2 (learning core) library, optionally within a collection.
38+
39+
Use a single transaction so that if any step fails, nothing happens.
40+
41+
@@TODO handle or document various exceptions
42+
@@TODO tags
43+
"""
44+
source = modulestore().get_library(source_key)
45+
target = ContentLibrary.objects.get(org=Organization.objects.get(short_name=target_key.org), slug=target_key.slug)
46+
assert target.learning_package_id
47+
collection = get_collection(target.learning_package_id, collection_slug) if collection_slug else None
48+
49+
# We need to be careful not to conflict with any existing block keys in the target library.
50+
# This is unlikely to happen, since legacy library block ids are genreally randomly-generated GUIDs.
51+
# Howevever, there are a couple scenarios where it could arise:
52+
# * An instance has two legacy libraries which were imported from the same source legacy library (and thus share
53+
# block GUIDs) which the author now wants to merge together into one big new library.
54+
# * A library was imported from handcrafted OLX, and thus has human-readable block IDs which are liable to overlap.
55+
# When there is conflict, we'll append "-1" to the end of the id (or "-2", "-3", etc., until we find a free ID).
56+
all_target_block_keys: set[BlockKey] = {
57+
BlockKey(*block_type_and_id)
58+
for block_type_and_id
59+
in Component.objects.filter(
60+
learning_package=target.learning_package,
61+
component_type__namespace="xblock.v1",
62+
).values_list("component_type__name", "local_key")
63+
}
64+
65+
# We also need to be careful not to conflict with other block IDs which we are moving in from the *source* library
66+
# This is very unlikely, but it could happen if, for example:
67+
# * the source library has a problem "foo", and
68+
# * the target library also has a problem "foo", and
69+
# * the source library ALSO has a problem "foo-1", thus
70+
# * the source library's "foo" must be moved to the target as "foo-2".
71+
all_source_block_keys: set[BlockKey] = {
72+
BlockKey.from_usage_key(child_key)
73+
for child_key in source.children
74+
}
75+
76+
target_block_entity_keys: set[str] = set()
77+
78+
with transaction.atomic():
79+
migration = ContentLibraryMigration.objects.create(
80+
source_key=source_key,
81+
target=target,
82+
target_collection=collection,
83+
)
84+
85+
for source_block in source.get_children():
86+
block_type: str = source_block.usage_key.block_type
87+
88+
# Determine an available block_id...
89+
target_block_key = BlockKey(block_type, source_block.usage_key.block_id)
90+
if target_block_key in all_target_block_keys:
91+
suffix = 0
92+
while target_block_key in all_target_block_keys | all_source_block_keys:
93+
suffix += 1
94+
target_block_key = BlockKey(block_type, f"{source_block.usage_key.block_id}-{suffix}")
95+
96+
# Create the block in the v2 library
97+
target_block_meta = create_library_block(
98+
library_key=target_key,
99+
block_type=block_type,
100+
definition_id=target_block_key.id,
101+
user_id=user.id,
102+
)
103+
target_block_entity_keys.add(f"xblock.v1:{block_type}:{target_block_key.id}")
104+
105+
# Copy its content over from the v1 library
106+
target_block = load_block(target_block_meta.usage_key, user)
107+
for field_name, field in source_block.__class__.fields.items():
108+
if field.scope not in [Scope.settings, Scope.content]:
109+
continue
110+
if not hasattr(target_block, field_name):
111+
continue
112+
source_value = getattr(source_block, field_name)
113+
if getattr(target_block, field_name) != source_value:
114+
setattr(target_block, field_name, source_value)
115+
target_block.save()
116+
117+
# If requested, add tags
118+
for taxonomy, taxonomy_tags in (tags_to_add or {}).items():
119+
tag_object(str(target_block_meta.usage_key), taxonomy, taxonomy_tags)
120+
121+
# Make a record of the migration
122+
ContentLibraryBlockMigration.objects.create(
123+
library_migration=migration,
124+
block_type=block_type,
125+
source_block_id=source_block.usage_key.block_id,
126+
target_block_id=target_block_key.id,
127+
)
128+
129+
# If requested, add to a collection, and add tags
130+
if collection_slug:
131+
add_to_collection(
132+
target.learning_package_id,
133+
collection_slug,
134+
PublishableEntity.objects.filter(
135+
key__in=target_block_entity_keys,
136+
),
137+
)

0 commit comments

Comments
 (0)