Skip to content

Commit 88e9b08

Browse files
committed
feat: add API to return list of downstream contexts for an upstream
1 parent 8e3a70d commit 88e9b08

File tree

4 files changed

+101
-0
lines changed

4 files changed

+101
-0
lines changed

cms/djangoapps/contentstore/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,15 @@ def get_by_downstream_context(cls, downstream_context_key: CourseKey) -> QuerySe
202202
"upstream_block__learning_package"
203203
)
204204

205+
@classmethod
206+
def get_by_upstream_usage_key(cls, upstream_usage_key: UsageKey) -> QuerySet["PublishableEntityLink"]:
207+
"""
208+
Get all downstream context keys for given upstream usage key
209+
"""
210+
return cls.objects.filter(
211+
upstream_usage_key=upstream_usage_key,
212+
)
213+
205214

206215
class LearningContextLinksStatusChoices(models.TextChoices):
207216
"""

cms/djangoapps/contentstore/rest_api/v2/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@
2929
downstreams.UpstreamListView.as_view(),
3030
name='upstream-list'
3131
),
32+
re_path(
33+
f'^upstream/{settings.USAGE_KEY_PATTERN}/downstream-contexts$',
34+
downstreams.DownstreamContextListView.as_view(),
35+
name='downstream-context-list'
36+
),
3237
re_path(
3338
fr'^downstreams/{settings.USAGE_KEY_PATTERN}/sync$',
3439
downstreams.SyncFromUpstreamView.as_view(),

cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@
4040
400: Downstream block is not linked to upstream content.
4141
404: Downstream block not found or user lacks permission to edit it.
4242
43+
/api/contentstore/v2/upstream/{usage_key_string}/downstream-contexts
44+
45+
GET: List all downstream contexts (Courses) linked to a library block.
46+
200: A list of Course IDs and their display names, along with the number of times the block
47+
is linked to each.
48+
4349
# NOT YET IMPLEMENTED -- Will be needed for full Libraries Relaunch in ~Teak.
4450
/api/contentstore/v2/downstreams
4551
/api/contentstore/v2/downstreams?course_id=course-v1:A+B+C&ready_to_sync=true
@@ -60,6 +66,7 @@
6066
import logging
6167

6268
from attrs import asdict as attrs_asdict
69+
from collections import Counter
6370
from django.contrib.auth.models import User # pylint: disable=imported-auth-user
6471
from opaque_keys import InvalidKeyError
6572
from opaque_keys.edx.keys import CourseKey, UsageKey
@@ -71,6 +78,7 @@
7178

7279
from cms.djangoapps.contentstore.helpers import import_static_assets_for_library_sync
7380
from cms.djangoapps.contentstore.models import PublishableEntityLink
81+
from cms.djangoapps.contentstore.utils import reverse_course_url
7482
from cms.djangoapps.contentstore.rest_api.v2.serializers import PublishableEntityLinksSerializer
7583
from cms.lib.xblock.upstream_sync import (
7684
BadDownstream,
@@ -91,6 +99,7 @@
9199
from xmodule.modulestore.django import modulestore
92100
from xmodule.modulestore.exceptions import ItemNotFoundError
93101

102+
94103
logger = logging.getLogger(__name__)
95104

96105

@@ -137,6 +146,40 @@ def get(self, request: _AuthenticatedRequest, course_key_string: str):
137146
return Response(serializer.data)
138147

139148

149+
@view_auth_classes()
150+
class DownstreamContextListView(DeveloperErrorViewMixin, APIView):
151+
"""
152+
Serves library block->courses links
153+
"""
154+
def get(self, _: _AuthenticatedRequest, usage_key_string: str) -> Response:
155+
"""
156+
Fetches downstream context links for given publishable entity
157+
"""
158+
try:
159+
usage_key = UsageKey.from_string(usage_key_string)
160+
print(usage_key)
161+
except InvalidKeyError as exc:
162+
raise ValidationError(detail=f"Malformed usage key: {usage_key_string}") from exc
163+
links = PublishableEntityLink.get_by_upstream_usage_key(upstream_usage_key=usage_key)
164+
downstream_key_list = [link.downstream_context_key for link in links]
165+
166+
# Count the number of times each course is linked to the library block
167+
counter = Counter(downstream_key_list)
168+
169+
result = []
170+
for context_key, count in counter.most_common():
171+
# The following code only can handle the correct display_name for Courses as context
172+
course = modulestore().get_course(context_key)
173+
result.append({
174+
"id": str(context_key),
175+
"display_name": course.display_name,
176+
"url": reverse_course_url('course_handler', context_key),
177+
"count": count,
178+
})
179+
180+
return Response(result)
181+
182+
140183
@view_auth_classes(is_authenticated=True)
141184
class DownstreamView(DeveloperErrorViewMixin, APIView):
142185
"""

cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@ def setUp(self):
6666
self.downstream_html_key = BlockFactory.create(
6767
category='html', parent=unit, upstream=MOCK_HTML_UPSTREAM_REF, upstream_version=1,
6868
).usage_key
69+
70+
self.another_course = CourseFactory.create(display_name="Another Course")
71+
another_chapter = BlockFactory.create(category='chapter', parent=self.another_course)
72+
another_sequential = BlockFactory.create(category='sequential', parent=another_chapter)
73+
another_unit = BlockFactory.create(category='vertical', parent=another_sequential)
74+
for _ in range(3):
75+
# Adds 3 videos linked to the same upstream
76+
BlockFactory.create(
77+
category='video', parent=another_unit, upstream=MOCK_UPSTREAM_REF, upstream_version=123,
78+
)
79+
6980
self.fake_video_key = self.course.id.make_usage_key("video", "NoSuchVideo")
7081
self.superuser = UserFactory(username="superuser", password="password", is_staff=True, is_superuser=True)
7182
self.learner = UserFactory(username="learner", password="password")
@@ -339,3 +350,36 @@ def test_200_all_upstreams(self):
339350
},
340351
]
341352
self.assertListEqual(data, expected)
353+
354+
355+
class GetDownstreamContextsTest(_BaseDownstreamViewTestMixin, SharedModuleStoreTestCase):
356+
"""
357+
Test that `GET /api/v2/contentstore/upstream/:usage_key/downstream-contexts returns list of
358+
link contexts (i.e. courses) in given upstream entity (i.e. library block).
359+
"""
360+
def call_api(self, usage_key_string):
361+
return self.client.get(f"/api/contentstore/v2/upstream/{usage_key_string}/downstream-contexts")
362+
363+
def test_200_downstream_context_list(self):
364+
"""
365+
Returns all downstream courses for given library block
366+
"""
367+
self.client.login(username="superuser", password="password")
368+
response = self.call_api(MOCK_UPSTREAM_REF)
369+
assert response.status_code == 200
370+
data = response.json()
371+
expected = [
372+
{
373+
'id': str(self.another_course.id),
374+
'display_name': str(self.another_course.display_name),
375+
'url': f'/course/{str(self.another_course.id)}',
376+
'count': 3,
377+
},
378+
{
379+
'id': str(self.course.id),
380+
'display_name': str(self.course.display_name),
381+
'url': f'/course/{str(self.course.id)}',
382+
'count': 1,
383+
},
384+
]
385+
self.assertListEqual(data, expected)

0 commit comments

Comments
 (0)