Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add API to return list of downstream contexts for an upstream [FC-0076] #36253

Draft
wants to merge 14 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions cms/djangoapps/contentstore/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ class PublishableEntityLink(models.Model):
# A downstream entity can only link to single upstream entity
# whereas an entity can be upstream for multiple downstream entities.
downstream_usage_key = UsageKeyField(max_length=255, unique=True)
# Search by parent key (i.e., unit key)
downstream_parent_usage_key = UsageKeyField(max_length=255, db_index=True)
# Search by course/downstream key
downstream_context_key = CourseKeyField(max_length=255, db_index=True)
version_synced = models.IntegerField()
Expand Down Expand Up @@ -147,6 +149,7 @@ def update_or_create(
upstream_usage_key: UsageKey,
upstream_context_key: str,
downstream_usage_key: UsageKey,
# downstream_parent_usage_key: UsageKey,
downstream_context_key: CourseKey,
version_synced: int,
version_declined: int | None = None,
Expand All @@ -161,6 +164,7 @@ def update_or_create(
'upstream_usage_key': upstream_usage_key,
'upstream_context_key': upstream_context_key,
'downstream_usage_key': downstream_usage_key,
# 'downstream_parent_usage_key': downstream_parent_usage_key,
'downstream_context_key': downstream_context_key,
'version_synced': version_synced,
'version_declined': version_declined,
Expand Down Expand Up @@ -202,6 +206,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
39 changes: 39 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-contexts

GET: List all downstream contexts (Courses) linked to a library block.
200: A list of Course IDs and their display names and URLs.

# 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 @@ -71,6 +76,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 @@ -84,6 +90,7 @@
sync_from_upstream,
)
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.lib.api.view_utils import (
DeveloperErrorViewMixin,
view_auth_classes,
Expand Down Expand Up @@ -137,6 +144,38 @@ 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)
except InvalidKeyError as exc:
raise ValidationError(detail=f"Malformed usage key: {usage_key_string}") from exc

# Get unique downstream context keys for the given usage key
context_key_list = set(PublishableEntityLink.get_by_upstream_usage_key(
upstream_usage_key=usage_key
).values_list('downstream_context_key', flat=True))

course_overviews = CourseOverview.objects.filter(id__in=context_key_list).values_list('id', 'display_name')

result = []
for context_key, display_name in course_overviews:
result.append({
"id": str(context_key),
"display_name": display_name,
"url": reverse_course_url('course_handler', context_key),
})

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 @@ -10,6 +10,7 @@
from cms.djangoapps.contentstore.helpers import StaticFileNotices
from cms.lib.xblock.upstream_sync import BadUpstream, UpstreamLink
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
Expand Down Expand Up @@ -56,6 +57,7 @@ def setUp(self):
freezer.start()
self.maxDiff = 2000
self.course = CourseFactory.create()
CourseOverviewFactory.create(id=self.course.id, display_name=self.course.display_name)
chapter = BlockFactory.create(category='chapter', parent=self.course)
sequential = BlockFactory.create(category='sequential', parent=chapter)
unit = BlockFactory.create(category='vertical', parent=sequential)
Expand All @@ -66,6 +68,18 @@ 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")
CourseOverviewFactory.create(id=self.another_course.id, display_name=self.another_course.display_name)
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 +353,34 @@ 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.course.id),
'display_name': str(self.course.display_name),
'url': f'/course/{str(self.course.id)}',
},
{
'id': str(self.another_course.id),
'display_name': str(self.another_course.display_name),
'url': f'/course/{str(self.another_course.id)}',
},
]
self.assertListEqual(data, expected)
2 changes: 2 additions & 0 deletions cms/djangoapps/contentstore/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2383,6 +2383,8 @@ def create_or_update_xblock_upstream_link(xblock, course_key: str | CourseKey, c
except ObjectDoesNotExist:
log.error(f"Library component not found for {upstream_usage_key}")
lib_component = None

print(f"!!!!!!!!!!!!!!!!! xblock.parent -> {xblock.parent}")
PublishableEntityLink.update_or_create(
lib_component,
upstream_usage_key=xblock.upstream,
Expand Down
Loading