506 lines
20 KiB
Ruby
506 lines
20 KiB
Ruby
#
|
|
# Copyright (C) 2013 - 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/>.
|
|
#
|
|
|
|
# @API Content Migrations
|
|
#
|
|
# API for accessing content migrations and migration issues
|
|
# @model ContentMigration
|
|
# {
|
|
# "id": "ContentMigration",
|
|
# "description": "",
|
|
# "properties": {
|
|
# "id": {
|
|
# "description": "the unique identifier for the migration",
|
|
# "example": 370663,
|
|
# "type": "integer"
|
|
# },
|
|
# "migration_type": {
|
|
# "description": "the type of content migration",
|
|
# "example": "common_cartridge_importer",
|
|
# "type": "string"
|
|
# },
|
|
# "migration_type_title": {
|
|
# "description": "the name of the content migration type",
|
|
# "example": "Canvas Cartridge Importer",
|
|
# "type": "string"
|
|
# },
|
|
# "migration_issues_url": {
|
|
# "description": "API url to the content migration's issues",
|
|
# "example": "https://example.com/api/v1/courses/1/content_migrations/1/migration_issues",
|
|
# "type": "string"
|
|
# },
|
|
# "attachment": {
|
|
# "description": "attachment api object for the uploaded file may not be present for all migrations",
|
|
# "example": "{\"url\"=>\"https://example.com/api/v1/courses/1/content_migrations/1/download_archive\"}",
|
|
# "type": "string"
|
|
# },
|
|
# "progress_url": {
|
|
# "description": "The api endpoint for polling the current progress",
|
|
# "example": "https://example.com/api/v1/progress/4",
|
|
# "type": "string"
|
|
# },
|
|
# "user_id": {
|
|
# "description": "The user who started the migration",
|
|
# "example": 4,
|
|
# "type": "integer"
|
|
# },
|
|
# "workflow_state": {
|
|
# "description": "Current state of the content migration: pre_processing, pre_processed, running, waiting_for_select, completed, failed",
|
|
# "example": "running",
|
|
# "type": "string",
|
|
# "allowableValues": {
|
|
# "values": [
|
|
# "pre_processing",
|
|
# "pre_processed",
|
|
# "running",
|
|
# "waiting_for_select",
|
|
# "completed",
|
|
# "failed"
|
|
# ]
|
|
# }
|
|
# },
|
|
# "started_at": {
|
|
# "description": "timestamp",
|
|
# "example": "2012-06-01T00:00:00-06:00",
|
|
# "type": "datetime"
|
|
# },
|
|
# "finished_at": {
|
|
# "description": "timestamp",
|
|
# "example": "2012-06-01T00:00:00-06:00",
|
|
# "type": "datetime"
|
|
# },
|
|
# "pre_attachment": {
|
|
# "description": "file uploading data, see {file:file_uploads.html File Upload Documentation} for file upload workflow This works a little differently in that all the file data is in the pre_attachment hash if there is no upload_url then there was an attachment pre-processing error, the error message will be in the message key This data will only be here after a create or update call",
|
|
# "example": "{\"upload_url\"=>\"\", \"message\"=>\"file exceeded quota\", \"upload_params\"=>{}}",
|
|
# "type": "string"
|
|
# }
|
|
# }
|
|
# }
|
|
#
|
|
# @model Migrator
|
|
# {
|
|
# "id": "Migrator",
|
|
# "description": "",
|
|
# "properties": {
|
|
# "type": {
|
|
# "description": "The value to pass to the create endpoint",
|
|
# "example": "common_cartridge_importer",
|
|
# "type": "string"
|
|
# },
|
|
# "requires_file_upload": {
|
|
# "description": "Whether this endpoint requires a file upload",
|
|
# "example": true,
|
|
# "type": "boolean"
|
|
# },
|
|
# "name": {
|
|
# "description": "Description of the package type expected",
|
|
# "example": "Common Cartridge 1.0/1.1/1.2 Package",
|
|
# "type": "string"
|
|
# },
|
|
# "required_settings": {
|
|
# "description": "A list of fields this system requires",
|
|
# "example": ["source_course_id"],
|
|
# "type": "array",
|
|
# "items": {"type": "string"}
|
|
# }
|
|
# }
|
|
# }
|
|
#
|
|
class ContentMigrationsController < ApplicationController
|
|
include Api::V1::ContentMigration
|
|
include Api::V1::ExternalTools
|
|
|
|
before_action :require_context
|
|
before_action :require_auth
|
|
|
|
# @API List content migrations
|
|
#
|
|
# Returns paginated content migrations
|
|
#
|
|
# @example_request
|
|
#
|
|
# curl https://<canvas>/api/v1/courses/<course_id>/content_migrations \
|
|
# -H 'Authorization: Bearer <token>'
|
|
#
|
|
# @returns [ContentMigration]
|
|
def index
|
|
return unless authorized_action(@context, @current_user, :manage_content)
|
|
|
|
Folder.root_folders(@context) # ensure course root folder exists so file imports can run
|
|
|
|
@migrations = Api.paginate(@context.content_migrations.order("id DESC"), self, api_v1_course_content_migration_list_url(@context))
|
|
@migrations.each{|mig| mig.check_for_pre_processing_timeout }
|
|
content_migration_json_hash = content_migrations_json(@migrations, @current_user, session)
|
|
|
|
if api_request?
|
|
render :json => content_migration_json_hash
|
|
else
|
|
@plugins = ContentMigration.migration_plugins(true).sort_by {|p| [p.metadata(:sort_order) || CanvasSort::Last, p.metadata(:select_text)]}
|
|
|
|
options = @plugins.map{|p| {:label => p.metadata(:select_text), :id => p.id}}
|
|
|
|
external_tools = ContextExternalTool.all_tools_for(@context, :placements => :migration_selection, :root_account => @domain_root_account, :current_user => @current_user)
|
|
options.concat(external_tools.map do |et|
|
|
{
|
|
id: et.asset_string,
|
|
label: et.label_for('migration_selection', I18n.locale)
|
|
}
|
|
end)
|
|
|
|
js_env :EXTERNAL_TOOLS => external_tools_json(external_tools, @context, @current_user, session)
|
|
js_env :UPLOAD_LIMIT => @context.storage_quota
|
|
js_env :SELECT_OPTIONS => options
|
|
js_env :QUESTION_BANKS => @context.assessment_question_banks.except(:preload).select([:title, :id]).active
|
|
js_env :COURSE_ID => @context.id
|
|
js_env :CONTENT_MIGRATIONS => content_migration_json_hash
|
|
js_env(:OLD_START_DATE => datetime_string(@context.start_at, :verbose))
|
|
js_env(:OLD_END_DATE => datetime_string(@context.conclude_at, :verbose))
|
|
js_env(:SHOW_SELECT => @current_user.manageable_courses.count <= 100)
|
|
set_tutorial_js_env
|
|
end
|
|
end
|
|
|
|
# @API Get a content migration
|
|
#
|
|
# Returns data on an individual content migration
|
|
#
|
|
# @example_request
|
|
#
|
|
# curl https://<canvas>/api/v1/courses/<course_id>/content_migrations/<id> \
|
|
# -H 'Authorization: Bearer <token>'
|
|
#
|
|
# @returns ContentMigration
|
|
def show
|
|
@content_migration = @context.content_migrations.find(params[:id])
|
|
@content_migration.check_for_pre_processing_timeout
|
|
render :json => content_migration_json(@content_migration, @current_user, session)
|
|
end
|
|
|
|
def migration_plugin_supported?(plugin)
|
|
Array(plugin.default_settings && plugin.default_settings[:valid_contexts]).include?(@context.class.to_s)
|
|
end
|
|
private :migration_plugin_supported?
|
|
|
|
# @API Create a content migration
|
|
#
|
|
# Create a content migration. If the migration requires a file to be uploaded
|
|
# the actual processing of the file will start once the file upload process is completed.
|
|
# File uploading works as described in the {file:file_uploads.html File Upload Documentation}
|
|
# except that the values are set on a *pre_attachment* sub-hash.
|
|
#
|
|
# For migrations that don't require a file to be uploaded, like course copy, the
|
|
# processing will begin as soon as the migration is created.
|
|
#
|
|
# You can use the {api:ProgressController#show Progress API} to track the
|
|
# progress of the migration. The migration's progress is linked to with the
|
|
# _progress_url_ value.
|
|
#
|
|
# The two general workflows are:
|
|
#
|
|
# If no file upload is needed:
|
|
#
|
|
# 1. POST to create
|
|
# 2. Use the {api:ProgressController#show Progress} specified in _progress_url_ to monitor progress
|
|
#
|
|
# For file uploading:
|
|
#
|
|
# 1. POST to create with file info in *pre_attachment*
|
|
# 2. Do {file:file_uploads.html file upload processing} using the data in the *pre_attachment* data
|
|
# 3. {api:ContentMigrationsController#show GET} the ContentMigration
|
|
# 4. Use the {api:ProgressController#show Progress} specified in _progress_url_ to monitor progress
|
|
#
|
|
# @argument migration_type [Required, String]
|
|
# The type of the migration. Use the
|
|
# {api:ContentMigrationsController#available_migrators Migrator} endpoint to
|
|
# see all available migrators. Default allowed values:
|
|
# canvas_cartridge_importer, common_cartridge_importer,
|
|
# course_copy_importer, zip_file_importer, qti_converter, moodle_converter
|
|
#
|
|
# @argument pre_attachment[name] [String]
|
|
# Required if uploading a file. This is the first step in uploading a file
|
|
# to the content migration. See the {file:file_uploads.html File Upload
|
|
# Documentation} for details on the file upload workflow.
|
|
#
|
|
# @argument pre_attachment[*]
|
|
# Other file upload properties, See {file:file_uploads.html File Upload
|
|
# Documentation}
|
|
#
|
|
# @argument settings[file_url] [string] A URL to download the file from. Must not require authentication.
|
|
#
|
|
# @argument settings[source_course_id] [String]
|
|
# The course to copy from for a course copy migration. (required if doing
|
|
# course copy)
|
|
#
|
|
# @argument settings[folder_id] [String]
|
|
# The folder to unzip the .zip file into for a zip_file_import.
|
|
# (required if doing .zip file upload)
|
|
#
|
|
# @argument settings[overwrite_quizzes] [Boolean]
|
|
# Whether to overwrite quizzes with the same identifiers between content
|
|
# packages.
|
|
#
|
|
# @argument settings[question_bank_id] [Integer]
|
|
# The existing question bank ID to import questions into if not specified in
|
|
# the content package.
|
|
#
|
|
# @argument settings[question_bank_name] [String]
|
|
# The question bank to import questions into if not specified in the content
|
|
# package, if both bank id and name are set, id will take precedence.
|
|
#
|
|
# @argument date_shift_options[shift_dates] [Boolean]
|
|
# Whether to shift dates in the copied course
|
|
#
|
|
# @argument date_shift_options[old_start_date] [Date]
|
|
# The original start date of the source content/course
|
|
#
|
|
# @argument date_shift_options[old_end_date] [Date]
|
|
# The original end date of the source content/course
|
|
#
|
|
# @argument date_shift_options[new_start_date] [Date]
|
|
# The new start date for the content/course
|
|
#
|
|
# @argument date_shift_options[new_end_date] [Date]
|
|
# The new end date for the source content/course
|
|
#
|
|
# @argument date_shift_options[day_substitutions][X] [Integer]
|
|
# Move anything scheduled for day 'X' to the specified day. (0-Sunday,
|
|
# 1-Monday, 2-Tuesday, 3-Wednesday, 4-Thursday, 5-Friday, 6-Saturday)
|
|
#
|
|
# @argument date_shift_options[remove_dates] [Boolean]
|
|
# Whether to remove dates in the copied course. Cannot be used
|
|
# in conjunction with *shift_dates*.
|
|
#
|
|
# @example_request
|
|
#
|
|
# curl 'https://<canvas>/api/v1/courses/<course_id>/content_migrations' \
|
|
# -F 'migration_type=common_cartridge_importer' \
|
|
# -F 'settings[question_bank_name]=importquestions' \
|
|
# -F 'date_shift_options[old_start_date]=1999-01-01' \
|
|
# -F 'date_shift_options[new_start_date]=2013-09-01' \
|
|
# -F 'date_shift_options[old_end_date]=1999-04-15' \
|
|
# -F 'date_shift_options[new_end_date]=2013-12-15' \
|
|
# -F 'date_shift_options[day_substitutions][1]=2' \
|
|
# -F 'date_shift_options[day_substitutions][2]=3' \
|
|
# -F 'date_shift_options[shift_dates]=true' \
|
|
# -F 'pre_attachment[name]=mycourse.imscc' \
|
|
# -F 'pre_attachment[size]=12345' \
|
|
# -H 'Authorization: Bearer <token>'
|
|
#
|
|
# @returns ContentMigration
|
|
def create
|
|
@plugin = find_migration_plugin params[:migration_type]
|
|
|
|
if !@plugin
|
|
return render(:json => { :message => t('bad_migration_type', "Invalid migration_type") }, :status => :bad_request)
|
|
end
|
|
unless migration_plugin_supported?(@plugin)
|
|
return render(:json => { :message => t('unsupported_migration_type', "Unsupported migration_type for context") }, :status => :bad_request)
|
|
end
|
|
|
|
settings = @plugin.settings || {}
|
|
if settings[:requires_file_upload]
|
|
if !(params[:pre_attachment] && params[:pre_attachment][:name].present?) && !(params[:settings] && params[:settings][:file_url].present?)
|
|
return render(:json => {:message => t('must_upload_file', "File upload or url is required")}, :status => :bad_request)
|
|
end
|
|
end
|
|
source_course = lookup_sis_source_course
|
|
if validator = settings[:required_options_validator]
|
|
if res = validator.has_error(params[:settings], @current_user, @context)
|
|
return render(:json => { :message => res.respond_to?(:call) ? res.call : res }, :status => :bad_request)
|
|
end
|
|
end
|
|
|
|
@content_migration = @context.content_migrations.build(
|
|
user: @current_user,
|
|
context: @context,
|
|
migration_type: params[:migration_type],
|
|
initiated_source: :api
|
|
)
|
|
@content_migration.workflow_state = 'created'
|
|
@content_migration.source_course = source_course if source_course
|
|
|
|
update_migration
|
|
end
|
|
|
|
# @API Update a content migration
|
|
#
|
|
# Update a content migration. Takes same arguments as create except that you
|
|
# can't change the migration type. However, changing most settings after the
|
|
# migration process has started will not do anything. Generally updating the
|
|
# content migration will be used when there is a file upload problem. If the
|
|
# first upload has a problem you can supply new _pre_attachment_ values to
|
|
# start the process again.
|
|
#
|
|
# @returns ContentMigration
|
|
def update
|
|
@content_migration = @context.content_migrations.find(params[:id])
|
|
@content_migration.check_for_pre_processing_timeout
|
|
@plugin = find_migration_plugin @content_migration.migration_type
|
|
lookup_sis_source_course
|
|
update_migration
|
|
end
|
|
|
|
def lookup_sis_source_course
|
|
if params.has_key?(:settings) && params[:settings].has_key?(:source_course_id)
|
|
course = api_find(Course, params[:settings][:source_course_id])
|
|
params[:settings][:source_course_id] = course.id
|
|
course
|
|
end
|
|
end
|
|
private :lookup_sis_source_course
|
|
|
|
|
|
# @API List Migration Systems
|
|
#
|
|
# Lists the currently available migration types. These values may change.
|
|
#
|
|
# @returns [Migrator]
|
|
def available_migrators
|
|
systems = ContentMigration.migration_plugins(true).select{|sys| migration_plugin_supported?(sys)}
|
|
json = systems.map{|p| {
|
|
:type => p.id,
|
|
:requires_file_upload => !!p.settings[:requires_file_upload],
|
|
:name => p.meta['select_text'].call,
|
|
:required_settings => p.settings[:required_settings] || []
|
|
}}
|
|
|
|
render :json => json
|
|
end
|
|
|
|
# @note Leaving undocumented for now because format is expected to change
|
|
# Get list of items in the migration for selective import of content
|
|
#
|
|
# If no type is sent you will get a list of the top-level sections in the content
|
|
# It will look something like this:
|
|
# [
|
|
# {
|
|
# "type": "course_settings",
|
|
# "property": "copy[all_course_settings]",
|
|
# "title": "Course Settings"
|
|
# },
|
|
# {
|
|
# "type": "syllabus_body",
|
|
# "property": "copy[all_syllabus_body]",
|
|
# "title": "Syllabus Body"
|
|
# },
|
|
# {
|
|
# "type": "context_modules",
|
|
# "property": "copy[all_context_modules]",
|
|
# "title": "Modules",
|
|
# "count": 1
|
|
# },
|
|
# {
|
|
# "type": "discussion_topics",
|
|
# "property": "copy[all_discussion_topics]",
|
|
# "title": "Discussion Topics",
|
|
# "count": 1
|
|
# },
|
|
# {
|
|
# "type": "wiki_pages",
|
|
# "property": "copy[all_wiki_pages]",
|
|
# "title": "Wiki Pages",
|
|
# "count": 1
|
|
# },
|
|
# {
|
|
# "type": "attachments",
|
|
# "property": "copy[all_attachments]",
|
|
# "title": "Files",
|
|
# "count": 1
|
|
# }
|
|
# ]
|
|
#
|
|
# If there is no count for an item that means there are no sub-items and you
|
|
# shouldn't try to fetch them
|
|
#
|
|
# @argument type [Optional, String] Return list of specified type
|
|
#
|
|
# @returns list of content items
|
|
def content_list
|
|
@content_migration = @context.content_migrations.find(params[:id])
|
|
base_url = api_v1_course_content_migration_selective_data_url(@context, @content_migration)
|
|
formatter = Canvas::Migration::Helpers::SelectiveContentFormatter.new(@content_migration, base_url)
|
|
|
|
unless formatter.valid_type?(params[:type])
|
|
return render :json => {:message => "unsupported migration type"}, :status => :bad_request
|
|
end
|
|
render :json => formatter.get_content_list(params[:type])
|
|
end
|
|
|
|
protected
|
|
|
|
def require_auth
|
|
authorized_action(@context, @current_user, :manage_content)
|
|
end
|
|
|
|
def find_migration_plugin(name)
|
|
if name =~ /context_external_tool/
|
|
plugin = Canvas::Plugin.new(name)
|
|
plugin.meta[:settings] = {requires_file_upload: true, worker: 'CCWorker', valid_contexts: %w{Course}}.with_indifferent_access
|
|
plugin
|
|
else
|
|
Canvas::Plugin.find(name)
|
|
end
|
|
end
|
|
|
|
def update_migration
|
|
@content_migration.update_migration_settings(params[:settings]) if params[:settings]
|
|
date_shift_params = params[:date_shift_options] ? params[:date_shift_options].to_hash.with_indifferent_access : {}
|
|
@content_migration.set_date_shift_options(date_shift_params)
|
|
|
|
params[:selective_import] = false if @plugin.settings && @plugin.settings[:no_selective_import]
|
|
if Canvas::Plugin.value_to_boolean(params[:selective_import])
|
|
@content_migration.migration_settings[:import_immediately] = false
|
|
if @plugin.settings[:skip_conversion_step]
|
|
# Mark the migration as 'waiting_for_select' since it doesn't need a conversion
|
|
# and is selective import
|
|
@content_migration.workflow_state = 'exported'
|
|
params[:do_not_run] = true
|
|
end
|
|
elsif params[:copy]
|
|
copy_options = ContentMigration.process_copy_params(params[:copy].to_hash.with_indifferent_access)
|
|
@content_migration.migration_settings[:migration_ids_to_import] ||= {}
|
|
@content_migration.migration_settings[:migration_ids_to_import][:copy] = copy_options
|
|
@content_migration.copy_options = copy_options
|
|
else
|
|
@content_migration.migration_settings[:import_immediately] = true
|
|
@content_migration.copy_options = {:everything => true}
|
|
@content_migration.migration_settings[:migration_ids_to_import] = {:copy => {:everything => true}}
|
|
end
|
|
|
|
if @content_migration.save
|
|
preflight_json = nil
|
|
if params[:pre_attachment]
|
|
@content_migration.workflow_state = 'pre_processing'
|
|
preflight_json = api_attachment_preflight(@content_migration, request, :params => params[:pre_attachment], :check_quota => true, :return_json => true)
|
|
if preflight_json[:error]
|
|
@content_migration.workflow_state = 'pre_process_error'
|
|
end
|
|
@content_migration.save!
|
|
@content_migration.reset_job_progress
|
|
elsif !params.has_key?(:do_not_run) || !Canvas::Plugin.value_to_boolean(params[:do_not_run])
|
|
@content_migration.queue_migration(@plugin)
|
|
end
|
|
|
|
render :json => content_migration_json(@content_migration, @current_user, session, preflight_json)
|
|
else
|
|
render :json => @content_migration.errors, :status => :bad_request
|
|
end
|
|
end
|
|
|
|
end
|