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