canvas-lms/app/models/context_external_tool.rb

515 lines
17 KiB
Ruby

class ContextExternalTool < ActiveRecord::Base
include Workflow
has_many :content_tags, :as => :content
belongs_to :context, :polymorphic => true
belongs_to :cloned_item
attr_accessible :privacy_level, :domain, :url, :shared_secret, :consumer_key,
:name, :description, :custom_fields, :custom_fields_string,
:course_navigation, :account_navigation, :user_navigation,
:resource_selection, :editor_button,
:config_type, :config_url, :config_xml, :tool_id
validates_presence_of :name
validates_presence_of :consumer_key
validates_presence_of :shared_secret
validate :url_or_domain_is_set
serialize :settings
attr_accessor :config_type, :config_url, :config_xml
before_save :infer_defaults
after_save :touch_context
validate :check_for_xml_error
workflow do
state :anonymous
state :name_only
state :email_only
state :public
state :deleted
end
def create_launch(context, user, return_url, selection_type=nil)
if selection_type
if self.settings[selection_type.to_sym]
resource_url = self.settings[selection_type.to_sym][:url]
else
raise t('no_selection_type', "This tool has no selection type %{type}", :type => selection_type)
end
end
resource_url ||= self.url
BasicLTI::ToolLaunch.new(:url => resource_url,
:tool => self,
:user => user,
:context => context,
:link_code => context.opaque_identifier(:asset_string),
:return_url => return_url,
:resource_type => selection_type)
end
set_policy do
given { |user, session| self.cached_context_grants_right?(user, session, :update) }
can :read and can :update and can :delete
end
def url_or_domain_is_set
setting_types = [:user_navigation, :course_navigation, :account_navigation, :resource_selection, :editor_button]
# both url and domain should not be set
if url.present? && domain.present?
errors.add(:url, t('url_or_domain_not_both', "Either the url or domain should be set, not both."))
errors.add(:domain, t('url_or_domain_not_both', "Either the url or domain should be set, not both."))
# url or domain (or url on canvas lti extension) is required
elsif url.blank? && domain.blank? && setting_types.all?{|k| !settings[k] || settings[k]['url'].blank? }
errors.add(:url, t('url_or_domain_required', "Either the url or domain should be set."))
errors.add(:domain, t('url_or_domain_required', "Either the url or domain should be set."))
end
end
def settings
read_attribute(:settings) || write_attribute(:settings, {})
end
def label_for(key, lang=nil)
labels = settings[key] && settings[key][:labels]
labels2 = settings[:labels]
(labels && labels[lang]) ||
(labels && lang && labels[lang.split('-').first]) ||
(settings[key] && settings[key][:text]) ||
(labels2 && labels2[lang]) ||
(labels2 && lang && labels2[lang.split('-').first]) ||
settings[:text] || name || "External Tool"
end
def xml_error(error)
@xml_error = error
end
def check_for_xml_error
if @xml_error
errors.add_to_base(@xml_error)
false
end
end
protected :check_for_xml_error
def readable_state
workflow_state.titleize
end
def privacy_level=(val)
if ['anonymous', 'name_only', 'email_only', 'public'].include?(val)
self.workflow_state = val
end
end
def privacy_level
self.workflow_state
end
def custom_fields_string
(settings[:custom_fields] || {}).map{|key, val|
"#{key}=#{val}"
}.join("\n")
end
def config_type=(val)
@config_type = val
process_extended_configuration
end
def config_xml=(val)
@config_xml = val
process_extended_configuration
end
def config_url=(val)
@config_url = val
process_extended_configuration
end
def process_extended_configuration
return unless (config_type == 'by_url' && config_url) || (config_type == 'by_xml' && config_xml)
tool_hash = nil
begin
converter = CC::Importer::Canvas::Converter.new({:no_archive_file => true})
if config_type == 'by_url'
tool_hash = converter.retrieve_and_convert_blti_url(config_url)
else
tool_hash = converter.convert_blti_xml(config_xml)
end
rescue CC::Importer::BLTIConverter::CCImportError => e
tool_hash = {:error => e.message}
end
real_name = self.name
if tool_hash[:error]
xml_error(tool_hash[:error])
else
ContextExternalTool.import_from_migration(tool_hash, self.context, self)
end
self.name = real_name unless real_name.blank?
end
def custom_fields_string=(str)
hash = {}
str.split(/\n/).each do |line|
key, val = line.split(/=/)
hash[key] = val if key.present? && val.present?
end
settings[:custom_fields] = hash
end
def custom_fields=(hash)
settings[:custom_fields] = hash if hash.is_a?(Hash)
end
def custom_fields
settings[:custom_fields]
end
def course_navigation=(hash)
tool_setting(:course_navigation, hash) { |nav_settings|
if hash[:visibility] == 'members' || hash[:visibility] == 'admins'
nav_settings[:visibility] = hash[:visibility]
end
nav_settings[:default] = !!hash[:default]
}
end
def account_navigation=(hash)
tool_setting(:account_navigation, hash)
end
def account_navigation
settings[:account_navigation]
end
def user_navigation=(hash)
tool_setting(:user_navigation, hash)
end
def user_navigation
settings[:user_navigation]
end
def resource_selection=(hash)
tool_setting(:resource_selection, hash, :selection_width, :selection_height, :icon_url)
end
def resource_selection
settings[:resource_selection]
end
def editor_button=(hash)
tool_setting(:editor_button, hash, :selection_width, :selection_height, :icon_url)
end
def editor_button
settings[:editor_button]
end
def icon_url=(i_url)
settings[:icon_url] = i_url
end
def icon_url
settings[:icon_url]
end
def text=(val)
settings[:text] = val
end
def text
settings[:text]
end
def shared_secret=(val)
write_attribute(:shared_secret, val) unless val.blank?
end
def infer_defaults
self.url = nil if url.blank?
self.domain = nil if domain.blank?
[:resource_selection, :editor_button].each do |type|
if settings[type]
settings[:icon_url] ||= settings[type][:icon_url] if settings[type][:icon_url]
settings[type][:selection_width] = settings[type][:selection_width].to_i if settings[type][:selection_width]
settings[type][:selection_height] = settings[type][:selection_height].to_i if settings[type][:selection_height]
end
end
[:course_navigation, :account_navigation, :user_navigation, :resource_selection, :editor_button].each do |type|
if settings[type]
if !(settings[type][:url] || self.url) || (settings[type].has_key?(:enabled) && !settings[type][:enabled])
settings.delete(type)
end
end
end
settings.delete(:resource_selection) if settings[:resource_selection] && (!settings[:resource_selection][:selection_width] || !settings[:resource_selection][:selection_height])
settings.delete(:editor_button) if settings[:editor_button] && !settings[:icon_url]
self.has_user_navigation = !!settings[:user_navigation]
self.has_course_navigation = !!settings[:course_navigation]
self.has_account_navigation = !!settings[:account_navigation]
self.has_resource_selection = !!settings[:resource_selection]
self.has_editor_button = !!settings[:editor_button]
true
end
def self.standardize_url(url)
return "" if url.empty?
url = "http://" + url unless url.match(/:\/\//)
res = URI.parse(url).normalize
res.query = res.query.split(/&/).sort.join('&') if !res.query.blank?
res.to_s
end
alias_method :destroy!, :destroy
def destroy
self.workflow_state = 'deleted'
save!
end
def include_email?
email_only? || public?
end
def include_name?
name_only? || public?
end
def precedence
if domain
# Somebody tell me if we should be expecting more than
# 25 dots in a url host...
25 - domain.split(/\./).length
elsif url
25
else
26
end
end
def matches_url?(url)
if !defined?(@standard_url)
@standard_url = !self.url.blank? && ContextExternalTool.standardize_url(self.url)
end
return true if url == @standard_url
host = URI.parse(url).host rescue nil
!!(host && ('.' + host).match(/\.#{domain}\z/))
end
def self.all_tools_for(context)
contexts = []
tools = []
while context
if context.is_a?(Group)
contexts << context
context = context.context || context.account
elsif context.is_a?(Course)
contexts << context
context = context.account
elsif context.is_a?(Account)
contexts << context
context = context.parent_account
else
context = nil
end
end
return nil if contexts.empty?
contexts.each do |context|
tools += context.context_external_tools.active
end
tools.sort_by(&:name)
end
# Order of precedence: Basic LTI defines precedence as first
# checking for a match on domain. Subdomains count as a match
# on less-specific domains, but the most-specific domain will
# match first. So awesome.bob.example.com matches an
# external_tool with example.com as the domain, but only if
# there isn't another external_tool where awesome.bob.example.com
# or bob.example.com is set as the domain.
#
# If there is no domain match then check for an exact url match
# as configured by an admin. If there is still no match
# then check for a match on the current context (configured by
# the teacher).
def self.find_external_tool(url, context, preferred_tool_id=nil)
url = ContextExternalTool.standardize_url(url)
contexts = []
while context
if context.is_a?(Group)
contexts << context
context = context.context || context.account
elsif context.is_a?(Course)
contexts << context
context = context.account
elsif context.is_a?(Account)
contexts << context
context = context.parent_account
else
context = nil
end
end
# Always use the preferred tool if it's valid and has a resource_selection configuration.
# If it didn't have resource_selection then a change in the URL would have been done manually,
# and there's no reason to assume a different URL was intended. With a resource_selection
# insertion, there's a stronger chance that a different URL was intended.
preferred_tool = ContextExternalTool.active.find_by_id(preferred_tool_id)
return preferred_tool if preferred_tool && preferred_tool.settings[:resource_selection]
contexts.each do |context|
res = context.context_external_tools.active.sort_by{|t| [t.precedence, t.id == preferred_tool_id ? 0 : 1] }.detect{|tool| tool.url && tool.matches_url?(url) }
return res if res
end
contexts.each do |context|
res = context.context_external_tools.active.sort_by{|t| [t.precedence, t.id == preferred_tool_id ? 0 : 1] }.detect{|tool| tool.domain && tool.matches_url?(url) }
return res if res
end
nil
end
named_scope :having_setting, lambda{|setting|
{:conditions => {"has_#{setting.to_s}" => true} }
}
def self.find_for(id, context, type)
tool = context.context_external_tools.having_setting(type).find_by_id(id)
if !tool && context.is_a?(Group)
context = context.context
tool = context.context_external_tools.having_setting(type).find_by_id(id)
end
if !tool
account_ids = context.account_chain_ids
tool = ContextExternalTool.having_setting(type).find_by_context_type_and_context_id_and_id('Account', account_ids, id)
end
raise ActiveRecord::RecordNotFound if !tool
tool
end
named_scope :active, :conditions => ['context_external_tools.workflow_state != ?', 'deleted']
def self.find_all_for(context, type)
tools = []
if !context.is_a?(Account) && context.respond_to?(:context_external_tools)
tools += context.context_external_tools.having_setting(type.to_s)
end
account_ids = context.account_chain_ids
tools += ContextExternalTool.having_setting(type.to_s).find_all_by_context_type_and_context_id('Account', account_ids)
end
def self.serialization_excludes; [:shared_secret,:settings]; end
def self.process_migration(data, migration)
tools = data['external_tools'] ? data['external_tools']: []
tools.each do |tool|
if migration.import_object?("external_tools", tool['migration_id'])
item = import_from_migration(tool, migration.context)
if item.consumer_key == 'fake' || item.shared_secret == 'fake'
migration.add_warning(t('external_tool_attention_needed', 'The security parameters for the external tool "%{tool_name}" need to be set in Course Settings.', :tool_name => item.name))
end
end
end
end
# sets the custom fields from the main tool settings, and any on individual resource type settings
def set_custom_fields(hash, resource_type)
fields = [settings[:custom_fields] || {}]
fields << (settings[resource_type.to_sym][:custom_fields] || {}) if resource_type && settings[resource_type.to_sym]
fields.each do |field_set|
field_set.each do |key, val|
key = key.to_s.gsub(/[^\w]/, '_').downcase
if key.match(/^custom_/)
hash[key] = val
else
hash["custom_#{key}"] = val
end
end
end
end
def clone_for(context, dup=nil, options={})
if !self.cloned_item && !self.new_record?
self.cloned_item = ClonedItem.create(:original_item => self)
self.save!
end
existing = ContextExternalTool.active.find_by_context_type_and_context_id_and_id(context.class.to_s, context.id, self.id)
existing ||= ContextExternalTool.active.find_by_context_type_and_context_id_and_cloned_item_id(context.class.to_s, context.id, self.cloned_item_id)
return existing if existing && !options[:overwrite]
new_tool = existing
new_tool ||= ContextExternalTool.new
new_tool.context = context
new_tool.settings = self.settings.clone
[:name, :shared_secret, :url, :domain, :consumer_key, :workflow_state, :description].each do |att|
new_tool.write_attribute(att, self.read_attribute(att))
end
new_tool.cloned_item_id = self.cloned_item_id
new_tool
end
def resource_selection_settings
settings[:resource_selection]
end
def self.import_from_migration(hash, context, item=nil)
hash = hash.with_indifferent_access
return nil if hash[:migration_id] && hash[:external_tools_to_import] && !hash[:external_tools_to_import][hash[:migration_id]]
item ||= find_by_context_id_and_context_type_and_migration_id(context.id, context.class.to_s, hash[:migration_id]) if hash[:migration_id]
item ||= context.context_external_tools.new
item.migration_id = hash[:migration_id]
item.name = hash[:title]
item.description = hash[:description]
item.tool_id = hash[:tool_id]
item.url = hash[:url] unless hash[:url].blank?
item.domain = hash[:domain] unless hash[:domain].blank?
item.privacy_level = hash[:privacy_level] || 'name_only'
item.consumer_key ||= hash[:consumer_key] || 'fake'
item.shared_secret ||= hash[:shared_secret] || 'fake'
item.settings = hash[:settings].with_indifferent_access if hash[:settings].is_a?(Hash)
if hash[:custom_fields].is_a? Hash
item.settings[:custom_fields] ||= {}
item.settings[:custom_fields].merge! hash[:custom_fields]
end
if hash[:extensions].is_a? Array
item.settings[:vendor_extensions] ||= []
hash[:extensions].each do |ext|
next unless ext[:custom_fields].is_a? Hash
if existing = item.settings[:vendor_extensions].find { |ve| ve[:platform] == ext[:platform] }
existing[:custom_fields] ||= {}
existing[:custom_fields].merge! ext[:custom_fields]
else
item.settings[:vendor_extensions] << {:platform => ext[:platform], :custom_fields => ext[:custom_fields]}
end
end
end
item.save!
context.imported_migration_items << item if context.respond_to?(:imported_migration_items) && context.imported_migration_items && item.new_record?
item
end
private
def tool_setting(setting, hash, *keys)
if !hash || !hash.is_a?(Hash)
settings.delete setting
return
else
settings[setting] = {}
end
settings[setting][:url] = hash[:url] if hash[:url]
settings[setting][:text] = hash[:text] if hash[:text]
settings[setting][:custom_fields] = hash[:custom_fields] if hash[:custom_fields]
settings[setting][:enabled] = Canvas::Plugin.value_to_boolean(hash[:enabled]) if hash.has_key?(:enabled)
keys.each { |key| settings[setting][key] = hash[key] if hash.has_key?(key) }
# if the type needs to do some validations for specific keys
yield settings[setting] if block_given?
settings[setting]
end
end