diff --git a/cms/djangoapps/contentstore/migrations/0012_componentlink_top_level_parent_and_more.py b/cms/djangoapps/contentstore/migrations/0012_componentlink_top_level_parent_and_more.py
new file mode 100644
index 000000000000..02f94f4b6593
--- /dev/null
+++ b/cms/djangoapps/contentstore/migrations/0012_componentlink_top_level_parent_and_more.py
@@ -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'),
+ ),
+ ]
diff --git a/cms/djangoapps/contentstore/models.py b/cms/djangoapps/contentstore/models.py
index 65f83c23b15f..f641fbee1f7f 100644
--- a/cms/djangoapps/contentstore/models.py
+++ b/cms/djangoapps/contentstore/models.py
@@ -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 @@
)
+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 @@ def upstream_context_title(self) -> str:
@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 @@ def filter_links(
)
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 @@ def update_or_create(
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 @@ def update_or_create(
"""
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 @@ def update_or_create(
'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 @@ def upstream_context_title(self) -> str:
@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 @@ def filter_links(
)
)
)
- 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 @@ def update_or_create(
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 @@ def update_or_create(
"""
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 @@ def update_or_create(
'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 @@ def update_or_create(
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):
"""
diff --git a/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py
index 4024cad8ff0e..d7143a4f14bc 100644
--- a/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py
+++ b/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py
@@ -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):
diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py
index f85b68bd2654..f4ddbc7abe7f 100644
--- a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py
+++ b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py
@@ -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 @@ def get(self, request: _AuthenticatedRequest):
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 @@ def get(self, request: _AuthenticatedRequest):
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 @@ def get(self, request: _AuthenticatedRequest):
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 @@ def get(self, request: _AuthenticatedRequest):
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):
diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py
index b7890f821dcc..64d8b4ab5cdd 100644
--- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py
+++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py
@@ -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"],
'multiple choice...'
)
- 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"],
'multi select...'
)
- 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"],
'This is the 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 @@ def _update_course_block_fields(self, usage_key: str, fields: dict[str, Any] = N
"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 @@ def test_unit_sync(self):
"""
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 @@ def test_unit_sync(self):
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 @@ def test_unit_sync(self):
# 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"""
This is the HTML.