Skip to content

Commit

Permalink
feat: add API to return list of downstream blocks for an upstream
Browse files Browse the repository at this point in the history
  • Loading branch information
rpenido committed Feb 20, 2025
1 parent 7dd4a09 commit e904b0a
Show file tree
Hide file tree
Showing 7 changed files with 101 additions and 3 deletions.
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-links$',
downstreams.DownstreamContextListView.as_view(),
name='downstream-link-list'
),
re_path(
fr'^downstreams/{settings.USAGE_KEY_PATTERN}/sync$',
downstreams.SyncFromUpstreamView.as_view(),
Expand Down
30 changes: 30 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
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-links
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
Expand Down Expand Up @@ -137,6 +142,31 @@ def get(self, request: _AuthenticatedRequest, course_key_string: str):
return Response(serializer.data)


@view_auth_classes()
class DownstreamContextListView(DeveloperErrorViewMixin, APIView):
"""
Serves library block->downstream usage keys
"""
def get(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
"""
Fetches downstream links for given publishable entity
"""
try:
usage_key = UsageKey.from_string(usage_key_string)
except InvalidKeyError as exc:
raise ValidationError(detail=f"Malformed usage key: {usage_key_string}") from exc

downstream_usage_key_list = (
PublishableEntityLink
.get_by_upstream_usage_key(upstream_usage_key=usage_key)
.values_list("downstream_usage_key", flat=True)
)

downstream_usage_key_str_list = [str(usage_key) for usage_key in downstream_usage_key_list]

return Response(downstream_usage_key_str_list)


@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,20 @@ 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)
self.another_video_keys = []
for _ in range(3):
# 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,
).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")
Expand Down Expand Up @@ -339,3 +353,23 @@ def test_200_all_upstreams(self):
},
]
self.assertListEqual(data, expected)


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_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 = [str(self.downstream_video_key)] + [str(key) for key in self.another_video_keys]
self.assertListEqual(data, expected)
2 changes: 1 addition & 1 deletion cms/djangoapps/contentstore/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2371,7 +2371,7 @@ def get_xblock_render_context(request, block):
return ""


def create_or_update_xblock_upstream_link(xblock, course_key: str | CourseKey, created: datetime | None = None):
def create_or_update_xblock_upstream_link(xblock, course_key: str | CourseKey, created: datetime | None = None) -> None:
"""
Create or update upstream->downstream link in database for given xblock.
"""
Expand Down
20 changes: 18 additions & 2 deletions openedx/core/djangoapps/content/search/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ class Fields:
context_key = "context_key"
org = "org"
access_id = "access_id" # .models.SearchAccess.id
# breadcrumbs: an array of {"display_name": "..."} entries. First one is the name of the course/library itself.
# breadcrumbs: an array of {"display_name": "...", "usage_key": "...", "url": "..."} entries.
# First one is the name of the course/library itself.
# After that is the name of any parent Section/Subsection/Unit/etc.
# It's a list of dictionaries because for now we just include the name of each but in future we may add their IDs.
breadcrumbs = "breadcrumbs"
# tags (dictionary)
# See https://blog.meilisearch.com/nested-hierarchical-facets-guide/
Expand Down Expand Up @@ -199,6 +199,7 @@ def _fields_from_block(block) -> dict:
class implementation returns only:
{"content": {"display_name": "..."}, "content_type": "..."}
"""

block_type = block.scope_ids.block_type
block_data = {
Fields.usage_key: str(block.usage_key),
Expand All @@ -213,6 +214,9 @@ class implementation returns only:
}
# Get the breadcrumbs (course, section, subsection, etc.):
if block.usage_key.context_key.is_course: # Getting parent is not yet implemented in Learning Core (for libraries).
# Import here to avoid circular imports
from cms.djangoapps.contentstore.utils import reverse_course_url, reverse_usage_url

cur_block = block
while cur_block.parent:
if not cur_block.has_cached_parent:
Expand All @@ -225,6 +229,18 @@ class implementation returns only:
}
if cur_block.scope_ids.block_type != "course":
parent_data["usage_key"] = str(cur_block.usage_key)
# Section and subsections don't have URLs in the CMS
if cur_block.scope_ids.block_type not in ["chapter", "sequential"]:
parent_data["url"] = (
reverse_course_url("course_handler", cur_block.context_key) +
reverse_usage_url("container_handler", cur_block.usage_key)
)
else:
parent_data["url"] = (
reverse_course_url("course_handler", cur_block.context_key) +
reverse_usage_url("container_handler", cur_block.usage_key)
)

block_data[Fields.breadcrumbs].insert(
0,
parent_data,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ def test_problem_block(self):
"breadcrumbs": [
{
'display_name': 'Toy Course',
'url': '/course/course-v1:edX+toy+2012_Fall/container/block-v1:edX+toy+2012_Fall+type@course+block@course',
},
{
'display_name': 'chapter',
Expand All @@ -170,6 +171,7 @@ def test_problem_block(self):
{
'display_name': 'vertical',
'usage_key': 'block-v1:edX+toy+2012_Fall+type@vertical+block@vertical_test',
'url': '/course/course-v1:edX+toy+2012_Fall/container/block-v1:edX+toy+2012_Fall+type@vertical+block@vertical_test',
},
],
"content": {
Expand Down Expand Up @@ -208,6 +210,7 @@ def test_html_block(self):
"breadcrumbs": [
{
'display_name': 'Toy Course',
'url': '/course/course-v1:edX+toy+2012_Fall/container/block-v1:edX+toy+2012_Fall+type@course+block@course',
},
{
'display_name': 'Overview',
Expand Down Expand Up @@ -251,6 +254,7 @@ def test_video_block_untagged(self):
"breadcrumbs": [
{
'display_name': 'Toy Course',
'url': '/course/course-v1:edX+toy+2012_Fall/container/block-v1:edX+toy+2012_Fall+type@course+block@course',
},
{
'display_name': 'Overview',
Expand Down

0 comments on commit e904b0a

Please sign in to comment.