-
Notifications
You must be signed in to change notification settings - Fork 29
Expand file tree
/
Copy pathsubmission.rb
More file actions
365 lines (315 loc) · 13.9 KB
/
submission.rb
File metadata and controls
365 lines (315 loc) · 13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
# frozen_string_literal: true
class Submission < ApplicationRecord
include Context
include ContributorCreation
CAUSES = %w[assess download file render run save submit test autosave requestComments remoteAssess
remoteSubmit].freeze
MAX_COMMENTS_ON_RECOMMENDED_RFC = 5
OLDEST_RFC_TO_SHOW = 1.month
belongs_to :exercise
belongs_to :study_group, optional: true
has_many :testruns
has_many :structured_errors, dependent: :destroy
has_many :comments, through: :files
has_one :request_for_comment
has_one :user_exercise_feedback
has_one :pair_programming_exercise_feedback
belongs_to :external_users, lambda {
where(submissions: {contributor_type: 'ExternalUser'}).includes(:submissions)
}, foreign_key: :contributor_id, class_name: 'ExternalUser', optional: true
belongs_to :internal_users, lambda {
where(submissions: {contributor_type: 'InternalUser'}).includes(:submissions)
}, foreign_key: :contributor_id, class_name: 'InternalUser', optional: true
belongs_to :programming_groups, lambda {
where(submissions: {contributor_type: 'ProgrammingGroup'}).includes(:submissions)
}, foreign_key: :contributor_id, class_name: 'ProgrammingGroup', optional: true
delegate :execution_environment, to: :exercise
scope :final, -> { where(cause: %w[submit remoteSubmit]) }
scope :intermediate, -> { where.not(cause: %w[submit remoteSubmit]) }
scope :before_deadline, lambda {
joins(:exercise).where('submissions.created_at <= exercises.submission_deadline OR exercises.submission_deadline IS NULL')
}
scope :within_grace_period, lambda {
joins(:exercise).where('(submissions.created_at > exercises.submission_deadline) AND (submissions.created_at <= exercises.late_submission_deadline OR exercises.late_submission_deadline IS NULL)')
}
scope :after_late_deadline, lambda {
joins(:exercise).where('submissions.created_at > exercises.late_submission_deadline')
}
scope :latest, -> { order(submissions: {created_at: :desc}).first }
validates :cause, inclusion: {in: CAUSES}
def collect_files
@collect_files ||= begin
ancestors = build_files_hash(exercise&.files&.includes(:file_type), :id)
descendants = build_files_hash(files.includes(:file_type), :file_id)
ancestors.merge(descendants).values
end
end
def main_file
collect_files.detect(&:main_file?)
end
def file_by_name(file_path)
# expects the full file path incl. file extension
# Caution: There must be no unnecessary path prefix included.
# Use `file.ext` rather than `./file.ext`
collect_files.detect {|file| file.filepath == file_path }
end
def full_score?
score == exercise.maximum_score
end
def normalized_score
@normalized_score ||= if !score.nil? && !exercise.maximum_score.nil? && exercise.maximum_score.positive?
score / exercise.maximum_score
else
0
end
end
def percentage
(normalized_score * 100).round
end
def siblings
contributor.submissions.where(exercise_id:)
end
def to_s
Submission.model_name.human
end
def before_deadline?
if exercise.submission_deadline.present?
created_at <= exercise.submission_deadline
else
false
end
end
def within_grace_period?
if exercise.submission_deadline.present? && exercise.late_submission_deadline.present?
created_at > exercise.submission_deadline && created_at <= exercise.late_submission_deadline
else
false
end
end
def after_late_deadline?
if exercise.late_submission_deadline.present?
created_at > exercise.late_submission_deadline
elsif exercise.submission_deadline.present?
created_at > exercise.submission_deadline
else
false
end
end
def redirect_to_feedback?
# 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
# desired, the number of feedbacks can be limited with exercise.needs_more_feedback?
(contributor_id + exercise.created_at.to_i) % 10 == 1
end
def own_unsolved_rfc(user)
Pundit.policy_scope(user, RequestForComment).joins(:submission).where(submission: {contributor:}).unsolved.find_by(exercise:)
end
def unsolved_rfc(user = self.user)
Pundit.policy_scope(user, RequestForComment)
.unsolved.where.not(question: [nil, ''])
.where(exercise_id: exercise, created_at: OLDEST_RFC_TO_SHOW.ago...)
.left_joins(:comments)
.having('COUNT(comments.id) < ?', MAX_COMMENTS_ON_RECOMMENDED_RFC)
.group(:id)
.order('RANDOM()').limit(1)
.first
end
# @raise [Runner::Error] if the score could not be calculated due to a failure with the runner.
# See the specific type and message for more details.
def calculate_score(requesting_user)
file_scores = nil
# If prepared_runner raises an error, no Testrun will be created.
prepared_runner do |runner, waiting_duration|
assessments = collect_files.select(&:teacher_defined_assessment?)
assessment_number = assessments.size
# We sort the test files, so that the linter checks are run first. This prevents a modification of the test file
file_scores = assessments.sort_by {|file| file.teacher_defined_linter? ? 0 : 1 }.map.with_index(1) do |file, index|
output = run_file :test_command, file, runner, waiting_duration
# If the previous execution failed and there is at least one more test, we request a new runner.
swap_runner(runner) if output[:status] == :timeout && index < assessment_number
score_file(output, file, requesting_user)
end
end
# We sort the files again, so that *optional* linter tests are displayed last.
# All other files are sorted alphabetically.
file_scores&.sort_by! do |file|
[file[:file_role] == 'teacher_defined_linter' && file[:weight].zero? ? 1 : 0, file[:filename]]
end
combine_file_scores(file_scores)
end
# @raise [Runner::Error] if the code could not be run due to a failure with the runner.
# See the specific type and message for more details.
def run(file, &)
run_command = command_for execution_environment.run_command, file.filepath
durations = {}
prepared_runner do |runner, waiting_duration|
durations[:execution_duration] = runner.attach_to_execution(run_command, exclusive: false, &)
durations[:waiting_duration] = waiting_duration
rescue Runner::Error => e
e.waiting_duration = waiting_duration
raise
end
durations
end
def test(file, requesting_user)
output = execute :test_command, file
score_file output, file, requesting_user
end
def self.ransackable_attributes(_auth_object = nil)
%w[study_group_id exercise_id cause]
end
def users
contributor.try(:users) || [contributor]
end
private
def build_files_hash(files, attribute)
files&.map(&attribute.to_proc)&.zip(files).to_h
end
def prepared_runner(existing_runner: nil, exclusive: true)
request_time = Time.zone.now
begin
runner = existing_runner || Runner.for(contributor, exercise.execution_environment)
runner.reserve! if exclusive
files = collect_files
case cause
when 'run', 'test'
files.reject! do |file|
next true if file.reference_implementation?
# Only remove teacher-defined assessments if they are hidden.
# Otherwise, 'test' might fail if a teacher-defined assessment is executed.
next true if file.teacher_defined_assessment? && file.hidden?
next false
end
when 'assess', 'submit', 'remoteAssess', 'remoteSubmit', 'requestComments'
regular_filepaths = files.reject(&:reference_implementation?).map(&:filepath)
files.reject! {|file| file.reference_implementation? && regular_filepaths.include?(file.filepath) }
end
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Copying files to Runner #{runner.id} for #{contributor_type} #{contributor_id} and Submission #{id}." }
# We don't want `copy_files` to be exclusive since we reserve runners for the whole `prepared_runner` block.
runner.copy_files(files, exclusive: false)
rescue Runner::Error => e
e.waiting_duration = Time.zone.now - request_time
raise
end
waiting_duration = Time.zone.now - request_time
yield(runner, waiting_duration) if block_given?
ensure
runner&.release! if exclusive
end
def swap_runner(runner, exclusive: true)
# We use a transaction to ensure that the runner is swapped atomically.
transaction do
# Due to the `before_validation` callback in the `Runner` model,
# the following line will immediately request a new runner.
runner.update(runner_id: nil)
# With the new runner being ready, we only need to prepare it (by copying the files).
# Since no actual execution is performed, we don't need to reserve the runner.
prepared_runner(existing_runner: runner, exclusive: false)
# Now, we update the locks if desired. This is only necessary when a runner is used exclusively.
runner.extend! if exclusive
end
end
def command_for(template, filepath)
template % command_substitutions(filepath)
end
def command_substitutions(filename)
{
class_name: File.basename(filename, File.extname(filename)).upcase_first,
filename:,
module_name: File.basename(filename, File.extname(filename)).underscore,
}
end
# @raise [Runner::Error] if the file could not be tested due to a failure with the runner.
# See the specific type and message for more details.
def execute(action, file)
prepared_runner do |runner, waiting_duration|
run_file action, file, runner, waiting_duration
rescue Runner::Error => e
e.waiting_duration = waiting_duration
raise
end
end
def run_file(action, file, runner, waiting_duration)
command = command_for execution_environment.public_send(action), file.filepath
result = {file_role: file.role, waiting_for_container_time: waiting_duration}
output = runner.execute_command(command, raise_exception: false, exclusive: false)
result.merge(output)
end
def score_file(output, file, requesting_user)
assessor = Assessor.new(execution_environment:)
assessment = assessor.assess(output)
passed = (assessment[:passed] == assessment[:count]) && assessment[:score].positive?
testrun_output = passed ? nil : "status: #{output[:status]}\n stdout: #{output[:stdout]}\n stderr: #{output[:stderr]}"
if testrun_output.present?
execution_environment.error_templates.each do |template|
pattern = Regexp.new(template.signature).freeze
StructuredError.create_from_template(template, testrun_output, self) if pattern.match(testrun_output)
end
end
testrun = Testrun.create(
submission: self,
user: requesting_user,
cause: 'assess', # Required to differ run and assess for RfC show
file:, # Test file that was executed
passed:,
exit_code: output[:exit_code],
status: output[:status],
output: testrun_output.presence,
container_execution_time: output[:container_execution_time],
waiting_for_container_time: output[:waiting_for_container_time]
)
TestrunMessage.create_for(testrun, output[:messages])
filename = file.filepath
if file.teacher_defined_linter?
LinterCheckRun.create_from(testrun, assessment)
assessment = assessor.translate_linter(assessment, I18n.locale)
# replace file name with hint if linter is not used for grading. Refactor!
filename = I18n.t('exercises.implement.not_graded') if file.weight.zero?
end
output.merge!(assessment)
output.merge!(filename:, message: feedback_message(file, output), weight: file.weight, hidden_feedback: file.hidden_feedback)
output.except!(:messages)
end
def feedback_message(file, output)
if output[:score] == Assessor::MAXIMUM_SCORE && output[:file_role] == 'teacher_defined_test'
I18n.t('exercises.implement.default_test_feedback')
elsif output[:score] == Assessor::MAXIMUM_SCORE && output[:file_role] == 'teacher_defined_linter'
I18n.t('exercises.implement.default_linter_feedback')
else
# The render_markdown method from application_helper.rb is not available in model classes.
ActionController::Base.helpers.sanitize(
Kramdown::Document.new(file.feedback_message, smart_quotes: 'apos,apos,quot,quot').to_html,
tags: %w[strong],
attributes: []
)
end
end
def combine_file_scores(outputs)
score = 0.0
if outputs.present?
outputs.each do |output|
score += output[:score] * output[:weight] unless output.nil?
end
end
# Prevent floating point precision issues by converting to BigDecimal, e.g., for `0.28 * 25`
update(score: score.to_d)
if normalized_score.to_d == BigDecimal('1.0')
Thread.new do
RequestForComment.joins(:submission).where(submission: {contributor:}, exercise:).find_each do |rfc|
rfc.full_score_reached = true
rfc.save
end
rescue StandardError => e
Sentry.capture_exception(e)
ensure
ActiveRecord::Base.connection_pool.release_connection
end
end
if @embed_options.present? && @embed_options[:hide_test_results] && outputs.present?
outputs.each do |output|
output.except!(:error_messages, :count, :failed, :filename, :message, :passed, :stderr, :stdout)
end
end
outputs&.reject {|output| output[:hidden_feedback] if output.present? }
end
end