Skip to content

Commit 0a05568

Browse files
DanielVZ96Asespinel
authored andcommitted
fix: static assets used in problem bank and library content block (openedx#36240)
* fix: render library v2 assets with whitespace (openedx#35974) Assets that contain whitespace fail to be rendered when uploaded to library v2 * fix: static assets used in problem bank and library content block (openedx#36173) Static assets were not being copied into the course when using library content via Problem Bank or "Add Library Content" workflows.
1 parent bc17b35 commit 0a05568

File tree

10 files changed

+189
-72
lines changed

10 files changed

+189
-72
lines changed

cms/djangoapps/contentstore/helpers.py

Lines changed: 70 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
2929
import openedx.core.djangoapps.content_staging.api as content_staging_api
3030
import openedx.core.djangoapps.content_tagging.api as content_tagging_api
31+
from openedx.core.djangoapps.content_staging.data import LIBRARY_SYNC_PURPOSE
3132

3233
from .utils import reverse_course_url, reverse_library_url, reverse_usage_url
3334

@@ -261,6 +262,37 @@ class StaticFileNotices:
261262
error_files: list[str] = Factory(list)
262263

263264

265+
def _insert_static_files_into_downstream_xblock(
266+
downstream_xblock: XBlock, staged_content_id: int, request
267+
) -> StaticFileNotices:
268+
"""
269+
Gets static files from staged content, and inserts them into the downstream XBlock.
270+
"""
271+
static_files = content_staging_api.get_staged_content_static_files(staged_content_id)
272+
notices, substitutions = _import_files_into_course(
273+
course_key=downstream_xblock.context_key,
274+
staged_content_id=staged_content_id,
275+
static_files=static_files,
276+
usage_key=downstream_xblock.scope_ids.usage_id,
277+
)
278+
279+
# Rewrite the OLX's static asset references to point to the new
280+
# locations for those assets. See _import_files_into_course for more
281+
# info on why this is necessary.
282+
store = modulestore()
283+
if hasattr(downstream_xblock, "data") and substitutions:
284+
data_with_substitutions = downstream_xblock.data
285+
for old_static_ref, new_static_ref in substitutions.items():
286+
data_with_substitutions = data_with_substitutions.replace(
287+
old_static_ref,
288+
new_static_ref,
289+
)
290+
downstream_xblock.data = data_with_substitutions
291+
if store is not None:
292+
store.update_item(downstream_xblock, request.user.id)
293+
return notices
294+
295+
264296
def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) -> tuple[XBlock | None, StaticFileNotices]:
265297
"""
266298
Import a block (along with its children and any required static assets) from
@@ -298,31 +330,43 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
298330
tags=user_clipboard.content.tags,
299331
)
300332

301-
# Now handle static files that need to go into Files & Uploads.
302-
static_files = content_staging_api.get_staged_content_static_files(user_clipboard.content.id)
303-
notices, substitutions = _import_files_into_course(
304-
course_key=parent_key.context_key,
305-
staged_content_id=user_clipboard.content.id,
306-
static_files=static_files,
307-
usage_key=new_xblock.scope_ids.usage_id,
308-
)
309-
310-
# Rewrite the OLX's static asset references to point to the new
311-
# locations for those assets. See _import_files_into_course for more
312-
# info on why this is necessary.
313-
if hasattr(new_xblock, 'data') and substitutions:
314-
data_with_substitutions = new_xblock.data
315-
for old_static_ref, new_static_ref in substitutions.items():
316-
data_with_substitutions = data_with_substitutions.replace(
317-
old_static_ref,
318-
new_static_ref,
319-
)
320-
new_xblock.data = data_with_substitutions
321-
store.update_item(new_xblock, request.user.id)
333+
notices = _insert_static_files_into_downstream_xblock(new_xblock, user_clipboard.content.id, request)
322334

323335
return new_xblock, notices
324336

325337

338+
def import_static_assets_for_library_sync(downstream_xblock: XBlock, lib_block: XBlock, request) -> StaticFileNotices:
339+
"""
340+
Import the static assets from the library xblock to the downstream xblock
341+
through staged content. Also updates the OLX references to point to the new
342+
locations of those assets in the downstream course.
343+
344+
Does not deal with permissions or REST stuff - do that before calling this.
345+
346+
Returns a summary of changes made to static files in the destination
347+
course.
348+
"""
349+
if not lib_block.runtime.get_block_assets(lib_block, fetch_asset_data=False):
350+
return StaticFileNotices()
351+
if not content_staging_api:
352+
raise RuntimeError("The required content_staging app is not installed")
353+
staged_content = content_staging_api.stage_xblock_temporarily(lib_block, request.user.id, LIBRARY_SYNC_PURPOSE)
354+
if not staged_content:
355+
# expired/error/loading
356+
return StaticFileNotices()
357+
358+
store = modulestore()
359+
try:
360+
with store.bulk_operations(downstream_xblock.context_key):
361+
# Now handle static files that need to go into Files & Uploads.
362+
# If the required files already exist, nothing will happen besides updating the olx.
363+
notices = _insert_static_files_into_downstream_xblock(downstream_xblock, staged_content.id, request)
364+
finally:
365+
staged_content.delete()
366+
367+
return notices
368+
369+
326370
def _fetch_and_set_upstream_link(
327371
copied_from_block: str,
328372
copied_from_version_num: int,
@@ -543,6 +587,9 @@ def _import_files_into_course(
543587
if result is True:
544588
new_files.append(file_data_obj.filename)
545589
substitutions.update(substitution_for_file)
590+
elif substitution_for_file:
591+
# substitutions need to be made because OLX references to these files need to be updated
592+
substitutions.update(substitution_for_file)
546593
elif result is None:
547594
pass # This file already exists; no action needed.
548595
else:
@@ -613,8 +660,8 @@ def _import_file_into_course(
613660
contentstore().save(content)
614661
return True, {clipboard_file_path: f"static/{import_path}"}
615662
elif current_file.content_digest == file_data_obj.md5_hash:
616-
# The file already exists and matches exactly, so no action is needed
617-
return None, {}
663+
# The file already exists and matches exactly, so no action is needed except substitutions
664+
return None, {clipboard_file_path: f"static/{import_path}"}
618665
else:
619666
# There is a conflict with some other file that has the same name.
620667
return False, {}

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,10 @@
5656
"ready_to_sync": Boolean
5757
}
5858
"""
59+
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_static_assets_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_static_assets_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/rest_api/v2/views/tests/test_downstreams.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from unittest.mock import patch
55
from django.conf import settings
66

7+
from cms.djangoapps.contentstore.helpers import StaticFileNotices
78
from cms.lib.xblock.upstream_sync import UpstreamLink, BadUpstream
89
from common.djangoapps.student.tests.factories import UserFactory
910
from xmodule.modulestore.django import modulestore
@@ -247,14 +248,16 @@ def call_api(self, usage_key_string):
247248

248249
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
249250
@patch.object(downstreams_views, "sync_from_upstream")
250-
def test_200(self, mock_sync_from_upstream):
251+
@patch.object(downstreams_views, "import_static_assets_for_library_sync", return_value=StaticFileNotices())
252+
def test_200(self, mock_sync_from_upstream, mock_import_staged_content):
251253
"""
252254
Does the happy path work?
253255
"""
254256
self.client.login(username="superuser", password="password")
255257
response = self.call_api(self.downstream_video_key)
256258
assert response.status_code == 200
257259
assert mock_sync_from_upstream.call_count == 1
260+
assert mock_import_staged_content.call_count == 1
258261

259262

260263
class DeleteDownstreamSyncViewtest(_DownstreamSyncViewTestMixin, SharedModuleStoreTestCase):

cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py

Lines changed: 5 additions & 2 deletions
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_static_assets_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_static_assets_for_library_sync(created_block, lib_block, request)
609611
modulestore().update_item(created_block, request.user.id)
610-
response['upstreamRef'] = upstream_ref
612+
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

Lines changed: 2 additions & 1 deletion
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_libraries/tests/test_static_assets.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ def test_asset_filenames(self):
6363
file_name = "a////////b"
6464
self._set_library_block_asset(block_id, file_name, SVG_DATA, expect_response=400)
6565

66+
# Names with spaces are allowed but replaced with underscores
67+
file_name_with_space = "o w o.svg"
68+
self._set_library_block_asset(block_id, file_name_with_space, SVG_DATA)
69+
file_name = "o_w_o.svg"
70+
assert self._get_library_block_asset(block_id, file_name)['path'] == file_name
71+
assert self._get_library_block_asset(block_id, file_name)['size'] == file_size
72+
6673
def test_video_transcripts(self):
6774
"""
6875
Test that video blocks can read transcript files out of learning core.

openedx/core/djangoapps/content_libraries/views.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,7 @@ def put(self, request, usage_key_str, file_path):
788788
"""
789789
Replace a static asset file belonging to this block.
790790
"""
791+
file_path = file_path.replace(" ", "_") # Messes up url/name correspondence due to URL encoding.
791792
usage_key = LibraryUsageLocatorV2.from_string(usage_key_str)
792793
api.require_permission_for_library_key(
793794
usage_key.lib_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY,

0 commit comments

Comments
 (0)