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

MarkUsProject / Markus / 15324676501

29 May 2025 01:13PM UTC coverage: 91.912%. First build
15324676501

Pull #7534

github

web-flow
Merge 32fe45423 into 77ccce7bf
Pull Request #7534: V2.7.1

631 of 1373 branches covered (45.96%)

Branch coverage included in aggregate %.

37 of 38 new or added lines in 4 files covered. (97.37%)

41746 of 44733 relevant lines covered (93.32%)

117.5 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
  responders :flash
1✔
6
  before_action { authorize! }
371✔
7

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

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

20
  # Publicly accessible actions ---------------------------------------
21

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

345
  # Return assignment grade distributions
346
  def grade_distribution
1✔
347
    assignment = record
14✔
348
    assignment_remark_requests = assignment.groupings.joins(current_submission_used: :submitted_remark)
14✔
349
    summary = {
350
      name: "#{assignment.short_identifier}: #{assignment.description}",
14✔
351
      average: assignment.results_average(points: true) || 0,
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
      num_marked_label = t('submissions.how_many_marked',
42✔
378
                           num_marked: assignment.get_num_marked(ta.id),
379
                           num_assigned: assignment.get_num_assigned(ta.id))
380
      { label: "#{ta.display_name} (#{num_marked_label})",
42✔
381
        data: ta.grade_distribution_array(assignment, intervals) }
382
    end
383
    json_data = {
384
      summary: summary,
14✔
385
      grade_distribution: grade_distribution,
386
      ta_data: { labels: ta_labels, datasets: ta_datasets }
387
    }
388
    if params[:get_criteria_data] == 'true'
14✔
389
      criteria_labels = (0..intervals - 1).map { |i| "#{5 * i}-#{5 * i + 5}" }
63✔
390
      criteria_datasets = assignment.criteria.map do |criterion|
3✔
391
        { label: criterion.name,
9✔
392
          data: criterion.grade_distribution_array(intervals),
393
          hidden: true }
394
      end
395
      criteria_summary = assignment.criteria.map do |criterion|
3✔
396
        criterion_grades = criterion.grades_array
9✔
397
        {
398
          name: criterion.name,
9✔
399
          average: criterion.average || 0,
400
          median: criterion.median || 0,
401
          max_mark: criterion.max_mark.to_f || 0,
402
          standard_deviation: criterion.standard_deviation || 0,
403
          position: criterion.position,
404
          num_zeros: criterion_grades.count(&:zero?)
405
        }
406
      end
407
      json_data[:criteria_summary] = criteria_summary
3✔
408
      json_data[:criteria_distributions] = { labels: criteria_labels, datasets: criteria_datasets }
3✔
409
    end
410
    render json: json_data
14✔
411
  end
412

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

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

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

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

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

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

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

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

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

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

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

571
    FileUtils.rm_f(zip_path)
2✔
572

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

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

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

586
    FileUtils.rm_f(zip_path)
53✔
587

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

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

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

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

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

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

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

688
  private
1✔
689

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1021
  def test_run_id_param
1✔
1022
    params[:test_run_id].to_i
375✔
1023
  end
1024
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