Quiz Submission Questions API - Update
This patch provides support for answering Quiz Questions via the API. closes CNVS-9844, CNVS-10225 TEST PLAN ---- ---- Testing this will be a bit rough because there are many variations and validations to cover. I'll spare the validations that are covered by specs from the test plan. Create a quiz with a question of *each* type except "Text" and "File Upload". There's a script that creates a quiz with its questions automatically for you if you don't want to keep doing this manually. See references. > Answering Questions Now you need to answer each question via the API. Most of them vary in formats, but they are fully specified in the API documentation page (along with examples). See DOCUMENTATION for more info. > Flagging Questions Flagging, and unflagging, a question is the same regardless of its type, see the "EXAMPLE REQUESTS" section. > Access Validations Here are some generic, non-question based validations to verify. You should NOT be able to answer a question if: - the quiz submission has been turned in - the quiz submission is overdue - the Access Code for the quiz is invalid - the IP filter of the Quiz prohibits you from taking the quiz - the quiz submission :validation_token is incorrectly specified (ie, other students shouldn't be able to answer your questions) - you don't specify the latest :attempt, so if the Quiz has multiple attempts, and this is your 2nd take, you specify an :attempt of 1, 3, or anything but 2 should fail - NEW: turn quiz into an OQAAT quiz with the "Can't go back" flag on; the API should not reject all requests to modify any of the questions with a 501 error saying that type of quizzes is not supported yet (support will come in CNVS-10224) > Grading Also, when you're done answering the questions, take a look at the grades and make sure everything gets graded just like it does when using the UI directly. > Verifying results in the browser While taking a quiz in the canvas UI, the scripts perform backups in the background that would overwrite any changes you do via the API. If you want to verify the changes you make via the API from the UI, you must append "?backup=false" to the take quiz page URL, something like this: http://localhost:3000/courses/1/quizzes/1/take?backup=false Setting that flag will (for now) disable the backup behaviour and should make things tick. EXAMPLE REQUESTS ------- -------- Don't forget to set the 'Content-Type' header to 'application/json'! > Answering a Multiple-Choice question [PUT] /api/v1/quiz_submissions/:quiz_submission_id/questions/:id { "attempt": 1, "validation_token": "1babd0...", "answer": 10 } > Flagging a question [PUT] /api/v1/quiz_submissions/:quiz_submission_id/questions/:id/flag { "attempt": 1, "validation_token": "1babd0..." } > Unflagging a question [PUT] /api/v1/quiz_submissions/:quiz_submission_id/questions/:id/unflag { "attempt": 1, "validation_token": "1babd0..." } DOCUMENTATION ------------- Run `bundle exec rake doc:api` and check out the Quiz Submission Questions page. There's an Appendix that contains example requests for each question type, as well as the errors produced by each handler. LINKS ----- - bootstrap script: https://gist.github.com/amireh/e7e8f835ffbf1d053e4c - direct link to the API documentation page: http://canvas.docs.kodoware.com/quiz_submission_questions.html Change-Id: I9a958323ece8854bc21a24c2affd8dc3972e46d5 Reviewed-on: https://gerrit.instructure.com/27206 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Derek DeVries <ddevries@instructure.com> QA-Review: Myller de Araujo <myller@instructure.com> Product-Review: Ahmad Amireh <ahmad@instructure.com>
This commit is contained in:
parent
28234f234f
commit
20a3562779
|
@ -0,0 +1,197 @@
|
|||
#
|
||||
# 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/>.
|
||||
#
|
||||
|
||||
# @API Quiz Submission Questions
|
||||
# @beta
|
||||
#
|
||||
|
||||
class QuizSubmissionQuestionsController < ApplicationController
|
||||
include Api::V1::QuizSubmissionQuestion
|
||||
include Api::V1::Helpers::QuizzesApiHelper
|
||||
include Api::V1::Helpers::QuizSubmissionsApiHelper
|
||||
|
||||
before_filter :require_user,
|
||||
:require_quiz_submission,
|
||||
:export_scopes,
|
||||
:prepare_service,
|
||||
:validate_ldb_status!,
|
||||
:require_question
|
||||
|
||||
# @API Answering a question.
|
||||
# @beta
|
||||
#
|
||||
# Provide or modify an answer to a QuizQuestion.
|
||||
#
|
||||
# @argument attempt [Integer]
|
||||
# The attempt number of the quiz submission being taken. Note that this
|
||||
# must be the latest attempt index, as questions for earlier attempts can
|
||||
# not be modified.
|
||||
#
|
||||
# @argument validation_token [String]
|
||||
# The unique validation token you received when the Quiz Submission was
|
||||
# created.
|
||||
#
|
||||
# @argument access_code [Optional, String]
|
||||
# Access code for the Quiz, if any.
|
||||
#
|
||||
# @argument answer [Optional, Mixed]
|
||||
# The answer to the question. The type and format of this argument depend
|
||||
# on the question type.
|
||||
#
|
||||
# See {Appendix: Question Answer Formats} for the accepted answer formats
|
||||
# for each question type.
|
||||
#
|
||||
# @example_request
|
||||
# {
|
||||
# "attempt": 1,
|
||||
# "validation_token": "YOUR_VALIDATION_TOKEN",
|
||||
# "access_code": null,
|
||||
# "answer": "Hello World!"
|
||||
# }
|
||||
def answer
|
||||
unless params.has_key?(:answer)
|
||||
reject! 400, 'missing required parameter :answer'
|
||||
end
|
||||
|
||||
serializer = QuizQuestion::AnswerSerializers.serializer_for @question
|
||||
serialization_rc = serializer.serialize(params[:answer])
|
||||
|
||||
unless serialization_rc.valid?
|
||||
reject! 400, serialization_rc.error
|
||||
end
|
||||
|
||||
submission_data = @service.update_question(serialization_rc.answer,
|
||||
@quiz_submission,
|
||||
params[:attempt])
|
||||
|
||||
render json: quiz_submission_questions_json([ @question ], submission_data)
|
||||
end
|
||||
|
||||
# @API Flagging a question.
|
||||
# @beta
|
||||
#
|
||||
# Set a flag on a quiz question to indicate that you want to return to it
|
||||
# later.
|
||||
#
|
||||
# @argument attempt [Integer]
|
||||
# The attempt number of the quiz submission being taken. Note that this
|
||||
# must be the latest attempt index, as questions for earlier attempts can
|
||||
# not be modified.
|
||||
#
|
||||
# @argument validation_token [String]
|
||||
# The unique validation token you received when the Quiz Submission was
|
||||
# created.
|
||||
#
|
||||
# @argument access_code [Optional, String]
|
||||
# Access code for the Quiz, if any.
|
||||
#
|
||||
# @example_request
|
||||
# {
|
||||
# "attempt": 1,
|
||||
# "validation_token": "YOUR_VALIDATION_TOKEN",
|
||||
# "access_code": null
|
||||
# }
|
||||
def flag
|
||||
render json: quiz_submission_questions_json([ @question ],
|
||||
flag_current_question(true))
|
||||
end
|
||||
|
||||
# @API Unflagging a question.
|
||||
# @beta
|
||||
#
|
||||
# Remove the flag that you previously set on a quiz question after you've
|
||||
# returned to it.
|
||||
#
|
||||
# @argument attempt [Integer]
|
||||
# The attempt number of the quiz submission being taken. Note that this
|
||||
# must be the latest attempt index, as questions for earlier attempts can
|
||||
# not be modified.
|
||||
#
|
||||
# @argument validation_token [String]
|
||||
# The unique validation token you received when the Quiz Submission was
|
||||
# created.
|
||||
#
|
||||
# @argument access_code [Optional, String]
|
||||
# Access code for the Quiz, if any.
|
||||
#
|
||||
# @example_request
|
||||
# {
|
||||
# "attempt": 1,
|
||||
# "validation_token": "YOUR_VALIDATION_TOKEN",
|
||||
# "access_code": null
|
||||
# }
|
||||
def unflag
|
||||
render json: quiz_submission_questions_json([ @question ],
|
||||
flag_current_question(false))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_question
|
||||
@question = @quiz.quiz_questions.find(params[:id].to_i)
|
||||
end
|
||||
|
||||
# Export the Quiz and Course from the resolved QS.
|
||||
def export_scopes
|
||||
@quiz = @quiz_submission.quiz
|
||||
|
||||
require_overridden_quiz
|
||||
|
||||
@context = @quiz.context
|
||||
end
|
||||
|
||||
# This is duplicated from QuizSubmissionsApiController and will be moved into
|
||||
# a Controller Filter once CNVS-10071 is in.
|
||||
#
|
||||
# [Transient:CNVS-10071]
|
||||
def validate_ldb_status!(quiz = @quiz)
|
||||
if quiz.require_lockdown_browser?
|
||||
unless ldb_plugin.authorized?(self)
|
||||
reject! 403, 'this quiz requires the lockdown browser'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# This is duplicated from QuizSubmissionsApiController and will be moved into
|
||||
# a Controller Filter once CNVS-10071 is in.
|
||||
#
|
||||
# [Transient:CNVS-10071]
|
||||
def ldb_plugin
|
||||
Canvas::LockdownBrowser.plugin.base
|
||||
end
|
||||
|
||||
# Toggle a question's "flagged" status.
|
||||
#
|
||||
# @param [Boolean] flagged_unflagged
|
||||
#
|
||||
# @return [Hash] the QS's submission_data.
|
||||
def flag_current_question(flagged_unflagged)
|
||||
question_record = {}.with_indifferent_access
|
||||
question_record["question_#{@question.id}_marked"] = flagged_unflagged
|
||||
|
||||
@service.update_question(question_record,
|
||||
@quiz_submission,
|
||||
params[:attempt],
|
||||
# we don't want a snapshot generated for each flagging action
|
||||
false)
|
||||
end
|
||||
|
||||
# @!appendix Question Answer Formats
|
||||
#
|
||||
# {include:file:doc/examples/quiz_question_answers.md}
|
||||
end
|
|
@ -393,7 +393,7 @@ module QuizzesHelper
|
|||
if answer_list && !answer_list.empty?
|
||||
|
||||
# Replace the {{question_BLAH}} template text with the user's answer text.
|
||||
match = match.sub(/\{\{question_.*?\}\}/, a).
|
||||
match = match.sub(/\{\{question_.*?\}\}/, a.to_s).
|
||||
# Match on "/>" but only when at the end of the string and insert "readonly" if set to be readonly
|
||||
sub(/\/\>\Z/, readonly_markup)
|
||||
end
|
||||
|
@ -404,7 +404,7 @@ module QuizzesHelper
|
|||
|
||||
unless answer_list && !answer_list.empty?
|
||||
answers.delete_if { |k, v| !k.match /^question_#{hash_get(question, :id)}/ }
|
||||
answers.each { |k, v| res.sub! /\{\{#{k}\}\}/, v }
|
||||
answers.each { |k, v| res.sub! /\{\{#{k}\}\}/, v.to_s }
|
||||
res.gsub! /\{\{question_[^}]+\}\}/, ""
|
||||
end
|
||||
|
||||
|
|
|
@ -0,0 +1,133 @@
|
|||
module QuizQuestion::AnswerSerializers
|
||||
class AnswerSerializer
|
||||
attr_accessor :question
|
||||
|
||||
def initialize(question)
|
||||
self.question = question
|
||||
end
|
||||
|
||||
# Serialize the user-supplied answer into a format compatible with
|
||||
# QuizSubmission#submission_data.
|
||||
#
|
||||
# @param [Mixed] answer
|
||||
# The user-supplied answer. The type of this argument may change between
|
||||
# serializers. See related AnswerSerializer documentation for the answer
|
||||
# format they accept.
|
||||
#
|
||||
# @return [SerializedAnswer]
|
||||
def serialize(answer)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
# Convert serialized answers from QuizSubmission#submission_data to something
|
||||
# presentable to the user.
|
||||
#
|
||||
# @note
|
||||
# The format of the output of this method must match the format of the
|
||||
# user-supplied answer. See #serialize.
|
||||
#
|
||||
# @return [Any]
|
||||
# The output is similar to the user-supplied answer, which may vary between
|
||||
# serializers.
|
||||
def deserialize(submission_data)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
# Automatically register answer serializers. Each serializer will be mapped
|
||||
# to the QuizQuestion of a type that is deduced from the serializer's class
|
||||
# name.
|
||||
#
|
||||
# See #question_type
|
||||
def self.inherited(klass)
|
||||
QuizQuestion::AnswerSerializers.register_serializer klass.question_type, klass
|
||||
end
|
||||
|
||||
# Override this to explicitly specify the question_type the serializer works
|
||||
# for.
|
||||
def self.question_type
|
||||
self.name.demodulize.underscore
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
# The hash-key of the question answer record in the submission_data.
|
||||
#
|
||||
# This varies between question types, so some serializers will override this.
|
||||
def question_key
|
||||
@question_key ||= [ 'question', self.question.id ].join('_')
|
||||
end
|
||||
|
||||
# Locate the question data that is usable by *students* when they take the
|
||||
# quiz, which might be different than the data of @question because the
|
||||
# teacher might be editing the question and has not yet published the
|
||||
# changes, in which case the students should use the "frozen" version of
|
||||
# the question and not the not-yet-published one.
|
||||
#
|
||||
# The data set is retrieved from Quiz#quiz_data, if that is not yet generated,
|
||||
# it falls back to Quiz#stored_questions, and if that still doesn't contain
|
||||
# our question, it falls back to QuizQuestion#question_data.
|
||||
#
|
||||
# Please make sure that you use this data set for any lookups of answer IDs,
|
||||
# matches, or whatever.
|
||||
#
|
||||
# @return [Hash] The question data.
|
||||
def frozen_question_data
|
||||
@frozen_question_data ||= begin
|
||||
question_id = self.question.id
|
||||
quiz = self.question.quiz
|
||||
quiz_data = quiz.quiz_data || quiz.stored_questions
|
||||
quiz_data.detect { |question| question[:id].to_i == question_id } ||
|
||||
self.question.question_data
|
||||
end
|
||||
end
|
||||
|
||||
# @return [Array<Hash>] Set of answer records for the frozen question.
|
||||
def answers
|
||||
@answers ||= frozen_question_data[:answers]
|
||||
end
|
||||
|
||||
# @return [Array<Integer>] Set of IDs for all the question answers.
|
||||
def answer_ids
|
||||
@answer_ids ||= answers.map { |answer| answer[:id].to_i }
|
||||
end
|
||||
|
||||
# @return [Boolean] True if the answer_id identifies a known answer
|
||||
def answer_available?(answer_id)
|
||||
answer_ids.include?(answer_id.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
attr_accessor :serializers
|
||||
|
||||
def register_serializer(question_type, serializer)
|
||||
self.serializers ||= {}
|
||||
self.serializers[question_type] = serializer
|
||||
end
|
||||
|
||||
# Get an instance of an AnswerSerializer appropriate for the given question.
|
||||
#
|
||||
# @param [QuizQuestion] question
|
||||
# The question to locate the serializer for.
|
||||
#
|
||||
# @return [AnswerSerializer]
|
||||
# The serializer.
|
||||
#
|
||||
# @throw RuntimeError if no serializer was found for the given question
|
||||
def serializer_for(question)
|
||||
self.serializers ||= {}
|
||||
|
||||
question_type = question.respond_to?(:data) ?
|
||||
question.data[:question_type] :
|
||||
question[:question_type]
|
||||
|
||||
question_type.gsub! /_question$/, ''
|
||||
|
||||
unless serializer_klass = self.serializers[question_type]
|
||||
raise "No known serializer for questions of type #{question_type}"
|
||||
end
|
||||
|
||||
serializer_klass.new(question)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,4 @@
|
|||
module QuizQuestion::AnswerSerializers
|
||||
class Calculated < Numerical
|
||||
end
|
||||
end
|
|
@ -0,0 +1,34 @@
|
|||
module QuizQuestion::AnswerSerializers
|
||||
class Essay < AnswerSerializer
|
||||
|
||||
# @param answer_html [String]
|
||||
# The textual/HTML answer. Will be HTML escaped.
|
||||
#
|
||||
# @example output for an answer for QuizQuestion#1
|
||||
# {
|
||||
# :question_1 => "<p>Hello World!</p>"
|
||||
# }
|
||||
def serialize(answer_html)
|
||||
rc = SerializedAnswer.new
|
||||
|
||||
unless answer_html.is_a?(String)
|
||||
return rc.reject :invalid_type, 'answer', String
|
||||
end
|
||||
|
||||
answer_html = Util.sanitize_html answer_html
|
||||
|
||||
if Util.text_too_long?(answer_html)
|
||||
return rc.reject :text_too_long
|
||||
end
|
||||
|
||||
rc.answer[question_key] = answer_html
|
||||
rc
|
||||
end
|
||||
|
||||
# @return [String]
|
||||
# The HTML-escaped textual answer.
|
||||
def deserialize(submission_data)
|
||||
submission_data[question_key]
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,123 @@
|
|||
module QuizQuestion::AnswerSerializers
|
||||
|
||||
# @internal
|
||||
# :nodoc:
|
||||
#
|
||||
# A note on "blanks" to help clear the confusion around their uses, especially
|
||||
# in the context of this serializer:
|
||||
#
|
||||
# There are three distinct versions of an answer's blank used at various
|
||||
# stages:
|
||||
#
|
||||
# 1. the "blank", which is a simple string defined by the teacher when they
|
||||
# created the quiz question, and this is what the API clients are expected
|
||||
# to use for sending answers to that blank
|
||||
#
|
||||
# 2. the "blank_id": which is a normalized version of the "blank", used
|
||||
# internally to identify the blank (it's a digest), see Util#blank_id
|
||||
# and AssessmentQuestion#variable_id for generating this version
|
||||
#
|
||||
# 3. the "blank key", which is the key to store the _answer_ to that blank in
|
||||
# a quiz submission's submission_data construct, nobody has to know about
|
||||
# this except for the parsers/serializers
|
||||
#
|
||||
# Unfortunately, the question_data records refer to the vanilla "blank" as
|
||||
# "blank_id", so if you do something like:
|
||||
#
|
||||
# qq = quiz.quiz_questions.first # say this is a FIMB question
|
||||
# qq.question_data[:answers][0]
|
||||
#
|
||||
# You will get a construct with:
|
||||
#
|
||||
# { "id"=>"9711", "text"=>"Red", "weight"=>100, "blank_id"=>"color" }
|
||||
#
|
||||
# Which gave me motive to write this note to help clear up the confusion.
|
||||
class FillInMultipleBlanks < AnswerSerializer
|
||||
# Accept textual answers for answer blanks.
|
||||
#
|
||||
# @example input for two blanks, "color1" and "color2":
|
||||
# {
|
||||
# color1: 'red',
|
||||
# color2: 'blue'
|
||||
# }
|
||||
#
|
||||
# @param [String] answer_hash[:blank]
|
||||
# The textual answer for the given blank. Will be sanitized
|
||||
# (stripped and lowercased).
|
||||
#
|
||||
# @example output for an answer for QuizQuestion#1 with blank "color":
|
||||
# {
|
||||
# "color" => "red"
|
||||
# }
|
||||
def serialize(answer_hash)
|
||||
rc = SerializedAnswer.new
|
||||
|
||||
unless answer_hash.is_a?(Hash)
|
||||
return rc.reject :invalid_type, 'answer', Hash
|
||||
end
|
||||
|
||||
answer_hash.stringify_keys.each_pair do |blank, answer_text|
|
||||
unless blank_available?(blank)
|
||||
return rc.reject :unknown_blank, blank
|
||||
end
|
||||
|
||||
validate_blank_answer(blank, answer_text, rc)
|
||||
|
||||
unless rc.valid?
|
||||
break
|
||||
end
|
||||
|
||||
rc.answer[answer_blank_key(blank)] = serialize_blank_answer(answer_text)
|
||||
end
|
||||
|
||||
rc
|
||||
end
|
||||
|
||||
# @note blanks that were not answered are not included in the output
|
||||
def deserialize(submission_data)
|
||||
answers.each_with_object({}) do |answer_record, out|
|
||||
blank = answer_record[:blank_id]
|
||||
blank_key = answer_blank_key(blank)
|
||||
blank_answer = submission_data[blank_key]
|
||||
|
||||
if blank_answer.present?
|
||||
out[blank] = deserialize_blank_answer blank_answer
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
# Tests that the answer is a string and isn't too long.
|
||||
#
|
||||
# Override this to provide support for non-textual answers.
|
||||
def validate_blank_answer(blank, answer_text, rc)
|
||||
if !answer_text.is_a?(String)
|
||||
rc.reject :invalid_type, "#{blank}.answer", String
|
||||
elsif Util.text_too_long?(answer_text)
|
||||
rc.reject :text_too_long
|
||||
end
|
||||
end
|
||||
|
||||
# Override this to provide support for non-textual answers.
|
||||
def serialize_blank_answer(answer_text)
|
||||
Util.sanitize_text(answer_text)
|
||||
end
|
||||
|
||||
def deserialize_blank_answer(answer_text)
|
||||
answer_text
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# @return [Boolean] True if the blank_id is recognized
|
||||
def blank_available?(blank_id)
|
||||
answers.any? { |answer| answer[:blank_id] == blank_id }
|
||||
end
|
||||
|
||||
# something like: "question_5_1813d2a7223184cf43e19db6622df40b"
|
||||
def answer_blank_key(blank)
|
||||
[ question_key, Util.blank_id(blank) ].join('_')
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,106 @@
|
|||
module QuizQuestion::AnswerSerializers
|
||||
class Matching < AnswerSerializer
|
||||
# Accept a set of pairings between answer and match IDs.
|
||||
#
|
||||
# Serialization request is rejected if:
|
||||
#
|
||||
# - the answer isn't an Array
|
||||
# - an answer entry (pairing) isn't a Hash
|
||||
# - an answer entry is missing either id
|
||||
# - either answer_id or match_id isn't a valid number
|
||||
# - either answer_id or match_id can't be resolved
|
||||
#
|
||||
# @example input
|
||||
# [{
|
||||
# answer_id: 123,
|
||||
# match_id: 456
|
||||
# }]
|
||||
#
|
||||
# @example output
|
||||
# {
|
||||
# question_5_answer_123: "456"
|
||||
# }
|
||||
def serialize(pairings)
|
||||
rc = SerializedAnswer.new
|
||||
|
||||
unless pairings.is_a?(Array)
|
||||
return rc.reject :invalid_type, 'answer', Array
|
||||
end
|
||||
|
||||
pairings.each_with_index do |entry, index|
|
||||
answer_id, match_id = nil, nil
|
||||
|
||||
unless entry.is_a?(Hash)
|
||||
return rc.reject :invalid_type, "answer[#{index}]", Hash
|
||||
end
|
||||
|
||||
entry = entry.with_indifferent_access
|
||||
|
||||
%w[ answer_id match_id ].each do |required_param|
|
||||
unless entry.has_key?(required_param)
|
||||
return rc.reject 'Matching pair is missing parameter "%s"' % [
|
||||
required_param
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
answer_id = Util.to_integer(entry[:answer_id])
|
||||
|
||||
if answer_id.nil?
|
||||
return rc.reject :invalid_type, 'answer_id', Integer
|
||||
end
|
||||
|
||||
unless answer_available? answer_id
|
||||
return rc.reject :unknown_answer, answer_id
|
||||
end
|
||||
|
||||
match_id = Util.to_integer(entry[:match_id])
|
||||
|
||||
if match_id.nil?
|
||||
return rc.reject :invalid_type, 'match_id', Integer
|
||||
end
|
||||
|
||||
unless match_available? match_id
|
||||
return rc.reject :unknown_match, match_id
|
||||
end
|
||||
|
||||
rc.answer[build_answer_key(answer_id)] = match_id.to_s
|
||||
end
|
||||
|
||||
rc
|
||||
end
|
||||
|
||||
# @note answers that were not matched will _not_ be present in the output
|
||||
def deserialize(submission_data)
|
||||
answers.each_with_object([]) do |answer_record, out|
|
||||
answer_id = answer_record[:id]
|
||||
answer_key = build_answer_key(answer_id)
|
||||
|
||||
match_id = submission_data[answer_key]
|
||||
|
||||
if match_id.present?
|
||||
out << {
|
||||
answer_id: answer_id,
|
||||
match_id: match_id.to_i
|
||||
}.with_indifferent_access
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_answer_key(answer_id)
|
||||
[ question_key, 'answer', answer_id ].join('_')
|
||||
end
|
||||
|
||||
def match_ids
|
||||
@match_ids ||= frozen_question_data[:matches].map do |match_record|
|
||||
match_record[:match_id].to_i
|
||||
end
|
||||
end
|
||||
|
||||
def match_available?(match_id)
|
||||
match_ids.include?(match_id)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,77 @@
|
|||
module QuizQuestion::AnswerSerializers
|
||||
class MultipleAnswers < AnswerSerializer
|
||||
# Serialize a selection, a set of answer IDs.
|
||||
#
|
||||
# Serialization request will be rejected if:
|
||||
#
|
||||
# - the selection is not an Array
|
||||
# - the selection contains a bad or unknown answer id
|
||||
#
|
||||
# @example selection for two answers with id 5 and 8:
|
||||
# [ 5, 8 ]
|
||||
#
|
||||
# @param answer_ids [Array<Integer>]
|
||||
# The selected answer IDs.
|
||||
#
|
||||
# @example Answers 5 and 8 are selected, answer#9 isn't in QuizQuestion#1:
|
||||
# {
|
||||
# question_1_answer_5: "1",
|
||||
# question_1_answer_8: "1",
|
||||
# question_1_answer_9: "0"
|
||||
# }
|
||||
def serialize(selection)
|
||||
rc = SerializedAnswer.new
|
||||
|
||||
unless selection.is_a?(Array)
|
||||
return rc.reject :invalid_type, 'answer', Array
|
||||
end
|
||||
|
||||
selection.each_with_index do |answer_id, index|
|
||||
answer_id = Util.to_integer(answer_id)
|
||||
|
||||
if answer_id.nil?
|
||||
return rc.reject :invalid_type, "answer[#{index}]", Integer
|
||||
elsif !answer_available?(answer_id)
|
||||
return rc.reject :unknown_answer, answer_id
|
||||
end
|
||||
end
|
||||
|
||||
selection = selection.map(&:to_i)
|
||||
|
||||
answer_ids.each_with_object(rc.answer) do |answer_id, out|
|
||||
is_selected = selection.include?(answer_id)
|
||||
out[answer_key(answer_id)] = answer_value(is_selected)
|
||||
end
|
||||
|
||||
rc
|
||||
end
|
||||
|
||||
# @return [Array<Integer>] IDs of the selected answers.
|
||||
# @example output for answers 5 and 8 selected:
|
||||
# [ 5, 8 ]
|
||||
def deserialize(submission_data)
|
||||
answers.each_with_object([]) do |answer_record, out|
|
||||
answer_id = answer_record[:id].to_i
|
||||
|
||||
is_selected = submission_data[answer_key(answer_id)]
|
||||
is_selected = Util.to_boolean(is_selected)
|
||||
|
||||
if is_selected
|
||||
out << answer_id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def answer_key(answer_id)
|
||||
[ question_key, 'answer', answer_id ].join('_')
|
||||
end
|
||||
|
||||
# Using anything other than "1" and "0" to indicate whether the answer is
|
||||
# selected won't work with the current UI.
|
||||
def answer_value(is_on)
|
||||
is_on ? "1" : "0"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,37 @@
|
|||
module QuizQuestion::AnswerSerializers
|
||||
class MultipleChoice < AnswerSerializer
|
||||
|
||||
# Select an answer from the set of available answers.
|
||||
#
|
||||
# Serialization request will be rejected if:
|
||||
#
|
||||
# - the answer id is bad or unknown
|
||||
#
|
||||
# @example input where the answer ID is 123
|
||||
# {
|
||||
# answer: 123
|
||||
# }
|
||||
#
|
||||
# @example output where the question ID is 5
|
||||
# {
|
||||
# question_5_answer: "123"
|
||||
# }
|
||||
def serialize(answer_id)
|
||||
rc = SerializedAnswer.new
|
||||
answer_id = Util.to_integer answer_id
|
||||
|
||||
if answer_id.nil?
|
||||
return rc.reject :invalid_type, 'answer', Integer
|
||||
elsif !answer_available? answer_id
|
||||
return rc.reject :unknown_answer, answer_id
|
||||
end
|
||||
|
||||
rc.answer[question_key] = answer_id.to_s
|
||||
rc
|
||||
end
|
||||
|
||||
def deserialize(submission_data)
|
||||
submission_data[question_key].to_i
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,24 @@
|
|||
module QuizQuestion::AnswerSerializers
|
||||
class MultipleDropdowns < FillInMultipleBlanks
|
||||
protected
|
||||
|
||||
# Rejects if the answer id is bad or doesn't identify a known answer
|
||||
def validate_blank_answer(blank, answer_id, rc)
|
||||
answer_id = Util.to_integer answer_id
|
||||
|
||||
if answer_id.nil?
|
||||
rc.reject :invalid_type, "answer.#{blank}", Integer
|
||||
elsif !answer_available? answer_id
|
||||
rc.reject :unknown_answer, answer_id
|
||||
end
|
||||
end
|
||||
|
||||
def serialize_blank_answer(answer_id)
|
||||
answer_id.to_i.to_s
|
||||
end
|
||||
|
||||
def deserialize_blank_answer(answer_id)
|
||||
answer_id.to_i
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,35 @@
|
|||
module QuizQuestion::AnswerSerializers
|
||||
class Numerical < AnswerSerializer
|
||||
# Serialize a decimal answer.
|
||||
#
|
||||
# @param [BigDecimal|String] answer
|
||||
#
|
||||
# @note
|
||||
# This serializer does not reject any input but instead coerces everything
|
||||
# to a BigDecimal, even if the input is not a number.
|
||||
#
|
||||
# @example acceptable inputs
|
||||
# { answer: 1 }
|
||||
# { answer: 2.3e-6 }
|
||||
# { answer: "8.4" }
|
||||
#
|
||||
# @example outputs, respectively from above
|
||||
# { question_5: 1 }
|
||||
# { question_5: 2.3e-6 }
|
||||
# { question_5: 8.4 }
|
||||
def serialize(answer)
|
||||
rc = SerializedAnswer.new
|
||||
rc.answer[question_key] = Util.to_decimal(answer).to_s
|
||||
rc
|
||||
end
|
||||
|
||||
# @return [BigDecimal|NilClass]
|
||||
def deserialize(submission_data)
|
||||
answer = submission_data[question_key]
|
||||
|
||||
if answer.present?
|
||||
Util.to_decimal(answer.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,50 @@
|
|||
module QuizQuestion::AnswerSerializers
|
||||
class SerializedAnswer
|
||||
|
||||
# @property [Hash] answer
|
||||
#
|
||||
# The output of the serializer which is compatible for merging with
|
||||
# QuizSubmission#submission_data.
|
||||
attr_accessor :answer
|
||||
|
||||
# @property [String|NilClass] error
|
||||
#
|
||||
# Will contain a descriptive error message if the serialization fails, nil
|
||||
# otherwise.
|
||||
attr_accessor :error
|
||||
|
||||
def initialize
|
||||
self.answer = {}.with_indifferent_access
|
||||
end
|
||||
|
||||
# @return [Boolean] Whether the answer has been serialized successfully.
|
||||
def valid?
|
||||
error.blank?
|
||||
end
|
||||
|
||||
def reject(reason, *args)
|
||||
self.error = reason.to_s
|
||||
|
||||
if reason.is_a?(Symbol) && ERROR_CODES.has_key?(reason)
|
||||
actual_reason = ERROR_CODES[reason]
|
||||
actual_reason = actual_reason.call(*args) if actual_reason.is_a?(Proc)
|
||||
|
||||
self.error = actual_reason
|
||||
end
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
ERROR_CODES = {
|
||||
invalid_type: lambda { |param_name, expected_type|
|
||||
'%s must be of type %s' % [ param_name, expected_type.to_s ]
|
||||
},
|
||||
unknown_answer: lambda { |id| "Unknown answer '#{id}'" },
|
||||
unknown_match: lambda { |id| "Unknown match '#{id}'" },
|
||||
unknown_blank: lambda { |id| "Unknown blank '#{id}'" },
|
||||
text_too_long: 'Text is too long.'
|
||||
}
|
||||
end
|
||||
end
|
|
@ -0,0 +1,34 @@
|
|||
module QuizQuestion::AnswerSerializers
|
||||
class ShortAnswer < AnswerSerializer
|
||||
|
||||
# Serialize a written, textual answer.
|
||||
#
|
||||
# Serialization request will be rejected if the answer isn't a string or is
|
||||
# too long. See Util#text_too_long?
|
||||
#
|
||||
# @param answer_hash[:text] String
|
||||
# The textual/HTML answer. Will be text-escaped.
|
||||
#
|
||||
# @example output for an answer for QuizQuestion#1
|
||||
# {
|
||||
# :question_1 => "sanitized_answer"
|
||||
# }
|
||||
def serialize(answer_text)
|
||||
rc = SerializedAnswer.new
|
||||
|
||||
if !answer_text.is_a?(String)
|
||||
return rc.reject :invalid_type, 'answer', String
|
||||
elsif Util.text_too_long? answer_text
|
||||
return rc.reject :text_too_long
|
||||
end
|
||||
|
||||
rc.answer[question_key] = Util.sanitize_text(answer_text)
|
||||
rc
|
||||
end
|
||||
|
||||
# @return [String|NilClass] The textual answer, if any.
|
||||
def deserialize(submission_data)
|
||||
submission_data[question_key]
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,4 @@
|
|||
module QuizQuestion::AnswerSerializers
|
||||
class TrueFalse < MultipleChoice
|
||||
end
|
||||
end
|
|
@ -0,0 +1,47 @@
|
|||
module QuizQuestion::AnswerSerializers
|
||||
module Util
|
||||
MaxTextualAnswerLength = 16.kilobyte
|
||||
|
||||
class << self
|
||||
def blank_id(blank)
|
||||
AssessmentQuestion.variable_id(blank)
|
||||
end
|
||||
|
||||
# Cast a numerical value to an Integer.
|
||||
#
|
||||
# @return [Integer|NilClass]
|
||||
# nil if the parameter isn't really an integer.
|
||||
def to_integer(number)
|
||||
begin
|
||||
Integer(number)
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Convert a value to a BigDecimal.
|
||||
#
|
||||
# @return [BigDecimal]
|
||||
def to_decimal(value)
|
||||
BigDecimal(value.to_s)
|
||||
end
|
||||
|
||||
def to_boolean(flag)
|
||||
Canvas::Plugin.value_to_boolean(flag)
|
||||
end
|
||||
|
||||
# See Util.MaxTextualAnswerLength for the threshold.
|
||||
def text_too_long?(text)
|
||||
text.to_s.length >= MaxTextualAnswerLength
|
||||
end
|
||||
|
||||
def sanitize_html(html)
|
||||
Sanitize.clean((html || '').to_s, Instructure::SanitizeField::SANITIZE)
|
||||
end
|
||||
|
||||
def sanitize_text(text)
|
||||
(text || '').to_s.strip.downcase
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -280,6 +280,8 @@ class QuizSubmission < ActiveRecord::Base
|
|||
new_params[:cnt] = (new_params[:cnt].to_i + 1) % 5
|
||||
snapshot!(params) if new_params[:cnt] == 1
|
||||
conn.execute("UPDATE quiz_submissions SET user_id=#{self.user_id || 'NULL'}, submission_data=#{conn.quote(new_params.to_yaml)} WHERE workflow_state NOT IN ('complete', 'pending_review') AND id=#{self.id}")
|
||||
|
||||
new_params
|
||||
end
|
||||
|
||||
def sanitize_params(params)
|
||||
|
|
|
@ -26,8 +26,8 @@ class QuizSubmissionService
|
|||
# @param [Quiz] quiz
|
||||
# The Quiz to take.
|
||||
#
|
||||
# @throw ServiceError(403) if the student isn't allowed to take the quiz
|
||||
# @throw ServiceError(403) if the user is not logged in and the Quiz isn't public
|
||||
# @throw ApiError(403) if the student isn't allowed to take the quiz
|
||||
# @throw ApiError(403) if the user is not logged in and the Quiz isn't public
|
||||
#
|
||||
# See #assert_takeability! for further errors that might be thrown.
|
||||
# See #assert_retriability! for further errors that might be thrown.
|
||||
|
@ -39,7 +39,7 @@ class QuizSubmissionService
|
|||
reject! 403, 'you are not allowed to participate in this quiz'
|
||||
end
|
||||
|
||||
assert_takeability! quiz, participant.access_code, participant.ip_address
|
||||
assert_takeability! quiz
|
||||
|
||||
# Look up an existing QS, and if one exists, make sure it is retriable.
|
||||
assert_retriability! participant.find_quiz_submission(quiz.quiz_submissions, {
|
||||
|
@ -59,7 +59,7 @@ class QuizSubmissionService
|
|||
# @param [Hash] session
|
||||
# The Rails session. Used for testing access permissions.
|
||||
#
|
||||
# @throw ServiceError(403) if the user isn't privileged to update the Quiz
|
||||
# @throw ApiError(403) if the user isn't privileged to update the Quiz
|
||||
#
|
||||
# @return [QuizSubmission]
|
||||
# The newly created preview QS.
|
||||
|
@ -82,8 +82,8 @@ class QuizSubmissionService
|
|||
# The QuizSubmission#attempt that is requested to be completed. This must
|
||||
# match the quiz_submission's current attempt index.
|
||||
#
|
||||
# @throw ServiceError(403) if the participant can't take the quiz
|
||||
# @throw ServiceError(400) if the QS is already complete
|
||||
# @throw ApiError(403) if the participant can't take the quiz
|
||||
# @throw ApiError(400) if the QS is already complete
|
||||
#
|
||||
# Further errors might be thrown from the following methods:
|
||||
#
|
||||
|
@ -102,7 +102,7 @@ class QuizSubmissionService
|
|||
end
|
||||
|
||||
# Participant must be able to take the quiz...
|
||||
assert_takeability! quiz, participant.access_code, participant.ip_address
|
||||
assert_takeability! quiz
|
||||
|
||||
# And be the owner of the quiz submission:
|
||||
validate_token! quiz_submission, participant.validation_token
|
||||
|
@ -197,6 +197,54 @@ class QuizSubmissionService
|
|||
end
|
||||
end
|
||||
|
||||
# Provide an answer to a question, or flag it, while taking a quiz. A snapshot
|
||||
# of the QS will be made with the new answer state.
|
||||
#
|
||||
# @param [Hash] question_record
|
||||
# The "answer record" for the question in the QS's submission_data. This
|
||||
# can be obtained using the QuizQuestion::AnswerSerializers for a given QQ.
|
||||
#
|
||||
# @param [QuizSubmission] quiz_submission
|
||||
# The QS we're manipulating (answering/flagging.)
|
||||
#
|
||||
# @param [Integer] attempt
|
||||
# The attempt index this answer/modification applies to. This must match
|
||||
# the quiz_submission's current attempt index.
|
||||
#
|
||||
# @throw ApiError(403) if the participant can't update the QS (ie, not the owner)
|
||||
# @throw ApiError(400) if the QS is complete or overdue
|
||||
#
|
||||
# Further errors might be thrown from the following methods:
|
||||
#
|
||||
# - #assert_takeability!
|
||||
# - #assert_retriability!
|
||||
# - #validate_token!
|
||||
# - #ensure_latest_attempt!
|
||||
#
|
||||
# @return [Hash] the recently-adjusted submission_data set
|
||||
def update_question(question_record, quiz_submission, attempt, snapshot=true)
|
||||
unless quiz_submission.grants_right?(participant.user, :update)
|
||||
reject! 403, 'you are not allowed to update questions for this quiz submission'
|
||||
end
|
||||
|
||||
if quiz_submission.completed?
|
||||
reject! 400, 'quiz submission is already complete'
|
||||
elsif quiz_submission.overdue?
|
||||
reject! 400, 'quiz submission is overdue'
|
||||
end
|
||||
|
||||
assert_takeability! quiz_submission.quiz
|
||||
|
||||
validate_token! quiz_submission, participant.validation_token
|
||||
|
||||
ensure_latest_attempt! quiz_submission, attempt
|
||||
|
||||
quiz_submission.backup_submission_data question_record.merge({
|
||||
validation_token: participant.validation_token,
|
||||
cnt: snapshot ? 5 : 1 # force generation of snapshot
|
||||
})
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
# Abort the current service request with an error similar to an API error.
|
||||
|
@ -215,26 +263,42 @@ class QuizSubmissionService
|
|||
#
|
||||
# @param [Quiz] quiz
|
||||
# The Quiz we're attempting to take.
|
||||
# @param [String] access_code
|
||||
# The Access Code provided by the client.
|
||||
# @param [String] ip_address
|
||||
# The IP address of the client.
|
||||
#
|
||||
# @throw ServiceError(400) if the Quiz is locked
|
||||
# @throw ServiceError(403) if the access code is invalid
|
||||
# @throw ServiceError(403) if the IP address isn't covered
|
||||
def assert_takeability!(quiz, access_code = nil, ip_address = nil)
|
||||
# @param [QuizParticipant] participant
|
||||
# The person trying to take the quiz.
|
||||
#
|
||||
# @param [String] participant.access_code
|
||||
# The Access Code provided by the participant.
|
||||
#
|
||||
# @param [String] participant.ip_address
|
||||
# The IP address of the participant.
|
||||
#
|
||||
# @throw ApiError(400) if the Quiz is locked
|
||||
# @throw ApiError(501) if the Quiz has the "can't go back" flag on
|
||||
# @throw ApiError(403) if the access code is invalid
|
||||
# @throw ApiError(403) if the IP address isn't covered
|
||||
def assert_takeability!(quiz, participant = self.participant)
|
||||
if quiz.locked?
|
||||
reject! 400, 'quiz is locked'
|
||||
end
|
||||
|
||||
validate_access_code! quiz, access_code
|
||||
validate_ip_address! quiz, ip_address
|
||||
# [Transient:CNVS-10224] - support for CGB-OQAAT quizzes
|
||||
if quiz.cant_go_back
|
||||
reject! 501, 'that type of quizzes is not supported yet'
|
||||
end
|
||||
|
||||
if quiz.access_code.present? && quiz.access_code != participant.access_code
|
||||
reject! 403, 'invalid access code'
|
||||
end
|
||||
|
||||
if quiz.ip_filter && !quiz.valid_ip?(participant.ip_address)
|
||||
reject! 403, 'IP address denied'
|
||||
end
|
||||
end
|
||||
|
||||
# Verify the given QS is retriable.
|
||||
#
|
||||
# @throw ServiceError(409) if the QS is not new and can not be retried
|
||||
# @throw ApiError(409) if the QS is not new and can not be retried
|
||||
#
|
||||
# See QuizSubmission#retriable?
|
||||
def assert_retriability!(quiz_submission)
|
||||
|
@ -243,34 +307,6 @@ class QuizSubmissionService
|
|||
end
|
||||
end
|
||||
|
||||
# Verify that the given access code matches the one set by the Quiz author.
|
||||
#
|
||||
# @param [Quiz] quiz
|
||||
# The Quiz to test.
|
||||
# @param [String] access_code
|
||||
# The user-supplied Access Code to validate.
|
||||
#
|
||||
# @throw ApiError(403) if the access code is invalid
|
||||
def validate_access_code!(quiz, access_code)
|
||||
if quiz.access_code.present? && quiz.access_code != access_code
|
||||
reject! 403, 'invalid access code'
|
||||
end
|
||||
end
|
||||
|
||||
# Verify that the given IP address is allowed to access a Quiz.
|
||||
#
|
||||
# @param [Quiz] quiz
|
||||
# The Quiz to test.
|
||||
# @param [String] ip_address
|
||||
# IP address of the request originated from.
|
||||
#
|
||||
# @throw ApiError(403) if the IP address isn't covered
|
||||
def validate_ip_address!(quiz, ip_address)
|
||||
if quiz.ip_filter && !quiz.valid_ip?(ip_address)
|
||||
reject! 403, 'IP address denied'
|
||||
end
|
||||
end
|
||||
|
||||
def validate_token!(quiz_submission, validation_token)
|
||||
unless quiz_submission.valid_token?(validation_token)
|
||||
reject! 403, 'invalid token'
|
||||
|
@ -290,8 +326,8 @@ class QuizSubmissionService
|
|||
# @param [Integer|String] attempt
|
||||
# The attempt to validate.
|
||||
#
|
||||
# @throw ServiceError(400) if attempt isn't a valid integer
|
||||
# @throw ServiceError(400) if attempt is invalid (ie, isn't the latest one)
|
||||
# @throw ApiError(400) if attempt isn't a valid integer
|
||||
# @throw ApiError(400) if attempt is invalid (ie, isn't the latest one)
|
||||
def ensure_latest_attempt!(quiz_submission, attempt)
|
||||
attempt = Integer(attempt) rescue nil
|
||||
|
||||
|
|
|
@ -1352,6 +1352,12 @@ routes.draw do
|
|||
post 'courses/:course_id/quizzes/:quiz_id/submissions/:id/complete', :action => :complete, :path_name => 'course_quiz_submission_complete'
|
||||
end
|
||||
|
||||
scope(:controller => :quiz_submission_questions) do
|
||||
put '/quiz_submissions/:quiz_submission_id/questions/:id', :action => :answer, :path_name => 'quiz_submission_question_answer'
|
||||
put '/quiz_submissions/:quiz_submission_id/questions/:id/flag', :action => :flag, :path_name => 'quiz_submission_question_flag'
|
||||
put '/quiz_submissions/:quiz_submission_id/questions/:id/unflag', :action => :unflag, :path_name => 'quiz_submission_question_unflag'
|
||||
end
|
||||
|
||||
scope(:controller => :quiz_ip_filters) do
|
||||
get 'courses/:course_id/quizzes/:quiz_id/ip_filters', :action => :index, :path_name => 'course_quiz_ip_filters'
|
||||
end
|
||||
|
|
|
@ -0,0 +1,415 @@
|
|||
<style>
|
||||
.appendix_entry th { text-align: left; }
|
||||
.appendix_entry th,
|
||||
.appendix_entry td {
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.appendix_entry div.syntaxhighlighter {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.appendix_entry div.syntaxhighlighter table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.appendix_entry h4 {
|
||||
color: green;
|
||||
}
|
||||
</style>
|
||||
|
||||
#### Essay Questions
|
||||
|
||||
- Question parametric type: `essay_question`
|
||||
- Parameter type: **`Text`**
|
||||
- Parameter synopsis: `{ "answer": "Answer text." }`
|
||||
|
||||
**Example request**
|
||||
|
||||
```javascript
|
||||
{
|
||||
"answer": "<h2>My essay</h2>\n\n<p>This is a long article.</p>"
|
||||
}
|
||||
```
|
||||
|
||||
**Possible errors**
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>HTTP RC</th>
|
||||
<th>Error Message</th>
|
||||
<th>Cause</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>400 Bad Request</td>
|
||||
<td><code>Text is too long.</code></td>
|
||||
<td>The answer text is larger than the allowed limit of 16 kilobytes.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
#### Fill In Multiple Blanks Questions
|
||||
|
||||
- Question parametric type: `fill_in_multiple_blanks_question`
|
||||
- Parameter type: **`Hash{String => String}`**
|
||||
- Parameter synopsis: `{ "answer": { "variable": "Answer string." } }`
|
||||
|
||||
**Example request**
|
||||
|
||||
Given that the question accepts answers to two variables, `color1` and `color2`:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"answer": {
|
||||
"color1": "red",
|
||||
"color2": "green"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Possible errors**
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>HTTP RC</th>
|
||||
<th>Error Message</th>
|
||||
<th>Cause</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>400 Bad Request</td>
|
||||
<td><code>Unknown variable 'var'.</code></td>
|
||||
<td>The answer map contains a variable that is not accepted by the question.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>400 Bad Request</td>
|
||||
<td><code>Text is too long.</code></td>
|
||||
<td>The answer text is larger than the allowed limit of 16 kilobytes.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
#### Fill In The Blank Questions
|
||||
|
||||
- Question parametric type: `short_answer_question`
|
||||
- Parameter type: **`String`**
|
||||
- Parameter synopsis: `{ "answer": "Some sentence." }`
|
||||
|
||||
**Example request**
|
||||
|
||||
```javascript
|
||||
{
|
||||
"answer": "Hello World!"
|
||||
}
|
||||
```
|
||||
|
||||
**Possible errors**
|
||||
|
||||
Similar to the errors produced by [Essay Questions](#essay-questions).
|
||||
|
||||
<a class="bookmark" id="formula-questions"></a>
|
||||
|
||||
#### Formula Questions
|
||||
|
||||
- Question parametric type: `calculated_question`
|
||||
- Parameter type: **`Decimal`**
|
||||
- Parameter synopsis: `{ "answer": decimal }` where `decimal` is either a rational
|
||||
number, or a literal version of it (String)
|
||||
|
||||
**Example request**
|
||||
|
||||
With an exponent:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"answer": 2.3e-6
|
||||
}
|
||||
```
|
||||
|
||||
With a string for a number:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"answer": "13.4"
|
||||
}
|
||||
```
|
||||
|
||||
**Possible errors**
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>HTTP RC</th>
|
||||
<th>Error Message</th>
|
||||
<th>Cause</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>400 Bad Request</td>
|
||||
<td><code>Parameter must be a valid decimal.</code></td>
|
||||
<td>The specified value could not be processed as a decimal.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
#### Matching Questions
|
||||
|
||||
- Question parametric type: `matching_question`
|
||||
- Parameter type: **`Array<Hash>`**
|
||||
- Parameter synopsis: `{ "answer": [{ "answer_id": id, "match_id": id }] }` where
|
||||
the IDs must identify answers and matches accepted by the question.
|
||||
|
||||
**Example request**
|
||||
|
||||
Given that the question accepts 3 answers with IDs `[ 3, 6, 9 ]` and 6 matches
|
||||
with IDs: `[ 10, 11, 12, 13, 14, 15 ]`:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"answer": [{
|
||||
"answer_id": 6,
|
||||
"match_id": 10
|
||||
}, {
|
||||
"answer_id": 3,
|
||||
"match_id": 14
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
The above request:
|
||||
|
||||
- pairs `answer#6` with `match#10`
|
||||
- pairs `answer#3` with `match#14`
|
||||
- leaves `answer#9` *un-matched*
|
||||
|
||||
**Possible errors**
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>HTTP RC</th>
|
||||
<th>Error Message</th>
|
||||
<th>Cause</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>400 Bad Request</td>
|
||||
<td><code>Answer must be of type Array.</code></td>
|
||||
<td>The match-pairings set you supplied is not an array.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>400 Bad Request</td>
|
||||
<td><code>Answer entry must be of type Hash, got '...'.</code></td>
|
||||
<td>One of the entries of the match-pairings set is not a valid hash.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>400 Bad Request</td>
|
||||
<td><code>Missing parameter 'answer_id'.</code></td>
|
||||
<td>One of the entries of the match-pairings does not specify an <code>answer_id</code>.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>400 Bad Request</td>
|
||||
<td><code>Missing parameter 'match_id'.</code></td>
|
||||
<td>One of the entries of the match-pairings does not specify an <code>match_id</code>.</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td>400 Bad Request</td>
|
||||
<td><code>Parameter must be of type Integer.</code></td>
|
||||
<td>
|
||||
One of the specified <code>answer_id</code> or <code>match_id</code>
|
||||
is not an integer.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>400 Bad Request</td>
|
||||
<td><code>Unknown answer '123'.</code></td>
|
||||
<td>An <code>answer_id</code> you supplied does not identify a valid answer
|
||||
for that question.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>400 Bad Request</td>
|
||||
<td><code>Unknown match '123'.</code></td>
|
||||
<td>A <code>match_id</code> you supplied does not identify a valid match
|
||||
for that question.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<a class="bookmark" id="multiple-choice-questions"></a>
|
||||
|
||||
|
||||
#### Multiple Choice Questions
|
||||
|
||||
- Question parametric type: `multiple_choice_question`
|
||||
- Parameter type: **`Integer`**
|
||||
- Parameter synopsis: `{ "answer": answer_id }` where `answer_id` is an ID of
|
||||
one of the question's answers.
|
||||
|
||||
**Example request**
|
||||
|
||||
Given an answer with an ID of 5:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"answer": 5
|
||||
}
|
||||
```
|
||||
|
||||
**Possible errors**
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>HTTP RC</th>
|
||||
<th>Error Message</th>
|
||||
<th>Cause</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>400 Bad Request</td>
|
||||
<td><code>Parameter must be of type Integer.</code></td>
|
||||
<td>The specified `answer_id` is not an integer.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>400 Bad Request</td>
|
||||
<td><code>Unknown answer '123'</code></td>
|
||||
<td>The specified `answer_id` is not a valid answer.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
#### Multiple Dropdowns Questions
|
||||
|
||||
- Question parametric type: `multiple_dropdowns_question`
|
||||
- Parameter type: **`Hash{String => Integer}`**
|
||||
- Parameter synopsis: `{ "answer": { "variable": answer_id } }` where the keys
|
||||
are variables accepted by the question, and their values are IDs of answers
|
||||
provided by the question.
|
||||
|
||||
**Example request**
|
||||
|
||||
Given that the question accepts 3 answers to a variable named `color` with the
|
||||
ids `[ 3, 6, 9 ]`:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"answer": {
|
||||
"color": 6
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Possible errors**
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>HTTP RC</th>
|
||||
<th>Error Message</th>
|
||||
<th>Cause</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>400 Bad Request</td>
|
||||
<td><code>Unknown variable 'var'.</code></td>
|
||||
<td>The answer map you supplied contains a variable that is not accepted
|
||||
by the question.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>400 Bad Request</td>
|
||||
<td><code>Unknown answer '123'.</code></td>
|
||||
<td>An <code>answer_id</code> you supplied does not identify a valid answer
|
||||
for that question.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
#### Multiple Answers Questions
|
||||
|
||||
- Question parametric type: `multiple_answers_question`
|
||||
- Parameter type: **`Array<Integer>`**
|
||||
- Parameter synopsis: `{ "answer": [ answer_id ] }` where the array items are
|
||||
IDs of answers accepted by the question.
|
||||
|
||||
**Example request**
|
||||
|
||||
Given that the question accepts 3 answers with the ids `[ 3, 6, 9 ]` and we
|
||||
want to select the answers `3` and `6`:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"answer": [ 3, 6 ]
|
||||
}
|
||||
```
|
||||
|
||||
**Possible errors**
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>HTTP RC</th>
|
||||
<th>Error Message</th>
|
||||
<th>Cause</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>400 Bad Request</td>
|
||||
<td><code>Selection must be of type Array.</code></td>
|
||||
<td>The selection set you supplied is not an array.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>400 Bad Request</td>
|
||||
<td><code>Parameter must be of type Integer.</code></td>
|
||||
<td>One of the answer IDs you supplied is not a valid ID.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>400 Bad Request</td>
|
||||
<td><code>Unknown answer '123'.</code></td>
|
||||
<td>An answer ID you supplied in the selection set does not identify a
|
||||
valid answer for that question.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
#### Numerical Questions
|
||||
|
||||
- Question parametric type: `numerical_question`
|
||||
|
||||
This is similar to [Formula Questions](#formula-questions).
|
||||
|
||||
<a class="bookmark" id="essay-questions"></a>
|
||||
|
||||
#### True/False Questions
|
||||
|
||||
- Question parametric type: `true_false_question`
|
||||
|
||||
The rest is similar to [Multiple Choice questions](#multiple-choice-questions).
|
|
@ -25,8 +25,9 @@ module Api::V1::Helpers::QuizSubmissionsApiHelper
|
|||
|
||||
def require_quiz_submission
|
||||
collection = @quiz ? @quiz.quiz_submissions : QuizSubmission
|
||||
id = params[:quiz_submission_id] || params[:id] || ''
|
||||
|
||||
unless @quiz_submission = collection.find(params[:id])
|
||||
unless @quiz_submission = collection.find(id.to_i)
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
#
|
||||
# Copyright (C) 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/>.
|
||||
#
|
||||
|
||||
module Api::V1::QuizSubmissionQuestion
|
||||
|
||||
def quiz_submission_questions_json(quiz_questions, submission_data)
|
||||
{
|
||||
quiz_submission_questions: quiz_questions.map do |qq|
|
||||
quiz_submission_question_json(qq, submission_data)
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
# A renderable version of a QuizQuestion's "answer" record in a submission's
|
||||
# answer set. The answer construct contains three pieces of data:
|
||||
#
|
||||
# - the question's id
|
||||
# - its "flagged" status
|
||||
# - a representation of its answer which depends on the type of question it
|
||||
# is. See QuizQuestion::AnswerSerializers for possible answer formats.
|
||||
#
|
||||
# @param [QuizQuestion] qq
|
||||
# A question of a Quiz.
|
||||
#
|
||||
# @param [Hash] submission_data
|
||||
# The QuizSubmission#submission_data in which the question's answer record
|
||||
# will be looked up and serialized.
|
||||
#
|
||||
# @return [Hash]
|
||||
# The question's answer record. See example for what it contains.
|
||||
#
|
||||
# @example output for a multiple-choice quiz question
|
||||
# {
|
||||
# id: 5,
|
||||
# flagged: true,
|
||||
# answer: 123
|
||||
# }
|
||||
def quiz_submission_question_json(qq, submission_data)
|
||||
answer_serializer = QuizQuestion::AnswerSerializers.serializer_for(qq)
|
||||
|
||||
data = {}
|
||||
data[:id] = qq.id
|
||||
data[:flagged] = to_boolean(submission_data["question_#{qq.id}_marked"])
|
||||
data[:answer] = answer_serializer.deserialize(submission_data)
|
||||
data
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def to_boolean(v)
|
||||
Canvas::Plugin.value_to_boolean(v)
|
||||
end
|
||||
end
|
|
@ -71,7 +71,18 @@ define([
|
|||
cantGoBack: $("#submit_quiz_form").hasClass("cant_go_back"),
|
||||
finalSubmitButtonClicked: false,
|
||||
clockInterval: 500,
|
||||
backupsDisabled: document.location.search.search(/backup=false/) > -1,
|
||||
updateSubmission: function(repeat, beforeLeave, autoInterval) {
|
||||
/**
|
||||
* Transient: CNVS-9844
|
||||
* Disable auto-backups if backup=true was passed as a query parameter.
|
||||
*
|
||||
* This is required to test updating questions via the API.
|
||||
*/
|
||||
if (quizSubmission.backupsDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(quizSubmission.submitting && !repeat) { return; }
|
||||
var now = new Date();
|
||||
if((now - quizSubmission.lastSubmissionUpdate) < 1000 && !autoInterval) {
|
||||
|
|
|
@ -0,0 +1,348 @@
|
|||
#
|
||||
# Copyright (C) 2013 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__) + '/../api_spec_helper')
|
||||
|
||||
describe QuizSubmissionQuestionsController, :type => :integration do
|
||||
module Helpers
|
||||
def create_question(type, factory_options = {}, quiz=@quiz)
|
||||
factory = method(:"#{type}_question_data")
|
||||
|
||||
# can't test for #arity directly since it might be an optional parameter
|
||||
data = factory.parameters.include?([ :opt, :options ]) ?
|
||||
factory.call(factory_options) :
|
||||
factory.call
|
||||
|
||||
data = data.except('id', 'assessment_question_id')
|
||||
|
||||
qq = quiz.quiz_questions.create!({ question_data: data })
|
||||
qq.assessment_question.question_data = data
|
||||
qq.assessment_question.save!
|
||||
|
||||
qq
|
||||
end
|
||||
|
||||
def api_update(data = {}, options = {})
|
||||
data = {
|
||||
validation_token: @quiz_submission.validation_token,
|
||||
attempt: @quiz_submission.attempt
|
||||
}.merge(data)
|
||||
|
||||
helper = method(options[:raw] ? :raw_api_call : :api_call)
|
||||
helper.call(:put,
|
||||
"/api/v1/quiz_submissions/#{@quiz_submission.id}/questions/#{@question[:id]}",
|
||||
{ :controller => 'quiz_submission_questions',
|
||||
:action => 'answer',
|
||||
:format => 'json',
|
||||
:quiz_submission_id => @quiz_submission.id.to_s,
|
||||
:id => @question[:id].to_s
|
||||
}, data)
|
||||
end
|
||||
|
||||
def api_flag(data = {}, options = {})
|
||||
data = {
|
||||
validation_token: @quiz_submission.validation_token,
|
||||
attempt: @quiz_submission.attempt
|
||||
}.merge(data)
|
||||
|
||||
helper = method(options[:raw] ? :raw_api_call : :api_call)
|
||||
helper.call(:put,
|
||||
"/api/v1/quiz_submissions/#{@quiz_submission.id}/questions/#{@question[:id]}/flag",
|
||||
{ :controller => 'quiz_submission_questions',
|
||||
:action => 'flag',
|
||||
:format => 'json',
|
||||
:quiz_submission_id => @quiz_submission.id.to_s,
|
||||
:id => @question[:id].to_s
|
||||
}, data)
|
||||
end
|
||||
|
||||
def api_unflag(data = {}, options = {})
|
||||
data = {
|
||||
validation_token: @quiz_submission.validation_token,
|
||||
attempt: @quiz_submission.attempt
|
||||
}.merge(data)
|
||||
|
||||
helper = method(options[:raw] ? :raw_api_call : :api_call)
|
||||
helper.call(:put,
|
||||
"/api/v1/quiz_submissions/#{@quiz_submission.id}/questions/#{@question[:id]}/unflag",
|
||||
{ :controller => 'quiz_submission_questions',
|
||||
:action => 'unflag',
|
||||
:format => 'json',
|
||||
:quiz_submission_id => @quiz_submission.id.to_s,
|
||||
:id => @question[:id].to_s
|
||||
}, data)
|
||||
end
|
||||
end
|
||||
|
||||
include Helpers
|
||||
|
||||
before :each do
|
||||
course_with_student_logged_in(:active_all => true)
|
||||
@quiz = quiz_model(course: @course)
|
||||
@quiz_submission = @quiz.generate_submission(@student)
|
||||
end
|
||||
|
||||
describe 'PUT /quiz_submissions/:quiz_submission_id/questions/:id [update]' do
|
||||
context 'answering questions' do
|
||||
it 'should answer a MultipleChoice question' do
|
||||
@question = create_question 'multiple_choice'
|
||||
|
||||
json = api_update({
|
||||
answer: 1658
|
||||
})
|
||||
|
||||
json['quiz_submission_questions'].present?.should be_true
|
||||
json['quiz_submission_questions'].length.should == 1
|
||||
json['quiz_submission_questions'][0]['answer'].should == 1658
|
||||
end
|
||||
|
||||
it 'should answer a TrueFalse question' do
|
||||
@question = create_question 'true_false'
|
||||
|
||||
json = api_update({
|
||||
answer: 8403
|
||||
})
|
||||
|
||||
json['quiz_submission_questions'].present?.should be_true
|
||||
json['quiz_submission_questions'].length.should == 1
|
||||
json['quiz_submission_questions'][0]['answer'].should == 8403
|
||||
end
|
||||
|
||||
it 'should answer a ShortAnswer question' do
|
||||
@question = create_question 'short_answer'
|
||||
|
||||
json = api_update({
|
||||
answer: 'Hello World!'
|
||||
})
|
||||
|
||||
json['quiz_submission_questions'].present?.should be_true
|
||||
json['quiz_submission_questions'].length.should == 1
|
||||
json['quiz_submission_questions'][0]['answer'].should == 'hello world!'
|
||||
end
|
||||
|
||||
it 'should answer a FillInMultipleBlanks question' do
|
||||
@question = create_question 'fill_in_multiple_blanks'
|
||||
|
||||
json = api_update({
|
||||
answer: {
|
||||
answer1: 'red',
|
||||
answer3: 'green',
|
||||
answer4: 'blue'
|
||||
}
|
||||
})
|
||||
|
||||
json['quiz_submission_questions'].present?.should be_true
|
||||
json['quiz_submission_questions'].length.should == 1
|
||||
json['quiz_submission_questions'][0]['answer'].should == {
|
||||
answer1: 'red',
|
||||
answer3: 'green',
|
||||
answer4: 'blue'
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
it 'should answer a MultipleAnswers question' do
|
||||
@question = create_question 'multiple_answers', {
|
||||
answer_parser_compatibility: true
|
||||
}
|
||||
|
||||
json = api_update({
|
||||
answer: [ 9761, 5194 ]
|
||||
})
|
||||
|
||||
json['quiz_submission_questions'].present?.should be_true
|
||||
json['quiz_submission_questions'][0]['answer'].include?(9761).should be_true
|
||||
json['quiz_submission_questions'][0]['answer'].include?(5194).should be_true
|
||||
end
|
||||
|
||||
it 'should answer an Essay question' do
|
||||
@question = create_question 'essay'
|
||||
|
||||
json = api_update({
|
||||
answer: 'Foobar'
|
||||
})
|
||||
|
||||
json['quiz_submission_questions'].present?.should be_true
|
||||
json['quiz_submission_questions'][0]['answer'].should == 'Foobar'
|
||||
end
|
||||
|
||||
it 'should answer a MultipleDropdowns question' do
|
||||
@question = create_question 'multiple_dropdowns'
|
||||
|
||||
json = api_update({
|
||||
answer: {
|
||||
structure1: 4390,
|
||||
event2: 599
|
||||
}
|
||||
})
|
||||
|
||||
json['quiz_submission_questions'].present?.should be_true
|
||||
json['quiz_submission_questions'][0]['answer'].should == {
|
||||
structure1: 4390,
|
||||
event2: 599
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
it 'should answer a Matching question' do
|
||||
@question = create_question 'matching', {
|
||||
answer_parser_compatibility: true
|
||||
}
|
||||
|
||||
json = api_update({
|
||||
answer: [
|
||||
{ answer_id: 7396, match_id: 6061 },
|
||||
{ answer_id: 4224, match_id: 3855 }
|
||||
]
|
||||
})
|
||||
|
||||
json['quiz_submission_questions'].present?.should be_true
|
||||
|
||||
answer = json['quiz_submission_questions'][0]['answer']
|
||||
answer
|
||||
.include?({ answer_id: 7396, match_id: 6061 }.with_indifferent_access)
|
||||
.should be_true
|
||||
|
||||
answer
|
||||
.include?({ answer_id: 4224, match_id: 3855 }.with_indifferent_access)
|
||||
.should be_true
|
||||
end
|
||||
|
||||
it 'should answer a Numerical question' do
|
||||
@question = create_question 'numerical'
|
||||
|
||||
json = api_update({
|
||||
answer: 2.5e-3
|
||||
})
|
||||
|
||||
json['quiz_submission_questions'].present?.should be_true
|
||||
json['quiz_submission_questions'][0]['answer'].should == 0.0025
|
||||
end
|
||||
|
||||
it 'should answer a Calculated question' do
|
||||
@question = create_question 'calculated'
|
||||
|
||||
json = api_update({
|
||||
answer: '122.1'
|
||||
})
|
||||
|
||||
json['quiz_submission_questions'].present?.should be_true
|
||||
json['quiz_submission_questions'][0]['answer'].should == 122.1
|
||||
end
|
||||
end
|
||||
|
||||
it 'should update an answer' do
|
||||
@question = create_question 'multiple_choice'
|
||||
|
||||
json = api_update({
|
||||
answer: 1658
|
||||
})
|
||||
|
||||
json['quiz_submission_questions'].present?.should be_true
|
||||
json['quiz_submission_questions'].length.should == 1
|
||||
json['quiz_submission_questions'][0]['answer'].should == 1658
|
||||
|
||||
@question = create_question 'multiple_choice'
|
||||
|
||||
json = api_update({
|
||||
answer: 2405
|
||||
})
|
||||
|
||||
json['quiz_submission_questions'].present?.should be_true
|
||||
json['quiz_submission_questions'].length.should == 1
|
||||
json['quiz_submission_questions'][0]['answer'].should == 2405
|
||||
end
|
||||
|
||||
it 'should answer according to the published state of the question' do
|
||||
@question = create_question 'multiple_choice'
|
||||
|
||||
new_question_data = @question.question_data
|
||||
new_question_data[:answers].each do |answer_record|
|
||||
answer_record[:id] += 1
|
||||
end
|
||||
@question.question_data = new_question_data
|
||||
@question.save!
|
||||
|
||||
api_update({ answer: 1658 }, { raw: true })
|
||||
|
||||
response.status.to_i.should == 400
|
||||
response.body.should match(/unknown answer '1658'/i)
|
||||
|
||||
json = api_update({ answer: 1659 })
|
||||
|
||||
json['quiz_submission_questions'].present?.should be_true
|
||||
json['quiz_submission_questions'].length.should == 1
|
||||
json['quiz_submission_questions'][0]['answer'].should == 1659
|
||||
end
|
||||
|
||||
it 'should present errors' do
|
||||
@question = create_question 'multiple_choice'
|
||||
|
||||
api_update({ answer: 'asdf' }, { raw: true })
|
||||
|
||||
response.status.to_i.should == 400
|
||||
response.body.should match(/must be of type integer/i)
|
||||
end
|
||||
|
||||
# This is duplicated from QuizSubmissionsApiController spec and will be
|
||||
# moved into a Controller Filter spec once CNVS-10071 is in.
|
||||
#
|
||||
# [Transient:CNVS-10071]
|
||||
it 'should respect the quiz LDB requirement' do
|
||||
@question = create_question 'multiple_choice'
|
||||
@quiz.require_lockdown_browser = true
|
||||
@quiz.save
|
||||
|
||||
Quiz.stubs(:lockdown_browser_plugin_enabled?).returns true
|
||||
|
||||
fake_plugin = Object.new
|
||||
fake_plugin.stubs(:authorized?).returns false
|
||||
fake_plugin.stubs(:base).returns fake_plugin
|
||||
|
||||
subject.stubs(:ldb_plugin).returns fake_plugin
|
||||
Canvas::LockdownBrowser.stubs(:plugin).returns fake_plugin
|
||||
|
||||
api_update({}, { raw: true })
|
||||
|
||||
response.status.to_i.should == 403
|
||||
response.body.should match(/requires the lockdown browser/i)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /quiz_submissions/:quiz_submission_id/questions/:id/flag [flag]' do
|
||||
it 'should flag the question' do
|
||||
@question = create_question('multiple_choice')
|
||||
|
||||
json = api_flag
|
||||
|
||||
json['quiz_submission_questions'].present?.should be_true
|
||||
json['quiz_submission_questions'].length.should == 1
|
||||
json['quiz_submission_questions'][0]['flagged'].should == true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /quiz_submissions/:quiz_submission_id/questions/:id/unflag [unflag]' do
|
||||
it 'should unflag the question' do
|
||||
@question = create_question('multiple_choice')
|
||||
|
||||
json = api_unflag
|
||||
|
||||
json['quiz_submission_questions'].present?.should be_true
|
||||
json['quiz_submission_questions'].length.should == 1
|
||||
json['quiz_submission_questions'][0]['flagged'].should == false
|
||||
end
|
||||
end
|
||||
end
|
|
@ -19,30 +19,6 @@
|
|||
require File.expand_path(File.dirname(__FILE__) + '/../api_spec_helper')
|
||||
|
||||
shared_examples_for 'Quiz Submissions API Restricted Endpoints' do
|
||||
it 'should require a valid access token' do
|
||||
@quiz.access_code = 'foobar'
|
||||
@quiz.save!
|
||||
|
||||
@request_proxy.call true, {
|
||||
attempt: 1
|
||||
}
|
||||
|
||||
response.status.to_i.should == 403
|
||||
response.body.should match(/invalid access code/)
|
||||
end
|
||||
|
||||
it 'should require a valid ip' do
|
||||
@quiz.ip_filter = '10.0.0.1/24'
|
||||
@quiz.save!
|
||||
|
||||
@request_proxy.call true, {
|
||||
attempt: 1
|
||||
}
|
||||
|
||||
response.status.to_i.should == 403
|
||||
response.body.should match(/ip address denied/i)
|
||||
end
|
||||
|
||||
it 'should require the LDB' do
|
||||
@quiz.require_lockdown_browser = true
|
||||
@quiz.save
|
||||
|
@ -168,8 +144,6 @@ describe QuizSubmissionsApiController, type: :request do
|
|||
@quiz.published_at = Time.now
|
||||
@quiz.workflow_state = 'available'
|
||||
@quiz.save!
|
||||
|
||||
@assignment = @quiz.assignment
|
||||
end
|
||||
|
||||
describe 'GET /courses/:course_id/quizzes/:quiz_id/submissions [INDEX]' do
|
||||
|
@ -339,28 +313,12 @@ describe QuizSubmissionsApiController, type: :request do
|
|||
qs_api_create
|
||||
end
|
||||
|
||||
context 'parameter, permission, and state validations' do
|
||||
context 'access validations' do
|
||||
it_should_behave_like 'Quiz Submissions API Restricted Endpoints'
|
||||
|
||||
before :each do
|
||||
@request_proxy = method(:qs_api_create)
|
||||
end
|
||||
|
||||
it 'should reject creating a QS when one already exists' do
|
||||
qs_api_create
|
||||
qs_api_create(true)
|
||||
response.status.to_i.should == 409
|
||||
end
|
||||
|
||||
it 'should respect the number of allowed attempts' do
|
||||
json = qs_api_create
|
||||
qs = QuizSubmission.find(json['quiz_submissions'][0]['id'])
|
||||
qs.mark_completed
|
||||
qs.save!
|
||||
|
||||
qs_api_create(true)
|
||||
response.status.to_i.should == 409
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -382,7 +340,7 @@ describe QuizSubmissionsApiController, type: :request do
|
|||
json['quiz_submissions'][0]['workflow_state'].should == 'complete'
|
||||
end
|
||||
|
||||
context 'parameter, permission, and state validations' do
|
||||
context 'access validations' do
|
||||
it_should_behave_like 'Quiz Submissions API Restricted Endpoints'
|
||||
|
||||
before do
|
||||
|
|
|
@ -113,8 +113,12 @@ def multiple_dropdowns_question_data
|
|||
], "question_text"=>"[structure1] [event1] [structure2] [structure3] [structure4] [structure5] [structure6] [event2] [structure7]"}.with_indifferent_access
|
||||
end
|
||||
|
||||
def matching_question_data
|
||||
{"id" => 1, "name"=>"Question", "correct_comments"=>"", "question_type"=>"matching_question", "assessment_question_id"=>4, "neutral_comments"=>"", "incorrect_comments"=>"", "question_name"=>"Question", "points_possible"=>50.0, "matches"=>[{"match_id"=>6061, "text"=>"1"}, {"match_id"=>3855, "text"=>"2"}, {"match_id"=>1397, "text"=>"1"}, {"match_id"=>2369, "text"=>"3"}, {"match_id"=>6065, "text"=>"4"}, {"match_id"=>5779, "text"=>"5"}, {"match_id"=>3562, "text"=>"6"}, {"match_id"=>1500, "text"=>"7"}, {"match_id"=>8513, "text"=>"8"}, {"match_id" => 6067, "text" => "a2"}, {"match_id" => 6068, "text" => "a3"}, {"match_id" => 6069, "text" => "a4"}], "answers"=>[
|
||||
# @param [Hash] options
|
||||
# @param [Boolean] options.answer_parser_compatibility
|
||||
# Set this to true if you want the fixture to be compatible with
|
||||
# QuizQuestion::AnswerParsers::Matching.
|
||||
def matching_question_data(options = {})
|
||||
data = {"id" => 1, "name"=>"Question", "correct_comments"=>"", "question_type"=>"matching_question", "assessment_question_id"=>4, "neutral_comments"=>"", "incorrect_comments"=>"", "question_name"=>"Question", "points_possible"=>50.0, "matches"=>[{"match_id"=>6061, "text"=>"1"}, {"match_id"=>3855, "text"=>"2"}, {"match_id"=>1397, "text"=>"1"}, {"match_id"=>2369, "text"=>"3"}, {"match_id"=>6065, "text"=>"4"}, {"match_id"=>5779, "text"=>"5"}, {"match_id"=>3562, "text"=>"6"}, {"match_id"=>1500, "text"=>"7"}, {"match_id"=>8513, "text"=>"8"}, {"match_id" => 6067, "text" => "a2"}, {"match_id" => 6068, "text" => "a3"}, {"match_id" => 6069, "text" => "a4"}], "answers"=>[
|
||||
{"left"=>"a", "comments"=>"", "match_id"=>6061, "text"=>"a", "id"=>7396, "right"=>"1"},
|
||||
{"left"=>"b", "comments"=>"", "match_id"=>3855, "text"=>"b", "id"=>6081, "right"=>"2"},
|
||||
{"left"=>"ca", "comments"=>"", "match_id"=>1397, "text"=>"ca", "id"=>4224, "right"=>"1"},
|
||||
|
@ -122,6 +126,25 @@ def matching_question_data
|
|||
{"left"=>"a3", "comments"=>"", "match_id"=>6068, "text"=>"a", "id"=>7398, "right"=>"a3"},
|
||||
{"left"=>"a4", "comments"=>"", "match_id"=>6069, "text"=>"a", "id"=>7399, "right"=>"a4"},
|
||||
], "question_text"=>"<p>Test Question</p>"}.with_indifferent_access
|
||||
|
||||
if options[:answer_parser_compatibility]
|
||||
data['answers'].each do |record|
|
||||
record['answer_match_left'] = record['left']
|
||||
record['answer_match_text'] = record['text']
|
||||
record['answer_match_right'] = record['right']
|
||||
record['answer_comments'] = record['comments']
|
||||
|
||||
%w[ left text right comments ].each { |k| record.delete k }
|
||||
end
|
||||
|
||||
# match#1397 has a duplicate text with #7396 that needs to be adjusted
|
||||
i = data['matches'].index { |record| record['match_id'] == 1397 }
|
||||
data['matches'][i]['text'] = '_1'
|
||||
i = data['answers'].index { |record| record['match_id'] == 1397 }
|
||||
data['answers'][i]['text'] = '_1'
|
||||
end
|
||||
|
||||
data
|
||||
end
|
||||
|
||||
def numerical_question_data
|
||||
|
@ -158,8 +181,12 @@ def calculated_question_data
|
|||
"id" => 1}.with_indifferent_access
|
||||
end
|
||||
|
||||
def multiple_answers_question_data
|
||||
{"name"=>"Question",
|
||||
# @param [Hash] options
|
||||
# @param [Boolean] options.answer_parser_compatibility
|
||||
# Set this to true if you want the fixture to be compatible with
|
||||
# QuizQuestion::AnswerParsers::MultipleAnswers.
|
||||
def multiple_answers_question_data(options = {})
|
||||
data = {"name"=>"Question",
|
||||
"correct_comments"=>"",
|
||||
"question_type"=>"multiple_answers_question",
|
||||
"assessment_question_id"=>8197062,
|
||||
|
@ -178,6 +205,14 @@ def multiple_answers_question_data
|
|||
{"comments"=>"", "weight"=>100, "text"=>"431", "id"=>9701},
|
||||
{"comments"=>"", "weight"=>0, "text"=>"schadenfreude", "id"=>7381}],
|
||||
"question_text"=>"<p>which of these are numbers?</p>", "id" => 1}.with_indifferent_access
|
||||
|
||||
if options[:answer_parser_compatibility]
|
||||
data['answers'].each do |record|
|
||||
record['answer_weight'] = record['weight']
|
||||
end
|
||||
end
|
||||
|
||||
data
|
||||
end
|
||||
|
||||
def fill_in_multiple_blanks_question_data
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
|
||||
|
||||
describe QuizQuestion::AnswerSerializers::AnswerSerializer do
|
||||
ASes = QuizQuestion::AnswerSerializers
|
||||
|
||||
it 'automatically registers answer serializers' do
|
||||
serializer = nil
|
||||
|
||||
qq = {}
|
||||
qq.stubs(:data).returns { { question_type: 'uber_hax_question' } }
|
||||
|
||||
expect { ASes.serializer_for qq }.to raise_error
|
||||
|
||||
class UberHax < QuizQuestion::AnswerSerializers::AnswerSerializer
|
||||
end
|
||||
|
||||
expect { serializer = ASes.serializer_for qq }.to_not raise_error
|
||||
|
||||
serializer.is_a?(ASes::AnswerSerializer).should be_true
|
||||
end
|
||||
end
|
|
@ -0,0 +1,25 @@
|
|||
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
|
||||
require File.expand_path(File.dirname(__FILE__) + '/support/answer_serializers_specs.rb')
|
||||
require File.expand_path(File.dirname(__FILE__) + '/support/textual_answer_serializers_specs.rb')
|
||||
|
||||
describe QuizQuestion::AnswerSerializers::Essay do
|
||||
it_should_behave_like 'Answer Serializers'
|
||||
|
||||
let :input do
|
||||
'Hello World!'
|
||||
end
|
||||
|
||||
let :output do
|
||||
{
|
||||
question_5: 'Hello World!'
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
it 'should return nil when un-answered' do
|
||||
subject.deserialize({}).should == nil
|
||||
end
|
||||
|
||||
context 'validations' do
|
||||
it_should_behave_like 'Textual Answer Serializers'
|
||||
end
|
||||
end
|
|
@ -0,0 +1,57 @@
|
|||
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
|
||||
require File.expand_path(File.dirname(__FILE__) + '/support/answer_serializers_specs.rb')
|
||||
require File.expand_path(File.dirname(__FILE__) + '/support/textual_answer_serializers_specs.rb')
|
||||
|
||||
describe QuizQuestion::AnswerSerializers::FillInMultipleBlanks do
|
||||
it_should_behave_like 'Answer Serializers'
|
||||
|
||||
let :input do
|
||||
{
|
||||
answer1: 'Red',
|
||||
answer3: 'Green',
|
||||
answer4: 'Blue'
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
let :output do
|
||||
{
|
||||
"question_5_#{AssessmentQuestion.variable_id 'answer1'}" => 'red',
|
||||
"question_5_#{AssessmentQuestion.variable_id 'answer3'}" => 'green',
|
||||
"question_5_#{AssessmentQuestion.variable_id 'answer4'}" => 'blue'
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
Util = QuizQuestion::AnswerSerializers::Util
|
||||
|
||||
# needed for auto specs
|
||||
def sanitize(answer_hash)
|
||||
answer_hash.each_pair do |variable, answer_text|
|
||||
answer_hash[variable] = Util.sanitize_text(answer_text)
|
||||
end
|
||||
|
||||
answer_hash
|
||||
end
|
||||
|
||||
# needed for auto specs
|
||||
def format(answer_text)
|
||||
{ answer1: answer_text }
|
||||
end
|
||||
|
||||
context 'validations' do
|
||||
it_should_behave_like 'Textual Answer Serializers'
|
||||
|
||||
it 'should reject unexpected types' do
|
||||
[ 'asdf', nil ].each do |bad_input|
|
||||
rc = subject.serialize(bad_input)
|
||||
rc.error.should_not be_nil
|
||||
rc.error.should match /must be of type hash/i
|
||||
end
|
||||
end
|
||||
|
||||
it 'should reject an answer to an unknown blank' do
|
||||
rc = subject.serialize({ foobar: 'yeeeeeeeeee' })
|
||||
rc.error.should_not be_nil
|
||||
rc.error.should match /unknown blank/i
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,92 @@
|
|||
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
|
||||
require File.expand_path(File.dirname(__FILE__) + '/support/answer_serializers_specs.rb')
|
||||
|
||||
describe QuizQuestion::AnswerSerializers::Matching do
|
||||
it_should_behave_like 'Answer Serializers'
|
||||
|
||||
let :input do
|
||||
[
|
||||
{ answer_id: 7396, match_id: 6061 }.with_indifferent_access,
|
||||
{ answer_id: 4224, match_id: 3855 }.with_indifferent_access
|
||||
]
|
||||
end
|
||||
|
||||
let :output do
|
||||
{
|
||||
"question_5_answer_7396" => "6061",
|
||||
"question_5_answer_4224" => "3855"
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
let :factory_options do
|
||||
{
|
||||
answer_parser_compatibility: true
|
||||
}
|
||||
end
|
||||
|
||||
context 'validations' do
|
||||
it 'should reject a bad pairing set' do
|
||||
[ nil, 'asdf' ].each do |bad_input|
|
||||
rc = subject.serialize(bad_input)
|
||||
rc.error.should_not be_nil
|
||||
rc.error.should match(/of type array/i)
|
||||
end
|
||||
end
|
||||
|
||||
it 'should reject a bad pairing entry' do
|
||||
rc = subject.serialize([ 'asdf' ])
|
||||
rc.error.should_not be_nil
|
||||
rc.error.should match(/of type hash/i)
|
||||
end
|
||||
|
||||
it 'should reject a pairing entry missing a required parameter' do
|
||||
rc = subject.serialize([ match_id: 123 ])
|
||||
rc.error.should_not be_nil
|
||||
rc.error.should match(/missing parameter "answer_id"/i)
|
||||
|
||||
rc = subject.serialize([ answer_id: 123 ])
|
||||
rc.error.should_not be_nil
|
||||
rc.error.should match(/missing parameter "match_id"/i)
|
||||
end
|
||||
|
||||
it 'should reject a match for an unknown answer' do
|
||||
rc = subject.serialize([{
|
||||
answer_id: 123,
|
||||
match_id: 6061
|
||||
}])
|
||||
|
||||
rc.error.should_not be_nil
|
||||
rc.error.should match(/unknown answer/i)
|
||||
end
|
||||
|
||||
it 'should reject an unknown match' do
|
||||
rc = subject.serialize([{
|
||||
answer_id: 7396,
|
||||
match_id: 123456
|
||||
}])
|
||||
|
||||
rc.error.should_not be_nil
|
||||
rc.error.should match(/unknown match/i)
|
||||
end
|
||||
|
||||
it 'should reject a bad match' do
|
||||
rc = subject.serialize([{
|
||||
answer_id: 7396,
|
||||
match_id: 'adooken'
|
||||
}])
|
||||
|
||||
rc.error.should_not be_nil
|
||||
rc.error.should match(/must be of type integer/i)
|
||||
end
|
||||
|
||||
it 'should reject a bad answer' do
|
||||
rc = subject.serialize([{
|
||||
answer_id: 'ping',
|
||||
match_id: 6061
|
||||
}])
|
||||
|
||||
rc.error.should_not be_nil
|
||||
rc.error.should match(/must be of type integer/i)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,48 @@
|
|||
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
|
||||
require File.expand_path(File.dirname(__FILE__) + '/support/answer_serializers_specs.rb')
|
||||
require File.expand_path(File.dirname(__FILE__) + '/support/id_answer_serializers_specs.rb')
|
||||
|
||||
describe QuizQuestion::AnswerSerializers::MultipleAnswers do
|
||||
it_should_behave_like 'Answer Serializers'
|
||||
|
||||
let :factory_options do
|
||||
{
|
||||
answer_parser_compatibility: true
|
||||
}
|
||||
end
|
||||
|
||||
let :input do
|
||||
[ 9761 ]
|
||||
end
|
||||
|
||||
let :output do
|
||||
{
|
||||
"question_5_answer_9761" => "1",
|
||||
"question_5_answer_3079" => "0",
|
||||
"question_5_answer_5194" => "0",
|
||||
"question_5_answer_166" => "0",
|
||||
"question_5_answer_4739" => "0",
|
||||
"question_5_answer_2196" => "0",
|
||||
"question_5_answer_8982" => "0",
|
||||
"question_5_answer_9701" => "0",
|
||||
"question_5_answer_7381" => "0"
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
# for auto specs
|
||||
def format(value)
|
||||
[ value ]
|
||||
end
|
||||
|
||||
context 'validations' do
|
||||
it_should_behave_like 'Id Answer Serializers'
|
||||
|
||||
it 'should reject unexpected types' do
|
||||
[ nil, 'asdf' ].each do |bad_input|
|
||||
rc = subject.serialize(bad_input)
|
||||
rc.error.should_not be_nil
|
||||
rc.error.should match(/of type array/i)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,21 @@
|
|||
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
|
||||
require File.expand_path(File.dirname(__FILE__) + '/support/answer_serializers_specs.rb')
|
||||
require File.expand_path(File.dirname(__FILE__) + '/support/id_answer_serializers_specs.rb')
|
||||
|
||||
describe QuizQuestion::AnswerSerializers::MultipleChoice do
|
||||
it_should_behave_like 'Answer Serializers'
|
||||
|
||||
let :input do
|
||||
2405
|
||||
end
|
||||
|
||||
let :output do
|
||||
{
|
||||
question_5: "2405"
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
context 'validations' do
|
||||
it_should_behave_like 'Id Answer Serializers'
|
||||
end
|
||||
end
|
|
@ -0,0 +1,36 @@
|
|||
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
|
||||
require File.expand_path(File.dirname(__FILE__) + '/support/answer_serializers_specs.rb')
|
||||
require File.expand_path(File.dirname(__FILE__) + '/support/id_answer_serializers_specs.rb')
|
||||
|
||||
describe QuizQuestion::AnswerSerializers::MultipleDropdowns do
|
||||
it_should_behave_like 'Answer Serializers'
|
||||
|
||||
let :input do
|
||||
{
|
||||
structure1: 4390,
|
||||
event2: 599
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
let :output do
|
||||
{
|
||||
"question_5_#{AssessmentQuestion.variable_id 'structure1'}" => "4390",
|
||||
"question_5_#{AssessmentQuestion.variable_id 'event2'}" => "599"
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
# for auto specs
|
||||
def format(value)
|
||||
{ structure1: value }
|
||||
end
|
||||
|
||||
context 'validations' do
|
||||
it_should_behave_like 'Id Answer Serializers'
|
||||
|
||||
it 'should reject an answer for an unknown blank' do
|
||||
rc = subject.serialize({ foobar: 123456 })
|
||||
rc.error.should_not be_nil
|
||||
rc.error.should match(/unknown blank/i)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,39 @@
|
|||
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
|
||||
require File.expand_path(File.dirname(__FILE__) + '/support/answer_serializers_specs.rb')
|
||||
|
||||
describe QuizQuestion::AnswerSerializers::Numerical do
|
||||
it_should_behave_like 'Answer Serializers'
|
||||
|
||||
let :inputs do
|
||||
[ 25.3, 25e-6, '0.12', '3' ]
|
||||
end
|
||||
|
||||
let :outputs do
|
||||
[
|
||||
{ question_5: "25.3" }.with_indifferent_access,
|
||||
{ question_5: "0.000025" }.with_indifferent_access,
|
||||
{ question_5: "0.12" }.with_indifferent_access,
|
||||
{ question_5: "3.0" }.with_indifferent_access
|
||||
]
|
||||
end
|
||||
|
||||
def sanitize(value)
|
||||
QuizQuestion::AnswerSerializers::Util.to_decimal value
|
||||
end
|
||||
|
||||
it 'should return nil when un-answered' do
|
||||
subject.deserialize({}).should == nil
|
||||
end
|
||||
|
||||
context 'validations' do
|
||||
it 'should turn garbage into 0.0' do
|
||||
[ 'foobar', nil, { foo: 'bar' } ].each do |garbage|
|
||||
rc = subject.serialize(garbage)
|
||||
rc.error.should be_nil
|
||||
rc.answer.should == {
|
||||
question_5: "0.0"
|
||||
}.with_indifferent_access
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,31 @@
|
|||
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
|
||||
require File.expand_path(File.dirname(__FILE__) + '/support/answer_serializers_specs.rb')
|
||||
require File.expand_path(File.dirname(__FILE__) + '/support/textual_answer_serializers_specs.rb')
|
||||
|
||||
describe QuizQuestion::AnswerSerializers::ShortAnswer do
|
||||
it_should_behave_like 'Answer Serializers'
|
||||
|
||||
let :input do
|
||||
'hello world!'
|
||||
end
|
||||
|
||||
let :output do
|
||||
{
|
||||
question_5: 'hello world!'
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
it 'should return nil when un-answered' do
|
||||
subject.deserialize({}).should == nil
|
||||
end
|
||||
|
||||
it 'should degracefully sanitize its text' do
|
||||
subject.serialize('Hello World!').answer.should == {
|
||||
question_5: 'hello world!'
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
context 'validations' do
|
||||
it_should_behave_like 'Textual Answer Serializers'
|
||||
end
|
||||
end
|
|
@ -0,0 +1,78 @@
|
|||
shared_examples_for 'Answer Serializers' do
|
||||
# A QuizQuestion of the type the AnswerSerializer deals with.
|
||||
#
|
||||
# Exports 'qq'.
|
||||
#
|
||||
# To pass options to the factory, define a mock called `factory_options`
|
||||
# somewhere in the test suite. For example:
|
||||
#
|
||||
# let :factory_options do
|
||||
# {
|
||||
# answer_parser_compatibility: true
|
||||
# }
|
||||
# end
|
||||
let(:qq) do
|
||||
question_type = self.class.described_class.question_type
|
||||
|
||||
factory = method(:"#{question_type}_question_data")
|
||||
options = respond_to?(:factory_options) ? factory_options : {}
|
||||
|
||||
# can't test for #arity directly since it might be an optional parameter
|
||||
data = factory.parameters.include?([ :opt, :options ]) ?
|
||||
factory.call(options) :
|
||||
factory.call
|
||||
|
||||
# we'll manually assign an ID of 5 so that we won't have to use variables
|
||||
# like "#{question_id}" all over the place, a readability thing that's all
|
||||
question_id = data[:id] = 5
|
||||
|
||||
# so, we could just build a new Quiz, but it's unnecessary and this is
|
||||
# faster as we only need the #quiz_data set
|
||||
quiz = Object.new
|
||||
quiz.stubs(:quiz_data).returns [ data ]
|
||||
|
||||
qq = QuizQuestion.new
|
||||
qq.id = question_id
|
||||
qq.question_data = data
|
||||
qq.stubs(:quiz).returns quiz
|
||||
|
||||
qq
|
||||
end
|
||||
|
||||
# An AnswerSerializer for the QuizQuestion being tested.
|
||||
subject { described_class.new qq }
|
||||
|
||||
context 'serialization' do
|
||||
before :each do
|
||||
if !respond_to?(:input) && !respond_to?(:inputs)
|
||||
raise 'missing :input or :outputs definition'
|
||||
elsif !respond_to?(:output) && !respond_to?(:outputs)
|
||||
raise 'missing :output or :outputs definition'
|
||||
end
|
||||
|
||||
@inputs = respond_to?(:inputs) ? inputs : [ input ]
|
||||
@outputs = respond_to?(:outputs) ? outputs : [ output ]
|
||||
end
|
||||
|
||||
it '[auto] should serialize' do
|
||||
@inputs.each_with_index do |input, index|
|
||||
rc = subject.serialize(input)
|
||||
rc.error.should be_nil
|
||||
rc.answer.should == @outputs[index]
|
||||
end
|
||||
end
|
||||
|
||||
it '[auto] should deserialize' do
|
||||
@outputs.each_with_index do |output, index|
|
||||
input = @inputs[index]
|
||||
|
||||
if respond_to?(:sanitize)
|
||||
input = sanitize(input)
|
||||
end
|
||||
|
||||
out = subject.deserialize(output)
|
||||
out.should == input
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,29 @@
|
|||
shared_examples_for 'Id Answer Serializers' do
|
||||
it '[auto] should reject an unknown answer ID' do
|
||||
input = 12321
|
||||
input = format(input) if respond_to?(:format)
|
||||
|
||||
rc = subject.serialize(input)
|
||||
rc.error.should_not be_nil
|
||||
rc.error.should match(/unknown answer/i)
|
||||
end
|
||||
|
||||
it '[auto] should accept a string answer ID' do
|
||||
input = '12321'
|
||||
input = format(input) if respond_to?(:format)
|
||||
|
||||
rc = subject.serialize(input)
|
||||
rc.error.should_not be_nil
|
||||
rc.error.should match(/unknown answer/i)
|
||||
end
|
||||
|
||||
it '[auto] should reject a bad answer ID' do
|
||||
[ nil, [], {} ].each do |bad_input|
|
||||
bad_input = format(bad_input) if respond_to?(:format)
|
||||
|
||||
rc = subject.serialize(bad_input)
|
||||
rc.error.should_not be_nil
|
||||
rc.error.should match(/must be of type integer/i)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,22 @@
|
|||
shared_examples_for 'Textual Answer Serializers' do
|
||||
MaxLength = QuizQuestion::AnswerSerializers::Util::MaxTextualAnswerLength
|
||||
|
||||
it '[auto] should reject an answer that is too long' do
|
||||
input = 'a' * (MaxLength+1)
|
||||
input = format(input) if respond_to?(:format)
|
||||
|
||||
rc = subject.serialize(input)
|
||||
rc.valid?.should be_false
|
||||
rc.error.should match(/too long/i)
|
||||
end
|
||||
|
||||
it '[auto] should reject a textual answer that is not a String' do
|
||||
[ nil, [], {} ].each do |bad_input|
|
||||
bad_input = format(bad_input) if respond_to?(:format)
|
||||
|
||||
rc = subject.serialize(bad_input)
|
||||
rc.valid?.should be_false
|
||||
rc.error.should match(/must be of type string/i)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -50,6 +50,12 @@ shared_examples_for 'Takeable Quiz Services' do
|
|||
|
||||
expect { service_action.call }.to_not raise_error
|
||||
end
|
||||
|
||||
it '[transient:CNVS-10224] it should reject CGB-OQAAT quiz requests' do
|
||||
quiz.stubs(:cant_go_back).returns true
|
||||
|
||||
expect { service_action.call }.to raise_error(ApiError, /not supported/i)
|
||||
end
|
||||
end
|
||||
|
||||
describe QuizSubmissionService do
|
||||
|
@ -201,14 +207,6 @@ describe QuizSubmissionService do
|
|||
subject.complete qs, qs.attempt
|
||||
end.to raise_error(ApiError, /already complete/)
|
||||
end
|
||||
|
||||
it 'should require the QS to be untaken' do
|
||||
qs.workflow_state = 'complete'
|
||||
|
||||
expect do
|
||||
subject.complete qs, qs.attempt
|
||||
end.to raise_error(ApiError, /already complete/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'as someone else' do
|
||||
|
@ -223,6 +221,77 @@ describe QuizSubmissionService do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#update_question' do
|
||||
let :qs do
|
||||
qs = QuizSubmission.new
|
||||
qs.attempt = 1
|
||||
qs.quiz = quiz
|
||||
qs.stubs(:completed?).returns false
|
||||
qs.stubs(:backup_submission_data)
|
||||
qs
|
||||
end
|
||||
|
||||
context 'as the participant' do
|
||||
before :each do
|
||||
quiz.stubs(:grants_right?).returns true
|
||||
end
|
||||
|
||||
let :service_action do
|
||||
lambda { |*_| subject.update_question({}, qs, qs.attempt) }
|
||||
end
|
||||
|
||||
it_should_behave_like 'Takeable Quiz Services'
|
||||
|
||||
it 'should update a question' do
|
||||
expect do
|
||||
subject.update_question({ question_5_marked: true }, qs, qs.attempt)
|
||||
end.to_not raise_error
|
||||
end
|
||||
|
||||
it 'should reject when the QS is complete' do
|
||||
qs.stubs(:completed?).returns true
|
||||
|
||||
expect do
|
||||
subject.update_question({ question_5_marked: true }, qs, qs.attempt)
|
||||
end.to raise_error(ApiError, /already complete/)
|
||||
end
|
||||
|
||||
it 'should reject when the QS is overdue' do
|
||||
qs.stubs(:overdue?).returns true
|
||||
|
||||
expect do
|
||||
subject.update_question({ question_5_marked: true }, qs, qs.attempt)
|
||||
end.to raise_error(ApiError, /is overdue/)
|
||||
end
|
||||
|
||||
it 'should reject an invalid attempt' do
|
||||
expect do
|
||||
subject.update_question({ question_5_marked: true }, qs, qs.attempt-1)
|
||||
end.to raise_error(ApiError, /attempt \d can not be modified/)
|
||||
end
|
||||
|
||||
it 'should reject an invalid validation_token' do
|
||||
qs.validation_token = 'yep'
|
||||
participant.validation_token = 'nope'
|
||||
|
||||
expect do
|
||||
subject.update_question({ question_5_marked: true }, qs, qs.attempt)
|
||||
end.to raise_error(ApiError, /invalid token/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'as someone else' do
|
||||
it 'should deny access' do
|
||||
quiz.context = Course.new
|
||||
participant.user = nil
|
||||
|
||||
expect do
|
||||
subject.update_question({}, qs, qs.attempt)
|
||||
end.to raise_error(ApiError, /you are not allowed to update questions/i)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#update_scores' do
|
||||
let :qs do
|
||||
qs = QuizSubmission.new
|
||||
|
|
Loading…
Reference in New Issue