From a1d0826a2a432d70aa677fe031ae734cf7dc39a3 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 18 Feb 2025 15:34:00 +0000 Subject: [PATCH] feat: entity link view and api (#36190) Adds api for listing upstream entity links for a given course. --- cms/djangoapps/contentstore/models.py | 32 ++++++++ .../rest_api/v2/serializers/__init__.py | 1 + .../rest_api/v2/serializers/downstreams.py | 28 +++++++ .../contentstore/rest_api/v2/urls.py | 8 +- .../rest_api/v2/views/downstreams.py | 38 +++++++-- .../v2/views/tests/test_downstreams.py | 82 ++++++++++++++++--- cms/djangoapps/contentstore/utils.py | 12 +++ cms/templates/widgets/header.html | 8 +- 8 files changed, 191 insertions(+), 18 deletions(-) create mode 100644 cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py diff --git a/cms/djangoapps/contentstore/models.py b/cms/djangoapps/contentstore/models.py index 6a1750b8e1c..99ff47f9763 100644 --- a/cms/djangoapps/contentstore/models.py +++ b/cms/djangoapps/contentstore/models.py @@ -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 @@ -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 @@ 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): """ diff --git a/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py index 6e102bab44a..83bc02b685e 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py @@ -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 diff --git a/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py new file mode 100644 index 00000000000..930408040e6 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py @@ -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'] diff --git a/cms/djangoapps/contentstore/rest_api/v2/urls.py b/cms/djangoapps/contentstore/rest_api/v2/urls.py index 3e653d07fbc..690e7879933 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v2/urls.py @@ -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 @@ 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(), diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py index 46e67e87ea0..94931acc8ff 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py @@ -62,19 +62,28 @@ 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, @@ -82,7 +91,6 @@ 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): """ diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py index 31877b9153d..ec7c842d163 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py @@ -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 @@ 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") @@ -63,6 +73,11 @@ def setUp(self): 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 @@ 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. """ @@ -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. """ @@ -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. """ @@ -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. """ @@ -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) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 79d8f757a7d..b7ec8c9d107 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -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. diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 941fe8d5e6a..af50a3c321a 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -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() %>

@@ -104,6 +105,11 @@

${_("Course" ${_("Outline")} % endif + % if libraries_v2_enabled: + + % endif % if not updates_mfe_enabled: