Skip to content

Commit

Permalink
feat: Backend for Migrating Legacy Libraries (wip)
Browse files Browse the repository at this point in the history
  • Loading branch information
kdmccormick committed Nov 4, 2024
1 parent f730276 commit 664021b
Show file tree
Hide file tree
Showing 8 changed files with 457 additions and 28 deletions.
40 changes: 26 additions & 14 deletions cms/djangoapps/contentstore/views/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,29 +177,41 @@ def _list_libraries(request):
org - The organization used to filter libraries
text_search - The string used to filter libraries by searching in title, id or org
"""
from openedx.core.djangoapps.content_libraries.models import ContentLibraryMigration
org = request.GET.get('org', '')
text_search = request.GET.get('text_search', '').lower()

if org:
libraries = modulestore().get_libraries(org=org)
else:
libraries = modulestore().get_libraries()

lib_info = [
{
lib_info = {}
for lib in libraries:
if not (
text_search in lib.display_name.lower() or
text_search in lib.context_key.org.lower() or
text_search in lib.context_key.library.lower()
):
continue
if not has_studio_read_access(request.user, lib.context_key):
continue
lib_info[lib.context_key] = {
"display_name": lib.display_name,
"library_key": str(lib.location.library_key),
"library_key": str(lib.context_key),
"migrated_to": None,
}
try:
migration = ContentLibraryMigration.objects.select_related(
"target", "target__learning_package", "target_collection"
).get(source_key=lib.context_key)
except ContentLibraryMigration.DoesNotExist:
continue
lib_info["migrated_to"] = {
"library_key": str(migration.target.library_key),
"display_name": str(migration.target.learning_packge.title),
"collection_key": str(migration.target_collection.key) if migration.target_collection else None,
"collection_display_name": str(migration.target_collection.key) if migration.target_collection else None,
}
for lib in libraries
if (
(
text_search in lib.display_name.lower() or
text_search in lib.location.library_key.org.lower() or
text_search in lib.location.library_key.library.lower()
) and
has_studio_read_access(request.user, lib.location.library_key)
)
]
return JsonResponse(lib_info)


Expand Down
61 changes: 51 additions & 10 deletions cms/lib/xblock/upstream_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,16 +146,46 @@ def get_for_block(cls, downstream: XBlock) -> t.Self:
If link exists, is supported, and is followable, returns UpstreamLink.
Otherwise, raises an UpstreamLinkException.
"""
if not downstream.upstream:
raise NoUpstream()
if not isinstance(downstream.usage_key.context_key, CourseKey):
raise BadDownstream(_("Cannot update content because it does not belong to a course."))
if downstream.has_children:
raise BadDownstream(_("Updating content with children is not yet supported."))
try:
upstream_key = LibraryUsageLocatorV2.from_string(downstream.upstream)
except InvalidKeyError as exc:
raise BadUpstream(_("Reference to linked library item is malformed")) from exc

# We need to determine the usage key of this block's upstream.
upstream_key: LibraryUsageLocatorV2
version_synced: int | None
version_available: int | None
# A few different scenarios...

# Do we have an upstream explicitly defined on the block? If so, use that.
if downstream.upstream:
try:
upstream_key = LibraryUsageLocatorV2.from_string(downstream.upstream)
except InvalidKeyError as exc:
raise BadUpstream(_("Reference to linked library item is malformed")) from exc
version_synced = downstream.upstream_version
version_declined = downstream.upstream_version_declined

# Otherwise, is this the child of a LegacyLibraryContentBlock?
# If so, then we know that this block was derived from block in a legacy (v1) content library.
# Try to get that block's migrated (v2) content library equivalent and use it as our upstream.
elif downstream.parent.block_type == "library_content":
from xmodule.library_content_block import LegacyLibraryContentBlock
parent: LegacyLibraryContentBlock = downstream.get_parent()
# Next line will raise UpstreamLinkException if no matching V2 library block.
upstream_key = parent.get_migrated_upstream_for_child(downstream.usage_key.block_id)
# If we are here, then there is indeed a migrated V2 library block, but we have not yet synced from it
# (otherwise `.upstream` would have been explicitly set). So, it is fair to set the version information
# to "None". That way, as soon as an updated version of the migrated upstream is published, it will be
# available to the course author.
version_synced = None
version_declined = None

