• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MarkUsProject / Markus / 18383183058

09 Oct 2025 04:57PM UTC coverage: 91.516% (-0.4%) from 91.87%
18383183058

Pull #7689

github

web-flow
Merge 2f8b34e09 into 50143bdfe
Pull Request #7689: ISSUE-7677: Display late penalty selection

781 of 1640 branches covered (47.62%)

Branch coverage included in aggregate %.

20 of 26 new or added lines in 2 files covered. (76.92%)

37 existing lines in 1 file now uncovered.

42494 of 45647 relevant lines covered (93.09%)

119.82 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.07
/app/controllers/assignments_controller.rb
1
class AssignmentsController < ApplicationController
1✔
2
  include RepositoryHelper
1✔
3
  include RoutingHelper
1✔
4
  include AutomatedTestsHelper
1✔
5

6
  responders :flash
1✔
7
  before_action { authorize! }
371✔
8

9
  authorize :test_run_id, through: :test_run_id_param
1✔
10

11
  CONFIG_FILES = {
12
    properties: 'properties.yml',
1✔
13
    tags: 'tags.yml',
14
    criteria: 'criteria.yml',
15
    annotations: 'annotations.yml',
16
    automated_tests_dir_entry: File.join('automated-test-config-files', 'automated-test-files'),
17
    automated_tests: File.join('automated-test-config-files', 'automated-test-specs.json'),
18
    starter_files: File.join('starter-file-config-files', 'starter-file-rules.yml')
19
  }.freeze
20

21
  # Publicly accessible actions ---------------------------------------
22

23
  def show
1✔
24
    assignment = record
6✔
25
    @assignment = assignment.is_peer_review? ? assignment.parent_assignment : assignment
6✔
26
    unless allowed_to?(:see_hidden?)
6✔
27
      render 'shared/http_status',
1✔
28
             formats: [:html],
29
             locals: {
30
               code: '404',
31
               message: HttpStatusHelper::ERROR_CODE['message']['404']
32
             },
33
             status: :not_found,
34
             layout: false
35
      return
1✔
36
    end
37

38
    @grouping = current_role.accepted_grouping_for(@assignment.id)
5✔
39

40
    if @grouping.nil?
5✔
41
      if @assignment.scanned_exam
5✔
42
        flash_now(:notice, t('assignments.scanned_exam.under_review'))
×
43
      elsif @assignment.group_max == 1 && (!@assignment.is_timed ||
5✔
44
                                           Time.current > assignment.section_due_date(current_role&.section))
45
        begin
46
          current_role.create_group_for_working_alone_student(@assignment.id)
2✔
47
        rescue StandardError => e
48
          flash_message(:error, e.message)
2✔
49
          redirect_to controller: :assignments
2✔
50
          return
2✔
51
        end
52
        @grouping = current_role.accepted_grouping_for(@assignment.id)
×
53
      end
54
    end
55
    unless @grouping.nil?
3✔
56
      flash_message(:warning, I18n.t('assignments.starter_file.changed_warning')) if @grouping.starter_file_changed
×
57
      if @assignment.is_timed && !@grouping.start_time.nil? && !@grouping.past_collection_date?
×
58
        flash_message(:note, I18n.t('assignments.timed.started_message_html'))
×
59
        unless @assignment.starter_file_updated_at.nil?
×
60
          flash_message(:note, I18n.t('assignments.timed.starter_file_prompt'))
×
61
        end
62
      elsif @assignment.is_timed && @grouping.start_time.nil? && @grouping.past_collection_date?
×
63
        flash_message(:warning, I18n.t('assignments.timed.past_end_time'))
×
64
      end
65
      set_repo_vars(@assignment, @grouping)
×
66
    end
67
    render layout: 'assignment_content'
3✔
68
  end
69

70
  def peer_review
1✔
71
    assignment = record
×
72
    @assignment = assignment.is_peer_review? ? assignment : assignment.pr_assignment
×
73
    if @assignment.nil? || !allowed_to?(:see_hidden?)
×
74
      render 'shared/http_status',
×
75
             formats: [:html],
76
             locals: {
77
               code: '404',
78
               message: HttpStatusHelper::ERROR_CODE['message']['404']
79
             },
80
             status: :not_found,
81
             layout: false
82
      return
×
83
    end
84

85
    @student = current_role
×
86
    @grouping = current_role.accepted_grouping_for(@assignment.id)
×
87
    @prs = current_role.grouping_for(@assignment.parent_assignment.id)
×
88
                       &.peer_reviews&.where(results: { released_to_students: true })
89
    if @prs.nil?
×
90
      @prs = []
×
91
    end
92

93
    if @assignment.past_all_collection_dates?
×
94
      flash_now(:notice, t('submissions.grading_can_begin'))
×
95
    elsif @assignment.section_due_dates_type
×
96
      section_due_dates = {}
×
97
      now = Time.current
×
98
      current_course.sections.find_each do |section|
×
99
        collection_time = @assignment.submission_rule.calculate_collection_time(section)
×
100
        collection_time = now if now >= collection_time
×
101
        if section_due_dates[collection_time].nil?
×
102
          section_due_dates[collection_time] = []
×
103
        end
104
        section_due_dates[collection_time].push(section.name)
×
105
      end
106
      section_due_dates.each do |collection_time, sections|
×
107
        sections = sections.join(', ')
×
108
        if collection_time == now
×
109
          flash_now(:notice, t('submissions.grading_can_begin_for_sections',
×
110
                               sections: sections))
111
        else
112
          flash_now(:notice, t('submissions.grading_can_begin_after_for_sections',
×
113
                               time: l(collection_time),
114
                               sections: sections))
115
        end
116
      end
117
    else
118
      collection_time = @assignment.submission_rule.calculate_collection_time
×
119
      flash_now(:notice, t('submissions.grading_can_begin_after',
×
120
                           time: l(collection_time)))
121
    end
122
  end
123

124
  # Displays "Manage Assignments" page for creating and editing assignment information.
125
  # Acts as dashboard for students and TAs.
126
  def index
1✔
127
    if current_role.student?
7✔
128
      @a_id_results = {}
3✔
129
      accepted_groupings = current_role.accepted_groupings.includes(:assignment, current_submission_used: :results)
3✔
130
      accepted_groupings.each do |grouping|
3✔
131
        if allowed_to?(:see_hidden?, grouping.assignment) && grouping.has_submission?
6✔
132
          submission = grouping.current_submission_used
5✔
133
          if submission.has_remark? && submission.remark_result.released_to_students
5✔
134
            @a_id_results[grouping.assignment.id] = submission.remark_result
×
135
          elsif submission.has_result? && submission.get_original_result.released_to_students
5✔
136
            @a_id_results[grouping.assignment.id] = submission.get_original_result
×
137
          end
138
        end
139
      end
140

141
      @g_id_entries = {}
3✔
142
      current_role.grade_entry_students.where(released_to_student: true).includes(:grade_entry_form).find_each do |g|
3✔
143
        if allowed_to?(:see_hidden?, g.grade_entry_form)
×
144
          @g_id_entries[g.assessment_id] = g
×
145
        end
146
      end
147

148
      render :student_assignment_list, layout: 'assignment_content'
3✔
149
    else
150
      render :index, layout: 'assignment_content'
4✔
151
    end
152
  end
153

154
  # Called on editing assignments (GET)
155
  def edit
1✔
156
    @assignment = record
2✔
157
    past_date = @assignment.section_names_past_due_date
2✔
158
    @assignments = current_course.assignments
2✔
159
    @sections = current_course.sections
2✔
160

161
    unless @assignment.scanned_exam
2✔
162
      if @assignment.past_collection_date?
2✔
163
        flash_now(:notice, t('assignments.due_date.final_due_date_passed'))
×
164
      elsif past_date.present?
2✔
165
        flash_now(:notice, t('assignments.due_date.past_due_date_notice') + past_date.join(', '))
×
166
      end
167
    end
168

169
    # build assessment_section_properties for each section that doesn't already have one
170
    current_course.sections.find_each do |s|
2✔
171
      unless AssessmentSectionProperties.find_by(assessment_id: @assignment.id, section_id: s.id)
×
172
        @assignment.assessment_section_properties.build(section: s)
×
173
      end
174
    end
175
    @assessment_section_properties = @assignment.assessment_section_properties
2✔
UNCOV
176
                                                .sort_by { |s| s.section.name }
×
177

178
    @lti_deployments = @assignment.course.lti_deployments.includes(:lti_client)
2✔
179
    render :edit, layout: 'assignment_content'
2✔
180
  end
181

182
  # Called when editing assignments form is submitted (PUT).
183
  def update
1✔
184
    @assignment = record
46✔
185
    @assignments = current_course.assignments
46✔
186
    @sections = current_course.sections
46✔
187
    begin
188
      @assignment.transaction do
46✔
189
        @assignment = process_assignment_form(@assignment)
46✔
190
        @assignment.save!
46✔
191
      end
192
    rescue StandardError
193
      # Do nothing
194
    end
195
    respond_with @assignment, location: -> { edit_course_assignment_path(current_course, @assignment) }
92✔
196
  end
197

198
  # Called in order to generate a form for creating a new assignment.
199
  # i.e. GET request on assignments/new
200
  def new
1✔
201
    @assignments = current_course.assignments
24✔
202
    @assignment = @assignments.new
24✔
203
    if params[:scanned].present?
24✔
204
      @assignment.scanned_exam = true
8✔
205
    end
206
    if params[:timed].present?
24✔
207
      @assignment.is_timed = true
8✔
208
    end
209
    if params[:is_peer_review].present?
24✔
UNCOV
210
      @assignment.parent_assignment = @assignments.first
×
211
    end
212
    @clone_assignments = @assignments.joins(:assignment_properties)
24✔
213
                                     .where(assignment_properties: { vcs_submit: true })
214
                                     .order(:id)
215
    @sections = current_course.sections
24✔
216

217
    # build assessment_section_properties for each section
218
    @sections.each { |s| @assignment.assessment_section_properties.build(section: s) }
24✔
219
    @assessment_section_properties = @assignment.assessment_section_properties
24✔
UNCOV
220
                                                .sort_by { |s| s.section.name }
×
221
    @lti_deployments = @assignment.course.lti_deployments.includes(:lti_client)
24✔
222
    render :new, layout: 'assignment_content'
24✔
223
  end
224

225
  # Called after a new assignment form is submitted.
226
  def create
1✔
227
    @assignment = current_course.assignments.new
40✔
228
    @assignment.transaction do
40✔
229
      begin
230
        @assignment = process_assignment_form(@assignment)
40✔
231
        @assignment.token_start_date = Time.current
40✔
232
        @assignment.token_period = 1
40✔
233
      rescue StandardError => e
UNCOV
234
        @assignment.errors.add(:base, e.message)
×
235
      end
236
      @assignment.save!
40✔
237
      if params[:persist_groups_assignment]
36✔
UNCOV
238
        clone_warnings = @assignment.clone_groupings_from(params[:persist_groups_assignment])
×
UNCOV
239
        unless clone_warnings.empty?
×
240
          clone_warnings.each { |w| flash_message(:warning, w) }
×
241
        end
242
      end
243
    rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved
244
      @assignments = current_course.assignments
4✔
245
      @sections = current_course.sections
4✔
246
      @clone_assignments = @assignments.joins(:assignment_properties)
4✔
247
                                       .where(assignment_properties: { vcs_submit: true })
248
                                       .order(:id)
249
      respond_with @assignment, location: -> { new_course_assignment_path(current_course, @assignment) }
4✔
250
    else
251
      respond_with @assignment, location: -> { edit_course_assignment_path(current_course, @assignment) }
72✔
252
    end
253
  end
254

255
  def summary
1✔
256
    @assignment = record
8✔
257
    respond_to do |format|
8✔
258
      format.html { render layout: 'assignment_content' }
10✔
259
      format.json { render json: @assignment.summary_json(current_role) }
12✔
260
      format.csv do
