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

MarkUsProject / Markus / 29422953784

15 Jul 2026 02:18PM UTC coverage: 90.326% (-0.001%) from 90.327%
29422953784

Pull #8059

github

web-flow
Merge af0c90545 into d582f871d
Pull Request #8059: Improve Assign Scans page UI and fix redirect-on-completion 404

1218 of 2470 branches covered (49.31%)

Branch coverage included in aggregate %.

9 of 10 new or added lines in 2 files covered. (90.0%)

47316 of 51262 relevant lines covered (92.3%)

127.82 hits per line

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

85.23
/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
    if params[:skip].blank?
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
      num_valid = @assignment.get_num_valid
1✔
244
      num_total = @assignment.groupings.size
1✔
245
      if num_valid == num_total
1✔
246
        flash_message(:success, t('exam_templates.assign_scans.done'))
1✔
247
      else
NEW
248
        flash_message(:warning, t('exam_templates.assign_scans.not_all_submissions_collected'))
×
249
      end
250
      render json: { redirect: course_assignment_groups_path(current_course, @assignment) }
1✔
251
      return
1✔
252
    end
253
    names = next_grouping.non_rejected_student_memberships.map do |u|
7✔
254
      u.user.display_name
×
255
    end
256
    num_valid = @assignment.get_num_valid
7✔
257
    num_total = @assignment.groupings.size
7✔
258
    if num_valid == num_total
7✔
259
      flash_message(:success, t('exam_templates.assign_scans.done'))
×
260
    end
261
    # Get OCR match data and suggestions if available
262
    ocr_match = OcrMatchService.get_match(next_grouping.id)
7✔
263
    ocr_suggestions = ocr_match ? OcrMatchService.get_suggestions(next_grouping.id, current_course.id) : []
7✔
264

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

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

314
        group_rows << row.compact_blank
26✔
315
      end
316

317
      if result[:invalid_lines].empty?
15✔
318
        create_groups_with_socket(assignment, group_rows)
8✔
319
      else
320
        flash_message(:error, result[:invalid_lines])
7✔
321
      end
322
    end
323
    redirect_to course_assignment_groups_path(current_course, assignment)
18✔
324
  end
325

326
  def create_groups_when_students_work_alone
1✔
327
    @assignment = Assignment.find(params[:assignment_id])
8✔
328
    if @assignment.group_max == 1
8✔
329
      # data is a list of lists containing: [[group_name, group_member], ...]
330
      data = current_course.students
4✔
331
                           .joins(:user)
332
                           .where(hidden: false)
333
                           .pluck('users.user_name')
334
                           .map { |user_name| [user_name, user_name] }
20✔
335
      create_groups_with_socket(@assignment, data)
4✔
336
    end
337

338
    head :ok
8✔
339
  end
340

341
  def download
1✔
342
    # TODO: make this a member route in a new SubmissionFileController
343
    file = SubmissionFile.find(params[:select_file_id])
×
344
    # TODO: remove this check once this is moved to a new SubmissionFileController
345
    return page_not_found unless file.course == current_course
×
346

347
    file_contents = file.retrieve_file
×
348

349
    filename = file.filename
×
350
    send_data_download file_contents, filename: filename
×
351
  end
352

353
  def download_grouplist
1✔
354
    assignment = Assignment.find(params[:assignment_id])
5✔
355
    groupings = assignment.groupings.includes(:group, student_memberships: :role)
5✔
356

357
    file_out = MarkusCsv.generate(groupings) do |grouping|
5✔
358
      # csv format is group_name, repo_name, user1_name, user2_name, ... etc
359
      [grouping.group.group_name].concat(
5✔
360
        grouping.student_memberships.map do |member|
361
          member.role.user_name
10✔
362
        end
363
      )
364
    end
365

366
    send_data(file_out,
5✔
367
              type: 'text/csv',
368
              filename: "#{assignment.short_identifier}_group_list.csv",
369
              disposition: 'attachment')
370
  end
371

372
  def use_another_assignment_groups
1✔
373
    target_assignment = Assignment.find(params[:assignment_id])
×
374
    source_assignment = Assignment.find(params[:clone_assignment_id])
×
375

376
    return head :unprocessable_content if target_assignment.course != source_assignment.course
×
377

378
    if source_assignment.nil?
×
379
      flash_message(:warning, t('groups.clone_warning.could_not_find_source'))
×
380
    elsif target_assignment.nil?
×
381
      flash_message(:warning, t('groups.clone_warning.could_not_find_target'))
