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

MarkUsProject / Markus / 18783844054

24 Oct 2025 03:06PM UTC coverage: 91.574% (+0.06%) from 91.516%
18783844054

Pull #7697

github

web-flow
Merge d198c3871 into 22805cd2d
Pull Request #7697: Add scheduled visibility for assessments

787 of 1638 branches covered (48.05%)

Branch coverage included in aggregate %.

193 of 198 new or added lines in 10 files covered. (97.47%)

55 existing lines in 4 files now uncovered.

42751 of 45906 relevant lines covered (93.13%)

121.25 hits per line

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

99.17
/app/models/result.rb
1
class Result < ApplicationRecord
1✔
2
  MARKING_STATES = {
3
    complete: 'complete',
1✔
4
    incomplete: 'incomplete'
5
  }.freeze
6

7
  belongs_to :submission
1✔
8
  has_one :grouping, through: :submission
1✔
9
  has_many :marks, dependent: :destroy
1✔
10
  has_many :extra_marks, dependent: :destroy
1✔
11
  has_many :annotations, dependent: :destroy
1✔
12
  has_many :peer_reviews, dependent: :destroy
1✔
13

14
  has_one :course, through: :submission
1✔
15

16
  has_secure_token :view_token
1✔
17

18
  before_save :check_for_nil_marks
1✔
19
  after_create :create_marks
1✔
20
  validates :marking_state, presence: true
1✔
21
  validates :marking_state, inclusion: { in: MARKING_STATES.values }
1✔
22

23
  validates :released_to_students, inclusion: { in: [true, false] }
1✔
24

25
  before_update :check_for_released
1✔
26

27
  # Release or unrelease the results of a set of groupings.
28
  def self.set_release_on_results(grouping_ids, release)
1✔
29
    groupings = Grouping.where(id: grouping_ids)
13✔
30
    without_submissions = groupings.where.not(id: groupings.joins(:current_submission_used))
13✔
31

32
    if without_submissions.present?
13✔
33
      group_names = without_submissions.joins(:group).pluck(:group_name).join(', ')
1✔
34
      raise StandardError, I18n.t('submissions.errors.no_submission', group_name: group_names)
1✔
35
    end
36

37
    assignment = groupings.first.assignment
12✔
38
    results = assignment.current_results.where('groupings.id': grouping_ids)
12✔
39
    incomplete_results = results.where('results.marking_state': Result::MARKING_STATES[:incomplete])
12✔
40

41
    without_complete_result = groupings.joins(:current_submission_used)
12✔
42
                                       .where('submissions.id': incomplete_results.pluck(:submission_id))
43

44
    if without_complete_result.present?
12✔
45
      group_names = without_complete_result.joins(:group).pluck(:group_name).join(', ')
2✔
46
      if release
2✔
47
        raise StandardError, I18n.t('submissions.errors.not_complete', group_name: group_names)
1✔
48
      else
49
        raise StandardError, I18n.t('submissions.errors.not_complete_unrelease', group_name: group_names)
1✔
50
      end
51
    end
52

53
    result = results.update_all(released_to_students: release)
10✔
54

55
    if release
10✔
56
      groupings.includes(:accepted_students).find_each do |grouping|
9✔
57
        next if grouping.assignment.release_with_urls  # don't email if release_with_urls is true
15✔
58
        grouping.accepted_students.each do |student|
15✔
59
          if student.receives_results_emails?
16✔
60
            NotificationMailer.with(user: student, grouping: grouping).release_email.deliver_later
14✔
61
          end
62
        end
63
      end
64
    end
65

66
    result
10✔
67
  end
68

69
  # Calculate the total mark for this submission
70
  def get_total_mark
1✔
71
    user_visibility = is_a_review? ? :peer_visible : :ta_visible
101✔
72
    Result.get_total_marks([self.id], user_visibility: user_visibility)[self.id]
101✔
73
  end
74

75
  # Return a hash mapping each id in +result_ids+ to the total mark for the result with that id.
76
  def self.get_total_marks(result_ids, user_visibility: :ta_visible, subtotals: nil, extra_marks: nil)
1✔
77
    subtotals ||= Result.get_subtotals(result_ids, user_visibility: user_visibility)
284✔
78
    extra_marks ||= Result.get_total_extra_marks(result_ids, user_visibility: user_visibility, subtotals: subtotals)
284✔
79
    subtotals.map { |r_id, subtotal| [r_id, [0, (subtotal || 0) + (extra_marks[r_id] || 0)].max] }.to_h