8✔
261
        data = @assignment.summary_csv(current_role)
2✔
262
        filename = "#{@assignment.short_identifier}_summary.csv"
2✔
263
        send_data data,
2✔
264
                  disposition: 'attachment',
265
                  type: 'text/csv',
266
                  filename: filename
267
      end
268
    end
269
  end
270

271
  def download_test_results
1✔
272
    @assignment = record
16✔
273
    respond_to do |format|
16✔
274
      format.json do
16✔
275
        data = @assignment.summary_test_result_json
6✔
276
        filename = "#{@assignment.short_identifier}_test_results.json"
6✔
277
        send_data data,
6✔
278
                  disposition: 'attachment',
279
                  type: 'application/json',
280
                  filename: filename
281
      end
282
      format.csv do
16✔
283
        data = @assignment.summary_test_result_csv
10✔
284
        filename = "#{@assignment.short_identifier}_test_results.csv"
10✔
285
        send_data data,
10✔
286
                  disposition: 'attachment',
287
                  type: 'text/csv',
288
                  filename: filename
289
      end
290
    end
291
  end
292

293
  def stop_test
1✔
294
    test_run_id = test_run_id_param
3✔
295
    assignment_id = params[:id]
3✔
296
    if current_role.student?
3✔
297
      AutotestCancelJob.perform_now(assignment_id, [test_run_id])
1✔
298
    else
299
      @current_job = AutotestCancelJob.perform_later(assignment_id, [test_run_id])
2✔
300
      session[:job_id] = @current_job.job_id
2✔
301
    end
302
    redirect_back(fallback_location: root_path)
3✔
303
  end
304

305
  def stop_batch_tests
1✔
UNCOV
306
    test_runs = TestRun.where(test_batch_id: params[:test_batch_id]).ids
×
UNCOV
307
    assignment_id = params[:id]
×
308
    @current_job = AutotestCancelJob.perform_later(assignment_id, test_runs)
×
309
    session[:job_id] = @current_job.job_id
×
310
    redirect_back(fallback_location: root_path)
×
311
  end
312

313
  def batch_runs
1✔
314
    @assignment = record
2✔
315

316
    respond_to do |format|
2✔
317
      format.html
2✔
318
      format.json do
2✔
UNCOV
319
        role_ids = current_role.instructor? ? current_course.instructors.ids : current_role.id
×
UNCOV
320
        test_runs = TestRun.left_outer_joins(:test_batch, grouping: [:group, :current_result])
×
321
                           .where(test_runs: { role_id: role_ids },
322
                                  'groupings.assessment_id': @assignment.id)
323
                           .pluck_to_hash(:id,
324
                                          :test_batch_id,
325
                                          :grouping_id,
326
                                          :submission_id,
327
                                          'test_batches.created_at',
328
                                          'test_runs.created_at',
329
                                          'test_runs.status',
330
                                          'groups.group_name',
331
                                          'results.id')
UNCOV
332
        test_runs.each do |test_run|
×
UNCOV
333
          created_at_raw = test_run.delete('test_batches.created_at') || test_run.delete('test_runs.created_at')
×
334
          test_run['created_at'] = I18n.l(created_at_raw)
×
335
          test_run['status'] = test_run['test_runs.status']
×
336
          test_run['group_name'] = test_run.delete('groups.group_name')
×
337
          test_run['result_id'] = test_run.delete('results.id')
×
338
        end
339
        render json: test_runs
×
340
      end
341
    end
342
  end
343

344
  # Return assignment grade distributions
345
  def grade_distribution
1✔
346
    assignment = record
14✔
347
    assignment_remark_requests = assignment.groupings.joins(current_submission_used: :submitted_remark)
14✔
348
    summary = {
349
      name: "#{assignment.short_identifier}: #{assignment.description}",
14✔
350
      average: assignment.results_average(points: true) || 0,
351
      average_annotations: assignment.average_annotations,
352
      median: assignment.results_median(points: true) || 0,
353
      max_mark: assignment.max_mark || 0,
354
      standard_deviation: assignment.results_standard_deviation || 0,
355
      num_submissions_collected: assignment.current_submissions_used.size,
356
      num_submissions_graded: assignment.current_submissions_used.size -
357
        assignment.ungraded_submission_results.size,
358
      num_fails: assignment.results_fails,
359
      num_zeros: assignment.results_zeros,
360
      groupings_size: assignment.groupings.size,
361
      num_students_in_group: assignment.groupings.joins(:accepted_students).size,
362
      num_active_students: assignment.course.students.active.size,
363
      remark_requests_enabled: assignment.allow_remarks,
364
      num_remark_requests: assignment_remark_requests.size,
365
      num_remark_requests_completed: assignment_remark_requests.where('results.marking_state': :complete).size
366
    }
367
    intervals = 20
14✔
368
    assignment_labels = (0..(intervals - 1)).map { |i| "#{5 * i}-#{5 * i + 5}" }
294✔
369
    assignment_datasets = [
370
      {
371
        data: assignment.grade_distribution_array
14✔
372
      }
373
    ]
374
    grade_distribution = { labels: assignment_labels, datasets: assignment_datasets }
14✔
375
    ta_labels = (0..(intervals - 1)).map { |i| "#{5 * i}-#{5 * i + 5}" }
294✔
376
    ta_datasets = assignment.tas.map do |ta|
14✔
377
      grade_distribution_arr = ta.grade_distribution_array(assignment, intervals)
42✔
378
      num_marked_label = t('submissions.how_many_marked',
42✔
379
                           num_marked: assignment.get_num_marked(ta.id, bulk: true),
380
                           num_assigned: assignment.get_num_assigned(ta.id, bulk: true))
381
      { label: "#{ta.display_name} (#{num_marked_label})",
42✔
382
        data: grade_distribution_arr }
383
    end
384
    json_data = {
385
      summary: summary,
14✔
386
      grade_distribution: grade_distribution,
387
      ta_data: { labels: ta_labels, datasets: ta_datasets }
388
    }
389
    if params[:get_criteria_data] == 'true'
