335 lines
11 KiB
Ruby
335 lines
11 KiB
Ruby
class Quizzes::QuizSubmissionService
|
|
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 RequestError(403) if the student isn't allowed to take the quiz
|
|
# @throw RequestError(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! 'you are not allowed to participate in this quiz', 403
|
|
end
|
|
|
|
assert_takeability! quiz
|
|
|
|
# Look up an existing QS, and if one exists, make sure it is retriable.
|
|
assert_retriability! quiz.quiz_submissions.order(:created_at).for_participant(participant).first
|
|
|
|
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 RequestError(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! 'you are not allowed to preview this quiz', 403
|
|
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 RequestError(403) if the participant can't take the quiz
|
|
# @throw RequestError(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! 'you are not allowed to complete this quiz submission', 403
|
|
end
|
|
|
|
# Participant must be able to take the quiz...
|
|
assert_takeability! quiz
|
|
|
|
# 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! 'quiz submission is already complete', 400
|
|
end
|
|
|
|
# And we need a valid attempt index to work with.
|
|
ensure_latest_attempt! quiz_submission, attempt
|
|
|
|
quiz_submission.complete!
|
|
end
|
|
|
|
# Modify question scores and comments for a student's quiz submission.
|
|
#
|
|
# @param [Hash] scoring_data
|
|
# So this is the set that contains the modifications you want to apply.
|
|
# The format is well-documented in the QuizSubmissionsApi#update endpoint,
|
|
# so check it out there.
|
|
#
|
|
# @option [Integer] attempt
|
|
# The attempt the modifications are for. This needs to map to a valid and
|
|
# _complete_ quiz submission attempt.
|
|
#
|
|
# @option [Float] scoring_data.fudge_points
|
|
# Amount of points to fudge the totalscore by.
|
|
#
|
|
# @option [Hash] scoring_data.questions
|
|
# Question scores and comments. The key is the question id.
|
|
#
|
|
# @option [Float] scoring_data.questions.score
|
|
# Question score. Nil, or lack of, represents no change.
|
|
#
|
|
# @option [String] scoring_data.questions.comment
|
|
# Question/answer comment. Nil, or lack of, represents no change. Empty
|
|
# string means remove any previous comments.
|
|
#
|
|
# @return [nil] nothing of significance
|
|
#
|
|
# @throw RequestError(403) if the participant user isn't a teacher
|
|
# @throw RequestError(400) if the attempt isn't valid, or isn't complete
|
|
# @throw RequestError(400) if a question score is funny
|
|
def update_scores(quiz_submission, attempt, scoring_data)
|
|
unless quiz_submission.grants_right?(participant.user, :update_scores)
|
|
reject! 'you are not allowed to update scores for this quiz submission', 403
|
|
end
|
|
|
|
if !attempt
|
|
reject! 'invalid attempt', 400
|
|
end
|
|
|
|
version = quiz_submission.versions.get(attempt.to_i)
|
|
|
|
if version.nil?
|
|
reject! 'invalid attempt', 400
|
|
elsif !version.model.completed?
|
|
reject! 'quiz submission attempt must be complete', 400
|
|
end
|
|
|
|
# map the scoring data to the legacy format of QuizSubmission#update_scores
|
|
legacy_params = {}.with_indifferent_access
|
|
legacy_params[:submission_version_number] = attempt.to_i
|
|
|
|
if scoring_data[:fudge_points].present?
|
|
legacy_params[:fudge_points] = scoring_data[:fudge_points].to_f
|
|
end
|
|
|
|
if scoring_data[:questions].is_a?(Hash)
|
|
scoring_data[:questions].each_pair do |question_id, question_data|
|
|
question_id = question_id.to_i
|
|
score, comment = question_data[:score], question_data[:comment]
|
|
|
|
if score.present?
|
|
legacy_params["question_score_#{question_id}".to_sym] = begin
|
|
score.to_f
|
|
rescue
|
|
reject! 'question score must be an unsigned decimal', 400
|
|
end
|
|
end
|
|
|
|
# nil represents lack of change to a comment, '' means no comment
|
|
unless comment.nil?
|
|
legacy_params["question_comment_#{question_id}".to_sym] = comment.to_s
|
|
end
|
|
end
|
|
end
|
|
|
|
unless legacy_params.except(:submission_version_number).empty?
|
|
quiz_submission.update_scores(legacy_params)
|
|
end
|
|
end
|
|
|
|
# Provide an answer to a question, or flag it, while taking a quiz. A snapshot
|
|
# of the QS will be made with the new answer state.
|
|
#
|
|
# @param [Hash] question_record
|
|
# The "answer record" for the question in the QS's submission_data. This
|
|
# can be obtained using the QuizQuestion::AnswerSerializers for a given QQ.
|
|
#
|
|
# @param [QuizSubmission] quiz_submission
|
|
# The QS we're manipulating (answering/flagging.)
|
|
#
|
|
# @param [Integer] attempt
|
|
# The attempt index this answer/modification applies to. This must match
|
|
# the quiz_submission's current attempt index.
|
|
#
|
|
# @throw RequestError(403) if the participant can't update the QS (ie, not the owner)
|
|
# @throw RequestError(400) if the QS is complete or overdue
|
|
#
|
|
# Further errors might be thrown from the following methods:
|
|
#
|
|
# - #assert_takeability!
|
|
# - #assert_retriability!
|
|
# - #validate_token!
|
|
# - #ensure_latest_attempt!
|
|
#
|
|
# @return [Hash] the recently-adjusted submission_data set
|
|
def update_question(question_record, quiz_submission, attempt, snapshot=true)
|
|
unless quiz_submission.grants_right?(participant.user, :update)
|
|
reject! 'you are not allowed to update questions for this quiz submission', 403
|
|
end
|
|
|
|
if quiz_submission.completed?
|
|
reject! 'quiz submission is already complete', 400
|
|
elsif quiz_submission.overdue?
|
|
reject! 'quiz submission is overdue', 400
|
|
end
|
|
|
|
assert_takeability! quiz_submission.quiz
|
|
|
|
validate_token! quiz_submission, participant.validation_token
|
|
|
|
ensure_latest_attempt! quiz_submission, attempt
|
|
|
|
quiz_submission.backup_submission_data question_record.merge({
|
|
validation_token: participant.validation_token,
|
|
cnt: snapshot ? 5 : 1 # force generation of snapshot
|
|
})
|
|
end
|
|
|
|
protected
|
|
|
|
# Abort the current service request with an error similar to an API error.
|
|
#
|
|
# See Api#reject! for usage.
|
|
def reject!(cause, status)
|
|
raise RequestError.new(cause, status)
|
|
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 [QuizParticipant] participant
|
|
# The person trying to take the quiz.
|
|
#
|
|
# @param [String] participant.access_code
|
|
# The Access Code provided by the participant.
|
|
#
|
|
# @param [String] participant.ip_address
|
|
# The IP address of the participant.
|
|
#
|
|
# @throw RequestError(400) if the Quiz is locked
|
|
# @throw RequestError(501) if the Quiz has the "can't go back" flag on
|
|
# @throw RequestError(403) if the access code is invalid
|
|
# @throw RequestError(403) if the IP address isn't covered
|
|
def assert_takeability!(quiz, participant = self.participant)
|
|
if quiz.locked?
|
|
reject! 'quiz is locked', 400
|
|
end
|
|
|
|
# [Transient:CNVS-10224] - support for CGB-OQAAT quizzes
|
|
if quiz.cant_go_back
|
|
reject! 'that type of quizzes is not supported yet', 501
|
|
end
|
|
|
|
if quiz.access_code.present? && quiz.access_code != participant.access_code
|
|
reject! 'invalid access code', 403
|
|
end
|
|
|
|
if quiz.ip_filter && !quiz.valid_ip?(participant.ip_address)
|
|
reject! 'IP address denied', 403
|
|
end
|
|
end
|
|
|
|
# Verify the given QS is retriable.
|
|
#
|
|
# @throw RequestError(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! 'a quiz submission already exists', 409
|
|
end
|
|
end
|
|
|
|
def validate_token!(quiz_submission, validation_token)
|
|
unless quiz_submission.valid_token?(validation_token)
|
|
reject! 'invalid token', 403
|
|
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 RequestError(400) if attempt isn't a valid integer
|
|
# @throw RequestError(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! 'invalid attempt', 400
|
|
elsif !quiz_submission.preview? && quiz_submission.attempt != attempt
|
|
reject! "attempt #{attempt} can not be modified", 400
|
|
end
|
|
end
|
|
end
|