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: entity link view and api #36190

Merged
Merged
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
32 changes: 32 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 @@ -115,6 +116,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 Down Expand Up @@ -170,6 +190,18 @@ 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"
)


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']
8 changes: 7 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,11 @@
downstreams.DownstreamView.as_view(),
name="downstream"
),
re_path(
f'^upstreams/{settings.COURSE_KEY_PATTERN}$',
downstreams.UpstreamListView.as_view(),
name='upstream-list'
),
re_path(
fr'^downstreams/{settings.USAGE_KEY_PATTERN}/sync$',
downstreams.SyncFromUpstreamView.as_view(),
Expand Down
38 changes: 32 additions & 6 deletions cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,27 +62,35 @@
from attrs import asdict as attrs_asdict
from django.contrib.auth.models import User # pylint: disable=imported-auth-user
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.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.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 +119,24 @@ 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(is_authenticated=True)
class DownstreamView(DeveloperErrorViewMixin, APIView):
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
"""
Unit tests for /api/contentstore/v2/downstreams/* JSON APIs.
"""
from datetime import datetime, timezone
from unittest.mock import patch

from django.conf import settings
from freezegun import freeze_time

from cms.djangoapps.contentstore.helpers import StaticFileNotices
from cms.lib.xblock.upstream_sync import UpstreamLink, BadUpstream
from cms.lib.xblock.upstream_sync import BadUpstream, UpstreamLink
from common.djangoapps.student.tests.factories import UserFactory
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory

from .. import downstreams as downstreams_views


MOCK_LIB_KEY = "lib:OpenedX:CSPROB3"
MOCK_UPSTREAM_REF = "lb:OpenedX:CSPROB3:html:843b4c73-1e2d-4ced-a0ff-24e503cdb3e4"
MOCK_UPSTREAM_REF = "lb:OpenedX:CSPROB3:video:843b4c73-1e2d-4ced-a0ff-24e503cdb3e4"
MOCK_HTML_UPSTREAM_REF = "lb:OpenedX:CSPROB3:html:843b4c73-1e2d-4ced-a0ff-24e503cdb3e4"
MOCK_UPSTREAM_LINK = "{mfe_url}/library/{lib_key}/components?usageKey={usage_key}".format(
mfe_url=settings.COURSE_AUTHORING_MICROFRONTEND_URL,
lib_key=MOCK_LIB_KEY,
Expand All @@ -38,16 +41,20 @@ def _get_upstream_link_bad(_downstream):
raise BadUpstream(MOCK_UPSTREAM_ERROR)


class _DownstreamViewTestMixin:
class _BaseDownstreamViewTestMixin:
"""
Shared data and error test cases.
"""

def setUp(self):
"""
Create a simple course with one unit and two videos, one of which is linked to an "upstream".
"""
super().setUp()
self.now = datetime.now(timezone.utc)
freezer = freeze_time(self.now)
self.addCleanup(freezer.stop)
freezer.start()
self.maxDiff = 2000
self.course = CourseFactory.create()
chapter = BlockFactory.create(category='chapter', parent=self.course)
sequential = BlockFactory.create(category='sequential', parent=chapter)
Expand All @@ -56,13 +63,21 @@ def setUp(self):
self.downstream_video_key = BlockFactory.create(
category='video', parent=unit, upstream=MOCK_UPSTREAM_REF, upstream_version=123,
).usage_key
self.downstream_html_key = BlockFactory.create(
category='html', parent=unit, upstream=MOCK_HTML_UPSTREAM_REF, upstream_version=1,
).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")

def call_api(self, usage_key_string):
raise NotImplementedError


class SharedErrorTestCases(_BaseDownstreamViewTestMixin):
"""
Shared error test cases.
"""
def test_404_downstream_not_found(self):
"""
Do we raise 404 if the specified downstream block could not be loaded?
Expand All @@ -82,7 +97,7 @@ def test_404_downstream_not_accessible(self):
assert "not found" in response.data["developer_message"]


class GetDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase):
class GetDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase):
"""
Test that `GET /api/v2/contentstore/downstreams/...` inspects a downstream's link to an upstream.
"""
Expand Down Expand Up @@ -128,7 +143,7 @@ def test_200_no_upstream(self):
assert response.data['upstream_link'] is None


class PutDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase):
class PutDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase):
"""
Test that `PUT /api/v2/contentstore/downstreams/...` edits a downstream's link to an upstream.
"""
Expand Down Expand Up @@ -185,7 +200,7 @@ def test_400(self, sync: str):
assert video_after.upstream is None


class DeleteDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase):
class DeleteDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase):
"""
Test that `DELETE /api/v2/contentstore/downstreams/...` severs a downstream's link to an upstream.
"""
Expand Down Expand Up @@ -214,7 +229,7 @@ def test_204_no_upstream(self, mock_sever):
assert mock_sever.call_count == 1


class _DownstreamSyncViewTestMixin(_DownstreamViewTestMixin):
class _DownstreamSyncViewTestMixin(SharedErrorTestCases):
"""
Shared tests between the /api/contentstore/v2/downstreams/.../sync endpoints.
"""
Expand Down Expand Up @@ -277,3 +292,50 @@ def test_204(self, mock_decline_sync):
response = self.call_api(self.downstream_video_key)
assert response.status_code == 204
assert mock_decline_sync.call_count == 1


class GetUpstreamViewTest(_BaseDownstreamViewTestMixin, SharedModuleStoreTestCase):
"""
Test that `GET /api/v2/contentstore/upstreams/...` returns list of links in given downstream context i.e. course.
"""
def call_api(self, usage_key_string):
return self.client.get(f"/api/contentstore/v2/upstreams/{usage_key_string}")

def test_200_all_upstreams(self):
"""
Returns all upstream links for given course
"""
self.client.login(username="superuser", password="password")
response = self.call_api(self.course.id)
assert response.status_code == 200
data = response.json()
date_format = self.now.isoformat().split("+")[0] + 'Z'
expected = [
{
'created': date_format,
'downstream_context_key': str(self.course.id),
'downstream_usage_key': str(self.downstream_video_key),
'id': 1,
'ready_to_sync': False,
'updated': date_format,
'upstream_context_key': MOCK_LIB_KEY,
'upstream_usage_key': MOCK_UPSTREAM_REF,
'upstream_version': None,
'version_declined': None,
'version_synced': 123
},
{
'created': date_format,
'downstream_context_key': str(self.course.id),
'downstream_usage_key': str(self.downstream_html_key),
'id': 2,
'ready_to_sync': False,
'updated': date_format,
'upstream_context_key': MOCK_LIB_KEY,
'upstream_usage_key': MOCK_HTML_UPSTREAM_REF,
'upstream_version': None,
'version_declined': None,
'version_synced': 1,
},
]
self.assertListEqual(data, expected)
12 changes: 12 additions & 0 deletions cms/djangoapps/contentstore/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,18 @@ def get_custom_pages_url(course_locator) -> str:
return custom_pages_url


def get_course_libraries_url(course_locator) -> str:
"""
Gets course authoring microfrontend URL for custom pages view.
"""
url = None
if libraries_v2_enabled():
mfe_base_url = get_course_authoring_url(course_locator)
if mfe_base_url:
url = f'{mfe_base_url}/course/{course_locator}/libraries'
return url


def get_taxonomy_list_url() -> str | None:
"""
Gets course authoring microfrontend URL for taxonomy list page view.
Expand Down
8 changes: 7 additions & 1 deletion cms/templates/widgets/header.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from urllib.parse import quote_plus
from common.djangoapps.student.auth import has_studio_advanced_settings_access
from cms.djangoapps.contentstore import toggles
from cms.djangoapps.contentstore.utils import get_pages_and_resources_url, get_course_outline_url, get_updates_url, get_files_uploads_url, get_video_uploads_url, get_schedule_details_url, get_grading_url, get_advanced_settings_url, get_import_url, get_export_url, get_studio_home_url, get_course_team_url, get_optimizer_url
from cms.djangoapps.contentstore.utils import get_pages_and_resources_url, get_course_outline_url, get_course_libraries_url, get_updates_url, get_files_uploads_url, get_video_uploads_url, get_schedule_details_url, get_grading_url, get_advanced_settings_url, get_import_url, get_export_url, get_studio_home_url, get_course_team_url, get_optimizer_url
from openedx.core.djangoapps.discussions.config.waffle import ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND
from openedx.core.djangoapps.lang_pref.api import header_language_selector_is_enabled, released_languages
%>
Expand Down Expand Up @@ -67,6 +67,7 @@ <h1 class="branding">
import_mfe_enabled = toggles.use_new_import_page(context_course.id)
export_mfe_enabled = toggles.use_new_export_page(context_course.id)
optimizer_enabled = toggles.enable_course_optimizer(context_course.id)
libraries_v2_enabled = toggles.libraries_v2_enabled()

%>
<h2 class="info-course">
Expand Down Expand Up @@ -104,6 +105,11 @@ <h3 class="title"><span class="label"><span class="label-prefix sr">${_("Course"
<a href="${get_course_outline_url(course_key)}">${_("Outline")}</a>
</li>
% endif
% if libraries_v2_enabled:
<li class="nav-item nav-course-courseware-outline">
<a href="${get_course_libraries_url(course_key)}">${_("Libraries")}</a>
</li>
% endif
% if not updates_mfe_enabled:
<li class="nav-item nav-course-courseware-updates">
<a href="${course_info_url}">${_("Updates")}</a>
Expand Down
Loading