canvas-lms/app/models/context_module_progression.rb

499 lines
15 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 ContextModuleProgression < ActiveRecord::Base
include Workflow
belongs_to :context_module
belongs_to :user
belongs_to :root_account, class_name: 'Account'
before_save :set_completed_at
before_create :set_root_account_id
after_save :touch_user
serialize :requirements_met, Array
serialize :incomplete_requirements, Array
validates_presence_of :user_id, :context_module_id
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 set_root_account_id
self.root_account_id = self.context_module.root_account_id
end
def finished_item?(item)
(self.requirements_met || []).any?{|r| r[:id] == item.id}
end
def collapse!(skip_save: false)
update_collapse_state(true, skip_save: skip_save)
end
def uncollapse!(skip_save: false)
update_collapse_state(false, skip_save: skip_save)
end
def update_collapse_state(collapsed_target_state, skip_save: false)
retry_count = 0
begin
return if self.collapsed == collapsed_target_state
self.collapsed = collapsed_target_state
self.save unless skip_save
rescue ActiveRecord::StaleObjectError => e
Canvas::Errors.capture_exception(:context_modules, e, :info)
retry_count += 1
if retry_count < 5
self.reload
retry
else
raise
end
end
end
private :update_collapse_state
def uncomplete_requirement(id)
requirement = requirements_met.find {|r| r[:id] == id}
requirements_met.delete(requirement)
self.remove_incomplete_requirement(id)
mark_as_outdated
end
class CompletedRequirementCalculator
attr_accessor :actions_done, :view_requirements, :met_requirement_count
def all_met?
!@any_unmet
end
def changed?
@orig_keys != sorted_action_keys
end
def initialize(actions_done)
self.actions_done = actions_done
self.met_requirement_count = 0
@orig_keys = sorted_action_keys
self.view_requirements = []
self.actions_done.reject!{ |r| r[:type] == 'min_score' }
end
def sorted_action_keys
self.actions_done.map{ |r| "#{r[:id]}_#{r[:type]}" }.sort
end
def increment_met_requirement_count!
self.met_requirement_count += 1
end
def requirement_met?(req, include_type = true)
self.actions_done.any? {|r| r[:id] == req[:id] && (include_type ? r[:type] == req[:type] : true)}
end
def check_action!(action, is_met)
if is_met
add_done_action!(action)
else
@any_unmet = true
end
end
def add_done_action!(action)
increment_met_requirement_count!
self.actions_done << action
end
def add_view_requirement(req)
self.view_requirements << req
end
def check_view_requirements
self.view_requirements.each do |req|
# should mark a must_view as true if a completed must_submit/min_score action already exists
check_action!(req, requirement_met?(req, false))
end
end
end
def evaluate_requirements_met
result = evaluate_uncompleted_requirements
count_needed = self.context_module.requirement_count.to_i
# if no requirement_count is specified, assume all are needed
if (count_needed && count_needed > 0 && result.met_requirement_count >= count_needed) || result.all_met?
self.workflow_state = 'completed'
elsif result.met_requirement_count >= 1 || self.incomplete_requirements.count >= 1 # submitting to a min_score requirement should move it to started
self.workflow_state = 'started'
else
self.workflow_state = 'unlocked'
end
if result.changed?
self.requirements_met = result.actions_done
end
end
private :evaluate_requirements_met
def evaluate_uncompleted_requirements
tags_hash = nil
calc = CompletedRequirementCalculator.new(self.requirements_met || [])
self.incomplete_requirements = [] # start from a clean slate
completion_requirements.each do |req|
# for an observer/student user we don't want to filter based on the normal observer logic,
# instead return vis for student enrollment only -> hence ignore_observer_logic below
# create the hash inside the loop in case the completion_requirements is empty (performance)
tags_hash ||= context_module.content_tags_visible_to(self.user, is_teacher: false, ignore_observer_logic: true).index_by(&:id)
tag = tags_hash[req[:id]]
next unless tag
if calc.requirement_met?(req)
calc.increment_met_requirement_count!
next
end
subs = get_submissions(tag) if tag.scoreable?
if subs && subs.any?{|sub| sub.respond_to?(:excused?) && sub.excused?}
calc.check_action!(req, true)
next
end
if req[:type] == 'must_view'
calc.add_view_requirement(req)
elsif %w(must_contribute must_mark_done).include? req[:type]
# must_contribute is handled by ContextModule#update_for
calc.check_action!(req, false)
elsif req[:type] == 'must_submit'
req_met = !!(subs && subs.any?{ |sub|
if sub.workflow_state == 'graded' && sub.attempt.nil?
# is a manual grade - doesn't count for submission
false
elsif %w(submitted graded complete pending_review).include?(sub.workflow_state)
true
end
})
calc.check_action!(req, req_met)
elsif req[:type] == 'min_score'
calc.check_action!(req, evaluate_score_requirement_met(req, subs, tag))
end
end
calc.check_view_requirements
calc
end
private :evaluate_uncompleted_requirements
def get_submissions(tag)
subs = []
if tag.content_type_quiz?
subs = Quizzes::QuizSubmission.where(quiz_id: tag.content_id, user_id: user).to_a +
Submission.active.where(assignment_id: tag.content.assignment_id, user_id: user).to_a
elsif tag.content_type_discussion?
if tag.content
subs = Submission.active.where(assignment_id: tag.content.assignment_id, user_id: user).to_a
end
else
subs = Submission.active.where(assignment_id: tag.content_id, user_id: user).to_a
end
subs
end
private :get_submissions
def get_submission_score(submission)
if submission.is_a?(Quizzes::QuizSubmission)
submission.try(:kept_score)
else
submission.try(:score)
end
end
private :get_submission_score
def remove_incomplete_requirement(requirement_id)
self.incomplete_requirements.delete_if{|r| r[:id] == id}
end
# hold onto the status of the incomplete min_score requirement
def update_incomplete_requirement!(requirement, score)
return unless requirement[:type] == "min_score"
incomplete_req = self.incomplete_requirements.detect{|r| r[:id] == requirement[:id]}
unless incomplete_req
incomplete_req = requirement.dup
self.incomplete_requirements << incomplete_req
end
if incomplete_req[:score].nil?
incomplete_req[:score] = score
elsif score
incomplete_req[:score] = score if score > incomplete_req[:score] # keep highest score so far
end
end
def evaluate_score_requirement_met(requirement, subs, tag)
return unless requirement[:type] == "min_score"
remove_incomplete_requirement(requirement[:id]) # start from a fresh slate so we don't hold onto a max score that doesn't exist anymore
return if subs.blank?
if (unposted_sub = subs.detect { |sub| sub.is_a?(Submission) && !sub.posted? })
# don't mark the progress as in-progress if they haven't submitted
self.update_incomplete_requirement!(requirement, nil) unless unposted_sub.unsubmitted?
return
end
subs.any? do |sub|
score = get_submission_score(sub)
requirement_met = (score.present? && score.to_d >= requirement[:min_score].to_f)
if requirement_met
remove_incomplete_requirement(requirement[:id])
else
unless sub.is_a?(Submission) && sub.unsubmitted?
self.update_incomplete_requirement!(requirement, score) # hold onto the score if requirement not met
end
end
requirement_met
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 && !(tag.assignment && tag.assignment.muted?) if requirement[:type] == 'min_score'
requirement_met = false if requirement[:type] == 'must_submit' # calculate later; requires the submission
if !requirement_met
self.requirements_met.delete(requirement)
self.mark_as_outdated
true
elsif !self.requirements_met.include?(requirement)
self.requirements_met.push(requirement)
self.mark_as_outdated
true
else
false
end
end
def update_requirement_met!(*args)
retry_count = 0
begin
if self.update_requirement_met(*args)
self.save!
delay_if_production.evaluate!
end
rescue ActiveRecord::StaleObjectError
# retry up to five times, otherwise return current (stale) data
self.reload
retry_count += 1
if retry_count < 5
retry
else
raise
end
end
end
def mark_as_outdated
self.current = false
end
def mark_as_outdated!
if self.new_record?
mark_as_outdated
GuardRail.activate(:primary) do
self.save!
end
else
self.class.where(:id => self).update_all(:current => false)
self.touch_user
end
end
def outdated?
if self.current && evaluated_at.present?
return true if evaluated_at < context_module.updated_at
# 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 ||= context_module.context.find_or_create_progressions_for_user(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 || []
# for an observer/student combo user we don't want to filter based on the
# normal observer logic, instead return vis for student enrollment only
context_module.content_tags_visible_to(self.user, is_teacher: false, ignore_observer_logic: true).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 < 10
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)
self.shard.activate do
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 ||= []
if check_prerequisites
evaluate_requirements_met
end
completion_changed = self.workflow_state_changed? && self.workflow_state_change.include?('completed')
evaluate_current_position
GuardRail.activate(:primary) do
self.save
end
if completion_changed
trigger_reevaluation_of_dependent_progressions(as_prerequisite_for)
trigger_completion_events if self.completed?
end
self
end
end
def trigger_reevaluation_of_dependent_progressions(dependent_module_to_skip=nil)
progressions = context_module.context.find_or_create_progressions_for_user(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
self.context_module.is_prerequisite_for?(progression.context_module)
end
# invalidate all, then re-evaluate each
GuardRail.activate(:primary) do
ContextModuleProgression.where(:id => progressions, :current => true).update_all(:current => false)
User.where(:id => progressions.map(&:user_id)).touch_all
progressions.each do |progression|
progression.delay_if_production(n_strand: ["dependent_progression_reevaluation", context_module.global_context_id]).
evaluate!(self)
end
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