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

@ -21,8 +21,29 @@ $(document).on('turbolinks:load', function() {
) )
$(document).on('theme:change:ace', CodeOceanEditor.handleAceThemeChangeEvent.bind(CodeOceanEditor)); $(document).on('theme:change:ace', CodeOceanEditor.handleAceThemeChangeEvent.bind(CodeOceanEditor));
$('#submit').one('click', CodeOceanEditorSubmissions.submitCode.bind(CodeOceanEditor)); $('#submit').one('click', submitCode.bind(CodeOceanEditor));
$('#accept').one('click', CodeOceanEditorSubmissions.submitCode.bind(CodeOceanEditor)); $('#accept').one('click', submitCode.bind(CodeOceanEditor));
} }
}); });
function submitCode(event) {
const button = $(event.target) || $('#submit');
this.startSentryTransaction(button);
this.teardownEventHandlers();
this.createSubmission(button, null, function (response) {
if (response.redirect) {
this.autosaveIfChanged();
this.stopCode(event);
this.editors = [];
Turbolinks.clearCache();
Turbolinks.visit(response.redirect);
} else if (response.status === 'container_depleted') {
this.showContainerDepletedMessage();
} else if (response.message) {
$.flash.danger({
text: response.message
});
}
this.initializeEventHandlers();
})
}

View File