# Otherwise, we don't have an upstream. Raise.
else:
raise NoUpstream()

# Ensure that the upstream block is of a compatible type.
downstream_type = downstream.usage_key.block_type
if upstream_key.block_type != downstream_type:
# Note: Currently, we strictly enforce that the downstream and upstream block_types must exactly match.
Expand All @@ -178,8 +208,8 @@ def get_for_block(cls, downstream: XBlock) -> t.Self:
except XBlockNotFoundError as exc:
raise BadUpstream(_("Linked library item was not found in the system")) from exc
return cls(
upstream_ref=downstream.upstream,
version_synced=downstream.upstream_version,
upstream_ref=str(upstream_key),
version_synced=downstream.upstream_version if downstream.upstream else 0,
version_available=(lib_meta.published_version_num if lib_meta else None),
version_declined=downstream.upstream_version_declined,
error_message=None,
Expand All @@ -201,6 +231,13 @@ def sync_from_upstream(downstream: XBlock, user: User) -> None:
_update_tags(upstream=upstream, downstream=downstream)
downstream.upstream_version = link.version_available

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


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

# (see comment in sync_from_upstream)
downstream.upstream = str(upstream.usage_key)


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


Expand Down
21 changes: 20 additions & 1 deletion openedx/core/djangoapps/content_libraries/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
Admin site for content libraries
"""
from django.contrib import admin
from .models import ContentLibrary, ContentLibraryPermission
from .models import (
ContentLibrary, ContentLibraryPermission, ContentLibraryMigration, ContentLibraryBlockMigration
)


class ContentLibraryPermissionInline(admin.TabularInline):
Expand Down Expand Up @@ -39,3 +41,20 @@ def get_readonly_fields(self, request, obj=None):
return ["library_key", "org", "slug"]
else:
return ["library_key", ]


class ContentLibraryBlockMigrationInline(admin.TabularInline):
"""
Django admin UI for content library block migrations
"""
model = ContentLibraryBlockMigration
list_display = ("library_migration", "block_type", "source_block_id", "target_block_id")


@admin.register(ContentLibraryMigration)
class ContentLibraryMigrationAdmin(admin.ModelAdmin):
"""
Django admin UI for content library migrations
"""
list_display = ("source_key", "target", "target_collection")
inlines = (ContentLibraryBlockMigrationInline,)
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""
Implements ./manage.py cms migrate_legacy_library
"""
import logging

from django.contrib.auth.models import User # pylint: disable=imported-auth-user
from django.core.management import BaseCommand

from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2
from openedx.core.djangoapps.content_libraries.migration_api import migrate_legacy_library


log = logging.getLogger(__name__)


class Command(BaseCommand):
"""
@TODO
"""

def add_arguments(self, parser):
"""
Add arguments to the argument parser.
"""
parser.add_argument(
'legacy_library',
type=LibraryLocator.from_string,
)
parser.add_argument(
'new_library',
type=LibraryLocatorV2.from_string,
)
parser.add_argument(
'collection',
type=str,
)

def handle( # pylint: disable=arguments-differ
self,
legacy_library: LibraryLocator,
new_library: LibraryLocatorV2,
collection: str | None,
**kwargs,
) -> None:
"""
Handle the command.
"""
user = User.objects.filter(is_superuser=True)[0]
migrate_legacy_library(legacy_library, new_library, collection_slug=collection, user=user)
137 changes: 137 additions & 0 deletions openedx/core/djangoapps/content_libraries/migration_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""
@@TODO
"""
from __future__ import annotations

import logging
from django.contrib.auth.models import User # pylint: disable=imported-auth-user
from django.db import transaction
from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2
from openedx_learning.api.authoring import add_to_collection, get_collection
from openedx_learning.api.authoring_models import PublishableEntity, Component
from openedx_tagging.core.tagging.api import tag_object
from openedx_tagging.core.tagging.models import Taxonomy
from organizations.models import Organization
from xblock.fields import Scope

from openedx.core.djangoapps.xblock.api import load_block
from openedx.core.djangoapps.content_libraries.api import create_library_block
from xmodule.util.keys import BlockKey
from xmodule.modulestore.django import modulestore

from .models import ContentLibrary, ContentLibraryMigration, ContentLibraryBlockMigration


log = logging.getLogger(__name__)


def migrate_legacy_library(
source_key: LibraryLocator,
target_key: LibraryLocatorV2,
*,
collection_slug: str | None,
user: User,
tags_to_add: dict[Taxonomy, list[str]] | None = None,
) -> None:
"""
Migrate a v1 (legacy) library into a v2 (learning core) library, optionally within a collection.
Use a single transaction so that if any step fails, nothing happens.
@@TODO handle or document various exceptions
@@TODO tags
"""
source = modulestore().get_library(source_key)
target = ContentLibrary.objects.get(org=Organization.objects.get(short_name=target_key.org), slug=target_key.slug)
assert target.learning_package_id
collection = get_collection(target.learning_package_id, collection_slug) if collection_slug else None

# We need to be careful not to conflict with any existing block keys in the target library.
# This is unlikely to happen, since legacy library block ids are genreally randomly-generated GUIDs.
# Howevever, there are a couple scenarios where it could arise:
# * An instance has two legacy libraries which were imported from the same source legacy library (and thus share
# block GUIDs) which the author now wants to merge together into one big new library.
# * A library was imported from handcrafted OLX, and thus has human-readable block IDs which are liable to overlap.
# When there is conflict, we'll append "-1" to the end of the id (or "-2", "-3", etc., until we find a free ID).
all_target_block_keys: set[BlockKey] = {
BlockKey(*block_type_and_id)
for block_type_and_id
in Component.objects.filter(
learning_package=target.learning_package,
component_type__namespace="xblock.v1",
).values_list("component_type__name", "local_key")
}

# We also need to be careful not to conflict with other block IDs which we are moving in from the *source* library
# This is very unlikely, but it could happen if, for example:
# * the source library has a problem "foo", and
# * the target library also has a problem "foo", and
# * the source library ALSO has a problem "foo-1", thus
# * the source library's "foo" must be moved to the target as "foo-2".
all_source_block_keys: set[BlockKey] = {
BlockKey.from_usage_key(child_key)
for child_key in source.children
}

target_block_entity_keys: set[str] = set()

with transaction.atomic():
migration = ContentLibraryMigration.objects.create(
source_key=source_key,
target=target,
target_collection=collection,
)

for source_block in source.get_children():
block_type: str = source_block.usage_key.block_type

# Determine an available block_id...
target_block_key = BlockKey(block_type, source_block.usage_key.block_id)
if target_block_key in all_target_block_keys:
suffix = 0
while target_block_key in all_target_block_keys | all_source_block_keys:
suffix += 1
target_block_key = BlockKey(block_type, f"{source_block.usage_key.block_id}-{suffix}")

# Create the block in the v2 library
target_block_meta = create_library_block(
library_key=target_key,
block_type=block_type,
definition_id=target_block_key.id,
user_id=user.id,
)
target_block_entity_keys.add(f"xblock.v1:{block_type}:{target_block_key.id}")

# Copy its content over from the v1 library
target_block = load_block(target_block_meta.usage_key, user)
for field_name, field in source_block.__class__.fields.items():
if field.scope not in [Scope.settings, Scope.content]:
continue
if not hasattr(target_block, field_name):
continue
source_value = getattr(source_block, field_name)
if getattr(target_block, field_name) != source_value:
setattr(target_block, field_name, source_value)
target_block.save()

# If requested, add tags
for taxonomy, taxonomy_tags in (tags_to_add or {}).items():
tag_object(str(target_block_meta.usage_key), taxonomy, taxonomy_tags)

# Make a record of the migration
ContentLibraryBlockMigration.objects.create(
library_migration=migration,
block_type=block_type,
source_block_id=source_block.usage_key.block_id,
target_block_id=target_block_key.id,
)

# If requested, add to a collection, and add tags
if collection_slug:
add_to_collection(
target.learning_package_id,
collection_slug,
PublishableEntity.objects.filter(
key__in=target_block_entity_keys,
),
)
Loading

0 comments on commit 664021b

Please sign in to comment.