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:
kiragrammel
2023-05-24 12:44:13 +02:00
committed by Sebastian Serth
parent 1e06ab3fa9
commit 175c8933f3
26 changed files with 779 additions and 408 deletions

View File

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

View File

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