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:
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
""")
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user