491 lines
18 KiB
Ruby
491 lines
18 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
#
|
|
# Copyright (C) 2011 - 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/>.
|
|
#
|
|
|
|
class Rubric < ActiveRecord::Base
|
|
class RubricUniqueAlignments < ActiveModel::Validator
|
|
def validate(record)
|
|
return if record.criteria.nil?
|
|
|
|
ids = record.criteria.pluck(:learning_outcome_id).compact
|
|
|
|
record.errors.add :base, I18n.t("rubric.alignments.duplicated_outcome", "This rubric has Outcomes aligned more than once") if ids.uniq.count != ids.count
|
|
end
|
|
end
|
|
|
|
include Workflow
|
|
include HtmlTextHelper
|
|
|
|
POINTS_POSSIBLE_PRECISION = 4
|
|
|
|
attr_writer :skip_updating_points_possible
|
|
|
|
belongs_to :user
|
|
belongs_to :rubric # based on another rubric
|
|
belongs_to :context, polymorphic: [:course, :account]
|
|
has_many :rubric_associations, -> { where(workflow_state: "active") }, class_name: "RubricAssociation", inverse_of: :rubric, dependent: :destroy
|
|
has_many :rubric_associations_with_deleted, class_name: "RubricAssociation", inverse_of: :rubric
|
|
has_many :rubric_assessments, through: :rubric_associations, dependent: :destroy
|
|
has_many :learning_outcome_alignments, -> { where("content_tags.tag_type='learning_outcome' AND content_tags.workflow_state<>'deleted'").preload(:learning_outcome) }, as: :content, inverse_of: :content, class_name: "ContentTag"
|
|
has_many :rubric_criteria, class_name: "RubricCriterion", inverse_of: :rubric, dependent: :destroy
|
|
|
|
validates :context_id, :context_type, :workflow_state, presence: true
|
|
validates :description, length: { maximum: maximum_text_length, allow_blank: true }
|
|
validates :title, length: { maximum: maximum_string_length, allow_blank: false }
|
|
|
|
validates_with RubricUniqueAlignments
|
|
|
|
before_validation :default_values
|
|
before_create :set_root_account_id
|
|
after_save :update_alignments
|
|
after_save :touch_associations
|
|
|
|
serialize :data
|
|
simply_versioned
|
|
|
|
scope :publicly_reusable, -> { where(reusable: true).order(best_unicode_collation_key("title")) }
|
|
scope :matching, ->(search) { where(wildcard("rubrics.title", search)).order("rubrics.association_count DESC") }
|
|
scope :before, ->(date) { where("rubrics.created_at<?", date) }
|
|
scope :active, -> { where.not(workflow_state: "deleted") }
|
|
|
|
set_policy do
|
|
given { |user, session| context.grants_right?(user, session, :manage_rubrics) }
|
|
can :read and can :create and can :delete_associations
|
|
|
|
given { |user, session| context.grants_any_right?(user, session, :manage_assignments, :manage_assignments_edit) }
|
|
can :read and can :create and can :delete_associations
|
|
|
|
given { |user, session| context.grants_right?(user, session, :manage) }
|
|
can :read and can :create and can :delete_associations
|
|
|
|
given { |user, session| context.grants_right?(user, session, :read_rubrics) }
|
|
can :read
|
|
|
|
# read_only means "associated with > 1 object for grading purposes"
|
|
given { |user, session| !read_only && rubric_associations.for_grading.length < 2 && context.grants_any_right?(user, session, :manage_assignments, :manage_assignments_edit) }
|
|
can :update and can :delete
|
|
|
|
given { |user, session| !read_only && rubric_associations.for_grading.length < 2 && context.grants_right?(user, session, :manage_rubrics) }
|
|
can :update and can :delete
|
|
|
|
given { |user, session| context.grants_any_right?(user, session, :manage_assignments, :manage_assignments_edit) }
|
|
can :delete
|
|
|
|
given { |user, session| context.grants_right?(user, session, :manage_rubrics) }
|
|
can :delete
|
|
|
|
given { |user, session| context.grants_right?(user, session, :read) }
|
|
can :read
|
|
end
|
|
|
|
workflow do
|
|
state :active do
|
|
event :archive, transitions_to: :archived
|
|
end
|
|
state :archived do
|
|
event :unarchive, transitions_to: :active
|
|
end
|
|
state :deleted
|
|
end
|
|
|
|
def archive
|
|
# overrides 'archive' event in workflow to make sure the feature flag is enabled
|
|
# remove this and 'unarchive' method when feature flag is removed
|
|
super if enhanced_rubrics_enabled?
|
|
end
|
|
|
|
def unarchive
|
|
super if enhanced_rubrics_enabled?
|
|
end
|
|
|
|
def self.aligned_to_outcomes
|
|
where(
|
|
ContentTag.learning_outcome_alignments
|
|
.active
|
|
.where(content_type: "Rubric")
|
|
.where("content_tags.content_id = rubrics.id")
|
|
.arel.exists
|
|
)
|
|
end
|
|
|
|
def self.with_at_most_one_association
|
|
joins(<<~SQL.squish)
|
|
LEFT JOIN #{RubricAssociation.quoted_table_name} associations_for_count
|
|
ON rubrics.id = associations_for_count.rubric_id
|
|
AND associations_for_count.purpose = 'grading'
|
|
AND associations_for_count.workflow_state = 'active'
|
|
SQL
|
|
.group("rubrics.id")
|
|
.having("COUNT(rubrics.id) < 2")
|
|
end
|
|
|
|
def self.unassessed
|
|
joins(<<~SQL.squish)
|
|
LEFT JOIN #{RubricAssociation.quoted_table_name} associations_for_unassessed
|
|
ON rubrics.id = associations_for_unassessed.rubric_id
|
|
AND associations_for_unassessed.purpose = 'grading'
|
|
AND associations_for_unassessed.workflow_state = 'active'
|
|
SQL
|
|
.joins(<<~SQL.squish)
|
|
LEFT JOIN #{RubricAssessment.quoted_table_name} assessments_for_unassessed
|
|
ON associations_for_unassessed.id = assessments_for_unassessed.rubric_association_id
|
|
SQL
|
|
.where(assessments_for_unassessed: { id: nil })
|
|
end
|
|
|
|
def self.unassessed_and_with_at_most_one_association
|
|
joins(<<~SQL.squish)
|
|
LEFT JOIN #{RubricAssociation.quoted_table_name} associations_for_unassessed
|
|
ON rubrics.id = associations_for_unassessed.rubric_id
|
|
AND associations_for_unassessed.purpose = 'grading'
|
|
AND associations_for_unassessed.workflow_state = 'active'
|
|
SQL
|
|
.where(<<~SQL.squish)
|
|
NOT EXISTS(
|
|
SELECT *
|
|
FROM #{RubricAssessment.quoted_table_name} assessments_for_unassessed
|
|
WHERE associations_for_unassessed.id = assessments_for_unassessed.rubric_association_id
|
|
)
|
|
SQL
|
|
.group("rubrics.id")
|
|
.having("COUNT(rubrics.id) < 2")
|
|
end
|
|
|
|
def default_values
|
|
if Rails.env.test?
|
|
populate_rubric_title # there are too many specs to change and i'm too lazy
|
|
end
|
|
|
|
cnt = 0
|
|
siblings = Rubric.where(context_id:, context_type:).where("workflow_state<>'deleted'")
|
|
siblings = siblings.where("id<>?", id) unless new_record?
|
|
if title.present?
|
|
original_title = title
|
|
while siblings.where(title:).exists?
|
|
cnt += 1
|
|
self.title = "#{original_title} (#{cnt})"
|
|
end
|
|
end
|
|
self.context_code = "#{context_type.underscore}_#{context_id}" rescue nil
|
|
end
|
|
|
|
alias_method :destroy_permanently!, :destroy
|
|
def destroy
|
|
self.workflow_state = "deleted"
|
|
if save
|
|
rubric_associations.in_batches.destroy_all
|
|
rubric_criteria.in_batches.destroy_all
|
|
true
|
|
end
|
|
end
|
|
|
|
def restore
|
|
self.workflow_state = "active"
|
|
if save
|
|
rubric_associations_with_deleted.where(workflow_state: "deleted").find_each(&:restore)
|
|
true
|
|
end
|
|
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, current_user: nil)
|
|
ras = rubric_associations.where(context_id: context, context_type: context.class.to_s)
|
|
if context.instance_of?(Course)
|
|
# if rubric is removed at the course level, we want to destroy any
|
|
# assignment associations found in the context of the course
|
|
ras.each do |association|
|
|
association.updating_user = current_user
|
|
association.destroy
|
|
end
|
|
else
|
|
ras.destroy_all
|
|
end
|
|
|
|
if rubric_associations.bookmarked.none?
|
|
destroy
|
|
end
|
|
end
|
|
|
|
def update_alignments
|
|
if alignments_need_update?
|
|
outcome_ids = []
|
|
unless deleted?
|
|
outcome_ids = data_outcome_ids
|
|
end
|
|
LearningOutcome.update_alignments(self, context, outcome_ids)
|
|
end
|
|
true
|
|
end
|
|
|
|
def touch_associations
|
|
if alignments_need_update?
|
|
# associations might need to update their alignments also
|
|
rubric_associations.bookmarked.each do |ra|
|
|
ra.skip_updating_points_possible = @skip_updating_points_possible
|
|
ra.save
|
|
end
|
|
end
|
|
end
|
|
|
|
def alignments_need_update?
|
|
saved_change_to_data? || saved_change_to_workflow_state?
|
|
end
|
|
|
|
def data_outcome_ids
|
|
(data || []).filter_map { |c| c[:learning_outcome_id] }.map(&:to_i).uniq
|
|
end
|
|
|
|
def outcome_friendly_descriptions
|
|
OutcomeFriendlyDescription.where(learning_outcome_id: data_outcome_ids)
|
|
end
|
|
|
|
def criteria_object
|
|
OpenObject.process(data)
|
|
end
|
|
|
|
def criteria
|
|
data
|
|
end
|
|
|
|
def associate_with(association, context, opts = {})
|
|
if opts[:purpose] == "grading"
|
|
res = rubric_associations.where(association_id: association, association_type: association.class.to_s, purpose: "grading").first
|
|
return res if res
|
|
elsif opts[:update_if_existing]
|
|
res = rubric_associations.where(association_id: association, association_type: association.class.to_s).first
|
|
return res if res
|
|
end
|
|
purpose = opts[:purpose] || "unknown"
|
|
ra = rubric_associations.build(association_object: association,
|
|
context:,
|
|
use_for_grading: !!opts[:use_for_grading],
|
|
purpose:)
|
|
ra.skip_updating_points_possible = opts[:skip_updating_points_possible] || @skip_updating_points_possible
|
|
ra.updating_user = opts[:current_user]
|
|
if ra.save && association.is_a?(Assignment)
|
|
association.mark_downstream_changes(["rubric"])
|
|
end
|
|
ra.updating_user = nil
|
|
ra
|
|
end
|
|
|
|
def update_with_association(current_user, rubric_params, context, association_params)
|
|
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]
|
|
@skip_updating_points_possible = association_params[:skip_updating_points_possible]
|
|
update_criteria(rubric_params)
|
|
|
|
return self unless valid?
|
|
|
|
RubricAssociation.generate(current_user, self, context, association_params) if association_params[:association_object] || association_params[:url]
|
|
end
|
|
|
|
def unique_item_id(id = nil)
|
|
@used_ids ||= {}
|
|
while !id || @used_ids[id]
|
|
id = "#{rubric_id || self.id}_#{rand(10_000)}"
|
|
end
|
|
@used_ids[id] = true
|
|
id
|
|
end
|
|
|
|
def update_criteria(params)
|
|
without_versioning(&:save) if new_record?
|
|
data = generate_criteria(params)
|
|
self.hide_score_total = params[:hide_score_total] if hide_score_total.nil? || (association_count || 0) < 2
|
|
self.data = data.criteria
|
|
self.title = data.title
|
|
self.points_possible = data.points_possible
|
|
save
|
|
self
|
|
end
|
|
|
|
def update_mastery_scales(save = true)
|
|
return unless context.root_account.feature_enabled?(:account_level_mastery_scales)
|
|
|
|
mastery_scale = context.resolved_outcome_proficiency
|
|
return if mastery_scale.nil?
|
|
|
|
data.each do |criterion|
|
|
update_criterion_from_mastery_scale(criterion, mastery_scale)
|
|
end
|
|
if data_changed?
|
|
self.points_possible = total_points_from_criteria(data)
|
|
save! if save
|
|
end
|
|
end
|
|
|
|
def criterion_needs_update?(criterion, mastery_scale)
|
|
return false if criterion[:learning_outcome_id].blank?
|
|
|
|
return true if criterion[:points] != mastery_scale.points_possible
|
|
return true if criterion[:mastery_points] != mastery_scale.mastery_points
|
|
return true if criterion[:ratings]&.length != mastery_scale.outcome_proficiency_ratings.length
|
|
|
|
criterion[:ratings].zip(mastery_scale.outcome_proficiency_ratings).any? do |criterion_rating, proficiency_rating|
|
|
criterion_rating[:description] != proficiency_rating.description ||
|
|
criterion_rating[:long_description] != "" ||
|
|
criterion_rating[:points] != proficiency_rating.points
|
|
end
|
|
end
|
|
|
|
def update_criterion_from_mastery_scale(criterion, mastery_scale)
|
|
return unless criterion_needs_update?(criterion, mastery_scale)
|
|
|
|
criterion[:points] = mastery_scale.points_possible
|
|
criterion[:mastery_points] = mastery_scale.mastery_points
|
|
criterion[:ratings] = mastery_scale.outcome_proficiency_ratings.map { |pr| criterion_rating(pr, criterion[:id]) }
|
|
end
|
|
|
|
def update_learning_outcome_criteria(outcome)
|
|
data.each do |criterion|
|
|
update_learning_outcome_criterion(criterion, outcome) if criterion[:learning_outcome_id] == outcome.id
|
|
end
|
|
if data_changed?
|
|
self.points_possible = total_points_from_criteria(data)
|
|
save!
|
|
end
|
|
end
|
|
|
|
def update_learning_outcome_criterion(criterion, outcome)
|
|
criterion[:description] = outcome.short_description
|
|
criterion[:long_description] = outcome.description
|
|
unless context.root_account.feature_enabled?(:account_level_mastery_scales)
|
|
criterion[:points] = outcome.points_possible
|
|
criterion[:mastery_points] = outcome.mastery_points
|
|
criterion[:ratings] = outcome.rubric_criterion.nil? ? [] : generate_criterion_ratings(outcome, criterion[:id])
|
|
end
|
|
end
|
|
|
|
def generate_criterion_ratings(outcome, criterion_id)
|
|
outcome.rubric_criterion[:ratings].map do |rating|
|
|
criterion_rating(rating, criterion_id)
|
|
end
|
|
end
|
|
|
|
def criterion_rating(rating_data, criterion_id)
|
|
{
|
|
description: (rating_data[:description].presence || t("No Description")).strip,
|
|
long_description: (rating_data[:long_description] || "").strip,
|
|
points: rating_data[:points].to_f || 0,
|
|
criterion_id:,
|
|
id: unique_item_id(rating_data[:id])
|
|
}
|
|
end
|
|
|
|
def will_change_with_update?(params)
|
|
params ||= {}
|
|
return true if params[:free_form_criterion_comments] && !!free_form_criterion_comments != (params[:free_form_criterion_comments] == "1")
|
|
|
|
data = generate_criteria(params)
|
|
return true if data.title != title || data.points_possible != points_possible
|
|
return true if Rubric.normalize(data.criteria) != Rubric.normalize(criteria)
|
|
|
|
false
|
|
end
|
|
|
|
def populate_rubric_title
|
|
self.title ||= context && t("context_name_rubric", "%{course_name} Rubric", course_name: context.name)
|
|
end
|
|
|
|
CriteriaData = Struct.new(:criteria, :points_possible, :title)
|
|
def generate_criteria(params)
|
|
@used_ids = {}
|
|
title = params[:title] || t("context_name_rubric", "%{course_name} Rubric", course_name: context.name)
|
|
criteria = []
|
|
(params[:criteria] || {}).each do |idx, criterion_data|
|
|
criterion = {}
|
|
criterion[:description] = (criterion_data[:description].presence || t("no_description", "No Description")).strip
|
|
# Outcomes descriptions are already html sanitized, so use that if an outcome criteria
|
|
# is present. Otherwise we need to sanitize the input ourselves.
|
|
unless criterion_data[:learning_outcome_id].present?
|
|
criterion[:long_description] = format_message((criterion_data[:long_description] || "").strip).first
|
|
end
|
|
criterion[:points] = criterion_data[:points].to_f || 0
|
|
criterion_data[:id] = 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])
|
|
criterion[:criterion_use_range] = [true, "true"].include?(criterion_data[:criterion_use_range])
|
|
if criterion_data[:learning_outcome_id].present?
|
|
outcome = LearningOutcome.where(id: criterion_data[:learning_outcome_id]).first
|
|
criterion[:long_description] = outcome&.description || ""
|
|
if outcome
|
|
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
|
|
|
|
ratings = (criterion_data[:ratings] || {}).values.map do |rating_data|
|
|
rating_data[:id] = rating_data[:id].strip if rating_data[:id]
|
|
criterion_rating(rating_data, criterion[:id])
|
|
end
|
|
criterion[:ratings] = ratings.sort_by { |r| [-1 * (r[:points] || 0), r[:description] || CanvasSort::First] }
|
|
criterion[:points] = criterion[:ratings].pluck(:points).max || 0
|
|
|
|
# Record both the criterion data and the original ID that was passed in
|
|
# (we'll use the ID when we sort the criteria below)
|
|
criteria.push([idx, criterion])
|
|
end
|
|
criteria = criteria.sort_by { |criterion| criterion.first&.to_i || CanvasSort::First }
|
|
.map(&:second)
|
|
points_possible = total_points_from_criteria(criteria)&.round(POINTS_POSSIBLE_PRECISION)
|
|
CriteriaData.new(criteria, points_possible, title)
|
|
end
|
|
|
|
def total_points_from_criteria(criteria)
|
|
criteria.reject { |c| c[:ignore_for_scoring] }.sum { |c| c[:points] }
|
|
end
|
|
|
|
# undo innocuous changes introduced by migrations which break `will_change_with_update?`
|
|
def self.normalize(criteria)
|
|
case criteria
|
|
when Array
|
|
criteria.map { |criterion| Rubric.normalize(criterion) }
|
|
when Hash
|
|
h = criteria.compact_blank.stringify_keys
|
|
h.delete("title") if h["title"] == h["description"]
|
|
h.each do |k, v|
|
|
h[k] = Rubric.normalize(v) if v.is_a?(Hash) || v.is_a?(Array)
|
|
end
|
|
h
|
|
else
|
|
criteria
|
|
end
|
|
end
|
|
|
|
def set_root_account_id
|
|
self.root_account_id ||=
|
|
if context_type == "Account" && context.root_account?
|
|
context.id
|
|
else
|
|
context&.root_account_id
|
|
end
|
|
end
|
|
|
|
def enhanced_rubrics_enabled?
|
|
Account.site_admin.feature_enabled?(:enhanced_rubrics)
|
|
end
|
|
end
|