Skip to content
Merged
Show file tree
Hide file tree
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 Jul 16, 2025
5ac9bcf
fix: make source_usage_key optional and removing upstram info for xbl…
rpenido Jul 24, 2025
7e78a72
test: add tests
rpenido Jul 28, 2025
1ae434e
refactor: remove unecessary changes to reduce diff
rpenido Jul 28, 2025
f6e46b3
fix: change assert
rpenido Jul 29, 2025
ca9ab98
feat: add `write_upstream` field to ContainerSerializer
rpenido Jul 29, 2025
8482ddb
fix: remove comment
rpenido Aug 5, 2025
857a7da
refactor: change `source_usage_key` type and more
rpenido Aug 5, 2025
815b33e
fix: try to infer the source version
rpenido Aug 5, 2025
13670bf
fix: InvalidKeyError while copying container with assets
rpenido Aug 6, 2025
893cb59
fix: read source_version from OLX
rpenido Aug 7, 2025
0e5689c
Merge branch 'master' into rpenido/fal-4221/copy-paste-containers
rpenido Aug 7, 2025
8b3660e
fix: remove store check
rpenido Aug 8, 2025
2cdafb2
fix: change ident
rpenido Aug 8, 2025
9b4efbd
feat: fill source_version and make get_component_version_from_block p…
rpenido Aug 8, 2025
fe653c8
Merge branch 'master' into rpenido/fal-4221/copy-paste-containers
rpenido Aug 12, 2025
2a6a1f1
refactor: rename `source_key` to `copied_from_block`
rpenido Aug 12, 2025
6f1fecc
test: add test to `write_copied_from=false`
rpenido Aug 12, 2025
30d5248
Merge branch 'master' into rpenido/fal-4221/copy-paste-containers
rpenido Aug 12, 2025
4f3e0bc
fix: removing unused fallback elif
rpenido Aug 13, 2025
70980c8
Merge branch 'master' into rpenido/fal-4221/copy-paste-containers
rpenido Aug 13, 2025
4e21349
fix: remove `copied_from_block` param
rpenido Aug 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 41 additions & 53 deletions cms/djangoapps/contentstore/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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


Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 url_name to get the source_key for this node.
Now, we are explicitly passing the source_key, so we no longer need it.

def concat_static_file_notices(notices: list[StaticFileNotices]) -> StaticFileNotices:
"""Combines multiple static file notices into a single object

Expand Down
1 change: 1 addition & 0 deletions openedx/core/djangoapps/content_libraries/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion openedx/core/djangoapps/content_libraries/api/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
147 changes: 147 additions & 0 deletions openedx/core/djangoapps/content_libraries/api/container_metadata.py
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,
)
Loading
Loading