provide lti resource links importer into course copy

refs INTEROP-6502
flag=none

* Have an LTI 1.3 installed;
* Have a Course recorded;
* Have an Assignment with lti resource links recorded in the `Course`
  context, use the RCE editor placement;
* Have an Assignment with lti resource link recorded in the `Assignment`
  context, use External Tool Submission;
* You sould be able to create a new course or use someone available;
* Into course settings, you sould be able to `Import Course Content` by
  choosing an option from the `Content type` dropdown menu;
* When the job is completed, you should be able to check if the resource
  links were created properly using rails console:
    $ Course.find(ID).assignments.map(&:lti_resource_links)
    $ Course.find(ID).lti_resource_links
  and you should check if the new assignments were launching the tool and
  expanding the custom params as expected;

Change-Id: I9ffc71a832409e6cfd488822f1e39d5c129bfff8
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/258967
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Xander Moffatt <xmoffatt@instructure.com>
QA-Review: Xander Moffatt <xmoffatt@instructure.com>
Product-Review: Wagner Goncalves <wagner.goncalves@instructure.com>
This commit is contained in:
Wagner Gonçalves 2021-02-19 13:24:35 -03:00 committed by Wagner Goncalves
parent b241135806
commit 3504465caa
14 changed files with 438 additions and 3 deletions

View File

@ -69,6 +69,7 @@ class Assignment < ActiveRecord::Base
restrict_columns :state, [:workflow_state] restrict_columns :state, [:workflow_state]
attribute :lti_resource_link_custom_params, :string, default: nil attribute :lti_resource_link_custom_params, :string, default: nil
attribute :lti_resource_link_lookup_uuid, :string, default: nil
has_many :submissions, -> { active.preload(:grading_period) }, inverse_of: :assignment has_many :submissions, -> { active.preload(:grading_period) }, inverse_of: :assignment
has_many :all_submissions, class_name: 'Submission', dependent: :delete_all has_many :all_submissions, class_name: 'Submission', dependent: :delete_all
@ -1111,9 +1112,17 @@ class Assignment < ActiveRecord::Base
end end
if lti_1_3_external_tool_tag? && !lti_resource_links.empty? if lti_1_3_external_tool_tag? && !lti_resource_links.empty?
return if primary_resource_link.custom == lti_resource_link_custom_params_as_hash options = {}
primary_resource_link.update!(custom: lti_resource_link_custom_params_as_hash) unless primary_resource_link.custom == lti_resource_link_custom_params_as_hash
options[:custom] = lti_resource_link_custom_params_as_hash
end
options[:lookup_uuid] = lti_resource_link_lookup_uuid unless lti_resource_link_lookup_uuid.nil?
return if options.empty?
primary_resource_link.update!(options)
end end
end end
end end

View File

@ -368,6 +368,7 @@ module Importers
item.needs_update_cached_due_dates = true if item.new_record? || item.update_cached_due_dates? item.needs_update_cached_due_dates = true if item.new_record? || item.update_cached_due_dates?
item.save_without_broadcasting! item.save_without_broadcasting!
item.skip_schedule_peer_reviews = nil item.skip_schedule_peer_reviews = nil
item.lti_resource_link_lookup_uuid = hash['resource_link_lookup_uuid']
create_lti_13_models(hash, context, migration, item) create_lti_13_models(hash, context, migration, item)

View File

@ -159,6 +159,7 @@ module Importers
migration.update_import_progress(85) migration.update_import_progress(85)
Importers::WikiPageImporter.process_migration_course_outline(data, migration) Importers::WikiPageImporter.process_migration_course_outline(data, migration)
Importers::CalendarEventImporter.process_migration(data, migration) Importers::CalendarEventImporter.process_migration(data, migration)
Importers::LtiResourceLinkImporter.process_migration(data, migration)
everything_selected = !migration.copy_options || migration.is_set?(migration.copy_options[:everything]) everything_selected = !migration.copy_options || migration.is_set?(migration.copy_options[:everything])
if everything_selected || migration.is_set?(migration.copy_options[:all_course_settings]) if everything_selected || migration.is_set?(migration.copy_options[:all_course_settings])

