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`
This commit is contained in:
Chris Chávez
2025-07-24 14:11:18 -05:00
committed by GitHub
parent cb6df9eee3
commit faad8bf020
7 changed files with 476 additions and 19 deletions

View File

@@ -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',
]

View File

@@ -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

View File

@@ -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(),

View File

@@ -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')

View File

@@ -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)

View File

@@ -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

View File

@@ -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.