-
Notifications
You must be signed in to change notification settings - Fork 4.2k
feat: copy/paste containers #37008
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
bradenmacdonald
merged 22 commits into
openedx:master
from
open-craft:rpenido/fal-4221/copy-paste-containers
Aug 14, 2025
Merged
feat: copy/paste containers #37008
Changes from all commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
857d0b4
feat: copy endpoint for Library Containers
rpenido 5ac9bcf
fix: make source_usage_key optional and removing upstram info for xbl…
rpenido 7e78a72
test: add tests
rpenido 1ae434e
refactor: remove unecessary changes to reduce diff
rpenido f6e46b3
fix: change assert
rpenido ca9ab98
feat: add `write_upstream` field to ContainerSerializer
rpenido 8482ddb
fix: remove comment
rpenido 857a7da
refactor: change `source_usage_key` type and more
rpenido 815b33e
fix: try to infer the source version
rpenido 13670bf
fix: InvalidKeyError while copying container with assets
rpenido 893cb59
fix: read source_version from OLX
rpenido 0e5689c
Merge branch 'master' into rpenido/fal-4221/copy-paste-containers
rpenido 8b3660e
fix: remove store check
rpenido 2cdafb2
fix: change ident
rpenido 9b4efbd
feat: fill source_version and make get_component_version_from_block p…
rpenido fe653c8
Merge branch 'master' into rpenido/fal-4221/copy-paste-containers
rpenido 2a6a1f1
refactor: rename `source_key` to `copied_from_block`
rpenido 6f1fecc
test: add test to `write_copied_from=false`
rpenido 30d5248
Merge branch 'master' into rpenido/fal-4221/copy-paste-containers
rpenido 4f3e0bc
fix: removing unused fallback elif
rpenido 70980c8
Merge branch 'master' into rpenido/fal-4221/copy-paste-containers
rpenido 4e21349
fix: remove `copied_from_block` param
rpenido File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
||
|
|
||
|
Comment on lines
-797
to
-817
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was used to join the parent context key to a node's |
||
| def concat_static_file_notices(notices: list[StaticFileNotices]) -> StaticFileNotices: | ||
| """Combines multiple static file notices into a single object | ||
|
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
4 changes: 1 addition & 3 deletions
4
openedx/core/djangoapps/content_libraries/api/block_metadata.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
147 changes: 147 additions & 0 deletions
147
openedx/core/djangoapps/content_libraries/api/container_metadata.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <vertical>...</vertical>. | ||
|
|
||
| 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 <unit>. When converting from OLX, we want to handle both <vertical> | ||
| # and <unit> as Unit containers, although the canonical serialization is still <vertical>. | ||
| 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, | ||
| ) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.