1
1
"""
2
2
API Views for managing & syncing links between upstream & downstream content
3
3
4
- Only [BETA] endpoints are implemented currently.
5
- The [FULL] endpoints should be implemented for the Full Libraries Relaunch, or removed from this doc.
6
-
7
- [FULL] List downstream blocks that can be synced, filterable by course or sync-readiness.
8
- GET /api/v2/contentstore/downstreams
9
- GET /api/v2/contentstore/downstreams?course_id=course-v1:A+B+C&ready_to_sync=true
10
- 200: A paginated list of applicable & accessible downstream blocks.
11
-
12
- [BETA] Inspect a single downstream block's link to upstream content.
13
- GET /api/v2/contentstore/downstreams/{usage_key_string}
14
- 200: Upstream link details successfully fetched.
15
- 404: Block not found, OR user lacks permission to read block.
16
- 400: Blocks is not linked to upstream content.
17
-
18
- [FULL] Sever a single downstream block's link to upstream content.
19
- DELETE /api/v2/contentstore/downstreams/{usage_key_string}
20
- 204: Block successfully unlinked. No response body.
21
- 404: Block not found, OR user lacks permission to edit block
22
- 400: Blocks is not linked to upstream content.
23
-
24
- [BETA] Establish or modify a single downstream block's link to upstream content.
25
- PUT /api/v2/contentstore/downstreams/{usage_key_string}
4
+ API paths (We will move these into proper api_doc_tools annotations soon
5
+ https://github.com/openedx/edx-platform/issues/35653):
6
+
7
+ /api/v2/contentstore/downstreams/{usage_key_string}
8
+
9
+ GET: Inspect a single downstream block's link to upstream content.
10
+ 200: Upstream link details successfully fetched. Returns UpstreamLink (may contain an error_message).
11
+ 404: Downstream block not found or user lacks permission to edit it.
12
+
13
+ DELETE: Sever a single downstream block's link to upstream content.
14
+ 204: Block successfully unlinked (or it wasn't linked in the first place). No response body.
15
+ 404: Downstream block not found or user lacks permission to edit it.
16
+
17
+ PUT: Establish or modify a single downstream block's link to upstream content. An authoring client could use this
18
+ endpoint to add library content in a two-step process, specifically: (1) add a blank block to a course, then
19
+ (2) link it to a content library with ?sync=True.
26
20
REQUEST BODY: {
27
21
"upstream_ref": str, // reference to upstream block (eg, library block usage key)
28
22
"sync": bool, // whether to sync in upstream content (False by default)
29
23
}
30
- 200: Block's upstream link successfully edited (and synced, if requested).
31
- 404: Block not found, OR user lacks permission to edit block
24
+ 200: Downstream block's upstream link successfully edited (and synced, if requested). Returns UpstreamLink.
32
25
400: upstream_ref is malformed, missing, or inaccessible.
33
- 400: Upstream block does not support syncing.
26
+ 400: Content at upstream_ref does not support syncing.
27
+ 404: Downstream block not found or user lacks permission to edit it.
28
+
29
+ /api/v2/contentstore/downstreams/{usage_key_string}/sync
34
30
35
- [BETA] Sync a downstream block with upstream content.
36
- POST /api/v2/contentstore/downstreams/{usage_key_string}/sync
37
- 200: Block successfully synced with upstream content.
38
- 404: Block is not linked to upstream, OR block not found, OR user lacks permission to edit block.
39
- 400: Blocks is not linked to upstream content.
31
+ POST: Sync a downstream block with upstream content.
32
+ 200: Downstream block successfully synced with upstream content.
33
+ 400: Downstream block is not linked to upstream content.
40
34
400: Upstream is malformed, missing, or inaccessible.
41
35
400: Upstream block does not support syncing.
36
+ 404: Downstream block not found or user lacks permission to edit it.
42
37
43
- [BETA] Decline an available sync for a downstream block.
44
- DELETE /api/v2/contentstore/downstreams/{usage_key_string}/sync
38
+ DELETE: Decline an available sync for a downstream block.
45
39
204: Sync successfuly dismissed. No response body.
46
- 404: Block not found, OR user lacks permission to edit block .
47
- 400: Blocks is not linked to upstream content .
40
+ 400: Downstream block is not linked to upstream content .
41
+ 404: Downstream block not found or user lacks permission to edit it .
48
42
49
- Schema for 200 responses, except where noted:
50
- {
51
- "upstream_ref": string?
52
- "version_synced": string?,
53
- "version_available": string?,
54
- "version_declined": string?,
55
- "error_message": string?,
56
- "ready_to_sync": Boolean
57
- }
43
+ # NOT YET IMPLEMENTED -- Will be needed for full Libraries Relaunch in ~Teak.
44
+ /api/v2/contentstore/downstreams
45
+ /api/v2/contentstore/downstreams?course_id=course-v1:A+B+C&ready_to_sync=true
46
+ GET: List downstream blocks that can be synced, filterable by course or sync-readiness.
47
+ 200: A paginated list of applicable & accessible downstream blocks. Entries are UpstreamLinks.
58
48
59
- Schema for 4XX responses :
49
+ UpstreamLink response schema :
60
50
{
61
- "developer_message": string?
51
+ "upstream_ref": string?
52
+ "version_synced": string?,
53
+ "version_available": string?,
54
+ "version_declined": string?,
55
+ "error_message": string?,
56
+ "ready_to_sync": Boolean
62
57
}
63
58
"""
59
+ import logging
60
+
64
61
from django .contrib .auth .models import User # pylint: disable=imported-auth-user
65
62
from opaque_keys import InvalidKeyError
66
63
from opaque_keys .edx .keys import UsageKey
71
68
from xblock .core import XBlock
72
69
73
70
from cms .lib .xblock .upstream_sync import (
74
- UpstreamLink , sync_from_upstream , decline_sync , BadUpstream , BadDownstream , fetch_customizable_fields
71
+ UpstreamLink , UpstreamLinkException , NoUpstream , BadUpstream , BadDownstream ,
72
+ fetch_customizable_fields , sync_from_upstream , decline_sync , sever_upstream_link
75
73
)
76
74
from common .djangoapps .student .auth import has_studio_write_access , has_studio_read_access
77
75
from openedx .core .lib .api .view_utils import (
82
80
from xmodule .modulestore .exceptions import ItemNotFoundError
83
81
84
82
83
+ logger = logging .getLogger (__name__ )
84
+
85
+
85
86
class _AuthenticatedRequest (Request ):
86
87
"""
87
88
Alias for the `Request` class which tells mypy to assume that `.user` is not an AnonymousUser.
@@ -117,19 +118,14 @@ def get(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response
117
118
Inspect an XBlock's link to upstream content.
118
119
"""
119
120
downstream = _load_accessible_block (request .user , usage_key_string , require_write_access = False )
120
- _ensure_upstream_ref (downstream )
121
- if link := UpstreamLink .try_get_for_block (downstream ):
122
- return Response (link .to_json ())
123
- raise ValidationError (detail = f"Block '{ usage_key_string } ' is not linked to an upstream" )
121
+ return Response (UpstreamLink .try_get_for_block (downstream ).to_json ())
124
122
125
123
def put (self , request : _AuthenticatedRequest , usage_key_string : str ) -> Response :
126
124
"""
127
125
Edit an XBlock's link to upstream content.
128
126
"""
129
127
downstream = _load_accessible_block (request .user , usage_key_string , require_write_access = True )
130
128
new_upstream_ref = request .data .get ("upstream_ref" )
131
- if not isinstance (new_upstream_ref , str ):
132
- raise ValidationError ({"upstream_ref" : "value missing" })
133
129
134
130
# Set `downstream.upstream` so that we can try to sync and/or fetch.
135
131
# Note that, if this fails and we raise a 4XX, then we will not call modulstore().update_item,
@@ -142,22 +138,47 @@ def put(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response
142
138
if sync_param == "true" :
143
139
sync_from_upstream (downstream = downstream , user = request .user )
144
140
else :
141
+ # Even if we're not syncing (i.e., updating the downstream's values with the upstream's), we still need
142
+ # to fetch the upstream's customizable values and store them as hidden fields on the downstream. This
143
+ # ensures that downstream authors can restore defaults based on the upstream.
145
144
fetch_customizable_fields (downstream = downstream , user = request .user )
146
145
except BadDownstream as exc :
146
+ logger .exception (
147
+ "'%s' is an invalid downstream; refusing to set its upstream to '%s'" ,
148
+ usage_key_string ,
149
+ new_upstream_ref ,
150
+ )
147
151
raise ValidationError (str (exc )) from exc
148
152
except BadUpstream as exc :
153
+ logger .exception (
154
+ "'%s' is an invalid upstream reference; refusing to set it as upstream of '%s'" ,
155
+ new_upstream_ref ,
156
+ usage_key_string ,
157
+ )
149
158
raise ValidationError ({"upstream_ref" : str (exc )}) from exc
159
+ except NoUpstream as exc :
160
+ raise ValidationError ({"upstream_ref" : "value missing" }) from exc
150
161
modulestore ().update_item (downstream , request .user .id )
151
- link : UpstreamLink = UpstreamLink .get_for_block (downstream ) # type: ignore[assignment]
152
- return Response (link .to_json ())
162
+ # Note: We call `get_for_block` (rather than `try_get_for_block`) because if anything is wrong with the
163
+ # upstream at this point, then that is completely unexpected, so it's appropriate to let the 500 happen.
164
+ return Response (UpstreamLink .get_for_block (downstream ).to_json ())
153
165
154
- # def delete(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
155
- # """
156
- # Sever an XBlock's link to upstream content.
157
- # """
158
- # downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
159
- # _ensure_upstream_ref(downstream)
160
- # ....
166
+ def delete (self , request : _AuthenticatedRequest , usage_key_string : str ) -> Response :
167
+ """
168
+ Sever an XBlock's link to upstream content.
169
+ """
170
+ downstream = _load_accessible_block (request .user , usage_key_string , require_write_access = True )
171
+ try :
172
+ sever_upstream_link (downstream )
173
+ except NoUpstream as exc :
174
+ logger .exception (
175
+ "Tried to DELETE upstream link of '%s', but it wasn't linked to anything in the first place. "
176
+ "Will do nothing. " ,
177
+ usage_key_string ,
178
+ )
179
+ else :
180
+ modulestore ().update_item (downstream , request .user .id )
181
+ return Response (status = 204 )
161
182
162
183
163
184
@view_auth_classes (is_authenticated = True )
@@ -171,27 +192,37 @@ def post(self, request: _AuthenticatedRequest, usage_key_string: str) -> Respons
171
192
Pull latest updates to the block at {usage_key_string} from its linked upstream content.
172
193
"""
173
194
downstream = _load_accessible_block (request .user , usage_key_string , require_write_access = True )
174
- _ensure_upstream_ref (downstream )
175
- if not downstream .upstream :
176
- raise NotFound (detail = f"Block '{ usage_key_string } ' is not linked to a library block" )
177
- old_version = downstream .upstream_version
178
195
try :
179
196
sync_from_upstream (downstream , request .user )
180
- except (BadUpstream , BadDownstream ) as exc :
197
+ except UpstreamLinkException as exc :
198
+ logger .exception (
199
+ "Could not sync from upstream '%s' to downstream '%s'" ,
200
+ downstream .upstream ,
201
+ usage_key_string ,
202
+ )
181
203
raise ValidationError (detail = str (exc )) from exc
182
204
modulestore ().update_item (downstream , request .user .id )
183
- link : UpstreamLink = UpstreamLink .get_for_block (downstream ) # type: ignore[assignment]
184
- return Response (link .to_json (), status = 200 )
205
+ # Note: We call `get_for_block` (rather than `try_get_for_block`) because if anything is wrong with the
206
+ # upstream at this point, then that is completely unexpected, so it's appropriate to let the 500 happen.
207
+ return Response (UpstreamLink .get_for_block (downstream ).to_json ())
185
208
186
209
def delete (self , request : _AuthenticatedRequest , usage_key_string : str ) -> Response :
187
210
"""
188
211
Decline the latest updates to the block at {usage_key_string}.
189
212
"""
190
213
downstream = _load_accessible_block (request .user , usage_key_string , require_write_access = True )
191
- _ensure_upstream_ref (downstream )
192
214
try :
193
215
decline_sync (downstream )
194
- except (BadUpstream , BadDownstream ) as exc :
216
+ except (NoUpstream , BadUpstream , BadDownstream ) as exc :
217
+ # This is somewhat unexpected. If the upstream link is missing or invalid, then the downstream author
218
+ # shouldn't have been prompted to accept/decline a sync in the first place. Of course, they could have just
219
+ # hit the HTTP API anyway, or they could be viewing a Studio page which hasn't been refreshed in a while.
220
+ # So, it's a 400, not a 500.
221
+ logger .exception (
222
+ "Tried to decline a sync to downstream '%s', but the upstream link '%s' is invalid." ,
223
+ usage_key_string ,
224
+ downstream .upstream ,
225
+ )
195
226
raise ValidationError (str (exc )) from exc
196
227
modulestore ().update_item (downstream , request .user .id )
197
228
return Response (status = 204 )
@@ -202,9 +233,7 @@ def _load_accessible_block(user: User, usage_key_string: str, *, require_write_a
202
233
Given a logged in-user and a serialized usage key of an upstream-linked XBlock, load it from the ModuleStore,
203
234
raising a DRF-friendly exception if anything goes wrong.
204
235
205
- Raises NotFound if usage key is malformed.
206
- Raises NotFound if user lacks access.
207
- Raises NotFound if block does not exist.
236
+ Raises NotFound if usage key is malformed, if the user lacks access, or if the block doesn't exist.
208
237
"""
209
238
not_found = NotFound (detail = f"Block not found or not accessible: { usage_key_string } " )
210
239
try :
@@ -220,11 +249,3 @@ def _load_accessible_block(user: User, usage_key_string: str, *, require_write_a
220
249
except ItemNotFoundError as exc :
221
250
raise not_found from exc
222
251
return block
223
-
224
-
225
- def _ensure_upstream_ref (block : XBlock ) -> None :
226
- """
227
- Raises ValidationError if block is not a downstream block.
228
- """
229
- if not block .upstream :
230
- raise ValidationError (detail = f"Block '{ block .usage_key } ' is not linked to a library block" )
0 commit comments