Skip to content

Commit

Permalink
Merge branch 'openedx:master' into jci/issue35245
Browse files Browse the repository at this point in the history
  • Loading branch information
jciasenza authored Feb 19, 2025
2 parents b3df781 + 45f44c3 commit ffeef6a
Show file tree
Hide file tree
Showing 78 changed files with 2,080 additions and 1,993 deletions.
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
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)
28 changes: 26 additions & 2 deletions cms/djangoapps/contentstore/signals/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,17 @@
from django.dispatch import receiver
from edx_toggles.toggles import SettingToggle
from opaque_keys.edx.keys import CourseKey
from openedx_events.content_authoring.data import CourseCatalogData, CourseData, CourseScheduleData, XBlockData
from openedx_events.content_authoring.data import (
CourseCatalogData,
CourseData,
CourseScheduleData,
LibraryBlockData,
XBlockData,
)
from openedx_events.content_authoring.signals import (
COURSE_CATALOG_INFO_CHANGED,
COURSE_IMPORT_COMPLETED,
LIBRARY_BLOCK_DELETED,
XBLOCK_CREATED,
XBLOCK_DELETED,
XBLOCK_UPDATED,
Expand All @@ -38,7 +45,11 @@
from xmodule.modulestore.exceptions import ItemNotFoundError

from ..models import PublishableEntityLink
from ..tasks import create_or_update_upstream_links, handle_create_or_update_xblock_upstream_link
from ..tasks import (
create_or_update_upstream_links,
handle_create_or_update_xblock_upstream_link,
handle_unlink_upstream_block,
)
from .signals import GRADING_POLICY_CHANGED

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -287,3 +298,16 @@ def handle_new_course_import(**kwargs):
force=True,
replace=True
)


@receiver(LIBRARY_BLOCK_DELETED)
def unlink_upstream_block_handler(**kwargs):
"""
Handle unlinking the upstream (library) block from any downstream (course) blocks.
"""
library_block = kwargs.get("library_block", None)
if not library_block or not isinstance(library_block, LibraryBlockData):
log.error("Received null or incorrect data for event")
return

handle_unlink_upstream_block.delay(str(library_block.usage_key))
Loading

0 comments on commit ffeef6a

Please sign in to comment.