refactor: downstream entity links api [FC-0076] (#36311)

Refactors downstream links API to handle multiple filters using a single API. Also adds a new route to return summary of library links for a given course.
This commit is contained in:
Navin Karkera
2025-03-12 17:56:12 +00:00
committed by GitHub
parent 3f844b6a21
commit 711d6aa357
7 changed files with 351 additions and 131 deletions

View File

@@ -7,8 +7,10 @@ from datetime import datetime, timezone
from config_models.models import ConfigurationModel
from django.db import models
from django.db.models import QuerySet
from django.db.models import Count, F, Q, QuerySet
from django.db.models.fields import IntegerField, TextField
from django.db.models.functions import Coalesce
from django.db.models.lookups import GreaterThan
from django.utils.translation import gettext_lazy as _
from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField
from opaque_keys.edx.keys import CourseKey, UsageKey
@@ -173,7 +175,12 @@ class PublishableEntityLink(models.Model):
)
try:
link = cls.objects.get(downstream_usage_key=downstream_usage_key)
has_changes = False
# TODO: until we save modified datetime for course xblocks in index, the modified time for links are updated
# everytime a downstream/course block is updated. This allows us to order links[1] based on recently
# modified downstream version.
# pylint: disable=line-too-long
# 1. https://github.com/open-craft/frontend-app-course-authoring/blob/0443d88824095f6f65a3a64b77244af590d4edff/src/course-libraries/ReviewTabContent.tsx#L222-L233
has_changes = True # change to false once above condition is met.
for key, value in new_values.items():
prev = getattr(link, key)
# None != None is True, so we need to check for it specially
@@ -191,16 +198,31 @@ class PublishableEntityLink(models.Model):
return link
@classmethod
def get_by_downstream_context(cls, downstream_context_key: CourseKey) -> QuerySet["PublishableEntityLink"]:
def filter_links(
cls,
**link_filter,
) -> QuerySet["PublishableEntityLink"]:
"""
Get all links for given downstream context, preselects related published version and learning package.
Get all links along with sync flag, upstream context title and version, with optional filtering.
"""
return cls.objects.filter(
downstream_context_key=downstream_context_key
).select_related(
ready_to_sync = link_filter.pop('ready_to_sync', None)
result = cls.objects.filter(**link_filter).select_related(
"upstream_block__published__version",
"upstream_block__learning_package"
).annotate(
ready_to_sync=(
GreaterThan(
Coalesce("upstream_block__published__version__version_num", 0),
Coalesce("version_synced", 0)
) & GreaterThan(
Coalesce("upstream_block__published__version__version_num", 0),
Coalesce("version_declined", 0)
)
)
)
if ready_to_sync is not None:
result = result.filter(ready_to_sync=ready_to_sync)
return result
@classmethod
def get_by_upstream_usage_key(cls, upstream_usage_key: UsageKey) -> QuerySet["PublishableEntityLink"]:
@@ -211,6 +233,35 @@ class PublishableEntityLink(models.Model):
upstream_usage_key=upstream_usage_key,
)
@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
},
{
"upstream_context_title": "CS problems 2",
"upstream_context_key": "lib:OpenedX:CSPROB2",
"ready_to_sync_count": 15,
"total_count": 24
},
]
"""
result = cls.filter_links(downstream_context_key=downstream_context_key).values(
"upstream_context_key",
upstream_context_title=F("upstream_block__learning_package__title"),
).annotate(
ready_to_sync_count=Count("id", Q(ready_to_sync=True)),
total_count=Count('id')
)
return result
class LearningContextLinksStatusChoices(models.TextChoices):
"""

View File

@@ -2,6 +2,12 @@
from cms.djangoapps.contentstore.rest_api.v2.serializers.downstreams import (
PublishableEntityLinksSerializer,
PublishableEntityLinksUsageKeySerializer,
PublishableEntityLinksSummarySerializer,
)
from cms.djangoapps.contentstore.rest_api.v2.serializers.home import CourseHomeTabSerializerV2
__all__ = [
'CourseHomeTabSerializerV2',
'PublishableEntityLinksSerializer',
'PublishableEntityLinksSummarySerializer',
]

