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:
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user