Merge branch 'master' into edx-depr31
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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 = [
|
||||
]
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<p>Have you changed your mind?</p>
|
||||
<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
|
||||
|
||||
@@ -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
|
||||
@@ -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',),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
13
openedx/features/content_tagging/models/__init__.py
Normal file
13
openedx/features/content_tagging/models/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Content Tagging and System defined models
|
||||
"""
|
||||
from .base import (
|
||||
TaxonomyOrg,
|
||||
ContentObjectTag,
|
||||
ContentTaxonomy,
|
||||
)
|
||||
from .system_defined import (
|
||||
ContentLanguageTaxonomy,
|
||||
ContentAuthorTaxonomy,
|
||||
ContentOrganizationTaxonomy,
|
||||
)
|
||||
@@ -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
|
||||
75
openedx/features/content_tagging/models/system_defined.py
Normal file
75
openedx/features/content_tagging/models/system_defined.py
Normal 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
|
||||
71
openedx/features/content_tagging/tests/test_models.py
Normal file
71
openedx/features/content_tagging/tests/test_models.py
Normal 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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user