Files
edx-platform/xmodule/library_tools.py
Kyle McCormick 2bbd8ecd18 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
2024-10-15 11:32:01 -04:00

203 lines
9.6 KiB
Python

"""
XBlock runtime services for LegacyLibraryContentBlock
"""
from __future__ import annotations
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.core.exceptions import ObjectDoesNotExist
from opaque_keys.edx.locator import LibraryLocator
from user_tasks.models import UserTaskStatus
from openedx.core.lib import ensure_cms
from openedx.core.djangoapps.content_libraries import tasks as library_tasks
from xmodule.library_content_block import LegacyLibraryContentBlock
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.exceptions import ItemNotFoundError
def normalize_key_for_search(library_key):
""" Normalizes library key for use with search indexing """
return library_key.replace(version_guid=None, branch=None)
class LegacyLibraryToolsService:
"""
Service for LegacyLibraryContentBlock.
Allows to interact with libraries in the modulestore and learning core.
Should only be used in the CMS.
"""
def __init__(self, modulestore, user_id):
self.store = modulestore
self.user_id = user_id
def get_latest_library_version(self, library_id: str | LibraryLocator) -> str | None:
"""
Get the version of the given library as string.
The return value (library version) could be:
str(<ObjectID>) - for V1 library;
None - if the library does not exist.
"""
library_key: LibraryLocator
if isinstance(library_id, str):
library_key = LibraryLocator.from_string(library_id)
else:
library_key = library_id
library_key = library_key.for_branch(ModuleStoreEnum.BranchName.library).for_version(None)
try:
library = self.store.get_library(
library_key, remove_version=False, remove_branch=False, head_validation=False
)
except ItemNotFoundError:
return None
if not library:
return None
# We need to know the library's version so ensure it's set in library.location.library_key.version_guid
assert library.location.library_key.version_guid is not None
return str(library.location.library_key.version_guid)
def create_block_analytics_summary(self, course_key, block_keys):
"""
Given a CourseKey and a list of (block_type, block_id) pairs,
prepare the JSON-ready metadata needed for analytics logging.
This is [
{"usage_key": x, "original_usage_key": y, "original_usage_version": z, "descendants": [...]}
]
where the main list contains all top-level blocks, and descendants contains a *flat* list of all
descendants of the top level blocks, if any.
"""
def summarize_block(usage_key):
""" Basic information about the given block """
orig_key, orig_version = self.store.get_block_original_usage(usage_key)
return {
"usage_key": str(usage_key),
"original_usage_key": str(orig_key) if orig_key else None,
"original_usage_version": str(orig_version) if orig_version else None,
}
result_json = []
for block_key in block_keys:
key = course_key.make_usage_key(*block_key)
info = summarize_block(key)
info['descendants'] = []
try:
block = self.store.get_item(key, depth=None) # Load the item and all descendants
children = list(getattr(block, "children", []))
while children:
child_key = children.pop()
child = self.store.get_item(child_key)
info['descendants'].append(summarize_block(child_key))
children.extend(getattr(child, "children", []))
except ItemNotFoundError:
pass # The block has been deleted
result_json.append(info)
return result_json
def can_use_library_content(self, block):
"""
Determines whether a modulestore holding a course_id supports libraries.
"""
return self.store.check_supports(block.location.course_key, 'copy_from_template')
def trigger_library_sync(self, dest_block: LegacyLibraryContentBlock, library_version: str | None) -> None:
"""
Queue task to synchronize the children of `dest_block` with it source library (at `library_version` or latest).
Raises ObjectDoesNotExist if library/version cannot be found.
The task will:
* Load that library at `dest_block.source_library_id` and `library_version`.
* If `library_version` is None, load the latest.
* Update `dest_block.source_library_version` based on what is loaded.
* Ensure that `dest_block` has children corresponding to all matching source library blocks.
* Considered fields of `dest_block` include: `source_library_id`, `source_library_version`, `capa_type`.
library version, and upate `dest_block.source_library_version` to match.
* Derive each child block id as a function of `dest_block`'s id and the library block's definition id.
* Follow these important create/update/delete semantics for children:
* When a matching library child DOES NOT EXIT in `dest_block.children`: import it in as a new block.
* When a matching library child ALREADY EXISTS in `dest_block.children`: re-import its definition, clobbering
any content updates in this existing child, but preserving any settings overrides in the existing child.
* When a block in `dest_block.children` DOES NOT MATCH any library children: delete it from
`dest_block.children`.
"""
ensure_cms("library_content block children may only be synced in a CMS context")
if not isinstance(dest_block, LegacyLibraryContentBlock):
raise ValueError(f"Can only sync children for library_content blocks, not {dest_block.tag} blocks.")
if not dest_block.source_library_id:
dest_block.source_library_version = ""
return
library_key = dest_block.source_library_key.for_branch(
ModuleStoreEnum.BranchName.library
).for_version(library_version)
try:
self.store.get_library(library_key, remove_version=False, remove_branch=False, head_validation=False)
except ItemNotFoundError as exc:
if library_version:
raise ObjectDoesNotExist(f"Version {library_version} of library {library_key} not found.") from exc
raise ObjectDoesNotExist(f"Library {library_key} not found.") from exc
# TODO: This task is synchronous until we can figure out race conditions with import.
# These race conditions lead to failed imports of library content from course import.
# See: TNL-11339, https://github.com/openedx/edx-platform/issues/34029 for more info.
library_tasks.sync_from_library.apply(
kwargs=dict(
user_id=self.user_id,
dest_block_id=str(dest_block.scope_ids.usage_id),
library_version=library_version,
),
)
def trigger_duplication(
self, source_block: LegacyLibraryContentBlock, dest_block: LegacyLibraryContentBlock
) -> None:
"""
Queue a task to duplicate the children of `source_block` to `dest_block`.
"""
ensure_cms("library_content block children may only be duplicated in a CMS context")
if not isinstance(dest_block, LegacyLibraryContentBlock):
raise ValueError(f"Can only duplicate children for library_content blocks, not {dest_block.tag} blocks.")
if source_block.scope_ids.usage_id.context_key != source_block.scope_ids.usage_id.context_key:
raise ValueError(
"Cannot duplicate_children across different learning contexts "
f"(source={source_block.scope_ids.usage_id}, dest={dest_block.scope_ids.usage_id})"
)
if source_block.source_library_key != dest_block.source_library_key:
raise ValueError(
"Cannot duplicate_children across different source libraries or versions thereof "
f"({source_block.source_library_key=}, {dest_block.source_library_key=})."
)
library_tasks.duplicate_children.delay(
user_id=self.user_id,
source_block_id=str(source_block.scope_ids.usage_id),
dest_block_id=str(dest_block.scope_ids.usage_id),
)
def are_children_syncing(self, library_content_block: LegacyLibraryContentBlock) -> bool:
"""
Is a task currently running to sync the children of `library_content_block`?
Only checks the latest task (so that this block's state can't get permanently messed up by
some older task that's stuck in PENDING).
"""
args = {'dest_block_id': library_content_block.scope_ids.usage_id}
name = library_tasks.LibrarySyncChildrenTask.generate_name(args)
status = UserTaskStatus.objects.filter(name=name).order_by('-created').first()
return status and status.state in [
UserTaskStatus.IN_PROGRESS, UserTaskStatus.PENDING, UserTaskStatus.RETRYING
]
def list_available_libraries(self):
"""
List all known legacy libraries.
Returns tuples of (library key, display_name).
"""
user = User.objects.get(id=self.user_id)
return [
(lib.location.library_key.replace(version_guid=None, branch=None), lib.display_name)
for lib in self.store.get_library_summaries()
]