diff --git a/cms/djangoapps/contentstore/models.py b/cms/djangoapps/contentstore/models.py index a884d0702d86..ca6171118c00 100644 --- a/cms/djangoapps/contentstore/models.py +++ b/cms/djangoapps/contentstore/models.py @@ -7,8 +7,10 @@ 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 @@ def update_or_create( ) 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 @@ def update_or_create( 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 @@ def get_by_upstream_usage_key(cls, upstream_usage_key: UsageKey) -> QuerySet["Pu 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): """ diff --git a/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py index 98c36357810e..1815b4435d25 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py @@ -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', +] diff --git a/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py index 03447d567fdb..1a2139d4cf91 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py @@ -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) diff --git a/cms/djangoapps/contentstore/rest_api/v2/urls.py b/cms/djangoapps/contentstore/rest_api/v2/urls.py index 95849bc33ecb..5fa954a4ef82 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v2/urls.py @@ -13,26 +13,20 @@ 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$', diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py index ec3bf31013fe..29e6a3961dcc 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py @@ -45,11 +45,29 @@ 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//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 @@ 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.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 @@ 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, course_key_string: str): + + def get(self, request: _AuthenticatedRequest): """ Fetches publishable entity links for given course key """ - 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) + 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 DownstreamContextListView(DeveloperErrorViewMixin, APIView): +class DownstreamSummaryView(DeveloperErrorViewMixin, APIView): """ - Serves library block->downstream usage keys + Serves course->library publishable entity links summary """ - def get(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response: + def get(self, request: _AuthenticatedRequest, course_key_string: str): """ - Fetches downstream links for given publishable entity + 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: - usage_key = UsageKey.from_string(usage_key_string) + course_key = CourseKey.from_string(course_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) - + raise ValidationError(detail=f"Malformed course key: {course_key_string}") from exc + links = PublishableEntityLink.summarize_by_downstream_context(downstream_context_key=course_key) + serializer = PublishableEntityLinksSummarySerializer(links, many=True) return Response(serializer.data) @@ -231,7 +286,7 @@ def delete(self, request: _AuthenticatedRequest, usage_key_string: str) -> Respo 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. ", diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py index b3cac12121c5..0ef7c8c32dc4 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py @@ -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 .. 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 @@ def setUp(self): 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 @@ def setUp(self): # 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, "Hello world!") + 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 @@ def test_200_good_upstream(self): 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 @@ def test_200_bad_upstream(self): 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 @@ def test_200_with_sync(self, mock_sync, mock_fetch): 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 @@ def test_200_no_sync(self, mock_sync, mock_fetch): 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 @@ def test_200(self, mock_sync_from_upstream, mock_import_staged_content, mock_cle 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 @@ def test_204(self, mock_decline_sync): 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 test_200_all_upstreams(self): - """ - Returns all upstream links for given course + 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_downstreams_for_a_course(self): + """ + 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 @@ def test_200_all_upstreams(self): '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//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) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 42987544f9d5..2cd8fcdf27b2 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -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,