Skip to content

Commit 795d039

Browse files
feat: Upstream Sync with Content Library Blocks (#34925)
This introdues the idea of "upstream" and "downstream" content, where downstreams (like course components) can pull content updates from upstreams (like learning core-backed content library blocks). This supports the upcoming Content Libraries Relaunch Beta for Sumac. New features include: * A new XBlockMixin: UpstreamSyncMixin. * A new CMS Python API: cms.lib.xblock.upstream_sync * A new CMS JSON API: /api/contentstore/v2/downstreams * A temporary, very basic UI for syncing from Content Library blocks Implements: https://github.com/kdmccormick/edx-platform/blob/kdmccormick/upstream-proto/docs/decisions/0020-upstream-block.rst Co-authored-by: Braden MacDonald <braden@opencraft.com>
1 parent 568fe84 commit 795d039

File tree

22 files changed

+1562
-23
lines changed

22 files changed

+1562
-23
lines changed

cms/djangoapps/contentstore/helpers.py

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from attrs import frozen, Factory
1111
from django.conf import settings
12+
from django.contrib.auth import get_user_model
1213
from django.utils.translation import gettext as _
1314
from opaque_keys.edx.keys import AssetKey, CourseKey, UsageKey
1415
from opaque_keys.edx.locator import DefinitionLocator, LocalId
@@ -22,6 +23,7 @@
2223
from xmodule.xml_block import XmlMixin
2324

2425
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
26+
from cms.lib.xblock.upstream_sync import UpstreamLink, BadUpstream, BadDownstream, fetch_customizable_fields
2527
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
2628
import openedx.core.djangoapps.content_staging.api as content_staging_api
2729
import openedx.core.djangoapps.content_tagging.api as content_tagging_api
@@ -30,6 +32,10 @@
3032

3133
log = logging.getLogger(__name__)
3234

35+
36+
User = get_user_model()
37+
38+
3339
# Note: Grader types are used throughout the platform but most usages are simply in-line
3440
# strings. In addition, new grader types can be defined on the fly anytime one is needed
3541
# (because they're just strings). This dict is an attempt to constrain the sprawl in Studio.
@@ -282,9 +288,10 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
282288
node,
283289
parent_xblock,
284290
store,
285-
user_id=request.user.id,
291+
user=request.user,
286292
slug_hint=user_clipboard.source_usage_key.block_id,
287293
copied_from_block=str(user_clipboard.source_usage_key),
294+
copied_from_version_num=user_clipboard.content.version_num,
288295
tags=user_clipboard.content.tags,
289296
)
290297
# Now handle static files that need to go into Files & Uploads:
@@ -293,7 +300,6 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
293300
staged_content_id=user_clipboard.content.id,
294301
static_files=static_files,
295302
)
296-
297303
return new_xblock, notices
298304

299305

@@ -302,12 +308,15 @@ def _import_xml_node_to_parent(
302308
parent_xblock: XBlock,
303309
# The modulestore we're using
304310
store,
305-
# The ID of the user who is performing this operation
306-
user_id: int,
311+
# The user who is performing this operation
312+
user: User,
307313
# Hint to use as usage ID (block_id) for the new XBlock
308314
slug_hint: str | None = None,
309315
# UsageKey of the XBlock that this one is a copy of
310316
copied_from_block: str | None = None,
317+
# Positive int version of source block, if applicable (e.g., library block).
318+
# Zero if not applicable (e.g., course block).
319+
copied_from_version_num: int = 0,
311320
# Content tags applied to the source XBlock(s)
312321
tags: dict[str, str] | None = None,
313322
) -> XBlock:
@@ -373,12 +382,32 @@ def _import_xml_node_to_parent(
373382
raise NotImplementedError("We don't yet support pasting XBlocks with children")
374383
temp_xblock.parent = parent_key
375384
if copied_from_block:
376-
# Store a reference to where this block was copied from, in the 'copied_from_block' field (AuthoringMixin)
377-
temp_xblock.copied_from_block = copied_from_block
385+
# Try to link the pasted block (downstream) to the copied block (upstream).
386+
temp_xblock.upstream = copied_from_block
387+
try:
388+
UpstreamLink.get_for_block(temp_xblock)
389+
except (BadDownstream, BadUpstream):
390+
# Usually this will fail. For example, if the copied block is a modulestore course block, it can't be an
391+
# upstream. That's fine! Instead, we store a reference to where this block was copied from, in the
392+
# 'copied_from_block' field (from AuthoringMixin).
393+
temp_xblock.upstream = None
394+
temp_xblock.copied_from_block = copied_from_block
395+
else:
396+
# But if it doesn't fail, then populate the `upstream_version` field based on what was copied. Note that
397+
# this could be the latest published version, or it could be an an even newer draft version.
398+
temp_xblock.upstream_version = copied_from_version_num
399+
# Also, fetch upstream values (`upstream_display_name`, etc.).
400+
# Recall that the copied block could be a draft. So, rather than fetching from the published upstream (which
401+
# could be older), fetch from the copied block itself. That way, if an author customizes a field, but then
402+
# later wants to restore it, it will restore to the value that the field had when the block was pasted. Of
403+
# course, if the author later syncs updates from a *future* published upstream version, then that will fetch
404+
# new values from the published upstream content.
405+
fetch_customizable_fields(upstream=temp_xblock, downstream=temp_xblock, user=user)
406+
378407
# Save the XBlock into modulestore. We need to save the block and its parent for this to work:
379-
new_xblock = store.update_item(temp_xblock, user_id, allow_not_found=True)
408+
new_xblock = store.update_item(temp_xblock, user.id, allow_not_found=True)
380409
parent_xblock.children.append(new_xblock.location)
381-
store.update_item(parent_xblock, user_id)
410+
store.update_item(parent_xblock, user.id)
382411

383412
children_handled = False
384413
if hasattr(new_xblock, 'studio_post_paste'):
@@ -394,7 +423,7 @@ def _import_xml_node_to_parent(
394423
child_node,
395424
new_xblock,
396425
store,
397-
user_id=user_id,
426+
user=user,
398427
copied_from_block=str(child_copied_from),
399428
tags=tags,
400429
)

cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,18 @@ def get_assets_url(self, obj):
103103
return None
104104

105105

106+
class UpstreamLinkSerializer(serializers.Serializer):
107+
"""
108+
Serializer holding info for syncing a block with its upstream (eg, a library block).
109+
"""
110+
upstream_ref = serializers.CharField()
111+
version_synced = serializers.IntegerField()
112+
version_available = serializers.IntegerField(allow_null=True)
113+
version_declined = serializers.IntegerField(allow_null=True)
114+
error_message = serializers.CharField(allow_null=True)
115+
ready_to_sync = serializers.BooleanField()
116+
117+
106118
class ChildVerticalContainerSerializer(serializers.Serializer):
107119
"""
108120
Serializer for representing a xblock child of vertical container.
@@ -113,6 +125,7 @@ class ChildVerticalContainerSerializer(serializers.Serializer):
113125
block_type = serializers.CharField()
114126
user_partition_info = serializers.DictField()
115127
user_partitions = serializers.ListField()
128+
upstream_link = UpstreamLinkSerializer(allow_null=True)
116129
actions = serializers.SerializerMethodField()
117130
validation_messages = MessageValidation(many=True)
118131
render_error = serializers.CharField()

cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ def setup_xblock(self):
7070
parent=self.vertical.location,
7171
category="html",
7272
display_name="Html Content 2",
73+
upstream="lb:FakeOrg:FakeLib:html:FakeBlock",
74+
upstream_version=5,
7375
)
7476

7577
def create_block(self, parent, category, display_name, **kwargs):
@@ -193,6 +195,7 @@ def test_children_content(self):
193195
"name": self.html_unit_first.display_name_with_default,
194196
"block_id": str(self.html_unit_first.location),
195197
"block_type": self.html_unit_first.location.block_type,
198+
"upstream_link": None,
196199
"user_partition_info": expected_user_partition_info,
197200
"user_partitions": expected_user_partitions,
198201
"actions": {
@@ -218,12 +221,21 @@ def test_children_content(self):
218221
"can_delete": True,
219222
"can_manage_tags": True,
220223
},
224+
"upstream_link": {
225+
"upstream_ref": "lb:FakeOrg:FakeLib:html:FakeBlock",
226+
"version_synced": 5,
227+
"version_available": None,
228+
"version_declined": None,
229+
"error_message": "Linked library item was not found in the system",
230+
"ready_to_sync": False,
231+
},
221232
"user_partition_info": expected_user_partition_info,
222233
"user_partitions": expected_user_partitions,
223234
"validation_messages": [],
224235
"render_error": "",
225236
},
226237
]
238+
self.maxDiff = None
227239
self.assertEqual(response.data["children"], expected_response)
228240

229241
def test_not_valid_usage_key_string(self):

cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
ContainerHandlerSerializer,
2121
VerticalContainerSerializer,
2222
)
23+
from cms.lib.xblock.upstream_sync import UpstreamLink
2324
from openedx.core.lib.api.view_utils import view_auth_classes
2425
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
2526
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
@@ -198,6 +199,7 @@ def get(self, request: Request, usage_key_string: str):
198199
"block_type": "drag-and-drop-v2",
199200
"user_partition_info": {},
200201
"user_partitions": {}
202+
"upstream_link": null,
201203
"actions": {
202204
"can_copy": true,
203205
"can_duplicate": true,
@@ -215,6 +217,13 @@ def get(self, request: Request, usage_key_string: str):
215217
"block_type": "video",
216218
"user_partition_info": {},
217219
"user_partitions": {}
220+
"upstream_link": {
221+
"upstream_ref": "lb:org:mylib:video:404",
222+
"version_synced": 16
223+
"version_available": null,
224+
"error_message": "Linked library item not found: lb:org:mylib:video:404",
225+
"ready_to_sync": false,
226+
},
218227
"actions": {
219228
"can_copy": true,
220229
"can_duplicate": true,
@@ -232,6 +241,13 @@ def get(self, request: Request, usage_key_string: str):
232241
"block_type": "html",
233242
"user_partition_info": {},
234243
"user_partitions": {},
244+
"upstream_link": {
245+
"upstream_ref": "lb:org:mylib:html:abcd",
246+
"version_synced": 43,
247+
"version_available": 49,
248+
"error_message": null,
249+
"ready_to_sync": true,
250+
},
235251
"actions": {
236252
"can_copy": true,
237253
"can_duplicate": true,
@@ -267,6 +283,7 @@ def get(self, request: Request, usage_key_string: str):
267283
child_info = modulestore().get_item(child)
268284
user_partition_info = get_visibility_partition_info(child_info, course=course)
269285
user_partitions = get_user_partition_info(child_info, course=course)
286+
upstream_link = UpstreamLink.try_get_for_block(child_info)
270287
validation_messages = get_xblock_validation_messages(child_info)
271288
render_error = get_xblock_render_error(request, child_info)
272289

@@ -277,6 +294,12 @@ def get(self, request: Request, usage_key_string: str):
277294
"block_type": child_info.location.block_type,
278295
"user_partition_info": user_partition_info,
279296
"user_partitions": user_partitions,
297+
"upstream_link": (
298+
# If the block isn't linked to an upstream (which is by far the most common case) then just
299+
# make this field null, which communicates the same info, but with less noise.
300+
upstream_link.to_json() if upstream_link.upstream_ref
301+
else None
302+
),
280303
"validation_messages": validation_messages,
281304
"render_error": render_error,
282305
})
Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,31 @@
11
"""Contenstore API v2 URLs."""
22

3-
from django.urls import path
4-
5-
from cms.djangoapps.contentstore.rest_api.v2.views import HomePageCoursesViewV2
3+
from django.conf import settings
4+
from django.urls import path, re_path
65

6+
from cms.djangoapps.contentstore.rest_api.v2.views import home, downstreams
77
app_name = "v2"
88

99
urlpatterns = [
1010
path(
1111
"home/courses",
12-
HomePageCoursesViewV2.as_view(),
12+
home.HomePageCoursesViewV2.as_view(),
1313
name="courses",
1414
),
15+
# TODO: Potential future path.
16+
# re_path(
17+
# fr'^downstreams/$',
18+
# downstreams.DownstreamsListView.as_view(),
19+
# name="downstreams_list",
20+
# ),
21+
re_path(
22+
fr'^downstreams/{settings.USAGE_KEY_PATTERN}$',
23+
downstreams.DownstreamView.as_view(),
24+
name="downstream"
25+
),
26+
re_path(
27+
fr'^downstreams/{settings.USAGE_KEY_PATTERN}/sync$',
28+
downstreams.SyncFromUpstreamView.as_view(),
29+
name="sync_from_upstream"
30+
),
1531
]
Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +0,0 @@
1-
"""Module for v2 views."""
2-
3-
from cms.djangoapps.contentstore.rest_api.v2.views.home import HomePageCoursesViewV2

0 commit comments

Comments
 (0)