diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py index ff2020afd89f..103c34a7b30c 100644 --- a/cms/djangoapps/contentstore/helpers.py +++ b/cms/djangoapps/contentstore/helpers.py @@ -16,6 +16,7 @@ from django.utils.translation import gettext as _ from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import DefinitionLocator, LocalId +from openedx.core.djangoapps.content_tagging.types import TagValuesByObjectIdDict from xblock.core import XBlock from xblock.fields import ScopeIds from xblock.runtime import IdGenerator @@ -286,6 +287,26 @@ class StaticFileNotices: error_files: list[str] = Factory(list) +def _rewrite_static_asset_references(downstream_xblock: XBlock, substitutions: dict[str, str], user_id: int) -> None: + """ + Rewrite the static asset references in the OLX string to point to the new locations in the course. + """ + store = modulestore() + if hasattr(downstream_xblock, "data"): + data_with_substitutions = downstream_xblock.data + for old_static_ref, new_static_ref in substitutions.items(): + data_with_substitutions = _replace_strings( + data_with_substitutions, + old_static_ref, + new_static_ref, + ) + downstream_xblock.data = data_with_substitutions + store.update_item(downstream_xblock, user_id) + + for child in downstream_xblock.get_children(): + _rewrite_static_asset_references(child, substitutions, user_id) + + def _insert_static_files_into_downstream_xblock( downstream_xblock: XBlock, staged_content_id: int, request ) -> StaticFileNotices: @@ -308,21 +329,12 @@ def _insert_static_files_into_downstream_xblock( static_files=static_files, ) - # Rewrite the OLX's static asset references to point to the new - # locations for those assets. See _import_files_into_course for more - # info on why this is necessary. - store = modulestore() - if hasattr(downstream_xblock, "data") and substitutions: - data_with_substitutions = downstream_xblock.data - for old_static_ref, new_static_ref in substitutions.items(): - data_with_substitutions = _replace_strings( - data_with_substitutions, - old_static_ref, - new_static_ref, - ) - downstream_xblock.data = data_with_substitutions - if store is not None: - store.update_item(downstream_xblock, request.user.id) + if substitutions: + # Rewrite the OLX's static asset references to point to the new + # locations for those assets. See _import_files_into_course for more + # info on why this is necessary. + _rewrite_static_asset_references(downstream_xblock, substitutions, request.user.id) + return notices @@ -375,9 +387,10 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) -> parent_xblock, store, user=request.user, - slug_hint=user_clipboard.source_usage_key.block_id, - copied_from_block=str(user_clipboard.source_usage_key), - copied_from_version_num=user_clipboard.content.version_num, + slug_hint=( + user_clipboard.source_usage_key.block_id + if isinstance(user_clipboard.source_usage_key, UsageKey) else None + ), tags=user_clipboard.content.tags, ) @@ -441,7 +454,7 @@ def _fetch_and_set_upstream_link( Fetch and set upstream link for the given xblock which is being pasted. This function handles following cases: * the xblock is copied from a v2 library; the library block is set as upstream. * the xblock is copied from a course; no upstream is set, only copied_from_block is set. - * the xblock is copied from a course where the source block was imported from a library; the original libary block + * the xblock is copied from a course where the source block was imported from a library; the original library block is set as upstream. """ # Try to link the pasted block (downstream) to the copied block (upstream). @@ -491,13 +504,8 @@ def _import_xml_node_to_parent( user: User, # Hint to use as usage ID (block_id) for the new XBlock slug_hint: str | None = None, - # UsageKey of the XBlock that this one is a copy of - copied_from_block: str | None = None, - # Positive int version of source block, if applicable (e.g., library block). - # Zero if not applicable (e.g., course block). - copied_from_version_num: int = 0, # Content tags applied to the source XBlock(s) - tags: dict[str, str] | None = None, + tags: TagValuesByObjectIdDict | None = None, ) -> XBlock: """ Given an XML node representing a serialized XBlock (OLX), import it into modulestore 'store' as a child of the @@ -508,6 +516,8 @@ def _import_xml_node_to_parent( runtime = parent_xblock.runtime parent_key = parent_xblock.scope_ids.usage_id block_type = node.tag + node_copied_from = node.attrib.get('copied_from_block', None) + node_copied_version = node.attrib.get('copied_from_version', None) # Modulestore's IdGenerator here is SplitMongoIdManager which is assigned # by CachingDescriptorSystem Runtime and since we need our custom ImportIdGenerator @@ -565,8 +575,10 @@ def _import_xml_node_to_parent( if xblock_class.has_children and temp_xblock.children: raise NotImplementedError("We don't yet support pasting XBlocks with children") - if copied_from_block: - _fetch_and_set_upstream_link(copied_from_block, copied_from_version_num, temp_xblock, user) + + if node_copied_from: + _fetch_and_set_upstream_link(node_copied_from, node_copied_version, temp_xblock, user) + # Save the XBlock into modulestore. We need to save the block and its parent for this to work: new_xblock = store.update_item(temp_xblock, user.id, allow_not_found=True) new_xblock.parent = parent_key @@ -582,26 +594,23 @@ def _import_xml_node_to_parent( if not children_handled: for child_node in child_nodes: - child_copied_from = _get_usage_key_from_node(child_node, copied_from_block) if copied_from_block else None _import_xml_node_to_parent( child_node, new_xblock, store, user=user, - copied_from_block=str(child_copied_from), tags=tags, ) # Copy content tags to the new xblock if new_xblock.upstream: - # If this block is synced from an upstream (e.g. library content), # copy the tags from the upstream as ready-only content_tagging_api.copy_tags_as_read_only( new_xblock.upstream, new_xblock.location, ) - elif copied_from_block and tags: - object_tags = tags.get(str(copied_from_block)) + elif tags and node_copied_from: + object_tags = tags.get(node_copied_from) if object_tags: content_tagging_api.set_all_object_tags( content_key=new_xblock.location, @@ -794,27 +803,6 @@ def is_item_in_course_tree(item): return ancestor is not None -def _get_usage_key_from_node(node, parent_id: str) -> UsageKey | None: - """ - Returns the UsageKey for the given node and parent ID. - - If the parent_id is not a valid UsageKey, or there's no "url_name" attribute in the node, then will return None. - """ - parent_key = UsageKey.from_string(parent_id) - parent_context = parent_key.context_key - usage_key = None - block_id = node.attrib.get("url_name") - block_type = node.tag - - if parent_context and block_id and block_type: - usage_key = parent_context.make_usage_key( - block_type=block_type, - block_id=block_id, - ) - - return usage_key - - def concat_static_file_notices(notices: list[StaticFileNotices]) -> StaticFileNotices: """Combines multiple static file notices into a single object diff --git a/openedx/core/djangoapps/content_libraries/api/__init__.py b/openedx/core/djangoapps/content_libraries/api/__init__.py index 6c5cbce2a2fa..5f7db8b17f72 100644 --- a/openedx/core/djangoapps/content_libraries/api/__init__.py +++ b/openedx/core/djangoapps/content_libraries/api/__init__.py @@ -3,6 +3,7 @@ """ from .block_metadata import * from .collections import * +from .container_metadata import * from .containers import * from .courseware_import import * from .exceptions import * diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py index ec5c43b1218b..f117d2762949 100644 --- a/openedx/core/djangoapps/content_libraries/api/block_metadata.py +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -1,7 +1,5 @@ """ -Content libraries API methods related to XBlocks/Components. - -These methods don't enforce permissions (only the REST APIs do). +Content libraries data classes related to XBlocks/Components. """ from __future__ import annotations from dataclasses import dataclass diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py index 3eff4c6c92d0..9980323447a1 100644 --- a/openedx/core/djangoapps/content_libraries/api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -538,7 +538,7 @@ def import_staged_content_from_user_clipboard(library_key: LibraryLocatorV2, use raise ValidationError("The user's clipboard is empty") staged_content_id = user_clipboard.content.id - source_context_key: LearningContextKey = user_clipboard.source_context_key + source_context_key = user_clipboard.source_context_key staged_content_files = content_staging_api.get_staged_content_static_files(staged_content_id) diff --git a/openedx/core/djangoapps/content_libraries/api/container_metadata.py b/openedx/core/djangoapps/content_libraries/api/container_metadata.py new file mode 100644 index 000000000000..841a51b625ff --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/api/container_metadata.py @@ -0,0 +1,147 @@ +""" +Content libraries data classes related to Containers. +""" +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2 +from openedx_learning.api import authoring as authoring_api +from openedx_learning.api.authoring_models import Container + +from openedx.core.djangoapps.content_tagging.api import get_object_tag_counts + +from .libraries import PublishableItem + +# The public API is only the following symbols: +__all__ = [ + # Models + "ContainerMetadata", + "ContainerType", + # Methods + "library_container_locator", +] + + +class ContainerType(Enum): + """ + The container types supported by content_libraries, and logic to map them to OLX. + """ + Unit = "unit" + Subsection = "subsection" + Section = "section" + + @property + def olx_tag(self) -> str: + """ + Canonical XML tag to use when representing this container as OLX. + + For example, Units are encoded as .... + + These tag names are historical. We keep them around for the backwards compatibility of OLX + and for easier interaction with legacy modulestore-powered structural XBlocks + (e.g., copy-paste of Units between courses and V2 libraries). + """ + match self: + case self.Unit: + return "vertical" + case self.Subsection: + return "sequential" + case self.Section: + return "chapter" + raise TypeError(f"unexpected ContainerType: {self!r}") + + @classmethod + def from_source_olx_tag(cls, olx_tag: str) -> 'ContainerType': + """ + Get the ContainerType that this OLX tag maps to. + """ + if olx_tag == "unit": + # There is an alternative implementation to VerticalBlock called UnitBlock whose + # OLX tag is . When converting from OLX, we want to handle both + # and as Unit containers, although the canonical serialization is still . + return cls.Unit + try: + return next(ct for ct in cls if olx_tag == ct.olx_tag) + except StopIteration: + raise ValueError(f"no container_type for XML tag: <{olx_tag}>") from None + + +@dataclass(frozen=True, kw_only=True) +class ContainerMetadata(PublishableItem): + """ + Class that represents the metadata about a Container (e.g. Unit) in a content library. + """ + container_key: LibraryContainerLocator + container_type: ContainerType + container_pk: int + + @classmethod + def from_container(cls, library_key, container: Container, associated_collections=None): + """ + Construct a ContainerMetadata object from a Container object. + """ + last_publish_log = container.versioning.last_publish_log + container_key = library_container_locator( + library_key, + container=container, + ) + container_type = ContainerType(container_key.container_type) + published_by = "" + if last_publish_log and last_publish_log.published_by: + published_by = last_publish_log.published_by.username + + draft = container.versioning.draft + published = container.versioning.published + last_draft_created = draft.created if draft else None + if draft and draft.publishable_entity_version.created_by: + last_draft_created_by = draft.publishable_entity_version.created_by.username + else: + last_draft_created_by = "" + tags = get_object_tag_counts(str(container_key), count_implicit=True) + + return cls( + container_key=container_key, + container_type=container_type, + container_pk=container.pk, + display_name=draft.title, + created=container.created, + modified=draft.created, + draft_version_num=draft.version_num, + published_version_num=published.version_num if published else None, + published_display_name=published.title if published else None, + last_published=None if last_publish_log is None else last_publish_log.published_at, + published_by=published_by, + last_draft_created=last_draft_created, + last_draft_created_by=last_draft_created_by, + has_unpublished_changes=authoring_api.contains_unpublished_changes(container.pk), + tags_count=tags.get(str(container_key), 0), + collections=associated_collections or [], + ) + + +def library_container_locator( + library_key: LibraryLocatorV2, + container: Container, +) -> LibraryContainerLocator: + """ + Returns a LibraryContainerLocator for the given library + container. + """ + container_type = None + if hasattr(container, 'unit'): + container_type = ContainerType.Unit + elif hasattr(container, 'subsection'): + container_type = ContainerType.Subsection + elif hasattr(container, 'section'): + container_type = ContainerType.Section + else: + # This should never happen, but we assert to ensure that we handle all cases. + # If this fails, it means that a new Container type was added without updating this code. + raise ValueError(f"Unexpected container type: {container!r}") + + return LibraryContainerLocator( + library_key, + container_type=container_type.value, + container_id=container.publishable_entity.key, + ) diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index 5e576a92f117..788e9116b7e1 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -3,11 +3,10 @@ """ from __future__ import annotations -from dataclasses import dataclass from datetime import datetime, timezone -from enum import Enum import logging from uuid import uuid4 +import typing from django.utils.text import slugify from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 @@ -26,158 +25,40 @@ from openedx_learning.api import authoring as authoring_api from openedx_learning.api.authoring_models import Container, ContainerVersion, Component from openedx.core.djangoapps.content_libraries.api.collections import library_collection_locator -from openedx.core.djangoapps.content_tagging.api import get_object_tag_counts from openedx.core.djangoapps.xblock.api import get_component_from_usage_key from ..models import ContentLibrary -from .exceptions import ContentLibraryContainerNotFound -from .libraries import PublishableItem from .block_metadata import LibraryXBlockMetadata +from .container_metadata import ContainerMetadata, ContainerType, library_container_locator +from .exceptions import ContentLibraryContainerNotFound +from .serializers import ContainerSerializer + from .. import tasks +if typing.TYPE_CHECKING: + from openedx.core.djangoapps.content_staging.api import UserClipboardData + + # The public API is only the following symbols: __all__ = [ - # Models - "ContainerMetadata", - "ContainerType", - # API methods "get_container", "create_container", "get_container_children", "get_container_children_count", - "library_container_locator", "update_container", "delete_container", "restore_container", "update_container_children", "get_containers_contains_item", "publish_container_changes", + "copy_container", + "library_container_locator", ] log = logging.getLogger(__name__) -class ContainerType(Enum): - """ - The container types supported by content_libraries, and logic to map them to OLX. - """ - Unit = "unit" - Subsection = "subsection" - Section = "section" - - @property - def olx_tag(self) -> str: - """ - Canonical XML tag to use when representing this container as OLX. - - For example, Units are encoded as .... - - These tag names are historical. We keep them around for the backwards compatibility of OLX - and for easier interaction with legacy modulestore-powered structural XBlocks - (e.g., copy-paste of Units between courses and V2 libraries). - """ - match self: - case self.Unit: - return "vertical" - case self.Subsection: - return "sequential" - case self.Section: - return "chapter" - raise TypeError(f"unexpected ContainerType: {self!r}") - - @classmethod - def from_source_olx_tag(cls, olx_tag: str) -> 'ContainerType': - """ - Get the ContainerType that this OLX tag maps to. - """ - if olx_tag == "unit": - # There is an alternative implementation to VerticalBlock called UnitBlock whose - # OLX tag is . When converting from OLX, we want to handle both - # and as Unit containers, although the canonical serialization is still . - return cls.Unit - try: - return next(ct for ct in cls if olx_tag == ct.olx_tag) - except StopIteration: - raise ValueError(f"no container_type for XML tag: <{olx_tag}>") from None - - -@dataclass(frozen=True, kw_only=True) -class ContainerMetadata(PublishableItem): - """ - Class that represents the metadata about a Container (e.g. Unit) in a content library. - """ - container_key: LibraryContainerLocator - container_type: ContainerType - container_pk: int - - @classmethod - def from_container(cls, library_key, container: Container, associated_collections=None): - """ - Construct a ContainerMetadata object from a Container object. - """ - last_publish_log = container.versioning.last_publish_log - container_key = library_container_locator( - library_key, - container=container, - ) - container_type = ContainerType(container_key.container_type) - published_by = "" - if last_publish_log and last_publish_log.published_by: - published_by = last_publish_log.published_by.username - - draft = container.versioning.draft - published = container.versioning.published - last_draft_created = draft.created if draft else None - if draft and draft.publishable_entity_version.created_by: - last_draft_created_by = draft.publishable_entity_version.created_by.username - else: - last_draft_created_by = "" - tags = get_object_tag_counts(str(container_key), count_implicit=True) - - return cls( - container_key=container_key, - container_type=container_type, - container_pk=container.pk, - display_name=draft.title, - created=container.created, - modified=draft.created, - draft_version_num=draft.version_num, - published_version_num=published.version_num if published else None, - published_display_name=published.title if published else None, - last_published=None if last_publish_log is None else last_publish_log.published_at, - published_by=published_by, - last_draft_created=last_draft_created, - last_draft_created_by=last_draft_created_by, - has_unpublished_changes=authoring_api.contains_unpublished_changes(container.pk), - tags_count=tags.get(str(container_key), 0), - collections=associated_collections or [], - ) - - -def library_container_locator( - library_key: LibraryLocatorV2, - container: Container, -) -> LibraryContainerLocator: - """ - Returns a LibraryContainerLocator for the given library + container. - """ - if hasattr(container, 'unit'): - container_type = ContainerType.Unit - elif hasattr(container, 'subsection'): - container_type = ContainerType.Subsection - elif hasattr(container, 'section'): - container_type = ContainerType.Section - - assert container_type is not None - - return LibraryContainerLocator( - library_key, - container_type=container_type.value, - container_id=container.publishable_entity.key, - ) - - def _get_container_from_key(container_key: LibraryContainerLocator, isDeleted=False) -> Container: """ Internal method to fetch the Container object from its LibraryContainerLocator @@ -728,3 +609,26 @@ def publish_container_changes(container_key: LibraryContainerLocator, user_id: i # Update the search index (and anything else) for the affected container + blocks # This is mostly synchronous but may complete some work asynchronously if there are a lot of changes. tasks.wait_for_post_publish_events(publish_log, library_key) + + +def copy_container(container_key: LibraryContainerLocator, user_id: int) -> UserClipboardData: + """ + Copy a container (a Section, Subsection, or Unit) to the content staging. + """ + container_metadata = get_container(container_key) + container_serializer = ContainerSerializer(container_metadata) + block_type = ContainerType(container_key.container_type).olx_tag + + from openedx.core.djangoapps.content_staging import api as content_staging_api + + return content_staging_api.save_content_to_user_clipboard( + user_id=user_id, + block_type=block_type, + olx=container_serializer.olx_str, + display_name=container_metadata.display_name, + suggested_url_name=str(container_key), + tags=container_serializer.tags, + copied_from=container_key, + version_num=container_metadata.published_version_num, + static_files=container_serializer.static_files, + ) diff --git a/openedx/core/djangoapps/content_libraries/api/serializers.py b/openedx/core/djangoapps/content_libraries/api/serializers.py new file mode 100644 index 000000000000..7590222d4521 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/api/serializers.py @@ -0,0 +1,65 @@ +""" +Serializer classes for containers +""" +from lxml import etree + +from openedx.core.djangoapps.xblock import api as xblock_api +from openedx.core.lib.xblock_serializer.api import StaticFile, XBlockSerializer +from openedx.core.djangoapps.content_tagging.api import TagValuesByObjectIdDict, get_all_object_tags + +from . import containers as container_api + + +class ContainerSerializer: + """ + Serializes a container (a Section, Subsection, or Unit) to OLX. + """ + static_files: list[StaticFile] + tags: TagValuesByObjectIdDict + + def __init__(self, container_metadata: container_api.ContainerMetadata): + self.container_metadata = container_metadata + self.static_files = [] + self.tags = {} + olx_node = self._serialize_container(container_metadata) + + self.olx_str = etree.tostring(olx_node, encoding="unicode", pretty_print=True) + + def _serialize_container(self, container_metadata: container_api.ContainerMetadata) -> etree.Element: + """ + Serialize the given container to OLX. + """ + # Create an XML node to hold the exported data + container_type = container_api.ContainerType(container_metadata.container_key.container_type) + container_key = container_metadata.container_key + + olx = etree.Element(container_type.olx_tag) + + olx.attrib["copied_from_block"] = str(container_key) + olx.attrib["copied_from_version"] = str(container_metadata.draft_version_num) + + # Serialize the container's metadata + olx.attrib["display_name"] = container_metadata.display_name + container_tags, _ = get_all_object_tags(content_key=container_key) + self.tags.update(container_tags) + + children = container_api.get_container_children(container_metadata.container_key) + for child in children: + if isinstance(child, container_api.ContainerMetadata): + # If the child is a container, serialize it recursively + child_node = self._serialize_container(child) + olx.append(child_node) + elif isinstance(child, container_api.LibraryXBlockMetadata): + xblock = xblock_api.load_block( + child.usage_key, + user=None, + ) + xblock_serializer = XBlockSerializer( + xblock, + fetch_asset_data=True, + ) + olx.append(xblock_serializer.olx_node) + self.static_files.extend(xblock_serializer.static_files) + self.tags.update(xblock_serializer.tags) + + return olx diff --git a/openedx/core/djangoapps/content_libraries/rest_api/containers.py b/openedx/core/djangoapps/content_libraries/rest_api/containers.py index f4c7631ff036..4289fbdf4fef 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/containers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/containers.py @@ -20,6 +20,7 @@ from openedx.core.djangoapps.content_libraries import api, permissions from openedx.core.lib.api.view_utils import view_auth_classes from openedx.core.types.http import RestRequest + from . import serializers from .utils import convert_exceptions @@ -384,3 +385,28 @@ def post(self, request: RestRequest, container_key: LibraryContainerLocator) -> # If we need to in the future, we could return a list of all the child containers/components that were # auto-published as a result. return Response({}) + + +@view_auth_classes() +class LibraryContainerCopyView(GenericAPIView): + """ + View to copy a container to clipboard + """ + @convert_exceptions + def post(self, request: RestRequest, container_key: LibraryContainerLocator) -> Response: + """ + Copy a Container to clipboard + """ + api.require_permission_for_library_key( + container_key.lib_key, + request.user, + permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, + ) + assert request.user.id is not None, "User must be authenticated to copy a container" + + api.copy_container( + container_key, + user_id=request.user.id, + ) + + return Response({}) diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index 0036c208b0c7..f3ae4749e523 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -42,6 +42,7 @@ URL_LIB_CONTAINER_RESTORE = URL_LIB_CONTAINER + 'restore/' # Restore a deleted container URL_LIB_CONTAINER_COLLECTIONS = URL_LIB_CONTAINER + 'collections/' # Handle associated collections URL_LIB_CONTAINER_PUBLISH = URL_LIB_CONTAINER + 'publish/' # Publish changes to the specified container + children +URL_LIB_CONTAINER_COPY = URL_LIB_CONTAINER + 'copy/' # Copy the specified container to the clipboard URL_LIB_COLLECTION = URL_LIB_COLLECTIONS + '{collection_key}/' # Get a collection in this library URL_LIB_COLLECTION_ITEMS = URL_LIB_COLLECTION + 'items/' # Get a collection in this library @@ -465,6 +466,10 @@ def _publish_container(self, container_key: ContainerKey | str, expect_response= """ Publish all changes in the specified container + children """ return self._api('post', URL_LIB_CONTAINER_PUBLISH.format(container_key=container_key), None, expect_response) + def _copy_container(self, container_key: ContainerKey | str, expect_response=200): + """ Copy the specified container to the clipboard """ + return self._api('post', URL_LIB_CONTAINER_COPY.format(container_key=container_key), None, expect_response) + def _create_collection( self, lib_key: LibraryLocatorV2 | str, diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py index 90b0c717946c..807054883667 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_containers.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -2,6 +2,7 @@ Tests for Learning-Core-based Content Libraries """ from datetime import datetime, timezone +import textwrap import ddt from freezegun import freeze_time @@ -657,3 +658,74 @@ def test_publish_container(self) -> None: # pylint: disable=too-many-statements assert c2_components_after[1]["id"] == html_block_3["id"] assert c2_components_after[1]["has_unpublished_changes"] # unaffected assert c2_components_after[1]["published_by"] is None + + def test_copy_container(self) -> None: + """ + Test that we can copy a container and its children. + """ + tagging_api.tag_object( + self.section_with_subsections["id"], + self.taxonomy, + ['one', 'three', 'four'], + ) + tagging_api.tag_object( + self.subsection_with_units["id"], + self.taxonomy, + ['one', 'two'], + ) + tagging_api.tag_object( + self.unit_with_components["id"], + self.taxonomy, + ['one'], + ) + self._copy_container(self.section_with_subsections["id"]) + + from openedx.core.djangoapps.content_staging import api as staging_api + + clipboard_data = staging_api.get_user_clipboard(self.user.id) + + assert clipboard_data is not None + assert clipboard_data.content.display_name == "Section with subsections" + assert clipboard_data.content.status == "ready" + assert clipboard_data.content.purpose == "clipboard" + assert clipboard_data.content.block_type == "chapter" + assert str(clipboard_data.source_usage_key) == self.section_with_subsections["id"] + + # Check the tags on the clipboard content: + assert clipboard_data.content.tags == { + 'lb:CL-TEST:containers:html:Html1': {}, + 'lb:CL-TEST:containers:html:Html2': {}, + 'lb:CL-TEST:containers:problem:Problem1': {}, + 'lb:CL-TEST:containers:problem:Problem2': {}, + self.section_with_subsections["id"]: { + str(self.taxonomy.id): ['one', 'three', 'four'], + }, + self.subsection_with_units["id"]: { + str(self.taxonomy.id): ['one', 'two'], + }, + self.unit_with_components["id"]: { + str(self.taxonomy.id): ['one'], + }, + } + + # Test the actual OLX in the clipboard: + olx_data = staging_api.get_staged_content_olx(clipboard_data.content.id) + assert olx_data is not None + assert olx_data == textwrap.dedent(f"""\ + + + + + + + + + + + + + + + + + """) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_runtime.py b/openedx/core/djangoapps/content_libraries/tests/test_runtime.py index 5fe5bb4eb1fc..9cb10e514a60 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_runtime.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_runtime.py @@ -71,7 +71,7 @@ def test_html_round_trip(self): Test that if we deserialize and serialize an HTMLBlock repeatedly, two things hold true: 1. Even if the OLX changes format, the inner content does not change format. - 2. The OLX settles into a stable state after 1 round trip. + 2. The OLX settles into a stable state after 1 round trip, except for the change in the version number (We are particularly testing HTML, but it would be good to confirm that these principles hold true for XBlocks in general.) @@ -124,7 +124,10 @@ def test_html_round_trip(self): assert block_saved_1.data == block_content # ...but the serialized OLX will have changed to match the 'canonical' OLX. - olx_2 = serializer_api.serialize_xblock_to_olx(block_saved_1).olx_str + olx_2 = serializer_api.XBlockSerializer( + block_saved_1, + write_copied_from=False, # Prevent adding copied_from_block/version attributes + ).olx_str assert olx_2 == canonical_olx # Now, save that OLX back to LC, and re-load it again. @@ -135,9 +138,12 @@ def test_html_round_trip(self): # Again, content should be preserved... assert block_saved_2.data == block_saved_1.data == block_content - # ...and this time, the OLX should have settled too. - olx_3 = serializer_api.serialize_xblock_to_olx(block_saved_2).olx_str - assert olx_3 == olx_2 == canonical_olx + # ...and this time, the OLX should have settled too + olx_3 = serializer_api.XBlockSerializer( + block_saved_1, + write_copied_from=False, # Prevent adding copied_from_block/version attributes + ).olx_str + assert olx_2 == olx_3 == canonical_olx class ContentLibraryRuntimeTests(ContentLibraryContentTestMixin, TestCase): diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index 2b9cd59af7e9..aa3dfc469b8e 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -86,6 +86,7 @@ path('collections/', containers.LibraryContainerCollectionsView.as_view(), name='update-collections-ct'), # Publish a container (or reset to last published) path('publish/', containers.LibraryContainerPublishView.as_view()), + path('copy/', containers.LibraryContainerCopyView.as_view()), ])), re_path(r'^lti/1.3/', include([ path('login/', libraries.LtiToolLoginView.as_view(), name='lti-login'), diff --git a/openedx/core/djangoapps/content_staging/api.py b/openedx/core/djangoapps/content_staging/api.py index 7baae10baed4..b73d550ed8cb 100644 --- a/openedx/core/djangoapps/content_staging/api.py +++ b/openedx/core/djangoapps/content_staging/api.py @@ -10,9 +10,10 @@ from django.db import transaction from django.http import HttpRequest from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import AssetKey, UsageKey +from opaque_keys.edx.keys import AssetKey, UsageKey, ContainerKey from xblock.core import XBlock +from openedx.core.djangoapps.content_tagging.api import TagValuesByObjectIdDict from openedx.core.lib.xblock_serializer.api import StaticFile, XBlockSerializer from xmodule import block_metadata_utils from xmodule.contentstore.content import StaticContent @@ -51,6 +52,40 @@ def _save_xblock_to_staged_content( ) usage_key = block.usage_key + staged_content = _save_data_to_staged_content( + user_id=user_id, + purpose=purpose, + block_type=usage_key.block_type, + olx=block_data.olx_str, + display_name=block_metadata_utils.display_name_with_default(block), + suggested_url_name=usage_key.block_id, + tags=block_data.tags or {}, + copied_from=usage_key, + version_num=(version_num or 0), + static_files=block_data.static_files, + ) + + return staged_content + + +def _save_data_to_staged_content( + user_id: int, + purpose: str, + block_type: str, + olx: str, + display_name: str, + suggested_url_name: str, + tags: TagValuesByObjectIdDict, + copied_from: UsageKey | ContainerKey, + version_num: int | None = None, + static_files: list[StaticFile] | None = None, +) -> _StagedContent: + """ + Save arbitrary OLX data to staged content. + This is used by the library sync functionality to save OLX data + that is not associated with any XBlock. + """ + expired_ids = [] with transaction.atomic(): if purpose == CLIPBOARD_PURPOSE: @@ -71,35 +106,34 @@ def _save_xblock_to_staged_content( user_id=user_id, purpose=purpose, status=StagedContentStatus.READY, - block_type=usage_key.block_type, - olx=block_data.olx_str, - display_name=block_metadata_utils.display_name_with_default(block), - suggested_url_name=usage_key.block_id, - tags=block_data.tags or {}, + block_type=block_type, + olx=olx, + display_name=display_name, + suggested_url_name=suggested_url_name, + tags=tags or {}, version_num=(version_num or 0), ) - # Log an event so we can analyze how this feature is used: - log.info(f'Saved {usage_key.block_type} component "{usage_key}" to staged content for {purpose}.') - - # Try to copy the static files. If this fails, we still consider the overall save attempt to have succeeded, - # because intra-course operations will still work fine, and users can manually resolve file issues. - try: - _save_static_assets_to_staged_content(block_data.static_files, usage_key, staged_content) - except Exception: # pylint: disable=broad-except - log.exception(f"Unable to copy static files to staged content for component {usage_key}") + if static_files: + # Try to copy the static files. If this fails, we still consider the overall save attempt to have succeeded, + # because intra-course operations will still work fine, and users can manually resolve file issues. + try: + _save_static_assets_to_staged_content(static_files, copied_from, staged_content) + except Exception: # pylint: disable=broad-except + log.exception(f"Unable to copy static files to staged content for component {copied_from}") - # Enqueue a (potentially slow) task to delete the old staged content - try: - delete_expired_clipboards.delay(expired_ids) - except Exception: # pylint: disable=broad-except - log.exception(f"Unable to enqueue cleanup task for StagedContents: {','.join(str(x) for x in expired_ids)}") + if expired_ids: + # Enqueue a (potentially slow) task to delete the old staged content + try: + delete_expired_clipboards.delay(expired_ids) + except Exception: # pylint: disable=broad-except + log.exception(f"Unable to enqueue cleanup task for StagedContents: {','.join(str(x) for x in expired_ids)}") return staged_content def _save_static_assets_to_staged_content( - static_files: list[StaticFile], usage_key: UsageKey, staged_content: _StagedContent + static_files: list[StaticFile], usage_key: UsageKey | ContainerKey, staged_content: _StagedContent ): """ Helper method for saving static files into staged content. @@ -107,7 +141,7 @@ def _save_static_assets_to_staged_content( """ for f in static_files: source_key = ( - StaticContent.get_asset_key_from_path(usage_key.context_key, f.url) + StaticContent.get_asset_key_from_path(usage_key.context_key if usage_key else "", f.url) if (f.url and f.url.startswith('/')) else None ) # Compute the MD5 hash and get the content: @@ -166,6 +200,45 @@ def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int return _user_clipboard_model_to_data(clipboard) +def save_content_to_user_clipboard( + user_id: int, + block_type: str, + olx: str, + display_name: str, + suggested_url_name: str, + tags: TagValuesByObjectIdDict, + copied_from: UsageKey | ContainerKey, + version_num: int | None = None, + static_files: list[StaticFile] | None = None, +) -> UserClipboardData: + """ + Copy arbitrary OLX data to the user's clipboard. + """ + staged_content = _save_data_to_staged_content( + user_id=user_id, + purpose=CLIPBOARD_PURPOSE, + block_type=block_type, + olx=olx, + display_name=display_name, + suggested_url_name=suggested_url_name, + tags=tags, + copied_from=copied_from, + version_num=version_num, + static_files=static_files, + ) + + # Create/update the clipboard entry + (clipboard, _created) = _UserClipboard.objects.update_or_create( + user_id=user_id, + defaults={ + "content": staged_content, + "source_usage_key": copied_from, + }, + ) + + return _user_clipboard_model_to_data(clipboard) + + def stage_xblock_temporarily( block: XBlock, user_id: int, purpose: str, version_num: int | None = None, ) -> _StagedContent: @@ -279,7 +352,10 @@ def str_to_key(source_key_str: str): try: return AssetKey.from_string(source_key_str) except InvalidKeyError: - return UsageKey.from_string(source_key_str) + try: + return UsageKey.from_string(source_key_str) + except InvalidKeyError: + return ContainerKey.from_string(source_key_str) return [ StagedContentFileData( diff --git a/openedx/core/djangoapps/content_staging/data.py b/openedx/core/djangoapps/content_staging/data.py index d095f2506b17..95128c7f6870 100644 --- a/openedx/core/djangoapps/content_staging/data.py +++ b/openedx/core/djangoapps/content_staging/data.py @@ -7,7 +7,9 @@ from django.db.models import TextChoices from django.utils.translation import gettext_lazy as _ -from opaque_keys.edx.keys import UsageKey, AssetKey, LearningContextKey +from opaque_keys.edx.keys import UsageKey, AssetKey, LearningContextKey, ContainerKey + +from openedx.core.djangoapps.content_tagging.api import TagValuesByObjectIdDict class StagedContentStatus(TextChoices): @@ -46,7 +48,7 @@ class StagedContentData: status: StagedContentStatus = field(validator=validators.in_(StagedContentStatus), converter=StagedContentStatus) block_type: str = field(validator=validators.instance_of(str)) display_name: str = field(validator=validators.instance_of(str)) - tags: dict = field(validator=validators.optional(validators.instance_of(dict))) + tags: TagValuesByObjectIdDict = field(validator=validators.optional(validators.instance_of(dict))) version_num: int = field(validator=validators.instance_of(int)) @@ -59,8 +61,8 @@ class StagedContentFileData: # If this asset came from Files & Uploads in a course, this is an AssetKey # as a string. If this asset came from an XBlock's filesystem, this is the # UsageKey of the XBlock. - source_key: AssetKey | UsageKey | None = field( - validator=validators.optional(validators.instance_of((AssetKey, UsageKey))) + source_key: AssetKey | UsageKey | ContainerKey | None = field( + validator=validators.optional(validators.instance_of((AssetKey, UsageKey, ContainerKey))) ) md5_hash: str | None = field(validator=validators.optional(validators.instance_of(str))) @@ -69,7 +71,7 @@ class StagedContentFileData: class UserClipboardData: """ Read-only data model for User Clipboard data (copied OLX) """ content: StagedContentData = field(validator=validators.instance_of(StagedContentData)) - source_usage_key: UsageKey = field(validator=validators.instance_of(UsageKey)) # type: ignore[type-abstract] + source_usage_key: UsageKey | ContainerKey source_context_title: str @property diff --git a/openedx/core/djangoapps/content_staging/migrations/0006_alter_userclipboard_source_usage_key.py b/openedx/core/djangoapps/content_staging/migrations/0006_alter_userclipboard_source_usage_key.py new file mode 100644 index 000000000000..c7d73bd94ada --- /dev/null +++ b/openedx/core/djangoapps/content_staging/migrations/0006_alter_userclipboard_source_usage_key.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.22 on 2025-08-05 15:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('content_staging', '0005_stagedcontent_version_num'), + ] + + operations = [ + migrations.AlterField( + model_name='userclipboard', + name='source_usage_key', + field=models.CharField(help_text='Original usage key/ID of the thing that is in the clipboard.', max_length=255), + ), + migrations.RenameField( + model_name='userclipboard', + old_name='source_usage_key', + new_name='_source_usage_key', + ), + ] diff --git a/openedx/core/djangoapps/content_staging/models.py b/openedx/core/djangoapps/content_staging/models.py index 1fd02cb43784..0e7426f2389c 100644 --- a/openedx/core/djangoapps/content_staging/models.py +++ b/openedx/core/djangoapps/content_staging/models.py @@ -2,15 +2,16 @@ Models for content staging (and clipboard) """ from __future__ import annotations + import logging from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ -from opaque_keys.edx.django.models import UsageKeyField -from opaque_keys.edx.keys import LearningContextKey -from openedx_learning.lib.fields import case_insensitive_char_field, MultiCollationTextField +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import ContainerKey, LearningContextKey, UsageKey +from openedx_learning.lib.fields import MultiCollationTextField, case_insensitive_char_field from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none @@ -110,11 +111,29 @@ class UserClipboard(models.Model): # previously copied items are not kept. user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True) content = models.ForeignKey(StagedContent, on_delete=models.CASCADE) - source_usage_key = UsageKeyField( + _source_usage_key = models.CharField( max_length=255, help_text=_("Original usage key/ID of the thing that is in the clipboard."), ) + @property + def source_usage_key(self) -> UsageKey | ContainerKey: + """ Get the original usage key of the object that is in the clipboard""" + try: + return UsageKey.from_string(self._source_usage_key) + except InvalidKeyError: + try: + return ContainerKey.from_string(self._source_usage_key) + except InvalidKeyError as e: + raise ValidationError(f"Invalid source_usage_key: {self._source_usage_key}") from e + + @source_usage_key.setter + def source_usage_key(self, value: UsageKey | ContainerKey): + """ Set the original usage key of the object that is in the clipboard """ + if not isinstance(value, (UsageKey, ContainerKey)): + raise ValidationError("source_usage_key must be a UsageKey or ContainerKey.") + self._source_usage_key = str(value) + @property def source_context_key(self) -> LearningContextKey: """ Get the context (course/library) that this was copied from """ diff --git a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py index ab65d444ed6f..5d332563a8f2 100644 --- a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py +++ b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py @@ -18,6 +18,7 @@ # OLX of the video in the toy course using course_key.make_usage_key("video", "sample_video") SAMPLE_VIDEO_OLX = """