316 lines
12 KiB
Ruby
316 lines
12 KiB
Ruby
#
|
|
# Copyright (C) 2011 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/>.
|
|
#
|
|
|
|
class LearningOutcome < ActiveRecord::Base
|
|
include Workflow
|
|
attr_accessible :context, :description, :short_description, :title, :rubric_criterion
|
|
belongs_to :context, :polymorphic => true
|
|
has_many :learning_outcome_results
|
|
has_many :alignments, :class_name => 'ContentTag', :conditions => ['content_tags.tag_type = ? AND content_tags.workflow_state != ?', 'learning_outcome', 'deleted']
|
|
serialize :data
|
|
before_save :infer_defaults
|
|
validates_length_of :description, :maximum => maximum_text_length, :allow_nil => true, :allow_blank => true
|
|
validates_presence_of :short_description
|
|
sanitize_field :description, Instructure::SanitizeField::SANITIZE
|
|
|
|
set_policy do
|
|
# managing a contextual outcome requires manage_outcomes on the outcome's context
|
|
given {|user, session| self.context_id && self.cached_context_grants_right?(user, session, :manage_outcomes) }
|
|
can :create and can :read and can :update and can :delete
|
|
|
|
# reading a contextual outcome is also allowed by read_outcomes on the outcome's context
|
|
given {|user, session| self.context_id && self.cached_context_grants_right?(user, session, :read_outcomes) }
|
|
can :read
|
|
|
|
# managing a global outcome requires manage_global_outcomes on the site_admin
|
|
given {|user, session| self.context_id.nil? && Account.site_admin.grants_right?(user, session, :manage_global_outcomes) }
|
|
can :create and can :read and can :update and can :delete
|
|
|
|
# reading a global outcome is also allowed by just being logged in
|
|
given {|user, session| self.context_id.nil? && user }
|
|
can :read
|
|
end
|
|
|
|
def infer_defaults
|
|
if self.data && self.data[:rubric_criterion]
|
|
self.data[:rubric_criterion][:description] = self.short_description
|
|
end
|
|
self.context_code = "#{self.context_type.underscore}_#{self.context_id}" rescue nil
|
|
end
|
|
|
|
def align(asset, context, opts={})
|
|
tag = self.alignments.find_by_content_id_and_content_type_and_tag_type_and_context_id_and_context_type(asset.id, asset.class.to_s, 'learning_outcome', context.id, context.class.to_s)
|
|
tag ||= self.alignments.create(:content => asset, :tag_type => 'learning_outcome', :context => context)
|
|
mastery_type = opts[:mastery_type]
|
|
if mastery_type == 'points'
|
|
mastery_type = 'points_mastery'
|
|
else
|
|
mastery_type = 'explicit_mastery'
|
|
end
|
|
tag.tag = mastery_type
|
|
tag.position = (self.alignments.map(&:position).compact.max || 1) + 1
|
|
tag.mastery_score = opts[:mastery_score] if opts[:mastery_score]
|
|
tag.save
|
|
tag
|
|
end
|
|
|
|
def reorder_alignments(context, order)
|
|
order_hash = {}
|
|
order.each_with_index{|o, i| order_hash[o.to_i] = i; order_hash[o] = i }
|
|
tags = self.alignments.find_all_by_context_id_and_context_type_and_tag_type(context.id, context.class.to_s, 'learning_outcome')
|
|
tags = tags.sort_by{|t| order_hash[t.id] || order_hash[t.content_asset_string] || 999 }
|
|
updates = []
|
|
tags.each_with_index do |tag, idx|
|
|
tag.position = idx + 1
|
|
updates << "WHEN id=#{tag.id} THEN #{idx + 1}"
|
|
end
|
|
ContentTag.connection.execute("UPDATE content_tags SET position=CASE #{updates.join(" ")} ELSE position END WHERE id IN (#{tags.map(&:id).join(",")})")
|
|
self.touch
|
|
tags
|
|
end
|
|
|
|
def remove_alignment(asset, context, opts={})
|
|
tag = self.alignments.find_by_content_id_and_content_type_and_tag_type_and_context_id_and_context_type(asset.id, asset.class.to_s, 'learning_outcome', context.id, context.class.to_s)
|
|
tag.destroy if tag
|
|
tag
|
|
end
|
|
|
|
def self.update_alignments(asset, context, new_outcome_ids)
|
|
old_alignments = asset.learning_outcome_alignments
|
|
old_outcome_ids = old_alignments.map(&:learning_outcome_id).compact.uniq
|
|
|
|
defunct_outcome_ids = old_outcome_ids - new_outcome_ids
|
|
unless defunct_outcome_ids.empty?
|
|
asset.learning_outcome_alignments.update_all({:workflow_state => 'deleted'}, {:learning_outcome_id => defunct_outcome_ids})
|
|
end
|
|
|
|
missing_outcome_ids = new_outcome_ids - old_outcome_ids
|
|
unless missing_outcome_ids.empty?
|
|
LearningOutcome.find_all_by_id(missing_outcome_ids).each do |learning_outcome|
|
|
learning_outcome.align(asset, context)
|
|
end
|
|
end
|
|
end
|
|
|
|
def title
|
|
self.short_description
|
|
end
|
|
|
|
def title=(new_title)
|
|
self.short_description = new_title
|
|
end
|
|
|
|
workflow do
|
|
state :active
|
|
state :retired
|
|
state :deleted
|
|
end
|
|
|
|
named_scope :active, lambda{
|
|
{:conditions => ['workflow_state != ?', 'deleted'] }
|
|
}
|
|
|
|
def cached_context_short_name
|
|
@cached_context_name ||= Rails.cache.fetch(['short_name_lookup', self.context_code].cache_key) do
|
|
self.context.short_name rescue ""
|
|
end
|
|
end
|
|
|
|
def rubric_criterion=(hash)
|
|
self.data ||= {}
|
|
|
|
if hash
|
|
criterion = {}
|
|
criterion[:description] = hash[:description] || t(:no_description, "No Description")
|
|
criterion[:ratings] = []
|
|
ratings = hash[:enable] ? hash[:ratings].values : (hash[:ratings] || [])
|
|
ratings.each do |rating|
|
|
criterion[:ratings] << {
|
|
:description => rating[:description] || t(:no_comment, "No Comment"),
|
|
:points => rating[:points].to_f || 0
|
|
}
|
|
end
|
|
criterion[:ratings] = criterion[:ratings].sort_by{|r| r[:points] }.reverse
|
|
criterion[:mastery_points] = (hash[:mastery_points] || criterion[:ratings][0][:points]).to_f
|
|
criterion[:points_possible] = criterion[:ratings][0][:points] rescue 0
|
|
else
|
|
criterion = nil
|
|
end
|
|
|
|
self.data[:rubric_criterion] = criterion
|
|
end
|
|
|
|
alias_method :destroy!, :destroy
|
|
def destroy
|
|
# delete any remaining links to the outcome. in case of UI, this was
|
|
# triggered by ContentTag#destroy and the checks have already run, we don't
|
|
# need to do it again. in case of console, we don't care to force the
|
|
# checks. so just an update_all of workflow_state will do.
|
|
ContentTag.learning_outcome_links.active.update_all({:workflow_state => 'deleted'}, {:content_id => self.id})
|
|
|
|
# in case this got called in a console, delete the alignments also. the UI
|
|
# won't (shouldn't) allow deleting the outcome if there are still
|
|
# alignments, so this will be a no-op in that case. either way, these are
|
|
# not outcome links, so ContentTag#destroy is just changing the
|
|
# workflow_state; use update_all for efficiency.
|
|
ContentTag.learning_outcome_alignments.active.update_all({:workflow_state => 'deleted'}, {:learning_outcome_id => self.id})
|
|
|
|
self.workflow_state = 'deleted'
|
|
save!
|
|
end
|
|
|
|
def tie_to(context)
|
|
@tied_context = context
|
|
end
|
|
|
|
def artifacts_count_for_tied_context
|
|
codes = [@tied_context.asset_string]
|
|
if @tied_context.is_a?(Account)
|
|
if @tied_context == context
|
|
codes = "all"
|
|
else
|
|
codes = @tied_context.all_courses.scoped({:select => 'id'}).map(&:asset_string)
|
|
end
|
|
end
|
|
self.learning_outcome_results.for_context_codes(codes).count
|
|
end
|
|
|
|
def clone_for(context, parent)
|
|
lo = context.created_learning_outcomes.new
|
|
lo.context = context
|
|
lo.short_description = self.short_description
|
|
lo.description = self.description
|
|
lo.data = self.data
|
|
lo.save
|
|
parent.add_outcome(lo)
|
|
|
|
lo
|
|
end
|
|
|
|
def self.delete_if_unused(ids)
|
|
tags = ContentTag.active.find_all_by_content_id_and_content_type(ids, 'LearningOutcome')
|
|
to_delete = []
|
|
ids.each do |id|
|
|
to_delete << id unless tags.any?{|t| t.content_id == id }
|
|
end
|
|
LearningOutcome.update_all({:workflow_state => 'deleted', :updated_at => Time.now.utc}, {:id => to_delete})
|
|
end
|
|
|
|
def self.process_migration(data, migration)
|
|
outcomes = data['learning_outcomes'] ? data['learning_outcomes'] : []
|
|
migration.outcome_to_id_map = {}
|
|
outcomes.each do |outcome|
|
|
begin
|
|
if outcome[:type] == 'learning_outcome_group'
|
|
LearningOutcomeGroup.import_from_migration(outcome, migration)
|
|
else
|
|
LearningOutcome.import_from_migration(outcome, migration)
|
|
end
|
|
rescue
|
|
migration.add_warning("Couldn't import learning outcome \"#{outcome[:title]}\"", $!)
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.import_from_migration(hash, migration, item=nil)
|
|
context = migration.context
|
|
hash = hash.with_indifferent_access
|
|
outcome = nil
|
|
if !item && hash[:external_identifier]
|
|
if hash[:is_global_outcome]
|
|
outcome = LearningOutcome.active.find_by_id_and_context_id(hash[:external_identifier], nil)
|
|
else
|
|
outcome = context.available_outcome(hash[:external_identifier])
|
|
end
|
|
|
|
if outcome
|
|
# Help prevent linking to the wrong outcome if copying into a different install of canvas
|
|
outcome = nil if outcome.short_description != hash[:title]
|
|
end
|
|
|
|
if !outcome
|
|
migration.add_warning(t(:no_context_found, %{The external Learning Outcome couldn't be found for "%{title}", creating a copy.}, :title => hash[:title]))
|
|
end
|
|
end
|
|
|
|
if !outcome
|
|
if hash[:is_global_standard]
|
|
if Account.site_admin.grants_right?(migration.user, :manage_global_outcomes)
|
|
# import from vendor with global outcomes
|
|
context = nil
|
|
hash[:learning_outcome_group] ||= LearningOutcomeGroup.global_root_outcome_group
|
|
item ||= LearningOutcome.global.find_by_migration_id(hash[:migration_id]) if hash[:migration_id]
|
|
item ||= LearningOutcome.global.find_by_vendor_guid(hash[:vendor_guid]) if hash[:vendor_guid]
|
|
item ||= LearningOutcome.new
|
|
else
|
|
migration.add_warning(t(:no_global_permission, %{You're not allowed to manage global outcomes, can't add "%{title}"}, :title => hash[:title]))
|
|
return
|
|
end
|
|
else
|
|
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.created_learning_outcomes.new
|
|
item.context = context
|
|
end
|
|
item.migration_id = hash[:migration_id]
|
|
item.vendor_guid = hash[:vendor_guid]
|
|
item.low_grade = hash[:low_grade]
|
|
item.high_grade = hash[:high_grade]
|
|
item.workflow_state = 'active' if item.deleted?
|
|
item.short_description = hash[:title]
|
|
item.description = hash[:description]
|
|
|
|
if hash[:ratings]
|
|
item.data = {:rubric_criterion=>{}}
|
|
item.data[:rubric_criterion][:ratings] = hash[:ratings] ? hash[:ratings].map(&:symbolize_keys) : []
|
|
item.data[:rubric_criterion][:mastery_points] = hash[:mastery_points]
|
|
item.data[:rubric_criterion][:points_possible] = hash[:points_possible]
|
|
item.data[:rubric_criterion][:description] = item.short_description || item.description
|
|
end
|
|
|
|
item.save!
|
|
context.imported_migration_items << item if context && context.imported_migration_items && item.new_record?
|
|
else
|
|
item = outcome
|
|
end
|
|
|
|
log = hash[:learning_outcome_group] || context.root_outcome_group
|
|
log.add_outcome(item)
|
|
|
|
migration.outcome_to_id_map[hash[:migration_id]] = item.id
|
|
|
|
item
|
|
end
|
|
|
|
named_scope :for_context_codes, lambda{|codes|
|
|
{:conditions => {:context_code => Array(codes)} }
|
|
}
|
|
named_scope :active, lambda{
|
|
{:conditions => ['learning_outcomes.workflow_state != ?', 'deleted'] }
|
|
}
|
|
named_scope :has_result_for, lambda{|user|
|
|
{:joins => [:learning_outcome_results],
|
|
:conditions => ['learning_outcomes.id = learning_outcome_results.learning_outcome_id AND learning_outcome_results.user_id = ?', user.id],
|
|
:order => 'short_description'
|
|
}
|
|
}
|
|
|
|
named_scope :global, lambda{
|
|
{:conditions => {:context_id => nil} }
|
|
}
|
|
end
|