add the ability to regrade a quiz

This commit gives teachers the ability to regrade quizzes by using
different options per quiz question:

* Current Correct Only
* Full Credit (regardless of answer choice)
* Previous and Current Correct
* No Point change (for updating the display of a question)

Test Plan:
  You'll want to run through each question regrade option making sure
  scores change appropriately.

"I seldom end up where I wanted to go, but almost always end up where I
need to be." - Douglas Adams

Change-Id: I9dbb88154cd3ac630bf59dbf3e997a87f75649dc
Reviewed-on: https://gerrit.instructure.com/22018
Tested-by: Jenkins <jenkins@instructure.com>
Reviewed-by: Derek DeVries <ddevries@instructure.com>
QA-Review: Myller de Araujo <myller@instructure.com>
Product-Review: Stanley Stuart <stanley@instructure.com>
This commit is contained in:
Stanley Stuart 2013-07-11 12:53:10 -05:00
parent 85236b90c2
commit 9b76539a3e
53 changed files with 1769 additions and 24 deletions

View File

@ -3,6 +3,7 @@ require [
'quiz_show'
'quiz_rubric'
'message_students'
'jquery.disableWhileLoading'
], (inputMethods) ->
$ ->
inputMethods.setWidths()
@ -12,3 +13,12 @@ require [
$(".download_submissions_link").click (event) ->
event.preventDefault()
INST.downloadSubmissions($(this).attr('href'))
# load in regrade versions
if ENV.SUBMISSION_VERSIONS_URL && !ENV.IS_SURVEY
versions = $("#quiz-submission-version-table")
versions.css(height: "100px")
dfd = $.get ENV.SUBMISSION_VERSIONS_URL, (data) ->
versions.html(data)
versions.css(height: "auto")
versions.disableWhileLoading(dfd)

View File

@ -57,7 +57,9 @@ class QuizQuestionsController < ApplicationController
if authorized_action(@quiz, @current_user, :update)
@question = @quiz.quiz_questions.find(params[:id])
question_data = params[:question]
question_data[:regrade_user] = @current_user
question_data ||= {}
if question_data[:quiz_group_id]
@group = @quiz.quiz_groups.find(question_data[:quiz_group_id])
if question_data[:quiz_group_id] != @question.quiz_group_id
@ -65,6 +67,7 @@ class QuizQuestionsController < ApplicationController
@question.position = @group.quiz_questions.length
end
end
@question.question_data = question_data
@question.save
@quiz.did_edit if @quiz.created?

View File

@ -25,7 +25,7 @@ class QuizzesController < ApplicationController
before_filter :require_context
add_crumb(proc { t('#crumbs.quizzes', "Quizzes") }) { |c| c.send :named_context_url, c.instance_variable_get("@context"), :context_quizzes_url }
before_filter { |c| c.active_tab = "quizzes" }
before_filter :get_quiz, :only => [:statistics, :edit, :show, :reorder, :history, :update, :destroy, :moderate, :filters, :read_only, :managed_quiz_data]
before_filter :get_quiz, :only => [:statistics, :edit, :show, :reorder, :history, :update, :destroy, :moderate, :filters, :read_only, :managed_quiz_data, :submission_versions]
before_filter :set_download_submission_dialog_title , only: [:show,:statistics]
# The number of questions that can display "details". After this number, the "Show details" option is disabled
# and the data is not even loaded.
@ -158,13 +158,17 @@ class QuizzesController < ApplicationController
flash[:notice] = t('notices.has_submissions_already', "Keep in mind, some students have already taken or started taking this quiz")
end
regrade_options = Hash[@quiz.current_quiz_question_regrades.map do |qqr|
[qqr.quiz_question_id, qqr.regrade_option]
end]
sections = @context.course_sections.active
hash = { :ASSIGNMENT_ID => @assigment.present? ? @assignment.id : nil,
:ASSIGNMENT_OVERRIDES => assignment_overrides_json(@quiz.overrides_visible_to(@current_user)),
:QUIZ => quiz_json(@quiz, @context, @current_user, session),
:SECTION_LIST => sections.map { |section| { :id => section.id, :name => section.name } },
:QUIZZES_URL => polymorphic_url([@context, :quizzes]),
:CONTEXT_ACTION_SOURCE => :quizzes }
:CONTEXT_ACTION_SOURCE => :quizzes,
:REGRADE_OPTIONS => regrade_options }
append_sis_data(hash)
js_env(hash)
render :action => "new"
@ -226,11 +230,9 @@ class QuizzesController < ApplicationController
@assignment = @quiz.assignment
@assignment = @assignment.overridden_for(@current_user) if @assignment
@submission = @quiz.quiz_submissions.find_by_user_id(@current_user.id, :order => 'created_at') rescue nil
if !@current_user || (params[:preview] && @quiz.grants_right?(@current_user, session, :update))
user_code = temporary_user_code
@submission = @quiz.quiz_submissions.find_by_temporary_user_code(user_code)
end
@submission = get_submission
@just_graded = false
if @submission && @submission.needs_grading?(!!params[:take])
@submission.grade_submission(:finished_at => @submission.end_at)
@ -240,7 +242,9 @@ class QuizzesController < ApplicationController
if @submission
upload_url = api_v1_quiz_submission_create_file_path(:course_id => @context.id, :quiz_id => @quiz.id)
js_env :UPLOAD_URL => upload_url
js_env :SUBMISSION_VERSIONS_URL => polymorphic_url([@context, @quiz, 'submission_versions'])
end
setup_attachments
submission_counts if @quiz.grants_right?(@current_user, session, :grade) || @quiz.grants_right?(@current_user, session, :read_statistics)
@stored_params = (@submission.temporary_data rescue nil) if params[:take] && @submission && (@submission.untaken? || @submission.preview?)
@ -447,13 +451,14 @@ class QuizzesController < ApplicationController
end
@current_submission = @submission
@version_instances = @submission.submitted_versions.sort_by{|v| v.version_number }
@versions = get_versions
params[:version] ||= @version_instances[0].version_number if @submission.untaken? && !@version_instances.empty?
@current_version = true
@version_number = "current"
if params[:version]
@version_number = params[:version].to_i
@unversioned_submission = @submission
@submission = @version_instances.detect{|s| s.version_number >= @version_number}
@submission = @versions.detect{|s| s.version_number >= @version_number}
@submission ||= @unversioned_submission.versions.get(params[:version]).model
@current_version = (@current_submission.version_number == @submission.version_number)
@version_number = "current" if @current_version
@ -637,6 +642,19 @@ class QuizzesController < ApplicationController
@headers = !params[:headless] && !session[:headless_quiz]
end
def submission_versions
if authorized_action(@quiz, @current_user, :read)
@submission = get_submission
@versions = get_versions
if @versions.size > 0
render :layout => false
else
render :nothing => true
end
end
end
protected
def get_quiz
@ -645,6 +663,20 @@ class QuizzesController < ApplicationController
@quiz
end
def get_submission
submission = @quiz.quiz_submissions.find_by_user_id(@current_user.id, :order => 'created_at') rescue nil
if !@current_user || (params[:preview] && @quiz.grants_right?(@current_user, session, :update))
user_code = temporary_user_code
submission = @quiz.quiz_submissions.find_by_temporary_user_code(user_code)
end
submission
end
def get_versions
@submission.submitted_attempts
end
# if this returns false, it's rendering or redirecting, so return from the
# action that called it
def check_lockdown_browser(security_level, redirect_return_url)

View File

@ -453,4 +453,16 @@ module QuizzesHelper
end
end
def has_regraded_version?(versions)
versions.detect {|v| v.score_before_regrade.present? }
end
def submission_has_regrade?(submission)
submission && submission.score_before_regrade.present?
end
def score_affected_by_regrade?(submission)
submission && submission.score_before_regrade != submission.score
end
end

View File

@ -119,6 +119,8 @@
delay_for: <%= 60*60 %>
- name: Submission Grade Changed
delay_for: <%= 5*60 %>
- name: Quiz Regrade Finished
delay_for: 0
- category: Grading Policies
notifications:

View File

@ -0,0 +1,12 @@
<% define_content :link do %>
<%= HostUrl.protocol %>://<%= HostUrl.context_host(asset.quiz.context) %>/<%= polymorphic_path([asset.quiz.context,asset.quiz]) %>
<% end %>
<% define_content :subject do %>
<%= t :subject, "Quiz Regrade Finished: %{quiz}, %{context}", :quiz => asset.quiz.title, :context => asset.quiz.context.name %>
<% end %>
<%= t :body, "A regrade has finsihed for the quiz %{quiz}", :quiz => asset.quiz %>
<%= t :link_message, "You can view the quiz here:" %>
<%= content :link %>

View File

@ -0,0 +1,16 @@
<% define_content :link do %>
<%= HostUrl.protocol %>://<%= HostUrl.context_host(asset.quiz.context) %>/<%= polymorphic_path([asset.quiz.context,asset.quiz]) %>
<% end %>
<p>
<% define_content :subject do %>
<%= t :subject, "Quiz Regrade Finished: %{quiz}, %{context}", :quiz => asset.quiz.title, :context => asset.quiz.context.name %>
<% end %>
</p>
<p><%= t :body, "A regrade has finsihed for the quiz %{quiz}", :quiz => asset.quiz %></p>
<p>
<%= t :link_message, "You can view the quiz here:" %>
<a href="<%= content :link %>"><%= t :link_message, "You can view the quiz here:" %></a>
</p>

View File

@ -0,0 +1,5 @@
<% define_content :link do %>
<%= HostUrl.protocol %>://<%= HostUrl.context_host(asset.quiz.context) %>/<%= polymorphic_path([asset.quiz.context,asset.quiz]) %>
<% end %>
<p><%= t :body, "Quiz Regrade Finished: %{title}", :title => asset.quiz.title %></p>

View File

@ -0,0 +1,3 @@
<%= t :body_sms, "Quiz Regrade Finished for %{title} in %{context}.", :title => asset.quiz.title, :context => asset.quiz.context %>
<%= t :more_info, "More info at %{url}", :url => HostUrl.context_host(asset.quiz.context) %>

View File

@ -0,0 +1,10 @@
<% define_content :link do %>
<%= HostUrl.protocol %>://<%= polymorphic_path([asset.quiz.context,asset.quiz]) %>
<% end %>
<% define_content :subject do %>
<%= t :subject, "Quiz Regraded: %{quiz}, %{context}", :quiz => asset.quiz.title, :context => asset.quiz.context.name %>
<% end %>
<%= t :body, "A regrade has finished for your quiz %{title}.", :title => asset.quiz.title, :wrapper => "<b><a href=\"#{content :link}\">\\1</a></b>" %>

View File

@ -0,0 +1,4 @@
<% define_content :link do %>
<%= HostUrl.protocol %>://<%= HostUrl.context_host(asset.quiz.context) %>/<%= polymorphic_path([asset.quiz.context,asset.quiz]) %>
<% end %>
<%= t :body, "Canvas Alert - Quiz Regraded : %{quiz}", :quiz => asset.quiz.title %>

View File

@ -227,6 +227,7 @@ class AssessmentQuestion < ActiveRecord::Base
question = HashWithIndifferentAccess.new
qdata = qdata.with_indifferent_access
previous_data = assessment_question.question_data rescue {}
question[:regrade_option] = qdata[:regrade_option] if qdata[:regrade_option].present?
question[:points_possible] = (qdata[:points_possible] || previous_data[:points_possible] || 0.0).to_f
question[:correct_comments] = check_length(qdata[:correct_comments] || previous_data[:correct_comments] || "", 'correct comments', 5.kilobyte)
question[:incorrect_comments] = check_length(qdata[:incorrect_comments] || previous_data[:incorrect_comments] || "", 'incorrect comments', 5.kilobyte)

View File

@ -17,6 +17,7 @@
#
require 'quiz_question_link_migrator'
require 'quiz_regrading'
class Quiz < ActiveRecord::Base
include Workflow
@ -43,6 +44,7 @@ class Quiz < ActiveRecord::Base
has_many :quiz_groups, :dependent => :destroy, :order => 'position'
has_many :quiz_statistics, :class_name => 'QuizStatistics', :order => 'created_at'
has_many :attachments, :as => :context, :dependent => :destroy
has_many :quiz_regrades
belongs_to :context, :polymorphic => true
belongs_to :assignment
belongs_to :cloned_item
@ -62,6 +64,7 @@ class Quiz < ActiveRecord::Base
before_save :set_defaults
after_save :update_assignment
after_save :touch_context
after_save :regrade_if_published
serialize :quiz_data
@ -579,6 +582,7 @@ class Quiz < ActiveRecord::Base
submission.quiz_data = user_questions
submission.quiz_version = self.version_number
submission.started_at = Time.now
submission.score_before_regrade = nil
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
@ -1127,6 +1131,10 @@ class Quiz < ActiveRecord::Base
scope :active, where("quizzes.workflow_state<>'deleted'")
scope :not_for_assignment, where(:assignment_id => nil)
def teachers
context.teacher_enrollments.map(&:user)
end
def migrate_file_links
QuizQuestionLinkMigrator.migrate_file_links_in_quiz(self)
end
@ -1225,4 +1233,20 @@ class Quiz < ActiveRecord::Base
end
end
def regrade_if_published
unless unpublished_changes?
QuizRegrader.send_later_if_production(:regrade!, self)
end
true
end
def current_regrade
QuizRegrade.where(quiz_id: id, quiz_version: version_number).
includes(:quiz_question_regrades => :quiz_question).first
end
def current_quiz_question_regrades
current_regrade ? current_regrade.quiz_question_regrades : []
end
end

View File

@ -48,6 +48,10 @@ class QuizQuestion < ActiveRecord::Base
end
def question_data=(data)
if data[:regrade_option]
update_question_regrade(data[:regrade_option], data[:regrade_user])
end
if data.is_a?(String)
data = ActiveSupport::JSON.decode(data) rescue nil
elsif data.class == Hash
@ -174,4 +178,16 @@ class QuizQuestion < ActiveRecord::Base
end
end
end
private
def update_question_regrade(regrade_option, regrade_user)
regrade = QuizRegrade.find_or_create_by_quiz_id_and_quiz_version(quiz.id, quiz.version_number) do |qr|
qr.user_id = regrade_user.id
end
question_regrade = QuizQuestionRegrade.find_or_initialize_by_quiz_question_id_and_quiz_regrade_id(id, regrade.id)
question_regrade.regrade_option = regrade_option
question_regrade.save!
end
end

View File

@ -0,0 +1,11 @@
class QuizQuestionRegrade < ActiveRecord::Base
attr_accessible :quiz_question_id, :quiz_regrade_id, :regrade_option
belongs_to :quiz_question
belongs_to :quiz_regrade
validates_presence_of :quiz_question_id
validates_presence_of :quiz_regrade_id
delegate :question_data, to: :quiz_question
end

View File

@ -0,0 +1,13 @@
class QuizRegrade < ActiveRecord::Base
attr_accessible :user_id, :quiz_id, :quiz_version
belongs_to :quiz
belongs_to :user
has_many :quiz_regrade_runs
has_many :quiz_question_regrades
validates_presence_of :quiz_version
validates_presence_of :quiz_id
validates_presence_of :user_id
delegate :teachers, to: :quiz
end

View File

@ -0,0 +1,24 @@
class QuizRegradeRun < ActiveRecord::Base
belongs_to :quiz_regrade
attr_accessible :quiz_regrade_id, :started_at, :finished_at
validates_presence_of :quiz_regrade_id
def self.perform(regrade)
run = create!(quiz_regrade_id: regrade.id, started_at: Time.now)
yield
run.finished_at = Time.now
run.save!
end
has_a_broadcast_policy
set_broadcast_policy do |policy|
policy.dispatch :quiz_regrade_finished
policy.to { teachers }
policy.whenever do |run|
old,new = run.changes['finished_at']
!!(new && old.nil?)
end
end
delegate :teachers, :quiz, to: :quiz_regrade
end

View File

@ -18,7 +18,7 @@
class QuizSubmission < ActiveRecord::Base
include Workflow
attr_accessible :quiz, :user, :temporary_user_code, :submission_data
attr_accessible :quiz, :user, :temporary_user_code, :submission_data, :score_before_regrade
attr_readonly :quiz_id, :user_id
validates_presence_of :quiz_id
@ -341,10 +341,17 @@ class QuizSubmission < ActiveRecord::Base
end
def highest_score_so_far(exclude_version_id=nil)
scores = []
scores << self.score if self.score
scores += versions.reload.reject{|v| v.id == exclude_version_id}.map{|v| v.model.score || 0.0} rescue []
scores.max
scores = {}
scores[attempt] = self.score if self.score
versions = self.versions.reload.reject {|v| v.id == exclude_version_id } rescue []
# only most recent version for each attempt - some have regraded a version
versions.sort {|v| v.number }.reverse.each do |ver|
scores[ver.model.attempt] ||= ver.model.score || 0.0
end
scores.values.max
end
private :highest_score_so_far
@ -445,6 +452,17 @@ class QuizSubmission < ActiveRecord::Base
end
end
def attempt_versions
versions = self.versions.order("number desc").each_with_object({}) do |ver, hash|
hash[ver.model.attempt] ||= ver
end
versions.sort.map {|attempt, version| version }
end
def submitted_attempts
attempt_versions.map {|ver| ver.model }
end
def attempts_left
return -1 if self.quiz.allowed_attempts < 0
[0, self.quiz.allowed_attempts - (self.attempt || 0) + (self.extra_attempts || 0)].max
@ -474,6 +492,7 @@ class QuizSubmission < ActiveRecord::Base
@user_answers.each do |answer|
self.workflow_state = "pending_review" if answer[:correct] == "undefined"
end
self.score_before_regrade = nil
self.finished_at = Time.now
self.manually_unlocked = nil
self.finished_at = opts[:finished_at] if opts[:finished_at]
@ -486,7 +505,12 @@ class QuizSubmission < ActiveRecord::Base
end
self.context_module_action
track_outcomes(self.attempt)
true
quiz = self.quiz
previous_version = quiz.versions.where(number: quiz_version).first
if previous_version && quiz_version != quiz.version_number
quiz = previous_version.model.reload
end
QuizRegrader.regrade!(quiz, [self])
end
# Updates a simply_versioned version instance in-place. We want
@ -678,6 +702,11 @@ class QuizSubmission < ActiveRecord::Base
self.validation_token.blank? || self.validation_token == token
end
# TODO: this could probably be put in as a convenience method in simply_versioned
def save_with_versioning!
self.with_versioning(true) { self.save! }
end
# evizitei: these 3 delegations allow quiz submissions to be used in
# templates designed for regular submissions. Any additional functionality
# put into those templates will need to be provided in both submissions and

