Skip to content

Commit bf907ee

Browse files
committed
feat: UpstreamSyncMixin
1 parent e76cb17 commit bf907ee

File tree

3 files changed

+220
-1
lines changed

3 files changed

+220
-1
lines changed

cms/djangoapps/contentstore/helpers.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
2525
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
26+
from openedx.core.djangoapps.content_libraries.sync import is_block_valid_upstream
2627
import openedx.core.djangoapps.content_staging.api as content_staging_api
2728
import openedx.core.djangoapps.content_tagging.api as content_tagging_api
2829

@@ -293,7 +294,6 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
293294
staged_content_id=user_clipboard.content.id,
294295
static_files=static_files,
295296
)
296-
297297
return new_xblock, notices
298298

299299

@@ -375,6 +375,12 @@ def _import_xml_node_to_parent(
375375
if copied_from_block:
376376
# Store a reference to where this block was copied from, in the 'copied_from_block' field (AuthoringMixin)
377377
temp_xblock.copied_from_block = copied_from_block
378+
copied_from_key = UsageKey.from_string(copied_from_block)
379+
if is_block_valid_upstream(copied_from_key):
380+
upstream_link_requested = lambda: True # @@TODO ask user
381+
if upstream_link_requested():
382+
temp_xblock.set_upstream(copied_from_key, user_id)
383+
378384
# Save the XBlock into modulestore. We need to save the block and its parent for this to work:
379385
new_xblock = store.update_item(temp_xblock, user_id, allow_not_found=True)
380386
parent_xblock.children.append(new_xblock.location)

cms/envs/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,6 +1019,7 @@
10191019
XModuleMixin,
10201020
EditInfoMixin,
10211021
AuthoringMixin,
1022+
"openedx.core.djangoapps.content_libraries.sync.UpstreamSyncMixin",
10221023
)
10231024

10241025
# .. setting_name: XBLOCK_EXTRA_MIXINS
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"""
2+
Synchronize content and settings from upstream blocks (in content libraries) to their
3+
downstream usages (in courses, etc.)
4+
5+
At the time of writing, upstream blocks are assumed to come from content libraries.
6+
However, the XBlock fields are designed to be agnostic to their upstream's source context,
7+
so this assumption could be relaxed in the future if there is a need for upstreams from
8+
other kinds of learning contexts.
9+
"""
10+
import json
11+
12+
from django.contrib.auth import get_user_model
13+
from opaque_keys import InvalidKeyError
14+
from opaque_keys.edx.keys import UsageKey
15+
from opaque_keys.edx.locator import LibraryUsageLocatorV2
16+
from xblock.fields import Scope, String, Integer, List, Dict
17+
from xblock.core import XBlockMixin, XBlock
18+
from webob import Request, Response
19+
20+
from openedx.core.djangoapps.content_libraries.api import (
21+
get_library_block,
22+
LibraryXBlockMetadata,
23+
ContentLibraryBlockNotFound,
24+
)
25+
from openedx.core.djangoapps.xblock.api import load_block, NotFound as XBlockNotFound
26+
27+
28+
class UpstreamSyncMixin(XBlockMixin):
29+
"""
30+
@@TODO docstring
31+
"""
32+
33+
upstream = String(
34+
scope=Scope.settings,
35+
help=(
36+
"The usage key of a block (generally within a Content Library) which serves as a source of upstream "
37+
"updates for this block, or None if there is no such upstream. Please note: It is valid for upstream_block "
38+
"to hold a usage key for a block that does not exist (or does not *yet* exist) on this instance, "
39+
"particularly if this block was imported from a different instance."
40+
),
41+
hidden=True,
42+
default=None,
43+
enforce_type=True,
44+
)
45+
upstream_version = Integer(
46+
scope=Scope.settings,
47+
help=(
48+
"The upstream_block's version number, at the time this block was created from it. "
49+
"If this version is older than the upstream_block's latest version, then CMS will "
50+
"allow this block to fetch updated content from upstream_block."
51+
),
52+
hidden=True,
53+
default=None,
54+
enforce_type=True,
55+
)
56+
upstream_overridden = List(
57+
scope=Scope.settings,
58+
help=(
59+
"@@TODO helptext"
60+
),
61+
hidden=True,
62+
default=[],
63+
enforce_type=True,
64+
)
65+
upstream_settings = Dict(
66+
scope=Scope.settings,
67+
help=(
68+
"@@TODO helptext"
69+
),
70+
hidden=True,
71+
default={},
72+
enforce_type=True,
73+
)
74+
75+
def save(self, *args, **kwargs):
76+
"""
77+
@@TODO docstring
78+
@@TODO use is_dirty instead of getattr for efficiency?
79+
"""
80+
for field_name, value in self.upstream_settings.items():
81+
if field_name not in self.upstream_overridden:
82+
if value != getattr(self, field_name):
83+
self.upstream_overridden.append(field_name)
84+
super().save()
85+
86+
@XBlock.handler
87+
def assign_upstream(self, request: Request, upstream_key: LibraryUsageLocatorV2, user_id: int) -> Response:
88+
"""
89+
Assign an upstream to this block and fetch upstream settings.
90+
91+
Does not save block; caller must do so.
92+
93+
@@TODO params
94+
"""
95+
if request.method != "POST":
96+
return Response(status_code=405)
97+
old_upstream = self.upstream
98+
self.upstream = str(upstream_key)
99+
try:
100+
self._sync_with_upstream(user_id=user_id, apply_updates=False)
101+
except BadUpstream as exc:
102+
self.upstream = old_upstream
103+
return Response(str(exc), status_code=400)
104+
self.save()
105+
return Response(status_code=204)
106+
107+
@XBlock.handler
108+
def upstream_info(self, request: Request, _suffix=None) -> Response:
109+
"""
110+
@@TODO docstring
111+
@@TODO more data?
112+
"""
113+
if request.method != "GET":
114+
return Response(status_code=405)
115+
try:
116+
upstream_block, upstream_meta = self._load_upstream(request.user.id)
117+
except BadUpstream as exc:
118+
return Response(str(exc), status_code=400)
119+
return Response(
120+
json.dumps(
121+
{
122+
"usage_key": self.upstream,
123+
"version_current": self.upstream_version,
124+
"version_latest": upstream_meta.version_num if upstream_meta else None,
125+
},
126+
),
127+
indent=4,
128+
)
129+
130+
@XBlock.handler
131+
def update_from_upstream(self, request: Request, suffix=None) -> Response:
132+
"""
133+
@@TODO docstring
134+
"""
135+
if request.method != "POST":
136+
return Response(status_code=405)
137+
try:
138+
user_id = request.user.id if request and request.user else 0
139+
self._sync_with_upstream(user_id=user_id, apply_updates=True)
140+
except BadUpstream as exc:
141+
return Response(str(exc), status_code=400)
142+
self.save()
143+
return Response(status_code=204)
144+
145+
def _sync_with_upstream(self, *, user_id: int, apply_updates: bool) -> None:
146+
"""
147+
@@TODO docstring
148+
149+
Does not save block; caller must do so.
150+
151+
Can raise NoUpstream or BadUpstream.
152+
"""
153+
upstream, upstream_meta = self._load_upstream(user_id)
154+
self.upstream_settings = {}
155+
self.upstream_version = upstream_meta.version_num
156+
for field_name, field in upstream.fields.items():
157+
if field.scope not in [Scope.settings, Scope.content]:
158+
continue
159+
value = getattr(upstream, field_name)
160+
if field.scope == Scope.settings:
161+
self.upstream_settings[field_name] = value
162+
if field_name in self.upstream_overridden:
163+
continue
164+
if not apply_updates:
165+
continue
166+
setattr(self, field_name, value)
167+
168+
def _load_upstream(self, user_id: int) -> tuple[XBlock, LibraryXBlockMetadata]:
169+
"""
170+
This this block's upstream from a content library.
171+
172+
Raises BadUpstream if the upstream block could not be loaded for any reason.
173+
"""
174+
cannot_load = "Cannot load updates for component at '{self.usage_key}'"
175+
if not self.upstream:
176+
raise BadUpstream(f"{cannot_load}: no linked content library item")
177+
try:
178+
upstream_key = LibraryUsageLocatorV2.from_string(self.upstream)
179+
except InvalidKeyError as exc:
180+
raise BadUpstream(
181+
f"{cannot_load}: invalid content library item reference '{self.upstream}'"
182+
) from exc
183+
try:
184+
upstream_meta = get_library_block(upstream_key)
185+
except ContentLibraryBlockNotFound as exc:
186+
raise BadUpstream(
187+
f"{cannot_load}: linked item '{upstream_key}' does not belong to a content library"
188+
) from exc
189+
try:
190+
upstream = load_block(upstream_key, get_user_model().objects.get(id=user_id))
191+
except XBlockNotFound as exc:
192+
raise BadUpstream(
193+
f"{cannot_load}: failed to load linked content library item at '{upstream_key}'. "
194+
"Either the item was deleted, or you lack permission to view its contents."
195+
) from exc
196+
return upstream, upstream_meta
197+
198+
199+
class BadUpstream(Exception):
200+
"""
201+
Base exception for any content-level problems we can hit while loading a block's upstream.
202+
203+
Should not represent unexpected internal server errors.
204+
May appear in API responses, so they should be somewhat user friendly and avoid sensitive info.
205+
"""
206+
207+
208+
def is_valid_upstream(usage_key: UsageKey) -> bool:
209+
"""
210+
@@TODO docstring
211+
"""
212+
return isinstance(usage_key, LibraryUsageLocatorV2)

0 commit comments

Comments
 (0)