Merge branch 'master' into edx-depr31

This commit is contained in:
Yagnesh1998
2023-08-04 10:28:54 +05:30
committed by GitHub
37 changed files with 731 additions and 947 deletions

View File

@@ -18,6 +18,7 @@ from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
from xmodule.exceptions import NotFoundError
from xmodule.modulestore.django import modulestore
from xmodule.xml_block import XmlMixin
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
import openedx.core.djangoapps.content_staging.api as content_staging_api
@@ -238,7 +239,6 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
if not user_clipboard:
# Clipboard is empty or expired/error/loading
return None, StaticFileNotices()
block_type = user_clipboard.content.block_type
olx_str = content_staging_api.get_staged_content_olx(user_clipboard.content.id)
static_files = content_staging_api.get_staged_content_static_files(user_clipboard.content.id)
node = etree.fromstring(olx_str)
@@ -246,35 +246,15 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
with store.bulk_operations(parent_key.course_key):
parent_descriptor = store.get_item(parent_key)
# Some blocks like drag-and-drop only work here with the full XBlock runtime loaded:
parent_xblock = _load_preview_block(request, parent_descriptor)
runtime = parent_xblock.runtime
# Generate the new ID:
id_generator = ImportIdGenerator(parent_key.context_key)
def_id = id_generator.create_definition(block_type, user_clipboard.source_usage_key.block_id)
usage_id = id_generator.create_usage(def_id)
keys = ScopeIds(None, block_type, def_id, usage_id)
# parse_xml is a really messy API. We pass both 'keys' and 'id_generator' and, depending on the XBlock, either
# one may be used to determine the new XBlock's usage key, and the other will be ignored. e.g. video ignores
# 'keys' and uses 'id_generator', but the default XBlock parse_xml ignores 'id_generator' and uses 'keys'.
# For children of this block, obviously only id_generator is used.
xblock_class = runtime.load_block_type(block_type)
# Note: if we find a case where any XBlock needs access to the block-specific static files that were saved to
# export_fs during copying, we could make them available here via runtime.resources_fs before calling parse_xml.
# However, currently the only known case for that is video block's transcript files, and those will
# automatically be "carried over" to the new XBlock even in a different course because the video ID is the same,
# and VAL will thus make the transcript available.
temp_xblock = xblock_class.parse_xml(node, runtime, keys, id_generator)
if xblock_class.has_children and temp_xblock.children:
raise NotImplementedError("We don't yet support pasting XBlocks with children")
temp_xblock.parent = parent_key
# Store a reference to where this block was copied from, in the 'copied_from_block' field (AuthoringMixin)
temp_xblock.copied_from_block = str(user_clipboard.source_usage_key)
# Save the XBlock into modulestore. We need to save the block and its parent for this to work:
new_xblock = store.update_item(temp_xblock, request.user.id, allow_not_found=True)
parent_xblock.children.append(new_xblock.location)
store.update_item(parent_xblock, request.user.id)
new_xblock = _import_xml_node_to_parent(
node,
parent_xblock,
store,
user_id=request.user.id,
slug_hint=user_clipboard.source_usage_key.block_id,
copied_from_block=str(user_clipboard.source_usage_key),
)
# Now handle static files that need to go into Files & Uploads:
notices = _import_files_into_course(
course_key=parent_key.context_key,
@@ -285,6 +265,80 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
return new_xblock, notices
def _import_xml_node_to_parent(
node,
parent_xblock: XBlock,
# The modulestore we're using
store,
# The ID of the user who is performing this operation
user_id: int,
# Hint to use as usage ID (block_id) for the new XBlock
slug_hint: str | None = None,
# UsageKey of the XBlock that this one is a copy of
copied_from_block: str | None = None,
) -> XBlock:
"""
Given an XML node representing a serialized XBlock (OLX), import it into modulestore 'store' as a child of the
specified parent block. Recursively copy children as needed.
"""
runtime = parent_xblock.runtime
parent_key = parent_xblock.scope_ids.usage_id
block_type = node.tag
# Generate the new ID:
id_generator = ImportIdGenerator(parent_key.context_key)
def_id = id_generator.create_definition(block_type, slug_hint)
usage_id = id_generator.create_usage(def_id)
keys = ScopeIds(None, block_type, def_id, usage_id)
# parse_xml is a really messy API. We pass both 'keys' and 'id_generator' and, depending on the XBlock, either
# one may be used to determine the new XBlock's usage key, and the other will be ignored. e.g. video ignores
# 'keys' and uses 'id_generator', but the default XBlock parse_xml ignores 'id_generator' and uses 'keys'.
# For children of this block, obviously only id_generator is used.
xblock_class = runtime.load_block_type(block_type)
# Note: if we find a case where any XBlock needs access to the block-specific static files that were saved to
# export_fs during copying, we could make them available here via runtime.resources_fs before calling parse_xml.
# However, currently the only known case for that is video block's transcript files, and those will
# automatically be "carried over" to the new XBlock even in a different course because the video ID is the same,
# and VAL will thus make the transcript available.
child_nodes = []
if not xblock_class.has_children:
# No children to worry about. The XML may contain child nodes, but they're not XBlocks.
temp_xblock = xblock_class.parse_xml(node, runtime, keys, id_generator)
else:
# We have to handle the children ourselves, because there are lots of complex interactions between
# * the vanilla XBlock parse_xml() method, and its lack of API for "create and save a new XBlock"
# * the XmlMixin version of parse_xml() which only works with ImportSystem, not modulestore or the v2 runtime
# * the modulestore APIs for creating and saving a new XBlock, which work but don't support XML parsing.
# We can safely assume that if the XBLock class supports children, every child node will be the XML
# serialization of a child block, in order. For blocks that don't support children, their XML content/nodes
# could be anything (e.g. HTML, capa)
node_without_children = etree.Element(node.tag, **node.attrib)
if issubclass(xblock_class, XmlMixin):
# Hack: XBlocks that use "XmlMixin" have their own XML parsing behavior, and in particular if they encounter
# an XML node that has no children and has only a "url_name" attribute, they'll try to load the XML data
# from an XML file in runtime.resources_fs. But that file doesn't exist here. So we set at least one
# additional attribute here to make sure that url_name is not the only attribute; otherwise in some cases,
# XmlMixin.parse_xml will try to load an XML file that doesn't exist, giving an error. The name and value
# of this attribute don't matter and should be ignored.
node_without_children.attrib["x-is-pointer-node"] = "no"
temp_xblock = xblock_class.parse_xml(node_without_children, runtime, keys, id_generator)
child_nodes = list(node)
if xblock_class.has_children and temp_xblock.children:
raise NotImplementedError("We don't yet support pasting XBlocks with children")
temp_xblock.parent = parent_key
if copied_from_block:
# Store a reference to where this block was copied from, in the 'copied_from_block' field (AuthoringMixin)
temp_xblock.copied_from_block = copied_from_block
# Save the XBlock into modulestore. We need to save the block and its parent for this to work:
new_xblock = store.update_item(temp_xblock, user_id, allow_not_found=True)
parent_xblock.children.append(new_xblock.location)
store.update_item(parent_xblock, user_id)
for child_node in child_nodes:
_import_xml_node_to_parent(child_node, new_xblock, store, user_id=user_id)
return new_xblock
def _import_files_into_course(
course_key: CourseKey,
staged_content_id: int,

View File

@@ -3,7 +3,6 @@
from django import forms
import edx_api_doc_tools as apidocs
from opaque_keys.edx.keys import CourseKey
from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
@@ -12,7 +11,6 @@ from xmodule.modulestore.django import modulestore
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
from cms.djangoapps.contentstore.api.views.utils import get_bool_param
from cms.djangoapps.contentstore.toggles import use_new_advanced_settings_page
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
from ..serializers import CourseAdvancedSettingsSerializer
@@ -117,8 +115,6 @@ class AdvancedCourseSettingsView(DeveloperErrorViewMixin, APIView):
if not filter_query_data.is_valid():
raise ValidationError(filter_query_data.errors)
course_key = CourseKey.from_string(course_id)
if not use_new_advanced_settings_page(course_key):
return Response(status=status.HTTP_403_FORBIDDEN)
if not has_studio_read_access(request.user, course_key):
self.permission_denied(request)
course_block = modulestore().get_course(course_key)

View File

@@ -34,8 +34,10 @@ class CourseSettingsSerializer(serializers.Serializer):
is_prerequisite_courses_enabled = serializers.BooleanField()
language_options = serializers.ListField(child=serializers.ListField(child=serializers.CharField()))
lms_link_for_about_page = serializers.URLField()
licensing_enabled = serializers.BooleanField()
marketing_enabled = serializers.BooleanField()
mfe_proctored_exam_settings_url = serializers.CharField(required=False, allow_null=True, allow_blank=True)
platform_name = serializers.CharField()
possible_pre_requisite_courses = PossiblePreRequisiteCourseSerializer(required=False, many=True)
short_description_editable = serializers.BooleanField()
show_min_grade_warning = serializers.BooleanField()

View File

@@ -74,9 +74,11 @@ class CourseSettingsView(DeveloperErrorViewMixin, APIView):
],
...
],
"licensing_enabled": false,
"lms_link_for_about_page": "http://localhost:18000/courses/course-v1:edX+E2E-101+course/about",
"marketing_enabled": true,
"mfe_proctored_exam_settings_url": "",
"platform_name": "edX",
"possible_pre_requisite_courses": [
{
"course_key": "course-v1:edX+M12+2T2023",
@@ -108,6 +110,8 @@ class CourseSettingsView(DeveloperErrorViewMixin, APIView):
'can_show_certificate_available_date_field': can_show_certificate_available_date_field(course_block),
'course_display_name': course_block.display_name,
'course_display_name_with_default': course_block.display_name_with_default,
'platform_name': settings.PLATFORM_NAME,
'licensing_enabled': settings.FEATURES.get("LICENSING", False),
'use_v2_cert_display_settings': settings.FEATURES.get("ENABLE_V2_CERT_DISPLAY_SETTINGS", False),
})

