326 lines
14 KiB
Ruby
326 lines
14 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 Rubric < ActiveRecord::Base
|
|
include Workflow
|
|
attr_accessible :user, :rubric, :context, :points_possible, :title, :description, :reusable, :public, :free_form_criterion_comments, :hide_score_total
|
|
belongs_to :user
|
|
belongs_to :rubric # based on another rubric
|
|
belongs_to :context, :polymorphic => true
|
|
has_many :rubric_associations, :class_name => 'RubricAssociation', :dependent => :destroy
|
|
has_many :rubric_assessments, :through => :rubric_associations, :dependent => :destroy
|
|
has_many :learning_outcome_tags, :as => :content, :class_name => 'ContentTag', :conditions => ['content_tags.tag_type = ? AND content_tags.workflow_state != ?', 'learning_outcome', 'deleted'], :include => :learning_outcome
|
|
before_save :default_values
|
|
after_save :update_outcome_tags
|
|
validates_length_of :description, :maximum => maximum_text_length, :allow_nil => true, :allow_blank => true
|
|
|
|
serialize :data
|
|
simply_versioned
|
|
|
|
named_scope :publicly_reusable, lambda {
|
|
{:conditions => {:reusable => true}, :order => :title}
|
|
}
|
|
named_scope :matching, lambda {|search|
|
|
{:order => 'rubrics.association_count DESC', :conditions => wildcard('rubrics.title', search)}
|
|
}
|
|
named_scope :before, lambda{|date|
|
|
{:conditions => ['rubrics.created_at < ?', date]}
|
|
}
|
|
named_scope :active, lambda{
|
|
{:conditions => ['workflow_state != ?', 'deleted'] }
|
|
}
|
|
|
|
set_policy do
|
|
given {|user, session| self.cached_context_grants_right?(user, session, :manage_grades)}
|
|
can :read and can :create and can :delete_associations
|
|
|
|
given {|user, session| self.cached_context_grants_right?(user, session, :manage_assignments)}
|
|
can :read and can :create and can :delete_associations
|
|
|
|
given {|user, session| self.cached_context_grants_right?(user, session, :manage)}
|
|
can :read and can :create and can :delete_associations
|
|
|
|
# read_only means "associated with > 1 object for grading purposes"
|
|
given {|user, session| !self.read_only && self.rubric_associations.for_grading.length < 2 && self.cached_context_grants_right?(user, session, :manage_assignments)}
|
|
can :update and can :delete
|
|
|
|
given {|user, session| !self.read_only && self.rubric_associations.for_grading.length < 2 && self.cached_context_grants_right?(user, session, :manage_grades)}
|
|
can :update and can :delete
|
|
|
|
given {|user, session| self.cached_context_grants_right?(user, session, :manage_assignments)}
|
|
can :delete
|
|
|
|
given {|user, session| self.cached_context_grants_right?(user, session, :manage_grades)}
|
|
can :delete
|
|
|
|
given {|user, session| self.cached_context_grants_right?(user, session, :read) }
|
|
can :read
|
|
end
|
|
|
|
workflow do
|
|
state :active
|
|
state :deleted
|
|
end
|
|
|
|
def default_values
|
|
original_title = self.title
|
|
cnt = 0
|
|
|
|
loop do
|
|
dup_title = if new_record?
|
|
Rubric.first :conditions => ["title = ? AND context_id = ? AND context_type = ? AND workflow_state != 'deleted'", self.title, self.context_id, self.context_type]
|
|
else
|
|
Rubric.first :conditions => ["title = ? AND context_id = ? AND context_type = ? AND id != ? AND workflow_state != 'deleted'", self.title, self.context_id, self.context_type, self.id]
|
|
end
|
|
break unless dup_title
|
|
|
|
cnt += 1
|
|
self.title = "#{original_title} (#{cnt})"
|
|
end
|
|
self.context_code = "#{self.context_type.underscore}_#{self.context_id}" rescue nil
|
|
end
|
|
|
|
alias_method :destroy!, :destroy
|
|
def destroy
|
|
RubricAssociation.update_all({:bookmarked => false, :updated_at => Time.now.utc}, {:rubric_id => self.id})
|
|
self.workflow_state = 'deleted'
|
|
self.save
|
|
end
|
|
|
|
def restore
|
|
self.workflow_state = 'active'
|
|
self.save
|
|
end
|
|
|
|
# If any rubric_associations for a given context are marked as
|
|
# bookmarked, then the rubric will show up in the context's list
|
|
# of rubrics. The two main values for the 'purpose' field on
|
|
# a rubric_association are 'grading' and 'bookmark'. Confusing,
|
|
# I know.
|
|
def destroy_for(context)
|
|
RubricAssociation.update_all({:bookmarked => false, :updated_at => Time.now.utc}, {:rubric_id => self.id, :context_id => context.id, :context_type => context.class.to_s})
|
|
if RubricAssociation.scoped(:conditions => {:rubric_id => self.id, :bookmarked => true}).count == 0
|
|
self.destroy
|
|
end
|
|
end
|
|
|
|
def update_outcome_tags
|
|
return unless @outcomes_changed
|
|
ids = (self.data || []).map{|c| c[:learning_outcome_id] }.compact.map(&:to_i).uniq
|
|
tags = self.learning_outcome_tags
|
|
tag_outcome_ids = tags.map(&:learning_outcome_id).compact.uniq
|
|
outcomes = LearningOutcome.find(ids)
|
|
missing_ids = ids.select{|id| !tag_outcome_ids.include?(id) }
|
|
tags_to_delete = tags.select{|t| !ids.include?(t.learning_outcome_id) }
|
|
missing_ids.each do |id|
|
|
lot = self.learning_outcome_tags.build(:context => self.context, :tag_type => 'learning_outcome')
|
|
lot.learning_outcome_id = id
|
|
lot.save!
|
|
end
|
|
tags_to_delete.each{|t| t.destroy }
|
|
true
|
|
end
|
|
|
|
def criteria_object
|
|
OpenObject.process(self.data)
|
|
end
|
|
|
|
def display_name
|
|
res = ""
|
|
res += self.user.name + ", " rescue ""
|
|
res += self.context.name rescue ""
|
|
res = t('unknown_details', "Unknown Details") if res.empty?
|
|
res
|
|
end
|
|
|
|
def criteria
|
|
self.data
|
|
end
|
|
|
|
def associate_with(association, context, opts={})
|
|
if opts[:purpose] == "grading"
|
|
res = self.rubric_associations.find_by_association_id_and_association_type_and_purpose(association.id, association.class.to_s, 'grading')
|
|
return res if res
|
|
elsif opts[:update_if_existing]
|
|
res = self.rubric_associations.find_by_association_id_and_association_type(association.id, association.class.to_s)
|
|
return res if res
|
|
end
|
|
purpose = opts[:purpose] || "unknown"
|
|
self.rubric_associations.create(:association => association, :context => context, :use_for_grading => !!opts[:use_for_grading], :purpose => purpose)
|
|
end
|
|
|
|
def clone_for_association(current_user, association, rubric_params, association_params, invitees="")
|
|
rubric = Rubric.new
|
|
self.attributes.delete_if{|k, v| false}.each do |key, value|
|
|
rubric.send("#{key}=", value) if rubric.respond_to?(key)
|
|
end
|
|
rubric.migration_id = "cloned_from_#{self.id}"
|
|
rubric.rubric_id = self.id
|
|
rubric.free_form_criterion_comments = rubric_params[:free_form_criterion_comments] == '1' if rubric_params[:free_form_criterion_comments]
|
|
rubric.user = current_user
|
|
rubric_params[:hide_score_total] ||= association_params[:hide_score_total]
|
|
rubric.update_criteria(rubric_params)
|
|
RubricAssociation.generate_with_invitees(current_user, rubric, context, association_params, invitees) if association_params[:association] || association_params[:url]
|
|
end
|
|
|
|
def update_with_association(current_user, rubric_params, context, association_params, invitees="")
|
|
self.free_form_criterion_comments = rubric_params[:free_form_criterion_comments] == '1' if rubric_params[:free_form_criterion_comments]
|
|
self.user ||= current_user
|
|
rubric_params[:hide_score_total] ||= association_params[:hide_score_total]
|
|
self.update_criteria(rubric_params)
|
|
RubricAssociation.generate_with_invitees(current_user, self, context, association_params, invitees) if association_params[:association] || association_params[:url]
|
|
end
|
|
|
|
def unique_item_id(id=nil)
|
|
@used_ids ||= {}
|
|
while !id || @used_ids[id]
|
|
id = "#{self.rubric_id || self.id}_#{rand(10000)}"
|
|
end
|
|
@used_ids[id] = true
|
|
id
|
|
end
|
|
|
|
def update_criteria(params)
|
|
self.without_versioning(&:save) if self.new_record?
|
|
data = generate_criteria(params)
|
|
self.update_assessments_for_new_criteria(data.criteria)
|
|
self.hide_score_total = params[:hide_score_total] if self.hide_score_total == nil || (self.association_count || 0) < 2
|
|
self.data = data.criteria
|
|
self.title = data.title
|
|
self.points_possible = data.points_possible
|
|
self.save
|
|
self
|
|
end
|
|
|
|
def will_change_with_update?(params)
|
|
return true if params[:free_form_criterion_comments] && !!self.free_form_criterion_comments != (params[:free_form_criterion_comments] == '1')
|
|
data = generate_criteria(params)
|
|
return true if data.title != self.title || data.points_possible != self.points_possible
|
|
return true if data.criteria != self.criteria
|
|
false
|
|
end
|
|
|
|
def generate_criteria(params)
|
|
@used_ids = {}
|
|
title = params[:title] || t('context_name_rubric', "%{course_name} Rubric", :course_name => context.name)
|
|
points_possible = 0
|
|
criteria = []
|
|
(params[:criteria] || {}).each do |idx, criterion_data|
|
|
criterion = {}
|
|
criterion[:description] = (criterion_data[:description] || t('no_description', "No Description")).strip
|
|
criterion[:long_description] = (criterion_data[:long_description] || "").strip
|
|
criterion[:points] = criterion_data[:points].to_f || 0
|
|
criterion_data[:id].strip! if criterion_data[:id]
|
|
criterion_data[:id] = nil if criterion_data[:id] && criterion_data[:id].empty?
|
|
criterion[:id] = unique_item_id(criterion_data[:id])
|
|
ratings = []
|
|
points = 0
|
|
if criterion_data[:learning_outcome_id].present?
|
|
outcome = LearningOutcome.find_by_id(criterion_data[:learning_outcome_id])
|
|
if outcome
|
|
@outcomes_changed = true
|
|
criterion[:learning_outcome_id] = outcome.id
|
|
criterion[:mastery_points] = ((criterion_data[:mastery_points] || outcome.data[:rubric_criterion][:mastery_points]).to_f rescue nil)
|
|
criterion[:ignore_for_scoring] = criterion_data[:ignore_for_scoring] == '1'
|
|
end
|
|
end
|
|
(criterion_data[:ratings] || {}).each do |jdx, rating_data|
|
|
rating = {}
|
|
rating[:description] = (rating_data[:description] || t('no_description', "No Description")).strip
|
|
rating[:long_description] = (rating_data[:long_description] || "").strip
|
|
rating[:points] = rating_data[:points].to_f || 0
|
|
rating[:criterion_id] = criterion[:id]
|
|
rating_data[:id].strip! if rating_data[:id]
|
|
rating[:id] = unique_item_id(rating_data[:id])
|
|
ratings[jdx.to_i] = rating
|
|
end
|
|
criterion[:ratings] = ratings.select{|r| r}.sort_by{|r| [-1 * (r[:points] || 0), (r[:description] || "")]}
|
|
criterion[:points] = criterion[:ratings].map{|r| r[:points]}.max || 0
|
|
points_possible += criterion[:points] unless criterion[:ignore_for_scoring]
|
|
criteria[idx.to_i] = criterion
|
|
end
|
|
criteria = criteria.select{|c| c}
|
|
OpenObject.new(:criteria => criteria, :points_possible => points_possible, :title => title)
|
|
end
|
|
|
|
def update_assessments_for_new_criteria(new_criteria)
|
|
criteria = self.data
|
|
end
|
|
|
|
def self.process_migration(data, migration)
|
|
rubrics = data['rubrics'] ? data['rubrics']: []
|
|
rubrics.each do |rubric|
|
|
if migration.import_object?("rubrics", rubric['migration_id'])
|
|
begin
|
|
import_from_migration(rubric, migration.context)
|
|
rescue
|
|
migration.add_warning(t('errors.could_not_import', "Couldn't import rubric %{rubric}", :rubric => rubric[:title]), $!)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.import_from_migration(hash, context, item=nil)
|
|
hash = hash.with_indifferent_access
|
|
return nil if hash[:migration_id] && hash[:rubrics_to_import] && !hash[:rubrics_to_import][hash[:migration_id]]
|
|
item ||= find_by_context_id_and_context_type_and_id(context.id, context.class.to_s, hash[: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 ||= self.new(:context => context)
|
|
item.migration_id = hash[:migration_id]
|
|
item.workflow_state = 'active' if item.deleted?
|
|
item.title = hash[:title]
|
|
item.description = hash[:description]
|
|
item.points_possible = hash[:points_possible].to_f
|
|
item.read_only = hash[:read_only] unless hash[:read_only].nil?
|
|
item.reusable = hash[:reusable] unless hash[:reusable].nil?
|
|
item.public = hash[:public] unless hash[:public].nil?
|
|
item.hide_score_total = hash[:hide_score_total] unless hash[:hide_score_total].nil?
|
|
item.free_form_criterion_comments = hash[:free_form_criterion_comments] unless hash[:free_form_criterion_comments].nil?
|
|
|
|
item.data = hash[:data]
|
|
item.data.each do |crit|
|
|
if crit[:learning_outcome_migration_id]
|
|
if lo = context.learning_outcomes.find_by_migration_id(crit[:learning_outcome_migration_id])
|
|
crit[:learning_outcome_id] = lo.id
|
|
end
|
|
crit.delete :learning_outcome_migration_id
|
|
end
|
|
end
|
|
|
|
context.imported_migration_items << item if context.imported_migration_items && item.new_record?
|
|
item.save!
|
|
|
|
unless context.rubric_associations.find_by_rubric_id(item.id)
|
|
item.associate_with(context, context)
|
|
end
|
|
|
|
item
|
|
end
|
|
|
|
def self.generate(opts={})
|
|
context = opts[:context]
|
|
raise "Context required for rubrics" unless context
|
|
rubric = context.rubrics.build(:user => opts[:user])
|
|
user = opts[:user]
|
|
params = opts[:data]
|
|
rubric.update_criteria(params)
|
|
rubric
|
|
end
|
|
|
|
end
|