View File

@ -0,0 +1,98 @@
# frozen_string_literal: true
#
# Copyright (C) 2021 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
require_dependency 'importers'
module Importers
class LtiResourceLinkImporter < Importer
self.item_class = Lti::ResourceLink
def self.process_migration(hash, migration)
lti_resource_links = hash.with_indifferent_access['lti_resource_links']
return false unless lti_resource_links
# Recover all resource links recorded by Importers::AssignmentImporter to
resource_links_from_assignments = imported_resource_links_from_assignments(migration)
# When a resource link was created into Assignment context we have to
# update the custom params.
# Otherwise, we have to create a resource link for Course context.
lti_resource_links.each do |lti_resource_link|
updated = update_custom_for_resource_link_from_assignment_context(resource_links_from_assignments, lti_resource_link)
next if updated
create_or_update_resource_link_for_a_course_context(lti_resource_link, migration)
end
true
end
def self.create_or_update_resource_link_for_a_course_context(lti_resource_link, migration)
custom_params = Lti::DeepLinkingUtil.validate_custom_params(lti_resource_link['custom'])
destination_course = migration.context
# In case a user import a course, and then delete the assignments, in the
# next importation, should we need to consider just the RL's active?
resource_link_for_course = Lti::ResourceLink.find_by(
context: destination_course,
lookup_uuid: lti_resource_link['lookup_uuid']
)
unless resource_link_for_course
tool = ContextExternalTool.find_external_tool(lti_resource_link['launch_url'], destination_course)
resource_link_for_course = Lti::ResourceLink.new(
context_external_tool: tool,
context: destination_course,
custom: custom_params,
lookup_uuid: lti_resource_link['lookup_uuid']
)
end
resource_link_for_course.custom = custom_params
resource_link_for_course.save
end
def self.find_resource_link_from_assignment_context(resource_links_from_assignments, lookup_uuid)
resource_links_from_assignments.find { |item| item.lookup_uuid == lookup_uuid }
end
def self.imported_resource_links_from_assignments(migration)
migration.context.assignments.joins(:lti_resource_links).map(&:lti_resource_links).flatten
end
def self.update_custom_for_resource_link_from_assignment_context(resource_links_from_assignments, lti_resource_link)
resource_link = find_resource_link_from_assignment_context(
resource_links_from_assignments,
lti_resource_link['lookup_uuid']
)
return false unless resource_link
resource_link.update(
custom: Lti::DeepLinkingUtil.validate_custom_params(lti_resource_link['custom'])
)
true
end
end
end

View File

@ -28,6 +28,7 @@ module CC::Importer::Canvas
include WebcontentConverter include WebcontentConverter
include QuizConverter include QuizConverter
include MediaTrackConverter include MediaTrackConverter
include LtiResourceLinkConverter
MANIFEST_FILE = "imsmanifest.xml" MANIFEST_FILE = "imsmanifest.xml"
@ -69,6 +70,8 @@ module CC::Importer::Canvas
set_progress(70) set_progress(70)
@course[:media_tracks] = convert_media_tracks(settings_doc(MEDIA_TRACKS)) @course[:media_tracks] = convert_media_tracks(settings_doc(MEDIA_TRACKS))
set_progress(71) set_progress(71)
@course[:lti_resource_links] = convert_lti_resource_links
set_progress(72)
convert_quizzes if Qti.qti_enabled? convert_quizzes if Qti.qti_enabled?
set_progress(80) set_progress(80)

View File

