fix: static assets used in problem bank and library content block (#36173)

Static assets were not being copied into the course when using library content via Problem Bank or "Add Library Content" workflows.
This commit is contained in:
Daniel Valenzuela
2025-02-07 15:30:05 -03:00
committed by GitHub
parent 02d2d34a25
commit 36c16d6952
7 changed files with 165 additions and 68 deletions

View File

@@ -14,29 +14,36 @@ from opaque_keys.edx.keys import AssetKey, UsageKey
from xblock.core import XBlock
from openedx.core.lib.xblock_serializer.api import StaticFile, XBlockSerializer
from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none
from xmodule import block_metadata_utils
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
from .data import (
CLIPBOARD_PURPOSE,
StagedContentData, StagedContentFileData, StagedContentStatus, UserClipboardData
StagedContentData,
StagedContentFileData,
StagedContentStatus,
UserClipboardData,
)
from .models import (
UserClipboard as _UserClipboard,
StagedContent as _StagedContent,
StagedContentFile as _StagedContentFile,
)
from .serializers import UserClipboardSerializer as _UserClipboardSerializer
from .serializers import (
UserClipboardSerializer as _UserClipboardSerializer,
)
from .tasks import delete_expired_clipboards
log = logging.getLogger(__name__)
def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int | None = None) -> UserClipboardData:
def _save_xblock_to_staged_content(
block: XBlock, user_id: int, purpose: str, version_num: int | None = None
) -> _StagedContent:
"""
Copy an XBlock's OLX to the user's clipboard.
Generic function to save an XBlock's OLX to staged content.
Used by both clipboard and library sync functionality.
"""
block_data = XBlockSerializer(
block,
@@ -49,7 +56,7 @@ def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int
# Mark all of the user's existing StagedContent rows as EXPIRED
to_expire = _StagedContent.objects.filter(
user_id=user_id,
purpose=CLIPBOARD_PURPOSE,
purpose=purpose,
).exclude(
status=StagedContentStatus.EXPIRED,
)
@@ -60,7 +67,7 @@ def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int
# Insert a new StagedContent row for this
staged_content = _StagedContent.objects.create(
user_id=user_id,
purpose=CLIPBOARD_PURPOSE,
purpose=purpose,
status=StagedContentStatus.READY,
block_type=usage_key.block_type,
olx=block_data.olx_str,
@@ -69,23 +76,16 @@ def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int
tags=block_data.tags or {},
version_num=(version_num or 0),
)
(clipboard, _created) = _UserClipboard.objects.update_or_create(user_id=user_id, defaults={
"content": staged_content,
"source_usage_key": usage_key,
})
# Log an event so we can analyze how this feature is used:
log.info(f"Copied {usage_key.block_type} component \"{usage_key}\" to their clipboard.")
log.info(f'Saved {usage_key.block_type} component "{usage_key}" to staged content for {purpose}.')
# Try to copy the static files. If this fails, we still consider the overall copy attempt to have succeeded,
# because intra-course pasting will still work fine, and in any case users can manually resolve the file issue.
# 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_user_clipboard(block_data.static_files, usage_key, staged_content)
_save_static_assets_to_staged_content(block_data.static_files, usage_key, staged_content)
except Exception: # pylint: disable=broad-except
# Regardless of what happened, with get_asset_key_from_path or contentstore or run_filter, we don't want the
# whole "copy to clipboard" operation to fail, which would be a bad user experience. For copying and pasting
# within a single course, static assets don't even matter. So any such errors become warnings here.
log.exception(f"Unable to copy static files to clipboard for component {usage_key}")
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:
@@ -93,14 +93,15 @@ def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int
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 _user_clipboard_model_to_data(clipboard)
return staged_content
def _save_static_assets_to_user_clipboard(
def _save_static_assets_to_staged_content(
static_files: list[StaticFile], usage_key: UsageKey, staged_content: _StagedContent
):
"""
Helper method for save_xblock_to_user_clipboard endpoint. This deals with copying static files into the clipboard.
Helper method for saving static files into staged content.
Used by both clipboard and library sync functionality.
"""
for f in static_files:
source_key = (
@@ -144,6 +145,37 @@ def _save_static_assets_to_user_clipboard(
log.exception(f"Unable to copy static file {f.name} to clipboard for component {usage_key}")
def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int | None = None) -> UserClipboardData:
"""
Copy an XBlock's OLX to the user's clipboard.
"""
staged_content = _save_xblock_to_staged_content(block, user_id, CLIPBOARD_PURPOSE, version_num)
usage_key = block.usage_key
# Create/update the clipboard entry
(clipboard, _created) = _UserClipboard.objects.update_or_create(
user_id=user_id,
defaults={
"content": staged_content,
"source_usage_key": usage_key,
},
)
return _user_clipboard_model_to_data(clipboard)
def stage_xblock_temporarily(
block: XBlock, user_id: int, purpose: str, version_num: int | None = None,
) -> _StagedContent:
"""
"Stage" an XBlock by copying it (and its associated children + static assets)
into the content staging area. This XBlock can then be accessed and manipulated
using any of the staged content APIs, before being deleted.
"""
staged_content = _save_xblock_to_staged_content(block, user_id, purpose, version_num)
return staged_content
def get_user_clipboard(user_id: int, only_ready: bool = True) -> UserClipboardData | None:
"""
Get the details of the user's clipboard.
@@ -190,28 +222,29 @@ def get_user_clipboard_json(user_id: int, request: HttpRequest | None = None):
return serializer.data
def _staged_content_to_data(content: _StagedContent) -> StagedContentData:
"""
Convert a StagedContent model instance to an immutable data object.
"""
return StagedContentData(
id=content.id,
user_id=content.user_id,
created=content.created,
purpose=content.purpose,
status=content.status,
block_type=content.block_type,
display_name=content.display_name,
tags=content.tags or {},
version_num=content.version_num,
)
def _user_clipboard_model_to_data(clipboard: _UserClipboard) -> UserClipboardData:
"""
Convert a UserClipboard model instance to an immutable data object.
"""
content = clipboard.content
source_context_key = clipboard.source_usage_key.context_key
if source_context_key.is_course and (course_overview := get_course_overview_or_none(source_context_key)):
source_context_title = course_overview.display_name_with_default
else:
source_context_title = str(source_context_key) # Fall back to stringified context key as a title
return UserClipboardData(
content=StagedContentData(
id=content.id,
user_id=content.user_id,
created=content.created,
purpose=content.purpose,
status=content.status,
block_type=content.block_type,
display_name=content.display_name,
tags=content.tags or {},
version_num=content.version_num,
),
content=_staged_content_to_data(clipboard.content),
source_usage_key=clipboard.source_usage_key,
source_context_title=clipboard.get_source_context_title(),
)

View File

@@ -25,6 +25,10 @@ class StagedContentStatus(TextChoices):
# Value of the "purpose" field on StagedContent objects used for clipboards.
CLIPBOARD_PURPOSE = "clipboard"
# Value of the "purpose" field on StagedContent objects used for library to course sync.
LIBRARY_SYNC_PURPOSE = "library_sync"
# There may be other valid values of "purpose" which aren't defined within this app.