From d4ad01b0b75ca1079525ccb8e911284dd43740c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Mon, 10 Feb 2025 13:09:20 -0300 Subject: [PATCH] feat: add API to return list of downstream contexts for an upstream --- cms/djangoapps/contentstore/models.py | 9 ++++ .../contentstore/rest_api/v2/urls.py | 5 +++ .../rest_api/v2/views/downstreams.py | 45 ++++++++++++++++++- .../v2/views/tests/test_downstreams.py | 44 ++++++++++++++++++ 4 files changed, 102 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/models.py b/cms/djangoapps/contentstore/models.py index 99ff47f97634..a884d0702d86 100644 --- a/cms/djangoapps/contentstore/models.py +++ b/cms/djangoapps/contentstore/models.py @@ -202,6 +202,15 @@ def get_by_downstream_context(cls, downstream_context_key: CourseKey) -> QuerySe "upstream_block__learning_package" ) + @classmethod + def get_by_upstream_usage_key(cls, upstream_usage_key: UsageKey) -> QuerySet["PublishableEntityLink"]: + """ + Get all downstream context keys for given upstream usage key + """ + return cls.objects.filter( + upstream_usage_key=upstream_usage_key, + ) + class LearningContextLinksStatusChoices(models.TextChoices): """ diff --git a/cms/djangoapps/contentstore/rest_api/v2/urls.py b/cms/djangoapps/contentstore/rest_api/v2/urls.py index 690e78799336..4c58ad9c57b1 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v2/urls.py @@ -29,6 +29,11 @@ downstreams.UpstreamListView.as_view(), name='upstream-list' ), + re_path( + f'^upstream/{settings.USAGE_KEY_PATTERN}/downstream-contexts$', + downstreams.DownstreamContextListView.as_view(), + name='downstream-context-list' + ), re_path( fr'^downstreams/{settings.USAGE_KEY_PATTERN}/sync$', downstreams.SyncFromUpstreamView.as_view(), diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py index 94931acc8ff2..952ac4242816 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py @@ -40,6 +40,12 @@ 400: Downstream block is not linked to upstream content. 404: Downstream block not found or user lacks permission to edit it. + /api/contentstore/v2/upstream/{usage_key_string}/downstream-contexts + + GET: List all downstream contexts (Courses) linked to a library block. + 200: A list of Course IDs and their display names, along with the number of times the block + is linked to each. + # 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 @@ -60,6 +66,7 @@ import logging from attrs import asdict as attrs_asdict +from collections import Counter from django.contrib.auth.models import User # pylint: disable=imported-auth-user from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey @@ -71,6 +78,7 @@ from cms.djangoapps.contentstore.helpers import import_static_assets_for_library_sync from cms.djangoapps.contentstore.models import PublishableEntityLink +from cms.djangoapps.contentstore.utils import reverse_course_url from cms.djangoapps.contentstore.rest_api.v2.serializers import PublishableEntityLinksSerializer from cms.lib.xblock.upstream_sync import ( BadDownstream, @@ -91,6 +99,7 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError + logger = logging.getLogger(__name__) @@ -124,7 +133,7 @@ class UpstreamListView(DeveloperErrorViewMixin, APIView): """ Serves course->library publishable entity links """ - def get(self, request: _AuthenticatedRequest, course_key_string: str): + def get(self, _request: _AuthenticatedRequest, course_key_string: str): """ Fetches publishable entity links for given course key """ @@ -137,6 +146,40 @@ def get(self, request: _AuthenticatedRequest, course_key_string: str): return Response(serializer.data) +@view_auth_classes() +class DownstreamContextListView(DeveloperErrorViewMixin, APIView): + """ + Serves library block->courses links + """ + def get(self, _request: _AuthenticatedRequest, usage_key_string: str) -> Response: + """ + Fetches downstream context links for given publishable entity + """ + try: + usage_key = UsageKey.from_string(usage_key_string) + print(usage_key) + 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) + downstream_key_list = [link.downstream_context_key for link in links] + + # Count the number of times each course is linked to the library block + counter = Counter(downstream_key_list) + + result = [] + for context_key, count in counter.most_common(): + # The following code only can handle the correct display_name for Courses as context + course = modulestore().get_course(context_key) + result.append({ + "id": str(context_key), + "display_name": course.display_name, + "url": reverse_course_url('course_handler', context_key), + "count": count, + }) + + return Response(result) + + @view_auth_classes(is_authenticated=True) class DownstreamView(DeveloperErrorViewMixin, APIView): """ 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 ec7c842d1637..094e7fe31a1d 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 @@ -66,6 +66,17 @@ def setUp(self): self.downstream_html_key = BlockFactory.create( category='html', parent=unit, upstream=MOCK_HTML_UPSTREAM_REF, upstream_version=1, ).usage_key + + self.another_course = CourseFactory.create(display_name="Another Course") + another_chapter = BlockFactory.create(category='chapter', parent=self.another_course) + another_sequential = BlockFactory.create(category='sequential', parent=another_chapter) + another_unit = BlockFactory.create(category='vertical', parent=another_sequential) + for _ in range(3): + # Adds 3 videos linked to the same upstream + BlockFactory.create( + category='video', parent=another_unit, upstream=MOCK_UPSTREAM_REF, upstream_version=123, + ) + 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") @@ -339,3 +350,36 @@ def test_200_all_upstreams(self): }, ] self.assertListEqual(data, expected) + + +class GetDownstreamContextsTest(_BaseDownstreamViewTestMixin, SharedModuleStoreTestCase): + """ + Test that `GET /api/v2/contentstore/upstream/:usage_key/downstream-contexts returns list of + link contexts (i.e. courses) 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-contexts") + + 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) + assert response.status_code == 200 + data = response.json() + expected = [ + { + 'id': str(self.another_course.id), + 'display_name': str(self.another_course.display_name), + 'url': f'/course/{str(self.another_course.id)}', + 'count': 3, + }, + { + 'id': str(self.course.id), + 'display_name': str(self.course.display_name), + 'url': f'/course/{str(self.course.id)}', + 'count': 1, + }, + ] + self.assertListEqual(data, expected)