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:
Kyle McCormick
2023-11-20 10:58:10 -05:00
committed by GitHub
parent c53cf9f1c3
commit e800ae7622
33 changed files with 1597 additions and 573 deletions

View File

@@ -48,7 +48,7 @@ remote platform instances as well as local modulestore APIs. Additionally,
there are Celery-based interfaces suitable for background processing controlled
through RESTful APIs (see :mod:`.views`).
"""
from __future__ import annotations
import abc
import collections
@@ -76,6 +76,7 @@ from opaque_keys.edx.locator import (
LibraryUsageLocatorV2,
LibraryLocator as LibraryLocatorV1
)
from opaque_keys import InvalidKeyError
from openedx_events.content_authoring.data import ContentLibraryData, LibraryBlockData
from openedx_events.content_authoring.signals import (
CONTENT_LIBRARY_CREATED,
@@ -85,11 +86,11 @@ from openedx_events.content_authoring.signals import (
LIBRARY_BLOCK_DELETED,
LIBRARY_BLOCK_UPDATED,
)
from organizations.models import Organization
from xblock.core import XBlock
from xblock.exceptions import XBlockNotFoundError
from edx_rest_api_client.client import OAuthAPIClient
from openedx.core.djangoapps.content_libraries import permissions
from openedx.core.djangoapps.content_libraries.constants import DRAFT_NAME, COMPLEX
from openedx.core.djangoapps.content_libraries.library_bundle import LibraryBundle
@@ -99,7 +100,6 @@ from openedx.core.djangoapps.content_libraries.models import (
ContentLibraryPermission,
ContentLibraryBlockImportTask,
)
from openedx.core.djangoapps.xblock.api import (
get_block_display_name,
get_learning_context_impl,
@@ -124,7 +124,10 @@ from openedx.core.lib.blockstore_api import (
)
from openedx.core.djangolib import blockstore_cache
from openedx.core.djangolib.blockstore_cache import BundleCache
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.library_root_xblock import LibraryRoot as LibraryRootV1
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from . import tasks
@@ -639,7 +642,7 @@ def delete_library(library_key):
raise
def get_library_blocks(library_key, text_search=None, block_types=None):
def get_library_blocks(library_key, text_search=None, block_types=None) -> list[LibraryXBlockMetadata]:
"""
Get the list of top-level XBlocks in the specified library.
@@ -668,7 +671,7 @@ def get_library_blocks(library_key, text_search=None, block_types=None):
# If indexing is disabled, or connection to elastic failed
if metadata is None:
metadata = []
ref = ContentLibrary.objects.get_by_key(library_key)
ref = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined]
lib_bundle = LibraryBundle(library_key, ref.bundle_uuid, draft_name=DRAFT_NAME)
usages = lib_bundle.get_top_level_usages()
@@ -701,7 +704,7 @@ def get_library_blocks(library_key, text_search=None, block_types=None):
]
def _lookup_usage_key(usage_key):
def _lookup_usage_key(usage_key) -> tuple[BundleDefinitionLocator, LibraryBundle]:
"""
Given a LibraryUsageLocatorV2 (usage key for an XBlock in a content library)
return the definition key and LibraryBundle
@@ -716,7 +719,7 @@ def _lookup_usage_key(usage_key):
return def_key, lib_bundle
def get_library_block(usage_key):
def get_library_block(usage_key) -> LibraryXBlockMetadata:
"""
Get metadata (LibraryXBlockMetadata) about one specific XBlock in a library
@@ -888,7 +891,7 @@ def delete_library_block(usage_key, remove_from_parent=True):
)
def create_library_block_child(parent_usage_key, block_type, definition_id):
def create_library_block_child(parent_usage_key, block_type, definition_id) -> LibraryXBlockMetadata:
"""
Create a new XBlock definition in this library of the specified type (e.g.
"html"), and add it as a child of the specified existing block.
@@ -908,7 +911,7 @@ def create_library_block_child(parent_usage_key, block_type, definition_id):
include_data = XBlockInclude(link_id=None, block_type=block_type, definition_id=definition_id, usage_hint=None)
parent_block.runtime.add_child_include(parent_block, include_data)
parent_block.save()
ref = ContentLibrary.objects.get_by_key(parent_usage_key.context_key)
ref = ContentLibrary.objects.get_by_key(parent_usage_key.context_key) # type: ignore[attr-defined]
LIBRARY_BLOCK_UPDATED.send_event(
library_block=LibraryBlockData(
library_key=ref.library_key,
@@ -1165,6 +1168,77 @@ def revert_changes(library_key):
)
# V1/V2 Compatibility Helpers
# (Should be removed as part of
# https://github.com/openedx/edx-platform/issues/32457)
# ======================================================
def get_v1_or_v2_library(
library_id: str | LibraryLocatorV1 | LibraryLocatorV2,
version: str | int | None,
) -> LibraryRootV1 | ContentLibraryMetadata | None:
"""
Fetch either a V1 or V2 content library from a V1/V2 key (or key string) and version.
V1 library versions are Mongo ObjectID strings.
V2 library versions can be positive ints, or strings of positive ints.
Passing version=None will return the latest version the library.
Returns None if not found.
If key is invalid, raises InvalidKeyError.
For V1, if key has a version, it is ignored in favor of `version`.
For V2, if version is provided but it isn't an int or parseable to one, we raise a ValueError.
Examples:
* get_v1_or_v2_library("library-v1:ProblemX+PR0B", None) -> <LibraryRootV1>
* get_v1_or_v2_library("library-v1:ProblemX+PR0B", "65ff...") -> <LibraryRootV1>
* get_v1_or_v2_library("lib:RG:rg-1", None) -> <ContentLibraryMetadata>
* get_v1_or_v2_library("lib:RG:rg-1", "36") -> <ContentLibraryMetadata>
* get_v1_or_v2_library("lib:RG:rg-1", "xyz") -> <ValueError>
* get_v1_or_v2_library("notakey", "xyz") -> <InvalidKeyError>
If you just want to get a V2 library, use `get_library` instead.
"""
library_key: LibraryLocatorV1 | LibraryLocatorV2
if isinstance(library_id, str):
try:
library_key = LibraryLocatorV1.from_string(library_id)
except InvalidKeyError:
library_key = LibraryLocatorV2.from_string(library_id)
else:
library_key = library_id
if isinstance(library_key, LibraryLocatorV2):
v2_version: int | None
if version:
v2_version = int(version)
else:
v2_version = None
try:
library = get_library(library_key)
if v2_version is not None and library.version != v2_version:
raise NotImplementedError(
f"Tried to load version {v2_version} of blockstore-based library {library_key}. "
f"Currently, only the latest version ({library.version}) may be loaded. "
"This is a known issue. "
"It will be fixed before the production release of blockstore-based (V2) content libraries. "
)
return library
except ContentLibrary.DoesNotExist:
return None
elif isinstance(library_key, LibraryLocatorV1):
v1_version: str | None
if version:
v1_version = str(version)
else:
v1_version = None
store = modulestore()
library_key = library_key.for_branch(ModuleStoreEnum.BranchName.library).for_version(v1_version)
try:
return store.get_library(library_key, remove_version=False, remove_branch=False, head_validation=False)
except ItemNotFoundError:
return None
# Import from Courseware
# ======================

View File

@@ -18,7 +18,7 @@ log = logging.getLogger(__name__)
class Command(BaseCommand):
"""
Import modulestore content, references by a course, into a Content Libraries
Import modulestore content references from a course into a Content Libraries
library.
"""

View File

@@ -1,20 +1,58 @@
"""
Celery tasks for Content Libraries.
"""
Architecture note:
Several functions in this file manage the copying/updating of blocks in modulestore
and blockstore. These operations should only be performed within the context of CMS.
However, due to existing edx-platform code structure, we've had to define the functions
in shared source tree (openedx/) and the tasks are registered in both LMS and CMS.
To ensure that we're not accidentally importing things from blockstore in the LMS context,
we use ensure_cms throughout this module.
A longer-term solution to this issue would be to move the content_libraries app to cms:
https://github.com/openedx/edx-platform/issues/33428
"""
from __future__ import annotations
import logging
import hashlib
from celery import shared_task
from celery_utils.logged_task import LoggedTask
from edx_django_utils.monitoring import set_code_owner_attribute
from celery.utils.log import get_task_logger
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.core.exceptions import PermissionDenied
from edx_django_utils.monitoring import set_code_owner_attribute, set_code_owner_attribute_from_module
from opaque_keys.edx.keys import UsageKey
from opaque_keys.edx.locator import (
BlockUsageLocator,
LibraryUsageLocator,
LibraryUsageLocatorV2
)
from search.search_engine_base import SearchEngine
from user_tasks.tasks import UserTask, UserTaskStatus
from xblock.fields import Scope
from common.djangoapps.student.auth import has_studio_write_access
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.content_libraries import api as library_api
from openedx.core.djangoapps.xblock.api import load_block
from openedx.core.lib import ensure_cms, blockstore_api
from xmodule.capa_block import ProblemBlock
from xmodule.library_content_block import ANY_CAPA_TYPE_VALUE, LibraryContentBlock
from xmodule.library_root_xblock import LibraryRoot as LibraryRootV1
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.mixed import MixedModuleStore
from . import api
from .models import ContentLibraryBlockImportTask
logger = logging.getLogger(__name__)
TASK_LOGGER = get_task_logger(__name__)
@shared_task(base=LoggedTask)
@@ -23,6 +61,7 @@ def import_blocks_from_course(import_task_id, course_key_str):
"""
A Celery task to import blocks from a course through modulestore.
"""
ensure_cms("import_blocks_from_course may only be executed in a CMS context")
course_key = CourseKey.from_string(course_key_str)
@@ -39,3 +78,302 @@ def import_blocks_from_course(import_task_id, course_key_str):
edx_client.import_blocks_from_course(
course_key, on_progress
)
def _normalize_key_for_search(library_key):
""" Normalizes library key for use with search indexing """
return library_key.replace(version_guid=None, branch=None)
def _import_block(store, user_id, source_block, dest_parent_key):
"""
Recursively import a blockstore block and its children.`
"""
def generate_block_key(source_key, dest_parent_key):
"""
Deterministically generate an ID for the new block and return the key
"""
block_id = (
dest_parent_key.block_id[:10] +
'-' +
hashlib.sha1(str(source_key).encode('utf-8')).hexdigest()[:10]
)
return dest_parent_key.context_key.make_usage_key(source_key.block_type, block_id)
source_key = source_block.scope_ids.usage_id
new_block_key = generate_block_key(source_key, dest_parent_key)
try:
new_block = store.get_item(new_block_key)
if new_block.parent.block_id != dest_parent_key.block_id:
raise ValueError(
"Expected existing block {} to be a child of {} but instead it's a child of {}".format(
new_block_key, dest_parent_key, new_block.parent,
)
)
except ItemNotFoundError:
new_block = store.create_child(
user_id,
dest_parent_key,
source_key.block_type,
block_id=new_block_key.block_id,
)
# Prepare a list of this block's static assets; any assets that are referenced as /static/{path} (the
# recommended way for referencing them) will stop working, and so we rewrite the url when importing.
# Copying assets not advised because modulestore doesn't namespace assets to each block like blockstore, which
# might cause conflicts when the same filename is used across imported blocks.
if isinstance(source_key, LibraryUsageLocatorV2):
all_assets = library_api.get_library_block_static_asset_files(source_key)
else:
all_assets = []
for field_name, field in source_block.fields.items():
if field.scope not in (Scope.settings, Scope.content):
continue # Only copy authored field data
if field.is_set_on(source_block) or field.is_set_on(new_block):
field_value = getattr(source_block, field_name)
if isinstance(field_value, str):
# If string field (which may also be JSON/XML data), rewrite /static/... URLs to point to blockstore
for asset in all_assets:
field_value = field_value.replace(f'/static/{asset.path}', asset.url)
# Make sure the URL is one that will work from the user's browser when using the docker devstack
field_value = blockstore_api.force_browser_url(field_value)
setattr(new_block, field_name, field_value)
new_block.save()
store.update_item(new_block, user_id)
if new_block.has_children:
# Delete existing children in the new block, which can be reimported again if they still exist in the
# source library
for existing_child_key in new_block.children:
store.delete_item(existing_child_key, user_id)
# Now import the children
for child in source_block.get_children():
_import_block(store, user_id, child, new_block_key)
return new_block_key
def _filter_child(store, usage_key, capa_type):
"""
Return whether this block is both a problem and has a `capa_type` which is included in the filter.
"""
if usage_key.block_type != "problem":
return False
descriptor = store.get_item(usage_key, depth=0)
assert isinstance(descriptor, ProblemBlock)
return capa_type in descriptor.problem_types
def _problem_type_filter(store, library, capa_type):
""" Filters library children by capa type."""
try:
search_engine = SearchEngine.get_search_engine(index="library_index")
except: # pylint: disable=bare-except
search_engine = None
if search_engine:
filter_clause = {
"library": str(_normalize_key_for_search(library.location.library_key)),
"content_type": ProblemBlock.INDEX_CONTENT_TYPE,
"problem_types": capa_type
}
search_result = search_engine.search(field_dictionary=filter_clause)
results = search_result.get('results', [])
return [LibraryUsageLocator.from_string(item['data']['id']) for item in results]
else:
return [key for key in library.children if _filter_child(store, key, capa_type)]
def _import_from_blockstore(user_id, store, dest_block, blockstore_block_ids):
"""
Imports a block from a blockstore-based learning context (usually a
content library) into modulestore, as a new child of dest_block.
Any existing children of dest_block are replaced.
"""
dest_key = dest_block.scope_ids.usage_id
if not isinstance(dest_key, BlockUsageLocator):
raise TypeError(f"Destination {dest_key} should be a modulestore course.")
if user_id is None:
raise ValueError("Cannot check user permissions - LibraryTools user_id is None")
if len(set(blockstore_block_ids)) != len(blockstore_block_ids):
# We don't support importing the exact same block twice because it would break the way we generate new IDs
# for each block and then overwrite existing copies of blocks when re-importing the same blocks.
raise ValueError("One or more library component IDs is a duplicate.")
dest_course_key = dest_key.context_key
user = User.objects.get(id=user_id)
if not has_studio_write_access(user, dest_course_key):
raise PermissionDenied()
# Read the source block; this will also confirm that user has permission to read it.
# (This could be slow and use lots of memory, except for the fact that LibraryContentBlock which calls this
# should be limiting the number of blocks to a reasonable limit. We load them all now instead of one at a
# time in order to raise any errors before we start actually copying blocks over.)
orig_blocks = [load_block(UsageKey.from_string(key), user) for key in blockstore_block_ids]
with store.bulk_operations(dest_course_key):
child_ids_updated = set()
for block in orig_blocks:
new_block_id = _import_block(store, user_id, block, dest_key)
child_ids_updated.add(new_block_id)
# Remove any existing children that are no longer used
for old_child_id in set(dest_block.children) - child_ids_updated:
store.delete_item(old_child_id, user_id)
# If this was called from a handler, it will save dest_block at the end, so we must update
# dest_block.children to avoid it saving the old value of children and deleting the new ones.
dest_block.children = store.get_item(dest_key).children
class LibrarySyncChildrenTask(UserTask): # pylint: disable=abstract-method
"""
Base class for tasks which operate upon library_content children.
"""
@classmethod
def generate_name(cls, arguments_dict) -> str:
"""
Create a name for this particular import task instance.
Should be both:
a. semi human-friendly
b. something we can query in order to determine whether the dest block has a task in progress
Arguments:
arguments_dict (dict): The arguments given to the task function
"""
key = arguments_dict['dest_block_id']
return f'Updating {key} from library'
# Note: The decorator @set_code_owner_attribute cannot be used here because the UserTaskMixin does stack
# inspection and can't handle additional decorators. So, wet set the code_owner attribute in the tasks' bodies instead.
@shared_task(base=LibrarySyncChildrenTask, bind=True)
def sync_from_library(
self: LibrarySyncChildrenTask,
user_id: int,
dest_block_id: str,
library_version: str | int | None,
) -> None:
"""
Celery task to update the children of the library_content block at `dest_block_id`.
"""
set_code_owner_attribute_from_module(__name__)
store = modulestore()
dest_block = store.get_item(BlockUsageLocator.from_string(dest_block_id))
_sync_children(
task=self,
store=store,
user_id=user_id,
dest_block=dest_block,
library_version=library_version,
)
@shared_task(base=LibrarySyncChildrenTask, bind=True)
def duplicate_children(
self: LibrarySyncChildrenTask,
user_id: int,
source_block_id: str,
dest_block_id: str,
) -> None:
"""
Celery task to duplicate the children from `source_block_id` to `dest_block_id`.
"""
set_code_owner_attribute_from_module(__name__)
store = modulestore()
# First, populate the destination block with children imported from the library.
# It's important that _sync_children does this at the currently-set version of the dest library
# (someone may be duplicating an out-of-date block).
dest_block = store.get_item(BlockUsageLocator.from_string(dest_block_id))
_sync_children(
task=self,
store=store,
user_id=user_id,
dest_block=dest_block,
library_version=dest_block.source_library_version,
)
# Then, copy over any overridden settings the course author may have applied to the blocks.
source_block = store.get_item(BlockUsageLocator.from_string(source_block_id))
with store.bulk_operations(source_block.scope_ids.usage_id.context_key):
_copy_overrides(store=store, user_id=user_id, source_block=source_block, dest_block=dest_block)
def _sync_children(
task: LibrarySyncChildrenTask,
store: MixedModuleStore,
user_id: int,
dest_block: LibraryContentBlock,
library_version: int | str | None,
) -> None:
"""
Implementation helper for `sync_from_library` and `duplicate_children` Celery tasks.
Can update children with a specific library `library_version`, or latest (`library_version=None`).
"""
source_blocks = []
library_key = dest_block.source_library_key
filter_children = (dest_block.capa_type != ANY_CAPA_TYPE_VALUE)
library = library_api.get_v1_or_v2_library(library_key, version=library_version)
if not library:
task.status.fail(f"Requested library {library_key} not found.")
elif isinstance(library, LibraryRootV1):
if filter_children:
# Apply simple filtering based on CAPA problem types:
source_blocks.extend(_problem_type_filter(store, library, dest_block.capa_type))
else:
source_blocks.extend(library.children)
with store.bulk_operations(dest_block.scope_ids.usage_id.context_key):
try:
dest_block.source_library_version = str(library.location.library_key.version_guid)
store.update_item(dest_block, user_id)
dest_block.children = store.copy_from_template(
source_blocks, dest_block.location, user_id, head_validation=True
)
# ^-- copy_from_template updates the children in the DB
# but we must also set .children here to avoid overwriting the DB again
except Exception as exception: # pylint: disable=broad-except
TASK_LOGGER.exception('Error importing children for %s', dest_block.scope_ids.usage_id, exc_info=True)
if task.status.state != UserTaskStatus.FAILED:
task.status.fail({'raw_error_msg': str(exception)})
raise
elif isinstance(library, library_api.ContentLibraryMetadata):
# TODO: add filtering by capa_type when V2 library will support different problem types
try:
source_blocks = library_api.get_library_blocks(library_key)
source_block_ids = [str(block.usage_key) for block in source_blocks]
_import_from_blockstore(user_id, store, dest_block, source_block_ids)
dest_block.source_library_version = str(library.version)
store.update_item(dest_block, user_id)
except Exception as exception: # pylint: disable=broad-except
TASK_LOGGER.exception('Error importing children for %s', dest_block.scope_ids.usage_id, exc_info=True)
if task.status.state != UserTaskStatus.FAILED:
task.status.fail({'raw_error_msg': str(exception)})
raise
def _copy_overrides(
store: MixedModuleStore,
user_id: int,
source_block: LibraryContentBlock,
dest_block: LibraryContentBlock
) -> None:
"""
Copy any overrides the user has made on children of `source` over to the children of `dest_block`, recursively.
"""
for field in source_block.fields.values():
if field.scope == Scope.settings and field.is_set_on(source_block):
setattr(dest_block, field.name, field.read_from(source_block))
if source_block.has_children:
for source_child_key, dest_child_key in zip(source_block.children, dest_block.children):
_copy_overrides(
store=store,
user_id=user_id,
source_block=source_block.runtime.get_block(source_child_key),
dest_block=dest_block.runtime.get_block(dest_child_key),
)
store.update_item(dest_block, user_id)

View File

@@ -17,11 +17,14 @@ import crum
from django.conf import settings
from django.contrib import sites
from django.core.cache import caches
from django.core.exceptions import ImproperlyConfigured
from django.db import DEFAULT_DB_ALIAS, connections
from django.test import RequestFactory, TestCase, override_settings
from django.test.utils import CaptureQueriesContext
from edx_django_utils.cache import RequestCache
from openedx.core.lib import ensure_cms, ensure_lms
class CacheIsolationMixin:
"""
@@ -245,11 +248,23 @@ def skip_unless_cms(func):
"""
Only run the decorated test in the CMS test suite
"""
return skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in CMS')(func)
try:
ensure_cms()
except ImproperlyConfigured:
is_cms = False
else:
is_cms = True
return skipUnless(is_cms, 'Test only valid in CMS')(func)
def skip_unless_lms(func):
"""
Only run the decorated test in the LMS test suite
"""
return skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in LMS')(func)
try:
ensure_lms()
except ImproperlyConfigured:
is_lms = False
else:
is_lms = True
return skipUnless(is_lms, 'Test only valid in LMS')(func)

View File

@@ -0,0 +1,40 @@
"""
This directory (openedx/core/lib) contains packages of utilities used by
both LMS and CMS. Packages with models should go in openedx/core/djangoapps instead.
Packages that are LMS-specific or CMS-specific should be in lms/ or cms/ instead.
This particular module contains a small handful of broadly useful utility functions.
"""
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
_LMS_URLCONF = 'lms.urls'
_CMS_URLCONF = 'cms.urls'
def ensure_lms(message: str = "This code may only be called by LMS, but it was called by CMS"):
"""
Assert that we're configured as LMS.
Useful if you want to forbid learner/instructor-oriented code from accidentally
running in the CMS process.
"""
if settings.ROOT_URLCONF != _LMS_URLCONF:
raise ImproperlyConfigured(
f"{message}. Expected ROOT_URLCONF to be '{_LMS_URLCONF}', got '{settings.ROOT_URLCONF}'"
)
def ensure_cms(message: str = "This code may only be called by CMS, but it was called by LMS"):
"""
Assert that we're configured as CMS.
Useful if you want to forbid authoring-oriented code from accidentally
running in the LMS process.
"""
if settings.ROOT_URLCONF != 'cms.urls':
raise ImproperlyConfigured(
f"{message}. Expected ROOT_URLCONF to be '{_CMS_URLCONF}', got '{settings.ROOT_URLCONF}'"
)

View File

@@ -195,15 +195,18 @@ def delete_collection(collection_uuid):
@toggle_blockstore_api
def get_bundles(uuids=None, text_search=None):
"""
Get the details of all bundles
Get the details of all bundles.
"""
query_params = {}
data = {}
if uuids:
query_params['uuid'] = ','.join(map(str, uuids))
# Potentially we could have a lot of libraries which will lead to 414 error (Request-URI Too Long)
# if sending uuids in the query_params. So we have to use the request data instead.
data = {'uuid': ','.join(map(str, uuids))}
if text_search:
query_params['text_search'] = text_search
version_url = api_url('bundles') + '?' + urlencode(query_params)
response = api_request('get', version_url)
response = api_request('get', version_url, json=data)
# build bundle from response, convert map object to list and return
return [_bundle_from_response(item) for item in response]

View File

@@ -217,8 +217,15 @@ class CompletionServiceTestCase(CompletionWaffleTestMixin, SharedModuleStoreTest
# Library Content Block needs its children to be completed.
self.assertFalse(self.completion_service.can_mark_block_complete_on_view(library_content_block))
library_content_block.refresh_children()
lib_vertical = self.store.get_item(lib_vertical.location)
# Dirty hack:
# sync_from_library isn't *supposed* to work inside LMS, but this test case was written
# before we made that rule. So, we need to trick this part of test case into thinking that it's
# running inside CMS instead of LMS. Please don't copy-paste this trick to any other LMS tests :)
# Long-term solution: https://github.com/openedx/edx-platform/issues/33545
with override_settings(ROOT_URLCONF="cms.urls"):
library_content_block.sync_from_library()
lib_vertical = self.store.get_item(lib_vertical.location)
self._bind_course_block(lib_vertical)
# We need to refetch the library_content_block to retrieve the
# fresh version from the call to get_item for lib_vertical