Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Unit tests for /api/contentstore/v2/downstreams/* JSON APIs.
"""
from unittest.mock import patch
from django.conf import settings

from cms.lib.xblock.upstream_sync import UpstreamLink, BadUpstream
from common.djangoapps.student.tests.factories import UserFactory
Expand All @@ -12,7 +13,13 @@
from .. import downstreams as downstreams_views


MOCK_UPSTREAM_REF = "mock-upstream-ref"
MOCK_LIB_KEY = "lib:OpenedX:CSPROB3"
MOCK_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,
usage_key=MOCK_UPSTREAM_REF,
)
MOCK_UPSTREAM_ERROR = "your LibraryGPT subscription has expired"


Expand Down Expand Up @@ -92,6 +99,7 @@ def test_200_good_upstream(self):
assert response.data['upstream_ref'] == MOCK_UPSTREAM_REF
assert response.data['error_message'] is None
assert response.data['ready_to_sync'] is True
assert response.data['upstream_link'] == MOCK_UPSTREAM_LINK

@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_bad)
def test_200_bad_upstream(self):
Expand All @@ -104,6 +112,7 @@ def test_200_bad_upstream(self):
assert response.data['upstream_ref'] == MOCK_UPSTREAM_REF
assert response.data['error_message'] == MOCK_UPSTREAM_ERROR
assert response.data['ready_to_sync'] is False
assert response.data['upstream_link'] is None

def test_200_no_upstream(self):
"""
Expand All @@ -115,6 +124,7 @@ def test_200_no_upstream(self):
assert response.data['upstream_ref'] is None
assert "is not linked" in response.data['error_message']
assert response.data['ready_to_sync'] is False
assert response.data['upstream_link'] is None


class PutDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase):
Expand Down
2 changes: 1 addition & 1 deletion cms/djangoapps/contentstore/views/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
"add-xblock-component-support-legend", "add-xblock-component-support-level", "add-xblock-component-menu-problem",
"xblock-string-field-editor", "xblock-access-editor", "publish-xblock", "publish-history", "tag-list",
"unit-outline", "container-message", "container-access", "license-selector", "copy-clipboard-button",
"edit-title-button",
"edit-title-button", "edit-upstream-alert",
]


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,10 @@ def _create_block(request):
boilerplate=request.json.get("boilerplate"),
)

response = {
"locator": str(created_block.location),
"courseKey": str(created_block.location.course_key),
}
# If it contains library_content_key, the block is being imported from a v2 library
# so it needs to be synced with upstream block.
if upstream_ref := request.json.get("library_content_key"):
Expand All @@ -602,13 +606,9 @@ def _create_block(request):
)
return JsonResponse({"error": str(exc)}, status=400)
modulestore().update_item(created_block, request.user.id)
response['upstreamRef'] = upstream_ref

return JsonResponse(
{
"locator": str(created_block.location),
"courseKey": str(created_block.location.course_key),
}
)
return JsonResponse(response)


def _get_source_index(source_usage_key, source_parent):
Expand Down
26 changes: 26 additions & 0 deletions cms/lib/xblock/upstream_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import typing as t
from dataclasses import dataclass, asdict

from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import NotFound
Expand Down Expand Up @@ -90,13 +91,27 @@ def ready_to_sync(self) -> bool:
self.version_available > (self.version_declined or 0)
)

@property
def upstream_link(self) -> str | None:
"""
Link to edit/view upstream block in library.
"""
if self.version_available is None or self.upstream_ref is None:
return None
try:
usage_key = LibraryUsageLocatorV2.from_string(self.upstream_ref)
except InvalidKeyError:
return None
return _get_library_xblock_url(usage_key)

def to_json(self) -> dict[str, t.Any]:
"""
Get an JSON-API-friendly representation of this upstream link.
"""
return {
**asdict(self),
"ready_to_sync": self.ready_to_sync,
"upstream_link": self.upstream_link,
}

@classmethod
Expand Down Expand Up @@ -349,6 +364,17 @@ def sever_upstream_link(downstream: XBlock) -> None:
setattr(downstream, fetched_upstream_field, None) # Null out upstream_display_name, et al.


def _get_library_xblock_url(usage_key: LibraryUsageLocatorV2):
"""
Gets authoring url for given library_key.
"""
library_url = None
if mfe_base_url := settings.COURSE_AUTHORING_MICROFRONTEND_URL: # type: ignore
library_key = usage_key.lib_key
library_url = f'{mfe_base_url}/library/{library_key}/components?usageKey={usage_key}'
return library_url


class UpstreamSyncMixin(XBlockMixin):
"""
Allows an XBlock in the CMS to be associated & synced with an upstream.
Expand Down
1 change: 1 addition & 0 deletions cms/static/js/spec_helpers/edit_helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ installEditTemplates = function(append) {
TemplateHelpers.installTemplate('edit-xblock-modal');
TemplateHelpers.installTemplate('editor-mode-button');
TemplateHelpers.installTemplate('edit-title-button');
TemplateHelpers.installTemplate('edit-upstream-alert');

// Add templates needed by the settings editor
TemplateHelpers.installTemplate('metadata-editor');
Expand Down
22 changes: 22 additions & 0 deletions cms/static/js/views/modals/edit_xblock.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,27 @@ function($, _, Backbone, gettext, BaseModal, ViewUtils, XBlockViewUtils, XBlockE
this.$('.modal-window-title').html(this.loadTemplate('edit-title-button')({title: title}));
},

createWarningToast: function(upstreamLink) {
// xss-lint: disable=javascript-jquery-insertion
this.$('.modal-header').before(this.loadTemplate('edit-upstream-alert')({
upstreamLink: upstreamLink,
}));
},

getXBlockUpstreamLink: function() {
const usageKey = this.xblockElement.data('locator');
$.ajax({
url: '/api/contentstore/v2/downstreams/' + usageKey,
type: 'GET',
success: function(data) {
if (data?.upstream_link) {
this.createWarningToast(data.upstream_link);
}
}.bind(this),
notifyOnError: false,
})
},

onDisplayXBlock: function() {
var editorView = this.editorView,
title = this.getTitle(),
Expand All @@ -101,6 +122,7 @@ function($, _, Backbone, gettext, BaseModal, ViewUtils, XBlockViewUtils, XBlockE
} else {
this.$('.modal-window-title').text(title);
}
this.getXBlockUpstreamLink();

// If the xblock is not using custom buttons then choose which buttons to show
if (!editorView.hasCustomButtons()) {
Expand Down
13 changes: 10 additions & 3 deletions cms/static/js/views/pages/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,13 @@ function($, _, Backbone, gettext, BasePage,
|| (useNewVideoEditor === 'True' && blockType === 'video')
|| (useNewProblemEditor === 'True' && blockType === 'problem')
) {
var destinationUrl = primaryHeader.attr('authoring_MFE_base_url') + '/' + blockType + '/' + encodeURI(primaryHeader.attr('data-usage-id'));
var destinationUrl = primaryHeader.attr('authoring_MFE_base_url')
+ '/' + blockType
+ '/' + encodeURI(primaryHeader.attr('data-usage-id'));
var upstreamRef = primaryHeader.attr('data-upstream-ref');
if(upstreamRef) {
destinationUrl += '?upstreamLibRef=' + upstreamRef;
}
window.location.href = destinationUrl;
return;
}
Expand Down Expand Up @@ -806,9 +812,10 @@ function($, _, Backbone, gettext, BasePage,
var matchBlockTypeFromLocator = /\@(.*?)\+/;
var blockType = data.locator.match(matchBlockTypeFromLocator);
}
if((useNewTextEditor === 'True' && blockType.includes('html'))
// open mfe editors for new blocks only and not for content imported from libraries
if(!data.hasOwnProperty('upstreamRef') && ((useNewTextEditor === 'True' && blockType.includes('html'))
|| (useNewVideoEditor === 'True' && blockType.includes('video'))
|| (useNewProblemEditor === 'True' && blockType.includes('problem'))
|| (useNewProblemEditor === 'True' && blockType.includes('problem')))
){
var destinationUrl;
if (useVideoGalleryFlow === "True" && blockType.includes("video")) {
Expand Down
35 changes: 35 additions & 0 deletions cms/static/sass/views/_container.scss
Original file line number Diff line number Diff line change
Expand Up @@ -595,3 +595,38 @@
}
}
}

.modal-alert {
display: flex;
align-items: center;
padding: $baseline;
margin-bottom: $baseline;

&.warning {
background-color: $state-warning-bg;

.fa-warning {
color: $orange;
margin-right: $baseline;
}
}

.action-btn {
text-wrap: nowrap;
background-color: $black;
color: $white;
margin-left: auto;
padding: ($baseline/2);

.fa {
margin-left: ($baseline/4);
color: $white
}

&:hover,
&:focus,
&:active {
background-color: lighten($black, 20%);
}
}
}
18 changes: 18 additions & 0 deletions cms/templates/js/edit-upstream-alert.underscore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<div class="modal-alert warning" role="alert">
<span class="fa fa-lg fa-warning" aria-hidden="true"/><span class="sr"><%- gettext("Warning") %></span>
<div class="message-content">
<strong><%- gettext("You are editing content from a Library.") %></strong>
<p>
<%- gettext("Edits made here will only be reflected in this course. These edits may be overridden later if updates are accepted.") %>
</p>
</div>
<a
href="<%- upstreamLink %>"
target="_blank"
class="btn action-btn"
>
<%- gettext('View in Library') %>
<span class="fa fa-external-link" aria-hidden="true" />
</a>
</div>