diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py
index a4ece6c85d59..e9f599772d3e 100644
--- a/cms/djangoapps/contentstore/helpers.py
+++ b/cms/djangoapps/contentstore/helpers.py
@@ -9,6 +9,7 @@
from attrs import frozen, Factory
from django.conf import settings
+from django.contrib.auth import get_user_model
from django.utils.translation import gettext as _
from opaque_keys.edx.keys import AssetKey, CourseKey, UsageKey
from opaque_keys.edx.locator import DefinitionLocator, LocalId
@@ -22,6 +23,7 @@
from xmodule.xml_block import XmlMixin
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
+from cms.lib.xblock.upstream_sync import UpstreamLink, BadUpstream, BadDownstream, fetch_customizable_fields
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
import openedx.core.djangoapps.content_staging.api as content_staging_api
import openedx.core.djangoapps.content_tagging.api as content_tagging_api
@@ -30,6 +32,10 @@
log = logging.getLogger(__name__)
+
+User = get_user_model()
+
+
# Note: Grader types are used throughout the platform but most usages are simply in-line
# strings. In addition, new grader types can be defined on the fly anytime one is needed
# (because they're just strings). This dict is an attempt to constrain the sprawl in Studio.
@@ -282,9 +288,10 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
node,
parent_xblock,
store,
- user_id=request.user.id,
+ user=request.user,
slug_hint=user_clipboard.source_usage_key.block_id,
copied_from_block=str(user_clipboard.source_usage_key),
+ copied_from_version_num=user_clipboard.content.version_num,
tags=user_clipboard.content.tags,
)
# Now handle static files that need to go into Files & Uploads:
@@ -293,7 +300,6 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
staged_content_id=user_clipboard.content.id,
static_files=static_files,
)
-
return new_xblock, notices
@@ -302,12 +308,15 @@ def _import_xml_node_to_parent(
parent_xblock: XBlock,
# The modulestore we're using
store,
- # The ID of the user who is performing this operation
- user_id: int,
+ # The user who is performing this operation
+ user: User,
# Hint to use as usage ID (block_id) for the new XBlock
slug_hint: str | None = None,
# UsageKey of the XBlock that this one is a copy of
copied_from_block: str | None = None,
+ # Positive int version of source block, if applicable (e.g., library block).
+ # Zero if not applicable (e.g., course block).
+ copied_from_version_num: int = 0,
# Content tags applied to the source XBlock(s)
tags: dict[str, str] | None = None,
) -> XBlock:
@@ -373,12 +382,32 @@ def _import_xml_node_to_parent(
raise NotImplementedError("We don't yet support pasting XBlocks with children")
temp_xblock.parent = parent_key
if copied_from_block:
- # Store a reference to where this block was copied from, in the 'copied_from_block' field (AuthoringMixin)
- temp_xblock.copied_from_block = copied_from_block
+ # Try to link the pasted block (downstream) to the copied block (upstream).
+ temp_xblock.upstream = copied_from_block
+ try:
+ UpstreamLink.get_for_block(temp_xblock)
+ except (BadDownstream, BadUpstream):
+ # Usually this will fail. For example, if the copied block is a modulestore course block, it can't be an
+ # upstream. That's fine! Instead, we store a reference to where this block was copied from, in the
+ # 'copied_from_block' field (from AuthoringMixin).
+ temp_xblock.upstream = None
+ temp_xblock.copied_from_block = copied_from_block
+ else:
+ # But if it doesn't fail, then populate the `upstream_version` field based on what was copied. Note that
+ # this could be the latest published version, or it could be an an even newer draft version.
+ temp_xblock.upstream_version = copied_from_version_num
+ # Also, fetch upstream values (`upstream_display_name`, etc.).
+ # Recall that the copied block could be a draft. So, rather than fetching from the published upstream (which
+ # could be older), fetch from the copied block itself. That way, if an author customizes a field, but then
+ # later wants to restore it, it will restore to the value that the field had when the block was pasted. Of
+ # course, if the author later syncs updates from a *future* published upstream version, then that will fetch
+ # new values from the published upstream content.
+ fetch_customizable_fields(upstream=temp_xblock, downstream=temp_xblock, user=user)
+
# Save the XBlock into modulestore. We need to save the block and its parent for this to work:
- new_xblock = store.update_item(temp_xblock, user_id, allow_not_found=True)
+ new_xblock = store.update_item(temp_xblock, user.id, allow_not_found=True)
parent_xblock.children.append(new_xblock.location)
- store.update_item(parent_xblock, user_id)
+ store.update_item(parent_xblock, user.id)
children_handled = False
if hasattr(new_xblock, 'studio_post_paste'):
@@ -394,7 +423,7 @@ def _import_xml_node_to_parent(
child_node,
new_xblock,
store,
- user_id=user_id,
+ user=user,
copied_from_block=str(child_copied_from),
tags=tags,
)
diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py
index d72707ed7836..f2e8b6ef431b 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py
@@ -103,6 +103,18 @@ def get_assets_url(self, obj):
return None
+class UpstreamLinkSerializer(serializers.Serializer):
+ """
+ Serializer holding info for syncing a block with its upstream (eg, a library block).
+ """
+ upstream_ref = serializers.CharField()
+ version_synced = serializers.IntegerField()
+ version_available = serializers.IntegerField(allow_null=True)
+ version_declined = serializers.IntegerField(allow_null=True)
+ error_message = serializers.CharField(allow_null=True)
+ ready_to_sync = serializers.BooleanField()
+
+
class ChildVerticalContainerSerializer(serializers.Serializer):
"""
Serializer for representing a xblock child of vertical container.
@@ -113,6 +125,7 @@ class ChildVerticalContainerSerializer(serializers.Serializer):
block_type = serializers.CharField()
user_partition_info = serializers.DictField()
user_partitions = serializers.ListField()
+ upstream_link = UpstreamLinkSerializer(allow_null=True)
actions = serializers.SerializerMethodField()
validation_messages = MessageValidation(many=True)
render_error = serializers.CharField()
diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py
index d3fc37198213..7cac074a433f 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py
@@ -70,6 +70,8 @@ def setup_xblock(self):
parent=self.vertical.location,
category="html",
display_name="Html Content 2",
+ upstream="lb:FakeOrg:FakeLib:html:FakeBlock",
+ upstream_version=5,
)
def create_block(self, parent, category, display_name, **kwargs):
@@ -193,6 +195,7 @@ def test_children_content(self):
"name": self.html_unit_first.display_name_with_default,
"block_id": str(self.html_unit_first.location),
"block_type": self.html_unit_first.location.block_type,
+ "upstream_link": None,
"user_partition_info": expected_user_partition_info,
"user_partitions": expected_user_partitions,
"actions": {
@@ -218,12 +221,21 @@ def test_children_content(self):
"can_delete": True,
"can_manage_tags": True,
},
+ "upstream_link": {
+ "upstream_ref": "lb:FakeOrg:FakeLib:html:FakeBlock",
+ "version_synced": 5,
+ "version_available": None,
+ "version_declined": None,
+ "error_message": "Linked library item was not found in the system",
+ "ready_to_sync": False,
+ },
"user_partition_info": expected_user_partition_info,
"user_partitions": expected_user_partitions,
"validation_messages": [],
"render_error": "",
},
]
+ self.maxDiff = None
self.assertEqual(response.data["children"], expected_response)
def test_not_valid_usage_key_string(self):
diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py
index 670b94afbbe0..0798c341cc1c 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py
@@ -20,6 +20,7 @@
ContainerHandlerSerializer,
VerticalContainerSerializer,
)
+from cms.lib.xblock.upstream_sync import UpstreamLink
from openedx.core.lib.api.view_utils import view_auth_classes
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
@@ -198,6 +199,7 @@ def get(self, request: Request, usage_key_string: str):
"block_type": "drag-and-drop-v2",
"user_partition_info": {},
"user_partitions": {}
+ "upstream_link": null,
"actions": {
"can_copy": true,
"can_duplicate": true,
@@ -215,6 +217,13 @@ def get(self, request: Request, usage_key_string: str):
"block_type": "video",
"user_partition_info": {},
"user_partitions": {}
+ "upstream_link": {
+ "upstream_ref": "lb:org:mylib:video:404",
+ "version_synced": 16
+ "version_available": null,
+ "error_message": "Linked library item not found: lb:org:mylib:video:404",
+ "ready_to_sync": false,
+ },
"actions": {
"can_copy": true,
"can_duplicate": true,
@@ -232,6 +241,13 @@ def get(self, request: Request, usage_key_string: str):
"block_type": "html",
"user_partition_info": {},
"user_partitions": {},
+ "upstream_link": {
+ "upstream_ref": "lb:org:mylib:html:abcd",
+ "version_synced": 43,
+ "version_available": 49,
+ "error_message": null,
+ "ready_to_sync": true,
+ },
"actions": {
"can_copy": true,
"can_duplicate": true,
@@ -267,6 +283,7 @@ def get(self, request: Request, usage_key_string: str):
child_info = modulestore().get_item(child)
user_partition_info = get_visibility_partition_info(child_info, course=course)
user_partitions = get_user_partition_info(child_info, course=course)
+ upstream_link = UpstreamLink.try_get_for_block(child_info)
validation_messages = get_xblock_validation_messages(child_info)
render_error = get_xblock_render_error(request, child_info)
@@ -277,6 +294,12 @@ def get(self, request: Request, usage_key_string: str):
"block_type": child_info.location.block_type,
"user_partition_info": user_partition_info,
"user_partitions": user_partitions,
+ "upstream_link": (
+ # If the block isn't linked to an upstream (which is by far the most common case) then just
+ # make this field null, which communicates the same info, but with less noise.
+ upstream_link.to_json() if upstream_link.upstream_ref
+ else None
+ ),
"validation_messages": validation_messages,
"render_error": render_error,
})
diff --git a/cms/djangoapps/contentstore/rest_api/v2/urls.py b/cms/djangoapps/contentstore/rest_api/v2/urls.py
index ad61cc937015..3e653d07fbcf 100644
--- a/cms/djangoapps/contentstore/rest_api/v2/urls.py
+++ b/cms/djangoapps/contentstore/rest_api/v2/urls.py
@@ -1,15 +1,31 @@
"""Contenstore API v2 URLs."""
-from django.urls import path
-
-from cms.djangoapps.contentstore.rest_api.v2.views import HomePageCoursesViewV2
+from django.conf import settings
+from django.urls import path, re_path
+from cms.djangoapps.contentstore.rest_api.v2.views import home, downstreams
app_name = "v2"
urlpatterns = [
path(
"home/courses",
- HomePageCoursesViewV2.as_view(),
+ home.HomePageCoursesViewV2.as_view(),
name="courses",
),
+ # TODO: Potential future path.
+ # re_path(
+ # fr'^downstreams/$',
+ # downstreams.DownstreamsListView.as_view(),
+ # name="downstreams_list",
+ # ),
+ re_path(
+ fr'^downstreams/{settings.USAGE_KEY_PATTERN}$',
+ downstreams.DownstreamView.as_view(),
+ name="downstream"
+ ),
+ re_path(
+ fr'^downstreams/{settings.USAGE_KEY_PATTERN}/sync$',
+ downstreams.SyncFromUpstreamView.as_view(),
+ name="sync_from_upstream"
+ ),
]
diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v2/views/__init__.py
index 73ddde98440c..e69de29bb2d1 100644
--- a/cms/djangoapps/contentstore/rest_api/v2/views/__init__.py
+++ b/cms/djangoapps/contentstore/rest_api/v2/views/__init__.py
@@ -1,3 +0,0 @@
-"""Module for v2 views."""
-
-from cms.djangoapps.contentstore.rest_api.v2.views.home import HomePageCoursesViewV2
diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py
new file mode 100644
index 000000000000..5079698082be
--- /dev/null
+++ b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py
@@ -0,0 +1,251 @@
+"""
+API Views for managing & syncing links between upstream & downstream content
+
+API paths (We will move these into proper api_doc_tools annotations soon
+https://github.com/openedx/edx-platform/issues/35653):
+
+ /api/contentstore/v2/downstreams/{usage_key_string}
+
+ GET: Inspect a single downstream block's link to upstream content.
+ 200: Upstream link details successfully fetched. Returns UpstreamLink (may contain an error_message).
+ 404: Downstream block not found or user lacks permission to edit it.
+
+ DELETE: Sever a single downstream block's link to upstream content.
+ 204: Block successfully unlinked (or it wasn't linked in the first place). No response body.
+ 404: Downstream block not found or user lacks permission to edit it.
+
+ PUT: Establish or modify a single downstream block's link to upstream content. An authoring client could use this
+ endpoint to add library content in a two-step process, specifically: (1) add a blank block to a course, then
+ (2) link it to a content library with ?sync=True.
+ REQUEST BODY: {
+ "upstream_ref": str, // reference to upstream block (eg, library block usage key)
+ "sync": bool, // whether to sync in upstream content (False by default)
+ }
+ 200: Downstream block's upstream link successfully edited (and synced, if requested). Returns UpstreamLink.
+ 400: upstream_ref is malformed, missing, or inaccessible.
+ 400: Content at upstream_ref does not support syncing.
+ 404: Downstream block not found or user lacks permission to edit it.
+
+ /api/contentstore/v2/downstreams/{usage_key_string}/sync
+
+ POST: Sync a downstream block with upstream content.
+ 200: Downstream block successfully synced with upstream content.
+ 400: Downstream block is not linked to upstream content.
+ 400: Upstream is malformed, missing, or inaccessible.
+ 400: Upstream block does not support syncing.
+ 404: Downstream block not found or user lacks permission to edit it.
+
+ DELETE: Decline an available sync for a downstream block.
+ 204: Sync successfuly dismissed. No response body.
+ 400: Downstream block is not linked to upstream content.
+ 404: Downstream block not found or user lacks permission to edit it.
+
+ # NOT YET IMPLEMENTED -- Will be needed for full Libraries Relaunch in ~Teak.
+ /api/contentstore/v2/downstreams
+ /api/contentstore/v2/downstreams?course_id=course-v1:A+B+C&ready_to_sync=true
+ GET: List downstream blocks that can be synced, filterable by course or sync-readiness.
+ 200: A paginated list of applicable & accessible downstream blocks. Entries are UpstreamLinks.
+
+UpstreamLink response schema:
+ {
+ "upstream_ref": string?
+ "version_synced": string?,
+ "version_available": string?,
+ "version_declined": string?,
+ "error_message": string?,
+ "ready_to_sync": Boolean
+ }
+"""
+import logging
+
+from django.contrib.auth.models import User # pylint: disable=imported-auth-user
+from opaque_keys import InvalidKeyError
+from opaque_keys.edx.keys import UsageKey
+from rest_framework.exceptions import NotFound, ValidationError
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from xblock.core import XBlock
+
+from cms.lib.xblock.upstream_sync import (
+ UpstreamLink, UpstreamLinkException, NoUpstream, BadUpstream, BadDownstream,
+ fetch_customizable_fields, sync_from_upstream, decline_sync, sever_upstream_link
+)
+from common.djangoapps.student.auth import has_studio_write_access, has_studio_read_access
+from openedx.core.lib.api.view_utils import (
+ DeveloperErrorViewMixin,
+ view_auth_classes,
+)
+from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.exceptions import ItemNotFoundError
+
+
+logger = logging.getLogger(__name__)
+
+
+class _AuthenticatedRequest(Request):
+ """
+ Alias for the `Request` class which tells mypy to assume that `.user` is not an AnonymousUser.
+
+ Using this does NOT ensure the request is actually authenticated--
+ you will some other way to ensure that, such as `@view_auth_classes(is_authenticated=True)`.
+ """
+ user: User
+
+
+# TODO: Potential future view.
+# @view_auth_classes(is_authenticated=True)
+# class DownstreamListView(DeveloperErrorViewMixin, APIView):
+# """
+# List all blocks which are linked to upstream content, with optional filtering.
+# """
+# def get(self, request: _AuthenticatedRequest) -> Response:
+# """
+# Handle the request.
+# """
+# course_key_string = request.GET['course_id']
+# syncable = request.GET['ready_to_sync']
+# ...
+
+
+@view_auth_classes(is_authenticated=True)
+class DownstreamView(DeveloperErrorViewMixin, APIView):
+ """
+ Inspect or manage an XBlock's link to upstream content.
+ """
+ def get(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
+ """
+ Inspect an XBlock's link to upstream content.
+ """
+ downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=False)
+ return Response(UpstreamLink.try_get_for_block(downstream).to_json())
+
+ def put(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
+ """
+ Edit an XBlock's link to upstream content.
+ """
+ downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
+ new_upstream_ref = request.data.get("upstream_ref")
+
+ # Set `downstream.upstream` so that we can try to sync and/or fetch.
+ # Note that, if this fails and we raise a 4XX, then we will not call modulstore().update_item,
+ # thus preserving the former value of `downstream.upstream`.
+ downstream.upstream = new_upstream_ref
+ sync_param = request.data.get("sync", "false").lower()
+ if sync_param not in ["true", "false"]:
+ raise ValidationError({"sync": "must be 'true' or 'false'"})
+ try:
+ if sync_param == "true":
+ sync_from_upstream(downstream=downstream, user=request.user)
+ else:
+ # Even if we're not syncing (i.e., updating the downstream's values with the upstream's), we still need
+ # to fetch the upstream's customizable values and store them as hidden fields on the downstream. This
+ # ensures that downstream authors can restore defaults based on the upstream.
+ fetch_customizable_fields(downstream=downstream, user=request.user)
+ except BadDownstream as exc:
+ logger.exception(
+ "'%s' is an invalid downstream; refusing to set its upstream to '%s'",
+ usage_key_string,
+ new_upstream_ref,
+ )
+ raise ValidationError(str(exc)) from exc
+ except BadUpstream as exc:
+ logger.exception(
+ "'%s' is an invalid upstream reference; refusing to set it as upstream of '%s'",
+ new_upstream_ref,
+ usage_key_string,
+ )
+ raise ValidationError({"upstream_ref": str(exc)}) from exc
+ except NoUpstream as exc:
+ raise ValidationError({"upstream_ref": "value missing"}) from exc
+ modulestore().update_item(downstream, request.user.id)
+ # Note: We call `get_for_block` (rather than `try_get_for_block`) because if anything is wrong with the
+ # upstream at this point, then that is completely unexpected, so it's appropriate to let the 500 happen.
+ return Response(UpstreamLink.get_for_block(downstream).to_json())
+
+ def delete(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
+ """
+ Sever an XBlock's link to upstream content.
+ """
+ downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
+ try:
+ sever_upstream_link(downstream)
+ except NoUpstream as exc:
+ logger.exception(
+ "Tried to DELETE upstream link of '%s', but it wasn't linked to anything in the first place. "
+ "Will do nothing. ",
+ usage_key_string,
+ )
+ else:
+ modulestore().update_item(downstream, request.user.id)
+ return Response(status=204)
+
+
+@view_auth_classes(is_authenticated=True)
+class SyncFromUpstreamView(DeveloperErrorViewMixin, APIView):
+ """
+ Accept or decline an opportunity to sync a downstream block from its upstream content.
+ """
+
+ def post(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
+ """
+ Pull latest updates to the block at {usage_key_string} from its linked upstream content.
+ """
+ downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
+ try:
+ sync_from_upstream(downstream, request.user)
+ except UpstreamLinkException as exc:
+ logger.exception(
+ "Could not sync from upstream '%s' to downstream '%s'",
+ downstream.upstream,
+ usage_key_string,
+ )
+ raise ValidationError(detail=str(exc)) from exc
+ modulestore().update_item(downstream, request.user.id)
+ # Note: We call `get_for_block` (rather than `try_get_for_block`) because if anything is wrong with the
+ # upstream at this point, then that is completely unexpected, so it's appropriate to let the 500 happen.
+ return Response(UpstreamLink.get_for_block(downstream).to_json())
+
+ def delete(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
+ """
+ Decline the latest updates to the block at {usage_key_string}.
+ """
+ downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
+ try:
+ decline_sync(downstream)
+ 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
+ # hit the HTTP API anyway, or they could be viewing a Studio page which hasn't been refreshed in a while.
+ # So, it's a 400, not a 500.
+ logger.exception(
+ "Tried to decline a sync to downstream '%s', but the upstream link '%s' is invalid.",
+ usage_key_string,
+ downstream.upstream,
+ )
+ raise ValidationError(str(exc)) from exc
+ modulestore().update_item(downstream, request.user.id)
+ return Response(status=204)
+
+
+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,
+ raising a DRF-friendly exception if anything goes wrong.
+
+ Raises NotFound if usage key is malformed, if the user lacks access, or if the block doesn't exist.
+ """
+ not_found = NotFound(detail=f"Block not found or not accessible: {usage_key_string}")
+ try:
+ usage_key = UsageKey.from_string(usage_key_string)
+ except InvalidKeyError as exc:
+ raise ValidationError(detail=f"Malformed block usage key: {usage_key_string}") from exc
+ if require_write_access and not has_studio_write_access(user, usage_key.context_key):
+ raise not_found
+ if not has_studio_read_access(user, usage_key.context_key):
+ raise not_found
+ try:
+ block = modulestore().get_item(usage_key)
+ except ItemNotFoundError as exc:
+ raise not_found from exc
+ return block
diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py
new file mode 100644
index 000000000000..616035473e7e
--- /dev/null
+++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py
@@ -0,0 +1,266 @@
+"""
+Unit tests for /api/contentstore/v2/downstreams/* JSON APIs.
+"""
+from unittest.mock import patch
+
+from cms.lib.xblock.upstream_sync import UpstreamLink, BadUpstream
+from common.djangoapps.student.tests.factories import UserFactory
+from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
+
+from .. import downstreams as downstreams_views
+
+
+MOCK_UPSTREAM_REF = "mock-upstream-ref"
+MOCK_UPSTREAM_ERROR = "your LibraryGPT subscription has expired"
+
+
+def _get_upstream_link_good_and_syncable(downstream):
+ return UpstreamLink(
+ upstream_ref=downstream.upstream,
+ version_synced=downstream.upstream_version,
+ version_available=(downstream.upstream_version or 0) + 1,
+ version_declined=downstream.upstream_version_declined,
+ error_message=None,
+ )
+
+
+def _get_upstream_link_bad(_downstream):
+ raise BadUpstream(MOCK_UPSTREAM_ERROR)
+
+
+class _DownstreamViewTestMixin:
+ """
+ Shared data and error test cases.
+ """
+
+ def setUp(self):
+ """
+ Create a simple course with one unit and two videos, one of which is linked to an "upstream".
+ """
+ super().setUp()
+ self.course = CourseFactory.create()
+ chapter = BlockFactory.create(category='chapter', parent=self.course)
+ sequential = BlockFactory.create(category='sequential', parent=chapter)
+ unit = BlockFactory.create(category='vertical', parent=sequential)
+ self.regular_video_key = BlockFactory.create(category='video', parent=unit).usage_key
+ self.downstream_video_key = BlockFactory.create(
+ category='video', parent=unit, upstream=MOCK_UPSTREAM_REF, upstream_version=123,
+ ).usage_key
+ self.fake_video_key = self.course.id.make_usage_key("video", "NoSuchVideo")
+ self.superuser = UserFactory(username="superuser", password="password", is_staff=True, is_superuser=True)
+ self.learner = UserFactory(username="learner", password="password")
+
+ def call_api(self, usage_key_string):
+ raise NotImplementedError
+
+ def test_404_downstream_not_found(self):
+ """
+ Do we raise 404 if the specified downstream block could not be loaded?
+ """
+ self.client.login(username="superuser", password="password")
+ response = self.call_api(self.fake_video_key)
+ assert response.status_code == 404
+ assert "not found" in response.data["developer_message"]
+
+ def test_404_downstream_not_accessible(self):
+ """
+ Do we raise 404 if the user lacks read access on the specified downstream block?
+ """
+ self.client.login(username="learner", password="password")
+ response = self.call_api(self.downstream_video_key)
+ assert response.status_code == 404
+ assert "not found" in response.data["developer_message"]
+
+
+class GetDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase):
+ """
+ Test that `GET /api/v2/contentstore/downstreams/...` inspects a downstream's link to an upstream.
+ """
+ def call_api(self, usage_key_string):
+ return self.client.get(f"/api/contentstore/v2/downstreams/{usage_key_string}")
+
+ @patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
+ def test_200_good_upstream(self):
+ """
+ Does the happy path work?
+ """
+ self.client.login(username="superuser", password="password")
+ response = self.call_api(self.downstream_video_key)
+ assert response.status_code == 200
+ assert response.data['upstream_ref'] == MOCK_UPSTREAM_REF
+ assert response.data['error_message'] is None
+ assert response.data['ready_to_sync'] is True
+
+ @patch.object(UpstreamLink, "get_for_block", _get_upstream_link_bad)
+ def test_200_bad_upstream(self):
+ """
+ If the upstream link is broken, do we still return 200, but with an error message in body?
+ """
+ self.client.login(username="superuser", password="password")
+ response = self.call_api(self.downstream_video_key)
+ assert response.status_code == 200
+ assert response.data['upstream_ref'] == MOCK_UPSTREAM_REF
+ assert response.data['error_message'] == MOCK_UPSTREAM_ERROR
+ assert response.data['ready_to_sync'] is False
+
+ def test_200_no_upstream(self):
+ """
+ If the upstream link is missing, do we still return 200, but with an error message in body?
+ """
+ self.client.login(username="superuser", password="password")
+ response = self.call_api(self.regular_video_key)
+ assert response.status_code == 200
+ assert response.data['upstream_ref'] is None
+ assert "is not linked" in response.data['error_message']
+ assert response.data['ready_to_sync'] is False
+
+
+class PutDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase):
+ """
+ Test that `PUT /api/v2/contentstore/downstreams/...` edits a downstream's link to an upstream.
+ """
+ def call_api(self, usage_key_string, sync: str | None = None):
+ return self.client.put(
+ f"/api/contentstore/v2/downstreams/{usage_key_string}",
+ data={
+ "upstream_ref": MOCK_UPSTREAM_REF,
+ **({"sync": sync} if sync else {}),
+ },
+ content_type="application/json",
+ )
+
+ @patch.object(downstreams_views, "fetch_customizable_fields")
+ @patch.object(downstreams_views, "sync_from_upstream")
+ @patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
+ def test_200_with_sync(self, mock_sync, mock_fetch):
+ """
+ Does the happy path work (with sync=True)?
+ """
+ self.client.login(username="superuser", password="password")
+ response = self.call_api(self.regular_video_key, sync='true')
+ assert response.status_code == 200
+ video_after = modulestore().get_item(self.regular_video_key)
+ assert mock_sync.call_count == 1
+ assert mock_fetch.call_count == 0
+ assert video_after.upstream == MOCK_UPSTREAM_REF
+
+ @patch.object(downstreams_views, "fetch_customizable_fields")
+ @patch.object(downstreams_views, "sync_from_upstream")
+ @patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
+ def test_200_no_sync(self, mock_sync, mock_fetch):
+ """
+ Does the happy path work (with sync=False)?
+ """
+ self.client.login(username="superuser", password="password")
+ response = self.call_api(self.regular_video_key, sync='false')
+ assert response.status_code == 200
+ video_after = modulestore().get_item(self.regular_video_key)
+ assert mock_sync.call_count == 0
+ assert mock_fetch.call_count == 1
+ assert video_after.upstream == MOCK_UPSTREAM_REF
+
+ @patch.object(downstreams_views, "fetch_customizable_fields", side_effect=BadUpstream(MOCK_UPSTREAM_ERROR))
+ def test_400(self, sync: str):
+ """
+ Do we raise a 400 if the provided upstream reference is malformed or not accessible?
+ """
+ self.client.login(username="superuser", password="password")
+ response = self.call_api(self.downstream_video_key)
+ assert response.status_code == 400
+ assert MOCK_UPSTREAM_ERROR in response.data['developer_message']['upstream_ref']
+ video_after = modulestore().get_item(self.regular_video_key)
+ assert video_after.upstream is None
+
+
+class DeleteDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase):
+ """
+ Test that `DELETE /api/v2/contentstore/downstreams/...` severs a downstream's link to an upstream.
+ """
+ def call_api(self, usage_key_string):
+ return self.client.delete(f"/api/contentstore/v2/downstreams/{usage_key_string}")
+
+ @patch.object(downstreams_views, "sever_upstream_link")
+ @patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
+ def test_204(self, mock_sever):
+ """
+ Does the happy path work?
+ """
+ self.client.login(username="superuser", password="password")
+ response = self.call_api(self.downstream_video_key)
+ assert response.status_code == 204
+ assert mock_sever.call_count == 1
+
+ @patch.object(downstreams_views, "sever_upstream_link")
+ def test_204_no_upstream(self, mock_sever):
+ """
+ If there's no upsream, do we still happily return 204?
+ """
+ self.client.login(username="superuser", password="password")
+ response = self.call_api(self.regular_video_key)
+ assert response.status_code == 204
+ assert mock_sever.call_count == 1
+
+
+class _DownstreamSyncViewTestMixin(_DownstreamViewTestMixin):
+ """
+ Shared tests between the /api/contentstore/v2/downstreams/.../sync endpoints.
+ """
+
+ @patch.object(UpstreamLink, "get_for_block", _get_upstream_link_bad)
+ def test_400_bad_upstream(self):
+ """
+ If the upstream link is bad, do we get a 400?
+ """
+ self.client.login(username="superuser", password="password")
+ response = self.call_api(self.downstream_video_key)
+ assert response.status_code == 400
+ assert MOCK_UPSTREAM_ERROR in response.data["developer_message"][0]
+
+ def test_400_no_upstream(self):
+ """
+ If the upstream link is missing, do we get a 400?
+ """
+ self.client.login(username="superuser", password="password")
+ response = self.call_api(self.regular_video_key)
+ assert response.status_code == 400
+ assert "is not linked" in response.data["developer_message"][0]
+
+
+class PostDownstreamSyncViewTest(_DownstreamSyncViewTestMixin, SharedModuleStoreTestCase):
+ """
+ Test that `POST /api/v2/contentstore/downstreams/.../sync` initiates a sync from the linked upstream.
+ """
+ def call_api(self, usage_key_string):
+ return self.client.post(f"/api/contentstore/v2/downstreams/{usage_key_string}/sync")
+
+ @patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
+ @patch.object(downstreams_views, "sync_from_upstream")
+ def test_200(self, mock_sync_from_upstream):
+ """
+ Does the happy path work?
+ """
+ self.client.login(username="superuser", password="password")
+ response = self.call_api(self.downstream_video_key)
+ assert response.status_code == 200
+ assert mock_sync_from_upstream.call_count == 1
+
+
+class DeleteDownstreamSyncViewtest(_DownstreamSyncViewTestMixin, SharedModuleStoreTestCase):
+ """
+ Test that `DELETE /api/v2/contentstore/downstreams/.../sync` declines a sync from the linked upstream.
+ """
+ def call_api(self, usage_key_string):
+ return self.client.delete(f"/api/contentstore/v2/downstreams/{usage_key_string}/sync")
+
+ @patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
+ @patch.object(downstreams_views, "decline_sync")
+ def test_204(self, mock_decline_sync):
+ """
+ Does the happy path work?
+ """
+ self.client.login(username="superuser", password="password")
+ response = self.call_api(self.downstream_video_key)
+ assert response.status_code == 204
+ assert mock_decline_sync.call_count == 1
diff --git a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py
index 7979a422a331..5706b44e2cec 100644
--- a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py
+++ b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py
@@ -7,11 +7,13 @@
from opaque_keys.edx.keys import UsageKey
from rest_framework.test import APIClient
from openedx_tagging.core.tagging.models import Tag
+from organizations.models import Organization
from xmodule.modulestore.django import contentstore, modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory, LibraryFactory
from cms.djangoapps.contentstore.utils import reverse_usage_url
+from openedx.core.djangoapps.content_libraries import api as library_api
from openedx.core.djangoapps.content_tagging import api as tagging_api
CLIPBOARD_ENDPOINT = "/api/content-staging/v1/clipboard/"
@@ -391,6 +393,78 @@ def test_paste_with_assets(self):
assert source_pic2_hash != dest_pic2_hash # Because there was a conflict, this file was unchanged.
+class ClipboardPasteFromV2LibraryTestCase(ModuleStoreTestCase):
+ """
+ Test Clipboard Paste functionality with a "new" (as of Sumac) library
+ """
+
+ def setUp(self):
+ """
+ Set up a v2 Content Library and a library content block
+ """
+ super().setUp()
+ self.client = APIClient()
+ self.client.login(username=self.user.username, password=self.user_password)
+ self.store = modulestore()
+
+ self.library = library_api.create_library(
+ library_type=library_api.COMPLEX,
+ org=Organization.objects.create(name="Test Org", short_name="CL-TEST"),
+ slug="lib",
+ title="Library",
+ )
+
+ self.lib_block_key = library_api.create_library_block(self.library.key, "problem", "p1").usage_key # v==1
+ library_api.set_library_block_olx(self.lib_block_key, """
+
${selected_groups_label}
@@ -114,6 +132,18 @@