1383 lines
56 KiB
Ruby
1383 lines
56 KiB
Ruby
#
|
|
# Copyright (C) 2011 - 2012 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/>.
|
|
#
|
|
|
|
require 'quiz_question_link_migrator'
|
|
|
|
class Quiz < ActiveRecord::Base
|
|
include Workflow
|
|
include HasContentTags
|
|
include CopyAuthorizedLinks
|
|
include ActionView::Helpers::SanitizeHelper
|
|
extend ActionView::Helpers::SanitizeHelper::ClassMethods
|
|
include ContextModuleItem
|
|
include DatesOverridable
|
|
|
|
attr_accessible :title, :description, :points_possible, :assignment_id, :shuffle_answers,
|
|
:show_correct_answers, :time_limit, :allowed_attempts, :scoring_policy, :quiz_type,
|
|
:lock_at, :unlock_at, :due_at, :access_code, :anonymous_submissions, :assignment_group_id,
|
|
:hide_results, :locked, :ip_filter, :require_lockdown_browser,
|
|
:require_lockdown_browser_for_results, :context, :notify_of_update,
|
|
:one_question_at_a_time, :cant_go_back
|
|
|
|
attr_readonly :context_id, :context_type
|
|
attr_accessor :notify_of_update
|
|
|
|
has_many :quiz_questions, :dependent => :destroy, :order => 'position'
|
|
has_many :quiz_submissions, :dependent => :destroy
|
|
has_many :quiz_groups, :dependent => :destroy, :order => 'position'
|
|
belongs_to :context, :polymorphic => true
|
|
belongs_to :assignment
|
|
belongs_to :cloned_item
|
|
validates_length_of :description, :maximum => maximum_long_text_length, :allow_nil => true, :allow_blank => true
|
|
validates_length_of :title, :maximum => maximum_string_length, :allow_nil => true
|
|
validates_presence_of :context_id
|
|
validates_presence_of :context_type
|
|
|
|
sanitize_field :description, Instructure::SanitizeField::SANITIZE
|
|
copy_authorized_links(:description) { [self.context, nil] }
|
|
before_save :build_assignment
|
|
before_save :set_defaults
|
|
after_save :update_assignment
|
|
after_save :touch_context
|
|
after_save :link_assignment_overrides, :if => :new_assignment_id?
|
|
|
|
serialize :quiz_data
|
|
|
|
simply_versioned
|
|
|
|
def infer_times
|
|
# set the time to 11:59 pm in the creator's time zone, if none given
|
|
self.due_at += ((60 * 60 * 24) - 60) if self.due_at && self.due_at.hour == 0 && self.due_at.min == 0
|
|
self.lock_at += ((60 * 60 * 24) - 60) if self.lock_at && self.lock_at.hour == 0 && self.lock_at.min == 0
|
|
end
|
|
|
|
def set_defaults
|
|
self.one_question_at_a_time = false if self.one_question_at_a_time == nil
|
|
self.cant_go_back = false if self.cant_go_back == nil || self.one_question_at_a_time == false
|
|
self.shuffle_answers = false if self.shuffle_answers == nil
|
|
self.show_correct_answers = true if self.show_correct_answers == nil
|
|
self.allowed_attempts = 1 if self.allowed_attempts == nil
|
|
self.scoring_policy = "keep_highest" if self.scoring_policy == nil
|
|
self.due_at ||= self.lock_at if self.lock_at.present?
|
|
self.ip_filter = nil if self.ip_filter && self.ip_filter.strip.empty?
|
|
if !self.available? && !self.survey?
|
|
self.points_possible = self.current_points_possible
|
|
end
|
|
self.title = t(:default_title, "Unnamed Quiz") if self.title.blank?
|
|
self.quiz_type ||= "assignment"
|
|
self.last_assignment_id = self.assignment_id_was if self.assignment_id_was
|
|
if (!graded? && self.assignment_id) || (self.assignment_id_was && self.assignment_id != self.assignment_id_was)
|
|
@old_assignment_id = self.assignment_id_was
|
|
self.assignment_id = nil
|
|
end
|
|
self.assignment_group_id ||= self.assignment.assignment_group_id if self.assignment
|
|
self.question_count = self.question_count(true)
|
|
@update_existing_submissions = true if self.for_assignment? && self.quiz_type_changed?
|
|
@stored_questions = nil
|
|
end
|
|
protected :set_defaults
|
|
|
|
def new_assignment_id?
|
|
last_assignment_id != assignment_id
|
|
end
|
|
|
|
def link_assignment_overrides
|
|
collections = [assignment_overrides, assignment_override_students]
|
|
collections += [assignment.assignment_overrides, assignment.assignment_override_students] if assignment
|
|
|
|
collections.each do |collection|
|
|
collection.update_all({ :assignment_id => assignment_id, :quiz_id => id })
|
|
end
|
|
end
|
|
|
|
def build_assignment
|
|
if self.available? && !self.assignment_id && self.graded? && @saved_by != :assignment && @saved_by != :clone
|
|
assignment = self.assignment
|
|
assignment ||= self.context.assignments.build(:title => self.title, :due_at => self.due_at, :submission_types => 'online_quiz')
|
|
assignment.assignment_group_id = self.assignment_group_id
|
|
assignment.saved_by = :quiz
|
|
assignment.save
|
|
self.assignment_id = assignment.id
|
|
end
|
|
end
|
|
|
|
def readable_type
|
|
self.survey? ? t('types.survey', "Survey") : t('types.quiz', "Quiz")
|
|
end
|
|
|
|
def valid_ip?(ip)
|
|
require 'ipaddr'
|
|
ip_filter.split(/,/).any? do |filter|
|
|
addr_range = IPAddr.new(filter) rescue nil
|
|
addr = IPAddr.new(ip) rescue nil
|
|
addr && addr_range && addr_range.include?(addr)
|
|
end
|
|
end
|
|
|
|
def current_points_possible
|
|
entries = self.root_entries
|
|
possible = 0
|
|
entries.each do |e|
|
|
if e[:question_points]
|
|
possible += (e[:question_points].to_f * e[:actual_pick_count])
|
|
else
|
|
possible += e[:points_possible].to_f unless e[:unsupported]
|
|
end
|
|
end
|
|
possible = self.assignment.points_possible if entries.empty? && self.assignment
|
|
possible
|
|
end
|
|
|
|
def set_unpublished_question_count
|
|
entries = self.root_entries(true)
|
|
cnt = 0
|
|
entries.each do |e|
|
|
if e[:question_points]
|
|
cnt += e[:actual_pick_count]
|
|
else
|
|
cnt += 1 unless e[:unsupported]
|
|
end
|
|
end
|
|
|
|
# TODO: this is hacky, but we don't want callbacks to run because we're in an after_save. Refactor.
|
|
Quiz.update_all({ :unpublished_question_count => cnt }, { :id => self.id })
|
|
self.unpublished_question_count = cnt
|
|
rescue => e
|
|
end
|
|
|
|
def for_assignment?
|
|
self.assignment_id && self.assignment && self.assignment.submission_types == 'online_quiz'
|
|
end
|
|
|
|
def muted?
|
|
self.assignment && self.assignment.muted?
|
|
end
|
|
|
|
alias_method :destroy!, :destroy
|
|
def destroy
|
|
self.workflow_state = 'deleted'
|
|
# self.deleted_at = Time.now
|
|
res = self.save
|
|
if self.for_assignment?
|
|
self.assignment.destroy unless self.assignment.deleted?
|
|
end
|
|
res
|
|
end
|
|
|
|
def restore
|
|
self.workflow_state = 'edited'
|
|
self.save
|
|
self.assignment.restore(:quiz)
|
|
end
|
|
|
|
def unlink_from(type)
|
|
@saved_by = type
|
|
if self.root_entries.empty? && !self.available?
|
|
self.assignment = nil
|
|
self.destroy
|
|
else
|
|
self.assignment = nil
|
|
self.save
|
|
end
|
|
end
|
|
|
|
def assignment_id=(val)
|
|
@assignment_id_set = true
|
|
write_attribute(:assignment_id, val)
|
|
end
|
|
|
|
def due_at=(val)
|
|
val = val.in_time_zone.end_of_day if val.is_a?(Date)
|
|
if val.is_a?(String)
|
|
super(Time.zone.parse(val))
|
|
infer_times unless val.match(/:/)
|
|
else
|
|
super(val)
|
|
end
|
|
end
|
|
|
|
def assignment?
|
|
self.quiz_type == 'assignment'
|
|
end
|
|
|
|
def survey?
|
|
self.quiz_type == 'survey' || self.quiz_type == 'graded_survey'
|
|
end
|
|
|
|
def graded?
|
|
self.quiz_type == 'assignment' || self.quiz_type == 'graded_survey'
|
|
end
|
|
|
|
def ungraded?
|
|
!self.graded?
|
|
end
|
|
|
|
# Determine if the quiz should display the correct answers.
|
|
# Takes into account the quiz settings, the user viewing and
|
|
# the submission to be viewed.
|
|
def display_correct_answers?(user, submission)
|
|
# NOTE: We don't have a submission user when the teacher is previewing the quiz and displaying the results'
|
|
self.show_correct_answers || (self.grants_right?(user, nil, :grade) && (submission && submission.user && submission.user != user))
|
|
end
|
|
|
|
def update_existing_submissions
|
|
# If the quiz suddenly changes from non-graded to graded,
|
|
# then this will update the existing submissions to reflect quiz
|
|
# scores in the gradebook.
|
|
self.quiz_submissions.each{|s| s.touch }
|
|
end
|
|
|
|
attr_accessor :saved_by
|
|
def update_assignment
|
|
send_later_if_production(:set_unpublished_question_count) if self.id
|
|
if !self.assignment_id && @old_assignment_id
|
|
self.context_module_tags.each { |tag| tag.confirm_valid_module_requirements }
|
|
end
|
|
if !self.graded? && (@old_assignment_id || self.last_assignment_id)
|
|
Assignment.update_all({:workflow_state => 'deleted', :updated_at => Time.now.utc}, {:id => [@old_assignment_id, self.last_assignment_id].compact, :submission_types => 'online_quiz'})
|
|
self.quiz_submissions.each do |qs|
|
|
submission = qs.submission
|
|
qs.submission = nil
|
|
qs.save! if qs.changed?
|
|
submission.try(:destroy)
|
|
end
|
|
ContentTag.delete_for(Assignment.find(@old_assignment_id)) if @old_assignment_id
|
|
ContentTag.delete_for(Assignment.find(self.last_assignment_id)) if self.last_assignment_id
|
|
end
|
|
send_later_if_production(:update_existing_submissions) if @update_existing_submissions
|
|
if self.assignment && (@assignment_id_set || self.for_assignment?) && @saved_by != :assignment
|
|
if !self.graded? && @old_assignment_id
|
|
else
|
|
Quiz.update_all({:workflow_state => 'deleted', :assignment_id => nil, :updated_at => Time.now.utc}, ["assignment_id = ? AND id != ?", self.assignment_id, self.id]) if self.assignment_id
|
|
a = self.assignment
|
|
a.points_possible = self.points_possible
|
|
a.description = self.description
|
|
a.title = self.title
|
|
a.due_at = self.due_at
|
|
a.lock_at = self.lock_at
|
|
a.unlock_at = self.unlock_at
|
|
a.submission_types = "online_quiz"
|
|
a.assignment_group_id = self.assignment_group_id
|
|
a.saved_by = :quiz
|
|
a.workflow_state = 'available' if a.deleted?
|
|
a.notify_of_update = @notify_of_update
|
|
a.with_versioning(false) do
|
|
@notify_of_update ? a.save : a.save_without_broadcasting!
|
|
end
|
|
self.assignment_id = a.id
|
|
Quiz.update_all({:assignment_id => a.id}, {:id => self.id})
|
|
end
|
|
end
|
|
end
|
|
protected :update_assignment
|
|
|
|
##
|
|
# when a quiz is updated, this method should be called to update the end_at
|
|
# of all open quiz submissions. this ensures that students who are taking the
|
|
# quiz when the time_limit is updated get the additional time added.
|
|
def update_quiz_submission_end_at_times
|
|
new_end_at = time_limit * 60.0
|
|
|
|
update_sql = case ActiveRecord::Base.connection.adapter_name.downcase
|
|
when /postgres/
|
|
"started_at + INTERVAL '+? seconds'"
|
|
when /mysql/
|
|
"started_at + INTERVAL ? SECOND"
|
|
when /sqlite/
|
|
"DATETIME(started_at, '+? seconds')"
|
|
end
|
|
|
|
# only update quiz submissions that:
|
|
# 1. belong to this quiz;
|
|
# 2. haven't been started; and
|
|
# 3. won't lose time through this change.
|
|
where_clause = <<-END
|
|
quiz_id = ? AND
|
|
started_at IS NOT NULL AND
|
|
finished_at IS NULL AND
|
|
#{update_sql} > end_at
|
|
END
|
|
|
|
QuizSubmission.update_all(["end_at = #{update_sql}", new_end_at], [where_clause, self.id, new_end_at]);
|
|
end
|
|
|
|
workflow do
|
|
state :created do
|
|
event :did_edit, :transitions_to => :edited
|
|
end
|
|
|
|
state :edited do
|
|
event :offer, :transitions_to => :available
|
|
end
|
|
|
|
state :available
|
|
state :deleted
|
|
end
|
|
|
|
def root_entries_max_position
|
|
question_max = self.quiz_questions.maximum(:position, :conditions => 'quiz_group_id is null')
|
|
group_max = self.quiz_groups.maximum(:position)
|
|
[question_max, group_max, 0].compact.max
|
|
end
|
|
|
|
# Returns the list of all "root" entries, either questions or question
|
|
# groups for this quiz. This is PRE-SAVED data. Once the quiz has
|
|
# been saved, all the data can be found in Quiz.quiz_data
|
|
def root_entries(force_check=false)
|
|
return @root_entries if @root_entries && !force_check
|
|
result = []
|
|
all_questions = self.quiz_questions
|
|
result.concat all_questions.select{|q| !q.quiz_group_id }
|
|
result.concat self.quiz_groups
|
|
result = result.sort_by{|e| e.position || 99999}.map do |e|
|
|
res = nil
|
|
if e.is_a? QuizQuestion
|
|
res = e.data
|
|
else #it's a QuizGroup
|
|
data = e.attributes.with_indifferent_access
|
|
data[:entry_type] = "quiz_group"
|
|
if e.assessment_question_bank_id
|
|
data[:assessment_question_bank_id] = e.assessment_question_bank_id
|
|
data[:questions] = []
|
|
else
|
|
data[:questions] = e.quiz_questions.sort_by{|q| q.position || 99999}.map(&:data)
|
|
end
|
|
data[:actual_pick_count] = e.actual_pick_count
|
|
res = data
|
|
end
|
|
res[:position] = e.position.to_i
|
|
res
|
|
end
|
|
@root_entries = result
|
|
end
|
|
|
|
# Returns the number of questions a student will see on the
|
|
# SAVED version of the quiz
|
|
def question_count(force_check=false)
|
|
return read_attribute(:question_count) if !force_check && read_attribute(:question_count)
|
|
question_count = 0
|
|
self.stored_questions.each do |q|
|
|
if q[:pick_count]
|
|
question_count += q[:actual_pick_count] || q[:pick_count]
|
|
else
|
|
question_count += 1 unless q[:question_type] == "text_only_question"
|
|
end
|
|
end
|
|
question_count || 0
|
|
end
|
|
|
|
# Returns data for the SAVED version of the quiz. That is, not
|
|
# the version found by gathering relationships on the Quiz data models,
|
|
# but the version being held in Quiz.quiz_data. Caches the result
|
|
# in @stored_questions.
|
|
def stored_questions(hashes=nil)
|
|
res = []
|
|
return @stored_questions if @stored_questions && !hashes
|
|
questions = hashes || self.quiz_data || []
|
|
questions.each do |val|
|
|
|
|
if val[:answers]
|
|
val[:answers] = prepare_answers(val)
|
|
val[:matches] = val[:matches].sort_by{|m| m[:text] || "" } if val[:matches]
|
|
elsif val[:questions] # It's a QuizGroup
|
|
if val[:assessment_question_bank_id]
|
|
# It points to a question bank
|
|
# question/answer/match shuffling happens when a submission is generated
|
|
else #normal QuizGroup
|
|
questions = []
|
|
val[:questions].each do |question|
|
|
if question[:answers]
|
|
question[:answers] = prepare_answers(question)
|
|
question[:matches] = question[:matches].sort_by{|m| m[:text] || ""} if question[:matches]
|
|
end
|
|
questions << question
|
|
end
|
|
questions = questions.sort_by{|q| rand}
|
|
val[:questions] = questions
|
|
end
|
|
end
|
|
res << val
|
|
end
|
|
@stored_questions = res
|
|
res
|
|
end
|
|
|
|
def single_attempt?
|
|
self.allowed_attempts == 1
|
|
end
|
|
|
|
def unlimited_attempts?
|
|
self.allowed_attempts == -1
|
|
end
|
|
|
|
def generate_submission_question(q)
|
|
@idx ||= 1
|
|
q[:name] = "Question #{@idx}"
|
|
if q[:question_type] == 'text_only_question'
|
|
q[:name] = "Spacer"
|
|
@idx -= 1
|
|
elsif q[:question_type] == 'fill_in_multiple_blanks_question'
|
|
text = q[:question_text]
|
|
variables = q[:answers].map{|a| a[:blank_id] }.uniq
|
|
variables.each do |variable|
|
|
variable_id = AssessmentQuestion.variable_id(variable)
|
|
re = Regexp.new("\\[#{variable}\\]")
|
|
text = text.sub(re, "<input class='question_input' type='text' autocomplete='off' style='width: 120px;' name='question_#{q[:id]}_#{variable_id}' value='{{question_#{q[:id]}_#{variable_id}}}' />")
|
|
end
|
|
q[:original_question_text] = q[:question_text]
|
|
q[:question_text] = text
|
|
elsif q[:question_type] == 'multiple_dropdowns_question'
|
|
text = q[:question_text]
|
|
variables = q[:answers].map{|a| a[:blank_id] }.uniq
|
|
variables.each do |variable|
|
|
variable_id = AssessmentQuestion.variable_id(variable)
|
|
variable_answers = q[:answers].select{|a| a[:blank_id] == variable }
|
|
options = variable_answers.map{|a| "<option value='#{a[:id]}'>#{CGI::escapeHTML(a[:text])}</option>" }
|
|
select = "<select class='question_input' name='question_#{q[:id]}_#{variable_id}'><option value=''>#{t(:default_question_input, "[ Select ]")}</option>#{options}</select>"
|
|
re = Regexp.new("\\[#{variable}\\]")
|
|
text = text.sub(re, select)
|
|
end
|
|
q[:original_question_text] = q[:question_text]
|
|
q[:question_text] = text
|
|
# on equation questions, pick one of the formulas, plug it in
|
|
# and you should be able to treat it like a numerical_answer
|
|
# question for all intents and purposes
|
|
elsif q[:question_type] == 'calculated_question'
|
|
text = q[:question_text]
|
|
q[:answers] = [q[:answers].sort_by{|a| rand }.first].compact
|
|
if q[:answers].first
|
|
q[:answers].first[:variables].each do |variable|
|
|
re = Regexp.new("\\[#{variable[:name]}\\]")
|
|
text = text.gsub(re, variable[:value].to_s)
|
|
end
|
|
end
|
|
q[:question_text] = text
|
|
end
|
|
q[:question_name] = q[:name]
|
|
@idx += 1
|
|
q
|
|
end
|
|
|
|
def find_or_create_submission(user, temporary=false, state=nil)
|
|
s = nil
|
|
state ||= 'untaken'
|
|
attempts = 0
|
|
if temporary || !user.is_a?(User)
|
|
user_code = "#{user.to_s}"
|
|
user_code = "user_#{user.id}" if user.is_a?(User)
|
|
s = QuizSubmission.find_by_quiz_id_and_temporary_user_code(self.id, user_code)
|
|
s ||= QuizSubmission.new(:quiz => self, :temporary_user_code => user_code)
|
|
s.workflow_state ||= state
|
|
s.save!
|
|
else
|
|
s = QuizSubmission.find_by_quiz_id_and_user_id(self.id, user.id)
|
|
s ||= QuizSubmission.new(:quiz => self, :user => user)
|
|
s.workflow_state ||= state
|
|
s.save!
|
|
end
|
|
s
|
|
end
|
|
|
|
# Generates a submission for the specified user on this quiz, based
|
|
# on the SAVED version of the quiz. Does not consider permissions.
|
|
def generate_submission(user, preview=false)
|
|
submission = nil
|
|
submission = self.find_or_create_submission(user, preview)
|
|
submission.retake
|
|
submission.attempt = (submission.attempt + 1) rescue 1
|
|
user_questions = []
|
|
@idx = 1
|
|
@stored_questions = nil
|
|
@submission_questions = self.stored_questions
|
|
if preview
|
|
@submission_questions = self.stored_questions(generate_quiz_data(:persist => false))
|
|
end
|
|
|
|
exclude_ids = @submission_questions.map{ |q| q[:assessment_question_id] }.compact
|
|
@submission_questions.each do |q|
|
|
if q[:pick_count] #QuizGroup
|
|
if q[:assessment_question_bank_id]
|
|
bank = AssessmentQuestionBank.find_by_id(q[:assessment_question_bank_id]) if q[:assessment_question_bank_id].present?
|
|
if bank
|
|
questions = bank.select_for_submission(q[:pick_count], exclude_ids)
|
|
questions = questions.map{|aq| aq.data}
|
|
questions.each do |question|
|
|
if question[:answers]
|
|
question[:answers] = prepare_answers(question)
|
|
question[:matches] = question[:matches].sort_by{|m| m[:text] || ""} if question[:matches]
|
|
end
|
|
question[:points_possible] = q[:question_points]
|
|
question[:published_at] = q[:published_at]
|
|
user_questions << generate_submission_question(question)
|
|
end
|
|
end
|
|
else
|
|
q[:pick_count].times do |i|
|
|
if q[:questions][i]
|
|
question = q[:questions][i]
|
|
question[:points_possible] = q[:question_points]
|
|
user_questions << generate_submission_question(question)
|
|
end
|
|
end
|
|
end
|
|
else #just a question
|
|
user_questions << generate_submission_question(q)
|
|
end
|
|
end
|
|
submission.score = nil
|
|
submission.fudge_points = nil
|
|
submission.quiz_data = user_questions
|
|
submission.quiz_version = self.version_number
|
|
submission.started_at = Time.now
|
|
submission.end_at = nil
|
|
submission.end_at = submission.started_at + (self.time_limit.to_f * 60.0) if self.time_limit
|
|
# Admins can take the full quiz whenever they want
|
|
unless user.is_a?(User) && self.grants_right?(user, nil, :grade)
|
|
submission.end_at = due_at if due_at && Time.now < due_at && (!submission.end_at || due_at < submission.end_at)
|
|
submission.end_at = lock_at if lock_at && !submission.manually_unlocked && (!submission.end_at || lock_at < submission.end_at)
|
|
end
|
|
submission.end_at += (submission.extra_time * 60.0) if submission.end_at && submission.extra_time
|
|
submission.finished_at = nil
|
|
submission.submission_data = {}
|
|
submission.workflow_state = 'preview' if preview
|
|
if preview || submission.untaken?
|
|
submission.save
|
|
else
|
|
submission.with_versioning(true, &:save!)
|
|
end
|
|
submission
|
|
end
|
|
|
|
def prepare_answers(question)
|
|
if answers = question[:answers]
|
|
if shuffle_answers && Quiz.shuffleable_question_type?(question[:question_type])
|
|
answers.sort_by { |a| rand }
|
|
else
|
|
answers
|
|
end
|
|
end
|
|
end
|
|
|
|
# Takes the PRE-SAVED version of the quiz and uses it to generate a
|
|
# SAVED version. That is, gathers the relationship entities from
|
|
# the database and uses them to populate a static version that will
|
|
# be held in Quiz.quiz_data
|
|
def generate_quiz_data(opts={})
|
|
entries = self.root_entries(true)
|
|
possible = 0
|
|
t = Time.now
|
|
entries.each do |e|
|
|
if e[:question_points] #QuizGroup
|
|
possible += (e[:question_points].to_f * e[:actual_pick_count])
|
|
else
|
|
possible += e[:points_possible].to_f
|
|
end
|
|
e[:published_at] = t
|
|
end
|
|
data = entries
|
|
if opts[:persist] != false
|
|
self.quiz_data = data
|
|
if !self.survey?
|
|
self.points_possible = possible
|
|
end
|
|
self.allowed_attempts ||= 1
|
|
check_if_submissions_need_review
|
|
end
|
|
data
|
|
end
|
|
|
|
def add_assessment_questions(assessment_questions, group=nil)
|
|
questions = assessment_questions.map do |assessment_question|
|
|
question = self.quiz_questions.build
|
|
question.quiz_group_id = group.id if group && group.quiz_id == self.id
|
|
question.write_attribute(:question_data, assessment_question.question_data)
|
|
question.assessment_question = assessment_question
|
|
question.assessment_question_version = assessment_question.version_number
|
|
question.save
|
|
question
|
|
end
|
|
questions.compact.uniq
|
|
end
|
|
|
|
def quiz_title
|
|
result = self.title
|
|
result = t(:default_title, "Unnamed Quiz") if result == "undefined" || !result
|
|
result = self.assignment.title if self.assignment
|
|
result
|
|
end
|
|
alias_method :to_s, :quiz_title
|
|
|
|
def locked_for?(user=nil, opts={})
|
|
return false if opts[:check_policies] && self.grants_right?(user, nil, :update)
|
|
Rails.cache.fetch(locked_cache_key(user), :expires_in => 1.minute) do
|
|
locked = false
|
|
if (self.unlock_at && self.unlock_at > Time.now)
|
|
sub = user && quiz_submissions.find_by_user_id(user.id)
|
|
if !sub || !sub.manually_unlocked
|
|
locked = {:asset_string => self.asset_string, :unlock_at => self.unlock_at}
|
|
end
|
|
elsif (self.lock_at && self.lock_at <= Time.now)
|
|
sub = user && quiz_submissions.find_by_user_id(user.id)
|
|
if !sub || !sub.manually_unlocked
|
|
locked = {:asset_string => self.asset_string, :lock_at => self.lock_at}
|
|
end
|
|
elsif (self.for_assignment? && l = self.assignment.locked_for?(user, opts))
|
|
sub = user && quiz_submissions.find_by_user_id(user.id)
|
|
if !sub || !sub.manually_unlocked
|
|
locked = l
|
|
end
|
|
elsif item = locked_by_module_item?(user, opts[:deep_check_if_needed])
|
|
sub = user && quiz_submissions.find_by_user_id(user.id)
|
|
if !sub || !sub.manually_unlocked
|
|
locked = {:asset_string => self.asset_string, :context_module => item.context_module.attributes}
|
|
end
|
|
end
|
|
locked
|
|
end
|
|
end
|
|
|
|
def context_module_action(user, action, points=nil)
|
|
tags_to_update = self.context_module_tags.to_a
|
|
if self.assignment
|
|
tags_to_update += self.assignment.context_module_tags
|
|
end
|
|
tags_to_update.each { |tag| tag.context_module_action(user, action, points) }
|
|
end
|
|
|
|
# virtual attribute
|
|
def locked=(new_val)
|
|
new_val = ActiveRecord::ConnectionAdapters::Column.value_to_boolean(new_val)
|
|
if new_val
|
|
#lock the quiz either until unlock_at, or indefinitely if unlock_at.nil?
|
|
self.lock_at = Time.now
|
|
self.unlock_at = [self.lock_at, self.unlock_at].min if self.unlock_at
|
|
else
|
|
# unlock the quiz
|
|
self.unlock_at = Time.now
|
|
end
|
|
end
|
|
|
|
def locked?
|
|
(self.unlock_at && self.unlock_at > Time.now) ||
|
|
(self.lock_at && self.lock_at <= Time.now)
|
|
end
|
|
|
|
def hide_results=(val)
|
|
if(val.is_a?(Hash))
|
|
if val[:last_attempt] == '1'
|
|
val = 'until_after_last_attempt'
|
|
elsif val[:never] != '1'
|
|
val = 'always'
|
|
else
|
|
val = nil
|
|
end
|
|
end
|
|
write_attribute(:hide_results, val)
|
|
end
|
|
|
|
def check_if_submissions_need_review
|
|
self.quiz_submissions.each{|s| s.update_if_needs_review(self) }
|
|
end
|
|
|
|
def changed_significantly_since?(version_number)
|
|
@significant_version ||= {}
|
|
return @significant_version[version_number] if @significant_version[version_number]
|
|
old_version = self.versions.get(version_number).model
|
|
|
|
needs_review = false
|
|
needs_review = true if old_version.points_possible != self.points_possible
|
|
needs_review = true if (old_version.quiz_data || []).length != (self.quiz_data || []).length
|
|
if !needs_review
|
|
new_data = self.quiz_data
|
|
old_data = old_version.quiz_data
|
|
new_data.each_with_index do |q, i|
|
|
needs_review = true if (q[:id] || q['id']) != (old_data[i][:id] || old_data[i]['id'])
|
|
end
|
|
end
|
|
@significant_version[version_number] = needs_review
|
|
end
|
|
|
|
def migrate_content_links_by_hand(user)
|
|
self.quiz_questions.each do |question|
|
|
data = QuizQuestion.migrate_question_hash(question.question_data, :context => self.context, :user => user)
|
|
question.write_attribute(:question_data, data)
|
|
question.save
|
|
end
|
|
data = self.quiz_data
|
|
if data
|
|
data.each_with_index do |obj, idx|
|
|
if obj[:answers]
|
|
data[idx] = QuizQuestion.migrate_question_hash(data[idx], :context => self.context, :user => user)
|
|
elsif val.questions
|
|
questions = []
|
|
obj[:questions].each do |question|
|
|
questions << QuizQuestion.migrate_question_hash(question, :context => self.context, :user => user)
|
|
end
|
|
obj[:questions] = questions
|
|
data[idx] = obj
|
|
end
|
|
end
|
|
end
|
|
self.quiz_data = data
|
|
end
|
|
|
|
attr_accessor :clone_updated
|
|
def clone_for(context, original_dup=nil, options={}, retrying = false)
|
|
dup = original_dup
|
|
if !self.cloned_item && !self.new_record?
|
|
self.cloned_item ||= ClonedItem.create(:original_item => self)
|
|
self.save!
|
|
end
|
|
existing = context.quizzes.active.find_by_id(self.id)
|
|
existing ||= context.quizzes.active.find_by_cloned_item_id(self.cloned_item_id || 0)
|
|
return existing if existing && !options[:overwrite]
|
|
if (context.merge_mapped_id(self.assignment))
|
|
dup ||= Quiz.find_by_assignment_id(context.merge_mapped_id(self.assignment))
|
|
end
|
|
dup ||= Quiz.new
|
|
dup = existing if existing && options[:overwrite]
|
|
self.attributes.delete_if{|k,v| [:id, :assignment_id, :assignment_group_id].include?(k.to_sym) }.each do |key, val|
|
|
dup.send("#{key}=", val)
|
|
end
|
|
# We need to save the quiz now so that the migrate_question_hash call will find
|
|
# the duplicated quiz and not try to make it itself.
|
|
dup.context = context
|
|
dup.saved_by = :clone
|
|
dup.save!
|
|
data = self.quiz_data
|
|
if data
|
|
data.each_with_index do |obj, idx|
|
|
if obj[:answers]
|
|
data[idx] = QuizQuestion.migrate_question_hash(data[idx], :old_context => self.context, :new_context => context)
|
|
elsif obj[:questions]
|
|
questions = []
|
|
obj[:questions].each do |question|
|
|
questions << QuizQuestion.migrate_question_hash(question, :old_context => self.context, :new_context => context)
|
|
end
|
|
obj[:questions] = questions
|
|
data[idx] = obj
|
|
end
|
|
end
|
|
end
|
|
dup.quiz_data = data
|
|
dup.assignment_id = context.merge_mapped_id(self.assignment) rescue nil
|
|
if !dup.assignment_id && self.assignment_id && self.assignment && !options[:cloning_for_assignment]
|
|
new_assignment = self.assignment.clone_for(context, nil, :cloning_for_quiz => true)
|
|
new_assignment.saved_by = :quiz
|
|
new_assignment.save_without_broadcasting!
|
|
context.map_merge(self.assignment, new_assignment)
|
|
dup.assignment_id = new_assignment.id
|
|
end
|
|
begin
|
|
dup.saved_by = :assignment if options[:cloning_for_assignment]
|
|
dup.save!
|
|
rescue => e
|
|
logger.warn "Couldn't save quiz copy: #{e.to_s}"
|
|
raise e if retrying
|
|
return self.clone_for(context, original_dup, options, true)
|
|
end
|
|
entities = self.quiz_groups + self.quiz_questions
|
|
entities.each do |entity|
|
|
entity_dup = entity.clone_for(dup, nil, :old_context => self.context, :new_context => context)
|
|
entity_dup.quiz_id = dup.id
|
|
if entity_dup.respond_to?(:quiz_group_id=)
|
|
entity_dup.quiz_group_id = context.merge_mapped_id(entity.quiz_group)
|
|
end
|
|
entity_dup.save!
|
|
context.map_merge(entity, entity_dup)
|
|
end
|
|
dup.reload
|
|
context.log_merge_result("Quiz \"#{self.title}\" created")
|
|
context.may_have_links_to_migrate(dup)
|
|
dup.updated_at = Time.now
|
|
dup.clone_updated = true
|
|
dup
|
|
end
|
|
|
|
def strip_html_answers(question)
|
|
return if !question || !question[:answers] || !(%w(multiple_choice_question multiple_answers_question).include? question[:question_type])
|
|
for answer in question[:answers] do
|
|
answer[:text] = strip_tags(answer[:html]) if !answer[:html].blank? && answer[:text].blank?
|
|
end
|
|
end
|
|
|
|
def submissions_for_statistics(include_all_versions=true)
|
|
ActiveRecord::Base::ConnectionSpecification.with_environment(:slave) do
|
|
for_users = context.student_ids
|
|
self.quiz_submissions.scoped(:include => [:versions], :conditions => { :user_id => for_users }).
|
|
map { |qs| if include_all_versions then qs.submitted_versions else qs.latest_submitted_version end }.
|
|
flatten.
|
|
compact.
|
|
select{ |s| s.completed? && s.submission_data.is_a?(Array) }.
|
|
sort_by(&:updated_at).
|
|
reverse
|
|
end
|
|
end
|
|
|
|
def statistics_csv(options={})
|
|
options ||= {}
|
|
columns = []
|
|
columns << t('statistics.csv_columns.name', 'name') unless options[:anonymous]
|
|
columns << t('statistics.csv_columns.id', 'id') unless options[:anonymous]
|
|
columns << t('statistics.csv_columns.sis_id', 'sis_id') unless options[:anonymous]
|
|
columns << t('statistics.csv_columns.section', 'section')
|
|
columns << t('statistics.csv_columns.section_id', 'section_id')
|
|
columns << t('statistics.csv_columns.section_sis_id', 'section_sis_id')
|
|
columns << t('statistics.csv_columns.submitted', 'submitted')
|
|
columns << t('statistics.csv_columns.attempt', 'attempt') if options[:include_all_versions]
|
|
first_question_index = columns.length
|
|
submissions = submissions_for_statistics(options[:include_all_versions])
|
|
found_question_ids = {}
|
|
quiz_datas = [quiz_data] + submissions.map(&:quiz_data)
|
|
quiz_datas.each do |quiz_data|
|
|
quiz_data.each do |question|
|
|
next if question['entry_type'] == 'quiz_group'
|
|
if !found_question_ids[question[:id]]
|
|
columns << "#{question[:id]}: #{strip_tags(question[:question_text])}"
|
|
columns << question[:points_possible]
|
|
found_question_ids[question[:id]] = true
|
|
end
|
|
end
|
|
end
|
|
last_question_index = columns.length - 1
|
|
columns << t('statistics.csv_columns.n_correct', 'n correct')
|
|
columns << t('statistics.csv_columns.n_incorrect', 'n incorrect')
|
|
columns << t('statistics.csv_columns.score', 'score')
|
|
rows = []
|
|
submissions.each do |submission|
|
|
row = []
|
|
row << submission.user.name unless options[:anonymous]
|
|
row << submission.user_id unless options[:anonymous]
|
|
row << submission.user.sis_pseudonym_for(context.account).try(:sis_user_id) unless options[:anonymous]
|
|
section_name = []
|
|
section_id = []
|
|
section_sis_id = []
|
|
enrollments = submission.quiz.context.student_enrollments.active.where(:user_id => submission.user_id).each do |enrollment|
|
|
section_name << enrollment.course_section.name
|
|
section_id << enrollment.course_section.id
|
|
section_sis_id << enrollment.course_section.try(:sis_source_id)
|
|
end
|
|
row << section_name.join(", ")
|
|
row << section_id.join(", ")
|
|
row << section_sis_id.join(", ")
|
|
row << submission.finished_at
|
|
row << submission.attempt if options[:include_all_versions]
|
|
columns[first_question_index..last_question_index].each do |id|
|
|
next unless id.is_a?(String)
|
|
id = id.to_i
|
|
answer = submission.submission_data.detect{|a| a[:question_id] == id }
|
|
question = submission.quiz_data.detect{|q| q[:id] == id}
|
|
unless question
|
|
# if this submission didn't answer this question, fill in with blanks
|
|
row << ''
|
|
row << ''
|
|
next
|
|
end
|
|
strip_html_answers(question)
|
|
answer_item = question && question[:answers].detect{|a| a[:id] == answer[:answer_id]}
|
|
answer_item ||= answer
|
|
if question[:question_type] == 'fill_in_multiple_blanks_question'
|
|
blank_ids = question[:answers].map{|a| a[:blank_id] }.uniq
|
|
row << blank_ids.map{|blank_id| answer["answer_for_#{blank_id}".to_sym].try(:gsub, /,/, '\,') }.compact.join(',')
|
|
elsif question[:question_type] == 'multiple_answers_question'
|
|
row << question[:answers].map{|a| answer["answer_#{a[:id]}".to_sym] == '1' ? a[:text].gsub(/,/, '\,') : nil }.compact.join(',')
|
|
elsif question[:question_type] == 'multiple_dropdowns_question'
|
|
blank_ids = question[:answers].map{|a| a[:blank_id] }.uniq
|
|
answer_ids = blank_ids.map{|blank_id| answer["answer_for_#{blank_id}".to_sym] }
|
|
row << answer_ids.map{|id| (question[:answers].detect{|a| a[:id] == id } || {})[:text].try(:gsub, /,/, '\,' ) }.compact.join(',')
|
|
elsif question[:question_type] == 'calculated_question'
|
|
list = question[:answers][0][:variables].map{|a| [a[:name],a[:value].to_s].map{|str| str.gsub(/=>/, '\=>') }.join('=>') }
|
|
list << answer[:text]
|
|
row << list.map{|str| (str || '').gsub(/,/, '\,') }.join(',')
|
|
elsif question[:question_type] == 'matching_question'
|
|
answer_ids = question[:answers].map{|a| a[:id] }
|
|
answer_and_matches = answer_ids.map{|id| [id, answer["answer_#{id}".to_sym].to_i] }
|
|
row << answer_and_matches.map{|id, match_id|
|
|
res = []
|
|
res << (question[:answers].detect{|a| a[:id] == id } || {})[:text]
|
|
match = question[:matches].detect{|m| m[:match_id] == match_id } || question[:answers].detect{|m| m[:match_id] == match_id} || {}
|
|
res << (match[:right] || match[:text])
|
|
res.map{|s| (s || '').gsub(/=>/, '\=>')}.join('=>').gsub(/,/, '\,')
|
|
}.join(',')
|
|
elsif question[:question_type] == 'numerical_question'
|
|
row << (answer && answer[:text])
|
|
else
|
|
row << ((answer_item && answer_item[:text]) || '')
|
|
end
|
|
row << (answer ? answer[:points] : "")
|
|
end
|
|
row << submission.submission_data.select{|a| a[:correct] }.length
|
|
row << submission.submission_data.reject{|a| a[:correct] }.length
|
|
row << submission.score
|
|
rows << row
|
|
end
|
|
FasterCSV.generate do |csv|
|
|
columns.each_with_index do |val, idx|
|
|
r = []
|
|
r << val
|
|
r << ''
|
|
rows.each do |row|
|
|
r << row[idx]
|
|
end
|
|
csv << r
|
|
end
|
|
end
|
|
end
|
|
|
|
def statistics(include_all_versions=true)
|
|
submissions = submissions_for_statistics(include_all_versions)
|
|
questions = (self.quiz_data || []).map{|q| q[:questions] ? q[:questions] : [q] }.flatten
|
|
stats = {}
|
|
found_ids = {}
|
|
score_counter = Stats::Counter.new
|
|
question_ids = []
|
|
questions_hash = {}
|
|
stats[:questions] = []
|
|
stats[:multiple_attempts_exist] = submissions.any?{|s| s.attempt && s.attempt > 1 }
|
|
stats[:multiple_attempts_included] = include_all_versions
|
|
stats[:submission_user_ids] = []
|
|
stats[:submission_count] = 0
|
|
stats[:submission_score_tally] = 0
|
|
stats[:submission_incorrect_tally] = 0
|
|
stats[:unique_submission_count] = 0
|
|
stats[:submission_correct_tally] = 0
|
|
stats[:submission_duration_tally] = 0
|
|
submissions.each do |sub|
|
|
stats[:submission_count] += 1
|
|
stats[:submission_user_ids] << sub.user_id if sub.user_id > 0
|
|
if !found_ids[sub.id]
|
|
stats[:unique_submission_count] += 1
|
|
found_ids[sub.id] = true
|
|
end
|
|
answers = sub.submission_data || []
|
|
next unless answers.is_a?(Array)
|
|
points = answers.map{|a| a[:points] }.sum
|
|
score_counter << points
|
|
stats[:submission_score_tally] += points
|
|
stats[:submission_incorrect_tally] += answers.count{|a| a[:correct] == false }
|
|
stats[:submission_correct_tally] += answers.count{|a| a[:correct] == true }
|
|
stats[:submission_duration_tally] += ((sub.finished_at - sub.started_at).to_i rescue 30)
|
|
sub.quiz_data.each do |question|
|
|
question_ids << question[:id]
|
|
questions_hash[question[:id]] ||= question
|
|
end
|
|
end
|
|
stats[:submission_score_average] = score_counter.mean
|
|
stats[:submission_score_high] = score_counter.max
|
|
stats[:submission_score_low] = score_counter.min
|
|
stats[:submission_duration_average] = stats[:submission_count] > 0 ? stats[:submission_duration_tally].to_f / stats[:submission_count].to_f : 0
|
|
stats[:submission_score_stdev] = score_counter.standard_deviation
|
|
stats[:submission_incorrect_count_average] = stats[:submission_count] > 0 ? stats[:submission_incorrect_tally].to_f / stats[:submission_count].to_f : 0
|
|
stats[:submission_correct_count_average] = stats[:submission_count] > 0 ? stats[:submission_correct_tally].to_f / stats[:submission_count].to_f : 0
|
|
assessment_questions = question_ids.empty? ? [] : AssessmentQuestion.find_all_by_id(question_ids).compact
|
|
question_ids.uniq.each do |id|
|
|
obj = questions.detect{|q| q[:answers] && q[:id] == id }
|
|
if !obj && questions_hash[id]
|
|
obj = questions_hash[id]
|
|
aq_name = assessment_questions.detect{|q| q.id == obj[:assessment_question_id] }.try(:name)
|
|
obj[:name] = aq_name || obj[:name]
|
|
end
|
|
if obj[:answers] && obj[:question_type] != 'text_only_question'
|
|
stat = stats_for_question(obj, submissions)
|
|
stats[:questions] << ['question', stat]
|
|
end
|
|
end
|
|
stats[:last_submission_at] = submissions.map{|s| s.finished_at }.compact.max || self.created_at
|
|
stats
|
|
end
|
|
|
|
def stats_for_question(question, submissions)
|
|
res = question
|
|
res[:responses] = 0
|
|
res[:response_values] = []
|
|
res[:unexpected_response_values] = []
|
|
res[:user_ids] = []
|
|
res[:answers] = question[:answers].map{|a|
|
|
answer = a
|
|
answer[:responses] = 0
|
|
answer[:user_ids] = []
|
|
answer
|
|
}
|
|
strip_html_answers(res)
|
|
res[:multiple_responses] = true if question[:question_type] == 'calculated_question'
|
|
if question[:question_type] == 'numerical_question'
|
|
res[:answers].each do |answer|
|
|
if answer[:numerical_answer_type] == 'exact_answer'
|
|
answer[:text] = t('statistics.exact_answer', "%{exact_value} +/- %{margin}", :exact_value => answer[:exact], :margin => answer[:margin])
|
|
else
|
|
answer[:text] = t('statistics.inexact_answer', "%{lower_bound} to %{upper_bound}", :lower_bound => answer[:start], :upper_bound => answer[:end])
|
|
end
|
|
end
|
|
end
|
|
if question[:question_type] == 'matching_question'
|
|
res[:multiple_responses] = true
|
|
res[:answers].each_with_index do |answer, idx|
|
|
res[:answers][idx][:answer_matches] = []
|
|
(res[:matches] || res[:answers]).each do |right|
|
|
match_answer = res[:answers].find{|a| a[:match_id].to_i == right[:match_id].to_i }
|
|
match = {:responses => 0, :text => (right[:right] || right[:text]), :user_ids => [], :id => match_answer ? match_answer[:id] : right[:match_id] }
|
|
res[:answers][idx][:answer_matches] << match
|
|
end
|
|
end
|
|
elsif ['fill_in_multiple_blanks_question', 'multiple_dropdowns_question'].include?(question[:question_type])
|
|
res[:multiple_responses] = true
|
|
answer_keys = {}
|
|
answers = []
|
|
res[:answers].each_with_index do |answer, idx|
|
|
if !answer_keys[answer[:blank_id]]
|
|
answers << {:id => answer[:blank_id], :text => answer[:blank_id], :blank_id => answer[:blank_id], :answer_matches => [], :responses => 0, :user_ids => []}
|
|
answer_keys[answer[:blank_id]] = answers.length - 1
|
|
end
|
|
end
|
|
answers.each do |found_answer|
|
|
res[:answers].select{|a| a[:blank_id] == found_answer[:blank_id] }.each do |sub_answer|
|
|
correct = sub_answer[:weight] == 100
|
|
match = {:responses => 0, :text => sub_answer[:text], :user_ids => [], :id => question[:question_type] == 'fill_in_multiple_blanks_question' ? found_answer[:blank_id] : sub_answer[:id], :correct => correct}
|
|
found_answer[:answer_matches] << match
|
|
end
|
|
end
|
|
res[:answer_sets] = answers
|
|
end
|
|
submissions.each do |submission|
|
|
answers = submission.submission_data || []
|
|
response = answers.detect{|a| a[:question_id] == question[:id] }
|
|
if response
|
|
res[:responses] += 1
|
|
res[:response_values] << response[:text]
|
|
res[:user_ids] << submission.user_id
|
|
if question[:question_type] == 'matching_question'
|
|
res[:multiple_answers] = true
|
|
res[:answers].each_with_index do |answer, idx|
|
|
res[:answers][idx][:responses] += 1 if response[:correct]
|
|
(res[:matches] || res[:answers]).each_with_index do |right, jdx|
|
|
if response["answer_#{answer[:id]}".to_sym].to_i == right[:match_id]
|
|
res[:answers][idx][:answer_matches][jdx][:responses] += 1
|
|
res[:answers][idx][:answer_matches][jdx][:user_ids] << submission.user_id
|
|
end
|
|
end
|
|
end
|
|
elsif question[:question_type] == 'fill_in_multiple_blanks_question'
|
|
res[:multiple_answers] = true
|
|
res[:answer_sets].each_with_index do |answer, idx|
|
|
found = false
|
|
response_hash_id = Digest::MD5.hexdigest(response["answer_for_#{answer[:blank_id]}".to_sym].strip) if !response["answer_for_#{answer[:blank_id]}".to_sym].try(:strip).blank?
|
|
res[:answer_sets][idx][:responses] += 1 if response[:correct]
|
|
res[:answer_sets][idx][:answer_matches].each_with_index do |right, jdx|
|
|
if response["answer_for_#{answer[:blank_id]}".to_sym] == right[:text]
|
|
found = true
|
|
res[:answer_sets][idx][:answer_matches][jdx][:responses] += 1
|
|
res[:answer_sets][idx][:answer_matches][jdx][:user_ids] << submission.user_id
|
|
end
|
|
end
|
|
if !found
|
|
if response_hash_id
|
|
answer = {:id => response_hash_id, :responses => 1, :user_ids => [submission.user_id], :text => response["answer_for_#{answer[:blank_id]}".to_sym]}
|
|
res[:answer_sets][idx][:answer_matches] << answer
|
|
end
|
|
end
|
|
end
|
|
elsif question[:question_type] == 'multiple_dropdowns_question'
|
|
res[:multiple_answers] = true
|
|
res[:answer_sets].each_with_index do |answer, idx|
|
|
res[:answer_sets][idx][:responses] += 1 if response[:correct]
|
|
res[:answer_sets][idx][:answer_matches].each_with_index do |right, jdx|
|
|
if response["answer_id_for_#{answer[:blank_id]}".to_sym] == right[:id]
|
|
res[:answer_sets][idx][:answer_matches][jdx][:responses] += 1
|
|
res[:answer_sets][idx][:answer_matches][jdx][:user_ids] << submission.user_id
|
|
end
|
|
end
|
|
end
|
|
elsif question[:question_type] == 'multiple_answers_question'
|
|
res[:answers].each_with_index do |answer, idx|
|
|
if response["answer_#{answer[:id]}".to_sym] == '1'
|
|
res[:answers][idx][:responses] += 1
|
|
res[:answers][idx][:user_ids] << submission.user_id
|
|
end
|
|
end
|
|
elsif question[:question_type] == 'calculated_question'
|
|
found = false
|
|
response_hash_id = Digest::MD5.hexdigest(response[:text].strip.to_f.to_s) if !response[:text].try(:strip).blank?
|
|
res[:answers].each_with_index do |answer, idx|
|
|
if res[:answers][idx][:id] == response[:answer_id] || res[:answers][idx][:id] == response_hash_id
|
|
found = true
|
|
res[:answers][idx][:numbers] ||= {}
|
|
res[:answers][idx][:numbers][response[:text].to_f] ||= {:responses => 0, :user_ids => [], :correct => true}
|
|
res[:answers][idx][:numbers][response[:text].to_f][:responses] += 1
|
|
res[:answers][idx][:numbers][response[:text].to_f][:user_ids] << submission.user_id
|
|
res[:answers][idx][:responses] += 1
|
|
res[:answers][idx][:user_ids] << submission.user_id
|
|
end
|
|
end
|
|
if !found
|
|
if ['numerical_question', 'short_answer_question'].include?(question[:question_type]) && response_hash_id
|
|
answer = {:id => response_hash_id, :responses => 1, :user_ids => [submission.user_id], :text => response[:text].to_f.to_s}
|
|
res[:answers] << answer
|
|
end
|
|
end
|
|
elsif question[:question_type] == 'text_only_question'
|
|
elsif question[:question_type] == 'essay_question'
|
|
res[:essay_responses] ||= []
|
|
res[:essay_responses] << {:user_id => submission.user_id, :text => response[:text].strip}
|
|
else
|
|
found = false
|
|
response_hash_id = Digest::MD5.hexdigest(response[:text].strip) if !response[:text].try(:strip).blank?
|
|
res[:answers].each_with_index do |answer, idx|
|
|
if answer[:id] == response[:answer_id] || answer[:id] == response_hash_id
|
|
found = true
|
|
res[:answers][idx][:responses] += 1
|
|
res[:answers][idx][:user_ids] << submission.user_id
|
|
end
|
|
end
|
|
if !found
|
|
|
|
if ['numerical_question', 'short_answer_question'].include?(question[:question_type]) && response_hash_id
|
|
answer = {:id => response_hash_id, :responses => 1, :user_ids => [submission.user_id], :text => response[:text]}
|
|
res[:answers] << answer
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
none = {
|
|
:responses => res[:responses] - res[:answers].map{|a| a[:responses] || 0}.sum,
|
|
:id => "none",
|
|
:weight => 0,
|
|
:text => t('statistics.no_answer', "No Answer"),
|
|
:user_ids => res[:user_ids] - res[:answers].map{|a| a[:user_ids] }.flatten
|
|
} rescue nil
|
|
res[:answers] << none if none && none[:responses] > 0
|
|
res
|
|
end
|
|
|
|
def unpublished_changes?
|
|
self.last_edited_at && self.published_at && self.last_edited_at > self.published_at
|
|
end
|
|
|
|
def has_student_submissions?
|
|
self.quiz_submissions.any?{|s| !s.settings_only? && context.includes_student?(s.user) }
|
|
end
|
|
|
|
# clear out all questions so that the quiz can be replaced. this is currently
|
|
# used by the respondus API.
|
|
# returns false if the quiz can't be safely replaced, for instance if anybody
|
|
# has taken the quiz.
|
|
def clear_for_replacement
|
|
return false if has_student_submissions?
|
|
|
|
self.question_count = 0
|
|
self.quiz_questions.destroy_all
|
|
self.quiz_groups.destroy_all
|
|
self.quiz_data = nil
|
|
true
|
|
end
|
|
|
|
def self.process_migration(data, migration, question_data)
|
|
assessments = data['assessments'] ? data['assessments']['assessments'] : []
|
|
assessments.each do |assessment|
|
|
migration_id = assessment['migration_id'] || assessment['assessment_id']
|
|
if migration.import_object?("quizzes", migration_id)
|
|
allow_update = false
|
|
# allow update if we find an existing item based on this migration setting
|
|
if item_id = migration.migration_settings[:quiz_id_to_update]
|
|
allow_update = true
|
|
assessment[:id] = item_id.to_i
|
|
if assessment[:assignment]
|
|
assessment[:assignment][:id] = Quiz.find(item_id.to_i).try(:assignment_id)
|
|
end
|
|
end
|
|
begin
|
|
assessment[:migration] = migration
|
|
Quiz.import_from_migration(assessment, migration.context, question_data, nil, allow_update)
|
|
rescue
|
|
migration.add_warning(t('warnings.import_from_migration_failed', "Couldn't import the quiz \"%{quiz_title}\"", :quiz_title => assessment[:title]), $!)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
# Import a quiz from a hash.
|
|
# It assumes that all the referenced questions are already in the database
|
|
def self.import_from_migration(hash, context, question_data, item=nil, allow_update = false)
|
|
hash = hash.with_indifferent_access
|
|
# there might not be an import id if it's just a text-only type...
|
|
item ||= find_by_context_type_and_context_id_and_id(context.class.to_s, context.id, hash[:id]) if hash[:id]
|
|
item ||= find_by_context_type_and_context_id_and_migration_id(context.class.to_s, context.id, hash[:migration_id]) if hash[:migration_id]
|
|
if item && !allow_update
|
|
if item.deleted?
|
|
item.workflow_state = hash[:available] ? 'available' : 'created'
|
|
item.save
|
|
end
|
|
end
|
|
item ||= context.quizzes.new
|
|
|
|
hash[:due_at] ||= hash[:due_date]
|
|
hash[:due_at] ||= hash[:grading][:due_date] if hash[:grading]
|
|
item.lock_at = Canvas::Migration::MigratorHelper.get_utc_time_from_timestamp(hash[:lock_at]) if hash[:lock_at]
|
|
item.unlock_at = Canvas::Migration::MigratorHelper.get_utc_time_from_timestamp(hash[:unlock_at]) if hash[:unlock_at]
|
|
item.due_at = Canvas::Migration::MigratorHelper.get_utc_time_from_timestamp(hash[:due_at]) if hash[:due_at]
|
|
item.scoring_policy = hash[:which_attempt_to_keep] if hash[:which_attempt_to_keep]
|
|
item.description = ImportedHtmlConverter.convert(hash[:description], context)
|
|
[:migration_id, :title, :allowed_attempts, :time_limit,
|
|
:shuffle_answers, :show_correct_answers, :points_possible, :hide_results,
|
|
:access_code, :ip_filter, :scoring_policy, :require_lockdown_browser,
|
|
:require_lockdown_browser_for_results, :anonymous_submissions,
|
|
:could_be_locked, :quiz_type, :one_question_at_a_time,
|
|
:cant_go_back].each do |attr|
|
|
item.send("#{attr}=", hash[attr]) if hash.key?(attr)
|
|
end
|
|
|
|
item.save!
|
|
if question_data
|
|
hash[:questions] ||= []
|
|
|
|
if question_data[:qq_data]
|
|
questions_to_update = item.quiz_questions.scoped(:conditions => {:migration_id => question_data[:qq_data].keys})
|
|
questions_to_update.each do |question_to_update|
|
|
question_data[:qq_data].values.find{|q| q['migration_id'].eql?(question_to_update.migration_id)}['quiz_question_id'] = question_to_update.id
|
|
end
|
|
end
|
|
|
|
hash[:questions].each_with_index do |question, i|
|
|
case question[:question_type]
|
|
when "question_reference"
|
|
if qq = question_data[:qq_data][question[:migration_id]]
|
|
qq[:position] = i + 1
|
|
if qq[:assessment_question_migration_id]
|
|
if aq = question_data[:aq_data][qq[:assessment_question_migration_id]]
|
|
qq['assessment_question_id'] = aq['assessment_question_id']
|
|
aq_hash = AssessmentQuestion.prep_for_import(qq, context)
|
|
QuizQuestion.import_from_migration(aq_hash, context, item)
|
|
else
|
|
aq_hash = AssessmentQuestion.import_from_migration(qq, context)
|
|
QuizQuestion.import_from_migration(aq_hash, context, item)
|
|
end
|
|
end
|
|
elsif aq = question_data[:aq_data][question[:migration_id]]
|
|
aq[:position] = i + 1
|
|
aq[:points_possible] = question[:points_possible] if question[:points_possible]
|
|
QuizQuestion.import_from_migration(aq, context, item)
|
|
end
|
|
when "question_group"
|
|
QuizGroup.import_from_migration(question, context, item, question_data, i + 1, hash[:migration])
|
|
when "text_only_question"
|
|
qq = item.quiz_questions.new
|
|
qq.question_data = question
|
|
qq.position = i + 1
|
|
qq.save!
|
|
end
|
|
end
|
|
end
|
|
item.reload # reload to catch question additions
|
|
|
|
if hash[:assignment] && hash[:available]
|
|
assignment = Assignment.import_from_migration(hash[:assignment], context)
|
|
item.assignment = assignment
|
|
elsif !item.assignment && grading = hash[:grading]
|
|
# The actual assignment will be created when the quiz is published
|
|
item.quiz_type = 'assignment'
|
|
hash[:assignment_group_migration_id] ||= grading[:assignment_group_migration_id]
|
|
end
|
|
|
|
if hash[:available]
|
|
item.generate_quiz_data
|
|
item.workflow_state = 'available'
|
|
item.published_at = Time.now
|
|
end
|
|
|
|
if hash[:assignment_group_migration_id]
|
|
if g = context.assignment_groups.find_by_migration_id(hash[:assignment_group_migration_id])
|
|
item.assignment_group_id = g.id
|
|
end
|
|
end
|
|
|
|
item.save
|
|
|
|
context.imported_migration_items << item if context.imported_migration_items
|
|
item
|
|
end
|
|
|
|
def self.serialization_excludes; [:access_code]; end
|
|
|
|
set_policy do
|
|
given { |user, session| self.cached_context_grants_right?(user, session, :manage_assignments) }#admins.include? user }
|
|
can :read_statistics and can :manage and can :read and can :update and can :delete and can :create and can :submit
|
|
|
|
given { |user, session| self.cached_context_grants_right?(user, session, :manage_grades) }#admins.include? user }
|
|
can :read_statistics and can :manage and can :read and can :update and can :delete and can :create and can :submit and can :grade
|
|
|
|
given { |user| self.available? && self.context.try_rescue(:is_public) && !self.graded? }
|
|
can :submit
|
|
|
|
given { |user, session| self.cached_context_grants_right?(user, session, :read) }#students.include?(user) }
|
|
can :read
|
|
|
|
given { |user, session| self.cached_context_grants_right?(user, session, :view_all_grades) }
|
|
can :read_statistics and can :review_grades
|
|
|
|
given { |user, session| self.available? && self.cached_context_grants_right?(user, session, :participate_as_student) }#students.include?(user) }
|
|
can :read and can :submit
|
|
end
|
|
named_scope :include_assignment, lambda{
|
|
{ :include => :assignment }
|
|
}
|
|
named_scope :before, lambda{|date|
|
|
{:conditions => ['quizzes.created_at < ?', date]}
|
|
}
|
|
named_scope :active, lambda{
|
|
{:conditions => ['quizzes.workflow_state != ?', 'deleted'] }
|
|
}
|
|
named_scope :not_for_assignment, lambda{
|
|
{:conditions => ['quizzes.assignment_id IS NULL'] }
|
|
}
|
|
|
|
def migrate_file_links
|
|
QuizQuestionLinkMigrator.migrate_file_links_in_quiz(self)
|
|
end
|
|
|
|
def self.batch_migrate_file_links(ids)
|
|
quizzes = Quiz.find(:all, :conditions => ['id in (?)', ids])
|
|
quizzes.each do |quiz|
|
|
if quiz.migrate_file_links
|
|
quiz.save
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.lockdown_browser_plugin_enabled?
|
|
Canvas::Plugin.all_for_tag(:lockdown_browser).any? { |p| p.settings[:enabled] }
|
|
end
|
|
|
|
def require_lockdown_browser
|
|
self[:require_lockdown_browser] && Quiz.lockdown_browser_plugin_enabled?
|
|
end
|
|
alias :require_lockdown_browser? :require_lockdown_browser
|
|
|
|
def require_lockdown_browser_for_results
|
|
self[:require_lockdown_browser_for_results] && Quiz.lockdown_browser_plugin_enabled?
|
|
end
|
|
alias :require_lockdown_browser_for_results? :require_lockdown_browser_for_results
|
|
|
|
def self.non_shuffled_questions
|
|
["true_false_question", "matching_question", "fill_in_multiple_blanks_question"]
|
|
end
|
|
|
|
def self.shuffleable_question_type?(question_type)
|
|
!non_shuffled_questions.include?(question_type)
|
|
end
|
|
end
|