Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 1 addition & 14 deletions cms/djangoapps/contentstore/rest_api/v2/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,11 @@
home.HomePageCoursesViewV2.as_view(),
name="courses",
),
# TODO: Rename this to `downstreams/` after full deprecate `DownstreamComponentsListView`
re_path(
r'^downstreams-all/$',
downstreams.DownstreamListView.as_view(),
name="downstreams_list_all",
),
# [DEPRECATED], use `downstreams-all/` instead.
re_path(
r'^downstreams/$',
downstreams.DownstreamComponentsListView.as_view(),
downstreams.DownstreamListView.as_view(),
name="downstreams_list",
),
# [DEPRECATED], use `downstreams-all/` instead.
re_path(
r'^downstream-containers/$',
downstreams.DownstreamContainerListView.as_view(),
name="container_downstreams_list",
),
re_path(
fr'^downstreams/{settings.USAGE_KEY_PATTERN}$',
downstreams.DownstreamView.as_view(),
Expand Down
90 changes: 3 additions & 87 deletions cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@
"""

import logging
import warnings

from attrs import asdict as attrs_asdict
from django.db.models import QuerySet
Expand All @@ -101,8 +100,6 @@
from cms.djangoapps.contentstore.models import ComponentLink, ContainerLink, EntityLinkBase
from cms.djangoapps.contentstore.rest_api.v2.serializers import (
PublishableEntityLinkSerializer,
ComponentLinksSerializer,
ContainerLinksSerializer,
PublishableEntityLinksSummarySerializer,
)
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import sync_library_content
Expand Down Expand Up @@ -276,47 +273,6 @@ def _remove_duplicates(self, links: list[EntityLinkBase]) -> list[EntityLinkBase
return unique_links


@view_auth_classes()
class DownstreamComponentsListView(DeveloperErrorViewMixin, APIView):
"""
[DEPRECATED], use DownstreamListView instead.

List all components which are linked to an upstream context, with optional filtering.
"""

def get(self, request: _AuthenticatedRequest):
"""
[DEPRECATED], use DownstreamListView.get instead, with `item_type='components'`

Fetches publishable entity links for given course key
"""
warnings.warn(
'`downstreams/` API is deprecated. Please use `downstreams-all/?item_type=components` instead.',
DeprecationWarning, stacklevel=3,
)
course_key_string = request.GET.get('course_id')
ready_to_sync = request.GET.get('ready_to_sync')
upstream_usage_key = request.GET.get('upstream_usage_key')
link_filter: dict[str, CourseKey | UsageKey | bool] = {}
paginator = DownstreamListPaginator()
if course_key_string:
try:
link_filter["downstream_context_key"] = CourseKey.from_string(course_key_string)
except InvalidKeyError as exc:
raise ValidationError(detail=f"Malformed course key: {course_key_string}") from exc
if ready_to_sync is not None:
link_filter["ready_to_sync"] = BooleanField().to_internal_value(ready_to_sync)
if upstream_usage_key:
try:
link_filter["upstream_usage_key"] = UsageKey.from_string(upstream_usage_key)
except InvalidKeyError as exc:
raise ValidationError(detail=f"Malformed usage key: {upstream_usage_key}") from exc
links = ComponentLink.filter_links(**link_filter)
paginated_links = paginator.paginate_queryset(links, self.request, view=self)
serializer = ComponentLinksSerializer(paginated_links, many=True)
return paginator.get_paginated_response(serializer.data, self.request)


@view_auth_classes()
class DownstreamSummaryView(DeveloperErrorViewMixin, APIView):
"""
Expand Down Expand Up @@ -551,8 +507,10 @@ def delete(self, request: _AuthenticatedRequest, usage_key_string: str) -> Respo
Decline the latest updates to the block at {usage_key_string}.
"""
downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
if downstream.upstream is None:
raise ValidationError(str(NoUpstream()))
try:
decline_sync(downstream)
decline_sync(downstream, request.user.id)
except (NoUpstream, BadUpstream, BadDownstream) as exc:
# This is somewhat unexpected. If the upstream link is missing or invalid, then the downstream author
# shouldn't have been prompted to accept/decline a sync in the first place. Of course, they could have just
Expand All @@ -564,51 +522,9 @@ def delete(self, request: _AuthenticatedRequest, usage_key_string: str) -> Respo
downstream.upstream,
)
raise ValidationError(str(exc)) from exc
modulestore().update_item(downstream, request.user.id)
return Response(status=204)


@view_auth_classes()
class DownstreamContainerListView(DeveloperErrorViewMixin, APIView):
"""
[DEPRECATED], use DownstreamListView instead.

List all container blocks which are linked to an upstream context, with optional filtering.
"""

def get(self, request: _AuthenticatedRequest):
"""
[DEPRECATED], use DownstreamListView.get instead, with `item_type='containers'`

Fetches publishable container entity links for given course key
"""
warnings.warn(
'`downstreams/` API is deprecated. Please use `downstreams-all/?item_type=components` instead.',
DeprecationWarning, stacklevel=3,
)
course_key_string = request.GET.get('course_id')
ready_to_sync = request.GET.get('ready_to_sync')
upstream_container_key = request.GET.get('upstream_container_key')
link_filter: dict[str, CourseKey | LibraryContainerLocator | bool] = {}
paginator = DownstreamListPaginator()
if course_key_string:
try:
link_filter["downstream_context_key"] = CourseKey.from_string(course_key_string)
except InvalidKeyError as exc:
raise ValidationError(detail=f"Malformed course key: {course_key_string}") from exc
if ready_to_sync is not None:
link_filter["ready_to_sync"] = BooleanField().to_internal_value(ready_to_sync)
if upstream_container_key:
try:
link_filter["upstream_container_key"] = LibraryContainerLocator.from_string(upstream_container_key)
except InvalidKeyError as exc:
raise ValidationError(detail=f"Malformed usage key: {upstream_container_key}") from exc
links = ContainerLink.filter_links(**link_filter)
paginated_links = paginator.paginate_queryset(links, self.request, view=self)
serializer = ContainerLinksSerializer(paginated_links, many=True)
return paginator.get_paginated_response(serializer.data, self.request)


def _load_accessible_block(user: User, usage_key_string: str, *, require_write_access: bool) -> XBlock:
"""
Given a logged in-user and a serialized usage key of an upstream-linked XBlock, load it from the ModuleStore,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ def _get_sync_status(self, usage_key: str):
def _sync_downstream(self, usage_key: str):
return self._api('post', f"/api/contentstore/v2/downstreams/{usage_key}/sync", {}, expect_response=200)

def _decline_sync_downstream(self, usage_key: str):
return self._api('delete', f"/api/contentstore/v2/downstreams/{usage_key}/sync", {}, expect_response=204)

def _get_course_block_olx(self, usage_key: str):
data = self._api('get', f'/api/olx-export/v1/xblock/{usage_key}/', {}, expect_response=200)
return data["blocks"][data["root_block_id"]]["olx"]
Expand Down Expand Up @@ -130,7 +133,7 @@ def _get_downstream_links(
data["item_type"] = str(item_type)
if use_top_level_parents is not None:
data["use_top_level_parents"] = str(use_top_level_parents)
return self.client.get("/api/contentstore/v2/downstreams-all/", data=data)
return self.client.get("/api/contentstore/v2/downstreams/", data=data)

def assertXmlEqual(self, xml_str_a: str, xml_str_b: str) -> bool:
""" Assert that the given XML strings are equal, ignoring attribute order and some whitespace variations. """
Expand Down Expand Up @@ -421,9 +424,7 @@ def test_unit_sync(self):
'version_available': 2, # <--- not updated since we didn't directly modify the unit
'version_synced': 2,
'version_declined': None,
# FIXME: ready_to_sync should be true, since a child block needs syncing.
# This may need to be fixed post-Teak, as syncing the children directly is still possible.
'ready_to_sync': False,
'ready_to_sync': True, # <--- It's the top-level parent of the block
'error_message': None,
})

