Skip to content

Commit

Permalink
Merge branch 'master' into chris/FAL-4029-fix-confitional-word-cloud
Browse files Browse the repository at this point in the history
  • Loading branch information
navinkarkera authored Feb 19, 2025
2 parents d5cb2bc + a1d0826 commit 123bd9a
Show file tree
Hide file tree
Showing 8 changed files with 191 additions and 18 deletions.
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

0 comments on commit 123bd9a

Please sign in to comment.