14✔
390
      criteria_labels = (0..(intervals - 1)).map { |i| "#{5 * i}-#{5 * i + 5}" }
63✔
391
      criteria_datasets = assignment.criteria.map do |criterion|
3✔
392
        { label: criterion.name,
9✔
393
          data: criterion.grade_distribution_array(intervals),
394
          hidden: true }
395
      end
396
      criteria_summary = assignment.criteria.map do |criterion|
3✔
397
        criterion_grades = criterion.grades_array
9✔
398
        {
399
          name: criterion.name,
9✔
400
          average: criterion.average || 0,
401
          median: criterion.median || 0,
402
          max_mark: criterion.max_mark.to_f,
403
          standard_deviation: criterion.standard_deviation || 0,
404
          position: criterion.position,
405
          num_zeros: criterion_grades.count(&:zero?)
406
        }
407
      end
408
      json_data[:criteria_summary] = criteria_summary
3✔
409
      json_data[:criteria_distributions] = { labels: criteria_labels, datasets: criteria_datasets }
3✔
410
    end
411
    render json: json_data
14✔
412
  end
413

414
  def view_summary
1✔
UNCOV
415
    @assignment = record
×
416
  end
417

418
  def starter_file
1✔
419
    @assignment = record
8✔
420
    if @assignment.nil?
8✔
UNCOV
421
      render 'shared/http_status',
×
422
             locals: { code: '404', message: HttpStatusHelper::ERROR_CODE['message']['404'] },
423
             status: :not_found
424
    else
425
      render layout: 'assignment_content'
8✔
426
    end
427
  end
428

429
  def populate_starter_file_manager
1✔
430
    assignment = record
14✔
431
    if assignment.groupings.exists?
14✔
UNCOV
432
      flash_message(:warning,
×
433
                    I18n.t('assignments.starter_file.groupings_exist_warning_html'))
434
    end
435
    file_data = assignment.starter_file_groups.order(:id).map do |g|
14✔
436
      { id: g.id,
10✔
437
        name: g.name,
438
        entry_rename: g.entry_rename,
439
        use_rename: g.use_rename,
440
        files: starter_file_group_file_data(g) }
441
    end
442
    sections = current_course.sections.pluck(:id, :name)
14✔
443
    section_data = current_course.sections
14✔
444
                                 .joins(:starter_file_groups)
445
                                 .where('starter_file_groups.assessment_id': assignment.id)
446
                                 .pluck_to_hash('sections.id as section_id',
447
                                                'sections.name as section_name',
448
                                                'starter_file_groups.id as group_id',
449
                                                'starter_file_groups.name as group_name')
450
    section_data_ids = section_data.pluck(:section_id)
14✔
451
    sections.each do |id, name|
14✔
452
      unless section_data_ids.include? id
4✔
UNCOV
453
        section_data << {
×
454
          section_id: id,
455
          section_name: name,
456
          group_id: nil,
457
          group_name: nil
458
        }
459
      end
460
    end
461
    section_data.sort_by! { |x| x[:section_id] }
18✔
462
    data = { files: file_data,
14✔
463
             sections: section_data,
464
             available_after_due: assignment.starter_files_after_due,
465
             starterfileType: assignment.starter_file_type,
466
             defaultStarterFileGroup: assignment.default_starter_file_group&.id || '' }
467
    render json: data
14✔
468
  end
469

470
  def update_starter_file
1✔
471
    assignment = record
13✔
472
    all_changed = false
13✔
473
    success = true
13✔
474
    ApplicationRecord.transaction do
13✔
475
      assignment.assignment_properties.update!(starter_file_assignment_params)
13✔
476
      all_changed =
477
        assignment.assignment_properties.saved_change_to_starter_file_type? ||
13✔
478
        assignment.assignment_properties.saved_change_to_default_starter_file_group_id?
479
      params[:sections]&.each do |section_params|
13✔
480
        Section.find_by(id: section_params[:section_id])
10✔
481
               &.update_starter_file_group(assignment.id, section_params[:group_id])
482
      end
483
      starter_file_group_params.each do |group_params|
13✔
484
        starter_file_group = assignment.starter_file_groups.find_by(id: group_params[:id])
13✔
485
        starter_file_group.update!(group_params)
13✔
486
        all_changed ||= starter_file_group.saved_changes?
13✔
487
      end
488
      assignment.assignment_properties.update!(starter_file_updated_at: Time.current) if all_changed
13✔
489
    rescue ActiveRecord::RecordInvalid, StandardError => e
UNCOV
490
      flash_message(:error, e.message)
×
UNCOV
491
      success = false
×
492
      raise ActiveRecord::Rollback
×
493
    end
494
    if success
13✔
495
      flash_message(:success, I18n.t('flash.actions.update.success',
13✔
496
                                     resource_name: I18n.t('assignments.starter_file.title')))
497
    end
498
    # mark all groupings with starter files that were changed as changed
499
    assignment.groupings.update_all(starter_file_changed: true) if success && all_changed
13✔
500
  end
501

502
  def download_starter_file_mappings
1✔
503
    assignment = record
4✔
504
    mappings = assignment.starter_file_mappings
4✔
505
    file_out = MarkusCsv.generate(mappings, [mappings.first&.keys].compact, &:values)
4✔
506
    send_data(file_out,
4✔
507
              type: 'text/csv',
508
              filename: "#{assignment.short_identifier}_starter_file_mappings.csv",
509
              disposition: 'inline')
510
  end
511

512
  # Switch to the assignment with id +params[:id]+. Try to redirect to the same page
513
  # as the referer url for the new assignment if possible. Otherwise redirect to a
514
  # default action depending on the type of user:
515
  #   - edit for instructors
516
  #   - summary for TAs
517
  #   - show for students
518
  def switch
1✔
519
    options = referer_options
24✔
520
    if switch_to_same(options)
24✔
521
      redirect_to options
8✔
522
    elsif current_role.instructor?
16✔
523
      redirect_to edit_course_assignment_path(current_course, params[:id])
5✔
524
    elsif current_role.ta?
11✔
525
      redirect_to summary_course_assignment_path(current_course, params[:id])
