Skip to content

Commit e3778b5

Browse files
committed
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>
1 parent c6808ec commit e3778b5

18 files changed

Lines changed: 1145 additions & 116 deletions

app/controllers/quiz_submissions_api_controller.rb

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,12 @@
127127
class QuizSubmissionsApiController < ApplicationController
128128
include Api::V1::QuizSubmission
129129
include Api::V1::Helpers::QuizzesApiHelper
130+
include Api::V1::Helpers::QuizSubmissionsApiHelper
130131

131132
before_filter :require_user, :require_context, :require_quiz
132-
before_filter :require_quiz_submission, :only => [ :show ]
133+
before_filter :require_overridden_quiz, :except => [ :index ]
134+
before_filter :require_quiz_submission, :except => [ :index, :create ]
135+
before_filter :prepare_service, :only => [ :create, :complete ]
133136

134137
# @API Get all quiz submissions.
135138
# @beta
@@ -175,25 +178,110 @@ def index
175178
# }
176179
def show
177180
if authorized_action(@quiz_submission, @current_user, :read)
178-
render :json => quiz_submissions_json([ @quiz_submission ],
179-
@quiz,
180-
@current_user,
181-
session,
182-
@context,
183-
Array(params[:include]))
181+
render_quiz_submission(@quiz_submission)
184182
end
185183
end
186184

185+
# @API Create the quiz submission (start a quiz-taking session)
186+
# @beta
187+
#
188+
# Start taking a Quiz by creating a QuizSubmission which you can use to answer
189+
# questions and submit your answers.
190+
#
191+
# @argument validation_token [String]
192+
# The unique validation token you received when this Quiz Submission was
193+
# created.
194+
#
195+
# @argument access_code [Optional, String]
196+
# Access code for the Quiz, if any.
197+
#
198+
# @argument preview [Optional, Boolean]
199+
# Whether this should be a preview QuizSubmission and not count towards
200+
# the user's course record. Teachers only.
201+
#
202+
# <b>Responses</b>
203+
#
204+
# * <b>200 OK</b> if the request was successful
205+
# * <b>400 Bad Request</b> if the quiz is locked
206+
# * <b>403 Forbidden</b> if an invalid access code is specified
207+
# * <b>403 Forbidden</b> if the Quiz's IP filter restriction does not pass
208+
# * <b>409 Conflict</b> if a QuizSubmission already exists for this user and quiz
209+
#
210+
# @example_response
211+
# {
212+
# "quiz_submissions": [QuizSubmission]
213+
# }
214+
def create
215+
quiz_submission = if previewing?
216+
@service.create_preview(@quiz, session)
217+
else
218+
@service.create(@quiz)
219+
end
220+
221+
log_asset_access(@quiz, 'quizzes', 'quizzes', 'participate')
222+
223+
render_quiz_submission(quiz_submission)
224+
end
225+
226+
def update
227+
end
228+
229+
# @API Complete the quiz submission (turn it in).
230+
# @beta
231+
#
232+
# Complete the quiz submission by marking it as complete and grading it. When
233+
# the quiz submission has been marked as complete, no further modifications
234+
# will be allowed.
235+
#
236+
# @argument attempt [Integer]
237+
# The attempt number of the quiz submission that should be completed. Note
238+
# that this must be the latest attempt index, as earlier attempts can not
239+
# be modified.
240+
#
241+
# @argument validation_token [String]
242+
# The unique validation token you received when this Quiz Submission was
243+
# created.
244+
#
245+
# @argument access_code [Optional, String]
246+
# Access code for the Quiz, if any.
247+
#
248+
# <b>Responses</b>
249+
#
250+
# * <b>200 OK</b> if the request was successful
251+
# * <b>403 Forbidden</b> if an invalid access code is specified
252+
# * <b>403 Forbidden</b> if the Quiz's IP filter restriction does not pass
253+
# * <b>403 Forbidden</b> if an invalid token is specified
254+
# * <b>400 Bad Request</b> if the QS is already complete
255+
# * <b>400 Bad Request</b> if the attempt parameter is missing
256+
# * <b>400 Bad Request</b> if the attempt parameter is not the latest attempt
257+
#
258+
# @example_response
259+
# {
260+
# "quiz_submissions": [QuizSubmission]
261+
# }
262+
def complete
263+
@service.complete @quiz_submission, params[:attempt]
264+
265+
render_quiz_submission(@quiz_submission)
266+
end
267+
187268
private
188269

189-
def require_quiz_submission
190-
unless @quiz_submission = @quiz.quiz_submissions.find(params[:id])
191-
raise ActiveRecord::RecordNotFound
192-
end
270+
def previewing?
271+
!!params[:preview]
193272
end
194273

195274
def visible_user_ids(opts = {})
196275
scope = @context.enrollments_visible_to(@current_user, opts)
197276
scope.pluck(:user_id)
198277
end
278+
279+
def render_quiz_submission(qs)
280+
render :json => quiz_submissions_json([ qs ],
281+
@quiz,
282+
@current_user,
283+
session,
284+
@context,
285+
Array(params[:include]))
286+
end
199287
end

app/models/quiz.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,16 @@ def generate_submission(user, preview=false)
650650
submission
651651
end
652652

653+
def generate_submission_for_participant(quiz_participant)
654+
identity = if quiz_participant.anonymous?
655+
:user_code
656+
else
657+
:user
658+
end
659+
660+
generate_submission quiz_participant.send(identity), false
661+
end
662+
653663
def prepare_answers(question)
654664
if answers = question[:answers]
655665
if shuffle_answers && Quiz.shuffleable_question_type?(question[:question_type])

