feat!: Remove outdated Libraries Relaunch cruft (#35644)

The V2 libraries project had a few past iterations which were never
launched. This commit cleans up pieces from those which we don't need
for the real Libraries Relaunch MVP in Sumac:

* Remove ENABLE_LIBRARY_AUTHORING_MICROFRONTEND,
  LIBRARY_AUTHORING_FRONTEND_URL, and
  REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND, all of which are obsolete
  now that library authoring has been merged into
  https://github.com/openedx/frontend-app-authoring.
  More details on the new Content Libraries configuration settings are
  here: https://github.com/openedx/frontend-app-authoring/issues/1334

* Remove dangling support for syncing V2 (learning core-backed) library
  content using the LibraryContentBlock. This code was all based on an
  older understanding of V2 Content Libraries, where the libraries were
  smaller and versioned as a whole rather then versioned by-item.
  Reference to V2 libraries will be done on a per-block basis using
  the upstream/downstream system, described here:
  https://github.com/openedx/edx-platform/blob/master/docs/decisions/0020-upstream-downstream.rst
  It's important that we remove this support now so that OLX course
  authors don't stuble upon it and use it, which would be buggy and
  complicate future migrations.

* Remove the "mode" parameter from LibraryContentBlock. The only
  supported mode was and is "random". We will not be adding any further
  modes. Going forward for V2, we will have an ItemBank block for
  randomizing items (regardless of source), which can be synthesized
  with upstream referenced as described above. Existing
  LibraryContentBlocks will be migrated.

* Finally, some renamings:

  * LibraryContentBlock -> LegacyLibraryContentBlock
  * LibraryToolsService -> LegacyLibraryToolsService
  * LibrarySummary -> LegacyLibrarySummary

  Module names and the old OLX tag (library_content) are unchanged.

Closes: https://github.com/openedx/frontend-app-authoring/issues/1115
This commit is contained in:
Kyle McCormick
2024-10-15 11:32:01 -04:00
committed by GitHub
parent 70df3deea6
commit 2bbd8ecd18
31 changed files with 180 additions and 604 deletions

View File

@@ -76,7 +76,6 @@ from opaque_keys.edx.locator import (
LibraryLocator as LibraryLocatorV1,
LibraryCollectionLocator,
)
from opaque_keys import InvalidKeyError
from openedx_events.content_authoring.data import (
ContentLibraryData,
LibraryBlockData,
@@ -99,10 +98,7 @@ from xblock.exceptions import XBlockNotFoundError
from openedx.core.djangoapps.xblock.api import get_component_from_usage_key, xblock_type_display_name
from openedx.core.lib.xblock_serializer.api import serialize_modulestore_block_for_learning_core
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 permissions, tasks
from .constants import ALL_RIGHTS_RESERVED, COMPLEX
@@ -421,8 +417,8 @@ def get_library(library_key):
# updated version of content that a course could pull in. But more recently,
# we've decided to do those version references at the level of the
# individual blocks being used, since a Learning Core backed library is
# intended to be used for many LibraryContentBlocks and not 1:1 like v1
# libraries. The top level version stays for now because LibraryContentBlock
# intended to be referenced in multiple course locations and not 1:1 like v1
# libraries. The top level version stays for now because LegacyLibraryContentBlock
# uses it, but that should hopefully change before the Redwood release.
version = 0 if last_publish_log is None else last_publish_log.pk
published_by = None
@@ -1340,77 +1336,6 @@ def get_library_collection_from_usage_key(
raise ContentLibraryCollectionNotFound from exc
# 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 learning_core-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 learning_core-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

@@ -21,33 +21,20 @@ import logging
from celery import shared_task
from celery_utils.logged_task import LoggedTask
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,
LibraryLocatorV2,
LibraryUsageLocatorV2,
LibraryLocator as LibraryLocatorV1
)
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 opaque_keys.edx.locator import BlockUsageLocator
from openedx.core.lib import ensure_cms
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.library_content_block import ANY_CAPA_TYPE_VALUE, LegacyLibraryContentBlock
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.mixed import MixedModuleStore
from xmodule.modulestore.split_mongo import BlockKey
from xmodule.util.keys import derive_key
from . import api
from .models import ContentLibraryBlockImportTask
@@ -84,77 +71,6 @@ def import_blocks_from_course(import_task_id, course_key_str, use_course_key_as_
)
def _import_block(store, user_id, source_block, dest_parent_key):
"""
Recursively import a learning core 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.
Keys are generated such that they appear identical to a v1 library with
the same input block_id, library name, library organization, and parent block using derive_key
"""
if not isinstance(source_key.lib_key, LibraryLocatorV2):
raise TypeError(f"Expected source library key of type LibraryLocatorV2, got {source_key.lib_key} instead.")
source_key_as_v1_course_key = LibraryLocatorV1(
org=source_key.lib_key.org,
library=source_key.lib_key.slug,
branch='library'
)
derived_block_key = derive_key(
source=source_key_as_v1_course_key.make_usage_key(source_key.block_type, source_key.block_id),
dest_parent=BlockKey(dest_parent_key.block_type, dest_parent_key.block_id),
)
return dest_parent_key.context_key.make_usage_key(*derived_block_key)
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 learning core, 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)
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.
@@ -172,49 +88,6 @@ def _problem_type_filter(store, library, capa_type):
return [key for key in library.children if _filter_child(store, key, capa_type)]
def _import_from_learning_core(user_id, store, dest_block, source_block_ids):
"""
Imports a block from a learning-core-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(source_block_ids)) != len(source_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 source_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.
@@ -244,7 +117,7 @@ def sync_from_library(
self: LibrarySyncChildrenTask,
user_id: int,
dest_block_id: str,
library_version: str | int | None,
library_version: str | None,
) -> None:
"""
Celery task to update the children of the library_content block at `dest_block_id`.
@@ -300,8 +173,8 @@ def _sync_children(
task: LibrarySyncChildrenTask,
store: MixedModuleStore,
user_id: int,
dest_block: LibraryContentBlock,
library_version: int | str | None,
dest_block: LegacyLibraryContentBlock,
library_version: str | None,
) -> None:
"""
Implementation helper for `sync_from_library` and `duplicate_children` Celery tasks.
@@ -309,41 +182,29 @@ def _sync_children(
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:
library_key = dest_block.source_library_key.for_branch(
ModuleStoreEnum.BranchName.library
).for_version(library_version)
try:
library = store.get_library(library_key, remove_version=False, remove_branch=False, head_validation=False)
except ItemNotFoundError:
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
return
filter_children = (dest_block.capa_type != ANY_CAPA_TYPE_VALUE)
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:
source_block_ids = [
str(library_api.LibraryXBlockMetadata.from_component(library_key, component).usage_key)
for component in library_api.get_library_components(library_key)
]
_import_from_learning_core(user_id, store, dest_block, source_block_ids)
dest_block.source_library_version = str(library.version)
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:
@@ -354,8 +215,8 @@ def _sync_children(
def _copy_overrides(
store: MixedModuleStore,
user_id: int,
source_block: LibraryContentBlock,
dest_block: LibraryContentBlock
source_block: LegacyLibraryContentBlock,
dest_block: LegacyLibraryContentBlock
) -> None:
"""
Copy any overrides the user has made on children of `source` over to the children of `dest_block`, recursively.

View File

@@ -452,7 +452,7 @@ def xblock_resource_pkg(block):
ProblemBlock, and most other built-in blocks currently. Handling for these
assets does not interact with this function.
2. The (preferred) standard XBlock runtime resource loading system, used by
LibraryContentBlock. Handling for these assets *does* interact with this
LegacyLibraryContentBlock. Handling for these assets *does* interact with this
function.
We hope to migrate to (2) eventually, tracked by:

View File

@@ -10,7 +10,7 @@ from completion.test_utils import CompletionWaffleTestMixin
from django.conf import settings
from django.test import override_settings
from opaque_keys.edx.keys import CourseKey
from xmodule.library_tools import LibraryToolsService
from xmodule.library_tools import LegacyLibraryToolsService
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, LibraryFactory
from xmodule.tests import prepare_block_runtime
@@ -122,7 +122,7 @@ class CompletionServiceTestCase(CompletionWaffleTestMixin, SharedModuleStoreTest
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': LibraryToolsService(self.store, self.user.id)}) # lint-amnesty, pylint: disable=protected-access
block.runtime._services.update({'library_tools': LegacyLibraryToolsService(self.store, self.user.id)}) # lint-amnesty, pylint: disable=protected-access
def get_block(descriptor):
"""Mocks module_system get_block_for_descriptor function"""