874✔
80
  end
81

82
  # The sum of the marks not including bonuses/deductions
83
  def get_subtotal
1✔
84
    if is_a_review?
10✔
85
      user_visibility = :peer_visible
1✔
86
    else
87
      user_visibility = :ta_visible
9✔
88
    end
89
    Result.get_subtotals([self.id], user_visibility: user_visibility)[self.id]
10✔
90
  end
91

92
  def self.get_subtotals(result_ids, user_visibility: :ta_visible, criterion_ids: nil)
1✔
93
    all_marks = Mark.joins(:criterion)
387✔
94
                    .where(result_id: result_ids, "criteria.#{user_visibility}": true)
95
    all_marks = all_marks.where('criteria.id': criterion_ids) if criterion_ids.present?
387✔
96

97
    marks = all_marks.group(:result_id).sum(:mark)
387✔
98
    result_ids.index_with { |r_id| marks[r_id] || 0 }
1,155✔
99
  end
100

101
  # The sum of the bonuses deductions and late penalties for multiple results.
102
  # This returns a hash mapping the result ids from the +result_ids+ argument to
103
  # the sum of all extra marks calculated for that result.
104
  #
105
  # If the +max_mark+ value is nil, its value will be determined dynamically
106
  # based on the max_mark value of the associated assignment.
107
  # However, passing the +max_mark+ value explicitly is more efficient if we are
108
  # repeatedly calling this method where the max_mark doesn't change, such as when
109
  # all the results are associated with the same assignment.
110
  #
111
  # +user_visibility+ is passed to the Assignment.max_mark method to determine the
112
  # max_mark value only if the +max_mark+ argument is nil.
113
  def self.get_total_extra_marks(result_ids, max_mark: nil, user_visibility: :ta_visible, subtotals: nil)
1✔
114
    result_data = Result.joins(:extra_marks, submission: [grouping: :assignment])
372✔
115
                        .where(id: result_ids)
116
                        .pluck(:id, :extra_mark, :unit, 'assessments.id')
117
    subtotals ||= Result.get_subtotals(result_ids, user_visibility: user_visibility)
372✔
118
    extra_marks_hash = Hash.new { |h, k| h[k] = nil }
1,071✔
119
    max_mark_hash = {}
372✔
120
    result_data.each do |id, extra_mark, unit, assessment_id|
372✔
121
      if extra_marks_hash[id].nil?
79✔
122
        extra_marks_hash[id] = 0
73✔
123
      end
124
      if unit == ExtraMark::POINTS
79✔
125
        extra_marks_hash[id] += extra_mark.round(2)
58✔
126
      elsif unit == ExtraMark::PERCENTAGE
21✔
127
        if max_mark
17✔
128
          assignment_max_mark = max_mark
2✔
129
        else
130
          max_mark_hash[assessment_id] ||= Assignment.find(assessment_id)&.max_mark(user_visibility)
15✔
131
          assignment_max_mark = max_mark_hash[assessment_id]
15✔
132
        end
133
        extra_marks_hash[id] += (extra_mark * assignment_max_mark / 100).round(2)
17✔
134
      elsif unit == ExtraMark::PERCENTAGE_OF_MARK
4✔
135
        marks_earned = subtotals[id] || 0
4✔
136
        extra_marks_hash[id] += (extra_mark * marks_earned / 100).round(2)
4✔
137
      end
138
    end
139
    extra_marks_hash
372✔
140
  end
141

142
  def copy_grading_data(old_result)
1✔
143
    return if old_result.blank?
23✔
144

145
    self.marks.destroy_all
23✔
146

147
    self.update(overall_comment: old_result.overall_comment,
23✔
148
                remark_request_submitted_at: old_result.remark_request_submitted_at)
149

150
    old_result.marks.each do |mark|
23✔
151
      mark_dup = mark.dup
59✔
152
      mark_dup.update!(result_id: self.id)
59✔
153
    end
154

155
    old_result.annotations.each do |annotation|
23✔
156
      # annotations are associated with files; if a file for an annotation doesn't exist
157
      # we just skip adding this annotation to the new result
158
      annotation_filename = annotation.submission_file.filename
5✔
159
      annotation_path = annotation.submission_file.path
5✔
160
      new_submission_file = self.submission.submission_files.find_by(filename: annotation_filename,
5✔
161
                                                                     path: annotation_path)
162

163
      next if new_submission_file.nil?
5✔
164

165
      annotation_dup = annotation.dup
4✔
166
      annotation_dup.update!(result_id: self.id, submission_file_id: new_submission_file.id)
4✔
167
    end
168

169
    # NOTE: We are only copying point-based extra marks (which were manually
170
    # added to the old result). Percentage-based extra marks are added at the
171
    # instructor's discretion on newly-collected submissions, and therefore would
172
    # be submission-specific.
173
    old_result.extra_marks.where(unit: 'points').find_each do |extra_mark|
23✔
174
      extra_mark_dup = extra_mark.dup
3✔
175
      extra_mark_dup.update!(result_id: self.id)
3✔
176
    end
177
  end
178

179
  # un-releases the result
180
  def unrelease_results
1✔
UNCOV
181
    self.released_to_students = false
×
UNCOV
182
    self.save
×
183
  end
184

185
  def mark_as_partial
1✔
186
    return if self.released_to_students
3✔
187
    self.marking_state = Result::MARKING_STATES[:incomplete]
3✔
188
    self.save
3✔
189
  end
190

191
  def is_a_review?
1✔
192
    peer_reviews.exists?
10,940✔
193
  end
194

195
  def is_review_for?(user, assignment)
1✔
196
    grouping = user.grouping_for(assignment.id)
36✔
197
    pr = PeerReview.find_by(result_id: self.id)
36✔
198
    !pr.nil? && submission.grouping == grouping
36✔
199
  end
200

201
  def create_marks
1✔
202
    assignment = self.submission.assignment
5,012✔
203
    assignment.ta_criteria.each do |criterion|
5,012✔
204
      criterion.marks.find_or_create_by(result_id: id)
4,314✔
205
    end
206
  end
207

208
  # Returns a hash of all marks for this result.
209
  # TODO: make it include extra marks as well.
210
  def mark_hash
1✔
211
    marks.pluck_to_hash(:criterion_id, :mark, :override).index_by { |x| x[:criterion_id] }
242✔
212
  end
213

214
  def view_token_expired?
1✔
215
    !self.view_token_expiry.nil? && Time.current >= self.view_token_expiry
26✔
216
  end
217

218
  # Generate a PDF report for this result.
219
  # Currently only supports PDF submission file (all other submission files are skipped).
220
  def generate_print_pdf
1✔
221
    marks = self.mark_hash
15✔
222
    extra_marks = self.extra_marks
15✔
223
    total_mark = self.get_total_mark
15✔
224
    overall_comment = self.overall_comment
15✔
225
    submission = self.submission
15✔
226
    grouping = submission.grouping
15✔
227
    assignment = grouping.assignment
15✔
228

229
    # Make folder for temporary files
230
    workdir = "tmp/print/#{self.id}"
15✔
231
    FileUtils.mkdir_p(workdir)
15✔
232

233
    # Constants used for PDF generation
234
    logo_width = 80
15✔
235
    line_space = 12
15✔
236
    annotation_size = 20
15✔
237

238
    # Generate front page
239
    Prawn::Document.generate("#{workdir}/front.pdf") do
15✔
240
      # Add MarkUs logo
241
      image Rails.root.join('app/assets/images/markus_logo_big.png'),
15✔
242
            at: [bounds.width - logo_width, bounds.height],
243
            width: logo_width
244

245
      font_families.update(
15✔
246
        'Open Sans' => {
247
          normal: Rails.root.join('vendor/assets/stylesheets/fonts/OpenSansEmoji.ttf'),
248
          bold: Rails.root.join('vendor/assets/stylesheets/fonts/OpenSans-Bold.ttf')
249
        }
250
      )
251
      font 'Open Sans'
15✔
252

253
      # Title
254
      formatted_text [{
15✔
255
        text: "#{assignment.short_identifier}: #{assignment.description}", size: 20, styles: [:bold]
256
      }]
257
      move_down line_space
15✔
258

259
      # Group members
260
      grouping.accepted_students.includes(:user).find_each do |student|
15✔
261
        text "#{student.user_name} - #{student.first_name} #{student.last_name}"
14✔
262
      end
263
      move_down line_space
15✔
264

265
      # Marks
266
      assignment.ta_criteria.order(:position).find_each do |criterion|
15✔
267
        mark = marks.dig(criterion.id, :mark)
28✔
268
        if criterion.is_a? RubricCriterion
28✔
269
          formatted_text [{ text: "#{criterion.name}:", styles: [:bold] }]
1✔
270
          indent(10) do
1✔
271
            criterion.levels.order(:mark).find_each do |level|
