Skip to content

Commit a730dad

Browse files
committed
fix: static assets in problem bank and library content block
1 parent a5b9b87 commit a730dad

File tree

9 files changed

+272
-43
lines changed

9 files changed

+272
-43
lines changed

cms/djangoapps/contentstore/helpers.py

+43
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,49 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
323323

324324
return new_xblock, notices
325325

326+
def import_staged_content_for_library_sync(new_xblock: XBlock, lib_block: XBlock, request) -> StaticFileNotices:
327+
"""
328+
Import a block (along with its children and any required static assets) from
329+
the "staged" OLX in the user's clipboard.
330+
331+
Does not deal with permissions or REST stuff - do that before calling this.
332+
333+
Returns (1) the newly created block on success or None if the clipboard is
334+
empty, and (2) a summary of changes made to static files in the destination
335+
course.
336+
"""
337+
if not content_staging_api:
338+
raise RuntimeError("The required content_staging app is not installed")
339+
library_sync = content_staging_api.save_xblock_to_user_library_sync(lib_block, request.user.id)
340+
if not library_sync:
341+
# expired/error/loading
342+
return StaticFileNotices()
343+
344+
store = modulestore()
345+
with store.bulk_operations(new_xblock.scope_ids.usage_id.context_key):
346+
# Now handle static files that need to go into Files & Uploads.
347+
static_files = content_staging_api.get_staged_content_static_files(library_sync.content.id)
348+
notices, substitutions = _import_files_into_course(
349+
course_key=new_xblock.scope_ids.usage_id.context_key,
350+
staged_content_id=library_sync.content.id,
351+
static_files=static_files,
352+
usage_key=new_xblock.scope_ids.usage_id,
353+
)
354+
355+
# Rewrite the OLX's static asset references to point to the new
356+
# locations for those assets. See _import_files_into_course for more
357+
# info on why this is necessary.
358+
if hasattr(new_xblock, 'data') and substitutions:
359+
data_with_substitutions = new_xblock.data
360+
for old_static_ref, new_static_ref in substitutions.items():
361+
data_with_substitutions = data_with_substitutions.replace(
362+
old_static_ref,
363+
new_static_ref,
364+
)
365+
new_xblock.data = data_with_substitutions
366+
367+
return notices
368+
326369

