-
Notifications
You must be signed in to change notification settings - Fork 29
Expand file tree
/
Copy pathexercises_controller.rb
More file actions
646 lines (543 loc) · 23.8 KB
/
exercises_controller.rb
File metadata and controls
646 lines (543 loc) · 23.8 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
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
# frozen_string_literal: true
class ExercisesController < ApplicationController
include CommonBehavior
include RedirectBehavior
include Lti
include SubmissionParameters
include TimeHelper
before_action :handle_file_uploads, only: %i[create update]
before_action :set_execution_environments, only: %i[index create edit new update]
before_action :set_exercise_and_authorize,
only: MEMBER_ACTIONS + %i[clone implement working_times intervention statistics reload feedback
study_group_dashboard export_external_check export_external_confirm
external_user_statistics download_proforma]
before_action :collect_set_and_unset_exercise_tags, only: MEMBER_ACTIONS
before_action :set_external_user_and_authorize, only: [:external_user_statistics]
before_action :set_file_types, only: %i[create edit new update]
before_action :set_available_tips, only: %i[implement show new edit]
skip_before_action :verify_authenticity_token, only: %i[import_task import_uuid_check]
skip_before_action :require_fully_authenticated_user!, only: %i[import_task import_uuid_check]
skip_after_action :verify_authorized, only: %i[import_task import_uuid_check import_start import_confirm]
skip_after_action :verify_policy_scoped, only: %i[import_task import_uuid_check], raise: false
rescue_from Pundit::NotAuthorizedError, with: :not_authorized_for_exercise
def authorize!
authorize(@exercise || @exercises)
end
private :authorize!
def max_intervention_count_per_day
3
end
def max_intervention_count_per_exercise
1
end
def batch_update
update_map = {}
update_params = params.permit(exercises: %i[id public])
update_params[:exercises].each_value do |param|
update_map[param[:id]] = param[:public]
end
@exercises = Exercise.where(id: update_map.keys).includes(:execution_environment, :files)
authorize!
@exercises.each do |exercise|
exercise.update(public: update_map[exercise.id.to_s])
end
render json: {exercises: @exercises}
end
def clone
exercise = @exercise.duplicate(public: false, token: nil, user: current_user, uuid: nil)
exercise.send(:generate_token)
if exercise.save
redirect_to exercise_path(exercise), notice: t('shared.object_cloned', model: Exercise.model_name.human), status: :see_other
else
flash[:danger] = t('shared.message_failure')
redirect_to @exercise, status: :see_other
end
end
def collect_paths(files)
unique_paths = files.map(&:path).compact_blank.uniq
subpaths = unique_paths.map do |path|
Array.new(path.split('/').length + 1) do |n|
path.split('/').shift(n).join('/')
end
end
subpaths.flatten.uniq
end
private :collect_paths
def index
@search = policy_scope(Exercise).ransack(params[:q])
@exercises = @search.result.includes(:execution_environment, :user, :files, :exercise_tags).order(:title).paginate(page: params[:page], per_page: per_page_param)
authorize!
end
def show
# Show exercise details for teachers and admins
end
def new
@exercise = Exercise.new
authorize!
collect_set_and_unset_exercise_tags
end
def feedback
authorize!
@feedbacks = @exercise
.user_exercise_feedbacks
.includes(:exercise, user: [:programming_groups])
.paginate(page: params[:page], per_page: per_page_param)
end
def export_external_check
codeharbor_check = ExerciseService::CheckExternal.call(uuid: @exercise.uuid,
codeharbor_link: current_user.codeharbor_link)
render json: {
message: codeharbor_check[:message],
actions: render_to_string(
partial: 'export_actions',
locals: {
exercise: @exercise,
uuid_found: codeharbor_check[:uuid_found],
update_right: codeharbor_check[:update_right],
error: codeharbor_check[:error],
exported: false,
}
),
}, status: :ok
end
def export_external_confirm
authorize!
@exercise.uuid = SecureRandom.uuid if @exercise.uuid.nil?
error = ExerciseService::PushExternal.call(
zip: ProformaService::ExportTask.call(exercise: @exercise),
codeharbor_link: current_user.codeharbor_link
)
if error.nil?
render json: {
status: 'success',
message: t('exercises.export_codeharbor.successfully_exported', id: @exercise.id, title: @exercise.title),
actions: render_to_string(partial: 'export_actions',
locals: {exercise: @exercise, exported: true, error:}),
}
@exercise.save
else
render json: {
status: 'fail',
message: t('exercises.export_codeharbor.export_failed', id: @exercise.id, title: @exercise.title, error:),
actions: render_to_string(partial: 'export_actions',
locals: {exercise: @exercise, exported: true, error:}),
}
end
end
def import_uuid_check
user = user_from_api_key
return render json: {}, status: :unauthorized if user.nil?
render json: uuid_check(user:, uuid: params[:uuid].presence)
end
def import_start
zip_file = params[:file]
unless zip_file.is_a?(ActionDispatch::Http::UploadedFile)
return render json: {status: 'failure', message: t('.choose_file_error')}
end
uuid = ProformaService::UuidFromZip.call(zip: zip_file)
exists, updatable = uuid_check(user: current_user, uuid:).values_at(:uuid_found, :update_right)
key = SecureRandom.hex
ActiveStorage::Blob.create_and_upload!(key:, io: zip_file, filename: zip_file.original_filename)
message = if exists && updatable
t('.exercise_exists_and_is_updatable')
elsif exists
t('.exercise_exists_and_is_not_updatable')
else
t('.exercise_is_importable')
end
render json: {
status: 'success',
message:,
actions: render_to_string(partial: 'import_actions',
locals: {exercise: @exercise, imported: false, exists:, updatable:, file_id: key}),
}
rescue ProformaXML::InvalidZip => e
render json: {
status: 'failure',
message: t('.error', message: e.message),
}
end
def import_confirm
ActiveStorage::Blob.find_by(key: params[:file_id]).open do |zip|
exercise = ::ProformaService::Import.call(zip:, user: current_user)
exercise.save!
render json: {
status: 'success',
message: t('.success'),
actions: render_to_string(partial: 'import_actions', locals: {exercise:, imported: true}),
}
end
rescue ProformaXML::ProformaError, ActiveRecord::RecordInvalid => e
render json: {
status: 'failure',
message: t('.error', error: e.message),
actions: '',
}
rescue StandardError => e
Sentry.capture_exception(e)
render json: {
status: 'failure',
message: t('exercises.import_proforma.import_errors.internal_error'),
actions: '',
}
end
def import_task
tempfile = Tempfile.new('codeharbor_import.zip')
tempfile.write request.body.read.force_encoding('UTF-8')
tempfile.rewind
user = user_from_api_key
return render json: {}, status: :unauthorized if user.nil?
ActiveRecord::Base.transaction do
exercise = ::ProformaService::Import.call(zip: tempfile, user:)
exercise.save!
render json: {}, status: :created
end
rescue ProformaXML::ExerciseNotOwned
render json: {}, status: :unauthorized
rescue ProformaXML::ProformaError
render json: t('exercises.import_proforma.import_errors.invalid'), status: :bad_request
rescue StandardError => e
Sentry.capture_exception(e)
render json: t('exercises.import_proforma.import_errors.internal_error'), status: :internal_server_error
end
def download_proforma
zip_file = ProformaService::ExportTask.call(exercise: @exercise)
send_data(zip_file.string, type: 'application/zip', filename: "exercise_#{@exercise.id}.zip", disposition: 'attachment')
rescue ProformaXML::PostGenerateValidationError => e
redirect_to :root, danger: JSON.parse(e.message).join('<br>'), status: :see_other
end
def user_from_api_key
authorization_header = request.headers['Authorization']
api_key = authorization_header&.split&.second
user_by_codeharbor_token(api_key)
end
private :user_from_api_key
def user_by_codeharbor_token(api_key)
link = CodeharborLink.find_by(api_key:)
link&.user
end
private :user_by_codeharbor_token
def exercise_params
@exercise_params ||= if params[:exercise].present?
params[:exercise].permit(
:description,
:execution_environment_id,
:file_id,
:instructions,
:submission_deadline,
:late_submission_deadline,
:public,
:unpublished,
:hide_file_tree,
:allow_file_creation,
:allow_auto_completion,
:title,
:internal_title,
:expected_difficulty,
:tips,
files_attributes: file_attributes,
tag_ids: []
).merge(
user: current_user
)
end
end
private :exercise_params
def exercise_params_with_tags
myparam = exercise_params.presence || {}
checked_exercise_tags = @exercise_tags.select {|et| myparam[:tag_ids]&.include? et.tag.id.to_s }
removed_exercise_tags = @exercise_tags.reject {|et| myparam[:tag_ids]&.include? et.tag.id.to_s }
checked_exercise_tags.each do |et|
et.factor = params[:tag_factors][et.tag_id.to_s][:factor]
et.exercise = @exercise
end
myparam[:exercise_tags] = checked_exercise_tags
myparam.delete :tag_ids
myparam.delete :tips
removed_exercise_tags.map(&:destroy)
myparam
end
private :exercise_params_with_tags
def handle_file_uploads
if exercise_params
exercise_params[:files_attributes].try(:each) do |_index, file_attributes|
if file_attributes[:attachment].respond_to?(:read)
if FileType.find_by(id: file_attributes[:file_type_id]).try(:binary?)
file_attributes[:content] = nil
else
file_attributes[:content] = file_attributes[:attachment].read.detect_encoding!.encode.delete("\x00")
file_attributes[:attachment] = nil
end
end
end
end
end
private :handle_file_uploads
def handle_exercise_tips(tips_params)
return unless tips_params
begin
exercise_tips = JSON.parse(tips_params)
# Order is important to ensure no foreign key restraints are violated during delete
previous_exercise_tips = ExerciseTip.where(exercise: @exercise).select(:id).order(rank: :desc).ids
remaining_exercise_tips = update_exercise_tips exercise_tips, nil, 1
# Destroy initializes each object and then calls a *single* SQL DELETE
ExerciseTip.destroy(previous_exercise_tips - remaining_exercise_tips)
rescue JSON::ParserError => e
flash[:danger] = "JSON error: #{e.message}"
redirect_to edit_exercise_path(@exercise), status: :see_other
end
end
private :handle_exercise_tips
def update_exercise_tips(exercise_tips, parent_exercise_tip_id, rank)
result = []
exercise_tips.each do |exercise_tip|
exercise_tip.symbolize_keys!
current_exercise_tip = ExerciseTip.find_or_initialize_by(id: exercise_tip[:id],
exercise: @exercise,
tip_id: exercise_tip[:tip_id])
current_exercise_tip.parent_exercise_tip_id = parent_exercise_tip_id
current_exercise_tip.rank = rank
rank += 1
unless current_exercise_tip.save
flash[:danger] = current_exercise_tip.errors.full_messages.join('. ')
redirect_to(edit_exercise_path(@exercise), status: :see_other) and break
end
children = update_exercise_tips exercise_tip[:children], current_exercise_tip.id, rank
rank += children.length
result << current_exercise_tip.id
result += children
end
result
end
private :update_exercise_tips
def implement # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
if session[:pg_id] && current_contributor.exercise != @exercise
# we are acting on behalf of a programming group
if current_user.admin?
session.delete(:pg_id)
session.delete(:pair_programming)
@current_contributor = current_user
else
return redirect_back_or_to implement_exercise_path(current_contributor.exercise),
alert: t('exercises.implement.existing_programming_group', exercise: current_contributor.exercise.title),
status: :see_other
end
elsif session[:pg_id].blank? && (pg = current_user.programming_groups.find_by(exercise: @exercise)) && pg.submissions.where(study_group_id: current_user.current_study_group_id).any?
# we are just acting on behalf of a single user who has already worked on this exercise as part of a programming group **in the context of the current study group**
session[:pg_id] = pg.id
@current_contributor = pg
elsif session[:pg_id].blank? && session[:pair_programming] == 'mandatory'
return redirect_back_or_to new_exercise_programming_group_path(@exercise), status: :see_other
elsif session[:pg_id].blank? && session[:pair_programming] == 'optional' && current_user.submissions.where(study_group_id: current_user.current_study_group_id, exercise: @exercise).none?
Event.find_or_create_by(category: 'pp_work_alone', user: current_user, exercise: @exercise, data: nil, file_id: nil)
current_user.pair_programming_waiting_users&.find_by(exercise: @exercise)&.update(status: :worked_alone)
end
user_solved_exercise = @exercise.solved_by?(current_contributor)
count_interventions_today = current_contributor.user_exercise_interventions.where(created_at: Time.zone.now.beginning_of_day..).count
user_got_intervention_in_exercise = current_contributor.user_exercise_interventions.where(exercise: @exercise).size >= max_intervention_count_per_exercise
(user_got_enough_interventions = count_interventions_today >= max_intervention_count_per_day) || user_got_intervention_in_exercise
if @embed_options[:disable_interventions]
@show_rfc_interventions = false
@show_break_interventions = false
@show_tips_interventions = false
else
show_intervention = (!user_solved_exercise && !user_got_enough_interventions).to_s
if @tips.present? && Java21Study.show_tips_intervention?(current_user, @exercise)
@show_tips_interventions = show_intervention
@show_break_interventions = false
@show_rfc_interventions = false
elsif Java21Study.show_break_intervention?(current_user, @exercise)
@show_tips_interventions = false
@show_break_interventions = show_intervention
@show_rfc_interventions = false
else
@show_tips_interventions = false
@show_break_interventions = false
@show_rfc_interventions = show_intervention
end
end
@embed_options[:disable_score] = true unless @exercise.teacher_defined_assessment?
@hide_rfc_button = @embed_options[:disable_rfc]
@submission = current_contributor.submissions.order(created_at: :desc).find_by(exercise: @exercise)
@files = (@submission ? @submission.collect_files : @exercise.files).select(&:visible?).sort_by(&:filepath)
@paths = collect_paths(@files)
end
def set_available_tips
# Order of elements is important and will be kept
available_tips = ExerciseTip.where(exercise: @exercise).order(rank: :asc).includes(:tip)
# Transform result set in a hash and prepare (temporary) children array.
# The children array will contain the sorted list of nested tips,
# shown for learners in the output sidebar with cards.
# Hash - Key: exercise_tip.id, value: exercise_tip Object loaded from database
nested_tips = available_tips.each_with_object({}) do |exercise_tip, hash|
exercise_tip.children = []
hash[exercise_tip.id] = exercise_tip
end
available_tips.each do |tip|
# A tip without a parent cannot be a children
next if tip.parent_exercise_tip_id.blank?
# Link tips if they are related
nested_tips[tip.parent_exercise_tip_id].children << tip
end
# Return an array with top-level tips
@tips = nested_tips.values.select {|tip| tip.parent_exercise_tip_id.nil? }
end
private :set_available_tips
def working_times
working_time_accumulated = @exercise.accumulated_working_time_for_only(current_contributor)
working_time_75_percentile = @exercise.get_quantiles([0.75]).first
render json: {working_time_75_percentile:,
working_time_accumulated:}
end
def intervention
intervention = Intervention.find_by(name: params[:intervention_type])
if intervention.nil?
render json: {success: 'false', error: "undefined intervention #{params[:intervention_type]}"}
else
uei = UserExerciseIntervention.new(
user: current_contributor, exercise: @exercise, intervention:,
accumulated_worktime_s: @exercise.accumulated_working_time_for_only(current_contributor)
)
uei.save
render json: {success: 'true'}
end
end
def edit; end
def create
@exercise = Exercise.new(exercise_params&.except(:tips))
authorize!
collect_set_and_unset_exercise_tags
tips_params = exercise_params&.dig(:tips)
return if performed?
create_and_respond(object: @exercise, params: exercise_params_with_tags) do
# We first need to create the exercise before handling tips
handle_exercise_tips tips_params
# Don't return a specific value from this block, so that the default is used.
nil
end
end
def not_authorized_for_exercise(_exception)
return render_not_authorized unless current_user
return render_not_authorized unless %w[implement working_times intervention reload].include?(action_name)
if current_user.admin? || current_user.teacher?
redirect_to(@exercise, alert: t('exercises.implement.unpublished'), status: :see_other) if @exercise.unpublished?
redirect_to(@exercise, alert: t('exercises.implement.no_files'), status: :see_other) unless @exercise.files.visible.exists?
redirect_to(@exercise, alert: t('exercises.implement.no_execution_environment'), status: :see_other) if @exercise.execution_environment.blank?
else
render_not_authorized
end
end
private :not_authorized_for_exercise
def set_execution_environments
@execution_environments = ExecutionEnvironment.order(:name)
end
private :set_execution_environments
def set_exercise_and_authorize
@exercise = Exercise.includes(:exercise_tips, files: [:file_type]).find(params[:id])
authorize!
end
private :set_exercise_and_authorize
def set_external_user_and_authorize
if params[:external_user_id]
@external_user = ExternalUser.find(params[:external_user_id])
authorize!
end
end
private :set_external_user_and_authorize
def set_file_types
@file_types = FileType.order(:name)
end
private :set_file_types
def collect_set_and_unset_exercise_tags
@tags = policy_scope(Tag)
checked_exercise_tags = @exercise.exercise_tags
checked_tags = checked_exercise_tags.to_set(&:tag)
unchecked_tags = Tag.all.to_set.subtract checked_tags
@exercise_tags = checked_exercise_tags + unchecked_tags.collect do |tag|
ExerciseTag.new(exercise: @exercise, tag:)
end
end
private :collect_set_and_unset_exercise_tags
def update
handle_exercise_tips exercise_params&.dig(:tips)
return if performed?
update_and_respond(object: @exercise, params: exercise_params_with_tags)
end
def reload
# Returns JSON with original file content
end
def statistics
# Show general statistic page for specific exercise
contributor_statistics = {InternalUser => {}, ExternalUser => {}, ProgrammingGroup => {}}
query = SubmissionPolicy::DeadlineScope.new(current_user, Submission).resolve
.select("contributor_id, contributor_type, MAX(score) AS maximum_score, COUNT(id) AS runs, MAX(created_at) FILTER (WHERE cause IN ('submit', 'assess', 'remoteSubmit', 'remoteAssess')) AS created_at, exercise_id")
.where(exercise_id: @exercise.id)
.group('contributor_id, contributor_type, exercise_id')
.includes(:contributor, :exercise)
query.each do |tuple|
contributor_statistics[tuple.contributor_type.constantize][tuple.contributor] = tuple
end
render locals: {
contributor_statistics:,
}
end
def external_user_statistics
# Render statistics page for one specific external user
submissions = SubmissionPolicy::DeadlineScope.new(current_user, Submission).resolve
.where(contributor: @external_user, exercise: @exercise)
.order(submissions: {created_at: :desc})
.includes(:exercise, testruns: [:testrun_messages, {file: [:file_type]}], files: [:file_type])
if policy(@exercise).detailed_statistics?
@show_autosaves = params[:show_autosaves] == 'true' || submissions.where.not(cause: 'autosave').none?
interventions = @external_user.user_exercise_interventions.where(exercise: @exercise)
@all_events = (submissions + interventions).sort_by(&:created_at)
@deltas = @all_events.map.with_index do |item, index|
delta = item.created_at - @all_events[index - 1].created_at if index.positive?
delta.nil? || (delta > StatisticsHelper::WORKING_TIME_DELTA_IN_SECONDS) ? 0 : delta
end
@working_times_until = []
@all_events.each_with_index do |_, index|
@working_times_until.push(format_time_difference(@deltas[0..index].sum))
end
unless @show_autosaves
# IMPORTANT: We always need to query the database for all submissions for the given external user and exercise.
# Otherwise, the working time estimation would be completely off and inaccurate.
# Consequentially, the 'show_autosaves' filter is applied here (not very nice, but it works).
autosave_indices = @all_events.each_index.select {|i| @all_events[i].is_a?(Submission) && @all_events[i].cause == 'autosave' }
# We need to delete from last to first (reverse), since we would otherwise change the indices of the following elements.
autosave_indices.reverse_each do |index|
@all_events.delete_at(index)
@working_times_until.delete_at(index)
# Hacky: If the autosave is the first element after a break, we need to set the delta of the following element to 0.
# Since the @delta array is "broken" for filtered views anyway, we use this hack to get the red "highlight" line right.
# TODO: Refactor the whole method into a more clean solution.
@deltas[index + 1] = 0 if @deltas[index].zero? && @deltas[index + 1].present?
@deltas.delete_at(index)
end
end
else
@all_events = submissions.sort_by(&:created_at)
end
render 'exercises/external_users/statistics'
end
def destroy
destroy_and_respond(object: @exercise)
end
def study_group_dashboard
authorize!
@study_group_id = params[:study_group_id]
@request_for_comments = RequestForComment
.where(exercise: @exercise).includes(:submission)
.where(submissions: {study_group_id: @study_group_id})
.order(created_at: :desc)
@graph_data = @exercise.get_working_times_for_study_group(@study_group_id)
end
private
def uuid_check(user:, uuid:)
return {uuid_found: false} if uuid.blank?
exercise = Exercise.find_by(uuid:)
return {uuid_found: false} if exercise.nil?
return {uuid_found: true, update_right: false} unless ExercisePolicy.new(user, exercise).update?
{uuid_found: true, update_right: true}
end
end