app/models/quiz_participant.rb

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
class QuizParticipant
2+
attr_accessor :user, :user_code, :access_code, :ip_address, :validation_token
3+
4+
# An identity for a quiz participant, which can be an enrolled student,
5+
# an anonymous user, or a teacher.
6+
#
7+
# @param [User] user
8+
# The person who wants to take the quiz.
9+
#
10+
# @param [String] user_code
11+
# A unique code to use for identifying the participant in case the user is
12+
# missing or is irrelevant (the case for preview mode). This code is usually
13+
# found in the Rails session. See ApplicationController#temporary_user_code
14+
# for more info.
15+
#
16+
# @param [String] access_code
17+
# Access code required to take the quiz (if any.)
18+
#
19+
# @param [String] ip_address
20+
# The IP address of the client's device that initiated the request.
21+
#
22+
# @param [String] token
23+
# Validation token for the participant's existing quiz submission.
24+
#
25+
# @return [QuizParticipant]
26+
# Participant instance ready for use by Quiz Services.
27+
def initialize(user, user_code, access_code=nil, ip_address=nil, token=nil)
28+
self.user = user
29+
self.user_code = user_code
30+
self.access_code = access_code
31+
self.ip_address = ip_address
32+
self.validation_token = token
33+
34+
super()
35+
end
36+
37+
# Locate the Quiz Submission for this participant, regardless of them being
38+
# enrolled students, or anonymous participants.
39+
#
40+
# @param [ActiveRecord::Association]
41+
# The pool of QuizSubmission instances to look in, defaults to all.
42+
#
43+
# @param [Hash] query_options
44+
# Options to pass to the AR query interface.
45+
#
46+
# @return [QuizSubmission]
47+
# The QS, if any, for the participant.
48+
def find_quiz_submission(scope = QuizSubmission, query_options = {})
49+
self.anonymous? ?
50+
scope.find_by_temporary_user_code(self.user_code, query_options) :
51+
scope.find_by_user_id(self.user.id, query_options)
52+
end
53+
54+
# Is this a Canvas user (enrolled student, teacher, TA, etc.) or an anonymous
55+
# person?
56+
#
57+
# Note that this does not actually take the Quiz's public-participation status
58+
# into account, only the fact that the participant is authentic or not.
59+
def anonymous?
60+
self.user.nil? && self.user_code.present?
61+
end
62+
end

app/models/quiz_submission.rb

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,8 +294,35 @@ def sanitize_params(params)
294294
params
295295
end
296296

297-
def snapshot!(params)
298-
QuizSubmissionSnapshot.create(:quiz_submission => self, :attempt => self.attempt, :data => params)
297+
# Generate a snapshot of the QS representing its current state and answer data.
298+
#
299+
# Multiple snapshots can be taken for a single QS, and they're further scoped
300+
# to the QuizSubmission#attempt index.
301+
#
302+
# @param [Hash] submission_data
303+
# Answer data the snapshot should represent.
304+
#
305+
# @param [Boolean] full_snapshot
306+
# Set to true to indicate that the snapshot should represent both the QS's
307+
# current answer data along with the passed in answer data (patched).
308+
# This is useful for supporting incremental snapshots where you're only
309+
# passing in the part of the answer data that has changed.
310+
#
311+
# @return [QuizSubmissionSnapshot]
312+
# The latest, newly-created snapshot.
313+
def snapshot!(submission_data={}, full_snapshot=false)
314+
snapshot_data = submission_data || {}
315+
316+
if full_snapshot
317+
snapshot_data = self.sanitize_params(snapshot_data).stringify_keys
318+
snapshot_data.merge!(self.submission_data || {})
319+
end
320+
321+
QuizSubmissionSnapshot.create({
322+
quiz_submission: self,
323+
attempt: self.attempt,
324+
data: snapshot_data
325+
})
299326
end
300327

301328
def questions_as_object
@@ -533,6 +560,24 @@ def grade_submission(opts={})
533560
QuizRegrader.regrade!(options)
534561
end
535562

563+
# Complete (e.g, turn-in) the quiz submission by doing the following:
564+
#
565+
# - generating a (full) snapshot of the current state along with any
566+
# additional answer data that you pass in
567+
# - marking the QS as complete (see #workflow_state)
568+
# - grading the QS (see #grade_submission)
569+
#
570+
# @param [Hash] submission_data
571+
# Additional answer data to attach to the QS before completing it.
572+
#
573+
# @return [QuizSubmission] self
574+
def complete!(submission_data={})
575+
self.snapshot!(submission_data, true)
576+
self.mark_completed
577+
self.grade_submission
578+
self
579+
end
580+
536581
# Updates a simply_versioned version instance in-place. We want
537582
# a teacher to be able to come in and update points for an already-
538583
# taken quiz, even if it's a prior version of the submission. Thank you
@@ -781,4 +826,24 @@ def grade_if_untaken
781826
delegate :assignment_id, :assignment, :to => :quiz
782827
delegate :graded_at, :to => :submission
783828
delegate :context, :to => :quiz
829+
830+
# Determine whether the QS can be retried (ie, re-generated).
831+
#
832+
# A QS is determined to be retriable if:
833+
#
834+
# - it's a settings_only? one
835+
# - it's a preview? one
836+
# - it's complete and still has attempts left to spare
837+
# - it's complete and the quiz allows for unlimited attempts
838+
#
839+
# @return [Boolean]
840+
# Whether the QS is retriable.
841+
def retriable?
842+
return true if self.preview?
843+
return true if self.settings_only?
844+
845+
attempts_left = self.attempts_left || 0
846+
847+
self.completed? && (attempts_left > 0 || self.quiz.unlimited_attempts?)
848+
end
784849
end

0 commit comments

Comments
 (0)