feat: Add top-level parent logic to Upstream/Dowstream links [FC-0097] (#37076)
- Adds the `top_level_parent_usage_key` to the `EntityLinkBase` - This field is used to save the top-level parent of a component or container when it is imported into a course. Example: A unit with components imported into a course. The unit is the top-level parent of the components. - Updates the `DownstreamListView` to return the top-level parents instead of downstream child, if this parent exists. - Each time containers with children were synchronized, a new downstream block was created for each child instead of updating the existing one. This occurred because the `upstream_key` was incorrectly validated as an `Opaquekey` against a list of key strings. This was fixed by converting the `upstream_key` to a string before the verification. (see34cd5a4781and29647831dc) - Which edX user roles will this change impact? "Course Author", "Developer".
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 4.2.23 on 2025-08-04 18:56
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contentstore', '0011_enable_markdown_editor_flag_by_default'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='componentlink',
|
||||
name='top_level_parent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contentstore.containerlink'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='containerlink',
|
||||
name='top_level_parent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contentstore.containerlink'),
|
||||
),
|
||||
]
|
||||
@@ -1,9 +1,9 @@
|
||||
"""
|
||||
Models for contentstore
|
||||
"""
|
||||
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from itertools import chain
|
||||
|
||||
from config_models.models import ConfigurationModel
|
||||
from django.db import models
|
||||
@@ -24,6 +24,9 @@ from openedx_learning.lib.fields import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VideoUploadConfig(ConfigurationModel):
|
||||
"""
|
||||
Configuration for the video upload feature.
|
||||
@@ -98,6 +101,11 @@ class EntityLinkBase(models.Model):
|
||||
downstream_usage_key = UsageKeyField(max_length=255, unique=True)
|
||||
# Search by course/downstream key
|
||||
downstream_context_key = CourseKeyField(max_length=255, db_index=True)
|
||||
# This is present if the creation of this link is a consequence of
|
||||
# importing a container that has one or more levels of children.
|
||||
# This represents the parent (container) in the top level
|
||||
# at the moment of the import.
|
||||
top_level_parent = models.ForeignKey("ContainerLink", on_delete=models.SET_NULL, null=True, blank=True)
|
||||
version_synced = models.IntegerField()
|
||||
version_declined = models.IntegerField(null=True, blank=True)
|
||||
created = manual_date_time_field()
|
||||
@@ -152,17 +160,27 @@ class ComponentLink(EntityLinkBase):
|
||||
@classmethod
|
||||
def filter_links(
|
||||
cls,
|
||||
*,
|
||||
use_top_level_parents=False,
|
||||
**link_filter,
|
||||
) -> QuerySet["EntityLinkBase"]:
|
||||
) -> QuerySet["EntityLinkBase"] | list["EntityLinkBase"]:
|
||||
"""
|
||||
Get all links along with sync flag, upstream context title and version, with optional filtering.
|
||||
|
||||
`use_top_level_parents` is an special filter, replace any result with the top-level parent if exists.
|
||||
Example: We have linkA and linkB with top-level parent as linkC, and linkD without top-level parent.
|
||||
After all other filters:
|
||||
Case 1: `use_top_level_parents` is False, the result is [linkA, linkB, linkC, linkD]
|
||||
Case 2: `use_top_level_parents` is True, the result is [linkC, linkD]
|
||||
"""
|
||||
ready_to_sync = link_filter.pop('ready_to_sync', None)
|
||||
result = cls.objects.filter(**link_filter).select_related(
|
||||
RELATED_FIELDS = [
|
||||
"upstream_block__publishable_entity__published__version",
|
||||
"upstream_block__publishable_entity__learning_package",
|
||||
"upstream_block__publishable_entity__published__publish_log_record__publish_log",
|
||||
).annotate(
|
||||
]
|
||||
|
||||
ready_to_sync = link_filter.pop('ready_to_sync', None)
|
||||
result = cls.objects.filter(**link_filter).select_related(*RELATED_FIELDS).annotate(
|
||||
ready_to_sync=(
|
||||
GreaterThan(
|
||||
Coalesce("upstream_block__publishable_entity__published__version__version_num", 0),
|
||||
@@ -175,6 +193,27 @@ class ComponentLink(EntityLinkBase):
|
||||
)
|
||||
if ready_to_sync is not None:
|
||||
result = result.filter(ready_to_sync=ready_to_sync)
|
||||
|
||||
# Handle top-level parents logic
|
||||
if use_top_level_parents:
|
||||
# Get objects without top_level_parent
|
||||
objects_without_top_level = result.filter(top_level_parent__isnull=True)
|
||||
|
||||
# Get the top-level parent keys
|
||||
top_level_keys = result.filter(top_level_parent__isnull=False).values_list(
|
||||
'top_level_parent', flat=True,
|
||||
)
|
||||
|
||||
# Get the top-level parents
|
||||
# Any top-level parent is a container
|
||||
top_level_objects = ContainerLink.filter_links(**{
|
||||
"id__in": top_level_keys
|
||||
})
|
||||
|
||||
# Returns a list of `EntityLinkBase` as can be a combination of `ComponentLink``
|
||||
# and `ContainerLink``
|
||||
return list(chain(top_level_objects, objects_without_top_level))
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
@@ -221,6 +260,7 @@ class ComponentLink(EntityLinkBase):
|
||||
downstream_usage_key: UsageKey,
|
||||
downstream_context_key: CourseKey,
|
||||
version_synced: int,
|
||||
top_level_parent_usage_key: UsageKey | None = None,
|
||||
version_declined: int | None = None,
|
||||
created: datetime | None = None,
|
||||
) -> "ComponentLink":
|
||||
@@ -229,6 +269,15 @@ class ComponentLink(EntityLinkBase):
|
||||
"""
|
||||
if not created:
|
||||
created = datetime.now(tz=timezone.utc)
|
||||
top_level_parent = None
|
||||
if top_level_parent_usage_key is not None:
|
||||
try:
|
||||
top_level_parent = ContainerLink.get_by_downstream_usage_key(
|
||||
top_level_parent_usage_key,
|
||||
)
|
||||
except ContainerLink.DoesNotExist:
|
||||
logger.info(f"Unable to find the link for the container with the link: {top_level_parent_usage_key}")
|
||||
|
||||
new_values = {
|
||||
'upstream_usage_key': upstream_usage_key,
|
||||
'upstream_context_key': upstream_context_key,
|
||||
@@ -236,6 +285,7 @@ class ComponentLink(EntityLinkBase):
|
||||
'downstream_context_key': downstream_context_key,
|
||||
'version_synced': version_synced,
|
||||
'version_declined': version_declined,
|
||||
'top_level_parent': top_level_parent,
|
||||
}
|
||||
if upstream_block:
|
||||
new_values['upstream_block'] = upstream_block
|
||||
@@ -304,17 +354,55 @@ class ContainerLink(EntityLinkBase):
|
||||
@classmethod
|
||||
def filter_links(
|
||||
cls,
|
||||
*,
|
||||
use_top_level_parents=False,
|
||||
**link_filter,
|
||||
) -> QuerySet["EntityLinkBase"]:
|
||||
"""
|
||||
Get all links along with sync flag, upstream context title and version, with optional filtering.
|
||||
|
||||
`use_top_level_parents` is an special filter, replace any result with the top-level parent if exists.
|
||||
Example: We have linkA and linkB with top-level parent as linkC and linkD without top-level parent.
|
||||
After all other filters:
|
||||
Case 1: `use_top_level_parents` is False, the result is [linkA, linkB, linkC, linkD]
|
||||
Case 2: `use_top_level_parents` is True, the result is [linkC, linkD]
|
||||
"""
|
||||
ready_to_sync = link_filter.pop('ready_to_sync', None)
|
||||
result = cls.objects.filter(**link_filter).select_related(
|
||||
RELATED_FIELDS = [
|
||||
"upstream_container__publishable_entity__published__version",
|
||||
"upstream_container__publishable_entity__learning_package",
|
||||
"upstream_container__publishable_entity__published__publish_log_record__publish_log",
|
||||
).annotate(
|
||||
]
|
||||
|
||||
ready_to_sync = link_filter.pop('ready_to_sync', None)
|
||||
result = cls._annotate_query_with_ready_to_sync(
|
||||
cls.objects.filter(**link_filter).select_related(*RELATED_FIELDS),
|
||||
)
|
||||
if ready_to_sync is not None:
|
||||
result = result.filter(ready_to_sync=ready_to_sync)
|
||||
|
||||
# Handle top-level parents logic
|
||||
if use_top_level_parents:
|
||||
# Get objects without top_level_parent
|
||||
objects_without_top_level = result.filter(top_level_parent__isnull=True)
|
||||
|
||||
# Get the top-level parent keys
|
||||
top_level_keys = result.filter(top_level_parent__isnull=False).values_list(
|
||||
'top_level_parent', flat=True,
|
||||
)
|
||||
|
||||
# Get the top-level parents
|
||||
# Any top-level parent is a container
|
||||
top_level_objects = cls._annotate_query_with_ready_to_sync(cls.objects.filter(
|
||||
id__in=top_level_keys,
|
||||
).select_related(*RELATED_FIELDS))
|
||||
|
||||
result = top_level_objects.union(objects_without_top_level)
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _annotate_query_with_ready_to_sync(cls, query_set: QuerySet["EntityLinkBase"]) -> QuerySet["EntityLinkBase"]:
|
||||
return query_set.annotate(
|
||||
ready_to_sync=(
|
||||
GreaterThan(
|
||||
Coalesce("upstream_container__publishable_entity__published__version__version_num", 0),
|
||||
@@ -325,9 +413,6 @@ class ContainerLink(EntityLinkBase):
|
||||
)
|
||||
)
|
||||
)
|
||||
if ready_to_sync is not None:
|
||||
result = result.filter(ready_to_sync=ready_to_sync)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def summarize_by_downstream_context(cls, downstream_context_key: CourseKey) -> QuerySet:
|
||||
@@ -373,6 +458,7 @@ class ContainerLink(EntityLinkBase):
|
||||
downstream_usage_key: UsageKey,
|
||||
downstream_context_key: CourseKey,
|
||||
version_synced: int,
|
||||
top_level_parent_usage_key: UsageKey | None = None,
|
||||
version_declined: int | None = None,
|
||||
created: datetime | None = None,
|
||||
) -> "ContainerLink":
|
||||
@@ -381,6 +467,15 @@ class ContainerLink(EntityLinkBase):
|
||||
"""
|
||||
if not created:
|
||||
created = datetime.now(tz=timezone.utc)
|
||||
top_level_parent = None
|
||||
if top_level_parent_usage_key is not None:
|
||||
try:
|
||||
top_level_parent = ContainerLink.get_by_downstream_usage_key(
|
||||
top_level_parent_usage_key,
|
||||
)
|
||||
except ContainerLink.DoesNotExist:
|
||||
logger.info(f"Unable to find the link for the container with the link: {top_level_parent_usage_key}")
|
||||
|
||||
new_values = {
|
||||
'upstream_container_key': upstream_container_key,
|
||||
'upstream_context_key': upstream_context_key,
|
||||
@@ -388,6 +483,7 @@ class ContainerLink(EntityLinkBase):
|
||||
'downstream_context_key': downstream_context_key,
|
||||
'version_synced': version_synced,
|
||||
'version_declined': version_declined,
|
||||
'top_level_parent': top_level_parent,
|
||||
}
|
||||
if upstream_container_id:
|
||||
new_values['upstream_container_id'] = upstream_container_id
|
||||
@@ -409,6 +505,10 @@ class ContainerLink(EntityLinkBase):
|
||||
link.save()
|
||||
return link
|
||||
|
||||
@classmethod
|
||||
def get_by_downstream_usage_key(cls, downstream_usage_key: UsageKey):
|
||||
return cls.objects.get(downstream_usage_key=downstream_usage_key)
|
||||
|
||||
|
||||
class LearningContextLinksStatusChoices(models.TextChoices):
|
||||
"""
|
||||
|
||||
@@ -14,10 +14,15 @@ class ComponentLinksSerializer(serializers.ModelSerializer):
|
||||
upstream_context_title = serializers.CharField(read_only=True)
|
||||
upstream_version = serializers.IntegerField(read_only=True, source="upstream_version_num")
|
||||
ready_to_sync = serializers.BooleanField()
|
||||
top_level_parent_usage_key = serializers.CharField(
|
||||
source='top_level_parent.downstream_usage_key',
|
||||
read_only=True,
|
||||
allow_null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ComponentLink
|
||||
exclude = ['upstream_block', 'uuid']
|
||||
exclude = ['upstream_block', 'uuid', 'top_level_parent']
|
||||
|
||||
|
||||
class PublishableEntityLinksSummarySerializer(serializers.Serializer):
|
||||
@@ -38,10 +43,15 @@ class ContainerLinksSerializer(serializers.ModelSerializer):
|
||||
upstream_context_title = serializers.CharField(read_only=True)
|
||||
upstream_version = serializers.IntegerField(read_only=True, source="upstream_version_num")
|
||||
ready_to_sync = serializers.BooleanField()
|
||||
top_level_parent_usage_key = serializers.CharField(
|
||||
source='top_level_parent.downstream_usage_key',
|
||||
read_only=True,
|
||||
allow_null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ContainerLink
|
||||
exclude = ['upstream_container', 'uuid']
|
||||
exclude = ['upstream_container', 'uuid', 'top_level_parent']
|
||||
|
||||
|
||||
class PublishableEntityLinkSerializer(serializers.Serializer):
|
||||
|
||||
@@ -168,6 +168,13 @@ class DownstreamListView(DeveloperErrorViewMixin, APIView):
|
||||
"""
|
||||
[ 🛑 UNSTABLE ]
|
||||
List all items (components and containers) wich are linked to an upstream context, with optional filtering.
|
||||
|
||||
* `course_key_string`: Get the links of a specific course.
|
||||
* `upstream_key`: Get the dowstream links of a spscific upstream component or container.
|
||||
* `ready_to_sync`: Boolean to filter links that are ready to sync.
|
||||
* `use_top_level_parents`: Set to True to return the top-level parents instead of downstream child,
|
||||
if this parent exists.
|
||||
* `item_type`: Filter the links by `components` or `containers`.
|
||||
"""
|
||||
|
||||
def get(self, request: _AuthenticatedRequest):
|
||||
@@ -175,9 +182,11 @@ class DownstreamListView(DeveloperErrorViewMixin, APIView):
|
||||
Fetches publishable entity links for given course key
|
||||
"""
|
||||
course_key_string = request.GET.get('course_id')
|
||||
ready_to_sync = request.GET.get('ready_to_sync')
|
||||
upstream_key = request.GET.get('upstream_key')
|
||||
ready_to_sync = request.GET.get('ready_to_sync')
|
||||
use_top_level_parents = request.GET.get('use_top_level_parents')
|
||||
item_type = request.GET.get('item_type')
|
||||
|
||||
link_filter: dict[str, CourseKey | UsageKey | LibraryContainerLocator | bool] = {}
|
||||
paginator = DownstreamListPaginator()
|
||||
|
||||
@@ -197,6 +206,8 @@ class DownstreamListView(DeveloperErrorViewMixin, APIView):
|
||||
raise PermissionDenied
|
||||
if ready_to_sync is not None:
|
||||
link_filter["ready_to_sync"] = BooleanField().to_internal_value(ready_to_sync)
|
||||
if use_top_level_parents is not None:
|
||||
link_filter["use_top_level_parents"] = BooleanField().to_internal_value(use_top_level_parents)
|
||||
if upstream_key:
|
||||
try:
|
||||
upstream_usage_key = UsageKey.from_string(upstream_key)
|
||||
@@ -232,6 +243,14 @@ class DownstreamListView(DeveloperErrorViewMixin, APIView):
|
||||
ComponentLink.filter_links(**link_filter),
|
||||
ContainerLink.filter_links(**link_filter)
|
||||
))
|
||||
|
||||
if use_top_level_parents is not None:
|
||||
# Delete duplicates. From `ComponentLink` and `ContainerLink`
|
||||
# repeated containers may come in this case:
|
||||
# If we have a `Unit A` and a `Component B`, if you update and publish
|
||||
# both, form `ComponentLink` and `ContainerLink` you get the same `Unit A`.
|
||||
links = self._remove_duplicates(links)
|
||||
|
||||
elif item_type == 'components':
|
||||
links = ComponentLink.filter_links(**link_filter)
|
||||
elif item_type == 'containers':
|
||||
@@ -240,6 +259,20 @@ class DownstreamListView(DeveloperErrorViewMixin, APIView):
|
||||
serializer = PublishableEntityLinkSerializer(paginated_links, many=True)
|
||||
return paginator.get_paginated_response(serializer.data, self.request)
|
||||
|
||||
def _remove_duplicates(self, links: list[EntityLinkBase]) -> list[EntityLinkBase]:
|
||||
"""
|
||||
Remove duplicates based on `EntityLinkBase.downstream_usage_key`
|
||||
"""
|
||||
seen_keys = set()
|
||||
unique_links = []
|
||||
|
||||
for link in links:
|
||||
if link.downstream_usage_key not in seen_keys:
|
||||
seen_keys.add(link.downstream_usage_key)
|
||||
unique_links.append(link)
|
||||
|
||||
return unique_links
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class DownstreamComponentsListView(DeveloperErrorViewMixin, APIView):
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
Unit and integration tests to ensure that syncing content from libraries to
|
||||
courses is working.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import ddt
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from freezegun import freeze_time
|
||||
|
||||
from openedx.core.djangoapps.content_libraries.tests import ContentLibrariesRestApiTest
|
||||
from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import get_block_key_dict
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
|
||||
@@ -23,33 +26,38 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.now = datetime.now(timezone.utc)
|
||||
freezer = freeze_time(self.now)
|
||||
self.addCleanup(freezer.stop)
|
||||
freezer.start()
|
||||
# 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.library_title = "Upstream Library"
|
||||
self.library = self._create_library(slug="testlib", title=self.library_title)
|
||||
self.library_id = self.library["id"] # the library ID as a string
|
||||
self.upstream_problem1 = self._add_block_to_library(self.library_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.upstream_problem2 = self._add_block_to_library(self.library_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.upstream_html1 = self._add_block_to_library(self.library_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.upstream_unit = self._create_container(self.library_id, "unit", slug="u1", display_name="Unit 1 Title")
|
||||
self._add_container_children(self.upstream_unit["id"], [
|
||||
self.upstream_html1["id"],
|
||||
self.upstream_problem1["id"],
|
||||
self.upstream_problem2["id"],
|
||||
])
|
||||
self._commit_library_changes(lib_id) # publish everything
|
||||
self._commit_library_changes(self.library_id) # publish everything
|
||||
|
||||
# The destination course:
|
||||
self.course = CourseFactory.create()
|
||||
@@ -100,6 +108,30 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
"metadata": fields,
|
||||
}, expect_response=200)
|
||||
|
||||
def _get_downstream_links(
|
||||
self,
|
||||
course_id: str | None = None,
|
||||
ready_to_sync: bool | None = None,
|
||||
upstream_key: str | None = None,
|
||||
item_type: str | None = None,
|
||||
use_top_level_parents: bool | None = None,
|
||||
):
|
||||
"""
|
||||
Call the API to get the downstreams links
|
||||
"""
|
||||
data = {}
|
||||
if course_id is not None:
|
||||
data["course_id"] = str(course_id)
|
||||
if ready_to_sync is not None:
|
||||
data["ready_to_sync"] = str(ready_to_sync)
|
||||
if upstream_key is not None:
|
||||
data["upstream_key"] = str(upstream_key)
|
||||
if item_type is not None:
|
||||
data["item_type"] = str(item_type)
|
||||
if use_top_level_parents is not None:
|
||||
data["use_top_level_parents"] = str(use_top_level_parents)
|
||||
return self.client.get("/api/contentstore/v2/downstreams-all/", data=data)
|
||||
|
||||
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(
|
||||
@@ -221,7 +253,10 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
"""
|
||||
Test that we can sync a unit from the library into the course
|
||||
"""
|
||||
# pylint: disable=too-many-statements
|
||||
|
||||
# 1️⃣ Create a "vertical" block in the course based on a "unit" container:
|
||||
date_format = self.now.isoformat().split("+")[0] + 'Z'
|
||||
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
|
||||
@@ -230,6 +265,9 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
parent_usage_key=str(self.course_subsection.usage_key),
|
||||
upstream_key=self.upstream_unit["id"],
|
||||
)
|
||||
downstream_unit_block_key = get_block_key_dict(
|
||||
UsageKey.from_string(downstream_unit["locator"]),
|
||||
)
|
||||
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'
|
||||
@@ -247,6 +285,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
# Note that:
|
||||
# (1) Every XBlock has an "upstream" field
|
||||
# (2) some "downstream only" fields like weight and max_attempts are omitted.
|
||||
# (3) The "top_level_downstream_parent" is the container created
|
||||
self.assertXmlEqual(self._get_course_block_olx(downstream_unit["locator"]), f"""
|
||||
<vertical
|
||||
display_name="Unit 1 Title"
|
||||
@@ -260,6 +299,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
editor="visual"
|
||||
upstream="{self.upstream_html1['id']}"
|
||||
upstream_version="2"
|
||||
top_level_downstream_parent_key="{downstream_unit_block_key}"
|
||||
>This is the HTML.</html>
|
||||
<problem
|
||||
display_name="Problem 1 Display Name"
|
||||
@@ -268,6 +308,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
{self.standard_capa_attributes}
|
||||
upstream="{self.upstream_problem1['id']}"
|
||||
upstream_version="2"
|
||||
top_level_downstream_parent_key="{downstream_unit_block_key}"
|
||||
>multiple choice...</problem>
|
||||
<problem
|
||||
display_name="Problem 2 Display Name"
|
||||
@@ -276,12 +317,94 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
{self.standard_capa_attributes}
|
||||
upstream="{self.upstream_problem2['id']}"
|
||||
upstream_version="2"
|
||||
top_level_downstream_parent_key="{downstream_unit_block_key}"
|
||||
>multi select...</problem>
|
||||
</vertical>
|
||||
""")
|
||||
|
||||
# 2️⃣ Now, lets modify the upstream problem 1:
|
||||
children_downstream_keys = self._get_course_block_children(downstream_unit["locator"])
|
||||
downstream_html1 = children_downstream_keys[0]
|
||||
assert "type@html" in downstream_html1
|
||||
downstream_problem1 = children_downstream_keys[1]
|
||||
assert "type@problem" in downstream_problem1
|
||||
downstream_problem2 = children_downstream_keys[2]
|
||||
assert "type@problem" in downstream_problem2
|
||||
|
||||
# Check that: The downstream links are created as expected for each component and the container
|
||||
downstreams = self._get_downstream_links(
|
||||
course_id=str(self.course.id)
|
||||
)
|
||||
expected_downstreams = [
|
||||
{
|
||||
'id': 1,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_html1,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'top_level_parent_usage_key': downstream_unit["locator"],
|
||||
'version_synced': 2,
|
||||
'version_declined': None,
|
||||
'created': date_format,
|
||||
'updated': date_format,
|
||||
'upstream_key': self.upstream_html1["id"],
|
||||
'upstream_type': 'component',
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_problem1,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'top_level_parent_usage_key': downstream_unit["locator"],
|
||||
'version_synced': 2,
|
||||
'version_declined': None,
|
||||
'created': date_format,
|
||||
'updated': date_format,
|
||||
'upstream_key': self.upstream_problem1["id"],
|
||||
'upstream_type': 'component'
|
||||
},
|
||||
{
|
||||
'id': 3,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_problem2,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'top_level_parent_usage_key': downstream_unit["locator"],
|
||||
'version_synced': 2,
|
||||
'version_declined': None,
|
||||
'created': date_format,
|
||||
'updated': date_format,
|
||||
'upstream_key': self.upstream_problem2["id"],
|
||||
'upstream_type': 'component'
|
||||
},
|
||||
{
|
||||
'id': 1,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_unit["locator"],
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'top_level_parent_usage_key': None,
|
||||
'version_synced': 2,
|
||||
'version_declined': None,
|
||||
'created': date_format,
|
||||
'updated': date_format,
|
||||
'upstream_key': self.upstream_unit["id"],
|
||||
'upstream_type': 'container'
|
||||
}
|
||||
]
|
||||
data = downstreams.json()
|
||||
self.assertEqual(data["count"], 4)
|
||||
self.assertListEqual(data["results"], expected_downstreams)
|
||||
|
||||
# 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>'
|
||||
@@ -302,8 +425,6 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
|
||||
# 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
|
||||
@@ -313,8 +434,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
'error_message': None,
|
||||
})
|
||||
|
||||
# 3️⃣ Now, sync and check the resulting OLX of the downstream
|
||||
|
||||
# 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"""
|
||||
@@ -330,6 +450,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
editor="visual"
|
||||
upstream="{self.upstream_html1['id']}"
|
||||
upstream_version="2"
|
||||
top_level_downstream_parent_key="{downstream_unit_block_key}"
|
||||
>This is the HTML.</html>
|
||||
<!-- 🟢 the problem below has been updated: -->
|
||||
<problem
|
||||
@@ -339,6 +460,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
{self.standard_capa_attributes}
|
||||
upstream="{self.upstream_problem1['id']}"
|
||||
upstream_version="3"
|
||||
top_level_downstream_parent_key="{downstream_unit_block_key}"
|
||||
>multiple choice v2...</problem>
|
||||
<problem
|
||||
display_name="Problem 2 Display Name"
|
||||
@@ -347,11 +469,86 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
{self.standard_capa_attributes}
|
||||
upstream="{self.upstream_problem2['id']}"
|
||||
upstream_version="2"
|
||||
top_level_downstream_parent_key="{downstream_unit_block_key}"
|
||||
>multi select...</problem>
|
||||
</vertical>
|
||||
""")
|
||||
|
||||
# Now, add and delete a component
|
||||
# Check that: The downstream link of the problem is updated and no more links are created
|
||||
downstreams = self._get_downstream_links(
|
||||
course_id=str(self.course.id)
|
||||
)
|
||||
expected_downstreams = [
|
||||
{
|
||||
'id': 1,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_html1,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'top_level_parent_usage_key': downstream_unit["locator"],
|
||||
'version_synced': 2,
|
||||
'version_declined': None,
|
||||
'created': date_format,
|
||||
'updated': date_format,
|
||||
'upstream_key': self.upstream_html1["id"],
|
||||
'upstream_type': 'component',
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 3, # <--- updated
|
||||
'ready_to_sync': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_problem1,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'top_level_parent_usage_key': downstream_unit["locator"],
|
||||
'version_synced': 3, # <--- updated
|
||||
'version_declined': None,
|
||||
'created': date_format,
|
||||
'updated': date_format,
|
||||
'upstream_key': self.upstream_problem1["id"],
|
||||
'upstream_type': 'component'
|
||||
},
|
||||
{
|
||||
'id': 3,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_problem2,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'top_level_parent_usage_key': downstream_unit["locator"],
|
||||
'version_synced': 2,
|
||||
'version_declined': None,
|
||||
'created': date_format,
|
||||
'updated': date_format,
|
||||
'upstream_key': self.upstream_problem2["id"],
|
||||
'upstream_type': 'component'
|
||||
},
|
||||
{
|
||||
'id': 1,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2, # <--- not updated since we didn't directly modify the unit
|
||||
'ready_to_sync': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_unit["locator"],
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'top_level_parent_usage_key': None,
|
||||
'version_synced': 2,
|
||||
'version_declined': None,
|
||||
'created': date_format,
|
||||
'updated': date_format,
|
||||
'upstream_key': self.upstream_unit["id"],
|
||||
'upstream_type': 'container'
|
||||
}
|
||||
]
|
||||
data = downstreams.json()
|
||||
self.assertEqual(data["count"], 4)
|
||||
self.assertListEqual(data["results"], expected_downstreams)
|
||||
|
||||
# 3️⃣ Now, add and delete a component
|
||||
upstream_problem3 = self._add_block_to_library(
|
||||
self.library["id"],
|
||||
"problem",
|
||||
@@ -376,7 +573,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
'error_message': None,
|
||||
})
|
||||
|
||||
# 3️⃣ Now, sync and check the resulting OLX of the downstream
|
||||
# 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"""
|
||||
@@ -392,6 +589,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
editor="visual"
|
||||
upstream="{self.upstream_html1['id']}"
|
||||
upstream_version="2"
|
||||
top_level_downstream_parent_key="{downstream_unit_block_key}"
|
||||
>This is the HTML.</html>
|
||||
<problem
|
||||
display_name="Problem 1 NEW name"
|
||||
@@ -400,6 +598,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
{self.standard_capa_attributes}
|
||||
upstream="{self.upstream_problem1['id']}"
|
||||
upstream_version="3"
|
||||
top_level_downstream_parent_key="{downstream_unit_block_key}"
|
||||
>multiple choice v2...</problem>
|
||||
<!-- 🟢 the problem 2 has been deleted: -->
|
||||
<!-- 🟢 the problem 3 has been added: -->
|
||||
@@ -410,11 +609,88 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
{self.standard_capa_attributes}
|
||||
upstream="{upstream_problem3['id']}"
|
||||
upstream_version="2"
|
||||
top_level_downstream_parent_key="{downstream_unit_block_key}"
|
||||
>single select...</problem>
|
||||
</vertical>
|
||||
""")
|
||||
|
||||
# Now, reorder components
|
||||
# Check that: The downstream links are created deleted as expected
|
||||
downstream_problem3 = self._get_course_block_children(downstream_unit["locator"])[2]
|
||||
assert "type@problem" in downstream_problem3
|
||||
downstreams = self._get_downstream_links(
|
||||
course_id=str(self.course.id)
|
||||
)
|
||||
expected_downstreams = [
|
||||
{
|
||||
'id': 1,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_html1,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'top_level_parent_usage_key': downstream_unit["locator"],
|
||||
'version_synced': 2,
|
||||
'version_declined': None,
|
||||
'created': date_format,
|
||||
'updated': date_format,
|
||||
'upstream_key': self.upstream_html1["id"],
|
||||
'upstream_type': 'component',
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 3,
|
||||
'ready_to_sync': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_problem1,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'top_level_parent_usage_key': downstream_unit["locator"],
|
||||
'version_synced': 3,
|
||||
'version_declined': None,
|
||||
'created': date_format,
|
||||
'updated': date_format,
|
||||
'upstream_key': self.upstream_problem1["id"],
|
||||
'upstream_type': 'component'
|
||||
},
|
||||
{
|
||||
'id': 4,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_problem3,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'top_level_parent_usage_key': downstream_unit["locator"],
|
||||
'version_synced': 2,
|
||||
'version_declined': None,
|
||||
'created': date_format,
|
||||
'updated': date_format,
|
||||
'upstream_key': upstream_problem3["id"],
|
||||
'upstream_type': 'component'
|
||||
},
|
||||
{
|
||||
'id': 1,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 4, # <--- updated
|
||||
'ready_to_sync': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_unit["locator"],
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'top_level_parent_usage_key': None,
|
||||
'version_synced': 4, # <--- updated
|
||||
'version_declined': None,
|
||||
'created': date_format,
|
||||
'updated': date_format,
|
||||
'upstream_key': self.upstream_unit["id"],
|
||||
'upstream_type': 'container'
|
||||
}
|
||||
]
|
||||
data = downstreams.json()
|
||||
self.assertEqual(data["count"], 4)
|
||||
self.assertListEqual(data["results"], expected_downstreams)
|
||||
|
||||
# 4️⃣ Now, reorder components
|
||||
self._patch_container_components(self.upstream_unit["id"], [
|
||||
upstream_problem3["id"],
|
||||
self.upstream_problem1["id"],
|
||||
@@ -422,7 +698,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
])
|
||||
self._publish_container(self.upstream_unit["id"])
|
||||
|
||||
# 3️⃣ Now, sync and check the resulting OLX of the downstream
|
||||
# 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"""
|
||||
@@ -440,6 +716,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
{self.standard_capa_attributes}
|
||||
upstream="{upstream_problem3['id']}"
|
||||
upstream_version="2"
|
||||
top_level_downstream_parent_key="{downstream_unit_block_key}"
|
||||
>single select...</problem>
|
||||
<!-- 🟢 the problem 1 has been moved to middle: -->
|
||||
<problem
|
||||
@@ -449,6 +726,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
{self.standard_capa_attributes}
|
||||
upstream="{self.upstream_problem1['id']}"
|
||||
upstream_version="3"
|
||||
top_level_downstream_parent_key="{downstream_unit_block_key}"
|
||||
>multiple choice v2...</problem>
|
||||
<!-- 🟢 the html 1 has been moved to end: -->
|
||||
<html
|
||||
@@ -457,6 +735,81 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
|
||||
editor="visual"
|
||||
upstream="{self.upstream_html1['id']}"
|
||||
upstream_version="2"
|
||||
top_level_downstream_parent_key="{downstream_unit_block_key}"
|
||||
>This is the HTML.</html>
|
||||
</vertical>
|
||||
""")
|
||||
|
||||
# Check that: The downstream link of the unit is updated and no more links are created
|
||||
downstreams = self._get_downstream_links(
|
||||
course_id=str(self.course.id)
|
||||
)
|
||||
expected_downstreams = [
|
||||
{
|
||||
'id': 1,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_html1,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'top_level_parent_usage_key': downstream_unit["locator"],
|
||||
'version_synced': 2,
|
||||
'version_declined': None,
|
||||
'created': date_format,
|
||||
'updated': date_format,
|
||||
'upstream_key': self.upstream_html1["id"],
|
||||
'upstream_type': 'component',
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 3,
|
||||
'ready_to_sync': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_problem1,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'top_level_parent_usage_key': downstream_unit["locator"],
|
||||
'version_synced': 3,
|
||||
'version_declined': None,
|
||||
'created': date_format,
|
||||
'updated': date_format,
|
||||
'upstream_key': self.upstream_problem1["id"],
|
||||
'upstream_type': 'component'
|
||||
},
|
||||
{
|
||||
'id': 4,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 2,
|
||||
'ready_to_sync': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_problem3,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'top_level_parent_usage_key': downstream_unit["locator"],
|
||||
'version_synced': 2,
|
||||
'version_declined': None,
|
||||
'created': date_format,
|
||||
'updated': date_format,
|
||||
'upstream_key': upstream_problem3["id"],
|
||||
'upstream_type': 'component'
|
||||
},
|
||||
{
|
||||
'id': 1,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_version': 5, # <--- updated
|
||||
'ready_to_sync': False,
|
||||
'upstream_context_key': self.library_id,
|
||||
'downstream_usage_key': downstream_unit["locator"],
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'top_level_parent_usage_key': None,
|
||||
'version_synced': 5, # <--- updated
|
||||
'version_declined': None,
|
||||
'created': date_format,
|
||||
'updated': date_format,
|
||||
'upstream_key': self.upstream_unit["id"],
|
||||
'upstream_type': 'container'
|
||||
}
|
||||
]
|
||||
data = downstreams.json()
|
||||
self.assertEqual(data["count"], 4)
|
||||
self.assertListEqual(data["results"], expected_downstreams)
|
||||
|
||||
@@ -15,6 +15,7 @@ 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 cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import get_block_key_dict
|
||||
from opaque_keys.edx.keys import ContainerKey, UsageKey
|
||||
from opaque_keys.edx.locator import LibraryLocatorV2
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
@@ -92,11 +93,29 @@ class _BaseDownstreamViewTestMixin:
|
||||
self.unit_id = self._create_container(self.library_id, "unit", "unit-1", "Unit 1")["id"]
|
||||
self.subsection_id = self._create_container(self.library_id, "subsection", "subsection-1", "Subsection 1")["id"]
|
||||
self.section_id = self._create_container(self.library_id, "section", "section-1", "Section 1")["id"]
|
||||
|
||||
# Creating container to test the top-level parent
|
||||
self.top_level_unit_id = self._create_container(self.library_id, "unit", "unit-2", "Unit 2")["id"]
|
||||
self.top_level_subsection_id = self._create_container(
|
||||
self.library_id,
|
||||
"subsection",
|
||||
"subsection-2",
|
||||
"Subsection 2",
|
||||
)["id"]
|
||||
self.top_level_section_id = self._create_container(self.library_id, "section", "section-2", "Section 2")["id"]
|
||||
self.html_lib_id_2 = self._add_block_to_library(self.library_id, "html", "html-baz-2")["id"]
|
||||
self.video_lib_id_2 = self._add_block_to_library(self.library_id, "video", "video-baz-2")["id"]
|
||||
|
||||
self._publish_library_block(self.html_lib_id)
|
||||
self._publish_library_block(self.video_lib_id)
|
||||
self._publish_library_block(self.html_lib_id_2)
|
||||
self._publish_library_block(self.video_lib_id_2)
|
||||
self._publish_container(self.unit_id)
|
||||
self._publish_container(self.subsection_id)
|
||||
self._publish_container(self.section_id)
|
||||
self._publish_container(self.top_level_unit_id)
|
||||
self._publish_container(self.top_level_subsection_id)
|
||||
self._publish_container(self.top_level_section_id)
|
||||
self.mock_upstream_link = f"{settings.COURSE_AUTHORING_MICROFRONTEND_URL}/library/{self.library_id}/components?usageKey={self.video_lib_id}" # pylint: disable=line-too-long # noqa: E501
|
||||
self.course = CourseFactory.create()
|
||||
add_users(self.superuser, CourseStaffRole(self.course.id), self.course_user)
|
||||
@@ -120,6 +139,47 @@ class _BaseDownstreamViewTestMixin:
|
||||
category='vertical', parent=sequential, upstream=self.unit_id, upstream_version=1,
|
||||
).usage_key
|
||||
|
||||
# Creating Blocks with top-level-parents
|
||||
self.top_level_downstream_chapter = BlockFactory.create(
|
||||
category='chapter', parent=self.course, upstream=self.top_level_section_id, upstream_version=1,
|
||||
)
|
||||
self.top_level_downstream_sequential = BlockFactory.create(
|
||||
category='sequential',
|
||||
parent=self.top_level_downstream_chapter,
|
||||
upstream=self.top_level_subsection_id,
|
||||
upstream_version=1,
|
||||
top_level_downstream_parent_key=get_block_key_dict(
|
||||
self.top_level_downstream_chapter.usage_key,
|
||||
),
|
||||
)
|
||||
self.top_level_downstream_unit = BlockFactory.create(
|
||||
category='vertical',
|
||||
parent=self.top_level_downstream_sequential,
|
||||
upstream=self.top_level_unit_id,
|
||||
upstream_version=1,
|
||||
top_level_downstream_parent_key=get_block_key_dict(
|
||||
self.top_level_downstream_sequential.usage_key,
|
||||
)
|
||||
)
|
||||
self.top_level_downstream_html_key = BlockFactory.create(
|
||||
category='html',
|
||||
parent=self.top_level_downstream_unit,
|
||||
upstream=self.html_lib_id_2,
|
||||
upstream_version=1,
|
||||
top_level_downstream_parent_key=get_block_key_dict(
|
||||
self.top_level_downstream_unit.usage_key,
|
||||
)
|
||||
).usage_key
|
||||
self.top_level_downstream_video_key = BlockFactory.create(
|
||||
category='video',
|
||||
parent=self.top_level_downstream_unit,
|
||||
upstream=self.video_lib_id_2,
|
||||
upstream_version=1,
|
||||
top_level_downstream_parent_key=get_block_key_dict(
|
||||
self.top_level_downstream_chapter.usage_key,
|
||||
)
|
||||
).usage_key
|
||||
|
||||
self.another_course = CourseFactory.create(display_name="Another Course")
|
||||
another_chapter = BlockFactory.create(category="chapter", parent=self.another_course)
|
||||
another_sequential = BlockFactory.create(category="sequential", parent=another_chapter)
|
||||
@@ -498,6 +558,7 @@ class GetUpstreamViewTest(
|
||||
ready_to_sync: bool | None = None,
|
||||
upstream_key: str | None = None,
|
||||
item_type: str | None = None,
|
||||
use_top_level_parents: bool | None = None,
|
||||
):
|
||||
data = {}
|
||||
if course_id is not None:
|
||||
@@ -508,6 +569,8 @@ class GetUpstreamViewTest(
|
||||
data["upstream_key"] = str(upstream_key)
|
||||
if item_type is not None:
|
||||
data["item_type"] = str(item_type)
|
||||
if use_top_level_parents is not None:
|
||||
data["use_top_level_parents"] = str(use_top_level_parents)
|
||||
return self.client.get("/api/contentstore/v2/downstreams-all/", data=data)
|
||||
|
||||
def test_200_all_downstreams_for_a_course(self):
|
||||
@@ -533,7 +596,8 @@ class GetUpstreamViewTest(
|
||||
'upstream_type': 'component',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
@@ -549,6 +613,39 @@ class GetUpstreamViewTest(
|
||||
'upstream_version': 2,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_html_key),
|
||||
'id': 3,
|
||||
'ready_to_sync': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.html_lib_id_2,
|
||||
'upstream_type': 'component',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': str(self.top_level_downstream_unit.usage_key),
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_video_key),
|
||||
'id': 4,
|
||||
'ready_to_sync': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.video_lib_id_2,
|
||||
'upstream_type': 'component',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key),
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
@@ -564,6 +661,7 @@ class GetUpstreamViewTest(
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
@@ -579,6 +677,7 @@ class GetUpstreamViewTest(
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
@@ -593,11 +692,60 @@ class GetUpstreamViewTest(
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 2,
|
||||
'version_declined': None,
|
||||
'version_synced': 1
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_chapter.usage_key),
|
||||
'id': 4,
|
||||
'ready_to_sync': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.top_level_section_id,
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_sequential.usage_key),
|
||||
'id': 5,
|
||||
'ready_to_sync': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.top_level_subsection_id,
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key),
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_unit.usage_key),
|
||||
'id': 6,
|
||||
'ready_to_sync': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.top_level_unit_id,
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': str(self.top_level_downstream_sequential.usage_key),
|
||||
},
|
||||
]
|
||||
self.assertListEqual(data["results"], expected)
|
||||
self.assertEqual(data["count"], 5)
|
||||
self.assertEqual(data["count"], 10)
|
||||
|
||||
def test_permission_denied_with_course_filter(self):
|
||||
self.client.login(username="simple_user", password="password")
|
||||
@@ -630,7 +778,8 @@ class GetUpstreamViewTest(
|
||||
'upstream_type': 'component',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
@@ -646,10 +795,43 @@ class GetUpstreamViewTest(
|
||||
'upstream_version': 2,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_html_key),
|
||||
'id': 3,
|
||||
'ready_to_sync': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.html_lib_id_2,
|
||||
'upstream_type': 'component',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': str(self.top_level_downstream_unit.usage_key),
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_video_key),
|
||||
'id': 4,
|
||||
'ready_to_sync': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.video_lib_id_2,
|
||||
'upstream_type': 'component',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key),
|
||||
},
|
||||
]
|
||||
self.assertListEqual(data["results"], expected)
|
||||
self.assertEqual(data["count"], 2)
|
||||
self.assertEqual(data["count"], 4)
|
||||
|
||||
def test_200_container_downstreams_for_a_course(self):
|
||||
"""
|
||||
@@ -678,6 +860,7 @@ class GetUpstreamViewTest(
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
@@ -693,6 +876,7 @@ class GetUpstreamViewTest(
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
@@ -707,11 +891,60 @@ class GetUpstreamViewTest(
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 2,
|
||||
'version_declined': None,
|
||||
'version_synced': 1
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_chapter.usage_key),
|
||||
'id': 4,
|
||||
'ready_to_sync': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.top_level_section_id,
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_sequential.usage_key),
|
||||
'id': 5,
|
||||
'ready_to_sync': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.top_level_subsection_id,
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key),
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_unit.usage_key),
|
||||
'id': 6,
|
||||
'ready_to_sync': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.top_level_unit_id,
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': str(self.top_level_downstream_sequential.usage_key),
|
||||
},
|
||||
]
|
||||
self.assertListEqual(data["results"], expected)
|
||||
self.assertEqual(data["count"], 3)
|
||||
self.assertEqual(data["count"], 6)
|
||||
|
||||
@ddt.data(
|
||||
('all', 2),
|
||||
@@ -764,52 +997,64 @@ class GetUpstreamViewTest(
|
||||
self.assertListEqual(got, expected)
|
||||
self.assertEqual(data["count"], 1)
|
||||
|
||||
|
||||
class GetComponentUpstreamViewTest(
|
||||
_BaseDownstreamViewTestMixin,
|
||||
SharedModuleStoreTestCase,
|
||||
):
|
||||
"""
|
||||
Test that `GET /api/v2/contentstore/downstreams?...` returns list of component links based on the provided filter.
|
||||
"""
|
||||
def call_api(
|
||||
self,
|
||||
course_id: str | None = None,
|
||||
ready_to_sync: bool | None = None,
|
||||
upstream_usage_key: str | None = None,
|
||||
):
|
||||
data = {}
|
||||
if course_id is not None:
|
||||
data["course_id"] = str(course_id)
|
||||
if ready_to_sync is not None:
|
||||
data["ready_to_sync"] = str(ready_to_sync)
|
||||
if upstream_usage_key is not None:
|
||||
data["upstream_usage_key"] = str(upstream_usage_key)
|
||||
return self.client.get("/api/contentstore/v2/downstreams/", data=data)
|
||||
|
||||
def test_200_all_component_downstreams_for_a_course(self):
|
||||
def test_200_get_ready_to_sync_top_level_parents_with_components(self):
|
||||
"""
|
||||
Returns all component links for given course
|
||||
Returns all links that are syncable using the top-level parents of components
|
||||
"""
|
||||
self.client.login(username="superuser", password="password")
|
||||
response = self.call_api(course_id=self.course.id)
|
||||
|
||||
# Publish components
|
||||
self._set_library_block_olx(self.html_lib_id_2, "<html><b>Hello world!</b></html>")
|
||||
self._publish_library_block(self.html_lib_id_2)
|
||||
self._set_library_block_olx(self.video_lib_id_2, "<video><b>Hello world!</b></video>")
|
||||
self._publish_library_block(self.video_lib_id_2)
|
||||
|
||||
response = self.call_api(
|
||||
ready_to_sync=True,
|
||||
item_type="all",
|
||||
use_top_level_parents=True,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
self.assertEqual(data["count"], 4)
|
||||
date_format = self.now.isoformat().split("+")[0] + 'Z'
|
||||
|
||||
# The expected results are
|
||||
# * The section that is the top-level parent of `video_lib_id_2`
|
||||
# * The unit that is the top-level parent of `html_lib_id_2`
|
||||
# * 2 links without top-level parents
|
||||
expected = [
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.downstream_video_key),
|
||||
'id': 1,
|
||||
'downstream_usage_key': str(self.top_level_downstream_chapter.usage_key),
|
||||
'id': 4,
|
||||
'ready_to_sync': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_usage_key': self.video_lib_id,
|
||||
'upstream_key': self.top_level_section_id,
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_unit.usage_key),
|
||||
'id': 6,
|
||||
'ready_to_sync': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.top_level_unit_id,
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': str(self.top_level_downstream_sequential.usage_key),
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
@@ -820,38 +1065,190 @@ class GetComponentUpstreamViewTest(
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_usage_key': self.html_lib_id,
|
||||
'upstream_key': self.html_lib_id,
|
||||
'upstream_type': 'component',
|
||||
'upstream_version': 2,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.downstream_unit_key),
|
||||
'id': 3,
|
||||
'ready_to_sync': True,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.unit_id,
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 2,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
]
|
||||
self.assertListEqual(data["results"], expected)
|
||||
self.assertEqual(data["count"], 2)
|
||||
|
||||
def test_200_all_component_downstreams_ready_to_sync(self):
|
||||
def test_200_get_ready_to_sync_top_level_parents_with_containers(self):
|
||||
"""
|
||||
Returns all component links that are syncable
|
||||
Returns all links that are syncable using the top-level parents of containers
|
||||
"""
|
||||
self.client.login(username="superuser", password="password")
|
||||
response = self.call_api(ready_to_sync=True)
|
||||
|
||||
# Publish Subsection
|
||||
self._update_container(self.top_level_subsection_id, display_name="Subsection 3")
|
||||
self._publish_container(self.top_level_subsection_id)
|
||||
|
||||
response = self.call_api(
|
||||
ready_to_sync=True,
|
||||
item_type="all",
|
||||
use_top_level_parents=True,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
self.assertTrue(all(o["ready_to_sync"] for o in data["results"]))
|
||||
self.assertEqual(data["count"], 1)
|
||||
self.assertEqual(data["count"], 3)
|
||||
date_format = self.now.isoformat().split("+")[0] + 'Z'
|
||||
|
||||
def test_200_component_downstream_context_list(self):
|
||||
# The expected results are
|
||||
# * 2 links without top-level parents
|
||||
# * The section that is the top-level parent of `top_level_subsection_id`
|
||||
expected = [
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.downstream_html_key),
|
||||
'id': 2,
|
||||
'ready_to_sync': True,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.html_lib_id,
|
||||
'upstream_type': 'component',
|
||||
'upstream_version': 2,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.downstream_unit_key),
|
||||
'id': 3,
|
||||
'ready_to_sync': True,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.unit_id,
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 2,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_chapter.usage_key),
|
||||
'id': 4,
|
||||
'ready_to_sync': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.top_level_section_id,
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
]
|
||||
self.assertListEqual(data["results"], expected)
|
||||
|
||||
def test_200_get_ready_to_sync_duplicated_top_level_parents(self):
|
||||
"""
|
||||
Returns all component downstream courses for given library block
|
||||
Returns all links that are syncable using the same top-level parents
|
||||
|
||||
According to the requirements, only the top-level parents should be displayed.
|
||||
Even if all containers and components within a section are updated, only the top-level parent,
|
||||
which is the section, should be displayed.
|
||||
This test checks that only the top-level parent is displayed and is not duplicated in the result.
|
||||
"""
|
||||
self.client.login(username="superuser", password="password")
|
||||
response = self.call_api(upstream_usage_key=self.video_lib_id)
|
||||
|
||||
# Publish Section and component/subsection that has the same section as top-level parent
|
||||
self._update_container(self.top_level_section_id, display_name="Section 3")
|
||||
self._publish_container(self.top_level_section_id)
|
||||
self._set_library_block_olx(self.video_lib_id_2, "<video><b>Hello world!</b></video>")
|
||||
self._publish_library_block(self.video_lib_id_2)
|
||||
self._update_container(self.top_level_subsection_id, display_name="Subsection 3")
|
||||
self._publish_container(self.top_level_subsection_id)
|
||||
|
||||
response = self.call_api(
|
||||
ready_to_sync=True,
|
||||
item_type="all",
|
||||
use_top_level_parents=True,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
expected = [str(self.downstream_video_key)] + [str(key) for key in self.another_video_keys]
|
||||
got = [str(o["downstream_usage_key"]) for o in data["results"]]
|
||||
self.assertListEqual(got, expected)
|
||||
self.assertEqual(data["count"], 4)
|
||||
self.assertEqual(data["count"], 3)
|
||||
date_format = self.now.isoformat().split("+")[0] + 'Z'
|
||||
|
||||
# The expected results are
|
||||
# * The section that is the top-level parent of `video_lib_id_2` and `top_level_subsection_id`
|
||||
# * 2 links without top-level parents
|
||||
expected = [
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.top_level_downstream_chapter.usage_key),
|
||||
'id': 4,
|
||||
'ready_to_sync': True,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.top_level_section_id,
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 2,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.downstream_html_key),
|
||||
'id': 2,
|
||||
'ready_to_sync': True,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.html_lib_id,
|
||||
'upstream_type': 'component',
|
||||
'upstream_version': 2,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.downstream_unit_key),
|
||||
'id': 3,
|
||||
'ready_to_sync': True,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_key': self.unit_id,
|
||||
'upstream_type': 'container',
|
||||
'upstream_version': 2,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
'top_level_parent_usage_key': None,
|
||||
},
|
||||
]
|
||||
self.assertListEqual(data["results"], expected)
|
||||
|
||||
|
||||
class GetDownstreamSummaryViewTest(
|
||||
@@ -888,97 +1285,7 @@ class GetDownstreamSummaryViewTest(
|
||||
'upstream_context_title': 'Test Library 1',
|
||||
'upstream_context_key': self.library_id,
|
||||
'ready_to_sync_count': 2,
|
||||
'total_count': 5,
|
||||
'total_count': 10,
|
||||
'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
|
||||
}]
|
||||
self.assertListEqual(data, expected)
|
||||
|
||||
|
||||
class GetContainerUpstreamViewTest(
|
||||
_BaseDownstreamViewTestMixin,
|
||||
SharedModuleStoreTestCase,
|
||||
):
|
||||
"""
|
||||
Test that `GET /api/v2/contentstore/downstream-containers?...` returns list of links based on the provided filter.
|
||||
"""
|
||||
def call_api(
|
||||
self,
|
||||
course_id: str | None = None,
|
||||
ready_to_sync: bool | None = None,
|
||||
upstream_container_key: str | None = None,
|
||||
):
|
||||
data = {}
|
||||
if course_id is not None:
|
||||
data["course_id"] = str(course_id)
|
||||
if ready_to_sync is not None:
|
||||
data["ready_to_sync"] = str(ready_to_sync)
|
||||
if upstream_container_key is not None:
|
||||
data["upstream_container_key"] = str(upstream_container_key)
|
||||
return self.client.get("/api/contentstore/v2/downstream-containers/", data=data)
|
||||
|
||||
def test_200_all_container_downstreams_for_a_course(self):
|
||||
"""
|
||||
Returns all container links for given course
|
||||
"""
|
||||
self.client.login(username="superuser", password="password")
|
||||
response = self.call_api(course_id=self.course.id)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
date_format = self.now.isoformat().split("+")[0] + 'Z'
|
||||
expected = [
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.downstream_chapter_key),
|
||||
'id': 1,
|
||||
'ready_to_sync': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_container_key': self.section_id,
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.downstream_sequential_key),
|
||||
'id': 2,
|
||||
'ready_to_sync': False,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_container_key': self.subsection_id,
|
||||
'upstream_version': 1,
|
||||
'version_declined': None,
|
||||
'version_synced': 1,
|
||||
},
|
||||
{
|
||||
'created': date_format,
|
||||
'downstream_context_key': str(self.course.id),
|
||||
'downstream_usage_key': str(self.downstream_unit_key),
|
||||
'id': 3,
|
||||
'ready_to_sync': True,
|
||||
'updated': date_format,
|
||||
'upstream_context_key': self.library_id,
|
||||
'upstream_context_title': self.library_title,
|
||||
'upstream_container_key': self.unit_id,
|
||||
'upstream_version': 2,
|
||||
'version_declined': None,
|
||||
'version_synced': 1
|
||||
},
|
||||
]
|
||||
self.assertListEqual(data["results"], expected)
|
||||
self.assertEqual(data["count"], 3)
|
||||
|
||||
def test_200_all_downstreams_ready_to_sync(self):
|
||||
"""
|
||||
Returns all links that are syncable
|
||||
"""
|
||||
self.client.login(username="superuser", password="password")
|
||||
response = self.call_api(ready_to_sync=True)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
self.assertTrue(all(o["ready_to_sync"] for o in data["results"]))
|
||||
self.assertEqual(data["count"], 1)
|
||||
|
||||
@@ -49,7 +49,8 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from ..models import ComponentLink, ContainerLink
|
||||
from ..tasks import (
|
||||
create_or_update_upstream_links,
|
||||
handle_create_or_update_xblock_upstream_link,
|
||||
handle_create_xblock_upstream_link,
|
||||
handle_update_xblock_upstream_link,
|
||||
handle_unlink_upstream_block,
|
||||
handle_unlink_upstream_container,
|
||||
)
|
||||
@@ -260,17 +261,29 @@ def handle_grading_policy_changed(sender, **kwargs):
|
||||
|
||||
|
||||
@receiver(XBLOCK_CREATED)
|
||||
@receiver(XBLOCK_UPDATED)
|
||||
def create_or_update_upstream_downstream_link_handler(**kwargs):
|
||||
def create_upstream_downstream_link_handler(**kwargs):
|
||||
"""
|
||||
Automatically create or update upstream->downstream link in database.
|
||||
Automatically create upstream->downstream link in database.
|
||||
"""
|
||||
xblock_info = kwargs.get("xblock_info", None)
|
||||
if not xblock_info or not isinstance(xblock_info, XBlockData):
|
||||
log.error("Received null or incorrect data for event")
|
||||
return
|
||||
|
||||
handle_create_or_update_xblock_upstream_link.delay(str(xblock_info.usage_key))
|
||||
handle_create_xblock_upstream_link.delay(str(xblock_info.usage_key))
|
||||
|
||||
|
||||
@receiver(XBLOCK_UPDATED)
|
||||
def update_upstream_downstream_link_handler(**kwargs):
|
||||
"""
|
||||
Automatically update upstream->downstream link in database.
|
||||
"""
|
||||
xblock_info = kwargs.get("xblock_info", None)
|
||||
if not xblock_info or not isinstance(xblock_info, XBlockData):
|
||||
log.error("Received null or incorrect data for event")
|
||||
return
|
||||
|
||||
handle_update_xblock_upstream_link.delay(str(xblock_info.usage_key))
|
||||
|
||||
|
||||
@receiver(XBLOCK_DELETED)
|
||||
|
||||
@@ -34,7 +34,7 @@ from olxcleaner.exceptions import ErrorLevel
|
||||
from olxcleaner.reporting import report_error_summary, report_errors
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocator
|
||||
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocator, BlockUsageLocator
|
||||
from organizations.api import add_organization_course, ensure_organization
|
||||
from organizations.exceptions import InvalidOrganizationException
|
||||
from organizations.models import Organization
|
||||
@@ -1598,11 +1598,42 @@ def _write_broken_links_to_file(broken_or_locked_urls, broken_links_file):
|
||||
|
||||
@shared_task
|
||||
@set_code_owner_attribute
|
||||
def handle_create_or_update_xblock_upstream_link(usage_key):
|
||||
def handle_create_xblock_upstream_link(usage_key):
|
||||
"""
|
||||
Create or update upstream link for a single xblock.
|
||||
Create upstream link for a single xblock.
|
||||
If the xblock has top-level parent, verify if the link for the parent is created,
|
||||
if not, create it before any subsequent operation.
|
||||
"""
|
||||
ensure_cms("handle_create_or_update_xblock_upstream_link may only be executed in a CMS context")
|
||||
ensure_cms("handle_create_xblock_upstream_link may only be executed in a CMS context")
|
||||
try:
|
||||
xblock = modulestore().get_item(UsageKey.from_string(usage_key))
|
||||
except (ItemNotFoundError, InvalidKeyError):
|
||||
LOGGER.exception(f'Could not find item for given usage_key: {usage_key}')
|
||||
return
|
||||
if not xblock.upstream or not xblock.upstream_version:
|
||||
return
|
||||
if xblock.top_level_downstream_parent_key is not None:
|
||||
top_level_parent_usage_key = BlockUsageLocator(
|
||||
xblock.course_id,
|
||||
xblock.top_level_downstream_parent_key.get('type'),
|
||||
xblock.top_level_downstream_parent_key.get('id'),
|
||||
)
|
||||
try:
|
||||
ContainerLink.get_by_downstream_usage_key(top_level_parent_usage_key)
|
||||
except ContainerLink.DoesNotExist:
|
||||
# The top-level parent link does not exist yet,
|
||||
# it is necessary to create it first.
|
||||
handle_create_xblock_upstream_link(str(top_level_parent_usage_key))
|
||||
create_or_update_xblock_upstream_link(xblock, xblock.course_id)
|
||||
|
||||
|
||||
@shared_task
|
||||
@set_code_owner_attribute
|
||||
def handle_update_xblock_upstream_link(usage_key):
|
||||
"""
|
||||
Update upstream link for a single xblock.
|
||||
"""
|
||||
ensure_cms("handle_update_xblock_upstream_link may only be executed in a CMS context")
|
||||
try:
|
||||
xblock = modulestore().get_item(UsageKey.from_string(usage_key))
|
||||
except (ItemNotFoundError, InvalidKeyError):
|
||||
|
||||
@@ -26,7 +26,7 @@ from lti_consumer.models import CourseAllowPIISharingInLTIFlag
|
||||
from milestones import api as milestones_api
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey, UsageKeyV2
|
||||
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocator
|
||||
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocator, BlockUsageLocator
|
||||
from openedx_events.content_authoring.data import DuplicatedXBlockData
|
||||
from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED
|
||||
from openedx_events.learning.data import CourseNotificationData
|
||||
@@ -2395,12 +2395,22 @@ def _create_or_update_component_link(course_key: CourseKey, created: datetime |
|
||||
except ObjectDoesNotExist:
|
||||
log.error(f"Library component not found for {upstream_usage_key}")
|
||||
lib_component = None
|
||||
|
||||
top_level_parent_usage_key = None
|
||||
if xblock.top_level_downstream_parent_key is not None:
|
||||
top_level_parent_usage_key = BlockUsageLocator(
|
||||
course_key,
|
||||
xblock.top_level_downstream_parent_key.get('type'),
|
||||
xblock.top_level_downstream_parent_key.get('id'),
|
||||
)
|
||||
|
||||
ComponentLink.update_or_create(
|
||||
lib_component,
|
||||
upstream_usage_key=upstream_usage_key,
|
||||
upstream_context_key=str(upstream_usage_key.context_key),
|
||||
downstream_context_key=course_key,
|
||||
downstream_usage_key=xblock.usage_key,
|
||||
top_level_parent_usage_key=top_level_parent_usage_key,
|
||||
version_synced=xblock.upstream_version,
|
||||
version_declined=xblock.upstream_version_declined,
|
||||
created=created,
|
||||
@@ -2417,6 +2427,15 @@ def _create_or_update_container_link(course_key: CourseKey, created: datetime |
|
||||
except ObjectDoesNotExist:
|
||||
log.error(f"Library component not found for {upstream_container_key}")
|
||||
lib_component = None
|
||||
|
||||
top_level_parent_usage_key = None
|
||||
if xblock.top_level_downstream_parent_key is not None:
|
||||
top_level_parent_usage_key = BlockUsageLocator(
|
||||
course_key,
|
||||
xblock.top_level_downstream_parent_key.get('type'),
|
||||
xblock.top_level_downstream_parent_key.get('id'),
|
||||
)
|
||||
|
||||
ContainerLink.update_or_create(
|
||||
lib_component,
|
||||
upstream_container_key=upstream_container_key,
|
||||
@@ -2424,6 +2443,7 @@ def _create_or_update_container_link(course_key: CourseKey, created: datetime |
|
||||
downstream_context_key=course_key,
|
||||
downstream_usage_key=xblock.usage_key,
|
||||
version_synced=xblock.upstream_version,
|
||||
top_level_parent_usage_key=top_level_parent_usage_key,
|
||||
version_declined=xblock.upstream_version_declined,
|
||||
created=created,
|
||||
)
|
||||
|
||||
@@ -33,6 +33,7 @@ from opaque_keys.edx.locator import LibraryUsageLocator, LibraryUsageLocatorV2
|
||||
from pytz import UTC
|
||||
from xblock.core import XBlock
|
||||
from xblock.fields import Scope
|
||||
from .xblock_helpers import get_block_key_dict
|
||||
|
||||
from cms.djangoapps.contentstore.config.waffle import SHOW_REVIEW_RULES_FLAG
|
||||
from cms.djangoapps.contentstore.helpers import StaticFileNotices
|
||||
@@ -528,12 +529,16 @@ def create_item(request):
|
||||
return _create_block(request)
|
||||
|
||||
|
||||
def sync_library_content(downstream: XBlock, request, store) -> StaticFileNotices:
|
||||
def sync_library_content(
|
||||
downstream: XBlock,
|
||||
request,
|
||||
store,
|
||||
top_level_parent: XBlock | None = None
|
||||
) -> StaticFileNotices:
|
||||
"""
|
||||
Handle syncing library content for given xblock depending on its upstream type.
|
||||
It can sync unit containers and lower level xblocks.
|
||||
"""
|
||||
# CHECK: Sync library content for given xblock depending on its upstream type.
|
||||
link = UpstreamLink.get_for_block(downstream)
|
||||
upstream_key = link.upstream_key
|
||||
if isinstance(upstream_key, LibraryUsageLocatorV2):
|
||||
@@ -547,15 +552,17 @@ def sync_library_content(downstream: XBlock, request, store) -> StaticFileNotice
|
||||
downstream_children_keys = [child.upstream for child in downstream_children]
|
||||
# Sync the children:
|
||||
notices = []
|
||||
# Store final children keys to update order of components in unit
|
||||
# Store final children keys to update order of items in containers
|
||||
children = []
|
||||
|
||||
top_level_downstream_parent = top_level_parent or downstream
|
||||
|
||||
for i, upstream_child in enumerate(upstream_children):
|
||||
if isinstance(upstream_child, LibraryXBlockMetadata):
|
||||
upstream_key = upstream_child.usage_key
|
||||
upstream_key = str(upstream_child.usage_key)
|
||||
block_type = upstream_child.usage_key.block_type
|
||||
elif isinstance(upstream_child, ContainerMetadata):
|
||||
upstream_key = upstream_child.container_key
|
||||
upstream_key = str(upstream_child.container_key)
|
||||
match upstream_child.container_type:
|
||||
case ContainerType.Unit:
|
||||
block_type = "vertical"
|
||||
@@ -585,7 +592,10 @@ def sync_library_content(downstream: XBlock, request, store) -> StaticFileNotice
|
||||
# TODO: Can we generate a unique but friendly block_id, perhaps using upstream block_id
|
||||
block_id=f"{block_type}{uuid4().hex[:8]}",
|
||||
fields={
|
||||
"upstream": str(upstream_key),
|
||||
"upstream": upstream_key,
|
||||
"top_level_downstream_parent_key": get_block_key_dict(
|
||||
top_level_downstream_parent.usage_key,
|
||||
),
|
||||
},
|
||||
)
|
||||
else:
|
||||
@@ -594,7 +604,12 @@ def sync_library_content(downstream: XBlock, request, store) -> StaticFileNotice
|
||||
|
||||
children.append(downstream_child.usage_key)
|
||||
|
||||
result = sync_library_content(downstream=downstream_child, request=request, store=store)
|
||||
result = sync_library_content(
|
||||
downstream=downstream_child,
|
||||
request=request,
|
||||
store=store,
|
||||
top_level_parent=top_level_downstream_parent,
|
||||
)
|
||||
notices.append(result)
|
||||
|
||||
for child in downstream_children:
|
||||
@@ -661,7 +676,6 @@ def _create_block(request):
|
||||
status=400,
|
||||
)
|
||||
|
||||
# CHECK: Add container to course
|
||||
created_block = create_xblock(
|
||||
parent_locator=parent_locator,
|
||||
user=request.user,
|
||||
|
||||
@@ -3,11 +3,13 @@ general helper functions for xblocks
|
||||
"""
|
||||
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from xblock.core import XBlock
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.util.keys import BlockKey
|
||||
from openedx.core.djangoapps.content_tagging.api import get_object_tag_counts
|
||||
|
||||
|
||||
def usage_key_with_run(usage_key_string):
|
||||
def usage_key_with_run(usage_key_string: str) -> UsageKey:
|
||||
"""
|
||||
Converts usage_key_string to a UsageKey, adding a course run if necessary
|
||||
"""
|
||||
@@ -16,7 +18,14 @@ def usage_key_with_run(usage_key_string):
|
||||
return usage_key
|
||||
|
||||
|
||||
def get_tags_count(xblock, include_children=False):
|
||||
def get_block_key_dict(usage_key: UsageKey) -> dict:
|
||||
"""
|
||||
Converts the usage_key in a dict with the form: `{"type": block_type, "id": block_id}`
|
||||
"""
|
||||
return BlockKey.from_usage_key(usage_key)._asdict()
|
||||
|
||||
|
||||
def get_tags_count(xblock: XBlock, include_children=False) -> dict[str, int]:
|
||||
"""
|
||||
Returns a map with tag count of the `xblock`
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryUsageLocatorV2
|
||||
from xblock.exceptions import XBlockNotFoundError
|
||||
from xblock.fields import Scope, String, Integer
|
||||
from xblock.fields import Scope, String, Integer, Dict
|
||||
from xblock.core import XBlockMixin, XBlock
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
@@ -327,6 +327,17 @@ class UpstreamSyncMixin(XBlockMixin):
|
||||
default=None, scope=Scope.settings, hidden=True, enforce_type=True,
|
||||
)
|
||||
|
||||
top_level_downstream_parent_key = Dict(
|
||||
help=(
|
||||
"The block key ('block_type@block_id') of the downstream block that is the top-level parent of "
|
||||
"this block. This is present if the creation of this block is a consequence of "
|
||||
"importing a container that has one or more levels of children. "
|
||||
"This represents the parent (container) in the top level "
|
||||
"at the moment of the import."
|
||||
),
|
||||
default=None, scope=Scope.settings, hidden=True, enforce_type=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_customizable_fields(cls) -> dict[str, str | None]:
|
||||
"""
|
||||
|
||||
@@ -138,6 +138,10 @@ class XBlockSerializer:
|
||||
if block.has_children:
|
||||
self._serialize_children(block, olx_node)
|
||||
|
||||
if "top_level_downstream_parent_key" in block.fields \
|
||||
and block.fields["top_level_downstream_parent_key"].is_set_on(block):
|
||||
olx_node.attrib["top_level_downstream_parent_key"] = str(block.top_level_downstream_parent_key)
|
||||
|
||||
return olx_node
|
||||
|
||||
def _serialize_children(self, block, parent_olx_node) -> None:
|
||||
@@ -162,7 +166,8 @@ class XBlockSerializer:
|
||||
if block.use_latex_compiler:
|
||||
olx_node.attrib["use_latex_compiler"] = "true"
|
||||
for field_name in block.fields:
|
||||
if field_name.startswith("upstream") and block.fields[field_name].is_set_on(block):
|
||||
if (field_name.startswith("upstream") or field_name == "top_level_downstream_parent_key") \
|
||||
and block.fields[field_name].is_set_on(block):
|
||||
olx_node.attrib[field_name] = str(getattr(block, field_name))
|
||||
|
||||
# Escape any CDATA special chars
|
||||
|
||||
@@ -167,11 +167,16 @@ class CommonMixedModuleStoreSetup(CourseComparisonTest, OpenEdxEventsTestMixin):
|
||||
# mock and ignore publishable link entity related tasks to avoid unnecessary
|
||||
# errors as it is tested separately
|
||||
if settings.ROOT_URLCONF == 'cms.urls':
|
||||
create_or_update_xblock_upstream_link_patch = patch(
|
||||
'cms.djangoapps.contentstore.signals.handlers.handle_create_or_update_xblock_upstream_link'
|
||||
create_xblock_upstream_link_patch = patch(
|
||||
'cms.djangoapps.contentstore.signals.handlers.handle_create_xblock_upstream_link'
|
||||
)
|
||||
create_or_update_xblock_upstream_link_patch.start()
|
||||
self.addCleanup(create_or_update_xblock_upstream_link_patch.stop)
|
||||
create_xblock_upstream_link_patch.start()
|
||||
self.addCleanup(create_xblock_upstream_link_patch.stop)
|
||||
update_xblock_upstream_link_patch = patch(
|
||||
'cms.djangoapps.contentstore.signals.handlers.handle_update_xblock_upstream_link'
|
||||
)
|
||||
update_xblock_upstream_link_patch.start()
|
||||
self.addCleanup(update_xblock_upstream_link_patch.stop)
|
||||
component_link_patch = patch(
|
||||
'cms.djangoapps.contentstore.signals.handlers.ComponentLink'
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user