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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 *
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
65
openedx/core/djangoapps/content_libraries/api/serializers.py
Normal file
65
openedx/core/djangoapps/content_libraries/api/serializers.py
Normal 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
|
||||
@@ -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({})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
""")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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 """
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 — Ω ≠
|
||||
|
||||
|
||||
]]></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"
|
||||
/>
|
||||
"""
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user