Quiz Submissions API - Index
Endpoints introduced: - GET /api/v1/courses/<course_id>/quizzes/<quiz_id>/submissions Refactoring/changes: - Creating a ZIP archive has been moved, and refactored, to the Submission API model because it was used by both QuizSubmission and Submission controllers - time_spent is a property now exposed for QuizSubmission objects - new API spec shared example group 'API tests': - new API spec helper 'assert_jsonapi_compliance!' that tests a given API response for compliance with the JSON-API format specification fixes CNVS-8978, CNVS-8923 Change-Id: I6a18e35a2bcd0ebe357faff46c18f1bce54ddac0 Reviewed-on: https://gerrit.instructure.com/26073 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Derek DeVries <ddevries@instructure.com> QA-Review: Myller de Araujo <myller@instructure.com> Product-Review: Derek DeVries <ddevries@instructure.com>
This commit is contained in:
parent
38bb3ca38a
commit
8609c2a8f3
|
@ -16,9 +16,200 @@
|
|||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
# @API Quiz Submissions
|
||||
# @beta
|
||||
#
|
||||
# API for accessing quiz submissions
|
||||
#
|
||||
# @model QuizSubmission
|
||||
# {
|
||||
# "id": "QuizSubmission",
|
||||
# "required": ["id", "quiz_id"],
|
||||
# "properties": {
|
||||
# "id": {
|
||||
# "description": "The ID of the quiz submission.",
|
||||
# "example": 1,
|
||||
# "type": "integer",
|
||||
# "format": "int64"
|
||||
# },
|
||||
# "quiz_id": {
|
||||
# "description": "The ID of the Quiz the quiz submission belongs to.",
|
||||
# "example": 2,
|
||||
# "type": "integer",
|
||||
# "format": "int64"
|
||||
# },
|
||||
# "user_id": {
|
||||
# "description": "The ID of the Student that made the quiz submission.",
|
||||
# "example": 3,
|
||||
# "type": "integer",
|
||||
# "format": "int64"
|
||||
# },
|
||||
# "submission_id": {
|
||||
# "description": "The ID of the Submission the quiz submission represents.",
|
||||
# "example": 1,
|
||||
# "type": "integer",
|
||||
# "format": "int64"
|
||||
# },
|
||||
# "started_at": {
|
||||
# "description": "The time at which the student started the quiz submission.",
|
||||
# "example": "2013-11-07T13:16:18Z",
|
||||
# "type": "string",
|
||||
# "format": "date-time"
|
||||
# },
|
||||
# "finished_at": {
|
||||
# "description": "The time at which the student submitted the quiz submission.",
|
||||
# "example": "2013-11-07T13:16:18Z",
|
||||
# "type": "string",
|
||||
# "format": "date-time"
|
||||
# },
|
||||
# "end_at": {
|
||||
# "description": "The time at which the quiz submission will be overdue, and be flagged as a late submission.",
|
||||
# "example": "2013-11-07T13:16:18Z",
|
||||
# "type": "string",
|
||||
# "format": "date-time"
|
||||
# },
|
||||
# "attempt": {
|
||||
# "description": "For quizzes that allow multiple attempts, this field specifies the quiz submission attempt number.",
|
||||
# "example": 3,
|
||||
# "type": "integer",
|
||||
# "format": "int64"
|
||||
# },
|
||||
# "extra_attempts": {
|
||||
# "description": "Number of times the student was allowed to re-take the quiz over the multiple-attempt limit.",
|
||||
# "example": 1,
|
||||
# "type": "integer",
|
||||
# "format": "int64"
|
||||
# },
|
||||
# "extra_time": {
|
||||
# "description": "Amount of extra time allowed for the quiz submission, in seconds.",
|
||||
# "example": 60,
|
||||
# "type": "integer",
|
||||
# "format": "int64"
|
||||
# },
|
||||
# "time_spent": {
|
||||
# "description": "Amount of time spent, in seconds.",
|
||||
# "example": 300,
|
||||
# "type": "integer",
|
||||
# "format": "int64"
|
||||
# },
|
||||
# "score": {
|
||||
# "description": "The score of the quiz submission, if graded.",
|
||||
# "example": 3,
|
||||
# "type": "integer",
|
||||
# "format": "int64"
|
||||
# },
|
||||
# "score_before_regrade": {
|
||||
# "description": "The original score of the quiz submission prior to any re-grading.",
|
||||
# "example": 2,
|
||||
# "type": "integer",
|
||||
# "format": "int64"
|
||||
# },
|
||||
# "kept_score": {
|
||||
# "description": "For quizzes that allow multiple attempts, this is the score that will be used, which might be the score of the latest, or the highest, quiz submission.",
|
||||
# "example": 5,
|
||||
# "type": "integer",
|
||||
# "format": "int64"
|
||||
# },
|
||||
# "fudge_points": {
|
||||
# "description": "Number of points the quiz submission's score was fudged by.",
|
||||
# "example": 1,
|
||||
# "type": "integer",
|
||||
# "format": "int64"
|
||||
# },
|
||||
# "workflow_state": {
|
||||
# "description": "The current state of the quiz submission. Possible values: ['untaken'|'pending_review'|'complete'|'settings_only'|'preview'].",
|
||||
# "example": "untaken",
|
||||
# "type": "string"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
#
|
||||
class QuizSubmissionsApiController < ApplicationController
|
||||
include Api::V1::Submission
|
||||
include Api::V1::QuizSubmission
|
||||
|
||||
before_filter :require_user, :require_context
|
||||
before_filter :require_user, :require_context, :require_quiz
|
||||
|
||||
# @API Get all quiz submissions.
|
||||
# @beta
|
||||
#
|
||||
# Get a list of all submissions for this quiz.
|
||||
#
|
||||
# @argument include[] [String, "submission"|"quiz"|"user"]
|
||||
# Associations to include with the quiz submission.
|
||||
#
|
||||
# <b>200 OK</b> response code is returned if the request was successful.
|
||||
#
|
||||
# @example_response
|
||||
# {
|
||||
# "quiz_submissions": [
|
||||
# {
|
||||
# "attempt": 6,
|
||||
# "end_at": null,
|
||||
# "extra_attempts": null,
|
||||
# "extra_time": null,
|
||||
# "finished_at": "2013-11-07T13:16:18Z",
|
||||
# "fudge_points": null,
|
||||
# "id": 8,
|
||||
# "kept_score": 4,
|
||||
# "quiz_id": 8,
|
||||
# "quiz_points_possible": 6,
|
||||
# "quiz_version": 13,
|
||||
# "score": 0,
|
||||
# "score_before_regrade": null,
|
||||
# "started_at": "2013-10-24T05:21:22Z",
|
||||
# "submission_id": 6,
|
||||
# "user_id": 2,
|
||||
# "workflow_state": "pending_review",
|
||||
# "time_spent": 1238095,
|
||||
# "html_url": "http://example.com/courses/1/quizzes/8/submissions/8"
|
||||
# },
|
||||
# {
|
||||
# "attempt": 1,
|
||||
# "end_at": "2013-10-31T05:59:59Z",
|
||||
# "extra_attempts": null,
|
||||
# "extra_time": null,
|
||||
# "finished_at": "2013-10-29T05:04:42Z",
|
||||
# "fudge_points": 0,
|
||||
# "id": 9,
|
||||
# "kept_score": 5,
|
||||
# "quiz_id": 8,
|
||||
# "quiz_points_possible": 6,
|
||||
# "quiz_version": 13,
|
||||
# "score": 5,
|
||||
# "score_before_regrade": null,
|
||||
# "started_at": "2013-10-29T05:04:32Z",
|
||||
# "submission_id": 7,
|
||||
# "user_id": 5,
|
||||
# "workflow_state": "complete",
|
||||
# "time_spent": 10,
|
||||
# "html_url": "http://example.com/courses/1/quizzes/8/submissions/9"
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
def index
|
||||
if authorized_action(@context, @current_user, [:manage_grades, :view_all_grades])
|
||||
scope = @quiz.quiz_submissions.where(:user_id => visible_user_ids)
|
||||
api_route = polymorphic_url([:api, :v1, @context, @quiz, :submissions])
|
||||
|
||||
quiz_submissions = Api.paginate(scope, self, api_route)
|
||||
|
||||
includes = Array(params[:include])
|
||||
out = quiz_submissions_json(quiz_submissions, @quiz, @current_user, session, @context, includes)
|
||||
|
||||
render :json => out
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_quiz
|
||||
unless @quiz = @context.quizzes.find(params[:quiz_id])
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
end
|
||||
|
||||
def visible_user_ids(opts = {})
|
||||
scope = @context.enrollments_visible_to(@current_user, opts)
|
||||
scope.pluck(:user_id)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
#
|
||||
|
||||
class QuizSubmissionsController < ApplicationController
|
||||
include Api::V1::QuizSubmission
|
||||
protect_from_forgery :except => [:create, :backup, :record_answer]
|
||||
before_filter :require_context
|
||||
batch_jobs_in_actions :only => [:update, :create], :batch => { :priority => Delayed::LOW_PRIORITY }
|
||||
|
@ -24,7 +25,7 @@ class QuizSubmissionsController < ApplicationController
|
|||
def index
|
||||
@quiz = @context.quizzes.find(params[:quiz_id])
|
||||
if params[:zip] && authorized_action(@quiz, @current_user, :grade)
|
||||
submission_zip
|
||||
generate_submission_zip(@quiz, @context)
|
||||
else
|
||||
redirect_to named_context_url(@context, :context_quiz_url, @quiz.id)
|
||||
end
|
||||
|
@ -182,42 +183,46 @@ class QuizSubmissionsController < ApplicationController
|
|||
is_previewing? ? { :preview => 1 } : {}
|
||||
end
|
||||
|
||||
# TODO: this is mostly copied and pasted from submission_controller.rb. pull
|
||||
# out common code
|
||||
def submission_zip
|
||||
@attachments = @quiz.attachments.where(:display_name => 'submissions.zip', :workflow_state => ['to_be_zipped', 'zipping', 'zipped', 'errored', 'unattached'], :user_id => @current_user).order(:created_at).all
|
||||
@attachment = @attachments.pop
|
||||
@attachments.each{|a| a.destroy! }
|
||||
if @attachment && (@attachment.created_at < 1.hour.ago || @attachment.created_at < (@quiz.quiz_submissions.map{|s| s.finished_at}.compact.max || @attachment.created_at))
|
||||
@attachment.destroy!
|
||||
@attachment = nil
|
||||
end
|
||||
if !@attachment
|
||||
@attachment = @quiz.attachments.build(:display_name => 'submissions.zip')
|
||||
@attachment.workflow_state = 'to_be_zipped'
|
||||
@attachment.file_state = '0'
|
||||
@attachment.user = @current_user
|
||||
@attachment.save!
|
||||
ContentZipper.send_later_enqueue_args(:process_attachment, { :priority => Delayed::LOW_PRIORITY, :max_attempts => 1 }, @attachment)
|
||||
render :json => @attachment
|
||||
else
|
||||
respond_to do |format|
|
||||
if @attachment.zipped?
|
||||
if Attachment.s3_storage?
|
||||
format.html { redirect_to @attachment.cacheable_s3_inline_url }
|
||||
format.zip { redirect_to @attachment.cacheable_s3_inline_url }
|
||||
else
|
||||
cancel_cache_buster
|
||||
format.html { send_file(@attachment.full_filename, :type => @attachment.content_type_with_encoding, :disposition => 'inline') }
|
||||
format.zip { send_file(@attachment.full_filename, :type => @attachment.content_type_with_encoding, :disposition => 'inline') }
|
||||
end
|
||||
format.json { render :json => @attachment.as_json(:methods => :readable_size) }
|
||||
|
||||
def generate_submission_zip(quiz, context)
|
||||
attachment = quiz_submission_zip(quiz)
|
||||
|
||||
respond_to do |format|
|
||||
if attachment.zipped?
|
||||
if Attachment.s3_storage?
|
||||
format.html { redirect_to attachment.cacheable_s3_inline_url }
|
||||
format.zip { redirect_to attachment.cacheable_s3_inline_url }
|
||||
else
|
||||
flash[:notice] = t('still_zipping', "File zipping still in process...")
|
||||
format.html { redirect_to named_context_url(@context, :context_quiz_url, @quiz.id) }
|
||||
format.zip { redirect_to named_context_url(@context, :context_quiz_url, @quiz.id) }
|
||||
format.json { render :json => @attachment }
|
||||
cancel_cache_buster
|
||||
|
||||
format.html do
|
||||
send_file(attachment.full_filename, {
|
||||
:type => attachment.content_type_with_encoding,
|
||||
:disposition => 'inline'
|
||||
})
|
||||
end
|
||||
|
||||
format.zip do
|
||||
send_file(attachment.full_filename, {
|
||||
:type => attachment.content_type_with_encoding,
|
||||
:disposition => 'inline'
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
format.json { render :json => attachment.as_json(:methods => :readable_size) }
|
||||
else
|
||||
flash[:notice] = t('still_zipping', "File zipping still in process...")
|
||||
|
||||
format.html do
|
||||
redirect_to named_context_url(context, :context_quiz_url, quiz.id)
|
||||
end
|
||||
|
||||
format.zip do
|
||||
redirect_to named_context_url(context, :context_quiz_url, quiz.id)
|
||||
end
|
||||
|
||||
format.json { render :json => attachment }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -106,7 +106,7 @@ class SubmissionsController < ApplicationController
|
|||
@assignment = @context.assignments.active.find(params[:assignment_id])
|
||||
if authorized_action(@assignment, @current_user, :grade)
|
||||
if params[:zip]
|
||||
submission_zip
|
||||
generate_submission_zip(@assignment, @context)
|
||||
else
|
||||
respond_to do |format|
|
||||
format.html { redirect_to named_context_url(@context, :context_assignment_url, @assignment.id) }
|
||||
|
@ -529,44 +529,6 @@ class SubmissionsController < ApplicationController
|
|||
|
||||
protected
|
||||
|
||||
def submission_zip
|
||||
@attachments = @assignment.attachments.where(:display_name => 'submissions.zip', :workflow_state => ['to_be_zipped', 'zipping', 'zipped', 'errored', 'unattached'], :user_id => @current_user).order(:created_at).all
|
||||
@attachment = @attachments.pop
|
||||
@attachments.each{|a| a.destroy! }
|
||||
if @attachment && (@attachment.created_at < 1.hour.ago || @attachment.created_at < (@assignment.submissions.map{|s| s.submitted_at}.compact.max || @attachment.created_at))
|
||||
@attachment.destroy!
|
||||
@attachment = nil
|
||||
end
|
||||
if !@attachment
|
||||
@attachment = @assignment.attachments.build(:display_name => 'submissions.zip')
|
||||
@attachment.workflow_state = 'to_be_zipped'
|
||||
@attachment.file_state = '0'
|
||||
@attachment.user = @current_user
|
||||
@attachment.save!
|
||||
ContentZipper.send_later_enqueue_args(:process_attachment, { :priority => Delayed::LOW_PRIORITY, :max_attempts => 1 }, @attachment)
|
||||
render :json => @attachment
|
||||
else
|
||||
respond_to do |format|
|
||||
if @attachment.zipped?
|
||||
if Attachment.s3_storage?
|
||||
format.html { redirect_to @attachment.cacheable_s3_inline_url }
|
||||
format.zip { redirect_to @attachment.cacheable_s3_inline_url }
|
||||
else
|
||||
cancel_cache_buster
|
||||
format.html { send_file(@attachment.full_filename, :type => @attachment.content_type_with_encoding, :disposition => 'inline') }
|
||||
format.zip { send_file(@attachment.full_filename, :type => @attachment.content_type_with_encoding, :disposition => 'inline') }
|
||||
end
|
||||
format.json { render :json => @attachment.as_json(:methods => :readable_size) }
|
||||
else
|
||||
flash[:notice] = t('still_zipping', "File zipping still in process...")
|
||||
format.html { redirect_to named_context_url(@context, :context_assignment_url, @assignment.id) }
|
||||
format.zip { redirect_to named_context_url(@context, :context_assignment_url, @assignment.id) }
|
||||
format.json { render :json => @attachment }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update_student_entered_score(score)
|
||||
if score.present? && score != "null"
|
||||
@submission.student_entered_score = score.to_f.round(2)
|
||||
|
@ -575,4 +537,46 @@ class SubmissionsController < ApplicationController
|
|||
end
|
||||
@submission.save
|
||||
end
|
||||
|
||||
def generate_submission_zip(assignment, context)
|
||||
attachment = submission_zip(assignment)
|
||||
|
||||
respond_to do |format|
|
||||
if attachment.zipped?
|
||||
if Attachment.s3_storage?
|
||||
format.html { redirect_to attachment.cacheable_s3_inline_url }
|
||||
format.zip { redirect_to attachment.cacheable_s3_inline_url }
|
||||
else
|
||||
cancel_cache_buster
|
||||
|
||||
format.html do
|
||||
send_file(attachment.full_filename, {
|
||||
:type => attachment.content_type_with_encoding,
|
||||
:disposition => 'inline'
|
||||
})
|
||||
end
|
||||
|
||||
format.zip do
|
||||
send_file(attachment.full_filename, {
|
||||
:type => attachment.content_type_with_encoding,
|
||||
:disposition => 'inline'
|
||||
})
|
||||
end
|
||||
end
|
||||
format.json { render :json => attachment.as_json(:methods => :readable_size) }
|
||||
else
|
||||
flash[:notice] = t('still_zipping', "File zipping still in process...")
|
||||
|
||||
format.html do
|
||||
redirect_to named_context_url(context, :context_assignment_url, assignment.id)
|
||||
end
|
||||
|
||||
format.zip do
|
||||
redirect_to named_context_url(context, :context_assignment_url, assignment.id)
|
||||
end
|
||||
|
||||
format.json { render :json => attachment }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -637,6 +637,11 @@ class QuizSubmission < ActiveRecord::Base
|
|||
(self.finished_at || self.started_at) - self.started_at rescue 0
|
||||
end
|
||||
|
||||
def time_spent
|
||||
return unless finished_at.present?
|
||||
(finished_at - started_at + (extra_time||0)).round
|
||||
end
|
||||
|
||||
def self.score_question(q, params)
|
||||
params = params.with_indifferent_access
|
||||
# TODO: undefined_if_blank - we need a better solution for the
|
||||
|
|
|
@ -1310,6 +1310,10 @@ routes.draw do
|
|||
post 'courses/:course_id/quizzes/:quiz_id/quiz_submissions/self/files', :action => :create, :path_name => 'quiz_submission_files'
|
||||
end
|
||||
|
||||
scope(:controller => :quiz_submissions_api) do
|
||||
get 'courses/:course_id/quizzes/:quiz_id/quiz_submissions', :action => :index, :path_name => 'course_quiz_submissions'
|
||||
end
|
||||
|
||||
scope(:controller => :outcome_groups_api) do
|
||||
def og_routes(context)
|
||||
prefix = (context == "global" ? context : "#{context}s/:#{context}_id")
|
||||
|
|
|
@ -0,0 +1,116 @@
|
|||
#
|
||||
# Copyright (C) 2011 - 2012 Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
module Api::V1::QuizSubmission
|
||||
include Api::V1::Json
|
||||
include Api::V1::Submission
|
||||
include Api::V1::Quiz
|
||||
include Api::V1::User
|
||||
|
||||
QUIZ_SUBMISSION_JSON_FIELDS = %w[
|
||||
id
|
||||
user_id
|
||||
submission_id
|
||||
quiz_id
|
||||
quiz_points_possible
|
||||
quiz_version
|
||||
attempt
|
||||
extra_attempts
|
||||
extra_time
|
||||
started_at
|
||||
finished_at
|
||||
end_at
|
||||
fudge_points
|
||||
kept_score
|
||||
score
|
||||
score_before_regrade
|
||||
workflow_state
|
||||
].freeze
|
||||
|
||||
QUIZ_SUBMISSION_JSON_FIELD_METHODS = %w[
|
||||
time_spent
|
||||
].freeze
|
||||
|
||||
def quiz_submission_json(qs, quiz, user, session, context = nil)
|
||||
context ||= quiz.context
|
||||
|
||||
hash = api_json(qs, user, session, {
|
||||
only: QUIZ_SUBMISSION_JSON_FIELDS,
|
||||
methods: QUIZ_SUBMISSION_JSON_FIELD_METHODS.dup
|
||||
})
|
||||
|
||||
hash.merge!({
|
||||
html_url: polymorphic_url([ context, quiz, qs ]),
|
||||
})
|
||||
|
||||
hash
|
||||
end
|
||||
|
||||
# Render a set of Quiz Submission objects as JSON-API.
|
||||
#
|
||||
# @param [QuizSubmission|Array<QuizSubmission>] quiz_submissions
|
||||
# The resource(s) to render.
|
||||
#
|
||||
# @param [Array<String>] includes
|
||||
# Associations to include in the output for each Quiz Submission.
|
||||
# Allowed associations are: "user", "quiz", and "submission"
|
||||
#
|
||||
# @return [Hash]
|
||||
# A JSON-API complying construct representing the quiz submissions, and
|
||||
# any associations requested.
|
||||
def quiz_submissions_json(quiz_submissions, quiz, user, session, context = nil, includes = [])
|
||||
hash = {}
|
||||
hash[:quiz_submissions] = [ quiz_submissions ].flatten.map do |qs|
|
||||
quiz_submission_json(qs, quiz, user, session, context)
|
||||
end
|
||||
|
||||
if includes.include?('submission')
|
||||
with_submissions = quiz_submissions.select { |qs| !!qs.submission }
|
||||
|
||||
hash[:submissions] = with_submissions.map do |qs|
|
||||
submission_json(qs.submission, quiz.assignment, user, session, context)
|
||||
end
|
||||
end
|
||||
|
||||
if includes.include?('quiz')
|
||||
hash[:quizzes] = [
|
||||
quiz_json(quiz, context, user, session)
|
||||
]
|
||||
end
|
||||
|
||||
if includes.include?('user')
|
||||
hash[:users] = quiz_submissions.map do |qs|
|
||||
user_json(qs.user, user, session, ['avatar_url'], context, nil)
|
||||
end
|
||||
end
|
||||
|
||||
unless includes.empty?
|
||||
hash[:meta] = {
|
||||
primaryCollection: 'quiz_submissions'
|
||||
}
|
||||
end
|
||||
|
||||
hash
|
||||
end
|
||||
|
||||
def quiz_submission_zip(quiz)
|
||||
latest_submission = quiz.quiz_submissions.map { |s| s.finished_at }.compact.max
|
||||
submission_zip(quiz, latest_submission)
|
||||
end
|
||||
end
|
||||
|
|
@ -149,5 +149,57 @@ module Api::V1::Submission
|
|||
|
||||
hash
|
||||
end
|
||||
|
||||
# Create an attachment with a ZIP archive of an assignment's submissions.
|
||||
# The attachment will be re-created if it's 1 hour old, or determined to be
|
||||
# "stale". See the argument descriptions for testing the staleness of the attachment.
|
||||
#
|
||||
# @param [Assignment] assignment
|
||||
# The assignment, or an object that implements its interface, for which the
|
||||
# submissions will be zipped.
|
||||
#
|
||||
# @param [DateTime] updated_at
|
||||
# A timestamp that marks the latest update to the assignment object which will
|
||||
# be used to determine whether the attachment will be re-created.
|
||||
#
|
||||
# Note that this timestamp will be ignored if the attachment is 1 hour old.
|
||||
#
|
||||
# @return [Attachment] The attachment that contains the archive.
|
||||
def submission_zip(assignment, updated_at = nil)
|
||||
attachments = assignment.attachments.where({
|
||||
display_name: 'submissions.zip',
|
||||
workflow_state: %w[to_be_zipped zipping zipped errored unattached],
|
||||
user_id: @current_user
|
||||
}).order(:created_at).all
|
||||
|
||||
attachment = attachments.pop
|
||||
attachments.each { |a| a.destroy! }
|
||||
|
||||
# Remove the earlier attachment and re-create it if it's "stale"
|
||||
if attachment
|
||||
created_at = attachment.created_at
|
||||
updated_at ||= assignment.submissions.map { |s| s.submitted_at }.compact.max
|
||||
|
||||
if created_at < 1.hour.ago || (updated_at && created_at < updated_at)
|
||||
attachment.destroy!
|
||||
attachment = nil
|
||||
end
|
||||
end
|
||||
|
||||
if !attachment
|
||||
attachment = assignment.attachments.build(:display_name => 'submissions.zip')
|
||||
attachment.workflow_state = 'to_be_zipped'
|
||||
attachment.file_state = '0'
|
||||
attachment.user = @current_user
|
||||
attachment.save!
|
||||
|
||||
ContentZipper.send_later_enqueue_args(:process_attachment, {
|
||||
priority: Delayed::LOW_PRIORITY,
|
||||
max_attempts: 1
|
||||
}, attachment)
|
||||
end
|
||||
|
||||
attachment
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
#
|
||||
|
||||
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
|
||||
require File.expand_path(File.dirname(__FILE__) + '/helpers/api_specs')
|
||||
|
||||
class HashWithDupCheck < Hash
|
||||
def []=(k,v)
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
shared_examples_for "API tests" do
|
||||
|
||||
# Assert the provided JSON hash complies with the JSON-API format specification.
|
||||
#
|
||||
# The following tests will be carried out:
|
||||
#
|
||||
# - all resource entries must be wrapped inside arrays, even if the set
|
||||
# includes only a single resource entry
|
||||
# - when associations are present, a "meta" entry should be present and
|
||||
# it should indicate the primary set in the "primaryCollection" key
|
||||
#
|
||||
# @param [Hash] json
|
||||
# The JSON construct to test.
|
||||
#
|
||||
# @param [String] primary_set
|
||||
# Name of the primary resource the construct represents, i.e, the model
|
||||
# the API endpoint represents, like 'quiz', 'assignment', or 'submission'.
|
||||
#
|
||||
# @param [Array<String>] associations
|
||||
# An optional set of associated resources that should be included with
|
||||
# the primary resource (e.g, a user, an assignment, a submission, etc.).
|
||||
#
|
||||
# @example Testing a Quiz API model:
|
||||
# test_jsonapi_compliance!(json, 'quiz')
|
||||
#
|
||||
# @example Testing a Quiz API model with its assignment included:
|
||||
# test_jsonapi_compliance!(json, 'quiz', [ 'assignment' ])
|
||||
#
|
||||
# @example A complying construct of a Quiz Submission with its Assignment:
|
||||
#
|
||||
# {
|
||||
# "quiz_submissions": [{
|
||||
# "id": 10,
|
||||
# "assignment_id": 5
|
||||
# }],
|
||||
# "assignments": [{
|
||||
# "id": 5
|
||||
# }],
|
||||
# "meta": {
|
||||
# "primaryCollection": "quiz_submissions"
|
||||
# }
|
||||
# }
|
||||
#
|
||||
def assert_jsonapi_compliance!(json, primary_set, associations = [])
|
||||
required_keys = [ primary_set ]
|
||||
|
||||
if associations.any?
|
||||
required_keys.concat associations.map { |s| s.pluralize }
|
||||
required_keys << 'meta'
|
||||
end
|
||||
|
||||
json.size.should == required_keys.size
|
||||
|
||||
required_keys.each do |key|
|
||||
json.has_key?(key).should be_true
|
||||
json[key].is_a?(Array).should be_true unless key == 'meta'
|
||||
end
|
||||
|
||||
if associations.any?
|
||||
json['meta']['primaryCollection'].should == primary_set
|
||||
end
|
||||
end
|
||||
end
|
|
@ -19,5 +19,140 @@
|
|||
require File.expand_path(File.dirname(__FILE__) + '/../api_spec_helper')
|
||||
|
||||
describe QuizSubmissionsApiController, :type => :integration do
|
||||
it_should_behave_like 'API tests'
|
||||
|
||||
before :each do
|
||||
course_with_teacher_logged_in :active_all => true
|
||||
|
||||
@quiz = Quiz.create!(:title => 'quiz', :context => @course)
|
||||
@quiz.quiz_data = [ multiple_choice_question_data ]
|
||||
@quiz.generate_quiz_data
|
||||
@quiz.published_at = Time.now
|
||||
@quiz.workflow_state = 'available'
|
||||
@quiz.save!
|
||||
|
||||
@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
|
||||
|
||||
context '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}/quiz_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.has_key?('quiz_submissions').should be_true
|
||||
json['quiz_submissions'].size.should == 0
|
||||
end
|
||||
|
||||
it 'should list quiz submissions' do
|
||||
enroll_student_and_submit
|
||||
|
||||
json = get_index
|
||||
json['quiz_submissions'].size.should == 1
|
||||
end
|
||||
|
||||
it 'should restrict access to itself' do
|
||||
student_in_course
|
||||
json = get_index(true)
|
||||
response.status.to_i.should == 401
|
||||
end
|
||||
|
||||
context 'quiz submission objects' do
|
||||
before :each do
|
||||
@student, @quiz_submission = *enroll_student_and_submit
|
||||
end
|
||||
|
||||
it "should include its associated user" do
|
||||
json = get_index(false, {
|
||||
:include => [ 'user' ]
|
||||
})
|
||||
|
||||
json.has_key?('users').should be_true
|
||||
json['quiz_submissions'].size.should == 1
|
||||
json['users'].size.should == 1
|
||||
json['users'][0]['id'].should == json['quiz_submissions'][0]['user_id']
|
||||
end
|
||||
|
||||
it "should include its associated quiz" do
|
||||
json = get_index(false, {
|
||||
:include => [ 'quiz' ]
|
||||
})
|
||||
|
||||
json.has_key?('quizzes').should be_true
|
||||
json['quiz_submissions'].size.should == 1
|
||||
json['quizzes'].size.should == 1
|
||||
json['quizzes'][0]['id'].should == json['quiz_submissions'][0]['quiz_id']
|
||||
end
|
||||
|
||||
it "should include its associated submission" do
|
||||
json = get_index(false, {
|
||||
:include => [ 'submission' ]
|
||||
})
|
||||
|
||||
json.has_key?('submissions').should be_true
|
||||
json['quiz_submissions'].size.should == 1
|
||||
json['submissions'].size.should == 1
|
||||
json['submissions'][0]['id'].should == json['quiz_submissions'][0]['submission_id']
|
||||
end
|
||||
|
||||
it "should include its associated user, quiz, and submission" do
|
||||
json = get_index(false, {
|
||||
:include => [ 'user', 'quiz', 'submission' ]
|
||||
})
|
||||
|
||||
json.has_key?('users').should be_true
|
||||
json.has_key?('quizzes').should be_true
|
||||
json.has_key?('submissions').should be_true
|
||||
end
|
||||
|
||||
context 'JSON-API compliance' do
|
||||
it 'should conform to the JSON-API spec when returning the object' do
|
||||
json = get_index(false)
|
||||
assert_jsonapi_compliance!(json, 'quiz_submissions')
|
||||
end
|
||||
|
||||
it 'should conform to the JSON-API spec when returning associated objects' do
|
||||
includes = [ 'user', 'quiz', 'submission' ]
|
||||
|
||||
json = get_index(false, {
|
||||
:include => includes
|
||||
})
|
||||
|
||||
assert_jsonapi_compliance!(json, 'quiz_submissions', includes)
|
||||
end
|
||||
end
|
||||
|
||||
it "should include time spent" do
|
||||
@quiz_submission.started_at = Time.now
|
||||
@quiz_submission.finished_at = @quiz_submission.started_at + 5.minutes
|
||||
@quiz_submission.save!
|
||||
|
||||
json = get_index
|
||||
json.has_key?('quiz_submissions').should be_true
|
||||
json['quiz_submissions'][0]['time_spent'].should == 5.minutes
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -66,6 +66,35 @@ describe QuizSubmission do
|
|||
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
|
||||
|
|
Loading…
Reference in New Issue