1✔
272
              styles = level.mark == mark ? [:bold] : [:normal]
5✔
273
              formatted_text [{
5✔
274
                text: "• #{level.mark} / #{criterion.max_mark} #{level.name}: #{level.description}",
275
                styles: styles
276
              }]
277
            end
278
          end
279
        else
280
          formatted_text [{
27✔
281
            text: "#{criterion.name}: #{mark || '-'} / #{criterion.max_mark}",
282
            styles: [:bold]
283
          }]
284
          text criterion.description if criterion.description.present?
27✔
285
        end
286
      end
287

288
      extra_marks.each do |extra_mark|
15✔
289
        text "#{extra_mark.description}: #{extra_mark.extra_mark}#{extra_mark.unit == 'percentage' ? '%' : ''}"
1✔
290
      end
291
      move_down line_space
15✔
292

293
      formatted_text [{ text: "#{I18n.t('results.total_mark')}: #{total_mark} / #{assignment.max_mark}",
15✔
294
                        styles: [:bold] }]
295
      move_down line_space
15✔
296

297
      # Annotations and overall comments
298
      formatted_text [{ text: Annotation.model_name.human.pluralize, styles: [:bold] }]
15✔
299
      submission.annotations.order(:annotation_number).includes(:annotation_text).each do |annotation|
15✔
300
        text "#{annotation.annotation_number}. #{annotation.annotation_text.content}"
1✔
301
      end
302
      move_down line_space
15✔
303

304
      formatted_text [{ text: Result.human_attribute_name(:overall_comment), styles: [:bold] }]
15✔
305
      if overall_comment.present?
15✔
306
        text overall_comment
1✔
307
      else
308
        text I18n.t(:not_applicable)
14✔
309
      end
310
    end
311

312
    # Copy all PDF submission files to workspace
313
    input_files = submission.submission_files.where("filename LIKE '%.pdf'").order(:path, :filename)
15✔
314
    grouping.access_repo do |repo|
15✔
315
      input_files.each do |sf|
15✔
316
        contents = sf.retrieve_file(repo: repo)
2✔
317
        FileUtils.mkdir_p(File.join(workdir, sf.path))
2✔
318
        f = File.open(File.join(workdir, sf.path, sf.filename), 'wb')
2✔
319
        f.write(contents)
2✔
320
        f.close
2✔
321
      end
322
    end
323

324
    combined_pdf = CombinePDF.new
15✔
325
    # Simultaneouly do two things:
326
    # 1. Generate combined_pdf, a concatenation of all PDF submission files
327
    # 2. Generate annotations.pdf, a PDF containing only markers for annotations.
328
    #    These will be overlaid onto combined_pdf.
329
    Prawn::Document.generate("#{workdir}/annotations.pdf", skip_page_creation: true) do
15✔
330
      total_num_pages = 0
15✔
331
      input_files.each do |input_file|
15✔
332
        # Process the submission file
333
        input_pdf = CombinePDF.load(File.join(workdir, input_file.path, input_file.filename))
2✔
334
        combined_pdf << input_pdf
2✔
335

336
        num_pages = input_pdf.pages.size
2✔
337
        num_pages.times do
2✔
338
          start_new_page
10✔
339
        end
340

341
        # Create markers for the annotations.
342
        # TODO: remove where clause after investigating how PDF annotations might have a nil page attribute
343
        input_file.annotations.where.not(page: nil).order(:annotation_number).each do |annotation|
2✔
344
          go_to_page(total_num_pages + annotation.page)
1✔
345
          width, height = bounds.width, bounds.height
1✔
346
          x1, y1 = annotation.x1 / 1.0e5 * width, annotation.y1 / 1.0e5 * height
1✔
347

348
          float do
1✔
349
            transparent(0.5) do
1✔
350
              fill_color 'AAAAAA'
1✔
351
              fill_rectangle([x1, height - y1], annotation_size, annotation_size)
1✔
352
            end
353

354
            bounding_box([x1, height - y1], width: annotation_size, height: annotation_size) do
1✔
355
              move_down 5
1✔
356
              text annotation.annotation_number.to_s, color: '000000', align: :center
1✔
357
            end
358
          end
359
        end
360

361
        total_num_pages += num_pages
2✔
362
      end
363
    end
364

365
    # Combine annotations and submission files
366
    annotations_pdf = CombinePDF.load("#{workdir}/annotations.pdf")
15✔
367
    combined_pdf.pages.zip(annotations_pdf.pages) do |combined_page, annotation_page|