View File

@ -447,7 +447,7 @@ div#content
.text
:clear left
:padding 5px 20px
:padding 5px 20px 20px 20px
.button-container
:clear both
@ -1455,3 +1455,71 @@ ul#quiz_versions
#quiz-draft-state
padding: 10px
font-weight: bold
&.published
color: #007711
&.not_published
color: #999999
.regrade-options
margin-top: 12px
color: #333
padding: 2px 10px 13px 10px
h3
margin: 0
text-transform: uppercase
font-size: 100%
font-weight: bold
span
font-weight: normal
text-transform: none
.checkbox
padding-left: 12px
input
height: 16px
margin: 0 9px 0 0
.regrade-options label
display: block
.user-regrade-points
color: orange
.regraded-warning
margin: 0 0 10px 0
#quiz-submission-version-table
margin: 30px 0 0 0
@include clearfix
.desc
width: 22%
float: left
margin-right: 2%
text-align: right
font-size: 110%
color: #555
table
width: 65%
float: left
border-color: #a7acb3
td
padding: 4px 8px
border-color: #a7acb3
thead td.regraded
color: white
background-color: #f89508
text-shadow: 0 -1px 0 #cf9b47
border-top: none
td.regraded
border-top: 1px solid #C4AE90
background-color: $warningBackground

View File