5✔
526
    else # current_role.student?
527
      redirect_to course_assignment_path(current_course, params[:id])
6✔
528
    end
529
  end
530

531
  def set_boolean_graders_options
1✔
532
    assignment = record
7✔
533
    attributes = graders_options_params
7✔
534
    return head :bad_request if attributes.empty? || attributes[:assignment_properties_attributes].empty?
7✔
535

536
    unless assignment.update(attributes)
6✔
UNCOV
537
      flash_now(:error, assignment.errors.full_messages.join(' '))
×
UNCOV
538
      head :unprocessable_content
×
539
      return
×
540
    end
541
    head :ok
6✔
542
  end
543

544
  # Start timed assignment for the current user's grouping for this assignment
545
  def start_timed_assignment
1✔
546
    assignment = record
4✔
547
    grouping = current_role.accepted_grouping_for(assignment.id)
4✔
548
    if grouping.nil? && assignment.group_max == 1
4✔
549
      begin
UNCOV
550
        current_role.create_group_for_working_alone_student(assignment.id)
×
UNCOV
551
        grouping = current_role.accepted_grouping_for(assignment.id)
×
552
        set_repo_vars(assignment, grouping)
×
553
      rescue StandardError => e
554
        flash_message(:error, e.message)
×
555
      end
556
    end
557
    return head :bad_request if grouping.nil?
4✔
558
    authorize! grouping
4✔
559
    unless grouping.update(start_time: Time.current)
4✔
560
      flash_message(:error, grouping.errors.full_messages.join(' '))
1✔
561
    end
562
    redirect_to action: :show
4✔
563
  end
564

565
  # Download a zip file containing an example of starter files that might be assigned to a grouping
566
  def download_sample_starter_files
1✔
567
    assignment = record
2✔
568

569
    zip_name = "#{assignment.short_identifier}-sample-starter-files.zip"
2✔
570
    zip_path = File.join('tmp', zip_name)
2✔
571

572
    FileUtils.rm_f(zip_path)
2✔
573

574
    Zip::File.open(zip_path, create: true) do |zip_file|
2✔
575
      assignment.sample_starter_file_entries.each { |entry| entry.add_files_to_zip_file(zip_file) }
6✔
576
    end
577
    send_file zip_path, filename: zip_name
2✔
578
  end
579

580
  # Downloads a zip file containing all the information and settings about an assignment
581
  def download_config_files
1✔
582
    assignment = record
53✔
583

584
    zip_name = "#{assignment.short_identifier}-config-files.zip"
53✔
585
    zip_path = File.join('tmp', zip_name)
53✔
586

587
    FileUtils.rm_f(zip_path)
53✔
588

589
    Zip::File.open(zip_path, create: true) do |zipfile|
53✔
590
      zipfile.get_output_stream(CONFIG_FILES[:properties]) do |f|
53✔
591
        f.write(assignment.assignment_properties_config.to_yaml)
53✔
592
      end
593
      zipfile.get_output_stream(CONFIG_FILES[:criteria]) do |f|
53✔
594
        yml_criteria = assignment.criteria.reduce({}) { |a, b| a.merge b.to_yml }
89✔
595
        f.write yml_criteria.to_yaml
53✔
596
      end
597
      zipfile.get_output_stream(CONFIG_FILES[:annotations]) do |f|
53✔
598
        f.write AnnotationCategory.annotation_categories_to_yml(assignment.annotation_categories)
53✔
599
      end
600
      unless assignment.scanned_exam || assignment.is_peer_review?
53✔
601
        assignment.automated_test_config_to_zip(zipfile, CONFIG_FILES[:automated_tests_dir_entry],
36✔
602
                                                CONFIG_FILES[:automated_tests])
603
      end
604
      assignment.starter_file_config_to_zip(zipfile, CONFIG_FILES[:starter_files])
53✔
605
      zipfile.get_output_stream(CONFIG_FILES[:tags]) do |f|
53✔
606
        f.write(assignment.tags.pluck_to_hash(:name, :description).to_yaml)
53✔
607
      end
608
    end
609
    send_file zip_path, filename: zip_name, type: 'application/zip', disposition: 'attachment'
53✔
610
  end
611

612
  # Uploads a zip file containing all the files specified in download_config_files
613
  # and modifies the assignment settings according to those files.
614
  def upload_config_files
1✔
615
    upload_file = params.require(:upload_files_for_config)
43✔
616
    raise I18n.t('upload_errors.blank') if upload_file.size == 0
43✔
617
    raise I18n.t('upload_errors.invalid_file_type', type: 'zip') unless File.extname(upload_file.path).casecmp?('.zip')
43✔
618

619
    Zip::File.open(upload_file.path) do |zipfile|
43✔
620
      ApplicationRecord.transaction do
43✔
621
        # Build assignment from properties
622
        prop_file = zipfile.get_entry(CONFIG_FILES[:properties])
43✔
623
        assignment = build_uploaded_assignment(prop_file)
43✔
624
        tag_prop = build_hash_from_zip(zipfile, :tags)
43✔
625
        criteria_prop = build_hash_from_zip(zipfile, :criteria)
43✔
626
        annotations_prop = build_hash_from_zip(zipfile, :annotations)
43✔
627
        assignment.save!
43✔
628
        Tag.from_yml(tag_prop, current_course, assignment.id, allow_ta_upload: true)
43✔
629
        Criterion.upload_criteria_from_yaml(assignment, criteria_prop)
43✔
630
        AnnotationCategory.upload_annotations_from_yaml(annotations_prop, assignment, current_role)
43✔
631
        config_automated_tests(assignment, zipfile) unless assignment.scanned_exam || assignment.is_peer_review?
43✔
632
        config_starter_files(assignment, zipfile)
43✔
633
        assignment.save!
43✔
634
        redirect_to edit_course_assignment_path(current_course, assignment)
43✔
635
      end
636
    end
637
  rescue StandardError => e
UNCOV
638
    flash_message(:error, e.message)
×
UNCOV
639
    redirect_to course_assignments_path(current_course)
×
640
  end