×
382
    else
383
      # Clone the groupings
384
      clone_warnings = target_assignment.clone_groupings_from(source_assignment.id)
×
385
      unless clone_warnings.empty?
×
386
        clone_warnings.each { |w| flash_message(:warning, w) }
×
387
      end
388
    end
389

390
    redirect_back_or_to(root_path)
×
391
  end
392

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

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

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

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

480
  def invite_member
1✔
481
    @assignment = Assignment.find(params[:assignment_id])
4✔
482

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

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

529
  # Deletes memberships which have been declined by students
530
  def delete_rejected
1✔
531
    @assignment = Assignment.find(params[:assignment_id])
5✔
532
    membership = @assignment.student_memberships.find(params[:membership])
5✔
533
    grouping = membership.grouping
5✔
534
    authorized = flash_allowance(:error,
5✔
535
                                 allowance_to(:delete_rejected?, grouping, context: { membership: membership })).value
536
    membership.destroy if authorized
5✔
537
    status = authorized ? :found : :forbidden
5✔
538
    redirect_to course_assignment_path(current_course, @assignment), status: status
5✔
539
  end
540

541
  # These actions act on all currently selected students & groups
542
  def global_actions
1✔
543
    assignment = Assignment.includes([{ groupings: [{ student_memberships: :role, ta_memberships: :role }, :group] }])
17✔
544
                           .find(params[:assignment_id])
545
    grouping_ids = params[:groupings]
17✔
546
    student_ids = params[:students]
17✔
547
    students_to_remove = params[:students_to_remove]
17✔
548

549
    # Start exception catching. If an exception is raised,
550
    # return http response code of 400 (bad request) along
551
    # the error string. The front-end should get it and display
552
    # the message in an error div.
553
    begin
554
      groupings = Grouping.where(id: grouping_ids)
17✔
555
      check_for_groupings(groupings)
17✔
556

557
      # Students are only needed for assign/unassign so don't
558
      # need to check.
559
      students = Student.where(id: student_ids)
16✔
560

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

580
  def download_starter_file
1✔
581
    assignment = Assignment.find(params[:assignment_id])
6✔
582
    grouping = current_role.accepted_grouping_for(assignment.id)
6✔
583

584
    authorize! grouping, with: GroupingPolicy
6✔
585

586
    grouping.reset_starter_file_entries if grouping.starter_file_changed
4✔
587

588
    zip_name = "#{assignment.short_identifier}-starter-files-#{current_role.user_name}"
4✔
589
    zip_path = File.join('tmp', zip_name + '.zip')
4✔
590
    FileUtils.rm_rf zip_path
4✔
591
    Zip::File.open(zip_path, create: true) do |zip_file|
4✔
592
      grouping.starter_file_entries.reload.each { |entry| entry.add_files_to_zip_file(zip_file) }
12✔
593
    end
594
    send_file zip_path, filename: File.basename(zip_path)
4✔
595
  end
596

597
  def populate_repo_with_starter_files
1✔
598
    assignment = Assignment.find(params[:assignment_id])
19✔
599
    grouping = current_role.accepted_grouping_for(assignment.id)
19✔
600

601
    authorize! grouping, with: GroupingPolicy
19✔
602

603
    grouping.reset_starter_file_entries if grouping.starter_file_changed
16✔
604

605
    grouping.access_repo do |repo|
16✔
606
      txn = repo.get_transaction(current_role.user_name)
16✔
607
      grouping.starter_file_entries.reload.each { |entry| entry.add_files_to_repo(repo, txn) }
48✔
608
      if repo.commit(txn)
16✔
609
        flash_message(:success, I18n.t('assignments.starter_file.populate_repo_success'))
16✔
610
      else
611
        flash_message(:error, I18n.t('assignments.starter_file.populate_repo_error'))
×
612
      end
613
    end
614
    redirect_to course_assignment_path(current_course, assignment)
16✔
615
  end
616

617
  def auto_match
1✔
618
    assignment = Assignment.find(params[:assignment_id])
×
619
    grouping_ids = params[:groupings]
×
620
    exam_template_id = params[:exam_template_id]
×
621
    groupings = assignment.groupings.find(grouping_ids)
×
622
    exam_template = assignment.exam_templates.find(exam_template_id)
×
623

624
    @current_job = AutoMatchJob.perform_later groupings, exam_template
×
625
    session[:job_id] = @current_job.job_id
×
626

627
    respond_to do |format|
×
628
      format.js { render 'shared/_poll_job' }
