Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
36cb15c
feat: Add top_level_parent field and updates API to optain the top la…
ChrisChV Jul 28, 2025
51e3927
test: Added tests for top-level parents logic
ChrisChV Jul 28, 2025
c34bf9d
style: Fix broken lint
ChrisChV Jul 28, 2025
bc484ec
style: Nits on the code
ChrisChV Jul 29, 2025
806cb4f
style: fix broken lints
ChrisChV Jul 29, 2025
966c998
feat: Add _annotate_query_with_ready_to_sync to ContainerLink
ChrisChV Jul 30, 2025
84ac4ba
Merge branch 'master' into chris/FAL-4231-top-level-parent
ChrisChV Jul 30, 2025
34cd5a4
fix: Bug with creating blocks on every sync
ChrisChV Jul 30, 2025
2964783
test: Add tests to verify the fix of the bug of creating blocks on ev…
ChrisChV Jul 30, 2025
9c4192c
style: Fix broken lint
ChrisChV Jul 30, 2025
e77a67f
refactor: Set use_top_level_parents as special filter separated from …
ChrisChV Aug 4, 2025
633e050
refactor: top_level_parent to ForeignKey of ContainerLink
ChrisChV Aug 4, 2025
49e1989
refactor: Use definition of the block instead of the full key in top-…
ChrisChV Aug 5, 2025
1d4ac9e
style: Fix broken lin
ChrisChV Aug 5, 2025
4b6855a
Merge branch 'master' into chris/FAL-4231-top-level-parent
ChrisChV Aug 5, 2025
96d1469
Merge branch 'master' into chris/FAL-4231-top-level-parent
ChrisChV Aug 8, 2025
c664a7a
refactor: Use BlockType in top_level_downstream_parent_key
ChrisChV Aug 8, 2025
1baf99b
style: Fix broken lint
ChrisChV Aug 8, 2025
6cc840a
refactor: Verify creation of top-level parents
ChrisChV Aug 13, 2025
d51aaee
Merge branch 'master' into chris/FAL-4231-top-level-parent
ChrisChV Aug 13, 2025
5d806b0
style: Update comment
ChrisChV Aug 14, 2025
eca0334
Merge branch 'master' into chris/FAL-4231-top-level-parent
ChrisChV Aug 14, 2025
a68379c
Merge branch 'master' into chris/FAL-4231-top-level-parent
ChrisChV Aug 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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'),
),
]
124 changes: 112 additions & 12 deletions cms/djangoapps/contentstore/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -24,6 +24,9 @@
)


logger = logging.getLogger(__name__)


class VideoUploadConfig(ConfigurationModel):
"""
Configuration for the video upload feature.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand Down Expand Up @@ -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":
Expand All @@ -229,13 +269,23 @@ 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,
'downstream_usage_key': downstream_usage_key,
'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
Expand Down Expand Up @@ -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),
Expand All @@ -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:
Expand Down Expand Up @@ -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":
Expand All @@ -381,13 +467,23 @@ 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,
'downstream_usage_key': downstream_usage_key,
'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
Expand All @@ -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):
"""
Expand Down
14 changes: 12 additions & 2 deletions cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down
35 changes: 34 additions & 1 deletion cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,16 +168,25 @@ 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):
"""
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()

Expand All @@ -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)
Expand Down Expand Up @@ -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':
Expand All @@ -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):
Expand Down
Loading
Loading