make quiz_statistics model

refs CNVS-4887

This probably should have happened anyway, but the intermediate model
will be necessary to handle attachments for the downloadable
quiz_statistics.csv

Test plan:
  * make sure the quiz statistics page still works
  * make sure downloading quiz statistics csv still works

Change-Id: I9562a731d171dae24329fc52782a4f9efa4cf8bd
Reviewed-on: https://gerrit.instructure.com/18977
Tested-by: Jenkins <jenkins@instructure.com>
QA-Review: Myller de Araujo <myller@instructure.com>
Reviewed-by: Simon Williams <simon@instructure.com>
Product-Review: Simon Williams <simon@instructure.com>
This commit is contained in:
Cameron Matheson 2013-03-25 17:31:18 -06:00
parent 6601753eb7
commit 7a3b4ec1c4
5 changed files with 608 additions and 518 deletions

View File

@ -40,6 +40,7 @@ class Quiz < ActiveRecord::Base
has_many :quiz_questions, :dependent => :destroy, :order => 'position'
has_many :quiz_submissions, :dependent => :destroy
has_many :quiz_groups, :dependent => :destroy, :order => 'position'
has_many :quiz_statistics, :class_name => 'QuizStatistics', :order => 'created_at'
belongs_to :context, :polymorphic => true
belongs_to :assignment
belongs_to :cloned_item
@ -875,293 +876,17 @@ class Quiz < ActiveRecord::Base
end
end
def submissions_for_statistics(include_all_versions=true)
ActiveRecord::Base::ConnectionSpecification.with_environment(:slave) do
for_users = context.student_ids
scope = self.quiz_submissions.where(:user_id => for_users)
scope = scope.includes(:versions) if include_all_versions
scope.map { |qs|
include_all_versions ?
qs.submitted_versions :
[qs.latest_submitted_version].compact
}.flatten.
select{ |s| s.completed? && s.submission_data.is_a?(Array) }.
sort { |a,b| b.updated_at <=> a.updated_at }
end
def statistics(include_all_versions = true)
quiz_statistics.build(
:includes_all_versions => include_all_versions
).generate
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
# returns a blob of stats junk like this:
# {
# :multiple_attempts_exist=>false,
# :submission_user_ids=>#<Set: {2, ...}>,
# :unique_submission_count=>50,
# :submission_score_average=>5,
# :submission_score_high=>10,
# :submission_score_low=>0,
# :submission_duration_average=>124,
# :submission_score_stdev=>0,
# :submission_incorrect_count_average=>3,
# :submission_correct_count_average=>1,
# :questions=>
# [output of stats_for_question for every question in submission_data]
def statistics(include_all_versions=true)
submissions = submissions_for_statistics(include_all_versions)
# questions: questions from quiz#quiz_data
#{1022=>
# {"id"=>1022,
# "points_possible"=>1,
# "question_type"=>"numerical_question",
# "question_name"=>"Really Hard Question",
# "name"=>"Really Hard Question",
# "answers"=> [{"id"=>6782},...],
# "assessment_question_id"=>1022,
# }, ...}
questions = Hash[
(quiz_data || []).map { |q| q[:questions] || q }.
flatten.
select { |q| q[:answers] }.
map { |q| [q[:id], q] }
]
stats = {}
found_ids = {}
score_counter = Stats::Counter.new
questions_hash = {}
stats[:questions] = []
stats[:multiple_attempts_exist] = submissions.any?{|s| s.attempt && s.attempt > 1 }
stats[:submission_user_ids] = Set.new
stats[:unique_submission_count] = 0
correct_cnt = incorrect_cnt = total_duration = 0
submissions.each do |sub|
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
correct_cnt += answers.count{|a| a[:correct] == true }
incorrect_cnt += answers.count{|a| a[:correct] == false }
total_duration += ((sub.finished_at - sub.started_at).to_i rescue 30)
sub.quiz_data.each do |question|
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_score_stdev] = score_counter.standard_deviation
if submissions.size > 0
stats[:submission_correct_count_average] = correct_cnt.to_f / submissions.size
stats[:submission_incorrect_count_average] = incorrect_cnt.to_f / submissions.size
stats[:submission_duration_average] = total_duration.to_f / submissions.size
else
stats[:submission_correct_count_average] =
stats[:submission_incorrect_count_average] =
stats[:submission_duration_average] = 0
end
assessment_questions = if questions_hash.any? { |_,q| q[:assessment_question_id] }
Hash[
AssessmentQuestion.where(:id => questions_hash.keys).
map { |aq| [aq.id, aq] }
]
else
{}
end
responses_for_question = {}
submissions.each do |s|
s.submission_data.each do |a|
q_id = a[:question_id]
a[:user_id] = s.user_id
responses_for_question[q_id] ||= []
responses_for_question[q_id] << a
end
end
questions_hash.keys.each do |id|
obj = questions[id]
unless obj
obj = questions_hash[id]
if obj[:assessment_question_id]
aq_name = assessment_questions[obj[:assessment_question_id]].try(:name)
obj[:name] = aq_name if aq_name
end
end
if obj[:answers] && obj[:question_type] != 'text_only_question'
stat = stats_for_question(obj, responses_for_question[obj[:id]])
stats[:questions] << ['question', stat]
end
end
stats
end
# takes a question hash from Quiz/Submission#quiz_data, and a set of
# responses (from Submission#submission_data)
#
# returns:
# ["question",
# {"points_possible"=>1,
# "question_type"=>"multiple_choice_question",
# "question_name"=>"Some Question",
# "name"=>"Some Question",
# "question_text"=>"<p>Blah blah blah?</p>",
# "answers"=>
# [{"text"=>"blah",
# "comments"=>"",
# "weight"=>100,
# "id"=>8379,
# "responses"=>2,
# "user_ids"=>[2, 3]},
# {"text"=>"blarb",
# "weight"=>0,
# "id"=>8153,
# "responses"=>1,
# "user_ids"=>[1]}],
# "assessment_question_id"=>1017,
# "id"=>1017,
# "responses"=>3,
# "response_values"=>[...],
# "unexpected_response_values"=>[],
# "user_ids"=>[1,2,3],
# "multiple_responses"=>false}],
def stats_for_question(question, responses)
question[:responses] = 0
question[:response_values] = []
question[:unexpected_response_values] = []
question[:user_ids] = []
question[:answers].each { |a|
a[:responses] = 0
a[:user_ids] = []
}
strip_html_answers(question)
question[:user_ids] = responses.map { |r| r[:user_id] }
question[:response_values] = responses.map { |r| r[:text] }
question[:responses] = responses.size
question = QuizQuestion::Base.from_question_data(question).stats(responses)
none = {
:responses => question[:responses] - question[:answers].map{|a| a[:responses] || 0}.sum,
:id => "none",
:weight => 0,
:text => t('statistics.no_answer', "No Answer"),
:user_ids => question[:user_ids] - question[:answers].map{|a| a[:user_ids] }.flatten
} rescue nil
question[:answers] << none if none && none[:responses] > 0
question
quiz_statistics.create!(
:includes_all_versions => options[:include_all_versions],
:anonymous => options[:anonymous]
).to_csv
end
def unpublished_changes?

