Skip to content

Commit 55d4ff8

Browse files
committed
feat: UpstreamSyncMixin
1 parent 2cd7f75 commit 55d4ff8

File tree

4 files changed

+250
-3
lines changed

4 files changed

+250
-3
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_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_valid_upstream(copied_from_key):
380+
upstream_link_requested = lambda: True # @@TODO ask user
381+
if upstream_link_requested():
382+
temp_xblock.assign_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
@@ -1020,6 +1020,7 @@
10201020
XModuleMixin,
10211021
EditInfoMixin,
10221022
AuthoringMixin,
1023+
"openedx.core.djangoapps.content_libraries.sync.UpstreamSyncMixin",
10231024
)
10241025

10251026
# .. setting_name: XBLOCK_EXTRA_MIXINS
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
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+
def assign_upstream(self, upstream_key: LibraryUsageLocatorV2) -> None:
87+
"""
88+
Assign an upstream to this block and fetch upstream settings.
89+
90+
Does not save block; caller must do so.
91+
92+
@@TODO params
93+
"""
94+
old_upstream = self.upstream
95+
self.upstream = str(upstream_key)
96+
try:
97+
self._sync_with_upstream(apply_updates=False)
98+
except BadUpstream:
99+
self.upstream = old_upstream
100+
raise
101+
self.save()
102+
103+
@XBlock.handler
104+
def upstream_link(self, request: Request, _suffix=None) -> Response:
105+
"""
106+
@@TODO docstring
107+
@@TODO more data?
108+
"""
109+
# @@TODO: There *has* to be a way to load a learning core block without invoking the user service...
110+
if request.method == "GET":
111+
try:
112+
upstream_meta = self.get_upstream_meta()
113+
except BadUpstream as exc:
114+
return Response(str(exc), status_code=400)
115+
return Response(
116+
json.dumps(
117+
{
118+
"usage_key": self.upstream,
119+
"version_current": self.upstream_version,
120+
"version_latest": upstream_meta.version_num if upstream_meta else None,
121+
},
122+
indent=4,
123+
),
124+
)
125+
if request.method == "PUT":
126+
# @@TODO better validation
127+
try:
128+
self.assign_upstream(UsageKey.from_string(json.loads(request.data["usage_key"])))
129+
except BadUpstream as exc:
130+
return Response(str(exc), status_code=400)
131+
return Response(status_code=204) # @@TODO what to returN?
132+
return Response(status_code=405)
133+
134+
@XBlock.handler
135+
def update_from_upstream(self, request: Request, suffix=None) -> Response:
136+
"""
137+
@@TODO docstring
138+
"""
139+
if request.method != "POST":
140+
return Response(status_code=405)
141+
try:
142+
self._sync_with_upstream(apply_updates=True)
143+
except BadUpstream as exc:
144+
return Response(str(exc), status_code=400)
145+
self.save()
146+
return Response(status_code=204)
147+
148+
def _sync_with_upstream(self, *, apply_updates: bool) -> None:
149+
"""
150+
@@TODO docstring
151+
152+
Does not save block; caller must do so.
153+
154+
Can raise NoUpstream or BadUpstream.
155+
"""
156+
upstream, upstream_meta = self._load_upstream()
157+
self.upstream_settings = {}
158+
self.upstream_version = upstream_meta.version_num
159+
for field_name, field in upstream.fields.items():
160+
if field.scope not in [Scope.settings, Scope.content]:
161+
continue
162+
value = getattr(upstream, field_name)
163+
if field.scope == Scope.settings:
164+
self.upstream_settings[field_name] = value
165+
if field_name in self.upstream_overridden:
166+
continue
167+
if not apply_updates:
168+
continue
169+
setattr(self, field_name, value)
170+
171+
def get_upstream_meta(self) -> LibraryXBlockMetadata:
172+
"""
173+
@@TODO docstring
174+
@@TODO _load_upstream should call this, not the other way around
175+
"""
176+
_, upstream_meta = self._load_upstream(load_block=False)
177+
return upstream_meta
178+
179+
def _load_upstream(self, load_block: bool = True) -> tuple[XBlock | None, LibraryXBlockMetadata]:
180+
"""
181+
This this block's upstream from a content library.
182+
183+
Raises BadUpstream if the upstream block could not be loaded for any reason.
184+
"""
185+
cannot_load = f"Cannot load updates for component at '{self.usage_key}'"
186+
if not self.upstream:
187+
raise BadUpstream(f"{cannot_load}: no linked content library item")
188+
try:
189+
print(self.upstream)
190+
upstream_key = LibraryUsageLocatorV2.from_string(self.upstream)
191+
except InvalidKeyError as exc:
192+
raise BadUpstream(
193+
f"{cannot_load}: invalid content library item reference '{self.upstream}'"
194+
) from exc
195+
try:
196+
upstream_meta = get_library_block(upstream_key)
197+
except ContentLibraryBlockNotFound as exc:
198+
raise BadUpstream(
199+
f"{cannot_load}: linked item '{upstream_key}' does not belong to a content library"
200+
) from exc
201+
if load_block: # @@TODO this is a hack
202+
user_id = self.runtime.service(self, "user")._django_user.id
203+
try:
204+
upstream = load_block(upstream_key, get_user_model().objects.get(id=user_id))
205+
except XBlockNotFound as exc:
206+
raise BadUpstream(
207+
f"{cannot_load}: failed to load linked content library item at '{upstream_key}'. "
208+
"Either the item was deleted, or you lack permission to view its contents."
209+
) from exc
210+
else:
211+
upstream = None
212+
return upstream, upstream_meta
213+
214+
215+
class BadUpstream(Exception):
216+
"""
217+
Base exception for any content-level problems we can hit while loading a block's upstream.
218+
219+
Should not represent unexpected internal server errors.
220+
May appear in API responses, so they should be somewhat user friendly and avoid sensitive info.
221+
"""
222+
223+
224+
def is_valid_upstream(usage_key: UsageKey) -> bool:
225+
"""
226+
@@TODO docstring
227+
"""
228+
return isinstance(usage_key, LibraryUsageLocatorV2)

xmodule/capa_block.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -486,10 +486,22 @@ def display_name_with_default(self):
486486
Default to the display_name if it isn't None or not an empty string,
487487
else fall back to problem category.
488488
"""
489+
# @@TODO: temporary suffix code
490+
from openedx.core.djangoapps.content_libraries.sync import BadUpstream
491+
try:
492+
upstream_meta = self.get_upstream_meta()
493+
except BadUpstream:
494+
suffix = ""
495+
else:
496+
latest = upstream_meta.version_num
497+
suffix = f" [v{self.upstream_version}]"
498+
if self.upstream_version < latest:
499+
suffix += f" [UPDATE AVAILBLE: v{latest}]"
500+
489501
if self.display_name is None or not self.display_name.strip():
490-
return self.location.block_type
502+
return self.location.block_type + suffix
491503

492-
return self.display_name
504+
return self.display_name + suffix
493505

494506
def grading_method_display_name(self) -> str | None:
495507
"""

0 commit comments

Comments
 (0)