canvas-lms/app/models/context_module_progression.rb

366 lines
11 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 ContextModuleProgression < ActiveRecord::Base
include Workflow
attr_accessible :context_module, :user
belongs_to :context_module
belongs_to :user
before_save :set_completed_at
EXPORTABLE_ATTRIBUTES = [:id, :context_module_id, :user_id, :requirements_met, :workflow_state, :created_at, :updated_at, :collapsed, :current_position, :completed_at]
EXPORTABLE_ASSOCIATIONS = [:context_module, :user]
after_save :touch_user
serialize :requirements_met, Array
def completion_requirements
context_module.try(:completion_requirements) || []
end
private :completion_requirements
def set_completed_at
if self.completed?
self.completed_at ||= Time.now
else
self.completed_at = nil
end
end
def finished_item?(item)
(self.requirements_met || []).any?{|r| r[:id] == item.id}
end
def uncollapse!
return unless self.collapsed?
self.collapsed = false
self.save
end
class CompletedRequirementCalculator
attr_accessor :requirements_met, :view_requirements
def started?
@started
end
def completed?
@completed
end
def changed?
@orig_keys != sorted_requirement_keys
end
def initialize(requirements_met)
@requirements_met = requirements_met
@orig_keys = sorted_requirement_keys
@view_requirements = []
@started = false
@completed = true
@requirements_met = @requirements_met.reject{ |r| %w(min_score max_score).include?(r[:type]) }
end
def sorted_requirement_keys
requirements_met.map{ |r| "#{r[:id]}_#{r[:type]}" }.sort
end
def requirement_met?(req)
met = requirements_met.any? {|r| r[:id] == req[:id] && r[:type] == req[:type] }
@started = true if met
met
end
def requirement_met(req, is_met)
unless is_met
@completed = false
return
end
@started = true
requirements_met << req
end
def any_requirement_met?(req)
met = requirements_met.any? {|r| r[:id] == req[:id] }
@started = true if met
met
end
def view_requirement(req)
view_requirements << req
end
def check_view_requirements
view_requirements.each do |req|
requirement_met(req, any_requirement_met?(req))
end
end
end
def evaluate_requirements_met
result = evaluate_uncompleted_requirements
if result.completed?
self.workflow_state = 'completed'
elsif result.started?
self.workflow_state = 'started'
end
if result.changed?
self.requirements_met = result.requirements_met
end
end
private :evaluate_requirements_met
def evaluate_uncompleted_requirements
tags_hash = nil
calc = CompletedRequirementCalculator.new(self.requirements_met || [])
completion_requirements.each do |req|
# create the hash inside the loop in case the completion_requirements is empty (performance)
tags_hash ||= context_module.cached_active_tags.index_by(&:id)
tag = tags_hash[req[:id]]
next unless tag
next if calc.requirement_met?(req)
if req[:type] == 'must_view'
calc.view_requirement(req)
elsif req[:type] == 'must_contribute'
calc.requirement_met(req, false)
elsif req[:type] == 'must_submit'
sub = get_submission_or_quiz_submission(tag)
calc.requirement_met(req, sub && %w(submitted graded complete pending_review).include?(sub.workflow_state))
elsif req[:type] == 'min_score' || req[:type] == 'max_score'
calc.requirement_met(req, evaluate_score_requirement_met(req, tag)) if tag.scoreable?
end
# must_contribute is handled by ContextModule#update_for
end
calc.check_view_requirements
calc
end
private :evaluate_uncompleted_requirements
def get_submission_or_quiz_submission(tag)
if tag.content_type_quiz?
Quizzes::QuizSubmission.find_by_quiz_id_and_user_id(tag.content_id, user.id)
elsif tag.content_type_discussion?
if tag.content
Submission.find_by_assignment_id_and_user_id(tag.content.assignment_id, user.id)
end
else
Submission.find_by_assignment_id_and_user_id(tag.content_id, user.id)
end
end
private :get_submission_or_quiz_submission
def get_submission_score(tag)
submission = get_submission_or_quiz_submission(tag)
if tag.content_type_quiz?
submission.try(:kept_score)
else
submission.try(:score)
end
end
private :get_submission_score
def evaluate_score_requirement_met(requirement, tag)
score = get_submission_score(tag)
if requirement[:type] == "max_score"
score.present? && score <= requirement[:max_score].to_f
else
score.present? && score >= requirement[:min_score].to_f
end
end
private :evaluate_score_requirement_met
def update_requirement_met(action, tag, points=nil)
requirement = context_module.completion_requirement_for(action, tag)
return nil unless requirement
requirement_met = true
requirement_met = points && points >= requirement[:min_score].to_f if requirement[:type] == 'min_score'
requirement_met = points && points <= requirement[:max_score].to_f if requirement[:type] == 'max_score'
if !requirement_met
self.requirements_met.delete(requirement)
self.mark_as_outdated
elsif !self.requirements_met.include?(requirement)
self.requirements_met.push(requirement)
self.mark_as_outdated
end
requirement
end
def mark_as_outdated
self.current = false
end
def mark_as_outdated!
self.mark_as_outdated
Shackles.activate(:master) do
self.save
end
end
def outdated?
if self.current && evaluated_at.present?
# context module not locked or still to be unlocked
return false if context_module.unlock_at.blank? || context_module.to_be_unlocked
# evaluated before unlock time
return evaluated_at < context_module.unlock_at
end
true
end
def self.prerequisites_satisfied?(user, context_module)
related_progressions = nil
(context_module.active_prerequisites || []).all? do |pre|
related_progressions ||= ContextModuleProgressions::Finder.find_or_create_for_module_and_user(context_module, user).index_by(&:context_module_id)
if pre[:type] == 'context_module' && progression = related_progressions[pre[:id]]
progression.evaluate!(context_module)
progression.completed?
else
true
end
end
end
def prerequisites_satisfied?
ContextModuleProgression.prerequisites_satisfied?(user, context_module)
end
def check_prerequisites
return false if context_module.to_be_unlocked
if self.locked?
self.workflow_state = 'unlocked' if prerequisites_satisfied?
end
return !self.locked?
end
private :check_prerequisites
def evaluate_current_position
self.current_position = nil
return unless context_module.require_sequential_progress
completion_requirements = context_module.completion_requirements || []
requirements_met = self.requirements_met || []
context_module.cached_active_tags.each do |tag|
self.current_position = tag.position if tag.position
all_met = completion_requirements.select{|r| r[:id] == tag.id }.all? do |req|
requirements_met.any?{|r| r[:id] == req[:id] && r[:type] == req[:type] }
end
break unless all_met
end
end
private :evaluate_current_position
# attempts to calculate and save the progression state
# will not raise a StaleObjectError if there is a conflict
# may reload the object and may return stale data (if there is a conflict)
def evaluate!(as_prerequisite_for=nil)
retry_count = 0
begin
evaluate(as_prerequisite_for)
rescue ActiveRecord::StaleObjectError
# retry up to five times, otherwise return current (stale) data
self.reload
retry_count += 1
retry if retry_count < 5
logger.error { "Failed to evaluate stale progression: #{self.inspect}" }
end
self
end
# calculates and saves the progression state
# raises a StaleObjectError if there is a conflict
def evaluate(as_prerequisite_for=nil)
return self unless outdated?
# there is no valid progression state for unpublished modules
return self if context_module.unpublished?
self.evaluated_at = Time.now.utc
self.current = true
self.requirements_met ||= []
self.workflow_state = 'locked'
if check_prerequisites
evaluate_requirements_met
end
completion_changed = self.workflow_state_changed? && self.workflow_state_change.include?('completed')
evaluate_current_position
Shackles.activate(:master) do
self.save
end
if completion_changed
trigger_reevaluation_of_dependent_progressions(as_prerequisite_for)
trigger_completion_events if self.completed?
end
self
end
def trigger_reevaluation_of_dependent_progressions(dependent_module_to_skip=nil)
progressions = ContextModuleProgressions::Finder.find_or_create_for_module_and_user(context_module, user)
# only recalculate progressions related to this module as a prerequisite
progressions = progressions.select do |progression|
# re-evaluating progressions that have requested our progression's evaluation can cause cyclic evaluation
next false if dependent_module_to_skip && progression.context_module_id == dependent_module_to_skip.id
(progression.context_module.prerequisites || []).any? do |prereq|
prereq[:type] == 'context_module' && prereq[:id] == context_module.id
end
end
# invalidate all, then re-evaluate each
progressions.each(&:mark_as_outdated!)
progressions.each do |progression|
progression.send_later_if_production(:evaluate!, self)
end
end
private :trigger_reevaluation_of_dependent_progressions
def trigger_completion_events
context_module.completion_event_callbacks.each do |event|
event.call(user)
end
end
private :trigger_completion_events
scope :for_user, lambda { |user| where(:user_id => user) }
scope :for_modules, lambda { |mods| where(:context_module_id => mods) }
workflow do
state :locked
state :unlocked
state :started
state :completed
end
end