@ -472,8 +472,7 @@ var CodeOceanEditor = {
}, },
initializeWorkspaceButtons: function () { initializeWorkspaceButtons: function () {
$('#submit').one('click', this.submitCode.bind(this)); $('#assess').one('click', this.scoreCode.bind(this));
$('#assess').on('click', this.scoreCode.bind(this));
$('#render').on('click', this.renderCode.bind(this)); $('#render').on('click', this.renderCode.bind(this));
$('#run').on('click', this.runCode.bind(this)); $('#run').on('click', this.runCode.bind(this));
$('#stop').on('click', this.stopCode.bind(this)); $('#stop').on('click', this.stopCode.bind(this));
@ -483,7 +482,6 @@ var CodeOceanEditor = {
}, },
teardownWorkspaceButtons: function () { teardownWorkspaceButtons: function () {
$('#submit').unbind('click');
$('#assess').unbind('click'); $('#assess').unbind('click');
$('#render').unbind('click'); $('#render').unbind('click');
$('#run').unbind('click'); $('#run').unbind('click');
@ -775,14 +773,32 @@ var CodeOceanEditor = {
}, },
showStatus: function (output) { showStatus: function (output) {
if (output.status === 'timeout' || output.status === 'buffer_overflow') { switch (output.status) {
this.showTimeoutMessage(); case 'timeout':
} else if (output.status === 'container_depleted') { case 'buffer_overflow':
this.showContainerDepletedMessage(); this.showTimeoutMessage();
} else if (output.status === 'out_of_memory') { break;
this.showOutOfMemoryMessage(); case 'container_depleted':
} else if (output.status === 'runner_in_use') { this.showContainerDepletedMessage();
this.showRunnerInUseMessage(); break;
case 'out_of_memory':
this.showOutOfMemoryMessage();
break;
case 'runner_in_use':
this.showRunnerInUseMessage();
break;
case 'scoring_failure':
this.showScoringFailureMessage();
break;
case 'not_for_all_users_submitted':
this.showNotForAllUsersSubmittedMessage(output.failed_users);
break;
case 'scoring_too_late':
this.showScoringTooLateMessage(output.score_sent);
break;
case 'exercise_finished':
this.showExerciseFinishedMessage(output.url);
break;
} }
}, },
@ -849,6 +865,35 @@ var CodeOceanEditor = {
}); });
}, },
showScoringFailureMessage: function () {
$.flash.danger({
icon: ['fa-solid', 'fa-exclamation-circle'],
text: I18n.t('exercises.editor.submit_failure_all')
});
},
showNotForAllUsersSubmittedMessage: function (failed_users) {
$.flash.warning({
icon: ['fa-solid', 'fa-triangle-exclamation'],
text: I18n.t('exercises.editor.submit_failure_other_users', {user: failed_users})
});
},
showScoringTooLateMessage: function (score_sent) {
$.flash.info({
icon: ['fa-solid', 'fa-circle-info'],
text: I18n.t('exercises.editor.submit_too_late', {score_sent: score_sent})
});
},
showExerciseFinishedMessage: function (url) {
$.flash.success({
showPermanent: true,
icon: ['fa-solid', 'fa-graduation-cap'],
text: I18n.t('exercises.editor.exercise_finished', {url: url})
});
},
showTimeoutMessage: function () { showTimeoutMessage: function () {
$.flash.info({ $.flash.info({
icon: ['fa-regular', 'fa-clock'], icon: ['fa-regular', 'fa-clock'],

View File

@ -29,27 +29,6 @@ CodeOceanEditorEvaluation = {
}, 0).toFixed(2); }, 0).toFixed(2);
$('#score').data('score', score); $('#score').data('score', score);
this.renderScore(); this.renderScore();
this.showSubmitButton();
},
showSubmitButton: function () {
if (this.submission_deadline || this.late_submission_deadline) {
const now = new Date();
if (now <= this.submission_deadline) {
// before_deadline
// default is btn-success, so no change in color
$('#submit').get(0).lastChild.nodeValue = I18n.t('exercises.editor.submit_on_time');
} else if (now > this.submission_deadline && this.late_submission_deadline && now <= this.late_submission_deadline) {
// within_grace_period
$('#submit').removeClass("btn-success btn-warning").addClass("btn-warning");
$('#submit').get(0).lastChild.nodeValue = I18n.t('exercises.editor.submit_within_grace_period');
} else if (this.late_submission_deadline && now > this.late_submission_deadline || now > this.submission_deadline) {
// after_late_deadline
$('#submit').removeClass("btn-success btn-warning btn-danger").addClass("btn-danger");
$('#submit').get(0).lastChild.nodeValue = I18n.t('exercises.editor.submit_after_late_deadline');
}
}
$('#submit').removeClass("d-none");
}, },
printScoringResult: function (result, index) { printScoringResult: function (result, index) {

View File

@ -21,7 +21,7 @@ CodeOceanEditorWebsocket = {
return sockURL.toString(); return sockURL.toString();
}, },
initializeSocket: function(url) { initializeSocket: function(url, closeCallback) {
const cleanedPath = url.replace(/\/\d+\//, '/*/').replace(/\/[^\/]+$/, '/*'); const cleanedPath = url.replace(/\/\d+\//, '/*/').replace(/\/[^\/]+$/, '/*');
const websocketHost = window.location.origin.replace(/^http/, 'ws'); const websocketHost = window.location.origin.replace(/^http/, 'ws');
const sentryDescription = `WebSocket ${websocketHost}${cleanedPath}`; const sentryDescription = `WebSocket ${websocketHost}${cleanedPath}`;
@ -33,7 +33,12 @@ CodeOceanEditorWebsocket = {
); );
CodeOceanEditorWebsocket.websocket = this.websocket; CodeOceanEditorWebsocket.websocket = this.websocket;
this.websocket.onError(this.showWebsocketError.bind(this)); this.websocket.onError(this.showWebsocketError.bind(this));
this.websocket.onClose(span?.finish?.bind(span)); this.websocket.onClose( function(span, callback){
span?.finish()
if(callback != null){
callback();
}
}.bind(this, span, closeCallback));
}, },
initializeSocketForTesting: function(url) { initializeSocketForTesting: function(url) {
@ -43,10 +48,13 @@ CodeOceanEditorWebsocket = {
}, },
initializeSocketForScoring: function(url) { initializeSocketForScoring: function(url) {
this.initializeSocket(url); this.initializeSocket(url, function() {
$('#assess').one('click', this.scoreCode.bind(this))
}.bind(this));
this.websocket.on('default',this.handleScoringResponse.bind(this)); this.websocket.on('default',this.handleScoringResponse.bind(this));
this.websocket.on('hint', this.showHint.bind(this)); this.websocket.on('hint', this.showHint.bind(this));
this.websocket.on('exit', this.handleExitCommand.bind(this)); this.websocket.on('exit', this.handleExitCommand.bind(this));
this.websocket.on('status', this.showStatus.bind(this));
}, },
initializeSocketForRunning: function(url) { initializeSocketForRunning: function(url) {

View File

@ -205,33 +205,6 @@ CodeOceanEditorSubmissions = {
} }
}, },
submitCode: function(event) {
const button = $(event.target) || $('#submit');
this.startSentryTransaction(button);
this.teardownEventHandlers();
this.createSubmission(button, null, function (response) {
if (response.redirect) {
App.synchronized_editor?.disconnect();
this.autosaveIfChanged();
this.stopCode(event);
this.editors = [];
Turbolinks.clearCache();
Turbolinks.visit(response.redirect);
} else if (response.status === 'container_depleted') {
this.initializeEventHandlers();
this.showContainerDepletedMessage();
} else {
this.initializeEventHandlers();
for (let [type, text] of Object.entries(response)) {
$.flash[type]({
text: text,
showPermanent: true // We might display a very long text message!
})
}
}
})
},
/** /**
* Autosave-Logic * Autosave-Logic
*/ */

View File

@ -123,47 +123,68 @@ module Lti
raise Error.new("Score #{submission.normalized_score} must be between 0 and #{MAXIMUM_SCORE}!") raise Error.new("Score #{submission.normalized_score} must be between 0 and #{MAXIMUM_SCORE}!")
end 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 end
private :send_scores private :send_scores
def send_score_for(submission, user) def send_score_for(submission, user, score)
if user.external_user? && user.consumer 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)
end
if provider.nil? lti_parameter = user.lti_parameters.find_by(exercise: submission.exercise, study_group: submission.study_group)
{status: 'error', user: user.displayname} provider = build_tool_provider(consumer: user.consumer, parameters: lti_parameter&.lti_parameters)
elsif provider.outcome_service? return {status: 'error', user:} if provider.nil?
Sentry.set_extras({ return {status: 'unsupported', user:} unless provider.outcome_service?
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
begin Sentry.set_extras({
response = provider.post_replace_result!(normalized_lti_score) provider: provider.inspect,
{code: response.response_code, message: response.post_response.body, status: response.code_major, score_sent: normalized_lti_score, user: user.displayname} normalized_score: submission.normalized_score,
rescue IMS::LTI::XMLParseError, Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, SocketError, EOFError score:,
# A parsing error might happen if the LTI provider is down and doesn't return a valid XML response lti_parameter: lti_parameter.inspect,
{status: 'error', user: user.displayname} session: defined?(session) ? session.to_hash : nil,
end exercise_id: submission.exercise_id,
else })
{status: 'unsupported', user: user.displayname}
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
end end

View File

@ -6,60 +6,19 @@ module RedirectBehavior
def redirect_after_submit def redirect_after_submit
Rails.logger.debug { "Redirecting user with score:s #{@submission.normalized_score}" } Rails.logger.debug { "Redirecting user with score:s #{@submission.normalized_score}" }
if @submission.normalized_score.to_d == BigDecimal('1.0') # Redirect to the corresponding community solution if enabled and the user is eligible.
if redirect_to_community_solution? return redirect_to_community_solution if redirect_to_community_solution?
redirect_to_community_solution
return
end
# 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, # Redirect 10 percent pseudo-randomly to the feedback page.
# 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.) return redirect_to_user_feedback if !@embed_options[:disable_redirect_to_feedback] && @submission.redirect_to_feedback?
# 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
rfc = @submission.own_unsolved_rfc(current_user) # If the user has an own rfc, redirect to it and message them to resolve and reflect on it.
if rfc return redirect_to_unsolved_rfc(own: true) if redirect_to_own_unsolved_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)
respond_to do |format| # Otherwise, redirect to an unsolved rfc and ask for assistance.
format.html { redirect_to(rfc) } return redirect_to_unsolved_rfc if redirect_to_unsolved_rfc?
format.json { render(json: {redirect: url_for(rfc)}) }
end
return
end
# else: show open rfc for same exercise if available # Fallback: Show the score and allow learners to return to the LTI consumer.
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
redirect_to_lti_return_path redirect_to_lti_return_path
end end
@ -74,9 +33,9 @@ module RedirectBehavior
end end
def redirect_to_community_solution? 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? return false if @community_solution.blank?
last_contribution = CommunitySolutionContribution.where(community_solution: @community_solution).order(created_at: :asc).last last_contribution = CommunitySolutionContribution.where(community_solution: @community_solution).order(created_at: :asc).last
@ -100,11 +59,11 @@ module RedirectBehavior
end end
def redirect_to_user_feedback 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 url = if uef
edit_user_exercise_feedback_path(uef) edit_user_exercise_feedback_path(uef)
else 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 end
respond_to do |format| respond_to do |format|
@ -113,6 +72,32 @@ module RedirectBehavior
end end
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 def redirect_to_lti_return_path
Sentry.set_extras( Sentry.set_extras(
consumers_id: current_user.consumer_id, consumers_id: current_user.consumer_id,

View File

@ -10,7 +10,7 @@ class ExercisesController < ApplicationController
before_action :handle_file_uploads, only: %i[create update] before_action :handle_file_uploads, only: %i[create update]
before_action :set_execution_environments, only: %i[index create edit new update] before_action :set_execution_environments, only: %i[index create edit new update]
before_action :set_exercise_and_authorize, before_action :set_exercise_and_authorize,
only: MEMBER_ACTIONS + %i[clone implement working_times intervention statistics submit reload feedback only: MEMBER_ACTIONS + %i[clone implement working_times intervention statistics reload feedback
study_group_dashboard export_external_check export_external_confirm study_group_dashboard export_external_check export_external_confirm
external_user_statistics] external_user_statistics]
before_action :collect_set_and_unset_exercise_tags, only: MEMBER_ACTIONS before_action :collect_set_and_unset_exercise_tags, only: MEMBER_ACTIONS
@ -548,57 +548,6 @@ class ExercisesController < ApplicationController
render 'exercises/external_users/statistics' render 'exercises/external_users/statistics'
end end
def submit
@submission = Submission.create(submission_params)
@submission.calculate_score(current_user)
if @submission.users.map {|user| lti_outcome_service?(@submission.exercise, user, @submission.study_group_id) }.any?
transmit_lti_score
else
redirect_after_submit
end
rescue Runner::Error => e
Rails.logger.debug { "Runner error while submitting submission #{@submission.id}: #{e.message}" }
respond_to do |format|
format.html { redirect_to(implement_exercise_path(@submission.exercise)) }
format.json { render(json: {danger: I18n.t('exercises.editor.depleted'), status: :container_depleted}) }
end
end
def transmit_lti_score
responses = send_scores(@submission)
messages = {}
failed_users = []
responses.each do |response|
if Lti::ERROR_STATUS.include? response[:status]
failed_users << response[:user]
elsif response[:score_sent] != @submission.normalized_score # the score was sent successfully, but received too late
messages[:warning] = I18n.t('exercises.submit.too_late')
end
end
if failed_users.size == responses.size # all submissions failed
messages[:danger] = I18n.t('exercises.submit.failure')
elsif failed_users.size.positive? # at least one submission failed
messages[:warning] = [[messages[:warning]], I18n.t('exercises.submit.warning_not_for_all_users_submitted', user: failed_users.join(', '))].join('<br><br>')
messages[:warning] = "#{messages[:warning]}\n\n#{I18n.t('exercises.submit.warning_not_for_all_users_submitted', user: failed_users.join(', '))}".strip
else
messages.each do |type, message_text|
flash.now[type] = message_text
flash.keep(type)
end
return redirect_after_submit
end
respond_to do |format|
format.html { redirect_to(implement_exercise_path(@submission.exercise), **messages) }
format.json { render(json: messages) } # We must not change the HTTP status code here, since otherwise the custom message is not displayed.
end
end
private :transmit_lti_score
def destroy def destroy
destroy_and_respond(object: @exercise) destroy_and_respond(object: @exercise)
end end

View File

@ -52,11 +52,11 @@ class RemoteEvaluationController < ApplicationController
def process_lti_response(lti_response) def process_lti_response(lti_response)
if (lti_response[:status] == 'success') && (lti_response[:score_sent] != @submission.normalized_score) if (lti_response[:status] == 'success') && (lti_response[:score_sent] != @submission.normalized_score)
# Score has been reduced due to the passed deadline # Score has been reduced due to the passed deadline
{message: I18n.t('exercises.submit.too_late'), status: 207, score: lti_response[:score_sent] * 100} {message: I18n.t('exercises.editor.submit_too_late', score_sent: lti_response[:score_sent] * 100), status: 207, score: lti_response[:score_sent] * 100}
elsif lti_response[:status] == 'success' elsif lti_response[:status] == 'success'
{message: I18n.t('sessions.destroy_through_lti.success_with_outcome', consumer: @submission.user.consumer.name), status: 202} {message: I18n.t('sessions.destroy_through_lti.success_with_outcome', consumer: @submission.user.consumer.name), status: 202}
else else
{message: I18n.t('exercises.submit.failure'), status: 424} {message: I18n.t('exercises.editor.submit_failure_all'), status: 424}
end end
# TODO: Delete LTI parameters? # TODO: Delete LTI parameters?
end end

View File

@ -2,12 +2,13 @@
class SubmissionsController < ApplicationController class SubmissionsController < ApplicationController
include CommonBehavior include CommonBehavior
include Lti
include FileConversion include FileConversion
include Lti
include RedirectBehavior
include SubmissionParameters include SubmissionParameters
include Tubesock::Hijack include Tubesock::Hijack
before_action :set_submission, only: %i[download download_file run score show statistics test] before_action :set_submission, only: %i[download download_file run score show statistics test finalize]
before_action :set_testrun, only: %i[run score test] before_action :set_testrun, only: %i[run score test]
before_action :set_files, only: %i[download show] before_action :set_files, only: %i[download show]
before_action :set_files_and_specific_file, only: %i[download_file run test] before_action :set_files_and_specific_file, only: %i[download_file run test]
@ -72,6 +73,11 @@ class SubmissionsController < ApplicationController
end end
end end
def finalize
@submission.update!(cause: 'submit')
redirect_after_submit
end
def show; end def show; end
def render_file def render_file
@ -166,7 +172,7 @@ class SubmissionsController < ApplicationController
durations = @submission.run(@file) do |socket, starting_time| durations = @submission.run(@file) do |socket, starting_time|
runner_socket = socket runner_socket = socket
@testrun[:starting_time] = starting_time @testrun[:starting_time] = starting_time
client_socket.send_data JSON.dump({cmd: :status, status: :container_running}) client_socket.send_data({cmd: :status, status: :container_running}.to_json)
runner_socket.on :stdout do |data| runner_socket.on :stdout do |data|
message = retrieve_message_from_output data, :stdout message = retrieve_message_from_output data, :stdout
@ -256,9 +262,11 @@ class SubmissionsController < ApplicationController
return true if disable_scoring return true if disable_scoring
# The score is stored separately, we can forward it to the client immediately # The score is stored separately, we can forward it to the client immediately
client_socket&.send_data(JSON.dump(@submission.calculate_score(current_user))) client_socket&.send_data(@submission.calculate_score(current_user).to_json)
# To enable hints when scoring a submission, uncomment the next line: # To enable hints when scoring a submission, uncomment the next line:
# send_hints(client_socket, StructuredError.where(submission: @submission)) # send_hints(client_socket, StructuredError.where(submission: @submission))
transmit_lti_score(client_socket)
rescue Runner::Error::RunnerInUse => e rescue Runner::Error::RunnerInUse => e
extract_durations(e) extract_durations(e)
send_and_store client_socket, {cmd: :status, status: :runner_in_use} send_and_store client_socket, {cmd: :status, status: :runner_in_use}
@ -300,7 +308,7 @@ class SubmissionsController < ApplicationController
return true if @embed_options[:disable_run] return true if @embed_options[:disable_run]
# The score is stored separately, we can forward it to the client immediately # The score is stored separately, we can forward it to the client immediately
client_socket&.send_data(JSON.dump(@submission.test(@file, current_user))) client_socket&.send_data(@submission.test(@file, current_user).to_json)
rescue Runner::Error::RunnerInUse => e rescue Runner::Error::RunnerInUse => e
extract_durations(e) extract_durations(e)
send_and_store client_socket, {cmd: :status, status: :runner_in_use} send_and_store client_socket, {cmd: :status, status: :runner_in_use}
@ -338,7 +346,7 @@ class SubmissionsController < ApplicationController
return unless client_socket return unless client_socket
# We don't want to store this (arbitrary) exit command and redirect it ourselves # We don't want to store this (arbitrary) exit command and redirect it ourselves
client_socket.send_data JSON.dump({cmd: :exit}) client_socket.send_data({cmd: :exit}.to_json)
client_socket.send_data nil, :close client_socket.send_data nil, :close
# We must not close the socket manually (with `client_socket.close`), as this would close it twice. # We must not close the socket manually (with `client_socket.close`), as this would close it twice.
# When the socket is closed twice, nginx registers a `Connection reset by peer` error. # When the socket is closed twice, nginx registers a `Connection reset by peer` error.
@ -401,7 +409,7 @@ class SubmissionsController < ApplicationController
end end
@testrun[:messages].push message @testrun[:messages].push message
@testrun[:status] = message[:status] if message[:status] @testrun[:status] = message[:status] if message[:status]
client_socket.send_data JSON.dump(message) client_socket.send_data(message.to_json)
end end
def max_output_buffer_size def max_output_buffer_size
@ -473,6 +481,53 @@ class SubmissionsController < ApplicationController
} }
end end
def check_scoring_too_late(submit_info)
# The submission was either performed before any deadline or no deadline was configured at all for the current exercise.
return if %i[within_grace_period after_late_deadline].exclude? submit_info[:deadline]
# The `lis_outcome_service` was not provided by the LMS, hence we were not able to send any score.
return if submit_info[:users][:unsupported].include?(current_user)
{status: :scoring_too_late, score_sent: submit_info[:score][:sent]}
end
def check_full_score
# The submission was not scored with the full score, hence the exercise is not finished yet.
return unless @submission.full_score?
{status: :exercise_finished, url: finalize_submission_path(@submission)}
end
def transmit_lti_score(client_socket)
submit_info = send_scores(@submission)
scored_users = submit_info[:users]
notifications = []
if scored_users[:all] == scored_users[:error] || scored_users[:error].include?(current_user)
# The score was not sent for any user or sending the score for the current user failed.
# In the latter case, we want to encourage the current user to reopen the exercise through the LMS.
# Hence, we always display the most severe error message.
notifications << {status: :scoring_failure}
elsif scored_users[:all] != scored_users[:success] && scored_users[:success].include?(current_user)
# The score was sent successfully for current user.
# However, at the same time, the transmission failed for some other users.
# This could either be due to a temporary network error, which is unlikely, or a more "permanent" error.
# Permanent errors would be that the deadline has passed on the LMS (which would then not provide a `lis_outcome_service`),
# working together with an internal user, or with someone who has never opened the exercise before.
notifications << {status: :not_for_all_users_submitted, failed_users: scored_users[:error].map(&:displayname).join(', ')}
end
if notifications.empty? || notifications.first[:status] != :scoring_failure
# Either, the score was sent successfully for the current user,
# or it was not attempted for any user (i.e., no `lis_outcome_service`).
notifications << check_scoring_too_late(submit_info)
notifications << check_full_score
end
notifications.compact.each do |notification|
client_socket&.send_data(notification&.merge(cmd: :status)&.to_json)
end
end
def retrieve_message_from_output(data, stream) def retrieve_message_from_output(data, stream)
parsed = JSON.parse(data) parsed = JSON.parse(data)
if parsed.instance_of?(Hash) && parsed.key?('cmd') if parsed.instance_of?(Hash) && parsed.key?('cmd')

View File

@ -593,12 +593,8 @@ class Exercise < ApplicationRecord
end end
private :valid_submission_deadlines? private :valid_submission_deadlines?
def needs_more_feedback?(submission) def needs_more_feedback?
if submission.normalized_score.to_d == BigDecimal('1.0') user_exercise_feedbacks.size <= MAX_GROUP_EXERCISE_FEEDBACKS
user_exercise_feedbacks.final.size <= MAX_GROUP_EXERCISE_FEEDBACKS
else
user_exercise_feedbacks.intermediate.size <= MAX_GROUP_EXERCISE_FEEDBACKS
end
end end
def last_submission_per_contributor def last_submission_per_contributor

View File

@ -74,6 +74,10 @@ class Submission < ApplicationRecord
collect_files.detect {|file| file.filepath == file_path } collect_files.detect {|file| file.filepath == file_path }
end end
def full_score?
score == exercise.maximum_score
end
def normalized_score def normalized_score
@normalized_score ||= if !score.nil? && !exercise.maximum_score.nil? && exercise.maximum_score.positive? @normalized_score ||= if !score.nil? && !exercise.maximum_score.nil? && exercise.maximum_score.positive?
score / exercise.maximum_score score / exercise.maximum_score
@ -123,7 +127,7 @@ class Submission < ApplicationRecord
def redirect_to_feedback? def redirect_to_feedback?
# Redirect 10% of users to the exercise feedback page. Ensure, that always the same # Redirect 10% of users to the exercise feedback page. Ensure, that always the same
# users get redirected per exercise and different users for different exercises. If # users get redirected per exercise and different users for different exercises. If
# desired, the number of feedbacks can be limited with exercise.needs_more_feedback?(submission) # desired, the number of feedbacks can be limited with exercise.needs_more_feedback?
(contributor_id + exercise.created_at.to_i) % 10 == 1 (contributor_id + exercise.created_at.to_i) % 10 == 1
end end

View File

@ -41,10 +41,6 @@ class ExercisePolicy < AdminOrAuthorPolicy
admin? admin?
end end
def submit?
everyone && @record.teacher_defined_assessment?
end
class Scope < Scope class Scope < Scope
def resolve def resolve
if @user.admin? if @user.admin?

View File

@ -8,7 +8,7 @@ class SubmissionPolicy < ApplicationPolicy
# insights? is used in the flowr_controller.rb as we use it to authorize the user for a submission # insights? is used in the flowr_controller.rb as we use it to authorize the user for a submission
# download_submission_file? is used in the live_streams_controller.rb # download_submission_file? is used in the live_streams_controller.rb
%i[download? download_file? download_submission_file? run? score? show? statistics? stop? test? %i[download? download_file? download_submission_file? run? score? show? statistics? stop? test?
insights?].each do |action| insights? finalize?].each do |action|
define_method(action) { admin? || author? || author_in_programming_group? } define_method(action) { admin? || author? || author_in_programming_group? }
end end

View File

@ -59,15 +59,11 @@ div.d-grid id='output_sidebar_uncollapsed' class='d-none col-sm-12 enforce-botto
.progress .progress
.progress-bar role='progressbar' .progress-bar role='progressbar'
br - if @exercise.submission_deadline.present? || @exercise.late_submission_deadline.present?
- if lti_outcome_service?(@exercise, current_user) br
p.text-center = render('editor_button', classes: 'btn-lg btn-success d-none', data: {:'data-url' => submit_exercise_path(@exercise)}, icon: 'fa-solid fa-paper-plane', id: 'submit', label: t('exercises.editor.submit')) #deadline data-submission-deadline=@exercise.submission_deadline&.rfc2822 data-late-submission-deadline=@exercise.late_submission_deadline&.rfc2822
- if @exercise.submission_deadline.present? || @exercise.late_submission_deadline.present? h4 = t('exercises.editor.deadline')
#deadline data-submission-deadline=@exercise.submission_deadline&.rfc2822 data-late-submission-deadline=@exercise.late_submission_deadline&.rfc2822 = t('exercises.editor.hints.disclaimer')
h4 = t('exercises.editor.deadline')
= t('exercises.editor.hints.disclaimer')
- else
p.text-center.disabled = render('editor_button', classes: 'btn-lg btn-secondary', data: {:'data-bs-placement' => 'bottom', :'data-bs-toggle' => 'tooltip', :'data-bs-container' => 'body'}, icon: 'fa-regular fa-clock', id: 'submit_outdated', label: t('exercises.editor.exercise_deadline_passed'), title: t('exercises.editor.tooltips.exercise_deadline_passed'))
hr hr
#turtlediv.enforce-big-bottom-margin.overflow-auto.d-none #turtlediv.enforce-big-bottom-margin.overflow-auto.d-none
canvas#turtlecanvas width=400 height=400 canvas#turtlecanvas width=400 height=400

View File

@ -16,3 +16,4 @@ unless @embed_options[:disable_download]
end end
json.run_url run_submission_path(@submission, 'a.', format: :json).gsub(/a\.\.json$/, '{filename}.json') json.run_url run_submission_path(@submission, 'a.', format: :json).gsub(/a\.\.json$/, '{filename}.json')
json.test_url test_submission_path(@submission, 'a.', format: :json).gsub(/a\.\.json$/, '{filename}.json') json.test_url test_submission_path(@submission, 'a.', format: :json).gsub(/a\.\.json$/, '{filename}.json')
json.finalize_url finalize_submission_path(@submission)

View File

@ -383,12 +383,14 @@ de:
collapse_output_sidebar: Ausgabe-Leiste Einklappen collapse_output_sidebar: Ausgabe-Leiste Einklappen
confirm_start_over: Wollen Sie in dieser Aufgabe wirklich von vorne anfangen? Ihre bisherigen Änderungen in dieser Aufgabe werden entfernt; andere Aufgaben bleiben unverändert. Diese Aktion kann nicht rückgängig gemacht werden. confirm_start_over: Wollen Sie in dieser Aufgabe wirklich von vorne anfangen? Ihre bisherigen Änderungen in dieser Aufgabe werden entfernt; andere Aufgaben bleiben unverändert. Diese Aktion kann nicht rückgängig gemacht werden.
confirm_start_over_active_file: Wollen Sie wirklich Ihre Änderungen in der ausgewählten Datei '%{filename}' zurücksetzen? Diese Aktion kann nicht rückgängig gemacht werden. confirm_start_over_active_file: Wollen Sie wirklich Ihre Änderungen in der ausgewählten Datei '%{filename}' zurücksetzen? Diese Aktion kann nicht rückgängig gemacht werden.
confirm_submit: Wollen Sie Ihren Code wirklich zur Bewertung abgeben?
create_file: Neue Datei create_file: Neue Datei
depleted: Alle Ausführungsumgebungen sind momentan in Benutzung. Probiere es später nochmal. depleted: Alle Ausführungsumgebungen sind momentan in Benutzung. Probiere es später nochmal.
destroy_file: Datei löschen destroy_file: Datei löschen
download: Herunterladen download: Herunterladen
dummy: Keine Aktion dummy: Keine Aktion
exercise_finished: Herzlichen Glückwunsch! Sie haben die Aufgabe vollständig gelöst. <a href="%{url}" class="alert-link">Klicken Sie hier, um die Aufgabe jetzt abzuschließen.</a>
exercise_finished_redirect_to_rfc: Herzlichen Glückwunsch! Sie haben die Aufgabe vollständig gelöst und die Punkte übertragen. Ein anderer Teilnehmer hat eine Frage zu der von Ihnen gelösten Aufgabe. Er würde sich sicherlich sehr über Ihre Hilfe und Kommentare freuen.
exercise_finished_redirect_to_own_rfc: Herzlichen Glückwunsch! Sie haben die Aufgabe vollständig gelöst und die Punkte übertragen. Ihre Frage ist damit wahrscheinlich gelöst? Falls ja, fügen Sie doch den entscheidenden Kniff als Antwort hinzu und markieren die Frage als gelöst, bevor Sie das Fenster schließen.
expand_action_sidebar: Aktions-Leiste Ausklappen expand_action_sidebar: Aktions-Leiste Ausklappen
expand_output_sidebar: Ausgabe-Leiste Ausklappen expand_output_sidebar: Ausgabe-Leiste Ausklappen
input: Ihre Eingabe input: Ihre Eingabe
@ -410,11 +412,10 @@ de:
start_over_active_file: Diese Datei zurücksetzen start_over_active_file: Diese Datei zurücksetzen
start_video: (Video-) Chat starten start_video: (Video-) Chat starten
stop: Stoppen stop: Stoppen
submit: Code zur Bewertung abgeben submit_failure_all: Beim Übermitteln Ihrer Punktzahl ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.
submit_failure_other_users: "Die Punkteübertragung war nur teilweise erfolgreich. Die Punkte konnten nicht für %{user} übertragen werden. Diese Person(en) sollte die Aufgabe über die e-Learning Platform erneut öffnen und anschließend die Punkte selbst übermitteln."
submit_too_late: Ihre Abgabe wurde erfolgreich gespeichert, ging jedoch nach der Abgabefrist ein, sodass nur %{score_sent} Punkte übertragen wurden.
deadline: Deadline deadline: Deadline
submit_on_time: Code rechtzeitig zur Bewertung abgeben
submit_within_grace_period: Code innerhalb der Gnadenfrist zur Bewertung abgeben
submit_after_late_deadline: Code verspätet zur Bewertung abgeben
test: Testen test: Testen
timeout: 'Ausführung gestoppt. Ihr Code hat die erlaubte Ausführungszeit von %{permitted_execution_time} Sekunden überschritten.' timeout: 'Ausführung gestoppt. Ihr Code hat die erlaubte Ausführungszeit von %{permitted_execution_time} Sekunden überschritten.'
out_of_memory: 'Ausführung gestoppt. Ihr Code hat den erlaubten Arbeitsspeicher von %{memory_limit} MB überschritten.' out_of_memory: 'Ausführung gestoppt. Ihr Code hat den erlaubten Arbeitsspeicher von %{memory_limit} MB überschritten.'
@ -424,7 +425,7 @@ de:
exercise_deadline_passed: 'Entweder ist die Abgabefrist bereits abgelaufen oder Sie haben die Aufgabe nicht direkt über die E-Learning Plattform gestartet. (Möglicherweise haben Sie den Zurück Button Ihres Browsers benutzt nachdem Sie Ihre Aufgabe abgegeben haben?)' exercise_deadline_passed: 'Entweder ist die Abgabefrist bereits abgelaufen oder Sie haben die Aufgabe nicht direkt über die E-Learning Plattform gestartet. (Möglicherweise haben Sie den Zurück Button Ihres Browsers benutzt nachdem Sie Ihre Aufgabe abgegeben haben?)'
request_for_comments_sent: "Kommentaranfrage gesendet." request_for_comments_sent: "Kommentaranfrage gesendet."
hints: hints:
submission_deadline: Diese Abgabe ist am <b>%{deadline}</b> fällig.<br/><small>Klicken Sie daher rechtzeitig auf 'Abgeben', um Ihre Punktzahl an die E-Learning-Plattform zu übertragen. %{otherwise}</small> submission_deadline: Diese Abgabe ist am <b>%{deadline}</b> fällig.<br/><small>Bitte schließen Sie daher die Aufgabe rechtzeitig vorher ab. %{otherwise}</small>
late_submission_deadline: Bis <b>%{deadline}</b> werden 80% Ihrer Punktzahl anerkannt.<br/><small>Wenn Sie diese erweiterte Frist ungenutzt verstreichen lassen und Ihre Abgabe später einreichen, werden 0 Punkte übertragen.</small> late_submission_deadline: Bis <b>%{deadline}</b> werden 80% Ihrer Punktzahl anerkannt.<br/><small>Wenn Sie diese erweiterte Frist ungenutzt verstreichen lassen und Ihre Abgabe später einreichen, werden 0 Punkte übertragen.</small>
otherwise: Nach der Abgabefrist werden 0 Punkte übertragen. otherwise: Nach der Abgabefrist werden 0 Punkte übertragen.
disclaimer: Bei Fragen zu Deadlines wenden Sie sich bitte an das Teaching Team. Die hier angezeigte Abgabefrist dient nur zur Information und Angaben auf der jeweiligen Kursseite in der E-Learning-Plattform sollen immer Vorrang haben. disclaimer: Bei Fragen zu Deadlines wenden Sie sich bitte an das Teaching Team. Die hier angezeigte Abgabefrist dient nur zur Information und Angaben auf der jeweiligen Kursseite in der E-Learning-Plattform sollen immer Vorrang haben.
@ -478,7 +479,7 @@ de:
default_test_feedback: Sehr gut. Alle Tests waren erfolgreich. default_test_feedback: Sehr gut. Alle Tests waren erfolgreich.
default_linter_feedback: Sehr gut. Der Linter hat nichts mehr zu beanstanden. default_linter_feedback: Sehr gut. Der Linter hat nichts mehr zu beanstanden.
error_messages: Fehlermeldungen error_messages: Fehlermeldungen
existing_programming_group: Sie arbeiten gerade an der Übung mit dem Titel '%{exercise}' als Teil einer Programmiergruppe. Bitte schließen Sie Ihre Arbeit dort ab, indem Sie Ihren Code bewerten und abgeben, bevor Sie mit der Bearbeitung dieser Übung beginnen. existing_programming_group: Sie arbeiten gerade an der Übung mit dem Titel '%{exercise}' als Teil einer Programmiergruppe. Bitte schließen Sie Ihre Arbeit dort ab, bevor Sie mit der Bearbeitung dieser Übung beginnen.
external_privacy_policy: Datenschutzerklärung external_privacy_policy: Datenschutzerklärung
messages: Meldungen messages: Meldungen
feedback: Feedback feedback: Feedback
@ -560,12 +561,6 @@ de:
external_users: Externe Nutzer external_users: Externe Nutzer
programming_groups: Programmiergruppen programming_groups: Programmiergruppen
finishing_rate: Abschlussrate finishing_rate: Abschlussrate
submit:
failure: Beim Übermitteln Ihrer Punktzahl ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.
too_late: Ihre Abgabe wurde erfolgreich gespeichert, ging jedoch nach der Abgabefrist ein.
full_score_redirect_to_rfc: Herzlichen Glückwunsch! Sie haben die maximale Punktzahl für diese Aufgabe an den Kurs übertragen. Ein anderer Teilnehmer hat eine Frage zu der von Ihnen gelösten Aufgabe. Er würde sich sicherlich sehr über ihre Hilfe und Kommentare freuen.
full_score_redirect_to_own_rfc: Herzlichen Glückwunsch! Sie haben die maximale Punktzahl für diese Aufgabe an den Kurs übertragen. Ihre Frage ist damit wahrscheinlich gelöst? Falls ja, fügen Sie doch den entscheidenden Kniff als Antwort hinzu und markieren die Frage als gelöst, bevor sie das Fenster schließen.
warning_not_for_all_users_submitted: "Die Punkteübertragung war nur teilweise erfolgreich. Die Punkte konnten nicht für %{user} übertragen werden. Diese Person(en) sollte die Aufgabe über die e-Learning Platform erneut öffnen und anschließend die Punkte selbst übermitteln."
study_group_dashboard: study_group_dashboard:
live_dashboard: Live Dashboard live_dashboard: Live Dashboard
time_spent_per_learner: Verwendete Zeit pro Lerner time_spent_per_learner: Verwendete Zeit pro Lerner
@ -876,7 +871,7 @@ de:
failure: Fehlerhafte E-Mail oder Passwort. failure: Fehlerhafte E-Mail oder Passwort.
success: Sie haben sich erfolgreich angemeldet. success: Sie haben sich erfolgreich angemeldet.
create_through_lti: create_through_lti:
session_with_outcome: 'Bitte beachten Sie, dass zur Gutschrift der Punkte Ihr Code nach der Bearbeitung durch Klicken auf den Button "Code zur Bewertung abgeben" eingetragen werden muss.' session_with_outcome: 'Ihre Punkte werden durch Klicken auf "Bewerten" automatisch Ihrem Fortschritt gutgeschrieben.'
session_without_outcome: 'Dies ist eine Übungs-Session. Ihre Bewertung wird nicht an %{consumer} übermittelt.' session_without_outcome: 'Dies ist eine Übungs-Session. Ihre Bewertung wird nicht an %{consumer} übermittelt.'
destroy: destroy:
link: Abmelden link: Abmelden

View File

@ -383,12 +383,14 @@ en:
collapse_output_sidebar: Collapse Output Sidebar collapse_output_sidebar: Collapse Output Sidebar
confirm_start_over: Do you really want to start over? Your previous changes in this exercise will be reset; other exercises remain untouched. You cannot undo this action. confirm_start_over: Do you really want to start over? Your previous changes in this exercise will be reset; other exercises remain untouched. You cannot undo this action.
confirm_start_over_active_file: Do you really want to remove any changes in the active file '%{filename}'? You cannot undo this action. confirm_start_over_active_file: Do you really want to remove any changes in the active file '%{filename}'? You cannot undo this action.
confirm_submit: Do you really want to submit your code for grading?
create_file: New File create_file: New File
depleted: All execution environments are busy. Please try again later. depleted: All execution environments are busy. Please try again later.
destroy_file: Delete File destroy_file: Delete File
download: Download download: Download
dummy: No Action dummy: No Action
exercise_finished: Congratulations! You have completely solved this exercise. <a href="%{url}" class="alert-link">Please click here to finish the exercise now.</a>
exercise_finished_redirect_to_rfc: Congratulations! You have completely solved this exercise and submitted the points. Another participant has a question concerning the exercise you just solved. Your help and comments will be greatly appreciated!
exercise_finished_redirect_to_own_rfc: Congratulations! You have completely solved this exercise and submitted the points. Your question concerning the exercise is solved? If so, please share the essential insight with your fellows and mark the question as solved, before you close this window!
expand_action_sidebar: Expand Action Sidebar expand_action_sidebar: Expand Action Sidebar
expand_output_sidebar: Expand Output Sidebar expand_output_sidebar: Expand Output Sidebar
input: Your input input: Your input
@ -410,11 +412,10 @@ en:
start_over_active_file: Reset this file start_over_active_file: Reset this file
start_video: Start (video) chat start_video: Start (video) chat
stop: Stop stop: Stop
submit: Submit Code For Assessment submit_failure_all: An error occurred while transmitting your score. Please try again later.
submit_failure_other_users: "The transmission of points was only partially successful. The score was not transmitted for %{user}. The user(s) should reopen the exercise via the e-learning platform and then try to submit the points themselves."
submit_too_late: Your submission was saved successfully but was received after the deadline, so that only %{score_sent} points were transmitted.
deadline: Deadline deadline: Deadline
submit_on_time: Submit Code for Assessment on Time
submit_within_grace_period: Submit Code for Assessment Within Grace Period
submit_after_late_deadline: Submit Code for Assessment After Deadline Passed
test: Test test: Test
timeout: 'Execution stopped. Your code exceeded the permitted execution time of %{permitted_execution_time} seconds.' timeout: 'Execution stopped. Your code exceeded the permitted execution time of %{permitted_execution_time} seconds.'
out_of_memory: 'Execution stopped. Your code exceeded the permitted RAM usage of %{memory_limit} MB.' out_of_memory: 'Execution stopped. Your code exceeded the permitted RAM usage of %{memory_limit} MB.'
@ -424,8 +425,8 @@ en:
exercise_deadline_passed: 'Either the deadline has already passed or you did not directly access this page from the e-learning platform. (Did you use the Back button of your browser after submitting the score?)' exercise_deadline_passed: 'Either the deadline has already passed or you did not directly access this page from the e-learning platform. (Did you use the Back button of your browser after submitting the score?)'
request_for_comments_sent: "Request for comments sent." request_for_comments_sent: "Request for comments sent."
hints: hints:
submission_deadline: This exercise is due <b>%{deadline}</b>.<br/><small>Click 'submit' to transfer your score to the e-learning platform before this deadline passes. %{otherwise}</small> submission_deadline: This exercise is due <b>%{deadline}</b>.<br/><small>Please finish the exercise before this deadline passes. %{otherwise}</small>
late_submission_deadline: Until <b>%{deadline}</b>, 80% of your score will be awarded.<br/><small>If you miss this extended deadline and submit your score afterwards, 0 points will be transmitted.</small> late_submission_deadline: Until <b>%{deadline}</b>, 80% of your score will be awarded.<br/><small>If you miss this extended deadline and score your code afterwards, 0 points will be transmitted.</small>
otherwise: Otherwise, a score of 0 points will be transmitted. otherwise: Otherwise, a score of 0 points will be transmitted.
disclaimer: If unsure about a deadline, please contact a course instructor. The deadline shown here is only informational and information from the e-learning platform should always take precedence. disclaimer: If unsure about a deadline, please contact a course instructor. The deadline shown here is only informational and information from the e-learning platform should always take precedence.
editor_file_tree: editor_file_tree:
@ -478,7 +479,7 @@ en:
default_test_feedback: Well done. All tests have been passed. default_test_feedback: Well done. All tests have been passed.
default_linter_feedback: Well done. The linter is completly satisfied. default_linter_feedback: Well done. The linter is completly satisfied.
error_messages: Error Messages error_messages: Error Messages
existing_programming_group: You are currently working on the exercise entitled '%{exercise}' as part of a programming group. Please finish your work there by scoring and submitting your code before you start implementing this exercise. existing_programming_group: You are currently working on the exercise entitled '%{exercise}' as part of a programming group. Please finish your work there before you start implementing this exercise.
external_privacy_policy: privacy policy external_privacy_policy: privacy policy
messages: Messages messages: Messages
feedback: Feedback feedback: Feedback
@ -560,12 +561,6 @@ en:
external_users: External Users external_users: External Users
programming_groups: Programming Groups programming_groups: Programming Groups
finishing_rate: Finishing Rate finishing_rate: Finishing Rate
submit:
failure: An error occurred while transmitting your score. Please try again later.
too_late: Your submission was saved successfully but was received after the deadline passed.
full_score_redirect_to_rfc: Congratulations! You achieved and submitted the highest possible score for this exercise. Another participant has a question concerning the exercise you just solved. Your help and comments will be greatly appreciated!
full_score_redirect_to_own_rfc: Congratulations! You achieved and submitted the highest possible score for this exercise. Your question concerning the exercise is solved? If so, please share the essential insight with your fellows and mark the question as solved, before you close this window!
warning_not_for_all_users_submitted: "The transmission of points was only partially successful. The score was not transmitted for %{user}. The user(s) should reopen the exercise via the e-learning platform and then try to submit the points themselves."
study_group_dashboard: study_group_dashboard:
live_dashboard: Live Dashboard live_dashboard: Live Dashboard
time_spent_per_learner: Time spent per Learner time_spent_per_learner: Time spent per Learner
@ -876,7 +871,7 @@ en:
failure: Invalid email or password. failure: Invalid email or password.
success: Successfully signed in. success: Successfully signed in.
create_through_lti: create_through_lti:
session_with_outcome: 'Please click "Submit Code for Assessment" after scoring to send your score to %{consumer}.' session_with_outcome: 'By clicking on "Score", your points will be added automatically to your progress.'
session_without_outcome: 'This is a practice session. Your grade will not be transmitted to %{consumer}.' session_without_outcome: 'This is a practice session. Your grade will not be transmitted to %{consumer}.'
destroy: destroy:
link: Sign out link: Sign out

View File

@ -91,7 +91,6 @@ Rails.application.routes.draw do
get :statistics get :statistics
get :feedback get :feedback
get :reload get :reload
post :submit
get 'study_group_dashboard/:study_group_id', to: 'exercises#study_group_dashboard' get 'study_group_dashboard/:study_group_id', to: 'exercises#study_group_dashboard'
post :export_external_check post :export_external_check
post :export_external_confirm post :export_external_confirm
@ -164,6 +163,7 @@ Rails.application.routes.draw do
get :score get :score
get :statistics get :statistics
get 'test/:filename', as: :test, constraints: {filename: FILENAME_REGEXP}, action: :test get 'test/:filename', as: :test, constraints: {filename: FILENAME_REGEXP}, action: :test
get :finalize
end end
end end

View File

@ -114,17 +114,19 @@ RSpec.describe Lti do
it 'returns a corresponding status' do it 'returns a corresponding status' do
allow_any_instance_of(IMS::LTI::ToolProvider).to receive(:outcome_service?).and_return(false) allow_any_instance_of(IMS::LTI::ToolProvider).to receive(:outcome_service?).and_return(false)
allow(submission).to receive(:normalized_score).and_return score allow(submission).to receive(:normalized_score).and_return score
expect(controller.send(:send_scores, submission).first[:status]).to eq('unsupported') submit_info = controller.send(:send_scores, submission)
expect(submit_info[:users][:all]).to eq(submit_info[:users][:unsupported])
end end
end end
context 'when grading is supported' do context 'when grading is supported' do
let(:response) { double } let(:response) { double }
let(:send_scores) { controller.send(:send_scores, submission).first } let(:send_scores) { controller.send(:send_scores, submission) }
let(:score_sent) { score }
before do before do
allow_any_instance_of(IMS::LTI::ToolProvider).to receive(:outcome_service?).and_return(true) allow_any_instance_of(IMS::LTI::ToolProvider).to receive(:outcome_service?).and_return(true)
allow_any_instance_of(IMS::LTI::ToolProvider).to receive(:post_replace_result!).with(score).and_return(response) allow_any_instance_of(IMS::LTI::ToolProvider).to receive(:post_replace_result!).with(score_sent).and_return(response)
allow(response).to receive(:response_code).at_least(:once).and_return(200) allow(response).to receive(:response_code).at_least(:once).and_return(200)
allow(response).to receive(:post_response).and_return(response) allow(response).to receive(:post_response).and_return(response)
allow(response).to receive(:body).at_least(:once).and_return('') allow(response).to receive(:body).at_least(:once).and_return('')
@ -133,14 +135,83 @@ RSpec.describe Lti do
end end
it 'sends the score' do it 'sends the score' do
expect_any_instance_of(IMS::LTI::ToolProvider).to receive(:post_replace_result!).with(score) expect_any_instance_of(IMS::LTI::ToolProvider).to receive(:post_replace_result!).with(score_sent)
send_scores send_scores
end end
it 'returns code, message, and status' do it 'returns code, message, deadline and status' do
expect(send_scores[:code]).to eq(response.response_code) expect(send_scores[:users][:all]).to eq(send_scores[:users][:success])
expect(send_scores[:message]).to eq(response.body) expect(send_scores[:deadline]).to eq(:none)
expect(send_scores[:status]).to eq(response.code_major) expect(send_scores[:detailed_results].first[:code]).to eq(response.response_code)
expect(send_scores[:detailed_results].first[:message]).to eq(response.body)
expect(send_scores[:detailed_results].first[:status]).to eq(response.code_major)
end
context 'when submission is before deadline' do
before do
allow(submission).to receive(:before_deadline?).and_return true
end
it 'returns deadline' do
expect(send_scores[:deadline]).to eq(:before_deadline)
end
end
context 'when submission is within grace period' do
let(:score_sent) { score * 0.8 }
before do
allow(submission).to receive_messages(before_deadline?: false, within_grace_period?: true)
end
it 'returns deadline and reduced score' do
expect(send_scores[:deadline]).to eq(:within_grace_period)
expect(send_scores[:score][:sent]).to eq(score * 0.8)
end
it 'sends the reduced score' do
expect_any_instance_of(IMS::LTI::ToolProvider).to receive(:post_replace_result!).with(score_sent)
send_scores
end
end
context 'when submission is after late deadline' do
let(:score_sent) { score * 0 }
before do
allow(submission).to receive_messages(before_deadline?: false,
within_grace_period?: false,
after_late_deadline?: true)
end
it 'returns deadline and reduced score' do
expect(send_scores[:deadline]).to eq(:after_late_deadline)
expect(send_scores[:score][:sent]).to eq(score * 0)
end
it 'sends the reduced score' do
expect_any_instance_of(IMS::LTI::ToolProvider).to receive(:post_replace_result!).with(score_sent)
send_scores
end
end
end
context 'when transmission fails' do
let(:send_scores) { controller.send(:send_scores, submission) }
let(:score_sent) { 0 }
before do
allow_any_instance_of(IMS::LTI::ToolProvider).to receive(:outcome_service?).and_return(true)
allow_any_instance_of(IMS::LTI::ToolProvider).to receive(:post_replace_result!).with(score_sent).and_raise(IMS::LTI::XMLParseError)
end
it 'does not raise an exception' do
expect { send_scores }.not_to raise_error
end
it 'returns an error status' do
expect(send_scores[:users][:all]).to eq(send_scores[:users][:error])
expect(send_scores[:detailed_results].first[:status]).to eq('error')
end end
end end
end end
@ -150,7 +221,8 @@ RSpec.describe Lti do
submission.contributor.consumer = nil submission.contributor.consumer = nil
allow(submission).to receive(:normalized_score).and_return score allow(submission).to receive(:normalized_score).and_return score
expect(controller.send(:send_scores, submission).first[:status]).to eq('error') submit_info = controller.send(:send_scores, submission)
expect(submit_info[:users][:all]).to eq(submit_info[:users][:unsupported])
end end
end end
end end

View File

@ -288,98 +288,6 @@ RSpec.describe ExercisesController do
end end
end end
describe 'POST #submit' do
let(:output) { {} }
let(:perform_request) { post :submit, format: :json, params: {id: exercise.id, submission: {cause: 'submit', exercise_id: exercise.id}} }
let(:contributor) { create(:external_user) }
let(:scoring_response) do
[{
status: :ok,
stdout: '',
stderr: '',
waiting_for_container_time: 0,
container_execution_time: 0,
file_role: 'teacher_defined_test',
count: 1,
failed: 0,
error_messages: [],
passed: 1,
score: 1.0,
filename: 'index.html_spec.rb',
message: 'Well done.',
weight: 2.0,
}]
end
before do
create(:lti_parameter, external_user: contributor, exercise:)
submission = build(:submission, exercise:, contributor:)
allow(submission).to receive_messages(normalized_score: 1, calculate_score: scoring_response, redirect_to_feedback?: false)
allow(Submission).to receive(:create).and_return(submission)
end
context 'when LTI outcomes are supported' do
before do
allow(controller).to receive(:lti_outcome_service?).and_return(true)
end
context 'when the score transmission succeeds' do
before do
allow(controller).to receive(:send_scores).and_return([{status: 'success'}])
perform_request
end
expect_assigns(exercise: :exercise)
it 'creates a submission' do
expect(assigns(:submission)).to be_a(Submission)
end
expect_json
expect_http_status(:ok)
end
context 'when the score transmission fails' do
before do
allow(controller).to receive(:send_scores).and_return([{status: 'unsupported'}])
perform_request
end
expect_assigns(exercise: :exercise)
it 'creates a submission' do
expect(assigns(:submission)).to be_a(Submission)
end
it 'returns an error message' do
expect(response.parsed_body).to eq('danger' => I18n.t('exercises.submit.failure'))
end
expect_json
end
end
context 'when LTI outcomes are not supported' do
before do
allow(controller).to receive(:lti_outcome_service?).and_return(false)
perform_request
end
expect_assigns(exercise: :exercise)
it 'creates a submission' do
expect(assigns(:submission)).to be_a(Submission)
end
it 'does not send scores' do
expect(controller).not_to receive(:send_scores)
end
expect_json
expect_http_status(:ok)
end
end
describe 'PUT #update' do describe 'PUT #update' do
context 'with a valid exercise' do context 'with a valid exercise' do
let(:exercise_attributes) { build(:dummy).attributes } let(:exercise_attributes) { build(:dummy).attributes }

View File

@ -6,7 +6,8 @@ RSpec.describe SubmissionsController do
render_views render_views
let(:exercise) { create(:math) } let(:exercise) { create(:math) }
let(:submission) { create(:submission, exercise:, contributor:) } let(:cause) { 'save' }
let(:submission) { create(:submission, exercise:, contributor:, cause:) }
shared_examples 'a regular user' do |record_not_found_status_code| shared_examples 'a regular user' do |record_not_found_status_code|
describe 'POST #create' do describe 'POST #create' do
@ -39,6 +40,15 @@ RSpec.describe SubmissionsController do
end end
end end
describe 'GET #download' do
let(:perform_request) { proc { get :download, params: {id: submission.id} } }
before { perform_request.call }
expect_assigns(submission: :submission)
expect_http_status(:ok)
end
describe 'GET #download_file' do describe 'GET #download_file' do
context 'with an invalid filename' do context 'with an invalid filename' do
before { get :download_file, params: {filename: SecureRandom.hex, id: submission.id, format: :json} } before { get :download_file, params: {filename: SecureRandom.hex, id: submission.id, format: :json} }
@ -98,6 +108,84 @@ RSpec.describe SubmissionsController do
end end
end end
describe 'GET #finalize' do
let(:perform_request) { proc { get :finalize, params: {id: submission.id} } }
let(:cause) { 'assess' }
context 'when the request is performed' do
before { perform_request.call }
expect_assigns(submission: :submission)
expect_redirect
end
it 'updates cause to submit' do
expect { perform_request.call && submission.reload }.to change(submission, :cause).from('assess').to('submit')
end
context 'when contributing to a community solution is possible' do
let!(:community_solution) { CommunitySolution.create(exercise:) }
before do
allow(Java21Study).to receive(:allow_redirect_to_community_solution?).and_return(true)
perform_request.call
end
expect_redirect { edit_community_solution_path(community_solution, lock_id: CommunitySolutionLock.last) }
end
context 'when sharing exercise feedback is desired' do
before do
uef&.save!
allow_any_instance_of(Submission).to receive(:redirect_to_feedback?).and_return(true)
perform_request.call
end
context 'without any previous feedback' do
let(:uef) { nil }
expect_redirect { new_user_exercise_feedback_path(user_exercise_feedback: {exercise_id: submission.exercise.id}) }
end
context 'with a previous feedback for the same exercise' do
let(:uef) { create(:user_exercise_feedback, exercise:, user: current_user) }
expect_redirect { edit_user_exercise_feedback_path(uef) }
end
end
context 'with an RfC' do
before do
rfc.save!
allow_any_instance_of(Submission).to receive(:redirect_to_feedback?).and_return(false)
perform_request.call
end
context 'when an own RfC is unsolved' do
let(:rfc) { create(:rfc, user: current_user, exercise:, submission:) }
expect_flash_message(:notice, I18n.t('exercises.editor.exercise_finished_redirect_to_own_rfc'))
expect_redirect { request_for_comment_url(rfc) }
end
context 'when another RfC is unsolved' do
let(:rfc) { create(:rfc, exercise:) }
expect_flash_message(:notice, I18n.t('exercises.editor.exercise_finished_redirect_to_rfc'))
expect_redirect { request_for_comment_url(rfc) }
end
end
context 'when neither a community solution, feedback nor RfC is available' do
before do
allow_any_instance_of(Submission).to receive(:redirect_to_feedback?).and_return(false)
perform_request.call
end
expect_redirect { lti_return_path(submission_id: submission.id) }
end
end
describe 'GET #render_file' do describe 'GET #render_file' do
let(:file) { submission.files.first } let(:file) { submission.files.first }
let(:signed_url) { AuthenticatedUrlHelper.sign(render_submission_url(submission, filename), submission) } let(:signed_url) { AuthenticatedUrlHelper.sign(render_submission_url(submission, filename), submission) }
@ -259,8 +347,9 @@ RSpec.describe SubmissionsController do
context 'with an admin user' do context 'with an admin user' do
let(:contributor) { create(:admin) } let(:contributor) { create(:admin) }
let(:current_user) { contributor }
before { allow(controller).to receive(:current_user).and_return(contributor) } before { allow(controller).to receive_messages(current_user:) }
describe 'GET #index' do describe 'GET #index' do
before do before do
@ -280,10 +369,9 @@ RSpec.describe SubmissionsController do
let(:group_author) { create(:external_user) } let(:group_author) { create(:external_user) }
let(:other_group_author) { create(:external_user) } let(:other_group_author) { create(:external_user) }
let(:contributor) { create(:programming_group, exercise:, users: [group_author, other_group_author]) } let(:contributor) { create(:programming_group, exercise:, users: [group_author, other_group_author]) }
let(:current_user) { group_author }
before do before { allow(controller).to receive_messages(current_contributor: contributor, current_user:) }
allow(controller).to receive_messages(current_contributor: contributor, current_user: group_author)
end
it_behaves_like 'a regular user', :unauthorized it_behaves_like 'a regular user', :unauthorized
it_behaves_like 'denies access for regular, non-admin users' it_behaves_like 'denies access for regular, non-admin users'
@ -291,10 +379,9 @@ RSpec.describe SubmissionsController do
context 'with a learner' do context 'with a learner' do
let(:contributor) { create(:external_user) } let(:contributor) { create(:external_user) }
let(:current_user) { contributor }
before do before { allow(controller).to receive_messages(current_user:) }
allow(controller).to receive_messages(current_user: contributor)
end
it_behaves_like 'a regular user', :unauthorized it_behaves_like 'a regular user', :unauthorized
it_behaves_like 'denies access for regular, non-admin users' it_behaves_like 'denies access for regular, non-admin users'

View File

@ -109,13 +109,4 @@ RSpec.describe 'Editor', :js do
expect(page).not_to have_content(I18n.t('exercises.editor.score')) expect(page).not_to have_content(I18n.t('exercises.editor.score'))
end end
end end
it 'contains a button for submitting the exercise' do
submission = build(:submission, contributor:, exercise:)
allow(submission).to receive(:calculate_score).and_return(scoring_response)
allow(Submission).to receive(:find).and_return(submission)
click_button(I18n.t('exercises.editor.score'))
expect(page).not_to have_content(I18n.t('exercises.editor.tooltips.exercise_deadline_passed'))
expect(page).to have_content(I18n.t('exercises.editor.submit'))
end
end end

317
spec/features/score_spec.rb Normal file
View File

@ -0,0 +1,317 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Score', :js do
let(:exercise) { create(:hello_world) }
let(:contributor) { create(:external_user) }
let(:submission) { create(:submission, exercise:, contributor:, score:) }
before do
allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(contributor)
allow(Submission).to receive(:find).and_return(submission)
visit(implement_exercise_path(exercise))
end
shared_examples 'exercise finished notification' do
it "shows an 'exercise finished' notification" do
# Text needs to be split because it includes the embedded URL in the HTML which is not shown in the notification.
# We compare the shown notification text and the URL separately.
expect(page).to have_content(I18n.t('exercises.editor.exercise_finished').split('.').first)
expect(page).to have_link(nil, href: finalize_submission_path(submission))
end
end
shared_examples 'no exercise finished notification' do
it "does not show an 'exercise finished' notification" do
# Text needs to be split because it includes the embedded URL in the HTML which is not shown in the notification.
# We compare the shown notification text and the URL separately.
expect(page).not_to have_content(I18n.t('exercises.editor.exercise_finished').split('.').first)
expect(page).not_to have_link(nil, href: finalize_submission_path(submission))
end
end
shared_examples 'notification' do |message_key|
it "shows a '#{message_key.split('.').last}' notification" do
options = {}
options[:score_sent] = (score_sent * 100).to_i if defined? score_sent
options[:user] = users_error.map(&:displayname).join(', ') if defined? users_error
expect(page).to have_content(I18n.t(message_key, **options))
end
end
shared_examples 'no notification' do |message_key|
it "does not show a '#{message_key.split('.').last}' notification" do
expect(page).not_to have_content(I18n.t(message_key))
end
end
context 'when scoring is successful' do
let(:lti_outcome_service?) { true }
let(:scoring_response) do
{
users: {all: users_success + users_error + users_unsupported, success: users_success, error: users_error, unsupported: users_unsupported},
score: {original: score, sent: score_sent},
deadline:,
detailed_results: [],
}
end
let(:calculate_response) do
[{
status: :ok,
stdout: '',
stderr: '',
waiting_for_container_time: 0,
container_execution_time: 0,
file_role: :teacher_defined_test,
count: 1,
failed: 0,
error_messages: [],
passed: 1,
score:,
filename: 'exercise_spec.rb',
message: 'Well done.',
weight: 1.0,
hidden_feedback: false,
exit_code: 0,
}]
end
before do
allow_any_instance_of(LtiHelper).to receive(:lti_outcome_service?).and_return(lti_outcome_service?)
allow(submission).to receive(:calculate_score).and_return(calculate_response)
allow_any_instance_of(SubmissionsController).to receive(:send_scores).and_return(scoring_response)
click_button(I18n.t('exercises.editor.score'))
end
shared_context 'when full score reached' do
let(:score) { 1 }
end
shared_context 'when full score is not reached' do
let(:score) { 0 }
end
shared_context 'when scored without deadline' do
let(:deadline) { :none }
let(:score_sent) { score }
end
shared_context 'when scored before deadline' do
let(:deadline) { :before_deadline }
let(:score_sent) { score }
end
shared_context 'when scored within grace period' do
let(:deadline) { :within_grace_period }
let(:score_sent) { score * 0.8 }
end
shared_context 'when scored after late deadline' do
let(:deadline) { :after_late_deadline }
let(:score_sent) { score * 0 }
end
context 'when the LTI outcome service is supported' do
describe 'LTI failure' do
let(:users_success) { [] }
let(:users_error) { [contributor] }
let(:users_unsupported) { [] }
context 'when full score is reached' do
include_context 'when full score reached'
%w[without_deadline before_deadline within_grace_period after_late_deadline].each do |scenario|
context "when scored #{scenario.tr('_', ' ')}" do
include_context "when scored #{scenario.tr('_', ' ')}"
it_behaves_like 'no exercise finished notification'
it_behaves_like 'notification', 'exercises.editor.submit_failure_all'
it_behaves_like 'no notification', 'exercises.editor.submit_failure_other_users'
it_behaves_like 'no notification', 'exercises.editor.submit_too_late'
end
end
end
context 'when full score is not reached' do
include_context 'when full score is not reached'
%w[without_deadline before_deadline within_grace_period after_late_deadline].each do |scenario|
context "when scored #{scenario.tr('_', ' ')}" do
include_context "when scored #{scenario.tr('_', ' ')}"
it_behaves_like 'no exercise finished notification'
it_behaves_like 'notification', 'exercises.editor.submit_failure_all'
it_behaves_like 'no notification', 'exercises.editor.submit_failure_other_users'
it_behaves_like 'no notification', 'exercises.editor.submit_too_late'
end
end
end
end
describe 'LTI success' do
let(:users_success) { [contributor] }
let(:users_error) { [] }
let(:users_unsupported) { [] }
context 'when full score is reached' do
include_context 'when full score reached'
%w[without_deadline before_deadline].each do |scenario|
context "when scored #{scenario.tr('_', ' ')}" do
include_context "when scored #{scenario.tr('_', ' ')}"
it_behaves_like 'exercise finished notification'
it_behaves_like 'no notification', 'exercises.editor.submit_failure_all'
it_behaves_like 'no notification', 'exercises.editor.submit_failure_other_users'
it_behaves_like 'no notification', 'exercises.editor.submit_too_late'
end
end
%w[within_grace_period after_late_deadline].each do |scenario|
context "when scored #{scenario.tr('_', ' ')}" do
include_context "when scored #{scenario.tr('_', ' ')}"
it_behaves_like 'exercise finished notification'
it_behaves_like 'no notification', 'exercises.editor.submit_failure_all'
it_behaves_like 'no notification', 'exercises.editor.submit_failure_other_users'
it_behaves_like 'notification', 'exercises.editor.submit_too_late'
end
end
end
context 'when full score is not reached' do
include_context 'when full score is not reached'
%w[within_grace_period after_late_deadline].each do |scenario|
context "when scored #{scenario.tr('_', ' ')}" do
include_context "when scored #{scenario.tr('_', ' ')}"
it_behaves_like 'no exercise finished notification'
it_behaves_like 'no notification', 'exercises.editor.submit_failure_all'
it_behaves_like 'no notification', 'exercises.editor.submit_failure_other_users'
it_behaves_like 'notification', 'exercises.editor.submit_too_late'
end
end
end
end
describe 'LTI success for current contributor and failure for other' do
let(:users_success) { [contributor] }
let(:users_error) { [create(:external_user)] }
let(:users_unsupported) { [] }
context 'when full score is reached' do
include_context 'when full score reached'
%w[without_deadline before_deadline].each do |scenario|
context "when scored #{scenario.tr('_', ' ')}" do
include_context "when scored #{scenario.tr('_', ' ')}"
it_behaves_like 'exercise finished notification'
it_behaves_like 'no notification', 'exercises.editor.submit_failure_all'
it_behaves_like 'notification', 'exercises.editor.submit_failure_other_users'
it_behaves_like 'no notification', 'exercises.editor.submit_too_late'
end
end
%w[within_grace_period after_late_deadline].each do |scenario|
context "when scored #{scenario.tr('_', ' ')}" do
include_context "when scored #{scenario.tr('_', ' ')}"
it_behaves_like 'exercise finished notification'
it_behaves_like 'no notification', 'exercises.editor.submit_failure_all'
it_behaves_like 'notification', 'exercises.editor.submit_failure_other_users'
it_behaves_like 'notification', 'exercises.editor.submit_too_late'
end
end
end
context 'when full score is not reached' do
include_context 'when full score is not reached'
%w[within_grace_period after_late_deadline].each do |scenario|
context "when scored #{scenario.tr('_', ' ')}" do
include_context "when scored #{scenario.tr('_', ' ')}"
it_behaves_like 'no exercise finished notification'
it_behaves_like 'no notification', 'exercises.editor.submit_failure_all'
it_behaves_like 'notification', 'exercises.editor.submit_failure_other_users'
it_behaves_like 'notification', 'exercises.editor.submit_too_late'
end
end
end
end
end
context 'when the LTI outcomes are not supported' do
let(:lti_outcome_service?) { false }
let(:users_success) { [] }
let(:users_error) { [] }
let(:users_unsupported) { [contributor] }
context 'when full score is reached' do
include_context 'when full score reached'
%w[without_deadline before_deadline within_grace_period after_late_deadline].each do |scenario|
context "when scored #{scenario.tr('_', ' ')}" do
include_context "when scored #{scenario.tr('_', ' ')}"
it_behaves_like 'exercise finished notification'
it_behaves_like 'no notification', 'exercises.editor.submit_failure_all'
it_behaves_like 'no notification', 'exercises.editor.submit_failure_other_users'
it_behaves_like 'no notification', 'exercises.editor.submit_too_late'
end
end
end
context 'when full score is not reached' do
include_context 'when full score is not reached'
%w[without_deadline before_deadline within_grace_period after_late_deadline].each do |scenario|
context "when scored #{scenario.tr('_', ' ')}" do
include_context "when scored #{scenario.tr('_', ' ')}"
it_behaves_like 'no exercise finished notification'
it_behaves_like 'no notification', 'exercises.editor.submit_failure_all'
it_behaves_like 'no notification', 'exercises.editor.submit_failure_other_users'
it_behaves_like 'no notification', 'exercises.editor.submit_too_late'
end
end
end
end
end
context 'when scoring is not successful' do
let(:score) { 0 }
context 'when the desired runner is already in use' do
before do
allow(submission).to receive(:calculate_score).and_raise(Runner::Error::RunnerInUse)
click_button(I18n.t('exercises.editor.score'))
end
it_behaves_like 'notification', 'exercises.editor.runner_in_use'
it_behaves_like 'no exercise finished notification'
it_behaves_like 'no notification', 'exercises.editor.submit_failure_all'
it_behaves_like 'no notification', 'exercises.editor.submit_failure_other_users'
it_behaves_like 'no notification', 'exercises.editor.submit_too_late'
end
context 'when no runner is available' do
before do
allow(submission).to receive(:calculate_score).and_raise(Runner::Error::NotAvailable)
click_button(I18n.t('exercises.editor.score'))
end
it_behaves_like 'notification', 'exercises.editor.depleted'
it_behaves_like 'no exercise finished notification'
it_behaves_like 'no notification', 'exercises.editor.submit_failure_all'
it_behaves_like 'no notification', 'exercises.editor.submit_failure_other_users'
it_behaves_like 'no notification', 'exercises.editor.submit_too_late'
end
end
end

View File

@ -188,29 +188,6 @@ RSpec.describe ExercisePolicy do
end end
end end
permissions :submit? do
context 'when teacher-defined assessments are available' do
before do
create(:test_file, context: exercise)
exercise.reload
end
it 'grants access to anyone' do
%i[admin external_user teacher].each do |factory_name|
expect(policy).to permit(create(factory_name), exercise)
end
end
end
context 'when teacher-defined assessments are not available' do
it 'does not grant access to anyone' do
%i[admin external_user teacher].each do |factory_name|
expect(policy).not_to permit(create(factory_name), exercise)
end
end
end
end
describe ExercisePolicy::Scope do describe ExercisePolicy::Scope do
describe '#resolve' do describe '#resolve' do
let(:admin) { create(:admin) } let(:admin) { create(:admin) }

View File

@ -13,7 +13,7 @@ RSpec.describe SubmissionPolicy do
end end
end end
%i[download_file? render_file? run? score? show? statistics? stop? test?].each do |action| %i[download? download_file? download_submission_file? render_file? run? score? show? statistics? stop? test? insights? finalize?].each do |action|
permissions(action) do permissions(action) do
let(:exercise) { build(:math) } let(:exercise) { build(:math) }
let(:group_author) { build(:external_user) } let(:group_author) { build(:external_user) }