15✔
368
      combined_page.fix_rotation  # Fix rotation metadata, useful for scanned pages
10✔
369
      combined_page << annotation_page
10✔
370
    end
371

372
    input_files = submission.submission_files.where("filename LIKE '%.ipynb'").order(:path, :filename)
15✔
373
    grouping.access_repo do |repo|
15✔
374
      input_files.each do |sf|
15✔
375
        contents = sf.retrieve_file(repo: repo)
2✔
376
        tmp_path = File.join(workdir, 'tmp_file.pdf')
2✔
377
        FileUtils.rm_rf(tmp_path)
2✔
378
        args = [
379
          Rails.application.config.python,
2✔
380
          '-m', 'nbconvert',
381
          '--to', 'webpdf',
382
          '--stdin',
383
          '--output', File.join(workdir, File.basename(tmp_path.to_s, '.pdf'))  # Can't include the .pdf extension
384
        ]
385
        _stdout, stderr, status = Open3.capture3(*args, stdin_data: contents)
2✔
386
        if status.success?
2✔
387
          input_pdf = CombinePDF.load(tmp_path)
1✔
388
          combined_pdf << input_pdf
1✔
389
        else
390
          raise stderr
1✔
391
        end
392
      end
393
    end
394

395
    # Finally, insert cover page at the front
396
    combined_pdf >> CombinePDF.load("#{workdir}/front.pdf")
14✔
397

398
    # Delete old files
399
    FileUtils.rm_rf(workdir)
14✔
400
    combined_pdf
14✔
401
  end
402

403
  # Generate a filename to be used for the printed PDF.
404
  # For individual submissions, we use the form "{id_number} - {FAMILY NAME}, {Given Name} ({username}).pdf".
405
  # This is the form requested by the University of Toronto Arts & Science Exams Office (for final exams).
406
  # For group submissions, we use the form "{group name}.pdf"
407
  def print_pdf_filename
1✔
408
    if submission.grouping.accepted_students.size == 1
10✔
409
      student = submission.grouping.accepted_students.first.user
8✔
410
      "#{student.id_number} - #{student.last_name.upcase}, #{student.first_name} (#{student.user_name}).pdf"
8✔
411
    else
412
      members = submission.grouping.accepted_students.includes(:user).map { |s| s.user.user_name }.sort
4✔
413
      if members.empty?
2✔
414
        "#{submission.grouping.group.group_name}.pdf"
1✔
415
      else
416
        "#{submission.grouping.group.group_name} (#{members.join(', ')}).pdf"
1✔
417
      end
418
    end
419
  end
420

421
  private
1✔
422

423
  # Do not allow the marking state to be changed to incomplete if the result is released
424
  def check_for_released
1✔
425
    if released_to_students && marking_state_changed?(to: Result::MARKING_STATES[:incomplete])
10,800✔
426
      errors.add(:base, I18n.t('results.marks_released'))
6✔
427
      throw(:abort)
6✔
428
    end
429
    true
10,794✔
430
  end
431

432
  def check_for_nil_marks(user_visibility = :ta_visible)
1✔
433
    # This check is only required when the marking state is being changed to complete.
434
    return true unless marking_state_changed?(to: Result::MARKING_STATES[:complete])
15,812✔
435

436
    # peer review result is a special case because when saving a pr result
437
    # we can't pass in a parameter to the before_save filter, so we need
438
    # to manually determine the visibility. If it's a pr result, we know we
439
    # want the peer-visible criteria
440
    if is_a_review?
1,797✔
441
      visibility = :peer_visible
224✔
442
      assignment = submission.assignment.pr_assignment
224✔
443
    else
444
      visibility = user_visibility
1,573✔
445
      assignment = submission.assignment
1,573✔
446
    end
447

448
    criteria = assignment.criteria.where(visibility => true).ids
1,797✔
449
    nil_marks = false
1,797✔
450
    num_marks = 0
1,797✔
451
    marks.each do |mark|
1,797✔
452
      if criteria.member? mark.criterion_id
3,477✔
453
        num_marks += 1
3,477✔
454
        if mark.mark.nil?
3,477✔
455
          nil_marks = true
8✔
456
          break
8✔
457
        end
458
      end
459
    end
460

461
    if nil_marks || num_marks < criteria.count
1,797✔
462
      errors.add(:base, I18n.t('results.criterion_incomplete_error'))
10✔
463
      throw(:abort)
10✔
464
    end
465
    true
1,787✔
466
  end
467
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