View File

@@ -48,10 +48,12 @@ class CourseSettingsViewTest(CourseTestCase, PermissionAccessMixin):
"mfe_proctored_exam_settings_url": get_proctored_exam_settings_url(
self.course.id
),
"platform_name": settings.PLATFORM_NAME,
"short_description_editable": True,
"sidebar_html_enabled": False,
"show_min_grade_warning": False,
"upgrade_deadline": None,
"licensing_enabled": False,
"use_v2_cert_display_settings": False,
}

View File

@@ -5,7 +5,7 @@ APIs.
"""
from opaque_keys.edx.keys import UsageKey
from rest_framework.test import APIClient
from xmodule.modulestore.django import contentstore, modulestore
from xmodule.modulestore.django import contentstore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory
@@ -34,7 +34,7 @@ class ClipboardPasteTestCase(ModuleStoreTestCase):
# Check how many blocks are in the vertical currently
parent_key = course_key.make_usage_key("vertical", "vertical_test") # This is the vertical that holds the video
orig_vertical = modulestore().get_item(parent_key)
orig_vertical = self.store.get_item(parent_key)
assert len(orig_vertical.children) == 4
# Copy the video
@@ -51,16 +51,54 @@ class ClipboardPasteTestCase(ModuleStoreTestCase):
new_block_key = UsageKey.from_string(paste_response.json()["locator"])
# Now there should be an extra block in the vertical:
updated_vertical = modulestore().get_item(parent_key)
updated_vertical = self.store.get_item(parent_key)
assert len(updated_vertical.children) == 5
assert updated_vertical.children[-1] == new_block_key
# And it should match the original:
orig_video = modulestore().get_item(video_key)
new_video = modulestore().get_item(new_block_key)
orig_video = self.store.get_item(video_key)
new_video = self.store.get_item(new_block_key)
assert new_video.youtube_id_1_0 == orig_video.youtube_id_1_0
# The new block should store a reference to where it was copied from
assert new_video.copied_from_block == str(video_key)
def test_copy_and_paste_unit(self):
"""
Test copying a unit (vertical) from one course into another
"""
course_key, client = self._setup_course()
dest_course = CourseFactory.create(display_name='Destination Course')
with self.store.bulk_operations(dest_course.id):
dest_chapter = BlockFactory.create(parent=dest_course, category='chapter', display_name='Section')
dest_sequential = BlockFactory.create(parent=dest_chapter, category='sequential', display_name='Subsection')
# Copy the unit
unit_key = course_key.make_usage_key("vertical", "vertical_test")
copy_response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(unit_key)}, format="json")
assert copy_response.status_code == 200
# Paste the unit
paste_response = client.post(XBLOCK_ENDPOINT, {
"parent_locator": str(dest_sequential.location),
"staged_content": "clipboard",
}, format="json")
assert paste_response.status_code == 200
dest_unit_key = UsageKey.from_string(paste_response.json()["locator"])
# Now there should be a one unit/vertical as a child of the destination sequential/subsection:
updated_sequential = self.store.get_item(dest_sequential.location)
assert updated_sequential.children == [dest_unit_key]
# And it should match the original:
orig_unit = self.store.get_item(unit_key)
dest_unit = self.store.get_item(dest_unit_key)
assert len(orig_unit.children) == len(dest_unit.children)
# Check details of the fourth child (a poll)
orig_poll = self.store.get_item(orig_unit.children[3])
dest_poll = self.store.get_item(dest_unit.children[3])
assert dest_poll.display_name == orig_poll.display_name
assert dest_poll.question == orig_poll.question
# The new block should store a reference to where it was copied from
assert dest_unit.copied_from_block == str(unit_key)
def test_paste_with_assets(self):
"""
When pasting into a different course, any required static assets should

View File