641

642
  def create_lti_grades
1✔
643
    assessment = record
1✔
644
    lti_deployments = LtiDeployment.where(course: assessment.course, id: params[:lti_deployments])
1✔
645
    @current_job = LtiSyncJob.perform_later(lti_deployments.to_a, assessment,
1✔
646
                                            can_create_users: allowed_to?(:lti_manage?, with: UserPolicy),
647
                                            can_create_roles: allowed_to?(:manage?, with: RolePolicy))
648
    session[:job_id] = @current_job.job_id
1✔
649
    render 'shared/_poll_job'
1✔
650
  end
651

652
  def create_lti_line_items
1✔
UNCOV
653
    @assignment = record
×
UNCOV
654
    @lti_deployments = LtiDeployment.where(course: @current_course)
×
655
    if params.key?(:lti_deployment)
×
656
      items_to_create = params[:lti_deployment].select { |_key, val| val == '1' }.keys.map(&:to_i)
×
657
      if items_to_create.empty?
×
658
        flash_message(:warning, I18n.t('lti.no_platform'))
×
659
      else
660
        @current_job = LtiLineItemJob.perform_later(items_to_create, @assignment)
×
UNCOV
661
        session[:job_id] = @current_job.job_id
×
662
      end
663
    end
UNCOV
664
    respond_with @assignment, location: -> { lti_settings_course_assignment_path(current_course, @assignment) }
×
665
  end
666

667
  def lti_settings
1✔
UNCOV
668
    @assignment = record
×
UNCOV
669
    @lti_deployments = LtiDeployment.where(course: @current_course)
×
670
    render layout: 'assignment_content'
×
671
  end
672

673
  def destroy
1✔
674
    @assignment = record
10✔
675
    begin
676
      @assignment.destroy
10✔
677
      # this fixes the problem of the flash no appearing when you delete an assignment right after creating it
678
      flash.delete(:success)
8✔
679
      respond_with @assignment, location: -> { course_assignments_path(current_course, @assignment) }
16✔
680
    rescue ActiveRecord::DeleteRestrictionError
681
      flash_message(:error, I18n.t('assignments.assignment_has_groupings'))
1✔
682
      redirect_back fallback_location: { action: :edit, id: @assignment.id }
1✔
683
    rescue StandardError => e
684
      flash_message(:error, I18n.t('activerecord.errors.models.assignment_deletion', problem_message: e.message))
1✔
685
      redirect_back fallback_location: { action: :edit, id: @assignment.id }
1✔
686
    end
687
  end
688

689
  private
1✔
690

691
  # Configures the automated test files and settings for an +assignment+ provided in the +zip_file+
692
  def config_automated_tests(assignment, zip_file)
1✔
693
    spec_file = zip_file.get_entry(CONFIG_FILES[:automated_tests])
36✔
694
    spec_content = spec_file.get_input_stream.read.encode(Encoding::UTF_8, 'UTF-8')
36✔
695
    begin
696
      spec_data = JSON.parse(spec_content)
36✔
697
    rescue JSON::ParserError
UNCOV
698
      raise I18n.t('automated_tests.invalid_specs_file')
×
699
    else
700
      update_test_groups_from_specs(assignment, spec_data) unless spec_data.empty?
36✔
701
      test_file_glob_pattern = File.join(CONFIG_FILES[:automated_tests_dir_entry], '**', '*')
36✔
702
      zip_file.glob(test_file_glob_pattern) do |entry|
36✔
703
        zip_file_path = Pathname.new(entry.name)
67✔
704
        filename = zip_file_path.relative_path_from(CONFIG_FILES[:automated_tests_dir_entry])
67✔
705
        file_path = File.join(assignment.autotest_files_dir, filename.to_s)
67✔
706
        if entry.directory?
67✔
707
          FileUtils.mkdir_p(file_path)
9✔
708
        else
709
          FileUtils.mkdir_p(File.dirname(file_path))
58✔
710
          test_file_content = entry.get_input_stream.read
58✔
711
          File.write(file_path, test_file_content, mode: 'wb')
58✔
712
        end
713
      end
714
    end
715
  end
716

717
  # Configures the starter files for an +assignment+ provided in the +zip_file+
718
  def config_starter_files(assignment, zip_file)
1✔
719
    starter_file_settings = build_hash_from_zip(zip_file, :starter_files).symbolize_keys
43✔
720
    starter_group_mappings = {}
43✔
721
    starter_file_settings[:starter_file_groups].each do |group|
43✔
722
      group = group.symbolize_keys
52✔
723
      file_group = StarterFileGroup.create!(name: group[:name],
52✔
724
                                            use_rename: group[:use_rename],
725
                                            entry_rename: group[:entry_rename],
726
                                            assignment: assignment)
727
      starter_group_mappings[group[:directory_name]] = file_group
52✔
728
    end
729
    default_name = starter_file_settings[:default_starter_file_group]
43✔
730
    if !default_name.nil? && starter_group_mappings.key?(default_name)
43✔
731
      assignment.default_starter_file_group_id = starter_group_mappings[default_name].id
20✔
732
    end
733
    zip_starter_dir = File.dirname(CONFIG_FILES[:starter_files])
43✔
734
    starter_file_glob_pattern = File.join(zip_starter_dir, '**', '*')
43✔
735
    zip_file.glob(starter_file_glob_pattern) do |entry|
43✔
736
      next if entry.name == CONFIG_FILES[:starter_files]
179✔
737
      # Set working directory to the location of all the starter file content, then find
738
      # directory for a starter group and add the file found in that directory to group
739
      zip_file_path = Pathname.new(entry.name)
136✔
740
      starter_base_dir = zip_file_path.relative_path_from(zip_starter_dir)
136✔
741
      grouping_dir = starter_base_dir.descend.first.to_s
136✔
742
      starter_file_group = starter_group_mappings[grouping_dir]
136✔
743
      sub_dir, filename = starter_base_dir.relative_path_from(grouping_dir).split
136✔
744
      starter_file_dir_path = File.join(starter_file_group.path, sub_dir.to_s)
