feat: support for syncing units from libraries to courses (#36553)

* feat: library unit sync
* feat: create component link only for component xblocks
* feat: container link model
* feat: update downstream api views
* feat: delete extra components in container on sync (not working)
* fix: duplicate definitions of LibraryXBlockMetadata
* test: add a new integration test suite for syncing
* feat: partially implement container+child syncing
* fix: blockserializer wasn't always serializing all HTML block fields
* feat: handle reorder, addition and deletion of components in sync

Updates children components of unit in course based on upstream unit,
deletes removed component, adds new ones and updates order as per
upstream.

* feat: return unit upstreamInfo and disallow edits to units in courses that are sourced from a library (#773)
* feat: Add upstream_info to unit
* feat: disallow edits to units in courses that are sourced from a library (#774)

---------

Co-authored-by: Jillian Vogel <jill@opencraft.com>
Co-authored-by: Rômulo Penido <romulo.penido@gmail.com>

* docs: capitalization of XBlock

Co-authored-by: David Ormsbee <dave@axim.org>

* refactor: (minor) change python property name to reflect type better

* fix: lots of "Tried to inspect a missing...upstream link" warnings

when viewing a unit in Studio

* docs: mention potential REST API for future refactor

* fix: check if upstream actually exists before making unit read-only

* chore: fix camel-case var

* fix: test failure when mocked XBlock doesn't have UpstreamSyncMixin

---------

Co-authored-by: Braden MacDonald <braden@opencraft.com>
Co-authored-by: Chris Chávez <xnpiochv@gmail.com>
Co-authored-by: Jillian Vogel <jill@opencraft.com>
Co-authored-by: Rômulo Penido <romulo.penido@gmail.com>
Co-authored-by: Braden MacDonald <mail@bradenm.com>
Co-authored-by: David Ormsbee <dave@axim.org>
This commit is contained in:
Navin Karkera
2025-04-24 18:41:47 +00:00
committed by GitHub
parent 875158f1ad
commit 1cd73d1b96
36 changed files with 1636 additions and 542 deletions

View File

@@ -48,7 +48,7 @@ https://github.com/openedx/edx-platform/issues/35653):
/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 PublishableEntityLinks.
200: A paginated list of applicable & accessible downstream blocks. Entries are ComponentLinks.
/api/contentstore/v2/downstreams/<course_key>/summary
GET: List summary of links by course key
@@ -87,6 +87,7 @@ from django.contrib.auth.models import User # pylint: disable=imported-auth-use
from edx_rest_framework_extensions.paginators import DefaultPagination
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locator import LibraryUsageLocatorV2, LibraryContainerLocator
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.fields import BooleanField
from rest_framework.request import Request
@@ -94,12 +95,12 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from xblock.core import XBlock
from cms.djangoapps.contentstore.helpers import import_static_assets_for_library_sync
from cms.djangoapps.contentstore.models import PublishableEntityLink
from cms.djangoapps.contentstore.models import ComponentLink
from cms.djangoapps.contentstore.rest_api.v2.serializers import (
PublishableEntityLinksSerializer,
ComponentLinksSerializer,
PublishableEntityLinksSummarySerializer,
)
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import sync_library_content
from cms.lib.xblock.upstream_sync import (
BadDownstream,
BadUpstream,
@@ -107,10 +108,10 @@ from cms.lib.xblock.upstream_sync import (
UpstreamLink,
UpstreamLinkException,
decline_sync,
fetch_customizable_fields,
sever_upstream_link,
sync_from_upstream,
)
from cms.lib.xblock.upstream_sync_block import fetch_customizable_fields_from_block
from cms.lib.xblock.upstream_sync_container import fetch_customizable_fields_from_container
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
from openedx.core.lib.api.view_utils import (
DeveloperErrorViewMixin,
@@ -183,9 +184,9 @@ class DownstreamListView(DeveloperErrorViewMixin, APIView):
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 = PublishableEntityLink.filter_links(**link_filter)
links = ComponentLink.filter_links(**link_filter)
paginated_links = paginator.paginate_queryset(links, self.request, view=self)
serializer = PublishableEntityLinksSerializer(paginated_links, many=True)
serializer = ComponentLinksSerializer(paginated_links, many=True)
return paginator.get_paginated_response(serializer.data, self.request)
@@ -217,7 +218,7 @@ class DownstreamSummaryView(DeveloperErrorViewMixin, APIView):
course_key = CourseKey.from_string(course_key_string)
except InvalidKeyError as exc:
raise ValidationError(detail=f"Malformed course key: {course_key_string}") from exc
links = PublishableEntityLink.summarize_by_downstream_context(downstream_context_key=course_key)
links = ComponentLink.summarize_by_downstream_context(downstream_context_key=course_key)
serializer = PublishableEntityLinksSummarySerializer(links, many=True)
return Response(serializer.data)
@@ -252,12 +253,21 @@ class DownstreamView(DeveloperErrorViewMixin, APIView):
raise ValidationError({"sync": "must be 'true' or 'false'"})
try:
if sync_param == "true" or sync_param is True:
sync_from_upstream(downstream=downstream, user=request.user)
sync_library_content(
downstream=downstream,
request=request,
store=modulestore()
)
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)
link = UpstreamLink.get_for_block(downstream)
if isinstance(link.upstream_key, LibraryUsageLocatorV2):
fetch_customizable_fields_from_block(downstream=downstream, user=request.user)
else:
assert isinstance(link.upstream_key, LibraryContainerLocator)
fetch_customizable_fields_from_container(downstream=downstream, user=request.user)
except BadDownstream as exc:
logger.exception(
"'%s' is an invalid downstream; refusing to set its upstream to '%s'",
@@ -312,8 +322,11 @@ class SyncFromUpstreamView(DeveloperErrorViewMixin, APIView):
if downstream.usage_key.block_type == "video":
# Delete all transcripts so we can copy new ones from upstream
clear_transcripts(downstream)
upstream = sync_from_upstream(downstream, request.user)
static_file_notices = import_static_assets_for_library_sync(downstream, upstream, request)
static_file_notices = sync_library_content(
downstream=downstream,
request=request,
store=modulestore()
)
except UpstreamLinkException as exc:
logger.exception(
"Could not sync from upstream '%s' to downstream '%s'",
@@ -321,7 +334,6 @@ class SyncFromUpstreamView(DeveloperErrorViewMixin, APIView):
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.
response = UpstreamLink.get_for_block(downstream).to_json()

View File

@@ -0,0 +1,462 @@
"""
Unit and integration tests to ensure that syncing content from libraries to
courses is working.
"""
from typing import Any
from xml.etree import ElementTree
import ddt
from opaque_keys.edx.keys import UsageKey
from openedx.core.djangoapps.content_libraries.tests import ContentLibrariesRestApiTest
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
@ddt.ddt
class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
"""
Tests that involve syncing content from libraries to courses.
"""
maxDiff = None # Necessary for debugging OLX differences
def setUp(self):
super().setUp()
# self.user is set up by ContentLibrariesRestApiTest
# The source library (contains the upstreams):
self.library = self._create_library(slug="testlib", title="Upstream Library")
lib_id = self.library["id"] # the library ID as a string
self.upstream_problem1 = self._add_block_to_library(lib_id, "problem", "prob1", can_stand_alone=True)
self._set_library_block_olx(
self.upstream_problem1["id"],
'<problem display_name="Problem 1 Display Name" weight="1" markdown="MD 1">multiple choice...</problem>'
)
self.upstream_problem2 = self._add_block_to_library(lib_id, "problem", "prob2", can_stand_alone=True)
self._set_library_block_olx(
self.upstream_problem2["id"],
'<problem display_name="Problem 2 Display Name" max_attempts="22">multi select...</problem>'
)
self.upstream_html1 = self._add_block_to_library(lib_id, "html", "html1", can_stand_alone=False)
self._set_library_block_olx(
self.upstream_html1["id"],
'<html display_name="Text Content">This is the HTML.</html>'
)
self.upstream_unit = self._create_container(lib_id, "unit", slug="u1", display_name="Unit 1 Title")
self._add_container_components(self.upstream_unit["id"], [
self.upstream_html1["id"],
self.upstream_problem1["id"],
self.upstream_problem2["id"],
])
self._commit_library_changes(lib_id) # publish everything
# The destination course:
self.course = CourseFactory.create()
self.course_section = BlockFactory.create(category='chapter', parent=self.course)
self.course_subsection = BlockFactory.create(category='sequential', parent=self.course_section)
self.course_unit = BlockFactory.create(category='vertical', parent=self.course_subsection)
def _get_sync_status(self, usage_key: str):
return self._api('get', f"/api/contentstore/v2/downstreams/{usage_key}", {}, expect_response=200)
def _sync_downstream(self, usage_key: str):
return self._api('post', f"/api/contentstore/v2/downstreams/{usage_key}/sync", {}, expect_response=200)
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"]
# def _get_course_block_fields(self, usage_key: str):
# return self._api('get', f'/xblock/{usage_key}', {}, expect_response=200)
def _get_course_block_children(self, usage_key: str) -> list[str]:
""" Get the IDs of the child XBlocks of the given XBlock """
# TODO: is there really no REST API to get the children of an XBlock in Studio?
# Maybe this one: /api/contentstore/v1/container/vertical/{usage_key_string}/children
return [str(k) for k in modulestore().get_item(UsageKey.from_string(usage_key), depth=0).children]
def _create_block_from_upstream(
self,
block_category: str,
parent_usage_key: str,
upstream_key: str,
expect_response: int = 200,
):
"""
Call the CMS API for inserting an XBlock that's cloned from a library
item. i.e. copy a *published* library block into a course, and create an
upstream link.
"""
return self._api('post', "/xblock/", {
"category": block_category,
"parent_locator": parent_usage_key,
"library_content_key": upstream_key,
}, expect_response=expect_response)
def _update_course_block_fields(self, usage_key: str, fields: dict[str, Any] = None):
""" Update fields of an XBlock """
return self._api('patch', f"/xblock/{usage_key}", {
"metadata": fields,
}, expect_response=200)
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. """
self.assertEqual(
ElementTree.canonicalize(xml_str_a, strip_text=True),
ElementTree.canonicalize(xml_str_b, strip_text=True),
)
# OLX attributes that will appear on capa problems when saved/exported. Excludes "markdown"
standard_capa_attributes = """
markdown_edited="false"
matlab_api_key="null"
name="null"
rerandomize="never"
source_code="null"
tags="[]"
use_latex_compiler="false"
"""
####################################################################################################################
def test_problem_sync(self):
"""
Test that we can sync a problem from a library into a course.
"""
# 1⃣ First, create the problem in the course, using the upstream problem as a template:
downstream_problem1 = self._create_block_from_upstream(
block_category="problem",
parent_usage_key=str(self.course_subsection.usage_key),
upstream_key=self.upstream_problem1["id"],
)
status = self._get_sync_status(downstream_problem1["locator"])
self.assertDictContainsEntries(status, {
'upstream_ref': self.upstream_problem1["id"], # e.g. 'lb:CL-TEST:testlib:problem:prob1'
'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/components?usageKey=...'
})
assert status["upstream_link"].startswith("http://course-authoring-mfe/library/")
assert status["upstream_link"].endswith(f"/components?usageKey={self.upstream_problem1['id']}")
# Check the OLX of the downstream block. Notice that:
# (1) fields display_name and markdown, as well as the 'data' (content/body of the <problem>) are synced.
# (2) per UpstreamSyncMixin.get_customizable_fields(), some fields like weight and max_attempts are
# DROPPED entirely from the upstream version when creating the downstream:
self.assertXmlEqual(self._get_course_block_olx(downstream_problem1["locator"]), f"""
<problem
display_name="Problem 1 Display Name"
markdown="MD 1"
upstream="{self.upstream_problem1['id']}"
upstream_display_name="Problem 1 Display Name"
upstream_version="2"
{self.standard_capa_attributes}
>multiple choice...</problem>
""")
# 2⃣ Now, lets modify the upstream problem AND the downstream problem:
self._update_course_block_fields(downstream_problem1["locator"], {
"display_name": "Custom Display Name",
"max_attempts": 3,
"markdown": "blow me away, scotty!", # This change will be lost
})
self._set_library_block_olx(
self.upstream_problem1["id"],
'<problem display_name="Problem 1 NEW name" markdown="updated">multiple choice v2...</problem>'
)
self._publish_library_block(self.upstream_problem1["id"])
# Here's how the downstream OLX looks now, before we sync:
self.assertXmlEqual(self._get_course_block_olx(downstream_problem1["locator"]), f"""
<problem
display_name="Custom Display Name"
markdown="blow me away, scotty!"
max_attempts="3"
upstream="{self.upstream_problem1['id']}"
upstream_display_name="Problem 1 Display Name"
upstream_version="2"
{self.standard_capa_attributes}
>multiple choice...</problem>
""")
status = self._get_sync_status(downstream_problem1["locator"])
self.assertDictContainsEntries(status, {
'upstream_ref': self.upstream_problem1["id"], # e.g. 'lb:CL-TEST:testlib:problem:prob1'
'version_available': 3, # <--- updated
'version_synced': 2,
'version_declined': None,
'ready_to_sync': True, # <--- updated
'error_message': None,
})
# 3⃣ Now, sync and check the resulting OLX of the downstream
self._sync_downstream(downstream_problem1["locator"])
# Here's how the downstream OLX looks now, after we synced it.
# Notice:
# (1) content like "markdown" and the body XML content are synced
# (2) the "display_name" is left alone (customized downstream), but
# (3) "upstream_display_name" is updated.
# (4) The customized "max_attempts" is also still present.
self.assertXmlEqual(self._get_course_block_olx(downstream_problem1["locator"]), f"""
<problem
display_name="Custom Display Name"
markdown="updated"
max_attempts="3"
upstream="{self.upstream_problem1['id']}"
upstream_display_name="Problem 1 NEW name"
upstream_version="3"
{self.standard_capa_attributes}
>multiple choice v2...</problem>
""")
def test_unit_sync(self):
"""
Test that we can 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"],
)
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.
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"
>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"
>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"
>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,
# 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,
'error_message': None,
})
# Check the upstream/downstream status of [one of] the children
downstream_problem1 = self._get_course_block_children(downstream_unit["locator"])[1]
assert "type@problem" in downstream_problem1
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': True, # <--- updated
'error_message': None,
})
# 3⃣ Now, sync and check the resulting OLX of the downstream
self._sync_downstream(downstream_unit["locator"])
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"
>This is the HTML.</html>
<!-- 🟢 the problem below has been updated: -->
<problem
display_name="Problem 1 NEW name"
upstream_display_name="Problem 1 NEW name"
markdown="updated"
{self.standard_capa_attributes}
upstream="{self.upstream_problem1['id']}"
upstream_version="3"
>multiple choice v2...</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"
>multi select...</problem>
</vertical>
""")
# Now, add and delete a component
upstream_problem3 = self._add_block_to_library(
self.library["id"],
"problem",
"prob3",
can_stand_alone=True
)
self._set_library_block_olx(
upstream_problem3["id"],
'<problem display_name="Problem 3 Display Name" max_attempts="22">single select...</problem>'
)
self._add_container_components(self.upstream_unit["id"], [upstream_problem3["id"]])
self._remove_container_components(self.upstream_unit["id"], [self.upstream_problem2["id"]])
self._commit_library_changes(self.library["id"]) # publish everything
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': 4, # <--- updated twice, delete and add component
'version_synced': 2,
'version_declined': None,
'ready_to_sync': True,
'error_message': None,
})
# 3⃣ Now, sync and check the resulting OLX of the downstream
self._sync_downstream(downstream_unit["locator"])
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="4"
>
<html
display_name="Text Content"
upstream_display_name="Text Content"
editor="visual"
upstream="{self.upstream_html1['id']}"
upstream_version="2"
>This is the HTML.</html>
<problem
display_name="Problem 1 NEW name"
upstream_display_name="Problem 1 NEW name"
markdown="updated"
{self.standard_capa_attributes}
upstream="{self.upstream_problem1['id']}"
upstream_version="3"
>multiple choice v2...</problem>
<!-- 🟢 the problem 2 has been deleted: -->
<!-- 🟢 the problem 3 has been added: -->
<problem
display_name="Problem 3 Display Name"
upstream_display_name="Problem 3 Display Name"
markdown="null"
{self.standard_capa_attributes}
upstream="{upstream_problem3['id']}"
upstream_version="2"
>single select...</problem>
</vertical>
""")
# Now, reorder components
self._patch_container_components(self.upstream_unit["id"], [
upstream_problem3["id"],
self.upstream_problem1["id"],
self.upstream_html1["id"],
])
self._publish_container(self.upstream_unit["id"])
# 3⃣ Now, sync and check the resulting OLX of the downstream
self._sync_downstream(downstream_unit["locator"])
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="5"
>
<!-- 🟢 the problem 3 has been moved to top: -->
<problem
display_name="Problem 3 Display Name"
upstream_display_name="Problem 3 Display Name"
markdown="null"
{self.standard_capa_attributes}
upstream="{upstream_problem3['id']}"
upstream_version="2"
>single select...</problem>
<!-- 🟢 the problem 1 has been moved to middle: -->
<problem
display_name="Problem 1 NEW name"
upstream_display_name="Problem 1 NEW name"
markdown="updated"
{self.standard_capa_attributes}
upstream="{self.upstream_problem1['id']}"
upstream_version="3"
>multiple choice v2...</problem>
<!-- 🟢 the html 1 has been moved to end: -->
<html
display_name="Text Content"
upstream_display_name="Text Content"
editor="visual"
upstream="{self.upstream_html1['id']}"
upstream_version="2"
>This is the HTML.</html>
</vertical>
""")

View File

@@ -13,6 +13,7 @@ from organizations.models import Organization
from cms.djangoapps.contentstore.helpers import StaticFileNotices
from cms.lib.xblock.upstream_sync import BadUpstream, UpstreamLink
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from cms.djangoapps.contentstore.xblock_storage_handlers import view_handlers as xblock_view_handlers
from opaque_keys.edx.keys import UsageKey
from common.djangoapps.student.tests.factories import UserFactory
from xmodule.modulestore.django import modulestore
@@ -32,6 +33,7 @@ URL_LIB_BLOCK_OLX = URL_PREFIX + 'blocks/{block_key}/olx/'
def _get_upstream_link_good_and_syncable(downstream):
return UpstreamLink(
upstream_ref=downstream.upstream,
upstream_key=UsageKey.from_string(downstream.upstream),
version_synced=downstream.upstream_version,
version_available=(downstream.upstream_version or 0) + 1,
version_declined=downstream.upstream_version_declined,
@@ -235,8 +237,8 @@ class PutDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase):
content_type="application/json",
)
@patch.object(downstreams_views, "fetch_customizable_fields")
@patch.object(downstreams_views, "sync_from_upstream")
@patch.object(downstreams_views, "fetch_customizable_fields_from_block")
@patch.object(downstreams_views, "sync_library_content")
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
def test_200_with_sync(self, mock_sync, mock_fetch):
"""
@@ -250,8 +252,8 @@ class PutDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase):
assert mock_fetch.call_count == 0
assert video_after.upstream == self.video_lib_id
@patch.object(downstreams_views, "fetch_customizable_fields")
@patch.object(downstreams_views, "sync_from_upstream")
@patch.object(downstreams_views, "fetch_customizable_fields_from_block")
@patch.object(downstreams_views, "sync_library_content")
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
def test_200_no_sync(self, mock_sync, mock_fetch):
"""
@@ -265,7 +267,9 @@ class PutDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase):
assert mock_fetch.call_count == 1
assert video_after.upstream == self.video_lib_id
@patch.object(downstreams_views, "fetch_customizable_fields", side_effect=BadUpstream(MOCK_UPSTREAM_ERROR))
@patch.object(
downstreams_views, "fetch_customizable_fields_from_block", 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?
@@ -366,7 +370,7 @@ class CreateDownstreamViewTest(CourseTestCase, _BaseDownstreamViewTestMixin, Sha
@patch("cms.djangoapps.contentstore.helpers._insert_static_files_into_downstream_xblock")
@patch("cms.djangoapps.contentstore.helpers.content_staging_api.stage_xblock_temporarily")
@patch("cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers.sync_from_upstream")
@patch("cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers.sync_from_upstream_block")
def test_200_video(self, mock_sync, mock_stage, mock_insert):
mock_lib_block = MagicMock()
mock_lib_block.runtime.get_block_assets.return_value = ['mocked_asset']
@@ -394,17 +398,15 @@ class PostDownstreamSyncViewTest(_DownstreamSyncViewTestMixin, SharedModuleStore
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")
@patch.object(downstreams_views, "import_static_assets_for_library_sync", return_value=StaticFileNotices())
@patch.object(xblock_view_handlers, "import_static_assets_for_library_sync", return_value=StaticFileNotices())
@patch.object(downstreams_views, "clear_transcripts")
def test_200(self, mock_sync_from_upstream, mock_import_staged_content, mock_clear_transcripts):
def test_200(self, mock_import_staged_content, mock_clear_transcripts):
"""
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
assert mock_import_staged_content.call_count == 1
assert mock_clear_transcripts.call_count == 1