@ -0,0 +1,83 @@
# frozen_string_literal: true
#
# Copyright (C) 2021 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
module CC::Importer::Canvas
module LtiResourceLinkConverter
include CC::Importer
FLOAT_REGEX = /^[-+]?\d+[.]\d+$/.freeze
INTEGER_REGEX = /^[-+]?\d+$/.freeze
def convert_lti_resource_links
resource_links = []
@manifest.css('resource[type$=imsbasiclti_xmlv1p3]').each do |resource|
identifier = resource.attributes['identifier'].value
resource_link_element = resource.at_css("file[href$='lti_resource_links/#{identifier}.xml']")
next unless resource_link_element
path = @package_root.item_path(resource_link_element['href'])
document = open_file_xml(path)
next unless document
custom = {}
lookup_uuid = nil
document.xpath("//blti:custom//lticm:property").each do |el|
key = el.attributes['name'].value
value = el.content
next if key.empty?
# As `el.content` returns a String, we're trying to convert the
# custom parameter value to the orignal data type
value = if FLOAT_REGEX.match? value
value.to_f
elsif INTEGER_REGEX.match? value
value.to_i
elsif value == 'true'
true
elsif value == 'false'
false
else
value
end
custom[key.to_sym] = value
end
document.xpath("//blti:extensions//lticm:property").each do |el|
lookup_uuid = el.content if el.attributes['name'].value == 'lookup_uuid'
end
launch_url = document.xpath("//blti:launch_url").first.content
resource_links << {
custom: custom,
launch_url: launch_url,
lookup_uuid: lookup_uuid
}
end
resource_links
end
end
end

View File

@ -99,6 +99,7 @@ module CC::Importer::Standard
assignment["external_tool_migration_id"] = get_node_val(meta_doc, "external_tool_identifierref") if meta_doc.at_css("external_tool_identifierref") assignment["external_tool_migration_id"] = get_node_val(meta_doc, "external_tool_identifierref") if meta_doc.at_css("external_tool_identifierref")
assignment["external_tool_id"] = get_node_val(meta_doc, "external_tool_external_identifier") if meta_doc.at_css("external_tool_external_identifier") assignment["external_tool_id"] = get_node_val(meta_doc, "external_tool_external_identifier") if meta_doc.at_css("external_tool_external_identifier")
assignment["tool_setting"] = get_tool_setting(meta_doc) if meta_doc.at_css('tool_setting').present? assignment["tool_setting"] = get_tool_setting(meta_doc) if meta_doc.at_css('tool_setting').present?
assignment["resource_link_lookup_uuid"] = get_node_val(meta_doc, "resource_link_lookup_uuid") if meta_doc.at_css("resource_link_lookup_uuid")
if meta_doc.at_css("saved_rubric_comments comment") if meta_doc.at_css("saved_rubric_comments comment")
assignment[:saved_rubric_comments] = {} assignment[:saved_rubric_comments] = {}

View File

@ -43,5 +43,8 @@
<resource identifier="i964fd8107ac2c2e75e9a142971693976" type="tool_profile"> <resource identifier="i964fd8107ac2c2e75e9a142971693976" type="tool_profile">
<file href="i964fd8107ac2c2e75e9a142971693976.json"/> <file href="i964fd8107ac2c2e75e9a142971693976.json"/>
</resource> </resource>
<resource identifier="g534facc599d2202cf67dce042ee3105b" type="imsbasiclti_xmlv1p3">
<file href="lti_resource_links/g534facc599d2202cf67dce042ee3105b.xml"/>
</resource>
</resources> </resources>
</manifest> </manifest>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<cartridge_basiclti_link xmlns="http://www.imsglobal.org/xsd/imslticc_v1p3" xmlns:blti="http://www.imsglobal.org/xsd/imsbasiclti_v1p0" xmlns:lticm="http://www.imsglobal.org/xsd/imslticm_v1p0" xmlns:lticp="http://www.imsglobal.org/xsd/imslticp_v1p0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.imsglobal.org/xsd/imslticc_v1p3.xsd http://www.imsglobal.org/xsd/imslticp_v1p0 imslticp_v1p0.xsd http://www.imsglobal.org/xsd/imslticm_v1p0 imslticm_v1p0.xsd http://www.imsglobal.org/xsd/imsbasiclti_v1p0 imsbasiclti_v1p0p1.xsd">
<blti:title>LTI 1.3 a30691c6</blti:title>
<blti:description>1.3 Test Tool</blti:description>
<blti:launch_url>http://lti13testtool.docker/launch</blti:launch_url>
<blti:custom>
<lticm:property name="param1">some string</lticm:property>
<lticm:property name="param2">1</lticm:property>
<lticm:property name="param3">2.56</lticm:property>
<lticm:property name="param4">true</lticm:property>
<lticm:property name="param5">false</lticm:property>
<lticm:property name="param6">a12.5</lticm:property>
<lticm:property name="param7">5d781f15-c6b0-4901-a1f7-2a77e7bf4982</lticm:property>
<lticm:property name="param8">+1(855)552-2338</lticm:property>
</blti:custom>
<blti:extensions platform="canvas.instructure.com">
<lticm:property name="lookup_uuid">1b302c1e-c0a2-42dc-88b6-c029699a7c7a</lticm:property>
</blti:extensions>
</cartridge_basiclti_link>

