feat: Add the top-level parent logic to the ready_to_sync value in the UpstreamLink (#37217)

- Updates `UpstreamLink.ready_to_sync` with the top-level parent logic: In a container, `ready_to_sync` is `True` if the container or any children have changes.
- Updates `decline_sync` to decline children recursively.
This commit is contained in:
Chris Chávez
2025-08-21 16:34:58 -05:00
committed by GitHub
parent 617b6447cc
commit fa3dcc5482
5 changed files with 263 additions and 119 deletions

View File

@@ -13,24 +13,11 @@ urlpatterns = [
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(),

View File

@@ -81,7 +81,6 @@ UpstreamLink response schema:
"""
import logging
import warnings
from attrs import asdict as attrs_asdict
from django.db.models import QuerySet
@@ -101,8 +100,6 @@ from xblock.core import XBlock
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
@@ -276,47 +273,6 @@ class DownstreamListView(DeveloperErrorViewMixin, APIView):
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):
"""
@@ -551,8 +507,10 @@ class SyncFromUpstreamView(DeveloperErrorViewMixin, APIView):
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
@@ -564,51 +522,9 @@ class SyncFromUpstreamView(DeveloperErrorViewMixin, APIView):
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,

View File

@@ -71,6 +71,9 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
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"]
@@ -130,7 +133,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
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. """
@@ -421,9 +424,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
'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,
})
@@ -434,7 +435,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
'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,
})
@@ -829,3 +830,167 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
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>
""")

View File

@@ -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,
)
@@ -582,7 +584,7 @@ class GetUpstreamViewTest(
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):
"""

View File

@@ -24,6 +24,7 @@ from django.utils.translation import gettext_lazy as _
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryUsageLocatorV2
from opaque_keys.edx.keys import UsageKey
from xblock.exceptions import XBlockNotFoundError
from xblock.fields import Scope, String, Integer, Dict
from xblock.core import XBlockMixin, XBlock
@@ -77,16 +78,15 @@ class UpstreamLink:
"""
upstream_ref: str | None # Reference to the upstream content, e.g., a serialized library block usage key.
upstream_key: LibraryUsageLocatorV2 | LibraryContainerLocator | None # parsed opaque key version of upstream_ref
downstream_key: str | None # Key of the downstream object.
version_synced: int | None # Version of the upstream to which the downstream was last synced.
version_available: int | None # Latest version of the upstream that's available, or None if it couldn't be loaded.
version_declined: int | None # Latest version which the user has declined to sync with, if any.
error_message: str | None # If link is valid, None. Otherwise, a localized, human-friendly error message.
has_top_level_parent: bool # True if this Upstream link has a top-level parent
@property
def ready_to_sync(self) -> bool:
"""
Should we invite the downstream's authors to sync the latest upstream updates?
"""
def _is_ready_to_sync_individually(self) -> bool:
return bool(
self.upstream_ref and
self.version_available and
@@ -94,6 +94,60 @@ class UpstreamLink:
self.version_available > (self.version_declined or 0)
)
@property
def ready_to_sync(self) -> bool:
"""
Calculates the ready to sync value using the version available.
If is a container, also verifies if the children needs sync.
"""
from xmodule.modulestore.django import modulestore
# If this component/container has top-level parent, so we need to sync the parent
if self.has_top_level_parent:
return False
if isinstance(self.upstream_key, LibraryUsageLocatorV2):
return self._is_ready_to_sync_individually
elif isinstance(self.upstream_key, LibraryContainerLocator):
# The container itself has changes to update, it is not necessary to review its children
if self._is_ready_to_sync_individually:
return True
def check_children_ready_to_sync(xblock_downstream):
"""
Checks if one of the children of `xblock_downstream` is ready to sync
"""
if not xblock_downstream.has_children:
return False
downstream_children = xblock_downstream.get_children()
for child in downstream_children:
if child.upstream:
child_upstream_link = UpstreamLink.get_for_block(child)
child_ready_to_sync = bool(
child_upstream_link.upstream_ref and
child_upstream_link.version_available and
child_upstream_link.version_available > (child_upstream_link.version_synced or 0) and
child_upstream_link.version_available > (child_upstream_link.version_declined or 0)
)
# If one child needs sync, it is not needed to check more children
if child_ready_to_sync:
return True
if check_children_ready_to_sync(child):
# If one child needs sync, it is not needed to check more children
return True
return False
if self.downstream_key is not None:
return check_children_ready_to_sync(
modulestore().get_item(UsageKey.from_string(self.downstream_key))
)
return False
@property
def upstream_link(self) -> str | None:
"""
@@ -138,10 +192,12 @@ class UpstreamLink:
return cls(
upstream_ref=getattr(downstream, "upstream", ""),
upstream_key=None,
downstream_key=str(getattr(downstream, "usage_key", "")),
version_synced=getattr(downstream, "upstream_version", None),
version_available=None,
version_declined=None,
error_message=str(exc),
has_top_level_parent=False,
)
@classmethod
@@ -189,15 +245,16 @@ class UpstreamLink:
except XBlockNotFoundError as exc:
raise BadUpstream(_("Linked upstream library block was not found in the system")) from exc
version_available = block_meta.published_version_num
else:
elif isinstance(upstream_key, LibraryContainerLocator):
# The upstream is a Container:
assert isinstance(upstream_key, LibraryContainerLocator)
try:
container_meta = lib_api.get_container(upstream_key)
except lib_api.ContentLibraryContainerNotFound as exc:
raise BadUpstream(_("Linked upstream library container was not found in the system")) from exc
expected_downstream_block_type = container_meta.container_type.olx_tag
version_available = container_meta.published_version_num
else:
raise BadUpstream(_("Linked `upstream_key` is not a valid key"))
if downstream_type != expected_downstream_block_type:
# Note: generally the upstream and downstream types must match, except that upstream containers
@@ -214,27 +271,44 @@ class UpstreamLink:
)
)
return cls(
result = cls(
upstream_ref=downstream.upstream,
upstream_key=upstream_key,
downstream_key=str(downstream.usage_key),
version_synced=downstream.upstream_version,
version_available=version_available,
version_declined=downstream.upstream_version_declined,
error_message=None,
has_top_level_parent=downstream.top_level_downstream_parent_key is not None,
)
return result
def decline_sync(downstream: XBlock) -> None:
def decline_sync(downstream: XBlock, user_id=None) -> None:
"""
Given an XBlock that is linked to upstream content, mark the latest available update as 'declined' so that its
authors are not prompted (until another upstream version becomes available).
Does not save `downstream` to the store. That is left up to the caller.
The function is called recursively to perform the same operation on the children of the `downstream`.
If `downstream` lacks a valid+supported upstream link, this raises an UpstreamLinkException.
"""
upstream_link = UpstreamLink.get_for_block(downstream) # Can raise UpstreamLinkException
downstream.upstream_version_declined = upstream_link.version_available
if downstream.upstream:
from xmodule.modulestore.django import modulestore
store = modulestore()
upstream_link = UpstreamLink.get_for_block(downstream) # Can raise UpstreamLinkException
upstream_key = upstream_link.upstream_key
downstream.upstream_version_declined = upstream_link.version_available
if isinstance(upstream_key, LibraryContainerLocator) and downstream.has_children:
with store.bulk_operations(downstream.usage_key.context_key):
children = downstream.get_children()
for child in children:
decline_sync(child, user_id)
store.update_item(downstream, user_id)
def sever_upstream_link(downstream: XBlock) -> None: