feat: copy/paste containers (units/subsections/sections) in Studio (#37008)

* feat: copy endpoint for Library Containers

* fix: make source_usage_key optional and removing upstram info for xblock olx

* test: add tests

* refactor: remove unecessary changes to reduce diff

* fix: change assert

* feat: add `write_upstream` field to ContainerSerializer

* fix: remove comment

* refactor: change `source_usage_key` type and more

* fix: try to infer the source version

* fix: InvalidKeyError while copying container with assets

* fix: read source_version from OLX

* fix: remove store check

* fix: change ident

Co-authored-by: Braden MacDonald <mail@bradenm.com>

* feat: fill source_version and make get_component_version_from_block public

* refactor: rename `source_key` to `copied_from_block`

* test: add test to `write_copied_from=false`

* fix: removing unused fallback elif

* fix: remove `copied_from_block` param

---------

Co-authored-by: Braden MacDonald <mail@bradenm.com>
This commit is contained in:
Rômulo Penido
2025-08-14 12:06:35 -03:00
committed by GitHub
parent 4d8e0556e1
commit 1a9f6e15a5
24 changed files with 666 additions and 266 deletions

View File

@@ -16,6 +16,7 @@ from django.contrib.auth import get_user_model
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

View File

@@ -3,6 +3,7 @@ Python API for working with content libraries
"""
from .block_metadata import *
from .collections import *
from .container_metadata import *
from .containers import *
from .courseware_import import *
from .exceptions import *

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -3,11 +3,10 @@ API for containers (Sections, Subsections, Units) in Content Libraries
"""
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_events.content_authoring.signals import (
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 <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.
"""
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,
)

View File

@@ -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

View File

@@ -20,6 +20,7 @@ from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_200_OK
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 @@ class LibraryContainerPublishView(GenericAPIView):
# 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({})

View File

@@ -42,6 +42,7 @@ URL_LIB_CONTAINER_CHILDREN = URL_LIB_CONTAINER + 'children/' # Get, add or dele
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 @@ class ContentLibrariesRestApiTest(APITransactionTestCase):
""" 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,

View File

@@ -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 @@ class ContainersTestCase(ContentLibrariesRestApiTest):
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"""\
<chapter copied_from_block="{self.section_with_subsections["id"]}" copied_from_version="2" display_name="Section with subsections">
<sequential copied_from_block="{self.subsection["id"]}" copied_from_version="1" display_name="Subsection Alpha"/>
<sequential copied_from_block="{self.subsection_with_units["id"]}" copied_from_version="2" display_name="Subsection with units">
<vertical copied_from_block="{self.unit["id"]}" copied_from_version="1" display_name="Alpha Bravo"/>
<vertical copied_from_block="{self.unit_with_components["id"]}" copied_from_version="2" display_name="Alpha Charly">
<problem url_name="Problem1" copied_from_block="{self.problem_block["id"]}" copied_from_version="1"/>
<html url_name="Html1" display_name="Text" copied_from_block="{self.html_block["id"]}" copied_from_version="1"><![CDATA[]]></html>
<problem url_name="Problem2" copied_from_block="{self.problem_block_2["id"]}" copied_from_version="1"/>
<html url_name="Html2" display_name="Text" copied_from_block="{self.html_block_2["id"]}" copied_from_version="1"><![CDATA[]]></html>
</vertical>
<vertical copied_from_block="{self.unit_2["id"]}" copied_from_version="1" display_name="Test Unit 2"/>
<vertical copied_from_block="{self.unit_3["id"]}" copied_from_version="1" display_name="Test Unit 3"/>
</sequential>
<sequential copied_from_block="{self.subsection_2["id"]}" copied_from_version="1" display_name="Test Subsection 2"/>
<sequential copied_from_block="{self.subsection_3["id"]}" copied_from_version="1" display_name="Test Subsection 3"/>
</chapter>
""")

View File

@@ -71,7 +71,7 @@ class ContentLibraryOlxTests(ContentLibraryContentTestMixin, TestCase):
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 @@ class ContentLibraryOlxTests(ContentLibraryContentTestMixin, TestCase):
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 @@ class ContentLibraryOlxTests(ContentLibraryContentTestMixin, TestCase):
# 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):

View File

@@ -86,6 +86,7 @@ urlpatterns = [
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'),

View File

@@ -10,9 +10,10 @@ from django.core.files.base import ContentFile
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}.')
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}")
# 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}")
# 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 get_staged_content_static_files(staged_content_id: int) -> list[StagedConten
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(

View File

@@ -7,7 +7,9 @@ from datetime import datetime
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

View File

@@ -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',
),
]

