
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.
561 lines
22 KiB
Ruby
561 lines
22 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class SubmissionsController < ApplicationController
|
|
include CommonBehavior
|
|
include FileConversion
|
|
include Lti
|
|
include RedirectBehavior
|
|
include SubmissionParameters
|
|
include Tubesock::Hijack
|
|
|
|
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_files, only: %i[download show]
|
|
before_action :set_files_and_specific_file, only: %i[download_file run test]
|
|
before_action :set_content_type_nosniff, only: %i[download download_file render_file]
|
|
|
|
# Overwrite the CSP header and some default actions for the :render_file action
|
|
content_security_policy false, only: :render_file
|
|
skip_before_action :deny_access_from_render_host, only: :render_file
|
|
before_action :require_user!, except: :render_file
|
|
# We want to serve .js files without raising a `ActionController::InvalidCrossOriginRequest` exception
|
|
skip_before_action :verify_authenticity_token, only: %i[render_file download_file]
|
|
|
|
def index
|
|
@search = Submission.ransack(params[:q])
|
|
@submissions = @search.result.includes(:exercise, :contributor).paginate(page: params[:page], per_page: per_page_param)
|
|
authorize!
|
|
end
|
|
|
|
def download
|
|
raise Pundit::NotAuthorizedError if @embed_options[:disable_download]
|
|
|
|
id_file = create_remote_evaluation_mapping
|
|
|
|
stringio = Zip::OutputStream.write_buffer do |zio|
|
|
@files.each do |file|
|
|
zio.put_next_entry(file.filepath.delete_prefix('/'))
|
|
zio.write(file.read)
|
|
end
|
|
|
|
# zip exercise description
|
|
zio.put_next_entry("#{t('activerecord.models.exercise.one')}.txt")
|
|
zio.write("#{@submission.exercise.title}\r\n======================\r\n")
|
|
zio.write(@submission.exercise.description)
|
|
|
|
# zip .co file
|
|
zio.put_next_entry('.co')
|
|
zio.write(File.read(id_file))
|
|
FileUtils.rm_rf(id_file)
|
|
|
|
# zip client scripts
|
|
scripts_path = 'app/assets/remote_scripts'
|
|
Dir.foreach(scripts_path) do |file|
|
|
next if (file == '.') || (file == '..')
|
|
|
|
zio.put_next_entry(File.join('.scripts', File.basename(file)))
|
|
zio.write(File.read(File.join(scripts_path, file)))
|
|
end
|
|
end
|
|
zip_data = stringio.string
|
|
response.set_header('Content-Length', zip_data.size)
|
|
send_data(zip_data, type: 'application/octet-stream', filename: "#{@submission.exercise.title.tr(' ', '_')}.zip", disposition: 'attachment')
|
|
end
|
|
|
|
def download_file
|
|
raise Pundit::NotAuthorizedError if @embed_options[:disable_download]
|
|
|
|
if @file.native_file?
|
|
redirect_to protected_upload_path(id: @file.id, filename: @file.filepath)
|
|
else
|
|
response.set_header('Content-Length', @file.size)
|
|
send_data(@file.content, type: 'application/octet-stream', filename: @file.name_with_extension, disposition: 'attachment')
|
|
end
|
|
end
|
|
|
|
def finalize
|
|
@submission.update!(cause: 'submit')
|
|
redirect_after_submit
|
|
end
|
|
|
|
def show; end
|
|
|
|
def render_file
|
|
# Set @current_user with a new *learner* for Pundit checks
|
|
@current_user = ExternalUser.new
|
|
|
|
@submission = authorize AuthenticatedUrlHelper.retrieve!(Submission, request, cookies)
|
|
|
|
# Throws an exception if the file is not found
|
|
set_files_and_specific_file
|
|
|
|
# Allows access to other files of the same submission, e.g., a linked JS or CSS file where we cannot expect a token in the URL
|
|
cookie_name = AuthenticatedUrlHelper.cookie_name_for(:render_file_token)
|
|
if params[AuthenticatedUrlHelper.query_parameter].present?
|
|
cookies[cookie_name] = AuthenticatedUrlHelper.prepare_short_living_cookie(request.url)
|
|
end
|
|
|
|
# Finally grant access and send the file
|
|
if @file.native_file?
|
|
url = render_protected_upload_url(id: @file.id, filename: @file.filepath)
|
|
redirect_to AuthenticatedUrlHelper.sign(url, @file)
|
|
else
|
|
response.set_header('Content-Length', @file.size)
|
|
send_data(@file.content, filename: @file.name_with_extension, disposition: 'inline')
|
|
end
|
|
end
|
|
|
|
def run # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
|
|
# These method-local socket variables are required in order to use one socket
|
|
# in the callbacks of the other socket. As the callbacks for the client socket
|
|
# are registered first, the runner socket may still be nil.
|
|
client_socket, runner_socket = nil
|
|
|
|
hijack do |tubesock|
|
|
client_socket = tubesock
|
|
|
|
client_socket.onopen do |_event|
|
|
kill_client_socket(client_socket) and return true if @embed_options[:disable_run]
|
|
end
|
|
|
|
client_socket.onclose do |_event|
|
|
runner_socket&.close(:terminated_by_client)
|
|
@testrun[:status] ||= :terminated_by_client
|
|
end
|
|
|
|
client_socket.onmessage do |raw_event|
|
|
# Obviously, this is just flushing the current connection: Filtering.
|
|
next if raw_event == "\n"
|
|
|
|
# Otherwise, we expect to receive a JSON: Parsing.
|
|
event = JSON.parse(raw_event).deep_symbolize_keys
|
|
event[:cmd] = event[:cmd].to_sym
|
|
event[:stream] = event[:stream].to_sym if event.key? :stream
|
|
|
|
# We could store the received event. However, it is also echoed by the container
|
|
# and correctly identified as the original input. Therefore, we don't store
|
|
# it here to prevent duplicated events.
|
|
# @testrun[:messages].push(event)
|
|
|
|
case event[:cmd]
|
|
when :client_kill
|
|
@testrun[:status] = :terminated_by_client
|
|
close_client_connection(client_socket)
|
|
Rails.logger.debug('Client exited container.')
|
|
when :result, :canvasevent, :exception
|
|
# The client cannot send something before the runner connection is established.
|
|
if runner_socket.present?
|
|
runner_socket.send_data raw_event
|
|
else
|
|
Rails.logger.info("Could not forward data from client because runner connection was not established yet: #{event[:data].inspect}")
|
|
end
|
|
else
|
|
Rails.logger.info("Unknown command from client: #{event[:cmd]}")
|
|
Sentry.set_extras(event:)
|
|
Sentry.capture_message("Unknown command from client: #{event[:cmd]}")
|
|
end
|
|
rescue JSON::ParserError => e
|
|
Rails.logger.info("Data received from client is not valid json: #{raw_event.inspect}")
|
|
Sentry.set_extras(data: raw_event)
|
|
Sentry.capture_exception(e)
|
|
rescue TypeError => e
|
|
Rails.logger.info("JSON data received from client cannot be parsed as hash: #{raw_event.inspect}")
|
|
Sentry.set_extras(data: raw_event)
|
|
Sentry.capture_exception(e)
|
|
end
|
|
end
|
|
|
|
# If running is not allowed (and the socket is closed), we can stop here.
|
|
return true if @embed_options[:disable_run]
|
|
|
|
@testrun[:output] = +''
|
|
durations = @submission.run(@file) do |socket, starting_time|
|
|
runner_socket = socket
|
|
@testrun[:starting_time] = starting_time
|
|
client_socket.send_data({cmd: :status, status: :container_running}.to_json)
|
|
|
|
runner_socket.on :stdout do |data|
|
|
message = retrieve_message_from_output data, :stdout
|
|
@testrun[:output] << message[:data].to_s[0, max_output_buffer_size - @testrun[:output].size] if message[:data]
|
|
send_and_store client_socket, message
|
|
end
|
|
|
|
runner_socket.on :stderr do |data|
|
|
message = retrieve_message_from_output data, :stderr
|
|
@testrun[:output] << message[:data].to_s[0, max_output_buffer_size - @testrun[:output].size] if message[:data]
|
|
send_and_store client_socket, message
|
|
end
|
|
|
|
runner_socket.on :exit do |exit_code|
|
|
@testrun[:exit_code] = exit_code
|
|
exit_statement =
|
|
if @testrun[:output].empty? && exit_code.zero?
|
|
@testrun[:status] = :ok
|
|
t('exercises.implement.no_output_exit_successful', timestamp: l(Time.zone.now, format: :short), exit_code:)
|
|
elsif @testrun[:output].empty?
|
|
@testrun[:status] = :failed
|
|
t('exercises.implement.no_output_exit_failure', timestamp: l(Time.zone.now, format: :short), exit_code:)
|
|
elsif exit_code.zero?
|
|
@testrun[:status] = :ok
|
|
"\n#{t('exercises.implement.exit_successful', timestamp: l(Time.zone.now, format: :short), exit_code:)}"
|
|
else
|
|
@testrun[:status] = :failed
|
|
"\n#{t('exercises.implement.exit_failure', timestamp: l(Time.zone.now, format: :short), exit_code:)}"
|
|
end
|
|
stream = @testrun[:status] == :ok ? :stdout : :stderr
|
|
send_and_store client_socket, {cmd: :write, stream:, data: "#{exit_statement}\n"}
|
|
end
|
|
|
|
runner_socket.on :files do |files|
|
|
downloadable_files, = convert_files_json_to_files files
|
|
if downloadable_files.present?
|
|
js_tree = FileTree.new(downloadable_files).to_js_tree
|
|
send_and_store client_socket, {cmd: :files, data: js_tree}
|
|
end
|
|
end
|
|
end
|
|
@testrun[:container_execution_time] = durations[:execution_duration]
|
|
@testrun[:waiting_for_container_time] = durations[:waiting_duration]
|
|
rescue Runner::Error::ExecutionTimeout => e
|
|
send_and_store client_socket, {cmd: :status, status: :timeout}
|
|
Rails.logger.debug { "Running a submission timed out: #{e.message}" }
|
|
@testrun[:status] ||= :timeout
|
|
@testrun[:output] = "timeout: #{@testrun[:output]}"
|
|
extract_durations(e)
|
|
rescue Runner::Error::OutOfMemory => e
|
|
send_and_store client_socket, {cmd: :status, status: :out_of_memory}
|
|
Rails.logger.debug { "Running a submission caused an out of memory error: #{e.message}" }
|
|
@testrun[:status] ||= :out_of_memory
|
|
@testrun[:exit_code] ||= 137
|
|
@testrun[:output] = "out_of_memory: #{@testrun[:output]}"
|
|
extract_durations(e)
|
|
rescue Runner::Error::RunnerInUse => e
|
|
send_and_store client_socket, {cmd: :status, status: :runner_in_use}
|
|
Rails.logger.debug { "Running a submission failed because the runner was already in use: #{e.message}" }
|
|
@testrun[:status] ||= :runner_in_use
|
|
@testrun[:output] = "runner_in_use: #{@testrun[:output]}"
|
|
extract_durations(e)
|
|
rescue Runner::Error => e
|
|
# Regardless of the specific error cause, we send a `container_depleted` status to the client.
|
|
send_and_store client_socket, {cmd: :status, status: :container_depleted}
|
|
@testrun[:status] ||= :container_depleted
|
|
Rails.logger.debug { "Runner error while running a submission: #{e.message}" }
|
|
Sentry.capture_exception(e)
|
|
extract_durations(e)
|
|
ensure
|
|
close_client_connection(client_socket)
|
|
save_testrun_output 'run'
|
|
end
|
|
|
|
def score
|
|
client_socket = nil
|
|
disable_scoring = @embed_options[:disable_score] || !@submission.exercise.teacher_defined_assessment?
|
|
|
|
hijack do |tubesock|
|
|
client_socket = tubesock
|
|
tubesock.onopen do |_event|
|
|
kill_client_socket(tubesock) and return true if disable_scoring
|
|
end
|
|
end
|
|
|
|
# If scoring is not allowed (and the socket is closed), we can stop here.
|
|
return true if disable_scoring
|
|
|
|
# The score is stored separately, we can forward it to the client immediately
|
|
client_socket&.send_data(@submission.calculate_score(current_user).to_json)
|
|
# To enable hints when scoring a submission, uncomment the next line:
|
|
# send_hints(client_socket, StructuredError.where(submission: @submission))
|
|
|
|
transmit_lti_score(client_socket)
|
|
rescue Runner::Error::RunnerInUse => e
|
|
extract_durations(e)
|
|
send_and_store client_socket, {cmd: :status, status: :runner_in_use}
|
|
Rails.logger.debug { "Scoring a submission failed because the runner was already in use: #{e.message}" }
|
|
@testrun[:passed] = false
|
|
@testrun[:status] ||= :runner_in_use
|
|
@testrun[:output] = "runner_in_use: #{@testrun[:output]}"
|
|
save_testrun_output 'assess'
|
|
rescue Runner::Error => e
|
|
extract_durations(e)
|
|
send_and_store client_socket, {cmd: :status, status: :container_depleted}
|
|
Rails.logger.debug { "Runner error while scoring submission #{@submission.id}: #{e.message}" }
|
|
Sentry.capture_exception(e)
|
|
@testrun[:passed] = false
|
|
save_testrun_output 'assess'
|
|
ensure
|
|
kill_client_socket(client_socket)
|
|
end
|
|
|
|
def create
|
|
@submission = Submission.new(submission_params)
|
|
authorize!
|
|
create_and_respond(object: @submission)
|
|
end
|
|
|
|
def statistics; end
|
|
|
|
def test
|
|
client_socket = nil
|
|
|
|
hijack do |tubesock|
|
|
client_socket = tubesock
|
|
tubesock.onopen do |_event|
|
|
kill_client_socket(tubesock) and return true if @embed_options[:disable_run]
|
|
end
|
|
end
|
|
|
|
# If running is not allowed (and the socket is closed), we can stop here.
|
|
return true if @embed_options[:disable_run]
|
|
|
|
# The score is stored separately, we can forward it to the client immediately
|
|
client_socket&.send_data(@submission.test(@file, current_user).to_json)
|
|
rescue Runner::Error::RunnerInUse => e
|
|
extract_durations(e)
|
|
send_and_store client_socket, {cmd: :status, status: :runner_in_use}
|
|
Rails.logger.debug { "Scoring a submission failed because the runner was already in use: #{e.message}" }
|
|
@testrun[:passed] = false
|
|
@testrun[:status] ||= :runner_in_use
|
|
@testrun[:output] = "runner_in_use: #{@testrun[:output]}"
|
|
save_testrun_output 'assess'
|
|
rescue Runner::Error => e
|
|
extract_durations(e)
|
|
send_and_store client_socket, {cmd: :status, status: :container_depleted}
|
|
Rails.logger.debug { "Runner error while testing submission #{@submission.id}: #{e.message}" }
|
|
Sentry.capture_exception(e)
|
|
@testrun[:passed] = false
|
|
save_testrun_output 'assess'
|
|
ensure
|
|
kill_client_socket(client_socket)
|
|
end
|
|
|
|
private
|
|
|
|
def authorize!
|
|
authorize(@submission || @submissions)
|
|
end
|
|
|
|
def close_client_connection(client_socket)
|
|
# search for errors and save them as StructuredError (for scoring runs see submission.rb)
|
|
errors = extract_errors
|
|
send_hints(client_socket, errors)
|
|
kill_client_socket(client_socket)
|
|
end
|
|
|
|
def kill_client_socket(client_socket)
|
|
# Do nothing if the socket is not passed, i.e., because the pipe broke
|
|
return unless client_socket
|
|
|
|
# We don't want to store this (arbitrary) exit command and redirect it ourselves
|
|
client_socket.send_data({cmd: :exit}.to_json)
|
|
client_socket.send_data nil, :close
|
|
# 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.
|
|
# Tubesock automatically closes the socket when the `hijack` block ends and otherwise ignores `Errno::ECONNRESET`.
|
|
end
|
|
|
|
def create_remote_evaluation_mapping
|
|
user = @submission.user
|
|
exercise_id = @submission.exercise_id
|
|
|
|
remote_evaluation_mapping = RemoteEvaluationMapping.create(
|
|
user:,
|
|
exercise_id:,
|
|
study_group_id: session[:study_group_id]
|
|
)
|
|
|
|
# create .co file
|
|
path = "tmp/#{user.id}.co"
|
|
# parse validation token
|
|
content = "#{remote_evaluation_mapping.validation_token}\n"
|
|
# parse remote request url
|
|
content += "#{evaluate_url}\n"
|
|
@submission.files.each do |file|
|
|
content += "#{file.filepath}=#{file.file_id}\n"
|
|
end
|
|
File.write(path, content)
|
|
path
|
|
end
|
|
|
|
def extract_durations(error)
|
|
@testrun[:starting_time] = error.starting_time
|
|
@testrun[:container_execution_time] = error.execution_duration
|
|
@testrun[:waiting_for_container_time] = error.waiting_duration
|
|
end
|
|
|
|
def extract_errors
|
|
results = []
|
|
if @testrun[:output].present?
|
|
# First, we test all error templates for a match.
|
|
matching_error_templates = @submission.exercise.execution_environment.error_templates.select do |template|
|
|
pattern = Regexp.new(template.signature).freeze
|
|
pattern.match(@testrun[:output])
|
|
end
|
|
# Second, if there is a match, we preload all ErrorTemplateAttributes and create a StructuredError
|
|
#
|
|
# Reloading the ErrorTemplate is necessary to allow preloading the ErrorTemplateAttributes.
|
|
# However, this results in less (and faster) SQL queries than performing manual lookups.
|
|
ErrorTemplate.where(id: matching_error_templates).joins(:error_template_attributes).includes(:error_template_attributes).find_each do |template|
|
|
results << StructuredError.create_from_template(template, @testrun[:output], @submission)
|
|
end
|
|
end
|
|
results
|
|
end
|
|
|
|
def send_and_store(client_socket, message)
|
|
message[:timestamp] = if @testrun[:starting_time]
|
|
ActiveSupport::Duration.build(Time.zone.now - @testrun[:starting_time])
|
|
else
|
|
0.seconds
|
|
end
|
|
@testrun[:messages].push message
|
|
@testrun[:status] = message[:status] if message[:status]
|
|
client_socket.send_data(message.to_json)
|
|
end
|
|
|
|
def max_output_buffer_size
|
|
if @submission.cause == 'requestComments'
|
|
5000
|
|
else
|
|
500
|
|
end
|
|
end
|
|
|
|
def sanitize_filename
|
|
params[:filename].gsub(/\.json$/, '')
|
|
end
|
|
|
|
# save the output of this "run" as a "testrun" (scoring runs are saved in submission.rb)
|
|
def save_testrun_output(cause)
|
|
testrun = Testrun.create!(
|
|
file: @file,
|
|
passed: @testrun[:passed],
|
|
cause:,
|
|
submission: @submission,
|
|
user: current_user,
|
|
exit_code: @testrun[:exit_code], # might be nil, e.g., when the run did not finish
|
|
status: @testrun[:status] || :failed,
|
|
output: @testrun[:output].presence, # TODO: Remove duplicated saving of the output after creating TestrunMessages
|
|
container_execution_time: @testrun[:container_execution_time],
|
|
waiting_for_container_time: @testrun[:waiting_for_container_time]
|
|
)
|
|
TestrunMessage.create_for(testrun, @testrun[:messages])
|
|
TestrunExecutionEnvironment.create(testrun:, execution_environment: @submission.used_execution_environment)
|
|
end
|
|
|
|
def send_hints(tubesock, errors)
|
|
return if @embed_options[:disable_hints]
|
|
|
|
errors = errors.to_a.uniq(&:hint)
|
|
errors.each do |error|
|
|
send_and_store tubesock, {cmd: :hint, hint: error.hint, description: error.error_template.description}
|
|
end
|
|
end
|
|
|
|
def set_files_and_specific_file
|
|
# @files contains all visible files for the user
|
|
# @file contains the specific file requested for run / test / render / ...
|
|
set_files
|
|
@file = @files.detect {|file| file.filepath == sanitize_filename }
|
|
raise ActiveRecord::RecordNotFound unless @file
|
|
end
|
|
|
|
def set_files
|
|
@files = @submission.collect_files.select(&:visible)
|
|
end
|
|
|
|
def set_content_type_nosniff
|
|
# When sending a file, we want to ensure that browsers follow our Content-Type header
|
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
|
end
|
|
|
|
def set_submission
|
|
@submission = Submission.find(params[:id])
|
|
authorize!
|
|
end
|
|
|
|
def set_testrun
|
|
@testrun = {
|
|
messages: [],
|
|
exit_code: nil,
|
|
status: nil,
|
|
}
|
|
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)
|
|
parsed = JSON.parse(data)
|
|
if parsed.instance_of?(Hash) && parsed.key?('cmd')
|
|
parsed.symbolize_keys!
|
|
# Symbolize two values if present
|
|
parsed[:cmd] = parsed[:cmd].to_sym
|
|
parsed[:stream] = parsed[:stream].to_sym if parsed.key? :stream
|
|
parsed
|
|
else
|
|
{cmd: :write, stream:, data:}
|
|
end
|
|
rescue JSON::ParserError
|
|
{cmd: :write, stream:, data:}
|
|
end
|
|
|
|
def augment_files_for_download(files)
|
|
submission_files = @submission.collect_files + @submission.exercise.files
|
|
files.filter_map do |file|
|
|
# Reject files that were already present in the submission
|
|
# We further reject files that share the same name (excl. file extension) and path as a file in the submission
|
|
# This is, for example, used to filter compiled .class files in Java submissions
|
|
next if submission_files.any? {|submission_file| submission_file.filepath_without_extension == file.filepath_without_extension }
|
|
|
|
# Downloadable files get a signed download_path and an indicator whether we performed a privileged execution
|
|
file.download_path = AuthenticatedUrlHelper.sign(download_stream_file_submission_url(@submission, file.filepath), @submission)
|
|
file.privileged_execution = @submission.execution_environment.privileged_execution
|
|
file
|
|
end
|
|
end
|
|
end
|