×
629
    end
630
  end
631

632
  private
1✔
633

634
  def create_groups_with_socket(assignment, data)
1✔
635
    enqueuing_user = current_user
12✔
636
    current_job = CreateGroupsJob.perform_later assignment, data,
12✔
637
                                                enqueuing_user: enqueuing_user,
638
                                                notify_socket: true
639
    GroupsChannel.broadcast_to(enqueuing_user, ActiveJob::Status.get(current_job).to_h) if enqueuing_user
12✔
640
  end
641

642
  # These methods are called through global actions.
643

644
  # Check that there is at least one grouping selected
645
  def check_for_groupings(groupings)
1✔
646
    if groupings.blank?
17✔
647
      raise I18n.t('groups.select_a_group')
1✔
648
    end
649
  end
650

651
  # Given a list of grouping, sets their group status to invalid if possible
652
  def invalidate_groupings(groupings)
1✔
653
    groupings.each(&:invalidate_grouping)
1✔
654
  end
655

656
  # Given a list of grouping, sets their group status to valid if possible
657
  def validate_groupings(groupings)
1✔
658
    groupings.each(&:validate_grouping)
1✔
659
  end
660

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

679
  # Adds students to grouping. `groupings` should be an array with
680
  # only one element, which is the grouping that is supposed to be
681
  # added to.
682
  def add_members(students, groupings, assignment)
1✔
683
    if groupings.size != 1
9✔
684
      raise I18n.t('groups.select_only_one_group')
1✔
685
    end
686
    if students.blank?
8✔
687
      raise I18n.t('groups.select_a_student')
1✔
688
    end
689

690
    grouping = groupings.first
7✔
691

692
    students.each do |student|
7✔
693
      add_member(student, grouping, assignment)
9✔
694
    end
695

696
    # Generate warning if the number of people assigned to a group exceeds
697
    # the maximum size of a group
698
    students_in_group = grouping.student_membership_number
5✔
699
    group_name = grouping.group.group_name
5✔
700
    if assignment.student_form_groups && (students_in_group > assignment.group_max)
5✔
701
      raise I18n.t('groups.assign_over_limit', group: group_name)
1✔
702
    end
703
  end
704

705
  # Adds the student given in student_id to the grouping given in grouping
706
  def add_member(student, grouping, assignment)
1✔
707
    set_membership_status = if grouping.student_memberships.empty?
9✔
708
                              StudentMembership::STATUSES[:inviter]
1✔
709
                            else
710
                              StudentMembership::STATUSES[:accepted]
8✔
711
                            end
712
    @bad_user_names = []
9✔
713

714
    if student.has_accepted_grouping_for?(assignment.id)
9✔
715
      raise I18n.t('groups.invite_member.errors.already_grouped', user_name: student.user_name)
1✔
716
    end
717
    errors = grouping.invite(student.user_name, set_membership_status, invoked_by_instructor: true)
8✔
718
    grouping.reload
8✔
719

720
    if errors.present?
8✔
721
      raise errors.join(' ')
1✔
722
    end
723

724
    # Generate a warning if a member is added to a group and they
725
    # have fewer grace days credits than already used by that group
726
    if student.remaining_grace_credits < grouping.grace_period_deduction_single
7✔
727
      flash_message(:warning, I18n.t('groups.grace_day_over_limit', group: grouping.group.group_name))
1✔
728
    end
729

730
    grouping.reload
7✔
731
  end
732

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

750
  # Removes the given student membership from the given grouping
751
  def remove_member(membership, grouping)
1✔
752
    grouping.remove_member(membership.id)
2✔
753
    grouping.reload
2✔
754
  end
755

756
  # Format OCR suggestions for JSON response
757
  def format_ocr_suggestions(ocr_suggestions)
1✔
758
    ocr_suggestions.map do |s|
19✔
759
      {
760
        id: s[:student].id,
4✔
761
        user_name: s[:student].user.user_name,
762
        id_number: s[:student].user.id_number,
763
        display_name: s[:student].user.display_name,
764
        similarity: (s[:similarity] * 100).round(1)
4✔
765
      }
766
    end
767
  end
768

769
  # This override is necessary because this controller is acting as a controller
770
  # for both groups and groupings.
771
  #
772
  # TODO: move all grouping methods into their own controller and remove this
773
  def record
1✔
774
    @record ||= Grouping.find_by(id: request.path_parameters[:id]) if request.path_parameters[:id]
172✔
775
  end
776
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