View File

@@ -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 """

View File

@@ -18,6 +18,7 @@ CLIPBOARD_ENDPOINT = "/api/content-staging/v1/clipboard/"
# OLX of the video in the toy course using course_key.make_usage_key("video", "sample_video")
SAMPLE_VIDEO_OLX = """
<video
copied_from_block="block-v1:edX+toy+2012_Fall+type@video+block@sample_video"
url_name="sample_video"
display_name="default"
youtube="0.75:JMD_ifUUfsU,1.00:OEoXaMPEzfM,1.25:AKqURZnYqpk,1.50:DYpADpL7jAY"
@@ -157,7 +158,7 @@ class ClipboardTestCase(ModuleStoreTestCase):
# For HTML, we really want to be sure that the OLX is serialized in this exact format (using CDATA), so we check
# the actual string directly rather than using assertXmlEqual():
assert olx_response.content.decode() == dedent("""
<html url_name="toyhtml" display_name="Text"><![CDATA[
<html url_name="toyhtml" display_name="Text" copied_from_block="block-v1:edX+toy+2012_Fall+type@html+block@toyhtml"><![CDATA[
<a href='/static/handouts/sample_handout.txt'>Sample</a>
]]></html>
""").replace("\n", "") + "\n" # No newlines, expect one trailing newline.
@@ -193,8 +194,9 @@ class ClipboardTestCase(ModuleStoreTestCase):
assert olx_response.status_code == 200
assert olx_response.get("Content-Type") == "application/vnd.openedx.xblock.v1.vertical+xml"
self.assertXmlEqual(olx_response.content.decode(), """
<vertical url_name="vertical_test">
<vertical url_name="vertical_test" copied_from_block="block-v1:edX+toy+2012_Fall+type@vertical+block@vertical_test">
<video
copied_from_block="block-v1:edX+toy+2012_Fall+type@video+block@sample_video"
url_name="sample_video"
display_name="default"
youtube="0.75:JMD_ifUUfsU,1.00:OEoXaMPEzfM,1.25:AKqURZnYqpk,1.50:DYpADpL7jAY"
@@ -204,6 +206,7 @@ class ClipboardTestCase(ModuleStoreTestCase):
youtube_id_1_5="DYpADpL7jAY"
/>
<video
copied_from_block="block-v1:edX+toy+2012_Fall+type@video+block@separate_file_video"
url_name="separate_file_video"
display_name="default"
youtube="0.75:JMD_ifUUfsU,1.00:OEoXaMPEzfM,1.25:AKqURZnYqpk,1.50:DYpADpL7jAY"
@@ -213,6 +216,7 @@ class ClipboardTestCase(ModuleStoreTestCase):
youtube_id_1_5="DYpADpL7jAY"
/>
<video
copied_from_block="block-v1:edX+toy+2012_Fall+type@video+block@video_with_end_time"
url_name="video_with_end_time"
display_name="default"
youtube="0.75:JMD_ifUUfsU,1.00:OEoXaMPEzfM,1.25:AKqURZnYqpk,1.50:DYpADpL7jAY"
@@ -223,6 +227,7 @@ class ClipboardTestCase(ModuleStoreTestCase):
youtube_id_1_5="DYpADpL7jAY"
/>
<poll_question
copied_from_block="block-v1:edX+toy+2012_Fall+type@poll_question+block@T1_changemind_poll_foo_2"
url_name="T1_changemind_poll_foo_2"
display_name="Change your answer"
reset="false"

View File

@@ -16,7 +16,6 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from common.djangoapps.student.auth import has_studio_read_access
from openedx.core.djangoapps.content_libraries import api as lib_api
from openedx.core.djangoapps.xblock import api as xblock_api
from openedx.core.lib.api.view_utils import view_auth_classes
from xmodule.modulestore.django import modulestore
@@ -110,6 +109,8 @@ class ClipboardEndpoint(APIView):
version_num = None
elif isinstance(course_key, LibraryLocatorV2):
from openedx.core.djangoapps.content_libraries import api as lib_api
lib_api.require_permission_for_library_key(
course_key,
request.user,

View File

@@ -207,8 +207,6 @@ def set_all_object_tags(
"""
Sets the tags for the given content object.
"""
context_key = get_context_key_from_key(content_key)
for taxonomy_id, tags_values in object_tags.items():
taxonomy = oel_tagging.get_taxonomy(taxonomy_id)

View File

@@ -50,7 +50,7 @@ from openedx.core.djangoapps.xblock.learning_context import LearningContext
log = logging.getLogger(__name__)
def get_runtime(user: UserType):
def get_runtime(user: UserType | None) -> LearningCoreXBlockRuntime:
"""
Return a new XBlockRuntime.
@@ -71,7 +71,7 @@ def get_runtime(user: UserType):
def load_block(
usage_key: UsageKeyV2,
user: UserType,
user: UserType | None,
*,
check_permission: CheckPerm | None = CheckPerm.CAN_LEARN,
version: int | LatestVersion = LatestVersion.AUTO,

View File

@@ -246,7 +246,7 @@ class LearningCoreXBlockRuntime(XBlockRuntime):
lookups one by one is going to get slow. At some point we're going to
want something to look up a bunch of blocks at once.
"""
component_version = self._get_component_version_from_block(block)
component_version = self.get_component_version_from_block(block)
# cvc = the ComponentVersionContent through-table
cvc_list = (
@@ -342,7 +342,7 @@ class LearningCoreXBlockRuntime(XBlockRuntime):
return component
def _get_component_version_from_block(self, block):
def get_component_version_from_block(self, block):
"""
Given an XBlock instance, return the Learning Core ComponentVersion.
@@ -443,7 +443,7 @@ class LearningCoreXBlockRuntime(XBlockRuntime):
not doing this yet in order to keep the code simpler, but I'm leaving
this note here in case someone needs to optimize this later.
"""
component_version = self._get_component_version_from_block(block)
component_version = self.get_component_version_from_block(block)
try:
content = (

View File

@@ -32,4 +32,4 @@ def serialize_modulestore_block_for_learning_core(block):
we have around how we should rewrite this (e.g. are we going to
remove <xblock-include>?).
"""
return XBlockSerializer(block, write_url_name=False)
return XBlockSerializer(block, write_url_name=False, write_copied_from=False)

View File

@@ -6,6 +6,7 @@ import logging
import os
from lxml import etree
from opaque_keys.edx.locator import LibraryLocatorV2
from openedx.core.djangoapps.content_tagging.api import get_all_object_tags, TagValuesByObjectIdDict
@@ -21,20 +22,23 @@ class XBlockSerializer:
"""
static_files: list[StaticFile]
tags: TagValuesByObjectIdDict
olx_node: etree.Element
olx_str: str
def __init__(self, block, write_url_name=True, fetch_asset_data=False):
def __init__(self, block, write_url_name=True, fetch_asset_data=False, write_copied_from=True):
"""
Serialize an XBlock to an OLX string + supporting files, and store the
resulting data in this object.
"""
self.write_url_name = write_url_name
self.write_copied_from = write_copied_from
self.orig_block_key = block.scope_ids.usage_id
self.static_files = []
self.tags = {}
olx_node = self._serialize_block(block)
self.olx_node = self._serialize_block(block)
self.olx_str = etree.tostring(olx_node, encoding="unicode", pretty_print=True)
self.olx_str = etree.tostring(self.olx_node, encoding="unicode", pretty_print=True)
course_key = self.orig_block_key.course_key
# Search the OLX for references to files stored in the course's
@@ -80,6 +84,15 @@ class XBlockSerializer:
if not self.write_url_name:
olx.attrib.pop("url_name", None)
# Add copied_from_block and copied_from_version attribute the XBlock's OLX node, to help identify the source of
# this block. This is used for tagging and linking back to the source library block, if applicable.
if self.write_copied_from:
olx.attrib["copied_from_block"] = str(block.usage_key)
if isinstance(block.usage_key.context_key, LibraryLocatorV2):
olx.attrib["copied_from_version"] = (
str(block.runtime.get_component_version_from_block(block).version_num)
)
# Store the block's tags
block_key = block.scope_ids.usage_id
block_id = str(block_key)
@@ -125,13 +138,9 @@ class XBlockSerializer:
if block.has_children:
self._serialize_children(block, olx_node)
# Ensure there's a url_name attribute, so we can resurrect child usage keys.
if "url_name" not in olx_node.attrib:
olx_node.attrib["url_name"] = block.scope_ids.usage_id.block_id
return olx_node
def _serialize_children(self, block, parent_olx_node):
def _serialize_children(self, block, parent_olx_node) -> None:
"""
Recursively serialize the children of XBlock 'block'.
Subclasses may override this.

View File

@@ -13,12 +13,13 @@ from openedx.core.djangoapps.content_tagging.models import TaxonomyOrg
from openedx.core.djangoapps.content_tagging import api as tagging_api
from . import api
from .block_serializer import XBlockSerializer
# The expected OLX string for the 'Toy_Videos' sequential in the toy course
EXPECTED_SEQUENTIAL_OLX = """
<sequential display_name="Toy Videos" format="Lecture Sequence" url_name="Toy_Videos">
<html url_name="secret:toylab" display_name="Toy lab"><![CDATA[
<sequential display_name="Toy Videos" format="Lecture Sequence" url_name="Toy_Videos" copied_from_block="block-v1:edX+toy+2012_Fall+type@sequential+block@Toy_Videos">
<html url_name="secret:toylab" copied_from_block="block-v1:edX+toy+2012_Fall+type@html+block@secret:toylab" display_name="Toy lab"><![CDATA[
<b>Lab 2A: Superposition Experiment</b>
@@ -32,33 +33,34 @@ And it shouldn't matter if we use entities or numeric codes &mdash; &Omega; &ne;
]]></html>
<html url_name="toyjumpto" display_name="Text"><![CDATA[
<html url_name="toyjumpto" copied_from_block="block-v1:edX+toy+2012_Fall+type@html+block@toyjumpto" display_name="Text"><![CDATA[
<a href="/jump_to_id/vertical_test">This is a link to another page and some Chinese 四節比分和七年前</a> <p>Some more Chinese 四節比分和七年前</p>
]]></html>
<html url_name="toyhtml" display_name="Text"><![CDATA[
<html url_name="toyhtml" copied_from_block="block-v1:edX+toy+2012_Fall+type@html+block@toyhtml" display_name="Text"><![CDATA[
<a href='/static/handouts/sample_handout.txt'>Sample</a>
]]></html>
<html url_name="nonportable" display_name="Text"><![CDATA[
<html url_name="nonportable" copied_from_block="block-v1:edX+toy+2012_Fall+type@html+block@nonportable" display_name="Text"><![CDATA[
<a href="/static/foo.jpg">link</a>
]]></html>
<html url_name="nonportable_link" display_name="Text"><![CDATA[
<html url_name="nonportable_link" copied_from_block="block-v1:edX+toy+2012_Fall+type@html+block@nonportable_link" display_name="Text"><![CDATA[
<a href="/jump_to_id/nonportable_link">link</a>
]]></html>
<html url_name="badlink" display_name="Text"><![CDATA[
<html url_name="badlink" display_name="Text" copied_from_block="block-v1:edX+toy+2012_Fall+type@html+block@badlink"><![CDATA[
<img src="/static//file.jpg" />
]]></html>
<html url_name="with_styling" display_name="Text"><![CDATA[
<html url_name="with_styling" display_name="Text" copied_from_block="block-v1:edX+toy+2012_Fall+type@html+block@with_styling"><![CDATA[
<p style="font:italic bold 72px/30px Georgia, serif; color: red; ">Red text here</p>
]]></html>
<html url_name="just_img" display_name="Text"><![CDATA[
<html url_name="just_img" display_name="Text" copied_from_block="block-v1:edX+toy+2012_Fall+type@html+block@just_img"><![CDATA[
<img src="/static/foo_bar.jpg" />
]]></html>
<video
copied_from_block="block-v1:edX+toy+2012_Fall+type@video+block@Video_Resources"
display_name="Video Resources"
url_name="Video_Resources"
youtube="1.00:1bK-WdDi6Qw"
@@ -119,8 +121,12 @@ class XBlockSerializationTestCase(SharedModuleStoreTestCase):
self.assertXmlEqual(
serialized.olx_str,
"""
<html display_name="Text" url_name="just_img"><![CDATA[
f"""
<html
copied_from_block="{str(block_id)}"
display_name="Text"
url_name="just_img"
><![CDATA[
<img src="/static/foo_bar.jpg" />
]]></html>
"""
@@ -170,8 +176,9 @@ class XBlockSerializationTestCase(SharedModuleStoreTestCase):
serialized = api.serialize_xblock_to_olx(html_block)
self.assertXmlEqual(
serialized.olx_str,
"""
f"""
<html
copied_from_block="{str(html_block.location)}"
url_name="Non-default_HTML_Block"
display_name="Non-default HTML Block"
editor="raw"
@@ -223,8 +230,13 @@ class XBlockSerializationTestCase(SharedModuleStoreTestCase):
assert not serialized.static_files
self.assertXmlEqual(
serialized.olx_str,
"""
<problem display_name="Problem No Python" url_name="Problem_No_Python" max_attempts="3">
f"""
<problem
copied_from_block="{regular_problem.location}"
display_name="Problem No Python"
url_name="Problem_No_Python"
max_attempts="3"
>
<optionresponse></optionresponse>
</problem>
"""
@@ -237,8 +249,12 @@ class XBlockSerializationTestCase(SharedModuleStoreTestCase):
assert serialized.static_files[0].name == "python_lib.zip"
self.assertXmlEqual(
serialized.olx_str,
"""
<problem display_name="Python Problem" url_name="Python_Problem">
f"""
<problem
copied_from_block="{python_problem.location}"
display_name="Python Problem"
url_name="Python_Problem"
>
This uses python: <script type="text/python">...</script>...
</problem>
"""
@@ -280,8 +296,12 @@ class XBlockSerializationTestCase(SharedModuleStoreTestCase):
self.assertXmlEqual(
serialized.olx_str,
"""
<problem display_name="JSInput Problem" url_name="JSInput_Problem">
f"""
<problem
copied_from_block="{jsinput_problem.location}"
display_name="JSInput Problem"
url_name="JSInput_Problem"
>
<jsinput html_file='/static/simple-question.html' />
</problem>
"""
@@ -314,8 +334,9 @@ class XBlockSerializationTestCase(SharedModuleStoreTestCase):
serialized = api.serialize_xblock_to_olx(unit)
self.assertXmlEqual(
serialized.olx_str,
"""
f"""
<vertical
copied_from_block="{str(unit.location)}"
display_name="Tagged Unit"
url_name="Tagged_Unit"
/>
@@ -362,8 +383,9 @@ class XBlockSerializationTestCase(SharedModuleStoreTestCase):
serialized = api.serialize_xblock_to_olx(html_block)
self.assertXmlEqual(
serialized.olx_str,
"""
f"""
<html
copied_from_block="{str(html_block.location)}"
url_name="Tagged_Non-default_HTML_Block"
display_name="Tagged Non-default HTML Block"
editor="raw"
@@ -436,8 +458,9 @@ class XBlockSerializationTestCase(SharedModuleStoreTestCase):
serialized = api.serialize_xblock_to_olx(regular_problem)
self.assertXmlEqual(
serialized.olx_str,
"""
f"""
<problem
copied_from_block="{str(regular_problem.location)}"
display_name="Tagged Problem No Python"
url_name="Tagged_Problem_No_Python"
max_attempts="3"
@@ -458,8 +481,9 @@ class XBlockSerializationTestCase(SharedModuleStoreTestCase):
serialized = api.serialize_xblock_to_olx(python_problem)
self.assertXmlEqual(
serialized.olx_str,
"""
f"""
<problem
copied_from_block="{str(python_problem.location)}"
display_name="Tagged Python Problem"
url_name="Tagged_Python_Problem"
>
@@ -503,6 +527,7 @@ class XBlockSerializationTestCase(SharedModuleStoreTestCase):
serialized.olx_str,
f"""
<library_content
copied_from_block="{str(lc_block.location)}"
display_name="Tagged LC Block"
max_count="1"
source_library_id="{str(lib.location.library_key)}"
@@ -538,8 +563,9 @@ class XBlockSerializationTestCase(SharedModuleStoreTestCase):
serialized = api.serialize_xblock_to_olx(video_block)
self.assertXmlEqual(
serialized.olx_str,
"""
f"""
<video
copied_from_block="{str(video_block.location)}"
youtube="1.00:3_yD_cEKoCk"
url_name="Tagged_Video_Block"
display_name="Tagged Video Block"
@@ -582,3 +608,31 @@ class XBlockSerializationTestCase(SharedModuleStoreTestCase):
self.taxonomy1.id: ["normal tag", "<special \"'-=,. |= chars > tag", "anotherTag"],
}
})
def test_write_copied_from_false(self):
"""
Test that using `write_copied_from=False` in XBlockSerializer
does not write copied_from_block or copied_from_version
"""
course = CourseFactory.create(display_name='Copied From False Test course', run="CFTC")
video_block = BlockFactory.create(
parent_location=course.location,
category="video",
display_name="Video Block",
)
serialized = XBlockSerializer(
video_block,
write_copied_from=False, # Disable copied_from_block and copied_from_version
)
self.assertXmlEqual(
serialized.olx_str,
"""
<video
youtube="1.00:3_yD_cEKoCk"
url_name="Video_Block"
display_name="Video Block"
/>
"""
)