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 #739

Closed
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
1 change: 1 addition & 0 deletions .github/workflows/unit-test-shards.json
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@
"cms/djangoapps/cms_user_tasks/",
"cms/djangoapps/course_creators/",
"cms/djangoapps/export_course_metadata/",
"cms/djangoapps/maintenance/",
"cms/djangoapps/models/",
"cms/djangoapps/pipeline_js/",
"cms/djangoapps/xblock_config/",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 4.2.18 on 2025-02-19 23:32

from django.db import migrations
from opaque_keys.edx.django.models import UsageKeyField
from opaque_keys.edx.keys import UsageKey


class Migration(migrations.Migration):

dependencies = [
('contentstore', '0009_learningcontextlinksstatus_publishableentitylink'),
]

operations = [
migrations.AddField(
model_name='publishableentitylink',
name='downstream_parent_usage_key',
field=UsageKeyField(
db_index=True,
# Adds a default invalid value to the field to prevent the migration from failing
default=UsageKey.from_string('block-v1:edX+DemoX+Demo_Course+type@vertical+block@invalid'),
max_length=255
),
),
migrations.AlterField(
model_name='publishableentitylink',
name='downstream_parent_usage_key',
field=UsageKeyField(db_index=True, max_length=255),
),
]
45 changes: 45 additions & 0 deletions cms/djangoapps/contentstore/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from config_models.models import ConfigurationModel
from django.db import models
from django.db.models import QuerySet
from django.db.models.fields import IntegerField, TextField
from django.utils.translation import gettext_lazy as _
from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField
Expand Down Expand Up @@ -105,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 All @@ -115,6 +118,25 @@ class PublishableEntityLink(models.Model):
def __str__(self):
return f"{self.upstream_usage_key}->{self.downstream_usage_key}"

@property
def upstream_version(self) -> int | None:
"""
Returns upstream block version number if available.
"""
version_num = None
if hasattr(self.upstream_block, 'published'):
if hasattr(self.upstream_block.published, 'version'):
if hasattr(self.upstream_block.published.version, 'version_num'):
version_num = self.upstream_block.published.version.version_num
return version_num

@property
def upstream_context_title(self) -> str:
"""
Returns upstream context title.
"""
return self.upstream_block.learning_package.title