136✔
745
      starter_file_name = filename.to_s
136✔
746
      if entry.directory?
136✔
747
        FileUtils.mkdir_p(File.join(starter_file_dir_path, starter_file_name))
32✔
748
      else
749
        FileUtils.mkdir_p(starter_file_dir_path)
104✔
750
        starter_file_content = entry.get_input_stream.read
104✔
751
        File.write(File.join(starter_file_dir_path, starter_file_name), starter_file_content, mode: 'wb')
104✔
752
      end
753
    end
754
    assignment.starter_file_groups.find_each(&:update_entries)
43✔
755
  end
756

757
  # Build the tag/criteria/starter file settings file specified by +hash_to_build+ found in +zip_file+
758
  # Delete the file from the +zip_file+ after loading in the content.
759
  def build_hash_from_zip(zip_file, hash_to_build)
1✔
760
    yaml_file = zip_file.get_entry(CONFIG_FILES[hash_to_build])
172✔
761
    yaml_content = yaml_file.get_input_stream.read.encode(Encoding::UTF_8, 'UTF-8')
172✔
762
    properties = parse_yaml_content(yaml_content)
172✔
763
    if hash_to_build == :tags
172✔
764
      properties.each { |row| row[:user] = current_role.user_name }
138✔
765
    end
766
    properties
172✔
767
  end
768

769
  # Builds an uploaded assignment/peer review assignment from its properties file. A peer review assignment is
770
  # built if and only if the provided properties file contains the +parent_assessment_short_identifier+ attribute
771
  # and an assignment with the same short identifier exists.
772
  # Precondition: prop_file must be a Zip::Entry object
773
  def build_uploaded_assignment(prop_file)
1✔
774
    yaml_content = prop_file.get_input_stream.read.encode(Encoding::UTF_8, 'UTF-8')
43✔
775
    properties = parse_yaml_content(yaml_content).deep_symbolize_keys
43✔
776
    parent_short_id = properties[:parent_assessment_short_identifier]
43✔
777
    properties = filter_nested_attributes(properties, Assignment)
43✔
778
    if parent_short_id.blank?
43✔
779
      assignment = current_course.assignments.new(properties)
36✔
780
    else
781
      # Filter properties not supported by peer review assignments, then build assignment
782
      peer_review_properties = properties.except(:submission_rule_attributes,
7✔
783
                                                 :assignment_files_attributes)
784
      assignment = current_course.assignments.new(peer_review_properties)
7✔
785
      assignment.enable_test = false
7✔
786
      parent_assignment = current_course.assignments.find_by(short_identifier: parent_short_id)
7✔
787
      raise t('peer_reviews.errors.no_source_assignment', source_assignment: parent_short_id) if parent_assignment.nil?
7✔
788
      assignment.parent_assignment = parent_assignment
7✔
789
    end
790
    assignment.repository_folder = assignment.short_identifier
43✔
791
    assignment
43✔
792
  end
793

794
  # Filters assignment properties to remove any properties that do not match the relevant models.
795
  def filter_nested_attributes(attributes, klass)
1✔
796
    class_attributes = attributes.slice(*klass.column_names.map(&:to_sym))
227✔
797
    attributes.keys.filter_map { |a| a.to_s.match(/^(.+)_attributes$/) }.each do |attr_match|
2,430✔
798
      attribute_key = attr_match[0].to_sym
172✔
799
      attribute_value = attributes[attribute_key]
172✔
800
      association_name = attr_match[1]
172✔
801
      associated_klass = klass.reflect_on_association(association_name).klass
172✔
802

803
      if attribute_value.is_a? Hash
172✔
804
        class_attributes[attribute_key] = filter_nested_attributes(attribute_value, associated_klass)
86✔
805
      elsif attribute_value.is_a? Array
86✔
806
        class_attributes[attribute_key] = attribute_value.map { |v| filter_nested_attributes(v, associated_klass) }
184✔
807
      end
808
    end
809
    class_attributes
227✔
810
  end
811

812
  def set_repo_vars(assignment, grouping)
1✔
UNCOV
813
    grouping.access_repo do |repo|
×
UNCOV
814
      @revision = repo.get_revision_by_timestamp(Time.current, assignment.repository_folder)
×
815
      @last_modified_date = @revision&.server_timestamp
×
816
      files = @revision.tree_at_path(assignment.repository_folder, with_attrs: false)
×
817
                       .select do |_, obj|
818
                         obj.is_a?(Repository::RevisionFile) &&
×
819
                           Repository.get_class.internal_file_names.exclude?(obj.name)
820
                       end
UNCOV
821
      @num_submitted_files = files.length
×
UNCOV
822
      missing_assignment_files = grouping.missing_assignment_files(@revision)
×
823
      @num_missing_assignment_files = missing_assignment_files.length
×
824
    end
825
  end
826

827
  def process_assignment_form(assignment)
1✔
828
    short_identifier = assignment_params[:short_identifier]
86✔
829
    # remove potentially invalid periods before updating
830
    unless assignment_params[:assignment_properties_attributes][:scanned_exam] == 'true'
86✔
831
      period_attrs = submission_rule_params['submission_rule_attributes']['periods_attributes']
84✔
832
      periods = period_attrs.to_h.values.map { |h| h[:id].presence }
134✔
833
      assignment.submission_rule.periods.where.not(id: periods).find_each(&:destroy)
84✔
834
    end
835
    assignment.assign_attributes(assignment_params)
86✔
836
    SubmissionRule.where(assignment: assignment).where.not(id: assignment.submission_rule.id).find_each(&:destroy)
86✔
837
    process_timed_duration(assignment) if assignment.is_timed
86✔
838
    assignment.repository_folder = short_identifier
86✔
839

840
    # if there are no assessment section properties, destroy the objects that were created
841
    if ['0', nil].include? params[:assignment][:assignment_properties_attributes][:section_due_dates_type]
86✔
842
      assignment.assessment_section_properties.each(&:destroy)
82✔
843
      assignment.section_due_dates_type = false
82✔
844
      assignment.section_groups_only = false
82✔
845
    else