Expand All @@ -434,7 +435,7 @@ def test_unit_sync(self):
'version_available': 3, # <--- updated since we modified the problem
'version_synced': 2,
'version_declined': None,
'ready_to_sync': True, # <--- updated
'ready_to_sync': False, # <-- It has top-level parent, the parent is the one who must synchronize
'error_message': None,
})

Expand Down Expand Up @@ -829,3 +830,167 @@ def test_unit_sync(self):
data = downstreams.json()
self.assertEqual(data["count"], 4)
self.assertListEqual(data["results"], expected_downstreams)

def test_unit_decline_sync(self):
"""
Test that we can decline sync a unit from the library into the course
"""
# 1️⃣ Create a "vertical" block in the course based on a "unit" container:
downstream_unit = self._create_block_from_upstream(
# The API consumer needs to specify "vertical" here, even though upstream is "unit".
# In the future we could create a nicer REST API endpoint for this that's not part of
# the messy '/xblock/' API and which auto-detects the types based on the upstream_key.
block_category="vertical",
parent_usage_key=str(self.course_subsection.usage_key),
upstream_key=self.upstream_unit["id"],
)
downstream_unit_block_key = get_block_key_dict(
UsageKey.from_string(downstream_unit["locator"]),
)
children_downstream_keys = self._get_course_block_children(downstream_unit["locator"])
downstream_problem1 = children_downstream_keys[1]
assert "type@problem" in downstream_problem1
status = self._get_sync_status(downstream_unit["locator"])
self.assertDictContainsEntries(status, {
'upstream_ref': self.upstream_unit["id"], # e.g. 'lct:CL-TEST:testlib:unit:u1'
'version_available': 2,
'version_synced': 2,
'version_declined': None,
'ready_to_sync': False,
'error_message': None,
# 'upstream_link': 'http://course-authoring-mfe/library/lib:CL-TEST:testlib/units/...'
})
assert status["upstream_link"].startswith("http://course-authoring-mfe/library/")
assert status["upstream_link"].endswith(f"/units/{self.upstream_unit['id']}")

