Skip to content

Commit c946d4d

Browse files
committed
fix: introduce NoUpstream exception; more logging; more consistent HTTP API errors
1 parent 0076b8a commit c946d4d

File tree

7 files changed

+307
-153
lines changed

7 files changed

+307
-153
lines changed

cms/djangoapps/contentstore/helpers.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -382,16 +382,16 @@ def _import_xml_node_to_parent(
382382
raise NotImplementedError("We don't yet support pasting XBlocks with children")
383383
temp_xblock.parent = parent_key
384384
if copied_from_block:
385-
# Store a reference to where this block was copied from, in the 'copied_from_block' field (AuthoringMixin)
386-
temp_xblock.copied_from_block = copied_from_block
387385
# Try to link the pasted block (downstream) to the copied block (upstream).
388386
temp_xblock.upstream = copied_from_block
389387
try:
390388
UpstreamLink.get_for_block(temp_xblock)
391389
except (BadDownstream, BadUpstream):
392390
# Usually this will fail. For example, if the copied block is a modulestore course block, it can't be an
393-
# upstream. That's fine; just don't link.
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).
394393
temp_xblock.upstream = None
394+
temp_xblock.copied_from_block = copied_from_block
395395
else:
396396
# But if it doesn't fail, then populate the `upstream_version` field based on what was copied. Note that
397397
# this could be the latest published version, or it could be an an even newer draft version.

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,12 @@ def get(self, request: Request, usage_key_string: str):
294294
"block_type": child_info.location.block_type,
295295
"user_partition_info": user_partition_info,
296296
"user_partitions": user_partitions,
297-
"upstream_link": upstream_link.to_json() if upstream_link else None,
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+
),
298303
"validation_messages": validation_messages,
299304
"render_error": render_error,
300305
})

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

Lines changed: 102 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,63 @@
11
"""
22
API Views for managing & syncing links between upstream & downstream content
33
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.
2620
REQUEST BODY: {
2721
"upstream_ref": str, // reference to upstream block (eg, library block usage key)
2822
"sync": bool, // whether to sync in upstream content (False by default)
2923
}
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.
3225
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
3430
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.
4034
400: Upstream is malformed, missing, or inaccessible.
4135
400: Upstream block does not support syncing.
36+
404: Downstream block not found or user lacks permission to edit it.
4237
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.
4539
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.
4842
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.
5848
59-
Schema for 4XX responses:
49+
UpstreamLink response schema:
6050
{
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
6257
}
6358
"""
59+
import logging
60+
6461
from django.contrib.auth.models import User # pylint: disable=imported-auth-user
6562
from opaque_keys import InvalidKeyError
6663
from opaque_keys.edx.keys import UsageKey
@@ -71,7 +68,8 @@
7168
from xblock.core import XBlock
7269

7370
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
7573
)
7674
from common.djangoapps.student.auth import has_studio_write_access, has_studio_read_access
7775
from openedx.core.lib.api.view_utils import (
@@ -82,6 +80,9 @@
8280
from xmodule.modulestore.exceptions import ItemNotFoundError
8381

8482

83+
logger = logging.getLogger(__name__)
84+
85+
8586
class _AuthenticatedRequest(Request):
8687
"""
8788
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
117118
Inspect an XBlock's link to upstream content.
118119
"""
119120
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())
124122

125123
def put(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
126124
"""
127125
Edit an XBlock's link to upstream content.
128126
"""
129127
downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
130128
new_upstream_ref = request.data.get("upstream_ref")
131-
if not isinstance(new_upstream_ref, str):
132-
raise ValidationError({"upstream_ref": "value missing"})
133129

134130
# Set `downstream.upstream` so that we can try to sync and/or fetch.
135131
# 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
142138
if sync_param == "true":
143139
sync_from_upstream(downstream=downstream, user=request.user)
144140
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.
145144
fetch_customizable_fields(downstream=downstream, user=request.user)
146145
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+
)
147151
raise ValidationError(str(exc)) from exc
148152
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+
)
149158
raise ValidationError({"upstream_ref": str(exc)}) from exc
159+
except NoUpstream as exc:
160+
raise ValidationError({"upstream_ref": "value missing"}) from exc
150161
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())
153165

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)
161182

162183

163184
@view_auth_classes(is_authenticated=True)
@@ -171,27 +192,37 @@ def post(self, request: _AuthenticatedRequest, usage_key_string: str) -> Respons
171192
Pull latest updates to the block at {usage_key_string} from its linked upstream content.
172193
"""
173194
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
178195
try:
179196
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+
)
181203
raise ValidationError(detail=str(exc)) from exc
182204
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())
185208

186209
def delete(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
187210
"""
188211
Decline the latest updates to the block at {usage_key_string}.
189212
"""
190213
downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
191-
_ensure_upstream_ref(downstream)
192214
try:
193215
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+
)
195226
raise ValidationError(str(exc)) from exc
196227
modulestore().update_item(downstream, request.user.id)
197228
return Response(status=204)
@@ -202,9 +233,7 @@ def _load_accessible_block(user: User, usage_key_string: str, *, require_write_a
202233
Given a logged in-user and a serialized usage key of an upstream-linked XBlock, load it from the ModuleStore,
203234
raising a DRF-friendly exception if anything goes wrong.
204235
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.
208237
"""
209238
not_found = NotFound(detail=f"Block not found or not accessible: {usage_key_string}")
210239
try:
@@ -220,11 +249,3 @@ def _load_accessible_block(user: User, usage_key_string: str, *, require_write_a
220249
except ItemNotFoundError as exc:
221250
raise not_found from exc
222251
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

Comments
 (0)