View File

@ -0,0 +1,59 @@
# frozen_string_literal: true
#
# Copyright (C) 2021 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
require File.expand_path(File.dirname(__FILE__) + '/../../../../import_helper')
describe CC::Importer::Canvas::LtiResourceLinkConverter do
subject do
Class.new do
include CC::Importer::Canvas::LtiResourceLinkConverter
def initialize(manifest, path)
@manifest = manifest
@package_root = PackageRoot.new(path)
end
end.new(manifest, path)
end
let(:manifest) { ImportHelper.get_import_data_xml('unzipped', 'imsmanifest') }
let(:path) { File.expand_path(File.dirname(__FILE__) + '/../../../../fixtures/importer/unzipped') }
describe '#convert_lti_resource_links' do
it 'extract custom params and lookup_uuid' do
lti_resource_links = subject.convert_lti_resource_links
expect(lti_resource_links).to include(
a_hash_including(
custom: {
param1: 'some string',
param2: 1,
param3: 2.56,
param4: true,
param5: false,
param6: 'a12.5',
param7: '5d781f15-c6b0-4901-a1f7-2a77e7bf4982',
param8: '+1(855)552-2338',
},
lookup_uuid: '1b302c1e-c0a2-42dc-88b6-c029699a7c7a',
launch_url: 'http://lti13testtool.docker/launch'
)
)
end
end
end

View File

