Skip to content

Commit

Permalink
feat: add API to return list of downstream contexts for an upstream
Browse files Browse the repository at this point in the history
  • Loading branch information
rpenido committed Feb 14, 2025
1 parent 8e3a70d commit 4516aa2
Show file tree
Hide file tree
Showing 4 changed files with 102 additions and 1 deletion.
9 changes: 9 additions & 0 deletions cms/djangoapps/contentstore/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
5 changes: 5 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v2/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
45 changes: 44 additions & 1 deletion cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -91,6 +99,7 @@
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError


logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -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, _: _AuthenticatedRequest, course_key_string: str):
"""
Fetches publishable entity links for given course key
"""
Expand All @@ -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, _: _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):
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)

0 comments on commit 4516aa2

Please sign in to comment.