feat: entity link view and api (#36190)
Adds api for listing upstream entity links for a given course.
This commit is contained in:
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
|
||||
|
||||
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
|
||||
@@ -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")
|
||||
@@ -170,6 +190,18 @@ class PublishableEntityLink(models.Model):
|
||||
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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
@@ -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 = [
|
||||
@@ -23,6 +24,11 @@ urlpatterns = [
|
||||
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(),
|
||||
|
||||
@@ -62,19 +62,28 @@ import logging
|
||||
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.lib.xblock.upstream_sync import (
|
||||
UpstreamLink, UpstreamLinkException, NoUpstream, BadUpstream, BadDownstream,
|
||||
fetch_customizable_fields, sync_from_upstream, decline_sync, sever_upstream_link
|
||||
)
|
||||
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 cms.djangoapps.contentstore.models import PublishableEntityLink
|
||||
from cms.djangoapps.contentstore.rest_api.v2.serializers import PublishableEntityLinksSerializer
|
||||
from cms.lib.xblock.upstream_sync import (
|
||||
BadDownstream,
|
||||
BadUpstream,
|
||||
NoUpstream,
|
||||
UpstreamLink,
|
||||
UpstreamLinkException,
|
||||
decline_sync,
|
||||
fetch_customizable_fields,
|
||||
sever_upstream_link,
|
||||
sync_from_upstream,
|
||||
)
|
||||
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,
|
||||
@@ -82,7 +91,6 @@ from openedx.core.lib.api.view_utils import (
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
@@ -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)
|
||||
@@ -56,6 +63,9 @@ class _DownstreamViewTestMixin:
|
||||
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")
|
||||
@@ -63,6 +73,11 @@ class _DownstreamViewTestMixin:
|
||||
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?
|
||||
@@ -82,7 +97,7 @@ class _DownstreamViewTestMixin:
|
||||
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.
|
||||
"""
|
||||
@@ -128,7 +143,7 @@ class GetDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase)
|
||||
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.
|
||||
"""
|
||||
@@ -185,7 +200,7 @@ class PutDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase)
|
||||
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.
|
||||
"""
|
||||
@@ -214,7 +229,7 @@ class DeleteDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCa
|
||||
assert mock_sever.call_count == 1
|
||||
|
||||
|
||||
class _DownstreamSyncViewTestMixin(_DownstreamViewTestMixin):
|
||||
class _DownstreamSyncViewTestMixin(SharedErrorTestCases):
|
||||
"""
|
||||
Shared tests between the /api/contentstore/v2/downstreams/.../sync endpoints.
|
||||
"""
|
||||
@@ -277,3 +292,50 @@ class DeleteDownstreamSyncViewtest(_DownstreamSyncViewTestMixin, SharedModuleSto
|
||||
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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
%>
|
||||
@@ -67,6 +67,7 @@
|
||||
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">
|
||||
@@ -104,6 +105,11 @@
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user