@ -1175,6 +1175,30 @@ XML
expect(@copy_to.attachments.where(migration_id: 'ghi').first).to be_deleted expect(@copy_to.attachments.where(migration_id: 'ghi').first).to be_deleted
end end
context 'importing lti resource links' do
let(:data) do
{
'lti_resource_links' => [
{
'custom' => {
'param1' => 'value1'
},
'lookup_uuid' => '1b302c1e-c0a2-42dc-88b6-c029699a7c7a',
'context_id' => @copy_from.id,
'context_type' => 'Course'
}
]
}
end
let(:migration) { ContentMigration.create(context: @copy_to) }
it 'process migration from LtiResourceLinkImporter' do
expect(Importers::LtiResourceLinkImporter).to receive(:process_migration).once.with(data, migration)
Importers::CourseContentImporter.import_content(@copy_to, data, nil, migration)
end
end
context "warnings for missing links in imported html" do context "warnings for missing links in imported html" do
it "should add warnings for assessment questions" do it "should add warnings for assessment questions" do
data = { data = {

View File

@ -0,0 +1,113 @@
# frozen_string_literal: true
#
# Copyright (C) 2021 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require 'spec_helper'
describe Importers::LtiResourceLinkImporter do
subject { described_class.process_migration(hash, migration) }
let!(:source_course) { course_model }
let!(:destination_course) { course_model }
let!(:migration) { ContentMigration.create(context: destination_course, source_course: source_course) }
let!(:tool) { external_tool_model(context: destination_course) }
context 'when `lti_resource_links` is not given' do
let(:hash) { { lti_resource_links: nil } }
it 'does not import lti resource links' do
expect(subject).to eq false
end
end
context 'when `lti_resource_links` is given' do
let(:custom_params) do
{ 'param1' => 'value1 ' }
end
let(:lookup_uuid) { '1b302c1e-c0a2-42dc-88b6-c029699a7c7a' }
let(:hash) do
{
'lti_resource_links' => [
{
'custom' => custom_params,
'lookup_uuid' => lookup_uuid,
'launch_url' => tool.url
}
]
}
end
context 'when the Lti::ResourceLink.context_type is an Assignment' do
let!(:assignment) do
destination_course.assignments.create!(
submission_types: 'external_tool',
external_tool_tag_attributes: { content: tool }
)
end
let!(:resource_link) do
Lti::ResourceLink.create!(
context_external_tool: tool,
context: assignment,
lookup_uuid: lookup_uuid,
custom: nil
)
end
it 'update the custom params' do
expect(resource_link.custom).to be_nil
expect(subject).to eq true
resource_link.reload
expect(resource_link.custom).to eq custom_params
end
end
context 'when the Lti::ResourceLink.context_type is a Course' do
context 'and the resource link was not recorded' do
it 'create the new resource link' do
expect(subject).to eq true
expect(destination_course.lti_resource_links.size).to eq 1
expect(destination_course.lti_resource_links.first.lookup_uuid).to eq lookup_uuid
expect(destination_course.lti_resource_links.first.custom).to eq custom_params
end
end
context 'and the resource link was recorded' do
before do
destination_course.lti_resource_links.create!(
context_external_tool: tool,
custom: nil,
lookup_uuid: lookup_uuid
)
end
it 'update the custom params' do
expect(subject).to eq true
expect(destination_course.lti_resource_links.size).to eq 1
expect(destination_course.lti_resource_links.first.lookup_uuid).to eq lookup_uuid
expect(destination_course.lti_resource_links.first.custom).to eq custom_params
end
end
end
end
end

View File

@ -9045,7 +9045,7 @@ describe Assignment do
it_behaves_like 'line item and resource link existence check' it_behaves_like 'line item and resource link existence check'
it_behaves_like 'assignment to line item attribute sync check' it_behaves_like 'assignment to line item attribute sync check'
it 'change the `custom` attribute at resource link when it is informed' do it 'change the `custom` attribute at resource link when it is given' do
assignment.lti_resource_link_custom_params = nil assignment.lti_resource_link_custom_params = nil
assignment.save! assignment.save!
assignment.reload assignment.reload
@ -9077,6 +9077,18 @@ describe Assignment do
expect(resource_link.custom).to eq new_custom_params.with_indifferent_access expect(resource_link.custom).to eq new_custom_params.with_indifferent_access
end end
it 'change the `lookup_uuid` attribute at resource link when it is given' do
lookup_uuid = '3d719897-4274-44ab-aff2-2fbd3c9d2977'
assignment.lti_resource_link_lookup_uuid = lookup_uuid
assignment.save!
assignment.reload
resource_link = assignment.line_items.first.resource_link
expect(resource_link.lookup_uuid).to eq lookup_uuid
end
context 'and no resource link or line item exist' do context 'and no resource link or line item exist' do
let(:resource_link) { subject.line_items.first.resource_link } let(:resource_link) { subject.line_items.first.resource_link }
let(:line_item) { subject.line_items.first } let(:line_item) { subject.line_items.first }

View File

@ -462,6 +462,12 @@ describe ContentMigration do
@copy_from.homeroom_course = true @copy_from.homeroom_course = true
@copy_from.save! @copy_from.save!
@copy_from.lti_resource_links.create!(
context_external_tool: external_tool_model(context: @copy_from),
custom: nil,
lookup_uuid: '1b302c1e-c0a2-42dc-88b6-c029699a7c7a'
)
run_course_copy run_course_copy
#compare settings #compare settings
@ -485,6 +491,9 @@ describe ContentMigration do
expect(@copy_to.send(att)).to eq(@copy_from.send(att)), "@copy_to.#{att}: expected #{@copy_from.send(att)}, got #{@copy_to.send(att)}" expect(@copy_to.send(att)).to eq(@copy_from.send(att)), "@copy_to.#{att}: expected #{@copy_from.send(att)}, got #{@copy_to.send(att)}"
end end
expect(@copy_to.tab_configuration).to eq @copy_from.tab_configuration expect(@copy_to.tab_configuration).to eq @copy_from.tab_configuration
expect(@copy_to.lti_resource_links.size).to eq 1
expect(@copy_to.lti_resource_links.first.lookup_uuid).to eq '1b302c1e-c0a2-42dc-88b6-c029699a7c7a'
end end
it "should copy the overridable course visibility setting" do it "should copy the overridable course visibility setting" do