Skip to content

Commit d57fd75

Browse files
committed
feat: add API to return downstream data for an upstream library content
1 parent 45f44c3 commit d57fd75

File tree

6 files changed

+152
-0
lines changed

6 files changed

+152
-0
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 4.2.18 on 2025-02-19 23:32
2+
3+
from django.db import migrations
4+
from opaque_keys.edx.django.models import UsageKeyField
5+
from opaque_keys.edx.keys import UsageKey
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('contentstore', '0009_learningcontextlinksstatus_publishableentitylink'),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name='publishableentitylink',
17+
name='downstream_parent_usage_key',
18+
field=UsageKeyField(
19+
db_index=True,
20+
# Adds a default invalid value to the field to prevent the migration from failing
21+
default=UsageKey.from_string('block-v1:edX+DemoX+Demo_Course+type@vertical+block@invalid'),
22+
max_length=255
23+
),
24+
),
25+
migrations.AlterField(
26+
model_name='publishableentitylink',
27+
name='downstream_parent_usage_key',
28+
field=UsageKeyField(db_index=True, max_length=255),
29+
),
30+
]

cms/djangoapps/contentstore/models.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ class PublishableEntityLink(models.Model):
106106
# A downstream entity can only link to single upstream entity
107107
# whereas an entity can be upstream for multiple downstream entities.
108108
downstream_usage_key = UsageKeyField(max_length=255, unique=True)
109+
# Search by parent key (i.e., unit key)
110+
downstream_parent_usage_key = UsageKeyField(max_length=255, db_index=True)
109111
# Search by course/downstream key
110112
downstream_context_key = CourseKeyField(max_length=255, db_index=True)
111113
version_synced = models.IntegerField()
@@ -147,6 +149,7 @@ def update_or_create(
147149
upstream_usage_key: UsageKey,
148150
upstream_context_key: str,
149151
downstream_usage_key: UsageKey,
152+
downstream_parent_usage_key: UsageKey,
150153
downstream_context_key: CourseKey,
151154
version_synced: int,
152155
version_declined: int | None = None,
@@ -161,6 +164,7 @@ def update_or_create(
161164
'upstream_usage_key': upstream_usage_key,
162165
'upstream_context_key': upstream_context_key,
163166
'downstream_usage_key': downstream_usage_key,
167+
'downstream_parent_usage_key': downstream_parent_usage_key,
164168
'downstream_context_key': downstream_context_key,
165169
'version_synced': version_synced,
166170
'version_declined': version_declined,
@@ -202,6 +206,15 @@ def get_by_downstream_context(cls, downstream_context_key: CourseKey) -> QuerySe
202206
"upstream_block__learning_package"
203207
)
204208

209+
@classmethod
210+
def get_by_upstream_usage_key(cls, upstream_usage_key: UsageKey) -> QuerySet["PublishableEntityLink"]:
211+
"""
212+
Get all downstream context keys for given upstream usage key
213+
"""
214+
return cls.objects.filter(
215+
upstream_usage_key=upstream_usage_key,
216+
)
217+
205218

206219
class LearningContextLinksStatusChoices(models.TextChoices):
207220
"""

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: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@
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 and URLs.
47+
4348
# NOT YET IMPLEMENTED -- Will be needed for full Libraries Relaunch in ~Teak.
4449
/api/contentstore/v2/downstreams
4550
/api/contentstore/v2/downstreams?course_id=course-v1:A+B+C&ready_to_sync=true
@@ -61,6 +66,8 @@
6166

6267
from attrs import asdict as attrs_asdict
6368
from django.contrib.auth.models import User # pylint: disable=imported-auth-user
69+
from django.utils.translation import gettext_lazy as _
70+
from itertools import groupby
6471
from opaque_keys import InvalidKeyError
6572
from opaque_keys.edx.keys import CourseKey, UsageKey
6673
from rest_framework.exceptions import NotFound, ValidationError
@@ -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, reverse_usage_url
7482
from cms.djangoapps.contentstore.rest_api.v2.serializers import PublishableEntityLinksSerializer
7583
from cms.lib.xblock.upstream_sync import (
7684
BadDownstream,
@@ -137,6 +145,55 @@ def get(self, request: _AuthenticatedRequest, course_key_string: str):
137145
return Response(serializer.data)
138146

139147

148+
@view_auth_classes()
149+
class DownstreamContextListView(DeveloperErrorViewMixin, APIView):
150+
"""
151+
Serves library block->course->unit links
152+
"""
153+
def get(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
154+
"""
155+
Fetches downstream links for given publishable entity
156+
"""
157+
try:
158+
usage_key = UsageKey.from_string(usage_key_string)
159+
except InvalidKeyError as exc:
160+
raise ValidationError(detail=f"Malformed usage key: {usage_key_string}") from exc
161+
162+
links = (
163+
PublishableEntityLink
164+
.get_by_upstream_usage_key(upstream_usage_key=usage_key)
165+
.order_by("downstream_context_key", "downstream_parent_usage_key")
166+
.values( "downstream_usage_key", "downstream_context_key", "downstream_parent_usage_key")
167+
)
168+
169+
result = []
170+
for context_key, links_by_context in groupby(links, lambda link: link["downstream_context_key"]):
171+
# Pre-fetch the course with all of its children:
172+
course = modulestore().get_course(context_key, depth=None)
173+
if course is None:
174+
raise BadDownstream(_("Course {context_key} not found").format(context_key=context_key))
175+
course_link = {
176+
"id": str(context_key),
177+
"display_name": course.display_name,
178+
"url": reverse_course_url("course_handler", context_key),
179+
"units": []
180+
}
181+
for unit_key, links_by_unit in groupby(links_by_context, lambda link: link["downstream_parent_usage_key"]):
182+
unit_link = {
183+
"id": str(unit_key),
184+
"url": reverse_usage_url("container_handler", unit_key),
185+
"links": []
186+
}
187+
for downstream_link in links_by_unit:
188+
unit_link["links"].append({
189+
"id": str(downstream_link["downstream_usage_key"]),
190+
})
191+
course_link["units"].append(unit_link)
192+
result.append(course_link)
193+
194+
return Response(result)
195+
196+
140197
@view_auth_classes(is_authenticated=True)
141198
class DownstreamView(DeveloperErrorViewMixin, APIView):
142199
"""

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from cms.djangoapps.contentstore.helpers import StaticFileNotices
1111
from cms.lib.xblock.upstream_sync import BadUpstream, UpstreamLink
1212
from common.djangoapps.student.tests.factories import UserFactory
13+
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
1314
from xmodule.modulestore.django import modulestore
1415
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
1516
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
@@ -56,6 +57,7 @@ def setUp(self):
5657
freezer.start()
5758
self.maxDiff = 2000
5859
self.course = CourseFactory.create()
60+
CourseOverviewFactory.create(id=self.course.id, display_name=self.course.display_name)
5961
chapter = BlockFactory.create(category='chapter', parent=self.course)
6062
sequential = BlockFactory.create(category='sequential', parent=chapter)
6163
unit = BlockFactory.create(category='vertical', parent=sequential)
@@ -66,6 +68,18 @@ def setUp(self):
6668
self.downstream_html_key = BlockFactory.create(
6769
category='html', parent=unit, upstream=MOCK_HTML_UPSTREAM_REF, upstream_version=1,
6870
).usage_key
71+
72+
self.another_course = CourseFactory.create(display_name="Another Course")
73+
CourseOverviewFactory.create(id=self.another_course.id, display_name=self.another_course.display_name)
74+
another_chapter = BlockFactory.create(category='chapter', parent=self.another_course)
75+
another_sequential = BlockFactory.create(category='sequential', parent=another_chapter)
76+
another_unit = BlockFactory.create(category='vertical', parent=another_sequential)
77+
for _ in range(3):
78+
# Adds 3 videos linked to the same upstream
79+
BlockFactory.create(
80+
category='video', parent=another_unit, upstream=MOCK_UPSTREAM_REF, upstream_version=123,
81+
)
82+
6983
self.fake_video_key = self.course.id.make_usage_key("video", "NoSuchVideo")
7084
self.superuser = UserFactory(username="superuser", password="password", is_staff=True, is_superuser=True)
7185
self.learner = UserFactory(username="learner", password="password")
@@ -339,3 +353,34 @@ def test_200_all_upstreams(self):
339353
},
340354
]
341355
self.assertListEqual(data, expected)
356+
357+
358+
class GetDownstreamContextsTest(_BaseDownstreamViewTestMixin, SharedModuleStoreTestCase):
359+
"""
360+
Test that `GET /api/v2/contentstore/upstream/:usage_key/downstream-contexts returns list of
361+
link contexts (i.e. courses) in given upstream entity (i.e. library block).
362+
"""
363+
def call_api(self, usage_key_string):
364+
return self.client.get(f"/api/contentstore/v2/upstream/{usage_key_string}/downstream-contexts")
365+
366+
def test_200_downstream_context_list(self):
367+
"""
368+
Returns all downstream courses for given library block
369+
"""
370+
self.client.login(username="superuser", password="password")
371+
response = self.call_api(MOCK_UPSTREAM_REF)
372+
assert response.status_code == 200
373+
data = response.json()
374+
expected = [
375+
{
376+
'id': str(self.course.id),
377+
'display_name': str(self.course.display_name),
378+
'url': f'/course/{str(self.course.id)}',
379+
},
380+
{
381+
'id': str(self.another_course.id),
382+
'display_name': str(self.another_course.display_name),
383+
'url': f'/course/{str(self.another_course.id)}',
384+
},
385+
]
386+
self.assertListEqual(data, expected)

cms/djangoapps/contentstore/utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2383,6 +2383,8 @@ def create_or_update_xblock_upstream_link(xblock, course_key: str | CourseKey, c
23832383
except ObjectDoesNotExist:
23842384
log.error(f"Library component not found for {upstream_usage_key}")
23852385
lib_component = None
2386+
2387+
print(f"!!!!!!!!!!!!!!!!! xblock.parent -> {xblock.parent}")
23862388
PublishableEntityLink.update_or_create(
23872389
lib_component,
23882390
upstream_usage_key=xblock.upstream,

0 commit comments

Comments
 (0)