• 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

93.82
/app/models/assignment.rb
1
require 'csv'
1✔
2

3
# Represents an assignment where students submit work to be graded
4
class Assignment < Assessment
1✔
5
  include AutomatedTestsHelper
1✔
6

7
  MIN_PEER_REVIEWS_PER_GROUP = 1
1✔
8

9
  validates :due_date, presence: true
1✔
10

11
  # this has to be before :peer_reviews or it throws a HasManyThroughOrderError
12
  has_many :groupings, foreign_key: :assessment_id, inverse_of: :assignment
1✔
13
  has_many :groups, through: :groupings, dependent: :restrict_with_exception
1✔
14

15
  has_one :assignment_properties,
1✔
16
          dependent: :destroy,
17
          inverse_of: :assignment,
18
          foreign_key: :assessment_id,
19
          autosave: true
20
  delegate_missing_to :assignment_properties
1✔
21
  accepts_nested_attributes_for :assignment_properties, update_only: true
1✔
22
  validates :assignment_properties, presence: true
1✔
23
  after_initialize :create_associations
1✔
24
  before_save :reset_collection_time
1✔
25
  after_create :update_parent_assignment, if: :is_peer_review?
1✔
26

27
  # Add assignment_properties to default scope because we almost always want to load an assignment with its properties
28
  default_scope { includes(:assignment_properties) }
50,919✔
29

30
  has_many :criteria,
1✔
31
           -> { order(:position) },
6,190✔
32
           dependent: :destroy,
33
           inverse_of: :assignment,
34
           foreign_key: :assessment_id
35

36
  has_many :ta_criteria,
1✔
37
           -> { where(ta_visible: true).order(:position) },
8,888✔
38
           class_name: 'Criterion',
39
           dependent: :destroy,
40
           inverse_of: :assignment,
41
           foreign_key: :assessment_id
42

43
  has_many :peer_criteria,
1✔
44
           -> { where(peer_visible: true).order(:position) },
64✔
45
           class_name: 'Criterion',
46
           dependent: :destroy,
47
           inverse_of: :assignment,
48
           foreign_key: :assessment_id
49

50
  has_many :test_groups, dependent: :destroy, inverse_of: :assignment, foreign_key: :assessment_id
1✔
51
  accepts_nested_attributes_for :test_groups, allow_destroy: true, reject_if: ->(attrs) { attrs[:name].blank? }
1✔
52

53
  has_many :annotation_categories,
1✔
54
           -> { order(:position) },
686✔
55
           class_name: 'AnnotationCategory',
56
           dependent: :destroy,
57
           inverse_of: :assignment,
58
           foreign_key: :assessment_id
59

60
  has_many :criterion_ta_associations, dependent: :destroy, foreign_key: :assessment_id, inverse_of: :assignment
1✔
61

62
  has_many :assignment_files, dependent: :destroy, inverse_of: :assignment, foreign_key: :assessment_id
1✔
63
  accepts_nested_attributes_for :assignment_files, allow_destroy: true
1✔
64
  validates_associated :assignment_files
1✔
65

66
  # Assignments can now refer to themselves, where this is null if there
67
  # is no parent (the same holds for the child peer reviews)
68
  belongs_to :parent_assignment,
1✔
69
             class_name: 'Assignment', optional: true, inverse_of: :pr_assignment, foreign_key: :parent_assessment_id
70
  has_one :pr_assignment,
1✔
71
          class_name: 'Assignment',
72
          dependent: :destroy,
73
          foreign_key: :parent_assessment_id,
74
          inverse_of: :parent_assignment
75
  has_many :peer_reviews, through: :groupings
1✔
76
  has_many :pr_peer_reviews, through: :parent_assignment, source: :peer_reviews
1✔
77

78
  has_many :current_submissions_used, through: :groupings,
1✔
79
                                      source: :current_submission_used
80

81
  has_many :ta_memberships, through: :groupings
1✔
82
  has_many :student_memberships, through: :groupings
1✔
83

84
  has_many :submissions, through: :groupings
1✔
85

86
  has_many :notes, as: :noteable, dependent: :destroy
1✔
87

88
  has_many :exam_templates, dependent: :destroy, inverse_of: :assignment, foreign_key: :assessment_id
1✔
89

90
  has_many :starter_file_groups, dependent: :destroy, inverse_of: :assignment, foreign_key: :assessment_id
1✔
91

92
  has_many :tas, -> { distinct }, through: :ta_memberships, source: :role
41✔
93

94
  before_save do
1✔
95
    @prev_assessment_section_property_ids = assessment_section_properties.ids
7,543✔
96
    @prev_assignment_file_ids = assignment_files.ids
7,543✔
97
  end
98

99
  after_create :create_autotest_dirs
1✔
100
  after_save_commit :update_repo_permissions
1✔
101
  after_save_commit :update_repo_required_files
1✔
102

103
  after_save :update_assigned_tokens
1✔
104
  after_save :create_peer_review_assignment_if_not_exist
1✔
105

106
  has_one :submission_rule, dependent: :destroy, inverse_of: :assignment, foreign_key: :assessment_id
1✔
107
  accepts_nested_attributes_for :submission_rule, allow_destroy: true
1✔
108
  validates_associated :submission_rule
1✔
109
  validates :submission_rule, presence: true
1✔
110
  validate :courses_should_match
1✔
111

112
  BLANK_MARK = ''.freeze
1✔
113

114
  # Copy of API::AssignmentController without selected attributes and order changed
115
  # to put first the 4 required fields
116
  DEFAULT_FIELDS = [:short_identifier, :description,
1✔
117
                    :due_date, :message, :group_min, :group_max, :tokens_per_period,
118
                    :allow_web_submits, :student_form_groups, :remark_due_date,
119
                    :remark_message, :assign_graders_to_criteria, :enable_test,
120
                    :enable_student_tests, :allow_remarks,
121
                    :display_grader_names_to_students,
122
                    :display_median_to_students, :group_name_autogenerated,
123
                    :is_hidden, :visible_on, :visible_until, :vcs_submit, :has_peer_review].freeze
124

125
  STARTER_FILES_DIR = (
126
    Settings.file_storage.starter_files || File.join(Settings.file_storage.default_root_path, 'starter_files')
1✔
127
  ).freeze
128

129
  # Set the default order of assignments: in ascending order of date (due_date)
130
  default_scope { order(:due_date, :id) }
50,919✔
131

132
  # Are we past all due_dates and section due_dates for this assignment?
133
  # This does not take extensions into consideration.
134
  def past_all_due_dates?
1✔
135
    # If no section due dates /!\ do not check empty? it could be wrong
136
    return false if !due_date.nil? && Time.current < due_date
8✔
137
    return false if assessment_section_properties.any? { |sec| !sec.due_date.nil? && Time.current < sec.due_date }
5✔
138

139
    true
3✔
140
  end
141

142
  # Return an array with names of sections past
143
  def section_names_past_due_date
1✔
144
    if !self.section_due_dates_type && !due_date.nil? && Time.current > due_date
9✔
145
      return []
1✔
146
    end
147

148
    sections_past = []
8✔
149
    self.assessment_section_properties.each do |d|
8✔
150
      if !d.due_date.nil? && Time.current > d.due_date
8✔
151
        sections_past << d.section.name
4✔
152
      end
153
    end
154

155
    sections_past
8✔
156
  end
157

158
  def upcoming(current_role)
1✔
159
    grouping = current_role.accepted_grouping_for(self.id)
9✔
160
    due_date = grouping&.collection_date
9✔
161
    return !past_collection_date?(current_role.section) if due_date.nil?
9✔
162
    due_date > Time.current
7✔
163
  end
164

165
  # Whether or not this grouping is past its due date for this assignment.
166
  def grouping_past_due_date?(grouping)
1✔
167
    return past_all_due_dates? if grouping.nil?
53✔
168

169
    date = grouping.due_date
49✔
170
    !date.nil? && Time.current > date
49✔
171
  end
172

173
  def section_due_date(section)
1✔
174
    unless section_due_dates_type && section
107✔
175
      return due_date
93✔
176
    end
177

178
    AssessmentSectionProperties.due_date_for(section, self)
14✔
179
  end
180

181
  # Return the start_time for +section+ if it is not nil, otherwise return this
182
  # assignments start_time instead.
183
  def section_start_time(section)
1✔
184
    return start_time unless section_due_dates_type
33✔
185

186
    section&.assessment_section_properties&.find_by(assessment: self)&.start_time || start_time
6✔
187
  end
188

189
  # Calculate the latest due date among all sections for the assignment.
190
  def latest_due_date
1✔
191
    return due_date unless section_due_dates_type
21✔
192
    due_dates = assessment_section_properties.map(&:due_date) << due_date
7✔
193
    due_dates.compact.max
7✔
194
  end
195

196
  # Return collection date for all groupings as a hash mapping grouping_id to collection date.
197
  def all_grouping_collection_dates
1✔
198
    submission_rule_hours = submission_rule.periods.pluck('periods.hours').sum.hours
86✔
199
    no_penalty = Set.new(groupings.joins(:extension).where('extensions.apply_penalty': false).ids)
86✔
200
    collection_dates = Hash.new { |h, k| h[k] = due_date + submission_rule_hours }
276✔
201
    all_grouping_due_dates.each do |grouping_id, grouping_due_date|
86✔
202
      if no_penalty.include? grouping_id
4✔
203
        collection_dates[grouping_id] = grouping_due_date
×
204
      else
205
        collection_dates[grouping_id] = grouping_due_date + submission_rule_hours
4✔
206
      end
207
    end
208
    collection_dates
86✔
209
  end
210

211
  # Return due date for all groupings as a hash mapping grouping_id to due date.
212
  def all_grouping_due_dates
1✔
213
    section_due_dates = groupings.joins(inviter: [section: :assessment_section_properties])
86✔
214
                                 .where('assessment_section_properties.assessment_id': id)
215
                                 .pluck('groupings.id', 'assessment_section_properties.due_date')
216

217
    grouping_extensions = groupings.joins(:extension)
86✔
218
                                   .pluck(:id, :time_delta)
219

220
    due_dates = Hash.new { |h, k| h[k] = due_date }
86✔
221
    section_due_dates.each do |grouping_id, sec_due_date|
86✔
222
      due_dates[grouping_id] = sec_due_date unless sec_due_date.nil?
4✔
223
    end
224
    grouping_extensions.each do |grouping_id, ext|
86✔
225
      due_dates[grouping_id] += ext
×
226
    end
227
    due_dates
86✔
228
  end
229

230
  # checks if the due date for +section+ has passed for this assignment
231
  # or if the main due date has passed if +section+ is nil.
232
  def past_collection_date?(section = nil)
1✔
233
    Time.current > submission_rule.calculate_collection_time(section)
18✔
234
  end
235

236
  def past_all_collection_dates?
1✔
237
    if section_due_dates_type && course.sections.any?
4✔
238
      course.sections.all? do |s|
2✔
239
        past_collection_date? s
4✔
240
      end
241
    else
242
      past_collection_date?
2✔
243
    end
244
  end
245

246
  def past_remark_due_date?
1✔
247
    !remark_due_date.nil? && Time.current > remark_due_date
33✔
248
  end
249

250
  # Return true if this is a group assignment; false otherwise
251
  def group_assignment?
1✔
252
    group_max > 1
6✔
253
  end
254

255
  # Return all released marks for this assignment
256
  def released_marks
1✔
257
    submissions.joins(:results).where(results: { released_to_students: true })
4,956✔
258
  end
259

260
  # Returns the group by the user for this assignment. If pending=true,
261
  # it will return the group that the user has a pending invitation to.
262
  # Returns nil if user does not have a group for this assignment, or if it is
263
  # not a group assignment
264
  def group_by(uid, pending: false)
1✔
265
    return unless group_assignment?
1✔
266

267
    # condition = "memberships.user_id = ?"
268
    # condition += " and memberships.status != 'rejected'"
269
    # add non-pending status clause to condition
270
    # condition += " and memberships.status != 'pending'" unless pending
271
    # groupings.first(include: :memberships, conditions: [condition, uid]) #FIXME: needs schema update
272

273
    # FIXME: needs to be rewritten using a proper query...
274
    Role.find(uid.id).accepted_grouping_for(id)
1✔
275
  end
276

277
  def display_for_note
1✔
278
    short_identifier
2✔
279
  end
280

281
  # Returns the maximum possible mark for a particular assignment as a float
282
  # The sum is converted from a BigDecimal to a float so that when it is passed to the frontend it is not a string
283
  def max_mark(user_visibility = :ta_visible)
1✔
284
    Float(criteria.where(user_visibility => true, bonus: false).sum(:max_mark).round(2))
534✔
285
  end
286

287
  # Returns a boolean indicating whether marking has started for at least
288
  # one submission for this assignment.  Only the most recently collected
289
  # submissions are considered.
290
  def marking_started?
1✔
291
    Result.joins(:marks, submission: :grouping)
28✔
292
          .where(groupings: { assessment_id: id },
293
                 submissions: { submission_version_used: true })
294
          .where.not(marks: { mark: nil })
295
          .any?
296
  end
297

298
  # Returns a list of total marks for each complete result for this assignment.
299
  # There is one mark per grouping (not per student). Does NOT include:
300
  #   - groupings with no submission
301
  #   - incomplete results
302
  #   - original results when a grouping has submitted a remark request that is not complete
303
  def completed_result_marks
1✔
304
    return @completed_result_marks if defined? @completed_result_marks
174✔
305

306
    completed_result_ids = self.current_results.where(marking_state: Result::MARKING_STATES[:complete]).ids
61✔
307
    @completed_result_marks = Result.get_total_marks(completed_result_ids).values.sort
61✔
308
  end
309

310
  def all_grouping_data
1✔
311
    student_data = self.course
12✔
312
                       .students
313
                       .joins(:user)
314
                       .pluck_to_hash(:id, :user_name, :first_name, :last_name, :hidden)
315
    students = student_data.map do |s|
12✔
316
      [s[:user_name], s.merge(_id: s[:id], assigned: false)]
72✔
317
    end.to_h
318
    grouping_data = self
319
                    .groupings
12✔
320
                    .joins(:group)
321
                    .left_outer_joins(:extension)
322
                    .left_outer_joins(non_rejected_student_memberships: [role: :user])
323
                    .left_outer_joins(inviter: :section)
324
                    .pluck_to_hash('groupings.id',
325
                                   'groupings.instructor_approved',
326
                                   'groups.group_name',
327
                                   'users.user_name',
328
                                   'roles.hidden',
329
                                   'memberships.membership_status',
330
                                   'sections.name',
331
                                   'extensions.id',
332
                                   'extensions.time_delta',
333
                                   'extensions.apply_penalty',
334
                                   'extensions.note')
335

336
    members = Hash.new { |h, k| h[k] = [] }
48✔
337
    grouping_data.each do |data|
12✔
338
      if data['users.user_name']
36✔
339
        members[data['groupings.id']] << [data['users.user_name'], data['memberships.membership_status'],
36✔
340
                                          data['roles.hidden']]
341
        students[data['users.user_name']][:assigned] = true
36✔
342
      end
343
    end
344
    ids = Set.new
12✔
345
    groupings = grouping_data.filter_map do |data|
12✔
346
      next if ids.include? data['groupings.id'] # distinct on the query doesn't seem to work
36✔
347

348
      ids << data['groupings.id']
36✔
349
      if data['extensions.time_delta'].nil?
36✔
350
        extension_data = {}
36✔
351
      elsif assignment.is_timed
×
352
        extension_data = AssignmentProperties.duration_parts data['extensions.time_delta']
×
353
      else
354
        extension_data = Extension.to_parts data['extensions.time_delta']
×
355
      end
356
      extension_data[:note] = data['extensions.note'] || ''
36✔
357
      extension_data[:apply_penalty] = data['extensions.apply_penalty'] || false
36✔
358
      extension_data[:id] = data['extensions.id']
36✔
359
      extension_data[:grouping_id] = data['groupings.id']
36✔
360
      {
361
        _id: data['groupings.id'],
36✔
362
        instructor_approved: data['groupings.instructor_approved'],
363
        group_name: data['groups.group_name'],
364
        extension: extension_data,
365
        members: members[data['groupings.id']],
366
        section: data['sections.name'] || ''
367
      }
368
    end
369

370
    {
371
      students: students.values,
12✔
372
      groups: groupings,
373
      exam_templates: assignment.exam_templates
374
    }
375
  end
376

377
  def add_group(new_group_name = nil)
1✔
378
    if group_name_autogenerated
4✔
379
      group = self.course.groups.new
1✔
380
      group.save(validate: false)
1✔
381
      group.group_name = group.get_autogenerated_group_name
1✔
382
      group.save
1✔
383
    else
384
      return if new_group_name.nil?
3✔
385
      if (group = self.course.groups.where(group_name: new_group_name).first)
3✔
386
        unless groupings.where(group_id: group.id).first.nil?
2✔
387
          raise "Group #{new_group_name} already exists"
1✔
388
        end
389
      else
390
        group = Group.create(group_name: new_group_name, course: self.course)
1✔
391
      end
392
    end
393
    Grouping.create(group: group, assignment: self)
3✔
394
  end
395

396
  def add_group_api(new_group_name = nil, members = [])
1✔
397
    members ||= []
32✔
398

399
    Group.transaction do
32✔
400
      if new_group_name.nil?
32✔
401
        if members.length == 1 && self.group_max == 1 && !self.is_timed
21✔
402
          student_user_name = members.first
4✔
403
          group = Group.find_or_initialize_by(group_name: student_user_name, course: self.course) do |g|
4✔
404
            g.repo_name = student_user_name
4✔
405
          end
406
        elsif group_name_autogenerated
17✔
407
          group = course.groups.new
14✔
408
          group.save(validate: false)
14✔
409
          group.group_name = group.get_autogenerated_group_name
14✔
410
        else
411
          raise 'A group name was not provided'
3✔
412
        end
413
      elsif (group = self.course.groups.where(group_name: new_group_name).first)
11✔
414
        unless groupings.where(group_id: group.id).first.nil?
4✔
415
          raise "Group #{new_group_name} already exists"
2✔
416
        end
417
      else
418
        group = Group.create(group_name: new_group_name, course: self.course, repo_name: new_group_name)
7✔
419
      end
420

421
      group.save!
27✔
422

423
      if self.groupings.exists?(group_id: group.id)
27✔
424
        raise "Group '#{group.group_name}' is already part of this assignment"
×
425
      end
426
      grouping = Grouping.create!(group: group, assignment: self)
27✔
427

428
      unless members.empty?
27✔
429
        students = self.course.students
17✔
430
                       .joins(:user)
431
                       .where(users: { user_name: members })
432
                       .pluck(:user_name, :id)
433
                       .to_h
434

435
        members.each_with_index do |user_name, index|
17✔
436
          student_id = students[user_name] || raise("Student #{user_name} not found")
29✔
437

438
          grouping.student_memberships.create!(
29✔
439
            role_id: student_id,
440
            membership_status: StudentMembership::STATUSES[index.zero? ? :inviter : :accepted]
29✔
441
          )
442
        end
443
      end
444
      group
27✔
445
    end
446
  rescue ActiveRecord::RecordInvalid => e
447
    raise "Operation failed: #{e.record.errors.full_messages.join(', ')}"
×
448
  end
449

450
  # Clones the Groupings from the assignment with id assessment_id
451
  # into self.  Destroys any previously existing Groupings associated
452
  # with this Assignment
453
  def clone_groupings_from(assessment_id)
1✔
454
    warnings = []
7✔
455
    original_assignment = Assignment.find(assessment_id)
7✔
456
    self.transaction do
7✔
457
      self.group_min = original_assignment.group_min
7✔
458
      self.group_max = original_assignment.group_max
7✔
459
      self.student_form_groups = original_assignment.student_form_groups
7✔
460
      self.group_name_autogenerated = original_assignment.group_name_autogenerated
7✔
461
      self.groupings.destroy_all
7✔
462
      self.assignment_properties.save
7✔
463
      self.save
7✔
464
      self.reload
7✔
465
      original_assignment.groupings.each do |g|
7✔
466
        active_student_memberships = g.accepted_student_memberships.reject { |m| m.role.hidden }
42✔
467
        if active_student_memberships.empty?
11✔
468
          warnings << I18n.t('groups.clone_warning.no_active_students', group: g.group.group_name)
1✔
469
          next
1✔
470
        end
471
        active_ta_memberships = g.ta_memberships.reject { |m| m.role.hidden }
15✔
472
        grouping = Grouping.new
10✔
473
        grouping.group_id = g.group_id
10✔
474
        grouping.assessment_id = self.id
10✔
475
        grouping.instructor_approved = g.instructor_approved
10✔
476
        unless grouping.save
10✔
477
          warnings << I18n.t('groups.clone_warning.other',
×
478
                             group: g.group.group_name, error: grouping.errors.messages)
479
          next
×
480
        end
481
        all_memberships = active_student_memberships + active_ta_memberships
10✔
482
        Repository.get_class.update_permissions_after(only_on_request: true) do
10✔
483
          all_memberships.each do |m|
9✔
484
            membership = Membership.new
29✔
485
            membership.role_id = m.role_id
29✔
486
            membership.type = m.type
29✔
487
            membership.membership_status = m.membership_status
29✔
488
            unless grouping.memberships << membership
29✔
489
              grouping.memberships.delete(membership)
×
490
              warnings << I18n.t('groups.clone_warning.no_member',
×
491
                                 member: m.role.user_name,
492
                                 group: g.group.group_name, error: membership.errors.messages)
493
            end
494
          end
495
        end
496
      end
497
    end
498

499
    warnings
7✔
500
  end
501

502
  def grouped_students
1✔
503
    student_memberships.map(&:role)
5✔
504
  end
505

506
  def ungrouped_students
1✔
507
    course.students.where(hidden: false) - grouped_students
2✔
508
  end
509

510
  def valid_groupings
1✔
511
    groupings.includes(student_memberships: :role).select(&:is_valid?)
13✔
512
  end
513

514
  def invalid_groupings
1✔
515
    groupings - valid_groupings
4✔
516
  end
517

518
  def assigned_groupings
1✔
519
    groupings.joins(:ta_memberships).includes(ta_memberships: :role).uniq
8✔
520
  end
521

522
  def unassigned_groupings
1✔
523
    groupings - assigned_groupings
4✔
524
  end
525

526
  # Get a list of repo checkout client commands to be used for scripting
527
  def get_repo_checkout_commands(ssh_url: false)
1✔
528
    self.groupings.includes(:group, :current_submission_used).filter_map do |grouping|
4✔
529
      submission = grouping.current_submission_used
10✔
530
      next if submission&.revision_identifier.nil?
10✔
531
      url = ssh_url ? grouping.group.repository_ssh_access_url : grouping.group.repository_external_access_url
5✔
532
      Repository.get_class.get_checkout_command(url,
5✔
533
                                                submission.revision_identifier,
534
                                                grouping.group.group_name, repository_folder)
535
    end
536
  end
537

538
  # Get a list of group_name, repo-url pairs
539
  def get_repo_list(ssh: false)
1✔
540
    CSV.generate do |csv|
3✔
541
      self.groupings.includes(:group).find_each do |grouping|
3✔
542
        group = grouping.group
3✔
543
        data = [group.group_name, group.repository_external_access_url]
3✔
544
        data << group.repository_ssh_access_url if ssh
3✔
545
        csv << data
3✔
546
      end
547
    end
548
  end
549

550
  # Generate JSON summary of grades for this assignment
551
  # for the current user. The user should be an instructor or TA.
552
  def summary_json(user)
1✔
553
    return {} unless user.instructor? || user.ta?
28✔
554
    lti_deployments = []
27✔
555

556
    if user.instructor?
27✔
557
      groupings = self.groupings
13✔
558
      graders = groupings.joins(tas: :user)
13✔
559
                         .pluck_to_hash(:id, 'users.user_name', 'users.first_name', 'users.last_name')
560
                         .group_by { |x| x[:id] }
30✔
561
      assigned_criteria = nil
13✔
562
      lti_deployments = LtiLineItem.where(assessment_id: self.id)
13✔
563
                                   .joins(lti_deployment: :lti_client)
564
                                   .pluck_to_hash('lti_deployments.id',
565
                                                  'lti_clients.host',
566
                                                  'lti_deployments.lms_course_name')
567
      lti_deployments.each { |deployment| deployment.transform_keys! { |key| key.to_s.split('.')[-1] } }
13✔
568
    else
569
      groupings = self.groupings
14✔
570
                      .joins(:memberships)
571
                      .where('memberships.role_id': user.id)
572
      graders = {}
14✔
573
      if self.assign_graders_to_criteria
14✔
574
        assigned_criteria = user.criterion_ta_associations
×
575
                                .where(assessment_id: self.id)
576
                                .pluck(:criterion_id)
577
      else
578
        assigned_criteria = nil
14✔
579
      end
580
    end
581
    grouping_data = groupings.joins(:group)
27✔
582
                             .left_outer_joins(inviter: :section)
583
                             .pluck_to_hash(:id, 'groups.group_name', 'sections.name')
584
                             .group_by { |x| x[:id] }
63✔
585
    members = Grouping.joins(accepted_students: :user)
27✔
586
                      .where(id: groupings)
587
                      .pluck_to_hash(:id, 'users.user_name', 'users.first_name', 'users.last_name', 'roles.hidden')
588
                      .group_by { |x| x[:id] }
63✔
589
    tag_data = groupings
27✔
590
               .joins(:tags)
591
               .pluck_to_hash(:id, 'tags.name')
592
               .group_by { |h| h[:id] }
6✔
593

594
    collection_dates = all_grouping_collection_dates
27✔
595
    all_results = current_results.where('groupings.id': groupings.ids).order(:id)
27✔
596
    results_data = all_results.pluck('groupings.id').zip(all_results.includes(:marks)).to_h
27✔
597
    result_ids = all_results.ids
27✔
598
    extra_marks_hash = Result.get_total_extra_marks(result_ids, max_mark: max_mark)
27✔
599

600
    hide_unassigned = user.ta? && hide_unassigned_criteria
27✔
601

602
    criteria_shown = Set.new
27✔
603
    max_mark = 0
27✔
604

605
    selected_criteria = user.instructor? ? self.criteria : self.ta_criteria
27✔
606
    criteria_columns = selected_criteria.filter_map do |crit|
27✔
607
      unassigned = !assigned_criteria.nil? && assigned_criteria.exclude?(crit.id)
36✔
608
      next if hide_unassigned && unassigned
36✔
609

610
      max_mark += crit.max_mark unless crit.bonus?
36✔
611
      accessor = crit.id
36✔
612
      criteria_shown << accessor
36✔
613
      {
614
        Header: crit.bonus? ? "#{crit.name} (#{Criterion.human_attribute_name(:bonus)})" : crit.name,
72✔
615
        accessor: "criteria.#{accessor}",
616
        className: unassigned ? 'number unassigned' : 'number',
36✔
617
        headerClassName: unassigned ? 'unassigned' : ''
36✔
618
      }
619
    end
620

621
    final_data = groupings.map do |g|
27✔
622
      result = results_data[g.id]
63✔
623
      has_remark = !result&.remark_request_submitted_at.nil?
63✔
624
      if user.ta? && anonymize_groups
63✔
625
        group_name = "#{Group.model_name.human} #{g.id}"
×
626
        section = ''
×
627
        group_members = []
×
628
      else
629
        group_name = grouping_data[g.id][0]['groups.group_name']
63✔
630
        section = grouping_data[g.id][0]['sections.name']
63✔
631
        group_members = members.fetch(g.id, [])
63✔
632
                               .map do |s|
633
          [s['users.user_name'], s['users.first_name'], s['users.last_name'], s['roles.hidden']]
63✔
634
        end
635
      end
636

637
      tag_info = tag_data.fetch(g.id, [])
63✔
638
                         .pluck('tags.name')
639
      criteria = result.nil? ? {} : result.mark_hash.slice(*criteria_shown)
63✔
640
      criteria.transform_values! { |data| data[:mark] }
153✔
641
      extra_mark = extra_marks_hash[result&.id]
63✔
642
      {
643
        group_name: group_name,
63✔
644
        section: section,
645
        members: group_members,
646
        tags: tag_info,
647
        graders: graders.fetch(g.id, [])
648
                        .map { |s| [s['users.user_name'], s['users.first_name'], s['users.last_name']] },
30✔
649
        marking_state: marking_state(has_remark,
650
                                     result&.marking_state,
651
                                     result&.released_to_students,
652
                                     collection_dates[g.id]),
653
        final_grade: [criteria.values.compact.sum + (extra_mark || 0), 0].max,
63✔
654
        criteria: criteria,
655
        max_mark: max_mark,
656
        result_id: result&.id,
657
        submission_id: result&.submission_id,
658
        total_extra_marks: extra_mark
659
      }
660
    end
661

662
    { data: final_data,
27✔
663
      criteriaColumns: criteria_columns,
664
      numAssigned: self.get_num_assigned(user.instructor? ? nil : user.id),
27✔
665
      numMarked: self.get_num_marked(user.instructor? ? nil : user.id),
27✔
666
      enableTest: self.enable_test,
667
      ltiDeployments: lti_deployments }
668
  end
669

670
  # Generates the summary of the most test results associated with an assignment.
671
  def summary_test_results
1✔
672
    latest_test_run_by_grouping = TestRun.group('grouping_id').select('MAX(created_at) as test_runs_created_at',
21✔
673
                                                                      'grouping_id')
674
                                         .where.not(submission_id: nil)
675
                                         .to_sql
676

677
    latest_test_runs = TestRun
21✔
678
                       .joins(grouping: :group)
679
                       .joins("INNER JOIN (#{latest_test_run_by_grouping}) latest_test_run_by_grouping \
680
            ON latest_test_run_by_grouping.grouping_id = test_runs.grouping_id \
681
            AND latest_test_run_by_grouping.test_runs_created_at = test_runs.created_at")
682
                       .select('id', 'test_runs.grouping_id', 'groups.group_name')
683
                       .to_sql
684

685
    self.test_groups.joins(test_group_results: :test_results)
21✔
686
        .joins("INNER JOIN (#{latest_test_runs}) latest_test_runs \
687
              ON test_group_results.test_run_id = latest_test_runs.id")
688
        .select('test_groups.name',
689
                'test_groups.id as test_groups_id',
690
                'latest_test_runs.group_name',
691
                'test_results.name as test_result_name',
692
                'test_results.status',
693
                'test_results.marks_earned',
694
                'test_results.marks_total',
695
                :output, :extra_info, :error_type)
696
  end
697

698
  # Generate a JSON summary of the most recent test results associated with an assignment.
699
  def summary_test_result_json
1✔
700
    self.summary_test_results.group_by(&:group_name).transform_values do |grouping|
9✔
701
      grouping.group_by(&:name)
27✔
702
    end.to_json
703
  end
704

705
  # Generate a CSV summary of the most recent test results associated with an assignment.
706
  def summary_test_result_csv
1✔
707
    results = {}
10✔
708
    headers = Set.new
10✔
709
    summary_test_results = self.summary_test_results.as_json
10✔
710

711
    summary_test_results.each do |test_result|
10✔
712
      header = "#{test_result['name']}:#{test_result['test_result_name']}"
42✔
713

714
      if results.key?(test_result['group_name'])
42✔
715
        results[test_result['group_name']][header] = test_result['status']
12✔
716
      else
717
        results[test_result['group_name']] = { header => test_result['status'] }
30✔
718
      end
719

720
      headers << header
42✔
721
    end
722
    headers = headers.sort
10✔
723

724
    CSV.generate do |csv|
10✔
725
      csv << [nil, *headers]
10✔
726

727
      results.sort_by(&:first).each do |(group_name, _test_group)|
10✔
728
        row = [group_name]
30✔
729

730
        headers.each do |header|
30✔
731
          if results[group_name].key?(header)
90✔
732
            row << results[group_name][header]
42✔
733
          else
734
            row << nil
48✔
735
          end
736
        end
737
        csv << row
30✔
738
      end
739
    end
740
  end
741

742
  # Generate CSV summary of grades for this assignment
743
  # for the current user. The user should be an instructor or TA.
744
  def summary_csv(role)
1✔
745
    return '' unless role.instructor?
10✔
746

747
    if role.instructor?
7✔
748
      groupings = self.groupings
7✔
749
                      .includes(:group,
750
                                current_result: :marks)
751
    else
752
      groupings = self.groupings
×
753
                      .includes(:group,
754
                                current_result: :marks)
755
                      .joins(:memberships)
756
                      .where('memberships.role_id': role.id)
757
    end
758

759
    students = Student.includes(:accepted_groupings, :section)
7✔
760
                      .where('accepted_groupings.assessment_id': self.id)
761
                      .joins(:user)
762
                      .order('users.user_name')
763

764
    first_row = [Group.human_attribute_name(:group_name)] +
7✔
765
      Student::CSV_ORDER.map { |field| User.human_attribute_name(field) } +
42✔
766
      [I18n.t('results.total_mark')]
767

768
    second_row = [' '] * Student::CSV_ORDER.length + [Assessment.human_attribute_name(:max_mark), self.max_mark]
7✔
769

770
    headers = [first_row, second_row]
7✔
771

772
    self.ta_criteria.each do |crit|
7✔
773
      headers[0] << (crit.bonus? ? "#{crit.name} (#{Criterion.human_attribute_name(:bonus)})" : crit.name)
21✔
774
      headers[1] << crit.max_mark
21✔
775
    end
776
    headers[0] << 'Bonus/Deductions'
7✔
777
    headers[1] << ''
7✔
778

779
    result_ids = groupings.pluck('results.id').uniq.compact
7✔
780
    subtotals = Result.get_subtotals(result_ids)
7✔
781
    extra_marks_hash = Result.get_total_extra_marks(result_ids, max_mark: max_mark, subtotals: subtotals)
7✔
782
    total_marks_hash = Result.get_total_marks(result_ids, subtotals: subtotals, extra_marks: extra_marks_hash)
7✔
783
    CSV.generate do |csv|
7✔
784
      csv << headers[0]
7✔
785
      csv << headers[1]
7✔
786

787
      students.each do |student|
7✔
788
        # filtered to keep only the groupings for this assignment when defining students above
789
        g = student.accepted_groupings.first
18✔
790
        result = g&.current_result
18✔
791
        marks = result.nil? ? {} : result.mark_hash
18✔
792
        other_info = Student::CSV_ORDER.map { |field| student.public_send(field) }
126✔
793
        row = [g&.group&.group_name] + other_info
18✔
794
        if result.nil?
18✔
UNCOV
795
          row += Array.new(2 + self.ta_criteria.count, nil)
×
796
        else
797
          row << total_marks_hash[result.id]
18✔
798
          row += self.ta_criteria.map { |crit| marks[crit.id]&.[](:mark) }
81✔
799
          row << extra_marks_hash[result.id]
18✔
800
        end
801
        csv << row
18✔
802
      end
803
    end
804
  end
805

806
  # Returns an array of [mark, max_mark].
807
  def get_marks_list(submission)
1✔
808
    criteria.map do |criterion|
×
809
      mark = submission.get_latest_result.marks.find_by(criterion: criterion)
×
UNCOV
810
      [mark.nil? || mark.mark.nil? ? '' : mark.mark,
×
811
       criterion.max_mark]
812
    end
813
  end
814

815
  def next_criterion_position
1✔
816
    # We're using count here because this fires off a DB query, thus
817
    # grabbing the most up-to-date count of the criteria.
818
    criteria.exists? ? criteria.last.position + 1 : 1
40✔
819
  end
820

821
  # Returns all the submissions that have not been graded (completed).
822
  # Note: This assumes that every submission has at least one result.
823
  def ungraded_submission_results
1✔
824
    current_results.where('results.marking_state': Result::MARKING_STATES[:incomplete])
18✔
825
  end
826

827
  def is_criteria_mark?(ta_id)
1✔
828
    assign_graders_to_criteria && self.criterion_ta_associations.where(ta_id: ta_id).any?
32✔
829
  end
830

831
  def get_num_assigned(ta_id = nil, bulk: false)
1✔
832
    if ta_id.nil?
69✔
833
      groupings.size
13✔
834
    elsif bulk
56✔
835
      cache_ta_results.dig(ta_id, :total_results)&.size || 0
42✔
836
    else
837
      ta_memberships.where(role_id: ta_id).size
14✔
838
    end
839
  end
840

841
  def get_num_collected(ta_id = nil)
1✔
842
    if ta_id.nil?
54✔
843
      groupings.where(is_collected: true).count
44✔
844
    else
845
      groupings.joins(:ta_memberships)
10✔
846
               .where('groupings.is_collected': true)
847
               .where('memberships.role_id': ta_id).count
848
    end
849
  end
850

851
  def get_num_valid
1✔
852
    groupings.includes(:non_rejected_student_memberships, current_submission_used: :submitted_remark)
18✔
853
             .to_a
854
             .count(&:is_valid?)
855
  end
856

857
  def marked_result_ids_for(ta_id)
1✔
858
    cache_ta_results.dig(ta_id, :marked_result_ids) || []
51✔
859
  end
860

861
  def cache_ta_results
1✔
862
    return @cache_ta_results if defined? @cache_ta_results
147✔
863

864
    data = current_results.joins(grouping: :tas).pluck('tas.id', 'results.id', 'results.marking_state')
26✔
865
    # Group results by TA ID
866
    grouped_data = data.group_by { |ta_id, _result_id, _marking_state| ta_id }
110✔
867
    # map ta_ids to criteria_ids
868
    ta_to_criteria = self.criterion_ta_associations
26✔
869
                         .pluck([:ta_id, :criterion_id])
870
                         .group_by { |ta_id, _| ta_id }
7✔
871
                         .transform_values { |pairs| pairs.map { |_, criterion_id| criterion_id } }
12✔
872

873
    @cache_ta_results = grouped_data.map do |ta_id, results|
26✔
874
      total_results = results.map { |_, result_id, _| result_id }
141✔
875

876
      if self.assign_graders_to_criteria
57✔
877
        # Get the list of criteria IDs assigned to this TA
878
        assigned_criteria_ids = ta_to_criteria[ta_id] || []
5✔
879
        criteria_count = assigned_criteria_ids.size
5✔
880

881
        if criteria_count == 0
5✔
882
          # If the TA has no assigned criteria, fallback to using marking_state to determine marked results
883
          complete_results = results.select do |_, _, marking_state|
3✔
884
            marking_state == Result::MARKING_STATES[:complete]
6✔
885
          end
886
          marked_result_ids = complete_results.map { |_, result_id, _| result_id }
5✔
887
        else
888
          # Count results where all assigned criteria have been marked
889
          marked_result_ids = Result.joins(:marks)
2✔
890
                                    .where(id: total_results, marks: { criterion_id: assigned_criteria_ids })
891
                                    .where.not(marks: { mark: nil })
892
                                    .group('results.id')
893
                                    .having('count(marks.id) = ?', criteria_count)
894
                                    .pluck('results.id')
895
        end
896
      else
897
        # Grading not by criterion: count only completed results
898
        marked_result_ids = results.select { |_, _, marking_state| marking_state == Result::MARKING_STATES[:complete] }
126✔
899
                                   .map { |_, result_id, _| result_id }
70✔
900
      end
901
      [ta_id, { total_results: results, marked_result_ids: marked_result_ids }]
57✔
902
    end.to_h
903
  end
904

905
  def get_num_marked(ta_id = nil, bulk: false)
1✔
906
    if bulk
122✔
907
      return cache_ta_results.dig(ta_id, :marked_result_ids)&.size || 0
48✔
908
    end
909
    if ta_id.nil?
74✔
910
      self.current_results.where(marking_state: Result::MARKING_STATES[:complete]).count
42✔
911
    elsif is_criteria_mark?(ta_id)
32✔
912
      assigned_criteria = self.criteria.joins(:criterion_ta_associations)
8✔
913
                              .where(criterion_ta_associations: { ta_id: ta_id })
914

915
      self.current_results.joins(:marks, grouping: :ta_memberships)
8✔
916
          .where('memberships.role_id': ta_id, 'marks.criterion_id': assigned_criteria.ids)
917
          .where.not('marks.mark': nil)
918
          .group('results.id')
919
          .having('count(*) = ?', assigned_criteria.count)
920
          .length
921
    else
922
      self.current_results.joins(grouping: :ta_memberships)
24✔
923
          .where('memberships.role_id': ta_id, 'results.marking_state': 'complete')
924
          .count
925
    end
926
  end
927

928
  def get_num_annotations(ta_id = nil)
1✔
929
    if ta_id.nil?
15✔
930
      num_annotations_all
15✔
931
    else
932
      # uniq is required since entries are doubled if there is a remark request
UNCOV
933
      Submission.joins(:annotations, :current_result, grouping: :ta_memberships)
×
934
                .where(submissions: { submission_version_used: true },
935
                       memberships: { role_id: ta_id },
936
                       results: { marking_state: Result::MARKING_STATES[:complete] },
937
                       groupings: { assessment_id: self.id })
938
                .select('annotations.id').uniq.size
939
    end
940
  end
941

942
  # Count annotations on all results for this assignment, including remark requests
943
  def num_annotations_all
1✔
944
    groupings = Grouping.arel_table
15✔
945
    submissions = Submission.arel_table
15✔
946
    subs = Submission.joins(:grouping)
15✔
947
                     .where(groupings[:assessment_id].eq(id)
948
                     .and(submissions[:submission_version_used].eq(true)))
949

950
    res = Result.where(submission_id: subs.pluck(:id), remark_request_submitted_at: nil)
15✔
951
    filtered_subs = subs.where(id: res.pluck(:submission_id))
15✔
952
    Annotation.joins(:submission_file)
15✔
953
              .where(submission_files:
954
                  { submission_id: filtered_subs.pluck(:id) }).size
955
  end
956

957
  def average_annotations(ta_id = nil)
1✔
958
    num_marked = get_num_marked(ta_id)
15✔
959
    avg = 0
15✔
960
    if num_marked != 0
15✔
961
      num_annotations = get_num_annotations(ta_id)
15✔
962
      avg = num_annotations.to_f / num_marked
15✔
963
    end
964
    avg.round(2)
15✔
965
  end
966

967
  # Returns the groupings of this assignment associated with the given section
968
  def section_groupings(section)
1✔
969
    groupings.select do |grouping|
×
UNCOV
970
      grouping.section.id == section.id
×
971
    end
972
  end
973

974
  def has_a_collected_submission?
1✔
UNCOV
975
    submissions.exists?(submission_version_used: true)
×
976
  end
977

978
  # Returns the groupings of this assignment that have no associated section
979
  def sectionless_groupings
1✔
980
    groupings.select do |grouping|
×
UNCOV
981
      grouping.inviter.present? &&
×
982
          !grouping.inviter.has_section?
983
    end
984
  end
985

986
  # Query for all current results for this assignment
987
  def current_results
1✔
988
    # The timestamps of all current results. This duplicates #non_pr_results,
989
    # except it renames the groupings table to avoid a name conflict with the second query below.
990
    subquery = Result.joins('INNER JOIN submissions AS _submissions ON results.submission_id = _submissions.id ' \
474✔
991
                            'INNER JOIN groupings AS _groupings ON _submissions.grouping_id = _groupings.id')
992
                     .where('_groupings.assessment_id': id, '_submissions.submission_version_used': true)
993
                     .where.missing(:peer_reviews)
994
                     .group('_groupings.id')
995
                     .select('_groupings.id AS grouping_id', 'MAX(results.created_at) AS results_created_at').to_sql
996

997
    Result.joins(:grouping)
474✔
998
          .joins("INNER JOIN (#{subquery}) sub ON groupings.id = sub.grouping_id AND " \
999
                 'results.created_at = sub.results_created_at')
1000
  end
1001

1002
  def current_remark_results
1✔
UNCOV
1003
    self.current_results.where.not('results.remark_request_submitted_at' => nil)
×
1004
  end
1005

1006
  # Query for all non-peer review results for this assignment (for the current submissions)
1007
  def non_pr_results
1✔
1008
    Result.joins(:grouping)
43✔
1009
          .where('groupings.assessment_id': id, 'submissions.submission_version_used': true)
1010
          .where.missing(:peer_reviews)
1011
  end
1012

1013
  # Returns true if this is a peer review, meaning it has a parent assignment,
1014
  # false otherwise.
1015
  def is_peer_review?
1✔
1016
    !parent_assessment_id.nil?
6,692✔
1017
  end
1018

1019
  # Returns true if this is a parent assignment that has a child peer review
1020
  # assignment.
1021
  def has_peer_review_assignment?
1✔
1022
    !pr_assignment.nil?
41✔
1023
  end
1024

1025
  def create_peer_review_assignment_if_not_exist
1✔
1026
    return unless self.has_peer_review && Assignment.where(parent_assessment_id: self.id).empty?
7,543✔
1027
    peerreview_assignment = Assignment.new
236✔
1028
    peerreview_assignment.parent_assignment = self
236✔
1029
    peerreview_assignment.course = self.course
236✔
1030
    peerreview_assignment.token_period = 1
236✔
1031
    peerreview_assignment.non_regenerating_tokens = false
236✔
1032
    peerreview_assignment.unlimited_tokens = false
236✔
1033
    peerreview_assignment.short_identifier = short_identifier + '_pr'
236✔
1034
    peerreview_assignment.repository_folder = peerreview_assignment.short_identifier
236✔
1035
    peerreview_assignment.description = description
236✔
1036
    peerreview_assignment.due_date = due_date
236✔
1037
    peerreview_assignment.is_hidden = true
236✔
1038
    peerreview_assignment.message = message
236✔
1039

1040
    # We do not want to have the database in an inconsistent state, so we
1041
    # need to have the database rollback the 'has_peer_review' column to
1042
    # be false
1043
    return if peerreview_assignment.save
236✔
UNCOV
1044
    raise ActiveRecord::Rollback
×
1045
  end
1046

1047
  ### REPO ###
1048

1049
  def starter_file_path
1✔
1050
    File.join(STARTER_FILES_DIR, self.id.to_s)
4,518✔
1051
  end
1052

1053
  def default_starter_file_group
1✔
1054
    default = starter_file_groups.find_by(id: self.default_starter_file_group_id)
6,208✔
1055
    default.nil? ? starter_file_groups.order(:id).first : default
6,208✔
1056
  end
1057

1058
  def starter_file_mappings
1✔
1059
    groupings.joins(:group, grouping_starter_file_entries: [starter_file_entry: :starter_file_group])
5✔
1060
             .pluck_to_hash('groups.group_name as group_name',
1061
                            'starter_file_groups.name as starter_file_group_name',
1062
                            'starter_file_entries.path as starter_file_entry_path')
1063
  end
1064

1065
  def sample_starter_file_entries
1✔
1066
    case self.starter_file_type
2✔
1067
    when 'simple'
1068
      default_starter_file_group&.starter_file_entries || []
2✔
1069
    when 'sections'
1070
      section = Section.find_by(id: self.course.students.distinct.pluck(:section_id).sample)
×
1071
      sf_group = section&.starter_file_group_for(self) || default_starter_file_group
×
UNCOV
1072
      sf_group&.starter_file_entries || []
×
1073
    when 'shuffle'
1074
      self.starter_file_groups.includes(:starter_file_entries).filter_map do |g|
×
UNCOV
1075
        StarterFileEntry.find_by(id: g.starter_file_entries.ids.sample)
×
1076
      end
1077
    when 'group'
UNCOV
1078
      StarterFileGroup.find_by(id: self.starter_file_groups.ids.sample)&.starter_file_entries || []
×
1079
    else
UNCOV
1080
      raise "starter_file_type is invalid: #{self.starter_file_type}"
×
1081
    end
1082
  end
1083

1084
  # Yield an open repo for each grouping of this assignment, then yield again for each repo that raised an exception, to
1085
  # try to mitigate concurrent accesses to those repos.
1086
  def each_group_repo(&block)
1✔
1087
    failed_groupings = []
3✔
1088
    self.groupings.each do |grouping|
3✔
1089
      grouping.access_repo(&block)
9✔
1090
    rescue StandardError
1091
      # in the event of a concurrent repo modification, retry later
UNCOV
1092
      failed_groupings << grouping
×
1093
    end
1094
    failed_groupings.each do |grouping|
3✔
UNCOV
1095
      grouping.access_repo(&block)
×
1096
    rescue StandardError
1097
      # give up
1098
    end
1099
  end
1100

1101
  ### /REPO ###
1102

1103
  def autotest_path
1✔
1104
    File.join(TestRun::SETTINGS_FILES_DIR, self.id.to_s)
13,006✔
1105
  end
1106

1107
  def autotest_files_dir
1✔
1108
    File.join(autotest_path, TestRun::FILES_DIR)
6,881✔
1109
  end
1110

1111
  def autotest_files
1✔
1112
    files_dir = Pathname.new autotest_files_dir
110✔
1113
    return [] unless Dir.exist? files_dir
110✔
1114

1115
    Dir.glob("#{files_dir}/**/*", File::FNM_DOTMATCH).filter_map do |f|
103✔
1116
      unless %w[.. .].include?(File.basename(f))
289✔
1117
        Pathname.new(f).relative_path_from(files_dir).to_s
186✔
1118
      end
1119
    end
1120
  end
1121

1122
  def scanned_exams_path
1✔
1123
    dir = Settings.file_storage.scanned_exams || File.join(Settings.file_storage.default_root_path, 'scanned_exams')
145✔
1124
    Rails.root.join(File.join(dir, self.id.to_s))
145✔
1125
  end
1126

1127
  # Retrieve current grader data.
1128
  def current_grader_data
1✔
1129
    ta_counts = self.criterion_ta_associations.group(:ta_id).count
8✔
1130
    grader_data = self.groupings
8✔
1131
                      .joins(tas: :user)
1132
                      .group('user_name')
1133
                      .count
1134
    graders = self.course.tas.joins(:user)
8✔
1135
                  .pluck(:user_name, :first_name, :last_name, 'roles.id',
1136
                         'roles.hidden').map do |user_name, first_name, last_name, id, hidden|
1137
      {
1138
        user_name: user_name,
4✔
1139
        first_name: first_name,
1140
        last_name: last_name,
1141
        groups: grader_data[user_name] || 0,
1142
        _id: id,
1143
        criteria: ta_counts[id] || 0,
1144
        hidden: hidden
1145
      }
1146
    end
1147

1148
    group_data = self.groupings
8✔
1149
                     .left_outer_joins(:group, tas: :user)
1150
                     .pluck('groupings.id', 'groups.group_name', 'users.user_name', 'roles.hidden',
1151
                            'groupings.criteria_coverage_count')
1152
    groups = Hash.new { |h, k| h[k] = [] }
18✔
1153
    group_data.each do |group_id, group_name, ta, hidden, count|
8✔
1154
      groups[[group_id, group_name, count]]
10✔
1155
      groups[[group_id, group_name, count]] << { grader: ta, hidden: hidden } unless ta.nil?
10✔
1156
    end
1157
    group_sections = self.groupings.left_outer_joins(:section).pluck('groupings.id', 'sections.id').to_h
8✔
1158
    groups = groups.map do |k, v|
8✔
1159
      {
1160
        _id: k[0],
10✔
1161
        group_name: k[1],
1162
        criteria_coverage_count: k[2],
1163
        section: group_sections[k[0]],
1164
        graders: v
1165
      }
1166
    end
1167

1168
    criterion_data =
1169
      self.criteria.left_outer_joins(tas: :user)
8✔
1170
          .pluck('criteria.name', 'criteria.position',
1171
                 'criteria.assigned_groups_count', 'users.user_name', 'roles.hidden')
1172
    criteria = Hash.new { |h, k| h[k] = [] }
8✔
1173
    criterion_data.sort_by { |c| c[3] || '' }.each do |name, pos, count, ta, hidden|
8✔
1174
      criteria[[name, pos, count]]
×
UNCOV
1175
      criteria[[name, pos, count]] << { grader: ta, hidden: hidden } unless ta.nil?
×
1176
    end
1177
    criteria = criteria.map do |k, v|
8✔
1178
      {
UNCOV
1179
        name: k[0],
×
1180
        _id: k[1], # NOTE: _id is the *position* of the criterion
1181
        coverage: k[2],
1182
        graders: v
1183
      }
1184
    end
1185

1186
    result = {
1187
      groups: groups,
8✔
1188
      criteria: criteria,
1189
      graders: graders,
1190
      assign_graders_to_criteria: self.assign_graders_to_criteria,
1191
      anonymize_groups: self.anonymize_groups,
1192
      hide_unassigned_criteria: self.hide_unassigned_criteria,
1193
      sections: assignment.course.sections.pluck(:id, :name).to_h
1194
    }
1195

1196
    members_data = assignment.groupings.joins(student_memberships: { role: :user })
8✔
1197
                             .pluck('groupings.id', 'users.user_name', 'memberships.membership_status', 'roles.hidden')
1198

1199
    grouped_data = members_data.group_by { |x| x[0] }
18✔
1200
    grouped_data.each_value { |a| a.each { |b| b.delete_at(0) } }
27✔
1201

1202
    result[:groups].each do |group|
8✔
1203
      group[:members] = grouped_data[group[:_id]] || []
10✔
1204
    end
1205

1206
    result
8✔
1207
  end
1208

1209
  # Retrieve data for submissions table.
1210
  # Uses joins and pluck rather than includes to improve query speed.
1211
  def current_submission_data(current_role)
1✔
1212
    if current_role.instructor?
44✔
1213
      groupings = self.groupings
33✔
1214
    elsif current_role.ta?
11✔
1215
      groupings = self.groupings.where(id: self.groupings.joins(:ta_memberships)
10✔
1216
                                                         .where('memberships.role_id': current_role.id)
1217
                                                         .select(:'groupings.id'))
1218
    else
1219
      return []
1✔
1220
    end
1221

1222
    data = groupings
43✔
1223
           .left_outer_joins(:group, :current_submission_used)
1224
           .pluck('groupings.id',
1225
                  'groups.group_name',
1226
                  'submissions.revision_timestamp',
1227
                  'submissions.is_empty',
1228
                  'groupings.start_time')
1229

1230
    tag_data = groupings
43✔
1231
               .joins(:tags)
1232
               .pluck_to_hash('groupings.id', 'tags.name')
1233
               .group_by { |h| h['groupings.id'] }
3✔
1234

1235
    if self.submission_rule.is_a? GracePeriodSubmissionRule
43✔
1236
      deductions = groupings
3✔
1237
                   .joins(:grace_period_deductions)
1238
                   .group('groupings.id')
1239
                   .maximum('grace_period_deductions.deduction')
1240
    else
1241
      deductions = {}
40✔
1242
    end
1243

1244
    # All results for the currently-used submissions, including both remark and original results
1245
    result_data = self.non_pr_results.joins(:grouping)
43✔
1246
                      .order('results.created_at DESC')
1247
                      .pluck_to_hash('groupings.id',
1248
                                     'results.id',
1249
                                     'results.marking_state',
1250
                                     'results.released_to_students',
1251
                                     'results.view_token',
1252
                                     'results.view_token_expiry')
1253
                      .group_by { |h| h['groupings.id'] }
40✔
1254

1255
    if current_role.ta? && anonymize_groups
43✔
1256
      member_data = {}
×
UNCOV
1257
      section_data = {}
×
1258
    else
1259
      member_data = groupings.joins(accepted_students: :user)
43✔
1260
                             .pluck_to_hash('groupings.id', 'users.user_name', 'roles.hidden')
1261
                             .group_by { |h| h['groupings.id'] }
110✔
1262

1263
      section_data = groupings.joins(inviter: :section)
43✔
1264
                              .pluck('groupings.id', 'sections.name')
1265
                              .to_h
1266
    end
1267

1268
    if current_role.ta? && hide_unassigned_criteria
43✔
1269
      assigned_criteria = current_role.criterion_ta_associations
1✔
1270
                                      .where(assessment_id: self.id)
1271
                                      .pluck(:criterion_id)
1272
    else
1273
      assigned_criteria = nil
42✔
1274
    end
1275

1276
    visible_criteria = current_role.instructor? ? self.criteria : self.ta_criteria
43✔
1277
    criteria = visible_criteria.reject do |crit|
43✔
1278
      !assigned_criteria.nil? && assigned_criteria.exclude?(crit.id)
7✔
1279
    end
1280

1281
    result_ids = result_data.values.flat_map { |arr| arr.pluck('results.id') }
75✔
1282

1283
    total_marks = Mark.where(criterion: criteria, result_id: result_ids)
43✔
1284
                      .pluck(:result_id, :mark)
1285
                      .group_by(&:first)
1286
                      .transform_values { |arr| arr.filter_map(&:second).sum }
4✔
1287

1288
    # The sum is converted from a BigDecimal to a float so that when it is passed to the frontend it is not a string
1289
    max_mark = Float(criteria.filter_map { |c| c.bonus ? nil : c.max_mark }.sum.round(2))
50✔
1290
    extra_marks_hash = Result.get_total_extra_marks(result_ids, max_mark: max_mark)
43✔
1291

1292
    collection_dates = all_grouping_collection_dates
43✔
1293

1294
    data_collections = [tag_data, result_data, member_data, section_data, collection_dates]
43✔
1295

1296
    # This is the submission data that's actually returned
1297
    data.map do |grouping_id, group_name, revision_timestamp, is_empty, start_time|
43✔
1298
      tag_info, result_info, member_info, section_info, collection_date = data_collections.pluck(grouping_id)
114✔
1299
      has_remark = result_info&.count&.> 1
114✔
1300
      result_info = result_info&.first || {}
114✔
1301

1302
      base = {
1303
        _id: grouping_id, # Needed for checkbox version of react-table
114✔
1304
        max_mark: max_mark,
1305
        group_name: current_role.ta? && anonymize_groups ? "#{Group.model_name.human} #{grouping_id}" : group_name,
114✔
1306
        tags: (tag_info.nil? ? [] : tag_info.pluck('tags.name')),
114✔
1307
        marking_state: marking_state(has_remark,
1308
                                     result_info['results.marking_state'],
1309
                                     result_info['results.released_to_students'],
1310
                                     collection_date)
1311
      }
1312

1313
      base[:start_time] = I18n.l(start_time) if self.is_timed && !start_time.nil?
114✔
1314

1315
      unless is_empty || revision_timestamp.nil?
114✔
1316
        # TODO: for some reason, this is not automatically converted to our timezone by the query
1317
        base[:submission_time] = I18n.l(revision_timestamp.in_time_zone)
26✔
1318
      end
1319

1320
      if result_info['results.id'].present?
114✔
1321
        extra_mark = extra_marks_hash[result_info['results.id']] || 0
27✔
1322
        base[:result_id] = result_info['results.id']
27✔
1323
        base[:final_grade] = [0, (total_marks[result_info['results.id']] || 0.0) + extra_mark].max
27✔
1324
        if self.release_with_urls
27✔
1325
          base[:result_view_token] = result_info['results.view_token']
2✔
1326
          token_expiry = result_info['results.view_token_expiry']
2✔
1327
          base[:result_view_token_expiry] = token_expiry.nil? ? nil : I18n.l(token_expiry.in_time_zone)
2✔
1328
        end
1329
      end
1330

1331
      base[:members] = member_info.nil? ? [] : member_info.pluck('users.user_name', 'roles.hidden')
114✔
1332
      base[:section] = section_info unless section_info.nil?
114✔
1333
      base[:grace_credits_used] = deductions[grouping_id] if self.submission_rule.is_a? GracePeriodSubmissionRule
114✔
1334

1335
      base
114✔
1336
    end
1337
  end
1338

1339
  def to_xml(options = {})
1✔
1340
    attributes_hash = self.assignment_properties.attributes.merge(self.attributes).symbolize_keys
63✔
1341
    attributes_hash.slice(*Api::AssignmentsController::DEFAULT_FIELDS).to_xml(options)
63✔
1342
  end
1343

1344
  def to_json(options = {})
1✔
1345
    self.assignment_properties.attributes.merge(self.attributes).symbolize_keys.to_json(options)
117✔
1346
  end
1347

1348
  # Returns an assignment's relevant properties for uploading/downloading an assignment's configuration as a hash
1349
  def assignment_properties_config
1✔
1350
    # Data to avoid including
1351
    exclude = %w[id created_at updated_at repository_folder has_peer_review]
53✔
1352
    should_reject = ->(attr) { attr.end_with?('_id', '_created_at', '_updated_at') }
3,180✔
1353
    # Helper lambda functions for filtering attributes
1354
    filter_attr = ->(attributes) { attributes.except(*exclude).reject { |attr| should_reject.call(attr) } }
2,332✔
1355
    filter_table = ->(data, model) do
53✔
1356
      data.pluck_to_hash(*(model.column_names - exclude).reject { |attr| should_reject.call(attr) })
477✔
1357
    end
1358
    # Build properties
1359
    properties = self.attributes.except(*exclude).reject { |attr| should_reject.call(attr) || attr == 'type' }
636✔
1360
    properties['parent_assessment_short_identifier'] = self.parent_assignment.short_identifier if self.is_peer_review?
53✔
1361
    properties['assignment_properties_attributes'] = filter_attr.call(self.assignment_properties.attributes)
53✔
1362
    properties['assignment_files_attributes'] = filter_table.call(self.assignment_files, AssignmentFile)
53✔
1363
    properties['submission_rule_attributes'] = filter_attr.call(self.submission_rule.attributes)
53✔
1364
    properties['submission_rule_attributes']['periods_attributes'] = filter_table.call(self.submission_rule.periods,
53✔
1365
                                                                                       Period)
1366
    properties
53✔
1367
  end
1368

1369
  # Writes this assignment's starter file settings to the file located at +settings_filepath+ located in the
1370
  # +zip_file+. Also writes the starter files for this assignment in the same directory as +settings_filepath+.
1371
  def starter_file_config_to_zip(zip_file, settings_filepath)
1✔
1372
    default_starter_group = nil
53✔
1373
    group_data = []
53✔
1374
    directory_path = File.dirname(settings_filepath)
53✔
1375
    self.starter_file_groups.each do |starter_file_group|
53✔
1376
      group_name = ActiveStorage::Filename.new(starter_file_group.name).sanitized
52✔
1377
      starter_file_group.write_starter_files_to_zip(zip_file, File.join(directory_path, group_name))
52✔
1378
      if starter_file_group.id == self.default_starter_file_group_id
52✔
UNCOV
1379
        default_starter_group = group_name
×
1380
      end
1381
      group_data << {
52✔
1382
        directory_name: group_name,
1383
        name: starter_file_group.name,
1384
        use_rename: starter_file_group.use_rename,
1385
        entry_rename: starter_file_group.entry_rename
1386
      }
1387
    end
1388
    starter_file_settings = {
1389
      default_starter_file_group: default_starter_group,
53✔
1390
      starter_file_groups: group_data
1391
    }.to_yaml
1392
    zip_file.get_output_stream(settings_filepath) { |f| f.write starter_file_settings }
106✔
1393
  end
1394

1395
  # zip all files in the folder at +self.autotest_files_dir+ and return the
1396
  # path to the zip file
1397
  def zip_automated_test_files(user)
1✔
1398
    zip_name = "#{self.short_identifier}-testfiles-#{user.user_name}"
33✔
1399
    zip_path = File.join('tmp', zip_name + '.zip')
33✔
1400
    FileUtils.rm_rf zip_path
33✔
1401
    Zip::File.open(zip_path, create: true) do |zip_file|
33✔
1402
      self.add_test_files_to_zip(zip_file, '')
33✔
1403
    end
1404
    zip_path
33✔
1405
  end
1406

1407
  # Writes all of this assignment's automated test files to the +zip_dir+ in +zip_file+. Also writes
1408
  # the tester settings specified in this assignment's properties to the json file at
1409
  # +specs_file_path+ in the +zip_file+.
1410
  def automated_test_config_to_zip(zip_file, zip_dir, specs_file_path)
1✔
1411
    self.add_test_files_to_zip(zip_file, zip_dir)
36✔
1412
    test_specs = autotest_settings_for(self)
36✔
1413
    test_specs['testers']&.each do |tester_info|
36✔
1414
      tester_info['test_data']&.each do |test_info|
29✔
1415
        test_info['extra_info']&.delete('test_group_id')
29✔
1416
      end
1417
    end
1418
    zip_file.get_output_stream(specs_file_path) do |f|
36✔
1419
      f.write(test_specs.to_json)
36✔
1420
    end
1421
  end
1422

1423
  private
1✔
1424

1425
  def add_test_files_to_zip(zip_file, zip_base_dir)
1✔
1426
    files_dir = Pathname.new self.autotest_files_dir
69✔
1427
    self.autotest_files.map do |file|
69✔
1428
      path = zip_base_dir.empty? ? file : File.join(zip_base_dir, file)
125✔
1429
      abs_path = files_dir.join(file)
125✔
1430
      if abs_path.directory?
125✔
1431
        zip_file.mkdir(path)
43✔
1432
      else
1433
        zip_file.get_output_stream(path) { |f| f.print File.read(abs_path.to_s, mode: 'rb') }
164✔
1434
      end
1435
    end
1436
  end
1437

1438
  def create_autotest_dirs
1✔
1439
    FileUtils.mkdir_p self.autotest_path
6,125✔
1440
    FileUtils.mkdir_p self.autotest_files_dir
6,125✔
1441
  end
1442

1443
  # Returns the marking state used in the submission and course summary tables
1444
  # for the result(s) for single submission.
1445
  #
1446
  # +has_remark+ is a boolean indicating whether a remark request exists for this submission
1447
  # +result_marking_state+ is one of Result::MARKING_STATES or nil if there are no results for this submission
1448
  # +released_to_students+ is a boolean indicating whether a result has been released to students
1449
  # +collection_date+ is a Time object indicating when the submission was collected
1450
  def marking_state(has_remark, result_marking_state, released_to_students, collection_date)
1✔
1451
    if result_marking_state.present?
177✔
1452
      return 'remark' if result_marking_state == Result::MARKING_STATES[:incomplete] && has_remark
57✔
1453
      return 'released' if released_to_students
54✔
1454

1455
      return result_marking_state
53✔
1456
    end
1457
    return 'not_collected' if collection_date < Time.current
120✔
1458

1459
    'before_due_date'
117✔
1460
  end
1461

1462
  def reset_collection_time
1✔
1463
    submission_rule.reset_collection_time
7,543✔
1464
  end
1465

1466
  def update_assigned_tokens
1✔
1467
    old, new = assignment_properties.saved_change_to_tokens_per_period || [0, 0]
7,543✔
1468
    difference = new - old
7,543✔
1469
    unless difference.zero?
7,543✔
1470
      max_tokens = assignment_properties.tokens_per_period
116✔
1471
      groupings.each do |g|
116✔
1472
        g.test_tokens = (g.test_tokens + difference).clamp(0, max_tokens)
3✔
1473
        g.save
3✔
1474
      end
1475
    end
1476
  end
1477

1478
  def create_associations
1✔
1479
    return unless self.new_record?
31,332✔
1480
    self.assignment_properties ||= AssignmentProperties.new
6,595✔
1481
    self.submission_rule ||= NoLateSubmissionRule.new
6,595✔
1482
  end
1483

1484
  # Update the repository permissions file if one of the following attributes was changed after a save:
1485
  # - vcs_submit
1486
  # - is_hidden or section-specific is_hidden
1487
  # - anonymize_groups
1488
  def update_repo_permissions
1✔
1489
    return unless
1490
      saved_change_to_vcs_submit? ||
6,861✔
1491
        saved_change_to_anonymize_groups? ||
1492
        visibility_changed?
1493

1494
    Repository.get_class.update_permissions
5,228✔
1495
  end
1496

1497
  # Update parent assignment of a peer review to ensure that it is marked as having a peer review
1498
  def update_parent_assignment
1✔
1499
    parent_assignment.update(has_peer_review: true)
252✔
1500
  end
1501

1502
  # Update list of required files in student repositories. Used for git hooks to prevent submitting
1503
  # non-required files. Updated when one of the following attributes was changed after a save:
1504
  # - only_required_files
1505
  # - is_hidden or section-specific is_hidden
1506
  # - any assignment files
1507
  def update_repo_required_files
1✔
1508
    return unless Settings.repository.type == 'git'
6,861✔
1509
    return unless
1510
      saved_change_to_only_required_files? ||
29✔
1511
        assignment_files.any?(&:saved_changes?) ||
1512
        visibility_changed? ||
1513
        @prev_assignment_file_ids != self.reload.assignment_files.ids
1514

1515
    UpdateRepoRequiredFilesJob.perform_later(self.id)
29✔
1516
  end
1517

1518
  # Returns whether the visibility for this assignment changed after a save.
1519
  def visibility_changed?
1✔
1520
    saved_change_to_is_hidden? ||
6,792✔
1521
      saved_change_to_visible_on? ||
1522
      saved_change_to_visible_until? ||
1523
      assessment_section_properties.any?(&:is_hidden_previously_changed?) ||
1524
      assessment_section_properties.any?(&:visible_on_previously_changed?) ||
1525
      assessment_section_properties.any?(&:visible_until_previously_changed?) ||
1526
      @prev_assessment_section_property_ids != self.reload.assessment_section_properties.ids
1527
  end
1528
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