View File

@ -0,0 +1,326 @@
#
# 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/>.
#
class QuizStatistics < ActiveRecord::Base
include TextHelper
attr_accessible :includes_all_versions, :anonymous
belongs_to :quiz
set_table_name :quiz_statistics
# returns a blob of stats junk like this:
# {
# :multiple_attempts_exist=>false,
# :submission_user_ids=>#<Set: {2, ...}>,
# :unique_submission_count=>50,
# :submission_score_average=>5,
# :submission_score_high=>10,
# :submission_score_low=>0,
# :submission_duration_average=>124,
# :submission_score_stdev=>0,
# :submission_incorrect_count_average=>3,
# :submission_correct_count_average=>1,
# :questions=>
# [output of stats_for_question for every question in submission_data]
def generate
submissions = submissions_for_statistics
# questions: questions from quiz#quiz_data
#{1022=>
# {"id"=>1022,
# "points_possible"=>1,
# "question_type"=>"numerical_question",
# "question_name"=>"Really Hard Question",
# "name"=>"Really Hard Question",
# "answers"=> [{"id"=>6782},...],
# "assessment_question_id"=>1022,
# }, ...}
questions = Hash[
(quiz.quiz_data || []).map { |q| q[:questions] || q }.
flatten.
select { |q| q[:answers] }.
map { |q| [q[:id], q] }
]
stats = {}
found_ids = {}
score_counter = Stats::Counter.new
questions_hash = {}
stats[:questions] = []
stats[:multiple_attempts_exist] = submissions.any?{ |s|
s.attempt && s.attempt > 1
}
stats[:submission_user_ids] = Set.new
stats[:unique_submission_count] = 0
correct_cnt = incorrect_cnt = total_duration = 0
submissions.each do |sub|
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
correct_cnt += answers.count{ |a| a[:correct] == true }
incorrect_cnt += answers.count{ |a| a[:correct] == false }
total_duration += ((sub.finished_at - sub.started_at).to_i rescue 30)
sub.quiz_data.each do |question|
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_score_stdev] = score_counter.standard_deviation
if submissions.size > 0
stats[:submission_correct_count_average] = correct_cnt.to_f / submissions.size
stats[:submission_incorrect_count_average] = incorrect_cnt.to_f / submissions.size
stats[:submission_duration_average] = total_duration.to_f / submissions.size
else
stats[:submission_correct_count_average] =
stats[:submission_incorrect_count_average] =
stats[:submission_duration_average] = 0
end
assessment_questions = if questions_hash.any? { |_,q| q[:assessment_question_id] }
Hash[
AssessmentQuestion.where(:id => questions_hash.keys).
map { |aq| [aq.id, aq] }
]
else
{}
end
responses_for_question = {}
submissions.each do |s|
s.submission_data.each do |a|
q_id = a[:question_id]
a[:user_id] = s.user_id
responses_for_question[q_id] ||= []
responses_for_question[q_id] << a
end
end
questions_hash.keys.each do |id|
obj = questions[id]
unless obj
obj = questions_hash[id]
if obj[:assessment_question_id]
aq_name = assessment_questions[obj[:assessment_question_id]].try(:name)
obj[:name] = aq_name if aq_name
end
end
if obj[:answers] && obj[:question_type] != 'text_only_question'
stat = stats_for_question(obj, responses_for_question[obj[:id]])
stats[:questions] << ['question', stat]
end
end
stats
end
def to_csv
columns = []
columns << t('statistics.csv_columns.name', 'name') unless anonymous?
columns << t('statistics.csv_columns.id', 'id') unless anonymous?
columns << t('statistics.csv_columns.sis_id', 'sis_id') unless 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 includes_all_versions?
first_question_index = columns.length
submissions = submissions_for_statistics
found_question_ids = {}
quiz_datas = [quiz.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 anonymous?
row << submission.user_id unless anonymous?
row << submission.user.sis_pseudonym_for(quiz.context.account).try(:sis_user_id) unless anonymous?
section_name = []
section_id = []
section_sis_id = []
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 includes_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
private
def submissions_for_statistics
ActiveRecord::Base::ConnectionSpecification.with_environment(:slave) do
for_users = quiz.context.student_ids
scope = quiz.quiz_submissions.where(:user_id => for_users)
scope = scope.includes(:versions) if includes_all_versions?
scope.map { |qs|
includes_all_versions? ?
qs.submitted_versions :
[qs.latest_submitted_version].compact
}.flatten.
select{ |s| s && s.completed? && s.submission_data.is_a?(Array) }.
sort { |a,b| b.updated_at <=> a.updated_at }
end
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
# takes a question hash from Quiz/Submission#quiz_data, and a set of
# responses (from Submission#submission_data)
#
# returns:
# ["question",
# {"points_possible"=>1,
# "question_type"=>"multiple_choice_question",
# "question_name"=>"Some Question",
# "name"=>"Some Question",
# "question_text"=>"<p>Blah blah blah?</p>",
# "answers"=>
# [{"text"=>"blah",
# "comments"=>"",
# "weight"=>100,
# "id"=>8379,
# "responses"=>2,
# "user_ids"=>[2, 3]},
# {"text"=>"blarb",
# "weight"=>0,
# "id"=>8153,
# "responses"=>1,
# "user_ids"=>[1]}],
# "assessment_question_id"=>1017,
# "id"=>1017,
# "responses"=>3,
# "response_values"=>[...],
# "unexpected_response_values"=>[],
# "user_ids"=>[1,2,3],
# "multiple_responses"=>false}],
def stats_for_question(question, responses)
question[:responses] = 0
question[:response_values] = []
question[:unexpected_response_values] = []
question[:user_ids] = []
question[:answers].each { |a|
a[:responses] = 0
a[:user_ids] = []
}
strip_html_answers(question)
question[:user_ids] = responses.map { |r| r[:user_id] }
question[:response_values] = responses.map { |r| r[:text] }
question[:responses] = responses.size
question = QuizQuestion::Base.from_question_data(question).stats(responses)
none = {
:responses => question[:responses] - question[:answers].map{|a| a[:responses] || 0}.sum,
:id => "none",
:weight => 0,
:text => t('statistics.no_answer', "No Answer"),
:user_ids => question[:user_ids] - question[:answers].map{|a| a[:user_ids] }.flatten
} rescue nil
question[:answers] << none if none && none[:responses] > 0
question
end
end

View File

@ -0,0 +1,17 @@
class CreateQuizStatisticsTable < ActiveRecord::Migration
tag :predeploy
def self.up
create_table :quiz_statistics do |t|
t.integer :quiz_id, :limit => 8
t.boolean :includes_all_versions
t.boolean :anonymous
t.timestamps
end
add_index :quiz_statistics, :quiz_id
end
def self.down
drop_table :quiz_statistics
end
end

View File

@ -542,240 +542,6 @@ describe Quiz do
q.quiz_submissions.first.submission.assignment.should == q.assignment
end
context 'statistics' do
it 'should calculate mean/stddev as expected with no submissions' do
stats = @course.quizzes.new.statistics
stats[:submission_score_average].should be_nil
stats[:submission_score_high].should be_nil
stats[:submission_score_low].should be_nil
stats[:submission_score_stdev].should be_nil
end
it 'should calculate mean/stddev as expected with a few submissions' do
q = @course.quizzes.new
q.save!
@user1 = User.create! :name => "some_user 1"
@user2 = User.create! :name => "some_user 2"
@user3 = User.create! :name => "some_user 2"
student_in_course :course => @course, :user => @user1
student_in_course :course => @course, :user => @user2
student_in_course :course => @course, :user => @user3
sub = q.generate_submission(@user1)
sub.workflow_state = 'complete'
sub.submission_data = [{ :points => 15, :text => "", :correct => "undefined", :question_id => -1 }]
sub.with_versioning(true, &:save!)
stats = q.statistics
stats[:submission_score_average].should == 15
stats[:submission_score_high].should == 15
stats[:submission_score_low].should == 15
stats[:submission_score_stdev].should == 0
sub = q.generate_submission(@user2)
sub.workflow_state = 'complete'
sub.submission_data = [{ :points => 17, :text => "", :correct => "undefined", :question_id => -1 }]
sub.with_versioning(true, &:save!)
stats = q.statistics
stats[:submission_score_average].should == 16
stats[:submission_score_high].should == 17
stats[:submission_score_low].should == 15
stats[:submission_score_stdev].should == 1
sub = q.generate_submission(@user3)
sub.workflow_state = 'complete'
sub.submission_data = [{ :points => 20, :text => "", :correct => "undefined", :question_id => -1 }]
sub.with_versioning(true, &:save!)
stats = q.statistics
stats[:submission_score_average].should be_close(17 + 1.0/3, 0.0000000001)
stats[:submission_score_high].should == 20
stats[:submission_score_low].should == 15
stats[:submission_score_stdev].should be_close(Math::sqrt(4 + 2.0/9), 0.0000000001)
end
it "should use the last completed submission, even if the current submission is in progress" do
student_in_course(:active_all => true)
q = @course.quizzes.create!
q.quiz_questions.create!(:question_data => { :name => "test 1" })
q.generate_quiz_data
q.save!
# one complete submission
qs = q.generate_submission(@student)
qs.grade_submission
# and one in progress
qs = q.generate_submission(@student)
stats = q.statistics(false)
stats[:multiple_attempts_exist].should be_false
end
context 'csv' do
before(:each) do
student_in_course(:active_all => true)
@quiz = @course.quizzes.create!
@quiz.quiz_questions.create!(:question_data => { :name => "test 1" })
@quiz.generate_quiz_data
@quiz.save!
end
it 'should include previous versions even if the current version is incomplete' do
# one complete submission
qs = @quiz.generate_submission(@student)
qs.grade_submission
# and one in progress
@quiz.generate_submission(@student)
stats = FasterCSV.parse(@quiz.statistics_csv(:include_all_versions => true))
# format for row is row_name, '', data1, data2, ...
stats.first.length.should == 3
end
it 'should not include user data for anonymous surveys' do
# one complete submission
qs = @quiz.generate_submission(@student)
qs.grade_submission
# and one in progress
@quiz.generate_submission(@student)
stats = FasterCSV.parse(@quiz.statistics_csv(:include_all_versions => true, :anonymous => true))
# format for row is row_name, '', data1, data2, ...
stats.first.length.should == 3
stats[0][0].should == "section"
end
it 'should have sections in quiz statistics_csv' do
#enroll user in multiple sections
pseudonym = pseudonym(@student)
@student.pseudonym.sis_user_id = "user_sis_id_01"
@student.pseudonym.save!
section1 = @course.course_sections.first
section1.sis_source_id = 'SISSection01'
section1.save!
section2 = CourseSection.new(:course => @course, :name => "section2")
section2.sis_source_id = 'SISSection02'
section2.save!
@course.enroll_user(@student, "StudentEnrollment", :enrollment_state => 'active', :allow_multiple_enrollments => true, :section => section2)
# one complete submission
qs = @quiz.generate_submission(@student)
qs.grade_submission
stats = FasterCSV.parse(@quiz.statistics_csv(:include_all_versions => true))
# format for row is row_name, '', data1, data2, ...
stats[0].should == ["name", "", "nobody@example.com"]
stats[1].should == ["id", "", @student.id.to_s]
stats[2].should == ["sis_id", "", "user_sis_id_01"]
expect_multi_value_row(stats[3], "section", ["section2", "Unnamed Course"])
expect_multi_value_row(stats[4], "section_id", [section1.id, section2.id])
expect_multi_value_row(stats[5], "section_sis_id", ["SISSection02", "SISSection01"])
stats.first.length.should == 3
end
def expect_multi_value_row(row, expected_name, expected_values)
row[0..1].should == [expected_name, ""]
row[2].split(', ').sort.should == expected_values.map(&:to_s).sort
end
it 'should not include previous versions by default' do
# two complete submissions
qs = @quiz.generate_submission(@student)
qs.grade_submission
qs = @quiz.generate_submission(@student)
qs.grade_submission
stats = FasterCSV.parse(@quiz.statistics_csv)
# format for row is row_name, '', data1, data2, ...
stats.first.length.should == 3
end
it 'should deal with incomplete fill-in-multiple-blanks questions' do
@quiz.quiz_questions.create!(:question_data => { :name => "test 2",
:question_type => 'fill_in_multiple_blanks_question',
:question_text => "[ans0]",
:answers =>
{'answer_0' => {'answer_text' => 'foo', 'blank_id' => 'ans0', 'answer_weight' => '100'}}})
@quiz.quiz_questions.create!(:question_data => { :name => "test 3",
:question_type => 'fill_in_multiple_blanks_question',
:question_text => "[ans0] [ans1]",
:answers =>
{'answer_0' => {'answer_text' => 'bar', 'blank_id' => 'ans0', 'answer_weight' => '100'},
'answer_1' => {'answer_text' => 'baz', 'blank_id' => 'ans1', 'answer_weight' => '100'}}})
@quiz.generate_quiz_data
@quiz.save!
@quiz.quiz_questions.size.should == 3
qs = @quiz.generate_submission(@student)
# submission will not answer question 2 and will partially answer question 3
qs.submission_data = {
"question_#{@quiz.quiz_questions[2].id}_#{AssessmentQuestion.variable_id('ans1')}" => 'baz'
}
qs.grade_submission
stats = FasterCSV.parse(@quiz.statistics_csv)
stats.size.should == 16 # 3 questions * 2 lines + ten more (name, id, sis_id, section, section_id, section_sis_id, submitted, correct, incorrect, score)
stats[11].size.should == 3
stats[11][2].should == ',baz'
end
it 'should contain answers to numerical questions' do
@quiz.quiz_questions.create!(:question_data => { :name => "numerical_question",
:question_type => 'numerical_question',
:question_text => "[num1]",
:answers => {'answer_0' => {:numerical_answer_type => 'exact_answer'}}})
@quiz.quiz_questions.last.question_data[:answers].first[:exact] = 5
@quiz.generate_quiz_data
@quiz.save!
qs = @quiz.generate_submission(@student)
qs.submission_data = {
"question_#{@quiz.quiz_questions[1].id}" => 5
}
qs.grade_submission
stats = FasterCSV.parse(@quiz.statistics_csv)
stats[9][2].should == '5'
end
end
it 'should strip tags from html multiple-choice/multiple-answers' do
student_in_course(:active_all => true)
q = @course.quizzes.create!(:title => "new quiz")
q.quiz_questions.create!(:question_data => {:name => 'q1', :points_possible => 1, 'question_type' => 'multiple_choice_question', 'answers' => {'answer_0' => {'answer_text' => '', 'answer_html' => '<em>zero</em>', 'answer_weight' => '100'}, 'answer_1' => {'answer_text' => "", 'answer_html' => "<p>one</p>", 'answer_weight' => '0'}}})
q.quiz_questions.create!(:question_data => {:name => 'q2', :points_possible => 1, 'question_type' => 'multiple_answers_question', 'answers' => {'answer_0' => {'answer_text' => '', 'answer_html' => "<a href='http://example.com/caturday.gif'>lolcats</a>", 'answer_weight' => '100'}, 'answer_1' => {'answer_text' => 'lolrus', 'answer_weight' => '100'}}})
q.generate_quiz_data
q.save
qs = q.generate_submission(@student)
qs.submission_data = {
"question_#{q.quiz_data[0][:id]}" => "#{q.quiz_data[0][:answers][0][:id]}",
"question_#{q.quiz_data[1][:id]}_answer_#{q.quiz_data[1][:answers][0][:id]}" => "1",
"question_#{q.quiz_data[1][:id]}_answer_#{q.quiz_data[1][:answers][1][:id]}" => "1"
}
qs.grade_submission
# visual statistics
stats = q.statistics
stats[:questions].length.should == 2
stats[:questions][0].length.should == 2
stats[:questions][0][0].should == "question"
stats[:questions][0][1][:answers].length.should == 2
stats[:questions][0][1][:answers][0][:responses].should == 1
stats[:questions][0][1][:answers][0][:text].should == "zero"
stats[:questions][0][1][:answers][1][:responses].should == 0
stats[:questions][0][1][:answers][1][:text].should == "one"
stats[:questions][1].length.should == 2
stats[:questions][1][0].should == "question"
stats[:questions][1][1][:answers].length.should == 2
stats[:questions][1][1][:answers][0][:responses].should == 1
stats[:questions][1][1][:answers][0][:text].should == "lolcats"
stats[:questions][1][1][:answers][1][:responses].should == 1
stats[:questions][1][1][:answers][1][:text].should == "lolrus"
# csv statistics
stats = FasterCSV.parse(q.statistics_csv)
stats[7][2].should == "zero"
stats[9][2].should == "lolcats,lolrus"
end
end
context "clone_for" do
it "should clone for other contexts" do
u = User.create!(:name => "some user")

View File

@ -0,0 +1,256 @@
#
# 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 File.expand_path(File.dirname(__FILE__) + '/../spec_helper.rb')
describe QuizStatistics do
before { course }
it 'should calculate mean/stddev as expected with no submissions' do
q = @course.quizzes.create!
stats = q.statistics
stats[:submission_score_average].should be_nil
stats[:submission_score_high].should be_nil
stats[:submission_score_low].should be_nil
stats[:submission_score_stdev].should be_nil
end
it 'should calculate mean/stddev as expected with a few submissions' do
q = @course.quizzes.create!
q.save!
@user1 = User.create! :name => "some_user 1"
@user2 = User.create! :name => "some_user 2"
@user3 = User.create! :name => "some_user 2"
student_in_course :course => @course, :user => @user1
student_in_course :course => @course, :user => @user2
student_in_course :course => @course, :user => @user3
sub = q.generate_submission(@user1)
sub.workflow_state = 'complete'
sub.submission_data = [{ :points => 15, :text => "", :correct => "undefined", :question_id => -1 }]
sub.with_versioning(true, &:save!)
stats = q.statistics
stats[:submission_score_average].should == 15
stats[:submission_score_high].should == 15
stats[:submission_score_low].should == 15
stats[:submission_score_stdev].should == 0
sub = q.generate_submission(@user2)
sub.workflow_state = 'complete'
sub.submission_data = [{ :points => 17, :text => "", :correct => "undefined", :question_id => -1 }]
sub.with_versioning(true, &:save!)
stats = q.statistics
stats[:submission_score_average].should == 16
stats[:submission_score_high].should == 17
stats[:submission_score_low].should == 15
stats[:submission_score_stdev].should == 1
sub = q.generate_submission(@user3)
sub.workflow_state = 'complete'
sub.submission_data = [{ :points => 20, :text => "", :correct => "undefined", :question_id => -1 }]
sub.with_versioning(true, &:save!)
stats = q.statistics
stats[:submission_score_average].should be_close(17 + 1.0/3, 0.0000000001)
stats[:submission_score_high].should == 20
stats[:submission_score_low].should == 15
stats[:submission_score_stdev].should be_close(Math::sqrt(4 + 2.0/9), 0.0000000001)
end
it "should use the last completed submission, even if the current submission is in progress" do
student_in_course(:active_all => true)
q = @course.quizzes.create!
q.quiz_questions.create!(:question_data => { :name => "test 1" })
q.generate_quiz_data
q.save!
# one complete submission
qs = q.generate_submission(@student)
qs.grade_submission
# and one in progress
qs = q.generate_submission(@student)
stats = q.statistics(false)
stats[:multiple_attempts_exist].should be_false
end
context 'csv' do
before(:each) do
student_in_course(:active_all => true)
@quiz = @course.quizzes.create!
@quiz.quiz_questions.create!(:question_data => { :name => "test 1" })
@quiz.generate_quiz_data
@quiz.save!
end
it 'should include previous versions even if the current version is incomplete' do
# one complete submission
qs = @quiz.generate_submission(@student)
qs.grade_submission
# and one in progress
@quiz.generate_submission(@student)
stats = FasterCSV.parse(@quiz.statistics_csv(:include_all_versions => true))
# format for row is row_name, '', data1, data2, ...
stats.first.length.should == 3
end
it 'should not include user data for anonymous surveys' do
# one complete submission
qs = @quiz.generate_submission(@student)
qs.grade_submission
# and one in progress
@quiz.generate_submission(@student)
stats = FasterCSV.parse(@quiz.statistics_csv(:include_all_versions => true, :anonymous => true))
# format for row is row_name, '', data1, data2, ...
stats.first.length.should == 3
stats[0][0].should == "section"
end
it 'should have sections in quiz statistics_csv' do
#enroll user in multiple sections
pseudonym = pseudonym(@student)
@student.pseudonym.sis_user_id = "user_sis_id_01"
@student.pseudonym.save!
section1 = @course.course_sections.first
section1.sis_source_id = 'SISSection01'
section1.save!
section2 = CourseSection.new(:course => @course, :name => "section2")
section2.sis_source_id = 'SISSection02'
section2.save!
@course.enroll_user(@student, "StudentEnrollment", :enrollment_state => 'active', :allow_multiple_enrollments => true, :section => section2)
# one complete submission
qs = @quiz.generate_submission(@student)
qs.grade_submission
stats = FasterCSV.parse(@quiz.statistics_csv(:include_all_versions => true))
# format for row is row_name, '', data1, data2, ...
stats[0].should == ["name", "", "nobody@example.com"]
stats[1].should == ["id", "", @student.id.to_s]
stats[2].should == ["sis_id", "", "user_sis_id_01"]
expect_multi_value_row(stats[3], "section", ["section2", "Unnamed Course"])
expect_multi_value_row(stats[4], "section_id", [section1.id, section2.id])
expect_multi_value_row(stats[5], "section_sis_id", ["SISSection02", "SISSection01"])
stats.first.length.should == 3
end
def expect_multi_value_row(row, expected_name, expected_values)
row[0..1].should == [expected_name, ""]
row[2].split(', ').sort.should == expected_values.map(&:to_s).sort
end
it 'should not include previous versions by default' do
# two complete submissions
qs = @quiz.generate_submission(@student)
qs.grade_submission
qs = @quiz.generate_submission(@student)
qs.grade_submission
stats = FasterCSV.parse(@quiz.statistics_csv)
# format for row is row_name, '', data1, data2, ...
stats.first.length.should == 3
end
it 'should deal with incomplete fill-in-multiple-blanks questions' do
@quiz.quiz_questions.create!(:question_data => { :name => "test 2",
:question_type => 'fill_in_multiple_blanks_question',
:question_text => "[ans0]",
:answers =>
{'answer_0' => {'answer_text' => 'foo', 'blank_id' => 'ans0', 'answer_weight' => '100'}}})
@quiz.quiz_questions.create!(:question_data => { :name => "test 3",
:question_type => 'fill_in_multiple_blanks_question',
:question_text => "[ans0] [ans1]",
:answers =>
{'answer_0' => {'answer_text' => 'bar', 'blank_id' => 'ans0', 'answer_weight' => '100'},
'answer_1' => {'answer_text' => 'baz', 'blank_id' => 'ans1', 'answer_weight' => '100'}}})
@quiz.generate_quiz_data
@quiz.save!
@quiz.quiz_questions.size.should == 3
qs = @quiz.generate_submission(@student)
# submission will not answer question 2 and will partially answer question 3
qs.submission_data = {
"question_#{@quiz.quiz_questions[2].id}_#{AssessmentQuestion.variable_id('ans1')}" => 'baz'
}
qs.grade_submission
stats = FasterCSV.parse(@quiz.statistics_csv)
stats.size.should == 16 # 3 questions * 2 lines + ten more (name, id, sis_id, section, section_id, section_sis_id, submitted, correct, incorrect, score)
stats[11].size.should == 3
stats[11][2].should == ',baz'
end
it 'should contain answers to numerical questions' do
@quiz.quiz_questions.create!(:question_data => { :name => "numerical_question",
:question_type => 'numerical_question',
:question_text => "[num1]",
:answers => {'answer_0' => {:numerical_answer_type => 'exact_answer'}}})
@quiz.quiz_questions.last.question_data[:answers].first[:exact] = 5
@quiz.generate_quiz_data
@quiz.save!
qs = @quiz.generate_submission(@student)
qs.submission_data = {
"question_#{@quiz.quiz_questions[1].id}" => 5
}
qs.grade_submission
stats = FasterCSV.parse(@quiz.statistics_csv)
stats[9][2].should == '5'
end
end
it 'should strip tags from html multiple-choice/multiple-answers' do
student_in_course(:active_all => true)
q = @course.quizzes.create!(:title => "new quiz")
q.quiz_questions.create!(:question_data => {:name => 'q1', :points_possible => 1, 'question_type' => 'multiple_choice_question', 'answers' => {'answer_0' => {'answer_text' => '', 'answer_html' => '<em>zero</em>', 'answer_weight' => '100'}, 'answer_1' => {'answer_text' => "", 'answer_html' => "<p>one</p>", 'answer_weight' => '0'}}})
q.quiz_questions.create!(:question_data => {:name => 'q2', :points_possible => 1, 'question_type' => 'multiple_answers_question', 'answers' => {'answer_0' => {'answer_text' => '', 'answer_html' => "<a href='http://example.com/caturday.gif'>lolcats</a>", 'answer_weight' => '100'}, 'answer_1' => {'answer_text' => 'lolrus', 'answer_weight' => '100'}}})
q.generate_quiz_data
q.save
qs = q.generate_submission(@student)
qs.submission_data = {
"question_#{q.quiz_data[0][:id]}" => "#{q.quiz_data[0][:answers][0][:id]}",
"question_#{q.quiz_data[1][:id]}_answer_#{q.quiz_data[1][:answers][0][:id]}" => "1",
"question_#{q.quiz_data[1][:id]}_answer_#{q.quiz_data[1][:answers][1][:id]}" => "1"
}
qs.grade_submission
# visual statistics
stats = q.statistics
stats[:questions].length.should == 2
stats[:questions][0].length.should == 2
stats[:questions][0][0].should == "question"
stats[:questions][0][1][:answers].length.should == 2
stats[:questions][0][1][:answers][0][:responses].should == 1
stats[:questions][0][1][:answers][0][:text].should == "zero"
stats[:questions][0][1][:answers][1][:responses].should == 0
stats[:questions][0][1][:answers][1][:text].should == "one"
stats[:questions][1].length.should == 2
stats[:questions][1][0].should == "question"
stats[:questions][1][1][:answers].length.should == 2
stats[:questions][1][1][:answers][0][:responses].should == 1
stats[:questions][1][1][:answers][0][:text].should == "lolcats"
stats[:questions][1][1][:answers][1][:responses].should == 1
stats[:questions][1][1][:answers][1][:text].should == "lolrus"
# csv statistics
stats = FasterCSV.parse(q.statistics_csv)
stats[7][2].should == "zero"
stats[9][2].should == "lolcats,lolrus"
end
end