View File

@@ -13,28 +13,18 @@ class PublishableEntityLinksSerializer(serializers.ModelSerializer):
"""
upstream_context_title = serializers.CharField(read_only=True)
upstream_version = serializers.IntegerField(read_only=True)
ready_to_sync = serializers.SerializerMethodField()
def get_ready_to_sync(self, obj):
"""Calculate ready_to_sync field"""
return bool(
obj.upstream_version and
obj.upstream_version > (obj.version_synced or 0) and
obj.upstream_version > (obj.version_declined or 0)
)
ready_to_sync = serializers.BooleanField()
class Meta:
model = PublishableEntityLink
exclude = ['upstream_block', 'uuid']
class PublishableEntityLinksUsageKeySerializer(serializers.ModelSerializer):
class PublishableEntityLinksSummarySerializer(serializers.Serializer):
"""
Serializer for returning a string list of the usage keys.
Serializer for summary for publishable entity links
"""
def to_representation(self, instance: PublishableEntityLink) -> str:
return str(instance.downstream_usage_key)
class Meta:
model = PublishableEntityLink
fields = ('downstream_usage_key')
upstream_context_title = serializers.CharField(read_only=True)
upstream_context_key = serializers.CharField(read_only=True)
ready_to_sync_count = serializers.IntegerField(read_only=True)
total_count = serializers.IntegerField(read_only=True)

View File

@@ -13,26 +13,20 @@ urlpatterns = [
home.HomePageCoursesViewV2.as_view(),
name="courses",
),
# TODO: Potential future path.
# re_path(
# fr'^downstreams/$',
# downstreams.DownstreamsListView.as_view(),
# name="downstreams_list",
# ),
re_path(
r'^downstreams/$',
downstreams.DownstreamListView.as_view(),
name="downstreams_list",
),
re_path(
fr'^downstreams/{settings.USAGE_KEY_PATTERN}$',
downstreams.DownstreamView.as_view(),
name="downstream"
),
re_path(
f'^upstreams/{settings.COURSE_KEY_PATTERN}$',
downstreams.UpstreamListView.as_view(),
name='upstream-list'
),
re_path(
f'^upstream/{settings.USAGE_KEY_PATTERN}/downstream-links$',
downstreams.DownstreamContextListView.as_view(),
name='downstream-link-list'
f'^downstreams/{settings.COURSE_KEY_PATTERN}/summary$',
downstreams.DownstreamSummaryView.as_view(),
name='upstream-summary-list'
),
re_path(
fr'^downstreams/{settings.USAGE_KEY_PATTERN}/sync$',

View File

@@ -45,11 +45,29 @@ https://github.com/openedx/edx-platform/issues/35653):
GET: List all downstream blocks linked to a library block.
200: A list of downstream usage_keys linked to the library block.
# NOT YET IMPLEMENTED -- Will be needed for full Libraries Relaunch in ~Teak.
/api/contentstore/v2/downstreams
/api/contentstore/v2/downstreams?course_id=course-v1:A+B+C&ready_to_sync=true
GET: List downstream blocks that can be synced, filterable by course or sync-readiness.
200: A paginated list of applicable & accessible downstream blocks. Entries are UpstreamLinks.
200: A paginated list of applicable & accessible downstream blocks. Entries are PublishableEntityLinks.
/api/contentstore/v2/downstreams/<course_key>/summary
GET: List summary of links by course key
200: A list of summary of links by course key
Example:
[
{
"upstream_context_title": "CS problems 3",
"upstream_context_key": "lib:OpenedX:CSPROB3",
"ready_to_sync_count": 11,
"total_count": 14
},
{
"upstream_context_title": "CS problems 2",
"upstream_context_key": "lib:OpenedX:CSPROB2",
"ready_to_sync_count": 15,
"total_count": 24
},
]
UpstreamLink response schema:
{
@@ -66,9 +84,11 @@ import logging
from attrs import asdict as attrs_asdict
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 rest_framework.exceptions import NotFound, ValidationError
from rest_framework.fields import BooleanField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -78,7 +98,7 @@ from cms.djangoapps.contentstore.helpers import import_static_assets_for_library
from cms.djangoapps.contentstore.models import PublishableEntityLink
from cms.djangoapps.contentstore.rest_api.v2.serializers import (
PublishableEntityLinksSerializer,
PublishableEntityLinksUsageKeySerializer,
PublishableEntityLinksSummarySerializer,
)
from cms.lib.xblock.upstream_sync import (
BadDownstream,
@@ -96,9 +116,9 @@ from openedx.core.lib.api.view_utils import (
DeveloperErrorViewMixin,
view_auth_classes,
)
from xmodule.video_block.transcripts_utils import clear_transcripts
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.video_block.transcripts_utils import clear_transcripts
logger = logging.getLogger(__name__)
@@ -113,57 +133,92 @@ class _AuthenticatedRequest(Request):
user: User
# TODO: Potential future view.
# @view_auth_classes(is_authenticated=True)
# class DownstreamListView(DeveloperErrorViewMixin, APIView):
# """
# List all blocks which are linked to upstream content, with optional filtering.
# """
# def get(self, request: _AuthenticatedRequest) -> Response:
# """
# Handle the request.
# """
# course_key_string = request.GET['course_id']
# syncable = request.GET['ready_to_sync']
# ...
class DownstreamListPaginator(DefaultPagination):
"""Custom paginator for downstream entity links"""
page_size = 100
max_page_size = 1000
def paginate_queryset(self, queryset, request, view=None):
if 'no_page' in request.query_params:
return queryset
return super().paginate_queryset(queryset, request, view)
def get_paginated_response(self, data, *args, **kwargs):
if 'no_page' in args[0].query_params:
return Response(data)
response = super().get_paginated_response(data)
# replace next and previous links by next and previous page number
response.data.update({
'next_page_num': self.page.next_page_number() if self.page.has_next() else None,
'previous_page_num': self.page.previous_page_number() if self.page.has_previous() else None,
})
return response
@view_auth_classes()
class UpstreamListView(DeveloperErrorViewMixin, APIView):
class DownstreamListView(DeveloperErrorViewMixin, APIView):
"""
Serves course->library publishable entity links
List all blocks which are linked to an upstream context, with optional filtering.
"""
def get(self, request: _AuthenticatedRequest):
"""
Fetches publishable entity links for given course key
"""
course_key_string = request.GET.get('course_id')
ready_to_sync = request.GET.get('ready_to_sync')
upstream_usage_key = request.GET.get('upstream_usage_key')
link_filter: dict[str, CourseKey | UsageKey | bool] = {}
paginator = DownstreamListPaginator()
if course_key_string:
try:
link_filter["downstream_context_key"] = CourseKey.from_string(course_key_string)
except InvalidKeyError as exc:
raise ValidationError(detail=f"Malformed course key: {course_key_string}") from exc
if ready_to_sync is not None:
link_filter["ready_to_sync"] = BooleanField().to_internal_value(ready_to_sync)
if upstream_usage_key:
try:
link_filter["upstream_usage_key"] = UsageKey.from_string(upstream_usage_key)
except InvalidKeyError as exc:
raise ValidationError(detail=f"Malformed usage key: {upstream_usage_key}") from exc
links = PublishableEntityLink.filter_links(**link_filter)
paginated_links = paginator.paginate_queryset(links, self.request, view=self)
serializer = PublishableEntityLinksSerializer(paginated_links, many=True)
return paginator.get_paginated_response(serializer.data, self.request)
@view_auth_classes()
class DownstreamSummaryView(DeveloperErrorViewMixin, APIView):
"""
Serves course->library publishable entity links summary
"""
def get(self, request: _AuthenticatedRequest, course_key_string: str):
"""
Fetches publishable entity links for given course key
Fetches publishable entity links summary for given course key
Example:
[
{
"upstream_context_title": "CS problems 3",
"upstream_context_key": "lib:OpenedX:CSPROB3",
"ready_to_sync_count": 11,
"total_count": 14
},
{
"upstream_context_title": "CS problems 2",
"upstream_context_key": "lib:OpenedX:CSPROB2",
"ready_to_sync_count": 15,
"total_count": 24
},
]
"""
try:
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 = PublishableEntityLink.get_by_downstream_context(downstream_context_key=course_key)
serializer = PublishableEntityLinksSerializer(links, many=True)
return Response(serializer.data)
@view_auth_classes()
class DownstreamContextListView(DeveloperErrorViewMixin, APIView):
"""
Serves library block->downstream usage keys
"""
def get(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
"""
Fetches downstream links for given publishable entity
"""
try:
usage_key = UsageKey.from_string(usage_key_string)
except InvalidKeyError as exc:
raise ValidationError(detail=f"Malformed usage key: {usage_key_string}") from exc
links = PublishableEntityLink.get_by_upstream_usage_key(upstream_usage_key=usage_key)
serializer = PublishableEntityLinksUsageKeySerializer(links, many=True)
links = PublishableEntityLink.summarize_by_downstream_context(downstream_context_key=course_key)
serializer = PublishableEntityLinksSummarySerializer(links, many=True)
return Response(serializer.data)
@@ -231,7 +286,7 @@ class DownstreamView(DeveloperErrorViewMixin, APIView):
downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
try:
sever_upstream_link(downstream)
except NoUpstream as exc:
except NoUpstream:
logger.exception(
"Tried to DELETE upstream link of '%s', but it wasn't linked to anything in the first place. "
"Will do nothing. ",

View File

@@ -1,11 +1,13 @@
"""
Unit tests for /api/contentstore/v2/downstreams/* JSON APIs.
"""
import json
from datetime import datetime, timezone
from unittest.mock import patch
from django.conf import settings
from freezegun import freeze_time
from organizations.models import Organization
from cms.djangoapps.contentstore.helpers import StaticFileNotices
from cms.lib.xblock.upstream_sync import BadUpstream, UpstreamLink
@@ -16,15 +18,12 @@ from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
from .. import downstreams as downstreams_views
MOCK_LIB_KEY = "lib:OpenedX:CSPROB3"
MOCK_UPSTREAM_REF = "lb:OpenedX:CSPROB3:video:843b4c73-1e2d-4ced-a0ff-24e503cdb3e4"
MOCK_HTML_UPSTREAM_REF = "lb:OpenedX:CSPROB3:html:843b4c73-1e2d-4ced-a0ff-24e503cdb3e4"
MOCK_UPSTREAM_LINK = "{mfe_url}/library/{lib_key}/components?usageKey={usage_key}".format(
mfe_url=settings.COURSE_AUTHORING_MICROFRONTEND_URL,
lib_key=MOCK_LIB_KEY,
usage_key=MOCK_UPSTREAM_REF,
)
MOCK_UPSTREAM_ERROR = "your LibraryGPT subscription has expired"
URL_PREFIX = '/api/libraries/v2/'
URL_LIB_CREATE = URL_PREFIX
URL_LIB_BLOCKS = URL_PREFIX + '{lib_key}/blocks/'
URL_LIB_BLOCK_PUBLISH = URL_PREFIX + 'blocks/{block_key}/publish/'
URL_LIB_BLOCK_OLX = URL_PREFIX + 'blocks/{block_key}/olx/'
def _get_upstream_link_good_and_syncable(downstream):
@@ -55,16 +54,35 @@ class _BaseDownstreamViewTestMixin:
self.addCleanup(freezer.stop)
freezer.start()
self.maxDiff = 2000
self.organization, _ = Organization.objects.get_or_create(
short_name="CL-TEST",
defaults={"name": "Content Libraries Tachyon Exploration & Survey Team"},
)
self.superuser = UserFactory(username="superuser", password="password", is_staff=True, is_superuser=True)
self.client.login(username=self.superuser.username, password="password")
self.library_title = "Test Library 1"
self.library_id = self._create_library(
slug="testlib1_preview",
title=self.library_title,
description="Testing XBlocks"
)["id"]
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._publish_library_block(self.html_lib_id)
self._publish_library_block(self.video_lib_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()
chapter = BlockFactory.create(category='chapter', parent=self.course)
sequential = BlockFactory.create(category='sequential', parent=chapter)
unit = BlockFactory.create(category='vertical', parent=sequential)
self.regular_video_key = BlockFactory.create(category='video', parent=unit).usage_key
self.downstream_video_key = BlockFactory.create(
category='video', parent=unit, upstream=MOCK_UPSTREAM_REF, upstream_version=123,
category='video', parent=unit, upstream=self.video_lib_id, upstream_version=1,
).usage_key
self.downstream_html_key = BlockFactory.create(
category='html', parent=unit, upstream=MOCK_HTML_UPSTREAM_REF, upstream_version=1,
category='html', parent=unit, upstream=self.html_lib_id, upstream_version=1,
).usage_key
self.another_course = CourseFactory.create(display_name="Another Course")
@@ -76,13 +94,56 @@ class _BaseDownstreamViewTestMixin:
# Adds 3 videos linked to the same upstream
self.another_video_keys.append(
BlockFactory.create(
category="video", parent=another_unit, upstream=MOCK_UPSTREAM_REF, upstream_version=123,
category="video",
parent=another_unit,
upstream=self.video_lib_id,
upstream_version=1
).usage_key
)
self.fake_video_key = self.course.id.make_usage_key("video", "NoSuchVideo")
self.superuser = UserFactory(username="superuser", password="password", is_staff=True, is_superuser=True)
self.learner = UserFactory(username="learner", password="password")
self._set_library_block_olx(self.html_lib_id, "<html><b>Hello world!</b></html>")
self._publish_library_block(self.html_lib_id)
def _api(self, method, url, data, expect_response):
"""
Call a REST API
"""
response = getattr(self.client, method)(url, data, format="json")
assert response.status_code == expect_response,\
'Unexpected response code {}:\n{}'.format(response.status_code, getattr(response, 'data', '(no data)'))
return response.data
def _create_library(
self, slug, title, description="", org=None,
license_type='', expect_response=200,
):
""" Create a library """
if org is None:
org = self.organization.short_name
return self._api('post', URL_LIB_CREATE, {
"org": org,
"slug": slug,
"title": title,
"description": description,
"license": license_type,
}, expect_response)
def _add_block_to_library(self, lib_key, block_type, slug, parent_block=None, expect_response=200):
""" Add a new XBlock to the library """
data = {"block_type": block_type, "definition_id": slug}
if parent_block:
data["parent_block"] = parent_block
return self._api('post', URL_LIB_BLOCKS.format(lib_key=lib_key), data, expect_response)
def _publish_library_block(self, block_key, expect_response=200):
""" Publish changes from a specified XBlock """
return self._api('post', URL_LIB_BLOCK_PUBLISH.format(block_key=block_key), None, expect_response)
def _set_library_block_olx(self, block_key, new_olx, expect_response=200):
""" Overwrite the OLX of a specific block in the library """
return self._api('post', URL_LIB_BLOCK_OLX.format(block_key=block_key), {"olx": new_olx}, expect_response)
def call_api(self, usage_key_string):
raise NotImplementedError
@@ -126,10 +187,10 @@ class GetDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase):
self.client.login(username="superuser", password="password")
response = self.call_api(self.downstream_video_key)
assert response.status_code == 200
assert response.data['upstream_ref'] == MOCK_UPSTREAM_REF
assert response.data['upstream_ref'] == self.video_lib_id
assert response.data['error_message'] is None
assert response.data['ready_to_sync'] is True
assert response.data['upstream_link'] == MOCK_UPSTREAM_LINK
assert response.data['upstream_link'] == self.mock_upstream_link
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_bad)
def test_200_bad_upstream(self):
@@ -139,7 +200,7 @@ class GetDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase):
self.client.login(username="superuser", password="password")
response = self.call_api(self.downstream_video_key)
assert response.status_code == 200
assert response.data['upstream_ref'] == MOCK_UPSTREAM_REF
assert response.data['upstream_ref'] == self.video_lib_id
assert response.data['error_message'] == MOCK_UPSTREAM_ERROR
assert response.data['ready_to_sync'] is False
assert response.data['upstream_link'] is None
@@ -164,10 +225,10 @@ class PutDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase):
def call_api(self, usage_key_string, sync: str | None = None):
return self.client.put(
f"/api/contentstore/v2/downstreams/{usage_key_string}",
data={
"upstream_ref": MOCK_UPSTREAM_REF,
data=json.dumps({
"upstream_ref": str(self.video_lib_id),
**({"sync": sync} if sync else {}),
},
}),
content_type="application/json",
)
@@ -179,12 +240,12 @@ class PutDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase):
Does the happy path work (with sync=True)?
"""
self.client.login(username="superuser", password="password")
response = self.call_api(self.regular_video_key, sync='true')
response = self.call_api(str(self.regular_video_key), sync='true')
assert response.status_code == 200
video_after = modulestore().get_item(self.regular_video_key)
assert mock_sync.call_count == 1
assert mock_fetch.call_count == 0
assert video_after.upstream == MOCK_UPSTREAM_REF
assert video_after.upstream == self.video_lib_id
@patch.object(downstreams_views, "fetch_customizable_fields")
@patch.object(downstreams_views, "sync_from_upstream")
@@ -199,7 +260,7 @@ class PutDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase):
video_after = modulestore().get_item(self.regular_video_key)
assert mock_sync.call_count == 0
assert mock_fetch.call_count == 1
assert video_after.upstream == MOCK_UPSTREAM_REF
assert video_after.upstream == self.video_lib_id
@patch.object(downstreams_views, "fetch_customizable_fields", side_effect=BadUpstream(MOCK_UPSTREAM_ERROR))
def test_400(self, sync: str):
@@ -291,7 +352,10 @@ class PostDownstreamSyncViewTest(_DownstreamSyncViewTestMixin, SharedModuleStore
assert mock_clear_transcripts.call_count == 1
class DeleteDownstreamSyncViewtest(_DownstreamSyncViewTestMixin, SharedModuleStoreTestCase):
class DeleteDownstreamSyncViewtest(
_DownstreamSyncViewTestMixin,
SharedModuleStoreTestCase,
):
"""
Test that `DELETE /api/v2/contentstore/downstreams/.../sync` declines a sync from the linked upstream.
"""
@@ -310,19 +374,34 @@ class DeleteDownstreamSyncViewtest(_DownstreamSyncViewTestMixin, SharedModuleSto
assert mock_decline_sync.call_count == 1
class GetUpstreamViewTest(_BaseDownstreamViewTestMixin, SharedModuleStoreTestCase):
class GetUpstreamViewTest(
_BaseDownstreamViewTestMixin,
SharedModuleStoreTestCase,
):
"""
Test that `GET /api/v2/contentstore/upstreams/...` returns list of links in given downstream context i.e. course.
Test that `GET /api/v2/contentstore/downstreams?...` returns list of links based on the provided filter.
"""
def call_api(self, usage_key_string):
return self.client.get(f"/api/contentstore/v2/upstreams/{usage_key_string}")
def call_api(
self,
course_id: str = None,
ready_to_sync: bool = None,
upstream_usage_key: str = 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_usage_key is not None:
data["upstream_usage_key"] = str(upstream_usage_key)
return self.client.get("/api/contentstore/v2/downstreams/", data=data)
def test_200_all_upstreams(self):
def test_200_all_downstreams_for_a_course(self):
"""
Returns all upstream links for given course
Returns all links for given course
"""
self.client.login(username="superuser", password="password")
response = self.call_api(self.course.id)
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'
@@ -334,44 +413,89 @@ class GetUpstreamViewTest(_BaseDownstreamViewTestMixin, SharedModuleStoreTestCas
'id': 1,
'ready_to_sync': False,
'updated': date_format,
'upstream_context_key': MOCK_LIB_KEY,
'upstream_usage_key': MOCK_UPSTREAM_REF,
'upstream_version': None,
'upstream_context_key': self.library_id,
'upstream_context_title': self.library_title,
'upstream_usage_key': self.video_lib_id,
'upstream_version': 1,
'version_declined': None,
'version_synced': 123
'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': False,
'ready_to_sync': True,
'updated': date_format,
'upstream_context_key': MOCK_LIB_KEY,
'upstream_usage_key': MOCK_HTML_UPSTREAM_REF,
'upstream_version': None,
'upstream_context_key': self.library_id,
'upstream_context_title': self.library_title,
'upstream_usage_key': self.html_lib_id,
'upstream_version': 2,
'version_declined': None,
'version_synced': 1,
},
]
self.assertListEqual(data, expected)
self.assertListEqual(data["results"], expected)
self.assertEqual(data["count"], 2)
class GetDownstreamContextsTest(_BaseDownstreamViewTestMixin, SharedModuleStoreTestCase):
"""
Test that `GET /api/v2/contentstore/upstream/:usage_key/downstream-links returns list of
linked blocks usage_keys in given upstream entity (i.e. library block).
"""
def call_api(self, usage_key_string):
return self.client.get(f"/api/contentstore/v2/upstream/{usage_key_string}/downstream-links")
def test_200_all_downstreams_ready_to_sync(self):
"""
Returns all links that are syncable
"""
self.client.login(username="superuser", password="password")
response = self.call_api(ready_to_sync=True)
assert response.status_code == 200
data = response.json()
self.assertTrue(all(o["ready_to_sync"] for o in data["results"]))
self.assertEqual(data["count"], 1)
def test_200_downstream_context_list(self):
"""
Returns all downstream courses for given library block
"""
self.client.login(username="superuser", password="password")
response = self.call_api(MOCK_UPSTREAM_REF)
response = self.call_api(upstream_usage_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)
class GetDownstreamSummaryViewTest(
_BaseDownstreamViewTestMixin,
SharedModuleStoreTestCase,
):
"""
Test that `GET /api/v2/contentstore/downstreams/<course_id>/summary` returns summary of links in course.
"""
def call_api(self, course_id):
return self.client.get(f"/api/contentstore/v2/downstreams/{course_id}/summary")
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
def test_200_summary(self):
"""
Does the happy path work?
"""
self.client.login(username="superuser", password="password")
response = self.call_api(str(self.another_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': 0,
'total_count': 3,
}]
self.assertListEqual(data, expected)
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': 1,
'total_count': 2,
}]
self.assertListEqual(data, expected)

View File

@@ -2385,7 +2385,7 @@ def create_or_update_xblock_upstream_link(xblock, course_key: str | CourseKey, c
lib_component = None
PublishableEntityLink.update_or_create(
lib_component,
upstream_usage_key=xblock.upstream,
upstream_usage_key=upstream_usage_key,
upstream_context_key=str(upstream_usage_key.context_key),
downstream_context_key=course_key,
downstream_usage_key=xblock.usage_key,