canvas-lms/app/models/grading_standard.rb

240 lines
7.0 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 GradingStandard < ActiveRecord::Base
include Workflow
attr_accessible :title, :standard_data
belongs_to :context, :polymorphic => true
belongs_to :user
has_many :assignments
# version 1 data is an array of [ letter, max_integer_value ]
# we created a version 2 because this is ambiguous once we added support for
# fractional values -- 89 used to actually mean < 90, so 89.9999... , but
# 89.5 actually means 89.5. by switching the version 2 format to [ letter,
# min_integer_value ], we remove the ambiguity.
#
# version 1:
#
# [ 'A', 100 ],
# [ 'A-', 93 ]
#
# It's implied that a 93.9 is actually an A-, not an A, but once we add fractional cutoffs to the mix, that implication breaks down.
#
# version 2:
#
# [ 'A', 94 ],
# [ 'A-', 89.5 ]
#
# No more ambiguity, because anything >= 94 is an A, and anything < 94 and >=
# 89.5 is an A-.
serialize :data
before_save :update_usage_count
workflow do
state :active
state :deleted
end
scope :active, where("grading_standards.workflow_state<>'deleted'")
scope :sorted, order("usage_count >= 3 DESC, title ASC")
VERSION = 2
def version
read_attribute(:version).presence || 1
end
# e.g. convert 89.7 to B+
def self.score_to_grade(scheme, score)
score = 0 if score < 0
# assign the highest grade whose min cutoff is less than the score
# if score is less than all scheme cutoffs, assign the lowest grade
scheme.max_by {|s| score >= s[1] * 100 ? s[1] : -s[1] }[0]
end
# e.g. convert B to 86
def self.grade_to_score(scheme, grade)
scheme = scheme.to_a.sort_by { |g, percent| -percent }
idx = scheme.index { |g, percent| g.downcase == grade.downcase }
if idx && idx > 0
# if there's room to step down at least one whole number, do that. this
# matches the previous behavior, before we added support for fractional
# grade cutoffs.
# otherwise, we step down just 1/10th of a point, which is the
# granularity we support right now
if (scheme[idx].last - scheme[idx - 1].last).abs >= 0.01
scheme[idx - 1].last * 100.0 - 1.0
else
scheme[idx - 1].last * 100.0 - 0.1
end
elsif idx
# first grade always goes to 100%
100.0
else
nil
end
end
def score_to_grade(score)
GradingStandard.score_to_grade(data, score)
end
def data=(new_val)
self.version = VERSION
# round values to the nearest 0.1 (0.001 since e.g. 78 is stored as .78)
# and dup the data while we're at it. (new_val.dup only dups one level, the
# elements of new_val.dup are the same objects as the elements of new_val)
new_val = new_val.map{ |row| [ row[0], (row[1] * 1000).to_i / 1000.0 ] }
write_attribute(:data, new_val)
end
def data
unless self.version == VERSION
data = read_attribute(:data)
data = GradingStandard.upgrade_data(data, self.version)
self.data = data
end
read_attribute(:data)
end
def self.upgrade_data(data, version)
case version.to_i
when VERSION
data
when 1
0.upto(data.length-2) do |i|
data[i][1] = data[i+1][1] + 0.01
end
data[-1][1] = 0
data
else
raise "Unknown GradingStandard data version: #{version}"
end
end
def update_usage_count
self.usage_count = self.assignments.active.count
self.context_code = "#{self.context_type.underscore}_#{self.context_id}" rescue nil
end
set_policy do
given {|user| true }
can :read and can :create
given {|user| self.assignments.active.length < 2}
can :update and can :delete
end
def update_data(params)
self.data = params.to_a.sort_by{|i| i[1]}.reverse
end
def display_name
res = ""
res += self.user.name + ", " rescue ""
res += self.context.name rescue ""
res = t("unknown_grading_details", "Unknown Details") if res.empty?
res
end
alias_method :destroy!, :destroy
def destroy
self.workflow_state = 'deleted'
self.save
end
def grading_scheme
res = {}
self.data.sort_by{|i| i[1]}.reverse.each do |i|
res[i[0].to_s] = i[1].to_f
end
res
end
def self.standards_for(context)
context_codes = [context.asset_string]
context_codes.concat Account.all_accounts_for(context).map(&:asset_string)
GradingStandard.active.where(:context_code => context_codes.uniq)
end
def standard_data=(params={})
params ||= {}
res = {}
params.each do |key, row|
res[row[:name]] = (row[:value].to_f / 100.0) if row[:name] && row[:value]
end
self.data = res.to_a.sort_by{|i| i[1]}.reverse
end
def self.default_grading_standard
default_grading_scheme.to_a.sort_by{|i| i[1]}.reverse
end
def self.default_grading_scheme
{
"A" => 0.94,
"A-" => 0.90,
"B+" => 0.87,
"B" => 0.84,
"B-" => 0.80,
"C+" => 0.77,
"C" => 0.74,
"C-" => 0.70,
"D+" => 0.67,
"D" => 0.64,
"D-" => 0.61,
"F" => 0.0,
}
end
def self.process_migration(data, migration)
standards = data['grading_standards'] ? data['grading_standards']: []
to_import = migration.to_import 'grading_standards'
standards.each do |standard|
if standard['migration_id'] && (!to_import || to_import[standard['migration_id']])
begin
import_from_migration(standard, migration.context)
rescue
migration.add_import_warning(t('#migration.grading_standard_type', "Grading Standard"), standard[: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[:grading_standards_to_import] && !hash[:grading_standards_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.grading_standards.new
item.migration_id = hash[:migration_id]
item.workflow_state = 'active' if item.deleted?
item.title = hash[:title]
begin
item.data = self.upgrade_data(JSON.parse(hash[:data]), hash[:version] || 1)
rescue
#todo - add to message to display to user
end
item.save!
context.imported_migration_items << item if context.imported_migration_items && item.new_record?
item
end
end