feat: provisionally support V2 libraries in LibraryContentBlock (randomized only) (#33263)
Refactors and reworks the LibraryContentBlock so that its sync-from-library operations are asynchronous and work with V2 content libraries. This also required us to make library_content block duplication asynchronous, as that involves syncing from the source library. For the sake of clarity, this PR includes two major method renames: * update_children(...) -> sync_from_library(...) * refresh_library(...) -> sync_from_library(upgrade_to_latest=True, ...) an an XBlock HTTP handler rename: /refresh_children -> /upgrade_and_sync There are still a couple issues with import or duplication of library_content blocks referencing V2 libraries other than latest. These will be resolved in an upcoming PR. Part of: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3820617729/Spec+Memo+Content+Library+Authoring+Experience+V2 Follow-up work: https://github.com/openedx/edx-platform/issues/33640 Co-authored-by: Connor Haugh <chaugh@2u.com> Co-authored-by: Eugene Dyudyunov <evgen.dyudyunov@raccoongang.com>
This commit is contained in:
@@ -4,16 +4,18 @@ Basic unit tests for LibraryContentBlock
|
||||
Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`.
|
||||
"""
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
import ddt
|
||||
|
||||
import ddt
|
||||
from bson.objectid import ObjectId
|
||||
from fs.memoryfs import MemoryFS
|
||||
from lxml import etree
|
||||
from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2
|
||||
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 rest_framework import status
|
||||
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_cms
|
||||
from xmodule.library_content_block import ANY_CAPA_TYPE_VALUE, LibraryContentBlock
|
||||
from xmodule.library_tools import LibraryToolsService
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
@@ -23,12 +25,14 @@ 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
|
||||
|
||||
dummy_render = lambda block, _: Fragment(block.data) # pylint: disable=invalid-name
|
||||
|
||||
|
||||
@skip_unless_cms
|
||||
class LibraryContentTest(MixedSplitTestCase):
|
||||
"""
|
||||
Base class for tests of LibraryContentBlock (library_content_block.py)
|
||||
@@ -36,7 +40,7 @@ class LibraryContentTest(MixedSplitTestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.user_id = UserFactory().id
|
||||
self.tools = LibraryToolsService(self.store, self.user_id)
|
||||
self.library = LibraryFactory.create(modulestore=self.store)
|
||||
self.lib_blocks = [
|
||||
@@ -53,13 +57,25 @@ class LibraryContentTest(MixedSplitTestCase):
|
||||
max_count=1,
|
||||
source_library_id=str(self.library.location.library_key)
|
||||
)
|
||||
self.lc_block.runtime._services.update({'library_tools': self.tools}) # pylint: disable=protected-access
|
||||
|
||||
def _sync_lc_block_from_library(self, upgrade_to_latest=False):
|
||||
"""
|
||||
Save the lc_block, then sync its children with the library, and then re-load it.
|
||||
|
||||
We must re-load it because the syncing happens in a Celery task, so that original self.lc_block instance will
|
||||
not have changes manifested on it, but the re-loaded instance will.
|
||||
"""
|
||||
self.store.update_item(self.lc_block, self.user_id)
|
||||
self.lc_block.sync_from_library(upgrade_to_latest=upgrade_to_latest)
|
||||
self.lc_block = self.store.get_item(self.lc_block.location)
|
||||
|
||||
def _bind_course_block(self, block):
|
||||
"""
|
||||
Bind a block (part of self.course) so we can access student-specific data.
|
||||
"""
|
||||
prepare_block_runtime(block.runtime, course_id=block.location.course_key)
|
||||
block.runtime._services.update({'library_tools': self.tools}) # lint-amnesty, pylint: disable=protected-access
|
||||
block.runtime._services.update({'library_tools': self.tools}) # pylint: disable=protected-access
|
||||
|
||||
def get_block(descriptor):
|
||||
"""Mocks module_system get_block function"""
|
||||
@@ -71,16 +87,59 @@ class LibraryContentTest(MixedSplitTestCase):
|
||||
block.runtime.get_block_for_descriptor = get_block
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class LibraryContentGeneralTest(LibraryContentTest):
|
||||
"""
|
||||
Test the base functionality of the LibraryContentBlock.
|
||||
"""
|
||||
|
||||
@ddt.data(
|
||||
('library-v1:ProblemX+PR0B', LibraryLocator),
|
||||
('lib:ORG:test-1', LibraryLocatorV2)
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_source_library_key(self, library_key, expected_locator_type):
|
||||
"""
|
||||
Test the source_library_key property of the xblock.
|
||||
|
||||
The method should correctly work either with V1 or V2 libraries.
|
||||
"""
|
||||
library = self.make_block(
|
||||
"library_content",
|
||||
self.vertical,
|
||||
max_count=1,
|
||||
source_library_id=library_key
|
||||
)
|
||||
assert isinstance(library.source_library_key, expected_locator_type)
|
||||
|
||||
def test_initial_sync_from_library(self):
|
||||
"""
|
||||
Test that a lc block starts without children, but is correctly populated upon first sync.
|
||||
"""
|
||||
source_library_key = self.library.location.library_key
|
||||
|
||||
# Normally the children get added when the "source_libraries" setting
|
||||
# is updated, but the way we do it through a factory doesn't do that.
|
||||
assert self.lc_block.source_library_key == source_library_key
|
||||
assert self.lc_block.source_library_version is None
|
||||
assert len(self.lc_block.children) == 0
|
||||
|
||||
# Update the LibraryContent block's children:
|
||||
self._sync_lc_block_from_library()
|
||||
|
||||
# Check that all blocks from the library are now children of the block:
|
||||
assert self.lc_block.source_library_key == source_library_key # Unchanged
|
||||
assert self.lc_block.source_library_version == self.tools.get_latest_library_version(source_library_key)
|
||||
assert len(self.lc_block.children) == len(self.lib_blocks)
|
||||
|
||||
|
||||
class TestLibraryContentExportImport(LibraryContentTest):
|
||||
"""
|
||||
Export and import tests for LibraryContentBlock
|
||||
"""
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Children will only set after calling this.
|
||||
self.lc_block.refresh_children()
|
||||
self.lc_block = self.store.get_item(self.lc_block.location)
|
||||
self._sync_lc_block_from_library()
|
||||
|
||||
self.expected_olx = (
|
||||
'<library_content display_name="{block.display_name}" max_count="{block.max_count}"'
|
||||
@@ -177,6 +236,10 @@ class LibraryContentBlockTestMixin:
|
||||
|
||||
problem_type_lookup = {}
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self._sync_lc_block_from_library()
|
||||
|
||||
def _get_capa_problem_type_xml(self, *args):
|
||||
""" Helper function to create empty CAPA problem definition """
|
||||
problem = "<problem>"
|
||||
@@ -185,7 +248,7 @@ class LibraryContentBlockTestMixin:
|
||||
problem += "</problem>"
|
||||
return problem
|
||||
|
||||
def _create_capa_problems(self):
|
||||
def _add_problems_to_library(self):
|
||||
"""
|
||||
Helper function to create a set of capa problems to test against.
|
||||
|
||||
@@ -196,26 +259,10 @@ class LibraryContentBlockTestMixin:
|
||||
block = self.make_block("problem", self.library, data=self._get_capa_problem_type_xml(*problem_type))
|
||||
self.problem_type_lookup[block.location] = problem_type
|
||||
|
||||
def test_lib_content_block(self):
|
||||
"""
|
||||
Test that blocks from a library are copied and added as children
|
||||
"""
|
||||
# Check that the LibraryContent block has no children initially
|
||||
# Normally the children get added when the "source_libraries" setting
|
||||
# is updated, but the way we do it through a factory doesn't do that.
|
||||
assert len(self.lc_block.children) == 0
|
||||
# Update the LibraryContent block:
|
||||
self.lc_block.refresh_children()
|
||||
self.lc_block = self.store.get_item(self.lc_block.location)
|
||||
# Check that all blocks from the library are now children of the block:
|
||||
assert len(self.lc_block.children) == len(self.lib_blocks)
|
||||
|
||||
def test_children_seen_by_a_user(self):
|
||||
"""
|
||||
Test that each student sees only one block as a child of the LibraryContent block.
|
||||
"""
|
||||
self.lc_block.refresh_children()
|
||||
self.lc_block = self.store.get_item(self.lc_block.location)
|
||||
self._bind_course_block(self.lc_block)
|
||||
# Make sure the runtime knows that the block's children vary per-user:
|
||||
assert self.lc_block.has_dynamic_children()
|
||||
@@ -234,93 +281,113 @@ class LibraryContentBlockTestMixin:
|
||||
"""
|
||||
# When source_library_id is blank, the validation summary should say this block needs to be configured:
|
||||
self.lc_block.source_library_id = ""
|
||||
self.lc_block.source_library_version = None
|
||||
result = self.lc_block.validate()
|
||||
assert not result
|
||||
# Validation fails due to at least one warning/message
|
||||
assert result.summary
|
||||
assert StudioValidationMessage.NOT_CONFIGURED == result.summary.type
|
||||
|
||||
# When source_library_id references a non-existent library, we should get an error:
|
||||
self.lc_block.source_library_id = "library-v1:BAD+WOLF"
|
||||
self.lc_block.source_library_version = None
|
||||
result = self.lc_block.validate()
|
||||
assert not result
|
||||
# Validation fails due to at least one warning/message
|
||||
assert result.summary
|
||||
assert StudioValidationMessage.ERROR == result.summary.type
|
||||
assert 'invalid' in result.summary.text
|
||||
|
||||
# When source_library_id is set but the block needs to be updated, the summary should say so:
|
||||
# When source_library_id is set but the block hasn't been synced, the summary should say so:
|
||||
self.lc_block.source_library_id = str(self.library.location.library_key)
|
||||
self.lc_block.source_library_version = None
|
||||
result = self.lc_block.validate()
|
||||
assert not result
|
||||
# Validation fails due to at least one warning/message
|
||||
assert result.summary
|
||||
assert StudioValidationMessage.WARNING == result.summary.type
|
||||
assert 'out of date' in result.summary.text
|
||||
|
||||
# Now if we update the block, all validation should pass:
|
||||
self.lc_block.refresh_children()
|
||||
self._sync_lc_block_from_library()
|
||||
assert self.lc_block.validate()
|
||||
|
||||
# But updating the library will cause it to fail again as out-of-date:
|
||||
self._add_problems_to_library()
|
||||
result = self.lc_block.validate()
|
||||
assert not result
|
||||
assert result.summary
|
||||
assert StudioValidationMessage.WARNING == result.summary.type
|
||||
assert 'out of date' in result.summary.text
|
||||
|
||||
# And a regular sync will not fix that:
|
||||
self._sync_lc_block_from_library()
|
||||
result = self.lc_block.validate()
|
||||
assert not result
|
||||
assert result.summary
|
||||
assert StudioValidationMessage.WARNING == result.summary.type
|
||||
assert 'out of date' in result.summary.text
|
||||
|
||||
# But a upgrade_to_latest sync will:
|
||||
self._sync_lc_block_from_library(upgrade_to_latest=True)
|
||||
assert self.lc_block.validate()
|
||||
|
||||
def _assert_has_only_N_matching_problems(self, result, n):
|
||||
assert result.summary
|
||||
assert StudioValidationMessage.WARNING == result.summary.type
|
||||
assert f'only {n} matching problem' in result.summary.text
|
||||
|
||||
def test_validation_of_matching_blocks(self):
|
||||
"""
|
||||
Test that the validation method of LibraryContent blocks can warn
|
||||
the user about problems with other settings (max_count and capa_type).
|
||||
"""
|
||||
# Ensure we're starting wtih clean validation
|
||||
assert self.lc_block.validate()
|
||||
|
||||
# Set max_count to higher value than exists in library
|
||||
self.lc_block.max_count = 50
|
||||
# In the normal studio editing process, editor_saved() calls refresh_children at this point
|
||||
self.lc_block.refresh_children()
|
||||
result = self.lc_block.validate()
|
||||
assert not result
|
||||
# Validation fails due to at least one warning/message
|
||||
assert result.summary
|
||||
assert StudioValidationMessage.WARNING == result.summary.type
|
||||
assert 'only 4 matching problems' in result.summary.text
|
||||
self._assert_has_only_N_matching_problems(result, 4)
|
||||
assert len(self.lc_block.selected_children()) == 4
|
||||
|
||||
# Add some capa problems so we can check problem type validation messages
|
||||
self._add_problems_to_library()
|
||||
self._sync_lc_block_from_library(upgrade_to_latest=True)
|
||||
self.lc_block.max_count = 1
|
||||
self._create_capa_problems()
|
||||
self.lc_block.refresh_children()
|
||||
assert self.lc_block.validate()
|
||||
assert len(self.lc_block.selected_children()) == 1
|
||||
|
||||
# Existing problem type should pass validation
|
||||
self.lc_block.max_count = 1
|
||||
self.lc_block.capa_type = 'multiplechoiceresponse'
|
||||
self.lc_block.refresh_children()
|
||||
self._sync_lc_block_from_library()
|
||||
self.lc_block.max_count = 1
|
||||
assert self.lc_block.validate()
|
||||
assert len(self.lc_block.selected_children()) == 1
|
||||
|
||||
# ... unless requested more blocks than exists in library
|
||||
self.lc_block.max_count = 10
|
||||
self.lc_block.capa_type = 'multiplechoiceresponse'
|
||||
self.lc_block.refresh_children()
|
||||
self._sync_lc_block_from_library()
|
||||
self.lc_block.max_count = 10
|
||||
result = self.lc_block.validate()
|
||||
assert not result
|
||||
# Validation fails due to at least one warning/message
|
||||
assert result.summary
|
||||
assert StudioValidationMessage.WARNING == result.summary.type
|
||||
assert 'only 1 matching problem' in result.summary.text
|
||||
self._assert_has_only_N_matching_problems(result, 1)
|
||||
assert len(self.lc_block.selected_children()) == 1
|
||||
|
||||
# Missing problem type should always fail validation
|
||||
self.lc_block.max_count = 1
|
||||
self.lc_block.capa_type = 'customresponse'
|
||||
self.lc_block.refresh_children()
|
||||
self._sync_lc_block_from_library()
|
||||
self.lc_block.max_count = 1
|
||||
result = self.lc_block.validate()
|
||||
assert not result
|
||||
# Validation fails due to at least one warning/message
|
||||
assert result.summary
|
||||
assert StudioValidationMessage.WARNING == result.summary.type
|
||||
assert 'no matching problem types' in result.summary.text
|
||||
assert 'There are no problems in the specified library of type customresponse' in result.summary.text
|
||||
assert len(self.lc_block.selected_children()) == 0
|
||||
|
||||
# -1 selects all blocks from the library.
|
||||
self.lc_block.max_count = -1
|
||||
self.lc_block.capa_type = ANY_CAPA_TYPE_VALUE
|
||||
self.lc_block.refresh_children()
|
||||
self._sync_lc_block_from_library()
|
||||
self.lc_block.max_count = -1
|
||||
assert self.lc_block.validate()
|
||||
assert len(self.lc_block.selected_children()) == len(self.lc_block.children)
|
||||
|
||||
@@ -328,27 +395,28 @@ class LibraryContentBlockTestMixin:
|
||||
"""
|
||||
Test that the capa type filter is actually filtering children
|
||||
"""
|
||||
self._create_capa_problems()
|
||||
assert len(self.lc_block.children) == 0
|
||||
# precondition check
|
||||
self._add_problems_to_library()
|
||||
self._sync_lc_block_from_library(upgrade_to_latest=True)
|
||||
assert self.lc_block.children
|
||||
assert len(self.lc_block.children) == len(self.library.children)
|
||||
|
||||
self.lc_block.capa_type = "multiplechoiceresponse"
|
||||
self.lc_block.refresh_children()
|
||||
self._sync_lc_block_from_library()
|
||||
assert len(self.lc_block.children) == 1
|
||||
|
||||
self.lc_block.capa_type = "optionresponse"
|
||||
self.lc_block.refresh_children()
|
||||
self._sync_lc_block_from_library()
|
||||
assert len(self.lc_block.children) == 3
|
||||
|
||||
self.lc_block.capa_type = "coderesponse"
|
||||
self.lc_block.refresh_children()
|
||||
self._sync_lc_block_from_library()
|
||||
assert len(self.lc_block.children) == 2
|
||||
|
||||
self.lc_block.capa_type = "customresponse"
|
||||
self.lc_block.refresh_children()
|
||||
assert len(self.lc_block.children) == 0
|
||||
self._sync_lc_block_from_library()
|
||||
|
||||
self.lc_block.capa_type = ANY_CAPA_TYPE_VALUE
|
||||
self.lc_block.refresh_children()
|
||||
self._sync_lc_block_from_library()
|
||||
assert len(self.lc_block.children) == (len(self.lib_blocks) + 4)
|
||||
|
||||
def test_non_editable_settings(self):
|
||||
@@ -367,24 +435,22 @@ class LibraryContentBlockTestMixin:
|
||||
blocks_seen = set()
|
||||
total_tries, max_tries = 0, 100
|
||||
|
||||
self.lc_block.refresh_children()
|
||||
self.lc_block = self.store.get_item(self.lc_block.location)
|
||||
self._bind_course_block(self.lc_block)
|
||||
|
||||
# Eventually, we should see every child block selected
|
||||
while len(blocks_seen) != len(self.lib_blocks):
|
||||
self._change_count_and_refresh_children(len(self.lib_blocks))
|
||||
self._change_count_and_reselect_children(len(self.lib_blocks))
|
||||
# Now set the number of selections to 1
|
||||
selected = self._change_count_and_refresh_children(1)
|
||||
selected = self._change_count_and_reselect_children(1)
|
||||
blocks_seen.update(selected)
|
||||
total_tries += 1
|
||||
if total_tries >= max_tries:
|
||||
assert False, "Max tries exceeded before seeing all blocks."
|
||||
break
|
||||
|
||||
def _change_count_and_refresh_children(self, count):
|
||||
def _change_count_and_reselect_children(self, count):
|
||||
"""
|
||||
Helper method that changes the max_count of self.lc_block, refreshes
|
||||
Helper method that changes the max_count of self.lc_block, reselects
|
||||
children, and asserts that the number of selected children equals the count provided.
|
||||
"""
|
||||
self.lc_block.max_count = count
|
||||
@@ -413,9 +479,8 @@ class LibraryContentBlockTestMixin:
|
||||
self.lc_block.allow_resetting_children = allow_resetting_children
|
||||
self.lc_block.max_count = max_count
|
||||
# Add some capa blocks
|
||||
self._create_capa_problems()
|
||||
self.lc_block.refresh_children()
|
||||
self.lc_block = self.store.get_item(self.lc_block.location)
|
||||
self._add_problems_to_library()
|
||||
self._sync_lc_block_from_library(upgrade_to_latest=True)
|
||||
# Mock the student view to return an empty dict to be returned as response
|
||||
self.lc_block.student_view = MagicMock()
|
||||
self.lc_block.student_view.return_value.content = {}
|
||||
@@ -434,19 +499,10 @@ class LibraryContentBlockTestMixin:
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
@patch('xmodule.library_tools.SearchEngine.get_search_engine', Mock(return_value=None, autospec=True))
|
||||
class TestLibraryContentBlockNoSearchIndex(LibraryContentBlockTestMixin, LibraryContentTest):
|
||||
"""
|
||||
Tests for library container when no search index is available.
|
||||
Tests fallback low-level CAPA problem introspection
|
||||
"""
|
||||
pass # pylint:disable=unnecessary-pass
|
||||
|
||||
|
||||
search_index_mock = Mock(spec=SearchEngine) # pylint: disable=invalid-name
|
||||
|
||||
|
||||
@patch('xmodule.library_tools.SearchEngine.get_search_engine', Mock(return_value=search_index_mock, autospec=True))
|
||||
@patch.object(SearchEngine, 'get_search_engine', Mock(return_value=None, autospec=True))
|
||||
class TestLibraryContentBlockWithSearchIndex(LibraryContentBlockTestMixin, LibraryContentTest):
|
||||
"""
|
||||
Tests for library container with mocked search engine response.
|
||||
@@ -481,10 +537,12 @@ class TestLibraryContentRender(LibraryContentTest):
|
||||
Rendering unit tests for LibraryContentBlock
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self._sync_lc_block_from_library()
|
||||
|
||||
def test_preview_view(self):
|
||||
""" Test preview view rendering """
|
||||
self.lc_block.refresh_children()
|
||||
self.lc_block = self.store.get_item(self.lc_block.location)
|
||||
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})
|
||||
@@ -492,8 +550,6 @@ class TestLibraryContentRender(LibraryContentTest):
|
||||
|
||||
def test_author_view(self):
|
||||
""" Test author view rendering """
|
||||
self.lc_block.refresh_children()
|
||||
self.lc_block = self.store.get_item(self.lc_block.location)
|
||||
assert len(self.lc_block.children) == len(self.lib_blocks)
|
||||
self._bind_course_block(self.lc_block)
|
||||
rendered = self.lc_block.render(AUTHOR_VIEW, {})
|
||||
@@ -511,8 +567,7 @@ class TestLibraryContentAnalytics(LibraryContentTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.publisher = Mock()
|
||||
self.lc_block.refresh_children()
|
||||
self.lc_block = self.store.get_item(self.lc_block.location)
|
||||
self._sync_lc_block_from_library()
|
||||
self._bind_course_block(self.lc_block)
|
||||
self.lc_block.runtime.publish = self.publisher
|
||||
|
||||
@@ -582,10 +637,9 @@ class TestLibraryContentAnalytics(LibraryContentTest):
|
||||
inner_vertical = self.make_block("vertical", main_vertical)
|
||||
html_block = self.make_block("html", inner_vertical)
|
||||
problem_block = self.make_block("problem", inner_vertical)
|
||||
self.lc_block.refresh_children()
|
||||
|
||||
# Reload lc_block and set it up for a student:
|
||||
self.lc_block = self.store.get_item(self.lc_block.location)
|
||||
self._sync_lc_block_from_library(upgrade_to_latest=True)
|
||||
self._bind_course_block(self.lc_block)
|
||||
self.lc_block.runtime.publish = self.publisher
|
||||
|
||||
@@ -645,6 +699,7 @@ class TestLibraryContentAnalytics(LibraryContentTest):
|
||||
Test the "removed" event emitted when we un-assign blocks previously assigned to a student.
|
||||
We go from two blocks assigned, to one because the others have been deleted from the library.
|
||||
"""
|
||||
|
||||
# Start by assigning two blocks to the student:
|
||||
self.lc_block.get_child_blocks() # This line is needed in the test environment or the change has no effect
|
||||
self.lc_block.max_count = 2
|
||||
@@ -654,13 +709,18 @@ class TestLibraryContentAnalytics(LibraryContentTest):
|
||||
# Now make sure that one of the assigned blocks will have to be un-assigned.
|
||||
# To cause an "invalid" event, we delete all blocks from the content library
|
||||
# except for one of the two already assigned to the student:
|
||||
|
||||
keep_block_key = initial_blocks_assigned[0].location
|
||||
keep_block_lib_usage_key, keep_block_lib_version = self.store.get_block_original_usage(keep_block_key)
|
||||
assert keep_block_lib_usage_key is not None
|
||||
deleted_block_key = initial_blocks_assigned[1].location
|
||||
self.library.children = [keep_block_lib_usage_key]
|
||||
self.store.update_item(self.library, self.user_id)
|
||||
self.lc_block.refresh_children()
|
||||
self.store.update_item(self.lc_block, self.user_id)
|
||||
old_selected = self.lc_block.selected
|
||||
self._sync_lc_block_from_library(upgrade_to_latest=True)
|
||||
self.lc_block.selected = old_selected
|
||||
self.lc_block.runtime.publish = self.publisher
|
||||
|
||||
# Check that the event says that one block was removed, leaving one block left:
|
||||
children = self.lc_block.get_child_blocks()
|
||||
|
||||
@@ -1,39 +1,64 @@
|
||||
"""
|
||||
Tests for library tools service (only used by CMS)
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
Currently, the only known user of the LibraryToolsService is the
|
||||
LibraryContentBlock, so these tests are all written with only that
|
||||
block type in mind.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from django.test import override_settings
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2
|
||||
|
||||
from common.djangoapps.student.roles import CourseInstructorRole
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_cms
|
||||
from openedx.core.djangoapps.content_libraries import api as library_api
|
||||
from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest
|
||||
from openedx.core.djangoapps.xblock.api import load_block
|
||||
from common.djangoapps.student.roles import CourseInstructorRole
|
||||
from xmodule.library_tools import LibraryToolsService
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory
|
||||
from xmodule.modulestore.tests.utils import MixedSplitTestCase
|
||||
|
||||
|
||||
@skip_unless_cms
|
||||
class LibraryToolsServiceTest(MixedSplitTestCase):
|
||||
"""
|
||||
Tests for library service.
|
||||
@ddt.ddt
|
||||
class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest):
|
||||
"""
|
||||
Tests for LibraryToolsService.
|
||||
|
||||
Tests interaction with blockstore-based (V2) and mongo-based (V1) content libraries.
|
||||
"""
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
UserFactory(is_staff=True, id=self.user_id)
|
||||
self.tools = LibraryToolsService(self.store, self.user_id)
|
||||
|
||||
def test_list_available_libraries(self):
|
||||
"""
|
||||
Test listing of libraries.
|
||||
|
||||
Collects Only V2 Libaries if the FEATURES[ENABLE_LIBRARY_AUTHORING_MICROFRONTEND] setting is True.
|
||||
Otherwise, return all v1 and v2 libraries.
|
||||
"""
|
||||
# create V1 library
|
||||
_ = LibraryFactory.create(modulestore=self.store)
|
||||
# create V2 library
|
||||
self._create_library(slug="testlib1_preview", title="Test Library 1", description="Testing XBlocks")
|
||||
all_libraries = self.tools.list_available_libraries()
|
||||
assert all_libraries
|
||||
assert len(all_libraries) == 1
|
||||
assert len(all_libraries) == 2
|
||||
|
||||
@patch('xmodule.modulestore.split_mongo.split.SplitMongoModuleStore.get_library_summaries')
|
||||
with override_settings(FEATURES={**settings.FEATURES, "ENABLE_LIBRARY_AUTHORING_MICROFRONTEND": True}):
|
||||
all_libraries = self.tools.list_available_libraries()
|
||||
assert all_libraries
|
||||
assert len(all_libraries) == 1
|
||||
|
||||
@mock.patch('xmodule.modulestore.split_mongo.split.SplitMongoModuleStore.get_library_summaries')
|
||||
def test_list_available_libraries_fetch(self, mock_get_library_summaries):
|
||||
"""
|
||||
Test that library list is compiled using light weight library summary objects.
|
||||
@@ -41,17 +66,75 @@ class LibraryToolsServiceTest(MixedSplitTestCase):
|
||||
_ = self.tools.list_available_libraries()
|
||||
assert mock_get_library_summaries.called
|
||||
|
||||
def test_get_latest_v1_library_version(self):
|
||||
"""
|
||||
Test get_v1_library_version for V1 libraries.
|
||||
|
||||
@skip_unless_cms
|
||||
class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest):
|
||||
"""
|
||||
Tests for LibraryToolsService which interact with blockstore-based content libraries
|
||||
"""
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.tools = LibraryToolsService(self.store, self.user.id)
|
||||
Covers getting results for either string library key or LibraryLocator.
|
||||
"""
|
||||
lib_key = LibraryFactory.create(modulestore=self.store).location.library_key
|
||||
# Re-load the library from the modulestore, explicitly including version information:
|
||||
lib = self.store.get_library(lib_key, remove_version=False, remove_branch=False)
|
||||
# check the result using the LibraryLocator
|
||||
assert isinstance(lib_key, LibraryLocator)
|
||||
result = self.tools.get_latest_library_version(lib_key)
|
||||
assert result
|
||||
assert result == str(lib.location.library_key.version_guid)
|
||||
# the same check for string representation of the LibraryLocator
|
||||
str_key = str(lib_key)
|
||||
result = self.tools.get_latest_library_version(str_key)
|
||||
assert result
|
||||
assert result == str(lib.location.library_key.version_guid)
|
||||
|
||||
def test_import_from_blockstore(self):
|
||||
@ddt.data(
|
||||
'library-v1:Fake+Key', # V1 library key
|
||||
'lib:Fake:V-2', # V2 library key
|
||||
LibraryLocator.from_string('library-v1:Fake+Key'),
|
||||
LibraryLocatorV2.from_string('lib:Fake:V-2'),
|
||||
)
|
||||
def test_get_latest_library_version_no_library(self, lib_key):
|
||||
"""
|
||||
Test get_latest_library_version result when the library does not exist.
|
||||
|
||||
Provided lib_key's are valid V1 or V2 keys.
|
||||
"""
|
||||
assert self.tools.get_latest_library_version(lib_key) is None
|
||||
|
||||
def test_update_children_for_v2_lib(self):
|
||||
"""
|
||||
Test update_children with V2 library as a source.
|
||||
"""
|
||||
library = self._create_library(
|
||||
slug="cool-v2-lib", title="The best Library", description="Spectacular description"
|
||||
)
|
||||
self._add_block_to_library(library["id"], "unit", "unit1_id")
|
||||
|
||||
course = CourseFactory.create(modulestore=self.store, user_id=self.user.id)
|
||||
CourseInstructorRole(course.id).add_users(self.user)
|
||||
|
||||
content_block = self.make_block(
|
||||
"library_content",
|
||||
course,
|
||||
max_count=1,
|
||||
source_library_id=library['id']
|
||||
)
|
||||
assert len(content_block.children) == 0
|
||||
|
||||
# Populate children from library
|
||||
self.tools.trigger_library_sync(content_block, library_version=None)
|
||||
|
||||
# The updates happen in a Celery task, so this particular content_block instance is no updated.
|
||||
# We must re-instantiate it from modulstore in order to see the updated children list.
|
||||
content_block = self.store.get_item(content_block.location)
|
||||
|
||||
assert len(content_block.children) == 1
|
||||
|
||||
def test_update_children_for_v2_lib_recursive(self):
|
||||
"""
|
||||
Test update_children for a V2 library containing a unit.
|
||||
|
||||
Ensures that _import_from_blockstore works on nested blocks.
|
||||
"""
|
||||
# Create a blockstore content library
|
||||
library = self._create_library(slug="testlib1_import", title="A Test Library", description="Testing XBlocks")
|
||||
# Create a unit block with an HTML block in it.
|
||||
@@ -66,10 +149,17 @@ class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest):
|
||||
course = CourseFactory.create(modulestore=self.store, user_id=self.user.id)
|
||||
CourseInstructorRole(course.id).add_users(self.user)
|
||||
# Add Source from library block to the course
|
||||
lc_block = self.make_block("library_content", course, user_id=self.user.id)
|
||||
lc_block = self.make_block(
|
||||
"library_content",
|
||||
course,
|
||||
user_id=self.user_id,
|
||||
max_count=1,
|
||||
source_library_id=str(library["id"]),
|
||||
)
|
||||
|
||||
# Import the unit block from the library to the course
|
||||
self.tools.import_from_blockstore(lc_block, [unit_block_id])
|
||||
self.tools.trigger_library_sync(lc_block, library_version=None)
|
||||
lc_block = self.store.get_item(lc_block.location)
|
||||
|
||||
# Verify imported block with its children
|
||||
assert len(lc_block.children) == 1
|
||||
@@ -89,7 +179,8 @@ class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest):
|
||||
|
||||
# Check that reimporting updates the target block
|
||||
self._set_library_block_olx(html_block_id, '<html><a href="/static/test.txt">Foo bar</a></html>')
|
||||
self.tools.import_from_blockstore(lc_block, [unit_block_id])
|
||||
self.tools.trigger_library_sync(lc_block, library_version=None)
|
||||
lc_block = self.store.get_item(lc_block.location)
|
||||
|
||||
assert len(lc_block.children) == 1
|
||||
imported_unit_block = self.store.get_item(lc_block.children[0])
|
||||
@@ -97,3 +188,24 @@ class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest):
|
||||
imported_html_block = self.store.get_item(imported_unit_block.children[0])
|
||||
assert 'Hello world' not in imported_html_block.data
|
||||
assert 'Foo bar' in imported_html_block.data
|
||||
|
||||
def test_update_children_for_v1_lib(self):
|
||||
"""
|
||||
Test update_children with V1 library as a source.
|
||||
|
||||
As for now, covers usage of update_children for the library content module only.
|
||||
"""
|
||||
library = LibraryFactory.create(modulestore=self.store)
|
||||
self.make_block("html", library, data="Hello world from the block")
|
||||
course = CourseFactory.create(modulestore=self.store)
|
||||
content_block = self.make_block(
|
||||
"library_content",
|
||||
course,
|
||||
max_count=1,
|
||||
source_library_id=str(library.location.library_key)
|
||||
)
|
||||
|
||||
assert len(content_block.children) == 0
|
||||
self.tools.trigger_library_sync(content_block, library_version=None)
|
||||
content_block = self.store.get_item(content_block.location)
|
||||
assert len(content_block.children) == 1
|
||||
|
||||
Reference in New Issue
Block a user