327370
def _fetch_and_set_upstream_link(
328371
copied_from_block: str,

cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,10 @@
5656
"ready_to_sync": Boolean
5757
}
5858
"""
59+
from dataclasses import asdict
5960
import logging
6061

62+
from attrs import asdict as attrs_asdict
6163
from django.contrib.auth.models import User # pylint: disable=imported-auth-user
6264
from opaque_keys import InvalidKeyError
6365
from opaque_keys.edx.keys import UsageKey
@@ -71,6 +73,7 @@
7173
UpstreamLink, UpstreamLinkException, NoUpstream, BadUpstream, BadDownstream,
7274
fetch_customizable_fields, sync_from_upstream, decline_sync, sever_upstream_link
7375
)
76+
from cms.djangoapps.contentstore.helpers import import_staged_content_for_library_sync
7477
from common.djangoapps.student.auth import has_studio_write_access, has_studio_read_access
7578
from openedx.core.lib.api.view_utils import (
7679
DeveloperErrorViewMixin,
@@ -195,7 +198,8 @@ def post(self, request: _AuthenticatedRequest, usage_key_string: str) -> Respons
195198
"""
196199
downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
197200
try:
198-
sync_from_upstream(downstream, request.user)
201+
upstream = sync_from_upstream(downstream, request.user)
202+
static_file_notices = import_staged_content_for_library_sync(downstream, upstream, request)
199203
except UpstreamLinkException as exc:
200204
logger.exception(
201205
"Could not sync from upstream '%s' to downstream '%s'",
@@ -206,7 +210,9 @@ def post(self, request: _AuthenticatedRequest, usage_key_string: str) -> Respons
206210
modulestore().update_item(downstream, request.user.id)
207211
# Note: We call `get_for_block` (rather than `try_get_for_block`) because if anything is wrong with the
208212
# upstream at this point, then that is completely unexpected, so it's appropriate to let the 500 happen.
209-
return Response(UpstreamLink.get_for_block(downstream).to_json())
213+
response = UpstreamLink.get_for_block(downstream).to_json()
214+
response["static_file_notices"] = attrs_asdict(static_file_notices)
215+
return Response(response)
210216

211217
def delete(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
212218
"""

cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
from ..helpers import (
8181
get_parent_xblock,
8282
import_staged_content_from_user_clipboard,
83+
import_staged_content_for_library_sync,
8384
is_unit,
8485
xblock_embed_lms_url,
8586
xblock_lms_url,
@@ -598,16 +599,18 @@ def _create_block(request):
598599
try:
599600
# Set `created_block.upstream` and then sync this with the upstream (library) version.
600601
created_block.upstream = upstream_ref
601-
sync_from_upstream(downstream=created_block, user=request.user)
602+
lib_block = sync_from_upstream(downstream=created_block, user=request.user)
602603
except BadUpstream as exc:
603604
_delete_item(created_block.location, request.user)
604605
log.exception(
605606
f"Could not sync to new block at '{created_block.usage_key}' "
606607
f"using provided library_content_key='{upstream_ref}'"
607608
)
608609
return JsonResponse({"error": str(exc)}, status=400)
610+
static_file_notices = import_staged_content_for_library_sync(created_block, lib_block, request)
609611
modulestore().update_item(created_block, request.user.id)
610612
response['upstreamRef'] = upstream_ref
613+
response['static_file_notices'] = asdict(static_file_notices)
611614

612615
return JsonResponse(response)
613616

cms/lib/xblock/upstream_sync.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ def get_for_block(cls, downstream: XBlock) -> t.Self:
186186
)
187187

188188

189-
def sync_from_upstream(downstream: XBlock, user: User) -> None:
189+
def sync_from_upstream(downstream: XBlock, user: User) -> XBlock:
190190
"""
191191
Update `downstream` with content+settings from the latest available version of its linked upstream content.
192192
@@ -200,6 +200,7 @@ def sync_from_upstream(downstream: XBlock, user: User) -> None:
200200
_update_non_customizable_fields(upstream=upstream, downstream=downstream)
201201
_update_tags(upstream=upstream, downstream=downstream)
202202
downstream.upstream_version = link.version_available
203+
return upstream
203204

204205

205206
def fetch_customizable_fields(*, downstream: XBlock, user: User, upstream: XBlock | None = None) -> None:

openedx/core/djangoapps/content_staging/api.py

+123-38
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,33 @@
2121

2222
from .data import (
2323
CLIPBOARD_PURPOSE,
24-
StagedContentData, StagedContentFileData, StagedContentStatus, UserClipboardData
24+
LIBRARY_SYNC_PURPOSE,
25+
StagedContentData, StagedContentFileData, StagedContentStatus, UserClipboardData, UserLibrarySyncData,
2526
)
2627
from .models import (
2728
UserClipboard as _UserClipboard,
2829
StagedContent as _StagedContent,
2930
StagedContentFile as _StagedContentFile,
31+
UserLibrarySync as _UserLibrarySync,
32+
)
33+
from .serializers import (
34+
UserClipboardSerializer as _UserClipboardSerializer,
35+
UserLibrarySyncSerializer as _UserLibrarySyncSerializer,
3036
)
31-
from .serializers import UserClipboardSerializer as _UserClipboardSerializer
3237
from .tasks import delete_expired_clipboards
3338

3439
log = logging.getLogger(__name__)
3540

3641

37-
def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int | None = None) -> UserClipboardData:
42+
def _save_xblock_to_staged_content(
43+
block: XBlock,
44+
user_id: int,
45+
purpose: str,
46+
version_num: int | None = None
47+
) -> _StagedContent:
3848
"""
39-
Copy an XBlock's OLX to the user's clipboard.
49+
Generic function to save an XBlock's OLX to staged content.
50+
Used by both clipboard and library sync functionality.
4051
"""
4152
block_data = XBlockSerializer(
4253
block,
@@ -49,7 +60,7 @@ def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int
4960
# Mark all of the user's existing StagedContent rows as EXPIRED
5061
to_expire = _StagedContent.objects.filter(
5162
user_id=user_id,
52-
purpose=CLIPBOARD_PURPOSE,
63+
purpose=purpose,
5364
).exclude(
5465
status=StagedContentStatus.EXPIRED,
5566
)
@@ -60,7 +71,7 @@ def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int
6071
# Insert a new StagedContent row for this
6172
staged_content = _StagedContent.objects.create(
6273
user_id=user_id,
63-
purpose=CLIPBOARD_PURPOSE,
74+
purpose=purpose,
6475
status=StagedContentStatus.READY,
6576
block_type=usage_key.block_type,
6677
olx=block_data.olx_str,
@@ -69,38 +80,32 @@ def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int
6980
tags=block_data.tags or {},
7081
version_num=(version_num or 0),
7182
)
72-
(clipboard, _created) = _UserClipboard.objects.update_or_create(user_id=user_id, defaults={
73-
"content": staged_content,
74-
"source_usage_key": usage_key,
75-
})
7683

7784
# Log an event so we can analyze how this feature is used:
78-
log.info(f"Copied {usage_key.block_type} component \"{usage_key}\" to their clipboard.")
85+
log.info(f"Saved {usage_key.block_type} component \"{usage_key}\" to staged content for {purpose}.")
7986

80-
# Try to copy the static files. If this fails, we still consider the overall copy attempt to have succeeded,
81-
# because intra-course pasting will still work fine, and in any case users can manually resolve the file issue.
87+
# Try to copy the static files. If this fails, we still consider the overall save attempt to have succeeded,
88+
# because intra-course operations will still work fine, and users can manually resolve file issues.
8289
try:
83-
_save_static_assets_to_user_clipboard(block_data.static_files, usage_key, staged_content)
90+
_save_static_assets_to_staged_content(block_data.static_files, usage_key, staged_content)
8491
except Exception: # pylint: disable=broad-except
85-
# Regardless of what happened, with get_asset_key_from_path or contentstore or run_filter, we don't want the
86-
# whole "copy to clipboard" operation to fail, which would be a bad user experience. For copying and pasting
87-
# within a single course, static assets don't even matter. So any such errors become warnings here.
88-
log.exception(f"Unable to copy static files to clipboard for component {usage_key}")
92+
log.exception(f"Unable to copy static files to staged content for component {usage_key}")
8993

9094
# Enqueue a (potentially slow) task to delete the old staged content
9195
try:
9296
delete_expired_clipboards.delay(expired_ids)
9397
except Exception: # pylint: disable=broad-except
9498
log.exception(f"Unable to enqueue cleanup task for StagedContents: {','.join(str(x) for x in expired_ids)}")
9599

96-
return _user_clipboard_model_to_data(clipboard)
100+
return staged_content
97101

98102

99-
def _save_static_assets_to_user_clipboard(
103+
def _save_static_assets_to_staged_content(
100104
static_files: list[StaticFile], usage_key: UsageKey, staged_content: _StagedContent
101105
):
102106
"""
103-
Helper method for save_xblock_to_user_clipboard endpoint. This deals with copying static files into the clipboard.
107+
Helper method for saving static files into staged content.
108+
Used by both clipboard and library sync functionality.
104109
"""
105110
for f in static_files:
106111
source_key = (
@@ -144,6 +149,46 @@ def _save_static_assets_to_user_clipboard(
144149
log.exception(f"Unable to copy static file {f.name} to clipboard for component {usage_key}")
145150

146151

152+
def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int | None = None) -> UserClipboardData:
153+
"""
154+
Copy an XBlock's OLX to the user's clipboard.
155+
"""
156+
staged_content = _save_xblock_to_staged_content(block, user_id, CLIPBOARD_PURPOSE, version_num)
157+
usage_key = block.usage_key
158+
159+
# Create/update the clipboard entry
160+
(clipboard, _created) = _UserClipboard.objects.update_or_create(
161+
user_id=user_id,
162+
defaults={
163+
"content": staged_content,
164+
"source_usage_key": usage_key,
165+
}
166+
)
167+
168+
return _user_clipboard_model_to_data(clipboard)
169+
170+
def save_xblock_to_user_library_sync(
171+
block: XBlock,
172+
user_id: int,
173+
version_num: int | None = None
174+
) -> UserLibrarySyncData:
175+
"""
176+
Save an XBlock's OLX for library sync.
177+
"""
178+
staged_content = _save_xblock_to_staged_content(block, user_id, LIBRARY_SYNC_PURPOSE, version_num)
179+
usage_key = block.usage_key
180+
181+
# Create/update the library sync entry
182+
(sync, _created) = _UserLibrarySync.objects.update_or_create(
183+
user_id=user_id,
184+
defaults={
185+
"content": staged_content,
186+
"source_usage_key": usage_key,
187+
}
188+
)
189+
190+
return _user_library_sync_model_to_data(sync)
191+
147192
def get_user_clipboard(user_id: int, only_ready: bool = True) -> UserClipboardData | None:
148193
"""
149194
Get the details of the user's clipboard.
@@ -190,32 +235,72 @@ def get_user_clipboard_json(user_id: int, request: HttpRequest | None = None):
190235
return serializer.data
191236

192237

238+
def get_user_library_sync_json(user_id: int, request: HttpRequest | None = None):
239+
"""
240+
Get the detailed status of the user's library sync, in exactly the same format
241+
as returned from the
242+
/api/content-staging/v1/library-sync/
243+
REST API endpoint. This version of the API is meant for "preloading" that
244+
REST API endpoint so it can be embedded in a larger response sent to the
245+
user's browser.
246+
247+
(request is optional; including it will make the "olx_url" absolute instead
248+
of relative.)
249+
"""
250+
try:
251+
sync = _UserLibrarySync.objects.get(user_id=user_id)
252+
except _UserLibrarySync.DoesNotExist:
253+
# This user does not have any library sync content.
254+
return {
255+
"content": None,
256+
"source_usage_key": "",
257+
"source_context_title": "",
258+
"source_edit_url": ""
259+
}
260+
261+
serializer = _UserLibrarySyncSerializer(
262+
_user_library_sync_model_to_data(sync),
263+
context={'request': request},
264+
)
265+
return serializer.data
266+
267+
268+
def _staged_content_to_data(content: _StagedContent) -> StagedContentData:
269+
"""
270+
Convert a StagedContent model instance to an immutable data object.
271+
"""
272+
return StagedContentData(
273+
id=content.id,
274+
user_id=content.user_id,
275+
created=content.created,
276+
purpose=content.purpose,
277+
status=content.status,
278+
block_type=content.block_type,
279+
display_name=content.display_name,
280+
tags=content.tags or {},
281+
version_num=content.version_num,
282+
)
283+
193284
def _user_clipboard_model_to_data(clipboard: _UserClipboard) -> UserClipboardData:
194285
"""
195286
Convert a UserClipboard model instance to an immutable data object.
196287
"""
197-
content = clipboard.content
198-
source_context_key = clipboard.source_usage_key.context_key
199-
if source_context_key.is_course and (course_overview := get_course_overview_or_none(source_context_key)):
200-
source_context_title = course_overview.display_name_with_default
201-
else:
202-
source_context_title = str(source_context_key) # Fall back to stringified context key as a title
203288
return UserClipboardData(
204-
content=StagedContentData(
205-
id=content.id,
206-
user_id=content.user_id,
207-
created=content.created,
208-
purpose=content.purpose,
209-
status=content.status,
210-
block_type=content.block_type,
211-
display_name=content.display_name,
212-
tags=content.tags or {},
213-
version_num=content.version_num,
214-
),
289+
content=_staged_content_to_data(clipboard.content),
215290
source_usage_key=clipboard.source_usage_key,
216291
source_context_title=clipboard.get_source_context_title(),
217292
)
218293

294+
def _user_library_sync_model_to_data(sync: _UserLibrarySync) -> UserLibrarySyncData:
295+
"""
296+
Convert a UserLibrarySync model instance to an immutable data object.
297+
"""
298+
return UserLibrarySyncData(
299+
content=_staged_content_to_data(sync.content),
300+
source_usage_key=sync.source_usage_key,
301+
source_context_title=sync.get_source_context_title(),
302+
)
303+
219304

220305
def get_staged_content_olx(staged_content_id: int) -> str | None:
221306
"""

0 commit comments

Comments
 (0)