
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.
266 lines
8.7 KiB
Ruby
266 lines
8.7 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'oauth/request_proxy/action_controller_request'
|
|
|
|
module Lti
|
|
extend ActiveSupport::Concern
|
|
include LtiHelper
|
|
|
|
MAXIMUM_SCORE = 1
|
|
MAXIMUM_SESSION_AGE = 60.minutes
|
|
SESSION_PARAMETERS = %w[launch_presentation_return_url lis_outcome_service_url lis_result_sourcedid].freeze
|
|
ERROR_STATUS = %w[error unsupported].freeze
|
|
|
|
def build_tool_provider(options = {})
|
|
if options[:consumer] && options[:parameters]
|
|
IMS::LTI::ToolProvider.new(options[:consumer].oauth_key, options[:consumer].oauth_secret, options[:parameters])
|
|
end
|
|
end
|
|
|
|
private :build_tool_provider
|
|
|
|
def consumer_return_url(provider, options = {})
|
|
consumer_return_url = provider.try(:launch_presentation_return_url) || params[:launch_presentation_return_url]
|
|
consumer_return_url += "?#{options.to_query}" if consumer_return_url && options.present?
|
|
consumer_return_url
|
|
end
|
|
|
|
def external_user_email(provider)
|
|
provider.lis_person_contact_email_primary
|
|
end
|
|
|
|
private :external_user_email
|
|
|
|
def external_user_name(provider)
|
|
# save person_name_full if supplied. this is the display_name, if it is set.
|
|
# else only save the firstname, we don't want lastnames (family names)
|
|
provider.lis_person_name_full || provider.lis_person_name_given
|
|
end
|
|
|
|
private :external_user_name
|
|
|
|
def external_user_role(provider)
|
|
result = 'learner'
|
|
if provider.roles.present?
|
|
provider.roles.each do |role|
|
|
case role.downcase
|
|
when 'administrator', 'instructor'
|
|
# We don't want anyone to get admin privileges through LTI
|
|
result = 'teacher' if result == 'learner'
|
|
else # 'learner'
|
|
next
|
|
end
|
|
end
|
|
end
|
|
result
|
|
end
|
|
|
|
def context_id?
|
|
# All platforms (except HPI Schul-Cloud) set the context_id
|
|
params[:context_id]
|
|
end
|
|
|
|
def refuse_lti_launch(options = {})
|
|
return_to_consumer(lti_errorlog: options[:message], lti_errormsg: t('sessions.oauth.failure'))
|
|
end
|
|
|
|
private :refuse_lti_launch
|
|
|
|
def require_oauth_parameters
|
|
refuse_lti_launch(message: t('sessions.oauth.missing_parameters')) unless params[:oauth_consumer_key] && params[:oauth_signature]
|
|
end
|
|
|
|
private :require_oauth_parameters
|
|
|
|
def require_unique_oauth_nonce
|
|
refuse_lti_launch(message: t('sessions.oauth.used_nonce')) if NonceStore.has?(params[:oauth_nonce])
|
|
end
|
|
|
|
private :require_unique_oauth_nonce
|
|
|
|
def require_valid_consumer_key
|
|
@consumer = Consumer.find_by(oauth_key: params[:oauth_consumer_key])
|
|
refuse_lti_launch(message: t('sessions.oauth.invalid_consumer')) unless @consumer
|
|
end
|
|
|
|
private :require_valid_consumer_key
|
|
|
|
def require_valid_exercise_token
|
|
proxy_exercise = ProxyExercise.find_by(token: params[:custom_token])
|
|
@exercise = if proxy_exercise.nil?
|
|
Exercise.find_by(token: params[:custom_token])
|
|
else
|
|
proxy_exercise.get_matching_exercise(current_user)
|
|
end
|
|
refuse_lti_launch(message: t('sessions.oauth.invalid_exercise_token')) unless @exercise
|
|
end
|
|
|
|
private :require_valid_exercise_token
|
|
|
|
def require_valid_oauth_signature
|
|
@provider = build_tool_provider(consumer: @consumer, parameters: params)
|
|
refuse_lti_launch(message: t('sessions.oauth.invalid_signature')) unless @provider.valid_request?(request)
|
|
end
|
|
|
|
private :require_valid_oauth_signature
|
|
|
|
def return_to_consumer(options = {})
|
|
consumer_return_url = @provider.try(:launch_presentation_return_url)
|
|
if consumer_return_url
|
|
consumer_return_url += "?#{options.to_query}" if options.present?
|
|
redirect_to(consumer_return_url, allow_other_host: true)
|
|
else
|
|
flash[:danger] = options[:lti_errormsg]
|
|
flash[:info] = options[:lti_msg]
|
|
redirect_to(:root)
|
|
end
|
|
end
|
|
|
|
private :return_to_consumer
|
|
|
|
def send_scores(submission)
|
|
unless (0..MAXIMUM_SCORE).cover?(submission.normalized_score)
|
|
raise Error.new("Score #{submission.normalized_score} must be between 0 and #{MAXIMUM_SCORE}!")
|
|
end
|
|
|
|
# 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, score)
|
|
return {status: 'error', user:} unless 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)
|
|
return {status: 'error', user:} if provider.nil?
|
|
return {status: 'unsupported', user:} unless provider.outcome_service?
|
|
|
|
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
|
|
|
|
private :send_score_for
|
|
|
|
def set_current_user
|
|
@current_user = ExternalUser.find_or_create_by(consumer_id: @consumer.id, external_id: @provider.user_id)
|
|
current_user.update(email: external_user_email(@provider), name: external_user_name(@provider))
|
|
end
|
|
|
|
private :set_current_user
|
|
|
|
def set_study_group_membership
|
|
group = if context_id?
|
|
# Ensure to find the group independent of the name and set it only once.
|
|
StudyGroup.find_or_create_by(external_id: @provider.context_id, consumer: @consumer) do |new_group|
|
|
new_group.name = @provider.context_title
|
|
end
|
|
else
|
|
StudyGroup.find_or_create_by(external_id: @provider.resource_link_id, consumer: @consumer)
|
|
end
|
|
|
|
study_group_membership = StudyGroupMembership.find_or_create_by(study_group: group, user: current_user)
|
|
study_group_membership.update(role: external_user_role(@provider))
|
|
session[:study_group_id] = group.id
|
|
current_user.store_current_study_group_id(group.id)
|
|
end
|
|
|
|
def set_embedding_options
|
|
@embed_options = {}
|
|
%i[hide_navbar
|
|
hide_exercise_description
|
|
collapse_exercise_description
|
|
disable_run
|
|
disable_score
|
|
disable_rfc
|
|
disable_redirect_to_rfcs
|
|
disable_redirect_to_feedback
|
|
disable_interventions
|
|
hide_sidebar
|
|
read_only
|
|
hide_test_results
|
|
disable_hints
|
|
disable_download].each do |option|
|
|
value = params["custom_embed_options_#{option}".to_sym] == 'true'
|
|
# Optimize storage and save only those that are true, the session cookie is limited to 4KB
|
|
@embed_options[option] = value if value.present?
|
|
end
|
|
session[:embed_options] = @embed_options
|
|
end
|
|
|
|
private :set_embedding_options
|
|
|
|
def store_lti_session_data(parameters)
|
|
@lti_parameters = LtiParameter.find_or_initialize_by(external_user: current_user,
|
|
study_group_id: session[:study_group_id],
|
|
exercise: @exercise)
|
|
|
|
@lti_parameters.lti_parameters = parameters.slice(*SESSION_PARAMETERS).permit!.to_h
|
|
@lti_parameters.save!
|
|
|
|
session[:external_user_id] = current_user.id
|
|
session[:pair_programming] = parameters[:custom_pair_programming] || false
|
|
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
|
|
retry
|
|
end
|
|
|
|
private :store_lti_session_data
|
|
|
|
def store_nonce(nonce)
|
|
NonceStore.add(nonce)
|
|
end
|
|
|
|
private :store_nonce
|
|
|
|
class Error < RuntimeError
|
|
end
|
|
end
|