feat: follow migrated legacy library content block (#37405)
* feat: show item bank ui for migrated legacy library content * feat: migrate legacy content block to item bank block on view in studio * fix: duplicate and copy issues * refactor: migration location and add tests * fix: lint issues * fix: item bank and library content children view add button functionality Newly added blocks from library in children view page of item bank block and migrated library content block were not displayed automatically. * fix: lint issues * fix: lint issues * feat: only migrate if same version of library is migrated * refactor: migrate block on request * fix: component reload on migration * fix: tests * refactor: comments and message wordings * refactor: update alert text * docs: add context * fix: component links not being created on migrating legacy blocks * fix: api docs and types * refactor: use inheritance and specific parent method call * fix: imports * fix: api typing * fix: upstream_version check * refactor: rename variables * refactor: parsing entity keys to usage_keys
This commit is contained in:
@@ -1677,7 +1677,7 @@ def handle_update_xblock_upstream_link(usage_key):
|
||||
except (ItemNotFoundError, InvalidKeyError):
|
||||
LOGGER.exception(f'Could not find item for given usage_key: {usage_key}')
|
||||
return
|
||||
if not xblock.upstream or not xblock.upstream_version:
|
||||
if not xblock.upstream or xblock.upstream_version is None:
|
||||
return
|
||||
create_or_update_xblock_upstream_link(xblock)
|
||||
|
||||
|
||||
@@ -2,22 +2,25 @@
|
||||
API for migration from modulestore to learning core
|
||||
"""
|
||||
from celery.result import AsyncResult
|
||||
from opaque_keys.edx.keys import CourseKey, LearningContextKey
|
||||
from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey, LearningContextKey, UsageKey
|
||||
from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2, LibraryUsageLocatorV2
|
||||
from openedx_learning.api.authoring import get_collection
|
||||
from openedx_learning.api.authoring_models import Component
|
||||
from user_tasks.models import UserTaskStatus
|
||||
|
||||
from openedx.core.djangoapps.content_libraries.api import get_library
|
||||
from openedx.core.djangoapps.content_libraries.api import get_library, library_component_usage_key
|
||||
from openedx.core.types.user import AuthUser
|
||||
|
||||
from . import tasks
|
||||
from .models import ModulestoreSource
|
||||
from .models import ModulestoreBlockMigration, ModulestoreSource
|
||||
|
||||
__all__ = (
|
||||
"start_migration_to_library",
|
||||
"start_bulk_migration_to_library",
|
||||
"is_successfully_migrated",
|
||||
"get_migration_info",
|
||||
"get_target_block_usage_keys",
|
||||
)
|
||||
|
||||
|
||||
@@ -102,13 +105,17 @@ def start_bulk_migration_to_library(
|
||||
)
|
||||
|
||||
|
||||
def is_successfully_migrated(source_key: CourseKey | LibraryLocator) -> bool:
|
||||
def is_successfully_migrated(
|
||||
source_key: CourseKey | LibraryLocator,
|
||||
source_version: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if the source course/library has been migrated successfully.
|
||||
"""
|
||||
return ModulestoreSource.objects.get_or_create(key=str(source_key))[0].migrations.filter(
|
||||
task_status__state=UserTaskStatus.SUCCEEDED
|
||||
).exists()
|
||||
filters = {"task_status__state": UserTaskStatus.SUCCEEDED}
|
||||
if source_version is not None:
|
||||
filters["source_version"] = source_version
|
||||
return ModulestoreSource.objects.get_or_create(key=str(source_key))[0].migrations.filter(**filters).exists()
|
||||
|
||||
|
||||
def get_migration_info(source_keys: list[CourseKey | LibraryLocator]) -> dict:
|
||||
@@ -129,3 +136,26 @@ def get_migration_info(source_keys: list[CourseKey | LibraryLocator]) -> dict:
|
||||
named=True,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def get_target_block_usage_keys(source_key: CourseKey | LibraryLocator) -> dict[UsageKey, LibraryUsageLocatorV2 | None]:
|
||||
"""
|
||||
For given source_key, get a map of legacy block key and its new location in migrated v2 library.
|
||||
"""
|
||||
query_set = ModulestoreBlockMigration.objects.filter(overall_migration__source__key=source_key).select_related(
|
||||
'source', 'target__component__component_type', 'target__learning_package'
|
||||
)
|
||||
|
||||
def construct_usage_key(lib_key_str: str, component: Component) -> LibraryUsageLocatorV2 | None:
|
||||
try:
|
||||
lib_key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
except InvalidKeyError:
|
||||
return None
|
||||
return library_component_usage_key(lib_key, component)
|
||||
|
||||
# Use LibraryUsageLocatorV2 and construct usage key
|
||||
return {
|
||||
obj.source.key: construct_usage_key(obj.target.learning_package.key, obj.target.component)
|
||||
for obj in query_set
|
||||
if obj.source.key is not None
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ class TestModulestoreMigratorAPI(LibraryTestCase):
|
||||
)
|
||||
self.library_v2 = lib_api.ContentLibrary.objects.get(slug=self.lib_key_v2.slug)
|
||||
self.learning_package = self.library_v2.learning_package
|
||||
self.blocks = []
|
||||
for _ in range(3):
|
||||
self.blocks.append(self._add_simple_content_block().usage_key)
|
||||
|
||||
def test_start_migration_to_library(self):
|
||||
"""
|
||||
@@ -384,3 +387,24 @@ class TestModulestoreMigratorAPI(LibraryTestCase):
|
||||
assert row.migrations__target__title == "Test Library"
|
||||
assert row.migrations__target_collection__key == collection_key
|
||||
assert row.migrations__target_collection__title == "Test Collection"
|
||||
|
||||
def test_get_target_block_usage_keys(self):
|
||||
"""
|
||||
Test that the API can get the list of target block usage keys for a given library.
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
api.start_migration_to_library(
|
||||
user=user,
|
||||
source_key=self.lib_key,
|
||||
target_library_key=self.library_v2.library_key,
|
||||
target_collection_slug=None,
|
||||
composition_level=CompositionLevel.Component.value,
|
||||
repeat_handling_strategy=RepeatHandlingStrategy.Skip.value,
|
||||
preserve_url_slugs=True,
|
||||
forward_source_to_target=True,
|
||||
)
|
||||
with self.assertNumQueries(1):
|
||||
result = api.get_target_block_usage_keys(self.lib_key)
|
||||
for key in self.blocks:
|
||||
assert result.get(key) is not None
|
||||
|
||||
@@ -17,17 +17,17 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import typing as t
|
||||
from dataclasses import dataclass, asdict
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryUsageLocatorV2
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from xblock.core import XBlock, XBlockMixin
|
||||
from xblock.exceptions import XBlockNotFoundError
|
||||
from xblock.fields import Scope, String, Integer, List
|
||||
from xblock.core import XBlockMixin, XBlock
|
||||
from xblock.fields import Integer, List, Scope, String
|
||||
|
||||
from xmodule.util.keys import BlockKey
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
|
||||
@@ -632,13 +632,13 @@ function($, _, Backbone, gettext, BasePage,
|
||||
doneAddingBlock = (addResult) => {
|
||||
const $placeholderEl = $(this.createPlaceholderElement());
|
||||
const placeholderElement = $placeholderEl.insertBefore($insertSpot);
|
||||
placeholderElement.data('locator', addResult.locator);
|
||||
return this.refreshXBlock(placeholderElement, true);
|
||||
return this.onNewXBlock(placeholderElement, 0, false, addResult);
|
||||
};
|
||||
doneAddingAllBlocks = () => {};
|
||||
}
|
||||
// Note: adding all the XBlocks in parallel will cause a race condition 😢 so we have to add
|
||||
// them one at a time:
|
||||
|
||||
let lastAdded = $.when();
|
||||
for (const { usageKey, blockType } of selectedBlocks) {
|
||||
const addData = {
|
||||
@@ -1220,12 +1220,13 @@ function($, _, Backbone, gettext, BasePage,
|
||||
refreshXBlock: function(element, block_added, is_duplicate) {
|
||||
var xblockElement = this.findXBlockElement(element),
|
||||
parentElement = xblockElement.parent(),
|
||||
rootLocator = this.xblockView.model.id;
|
||||
rootLocator = this.xblockView.model.id,
|
||||
parentBlockType = parentElement.data('block-type');
|
||||
if (xblockElement.length === 0 || xblockElement.data('locator') === rootLocator) {
|
||||
if (block_added) {
|
||||
this.render({refresh: true, block_added: block_added});
|
||||
}
|
||||
} else if (parentElement.hasClass('reorderable-container')) {
|
||||
} else if (parentElement.hasClass('reorderable-container') || ["itembank", "library_content"].includes(parentBlockType) ) {
|
||||
this.refreshChildXBlock(xblockElement, block_added, is_duplicate);
|
||||
} else {
|
||||
this.refreshXBlock(this.findXBlockElement(parentElement));
|
||||
|
||||
@@ -1,11 +1,39 @@
|
||||
/* JavaScript for special editing operations that can be done on LibraryContentXBlock */
|
||||
window.LibraryContentAuthorView = function(runtime, element) {
|
||||
window.LibraryContentAuthorView = function(runtime, element, initArgs) {
|
||||
'use strict';
|
||||
var $element = $(element);
|
||||
var usage_id = $element.data('usage-id');
|
||||
// The "Update Now" button is not a child of 'element', as it is in the validation message area
|
||||
// But it is still inside this xblock's wrapper element, which we can easily find:
|
||||
var $wrapper = $element.parents('*[data-locator="' + usage_id + '"]');
|
||||
var { is_root: isRoot = false } = initArgs;
|
||||
|
||||
function postMessageToParent(body, callbackFn = null) {
|
||||
try {
|
||||
window.parent.postMessage(body, document.referrer);
|
||||
if (callbackFn) {
|
||||
callbackFn();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to post message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
function reloadPreviewPage() {
|
||||
if (window.self !== window.top) {
|
||||
// We are inside iframe
|
||||
// Normal location.reload() reloads the iframe but subsequent calls to
|
||||
// postMessage fails. So we are using postMessage to tell the parent page
|
||||
// to reload the iframe.
|
||||
postMessageToParent({
|
||||
type: 'refreshIframe',
|
||||
message: 'Refresh Iframe',
|
||||
payload: {},
|
||||
})
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
$wrapper.on('click', '.library-update-btn', function(e) {
|
||||
e.preventDefault();
|
||||
@@ -20,17 +48,33 @@ window.LibraryContentAuthorView = function(runtime, element) {
|
||||
state: 'end',
|
||||
element: element
|
||||
});
|
||||
if ($element.closest('.wrapper-xblock').is(':not(.level-page)')) {
|
||||
// We are on a course unit page. The notify('save') should refresh this block,
|
||||
// but that is only working on the container page view of this block.
|
||||
// Why? On the unit page, this XBlock's runtime has no reference to the
|
||||
// XBlockContainerPage - only the top-level XBlock (a vertical) runtime does.
|
||||
// But unfortunately there is no way to get a reference to our parent block's
|
||||
// JS 'runtime' object. So instead we must refresh the whole page:
|
||||
location.reload();
|
||||
if (isRoot) {
|
||||
// We are inside preview page where all children blocks are listed.
|
||||
reloadPreviewPage();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$wrapper.on('click', '.library-block-migrate-btn', function(e) {
|
||||
e.preventDefault();
|
||||
// migrate library content block to item bank block
|
||||
runtime.notify('save', {
|
||||
state: 'start',
|
||||
element: element,
|
||||
message: gettext('Migrating to Problem Bank')
|
||||
});
|
||||
$.post(runtime.handlerUrl(element, 'upgrade_to_v2_library')).done(function() {
|
||||
runtime.notify('save', {
|
||||
state: 'end',
|
||||
element: element
|
||||
});
|
||||
if (isRoot) {
|
||||
// We are inside preview page where all children blocks are listed.
|
||||
reloadPreviewPage();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Hide loader and show element when update task finished.
|
||||
var $loader = $wrapper.find('.ui-loading');
|
||||
var $xblockHeader = $wrapper.find('.xblock-header');
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import logging
|
||||
import random
|
||||
from copy import copy
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.functional import classproperty
|
||||
from lxml import etree
|
||||
@@ -24,13 +25,13 @@ from xmodule.mako_block import MakoTemplateBlockBase
|
||||
from xmodule.studio_editable import StudioEditableBlock
|
||||
from xmodule.util.builtin_assets import add_webpack_js_to_fragment
|
||||
from xmodule.validation import StudioValidation, StudioValidationMessage
|
||||
from xmodule.xml_block import XmlMixin
|
||||
from xmodule.x_module import (
|
||||
STUDENT_VIEW,
|
||||
ResourceTemplates,
|
||||
XModuleMixin,
|
||||
shim_xmodule_js,
|
||||
STUDENT_VIEW,
|
||||
)
|
||||
from xmodule.xml_block import XmlMixin
|
||||
|
||||
_ = lambda text: text
|
||||
|
||||
@@ -268,20 +269,6 @@ class ItemBankMixin(
|
||||
|
||||
return self.selected
|
||||
|
||||
def format_block_keys_for_analytics(self, block_keys: list[tuple[str, str]]) -> list[dict]:
|
||||
"""
|
||||
Given a list of (block_type, block_id) pairs, prepare the JSON-ready metadata needed for analytics logging.
|
||||
|
||||
This is [
|
||||
{"usage_key": x, "original_usage_key": y, "original_usage_version": z, "descendants": [...]}
|
||||
]
|
||||
where the main list contains all top-level blocks, and descendants contains a *flat* list of all
|
||||
descendants of the top level blocks, if any.
|
||||
|
||||
Must be implemented in child class.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@XBlock.handler
|
||||
def reset_selected_children(self, _, __):
|
||||
"""
|
||||
@@ -432,6 +419,40 @@ class ItemBankMixin(
|
||||
xml_object.set(field_name, str(field.read_from(self)))
|
||||
return xml_object
|
||||
|
||||
def author_view(self, context):
|
||||
"""
|
||||
Renders the Studio views.
|
||||
Normal studio view: If block is properly configured, displays library status summary
|
||||
Studio container view: displays a preview of all possible children.
|
||||
"""
|
||||
fragment = Fragment()
|
||||
root_xblock = context.get('root_xblock')
|
||||
is_root = root_xblock and root_xblock.usage_key == self.usage_key
|
||||
if is_root and self.children:
|
||||
# User has clicked the "View" link. Show a preview of all possible children:
|
||||
context['can_edit_visibility'] = False
|
||||
context['can_move'] = False
|
||||
context['can_collapse'] = True
|
||||
self.render_children(context, fragment, can_reorder=False, can_add=False)
|
||||
else:
|
||||
# We're just on the regular unit page, or we're on the "view" page but no children exist yet.
|
||||
# Show a summary message and instructions.
|
||||
summary_html = loader.render_django_template('templates/item_bank/author_view.html', {
|
||||
# Due to template interpolation limitations, we have to pass some HTML for the link here:
|
||||
"view_link": f'<a target="_top" href="/container/{self.usage_key}">',
|
||||
"blocks": [
|
||||
{"display_name": display_name_with_default(child)}
|
||||
for child in self.get_children()
|
||||
],
|
||||
"block_count": len(self.children),
|
||||
"max_count": self.max_count,
|
||||
})
|
||||
fragment.add_content(summary_html)
|
||||
# Whether on the main author view or the detailed children view, show a button to add more from the library:
|
||||
add_html = loader.render_django_template('templates/item_bank/author_view_add.html', {})
|
||||
fragment.add_content(add_html)
|
||||
return fragment
|
||||
|
||||
@classmethod
|
||||
def get_selected_event_prefix(cls) -> str:
|
||||
"""
|
||||
@@ -442,21 +463,6 @@ class ItemBankMixin(
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ItemBankBlock(ItemBankMixin, XBlock):
|
||||
"""
|
||||
An XBlock which shows a random subset of its children to each learner.
|
||||
|
||||
Unlike LegacyLibraryContentBlock, this block does not need to worry about synchronization, capa_type filtering, etc.
|
||||
That is all implemented using `upstream` links on each individual child.
|
||||
"""
|
||||
display_name = String(
|
||||
display_name=_("Display Name"),
|
||||
help=_("The display name for this component."),
|
||||
default="Problem Bank",
|
||||
scope=Scope.settings,
|
||||
)
|
||||
|
||||
def validate(self):
|
||||
"""
|
||||
Validates the state of this ItemBankBlock Instance.
|
||||
@@ -492,40 +498,6 @@ class ItemBankBlock(ItemBankMixin, XBlock):
|
||||
)
|
||||
return validation
|
||||
|
||||
def author_view(self, context):
|
||||
"""
|
||||
Renders the Studio views.
|
||||
Normal studio view: If block is properly configured, displays library status summary
|
||||
Studio container view: displays a preview of all possible children.
|
||||
"""
|
||||
fragment = Fragment()
|
||||
root_xblock = context.get('root_xblock')
|
||||
is_root = root_xblock and root_xblock.usage_key == self.usage_key
|
||||
if is_root and self.children:
|
||||
# User has clicked the "View" link. Show a preview of all possible children:
|
||||
context['can_edit_visibility'] = False
|
||||
context['can_move'] = False
|
||||
context['can_collapse'] = True
|
||||
self.render_children(context, fragment, can_reorder=False, can_add=False)
|
||||
else:
|
||||
# We're just on the regular unit page, or we're on the "view" page but no children exist yet.
|
||||
# Show a summary message and instructions.
|
||||
summary_html = loader.render_django_template('templates/item_bank/author_view.html', {
|
||||
# Due to template interpolation limitations, we have to pass some HTML for the link here:
|
||||
"view_link": f'<a target="_top" href="/container/{self.usage_key}">',
|
||||
"blocks": [
|
||||
{"display_name": display_name_with_default(child)}
|
||||
for child in self.get_children()
|
||||
],
|
||||
"block_count": len(self.children),
|
||||
"max_count": self.max_count,
|
||||
})
|
||||
fragment.add_content(summary_html)
|
||||
# Whether on the main author view or the detailed children view, show a button to add more from the library:
|
||||
add_html = loader.render_django_template('templates/item_bank/author_view_add.html', {})
|
||||
fragment.add_content(add_html)
|
||||
return fragment
|
||||
|
||||
def format_block_keys_for_analytics(self, block_keys: list[tuple[str, str]]) -> list[dict]:
|
||||
"""
|
||||
Implement format_block_keys_for_analytics using the `upstream` link system.
|
||||
@@ -541,6 +513,21 @@ class ItemBankBlock(ItemBankMixin, XBlock):
|
||||
} for block_key in block_keys
|
||||
]
|
||||
|
||||
|
||||
class ItemBankBlock(ItemBankMixin, XBlock):
|
||||
"""
|
||||
An XBlock which shows a random subset of its children to each learner.
|
||||
|
||||
Unlike LegacyLibraryContentBlock, this block does not need to worry about synchronization, capa_type filtering, etc.
|
||||
That is all implemented using `upstream` links on each individual child.
|
||||
"""
|
||||
display_name = String(
|
||||
display_name=_("Display Name"),
|
||||
help=_("The display name for this component."),
|
||||
default="Problem Bank",
|
||||
scope=Scope.settings,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_selected_event_prefix(cls) -> str:
|
||||
"""
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from gettext import ngettext, gettext
|
||||
from gettext import gettext, ngettext
|
||||
|
||||
import nh3
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
@@ -20,11 +20,12 @@ from opaque_keys.edx.locator import LibraryLocator
|
||||
from web_fragments.fragment import Fragment
|
||||
from webob import Response
|
||||
from xblock.core import XBlock
|
||||
from xblock.fields import Integer, Scope, String
|
||||
from xblock.fields import Boolean, Scope, String
|
||||
|
||||
from xmodule.capa.responsetypes import registry
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.item_bank_block import ItemBankMixin
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.validation import StudioValidation, StudioValidationMessage
|
||||
from xmodule.x_module import XModuleToXBlockMixin
|
||||
|
||||
@@ -90,12 +91,6 @@ class LegacyLibraryContentBlock(ItemBankMixin, XModuleToXBlockMixin, XBlock):
|
||||
display_name=_("Library Version"),
|
||||
scope=Scope.settings,
|
||||
)
|
||||
max_count = Integer(
|
||||
display_name=_("Count"),
|
||||
help=_("Enter the number of components to display to each student. Set it to -1 to display all components."),
|
||||
default=1,
|
||||
scope=Scope.settings,
|
||||
)
|
||||
capa_type = String(
|
||||
display_name=_("Problem Type"),
|
||||
help=_('Choose a problem type to fetch from the library. If "Any Type" is selected no filtering is applied.'),
|
||||
@@ -103,6 +98,17 @@ class LegacyLibraryContentBlock(ItemBankMixin, XModuleToXBlockMixin, XBlock):
|
||||
values=_get_capa_types(),
|
||||
scope=Scope.settings,
|
||||
)
|
||||
# This is a hidden field that stores whether child blocks are migrated to v2, i.e., whether they have an upstream.
|
||||
# We can never completely remove the legacy library_content block; otherwise, we'd lose student data,
|
||||
# (such as selected fields), which tracks the children selected for each user.
|
||||
# However, once all legacy libraries are migrated to v2 and removed, this block can be converted into a very thin
|
||||
# compatibility wrapper around ItemBankBlock. All other aspects of LegacyLibraryContentBlock (the editor, the child
|
||||
# viewer, the block picker, the legacy syncing mechanism, etc.) can then be removed.
|
||||
is_migrated_to_v2 = Boolean(
|
||||
display_name=_("Is Migrated to library v2"),
|
||||
scope=Scope.settings,
|
||||
default=False,
|
||||
)
|
||||
|
||||
@property
|
||||
def source_library_key(self):
|
||||
@@ -111,12 +117,35 @@ class LegacyLibraryContentBlock(ItemBankMixin, XModuleToXBlockMixin, XBlock):
|
||||
"""
|
||||
return LibraryLocator.from_string(self.source_library_id)
|
||||
|
||||
@property
|
||||
def is_source_lib_migrated_to_v2(self):
|
||||
"""
|
||||
Determines whether the source library has been migrated to v2.
|
||||
"""
|
||||
from cms.djangoapps.modulestore_migrator.api import is_successfully_migrated
|
||||
|
||||
return (
|
||||
self.source_library_id
|
||||
and self.source_library_version
|
||||
and is_successfully_migrated(self.source_library_key, source_version=self.source_library_version)
|
||||
)
|
||||
|
||||
@property
|
||||
def is_ready_to_migrated_to_v2(self):
|
||||
"""
|
||||
Returns whether the block can be migrated to v2.
|
||||
"""
|
||||
return self.is_source_lib_migrated_to_v2 and not self.is_migrated_to_v2
|
||||
|
||||
def author_view(self, context):
|
||||
"""
|
||||
Renders the Studio views.
|
||||
Normal studio view: If block is properly configured, displays library status summary
|
||||
Studio container view: displays a preview of all possible children.
|
||||
"""
|
||||
if self.is_migrated_to_v2:
|
||||
# Show ItemBank UI in this case
|
||||
return super().author_view(context)
|
||||
fragment = Fragment()
|
||||
root_xblock = context.get('root_xblock')
|
||||
is_root = root_xblock and root_xblock.location == self.location
|
||||
@@ -151,7 +180,7 @@ class LegacyLibraryContentBlock(ItemBankMixin, XModuleToXBlockMixin, XBlock):
|
||||
else:
|
||||
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_content_edit.js'))
|
||||
|
||||
fragment.initialize_js('LibraryContentAuthorView')
|
||||
fragment.initialize_js('LibraryContentAuthorView', {"is_root": is_root})
|
||||
return fragment
|
||||
|
||||
@property
|
||||
@@ -159,7 +188,14 @@ class LegacyLibraryContentBlock(ItemBankMixin, XModuleToXBlockMixin, XBlock):
|
||||
non_editable_fields = super().non_editable_metadata_fields
|
||||
non_editable_fields.extend([
|
||||
LegacyLibraryContentBlock.source_library_version,
|
||||
LegacyLibraryContentBlock.is_migrated_to_v2,
|
||||
])
|
||||
if self.is_migrated_to_v2:
|
||||
# If the block is migrated, hide legacy settings to make it similar to the new ItemBankBlock.
|
||||
non_editable_fields.extend([
|
||||
LegacyLibraryContentBlock.capa_type,
|
||||
LegacyLibraryContentBlock.source_library_id,
|
||||
])
|
||||
return non_editable_fields
|
||||
|
||||
def get_tools(self, to_read_library_content: bool = False) -> 'LegacyLibraryToolsService':
|
||||
@@ -195,6 +231,12 @@ class LegacyLibraryContentBlock(ItemBankMixin, XModuleToXBlockMixin, XBlock):
|
||||
Returns 400 if libraray tools or user permission services are not available.
|
||||
Returns 403/404 if user lacks read access on source library or write access on this block.
|
||||
"""
|
||||
if self.is_migrated_to_v2:
|
||||
# If the block is already migrated to behave like ItemBankBlock
|
||||
return Response(
|
||||
_("This block is already migrated to use library v2. You can sync individual blocks now"),
|
||||
status=400
|
||||
)
|
||||
self._validate_sync_permissions()
|
||||
if not self.source_library_id:
|
||||
return Response(_("Source content library has not been specified."), status=400)
|
||||
@@ -246,6 +288,10 @@ class LegacyLibraryContentBlock(ItemBankMixin, XModuleToXBlockMixin, XBlock):
|
||||
if hasattr(super(), 'studio_post_duplicate'):
|
||||
super().studio_post_duplicate(store, source_block)
|
||||
|
||||
if self.is_migrated_to_v2:
|
||||
# If the block is already migrated to behave like ItemBankBlock
|
||||
return False # Children have not been handled
|
||||
|
||||
self._validate_sync_permissions()
|
||||
self.get_tools(to_read_library_content=True).trigger_duplication(source_block=source_block, dest_block=self)
|
||||
return True # Children have been handled.
|
||||
@@ -257,14 +303,57 @@ class LegacyLibraryContentBlock(ItemBankMixin, XModuleToXBlockMixin, XBlock):
|
||||
if hasattr(super(), 'studio_post_paste'):
|
||||
super().studio_post_paste(store, source_node)
|
||||
|
||||
if self.is_migrated_to_v2:
|
||||
# If the block is already migrated to behave like ItemBankBlock
|
||||
return False # Children have not been handled
|
||||
|
||||
self.sync_from_library(upgrade_to_latest=False)
|
||||
return True # Children have been handled
|
||||
|
||||
def _v2_update_children_upstream_version(self):
|
||||
"""
|
||||
Update the upstream and upstream version fields of all children to point to library v2 version of the legacy
|
||||
library blocks. This essentially converts this legacy block to new ItemBankBlock.
|
||||
"""
|
||||
from cms.djangoapps.modulestore_migrator.api import get_target_block_usage_keys
|
||||
blocks = get_target_block_usage_keys(self.source_library_key)
|
||||
store = modulestore()
|
||||
with store.bulk_operations(self.course_id):
|
||||
for child in self.get_children():
|
||||
source_key, _ = self.runtime.modulestore.get_block_original_usage(child.usage_key)
|
||||
child.upstream = str(blocks.get(source_key, ""))
|
||||
# Since after migration, the component in library is in draft state, we want to make sure that sync icon
|
||||
# appears when it is published
|
||||
child.upstream_version = 0
|
||||
child.save()
|
||||
# Use `modulestore()` instead of `self.runtime.modulestore` to make sure that the XBLOCK_UPDATED signal
|
||||
# is triggered
|
||||
store.update_item(child, None)
|
||||
self.is_migrated_to_v2 = True
|
||||
self.save()
|
||||
store.update_item(self, None)
|
||||
|
||||
def _validate_library_version(self, validation, lib_tools, version, library_key):
|
||||
"""
|
||||
Validates library version
|
||||
"""
|
||||
latest_version = lib_tools.get_latest_library_version(library_key)
|
||||
if self.is_ready_to_migrated_to_v2:
|
||||
validation.set_summary(
|
||||
StudioValidationMessage(
|
||||
StudioValidationMessage.WARNING,
|
||||
_(
|
||||
'This legacy library reference is no longer supported, and'
|
||||
' needs to be updated to receive future changes'
|
||||
),
|
||||
# TODO: change this to action_runtime_event='...' once the unit page supports that feature.
|
||||
# See https://openedx.atlassian.net/browse/TNL-993
|
||||
action_class='library-block-migrate-btn',
|
||||
# Translators: {refresh_icon} placeholder is substituted to "↻" (without double quotes)
|
||||
action_label=_('{refresh_icon} Update reference').format(refresh_icon='↻'),
|
||||
)
|
||||
)
|
||||
return False
|
||||
if latest_version is not None:
|
||||
if version is None or version != latest_version:
|
||||
validation.set_summary(
|
||||
@@ -296,13 +385,33 @@ class LegacyLibraryContentBlock(ItemBankMixin, XModuleToXBlockMixin, XBlock):
|
||||
if validation.empty:
|
||||
validation.set_summary(summary)
|
||||
|
||||
@XBlock.handler
|
||||
def upgrade_to_v2_library(self, request=None, suffix=None):
|
||||
"""
|
||||
Upgrate this legacy block to a mode where it behaves like the new ItemBankBlock which uses library v2 blocks as
|
||||
children.
|
||||
"""
|
||||
if not self.is_source_lib_migrated_to_v2:
|
||||
return Response(_("The source library has not been migrated to version 2"), status=400)
|
||||
if self.is_migrated_to_v2:
|
||||
return Response(_("The block has already been upgraded to version 2"), status=400)
|
||||
# If the source library is migrated but this block still depends on legacy library
|
||||
# Migrate the block by setting upstream field to all children blocks
|
||||
self._v2_update_children_upstream_version()
|
||||
return Response()
|
||||
|
||||
def validate(self):
|
||||
"""
|
||||
Validates the state of this Library Content Block Instance. This
|
||||
is the override of the general XBlock method, and it will also ask
|
||||
its superclass to validate.
|
||||
Validates the state of this Library Content Block Instance.
|
||||
"""
|
||||
validation = super().validate()
|
||||
if self.is_migrated_to_v2:
|
||||
# If the block is already migrated to v2 i.e. ItemBankBlock
|
||||
# super() will call ItemBankMixin.validate() as it is first in inheritance order
|
||||
return super().validate()
|
||||
|
||||
# We cannot use `super()` here because we do not want to invoke `ItemBankMixin.validate()`.
|
||||
# Instead, we want to use `XBlock.validate`.
|
||||
validation = XBlock.validate(self)
|
||||
if not isinstance(validation, StudioValidation):
|
||||
validation = StudioValidation.copy(validation)
|
||||
try:
|
||||
@@ -328,7 +437,12 @@ class LegacyLibraryContentBlock(ItemBankMixin, XModuleToXBlockMixin, XBlock):
|
||||
)
|
||||
)
|
||||
return validation
|
||||
self._validate_library_version(validation, lib_tools, self.source_library_version, self.source_library_key)
|
||||
self._validate_library_version(
|
||||
validation,
|
||||
lib_tools,
|
||||
self.source_library_version,
|
||||
self.source_library_key
|
||||
)
|
||||
|
||||
# Note: we assume children have been synced
|
||||
# since the last time fields like source_library_id or capa_types were changed.
|
||||
@@ -390,6 +504,9 @@ class LegacyLibraryContentBlock(ItemBankMixin, XModuleToXBlockMixin, XBlock):
|
||||
"""
|
||||
If source library or capa_type have been edited, upgrade library & sync automatically.
|
||||
"""
|
||||
if self.is_migrated_to_v2:
|
||||
# If the block is already migrated to v2 i.e. ItemBankBlock, Do nothing
|
||||
return True
|
||||
source_lib_changed = (self.source_library_id != old_metadata.get("source_library_id", ""))
|
||||
capa_filter_changed = (self.capa_type != old_metadata.get("capa_type", ANY_CAPA_TYPE_VALUE))
|
||||
if source_lib_changed or capa_filter_changed:
|
||||
@@ -403,6 +520,9 @@ class LegacyLibraryContentBlock(ItemBankMixin, XModuleToXBlockMixin, XBlock):
|
||||
"""
|
||||
Implement format_block_keys_for_analytics using the modulestore-specific legacy library original-usage system.
|
||||
"""
|
||||
if self.is_migrated_to_v2:
|
||||
return super().format_block_keys_for_analytics(block_keys)
|
||||
|
||||
def summarize_block(usage_key):
|
||||
""" Basic information about the given block """
|
||||
orig_key, orig_version = self.runtime.modulestore.get_block_original_usage(usage_key)
|
||||
|
||||
@@ -7,13 +7,17 @@ import ddt
|
||||
from bson.objectid import ObjectId
|
||||
from fs.memoryfs import MemoryFS
|
||||
from lxml import etree
|
||||
from opaque_keys.edx.locator import LibraryLocator
|
||||
from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2
|
||||
from organizations.tests.factories import OrganizationFactory
|
||||
from rest_framework import status
|
||||
from search.search_engine_base import SearchEngine
|
||||
from web_fragments.fragment import Fragment
|
||||
from xblock.runtime import Runtime as VanillaRuntime
|
||||
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from openedx.core.djangoapps.content_libraries import api as lib_api
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_cms
|
||||
from xmodule.capa_block import ProblemBlock
|
||||
from xmodule.library_content_block import ANY_CAPA_TYPE_VALUE, LegacyLibraryContentBlock
|
||||
from xmodule.library_tools import LegacyLibraryToolsService
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
@@ -22,8 +26,6 @@ from xmodule.modulestore.tests.utils import MixedSplitTestCase
|
||||
from xmodule.tests import prepare_block_runtime
|
||||
from xmodule.validation import StudioValidationMessage
|
||||
from xmodule.x_module import AUTHOR_VIEW
|
||||
from xmodule.capa_block import ProblemBlock
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
|
||||
from .test_course_block import DummySystem as TestImportSystem
|
||||
|
||||
@@ -42,7 +44,12 @@ class LegacyLibraryContentTest(MixedSplitTestCase):
|
||||
self.tools = LegacyLibraryToolsService(self.store, self.user_id)
|
||||
self.library = LibraryFactory.create(modulestore=self.store)
|
||||
self.lib_blocks = [
|
||||
self.make_block("html", self.library, data=f"Hello world from block {i}")
|
||||
self.make_block(
|
||||
"html",
|
||||
self.library,
|
||||
data=f"Hello world from block {i}",
|
||||
display_name=f"html {i}"
|
||||
)
|
||||
for i in range(1, 5)
|
||||
]
|
||||
self.course = CourseFactory.create(modulestore=self.store)
|
||||
@@ -84,6 +91,18 @@ class LegacyLibraryContentTest(MixedSplitTestCase):
|
||||
|
||||
block.runtime.get_block_for_descriptor = get_block
|
||||
|
||||
def _verify_xblock_properties(self, imported_lc_block):
|
||||
"""
|
||||
Check the new XBlock has the same properties as the old one.
|
||||
"""
|
||||
assert imported_lc_block.display_name == self.lc_block.display_name
|
||||
assert imported_lc_block.source_library_id == self.lc_block.source_library_id
|
||||
assert imported_lc_block.source_library_version == self.lc_block.source_library_version
|
||||
assert imported_lc_block.max_count == self.lc_block.max_count
|
||||
assert imported_lc_block.capa_type == self.lc_block.capa_type
|
||||
assert len(imported_lc_block.children) == len(self.lc_block.children)
|
||||
assert imported_lc_block.children == self.lc_block.children
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class LegacyLibraryContentGeneralTest(LegacyLibraryContentTest):
|
||||
@@ -133,15 +152,14 @@ class TestLibraryContentExportImport(LegacyLibraryContentTest):
|
||||
self._sync_lc_block_from_library()
|
||||
|
||||
self.expected_olx = (
|
||||
'<library_content display_name="{block.display_name}" max_count="{block.max_count}"'
|
||||
' source_library_id="{block.source_library_id}" source_library_version="{block.source_library_version}">\n'
|
||||
' <html url_name="{block.children[0].block_id}"/>\n'
|
||||
' <html url_name="{block.children[1].block_id}"/>\n'
|
||||
' <html url_name="{block.children[2].block_id}"/>\n'
|
||||
' <html url_name="{block.children[3].block_id}"/>\n'
|
||||
f'<library_content display_name="{self.lc_block.display_name}" max_count="{self.lc_block.max_count}"'
|
||||
f' source_library_id="{self.lc_block.source_library_id}" '
|
||||
f'source_library_version="{self.lc_block.source_library_version}">\n'
|
||||
f' <html url_name="{self.lc_block.children[0].block_id}"/>\n'
|
||||
f' <html url_name="{self.lc_block.children[1].block_id}"/>\n'
|
||||
f' <html url_name="{self.lc_block.children[2].block_id}"/>\n'
|
||||
f' <html url_name="{self.lc_block.children[3].block_id}"/>\n'
|
||||
'</library_content>\n'
|
||||
).format(
|
||||
block=self.lc_block,
|
||||
)
|
||||
|
||||
# Set the virtual FS to export the olx to.
|
||||
@@ -157,27 +175,13 @@ class TestLibraryContentExportImport(LegacyLibraryContentTest):
|
||||
node = etree.Element("unknown_root")
|
||||
self.lc_block.add_xml_to_node(node)
|
||||
|
||||
def _verify_xblock_properties(self, imported_lc_block):
|
||||
"""
|
||||
Check the new XBlock has the same properties as the old one.
|
||||
"""
|
||||
assert imported_lc_block.display_name == self.lc_block.display_name
|
||||
assert imported_lc_block.source_library_id == self.lc_block.source_library_id
|
||||
assert imported_lc_block.source_library_version == self.lc_block.source_library_version
|
||||
assert imported_lc_block.max_count == self.lc_block.max_count
|
||||
assert imported_lc_block.capa_type == self.lc_block.capa_type
|
||||
assert len(imported_lc_block.children) == len(self.lc_block.children)
|
||||
assert imported_lc_block.children == self.lc_block.children
|
||||
|
||||
def test_xml_export_import_cycle(self):
|
||||
"""
|
||||
Test the export-import cycle.
|
||||
"""
|
||||
# Read back the olx.
|
||||
with self.export_fs.open('{dir}/{file_name}.xml'.format(
|
||||
dir=self.lc_block.scope_ids.usage_id.block_type,
|
||||
file_name=self.lc_block.scope_ids.usage_id.block_id
|
||||
)) as f:
|
||||
file_path = f'{self.lc_block.scope_ids.usage_id.block_type}/{self.lc_block.scope_ids.usage_id.block_id}.xml'
|
||||
with self.export_fs.open(file_path) as f:
|
||||
exported_olx = f.read()
|
||||
|
||||
# And compare.
|
||||
@@ -195,16 +199,15 @@ class TestLibraryContentExportImport(LegacyLibraryContentTest):
|
||||
"""
|
||||
olx_with_comments = (
|
||||
'<!-- Comment -->\n'
|
||||
'<library_content display_name="{block.display_name}" max_count="{block.max_count}"'
|
||||
' source_library_id="{block.source_library_id}" source_library_version="{block.source_library_version}">\n'
|
||||
f'<library_content display_name="{self.lc_block.display_name}" max_count="{self.lc_block.max_count}"'
|
||||
f' source_library_id="{self.lc_block.source_library_id}" '
|
||||
f'source_library_version="{self.lc_block.source_library_version}">\n'
|
||||
'<!-- Comment -->\n'
|
||||
' <html url_name="{block.children[0].block_id}"/>\n'
|
||||
' <html url_name="{block.children[1].block_id}"/>\n'
|
||||
' <html url_name="{block.children[2].block_id}"/>\n'
|
||||
' <html url_name="{block.children[3].block_id}"/>\n'
|
||||
f' <html url_name="{self.lc_block.children[0].block_id}"/>\n'
|
||||
f' <html url_name="{self.lc_block.children[1].block_id}"/>\n'
|
||||
f' <html url_name="{self.lc_block.children[2].block_id}"/>\n'
|
||||
f' <html url_name="{self.lc_block.children[3].block_id}"/>\n'
|
||||
'</library_content>\n'
|
||||
).format(
|
||||
block=self.lc_block,
|
||||
)
|
||||
|
||||
# Import the olx.
|
||||
@@ -234,7 +237,7 @@ class LegacyLibraryContentBlockTestMixin:
|
||||
""" Helper function to create empty CAPA problem definition """
|
||||
problem = "<problem>"
|
||||
for problem_type in args:
|
||||
problem += "<{problem_type}></{problem_type}>".format(problem_type=problem_type)
|
||||
problem += f"<{problem_type}></{problem_type}>"
|
||||
problem += "</problem>"
|
||||
return problem
|
||||
|
||||
@@ -500,7 +503,7 @@ class TestLegacyLibraryContentBlockWithSearchIndex(LegacyLibraryContentBlockTest
|
||||
|
||||
def _get_search_response(self, field_dictionary=None):
|
||||
""" Mocks search response as returned by search engine """
|
||||
target_type = field_dictionary.get('problem_types')
|
||||
target_type = (field_dictionary or {}).get('problem_types')
|
||||
matched_block_locations = [
|
||||
key for key, problem_types in
|
||||
self.problem_type_lookup.items() if target_type in problem_types
|
||||
@@ -726,3 +729,110 @@ class TestLibraryContentAnalytics(LegacyLibraryContentTest):
|
||||
'original_usage_key': str(keep_block_lib_usage_key),
|
||||
'original_usage_version': str(keep_block_lib_version), 'descendants': []}]
|
||||
assert event_data['reason'] == 'invalid'
|
||||
|
||||
|
||||
@patch(
|
||||
'xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render
|
||||
)
|
||||
@patch('xmodule.html_block.HtmlBlock.author_view', dummy_render, create=True)
|
||||
@patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: [])
|
||||
class TestMigratedLibraryContentRender(LegacyLibraryContentTest):
|
||||
"""
|
||||
Rendering unit tests for LegacyLibraryContentBlock
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
from cms.djangoapps.modulestore_migrator import api
|
||||
from cms.djangoapps.modulestore_migrator.data import CompositionLevel, RepeatHandlingStrategy
|
||||
super().setUp()
|
||||
user = UserFactory()
|
||||
self._sync_lc_block_from_library()
|
||||
self.organization = OrganizationFactory()
|
||||
self.lib_key_v2 = LibraryLocatorV2.from_string(
|
||||
f"lib:{self.organization.short_name}:test-key"
|
||||
)
|
||||
lib_api.create_library(
|
||||
org=self.organization,
|
||||
slug=self.lib_key_v2.slug,
|
||||
title="Test Library",
|
||||
)
|
||||
self.library_v2 = lib_api.ContentLibrary.objects.get(slug=self.lib_key_v2.slug)
|
||||
api.start_migration_to_library(
|
||||
user=user,
|
||||
source_key=self.library.location.library_key,
|
||||
target_library_key=self.library_v2.library_key,
|
||||
target_collection_slug=None,
|
||||
composition_level=CompositionLevel.Component.value,
|
||||
repeat_handling_strategy=RepeatHandlingStrategy.Skip.value,
|
||||
preserve_url_slugs=True,
|
||||
forward_source_to_target=True,
|
||||
)
|
||||
# Migrate block
|
||||
self.lc_block.upgrade_to_v2_library(None, None)
|
||||
|
||||
def test_preview_view(self):
|
||||
""" Test preview view rendering """
|
||||
assert len(self.lc_block.children) == len(self.lib_blocks)
|
||||
self._bind_course_block(self.lc_block)
|
||||
rendered = self.lc_block.render(AUTHOR_VIEW, {'root_xblock': self.lc_block})
|
||||
assert 'Hello world from block 1' in rendered.content
|
||||
assert 'Hello world from block 2' in rendered.content
|
||||
assert 'Hello world from block 3' in rendered.content
|
||||
assert 'Hello world from block 4' in rendered.content
|
||||
|
||||
def test_author_view(self):
|
||||
""" Test author view rendering """
|
||||
assert len(self.lc_block.children) == len(self.lib_blocks)
|
||||
self._bind_course_block(self.lc_block)
|
||||
rendered = self.lc_block.render(AUTHOR_VIEW, {})
|
||||
# content should be similar to ItemBankBlock
|
||||
assert 'Learners will see 1 of the 4 selected components' in rendered.content
|
||||
assert '<li>html 1</li>' in rendered.content
|
||||
assert '<li>html 2</li>' in rendered.content
|
||||
assert '<li>html 3</li>' in rendered.content
|
||||
assert '<li>html 4</li>' in rendered.content
|
||||
|
||||
def test_xml_export_import_cycle(self):
|
||||
"""
|
||||
Test the export-import cycle.
|
||||
"""
|
||||
# Render block to migrate it first
|
||||
self.lc_block.render(AUTHOR_VIEW, {})
|
||||
# Set the virtual FS to export the olx to.
|
||||
export_fs = MemoryFS()
|
||||
self.lc_block.runtime.export_fs = export_fs # pylint: disable=protected-access
|
||||
|
||||
# Export the olx.
|
||||
node = etree.Element("unknown_root")
|
||||
self.lc_block.add_xml_to_node(node)
|
||||
|
||||
# Read back the olx.
|
||||
file_path = f'{self.lc_block.scope_ids.usage_id.block_type}/{self.lc_block.scope_ids.usage_id.block_id}.xml'
|
||||
with export_fs.open(file_path) as f:
|
||||
exported_olx = f.read()
|
||||
|
||||
expected_olx_export = (
|
||||
f'<library_content display_name="{self.lc_block.display_name}" is_migrated_to_v2="true"'
|
||||
f' max_count="{self.lc_block.max_count}" source_library_id="{self.lc_block.source_library_id}" '
|
||||
f'source_library_version="{self.lc_block.source_library_version}">\n'
|
||||
f' <html url_name="{self.lc_block.children[0].block_id}"/>\n'
|
||||
f' <html url_name="{self.lc_block.children[1].block_id}"/>\n'
|
||||
f' <html url_name="{self.lc_block.children[2].block_id}"/>\n'
|
||||
f' <html url_name="{self.lc_block.children[3].block_id}"/>\n'
|
||||
'</library_content>\n'
|
||||
)
|
||||
# And compare.
|
||||
assert exported_olx == expected_olx_export
|
||||
|
||||
# Now import it.
|
||||
runtime = TestImportSystem(load_error_blocks=True, course_id=self.lc_block.location.course_key)
|
||||
runtime.resources_fs = export_fs
|
||||
olx_element = etree.fromstring(exported_olx)
|
||||
imported_lc_block = LegacyLibraryContentBlock.parse_xml(olx_element, runtime, None)
|
||||
|
||||
self._verify_xblock_properties(imported_lc_block)
|
||||
# Verify migration info in the child
|
||||
assert imported_lc_block.is_migrated_to_v2
|
||||
for child in imported_lc_block.get_children():
|
||||
assert child.xml_attributes.get('upstream') is not None
|
||||
assert str(child.xml_attributes.get('upstream_version')) == '0'
|
||||
|
||||
Reference in New Issue
Block a user