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:
Ahmad Amireh 2013-12-05 17:10:12 +03:00
parent c6808ec567
commit e3778b529c
18 changed files with 1146 additions and 117 deletions

View File

@ -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

View File

@ -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])

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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/'

View File

@ -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

View File

@ -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')

View File

@ -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

20
lib/api/v1/api_error.rb Normal file
View File

@ -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

View File

@ -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

View File

@ -17,7 +17,7 @@
#
module Api::V1::Helpers::QuizzesApiHelper
private
protected
def require_quiz
unless @quiz = @context.quizzes.find(params[:quiz_id])

View File

@ -39,6 +39,7 @@ module Api::V1::QuizSubmission
kept_score
score
score_before_regrade
validation_token
workflow_state
].freeze

View File

@ -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')

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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