846
      assignment.section_due_dates_type = true
4✔
847
      assignment.section_groups_only = true
4✔
848
    end
849

850
    if params[:is_group_assignment] == 'true'
86✔
851
      # Is the instructor forming groups?
852
      if assignment_params[:assignment_properties_attributes][:student_form_groups] == '0'
72✔
853
        assignment.student_form_groups = false
68✔
854
      else
855
        assignment.student_form_groups = true
4✔
856
        assignment.group_name_autogenerated = true
4✔
857
      end
858
    else
859
      assignment.student_form_groups = false
14✔
860
      assignment.group_min = 1
14✔
861
      assignment.group_max = 1
14✔
862
    end
863
    if params.key?(:lti_deployment)
86✔
UNCOV
864
      items_to_create = params[:lti_deployment].select { |_key, val| val == '1' }.keys.map(&:to_i)
×
UNCOV
865
      unless items_to_create.empty?
×
866
        @current_job = LtiLineItemJob.perform_later(items_to_create, assignment)
×
867
        session[:job_id] = @current_job.job_id
×
868
      end
869
    end
870
    assignment
86✔
871
  end
872

873
  # Convert the hours and minutes value given in the params to a duration value
874
  # and assign it to the duration attribute of +assignment+.
875
  def process_timed_duration(assignment)
1✔
876
    durs = duration_params['assignment_properties_attributes']['duration']
4✔
877
    assignment.duration = durs['hours'].to_i.hours + durs['minutes'].to_i.minutes
4✔
878
  end
879

880
  def starter_file_group_file_data(starter_file_group)
1✔
881
    starter_file_group.files_and_dirs.map do |file|
10✔
882
      if (starter_file_group.path + file).directory?
18✔
883
        { key: "#{file}/" }
6✔
884
      else
885
        submitted_date = l(File.mtime(starter_file_group.path + file).in_time_zone(current_role.time_zone))
12✔
886
        { key: file, size: 1, submitted_date: submitted_date,
12✔
887
          url: download_file_course_starter_file_group_url(starter_file_group.assignment.course,
888
                                                           starter_file_group,
889
                                                           file_name: file) }
890
      end
891
    end
892
  end
893

894
  def graders_options_params
1✔
895
    params.require(:attribute)
7✔
896
          .permit(assignment_properties_attributes: [
897
            :assign_graders_to_criteria,
898
            :anonymize_groups,
899
            :hide_unassigned_criteria
900
          ])
901
  end
902

903
  def assignment_params
1✔
904
    params.require(:assignment).permit(
330✔
905
      :short_identifier,
906
      :description,
907
      :message,
908
      :due_date,
909
      :is_hidden,
910
      :parent_assessment_id,
911
      assignment_properties_attributes: [
912
        :id,
913
        :allow_web_submits,
914
        :vcs_submit,
915
        :url_submit,
916
        :api_submit,
917
        :display_median_to_students,
918
        :display_grader_names_to_students,
919
        :group_min,
920
        :group_max,
921
        :student_form_groups,
922
        :group_name_autogenerated,
923
        :allow_remarks,
924
        :remark_due_date,
925
        :remark_message,
926
        :section_groups_only,
927
        :enable_test,
928
        :enable_student_tests,
929
        :has_peer_review,
930
        :assign_graders_to_criteria,
931
        :section_groups_only,
932
        :only_required_files,
933
        :section_due_dates_type,
934
        :scanned_exam,
935
        :is_timed,
936
        :start_time,
937
        :release_with_urls
938
      ],
939
      assessment_section_properties_attributes: [
940
        :_destroy,
941
        :id,
942
        :section_id,
943
        :due_date,
944
        :start_time,
945
        :is_hidden
946
      ],
947
      assignment_files_attributes: [
948
        :_destroy,
949
        :id,
950
        :filename
951
      ],
952
      submission_rule_attributes: [
953
        :_destroy,
954
        :id,
955
        :type,
956
        { periods_attributes: [
957
          :id,
958
          :deduction,
959
          :interval,
960
          :hours,
961
          :_destroy
962
        ] }
963
      ]
964
    )
965
  end
966

967
  def duration_params
1✔
968
    params.require(:assignment).permit(
4✔
969
      assignment_properties_attributes: [
970
        duration: [
971
          :hours,
972
          :minutes
973
        ]
974
      ]
975
    )
976
  end
977

978
  def submission_rule_params
1✔
979
    params.require(:assignment)
84✔
980
          .permit(submission_rule_attributes: [
981
            :_destroy,
982
            :id,
983
            :type,
984
            { periods_attributes: [
985
              :id,
986
              :deduction,
987
              :interval,
988
              :hours,
989
              :_destroy
990
            ] }
991
          ])
992
  end
993

994
  def starter_file_assignment_params
1✔
995
    params.require(:assignment).permit(:starter_file_type, :default_starter_file_group_id, :starter_files_after_due)
13✔
996
  end
997

998
  def starter_file_group_params
1✔
999
    params.permit(starter_file_groups: [:id, :name, :entry_rename, :use_rename])
13✔
1000
          .require(:starter_file_groups)
1001
  end
1002

1003
  def flash_interpolation_options
1✔
1004
    { resource_name: @assignment.short_identifier.presence || @assignment.model_name.human,
94✔
1005
      errors: @assignment.errors.full_messages.join('; ') }
1006
  end
1007

1008
  def switch_to_same(options)
1✔
1009
    return false if options[:controller] == 'submissions' && %w[file_manager repo_browser].include?(options[:action])
24✔
1010
    return false if %w[submissions results].include?(options[:controller]) && !options[:id].nil?
23✔
1011

1012
    if options[:controller] == 'assignments'
20✔
1013
      options[:id] = params[:id]
3✔
1014
    elsif options[:assignment_id]
17✔
1015
      options[:assignment_id] = params[:id]
5✔
1016
    else
1017
      return false
12✔
1018
    end
1019
    true
8✔
1020
  end
1021

1022
  def test_run_id_param
1✔
1023
    params[:test_run_id].to_i
375✔
1024
  end
1025
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc