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:
@@ -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
|
||||
# ======================
|
||||
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}'"
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user