# Check that the downstream container matches our expectations.
# Note that:
# (1) Every XBlock has an "upstream" field
# (2) some "downstream only" fields like weight and max_attempts are omitted.
# (3) The "top_level_downstream_parent" is the container created
self.assertXmlEqual(self._get_course_block_olx(downstream_unit["locator"]), f"""
<vertical
display_name="Unit 1 Title"
upstream_display_name="Unit 1 Title"
upstream="{self.upstream_unit['id']}"
upstream_version="2"
>
<html
display_name="Text Content"
upstream_display_name="Text Content"
editor="visual"
upstream="{self.upstream_html1['id']}"
upstream_version="2"
top_level_downstream_parent_key="{downstream_unit_block_key}"
>This is the HTML.</html>
<problem
display_name="Problem 1 Display Name"
upstream_display_name="Problem 1 Display Name"
markdown="MD 1"
{self.standard_capa_attributes}
upstream="{self.upstream_problem1['id']}"
upstream_version="2"
top_level_downstream_parent_key="{downstream_unit_block_key}"
>multiple choice...</problem>
<problem
display_name="Problem 2 Display Name"
upstream_display_name="Problem 2 Display Name"
markdown="null"
{self.standard_capa_attributes}
upstream="{self.upstream_problem2['id']}"
upstream_version="2"
top_level_downstream_parent_key="{downstream_unit_block_key}"
>multi select...</problem>
</vertical>
""")

# 2️⃣ Now, lets modify the upstream problem 1:
self._set_library_block_olx(
self.upstream_problem1["id"],
'<problem display_name="Problem 1 NEW name" markdown="updated">multiple choice v2...</problem>'
)
self._publish_container(self.upstream_unit["id"])

