From faad8bf02051493f94ec023b46627f0ac73438ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Ch=C3=A1vez?= Date: Thu, 24 Jul 2025 14:11:18 -0500 Subject: [PATCH] feat: New `DownstreamListView` to get all components and container links [FC-0097] (#37024) There are two different views and entry points for components and containers, which have the same logic and filters. In this PR, a single view has been created that allows you to get all links or filter them by component or container. * Rename `DownstreamComponentsListView` and mark it as deprecated. * Mark `DownstreamContainerListView` as deprecated. * Update `DownstreamSummaryView` to support container on the summary. * Add `show` param in `get_course_outline_url` --- .../rest_api/v2/serializers/__init__.py | 2 + .../rest_api/v2/serializers/downstreams.py | 26 +- .../contentstore/rest_api/v2/urls.py | 10 +- .../rest_api/v2/views/downstreams.py | 136 +++++++- .../v2/views/tests/test_downstreams.py | 314 +++++++++++++++++- cms/djangoapps/contentstore/utils.py | 4 +- cms/djangoapps/contentstore/views/course.py | 3 +- 7 files changed, 476 insertions(+), 19 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py index 98017ea86f..4a48fd6395 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py @@ -4,12 +4,14 @@ from cms.djangoapps.contentstore.rest_api.v2.serializers.downstreams import ( ComponentLinksSerializer, ContainerLinksSerializer, PublishableEntityLinksSummarySerializer, + PublishableEntityLinkSerializer ) from cms.djangoapps.contentstore.rest_api.v2.serializers.home import CourseHomeTabSerializerV2 __all__ = [ 'CourseHomeTabSerializerV2', 'ComponentLinksSerializer', + 'PublishableEntityLinkSerializer', 'ContainerLinksSerializer', 'PublishableEntityLinksSummarySerializer', ] diff --git a/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py index 5ec27cc176..4024cad8ff 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py @@ -9,7 +9,7 @@ from cms.djangoapps.contentstore.models import ComponentLink, ContainerLink class ComponentLinksSerializer(serializers.ModelSerializer): """ - Serializer for publishable entity links. + Serializer for publishable component entity links. """ upstream_context_title = serializers.CharField(read_only=True) upstream_version = serializers.IntegerField(read_only=True, source="upstream_version_num") @@ -42,3 +42,27 @@ class ContainerLinksSerializer(serializers.ModelSerializer): class Meta: model = ContainerLink exclude = ['upstream_container', 'uuid'] + + +class PublishableEntityLinkSerializer(serializers.Serializer): + """ + Serializer for publishable component or container entity links. + """ + upstream_key = serializers.CharField(read_only=True) + upstream_type = serializers.ChoiceField(read_only=True, choices=['component', 'container']) + + def to_representation(self, instance): + if isinstance(instance, ComponentLink): + data = ComponentLinksSerializer(instance).data + data['upstream_key'] = data.get('upstream_usage_key') + data['upstream_type'] = 'component' + del data['upstream_usage_key'] + elif isinstance(instance, ContainerLink): + data = ContainerLinksSerializer(instance).data + data['upstream_key'] = data.get('upstream_container_key') + data['upstream_type'] = 'container' + del data['upstream_container_key'] + else: + raise Exception("Unexpected type") + + return data diff --git a/cms/djangoapps/contentstore/rest_api/v2/urls.py b/cms/djangoapps/contentstore/rest_api/v2/urls.py index 1d22cb021b..ce2a78c0e2 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v2/urls.py @@ -13,11 +13,19 @@ urlpatterns = [ home.HomePageCoursesViewV2.as_view(), name="courses", ), + # TODO: Rename this to `downstreams/` after full deprecate `DownstreamComponentsListView` + re_path( + r'^downstreams-all/$', + downstreams.DownstreamListView.as_view(), + name="downstreams_list_all", + ), + # [DEPRECATED], use `downstreams-all/` instead. re_path( r'^downstreams/$', - downstreams.DownstreamListView.as_view(), + downstreams.DownstreamComponentsListView.as_view(), name="downstreams_list", ), + # [DEPRECATED], use `downstreams-all/` instead. re_path( r'^downstream-containers/$', downstreams.DownstreamContainerListView.as_view(), diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py index 53735ec7c4..f85b68bd26 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py @@ -81,22 +81,26 @@ UpstreamLink response schema: """ import logging +import warnings from attrs import asdict as attrs_asdict +from django.db.models import QuerySet from django.contrib.auth.models import User # pylint: disable=imported-auth-user from edx_rest_framework_extensions.paginators import DefaultPagination from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey -from opaque_keys.edx.locator import LibraryUsageLocatorV2, LibraryContainerLocator -from rest_framework.exceptions import NotFound, ValidationError +from opaque_keys.edx.locator import LibraryUsageLocatorV2, LibraryContainerLocator, LibraryLocatorV2 +from rest_framework.exceptions import NotFound, ValidationError, PermissionDenied from rest_framework.fields import BooleanField from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from itertools import chain from xblock.core import XBlock -from cms.djangoapps.contentstore.models import ComponentLink, ContainerLink +from cms.djangoapps.contentstore.models import ComponentLink, ContainerLink, EntityLinkBase from cms.djangoapps.contentstore.rest_api.v2.serializers import ( + PublishableEntityLinkSerializer, ComponentLinksSerializer, ContainerLinksSerializer, PublishableEntityLinksSummarySerializer, @@ -118,6 +122,7 @@ from openedx.core.lib.api.view_utils import ( DeveloperErrorViewMixin, view_auth_classes, ) +from openedx.core.djangoapps.content_libraries import api as lib_api from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.video_block.transcripts_utils import clear_transcripts @@ -161,7 +166,8 @@ class DownstreamListPaginator(DefaultPagination): @view_auth_classes() class DownstreamListView(DeveloperErrorViewMixin, APIView): """ - List all blocks which are linked to an upstream context, with optional filtering. + [ 🛑 UNSTABLE ] + List all items (components and containers) wich are linked to an upstream context, with optional filtering. """ def get(self, request: _AuthenticatedRequest): @@ -170,6 +176,91 @@ class DownstreamListView(DeveloperErrorViewMixin, APIView): """ course_key_string = request.GET.get('course_id') ready_to_sync = request.GET.get('ready_to_sync') + upstream_key = request.GET.get('upstream_key') + item_type = request.GET.get('item_type') + link_filter: dict[str, CourseKey | UsageKey | LibraryContainerLocator | bool] = {} + paginator = DownstreamListPaginator() + + if course_key_string is None and upstream_key is None and not request.user.is_superuser: + # This case without course or upstream filter means that the user need permissions to + # multiple courses/libraries, so raise `PermissionDenied` if the user is not superuser. + raise PermissionDenied + + if course_key_string: + try: + course_key = CourseKey.from_string(course_key_string) + link_filter["downstream_context_key"] = course_key + except InvalidKeyError as exc: + raise ValidationError(detail=f"Malformed course key: {course_key_string}") from exc + + if not has_studio_read_access(request.user, course_key): + raise PermissionDenied + if ready_to_sync is not None: + link_filter["ready_to_sync"] = BooleanField().to_internal_value(ready_to_sync) + if upstream_key: + try: + upstream_usage_key = UsageKey.from_string(upstream_key) + link_filter["upstream_usage_key"] = upstream_usage_key + + # Verify that the user has permission to view the library that contains + # the upstream component + lib_api.require_permission_for_library_key( + LibraryLocatorV2.from_string(str(upstream_usage_key.context_key)), + request.user, + permission=lib_api.permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, + ) + # At this point we just need to bring components + item_type = 'components' + except InvalidKeyError: + try: + upstream_container_key = LibraryContainerLocator.from_string(upstream_key) + link_filter["upstream_container_key"] = upstream_container_key + # Verify that the user has permission to view the library that contains + # the upstream container + lib_api.require_permission_for_library_key( + upstream_container_key.lib_key, + request.user, + permission=lib_api.permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, + ) + # At this point we just need to bring containers + item_type = 'containers' + except InvalidKeyError as exc: + raise ValidationError(detail=f"Malformed key: {upstream_key}") from exc + links: list[EntityLinkBase] | QuerySet[EntityLinkBase] = [] + if item_type is None or item_type == 'all': + links = list(chain( + ComponentLink.filter_links(**link_filter), + ContainerLink.filter_links(**link_filter) + )) + elif item_type == 'components': + links = ComponentLink.filter_links(**link_filter) + elif item_type == 'containers': + links = ContainerLink.filter_links(**link_filter) + paginated_links = paginator.paginate_queryset(links, self.request, view=self) + serializer = PublishableEntityLinkSerializer(paginated_links, many=True) + return paginator.get_paginated_response(serializer.data, self.request) + + +@view_auth_classes() +class DownstreamComponentsListView(DeveloperErrorViewMixin, APIView): + """ + [DEPRECATED], use DownstreamListView instead. + + List all components which are linked to an upstream context, with optional filtering. + """ + + def get(self, request: _AuthenticatedRequest): + """ + [DEPRECATED], use DownstreamListView.get instead, with `item_type='components'` + + Fetches publishable entity links for given course key + """ + warnings.warn( + '`downstreams/` API is deprecated. Please use `downstreams-all/?item_type=components` instead.', + DeprecationWarning, stacklevel=3, + ) + course_key_string = request.GET.get('course_id') + ready_to_sync = request.GET.get('ready_to_sync') upstream_usage_key = request.GET.get('upstream_usage_key') link_filter: dict[str, CourseKey | UsageKey | bool] = {} paginator = DownstreamListPaginator() @@ -194,6 +285,7 @@ class DownstreamListView(DeveloperErrorViewMixin, APIView): @view_auth_classes() class DownstreamSummaryView(DeveloperErrorViewMixin, APIView): """ + [ 🛑 UNSTABLE ] Serves course->library publishable entity links summary """ def get(self, request: _AuthenticatedRequest, course_key_string: str): @@ -221,7 +313,31 @@ class DownstreamSummaryView(DeveloperErrorViewMixin, APIView): course_key = CourseKey.from_string(course_key_string) except InvalidKeyError as exc: raise ValidationError(detail=f"Malformed course key: {course_key_string}") from exc - links = ComponentLink.summarize_by_downstream_context(downstream_context_key=course_key) + 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) return Response(serializer.data) @@ -229,6 +345,7 @@ class DownstreamSummaryView(DeveloperErrorViewMixin, APIView): @view_auth_classes(is_authenticated=True) class DownstreamView(DeveloperErrorViewMixin, APIView): """ + [ 🛑 UNSTABLE ] Inspect or manage an XBlock's link to upstream content. """ def get(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response: @@ -313,6 +430,7 @@ class DownstreamView(DeveloperErrorViewMixin, APIView): @view_auth_classes(is_authenticated=True) class SyncFromUpstreamView(DeveloperErrorViewMixin, APIView): """ + [ 🛑 UNSTABLE ] Accept or decline an opportunity to sync a downstream block from its upstream content. """ @@ -368,13 +486,21 @@ class SyncFromUpstreamView(DeveloperErrorViewMixin, APIView): @view_auth_classes() class DownstreamContainerListView(DeveloperErrorViewMixin, APIView): """ + [DEPRECATED], use DownstreamListView instead. + List all container blocks which are linked to an upstream context, with optional filtering. """ def get(self, request: _AuthenticatedRequest): """ + [DEPRECATED], use DownstreamListView.get instead, with `item_type='containers'` + Fetches publishable container entity links for given course key """ + warnings.warn( + '`downstreams/` API is deprecated. Please use `downstreams-all/?item_type=components` instead.', + DeprecationWarning, stacklevel=3, + ) course_key_string = request.GET.get('course_id') ready_to_sync = request.GET.get('ready_to_sync') upstream_container_key = request.GET.get('upstream_container_key') 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 9c7de63000..0b62b5ba1a 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 @@ -2,6 +2,7 @@ Unit tests for /api/contentstore/v2/downstreams/* JSON APIs. """ import json +import ddt from datetime import datetime, timezone from unittest.mock import patch, MagicMock @@ -15,10 +16,14 @@ from cms.lib.xblock.upstream_sync import BadUpstream, UpstreamLink from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.xblock_storage_handlers import view_handlers as xblock_view_handlers from opaque_keys.edx.keys import ContainerKey, UsageKey +from opaque_keys.edx.locator import LibraryLocatorV2 from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.student.auth import add_users +from common.djangoapps.student.roles import CourseStaffRole from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory +from openedx.core.djangoapps.content_libraries import api as lib_api from .. import downstreams as downstreams_views @@ -56,6 +61,7 @@ class _BaseDownstreamViewTestMixin: """ Create a simple course with one unit and two videos, one of which is linked to an "upstream". """ + # pylint: disable=too-many-statements super().setUp() self.now = datetime.now(timezone.utc) freezer = freeze_time(self.now) @@ -68,6 +74,9 @@ class _BaseDownstreamViewTestMixin: defaults={"name": "Content Libraries Tachyon Exploration & Survey Team"}, ) self.superuser = UserFactory(username="superuser", password="password", is_staff=True, is_superuser=True) + self.simple_user = UserFactory(username="simple_user", password="password") + self.course_user = UserFactory(username="course_user", password="password") + self.lib_user = UserFactory(username="lib_user", password="password") self.client.login(username=self.superuser.username, password="password") self.library_title = "Test Library 1" @@ -76,6 +85,8 @@ class _BaseDownstreamViewTestMixin: title=self.library_title, description="Testing XBlocks" )["id"] + self.library_key = LibraryLocatorV2.from_string(self.library_id) + lib_api.set_library_user_permissions(self.library_key, self.lib_user, access_level="read") self.html_lib_id = self._add_block_to_library(self.library_id, "html", "html-baz")["id"] self.video_lib_id = self._add_block_to_library(self.library_id, "video", "video-baz")["id"] self.unit_id = self._create_container(self.library_id, "unit", "unit-1", "Unit 1")["id"] @@ -88,6 +99,7 @@ class _BaseDownstreamViewTestMixin: self._publish_container(self.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) chapter = BlockFactory.create(category='chapter', parent=self.course) sequential = BlockFactory.create(category='sequential', parent=chapter) unit = BlockFactory.create(category='vertical', parent=sequential) @@ -215,7 +227,7 @@ class SharedErrorTestCases(_BaseDownstreamViewTestMixin): assert "not found" in response.data["developer_message"] -class GetDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase): +class GetComponentDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase): """ Test that `GET /api/v2/contentstore/downstreams/...` inspects a downstream's link to an upstream. """ @@ -471,12 +483,294 @@ class DeleteDownstreamSyncViewtest( assert mock_decline_sync.call_count == 1 +@ddt.ddt class GetUpstreamViewTest( _BaseDownstreamViewTestMixin, SharedModuleStoreTestCase, ): """ - Test that `GET /api/v2/contentstore/downstreams?...` returns list of links based on the provided filter. + Test that `GET /api/v2/contentstore/downstreams-all?...` 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_key: str | None = None, + item_type: 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_key is not None: + data["upstream_key"] = str(upstream_key) + if item_type is not None: + data["item_type"] = str(item_type) + return self.client.get("/api/contentstore/v2/downstreams-all/", data=data) + + def test_200_all_downstreams_for_a_course(self): + """ + Returns all links for given course + """ + self.client.login(username="course_user", 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_video_key), + 'id': 1, + '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, + 'upstream_type': 'component', + '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_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, + }, + { + '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_key': self.section_id, + 'upstream_type': 'container', + '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_key': self.subsection_id, + 'upstream_type': 'container', + '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_key': self.unit_id, + 'upstream_type': 'container', + 'upstream_version': 2, + 'version_declined': None, + 'version_synced': 1 + }, + ] + self.assertListEqual(data["results"], expected) + self.assertEqual(data["count"], 5) + + def test_permission_denied_with_course_filter(self): + self.client.login(username="simple_user", password="password") + response = self.call_api(course_id=self.course.id) + assert response.status_code == 403 + + def test_200_component_downstreams_for_a_course(self): + """ + Returns all component links for given course + """ + self.client.login(username="course_user", password="password") + response = self.call_api( + course_id=self.course.id, + item_type='components', + ) + 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_video_key), + 'id': 1, + '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, + 'upstream_type': 'component', + '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_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, + }, + ] + self.assertListEqual(data["results"], expected) + self.assertEqual(data["count"], 2) + + def test_200_container_downstreams_for_a_course(self): + """ + Returns all container links for given course + """ + self.client.login(username="course_user", password="password") + response = self.call_api( + course_id=self.course.id, + item_type='containers', + ) + 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_key': self.section_id, + 'upstream_type': 'container', + '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_key': self.subsection_id, + 'upstream_type': 'container', + '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_key': self.unit_id, + 'upstream_type': 'container', + 'upstream_version': 2, + 'version_declined': None, + 'version_synced': 1 + }, + ] + self.assertListEqual(data["results"], expected) + self.assertEqual(data["count"], 3) + + @ddt.data( + ('all', 2), + ('components', 1), + ('containers', 1), + ) + @ddt.unpack + def test_200_downstreams_ready_to_sync(self, item_type, expected_count): + """ + Returns all links that are syncable + """ + self.client.login(username="superuser", password="password") + response = self.call_api( + ready_to_sync=True, + item_type=item_type, + ) + assert response.status_code == 200 + data = response.json() + self.assertTrue(all(o["ready_to_sync"] for o in data["results"])) + self.assertEqual(data["count"], expected_count) + + def test_permission_denied_without_filter(self): + self.client.login(username="simple_user", password="password") + response = self.call_api() + assert response.status_code == 403 + + def test_200_component_downstream_context_list(self): + """ + Returns all entity downstream links for given component + """ + self.client.login(username="lib_user", password="password") + response = self.call_api(upstream_key=self.video_lib_id) + 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) + + def test_200_container_downstream_context_list(self): + """ + Returns all entity downstream links for given container + """ + self.client.login(username="lib_user", password="password") + response = self.call_api(upstream_key=self.unit_id) + assert response.status_code == 200 + data = response.json() + expected = [str(self.downstream_unit_key)] + got = [str(o["downstream_usage_key"]) for o in data["results"]] + 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, @@ -493,9 +787,9 @@ class GetUpstreamViewTest( data["upstream_usage_key"] = str(upstream_usage_key) return self.client.get("/api/contentstore/v2/downstreams/", data=data) - def test_200_all_downstreams_for_a_course(self): + def test_200_all_component_downstreams_for_a_course(self): """ - Returns all links for given course + Returns all component links for given course """ self.client.login(username="superuser", password="password") response = self.call_api(course_id=self.course.id) @@ -535,9 +829,9 @@ class GetUpstreamViewTest( self.assertListEqual(data["results"], expected) self.assertEqual(data["count"], 2) - def test_200_all_downstreams_ready_to_sync(self): + def test_200_all_component_downstreams_ready_to_sync(self): """ - Returns all links that are syncable + Returns all component links that are syncable """ self.client.login(username="superuser", password="password") response = self.call_api(ready_to_sync=True) @@ -546,9 +840,9 @@ class GetUpstreamViewTest( self.assertTrue(all(o["ready_to_sync"] for o in data["results"])) self.assertEqual(data["count"], 1) - def test_200_downstream_context_list(self): + def test_200_component_downstream_context_list(self): """ - Returns all downstream courses for given library block + Returns all component downstream courses for given library block """ self.client.login(username="superuser", password="password") response = self.call_api(upstream_usage_key=self.video_lib_id) @@ -593,8 +887,8 @@ class GetDownstreamSummaryViewTest( expected = [{ 'upstream_context_title': 'Test Library 1', 'upstream_context_key': self.library_id, - 'ready_to_sync_count': 1, - 'total_count': 2, + 'ready_to_sync_count': 2, + 'total_count': 5, 'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), }] self.assertListEqual(data, expected) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 915d2272eb..c4049a818f 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -436,7 +436,7 @@ def get_video_uploads_url(course_locator) -> str: return video_uploads_url -def get_course_outline_url(course_locator) -> str: +def get_course_outline_url(course_locator, block_to_show=None) -> str: """ Gets course authoring microfrontend URL for course oultine page view. """ @@ -444,6 +444,8 @@ def get_course_outline_url(course_locator) -> str: if use_new_course_outline_page(course_locator): mfe_base_url = get_course_authoring_url(course_locator) course_mfe_url = f'{mfe_base_url}/course/{course_locator}' + if block_to_show: + course_mfe_url += f'?show={quote_plus(block_to_show)}' if mfe_base_url: course_outline_url = course_mfe_url return course_outline_url diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 3eb105742d..ffb93ed010 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -736,7 +736,8 @@ def course_index(request, course_key): org, course, name: Attributes of the Location for the item to edit """ if use_new_course_outline_page(course_key): - return redirect(get_course_outline_url(course_key)) + block_to_show = request.GET.get("show") + return redirect(get_course_outline_url(course_key, block_to_show)) with modulestore().bulk_operations(course_key): # A depth of None implies the whole course. The course outline needs this in order to compute has_changes. # A unit may not have a draft version, but one of its components could, and hence the unit itself has changes.