class Meta:
verbose_name = _("Publishable Entity Link")
verbose_name_plural = _("Publishable Entity Links")
Expand All @@ -127,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 @@ -141,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 @@ -170,6 +194,27 @@ def update_or_create(
link.save()
return link

@classmethod
def get_by_downstream_context(cls, downstream_context_key: CourseKey) -> QuerySet["PublishableEntityLink"]:
"""
Get all links for given downstream context, preselects related published version and learning package.
"""
return cls.objects.filter(
downstream_context_key=downstream_context_key
).select_related(
"upstream_block__published__version",
"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
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
"""Module for v2 serializers."""

from cms.djangoapps.contentstore.rest_api.v2.serializers.downstreams import PublishableEntityLinksSerializer
from cms.djangoapps.contentstore.rest_api.v2.serializers.home import CourseHomeTabSerializerV2
28 changes: 28 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""
Serializers for upstream -> downstream entity links.
"""

from rest_framework import serializers

from cms.djangoapps.contentstore.models import PublishableEntityLink


class PublishableEntityLinksSerializer(serializers.ModelSerializer):
"""
Serializer for publishable entity links.
"""
upstream_context_title = serializers.CharField(read_only=True)
upstream_version = serializers.IntegerField(read_only=True)
ready_to_sync = serializers.SerializerMethodField()

def get_ready_to_sync(self, obj):
"""Calculate ready_to_sync field"""
return bool(
obj.upstream_version and
obj.upstream_version > (obj.version_synced or 0) and
obj.upstream_version > (obj.version_declined or 0)
)

class Meta:
model = PublishableEntityLink
exclude = ['upstream_block', 'uuid']
13 changes: 12 additions & 1 deletion cms/djangoapps/contentstore/rest_api/v2/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
from django.conf import settings
from django.urls import path, re_path

from cms.djangoapps.contentstore.rest_api.v2.views import home, downstreams
from cms.djangoapps.contentstore.rest_api.v2.views import downstreams, home

app_name = "v2"

urlpatterns = [
Expand All @@ -23,6 +24,16 @@
downstreams.DownstreamView.as_view(),
name="downstream"
),
re_path(
f'^upstreams/{settings.COURSE_KEY_PATTERN}$',
downstreams.UpstreamListView.as_view(),
name='upstream-list'
),
re_path(
f'^upstream/{settings.USAGE_KEY_PATTERN}/downstream-contexts$',
Copy link
Member Author

@rpenido rpenido Feb 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not happy with my URL choice here. I was trying to have a structure like parent/{parentId}/children instead of children/{parentId}, but I`m not sure if this is clear close to the other endpoints.

Do you think there is a better way to make it consistent with the other URLs?

downstreams.DownstreamContextListView.as_view(),
name='downstream-context-list'
),
re_path(
fr'^downstreams/{settings.USAGE_KEY_PATTERN}/sync$',
downstreams.SyncFromUpstreamView.as_view(),
Expand Down
120 changes: 114 additions & 6 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 All @@ -58,31 +63,43 @@
"""

import logging
from itertools import groupby

from attrs import asdict as attrs_asdict
from django.contrib.auth.models import User # pylint: disable=imported-auth-user
from django.utils.translation import gettext_lazy as _
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey
from opaque_keys.edx.keys import CourseKey, UsageKey
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from xblock.core import XBlock

from cms.djangoapps.contentstore.helpers import import_static_assets_for_library_sync
from cms.djangoapps.contentstore.models import PublishableEntityLink
from cms.djangoapps.contentstore.rest_api.v2.serializers import PublishableEntityLinksSerializer
from cms.djangoapps.contentstore.utils import reverse_course_url, reverse_usage_url
from cms.lib.xblock.upstream_sync import (
UpstreamLink, UpstreamLinkException, NoUpstream, BadUpstream, BadDownstream,
fetch_customizable_fields, sync_from_upstream, decline_sync, sever_upstream_link
BadDownstream,
BadUpstream,
NoUpstream,
UpstreamLink,
UpstreamLinkException,
decline_sync,
fetch_customizable_fields,
sever_upstream_link,
sync_from_upstream,
)
from cms.djangoapps.contentstore.helpers import import_static_assets_for_library_sync
from common.djangoapps.student.auth import has_studio_write_access, has_studio_read_access
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,
)
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError


logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -111,6 +128,97 @@ class _AuthenticatedRequest(Request):
# ...


@view_auth_classes()
class UpstreamListView(DeveloperErrorViewMixin, APIView):
"""
Serves course->library publishable entity links
"""
def get(self, request: _AuthenticatedRequest, course_key_string: str):
"""
Fetches publishable entity links for given course key
"""
try:
course_key = CourseKey.from_string(course_key_string)
except InvalidKeyError as exc:
raise ValidationError(detail=f"Malformed course key: {course_key_string}") from exc
links = PublishableEntityLink.get_by_downstream_context(downstream_context_key=course_key)
serializer = PublishableEntityLinksSerializer(links, many=True)
return Response(serializer.data)


@view_auth_classes()
class DownstreamContextListView(DeveloperErrorViewMixin, APIView):
"""
Serves library block->course->container links
"""
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

links = (
PublishableEntityLink
.get_by_upstream_usage_key(upstream_usage_key=usage_key)
.order_by("downstream_context_key", "downstream_parent_usage_key")
.values("downstream_usage_key", "downstream_context_key", "downstream_parent_usage_key")
)

context_key_list = set(link["downstream_context_key"] for link in links)

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

result = []
for context_key, links_by_context in groupby(links, lambda x: x["downstream_context_key"]):
if context_key not in courses_display_name:
raise BadDownstream(_("Course {context_key} not found").format(context_key=context_key))

course_link = {
"id": str(context_key),
"display_name": courses_display_name[context_key],
"url": reverse_course_url("course_handler", context_key),
"containers": []
}

for parent_key, links_by_unit in groupby(links_by_context, lambda x: x["downstream_parent_usage_key"]):
try:
parent = modulestore().get_item(parent_key)

parent_link = {
"id": str(parent_key),
"display_name": parent.display_name,
"url": reverse_usage_url("container_handler", parent_key),
"links": []
}

for downstream_link in links_by_unit:
parent_link["links"].append({
"id": str(downstream_link["downstream_usage_key"]),
})

# Don't include empty containers
if parent_link["links"]:
course_link["containers"].append(parent_link)

except ItemNotFoundError:
logger.exception(
"Unit %s not found in course %s",
parent_key,
context_key
)

# Don't include empty courses
if course_link["containers"]:
result.append(course_link)

return Response(result)


@view_auth_classes(is_authenticated=True)
class DownstreamView(DeveloperErrorViewMixin, APIView):
"""
Expand Down
Loading