@@ -882,7 +882,6 @@ def _duplicate_block(
@login_required
@expect_json
def delete_item(request, usage_key):
"""
Exposes internal helper method without breaking existing bindings/dependencies

View File

@@ -243,7 +243,6 @@
'js/spec/video/transcripts/message_manager_spec',
'js/spec/video/transcripts/utils_spec',
'js/spec/video/transcripts/editor_spec',
'js/spec/video/transcripts/videolist_spec',
'js/spec/video/transcripts/file_uploader_spec',
'js/spec/models/component_template_spec',
'js/spec/models/explicit_url_spec',

View File

@@ -1,788 +0,0 @@
define(
[
'jquery', 'underscore', 'backbone',
'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
'js/views/video/transcripts/utils',
'js/views/video/transcripts/editor',
'js/views/video/transcripts/metadata_videolist', 'js/models/metadata',
'js/views/abstract_editor',
'js/views/video/transcripts/message_manager',
'xmodule'
],
function($, _, Backbone, AjaxHelpers, Utils, Editor, VideoList, MetadataModel, AbstractEditor, MessageManager) {
'use strict';
describe('CMS.Views.Metadata.VideoList', function() {
var videoListEntryTemplate = readFixtures(
'video/transcripts/metadata-videolist-entry.underscore'
),
abstractEditor = AbstractEditor.prototype,
component_locator = 'component_locator',
videoList = [
{
mode: 'youtube',
type: 'youtube',
video: '12345678901'
},
{
mode: 'html5',
type: 'mp4',
video: 'video'
},
{
mode: 'html5',
type: 'webm',
video: 'video'
}
],
modelStub = {
default_value: ['a thing', 'another thing'],
display_name: 'Video URL',
explicitly_set: true,
field_name: 'video_url',
help: 'A list of things.',
options: [],
type: MetadataModel.VIDEO_LIST_TYPE,
value: [
'http://youtu.be/12345678901',
'video.mp4',
'video.webm'
]
},
videoIDStub = {
default_value: 'test default value',
display_name: 'Video ID',
explicitly_set: true,
field_name: 'edx_video_id',
help: 'Specifies the video ID.',
options: [],
type: 'VideoID',
value: 'advanced tab video id'
},
response = JSON.stringify({
command: 'found',
status: 'Success',
subs: 'video_id'
}),
waitForEvent,
createVideoListView;
var createMockAjaxServer = function() {
var mockServer = AjaxHelpers.server(
[
200,
{'Content-Type': 'application/json'},
response
]
);
mockServer.autoRespond = true;
return mockServer;
};
beforeEach(function() {
var tpl = sandbox({ // eslint-disable-line no-undef
class: 'component',
'data-locator': component_locator
});
setFixtures(tpl);
appendSetFixtures(
$('<script>',
{
id: 'metadata-videolist-entry',
type: 'text/template'
}
).text(videoListEntryTemplate)
);
// create mock server
this.mockServer = createMockAjaxServer();
spyOn($.fn, 'on').and.callThrough();
spyOn(Backbone, 'trigger').and.callThrough();
spyOn(Utils, 'command').and.callThrough();
spyOn(abstractEditor, 'initialize').and.callThrough();
spyOn(abstractEditor, 'render').and.callThrough();
spyOn(console, 'error');
spyOn(MessageManager.prototype, 'initialize').and.callThrough();
spyOn(MessageManager.prototype, 'render').and.callThrough();
spyOn(MessageManager.prototype, 'showError').and.callThrough();
spyOn(MessageManager.prototype, 'hideError').and.callThrough();
jasmine.addMatchers({
assertValueInView: function() {
return {
compare: function(actual, expected) {
var actualValue = actual.getValueFromEditor(),
passed = _.isEqual(actualValue, expected);
return {
pass: passed
};
}
};
},
assertCanUpdateView: function() {
return {
compare: function(actual, expected) {
var actualValue,
passed;
actual.setValueInEditor(expected);
actualValue = actual.getValueFromEditor();
passed = _.isEqual(actualValue, expected);
return {
pass: passed
};
}
};
},
assertIsCorrectVideoList: function() {
return {
compare: function(actual, expected) {
var actualValue = actual.getVideoObjectsList(),
passed = _.isEqual(actualValue, expected);
return {
pass: passed
};
}
};
}
});
});
afterEach(function() {
// restore mock server
this.mockServer.restore();
});
waitForEvent = function() {
var triggerCallArgs;
return jasmine.waitUntil(function() {
triggerCallArgs = Backbone.trigger.calls.mostRecent().args;
return Backbone.trigger.calls.count() === 1
&& triggerCallArgs[0] === 'transcripts:basicTabFieldChanged';
});
};
createVideoListView = function(mockServer) {
var $container, editor, model, videoListView;
appendSetFixtures(
sandbox({ // eslint-disable-line no-undef
class: 'wrapper-comp-settings basic_metadata_edit',
'data-metadata': JSON.stringify({video_url: modelStub, edx_video_id: videoIDStub})
})
);
$container = $('.basic_metadata_edit');
editor = new Editor({
el: $container
});
spyOn(editor, 'getLocator').and.returnValue(component_locator);
// reset
Backbone.trigger.calls.reset();
mockServer.requests.length = 0;
model = new MetadataModel(modelStub);
videoListView = new VideoList({
el: $('.component'),
model: model,
MessageManager: MessageManager
});
waitForEvent()
.then(function() {
return true;
});
return videoListView;
};
var waitsForResponse = function(mockServer) {
return jasmine.waitUntil(function() {
var requests = mockServer.requests,
len = requests.length;
return len && requests[0].readyState === 4;
});
};
it('Initialize', function(done) {
var view = createVideoListView(this.mockServer),
callArgs;
waitsForResponse(this.mockServer)
.then(function() {
expect(abstractEditor.initialize).toHaveBeenCalled();
expect(MessageManager.prototype.initialize).toHaveBeenCalled();
expect(view.component_locator).toBe(component_locator);
expect(view.$el).toHandle('input');
callArgs = view.$el.on.calls.mostRecent().args;
expect(callArgs[0]).toEqual('input');
expect(callArgs[1]).toEqual('.videolist-settings-item input');
}).always(done);
});
describe('Render', function() {
var assertToHaveBeenRendered = function(expectedVideoList) {
var commandCallArgs = Utils.command.calls.mostRecent().args,
actualVideoList = commandCallArgs[2].slice(0, expectedVideoList.length);
expect(commandCallArgs[0]).toEqual('check');
expect(commandCallArgs[1]).toEqual(component_locator);
_.each([0, 1, 2], function(index) {
expect(_.isEqual(expectedVideoList[index], actualVideoList[index])).toBeTruthy();
});
expect(abstractEditor.render).toHaveBeenCalled();
expect(MessageManager.prototype.render).toHaveBeenCalled();
},
resetSpies = function(mockServer) {
abstractEditor.render.calls.reset();
Utils.command.calls.reset();
MessageManager.prototype.render.calls.reset();
mockServer.requests.length = 0; // eslint-disable-line no-param-reassign
};
afterEach(function() {
Backbone.trigger('xblock:editorModalHidden');
});
it('is rendered in correct way', function(done) {
var view = createVideoListView(this.mockServer);
waitsForResponse(this.mockServer)
.then(function() {
assertToHaveBeenRendered(videoList);
})
.always(done);
});
it('is rendered with opened extra videos bar', function(done) {
var view = createVideoListView(this.mockServer);
var videoListLength = [
{
mode: 'youtube',
type: 'youtube',
video: '12345678901'
},
{
mode: 'html5',
type: 'mp4',
video: 'video'
}
],
videoListHtml5mode = [
{
mode: 'html5',
type: 'mp4',
video: 'video'
}
];
spyOn(VideoList.prototype, 'getVideoObjectsList').and.returnValue(videoListLength);
spyOn(VideoList.prototype, 'openExtraVideosBar');
resetSpies(this.mockServer);
view.render();
waitsForResponse(this.mockServer)
.then(function() {
assertToHaveBeenRendered(videoListLength);
view.getVideoObjectsList.and.returnValue(videoListLength);
expect(view.openExtraVideosBar).toHaveBeenCalled();
})
.then(_.bind(function() {
resetSpies(this.mockServer);
view.openExtraVideosBar.calls.reset();
view.getVideoObjectsList.and.returnValue(videoListHtml5mode);
view.render();
return waitsForResponse(this.mockServer)
.then(function() {
assertToHaveBeenRendered(videoListHtml5mode);
expect(view.openExtraVideosBar).toHaveBeenCalled();
}).then(done);
}, this));
});
it('is rendered without opened extra videos bar', function(done) {
var view = createVideoListView(this.mockServer),
videoList = [
{
mode: 'youtube',
type: 'youtube',
video: '12345678901'
}
];
spyOn(VideoList.prototype, 'getVideoObjectsList').and.returnValue(videoList);
spyOn(VideoList.prototype, 'closeExtraVideosBar');
resetSpies(this.mockServer);
view.render();
waitsForResponse(this.mockServer)
.then(function() {
assertToHaveBeenRendered(videoList);
expect(view.closeExtraVideosBar).toHaveBeenCalled();
})
.always(done);
});
});
describe('isUniqOtherVideos', function() {
it('Unique data - return true', function(done) {
var view = createVideoListView(this.mockServer),
data = videoList.concat([{
mode: 'html5',
type: 'other',
video: 'pxxZrg'
}]);
waitsForResponse(this.mockServer)
.then(function() {
var result = view.isUniqOtherVideos(data);
expect(result).toBe(true);
})
.always(done);
});
it('Not Unique data - return false', function(done) {
var view = createVideoListView(this.mockServer),
data = [
{
mode: 'html5',
type: 'mp4',
video: 'video'
},
{
mode: 'html5',
type: 'mp4',
video: 'video'
},
{
mode: 'html5',
type: 'other',
video: 'pxxZrg'
},
{
mode: 'html5',
type: 'other',
video: 'pxxZrg'
},
{
mode: 'youtube',
type: 'youtube',
video: '12345678901'
}
];
waitsForResponse(this.mockServer)
.then(function() {
var result = view.isUniqOtherVideos(data);
expect(result).toBe(false);
})
.always(done);
});
});
describe('isUniqVideoTypes', function() {
it('Unique data - return true', function(done) {
var view = createVideoListView(this.mockServer),
data = videoList;
waitsForResponse(this.mockServer)
.then(function() {
var result = view.isUniqVideoTypes(data);
expect(result).toBe(true);
})
.always(done);
});
it('Not Unique data - return false', function(done) {
var view = createVideoListView(this.mockServer),
data = [
{
mode: 'html5',
type: 'mp4',
video: 'video'
},
{
mode: 'html5',
type: 'mp4',
video: 'video'
},
{
mode: 'html5',
type: 'other',
video: 'pxxZrg'
},
{
mode: 'youtube',
type: 'youtube',
video: '12345678901'
}
];
waitsForResponse(this.mockServer)
.then(function() {
var result = view.isUniqVideoTypes(data);
expect(result).toBe(false);
})
.always(done);
});
});
describe('checkIsUniqVideoTypes', function() {
it('Error is shown', function(done) {
var view = createVideoListView(this.mockServer),
data = [
{
mode: 'html5',
type: 'mp4',
video: 'video'
},
{
mode: 'html5',
type: 'mp4',
video: 'video'
},
{
mode: 'html5',
type: 'other',
video: 'pxxZrg'
},
{
mode: 'youtube',
type: 'youtube',
video: '12345678901'
}
];
waitsForResponse(this.mockServer)
.then(function() {
var result = view.checkIsUniqVideoTypes(data);
expect(MessageManager.prototype.showError).toHaveBeenCalled();
expect(result).toBe(false);
})
.always(done);
});
it('All works okay if arguments are not passed', function(done) {
var view = createVideoListView(this.mockServer);
spyOn(view, 'getVideoObjectsList').and.returnValue(videoList);
waitsForResponse(this.mockServer)
.then(function() {
var result = view.checkIsUniqVideoTypes();
expect(view.getVideoObjectsList).toHaveBeenCalled();
expect(MessageManager.prototype.showError).not.toHaveBeenCalled();
expect(result).toBe(true);
})
.always(done);
});
});
describe('checkValidity', function() {
it('Error message is shown', function(done) {
var view = createVideoListView(this.mockServer);
spyOn(view, 'checkIsUniqVideoTypes').and.returnValue(true);
waitsForResponse(this.mockServer)
.then(function() {
var data = {mode: 'incorrect'},
result = view.checkValidity(data, true);
expect(MessageManager.prototype.showError).toHaveBeenCalled();
expect(view.checkIsUniqVideoTypes).toHaveBeenCalled();
expect(result).toBe(false);
})
.always(done);
});
it('Error message is shown when flag is not passed', function(done) {
var view = createVideoListView(this.mockServer);
spyOn(view, 'checkIsUniqVideoTypes').and.returnValue(true);
waitsForResponse(this.mockServer)
.then(function() {
var data = {mode: 'incorrect'},
result = view.checkValidity(data);
expect(MessageManager.prototype.showError).not.toHaveBeenCalled();
expect(view.checkIsUniqVideoTypes).toHaveBeenCalled();
expect(result).toBe(true);
}).always(done);
});
it('All works okay if correct data is passed', function(done) {
var view = createVideoListView(this.mockServer);
spyOn(view, 'checkIsUniqVideoTypes').and.returnValue(true);
waitsForResponse(this.mockServer)
.then(function() {
var data = videoList,
result = view.checkValidity(data);
expect(MessageManager.prototype.showError).not.toHaveBeenCalled();
expect(view.checkIsUniqVideoTypes).toHaveBeenCalled();
expect(result).toBe(true);
})
.always(done);
});
});
it('openExtraVideosBar', function(done) {
var view = createVideoListView(this.mockServer);
waitsForResponse(this.mockServer)
.then(function() {
view.$extraVideosBar.removeClass('is-visible');
view.openExtraVideosBar();
expect(view.$extraVideosBar).toHaveClass('is-visible');
})
.always(done);
});
it('closeExtraVideosBar', function(done) {
var view = createVideoListView(this.mockServer);
waitsForResponse(this.mockServer)
.then(function() {
view.$extraVideosBar.addClass('is-visible');
view.closeExtraVideosBar();
expect(view.$extraVideosBar).not.toHaveClass('is-visible');
})
.always(done);
});
it('toggleExtraVideosBar', function(done) {
var view = createVideoListView(this.mockServer);
waitsForResponse(this.mockServer)
.then(function() {
view.$extraVideosBar.addClass('is-visible');
view.toggleExtraVideosBar();
expect(view.$extraVideosBar).not.toHaveClass('is-visible');
view.toggleExtraVideosBar();
expect(view.$extraVideosBar).toHaveClass('is-visible');
})
.always(done);
});
it('getValueFromEditor', function(done) {
var view = createVideoListView(this.mockServer);
waitsForResponse(this.mockServer)
.then(function() {
expect(view).assertValueInView(modelStub.value);
})
.always(done);
});
it('setValueInEditor', function(done) {
var view = createVideoListView(this.mockServer);
waitsForResponse(this.mockServer)
.then(function() {
expect(view).assertCanUpdateView(['abc.mp4']);
})
.always(done);
});
it('getVideoObjectsList', function(done) {
var view = createVideoListView(this.mockServer);
var value = [
{
mode: 'youtube',
type: 'youtube',
video: '12345678901'
},
{
mode: 'html5',
type: 'mp4',
video: 'video'
},
{
mode: 'html5',
type: 'other',
video: 'pxxZrg'
}
];
waitsForResponse(this.mockServer)
.then(function() {
view.setValueInEditor([
'http://youtu.be/12345678901',
'video.mp4',
'http://goo.gl/pxxZrg',
'video'
]);
expect(view).assertIsCorrectVideoList(value);
})
.always(done);
});
describe('getPlaceholders', function() {
it('All works okay if empty values are passed', function(done) {
var view = createVideoListView(this.mockServer),
defaultPlaceholders = view.placeholders;
waitsForResponse(this.mockServer)
.then(function() {
var result = view.getPlaceholders([]),
expectedResult = _.values(defaultPlaceholders).reverse();
expect(result).toEqual(expectedResult);
})
.always(done);
});
it('On filling less than 3 fields, remaining fields should have '
+ 'placeholders for video types that were not filled yet',
function(done) {
var view = createVideoListView(this.mockServer),
defaultPlaceholders = view.placeholders;
var dataDict = {
youtube: {
value: [modelStub.value[0]],
expectedResult: [
defaultPlaceholders.youtube,
defaultPlaceholders.mp4,
defaultPlaceholders.webm
]
},
mp4: {
value: [modelStub.value[1]],
expectedResult: [
defaultPlaceholders.mp4,
defaultPlaceholders.youtube,
defaultPlaceholders.webm
]
},
webm: {
value: [modelStub.value[2]],
expectedResult: [
defaultPlaceholders.webm,
defaultPlaceholders.youtube,
defaultPlaceholders.mp4
]
}
};
defaultPlaceholders = view.placeholders;
waitsForResponse(this.mockServer)
.then(function() {
$.each(dataDict, function(index, val) {
var result = view.getPlaceholders(val.value);
expect(result).toEqual(val.expectedResult);
});
})
.always(done);
}
);
});
describe('inputHandler', function() {
var eventObject;
var resetSpies = function(view) {
MessageManager.prototype.hideError.calls.reset();
view.updateModel.calls.reset();
view.closeExtraVideosBar.calls.reset();
};
var setUp = function(view) {
eventObject = jQuery.Event('input');
spyOn(view, 'updateModel');
spyOn(view, 'closeExtraVideosBar');
spyOn(view, 'checkValidity');
spyOn($.fn, 'hasClass');
spyOn($.fn, 'addClass');
spyOn($.fn, 'removeClass');
spyOn($.fn, 'prop').and.callThrough();
spyOn(_, 'isEqual');
resetSpies(view);
};
var videoListView = function() {
return new VideoList({
el: $('.component'),
model: new MetadataModel(modelStub),
MessageManager: MessageManager
});
};
beforeEach(function() {
MessageManager.prototype.render.and.callFake(function() { return true; });
});
afterEach(function() {
MessageManager.prototype.render.and.callThrough();
});
it('Field has invalid value - nothing should happen', function() {
var view = videoListView();
setUp(view);
$.fn.hasClass.and.returnValue(false);
view.checkValidity.and.returnValue(false);
view.inputHandler(eventObject);
expect(MessageManager.prototype.hideError).not.toHaveBeenCalled();
expect(view.updateModel).not.toHaveBeenCalled();
expect(view.closeExtraVideosBar).not.toHaveBeenCalled();
expect($.fn.prop).toHaveBeenCalledWith('disabled', true);
expect($.fn.addClass).toHaveBeenCalledWith('is-disabled');
});
it('Main field has invalid value - extra Videos Bar is closed', function() {
var view = videoListView();
setUp(view);
$.fn.hasClass.and.returnValue(true);
view.checkValidity.and.returnValue(false);
view.inputHandler(eventObject);
expect(MessageManager.prototype.hideError).not.toHaveBeenCalled();
expect(view.updateModel).not.toHaveBeenCalled();
expect(view.closeExtraVideosBar).toHaveBeenCalled();
expect($.fn.prop).toHaveBeenCalledWith('disabled', true);
expect($.fn.addClass).toHaveBeenCalledWith('is-disabled');
});
it('Model is updated if value is valid', function() {
var view = videoListView();
setUp(view);
view.checkValidity.and.returnValue(true);
_.isEqual.and.returnValue(false);
view.inputHandler(eventObject);
expect(MessageManager.prototype.hideError).not.toHaveBeenCalled();
expect(view.updateModel).toHaveBeenCalled();
expect(view.closeExtraVideosBar).not.toHaveBeenCalled();
expect($.fn.prop).toHaveBeenCalledWith('disabled', false);
expect($.fn.removeClass).toHaveBeenCalledWith('is-disabled');
});
it('Corner case: Error is hided', function() {
var view = videoListView();
setUp(view);
view.checkValidity.and.returnValue(true);
_.isEqual.and.returnValue(true);
view.inputHandler(eventObject);
expect(MessageManager.prototype.hideError).toHaveBeenCalled();
expect(view.updateModel).not.toHaveBeenCalled();
expect(view.closeExtraVideosBar).not.toHaveBeenCalled();
expect($.fn.prop).toHaveBeenCalledWith('disabled', false);
expect($.fn.removeClass).toHaveBeenCalledWith('is-disabled');
});
});
});
});

View File

@@ -14,7 +14,7 @@
<h2>Course Staff</h2>
<article class="teacher">
<div class="teacher-image">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin:0 20 px 0" alt="Course Staff Image #1">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin: 0 20px 0;" alt="Course Staff Image #1" />
</div>
<h3>Staff Member #1</h3>
@@ -23,7 +23,7 @@
<article class="teacher">
<div class="teacher-image">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin:0 20 px 0" alt="Course Staff Image #2">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin: 0 20px 0;" alt="Course Staff Image #2" />
</div>
<h3>Staff Member #2</h3>

View File

@@ -14,7 +14,7 @@
<h2>Course Staff</h2>
<article class="teacher">
<div class="teacher-image">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin:0 20 px 0" alt="Course Staff Image #1">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin: 0 20px 0;" alt="Course Staff Image #1" />
</div>
<h3>Staff Member #1</h3>
@@ -23,7 +23,7 @@
<article class="teacher">
<div class="teacher-image">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin:0 20 px 0" alt="Course Staff Image #2">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin: 0 20px 0;" alt="Course Staff Image #2" />
</div>
<h3>Staff Member #2</h3>

View File

@@ -14,7 +14,7 @@
<h2>Course Staff</h2>
<article class="teacher">
<div class="teacher-image">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin:0 20 px 0" alt="Course Staff Image #1">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin: 0 20px 0;" alt="Course Staff Image #1" />
</div>
<h3>Staff Member #1</h3>
@@ -23,7 +23,7 @@
<article class="teacher">
<div class="teacher-image">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin:0 20 px 0" alt="Course Staff Image #2">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin: 0 20px 0;" alt="Course Staff Image #2" />
</div>
<h3>Staff Member #2</h3>

View File

@@ -14,7 +14,7 @@
<h2>Course Staff</h2>
<article class="teacher">
<div class="teacher-image">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin:0 20 px 0" alt="Course Staff Image #1">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin: 0 20px 0;" alt="Course Staff Image #1" />
</div>
<h3>Staff Member #1</h3>
@@ -23,7 +23,7 @@
<article class="teacher">
<div class="teacher-image">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin:0 20 px 0" alt="Course Staff Image #2">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin: 0 20px 0;" alt="Course Staff Image #2" />
</div>
<h3>Staff Member #2</h3>

View File

@@ -14,7 +14,7 @@
<h2>Course Staff</h2>
<article class="teacher">
<div class="teacher-image">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin:0 20 px 0" alt="Course Staff Image #1">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin: 0 20px 0;" alt="Course Staff Image #1" />
</div>
<h3>Staff Member #1</h3>
@@ -23,7 +23,7 @@
<article class="teacher">
<div class="teacher-image">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin:0 20 px 0" alt="Course Staff Image #2">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin: 0 20px 0;" alt="Course Staff Image #2" />
</div>
<h3>Staff Member #2</h3>

View File

@@ -10,6 +10,7 @@ from django.urls import reverse
from django.utils import timezone
from opaque_keys.edx.keys import CourseKey
from rest_framework import serializers
from openedx_filters.learning.filters import CourseEnrollmentAPIRenderStarted
from common.djangoapps.course_modes.models import CourseMode
from openedx.features.course_experience import course_home_url
@@ -244,6 +245,15 @@ class EnrollmentSerializer(serializers.Serializer):
def get_hasOptedOutOfEmail(self, enrollment):
return enrollment.course_id in self.context.get("course_optouts", [])
def to_representation(self, instance):
"""Serialize the enrollment instance to be able to update the values before the API finishes rendering."""
serialized_enrollment = super().to_representation(instance)
course_key, serialized_enrollment = CourseEnrollmentAPIRenderStarted().run_filter(
course_key=instance.course_id,
serialized_enrollment=serialized_enrollment,
)
return serialized_enrollment
class GradeDataSerializer(serializers.Serializer):
"""Info about grades for this enrollment"""

View File

@@ -0,0 +1,27 @@
# Generated by Django 3.2.20 on 2023-08-02 13:59
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import opaque_keys.edx.django.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('agreements', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='LTIPIISignature',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('course_key', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255)),
('lti_tools', models.JSONField()),
('lti_tools_hash', models.IntegerField()),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -0,0 +1,14 @@
# Generated by Django 3.2.20 on 2023-08-03 13:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('agreements', '0002_ltipiisignature'),
('agreements', '0002_ltipiitool'),
]
operations = [
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 3.2.20 on 2023-08-03 16:09
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import opaque_keys.edx.django.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('agreements', '0003_merge_0002_ltipiisignature_0002_ltipiitool'),
]
operations = [
migrations.CreateModel(
name='ProctoringPIISignature',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('course_key', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255)),
('proctoring_provider', models.CharField(max_length=255)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -34,3 +34,33 @@ class LTIPIITool(models.Model):
class Meta:
app_label = 'agreements'
class LTIPIISignature(models.Model):
"""
This model stores a user's acknowledgement to share PII via LTI tools in a particular course.
"""
user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE)
course_key = CourseKeyField(max_length=255, db_index=True)
lti_tools = models.JSONField()
# lti_tools_hash represents the hash of the list of LTI tools receiving
# PII acknowledged by the user. The hash is used to compare user
# acknowledgments - which reduces response time and decreases any impact
# on unit rendering time.
lti_tools_hash = models.IntegerField()
class Meta:
app_label = 'agreements'
class ProctoringPIISignature(models.Model):
"""
This model stores a user's acknowledgment to share PII via proctoring in a particular course.
"""
user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE)
course_key = CourseKeyField(max_length=255, db_index=True)
proctoring_provider = models.CharField(max_length=255)
class Meta:
app_label = 'agreements'

View File

@@ -42,20 +42,17 @@ class ClipboardTestCase(ModuleStoreTestCase):
client.login(username=self.user.username, password=self.user_password)
response = client.get(CLIPBOARD_ENDPOINT)
# We don't consider this a 404 error, it's a 200 with an empty response
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {
assert response.status_code == 200
assert response.json() == {
"content": None,
"source_usage_key": "",
"source_context_title": "",
"source_edit_url": "",
})
}
## The Python method for getting the API response should be identical:
self.assertEqual(
response.json(),
python_api.get_user_clipboard_json(self.user.id, response.wsgi_request),
)
assert response.json() == python_api.get_user_clipboard_json(self.user.id, response.wsgi_request)
# And the pure python API should return None
self.assertEqual(python_api.get_user_clipboard(self.user.id), None)
assert python_api.get_user_clipboard(self.user.id) is None
def _setup_course(self):
""" Set up the "Toy Course" and an APIClient for testing clipboard functionality. """
@@ -66,8 +63,8 @@ class ClipboardTestCase(ModuleStoreTestCase):
# Initial conditions: clipboard is empty:
response = client.get(CLIPBOARD_ENDPOINT)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["content"], None)
assert response.status_code == 200
assert response.json()["content"] is None
return (course_key, client)
@@ -82,27 +79,27 @@ class ClipboardTestCase(ModuleStoreTestCase):
response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(video_key)}, format="json")
# Validate the response:
self.assertEqual(response.status_code, 200)
assert response.status_code == 200
response_data = response.json()
self.assertEqual(response_data["source_usage_key"], str(video_key))
self.assertEqual(response_data["source_context_title"], "Toy Course")
self.assertEqual(response_data["content"], {**response_data["content"], **{
assert response_data["source_usage_key"] == str(video_key)
assert response_data["source_context_title"] == "Toy Course"
assert response_data["content"] == {**response_data["content"], **{
"block_type": "video",
"block_type_display": "Video",
# To ensure API stability, we are hard-coding these expected values:
"purpose": "clipboard",
"status": "ready",
"display_name": "default", # Weird name but that's what defined in the toy course
}})
}}
# Test the actual OLX in the clipboard:
olx_url = response_data["content"]["olx_url"]
olx_response = client.get(olx_url)
self.assertEqual(olx_response.status_code, 200)
self.assertEqual(olx_response.get("Content-Type"), "application/vnd.openedx.xblock.v1.video+xml")
assert olx_response.status_code == 200
assert olx_response.get("Content-Type") == "application/vnd.openedx.xblock.v1.video+xml"
self.assertXmlEqual(olx_response.content.decode(), SAMPLE_VIDEO_OLX)
# Now if we GET the clipboard again, the GET response should exactly equal the last POST response:
self.assertEqual(client.get(CLIPBOARD_ENDPOINT).json(), response_data)
assert client.get(CLIPBOARD_ENDPOINT).json() == response_data
def test_copy_video_python_get(self):
"""
@@ -113,25 +110,25 @@ class ClipboardTestCase(ModuleStoreTestCase):
# Copy the video
video_key = course_key.make_usage_key("video", "sample_video")
response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(video_key)}, format="json")
self.assertEqual(response.status_code, 200)
assert response.status_code == 200
# Get the clipboard status using python:
clipboard_data = python_api.get_user_clipboard(self.user.id)
self.assertIsNotNone(clipboard_data)
self.assertEqual(clipboard_data.source_usage_key, video_key)
assert clipboard_data is not None
assert clipboard_data.source_usage_key == video_key
# source_context_title is not in the python API because it's easy to retrieve a course's name from python code.
self.assertEqual(clipboard_data.content.block_type, "video")
assert clipboard_data.content.block_type == "video"
# To ensure API stability, we are hard-coding these expected values:
self.assertEqual(clipboard_data.content.purpose, "clipboard")
self.assertEqual(clipboard_data.content.status, "ready")
self.assertEqual(clipboard_data.content.display_name, "default")
assert clipboard_data.content.purpose == "clipboard"
assert clipboard_data.content.status == "ready"
assert clipboard_data.content.display_name == "default"
# Test the actual OLX in the clipboard:
olx_data = python_api.get_staged_content_olx(clipboard_data.content.id)
self.assertXmlEqual(olx_data, SAMPLE_VIDEO_OLX)
def test_copy_html(self):
"""
Test copying an HTML from the course
Test copying an HTML XBlock from the course
"""
course_key, client = self._setup_course()
@@ -140,32 +137,104 @@ class ClipboardTestCase(ModuleStoreTestCase):
response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_key)}, format="json")
# Validate the response:
self.assertEqual(response.status_code, 200)
assert response.status_code == 200
response_data = response.json()
self.assertEqual(response_data["source_usage_key"], str(html_key))
self.assertEqual(response_data["source_context_title"], "Toy Course")
self.assertEqual(response_data["content"], {**response_data["content"], **{
assert response_data["source_usage_key"] == str(html_key)
assert response_data["source_context_title"] == "Toy Course"
assert response_data["content"] == {**response_data["content"], **{
"block_type": "html",
# To ensure API stability, we are hard-coding these expected values:
"purpose": "clipboard",
"status": "ready",
"display_name": "Text", # Has no display_name set so we fallback to this default
}})
}}
# Test the actual OLX in the clipboard:
olx_url = response_data["content"]["olx_url"]
olx_response = client.get(olx_url)
self.assertEqual(olx_response.status_code, 200)
self.assertEqual(olx_response.get("Content-Type"), "application/vnd.openedx.xblock.v1.html+xml")
assert olx_response.status_code == 200
assert olx_response.get("Content-Type") == "application/vnd.openedx.xblock.v1.html+xml"
# For HTML, we really want to be sure that the OLX is serialized in this exact format (using CDATA), so we check
# the actual string directly rather than using assertXmlEqual():
self.assertEqual(olx_response.content.decode(), dedent("""
assert olx_response.content.decode() == dedent("""
<html url_name="toyhtml" display_name="Text"><![CDATA[
<a href='/static/handouts/sample_handout.txt'>Sample</a>
]]></html>
""").lstrip())
""").lstrip()
# Now if we GET the clipboard again, the GET response should exactly equal the last POST response:
self.assertEqual(client.get(CLIPBOARD_ENDPOINT).json(), response_data)
assert client.get(CLIPBOARD_ENDPOINT).json() == response_data
def test_copy_unit(self):
"""
Test copying a unit (vertical block) from the course
"""
course_key, client = self._setup_course()
# Copy the HTML
unit_key = course_key.make_usage_key("vertical", "vertical_test")
response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(unit_key)}, format="json")
# Validate the response:
assert response.status_code == 200
response_data = response.json()
assert response_data["source_usage_key"] == str(unit_key)
assert response_data["source_context_title"] == "Toy Course"
assert response_data["content"] == {**response_data["content"], **{
"block_type": "vertical",
# To ensure API stability, we are hard-coding these expected values:
"purpose": "clipboard",
"status": "ready",
"display_name": "vertical test", # Has no display_name set so display_name_with_default falls back to this
}}
# Test the actual OLX in the clipboard:
olx_url = response_data["content"]["olx_url"]
olx_response = client.get(olx_url)
assert olx_response.status_code == 200
assert olx_response.get("Content-Type") == "application/vnd.openedx.xblock.v1.vertical+xml"
self.assertXmlEqual(olx_response.content.decode(), """
<vertical url_name="vertical_test">
<video
url_name="sample_video"
display_name="default"
youtube="0.75:JMD_ifUUfsU,1.00:OEoXaMPEzfM,1.25:AKqURZnYqpk,1.50:DYpADpL7jAY"
youtube_id_0_75="JMD_ifUUfsU"
youtube_id_1_0="OEoXaMPEzfM"
youtube_id_1_25="AKqURZnYqpk"
youtube_id_1_5="DYpADpL7jAY"
/>
<video
url_name="separate_file_video"
display_name="default"
youtube="0.75:JMD_ifUUfsU,1.00:OEoXaMPEzfM,1.25:AKqURZnYqpk,1.50:DYpADpL7jAY"
youtube_id_0_75="JMD_ifUUfsU"
youtube_id_1_0="OEoXaMPEzfM"
youtube_id_1_25="AKqURZnYqpk"
youtube_id_1_5="DYpADpL7jAY"
/>
<video
url_name="video_with_end_time"
display_name="default"
youtube="0.75:JMD_ifUUfsU,1.00:OEoXaMPEzfM,1.25:AKqURZnYqpk,1.50:DYpADpL7jAY"
end_time="00:00:10"
youtube_id_0_75="JMD_ifUUfsU"
youtube_id_1_0="OEoXaMPEzfM"
youtube_id_1_25="AKqURZnYqpk"
youtube_id_1_5="DYpADpL7jAY"
/>
<poll_question
url_name="T1_changemind_poll_foo_2"
display_name="Change your answer"
reset="false"
>
&lt;p&gt;Have you changed your mind?&lt;/p&gt;
<answer id="yes">Yes</answer>
<answer id="no">No</answer>
</poll_question>
</vertical>
""")
# Now if we GET the clipboard again, the GET response should exactly equal the last POST response:
assert client.get(CLIPBOARD_ENDPOINT).json() == response_data
def test_copy_several_things(self):
"""
@@ -176,29 +245,29 @@ class ClipboardTestCase(ModuleStoreTestCase):
# Copy the video and validate the response:
video_key = course_key.make_usage_key("video", "sample_video")
response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(video_key)}, format="json")
self.assertEqual(response.status_code, 200)
assert response.status_code == 200
video_clip_data = response.json()
self.assertEqual(video_clip_data["source_usage_key"], str(video_key))
self.assertEqual(video_clip_data["content"]["block_type"], "video")
assert video_clip_data["source_usage_key"] == str(video_key)
assert video_clip_data["content"]["block_type"] == "video"
old_olx_url = video_clip_data["content"]["olx_url"]
self.assertEqual(client.get(old_olx_url).status_code, 200)
assert client.get(old_olx_url).status_code == 200
# Now copy some HTML:
html_key = course_key.make_usage_key("html", "toyhtml")
response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_key)}, format="json")
self.assertEqual(response.status_code, 200)
assert response.status_code == 200
# Now check the clipboard:
response = client.get(CLIPBOARD_ENDPOINT)
html_clip_data = response.json()
self.assertEqual(html_clip_data["source_usage_key"], str(html_key))
self.assertEqual(html_clip_data["content"]["block_type"], "html")
self.assertEqual(html_clip_data["content"]["block_type_display"], "Text")
assert html_clip_data["source_usage_key"] == str(html_key)
assert html_clip_data["content"]["block_type"] == "html"
assert html_clip_data["content"]["block_type_display"] == "Text"
## The Python method for getting the API response should be identical:
self.assertEqual(html_clip_data, python_api.get_user_clipboard_json(self.user.id, response.wsgi_request))
assert html_clip_data == python_api.get_user_clipboard_json(self.user.id, response.wsgi_request)
# The OLX link from the video will no longer work:
self.assertEqual(client.get(old_olx_url).status_code, 404)
assert client.get(old_olx_url).status_code == 404
def test_copy_static_assets(self):
"""
@@ -218,7 +287,7 @@ class ClipboardTestCase(ModuleStoreTestCase):
response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_key)}, format="json")
# Validate the response:
self.assertEqual(response.status_code, 200)
assert response.status_code == 200
response_data = response.json()
staged_content_id = response_data["content"]["id"]
olx_str = python_api.get_staged_content_olx(staged_content_id)
@@ -253,7 +322,7 @@ class ClipboardTestCase(ModuleStoreTestCase):
response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_block.location)}, format="json")
# Validate the response:
self.assertEqual(response.status_code, 200)
assert response.status_code == 200
response_data = response.json()
staged_content_id = response_data["content"]["id"]
olx_str = python_api.get_staged_content_olx(staged_content_id)
@@ -274,9 +343,9 @@ class ClipboardTestCase(ModuleStoreTestCase):
html_key = course_key.make_usage_key("html", "toyhtml")
with self.allow_transaction_exception():
response = nonstaff_client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_key)}, format="json")
self.assertEqual(response.status_code, 403)
assert response.status_code == 403
response = nonstaff_client.get(CLIPBOARD_ENDPOINT)
self.assertEqual(response.json()["content"], None)
assert response.json()["content"] is None
def test_no_stealing_clipboard_content(self):
"""
@@ -293,11 +362,10 @@ class ClipboardTestCase(ModuleStoreTestCase):
# Then another user tries to get the OLX:
olx_url = response.json()["content"]["olx_url"]
response = nonstaff_client.get(olx_url)
self.assertEqual(response.status_code, 403)
assert response.status_code == 403
def assertXmlEqual(self, xml_str_a: str, xml_str_b: str) -> bool:
""" Assert that the given XML strings are equal, ignoring attribute order and some whitespace variations. """
self.assertEqual(
ElementTree.canonicalize(xml_str_a, strip_text=True),
ElementTree.canonicalize(xml_str_b, strip_text=True),
)
a = ElementTree.canonicalize(xml_str_a, strip_text=True)
b = ElementTree.canonicalize(xml_str_b, strip_text=True)
assert a == b

View File

@@ -0,0 +1,22 @@
- model: oel_tagging.taxonomy
pk: -2
fields:
name: Organizations
description: Allows tags for any organization ID created on the instance.
enabled: true
required: true
allow_multiple: false
allow_free_text: false
visible_to_authors: false
_taxonomy_class: openedx.features.content_tagging.models.ContentAuthorTaxonomy
- model: oel_tagging.taxonomy
pk: -3
fields:
name: Content Authors
description: Allows tags for any user ID created on the instance.
enabled: true
required: true
allow_multiple: false
allow_free_text: false
visible_to_authors: false
_taxonomy_class: openedx.features.content_tagging.models.ContentOrganizationTaxonomy

View File

@@ -0,0 +1,59 @@
# Generated by Django 3.2.20 on 2023-07-31 21:07
from django.db import migrations
import openedx.features.content_tagging.models.base
class Migration(migrations.Migration):
dependencies = [
('oel_tagging', '0005_language_taxonomy'),
('content_tagging', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ContentAuthorTaxonomy',
fields=[
],
options={
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=(openedx.features.content_tagging.models.base.ContentTaxonomyMixin, 'oel_tagging.usersystemdefinedtaxonomy'),
),
migrations.CreateModel(
name='ContentLanguageTaxonomy',
fields=[
],
options={
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=(openedx.features.content_tagging.models.base.ContentTaxonomyMixin, 'oel_tagging.languagetaxonomy'),
),
migrations.CreateModel(
name='ContentOrganizationTaxonomy',
fields=[
],
options={
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=(openedx.features.content_tagging.models.base.ContentTaxonomyMixin, 'oel_tagging.modelsystemdefinedtaxonomy'),
),
migrations.CreateModel(
name='OrganizationModelObjectTag',
fields=[
],
options={
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('oel_tagging.modelobjecttag',),
),
]

View File

@@ -0,0 +1,39 @@
# Generated by Django 3.2.20 on 2023-07-11 22:57
from django.db import migrations
from django.core.management import call_command
from openedx.features.content_tagging.models import ContentLanguageTaxonomy
def load_system_defined_taxonomies(apps, schema_editor):
"""
Creates system defined taxonomies
"""
# Create system defined taxonomy instances
call_command('loaddata', '--app=content_tagging', 'system_defined.yaml')
# Adding taxonomy class to the language taxonomy
Taxonomy = apps.get_model('oel_tagging', 'Taxonomy')
language_taxonomy = Taxonomy.objects.get(id=-1)
language_taxonomy.taxonomy_class = ContentLanguageTaxonomy
def revert_system_defined_taxonomies(apps, schema_editor):
"""
Deletes all system defined taxonomies
"""
Taxonomy = apps.get_model('oel_tagging', 'Taxonomy')
Taxonomy.objects.get(id=-2).delete()
Taxonomy.objects.get(id=-3).delete()
class Migration(migrations.Migration):
dependencies = [
('content_tagging', '0002_system_defined_taxonomies'),
]
operations = [
migrations.RunPython(load_system_defined_taxonomies, revert_system_defined_taxonomies),
]

View File

@@ -0,0 +1,13 @@
"""
Content Tagging and System defined models
"""
from .base import (
TaxonomyOrg,
ContentObjectTag,
ContentTaxonomy,
)
from .system_defined import (
ContentLanguageTaxonomy,
ContentAuthorTaxonomy,
ContentOrganizationTaxonomy,
)

View File

@@ -104,16 +104,13 @@ class ContentObjectTag(ObjectTag):
return BlockUsageLocator.from_string(str(self.object_id))
class ContentTaxonomy(Taxonomy):
class ContentTaxonomyMixin:
"""
Taxonomy which can only tag Content objects (e.g. XBlocks or Courses) via ContentObjectTag.
Also ensures a valid TaxonomyOrg owner relationship with the content object.
"""
class Meta:
proxy = True
@classmethod
def taxonomies_for_org(
cls,
@@ -164,3 +161,13 @@ class ContentTaxonomy(Taxonomy):
).exists():
return False
return super()._check_taxonomy(content_tag)
class ContentTaxonomy(ContentTaxonomyMixin, Taxonomy):
"""
Taxonomy that accepts ContentTags,
and ensures a valid TaxonomyOrg owner relationship with the content object.
"""
class Meta:
proxy = True

View File

@@ -0,0 +1,75 @@
"""
System defined models
"""
from typing import Type
from openedx_tagging.core.tagging.models import (
ModelSystemDefinedTaxonomy,
ModelObjectTag,
UserSystemDefinedTaxonomy,
LanguageTaxonomy,
)
from organizations.models import Organization
from .base import ContentTaxonomyMixin
class OrganizationModelObjectTag(ModelObjectTag):
"""
ObjectTags for the OrganizationSystemDefinedTaxonomy.
"""
class Meta:
proxy = True
@property
def tag_class_model(self) -> Type:
"""
Associate the organization model
"""
return Organization
@property
def tag_class_value(self) -> str:
"""
Returns the organization name to use it on Tag.value when creating Tags for this taxonomy.
"""
return "name"
class ContentOrganizationTaxonomy(ContentTaxonomyMixin, ModelSystemDefinedTaxonomy):
"""
Organization system-defined taxonomy that accepts ContentTags
Side note: The organization of an object is already encoded in its usage ID,
but a Taxonomy with Organization as Tags is being used so that the objects can be
indexed and can be filtered in the same tagging system, without any special casing.
"""
class Meta:
proxy = True
@property
def object_tag_class(self) -> Type:
"""
Returns OrganizationModelObjectTag as ObjectTag subclass associated with this taxonomy.
"""
return OrganizationModelObjectTag
class ContentLanguageTaxonomy(ContentTaxonomyMixin, LanguageTaxonomy):
"""
Language system-defined taxonomy that accepts ContentTags
"""
class Meta:
proxy = True
class ContentAuthorTaxonomy(ContentTaxonomyMixin, UserSystemDefinedTaxonomy):
"""
Author system-defined taxonomy that accepts ContentTags
"""
class Meta:
proxy = True

View File

@@ -0,0 +1,71 @@
"""
Test for Content models
"""
import ddt
from django.test.testcases import TestCase
from openedx_tagging.core.tagging.models import (
ObjectTag,
Tag,
)
from openedx_tagging.core.tagging.api import create_taxonomy
from ..models import (
ContentLanguageTaxonomy,
ContentAuthorTaxonomy,
ContentOrganizationTaxonomy,
)
@ddt.ddt
class TestSystemDefinedModels(TestCase):
"""
Test for System defined models
"""
@ddt.data(
(ContentLanguageTaxonomy, "taxonomy"), # Invalid object key
(ContentLanguageTaxonomy, "tag"), # Invalid external_id, invalid language
(ContentLanguageTaxonomy, "object"), # Invalid object key
(ContentAuthorTaxonomy, "taxonomy"), # Invalid object key
(ContentAuthorTaxonomy, "tag"), # Invalid external_id, User don't exits
(ContentAuthorTaxonomy, "object"), # Invalid object key
(ContentOrganizationTaxonomy, "taxonomy"), # Invalid object key
(ContentOrganizationTaxonomy, "tag"), # Invalid external_id, Organization don't exits
(ContentOrganizationTaxonomy, "object"), # Invalid object key
)
@ddt.unpack
def test_validations(
self,
taxonomy_cls,
check,
):
"""
Test that the respective validations are being called
"""
taxonomy = create_taxonomy(
name='Test taxonomy',
taxonomy_class=taxonomy_cls,
)
tag = Tag(
value="value",
external_id="external_id",
taxonomy=taxonomy,
)
tag.save()
object_tag = ObjectTag(
object_id='object_id',
taxonomy=taxonomy,
tag=tag,
)
check_taxonomy = check == 'taxonomy'
check_object = check == 'object'
check_tag = check == 'tag'
assert not taxonomy.validate_object_tag(
object_tag=object_tag,
check_taxonomy=check_taxonomy,
check_object=check_object,
check_tag=check_tag,
)

View File

@@ -3,7 +3,11 @@
import ddt
from django.contrib.auth import get_user_model
from django.test.testcases import TestCase, override_settings
from openedx_tagging.core.tagging.models import ObjectTag, Tag
from openedx_tagging.core.tagging.models import (
ObjectTag,
Tag,
UserSystemDefinedTaxonomy,
)
from organizations.models import Organization
from common.djangoapps.student.auth import add_users, update_org_role
@@ -136,7 +140,8 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
system_taxonomy = api.create_taxonomy(
name="System Languages",
)
system_taxonomy.system_defined = True
system_taxonomy.taxonomy_class = UserSystemDefinedTaxonomy
system_taxonomy = system_taxonomy.cast()
assert self.superuser.has_perm(perm, system_taxonomy)
assert not self.staff.has_perm(perm, system_taxonomy)
assert not self.user_all_orgs.has_perm(perm, system_taxonomy)

View File

@@ -483,7 +483,7 @@ edx-enterprise==4.0.7
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
edx-event-bus-kafka==5.1.0
edx-event-bus-kafka==5.2.0
# via -r requirements/edx/kernel.in
edx-event-bus-redis==0.3.1
# via -r requirements/edx/kernel.in
@@ -768,12 +768,12 @@ openedx-events==8.3.0
# edx-event-bus-kafka
# edx-event-bus-redis
# skill-tagging
openedx-filters==1.4.0
openedx-filters==1.5.0
# via
# -r requirements/edx/kernel.in
# lti-consumer-xblock
# skill-tagging
openedx-learning==0.1.0
openedx-learning==0.1.1
# via -r requirements/edx/kernel.in
openedx-mongodbproxy==0.2.0
# via -r requirements/edx/kernel.in

View File

@@ -755,7 +755,7 @@ edx-enterprise==4.0.7
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
edx-event-bus-kafka==5.1.0
edx-event-bus-kafka==5.2.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1301,13 +1301,13 @@ openedx-events==8.3.0
# edx-event-bus-kafka
# edx-event-bus-redis
# skill-tagging
openedx-filters==1.4.0
openedx-filters==1.5.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# lti-consumer-xblock
# skill-tagging
openedx-learning==0.1.0
openedx-learning==0.1.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -2138,6 +2138,7 @@ walrus==0.9.3
# edx-event-bus-redis
watchdog==3.0.0
# via
# -r requirements/edx/development.in
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
wcwidth==0.2.6

View File

@@ -556,11 +556,11 @@ edx-drf-extensions==8.8.0
# edx-rbac
# edx-when
# edxval
edx-enterprise==4.0.6
edx-enterprise==4.0.7
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
edx-event-bus-kafka==5.1.0
edx-event-bus-kafka==5.2.0
# via -r requirements/edx/base.txt
edx-event-bus-redis==0.3.1
# via -r requirements/edx/base.txt
@@ -911,12 +911,12 @@ openedx-events==8.3.0
# edx-event-bus-kafka
# edx-event-bus-redis
# skill-tagging
openedx-filters==1.4.0
openedx-filters==1.5.0
# via
# -r requirements/edx/base.txt
# lti-consumer-xblock
# skill-tagging
openedx-learning==0.1.0
openedx-learning==0.1.1
# via -r requirements/edx/base.txt
openedx-mongodbproxy==0.2.0
# via -r requirements/edx/base.txt

View File

@@ -115,7 +115,7 @@ openedx-calc # Library supporting mathematical calculatio
openedx-django-require
openedx-events>=8.3.0 # Open edX Events from Hooks Extension Framework (OEP-50)
openedx-filters # Open edX Filters from Hooks Extension Framework (OEP-50)
openedx-learning<=0.1
openedx-learning # Open edX Learning core (experimental)
openedx-mongodbproxy
openedx-django-wiki
openedx-blockstore

View File

@@ -589,7 +589,7 @@ edx-enterprise==4.0.7
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
edx-event-bus-kafka==5.1.0
edx-event-bus-kafka==5.2.0
# via -r requirements/edx/base.txt
edx-event-bus-redis==0.3.1
# via -r requirements/edx/base.txt
@@ -980,12 +980,12 @@ openedx-events==8.3.0
# edx-event-bus-kafka
# edx-event-bus-redis
# skill-tagging
openedx-filters==1.4.0
openedx-filters==1.5.0
# via
# -r requirements/edx/base.txt
# lti-consumer-xblock
# skill-tagging
openedx-learning==0.1.0
openedx-learning==0.1.1
# via -r requirements/edx/base.txt
openedx-mongodbproxy==0.2.0
# via -r requirements/edx/base.txt

View File

@@ -17,15 +17,18 @@ class MakoDescriptorSystem(DescriptorSystem): # lint-amnesty, pylint: disable=a
self.render_template = render_template
# Add the MakoService to the descriptor system.
# Add the MakoService to the runtime services.
# If it already exists, do not attempt to reinitialize it; otherwise, this could override the `namespace_prefix`
# of the `MakoService`, breaking template rendering in Studio.
#
# This is not needed by most XBlocks, because the MakoService is added to their runtimes.
# However, there are a few cases where the MakoService is not added to the XBlock's
# runtime. Specifically:
# * in the Instructor Dashboard bulk emails tab, when rendering the HtmlBlock for its WYSIWYG editor.
# * during testing, when fetching factory-created blocks.
from common.djangoapps.edxmako.services import MakoService
self._services['mako'] = MakoService()
if 'mako' not in self._services:
from common.djangoapps.edxmako.services import MakoService
self._services['mako'] = MakoService()
class MakoTemplateBlockBase:

View File

@@ -19,7 +19,7 @@ data: |
<h2>Course Staff</h2>
<article class="teacher">
<div class="teacher-image">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin:0 20 px 0" alt="Course Staff Image #1">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin: 0 20px 0;" alt="Course Staff Image #1" />
</div>
<h3>Staff Member #1</h3>
@@ -28,7 +28,7 @@ data: |
<article class="teacher">
<div class="teacher-image">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin:0 20 px 0" alt="Course Staff Image #2">
<img src="/static/images/placeholder-faculty.png" align="left" style="margin: 0 20px 0;" alt="Course Staff Image #2" />
</div>
<h3>Staff Member #2</h3>

View File

@@ -1,7 +1,6 @@
# lint-amnesty, pylint: disable=missing-module-docstring
import datetime
import copy
import datetime
import json
import logging
import os
@@ -286,7 +285,7 @@ class XmlMixin:
metadata[attr] = value
@classmethod
def parse_xml(cls, node, runtime, _keys, id_generator):
def parse_xml(cls, node, runtime, keys, id_generator): # pylint: disable=too-many-statements
"""
Use `node` to construct a new block.
@@ -295,8 +294,8 @@ class XmlMixin:
runtime (:class:`.Runtime`): The runtime to use while parsing.
_keys (:class:`.ScopeIds`): The keys identifying where this block
will store its data. Not used by this implementation.
keys (:class:`.ScopeIds`): The keys identifying where this block
will store its data.
id_generator (:class:`.IdGenerator`): An object that will allow the
runtime to generate correct definition and usage ids for
@@ -305,9 +304,13 @@ class XmlMixin:
Returns (XBlock): The newly parsed XBlock
"""
url_name = node.get('url_name')
def_id = id_generator.create_definition(node.tag, url_name)
usage_id = id_generator.create_usage(def_id)
from xmodule.modulestore.xml import ImportSystem # done here to avoid circular import
if id_generator is None:
id_generator = runtime.id_generator
if keys is None:
# Passing keys=None is against the XBlock API but some platform tests do it.
def_id = id_generator.create_definition(node.tag, node.get('url_name'))
keys = ScopeIds(None, node.tag, def_id, id_generator.create_usage(def_id))
aside_children = []
# VS[compat]
@@ -320,14 +323,14 @@ class XmlMixin:
if is_pointer_tag(node):
# new style:
# read the actual definition file--named using url_name.replace(':','/')
definition_xml, filepath = cls.load_definition_xml(node, runtime, def_id)
aside_children = runtime.parse_asides(definition_xml, def_id, usage_id, id_generator)
definition_xml, filepath = cls.load_definition_xml(node, runtime, keys.def_id)
aside_children = runtime.parse_asides(definition_xml, keys.def_id, keys.usage_id, id_generator)
else:
filepath = None
definition_xml = node
# Note: removes metadata.
definition, children = cls.load_definition(definition_xml, runtime, def_id, id_generator)
definition, children = cls.load_definition(definition_xml, runtime, keys.def_id, id_generator)
# VS[compat]
# Make Ike's github preview links work in both old and new file layouts.
@@ -352,26 +355,31 @@ class XmlMixin:
aside_children.extend(definition_aside_children)
# Set/override any metadata specified by policy
cls.apply_policy(metadata, runtime.get_policy(usage_id))
field_data = {}
field_data.update(metadata)
field_data.update(definition)
field_data['children'] = children
cls.apply_policy(metadata, runtime.get_policy(keys.usage_id))
field_data = {**metadata, **definition, "children": children}
field_data['xml_attributes']['filename'] = definition.get('filename', ['', None]) # for git link
# TODO: we shouldn't be instantiating our own field data instance here, but rather just call to
# runtime.construct_xblock_from_class() and then set fields on the returned block.
# See the base XBlock class (XmlSerializationMixin.parse_xml) for how it should be done.
kvs = InheritanceKeyValueStore(initial_values=field_data)
field_data = KvsFieldData(kvs)
if "filename" in field_data:
del field_data["filename"] # filename should only be in xml_attributes.
xblock = runtime.construct_xblock_from_class(
cls,
# We're loading a block, so student_id is meaningless
ScopeIds(None, node.tag, def_id, usage_id),
field_data,
)
if isinstance(runtime, ImportSystem):
# we shouldn't be instantiating our own field data instance here, but there are complex inter-depenencies
# between this mixin and ImportSystem that currently seem to require it for proper metadata inheritance.
kvs = InheritanceKeyValueStore(initial_values=field_data)
field_data = KvsFieldData(kvs)
xblock = runtime.construct_xblock_from_class(cls, keys, field_data)
else:
# The "normal" / new way to set field data:
xblock = runtime.construct_xblock_from_class(cls, keys)
for (key, value_jsonish) in field_data.items():
if key in cls.fields:
setattr(xblock, key, cls.fields[key].from_json(value_jsonish))
elif key == 'children':
xblock.children = value_jsonish
else:
log.warning(
"Imported %s XBlock does not have field %s found in XML.", xblock.scope_ids.block_type, key,
)
if aside_children:
asides_tags = [x.tag for x in aside_children]
@@ -447,7 +455,7 @@ class XmlMixin:
if (attr not in self.metadata_to_strip
and attr not in self.metadata_to_export_to_policy
and attr not in not_to_clean_fields):
val = serialize_field(self._field_data.get(self, attr))
val = serialize_field(self.fields[attr].to_json(getattr(self, attr)))
try:
xml_object.set(attr, val)
except Exception: # lint-amnesty, pylint: disable=broad-except