@ -0,0 +1,37 @@
<div class="regrade-options alert border-round">
<h3>
{{#t "regrade_options"}}
Regrade Options <span>(for students who have already answered this question):</span>
{{/t}}
</h3>
<label class="checkbox">
<input type="radio" name="regrade_option" class="regrade_option" value="current_and_previous_correct" {{checkedIf regradeOption "current_and_previous_correct"}}>
{{#t "no_scores_reduced"}}
Award points for both corrected and previously correct answers (<em>no scores will be reduced</em>)
{{/t}}
</label>
<label class="checkbox">
<input type="radio" name="regrade_option" class="regrade_option" value="current_correct_only" {{checkedIf regradeOption "current_correct_only"}} />
{{#t "some_scores_reduced"}}
Only award points for the correct answer (<em>some students' scores may be reduced</em>)
{{/t}}
</label>
<label class="checkbox">
<input type="radio" name="regrade_option" class="regrade_option" value="full_credit" {{checkedIf regradeOption "full_credit"}} >
{{#t "give_everyone_full_credit"}}
Give everyone full credit for this question.
{{/t}}
</label>
<label class="checkbox">
<input type="radio" name="regrade_option" class="regrade_option" value="no_regrade" {{checkedIf regradeOption "no_regrade"}} >
{{#t "update_question_without_regrading"}}
Update question without regrading
{{/t}}
</label>
</div>

View File

@ -36,6 +36,7 @@
<!-- Display skipped -->
<% else %>
<div class="answer answer_for_<%= hash_get(answer, :blank_id) %> <%= "hide_right_arrow" if hide_right_arrow %> <%= 'skipped' if should_skip %> <%= 'wrong_answer' if wrong_answer && show_correct_answers %> <%= 'no_answer' if no_answer %> <%= "selected_answer" if selected_answer %> <%= correct_answer_class %>" id="answer_<%= hash_get(answer, :id, "template") %>" style="<%= hidden unless answer %>" title="<%= t(:selected_answer, "You selected this answer.") if selected_answer %> <%= t(:correct_answer, "This was the correct answer.") if correct_answer && show_correct_answers %>">
<span class='hidden id'><%= answer_id %></span>
<% if !user_answer || question_type.display_answers != "xsingle" %>
<div class="select_answer answer_type" <%= hidden(true) unless answer_type == "select_answer" %>>
<% if %w{radio checkbox}.include?(question_type.entry_type) %>

View File

@ -1,5 +1,5 @@
<%
question = display_question
<%
question = display_question
question_type = answer_type(question)
user_answer ||= nil
user_answer_hash ||= {}
@ -43,16 +43,28 @@
<% if hash_get(user_answer, :correct) == "undefined" %>
<%= t(:not_yet_graded, 'Not yet graded') %>
<% else %>
<%= render_score(hash_get(user_answer, :points)) %>
<% if hash_get(user_answer,:score_before_regrade)%>
<%= t('original_score', 'Original Score:') %>
<%= render_score(hash_get(user_answer, :score_before_regrade)) %>
<% else %>
<%= render_score(hash_get(user_answer, :points)) %>
<% end %>
<% end %>
<% end %>
<% question[:points_possible] = 0 if question_type.answer_type == 'none' %>
<%= t(:points_possible, "%{points_possible} pts", :points_possible => raw("<span class=\"points question_points\"> / #{hash_get(question, :points_possible, "0")}</span>")) %>
<% if hash_get(user_answer, :score_before_regrade) %>
<span class=user-regrade-points>
<%= t('regraded_score', 'Regraded Score:') %>
<%= render_score hash_get(user_answer, :points) %>
<%= t(:points_possible, "%{points_possible} pts", :points_possible => raw("<span class=\"points question_points\"> / #{hash_get(question, :points_possible, "0")}</span>")) %>
</span>
<% end %>
</div>
<% else %>
<%= t(:points_possible, "%{points_possible} pts", :points_possible => raw("<span class=\"points question_points\">#{hash_get(question, :points_possible, "0")}</span>")) %>
<% end %>
</span>
</span>
<% if question && hash_get(question, :question_type) != "missing_word_question" && hash_get(question, :question_text) && hash_get(question, :question_text).length < 255 %>
<span class='ui-helper-hidden-accessible'><%= hash_get(question, :question_text) %></span>
<% else %>
@ -79,7 +91,19 @@
<textarea style="display: none;" name="question_text" class="textarea_question_text"><%= h(hash_get(question, :question_text)) %></textarea>
</div>
<div id="question_<%= hash_get(question, :id, "new") %>_question_text" class="question_text user_content">
<% if user_answer && user_answer[:regrade_option] %>
<p class="ui-widget">
<div class="ui-state-warning ui-corner-all pad-box-micro text-center">
<i class=icon-warning></i>
<strong>
<%= t('submission_was_regraded',
'This question has been regraded. Your score has been updated.') %>
</strong>
</div>
</p>
<% end %>
<% if question && hash_get(question, :question_type) == "missing_word_question" %>
<span class="text_before_answers"><%= user_content(hash_get(question, :question_text)) %></span>
<select class="answer_select question_input" name="question_<%= hash_get(question, :id, "blank") %>">
<option value=""><%= t(:default_question_answer, "[ Choose ]") %></option>

View File

@ -0,0 +1,37 @@
<tr class="<%= 'kept' if kept %>">
<td>
<% if kept %>
<%= t 'kept', 'KEPT' %>
<% elsif version.version_number == @submission.version_number %>
<%= t 'latest', 'LATEST' %>
<% end %>
</td>
<td>
<a href="<%= polymorphic_path([@context,@quiz,:history], version: version.version_number)%>">
<%= t 'attempt_number', 'Attempt %{att_no}', :att_no => index %>
</a>
</td>
<td>
<%= duration_in_minutes((version.finished_at || version.end_at || version.started_at) - version.started_at) %>
</td>
<% if submission_has_regrade?(@submission) && version.score_before_regrade.present? %>
<td>
<%= score_out_of_points_possible(version.score_before_regrade, params[:preview] ? version.points_possible_at_submission_time : @quiz.points_possible) %>
</td>
<td class="regraded">
<%= score_out_of_points_possible(version.score, params[:preview] ? version.points_possible_at_submission_time : @quiz.points_possible) %>
</td>
<% elsif submission_has_regrade?(@submission) %>
<td>
<%= score_out_of_points_possible(version.score, params[:preview] ? version.points_possible_at_submission_time : @quiz.points_possible) %>
</td>
<td class="regraded">-</td>
<% else %>
<td>
<%= score_out_of_points_possible(version.score, params[:preview] ? version.points_possible_at_submission_time : @quiz.points_possible) %>
</td>
<% end %>
</tr>

View File

@ -188,6 +188,18 @@
<% end %>
<header class="quiz-header">
<% if submission_has_regrade?(@submission) %>
<div class="ui-state-warning ui-corner-all pad-box-micro text-center regraded-warning">
<i class=icon-warning></i>
<strong>
<% if score_affected_by_regrade?(@submission) %>
<%= t 'quiz_regraded_your_score_affected', 'This quiz has been re-graded, your score was affected.' %>
<% else %>
<%= t 'quiz_regraded_your_score_not_affected', 'This quiz has been re-graded, your score was not affected.' %>
<% end %>
</strong>
</div>
<% end %>
<% if @domain_root_account.enable_draft? %>
<% if needs_unpublished_warning?(@quiz) %>
@ -391,6 +403,9 @@
<% end %>
<% end %>
<% end %>
<div id="quiz-submission-version-table"></div>
</header>
<% if @submission && @submission.completed? %>

View File

@ -0,0 +1,29 @@
<div class="desc">
<%= t 'attempt_history', 'Attempt History' %>
</div>
<table class="table table-bordered">
<thead>
<tr>
<td>&nbsp;</td>
<td><%= t 'attempt', 'Attempt' %></td>
<td><%= t 'time', 'Time' %></td>
<td><%= t 'score', 'Score' %></td>
<% if submission_has_regrade?(@submission) %>
<td class="regraded"><%= t 'regraded', 'Re-Graded' %></td>
<% end %>
</tr>
</thead>
<tbody>
<% if @versions.size > 1 %>
<% version, index = @versions.reverse.each_with_index.detect {|k, v| k.score == @submission.kept_score } %>
<% if version %>
<%= render :partial => "submission_version", :locals => {:version => version, :index => @versions.length - index, :kept => true} %>
<% end %>
<% end %>
<% (@versions.reverse!).each_with_index do |version, index| %>
<%= render :partial => "submission_version", :locals => {:version => version, :index => @versions.length - index, :kept => false} %>
<% end %>
</tbody>
</table>

View File

@ -311,6 +311,7 @@ FakeRails3Routes.draw do
match 'quizzes/unpublish' => 'quizzes#unpublish', :as => :quizzes_unpublish
resources :quizzes do
match 'managed_quiz_data' => 'quizzes#managed_quiz_data', :as => :managed_quiz_data
match 'submission_versions' => 'quizzes#submission_versions', :as => :submission_versions
match 'reorder' => 'quizzes#reorder', :as => :reorder
match 'history' => 'quizzes#history', :as => :history
match 'statistics' => 'quizzes#statistics', :as => :statistics

View File

@ -0,0 +1,11 @@
class AddScoreBeforeRegradeToQuizSubmission < ActiveRecord::Migration
tag :predeploy
def self.up
add_column :quiz_submissions, :score_before_regrade, :float
end
def self.down
remove_column :quiz_submissions, :score_before_regrade
end
end

View File

@ -0,0 +1,23 @@
class CreateQuizRegrades < ActiveRecord::Migration
tag :predeploy
def self.up
create_table :quiz_regrades do |t|
t.integer :user_id, limit: 8, null: false
t.integer :quiz_id, limit: 8, null: false
t.integer :quiz_version, null: false
t.foreign_key :users
t.foreign_key :quizzes
t.foreign_key :versions
t.timestamps
end
add_index :quiz_regrades, [:quiz_id, :quiz_version], unique: true
end
def self.down
drop_table :quiz_regrades
end
end

View File

@ -0,0 +1,21 @@
class CreateQuizQuestionRegrades < ActiveRecord::Migration
tag :predeploy
def self.up
create_table :quiz_question_regrades do |t|
t.integer :quiz_regrade_id, limit: 8, null: false
t.integer :quiz_question_id, limit: 8, null: false
t.string :regrade_option, null: false
t.foreign_key :quiz_regrades
t.foreign_key :quiz_questions
t.timestamps
end
add_index :quiz_question_regrades, [:quiz_regrade_id, :quiz_question_id], unique: true, name: 'index_qqr_on_qr_id_and_qq_id'
end
def self.down
drop_table :quiz_question_regrades
end
end

View File

@ -0,0 +1,17 @@
class CreateQuizRegradeRuns < ActiveRecord::Migration
tag :predeploy
def self.up
create_table :quiz_regrade_runs do |t|
t.integer :quiz_regrade_id, limit: 8, null: false
t.timestamp :started_at
t.timestamp :finished_at
t.timestamps
t.foreign_key :quiz_regrades
end
end
def self.down
drop_table :quiz_regrade_runs
end
end

View File

@ -0,0 +1,16 @@
class LoadQuizRegradeFinishedNotification < ActiveRecord::Migration
tag :predeploy
def self.up
return unless Shard.current == Shard.default
Canvas::MessageHelper.create_notification({
name: 'Quiz Regrade Finished',
delay_for: 0,
category: 'Grading'
})
end
def self.down
return unless Shard.current == Shard.default
Notification.find_by_name('Quiz Regrade Finished').destroy
end
end

4
lib/quiz_regrading.rb Normal file
View File

@ -0,0 +1,4 @@
require 'quiz_regrading/quiz_regrader'
require 'quiz_regrading/answer'
require 'quiz_regrading/submission'
require 'quiz_regrading/attempt_version'

View File

@ -0,0 +1,120 @@
class QuizRegrader::Answer
REGRADE_OPTIONS = [
'full_credit',
'current_and_previous_correct',
'current_correct_only',
'no_regrade'
].freeze
attr_accessor :answer, :question, :regrade_option
def initialize(answer, question_regrade)
@answer = answer
@question = question_regrade.quiz_question
@regrade_option = question_regrade.regrade_option
unless REGRADE_OPTIONS.include?(regrade_option)
raise ArgumentError.new("Regrade option not valid!")
end
end
def regrade!
return 0 if regrade_option == 'no_regrade'
previous_score = points
score = send("mark_#{regrade_option}!")
answer[:regrade_option] = regrade_option
answer[:score_before_regrade] = previous_score unless points == previous_score
answer[:question_id] = question.id
score
end
private
def mark_full_credit!
return 0 if correct?
answer[:correct] = true
points_possible - points
end
def mark_current_and_previous_correct!
return 0 if correct?
previously_partial = partial?
previous_points = points
regrade_and_merge_answer!
# previously partial correct
if previously_partial
points_possible - previous_points
# now correct
elsif correct?
points
else
0
end
end
def mark_current_correct_only!
previously_partial = partial?
previously_correct = correct?
previous_points = points
regrade_and_merge_answer!
# now fully correct
if !previously_correct && correct?
points_possible - previous_points
# now partial correct
elsif previously_correct && partial?
-(points_possible - points)
# no longer correct
elsif previously_correct && !correct?
-previous_points
else
0
end
end
def correct?
answer[:correct] == true
end
def partial?
answer[:correct] == "partial"
end
def points
answer[:points] || 0
end
def points_possible
question_data[:points_possible] || 0
end
def question_data
question.question_data
end
def regrade_and_merge_answer!
question_id = question.id
fake_submission_data = if question_data[:question_type] == 'multiple_answers_question'
hash = {}
answer.each { |k,v| hash["question_#{question_id}_#{k}"] = v if /answer/ =~ k.to_s }
answer.merge(hash)
else
answer.merge("question_#{question_id}" => answer[:text])
end
question_data.merge!(id: question_id, question_id: question_id)
newly_scored_data = QuizSubmission.score_question(question_data, fake_submission_data)
# clear the answer data and modify it in-place with the newly scored data
answer.clear
answer.merge!(newly_scored_data)
end
end

View File

@ -0,0 +1,17 @@
class QuizRegrader::AttemptVersion
attr_reader :version, :question_regrades
def initialize(hash)
@version = hash.fetch(:version)
@question_regrades = hash.fetch(:question_regrades)
end
def regrade!
version.model = QuizRegrader::Submission.new(
:submission => version.model,
:question_regrades => question_regrades).rescored_submission
version.save!
end
end

View File

@ -0,0 +1,42 @@
class QuizRegrader
attr_reader :quiz
def initialize(quiz, submissions=nil)
@quiz = quiz
@submissions = submissions
end
def regrade!
regrade = quiz.current_regrade
return true unless regrade && question_regrades.size > 0
QuizRegradeRun.perform(regrade) do
submissions.each do |submission|
QuizRegrader::Submission.new(
:submission => submission,
:question_regrades => question_regrades).regrade!
end
end
end
def self.regrade!(quiz, submissions=nil)
QuizRegrader.new(quiz, submissions).regrade!
end
def submissions
# Using a class level scope here because if a restored "model" from a quiz
# version is passed (e.g. during the grade_submission method on quiz
# submissions), the association will always be empty.
@submissions ||= QuizSubmission.where(quiz_id: quiz.id).select(&:completed?)
end
private
# quiz question regrades keyed by question id
def question_regrades
@questions ||= @quiz.current_quiz_question_regrades.each_with_object({}) do |qr, hash|
hash[qr.quiz_question_id] = qr
end
end
end

View File

@ -0,0 +1,61 @@
class QuizRegrader::Submission
attr_reader :submission, :question_regrades
def initialize(hash)
@submission = hash.fetch(:submission)
@question_regrades = hash.fetch(:question_regrades)
end
def regrade!
return unless answers_to_grade.size > 0
# regrade all previous versions
submission.attempt_versions.each do |version|
QuizRegrader::AttemptVersion.new(
:version => version,
:question_regrades => question_regrades).regrade!
end
# save this version
rescored_submission.save_with_versioning!
end
def rescored_submission
previous_score = submission.score_before_regrade || submission.score
submission.score += answers_to_grade.map(&:regrade!).inject(&:+)
submission.score_before_regrade = previous_score
submission.quiz_data = regraded_question_data
submission
end
private
def answers_to_grade
@answers_to_grade ||= submitted_answers.map do |answer|
QuizRegrader::Answer.new(answer, question_regrades[answer[:question_id]])
end
end
def submitted_answers
@submitted_answers ||= submission.submission_data.select do |answer|
question_regrades[answer[:question_id]].present?
end
end
def submitted_answer_ids
@submitted_answer_ids ||= submitted_answers.map {|q| q[:question_id] }.to_set
end
def regraded_question_data
submission.quiz_data.map do |question|
id = question[:id]
if submitted_answer_ids.include?(id)
question.keep_if {|k, v| %w{id position published_at}.include?(k) }
question.merge(question_regrades[id].question_data)
else
question
end
end
end
end

View File

@ -16,6 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
define([
'jst/quiz/regrade',
'i18n!quizzes',
'underscore',
'jquery' /* $ */,
@ -48,8 +49,9 @@ define([
'vendor/jquery.scrollTo' /* /\.scrollTo/ */,
'jqueryui/sortable' /* /\.sortable/ */,
'jqueryui/tabs' /* /\.tabs/ */
], function(I18n,_,$,calcCmd, htmlEscape, pluralize, wikiSidebar,
DueDateListView, DueDateOverrideView, Quiz, DueDateList,SectionList,
], function(regradeTemplate, I18n,_,$,calcCmd, htmlEscape, pluralize,
wikiSidebar, DueDateListView, DueDateOverrideView, Quiz,
DueDateList,SectionList,
MissingDateDialog,MultipleChoiceToggle,TextHelper){
var dueDateList, overrideView, quizModel, sectionList;
@ -888,6 +890,9 @@ define([
return $answer;
}
var REGRADE_DATA = {};
var REGRADE_OPTIONS = ENV.REGRADE_OPTIONS || {};
function quizData($question) {
var $quiz = $("#questions");
var quiz = {
@ -899,7 +904,7 @@ define([
$list.each(function(i) {
var $question = $(this);
var questionData = $question.getTemplateData({
textValues: ['question_name', 'question_points', 'question_type', 'answer_selection_type', 'assessment_question_id', 'correct_comments', 'incorrect_comments', 'neutral_comments', 'matching_answer_incorrect_matches', 'equation_combinations', 'equation_formulas'],
textValues: ['question_name', 'question_points', 'question_type', 'answer_selection_type', 'assessment_question_id', 'correct_comments', 'incorrect_comments', 'neutral_comments', 'matching_answer_incorrect_matches', 'equation_combinations', 'equation_formulas', 'regrade_option'],
htmlValues: ['question_text', 'text_before_answers', 'text_after_answers', 'correct_comments_html', 'incorrect_comments_html', 'neutral_comments_html']
});
questionData = $.extend(questionData, $question.find(".original_question_text").getFormData());
@ -1001,6 +1006,7 @@ define([
data[id + '[incorrect_comments]'] = question.incorrect_comments;
data[id + '[neutral_comments]'] = question.neutral_comments;
data[id + '[question_text]'] = question.question_text;
data[id + '[regrade_option]'] = question.regrade_option;
data[id + '[position]'] = question.position;
data[id + '[text_after_answers]'] = question.text_after_answers;
data[id + '[matching_answer_incorrect_matches]'] = question.matching_answer_incorrect_matches;
@ -1438,6 +1444,7 @@ define([
$(document).delegate(".edit_question_link", 'click', function(event) {
event.preventDefault();
var $question = $(this).parents(".question");
var questionID = $(this).closest('.question_holder').find('.display_question').attr('id');
var question = $question.getTemplateData({
textValues: ['question_type', 'correct_comments', 'incorrect_comments', 'neutral_comments', 'question_name', 'question_points', 'answer_selection_type', 'blank_id'],
htmlValues: ['question_text', 'correct_comments_html', 'incorrect_comments_html', 'neutral_comments_html']
@ -1550,6 +1557,15 @@ define([
$formQuestion.find(".question_content").triggerHandler('change');
$formQuestion.addClass('ready');
}, 100);
// show regrade options if question was changed but quiz not saved
var $question = $form.find(".question");
var questionID = $form.prev('.display_question').attr('id');
var idValue = questionID.replace("question_", "");
if (REGRADE_OPTIONS[idValue]) {
showRegradeOptions($question,questionID);
}
});
$(".question_form :input[name='question_type']").change(function() {
@ -1597,7 +1613,11 @@ define([
$(document).delegate(".select_answer_link", 'click', function(event) {
event.preventDefault();
var $question = $(this).parents(".question");
var questionID = $(this).closest('.question_holder').find('.display_question').attr('id');
if (!$question.hasClass('selectable')) { return; }
if (!REGRADE_DATA[questionID]){
REGRADE_DATA[questionID] = correctAnswerIDs($question)
}
if ($question.find(":input[name='question_type']").val() != "multiple_answers_question") {
$question.find(".answer:visible").removeClass('correct_answer')
.find('.select_answer_link').attr('title', clickSetCorrect)
@ -1618,8 +1638,77 @@ define([
.find('img').attr('alt', clickSetCorrect);
}
}
showRegradeOptions($question,questionID);
});
function showRegradeOptions($el,questionID) {
if ($("#student_submissions_warning").length == 0) {
return;
}
var regradeOptions = $el.find('.regrade-options')
if (regradeOptions.length && answersAreTheSameAsBefore($el)) {
regradeOptions.remove();
enableQuestionForm();
return;
}
if (!regradeOptions.length){
questionID = /question_(\d+)/.exec(questionID.toString());
var regradeOption = REGRADE_OPTIONS[questionID[1]];
$el.find('.button-container').before(regradeTemplate({regradeOption: regradeOption}));
clickRegradeOptions();
}
}
$(document).delegate(".regrade-options", 'click', clickRegradeOptions);
function clickRegradeOptions(event) {
if ($('input[name="regrade_option"]:checked').length === 0) {
disableQuestionForm();
} else {
enableQuestionForm();
}
}
function disableQuestionForm() {
$('.question_form').find(".submit_button")
.attr('disabled', true)
.addClass('disabled')
.removeClass('button_primary btn-primary');
}
function enableQuestionForm() {
$('.question_form').find(".submit_button")
.removeClass('disabled')
.removeAttr('disabled')
.addClass('button_primary btn-primary');
}
function correctAnswerIDs($el){
var answers = [];
$el.find('.answer').each(function(index) {
if ($(this).hasClass('correct_answer')) answers.push(index);
});
return answers;
}
function answersAreTheSameAsBefore($el) {
var questionID = $el.closest('.question_holder').find('.display_question').attr('id');
var idValue = questionID.replace("question_", "");
// we don't know 'old answers' if they've updated and returned
if (REGRADE_OPTIONS[idValue]) {
return false;
} else {
var oldAnswers = REGRADE_DATA[questionID];
var newAnswers = correctAnswerIDs($el);
return !_.difference(oldAnswers, newAnswers).length;
}
}
$(".question_form :input").change(function() {
if ($(this).parents(".answer").length > 0) {
var $answer = $(this).parents(".answer");
@ -2115,8 +2204,9 @@ define([
var $question = $(this).find(".question");
var answers = [];
var questionData = $question.getFormData({
values: ['question_type', 'question_name', 'question_points', 'correct_comments', 'incorrect_comments', 'neutral_comments',
'question_text', 'answer_selection_type', 'text_after_answers', 'matching_answer_incorrect_matches']
textValues: ['question_type', 'question_name', 'question_points', 'correct_comments', 'incorrect_comments', 'neutral_comments',
'question_text', 'answer_selection_type', 'text_after_answers', 'matching_answer_incorrect_matches',
'regrade_option']
});
// save any open html answers
@ -2162,6 +2252,7 @@ define([
var $answer = $(this);
$answer.show();
var data = $answer.getFormData();
data.id = $answer.find('.id').text();
data.blank_id = $answer.find(".blank_id").text();
data.answer_text = $answer.find("input[name='answer_text']:visible").val();
data.answer_html = $answer.find(".answer_html").html();
@ -2234,9 +2325,11 @@ define([
url = $displayQuestion.find(".update_question_url").attr('href');
method = 'PUT';
}
var oldQuestionData = questionData;
var questionData = quizData($displayQuestion);
var formData = generateFormQuiz(questionData);
var questionData = generateFormQuizQuestion(formData);
questionData['question[regrade_option]'] = oldQuestionData.regrade_option;
if ($displayQuestion.parent(".question_holder").hasClass('group')) {
var $group = quiz.findContainerGroup($displayQuestion.parent(".question_holder"));
if ($group) {
@ -2267,6 +2360,10 @@ define([
// after save process completed. Used in quizzes_bundle.coffee
$displayQuestion.trigger('saved');
$("#unpublished_changes_message").slideDown();
if (question) {
REGRADE_OPTIONS[question.id] = question.question_data.regrade_option;
delete REGRADE_DATA['question_' + question.id];
}
}, function(data) {
$displayQuestion.formErrors(data);
});

View File

@ -172,9 +172,13 @@ describe QuizzesController do
it "should assign variables" do
course_with_teacher_logged_in(:active_all => true)
course_quiz
regrade = @quiz.quiz_regrades.create!(:user_id => @teacher.id, quiz_version: @quiz.version_number)
q = @quiz.quiz_questions.create!
regrade.quiz_question_regrades.create!(:quiz_question_id => q.id,:regrade_option => 'no_regrade')
get 'edit', :course_id => @course.id, :id => @quiz.id
assigns[:quiz].should_not be_nil
assigns[:quiz].should eql(@quiz)
assigns[:js_env][:REGRADE_OPTIONS].should == {q.id => 'no_regrade' }
response.should render_template("new")
end
end
@ -267,6 +271,19 @@ describe QuizzesController do
:display_name => attachment.display_name }
}
end
it "assigns js_env for versions if submission is present" do
require 'action_controller'
require 'action_controller/test_process.rb'
course_with_student_logged_in :active_all => true
course_quiz !!:active
submission = @quiz.generate_submission @user
create_attachment_for_file_upload_submission!(submission)
get 'show', :course_id => @course.id, :id => @quiz.id
path = "courses/#{@course.id}/quizzes/#{@quiz.id}/submission_versions"
assigns[:js_env][:SUBMISSION_VERSIONS_URL].should include(path)
end
end
describe "GET 'managed_quiz_data'" do
@ -1166,5 +1183,28 @@ describe QuizzesController do
@quiz.reload.published?.should be_false
end
end
describe "GET submission_versions" do
it "requires authorization" do
course_with_teacher(:active_all => true)
course_quiz
get 'submission_versions', :course_id => @course.id, :quiz_id => @quiz.id
assert_unauthorized
assigns[:quiz].should_not be_nil
assigns[:quiz].should eql(@quiz)
end
it "assigns variables" do
course_with_teacher_logged_in(:active_all => true)
course_quiz
submission = @quiz.generate_submission @user
create_attachment_for_file_upload_submission!(submission)
get 'submission_versions', :course_id => @course.id, :quiz_id => @quiz.id
assigns[:quiz].should_not be_nil
assigns[:quiz].should eql(@quiz)
assigns[:submission].should_not be_nil
assigns[:versions].should_not be_nil
end
end
end

View File

@ -0,0 +1,104 @@
require 'spec_helper'
require 'lib/quiz_regrading'
describe "QuizRegrading" do
def create_quiz_question!(data)
question = @quiz.quiz_questions.create!
data.merge!(:id => question.id)
question.write_attribute(:question_data,data)
question.save!
question
end
def reset_submission_data!
@submission.submission_data = {
"question_#{@true_false_question.id}"=> "2",
"question_#{@multiple_choice_question.id}" => "4",
"question_#{@multiple_answers_question.id}_answer_5" => "1",
"question_#{@multiple_answers_question.id}_answer_6" => "0",
"question_#{@multiple_answers_question.id}_answer_7" => "0"
}.with_indifferent_access
@submission.grade_submission
@submission.save!
end
def set_regrade_option!(regrade_option)
[@ttf_qqr,@maq_qqr,@mcq_qqr].each do |qqr|
qqr.regrade_option = regrade_option
qqr.save!
end
reset_submission_data!
@quiz.reload
end
before do
course_with_student_logged_in(active_all: true)
quiz_model(course: @course)
@regrade = @quiz.quiz_regrades.find_or_create_by_quiz_id_and_quiz_version(@quiz.id,@quiz.version_number) { |qr| qr.user_id = @student.id }
@regrade.should_not be_new_record
@true_false_question = create_quiz_question!({
:points_possible => 1,
:question_type => 'true_false_question',
:question_name => 'True/False Question',
:answers => [
{:text => 'true', :id => 1, :weight => 100},
{:text => 'false', :id => 2, :weight => 0}
]
})
@multiple_choice_question = create_quiz_question!({
:points_possible => 1,
:question_type => 'multiple_choice_question',
:question_name => 'Multiple Choice Question',
:answers => [
{:text => "correct", :id => 3, :weight => 100 },
{:text => "nope", :id => 4, :weight => 0}
]
})
@multiple_answers_question = create_quiz_question!({
:points_possible => 1,
:question_type => 'multiple_answers_question',
:question_name => 'Multiple Answers Question',
:answers => [
{:text => "correct1", :id => 5, :weight => 100},
{:text=> "correct2", :id => 6, :weight => 100},
{:text => "nope", :id=> 7, :weight => 0 }
]
})
@maq_qqr = @regrade.quiz_question_regrades.create!(quiz_question_id: @multiple_answers_question.id, regrade_option: 'no_regrade')
@mcq_qqr = @regrade.quiz_question_regrades.create!(quiz_question_id: @multiple_choice_question.id, regrade_option: 'no_regrade')
@ttf_qqr = @regrade.quiz_question_regrades.create!(quiz_question_id: @true_false_question.id, regrade_option: 'no_regrade')
@quiz.generate_quiz_data
@quiz.workflow_state = 'available'; @quiz.without_versioning { @quiz.save! }
@submission = @quiz.generate_submission(@student)
reset_submission_data!
@submission.save!
@submission.score.should == 0.5
end
it 'succesfully regrades the submissions and updates the scores' do
set_regrade_option!('full_credit')
QuizRegrader.regrade!(@quiz)
@submission.reload.score.should == 3
set_regrade_option!('current_correct_only')
data = @true_false_question.question_data
data[:answers].first[:weight] = 0
data[:answers].second[:weight] = 100
@true_false_question.write_attribute(:question_data, data)
@true_false_question.save!
data = @multiple_choice_question.question_data
data[:answers].first[:weight] = 0
data[:answers].second[:weight] = 100
@multiple_choice_question.write_attribute(:data, data)
@multiple_choice_question.save!
data = @multiple_answers_question.reload.question_data
data[:answers].second[:weight] = 0
@multiple_answers_question.write_attribute(:data, data)
@multiple_answers_question.save!
@quiz.reload
QuizRegrader.regrade!(@quiz)
@submission.reload.score.should == 3
end
end

View File

@ -0,0 +1,179 @@
require 'active_support'
require_relative '../../mocha_rspec_adapter'
require_relative '../../../lib/quiz_regrading'
class QuizSubmission; end
describe QuizRegrader::Answer do
let(:points) { 15 }
let(:question) do
stub(:id => 1, :question_data => {:id => 1,
:regrade_option => 'full_credit',
:points_possible => points})
end
let(:question_regrade) do
stub(:quiz_question => question,
:regrade_option => "full_credit")
end
let(:answer) do
{ :question_id => 1, :points => points, :text => ""}
end
let(:wrapper) do
QuizRegrader::Answer.new(answer, question_regrade)
end
def mark_original_answer_as!(correct)
answer[:correct] = case correct
when :correct then true
when :wrong then false
when :partial then "partial"
end
end
def assert_answer_has_regrade_option!(regrade_option)
answer[:regrade_option].should == regrade_option
end
def score_question_as!(correct)
correct = case correct
when :correct then true
when :wrong then false
when :partial then "partial"
end
sent_params = {}
QuizSubmission.expects(:score_question).with do |*args|
sent_params, sent_answer_data = args
if question.question_data[:question_type] == 'multiple_answers_question'
answer.each do |k,v|
next unless /answer/ =~ k
key = "question_#{question.id}_#{k}"
sent_answer_data[key].should == v
end
else
sent_answer_data.should == answer.merge("question_#{question.id}" => answer[:text])
end
end.returns(sent_params.merge(:points => answer[:points], :correct => correct)).at_least_once
end
describe "#initialize" do
it 'saves a reference to the passed answer hash' do
wrapper.answer.should == answer
end
it 'saves a reference to the passed question hash' do
wrapper.question.should == question
end
it 'raises an error if the question has an unrecognized regrade_option' do
question_regrade = stub(:quiz_question => question,
:regrade_option => "be_a_jerk")
expect { QuizRegrader::Answer.new(answer, question_regrade) }.to raise_error
end
it 'does not raise an error if question has recognized regrade_option' do
question_regrade = stub(:quiz_question => question,
:regrade_option => "current_correct_only")
QuizRegrader::Answer::REGRADE_OPTIONS.each do |regrade_option|
expect { QuizRegrader::Answer.new(answer, question_regrade) }.to_not raise_error
end
end
end
describe '#regrade!' do
context 'full_credit regrade option' do
it 'returns the points possible for the question if the answer '+
'was not correct before' do
mark_original_answer_as!(:wrong)
answer[:points] = 0
wrapper.regrade!.should == points
assert_answer_has_regrade_option!('full_credit')
end
it 'returns 0 if answer was previously correct' do
mark_original_answer_as!(:correct)
wrapper.regrade!.should == 0
assert_answer_has_regrade_option!('full_credit')
end
end
context 'current_and_previous_correct regrade option' do
before { wrapper.regrade_option = 'current_and_previous_correct' }
it 'returns 0 if previously correct' do
mark_original_answer_as!(:correct)
wrapper.regrade!.should == 0
assert_answer_has_regrade_option!('current_and_previous_correct')
end
it 'returns points possible if previously wrong but now correct' do
mark_original_answer_as!(:wrong)
score_question_as!(:correct)
wrapper.regrade!.should == points
assert_answer_has_regrade_option!('current_and_previous_correct')
end
it 'returns points possible - previous score if previously partial correct' do
previous_score = answer[:points]
mark_original_answer_as!(:partial)
score_question_as!(:correct)
wrapper.regrade!.should == points - previous_score
assert_answer_has_regrade_option!('current_and_previous_correct')
end
it 'returns 0 if previously wrong and wrong now' do
mark_original_answer_as!(:wrong)
score_question_as!(:wrong)
wrapper.regrade!.should == 0
assert_answer_has_regrade_option!('current_and_previous_correct')
end
end
context 'current_correct_only regrade option' do
before { wrapper.regrade_option = 'current_correct_only' }
it 'returns points_possible - points if previously wrong but now correct' do
mark_original_answer_as!(:wrong)
score_question_as!(:correct)
wrapper.regrade!.should == 0
assert_answer_has_regrade_option!('current_correct_only')
end
it 'returns 0 if previously correct and correct after regrading' do
mark_original_answer_as!(:correct)
score_question_as!(:correct)
wrapper.regrade!.should == 0
assert_answer_has_regrade_option!('current_correct_only')
end
it 'returns -points if prev correct but wrong after regrading' do
mark_original_answer_as!(:correct)
score_question_as!(:wrong)
wrapper.regrade!.should == -points
assert_answer_has_regrade_option!('current_correct_only')
end
it 'works with multiple_answer_questions' do
question.question_data.merge!(:question_type => 'multiple_answers_question')
answer.merge!(:answer_1 => "0", :answer_2 => "1")
mark_original_answer_as!(:correct)
score_question_as!(:correct)
wrapper.regrade!.should == 0
assert_answer_has_regrade_option!('current_correct_only')
end
end
end
end

View File

@ -0,0 +1,77 @@
require 'active_support'
require_relative '../../mocha_rspec_adapter'
require_relative '../../../lib/quiz_regrading'
describe QuizRegrader::AttemptVersion do
let(:regrade_options) do
{1 => 'no_regrade', 2 => 'full_credit', 3 => 'current_correct_only' }
end
let(:question_regrades) do
1.upto(3).each_with_object({}) do |i, hash|
hash[i] = stub(:quiz_question => stub(:id => i, :question_data => {:id => i}),
:question_data => {:id => i},
:regrade_option => regrade_options[i])
end
end
let(:quiz_data) do
question_regrades.map {|id, q| q.quiz_question.question_data.dup }
end
let(:submission_data) do
1.upto(3).map {|i| {:question_id => i} }
end
let(:submission) do
stub(:score => 0,
:quiz_data => quiz_data,
:submission_data => submission_data,
:write_attribute => {})
end
let(:version) do
stub(:model => submission)
end
let(:attempt_version) do
QuizRegrader::AttemptVersion.new(:version => version,
:question_regrades => question_regrades)
end
describe "#initialize" do
it "saves a reference to the passed version" do
attempt_version.version.should == version
end
it "saves a reference to the passed regrade quiz questions" do
attempt_version.question_regrades.should == question_regrades
end
end
describe "#regrade!" do
it "assigns the model and saves the version" do
submission_data.each do |answer|
answer_stub = stub
answer_stub.expects(:regrade!).once.returns(1)
QuizRegrader::Answer.expects(:new).returns answer_stub
end
# submission data isn't called if not included in question_regrades
submission_data << {:question_id => 4}
QuizRegrader::Answer.expects(:new).with(submission_data.last, nil).never
submission.expects(:score=).with(3)
submission.expects(:score_before_regrade).returns nil
submission.expects(:score_before_regrade=).with(0)
submission.expects(:quiz_data=).with(question_regrades.map { |id, q| q.question_data })
version.expects(:model=)
version.expects(:save!)
attempt_version.regrade!
end
end
end

View File

@ -0,0 +1,73 @@
require 'spec_helper'
require_relative '../../../lib/quiz_regrading'
describe QuizRegrader do
before { Timecop.freeze(Time.local(2013)) }
after { Timecop.return }
let(:questions) do
1.upto(4).map do |i|
stub(:id => i, :question_data => { :id => i, :regrade_option => 'full_credit'})
end
end
let(:submissions) do
1.upto(4).map {|i| stub(:id => i, :completed? => true) }
end
let(:current_quiz_question_regrades) do
1.upto(4).map { |i| stub(:quiz_question_id => i, :regrade_option => 'full_credit') }
end
let(:quiz) { stub(:quiz_questions => questions,
:id => 1,
:version_number => 1,
:current_quiz_question_regrades => current_quiz_question_regrades,
:quiz_submissions => submissions) }
let(:quiz_regrade) { stub(:id => 1, :quiz => quiz) }
before do
quiz.stubs(:current_regrade).returns quiz_regrade
QuizQuestion.stubs(:where).with(quiz_id: quiz.id).returns questions
QuizSubmission.stubs(:where).with(quiz_id: quiz.id).returns submissions
end
let(:quiz_regrader) { QuizRegrader.new(quiz) }
describe '#initialize' do
it 'saves the quiz passed' do
quiz_regrader.quiz.should == quiz
end
it 'takes an optional submissions argument' do
submissions = []
QuizRegrader.new(quiz,submissions).submissions.should == submissions
end
end
describe "#submissions" do
it 'should skip submissions that are in progress' do
questions << stub(:id => 5, :question_data => {:regrade_option => 'no_regrade'})
uncompleted_submission = stub(:id => 5, :completed? => false)
submissions << uncompleted_submission
quiz_regrader.submissions.length.should == 4
quiz_regrader.submissions.detect {|s| s.id == 5 }.should be_nil
end
end
describe '#regrade!' do
it 'creates a QuizRegrader::Submission for each submission and regrades them' do
questions << stub(:id => 5, :question_data => {:regrade_option => 'no_regrade'})
questions << stub(:id => 6, :question_data => {} )
QuizRegradeRun.expects(:perform).with(quiz_regrade)
QuizRegrader::Submission.any_instance.stubs(:regrade!)
quiz_regrader.regrade!
end
end
end

View File

@ -0,0 +1,74 @@
require 'active_support'
require_relative '../../mocha_rspec_adapter'
require_relative '../../../lib/quiz_regrading'
describe QuizRegrader::Submission do
let(:regrade_options) do
{1 => 'no_regrade', 2 => 'full_credit', 3 => 'current_correct_only' }
end
let(:question_regrades) do
1.upto(3).each_with_object({}) do |i, hash|
hash[i] = stub(:quiz_question => stub(:id => i, :question_data => {:id => i}),
:question_data => {:id => i},
:regrade_option => regrade_options[i])
end
end
let(:quiz_data) do
question_regrades.map {|id, q| q.quiz_question.question_data.dup }
end
let(:submission_data) do
1.upto(3).map {|i| {:question_id => i} }
end
let(:submission) do
stub(:score => 0,
:quiz_data => quiz_data,
:submission_data => submission_data,
:write_attribute => {})
end
let(:wrapper) do
QuizRegrader::Submission.new(:submission => submission,
:question_regrades => question_regrades)
end
describe "#initialize" do
it "saves a reference to the passed submission" do
wrapper.submission.should == submission
end
it "saves a reference to the passed regrade quiz questions" do
wrapper.question_regrades.should == question_regrades
end
end
describe "#regrade!" do
it "wraps each answer in the submisison's submission_data and regrades" do
submission_data.each do |answer|
answer_stub = stub
answer_stub.expects(:regrade!).once.returns(1)
QuizRegrader::Answer.expects(:new).returns answer_stub
end
# submission data isn't called if not included in question_regrades
submission_data << {:question_id => 4}
QuizRegrader::Answer.expects(:new).with(submission_data.last, nil).never
# submission updates and saves correct data
submission.expects(:save_with_versioning!).once
submission.expects(:score=).with(3)
submission.expects(:score_before_regrade).returns nil
submission.expects(:score_before_regrade=).with(0)
submission.expects(:quiz_data=).with(question_regrades.map { |id, q| q.question_data })
submission.expects(:attempt_versions).returns []
wrapper.regrade!
end
end
end

View File

@ -687,6 +687,7 @@ describe ContextModule do
# the quiz keeps the highest score; should still be unlocked
@submission.score = 50
@submission.attempt = 2
@submission.with_versioning(&:save)
@submission.kept_score.should == 100

View File

@ -0,0 +1,34 @@
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper.rb')
describe QuizQuestionRegrade do
describe "relationships" do
it "belongs to a quiz_question" do
QuizQuestionRegrade.new.should respond_to :quiz_question
end
it "belongs to a quiz_regrade" do
QuizQuestionRegrade.new.should respond_to :quiz_regrade
end
end
describe "validations" do
it "validates the presence of quiz_question_id & quiz_regrade_id" do
QuizQuestionRegrade.new.should_not be_valid
QuizQuestionRegrade.new(quiz_question_id: 1, quiz_regrade_id: 1).should be_valid
end
end
describe "#question_data" do
it "should delegate to quiz question" do
question = QuizQuestion.new
question.stubs(:question_data => "foo")
qq_regrade = QuizQuestionRegrade.new
qq_regrade.quiz_question = question
qq_regrade.question_data.should == "foo"
end
end
end

View File

@ -38,6 +38,36 @@ describe QuizQuestion do
data[:answers][1][:weight].should eql(0.0)
end
describe "#question_data=" do
before do
course_with_teacher
@quiz = @course.quizzes.create
@data = {:question_name => 'test question',
:points_possible => '1',
:question_type => 'multiple_choice_question',
:answers => {'answer_0' => {'answer_text' => '1', 'id' => 1},
'answer_1' => {'answer_text' => '2', 'id' => 2},
'answer_1' => {'answer_text' => '3', 'id' => 3},
'answer_1' => {'answer_text' => '4', 'id' => 4}}}
@question = @quiz.quiz_questions.create(:question_data => @data)
end
it "should save regrade if passed in regrade option in data hash" do
QuizQuestionRegrade.first.should be_nil
QuizRegrade.create(quiz_id: @quiz.id, user_id: @user.id, quiz_version: @quiz.version_number)
@question.question_data = @data.merge(:regrade_option => 'full_credit',
:regrade_user => @user)
@question.save
question_regrade = QuizQuestionRegrade.first
question_regrade.should be
question_regrade.regrade_option.should == 'full_credit'
end
end
context "migrate_question_hash" do
before do
course_with_teacher

View File

@ -0,0 +1,31 @@
require 'spec_helper'
describe QuizRegradeRun do
it "validates presence of quiz_regrade_id" do
QuizRegradeRun.new(quiz_regrade_id: 1).should be_valid
QuizRegradeRun.new(quiz_regrade_id: nil).should_not be_valid
end
describe "#perform" do
before(:each) do
@course = Course.create!
@quiz = Quiz.create!(:context => @course)
@user = User.create!
@regrade = QuizRegrade.create(:user_id => @user.id, :quiz_id => @quiz.id, :quiz_version => 1)
end
it "creates a new quiz regrade run" do
QuizRegradeRun.first.should be_nil
QuizRegradeRun.perform(@regrade) do
# noop
end
run = QuizRegradeRun.first
run.started_at.should_not be_nil
run.finished_at.should_not be_nil
end
end
end

View File

@ -0,0 +1,44 @@
require 'spec_helper'
describe QuizRegrade do
before { Timecop.freeze(Time.local(2013)) }
after { Timecop.return }
def quiz_regrade
QuizRegrade.new(quiz_id: 1, user_id: 1, quiz_version: 1)
end
describe "relationships" do
it "belongs to a quiz" do
QuizRegrade.new.should respond_to :quiz
end
it "belongs to a user" do
QuizRegrade.new.should respond_to :user
end
end
describe "validations" do
it "validates presence of quiz_id" do
QuizRegrade.new(quiz_id: nil).should_not be_valid
end
it "validates presence of user id" do
QuizRegrade.new(quiz_id: 1,user_id: nil).should_not be_valid
end
it "validates presence of quiz_version" do
QuizRegrade.new(quiz_id: 1, user_id: 1, quiz_version: nil).
should_not be_valid
end
it "is valid when all required attributes are present" do
QuizRegrade.new(quiz_id: 1, user_id: 1, quiz_version: 1).
should be_valid
end
end
end

View File

@ -1104,4 +1104,26 @@ describe Quiz do
@quiz.should_not be_published
end
end
context "#current_regrade" do
before { @quiz = @course.quizzes.create! title: 'Test Quiz' }
it "returns the regrade for the quiz and quiz version" do
regrade = QuizRegrade.find_or_create_by_quiz_id_and_quiz_version(@quiz.id,@quiz.version_number) { |qr| qr.user_id = 1 }
@quiz.current_regrade.should == regrade
end
end
context "#current_regrade_question_ids" do
before { @quiz = @course.quizzes.create! title: 'Test Quiz' }
it "returns the correct question ids" do
q = @quiz.quiz_questions.create!
regrade = QuizRegrade.find_or_create_by_quiz_id_and_quiz_version(@quiz.id,@quiz.version_number) { |qr| qr.user_id = 1 }
rq = regrade.quiz_question_regrades.create! quiz_question_id: q.id, regrade_option: 'current_correct_only'
@quiz.current_quiz_question_regrades.should == [rq]
end
end
end

View File

@ -332,6 +332,34 @@ describe QuizSubmission do
s.kept_score.should eql(6.0)
end
it "should calculate highest score based on most recent version of an attempt" do
q = @course.quizzes.create!(:scoring_policy => "keep_highest")
s = q.quiz_submissions.new
s.workflow_state = "complete"
s.score = 5.0
s.attempt = 1
s.with_versioning(true, &:save!)
s.version_number.should eql(1)
s.score.should eql(5.0)
s.kept_score.should eql(5.0)
# regrade
s.score_before_regrade = 5.0
s.score = 4.0
s.attempt = 1
s.with_versioning(true, &:save!)
s.version_number.should eql(2)
s.kept_score.should eql(4.0)
# new attempt
s.score = 3.0
s.attempt = 2
s.with_versioning(true, &:save!)
s.version_number.should eql(3)
s.kept_score.should eql(4.0)
end
describe "with an essay question" do
before(:each) do
quiz_with_graded_submission([{:question_data => {:name => 'question 1', :points_possible => 1, 'question_type' => 'essay_question'}}]) do
@ -1460,6 +1488,80 @@ describe QuizSubmission do
end
describe "submitted_versions" do
let(:submission) { @quiz.quiz_submissions.build }
before do
submission.grade_submission
end
it "should find regrade versions for a submission" do
submission.submitted_versions.length.should == 1
end
end
describe "attempt_versions" do
let(:quiz) { @course.quizzes.create! }
let(:submission) { quiz.quiz_submissions.new }
it "should find attempt versions for a submission" do
submission.workflow_state = "complete"
submission.score = 5.0
submission.attempt = 1
submission.with_versioning(true, &:save!)
submission.version_number.should eql(1)
submission.score.should eql(5.0)
# regrade
submission.score_before_regrade = 5.0
submission.score = 4.0
submission.attempt = 1
submission.with_versioning(true, &:save!)
submission.version_number.should eql(2)
# new attempt
submission.score = 3.0
submission.attempt = 2
submission.with_versioning(true, &:save!)
submission.version_number.should eql(3)
attempt_versions = submission.attempt_versions
attempt_versions.length.should == 2
attempt_versions.first.should be_a(Version)
end
end
describe "submitted_attempts" do
let(:quiz) { @course.quizzes.create! }
let(:submission) { quiz.quiz_submissions.new }
it "should find attempt versions for a submission" do
submission.workflow_state = "complete"
submission.score = 5.0
submission.attempt = 1
submission.with_versioning(true, &:save!)
submission.version_number.should eql(1)
submission.score.should eql(5.0)
# regrade
submission.score_before_regrade = 5.0
submission.score = 4.0
submission.attempt = 1
submission.with_versioning(true, &:save!)
submission.version_number.should eql(2)
# new attempt
submission.score = 3.0
submission.attempt = 2
submission.with_versioning(true, &:save!)
submission.version_number.should eql(3)
submitted_attempts = submission.submitted_attempts
submitted_attempts.length.should == 2
submitted_attempts.first.should be_a(QuizSubmission)
end
end
describe 'broadcast policy' do
before do
Notification.create(:name => 'Submission Graded')

View File

@ -0,0 +1,35 @@
#
# Copyright (C) 2011 Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require File.expand_path(File.dirname(__FILE__) + '/../../spec_helper')
require File.expand_path(File.dirname(__FILE__) + '/../views_helper')
describe "/quizzes/submission_versions" do
it "should render" do
course_with_teacher(:active_all => true)
course_quiz
view_context
ActiveRecord::Base.clear_cached_contexts
assigns[:quiz] = @quiz
assigns[:versions] = []
render "quizzes/submission_versions"
response.should_not be_nil
end
end

View File

@ -30,6 +30,12 @@ class Version < ActiveRecord::Base #:nodoc:
obj.send("force_version_number", self.number)
obj
end
# INSTRUCTURE: Added to allow previous version models to be updated
def model=(model)
options = model.class.simply_versioned_options
self.yaml = model.attributes.except(*options[:exclude]).to_yaml
end
# Return the next higher numbered version, or nil if this is the last version
def next

View File

@ -80,6 +80,31 @@ describe 'simply_versioned' do
end
end
describe "#model=" do
let(:woozel) { Woozel.create!(:name => 'Eeyore') }
it "should assign the model for the version" do
woozel.versions.length.should eql(1)
woozel.versions.current.model.name.should eql('Eeyore')
woozel.name = 'Piglet'
woozel.with_versioning(:explicit => true, &:save!)
woozel.versions.length.should eql(2)
first_version = woozel.versions.first
first_model = first_version.model
first_model.name.should eql('Eeyore')
first_model.name = 'Foo'
first_version.model = first_model
first_version.save!
versions = woozel.reload.versions
versions.first.model.name.should eql('Foo')
end
end
describe "#current_version?" do
before do
@woozel = Woozel.create! name: 'test'