Automatically submit LTI grade on each score run
With this commit, we refactor the overall score handling of CodeOcean. Previously, "Score" and "Submit" were two distinct actions, requiring users to confirm the LTI transmission of their score (after assessing their submission). This yielded many questions and was unnecessary, since LTI parameters are no longer expiring after each use. Therefore, we can now transmit the current grade on each score run with the very same LTI parameters. As a consequence, the LTI consumer gets a more detailed history of the scores, enabling further analytical insights. For users, the previous "Submit" button got replaced with a notification that is shown as soon as the full score got reached. Then, learners can decide to "finalize" their work on the given exercise, which will initiate a redirect to a follow-up action (as defined in the RedirectBehavior). This RedirectBehavior has also been unified and simplified for better readability. As part of this refactoring, we rephrased the notifications and UX workflow of a) the LTI transmission, b) the finalization of an exercise (measured by reaching the full score) and c) the deadline handling (on time, within grace period, too late). Those information are now separately shown, potentially resulting in multiple notifications. As a side effect, they are much better maintainable, and the LTI transmission is more decoupled from this notification handling.
This commit is contained in:

committed by
Sebastian Serth

parent
1e06ab3fa9
commit
175c8933f3
@@ -123,47 +123,68 @@ module Lti
|
||||
raise Error.new("Score #{submission.normalized_score} must be between 0 and #{MAXIMUM_SCORE}!")
|
||||
end
|
||||
|
||||
submission.users.map {|user| send_score_for submission, user }
|
||||
# Prepare score to be sent
|
||||
score = submission.normalized_score
|
||||
deadline = :none
|
||||
if submission.before_deadline?
|
||||
# Keep the full score
|
||||
deadline = :before_deadline
|
||||
elsif submission.within_grace_period?
|
||||
# Reduce score by 20%
|
||||
score *= 0.8
|
||||
deadline = :within_grace_period
|
||||
elsif submission.after_late_deadline?
|
||||
# Reduce score by 100%
|
||||
score *= 0.0
|
||||
deadline = :after_late_deadline
|
||||
end
|
||||
|
||||
# Actually send the score for all users
|
||||
detailed_results = submission.users.map {|user| send_score_for submission, user, score }
|
||||
|
||||
# Prepare return value
|
||||
erroneous_results = detailed_results.filter {|result| result[:status] == 'error' }
|
||||
unsupported_results = detailed_results.filter {|result| result[:status] == 'unsupported' }
|
||||
statistics = {
|
||||
all: detailed_results,
|
||||
success: detailed_results - erroneous_results - unsupported_results,
|
||||
error: erroneous_results,
|
||||
unsupported: unsupported_results,
|
||||
}
|
||||
|
||||
{
|
||||
users: statistics.transform_values {|value| value.pluck(:user) },
|
||||
score: {original: submission.normalized_score, sent: score},
|
||||
deadline:,
|
||||
detailed_results:,
|
||||
}
|
||||
end
|
||||
|
||||
private :send_scores
|
||||
|
||||
def send_score_for(submission, user)
|
||||
if user.external_user? && user.consumer
|
||||
lti_parameter = user.lti_parameters.find_by(exercise: submission.exercise, study_group: submission.study_group)
|
||||
provider = build_tool_provider(consumer: user.consumer, parameters: lti_parameter&.lti_parameters)
|
||||
end
|
||||
def send_score_for(submission, user, score)
|
||||
return {status: 'error', user:} unless user.external_user? && user.consumer
|
||||
|
||||
if provider.nil?
|
||||
{status: 'error', user: user.displayname}
|
||||
elsif provider.outcome_service?
|
||||
Sentry.set_extras({
|
||||
provider: provider.inspect,
|
||||
score: submission.normalized_score,
|
||||
lti_parameter: lti_parameter.inspect,
|
||||
session: session.to_hash,
|
||||
exercise_id: submission.exercise_id,
|
||||
})
|
||||
normalized_lti_score = submission.normalized_score
|
||||
if submission.before_deadline?
|
||||
# Keep the full score
|
||||
elsif submission.within_grace_period?
|
||||
# Reduce score by 20%
|
||||
normalized_lti_score *= 0.8
|
||||
elsif submission.after_late_deadline?
|
||||
# Reduce score by 100%
|
||||
normalized_lti_score *= 0.0
|
||||
end
|
||||
lti_parameter = user.lti_parameters.find_by(exercise: submission.exercise, study_group: submission.study_group)
|
||||
provider = build_tool_provider(consumer: user.consumer, parameters: lti_parameter&.lti_parameters)
|
||||
return {status: 'error', user:} if provider.nil?
|
||||
return {status: 'unsupported', user:} unless provider.outcome_service?
|
||||
|
||||
begin
|
||||
response = provider.post_replace_result!(normalized_lti_score)
|
||||
{code: response.response_code, message: response.post_response.body, status: response.code_major, score_sent: normalized_lti_score, user: user.displayname}
|
||||
rescue IMS::LTI::XMLParseError, Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, SocketError, EOFError
|
||||
# A parsing error might happen if the LTI provider is down and doesn't return a valid XML response
|
||||
{status: 'error', user: user.displayname}
|
||||
end
|
||||
else
|
||||
{status: 'unsupported', user: user.displayname}
|
||||
Sentry.set_extras({
|
||||
provider: provider.inspect,
|
||||
normalized_score: submission.normalized_score,
|
||||
score:,
|
||||
lti_parameter: lti_parameter.inspect,
|
||||
session: defined?(session) ? session.to_hash : nil,
|
||||
exercise_id: submission.exercise_id,
|
||||
})
|
||||
|
||||
begin
|
||||
response = provider.post_replace_result!(score)
|
||||
{code: response.response_code, message: response.post_response.body, status: response.code_major, user:}
|
||||
rescue IMS::LTI::XMLParseError, Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, SocketError, EOFError
|
||||
# A parsing error might happen if the LTI provider is down and doesn't return a valid XML response
|
||||
{status: 'error', user:}
|
||||
end
|
||||
end
|
||||
|
||||
|
@@ -6,60 +6,19 @@ module RedirectBehavior
|
||||
def redirect_after_submit
|
||||
Rails.logger.debug { "Redirecting user with score:s #{@submission.normalized_score}" }
|
||||
|
||||
if @submission.normalized_score.to_d == BigDecimal('1.0')
|
||||
if redirect_to_community_solution?
|
||||
redirect_to_community_solution
|
||||
return
|
||||
end
|
||||
# Redirect to the corresponding community solution if enabled and the user is eligible.
|
||||
return redirect_to_community_solution if redirect_to_community_solution?
|
||||
|
||||
# if user is external and has an own rfc, redirect to it and message him to clean up and accept the answer. (we need to check that the user is external,
|
||||
# otherwise an internal user could be shown a false rfc here, since current_user.id is polymorphic, but only makes sense for external users when used with rfcs.)
|
||||
# redirect 10 percent pseudorandomly to the feedback page
|
||||
if current_user.respond_to? :external_id
|
||||
if @submission.redirect_to_feedback? && !@embed_options[:disable_redirect_to_feedback]
|
||||
redirect_to_user_feedback
|
||||
return
|
||||
end
|
||||
# Redirect 10 percent pseudo-randomly to the feedback page.
|
||||
return redirect_to_user_feedback if !@embed_options[:disable_redirect_to_feedback] && @submission.redirect_to_feedback?
|
||||
|
||||
rfc = @submission.own_unsolved_rfc(current_user)
|
||||
if rfc
|
||||
# set a message that informs the user that his own RFC should be closed.
|
||||
flash[:notice] = I18n.t('exercises.submit.full_score_redirect_to_own_rfc')
|
||||
flash.keep(:notice)
|
||||
# If the user has an own rfc, redirect to it and message them to resolve and reflect on it.
|
||||
return redirect_to_unsolved_rfc(own: true) if redirect_to_own_unsolved_rfc?
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to(rfc) }
|
||||
format.json { render(json: {redirect: url_for(rfc)}) }
|
||||
end
|
||||
return
|
||||
end
|
||||
# Otherwise, redirect to an unsolved rfc and ask for assistance.
|
||||
return redirect_to_unsolved_rfc if redirect_to_unsolved_rfc?
|
||||
|
||||
# else: show open rfc for same exercise if available
|
||||
rfc = @submission.unsolved_rfc(current_user)
|
||||
unless rfc.nil? || @embed_options[:disable_redirect_to_rfcs] || @embed_options[:disable_rfc]
|
||||
# set a message that informs the user that his score was perfect and help in RFC is greatly appreciated.
|
||||
flash[:notice] = I18n.t('exercises.submit.full_score_redirect_to_rfc')
|
||||
flash.keep(:notice)
|
||||
|
||||
# increase counter 'times_featured' in rfc
|
||||
rfc.increment(:times_featured)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to(rfc) }
|
||||
format.json { render(json: {redirect: url_for(rfc)}) }
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
else
|
||||
# redirect to feedback page if score is less than 100 percent
|
||||
if @exercise.needs_more_feedback?(@submission) && !@embed_options[:disable_redirect_to_feedback]
|
||||
redirect_to_user_feedback
|
||||
else
|
||||
redirect_to_lti_return_path
|
||||
end
|
||||
return
|
||||
end
|
||||
# Fallback: Show the score and allow learners to return to the LTI consumer.
|
||||
redirect_to_lti_return_path
|
||||
end
|
||||
|
||||
@@ -74,9 +33,9 @@ module RedirectBehavior
|
||||
end
|
||||
|
||||
def redirect_to_community_solution?
|
||||
return false unless Java21Study.allow_redirect_to_community_solution?(current_user, @exercise)
|
||||
return false unless Java21Study.allow_redirect_to_community_solution?(current_user, @submission.exercise)
|
||||
|
||||
@community_solution = CommunitySolution.find_by(exercise: @exercise)
|
||||
@community_solution = CommunitySolution.find_by(exercise: @submission.exercise)
|
||||
return false if @community_solution.blank?
|
||||
|
||||
last_contribution = CommunitySolutionContribution.where(community_solution: @community_solution).order(created_at: :asc).last
|
||||
@@ -100,11 +59,11 @@ module RedirectBehavior
|
||||
end
|
||||
|
||||
def redirect_to_user_feedback
|
||||
uef = UserExerciseFeedback.find_by(exercise: @exercise, user: current_user)
|
||||
uef = UserExerciseFeedback.find_by(exercise: @submission.exercise, user: current_user)
|
||||
url = if uef
|
||||
edit_user_exercise_feedback_path(uef)
|
||||
else
|
||||
new_user_exercise_feedback_path(user_exercise_feedback: {exercise_id: @exercise.id})
|
||||
new_user_exercise_feedback_path(user_exercise_feedback: {exercise_id: @submission.exercise.id})
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
@@ -113,6 +72,32 @@ module RedirectBehavior
|
||||
end
|
||||
end
|
||||
|
||||
def redirect_to_unsolved_rfc(own: false)
|
||||
# Set a message that informs the user that their own RFC should be closed or help in another RFC is greatly appreciated.
|
||||
flash[:notice] = I18n.t("exercises.editor.exercise_finished_redirect_to_#{own ? 'own_' : ''}rfc")
|
||||
flash.keep(:notice)
|
||||
|
||||
# Increase counter 'times_featured' in rfc
|
||||
@rfc.increment(:times_featured) unless own
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to(@rfc) }
|
||||
format.json { render(json: {redirect: url_for(@rfc)}) }
|
||||
end
|
||||
end
|
||||
|
||||
def redirect_to_own_unsolved_rfc?
|
||||
@rfc = @submission.own_unsolved_rfc(current_user)
|
||||
@rfc.present?
|
||||
end
|
||||
|
||||
def redirect_to_unsolved_rfc?
|
||||
return false if @embed_options[:disable_redirect_to_rfcs] || @embed_options[:disable_rfc]
|
||||
|
||||
@rfc = @submission.unsolved_rfc(current_user)
|
||||
@rfc.present?
|
||||
end
|
||||
|
||||
def redirect_to_lti_return_path
|
||||
Sentry.set_extras(
|
||||
consumers_id: current_user.consumer_id,
|
||||
|
Reference in New Issue
Block a user