diff --git a/cms/djangoapps/contentstore/models.py b/cms/djangoapps/contentstore/models.py index f641fbee1f7f..fc7bec1dc3f9 100644 --- a/cms/djangoapps/contentstore/models.py +++ b/cms/djangoapps/contentstore/models.py @@ -7,8 +7,8 @@ from config_models.models import ConfigurationModel from django.db import models -from django.db.models import Count, F, Q, QuerySet, Max -from django.db.models.fields import IntegerField, TextField +from django.db.models import QuerySet, OuterRef, Case, When, Exists, Value, ExpressionWrapper +from django.db.models.fields import IntegerField, TextField, BooleanField from django.db.models.functions import Coalesce from django.db.models.lookups import GreaterThan from django.utils.translation import gettext_lazy as _ @@ -111,6 +111,20 @@ class EntityLinkBase(models.Model): created = manual_date_time_field() updated = manual_date_time_field() + @property + def upstream_context_title(self) -> str: + """ + Returns upstream context title. + """ + raise NotImplementedError + + @property + def published_at(self) -> str | None: + """ + Returns the published date of the entity + """ + raise NotImplementedError + class Meta: abstract = True @@ -157,6 +171,15 @@ def upstream_context_title(self) -> str: """ return self.upstream_block.publishable_entity.learning_package.title + @property + def published_at(self) -> str | None: + """ + Returns the published date of the component + """ + if self.upstream_block.publishable_entity.published is None: + raise AttributeError(_("The component must be published to access `published_at`")) + return self.upstream_block.publishable_entity.published.publish_log_record.publish_log.published_at + @classmethod def filter_links( cls, @@ -189,7 +212,9 @@ def filter_links( Coalesce("upstream_block__publishable_entity__published__version__version_num", 0), Coalesce("version_declined", 0) ) - ) + ), + # This is alwys False, the components doens't have children + ready_to_sync_from_children=Value(False, output_field=BooleanField()) ) if ready_to_sync is not None: result = result.filter(ready_to_sync=ready_to_sync) @@ -216,40 +241,6 @@ def filter_links( return result - @classmethod - def summarize_by_downstream_context(cls, downstream_context_key: CourseKey) -> QuerySet: - """ - Returns a summary of links by upstream context for given downstream_context_key. - Example: - [ - { - "upstream_context_title": "CS problems 3", - "upstream_context_key": "lib:OpenedX:CSPROB3", - "ready_to_sync_count": 11, - "total_count": 14, - "last_published_at": "2025-05-02T20:20:44.989042Z" - }, - { - "upstream_context_title": "CS problems 2", - "upstream_context_key": "lib:OpenedX:CSPROB2", - "ready_to_sync_count": 15, - "total_count": 24, - "last_published_at": "2025-05-03T21:20:44.989042Z" - }, - ] - """ - result = cls.filter_links(downstream_context_key=downstream_context_key).values( - "upstream_context_key", - upstream_context_title=F("upstream_block__publishable_entity__learning_package__title"), - ).annotate( - ready_to_sync_count=Count("id", Q(ready_to_sync=True)), - total_count=Count("id"), - last_published_at=Max( - "upstream_block__publishable_entity__published__publish_log_record__publish_log__published_at" - ) - ) - return result - @classmethod def update_or_create( cls, @@ -351,6 +342,15 @@ def upstream_context_title(self) -> str: """ return self.upstream_container.publishable_entity.learning_package.title + @property + def published_at(self) -> str | None: + """ + Returns the published date of the container + """ + if self.upstream_container.publishable_entity.published is None: + raise AttributeError(_("The container must be published to access `published_at`")) + return self.upstream_container.publishable_entity.published.publish_log_record.publish_log.published_at + @classmethod def filter_links( cls, @@ -402,6 +402,54 @@ def filter_links( @classmethod def _annotate_query_with_ready_to_sync(cls, query_set: QuerySet["EntityLinkBase"]) -> QuerySet["EntityLinkBase"]: + """ + Adds ready to sync related values to the query set: + * `ready_to_sync`: When the container is ready to sync. + * `ready_to_sync_from_children`: When any children is ready to sync. + """ + # SubQuery to verify if some container children (associated with top-level parent) + # needs sync. + subq_container = cls.objects.filter( + top_level_parent=OuterRef('pk') + ).annotate( + child_ready=Case( + When( + GreaterThan( + Coalesce("upstream_container__publishable_entity__published__version__version_num", 0), + Coalesce("version_synced", 0) + ) & GreaterThan( + Coalesce("upstream_container__publishable_entity__published__version__version_num", 0), + Coalesce("version_declined", 0) + ), + then=1 + ), + default=0, + output_field=models.IntegerField() + ) + ).filter(child_ready=1) + + # SubQuery to verify if some component children (assisiated with top-level parent) + # needs sync. + subq_components = ComponentLink.objects.filter( + top_level_parent=OuterRef('pk') + ).annotate( + child_ready=Case( + When( + GreaterThan( + Coalesce("upstream_block__publishable_entity__published__version__version_num", 0), + Coalesce("version_synced", 0) + ) & GreaterThan( + Coalesce("upstream_block__publishable_entity__published__version__version_num", 0), + Coalesce("version_declined", 0) + ), + then=1 + ), + default=0, + output_field=models.IntegerField() + ) + ).filter(child_ready=1) + + # TODO: is there a way to run `subq_container` or `subq_components` depending on the container type? return query_set.annotate( ready_to_sync=( GreaterThan( @@ -411,42 +459,12 @@ def _annotate_query_with_ready_to_sync(cls, query_set: QuerySet["EntityLinkBase" Coalesce("upstream_container__publishable_entity__published__version__version_num", 0), Coalesce("version_declined", 0) ) - ) - ) - - @classmethod - def summarize_by_downstream_context(cls, downstream_context_key: CourseKey) -> QuerySet: - """ - Returns a summary of links by upstream context for given downstream_context_key. - Example: - [ - { - "upstream_context_title": "CS problems 3", - "upstream_context_key": "lib:OpenedX:CSPROB3", - "ready_to_sync_count": 11, - "total_count": 14, - "last_published_at": "2025-05-02T20:20:44.989042Z" - }, - { - "upstream_context_title": "CS problems 2", - "upstream_context_key": "lib:OpenedX:CSPROB2", - "ready_to_sync_count": 15, - "total_count": 24, - "last_published_at": "2025-05-03T21:20:44.989042Z" - }, - ] - """ - result = cls.filter_links(downstream_context_key=downstream_context_key).values( - "upstream_context_key", - upstream_context_title=F("upstream_container__publishable_entity__learning_package__title"), - ).annotate( - ready_to_sync_count=Count("id", Q(ready_to_sync=True)), - total_count=Count('id'), - last_published_at=Max( - "upstream_container__publishable_entity__published__publish_log_record__publish_log__published_at" - ) + ), + ready_to_sync_from_children=ExpressionWrapper( + Exists(subq_container) | Exists(subq_components), + output_field=BooleanField(), + ), ) - return result @classmethod def update_or_create( diff --git a/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py index d7143a4f14bc..b6bb234ec12a 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py @@ -14,6 +14,7 @@ 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() + ready_to_sync_from_children = serializers.BooleanField() top_level_parent_usage_key = serializers.CharField( source='top_level_parent.downstream_usage_key', read_only=True, @@ -43,6 +44,7 @@ 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() + ready_to_sync_from_children = serializers.BooleanField() top_level_parent_usage_key = serializers.CharField( source='top_level_parent.downstream_usage_key', read_only=True, diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py index f4ddbc7abe7f..b08a222ff351 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py @@ -239,6 +239,8 @@ def get(self, request: _AuthenticatedRequest): raise ValidationError(detail=f"Malformed key: {upstream_key}") from exc links: list[EntityLinkBase] | QuerySet[EntityLinkBase] = [] if item_type is None or item_type == 'all': + # itertools.chain() efficiently concatenates multiple iterables into one iterator, + # yielding items from each in sequence without creating intermediate lists. links = list(chain( ComponentLink.filter_links(**link_filter), ContainerLink.filter_links(**link_filter) @@ -346,34 +348,84 @@ def get(self, request: _AuthenticatedRequest, course_key_string: str): course_key = CourseKey.from_string(course_key_string) except InvalidKeyError as exc: raise ValidationError(detail=f"Malformed course key: {course_key_string}") from exc - component_links = ComponentLink.summarize_by_downstream_context(downstream_context_key=course_key) - container_links = ContainerLink.summarize_by_downstream_context(downstream_context_key=course_key) - - merged = {} - - def process_list(lst): - """ - Process a list to merge it with values in `merged` - """ - for item in lst: - key = item["upstream_context_key"] - if key not in merged: - merged[key] = item.copy() - else: - merged[key]["ready_to_sync_count"] += item["ready_to_sync_count"] - merged[key]["total_count"] += item["total_count"] - if item["last_published_at"] > merged[key]["last_published_at"]: - merged[key]["last_published_at"] = item["last_published_at"] - - # Merge `component_links` and `container_links` by adding the values of - # `ready_to_sync_count` and `total_count` of each library. - process_list(component_links) - process_list(container_links) - - links = list(merged.values()) - serializer = PublishableEntityLinksSummarySerializer(links, many=True) + + if not has_studio_read_access(request.user, course_key): + raise PermissionDenied + + # Gets all links of the Course, using the + # top-level parents filter (see `filter_links()` for more info about top-level parents). + # `itertools.chain()` efficiently concatenates multiple iterables into one iterator, + # yielding items from each in sequence without creating intermediate lists. + links = list(chain( + ComponentLink.filter_links( + downstream_context_key=course_key, + use_top_level_parents=True, + ), + ContainerLink.filter_links( + downstream_context_key=course_key, + use_top_level_parents=True, + ), + )) + + # 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) + result = {} + + for link in links: + # We iterate each list to do the counting by Library (`context_key`) + context_key = link.upstream_context_key + + if context_key not in result: + result[context_key] = { + "upstream_context_key": context_key, + "upstream_context_title": link.upstream_context_title, + "ready_to_sync_count": 0, + "total_count": 0, + "last_published_at": None, + } + + # Total count + result[context_key]["total_count"] += 1 + + # Ready to sync count, it also checks if the container has + # descendants that need sync (`ready_to_sync_from_children`). + if link.ready_to_sync or link.ready_to_sync_from_children: # type: ignore[attr-defined] + result[context_key]["ready_to_sync_count"] += 1 + + # The Max `published_at` value + # An AttributeError may be thrown if copied/pasted an unpublished item from library to course. + # That case breaks all the course library sync page. + # TODO: Delete this `try` after avoid copy/paster unpublished items. + try: + published_at = link.published_at + except AttributeError: + published_at = None + if published_at is not None and ( + result[context_key]["last_published_at"] is None + or result[context_key]["last_published_at"] < published_at + ): + result[context_key]["last_published_at"] = published_at + + serializer = PublishableEntityLinksSummarySerializer(list(result.values()), many=True) return Response(serializer.data) + 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(is_authenticated=True) class DownstreamView(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 64d8b4ab5cdd..b5741b4bb858 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 @@ -340,6 +340,7 @@ def test_unit_sync(self): 'upstream_context_title': self.library_title, 'upstream_version': 2, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'upstream_context_key': self.library_id, 'downstream_usage_key': downstream_html1, 'downstream_context_key': str(self.course.id), @@ -356,6 +357,7 @@ def test_unit_sync(self): 'upstream_context_title': self.library_title, 'upstream_version': 2, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'upstream_context_key': self.library_id, 'downstream_usage_key': downstream_problem1, 'downstream_context_key': str(self.course.id), @@ -372,6 +374,7 @@ def test_unit_sync(self): 'upstream_context_title': self.library_title, 'upstream_version': 2, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'upstream_context_key': self.library_id, 'downstream_usage_key': downstream_problem2, 'downstream_context_key': str(self.course.id), @@ -388,6 +391,7 @@ def test_unit_sync(self): 'upstream_context_title': self.library_title, 'upstream_version': 2, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'upstream_context_key': self.library_id, 'downstream_usage_key': downstream_unit["locator"], 'downstream_context_key': str(self.course.id), @@ -484,6 +488,7 @@ def test_unit_sync(self): 'upstream_context_title': self.library_title, 'upstream_version': 2, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'upstream_context_key': self.library_id, 'downstream_usage_key': downstream_html1, 'downstream_context_key': str(self.course.id), @@ -500,6 +505,7 @@ def test_unit_sync(self): 'upstream_context_title': self.library_title, 'upstream_version': 3, # <--- updated 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'upstream_context_key': self.library_id, 'downstream_usage_key': downstream_problem1, 'downstream_context_key': str(self.course.id), @@ -516,6 +522,7 @@ def test_unit_sync(self): 'upstream_context_title': self.library_title, 'upstream_version': 2, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'upstream_context_key': self.library_id, 'downstream_usage_key': downstream_problem2, 'downstream_context_key': str(self.course.id), @@ -532,6 +539,7 @@ def test_unit_sync(self): 'upstream_context_title': self.library_title, 'upstream_version': 2, # <--- not updated since we didn't directly modify the unit 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'upstream_context_key': self.library_id, 'downstream_usage_key': downstream_unit["locator"], 'downstream_context_key': str(self.course.id), @@ -626,6 +634,7 @@ def test_unit_sync(self): 'upstream_context_title': self.library_title, 'upstream_version': 2, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'upstream_context_key': self.library_id, 'downstream_usage_key': downstream_html1, 'downstream_context_key': str(self.course.id), @@ -642,6 +651,7 @@ def test_unit_sync(self): 'upstream_context_title': self.library_title, 'upstream_version': 3, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'upstream_context_key': self.library_id, 'downstream_usage_key': downstream_problem1, 'downstream_context_key': str(self.course.id), @@ -658,6 +668,7 @@ def test_unit_sync(self): 'upstream_context_title': self.library_title, 'upstream_version': 2, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'upstream_context_key': self.library_id, 'downstream_usage_key': downstream_problem3, 'downstream_context_key': str(self.course.id), @@ -674,6 +685,7 @@ def test_unit_sync(self): 'upstream_context_title': self.library_title, 'upstream_version': 4, # <--- updated 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'upstream_context_key': self.library_id, 'downstream_usage_key': downstream_unit["locator"], 'downstream_context_key': str(self.course.id), @@ -750,6 +762,7 @@ def test_unit_sync(self): 'upstream_context_title': self.library_title, 'upstream_version': 2, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'upstream_context_key': self.library_id, 'downstream_usage_key': downstream_html1, 'downstream_context_key': str(self.course.id), @@ -766,6 +779,7 @@ def test_unit_sync(self): 'upstream_context_title': self.library_title, 'upstream_version': 3, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'upstream_context_key': self.library_id, 'downstream_usage_key': downstream_problem1, 'downstream_context_key': str(self.course.id), @@ -782,6 +796,7 @@ def test_unit_sync(self): 'upstream_context_title': self.library_title, 'upstream_version': 2, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'upstream_context_key': self.library_id, 'downstream_usage_key': downstream_problem3, 'downstream_context_key': str(self.course.id), @@ -798,6 +813,7 @@ def test_unit_sync(self): 'upstream_context_title': self.library_title, 'upstream_version': 5, # <--- updated 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'upstream_context_key': self.library_id, 'downstream_usage_key': downstream_unit["locator"], 'downstream_context_key': str(self.course.id), diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py index 5cd953aedd43..cad103c81185 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py @@ -96,6 +96,7 @@ def setUp(self): # 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_unit_id_2 = self._create_container(self.library_id, "unit", "unit-3", "Unit 3")["id"] self.top_level_subsection_id = self._create_container( self.library_id, "subsection", @@ -114,6 +115,7 @@ def setUp(self): 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_unit_id_2) 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 @@ -140,6 +142,24 @@ def setUp(self): ).usage_key # Creating Blocks with top-level-parents + # Unit created as a top-level parent + self.top_level_downstream_unit = BlockFactory.create( + category='vertical', + parent=sequential, + upstream=self.top_level_unit_id, + upstream_version=1, + ) + 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 + + # Section created as a top-level parent self.top_level_downstream_chapter = BlockFactory.create( category='chapter', parent=self.course, upstream=self.top_level_section_id, upstream_version=1, ) @@ -152,27 +172,18 @@ def setUp(self): self.top_level_downstream_chapter.usage_key, ), ) - self.top_level_downstream_unit = BlockFactory.create( + self.top_level_downstream_unit_2 = BlockFactory.create( category='vertical', parent=self.top_level_downstream_sequential, - upstream=self.top_level_unit_id, + upstream=self.top_level_unit_id_2, upstream_version=1, top_level_downstream_parent_key=get_block_key_dict( - self.top_level_downstream_sequential.usage_key, - ) + self.top_level_downstream_chapter.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, + parent=self.top_level_downstream_unit_2, upstream=self.video_lib_id_2, upstream_version=1, top_level_downstream_parent_key=get_block_key_dict( @@ -589,6 +600,7 @@ def test_200_all_downstreams_for_a_course(self): 'downstream_usage_key': str(self.downstream_video_key), 'id': 1, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -605,6 +617,7 @@ def test_200_all_downstreams_for_a_course(self): 'downstream_usage_key': str(self.downstream_html_key), 'id': 2, 'ready_to_sync': True, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -621,6 +634,7 @@ def test_200_all_downstreams_for_a_course(self): 'downstream_usage_key': str(self.top_level_downstream_html_key), 'id': 3, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -637,6 +651,7 @@ def test_200_all_downstreams_for_a_course(self): 'downstream_usage_key': str(self.top_level_downstream_video_key), 'id': 4, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -653,6 +668,7 @@ def test_200_all_downstreams_for_a_course(self): 'downstream_usage_key': str(self.downstream_chapter_key), 'id': 1, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -669,6 +685,7 @@ def test_200_all_downstreams_for_a_course(self): 'downstream_usage_key': str(self.downstream_sequential_key), 'id': 2, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -685,6 +702,7 @@ def test_200_all_downstreams_for_a_course(self): 'downstream_usage_key': str(self.downstream_unit_key), 'id': 3, 'ready_to_sync': True, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -698,9 +716,27 @@ def test_200_all_downstreams_for_a_course(self): { 'created': date_format, 'downstream_context_key': str(self.course.id), - 'downstream_usage_key': str(self.top_level_downstream_chapter.usage_key), + 'downstream_usage_key': str(self.top_level_downstream_unit.usage_key), 'id': 4, 'ready_to_sync': False, + 'ready_to_sync_from_children': 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': None, + }, + { + 'created': date_format, + 'downstream_context_key': str(self.course.id), + 'downstream_usage_key': str(self.top_level_downstream_chapter.usage_key), + 'id': 5, + 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -715,8 +751,9 @@ def test_200_all_downstreams_for_a_course(self): 'created': date_format, 'downstream_context_key': str(self.course.id), 'downstream_usage_key': str(self.top_level_downstream_sequential.usage_key), - 'id': 5, + 'id': 6, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -730,22 +767,23 @@ def test_200_all_downstreams_for_a_course(self): { 'created': date_format, 'downstream_context_key': str(self.course.id), - 'downstream_usage_key': str(self.top_level_downstream_unit.usage_key), - 'id': 6, + 'downstream_usage_key': str(self.top_level_downstream_unit_2.usage_key), + 'id': 7, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, - 'upstream_key': self.top_level_unit_id, + 'upstream_key': self.top_level_unit_id_2, '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), + 'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key), }, ] self.assertListEqual(data["results"], expected) - self.assertEqual(data["count"], 10) + self.assertEqual(data["count"], 11) def test_permission_denied_with_course_filter(self): self.client.login(username="simple_user", password="password") @@ -771,6 +809,7 @@ def test_200_component_downstreams_for_a_course(self): 'downstream_usage_key': str(self.downstream_video_key), 'id': 1, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -787,6 +826,7 @@ def test_200_component_downstreams_for_a_course(self): 'downstream_usage_key': str(self.downstream_html_key), 'id': 2, 'ready_to_sync': True, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -803,6 +843,7 @@ def test_200_component_downstreams_for_a_course(self): 'downstream_usage_key': str(self.top_level_downstream_html_key), 'id': 3, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -819,6 +860,7 @@ def test_200_component_downstreams_for_a_course(self): 'downstream_usage_key': str(self.top_level_downstream_video_key), 'id': 4, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -852,6 +894,7 @@ def test_200_container_downstreams_for_a_course(self): 'downstream_usage_key': str(self.downstream_chapter_key), 'id': 1, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -868,6 +911,7 @@ def test_200_container_downstreams_for_a_course(self): 'downstream_usage_key': str(self.downstream_sequential_key), 'id': 2, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -884,6 +928,7 @@ def test_200_container_downstreams_for_a_course(self): 'downstream_usage_key': str(self.downstream_unit_key), 'id': 3, 'ready_to_sync': True, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -897,9 +942,27 @@ def test_200_container_downstreams_for_a_course(self): { 'created': date_format, 'downstream_context_key': str(self.course.id), - 'downstream_usage_key': str(self.top_level_downstream_chapter.usage_key), + 'downstream_usage_key': str(self.top_level_downstream_unit.usage_key), 'id': 4, 'ready_to_sync': False, + 'ready_to_sync_from_children': 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': None, + }, + { + 'created': date_format, + 'downstream_context_key': str(self.course.id), + 'downstream_usage_key': str(self.top_level_downstream_chapter.usage_key), + 'id': 5, + 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -914,8 +977,9 @@ def test_200_container_downstreams_for_a_course(self): 'created': date_format, 'downstream_context_key': str(self.course.id), 'downstream_usage_key': str(self.top_level_downstream_sequential.usage_key), - 'id': 5, + 'id': 6, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -929,22 +993,23 @@ def test_200_container_downstreams_for_a_course(self): { 'created': date_format, 'downstream_context_key': str(self.course.id), - 'downstream_usage_key': str(self.top_level_downstream_unit.usage_key), - 'id': 6, + 'downstream_usage_key': str(self.top_level_downstream_unit_2.usage_key), + 'id': 7, 'ready_to_sync': False, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, - 'upstream_key': self.top_level_unit_id, + 'upstream_key': self.top_level_unit_id_2, '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), + 'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key), }, ] self.assertListEqual(data["results"], expected) - self.assertEqual(data["count"], 6) + self.assertEqual(data["count"], 7) @ddt.data( ('all', 2), @@ -1027,13 +1092,14 @@ def test_200_get_ready_to_sync_top_level_parents_with_components(self): { 'created': date_format, 'downstream_context_key': str(self.course.id), - 'downstream_usage_key': str(self.top_level_downstream_chapter.usage_key), + 'downstream_usage_key': str(self.top_level_downstream_unit.usage_key), 'id': 4, - 'ready_to_sync': False, + 'ready_to_sync': False, # <-- It's False because the container doesn't have changes + 'ready_to_sync_from_children': True, # <-- It's True because a child has changes 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, - 'upstream_key': self.top_level_section_id, + 'upstream_key': self.top_level_unit_id, 'upstream_type': 'container', 'upstream_version': 1, 'version_declined': None, @@ -1043,18 +1109,19 @@ def test_200_get_ready_to_sync_top_level_parents_with_components(self): { '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, + 'downstream_usage_key': str(self.top_level_downstream_chapter.usage_key), + 'id': 5, + 'ready_to_sync': False, # <-- It's False because the container doesn't have changes + 'ready_to_sync_from_children': True, # <-- It's True because a child has changes 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, - 'upstream_key': self.top_level_unit_id, + 'upstream_key': self.top_level_section_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), + 'top_level_parent_usage_key': None, }, { 'created': date_format, @@ -1062,6 +1129,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_components(self): 'downstream_usage_key': str(self.downstream_html_key), 'id': 2, 'ready_to_sync': True, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -1078,6 +1146,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_components(self): 'downstream_usage_key': str(self.downstream_unit_key), 'id': 3, 'ready_to_sync': True, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -1089,6 +1158,8 @@ def test_200_get_ready_to_sync_top_level_parents_with_components(self): 'top_level_parent_usage_key': None, }, ] + print(data["results"]) + print(expected) self.assertListEqual(data["results"], expected) def test_200_get_ready_to_sync_top_level_parents_with_containers(self): @@ -1121,6 +1192,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_containers(self): 'downstream_usage_key': str(self.downstream_html_key), 'id': 2, 'ready_to_sync': True, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -1137,6 +1209,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_containers(self): 'downstream_usage_key': str(self.downstream_unit_key), 'id': 3, 'ready_to_sync': True, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -1151,8 +1224,9 @@ def test_200_get_ready_to_sync_top_level_parents_with_containers(self): '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, + 'id': 5, + 'ready_to_sync': False, # <-- It's False because the container doesn't have changes + 'ready_to_sync_from_children': True, # <-- It's True because a child has changes 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -1203,8 +1277,9 @@ def test_200_get_ready_to_sync_duplicated_top_level_parents(self): '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, + 'id': 5, + 'ready_to_sync': True, # <-- It's True because the section has changes + 'ready_to_sync_from_children': True, # <-- It's True because a child has changes 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -1221,6 +1296,7 @@ def test_200_get_ready_to_sync_duplicated_top_level_parents(self): 'downstream_usage_key': str(self.downstream_html_key), 'id': 2, 'ready_to_sync': True, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -1237,6 +1313,7 @@ def test_200_get_ready_to_sync_duplicated_top_level_parents(self): 'downstream_usage_key': str(self.downstream_unit_key), 'id': 3, 'ready_to_sync': True, + 'ready_to_sync_from_children': False, 'updated': date_format, 'upstream_context_key': self.library_id, 'upstream_context_title': self.library_title, @@ -1281,11 +1358,52 @@ def test_200_summary(self): response = self.call_api(str(self.course.id)) assert response.status_code == 200 data = response.json() + + # The `total_count` is 7 because the top-level logic: + # * The `section-2`, that is the top-level parent of `subsection-2`, `unit-3`, `html-baz-2` + # * The `unit-2`, that is the top-level parent of `video-baz-2` + # * The `section-1` + # * The `subsection-1` + # * The `unit-1` + # * The `html-baz-1` + # * The `video-baz-1` expected = [{ 'upstream_context_title': 'Test Library 1', 'upstream_context_key': self.library_id, 'ready_to_sync_count': 2, - 'total_count': 10, + 'total_count': 7, + 'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + }] + self.assertListEqual(data, expected) + + # 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(str(self.course.id)) + assert response.status_code == 200 + data = response.json() + expected = [{ + 'upstream_context_title': 'Test Library 1', + 'upstream_context_key': self.library_id, + 'ready_to_sync_count': 3, # <-- + the section (top-level parent of subsection) + 'total_count': 7, + 'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + }] + self.assertListEqual(data, expected) + + # Publish Section + self._update_container(self.top_level_section_id, display_name="Section 3") + self._publish_container(self.top_level_section_id) + + response = self.call_api(str(self.course.id)) + assert response.status_code == 200 + data = response.json() + expected = [{ + 'upstream_context_title': 'Test Library 1', + 'upstream_context_key': self.library_id, + 'ready_to_sync_count': 3, # <-- is the same value because the section is the top-level parent + 'total_count': 7, 'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), }] self.assertListEqual(data, expected)