status = self._get_sync_status(downstream_unit["locator"])
self.assertDictContainsEntries(status, {
'upstream_ref': self.upstream_unit["id"], # e.g. 'lct:CL-TEST:testlib:unit:u1'
'version_available': 2, # <--- not updated since we didn't directly modify the unit
'version_synced': 2,
'version_declined': None,
'ready_to_sync': True, # <--- It's the top-level parent of the block
'error_message': None,
})

# Check the upstream/downstream status of [one of] the children
self.assertDictContainsEntries(self._get_sync_status(downstream_problem1), {
'upstream_ref': self.upstream_problem1["id"],
'version_available': 3, # <--- updated since we modified the problem
'version_synced': 2,
'version_declined': None,
'ready_to_sync': False, # <-- It has top-level parent, the parent is the one who must synchronize
'error_message': None,
})

# Now, decline the sync
self._decline_sync_downstream(downstream_unit["locator"])

status = self._get_sync_status(downstream_unit["locator"])
self.assertDictContainsEntries(status, {
'upstream_ref': self.upstream_unit["id"],
'version_available': 2,
'version_synced': 2,
'version_declined': 2,
'ready_to_sync': False,
'error_message': None,
})

# Check the upstream/downstream status of [one of] the children
self.assertDictContainsEntries(self._get_sync_status(downstream_problem1), {
'upstream_ref': self.upstream_problem1["id"],
'version_available': 3,
'version_synced': 2,
'version_declined': 3,
'ready_to_sync': False,
'error_message': None,
})

# Check that the downstream container has not had any changes
self.assertXmlEqual(self._get_course_block_olx(downstream_unit["locator"]), f"""
<vertical
display_name="Unit 1 Title"
upstream_display_name="Unit 1 Title"
upstream="{self.upstream_unit['id']}"
upstream_version="2"
upstream_version_declined="2"
>
<html
display_name="Text Content"
upstream_display_name="Text Content"
editor="visual"
upstream="{self.upstream_html1['id']}"
upstream_version="2"
upstream_version_declined="2"
top_level_downstream_parent_key="{downstream_unit_block_key}"
>This is the HTML.</html>
<problem
display_name="Problem 1 Display Name"
upstream_display_name="Problem 1 Display Name"
markdown="MD 1"
{self.standard_capa_attributes}
upstream="{self.upstream_problem1['id']}"
upstream_version="2"
upstream_version_declined="3"
top_level_downstream_parent_key="{downstream_unit_block_key}"
>multiple choice...</problem>
<problem
display_name="Problem 2 Display Name"
upstream_display_name="Problem 2 Display Name"
markdown="null"
{self.standard_capa_attributes}
upstream="{self.upstream_problem2['id']}"
upstream_version="2"
upstream_version_declined="2"
top_level_downstream_parent_key="{downstream_unit_block_key}"
>multi select...</problem>
</vertical>
""")
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,12 @@ def _get_upstream_link_good_and_syncable(downstream):
return UpstreamLink(
upstream_ref=downstream.upstream,
upstream_key=UsageKey.from_string(downstream.upstream),
downstream_key=str(downstream.usage_key),
version_synced=downstream.upstream_version,
version_available=(downstream.upstream_version or 0) + 1,
version_declined=downstream.upstream_version_declined,
error_message=None,
has_top_level_parent=False,
)


Expand Down Expand Up @@ -582,7 +584,7 @@ def call_api(
data["item_type"] = str(item_type)
if use_top_level_parents is not None:
data["use_top_level_parents"] = str(use_top_level_parents)
return self.client.get("/api/contentstore/v2/downstreams-all/", data=data)
return self.client.get("/api/contentstore/v2/downstreams/", data=data)

def test_200_all_downstreams_for_a_course(self):
"""
Expand Down
Loading
Loading