Quiz Submissions API - Create & Complete
Allows users to start a "quiz-taking session" via the API by creating a QuizSubmission and later on completing it. Note that this patch isn't concerned with actually using the QS to answer questions. That task will be the concern of a new API controller, QuizSubmissionQuestions. closes CNVS-8980 TEST PLAN ---- ---- - Create a quiz - Keep a tab open on the Moderate Quiz (MQ from now) page Create the quiz submission (ie, start a quiz-taking session): - Via the API, as a student: - POST to /courses/:course_id/quizzes/:quiz_id/submissions - Verify that you receive a 200 response with the newly created QuizSubmission in the JSON response. - Copy the "validation_token" field down, you will need this later - Go to the MQ tab and verify that it says the student has started a quiz attempt Complete the quiz submission (ie, finish a quiz-taking session): - Via the API, as a student, prepare a request with: - Method: POST - URI: /courses/:course_id/quizzes/:quiz_id/submissions/:id/complete - Parameter "validation_token" to what you copied earlier - Parameter "attempt" to the current attempt number (starts at 1) - Now perform the request, and: - Verify that you receive a 200 response - Go to the MQ tab and verify that it says the submission has been completed (ie, Time column reads "finished in X seconds/minutes") Other stuff to test (failure scenarios): The first endpoint (one for starting a quiz attempt) should reject your request in any of the following cases: - The quiz has been locked - You are not enrolled in the quiz course - The Quiz has an Access Code that you either didn't pass, or passed incorrectly - The Quiz has an IP filter and you're not in the address range - You are already taking the quiz (you've created the submission and did not call /complete yet) - You are not currently taking the quiz, but you already took it earlier and the Quiz does not allow for multiple attempts The second endpoint (one for completing the quiz attempt) should reject your request in any of the following cases: - You pass in an invalid "validation_token" - You already completed that quiz submission (e.g, you called that endpoint earlier) Change-Id: Iff8a47859d7477c210de46ea034544d5e2527fb2 Reviewed-on: https://gerrit.instructure.com/27015 Reviewed-by: Derek DeVries <ddevries@instructure.com> Tested-by: Jenkins <jenkins@instructure.com> QA-Review: Myller de Araujo <myller@instructure.com> Product-Review: Ahmad Amireh <ahmad@instructure.com>
This commit is contained in:
parent
c6808ec567
commit
e3778b529c
|
@ -127,9 +127,12 @@
|
|||
class QuizSubmissionsApiController < ApplicationController
|
||||
include Api::V1::QuizSubmission
|
||||
include Api::V1::Helpers::QuizzesApiHelper
|
||||
include Api::V1::Helpers::QuizSubmissionsApiHelper
|
||||
|
||||
before_filter :require_user, :require_context, :require_quiz
|
||||
before_filter :require_quiz_submission, :only => [ :show ]
|
||||
before_filter :require_overridden_quiz, :except => [ :index ]
|
||||
before_filter :require_quiz_submission, :except => [ :index, :create ]
|
||||
before_filter :prepare_service, :only => [ :create, :complete ]
|
||||
|
||||
# @API Get all quiz submissions.
|
||||
# @beta
|
||||
|
@ -175,25 +178,110 @@ class QuizSubmissionsApiController < ApplicationController
|
|||
# }
|
||||
def show
|
||||
if authorized_action(@quiz_submission, @current_user, :read)
|
||||
render :json => quiz_submissions_json([ @quiz_submission ],
|
||||
@quiz,
|
||||
@current_user,
|
||||
session,
|
||||
@context,
|
||||
Array(params[:include]))
|
||||
render_quiz_submission(@quiz_submission)
|
||||
end
|
||||
end
|
||||
|
||||
# @API Create the quiz submission (start a quiz-taking session)
|
||||
# @beta
|
||||
#
|
||||
# Start taking a Quiz by creating a QuizSubmission which you can use to answer
|
||||
# questions and submit your answers.
|
||||
#
|
||||
# @argument validation_token [String]
|
||||
# The unique validation token you received when this Quiz Submission was
|
||||
# created.
|
||||
#
|
||||
# @argument access_code [Optional, String]
|
||||
# Access code for the Quiz, if any.
|
||||
#
|
||||
# @argument preview [Optional, Boolean]
|
||||
# Whether this should be a preview QuizSubmission and not count towards
|
||||
# the user's course record. Teachers only.
|
||||
#
|
||||
# <b>Responses</b>
|
||||
#
|
||||
# * <b>200 OK</b> if the request was successful
|
||||
# * <b>400 Bad Request</b> if the quiz is locked
|
||||
# * <b>403 Forbidden</b> if an invalid access code is specified
|
||||
# * <b>403 Forbidden</b> if the Quiz's IP filter restriction does not pass
|
||||
# * <b>409 Conflict</b> if a QuizSubmission already exists for this user and quiz
|
||||
#
|
||||
# @example_response
|
||||
# {
|
||||
# "quiz_submissions": [QuizSubmission]
|
||||
# }
|
||||
def create
|
||||
quiz_submission = if previewing?
|
||||
@service.create_preview(@quiz, session)
|
||||
else
|
||||
@service.create(@quiz)
|
||||
end
|
||||
|
||||
log_asset_access(@quiz, 'quizzes', 'quizzes', 'participate')
|
||||
|
||||
render_quiz_submission(quiz_submission)
|
||||
end
|
||||
|
||||
def update
|
||||
end
|
||||
|
||||
# @API Complete the quiz submission (turn it in).
|
||||
# @beta
|
||||
#
|
||||
# Complete the quiz submission by marking it as complete and grading it. When
|
||||
# the quiz submission has been marked as complete, no further modifications
|
||||
# will be allowed.
|
||||
#
|
||||
# @argument attempt [Integer]
|
||||
# The attempt number of the quiz submission that should be completed. Note
|
||||
# that this must be the latest attempt index, as earlier attempts can not
|
||||
# be modified.
|
||||
#
|
||||
# @argument validation_token [String]
|
||||
# The unique validation token you received when this Quiz Submission was
|
||||
# created.
|
||||
#
|
||||
# @argument access_code [Optional, String]
|
||||
# Access code for the Quiz, if any.
|
||||
#
|
||||
# <b>Responses</b>
|
||||
#
|
||||
# * <b>200 OK</b> if the request was successful
|
||||
# * <b>403 Forbidden</b> if an invalid access code is specified
|
||||
# * <b>403 Forbidden</b> if the Quiz's IP filter restriction does not pass
|
||||
# * <b>403 Forbidden</b> if an invalid token is specified
|
||||
# * <b>400 Bad Request</b> if the QS is already complete
|
||||
# * <b>400 Bad Request</b> if the attempt parameter is missing
|
||||
# * <b>400 Bad Request</b> if the attempt parameter is not the latest attempt
|
||||
#
|
||||
# @example_response
|
||||
# {
|
||||
# "quiz_submissions": [QuizSubmission]
|
||||
# }
|
||||
def complete
|
||||
@service.complete @quiz_submission, params[:attempt]
|
||||
|
||||
render_quiz_submission(@quiz_submission)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_quiz_submission
|
||||
unless @quiz_submission = @quiz.quiz_submissions.find(params[:id])
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
def previewing?
|
||||
!!params[:preview]
|
||||
end
|
||||
|
||||
def visible_user_ids(opts = {})
|
||||
scope = @context.enrollments_visible_to(@current_user, opts)
|
||||
scope.pluck(:user_id)
|
||||
end
|
||||
|
||||
def render_quiz_submission(qs)
|
||||
render :json => quiz_submissions_json([ qs ],
|
||||
@quiz,
|
||||
@current_user,
|
||||
session,
|
||||
@context,
|
||||
Array(params[:include]))
|
||||
end
|
||||
end
|
||||
|
|
|
@ -650,6 +650,16 @@ class Quiz < ActiveRecord::Base
|
|||
submission
|
||||
end
|
||||
|
||||
def generate_submission_for_participant(quiz_participant)
|
||||
identity = if quiz_participant.anonymous?
|
||||
:user_code
|
||||
else
|
||||
:user
|
||||
end
|
||||
|
||||
generate_submission quiz_participant.send(identity), false
|
||||
end
|
||||
|
||||
def prepare_answers(question)
|
||||
if answers = question[:answers]
|
||||
if shuffle_answers && Quiz.shuffleable_question_type?(question[:question_type])
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
class QuizParticipant
|
||||
attr_accessor :user, :user_code, :access_code, :ip_address, :validation_token
|
||||
|
||||
# An identity for a quiz participant, which can be an enrolled student,
|
||||
# an anonymous user, or a teacher.
|
||||
#
|
||||
# @param [User] user
|
||||
# The person who wants to take the quiz.
|
||||
#
|
||||
# @param [String] user_code
|
||||
# A unique code to use for identifying the participant in case the user is
|
||||
# missing or is irrelevant (the case for preview mode). This code is usually
|
||||
# found in the Rails session. See ApplicationController#temporary_user_code
|
||||
# for more info.
|
||||
#
|
||||
# @param [String] access_code
|
||||
# Access code required to take the quiz (if any.)
|
||||
#
|
||||
# @param [String] ip_address
|
||||
# The IP address of the client's device that initiated the request.
|
||||
#
|
||||
# @param [String] token
|
||||
# Validation token for the participant's existing quiz submission.
|
||||
#
|
||||
# @return [QuizParticipant]
|
||||
# Participant instance ready for use by Quiz Services.
|
||||
def initialize(user, user_code, access_code=nil, ip_address=nil, token=nil)
|
||||
self.user = user
|
||||
self.user_code = user_code
|
||||
self.access_code = access_code
|
||||
self.ip_address = ip_address
|
||||
self.validation_token = token
|
||||
|
||||
super()
|
||||
end
|
||||
|
||||
# Locate the Quiz Submission for this participant, regardless of them being
|
||||
# enrolled students, or anonymous participants.
|
||||
#
|
||||
# @param [ActiveRecord::Association]
|
||||
# The pool of QuizSubmission instances to look in, defaults to all.
|
||||
#
|
||||
# @param [Hash] query_options
|
||||
# Options to pass to the AR query interface.
|
||||
#
|
||||
# @return [QuizSubmission]
|
||||
# The QS, if any, for the participant.
|
||||
def find_quiz_submission(scope = QuizSubmission, query_options = {})
|
||||
self.anonymous? ?
|
||||
scope.find_by_temporary_user_code(self.user_code, query_options) :
|
||||
scope.find_by_user_id(self.user.id, query_options)
|
||||
end
|
||||
|
||||
# Is this a Canvas user (enrolled student, teacher, TA, etc.) or an anonymous
|
||||
# person?
|
||||
#
|
||||
# Note that this does not actually take the Quiz's public-participation status
|
||||
# into account, only the fact that the participant is authentic or not.
|
||||
def anonymous?
|
||||
self.user.nil? && self.user_code.present?
|
||||
end
|
||||
end
|
|
@ -294,8 +294,35 @@ class QuizSubmission < ActiveRecord::Base
|
|||
params
|
||||
end
|
||||
|
||||
def snapshot!(params)
|
||||
QuizSubmissionSnapshot.create(:quiz_submission => self, :attempt => self.attempt, :data => params)
|
||||
# Generate a snapshot of the QS representing its current state and answer data.
|
||||
#
|
||||
# Multiple snapshots can be taken for a single QS, and they're further scoped
|
||||
# to the QuizSubmission#attempt index.
|
||||
#
|
||||
# @param [Hash] submission_data
|
||||
# Answer data the snapshot should represent.
|
||||
#
|
||||
# @param [Boolean] full_snapshot
|
||||
# Set to true to indicate that the snapshot should represent both the QS's
|
||||
# current answer data along with the passed in answer data (patched).
|
||||
# This is useful for supporting incremental snapshots where you're only
|
||||
# passing in the part of the answer data that has changed.
|
||||
#
|
||||
# @return [QuizSubmissionSnapshot]
|
||||
# The latest, newly-created snapshot.
|
||||
def snapshot!(submission_data={}, full_snapshot=false)
|
||||
snapshot_data = submission_data || {}
|
||||
|
||||
if full_snapshot
|
||||
snapshot_data = self.sanitize_params(snapshot_data).stringify_keys
|
||||
snapshot_data.merge!(self.submission_data || {})
|
||||
end
|
||||
|
||||
QuizSubmissionSnapshot.create({
|
||||
quiz_submission: self,
|
||||
attempt: self.attempt,
|
||||
data: snapshot_data
|
||||
})
|
||||
end
|
||||
|
||||
def questions_as_object
|
||||
|
@ -533,6 +560,24 @@ class QuizSubmission < ActiveRecord::Base
|
|||
QuizRegrader.regrade!(options)
|
||||
end
|
||||
|
||||
# Complete (e.g, turn-in) the quiz submission by doing the following:
|
||||
#
|
||||
# - generating a (full) snapshot of the current state along with any
|
||||
# additional answer data that you pass in
|
||||
# - marking the QS as complete (see #workflow_state)
|
||||
# - grading the QS (see #grade_submission)
|
||||
#
|
||||
# @param [Hash] submission_data
|
||||
# Additional answer data to attach to the QS before completing it.
|
||||
#
|
||||
# @return [QuizSubmission] self
|
||||
def complete!(submission_data={})
|
||||
self.snapshot!(submission_data, true)
|
||||
self.mark_completed
|
||||
self.grade_submission
|
||||
self
|
||||
end
|
||||
|
||||
# Updates a simply_versioned version instance in-place. We want
|
||||
# a teacher to be able to come in and update points for an already-
|
||||
# taken quiz, even if it's a prior version of the submission. Thank you
|
||||
|
@ -781,4 +826,24 @@ class QuizSubmission < ActiveRecord::Base
|
|||
delegate :assignment_id, :assignment, :to => :quiz
|
||||
delegate :graded_at, :to => :submission
|
||||
delegate :context, :to => :quiz
|
||||
|
||||
# Determine whether the QS can be retried (ie, re-generated).
|
||||
#
|
||||
# A QS is determined to be retriable if:
|
||||
#
|
||||
# - it's a settings_only? one
|
||||
# - it's a preview? one
|
||||
# - it's complete and still has attempts left to spare
|
||||
# - it's complete and the quiz allows for unlimited attempts
|
||||
#
|
||||
# @return [Boolean]
|
||||
# Whether the QS is retriable.
|
||||
def retriable?
|
||||
return true if self.preview?
|
||||
return true if self.settings_only?
|
||||
|
||||
attempts_left = self.attempts_left || 0
|
||||
|
||||
self.completed? && (attempts_left > 0 || self.quiz.unlimited_attempts?)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,225 @@
|
|||
class QuizSubmissionService
|
||||
include Api::V1::Helpers::QuizzesApiHelper
|
||||
include Api::V1::Helpers::QuizSubmissionsApiHelper
|
||||
|
||||
attr_accessor :participant
|
||||
|
||||
# @param [QuizParticipant] participant
|
||||
# The person that wants to take the quiz. This could be:
|
||||
#
|
||||
# - a student
|
||||
# - a teacher/quiz author who wants to preview the quiz.
|
||||
# Note that the participant's user_code must be set in this case.
|
||||
# - an anonymous user taking a public course's quiz
|
||||
#
|
||||
def initialize(participant)
|
||||
self.participant = participant
|
||||
super()
|
||||
end
|
||||
|
||||
# Create a QuizSubmission, or re-generate an existing one if viable.
|
||||
#
|
||||
# Semantically, creating a QS means taking a Quiz, and re-generating it means
|
||||
# re-trying it (doing another attempt.) See QuizSubmission#retriable? for
|
||||
# regeneration conditions.
|
||||
#
|
||||
# @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
|
||||
#
|
||||
# See #assert_takeability! for further errors that might be thrown.
|
||||
# See #assert_retriability! for further errors that might be thrown.
|
||||
#
|
||||
# @return [QuizSubmission]
|
||||
# The (re)generated QS.
|
||||
def create(quiz)
|
||||
unless quiz.grants_right?(participant.user, :submit)
|
||||
reject! 403, 'you are not allowed to participate in this quiz'
|
||||
end
|
||||
|
||||
assert_takeability! quiz, participant.access_code, participant.ip_address
|
||||
|
||||
# Look up an existing QS, and if one exists, make sure it is retriable.
|
||||
assert_retriability! participant.find_quiz_submission(quiz.quiz_submissions, {
|
||||
:order => 'created_at'
|
||||
})
|
||||
|
||||
quiz.generate_submission_for_participant participant
|
||||
end
|
||||
|
||||
# Create a "preview" Quiz Submission that doesn't count towards the quiz stats.
|
||||
#
|
||||
# Only quiz authors can launch the preview mode.
|
||||
#
|
||||
# @param [Quiz] quiz
|
||||
# The Quiz to be previewed.
|
||||
#
|
||||
# @param [Hash] session
|
||||
# The Rails session. Used for testing access permissions.
|
||||
#
|
||||
# @throw ServiceError(403) if the user isn't privileged to update the Quiz
|
||||
#
|
||||
# @return [QuizSubmission]
|
||||
# The newly created preview QS.
|
||||
def create_preview(quiz, session)
|
||||
unless quiz.grants_right?(participant.user, session, :update)
|
||||
reject! 403, 'you are not allowed to preview this quiz'
|
||||
end
|
||||
|
||||
quiz.generate_submission(participant.user_code, true)
|
||||
end
|
||||
|
||||
# Complete the quiz submission by marking it as complete and grading it. When
|
||||
# the quiz submission has been marked as complete, no further modifications
|
||||
# will be allowed.
|
||||
#
|
||||
# @param [QuizSubmission] quiz_submission
|
||||
# The QS to complete.
|
||||
#
|
||||
# @param [Integer] attempt
|
||||
# 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
|
||||
#
|
||||
# Further errors might be thrown from the following methods:
|
||||
#
|
||||
# - #assert_takeability!
|
||||
# - #assert_retriability!
|
||||
# - #validate_token!
|
||||
# - #ensure_latest_attempt!
|
||||
#
|
||||
# @return [QuizSubmission]
|
||||
# The QS that was completed.
|
||||
def complete(quiz_submission, attempt)
|
||||
quiz = quiz_submission.quiz
|
||||
|
||||
unless quiz.grants_right?(participant.user, :submit)
|
||||
reject! 403, 'you are not allowed to complete this quiz submission'
|
||||
end
|
||||
|
||||
# Participant must be able to take the quiz...
|
||||
assert_takeability! quiz, participant.access_code, participant.ip_address
|
||||
|
||||
# And be the owner of the quiz submission:
|
||||
validate_token! quiz_submission, participant.validation_token
|
||||
|
||||
# The QS must be completable:
|
||||
unless quiz_submission.untaken?
|
||||
reject! 400, 'quiz submission is already complete'
|
||||
end
|
||||
|
||||
# And we need a valid attempt index to work with.
|
||||
ensure_latest_attempt! quiz_submission, attempt
|
||||
|
||||
quiz_submission.complete!
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
# Abort the current service request with an error similar to an API error.
|
||||
#
|
||||
# See Api#reject! for usage.
|
||||
def reject!(status, cause)
|
||||
raise Api::V1::ApiError.new(status, cause)
|
||||
end
|
||||
|
||||
# Verify that none of the following Quiz restrictions are preventing the Quiz
|
||||
# from being taken by a client:
|
||||
#
|
||||
# - the Quiz being locked
|
||||
# - the Quiz access code
|
||||
# - the Quiz active IP filter
|
||||
#
|
||||
# @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)
|
||||
if quiz.locked?
|
||||
reject! 400, 'quiz is locked'
|
||||
end
|
||||
|
||||
validate_access_code! quiz, access_code
|
||||
validate_ip_address! quiz, ip_address
|
||||
end
|
||||
|
||||
# Verify the given QS is retriable.
|
||||
#
|
||||
# @throw ServiceError(409) if the QS is not new and can not be retried
|
||||
#
|
||||
# See QuizSubmission#retriable?
|
||||
def assert_retriability!(quiz_submission)
|
||||
if quiz_submission.present? && !quiz_submission.retriable?
|
||||
reject! 409, 'a quiz submission already exists'
|
||||
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'
|
||||
end
|
||||
end
|
||||
|
||||
# Ensure that the QS attempt index is specified and is the latest one, unless
|
||||
# it's a preview QS.
|
||||
#
|
||||
# The reason we require an attempt to be explicitly specified and that
|
||||
# it must match the latest one is to avoid cases where the student leaves
|
||||
# an open session for an earlier attempt which might try to auto-submit
|
||||
# or backup answers, and those shouldn't overwrite the latest attempt's
|
||||
# data.
|
||||
#
|
||||
# @param [QuizSubmission] quiz_submission
|
||||
# @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)
|
||||
def ensure_latest_attempt!(quiz_submission, attempt)
|
||||
attempt = Integer(attempt) rescue nil
|
||||
|
||||
if !attempt
|
||||
reject! 400, 'invalid attempt'
|
||||
elsif !quiz_submission.preview? && quiz_submission.attempt != attempt
|
||||
reject! 400, "attempt #{attempt} can not be modified"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -19,6 +19,7 @@ if ENV['COVERAGE'] == "1"
|
|||
|
||||
add_group 'Controllers', 'app/controllers'
|
||||
add_group 'Models', 'app/models'
|
||||
add_group 'Services', 'app/services'
|
||||
add_group 'App', '/app/'
|
||||
add_group 'Helpers', 'app/helpers'
|
||||
add_group 'Libraries', '/lib/'
|
||||
|
|
|
@ -1330,6 +1330,9 @@ routes.draw do
|
|||
scope(:controller => :quiz_submissions_api) do
|
||||
get 'courses/:course_id/quizzes/:quiz_id/submissions', :action => :index, :path_name => 'course_quiz_submissions'
|
||||
get 'courses/:course_id/quizzes/:quiz_id/submissions/:id', :action => :show, :path_name => 'course_quiz_submission'
|
||||
post 'courses/:course_id/quizzes/:quiz_id/submissions', :action => :create, :path_name => 'course_quiz_submission_create'
|
||||
put 'courses/:course_id/quizzes/:quiz_id/submissions/:id', :action => :update, :path_name => 'course_quiz_submission_update'
|
||||
post 'courses/:course_id/quizzes/:quiz_id/submissions/:id/complete', :action => :complete, :path_name => 'course_quiz_submission_complete'
|
||||
end
|
||||
|
||||
scope(:controller => :quiz_ip_filters) do
|
||||
|
|
|
@ -57,7 +57,8 @@ config.active_record.observers = [:cacher, :stream_item_cache]
|
|||
|
||||
config.autoload_paths += %W(#{Rails.root}/app/middleware
|
||||
#{Rails.root}/app/observers
|
||||
#{Rails.root}/app/presenters)
|
||||
#{Rails.root}/app/presenters
|
||||
#{Rails.root}/app/services)
|
||||
|
||||
if CANVAS_RAILS2
|
||||
config.middleware.insert_before(ActionController::Base.session_store, 'LoadAccount')
|
||||
|
|
11
lib/api.rb
11
lib/api.rb
|
@ -578,4 +578,15 @@ module Api
|
|||
def accepts_jsonapi?
|
||||
!!(/application\/vnd\.api\+json/ =~ request.headers['Accept'].to_s)
|
||||
end
|
||||
|
||||
# Reject the API request by halting the execution of the current handler
|
||||
# and returning a helpful error message (and HTTP status code).
|
||||
#
|
||||
# @param [Fixnum] status
|
||||
# HTTP status code.
|
||||
# @param [String] cause
|
||||
# The reason the request is rejected for.
|
||||
def reject!(status, cause)
|
||||
raise Api::V1::ApiError.new(status, cause)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
module Api::V1
|
||||
class ApiError < ::RuntimeError
|
||||
attr_accessor :response_status, :status
|
||||
|
||||
def initialize(response_status, message)
|
||||
self.response_status = response_status
|
||||
self.status = Rack::Utils::HTTP_STATUS_CODES[self.response_status]
|
||||
self.status = self.status.underscore.to_sym
|
||||
|
||||
super(message)
|
||||
end
|
||||
|
||||
def error_json
|
||||
{
|
||||
status: self.status,
|
||||
message: self.message
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,42 @@
|
|||
#
|
||||
# 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::Helpers::QuizSubmissionsApiHelper
|
||||
protected
|
||||
|
||||
def require_overridden_quiz
|
||||
@quiz = @quiz.overridden_for(@current_user)
|
||||
end
|
||||
|
||||
def require_quiz_submission
|
||||
collection = @quiz ? @quiz.quiz_submissions : QuizSubmission
|
||||
|
||||
unless @quiz_submission = collection.find(params[:id])
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
end
|
||||
|
||||
def prepare_service
|
||||
participant = QuizParticipant.new(@current_user, temporary_user_code)
|
||||
participant.access_code = params[:access_code]
|
||||
participant.ip_address = request.remote_ip
|
||||
participant.validation_token = params[:validation_token]
|
||||
|
||||
@service = QuizSubmissionService.new(participant)
|
||||
end
|
||||
end
|
|
@ -17,7 +17,7 @@
|
|||
#
|
||||
|
||||
module Api::V1::Helpers::QuizzesApiHelper
|
||||
private
|
||||
protected
|
||||
|
||||
def require_quiz
|
||||
unless @quiz = @context.quizzes.find(params[:quiz_id])
|
||||
|
|
|
@ -39,6 +39,7 @@ module Api::V1::QuizSubmission
|
|||
kept_score
|
||||
score
|
||||
score_before_regrade
|
||||
validation_token
|
||||
workflow_state
|
||||
].freeze
|
||||
|
||||
|
|
|
@ -96,7 +96,7 @@ unless ARGV.any? { |a| a =~ /\Agems/ }
|
|||
t.spec_files = FileList['vendor/plugins/**/spec/**/*/*_spec.rb'].exclude('vendor/plugins/rspec/*')
|
||||
end
|
||||
|
||||
[:models, :controllers, :views, :helpers, :lib, :selenium].each do |sub|
|
||||
[:models, :services, :controllers, :views, :helpers, :lib, :selenium].each do |sub|
|
||||
desc "Run the code examples in spec/#{sub}"
|
||||
Spec::Rake::SpecTask.new(sub) do |t|
|
||||
t.spec_opts = ['--options', "\"#{Rails.root}/spec/spec.opts\""]
|
||||
|
@ -122,12 +122,14 @@ unless ARGV.any? { |a| a =~ /\Agems/ }
|
|||
task :statsetup do
|
||||
require 'code_statistics'
|
||||
::STATS_DIRECTORIES << %w(Model\ specs spec/models) if File.exist?('spec/models')
|
||||
::STATS_DIRECTORIES << %w(Service\ specs spec/services) if File.exist?('spec/services')
|
||||
::STATS_DIRECTORIES << %w(View\ specs spec/views) if File.exist?('spec/views')
|
||||
::STATS_DIRECTORIES << %w(Controller\ specs spec/controllers) if File.exist?('spec/controllers')
|
||||
::STATS_DIRECTORIES << %w(Helper\ specs spec/helpers) if File.exist?('spec/helpers')
|
||||
::STATS_DIRECTORIES << %w(Library\ specs spec/lib) if File.exist?('spec/lib')
|
||||
::STATS_DIRECTORIES << %w(Routing\ specs spec/lib) if File.exist?('spec/routing')
|
||||
::CodeStatistics::TEST_TYPES << "Model specs" if File.exist?('spec/models')
|
||||
::CodeStatistics::TEST_TYPES << "Service specs" if File.exist?('spec/services')
|
||||
::CodeStatistics::TEST_TYPES << "View specs" if File.exist?('spec/views')
|
||||
::CodeStatistics::TEST_TYPES << "Controller specs" if File.exist?('spec/controllers')
|
||||
::CodeStatistics::TEST_TYPES << "Helper specs" if File.exist?('spec/helpers')
|
||||
|
|
|
@ -18,9 +18,117 @@
|
|||
|
||||
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
|
||||
end
|
||||
|
||||
describe QuizSubmissionsApiController, :type => :integration do
|
||||
it_should_behave_like 'API tests'
|
||||
|
||||
module Helpers
|
||||
def enroll_student(opts = {})
|
||||
last_user = @teacher = @user
|
||||
student_in_course
|
||||
@student = @user
|
||||
@user = last_user
|
||||
|
||||
if opts[:login]
|
||||
remove_user_session
|
||||
user_session(@student)
|
||||
end
|
||||
end
|
||||
|
||||
def enroll_student_and_submit(opts = {})
|
||||
enroll_student(opts)
|
||||
|
||||
@quiz_submission = @quiz.generate_submission(@student)
|
||||
@quiz_submission.submission_data = { "question_1" => "1658" }
|
||||
@quiz_submission.mark_completed
|
||||
@quiz_submission.grade_submission
|
||||
@quiz_submission.reload
|
||||
end
|
||||
|
||||
def normalize(value)
|
||||
value.to_json.to_s
|
||||
end
|
||||
|
||||
def qs_api_index(raw = false, data = {})
|
||||
helper = method(raw ? :raw_api_call : :api_call)
|
||||
helper.call(:get,
|
||||
"/api/v1/courses/#{@course.id}/quizzes/#{@quiz.id}/submissions.json",
|
||||
{ :controller => 'quiz_submissions_api', :action => 'index', :format => 'json',
|
||||
:course_id => @course.id.to_s,
|
||||
:quiz_id => @quiz.id.to_s
|
||||
}, data)
|
||||
end
|
||||
|
||||
def qs_api_show(raw = false, data = {})
|
||||
helper = method(raw ? :raw_api_call : :api_call)
|
||||
helper.call(:get,
|
||||
"/api/v1/courses/#{@course.id}/quizzes/#{@quiz.id}/submissions/#{@quiz_submission.id}.json",
|
||||
{ :controller => 'quiz_submissions_api',
|
||||
:action => 'show',
|
||||
:format => 'json',
|
||||
:course_id => @course.id.to_s,
|
||||
:quiz_id => @quiz.id.to_s,
|
||||
:id => @quiz_submission.id.to_s
|
||||
}, data)
|
||||
end
|
||||
|
||||
def qs_api_create(raw = false, data = {})
|
||||
helper = method(raw ? :raw_api_call : :api_call)
|
||||
helper.call(:post,
|
||||
"/api/v1/courses/#{@course.id}/quizzes/#{@quiz.id}/submissions",
|
||||
{ :controller => 'quiz_submissions_api',
|
||||
:action => 'create',
|
||||
:format => 'json',
|
||||
:course_id => @course.id.to_s,
|
||||
:quiz_id => @quiz.id.to_s
|
||||
}, data)
|
||||
end
|
||||
|
||||
def qs_api_complete(raw = false, data = {})
|
||||
data = {
|
||||
validation_token: @quiz_submission.validation_token
|
||||
}.merge(data)
|
||||
|
||||
helper = method(raw ? :raw_api_call : :api_call)
|
||||
helper.call(:post,
|
||||
"/api/v1/courses/#{@course.id}/quizzes/#{@quiz.id}/submissions/#{@quiz_submission.id}/complete",
|
||||
{ :controller => 'quiz_submissions_api',
|
||||
:action => 'complete',
|
||||
:format => 'json',
|
||||
:course_id => @course.id.to_s,
|
||||
:quiz_id => @quiz.id.to_s,
|
||||
:id => @quiz_submission.id.to_s
|
||||
}, data)
|
||||
end
|
||||
end
|
||||
|
||||
include Helpers
|
||||
|
||||
before :each do
|
||||
course_with_teacher_logged_in :active_all => true
|
||||
|
||||
|
@ -34,34 +142,9 @@ describe QuizSubmissionsApiController, :type => :integration do
|
|||
@assignment = @quiz.assignment
|
||||
end
|
||||
|
||||
def enroll_student_and_submit
|
||||
last_user = @user
|
||||
student_in_course
|
||||
@student = @user
|
||||
@user = last_user
|
||||
|
||||
@quiz_submission = @quiz.generate_submission(@student)
|
||||
@quiz_submission.submission_data = { "question_1" => "1658" }
|
||||
@quiz_submission.mark_completed
|
||||
@quiz_submission.grade_submission
|
||||
@quiz_submission.reload
|
||||
|
||||
[ @student, @quiz_submission ]
|
||||
end
|
||||
|
||||
describe 'GET /courses/:course_id/quizzes/:quiz_id/submissions [INDEX]' do
|
||||
def get_index(raw = false, data = {})
|
||||
helper = method(raw ? :raw_api_call : :api_call)
|
||||
helper.call(:get,
|
||||
"/api/v1/courses/#{@course.id}/quizzes/#{@quiz.id}/submissions.json",
|
||||
{ :controller => 'quiz_submissions_api', :action => 'index', :format => 'json',
|
||||
:course_id => @course.id.to_s,
|
||||
:quiz_id => @quiz.id.to_s
|
||||
}, data)
|
||||
end
|
||||
|
||||
it 'should return an empty list' do
|
||||
json = get_index
|
||||
json = qs_api_index
|
||||
json.has_key?('quiz_submissions').should be_true
|
||||
json['quiz_submissions'].size.should == 0
|
||||
end
|
||||
|
@ -69,55 +152,38 @@ describe QuizSubmissionsApiController, :type => :integration do
|
|||
it 'should list quiz submissions' do
|
||||
enroll_student_and_submit
|
||||
|
||||
json = get_index
|
||||
json = qs_api_index
|
||||
json['quiz_submissions'].size.should == 1
|
||||
end
|
||||
|
||||
it 'should restrict access to itself' do
|
||||
student_in_course
|
||||
json = get_index(true)
|
||||
json = qs_api_index(true)
|
||||
response.status.to_i.should == 401
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /courses/:course_id/quizzes/:quiz_id/submissions/:id [SHOW]' do
|
||||
def get_show(raw = false, data = {})
|
||||
helper = method(raw ? :raw_api_call : :api_call)
|
||||
helper.call(:get,
|
||||
"/api/v1/courses/#{@course.id}/quizzes/#{@quiz.id}/submissions/#{@quiz_submission.id}.json",
|
||||
{ :controller => 'quiz_submissions_api',
|
||||
:action => 'show',
|
||||
:format => 'json',
|
||||
:course_id => @course.id.to_s,
|
||||
:quiz_id => @quiz.id.to_s,
|
||||
:id => @quiz_submission.id.to_s
|
||||
}, data)
|
||||
end
|
||||
|
||||
before :each do
|
||||
enroll_student_and_submit
|
||||
end
|
||||
|
||||
it 'should grant access to its student' do
|
||||
@user = @student
|
||||
json = get_show
|
||||
json = qs_api_show
|
||||
json.has_key?('quiz_submissions').should be_true
|
||||
json['quiz_submissions'].length.should == 1
|
||||
end
|
||||
|
||||
it 'should deny access by other students' do
|
||||
student_in_course
|
||||
get_show(true)
|
||||
qs_api_show(true)
|
||||
response.status.to_i.should == 401
|
||||
end
|
||||
|
||||
context 'Output' do
|
||||
def normalize(value)
|
||||
value.to_json.to_s
|
||||
end
|
||||
|
||||
it 'should include the allowed quiz submission output fields' do
|
||||
json = get_show
|
||||
json = qs_api_show
|
||||
json.has_key?('quiz_submissions').should be_true
|
||||
|
||||
qs_json = json['quiz_submissions'][0].with_indifferent_access
|
||||
|
@ -137,13 +203,13 @@ describe QuizSubmissionsApiController, :type => :integration do
|
|||
@quiz_submission.finished_at = @quiz_submission.started_at + 5.minutes
|
||||
@quiz_submission.save!
|
||||
|
||||
json = get_show
|
||||
json = qs_api_show
|
||||
json.has_key?('quiz_submissions').should be_true
|
||||
json['quiz_submissions'][0]['time_spent'].should == 5.minutes
|
||||
end
|
||||
|
||||
it 'should include html_url' do
|
||||
json = get_show
|
||||
json = qs_api_show
|
||||
json.has_key?('quiz_submissions').should be_true
|
||||
|
||||
qs_json = json['quiz_submissions'][0]
|
||||
|
@ -153,7 +219,7 @@ describe QuizSubmissionsApiController, :type => :integration do
|
|||
|
||||
context 'Links' do
|
||||
it 'should include its linked user' do
|
||||
json = get_show(false, {
|
||||
json = qs_api_show(false, {
|
||||
:include => [ 'user' ]
|
||||
})
|
||||
|
||||
|
@ -164,7 +230,7 @@ describe QuizSubmissionsApiController, :type => :integration do
|
|||
end
|
||||
|
||||
it 'should include its linked quiz' do
|
||||
json = get_show(false, {
|
||||
json = qs_api_show(false, {
|
||||
:include => [ 'quiz' ]
|
||||
})
|
||||
|
||||
|
@ -175,7 +241,7 @@ describe QuizSubmissionsApiController, :type => :integration do
|
|||
end
|
||||
|
||||
it 'should include its linked submission' do
|
||||
json = get_show(false, {
|
||||
json = qs_api_show(false, {
|
||||
:include => [ 'submission' ]
|
||||
})
|
||||
|
||||
|
@ -186,7 +252,7 @@ describe QuizSubmissionsApiController, :type => :integration do
|
|||
end
|
||||
|
||||
it 'should include its linked user, quiz, and submission' do
|
||||
json = get_show(false, {
|
||||
json = qs_api_show(false, {
|
||||
:include => [ 'user', 'quiz', 'submission' ]
|
||||
})
|
||||
|
||||
|
@ -198,14 +264,14 @@ describe QuizSubmissionsApiController, :type => :integration do
|
|||
|
||||
context 'JSON-API compliance' do
|
||||
it 'should conform to the JSON-API spec when returning the object' do
|
||||
json = get_show(false)
|
||||
json = qs_api_show(false)
|
||||
assert_jsonapi_compliance!(json, 'quiz_submissions')
|
||||
end
|
||||
|
||||
it 'should conform to the JSON-API spec when returning linked objects' do
|
||||
includes = [ 'user', 'quiz', 'submission' ]
|
||||
|
||||
json = get_show(false, {
|
||||
json = qs_api_show(false, {
|
||||
:include => includes
|
||||
})
|
||||
|
||||
|
@ -213,4 +279,128 @@ describe QuizSubmissionsApiController, :type => :integration do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /courses/:course_id/quizzes/:quiz_id/submissions [create]' do
|
||||
before :each do
|
||||
enroll_student({ login: true })
|
||||
end
|
||||
|
||||
it 'should create a quiz submission' do
|
||||
json = qs_api_create
|
||||
json.has_key?('quiz_submissions').should be_true
|
||||
json['quiz_submissions'].length.should == 1
|
||||
json['quiz_submissions'][0]['workflow_state'].should == 'untaken'
|
||||
end
|
||||
|
||||
it 'should create a preview quiz submission' do
|
||||
json = qs_api_create false, { preview: true }
|
||||
QuizSubmission.find(json['quiz_submissions'][0]['id']).preview?.should be_true
|
||||
end
|
||||
|
||||
it 'should allow the creation of multiple, subsequent QSes' do
|
||||
@quiz.allowed_attempts = -1
|
||||
@quiz.save
|
||||
|
||||
json = qs_api_create
|
||||
qs = QuizSubmission.find(json['quiz_submissions'][0]['id'])
|
||||
qs.mark_completed
|
||||
qs.save
|
||||
|
||||
qs_api_create
|
||||
end
|
||||
|
||||
context 'parameter, permission, and state 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
|
||||
|
||||
describe 'POST /courses/:course_id/quizzes/:quiz_id/submissions/:id/complete [complete]' do
|
||||
before :each do
|
||||
enroll_student({ login: true })
|
||||
|
||||
@quiz_submission = @quiz.generate_submission(@student)
|
||||
# @quiz_submission.submission_data = { "question_1" => "1658" }
|
||||
end
|
||||
|
||||
it 'should complete a quiz submission' do
|
||||
json = qs_api_complete false, {
|
||||
attempt: 1
|
||||
}
|
||||
|
||||
json.has_key?('quiz_submissions').should be_true
|
||||
json['quiz_submissions'].length.should == 1
|
||||
json['quiz_submissions'][0]['workflow_state'].should == 'complete'
|
||||
end
|
||||
|
||||
context 'parameter, permission, and state validations' do
|
||||
it_should_behave_like 'Quiz Submissions API Restricted Endpoints'
|
||||
|
||||
before do
|
||||
@request_proxy = method(:qs_api_complete)
|
||||
end
|
||||
|
||||
it 'should reject completing an already complete QS' do
|
||||
@quiz_submission.mark_completed
|
||||
@quiz_submission.grade_submission
|
||||
|
||||
json = qs_api_complete true, {
|
||||
attempt: 1
|
||||
}
|
||||
|
||||
response.status.to_i.should == 400
|
||||
response.body.should match(/already complete/)
|
||||
end
|
||||
|
||||
it 'should require the attempt index' do
|
||||
json = qs_api_complete true
|
||||
|
||||
response.status.to_i.should == 400
|
||||
response.body.should match(/invalid attempt/)
|
||||
end
|
||||
|
||||
it 'should require the current attempt index' do
|
||||
json = qs_api_complete true, {
|
||||
attempt: 123123123
|
||||
}
|
||||
|
||||
response.status.to_i.should == 400
|
||||
response.body.should match(/attempt.*can not be modified/)
|
||||
end
|
||||
|
||||
it 'should require a valid validation token' do
|
||||
json = qs_api_complete true, {
|
||||
validation_token: 'aaaooeeeee'
|
||||
}
|
||||
|
||||
response.status.to_i.should == 403
|
||||
response.body.should match(/invalid token/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'Taking a quiz' do
|
||||
it 'should start and complete a quiz-taking session' do
|
||||
pending 'answering questions via the API'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1432,4 +1432,23 @@ describe Quiz do
|
|||
@quiz.reload.should be_unpublished
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_submission_for_participant' do
|
||||
let :participant do
|
||||
QuizParticipant.new(User.new, 'foobar')
|
||||
end
|
||||
|
||||
it 'should link the generated QS to a user' do
|
||||
subject.expects(:generate_submission).with(participant.user, false)
|
||||
|
||||
subject.generate_submission_for_participant(participant)
|
||||
end
|
||||
|
||||
it 'should link the generated QS to a temporary user code' do
|
||||
subject.expects(:generate_submission).with(participant.user_code, false)
|
||||
|
||||
participant.stubs(:anonymous?).returns true
|
||||
subject.generate_submission_for_participant(participant)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper.rb')
|
||||
|
||||
describe QuizSubmission do
|
||||
context 'with course and quiz' do
|
||||
before(:each) do
|
||||
course
|
||||
@quiz = @course.quizzes.create!
|
||||
|
@ -87,51 +88,6 @@ describe QuizSubmission do
|
|||
q.end_at.should eql original_end_at
|
||||
end
|
||||
|
||||
describe "#time_left" do
|
||||
it "should return nil if there's no end_at" do
|
||||
q = @quiz.quiz_submissions.create!
|
||||
q.update_attribute(:end_at, nil)
|
||||
|
||||
q.time_left.should be_nil
|
||||
end
|
||||
|
||||
it "should return the correct time left in seconds" do
|
||||
q = @quiz.quiz_submissions.create!
|
||||
q.update_attribute(:end_at, Time.now + 1.hour)
|
||||
|
||||
q.time_left.should eql(60 * 60)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#time_spent" do
|
||||
it "should return nil if there's no finished_at" do
|
||||
q = @quiz.quiz_submissions.new
|
||||
q.finished_at = nil
|
||||
|
||||
q.time_spent.should be_nil
|
||||
end
|
||||
|
||||
it "should return the correct time spent in seconds" do
|
||||
anchor = Time.now
|
||||
|
||||
q = @quiz.quiz_submissions.new
|
||||
q.started_at = anchor
|
||||
q.finished_at = anchor + 1.hour
|
||||
q.time_spent.should eql(1.hour.to_i)
|
||||
end
|
||||
|
||||
it "should account for extra time" do
|
||||
anchor = Time.now
|
||||
|
||||
q = @quiz.quiz_submissions.new
|
||||
q.started_at = anchor
|
||||
q.finished_at = anchor + 1.hour
|
||||
q.extra_time = 5.minutes
|
||||
|
||||
q.time_spent.should eql((1.hour + 5.minutes).to_i)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#update_scores" do
|
||||
before(:each) do
|
||||
student_in_course
|
||||
|
@ -1757,5 +1713,113 @@ describe QuizSubmission do
|
|||
@submission.reload.messages_sent.keys.should_not include 'Submission Needs Grading'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#time_spent" do
|
||||
it "should return nil if there's no finished_at" do
|
||||
subject.finished_at = nil
|
||||
subject.time_spent.should be_nil
|
||||
end
|
||||
|
||||
it "should return the correct time spent in seconds" do
|
||||
anchor = Time.now
|
||||
|
||||
subject.started_at = anchor
|
||||
subject.finished_at = anchor + 1.hour
|
||||
subject.time_spent.should eql(1.hour.to_i)
|
||||
end
|
||||
|
||||
it "should account for extra time" do
|
||||
anchor = Time.now
|
||||
|
||||
subject.started_at = anchor
|
||||
subject.finished_at = anchor + 1.hour
|
||||
subject.extra_time = 5.minutes
|
||||
|
||||
subject.time_spent.should eql((1.hour + 5.minutes).to_i)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#time_left" do
|
||||
it "should return nil if there's no end_at" do
|
||||
subject.end_at = nil
|
||||
subject.time_left.should be_nil
|
||||
end
|
||||
|
||||
it "should return the correct time left in seconds" do
|
||||
subject.end_at = 1.hour.from_now
|
||||
subject.time_left.should eql(60 * 60)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#retriable?' do
|
||||
it 'should not be retriable by default' do
|
||||
subject.stubs(:attempts_left).returns 0
|
||||
subject.retriable?.should be_false
|
||||
end
|
||||
|
||||
it 'should not be retriable unless it is complete' do
|
||||
subject.stubs(:attempts_left).returns 3
|
||||
subject.retriable?.should be_false
|
||||
end
|
||||
|
||||
it 'should be retriable if it is a preview QS' do
|
||||
subject.workflow_state = 'preview'
|
||||
subject.retriable?.should be_true
|
||||
end
|
||||
|
||||
it 'should be retriable if it is a settings only QS' do
|
||||
subject.workflow_state = 'settings_only'
|
||||
subject.retriable?.should be_true
|
||||
end
|
||||
|
||||
it 'should be retriable if it is complete and has attempts left to spare' do
|
||||
subject.workflow_state = 'complete'
|
||||
subject.stubs(:attempts_left).returns 3
|
||||
subject.retriable?.should be_true
|
||||
end
|
||||
|
||||
it 'should be retriable if it is complete and the quiz has unlimited attempts' do
|
||||
subject.workflow_state = 'complete'
|
||||
subject.stubs(:attempts_left).returns 0
|
||||
subject.quiz = Quiz.new
|
||||
subject.quiz.stubs(:unlimited_attempts?).returns true
|
||||
subject.retriable?.should be_true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#snapshot!' do
|
||||
before :each do
|
||||
subject.quiz = Quiz.new
|
||||
subject.attempt = 1
|
||||
end
|
||||
|
||||
it 'should generate a snapshot' do
|
||||
snapshot_data = { 'question_5_marked' => true }
|
||||
|
||||
QuizSubmissionSnapshot.expects(:create).with({
|
||||
quiz_submission: subject,
|
||||
attempt: 1,
|
||||
data: snapshot_data.with_indifferent_access
|
||||
})
|
||||
|
||||
subject.snapshot! snapshot_data
|
||||
end
|
||||
|
||||
it 'should generate a full snapshot' do
|
||||
subject.stubs(:submission_data).returns({
|
||||
'question_5' => 100
|
||||
})
|
||||
|
||||
snapshot_data = { 'question_5_marked' => true }
|
||||
|
||||
QuizSubmissionSnapshot.expects(:create).with({
|
||||
quiz_submission: subject,
|
||||
attempt: 1,
|
||||
data: snapshot_data.merge(subject.submission_data).with_indifferent_access
|
||||
})
|
||||
|
||||
subject.snapshot! snapshot_data, true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,225 @@
|
|||
# encoding: UTF-8
|
||||
#
|
||||
# Copyright (C) 2011 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper.rb')
|
||||
|
||||
shared_examples_for 'Takeable Quiz Services' do
|
||||
it 'should deny access to locked quizzes' do
|
||||
quiz.stubs(:locked?).returns true
|
||||
|
||||
expect { service_action.call }.to raise_error(ApiError, /is locked/i)
|
||||
end
|
||||
|
||||
it 'should validate the access code' do
|
||||
quiz.access_code = 'adooken'
|
||||
|
||||
expect { service_action.call }.to raise_error(ApiError, /access code/i)
|
||||
end
|
||||
|
||||
it 'should accept a valid access code' do
|
||||
participant.access_code = quiz.access_code = 'adooken'
|
||||
|
||||
expect { service_action.call }.to_not raise_error
|
||||
end
|
||||
|
||||
it 'should validate the IP address of the participant' do
|
||||
quiz.ip_filter = '10.0.0.1/24'
|
||||
participant.ip_address = '192.168.0.1'
|
||||
|
||||
expect { service_action.call }.to raise_error(ApiError, /ip address/i)
|
||||
end
|
||||
|
||||
it 'should accept a covered IP' do
|
||||
participant.ip_address = quiz.ip_filter = '10.0.0.1'
|
||||
|
||||
expect { service_action.call }.to_not raise_error
|
||||
end
|
||||
end
|
||||
|
||||
describe QuizSubmissionService do
|
||||
ApiError = Api::V1::ApiError
|
||||
|
||||
subject { QuizSubmissionService.new participant }
|
||||
|
||||
let :quiz do
|
||||
quiz = Quiz.new
|
||||
quiz.workflow_state = 'available'
|
||||
quiz
|
||||
end
|
||||
|
||||
let :participant do
|
||||
QuizParticipant.new(User.new, 'some temporary user code')
|
||||
end
|
||||
|
||||
describe '#create' do
|
||||
before :each do
|
||||
# consume all calls to actual QS generation, no need to test this
|
||||
quiz.stubs(:generate_submission)
|
||||
end
|
||||
|
||||
context 'as an authentic user' do
|
||||
before :each do
|
||||
quiz.stubs(:grants_right?).returns true
|
||||
end
|
||||
|
||||
let :service_action do
|
||||
lambda { |*_| subject.create quiz }
|
||||
end
|
||||
|
||||
it_should_behave_like 'Takeable Quiz Services'
|
||||
|
||||
it 'should create a QS' do
|
||||
expect { subject.create quiz }.to_not raise_error
|
||||
end
|
||||
|
||||
context 'retrying a quiz' do
|
||||
let :retriable_qs do
|
||||
qs = QuizSubmission.new
|
||||
qs.stubs(:retriable?).returns true
|
||||
qs
|
||||
end
|
||||
|
||||
let :unretriable_qs do
|
||||
qs = QuizSubmission.new
|
||||
qs.stubs(:retriable?).returns false
|
||||
qs
|
||||
end
|
||||
|
||||
it 'should regenerate when possible' do
|
||||
participant.stubs(:find_quiz_submission).returns { retriable_qs }
|
||||
|
||||
expect do
|
||||
subject.create quiz
|
||||
end.to_not raise_error
|
||||
end
|
||||
|
||||
it 'should not regenerate if the QS is not retriable' do
|
||||
participant.stubs(:find_quiz_submission).returns { unretriable_qs }
|
||||
|
||||
expect do
|
||||
subject.create quiz
|
||||
end.to raise_error(ApiError, /already exists/i)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'as an anonymous participant' do
|
||||
before :each do
|
||||
participant.user = nil
|
||||
quiz.context = Course.new
|
||||
end
|
||||
|
||||
it 'should allow taking a quiz in a public course' do
|
||||
quiz.context.is_public = true
|
||||
|
||||
expect { subject.create quiz }.to_not raise_error
|
||||
end
|
||||
|
||||
it 'should deny access otherwise' do
|
||||
expect do
|
||||
subject.create quiz
|
||||
end.to raise_error(ApiError, /not allowed to participate/i)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_preview' do
|
||||
it 'should utilize the user code instead of the user' do
|
||||
quiz.expects(:generate_submission).with(participant.user_code, true)
|
||||
quiz.stubs(:grants_right?).returns true
|
||||
|
||||
subject.create_preview quiz, nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#complete' do
|
||||
let :qs do
|
||||
qs = QuizSubmission.new
|
||||
qs.attempt = 1
|
||||
qs.quiz = quiz
|
||||
qs
|
||||
end
|
||||
|
||||
context 'as the participant' do
|
||||
before :each do
|
||||
quiz.stubs(:grants_right?).returns true
|
||||
end
|
||||
|
||||
let :service_action do
|
||||
lambda { |*_| subject.complete qs, qs.attempt }
|
||||
end
|
||||
|
||||
it_should_behave_like 'Takeable Quiz Services'
|
||||
|
||||
it 'should complete the QS' do
|
||||
expect do
|
||||
subject.complete qs, qs.attempt
|
||||
end.to_not raise_error
|
||||
end
|
||||
|
||||
it 'should reject an invalid attempt' do
|
||||
expect do
|
||||
subject.complete qs, 'hi'
|
||||
end.to raise_error(ApiError, /invalid attempt/)
|
||||
end
|
||||
|
||||
it 'should reject completing an old attempt' do
|
||||
expect do
|
||||
subject.complete qs, 0
|
||||
end.to raise_error(ApiError, /attempt 0 can not be modified/)
|
||||
end
|
||||
|
||||
it 'should reject an invalid validation_token' do
|
||||
qs.validation_token = 'yep'
|
||||
participant.validation_token = 'nope'
|
||||
|
||||
expect do
|
||||
subject.complete qs, qs.attempt
|
||||
end.to raise_error(ApiError, /invalid token/)
|
||||
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
|
||||
|
||||
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
|
||||
it 'should deny access' do
|
||||
quiz.context = Course.new
|
||||
participant.user = nil
|
||||
|
||||
expect do
|
||||
subject.complete qs, qs.attempt
|
||||
end.to raise_error(ApiError, /not allowed to complete/i)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue