conditional release content migration service
test plan: * make sure canvas is configured with a conditional release service with g/85046 merged or checked out * create assignments in a course with conditional content * after copying the course, or exporting/importing the course, the new assignments should have conditional content closes #CNVS-30371 #CYOE-235 Change-Id: I42693f2d12185f5c7f665d303baf938ad4af08ad Reviewed-on: https://gerrit.instructure.com/85108 Tested-by: Jenkins Reviewed-by: Michael Brewer-Davis <mbd@instructure.com> Reviewed-by: Dan Minkevitch <dan@instructure.com> Reviewed-by: Jeremy Stanley <jeremy@instructure.com> Reviewed-by: Christian Prescott <cprescott@instructure.com> QA-Review: Alex Morris <amorris@instructure.com> Product-Review: James Williams <jamesw@instructure.com>
This commit is contained in:
parent
d086e37945
commit
a4f7dc86a1
|
@ -168,7 +168,7 @@ class BigBlueButtonConference < WebConference
|
|||
http_response = nil
|
||||
Canvas.timeout_protection("big_blue_button") do
|
||||
logger.debug "big blue button api call: #{url_str}"
|
||||
http_response = CanvasHttp.get(url_str, {}, 5)
|
||||
http_response = CanvasHttp.get(url_str, redirect_limit: 5)
|
||||
end
|
||||
|
||||
case http_response
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
module ConditionalRelease
|
||||
class MigrationService
|
||||
class << self
|
||||
def applies_to_course?(course)
|
||||
ConditionalRelease::Service.enabled_in_context?(course)
|
||||
end
|
||||
|
||||
def begin_export(course, opts)
|
||||
data = nil
|
||||
if opts[:selective]
|
||||
assignment_ids = opts[:exported_assets].map{|asset| (match = asset.match(/assignment_(\d+)/) && match[1])}.compact
|
||||
return unless assignment_ids.any?
|
||||
data = {:export_settings => {:selective => '1', :exported_assignment_ids => assignment_ids}}.to_param
|
||||
end
|
||||
response = CanvasHttp.post(ConditionalRelease::Service.content_exports_url, headers_for(course), form_data: data)
|
||||
if response.code =~ /^2/
|
||||
json = JSON.parse(response.body)
|
||||
{:export_id => json['id'], :course => course}
|
||||
else
|
||||
raise "Error queueing export for Conditional Release: #{response.body}"
|
||||
end
|
||||
end
|
||||
|
||||
def export_completed?(export_data)
|
||||
response = CanvasHttp.get("#{ConditionalRelease::Service.content_exports_url}/#{export_data[:export_id]}", headers_for(export_data[:course]))
|
||||
if response.code =~ /^2/
|
||||
json = JSON.parse(response.body)
|
||||
case json['state']
|
||||
when 'completed'
|
||||
true
|
||||
when 'failed'
|
||||
raise "Content Export for Conditional Release failed"
|
||||
else
|
||||
false
|
||||
end
|
||||
else
|
||||
raise "Error retrieving export state for Conditional Release: #{response.body}"
|
||||
end
|
||||
end
|
||||
|
||||
def retrieve_export(export_data)
|
||||
response = CanvasHttp.get("#{ConditionalRelease::Service.content_exports_url}/#{export_data[:export_id]}/download", headers_for(export_data[:course]))
|
||||
if response.code =~ /^2/
|
||||
json = JSON.parse(response.body)
|
||||
unless json.values.all?(&:empty?) # don't bother saving if there's nothing to import
|
||||
return json
|
||||
end
|
||||
else
|
||||
raise "Error retrieving export for Conditional Release: #{response.body}"
|
||||
end
|
||||
end
|
||||
|
||||
def send_imported_content(course, imported_content)
|
||||
data = {:file => StringIO.new(imported_content.to_json)}
|
||||
response = CanvasHttp.post(ConditionalRelease::Service.content_imports_url, headers_for(course), form_data: data, multipart: true)
|
||||
if response.code =~ /^2/
|
||||
json = JSON.parse(response.body)
|
||||
{:import_id => json['id'], :course => course}
|
||||
else
|
||||
raise "Error sending import for Conditional Release: #{response.body}"
|
||||
end
|
||||
end
|
||||
|
||||
def import_completed?(import_data)
|
||||
response = CanvasHttp.get("#{ConditionalRelease::Service.content_imports_url}/#{import_data[:import_id]}", headers_for(import_data[:course]))
|
||||
if response.code =~ /^2/
|
||||
json = JSON.parse(response.body)
|
||||
case json['state']
|
||||
when 'completed'
|
||||
true
|
||||
when 'failed'
|
||||
raise "Content Import for Conditional Release failed"
|
||||
else
|
||||
false
|
||||
end
|
||||
else
|
||||
raise "Error retrieving import state for Conditional Release: #{response.body}"
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def headers_for(course)
|
||||
token = Canvas::Security::ServicesJwt.generate({
|
||||
sub: 'MIGRATION_SERVICE',
|
||||
role: 'admin',
|
||||
account_id: Context.get_account(course).root_account.lti_guid.to_s,
|
||||
context_type: 'Course',
|
||||
context_id: course.id.to_s,
|
||||
workflow: 'conditonal-release-api'
|
||||
})
|
||||
{"Authorization" => "Bearer #{token}"}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -26,6 +26,8 @@ module ConditionalRelease
|
|||
protocol: nil, # defaults to Canvas
|
||||
edit_rule_path: "ui/editor",
|
||||
create_account_path: 'api/account',
|
||||
content_exports_path: 'api/content_exports',
|
||||
content_imports_path: 'api/content_imports',
|
||||
}.freeze
|
||||
|
||||
def self.env_for(context, user = nil, session: nil, assignment: nil, domain: nil, real_user: nil)
|
||||
|
@ -105,6 +107,14 @@ module ConditionalRelease
|
|||
config[:create_account_path]
|
||||
end
|
||||
|
||||
def self.content_exports_url
|
||||
build_url(config[:content_exports_path])
|
||||
end
|
||||
|
||||
def self.content_imports_url
|
||||
build_url(config[:content_imports_path])
|
||||
end
|
||||
|
||||
class << self
|
||||
private
|
||||
def config_file
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
Canvas::Migration::ExternalContent::Migrator.register_service('conditional_release', ConditionalRelease::MigrationService)
|
|
@ -41,11 +41,7 @@ module CanvasHttp
|
|||
# rather than reading it all into memory.
|
||||
#
|
||||
# Eventually it may be expanded to optionally do cert verification as well.
|
||||
#
|
||||
# TODO: this doesn't yet handle relative redirects (relative Location HTTP
|
||||
# header), which actually isn't even technically allowed by the HTTP spec.
|
||||
# But everybody allows and handles it.
|
||||
def self.request(request_class, url_str, other_headers = {}, redirect_limit = 3)
|
||||
def self.request(request_class, url_str, other_headers = {}, redirect_limit: 3, form_data: nil, multipart: false)
|
||||
last_scheme = nil
|
||||
last_host = nil
|
||||
|
||||
|
@ -54,9 +50,20 @@ module CanvasHttp
|
|||
|
||||
_, uri = CanvasHttp.validate_url(url_str, last_host, last_scheme) # uses the last host and scheme for relative redirects
|
||||
http = CanvasHttp.connection_for_uri(uri)
|
||||
|
||||
multipart_query = nil
|
||||
if form_data && multipart
|
||||
multipart_query, multipart_headers = Multipart::Post.new.prepare_query(form_data)
|
||||
other_headers = other_headers.merge(multipart_headers)
|
||||
end
|
||||
|
||||
request = request_class.new(uri.request_uri, other_headers)
|
||||
add_form_data(request, form_data) if form_data && !multipart
|
||||
|
||||
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
||||
http.request(request) do |response|
|
||||
args = [request]
|
||||
args << multipart_query if multipart
|
||||
http.request(*args) do |response|
|
||||
if response.is_a?(Net::HTTPRedirection) && !response.is_a?(Net::HTTPNotModified)
|
||||
last_host = uri.host
|
||||
last_scheme = uri.scheme
|
||||
|
@ -76,6 +83,15 @@ module CanvasHttp
|
|||
end
|
||||
end
|
||||
|
||||
def self.add_form_data(request, form_data)
|
||||
if form_data.is_a?(String)
|
||||
request.body = form_data
|
||||
request.content_type = 'application/x-www-form-urlencoded'
|
||||
else
|
||||
request.set_form_data(form_data)
|
||||
end
|
||||
end
|
||||
|
||||
# returns [normalized_url_string, URI] if valid, raises otherwise
|
||||
def self.validate_url(value, host=nil, scheme=nil)
|
||||
value = value.strip
|
||||
|
|
|
@ -84,7 +84,7 @@ describe "CanvasHttp" do
|
|||
to_return(status: 301, headers: { 'Location' => 'http://www.example2.com/a'})
|
||||
stub_request(:get, "http://www.example2.com/a").
|
||||
to_return(status: 301, headers: { 'Location' => 'http://www.example3.com/a'})
|
||||
expect { CanvasHttp.get("http://www.example.com/a", {}, 2) }.to raise_error(CanvasHttp::TooManyRedirectsError)
|
||||
expect { CanvasHttp.get("http://www.example.com/a", redirect_limit: 2) }.to raise_error(CanvasHttp::TooManyRedirectsError)
|
||||
end
|
||||
|
||||
it "should yield requests to blocks" do
|
||||
|
|
|
@ -7,7 +7,6 @@ module Canvas::Migration::ExternalContent
|
|||
end
|
||||
|
||||
def register_service(key, service)
|
||||
key = key.to_url
|
||||
raise "service with the key #{key} is already registered" if self.registered_services[key] && self.registered_services[key] != service
|
||||
Canvas::Migration::ExternalContent::ServiceInterface.validate_service!(service)
|
||||
self.registered_services[key] = service
|
||||
|
@ -29,29 +28,79 @@ module Canvas::Migration::ExternalContent
|
|||
pending_exports
|
||||
end
|
||||
|
||||
def retry_delay
|
||||
Setting.get('external_content_retry_delay_seconds', '20').to_i.seconds
|
||||
end
|
||||
|
||||
def retry_limit
|
||||
Setting.get('external_content_retry_limit', '5').to_i
|
||||
end
|
||||
|
||||
def retry_block_for_each(pending_keys)
|
||||
retry_count = 0
|
||||
|
||||
while pending_keys.any? && retry_count <= retry_limit
|
||||
sleep(retry_delay) if retry_count > 0
|
||||
|
||||
pending_keys.each do |service_key|
|
||||
begin
|
||||
pending_keys.delete(service_key) if yield(service_key)
|
||||
rescue => e
|
||||
pending_keys.delete(service_key) # don't retry if failed
|
||||
Canvas::Errors.capture_exception(:external_content_migration, e)
|
||||
end
|
||||
end
|
||||
retry_count += 1
|
||||
end
|
||||
if pending_keys.any?
|
||||
Canvas::Errors.capture_exception(:external_content_migration,
|
||||
"External content migrations timed out for #{pending_keys.join(', ')}")
|
||||
end
|
||||
end
|
||||
|
||||
# retrieves data from each service to be saved as JSON in the exported package
|
||||
def retrieve_exported_content(pending_exports)
|
||||
exported_content = {}
|
||||
pending_exports.each do |key, pending_export|
|
||||
service_data = self.registered_services[key].retrieve_export(pending_export)
|
||||
exported_content[key] = Canvas::Migration::ExternalContent::Translator.new.translate_data(service_data, :export) if service_data
|
||||
|
||||
retry_block_for_each(pending_exports.keys) do |key|
|
||||
pending_export = pending_exports[key]
|
||||
service = self.registered_services[key]
|
||||
|
||||
if service.export_completed?(pending_export)
|
||||
service_data = service.retrieve_export(pending_export)
|
||||
exported_content[key] = Canvas::Migration::ExternalContent::Translator.new.translate_data(service_data, :export) if service_data
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
exported_content
|
||||
end
|
||||
|
||||
# sends back the imported content to the external services
|
||||
def send_imported_content(migration, imported_content)
|
||||
imported_content = Canvas::Migration::ExternalContent::Translator.new(migration).translate_data(imported_content, :import)
|
||||
|
||||
pending_imports = {}
|
||||
imported_content.each do |key, content|
|
||||
service = self.registered_services[key]
|
||||
if service
|
||||
begin
|
||||
service.send_imported_content(migration.context, content)
|
||||
if import = service.send_imported_content(migration.context, content)
|
||||
pending_imports[key] = import
|
||||
end
|
||||
rescue => e
|
||||
Canvas::Errors.capture_exception(:external_content_migration, e)
|
||||
end
|
||||
end
|
||||
end
|
||||
ensure_imports_completed(pending_imports)
|
||||
end
|
||||
|
||||
def ensure_imports_completed(pending_imports)
|
||||
# keep pinging until they're all finished
|
||||
retry_block_for_each(pending_imports.keys) do |key|
|
||||
self.registered_services[key].import_completed?(pending_imports[key])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -14,16 +14,25 @@ module Canvas::Migration::ExternalContent
|
|||
# if the export is selective, opts[:selective] will be true
|
||||
# and opts[:exported_assets] will be a set of exported asset strings
|
||||
#
|
||||
# export_completed?(export_data)
|
||||
# check to see if the export is ready to be downloaded
|
||||
#
|
||||
# retrieve_export(export_data)
|
||||
# return the data that we need to save in the package
|
||||
#
|
||||
# send_imported_content(course, imported_content)
|
||||
# give back the translated data for importing
|
||||
# gives back the translated data for importing to the service
|
||||
# return information needed to verify import is complete
|
||||
#
|
||||
# import_completed?(import_data)
|
||||
# verifies that the import completed
|
||||
methods = {
|
||||
:applies_to_course? => 1,
|
||||
:begin_export => 2,
|
||||
:export_completed? => 1,
|
||||
:retrieve_export => 1,
|
||||
:send_imported_content => 2
|
||||
:send_imported_content => 2,
|
||||
:import_completed? => 1
|
||||
}
|
||||
methods.each do |method_name, arity|
|
||||
raise "external content service needs to implement #{method_name}" unless service.respond_to?(method_name)
|
||||
|
|
|
@ -273,9 +273,9 @@ class CourseLinkValidator
|
|||
# ping the url and make sure we get a 200
|
||||
def reachable_url?(url)
|
||||
begin
|
||||
response = CanvasHttp.head(url, { "Accept-Encoding" => "gzip" }, 9)
|
||||
response = CanvasHttp.head(url, { "Accept-Encoding" => "gzip" }, redirect_limit: 9)
|
||||
if %w{404 405}.include?(response.code)
|
||||
response = CanvasHttp.get(url, { "Accept-Encoding" => "gzip" }, 9)
|
||||
response = CanvasHttp.get(url, { "Accept-Encoding" => "gzip" }, redirect_limit: 9)
|
||||
end
|
||||
|
||||
case response.code
|
||||
|
|
|
@ -19,6 +19,7 @@ describe ContentMigration do
|
|||
it "should skip everything if #applies_to_course? returns false" do
|
||||
TestExternalContentService.stubs(:applies_to_course?).returns(false)
|
||||
TestExternalContentService.expects(:begin_export).never
|
||||
TestExternalContentService.expects(:export_completed?).never
|
||||
TestExternalContentService.expects(:retrieve_export).never
|
||||
TestExternalContentService.expects(:send_imported_content).never
|
||||
|
||||
|
@ -30,6 +31,7 @@ describe ContentMigration do
|
|||
|
||||
test_data = {:sometestdata => "something"}
|
||||
TestExternalContentService.expects(:begin_export).with(@copy_from, {}).returns(test_data)
|
||||
TestExternalContentService.expects(:export_completed?).with(test_data).returns(true)
|
||||
TestExternalContentService.expects(:retrieve_export).with(test_data).returns(nil)
|
||||
TestExternalContentService.expects(:send_imported_content).never
|
||||
run_course_copy
|
||||
|
@ -58,6 +60,7 @@ describe ContentMigration do
|
|||
'$canvas_page_id' => page.id,
|
||||
'$canvas_quiz_id' => quiz.id
|
||||
}
|
||||
TestExternalContentService.stubs(:export_completed?).returns(true)
|
||||
TestExternalContentService.stubs(:retrieve_export).returns(data)
|
||||
|
||||
run_course_copy
|
||||
|
@ -92,6 +95,7 @@ describe ContentMigration do
|
|||
|
||||
TestExternalContentService.stubs(:applies_to_course?).returns(true)
|
||||
TestExternalContentService.stubs(:begin_export).returns(true)
|
||||
TestExternalContentService.stubs(:export_completed?).returns(true)
|
||||
TestExternalContentService.stubs(:retrieve_export).returns(
|
||||
{'$canvas_assignment_id' => assmt.id, '$canvas_discussion_topic_id' => topic.id})
|
||||
|
||||
|
@ -115,6 +119,7 @@ describe ContentMigration do
|
|||
item = cm.add_item(:id => assmt.id, :type => 'assignment')
|
||||
|
||||
TestExternalContentService.stubs(:applies_to_course?).returns(true)
|
||||
TestExternalContentService.stubs(:export_completed?).returns(true)
|
||||
TestExternalContentService.stubs(:retrieve_export).returns({})
|
||||
|
||||
@cm.copy_options = {:context_modules => {mig_id(cm) => "1"}}
|
||||
|
@ -125,5 +130,17 @@ describe ContentMigration do
|
|||
|
||||
run_course_copy
|
||||
end
|
||||
|
||||
it "should only check a few times for the export to finish before timing out" do
|
||||
TestExternalContentService.stubs(:applies_to_course?).returns(true)
|
||||
TestExternalContentService.stubs(:begin_export).returns(true)
|
||||
Canvas::Migration::ExternalContent::Migrator.expects(:retry_delay).at_least_once.returns(0) # so we're not actually sleeping for 30s a pop
|
||||
TestExternalContentService.expects(:export_completed?).times(6).returns(false) # retries 5 times
|
||||
|
||||
Canvas::Errors.expects(:capture_exception).with(:external_content_migration,
|
||||
"External content migrations timed out for test_service")
|
||||
|
||||
run_course_copy
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue