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

MarkUsProject / Markus / 28887404160

07 Jul 2026 05:56PM UTC coverage: 90.286%. Remained the same
28887404160

Pull #8048

github

web-flow
Merge 7e0012bc8 into 93a189a5e
Pull Request #8048: Chore: Ran rubocop on all files

1194 of 2444 branches covered (48.85%)

Branch coverage included in aggregate %.

9 of 13 new or added lines in 6 files covered. (69.23%)

47079 of 51023 relevant lines covered (92.27%)

127.14 hits per line

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

85.3
/app/controllers/groups_controller.rb
1
# Manages actions relating to editing and modifying
2
# groups.
3
class GroupsController < ApplicationController
1✔
4
  # Administrator
5
  before_action { authorize! }
163✔
6
  layout 'assignment_content'
1✔
7

8
  content_security_policy only: [:assign_scans] do |p|
1✔
9
    p.img_src :self, :blob
16✔
10
  end
11

12
  # Group administration functions -----------------------------------------
13
  # Verify that all functions below are included in the authorize filter above
14

15
  def new
1✔
16
    assignment = Assignment.find(params[:assignment_id])
3✔
17
    begin
18
      assignment.add_group(params[:new_group_name])
3✔
19
      flash_now(:success, I18n.t('flash.actions.create.success',
2✔
20
                                 resource_name: Group.model_name.human))
21
    rescue StandardError => e
22
      flash_now(:error, e.message)
1✔
23
    ensure
24
      head :ok
3✔
25
    end
26
  end
27

28
  def remove_group
1✔
29
    # When a success div exists we can return successfully removed groups
30
    groupings = Grouping.where(id: params[:grouping_id])
14✔
31
    errors = []
14✔
32
    @removed_groupings = []
14✔
33
    Repository.get_class.update_permissions_after(only_on_request: true) do
14✔
34
      groupings.each do |grouping|
12✔
35
        grouping.student_memberships.each do |member|
12✔
36
          grouping.remove_member(member.id)
×
37
        end
38
      end
39
    end
40
    groupings.each do |grouping|
14✔
41
      if grouping.has_submission?
14✔
42
        errors.push(grouping.group.group_name)
8✔
43
      else
44
        grouping.delete_grouping
6✔
45
        @removed_groupings.push(grouping)
6✔
46
      end
47
    end
48
    if errors.any?
14✔
49
      err_groups = errors.join(', ')
8✔
50
      flash_message(:error, I18n.t('groups.delete_group_has_submission') + err_groups)
8✔
51
    end
52
    head :ok
14✔
53
  end
54

55
  def rename_group
1✔
56
    @grouping = record
3✔
57
    @assignment = record.assignment
3✔
58
    @group = @grouping.group
3✔
59

60
    # Checking if a group with this name already exists
61
    if (@existing_group = current_course.groups.where(group_name: params[:new_groupname]).first)
3✔
62
      existing = true
2✔
63
      groupexist_id = @existing_group.id
2✔
64
    end
65

66
    if existing
3✔
67

68
      # We link the grouping to the group already existing
69

70
      # We verify there is no other grouping linked to this group on the
71
      # same assignement
72
      params[:groupexist_id] = groupexist_id
2✔
73
      params[:assignment_id] = @assignment.id
2✔
74

75
      if Grouping.exists?(assessment_id: @assignment.id, group_id: groupexist_id)
2✔
76
        flash_now(:error, I18n.t('groups.group_name_already_in_use'))
×
77
      elsif @grouping.has_submitted_files? || @grouping.has_non_empty_submission?
2✔
78
        flash_now(:error, I18n.t('groups.group_name_already_in_use_diff_assignment'))
2✔
79
      else
80
        @grouping.update_attribute(:group_id, groupexist_id)
×
81
      end
82
    else
83
      # We update the group_name
84
      @group.group_name = params[:new_groupname]
1✔
85
      if @group.save
1✔
86
        flash_now(:success, I18n.t('flash.actions.update.success',
1✔
87
                                   resource_name: Group.human_attribute_name(:group_name)))
88
      end
89
    end
90
    head :ok
3✔
91
  end
92

93
  def valid_grouping
1✔
94
    # TODO: make this a member route in a new GroupingsController
95
    assignment = Assignment.find(params[:assignment_id])
2✔
96
    grouping = assignment.groupings.find(params[:grouping_id])
2✔
97
    grouping.validate_grouping
2✔
98
    head :ok
2✔
99
  end
100

101
  def invalid_grouping
1✔
102
    # TODO: make this a member route in a new GroupingsController
103
    assignment = Assignment.find(params[:assignment_id])
2✔
104
    grouping = assignment.groupings.find(params[:grouping_id])
2✔
105
    grouping.invalidate_grouping
2✔
106
    head :ok
2✔
107
  end
108

109
  def index
1✔
110
    @assignment = Assignment.find(params[:assignment_id])
2✔
111
    @clone_assignments = current_course.assignments
2✔
112
                                       .joins(:assignment_properties)
113
                                       .where(assignment_properties: { vcs_submit: true })
114
                                       .where.not(id: @assignment.id)
115
                                       .order(:id)
116

117
    respond_to do |format|
2✔
118
      format.html
2✔
119
      format.json do
2✔
120
        render json: @assignment.all_grouping_data.merge(
×
121
          clone_assignments: @clone_assignments.as_json(only: [:id, :short_identifier])
122
        )
123
      end
124
    end
125
  end
126

127
  def assign_scans
1✔
128
    # TODO: make this a member route in a new GroupingsController
129
    @assignment = Assignment.find(params[:assignment_id])
16✔
130
    if params.key?(:grouping_id)
16✔
131
      next_grouping = @assignment.groupings.find(params[:grouping_id])
10✔
132
    else
133
      next_grouping = Grouping.get_assign_scans_grouping(@assignment)
6✔
134
    end
135
    if next_grouping&.current_submission_used.nil?
16✔
136
      if @assignment.groupings.left_outer_joins(:current_submission_used).where('submissions.id': nil).any?
4✔
137
        flash_message(:warning, I18n.t('exam_templates.assign_scans.not_all_submissions_collected'))
3✔
138
      end
139
      redirect_back_or_to(course_assignment_groups_path(current_course, @assignment))
4✔
140
      return
4✔
141
    end
142
    names = next_grouping.non_rejected_student_memberships.map do |u|
12✔
143
      u.user.display_name
8✔
144
    end
145
    num_valid = @assignment.get_num_valid
12✔
146
    num_total = @assignment.groupings.size
12✔
147
    if num_valid == num_total
12✔
148
      flash_message(:success, t('exam_templates.assign_scans.done'))
8✔
149
    end
150
    # Get OCR match data and suggestions if available
151
    ocr_match = OcrMatchService.get_match(next_grouping.id)
12✔
152
    ocr_suggestions = ocr_match ? OcrMatchService.get_suggestions(next_grouping.id, current_course.id) : []
12✔
153

154
    @data = {
155
      group_name: next_grouping.group.group_name,
12✔
156
      grouping_id: next_grouping.id,
157
      students: names,
158
      num_total: num_total,
159
      num_valid: num_valid,
160
      ocr_match: ocr_match,
161
      ocr_suggestions: format_ocr_suggestions(ocr_suggestions)
162
    }
163
    next_file = next_grouping.current_submission_used.submission_files.find_by(filename: 'COVER.pdf')
12✔
164
    if next_file.nil?
12✔
165
      flash_message(:warning, I18n.t('exam_templates.assign_scans.no_cover_page'))
10✔
166
    else
167
      @data[:filelink] = download_course_assignment_groups_path(
2✔
168
        current_course, @assignment,
169
        select_file_id: next_grouping.current_submission_used.submission_files.find_by(filename: 'COVER.pdf').id,
170
        show_in_browser: true
171
      )
172
    end
173
  end
174

175
  def get_names
1✔
176
    names = current_course.students
9✔
177
                          .joins(:user)
178
                          .where("(lower(first_name) like ? OR
179
                                   lower(last_name) like ? OR
180
                                   lower(user_name) like ? OR
181
                                   id_number like ?) AND roles.hidden IN (?) AND roles.id NOT IN (?)",
182
                                 "#{ApplicationRecord.sanitize_sql_like(params[:term].downcase)}%",
183
                                 "#{ApplicationRecord.sanitize_sql_like(params[:term].downcase)}%",
184
                                 "#{ApplicationRecord.sanitize_sql_like(params[:term].downcase)}%",
185
                                 "#{ApplicationRecord.sanitize_sql_like(params[:term])}%",
186
                                 params[:display_inactive] == 'true' ? [true, false] : [false],
9✔
187
                                 Membership.select(:role_id)
188
                                           .joins(:grouping)
189
                                           .where(groupings: { assessment_id: params[:assignment_id] }))
190
                          .pluck_to_hash(:id, 'users.id_number', 'users.user_name',
191
                                         'users.first_name', 'users.last_name', 'roles.hidden')
192

193
    names = names.map do |h|
9✔
194
      inactive = h['roles.hidden'] ? " (#{I18n.t('activerecord.attributes.user.hidden')})" : ''
9✔
195
      { id: h[:id],
9✔
196
        id_number: h['users.id_number'],
197
        user_name: h['users.user_name'],
198
        value: "#{h['users.first_name']} #{h['users.last_name']}#{inactive}" }
199
    end
200
    render json: names
9✔
201
  end
202

203
  def assign_student_and_next
1✔
204
    # TODO: make this a member route in a new GroupingsController
205
    @grouping = Grouping.joins(:assignment).where('assessments.course_id': current_course.id).find(params[:g_id])
10✔
206
    @assignment = @grouping.assignment
10✔
207
    unless params[:skip]
10✔
208
      # if the user has selected a name from the dropdown, s_id is set
209
      if params[:s_id].present?
10✔
210
        student = current_course.students.find(params[:s_id])
5✔
211
      end
212
      replace_pattern = /\s*\(#{Regexp.escape(I18n.t('activerecord.attributes.user.hidden'))}\)\s*$/
10✔
213
      student_name = params[:names].sub(replace_pattern, '').strip
10✔
214

215
      # if the user has typed in the whole name without select, or if they typed a name different from the select s_id
216
      if student.nil? || "#{student.first_name} #{student.last_name}" != student_name
10✔
217
        student = current_course.students.joins(:user).where(
6✔
218
          'lower(CONCAT(first_name, \' \', last_name)) like ? OR lower(CONCAT(last_name, \' \', first_name)) like ?',
219
          ApplicationRecord.sanitize_sql_like(student_name.downcase),
220
          ApplicationRecord.sanitize_sql_like(student_name.downcase)
221
        ).first
222
      end
223
      if student.nil?
10✔
224
        flash_message(:error, t('exam_templates.assign_scans.student_not_found', name: params[:names]))
2✔
225
        head :not_found
2✔
226
        return
2✔
227
      end
228
      StudentMembership
8✔
229
        .find_or_create_by(role: student, grouping: @grouping, membership_status: StudentMembership::STATUSES[:inviter])
230
      # Clear OCR match data after successful assignment
231
      OcrMatchService.clear_match(@grouping.id)
8✔
232
    end
233
    next_grouping
8✔
234
  end
235

236
  def next_grouping
1✔
237
    # TODO: is this actually a route that is called from anywhere or just a helper method?
238
    if params[:a_id].present?
8✔
239
      @assignment = Assignment.find(params[:a_id])
×
240
    end
241
    next_grouping = Grouping.get_assign_scans_grouping(@assignment, params[:g_id])
8✔
242
    if next_grouping.nil?
8✔
243
      head :not_found
1✔
244
      return
1✔
245
    end
246
    names = next_grouping.non_rejected_student_memberships.map do |u|
7✔
247
      u.user.display_name
×
248
    end
249
    num_valid = @assignment.get_num_valid
7✔
250
    num_total = @assignment.groupings.size
7✔
251
    if num_valid == num_total
7✔
252
      flash_message(:success, t('exam_templates.assign_scans.done'))
×
253
    end
254
    # Get OCR match data and suggestions if available
255
    ocr_match = OcrMatchService.get_match(next_grouping.id)
7✔
256
    ocr_suggestions = ocr_match ? OcrMatchService.get_suggestions(next_grouping.id, current_course.id) : []
7✔
257

258
    if !@grouping.nil? && next_grouping.id == @grouping.id
7✔
259
      render json: {
×
260
        grouping_id: next_grouping.id,
261
        students: names,
262
        num_total: num_total,
263
        num_valid: num_valid,
264
        ocr_match: ocr_match,
265
        ocr_suggestions: format_ocr_suggestions(ocr_suggestions)
266
      }
267
    else
268
      data = {
269
        group_name: next_grouping.group.group_name,
7✔
270
        grouping_id: next_grouping.id,
271
        students: names,
272
        num_total: num_total,
273
        num_valid: num_valid,
274
        ocr_match: ocr_match,
275
        ocr_suggestions: format_ocr_suggestions(ocr_suggestions)
276
      }
277
      next_file = next_grouping.current_submission_used.submission_files.find_by(filename: 'COVER.pdf')
7✔
278
      unless next_file.nil?
7✔
279
        data[:filelink] = download_course_assignment_groups_path(
×
280
          current_course,
281
          @assignment,
282
          select_file_id: next_grouping.current_submission_used.submission_files.find_by(filename: 'COVER.pdf').id,
283
          show_in_browser: true
284
        )
285
      end
286
      render json: data
7✔
287
    end
288
  end
289

290
  # Allows the user to upload a csv file listing groups. If group_name is equal
291
  # to the only member of a group and the assignment is configured with
292
  # allow_web_subits == false, the student's username will be used as the
293
  # repository name. If MarkUs is not repository admin, the repository name as
294
  # specified by the second field will be used instead.
295
  def upload
1✔
296
    assignment = Assignment.find(params[:assignment_id])
18✔
297
    begin
298
      data = process_file_upload(['.csv'])
18✔
299
    rescue StandardError => e
300
      flash_message(:error, e.message)
3✔
301
    else
302
      group_rows = []
15✔
303
      result = MarkusCsv.parse(data[:contents], encoding: data[:encoding]) do |row|
15✔
304
        next if row.blank?
27✔
305
        raise CsvInvalidLineError if row[0].blank?
27✔
306

307
        group_rows << row.compact_blank
26✔
308
      end
309

310
      if result[:invalid_lines].empty?
15✔
311
        create_groups_with_socket(assignment, group_rows)
8✔
312
      else
313
        flash_message(:error, result[:invalid_lines])
7✔
314
      end
315
    end
316
    redirect_to course_assignment_groups_path(current_course, assignment)
18✔
317
  end
318

319
  def create_groups_when_students_work_alone
1✔
320
    @assignment = Assignment.find(params[:assignment_id])
8✔
321
    if @assignment.group_max == 1
8✔
322
      # data is a list of lists containing: [[group_name, group_member], ...]
323
      data = current_course.students
4✔
324
                           .joins(:user)
325
                           .where(hidden: false)
326
                           .pluck('users.user_name')
327
                           .map { |user_name| [user_name, user_name] }
20✔
328
      create_groups_with_socket(@assignment, data)
4✔
329
    end
330

331
    head :ok
8✔
332
  end
333

334
  def download
1✔
335
    # TODO: make this a member route in a new SubmissionFileController
336
    file = SubmissionFile.find(params[:select_file_id])
×
337
    # TODO: remove this check once this is moved to a new SubmissionFileController
338
    return page_not_found unless file.course == current_course
×
339

340
    file_contents = file.retrieve_file
×
341

342
    filename = file.filename
×
343
    send_data_download file_contents, filename: filename
×
344
  end
345

346
  def download_grouplist
1✔
347
    assignment = Assignment.find(params[:assignment_id])
5✔
348
    groupings = assignment.groupings.includes(:group, student_memberships: :role)
5✔
349

350
    file_out = MarkusCsv.generate(groupings) do |grouping|
5✔
351
      # csv format is group_name, repo_name, user1_name, user2_name, ... etc
352
      [grouping.group.group_name].concat(
5✔
353
        grouping.student_memberships.map do |member|
354
          member.role.user_name
10✔
355
        end
356
      )
357
    end
358

359
    send_data(file_out,
5✔
360
              type: 'text/csv',
361
              filename: "#{assignment.short_identifier}_group_list.csv",
362
              disposition: 'attachment')
363
  end
364

365
  def use_another_assignment_groups
1✔
366
    target_assignment = Assignment.find(params[:assignment_id])
×
367
    source_assignment = Assignment.find(params[:clone_assignment_id])
×
368

369
    return head :unprocessable_content if target_assignment.course != source_assignment.course
×
370

371
    if source_assignment.nil?
×
372
      flash_message(:warning, t('groups.clone_warning.could_not_find_source'))
×
373
    elsif target_assignment.nil?
×
374
      flash_message(:warning, t('groups.clone_warning.could_not_find_target'))
×
375
    else
376
      # Clone the groupings
377
      clone_warnings = target_assignment.clone_groupings_from(source_assignment.id)
×
378
      unless clone_warnings.empty?
×
379
        clone_warnings.each { |w| flash_message(:warning, w) }
×
380
      end
381
    end
382

NEW
383
    redirect_back_or_to(root_path)
×
384
  end
385

386
  def accept_invitation
1✔
387
    # TODO: make this a member route in a new GroupingsController
388
    @assignment = Assignment.find(params[:assignment_id])
6✔
389
    @grouping = @assignment.groupings.find(params[:grouping_id])
6✔
390
    begin
391
      current_role.join(@grouping)
6✔
392
    rescue ActiveRecord::RecordInvalid, RuntimeError => e
393
      flash_message(:error, e.message)
3✔
394
      status = :unprocessable_content
3✔
395
    else
396
      m_logger = MarkusLogger.instance
3✔
397
      m_logger.log("Student '#{current_role.user_name}' joined group " \
3✔
398
                   "'#{@grouping.group.group_name}'(accepted invitation).")
399
      status = :found
3✔
400
    end
401
    redirect_to course_assignment_path(current_course, @assignment), status: status
6✔
402
  end
403

404
  def decline_invitation
1✔
405
    # TODO: make this a member route in a new GroupingsController
406
    @assignment = Assignment.find(params[:assignment_id])
3✔
407
    @grouping = @assignment.groupings.find(params[:grouping_id])
3✔
408
    begin
409
      @grouping.decline_invitation(current_role)
3✔
410
    rescue RuntimeError => e
411
      flash_message(:error, e.message)
2✔
412
      status = :unprocessable_content
2✔
413
    else
414
      m_logger = MarkusLogger.instance
1✔
415
      m_logger.log("Student '#{current_role.user_name}' declined invitation for group '#{@grouping.group.group_name}'.")
1✔
416
      status = :found
1✔
417
    end
418
    redirect_to course_assignment_path(current_course, @assignment), status: status
3✔
419
  end
420

421
  def create
1✔
422
    @assignment = Assignment.find(params[:assignment_id])
1✔
423
    @student = current_role
1✔
424
    m_logger = MarkusLogger.instance
1✔
425
    begin
426
      return unless flash_allowance(:error, allowance_to(:create_group?, @assignment)).value
1✔
427
      if params[:workalone]
×
428
        return unless flash_allowance(:error, allowance_to(:work_alone?, @assignment)).value
×
429
        @student.create_group_for_working_alone_student(@assignment.id)
×
430
      else
431
        return unless flash_allowance(:error, allowance_to(:autogenerate_group_name?, @assignment)).value
×
432
        @student.create_autogenerated_name_group(@assignment)
×
433
      end
434
      m_logger.log("Student '#{@student.user_name}' created group.", MarkusLogger::INFO)
×
435
    rescue RuntimeError => e
436
      flash_message(:error, e.message)
×
437
      m_logger.log("Failed to create group. User: '#{@student.user_name}', Error: '#{e.message}'.", MarkusLogger::ERROR)
×
438
    end
439
  ensure
440
    redirect_to course_assignment_path(current_course, @assignment)
1✔
441
  end
442

443
  def destroy
1✔
444
    @assignment = Assignment.find(params[:assignment_id])
1✔
445
    @grouping = current_role.accepted_grouping_for(@assignment.id)
1✔
446
    m_logger = MarkusLogger.instance
1✔
447
    if @grouping.nil?
1✔
448
      m_logger.log('Failed to delete group, since no accepted group for this user existed.' \
1✔
449
                   "User: '#{current_role.user_name}'.", MarkusLogger::ERROR)
450
      flash_message(:error, I18n.t('groups.destroy.errors.do_not_have_a_group'))
1✔
451
      redirect_to course_assignment_path(current_course, @assignment)
1✔
452
      return
1✔
453
    end
454
    if flash_allowance(:error, allowance_to(:destroy?, @grouping)).value
×
455
      begin
456
        Repository.get_class.update_permissions_after(only_on_request: true) do
×
457
          @grouping.student_memberships.each do |member|
×
458
            @grouping.remove_member(member.id)
×
459
          end
460
        end
461
        @grouping.destroy
×
462
        flash_message(:success, I18n.t('flash.actions.destroy.success', resource_name: Group.model_name.human))
×
463
        m_logger.log("Student '#{current_role.user_name}' deleted group '" \
×
464
                     "#{@grouping.group.group_name}'.", MarkusLogger::INFO)
465
      rescue RuntimeError => e
466
        m_logger.log("Failed to delete group '#{@grouping.group.group_name}'. User: '" \
×
467
                     "#{current_role.user_name}', Error: '#{e.message}'.", MarkusLogger::ERROR)
468
      end
469
    end
470
    redirect_to course_assignment_path(current_course, @assignment)
×
471
  end
472

473
  def invite_member
1✔
474
    @assignment = Assignment.find(params[:assignment_id])
4✔
475

476
    @grouping = current_role.accepted_grouping_for(@assignment.id)
4✔
477
    if @grouping.nil?
4✔
478
      flash_message(:error,
×
479
                    I18n.t('groups.invite_member.errors.need_to_create_group'))
480
      redirect_to course_assignment_path(@course, @assignment)
×
481
      return
×
482
    end
483
    if flash_allowance(:error, allowance_to(:invite_member?, @grouping)).value
4✔
484
      to_invite = params[:invite_member].split(',')
4✔
485
      errors = @grouping.invite(to_invite)
4✔
486
      if errors.blank?
4✔
487
        to_invite.each do |i|
4✔
488
          i = i.strip
6✔
489
          invited_user = current_course.students.joins(:user).find_by('users.user_name': i)
6✔
490
          if invited_user&.receives_invite_emails?
6✔
491
            NotificationMailer.with(inviter: current_role,
4✔
492
                                    invited: invited_user,
493
                                    grouping: @grouping).grouping_invite_email.deliver_later
494
          end
495
        end
496
        flash_message(:success, I18n.t('groups.invite_member.success'))
4✔
497
      else
498
        flash_message(:error, errors.join(' '))
×
499
      end
500
    end
501
    redirect_to course_assignment_path(current_course, @assignment.id)
4✔
502
  end
503

504
  # Deletes pending invitations
505
  def disinvite_member
1✔
506
    assignment = Assignment.find(params[:assignment_id])
4✔
507
    membership = assignment.student_memberships.find(params[:membership])
4✔
508
    authorized = flash_allowance(:error, allowance_to(:disinvite_member?,
4✔
509
                                                      membership.grouping,
510
                                                      context: { membership: membership })).value
511
    if authorized
4✔
512
      disinvited_student = membership.role
1✔
513
      membership.destroy
1✔
514
      m_logger = MarkusLogger.instance
1✔
515
      m_logger.log("Student '#{current_role.user_name}' cancelled invitation for '#{disinvited_student.user_name}'.")
1✔
516
      flash_message(:success, I18n.t('groups.members.member_disinvited'))
1✔
517
    end
518
    status = authorized ? :found : :forbidden
4✔
519
    redirect_to course_assignment_path(current_course, assignment.id), status: status
4✔
520
  end
521

522
  # Deletes memberships which have been declined by students
523
  def delete_rejected
1✔
524
    @assignment = Assignment.find(params[:assignment_id])
5✔
525
    membership = @assignment.student_memberships.find(params[:membership])
5✔
526
    grouping = membership.grouping
5✔
527
    authorized = flash_allowance(:error,
5✔
528
                                 allowance_to(:delete_rejected?, grouping, context: { membership: membership })).value
529
    membership.destroy if authorized
5✔
530
    status = authorized ? :found : :forbidden
5✔
531
    redirect_to course_assignment_path(current_course, @assignment), status: status
5✔
532
  end
533

534
  # These actions act on all currently selected students & groups
535
  def global_actions
1✔
536
    assignment = Assignment.includes([{ groupings: [{ student_memberships: :role, ta_memberships: :role }, :group] }])
17✔
537
                           .find(params[:assignment_id])
538
    grouping_ids = params[:groupings]
17✔
539
    student_ids = params[:students]
17✔
540
    students_to_remove = params[:students_to_remove]
17✔
541

542
    # Start exception catching. If an exception is raised,
543
    # return http response code of 400 (bad request) along
544
    # the error string. The front-end should get it and display
545
    # the message in an error div.
546
    begin
547
      groupings = Grouping.where(id: grouping_ids)
17✔
548
      check_for_groupings(groupings)
17✔
549

550
      # Students are only needed for assign/unassign so don't
551
      # need to check.
552
      students = Student.where(id: student_ids)
16✔
553

554
      case params[:global_actions]
16✔
555
      when 'delete'
556
        delete_groupings(groupings)
2✔
557
      when 'invalid'
558
        invalidate_groupings(groupings)
1✔
559
      when 'valid'
560
        validate_groupings(groupings)
1✔
561
      when 'assign'
562
        add_members(students, groupings, assignment)
9✔
563
      when 'unassign'
564
        remove_members(students_to_remove, groupings)
3✔
565
      end
566
      head :ok
10✔
567
    rescue StandardError => e
568
      flash_now(:error, e.message)
7✔
569
      head :bad_request
7✔
570
    end
571
  end
572

573
  def download_starter_file
1✔
574
    assignment = Assignment.find(params[:assignment_id])
6✔
575
    grouping = current_role.accepted_grouping_for(assignment.id)
6✔
576

577
    authorize! grouping, with: GroupingPolicy
6✔
578

579
    grouping.reset_starter_file_entries if grouping.starter_file_changed
4✔
580

581
    zip_name = "#{assignment.short_identifier}-starter-files-#{current_role.user_name}"
4✔
582
    zip_path = File.join('tmp', zip_name + '.zip')
4✔
583
    FileUtils.rm_rf zip_path
4✔
584
    Zip::File.open(zip_path, create: true) do |zip_file|
4✔
585
      grouping.starter_file_entries.reload.each { |entry| entry.add_files_to_zip_file(zip_file) }
12✔
586
    end
587
    send_file zip_path, filename: File.basename(zip_path)
4✔
588
  end
589

590
  def populate_repo_with_starter_files
1✔
591
    assignment = Assignment.find(params[:assignment_id])
19✔
592
    grouping = current_role.accepted_grouping_for(assignment.id)
19✔
593

594
    authorize! grouping, with: GroupingPolicy
19✔
595

596
    grouping.reset_starter_file_entries if grouping.starter_file_changed
16✔
597

598
    grouping.access_repo do |repo|
16✔
599
      txn = repo.get_transaction(current_role.user_name)
16✔
600
      grouping.starter_file_entries.reload.each { |entry| entry.add_files_to_repo(repo, txn) }
48✔
601
      if repo.commit(txn)
16✔
602
        flash_message(:success, I18n.t('assignments.starter_file.populate_repo_success'))
16✔
603
      else
604
        flash_message(:error, I18n.t('assignments.starter_file.populate_repo_error'))
×
605
      end
606
    end
607
    redirect_to course_assignment_path(current_course, assignment)
16✔
608
  end
609

610
  def auto_match
1✔
611
    assignment = Assignment.find(params[:assignment_id])
×
612
    grouping_ids = params[:groupings]
×
613
    exam_template_id = params[:exam_template_id]
×
614
    groupings = assignment.groupings.find(grouping_ids)
×
615
    exam_template = assignment.exam_templates.find(exam_template_id)
×
616

617
    @current_job = AutoMatchJob.perform_later groupings, exam_template
×
618
    session[:job_id] = @current_job.job_id
×
619

620
    respond_to do |format|
×
621
      format.js { render 'shared/_poll_job' }
×
622
    end
623
  end
624

625
  private
1✔
626

627
  def create_groups_with_socket(assignment, data)
1✔
628
    enqueuing_user = current_user
12✔
629
    current_job = CreateGroupsJob.perform_later assignment, data,
12✔
630
                                                enqueuing_user: enqueuing_user,
631
                                                notify_socket: true
632
    GroupsChannel.broadcast_to(enqueuing_user, ActiveJob::Status.get(current_job).to_h) if enqueuing_user
12✔
633
  end
634

635
  # These methods are called through global actions.
636

637
  # Check that there is at least one grouping selected
638
  def check_for_groupings(groupings)
1✔
639
    if groupings.blank?
17✔
640
      raise I18n.t('groups.select_a_group')
1✔
641
    end
642
  end
643

644
  # Given a list of grouping, sets their group status to invalid if possible
645
  def invalidate_groupings(groupings)
1✔
646
    groupings.each(&:invalidate_grouping)
1✔
647
  end
648

649
  # Given a list of grouping, sets their group status to valid if possible
650
  def validate_groupings(groupings)
1✔
651
    groupings.each(&:validate_grouping)
1✔
652
  end
653

654
  # Deletes the given list of groupings if possible. Removes each member first.
655
  def delete_groupings(groupings)
1✔
656
    # If any groupings have a submission raise an error.
657
    if groupings.any?(&:has_submission?)
2✔
658
      raise I18n.t('groups.could_not_delete') # should add names of grouping we could not delete
1✔
659
    else
660
      # Remove each student from every group.
661
      Repository.get_class.update_permissions_after(only_on_request: true) do
1✔
662
        groupings.each do |grouping|
1✔
663
          grouping.student_memberships.each do |mem|
1✔
664
            grouping.remove_member(mem.id)
1✔
665
          end
666
          grouping.delete_grouping
1✔
667
        end
668
      end
669
    end
670
  end
671

672
  # Adds students to grouping. `groupings` should be an array with
673
  # only one element, which is the grouping that is supposed to be
674
  # added to.
675
  def add_members(students, groupings, assignment)
1✔
676
    if groupings.size != 1
9✔
677
      raise I18n.t('groups.select_only_one_group')
1✔
678
    end
679
    if students.blank?
8✔
680
      raise I18n.t('groups.select_a_student')
1✔
681
    end
682

683
    grouping = groupings.first
7✔
684

685
    students.each do |student|
7✔
686
      add_member(student, grouping, assignment)
9✔
687
    end
688

689
    # Generate warning if the number of people assigned to a group exceeds
690
    # the maximum size of a group
691
    students_in_group = grouping.student_membership_number
5✔
692
    group_name = grouping.group.group_name
5✔
693
    if assignment.student_form_groups && (students_in_group > assignment.group_max)
5✔
694
      raise I18n.t('groups.assign_over_limit', group: group_name)
1✔
695
    end
696
  end
697

698
  # Adds the student given in student_id to the grouping given in grouping
699
  def add_member(student, grouping, assignment)
1✔
700
    set_membership_status = if grouping.student_memberships.empty?
9✔
701
                              StudentMembership::STATUSES[:inviter]
1✔
702
                            else
703
                              StudentMembership::STATUSES[:accepted]
8✔
704
                            end
705
    @bad_user_names = []
9✔
706

707
    if student.has_accepted_grouping_for?(assignment.id)
9✔
708
      raise I18n.t('groups.invite_member.errors.already_grouped', user_name: student.user_name)
1✔
709
    end
710
    errors = grouping.invite(student.user_name, set_membership_status, invoked_by_instructor: true)
8✔
711
    grouping.reload
8✔
712

713
    if errors.present?
8✔
714
      raise errors.join(' ')
1✔
715
    end
716

717
    # Generate a warning if a member is added to a group and they
718
    # have fewer grace days credits than already used by that group
719
    if student.remaining_grace_credits < grouping.grace_period_deduction_single
7✔
720
      flash_message(:warning, I18n.t('groups.grace_day_over_limit', group: grouping.group.group_name))
1✔
721
    end
722

723
    grouping.reload
7✔
724
  end
725

726
  # Removes the students with user names in +member_names+ from the
727
  # groupings in +groupings+. This removes any type of student membership
728
  # even pending memberships.
729
  #
730
  # This is meant to be called with the params from global_actions
731
  def remove_members(member_names, groupings)
1✔
732
    members_to_remove = current_course.students.joins(:user).where('users.user_name': member_names)
3✔
733
    Repository.get_class.update_permissions_after(only_on_request: true) do
3✔
734
      members_to_remove.each do |member|
3✔
735
        groupings.each do |grouping|
2✔
736
          membership = grouping.student_memberships.find_by(role_id: member.id)
2✔
737
          remove_member(membership, grouping)
2✔
738
        end
739
      end
740
    end
741
  end
742

743
  # Removes the given student membership from the given grouping
744
  def remove_member(membership, grouping)
1✔
745
    grouping.remove_member(membership.id)
2✔
746
    grouping.reload
2✔
747
  end
748

749
  # Format OCR suggestions for JSON response
750
  def format_ocr_suggestions(ocr_suggestions)
1✔
751
    ocr_suggestions.map do |s|
19✔
752
      {
753
        id: s[:student].id,
4✔
754
        user_name: s[:student].user.user_name,
755
        id_number: s[:student].user.id_number,
756
        display_name: s[:student].user.display_name,
757
        similarity: (s[:similarity] * 100).round(1)
4✔
758
      }
759
    end
760
  end
761

762
  # This override is necessary because this controller is acting as a controller
763
  # for both groups and groupings.
764
  #
765
  # TODO: move all grouping methods into their own controller and remove this
766
  def record
1✔
767
    @record ||= Grouping.find_by(id: request.path_parameters[:id]) if request.path_parameters[:id]
172✔
768
  end
769
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

© 2026 Coveralls, Inc