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

MarkUsProject / Markus / 14766535248

30 Apr 2025 11:47PM UTC coverage: 91.901%. First build
14766535248

Pull #7499

github

web-flow
Merge e4e92e092 into 5cd776bf6
Pull Request #7499: Decouple matching from scanning job

631 of 1371 branches covered (46.02%)

Branch coverage included in aggregate %.

86 of 100 new or added lines in 5 files covered. (86.0%)

41741 of 44735 relevant lines covered (93.31%)

117.45 hits per line

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

82.43
/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! }
139✔
6
  layout 'assignment_content'
1✔
7

8
  content_security_policy only: [:assign_scans] do |p|
1✔
9
    p.img_src :self, :blob
12✔
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
×
121
      end
122
    end
123
  end
124

125
  def assign_scans
1✔
126
    # TODO: make this a member route in a new GroupingsController
127
    @assignment = Assignment.find(params[:assignment_id])
12✔
128
    if params.key?(:grouping_id)
12✔
129
      next_grouping = @assignment.groupings.find(params[:grouping_id])
6✔
130
    else
131
      next_grouping = Grouping.get_assign_scans_grouping(@assignment)
6✔
132
    end
133
    if next_grouping&.current_submission_used.nil?
12✔
134
      if @assignment.groupings.left_outer_joins(:current_submission_used).where('submissions.id': nil).any?
4✔
135
        flash_message(:warning, I18n.t('exam_templates.assign_scans.not_all_submissions_collected'))
3✔
136
      end
137
      redirect_back(fallback_location: course_assignment_groups_path(current_course, @assignment))
4✔
138
      return
4✔
139
    end
140
    names = next_grouping.non_rejected_student_memberships.map do |u|
8✔
141
      u.user.display_name
4✔
142
    end
143
    num_valid = @assignment.get_num_valid
8✔
144
    num_total = @assignment.groupings.size
8✔
145
    if num_valid == num_total
8✔
146
      flash_message(:success, t('exam_templates.assign_scans.done'))
4✔
147
    end
148
    @data = {
149
      group_name: next_grouping.group.group_name,
8✔
150
      grouping_id: next_grouping.id,
151
      students: names,
152
      num_total: num_total,
153
      num_valid: num_valid
154
    }
155
    next_file = next_grouping.current_submission_used.submission_files.find_by(filename: 'COVER.pdf')
8✔
156
    if next_file.nil?
8✔
157
      flash_message(:warning, I18n.t('exam_templates.assign_scans.no_cover_page'))
6✔
158
    else
159
      @data[:filelink] = download_course_assignment_groups_path(
2✔
160
        current_course, @assignment,
161
        select_file_id: next_grouping.current_submission_used.submission_files.find_by(filename: 'COVER.pdf').id,
162
        show_in_browser: true
163
      )
164
    end
165
  end
166

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

193
  def assign_student_and_next
1✔
194
    # TODO: make this a member route in a new GroupingsController
195
    @grouping = Grouping.joins(:assignment).where('assessments.course_id': current_course.id).find(params[:g_id])
5✔
196
    @assignment = @grouping.assignment
5✔
197
    unless params[:skip]
5✔
198
      # if the user has selected a name from the dropdown, s_id is set
199
      if params[:s_id].present?
5✔
200
        student = current_course.students.find(params[:s_id])
2✔
201
      end
202
      # if the user has typed in the whole name without select, or if they typed a name different from the select s_id
203
      if student.nil? || "#{student.first_name} #{student.last_name}" != params[:names]
5✔
204
        student = current_course.students.joins(:user).where(
3✔
205
          'lower(CONCAT(first_name, \' \', last_name)) like ? OR lower(CONCAT(last_name, \' \', first_name)) like ?',
206
          ApplicationRecord.sanitize_sql_like(params[:names].downcase),
207
          ApplicationRecord.sanitize_sql_like(params[:names].downcase)
208
        ).first
209
      end
210
      if student.nil?
5✔
211
        flash_message(:error, t('exam_templates.assign_scans.student_not_found', name: params[:names]))
2✔
212
        head :not_found
2✔
213
        return
2✔
214
      end
215
      StudentMembership
3✔
216
        .find_or_create_by(role: student, grouping: @grouping, membership_status: StudentMembership::STATUSES[:inviter])
217
    end
218
    next_grouping
3✔
219
  end
220

221
  def next_grouping
1✔
222
    # TODO: is this actually a route that is called from anywhere or just a helper method?
223
    if params[:a_id].present?
3✔
224
      @assignment = Assignment.find(params[:a_id])
×
225
    end
226
    next_grouping = Grouping.get_assign_scans_grouping(@assignment, params[:g_id])
3✔
227
    if next_grouping.nil?
3✔
228
      head :not_found
1✔
229
      return
1✔
230
    end
231
    names = next_grouping.non_rejected_student_memberships.map do |u|
2✔
232
      u.user.display_name
×
233
    end
234
    num_valid = @assignment.get_num_valid
2✔
235
    num_total = @assignment.groupings.size
2✔
236
    if num_valid == num_total
2✔
237
      flash_message(:success, t('exam_templates.assign_scans.done'))
×
238
    end
239
    if !@grouping.nil? && next_grouping.id == @grouping.id
2✔
240
      render json: {
×
241
        grouping_id: next_grouping.id,
242
        students: names,
243
        num_total: num_total,
244
        num_valid: num_valid
245
      }
246
    else
247
      data = {
248
        group_name: next_grouping.group.group_name,
2✔
249
        grouping_id: next_grouping.id,
250
        students: names,
251
        num_total: num_total,
252
        num_valid: num_valid
253
      }
254
      next_file = next_grouping.current_submission_used.submission_files.find_by(filename: 'COVER.pdf')
2✔
255
      unless next_file.nil?
2✔
256
        data[:filelink] = download_course_assignment_groups_path(
×
257
          current_course,
258
          @assignment,
259
          select_file_id: next_grouping.current_submission_used.submission_files.find_by(filename: 'COVER.pdf').id,
260
          show_in_browser: true
261
        )
262
      end
263
      render json: data
2✔
264
    end
265
  end
266

267
  # Allows the user to upload a csv file listing groups. If group_name is equal
268
  # to the only member of a group and the assignment is configured with
269
  # allow_web_subits == false, the student's username will be used as the
270
  # repository name. If MarkUs is not repository admin, the repository name as
271
  # specified by the second field will be used instead.
272
  def upload
1✔
273
    assignment = Assignment.find(params[:assignment_id])
13✔
274
    begin
275
      data = process_file_upload(['.csv'])
13✔
276
    rescue StandardError => e
277
      flash_message(:error, e.message)
3✔
278
    else
279
      group_rows = []
10✔
280
      result = MarkusCsv.parse(data[:contents], encoding: data[:encoding]) do |row|
10✔
281
        next if row.blank?
22✔
282
        raise CsvInvalidLineError if row[0].blank?
22✔
283

284
        group_rows << row.compact_blank
21✔
285
      end
286
      if result[:invalid_lines].empty?
10✔
287
        @current_job = CreateGroupsJob.perform_later assignment, group_rows
3✔
288
        session[:job_id] = @current_job.job_id
3✔
289
      else
290
        flash_message(:error, result[:invalid_lines])
7✔
291
      end
292
    end
293
    redirect_to course_assignment_groups_path(current_course, assignment)
13✔
294
  end
295

296
  def create_groups_when_students_work_alone
1✔
297
    @assignment = Assignment.find(params[:assignment_id])
6✔
298
    if @assignment.group_max == 1
6✔
299
      # data is a list of lists containing: [[group_name, group_member], ...]
300
      data = current_course.students
3✔
301
                           .joins(:user)
302
                           .where(hidden: false)
303
                           .pluck('users.user_name')
304
                           .map { |user_name| [user_name, user_name] }
15✔
305
      @current_job = CreateGroupsJob.perform_later @assignment, data
3✔
306
      session[:job_id] = @current_job.job_id
3✔
307
    end
308

309
    respond_to do |format|
6✔
310
      format.js { render 'shared/_poll_job' }
12✔
311
    end
312
  end
313

314
  def download
1✔
315
    # TODO: make this a member route in a new SubmissionFileController
316
    file = SubmissionFile.find(params[:select_file_id])
×
317
    # TODO: remove this check once this is moved to a new SubmissionFileController
318
    return page_not_found unless file.course == current_course
×
319

320
    file_contents = file.retrieve_file
×
321

322
    filename = file.filename
×
323
    send_data_download file_contents, filename: filename
×
324
  end
325

326
  def download_grouplist
1✔
327
    assignment = Assignment.find(params[:assignment_id])
5✔
328
    groupings = assignment.groupings.includes(:group, student_memberships: :role)
5✔
329

330
    file_out = MarkusCsv.generate(groupings) do |grouping|
5✔
331
      # csv format is group_name, repo_name, user1_name, user2_name, ... etc
332
      [grouping.group.group_name].concat(
5✔
333
        grouping.student_memberships.map do |member|
334
          member.role.user_name
10✔
335
        end
336
      )
337
    end
338

339
    send_data(file_out,
5✔
340
              type: 'text/csv',
341
              filename: "#{assignment.short_identifier}_group_list.csv",
342
              disposition: 'attachment')
343
  end
344

345
  def use_another_assignment_groups
1✔
346
    target_assignment = Assignment.find(params[:assignment_id])
×
347
    source_assignment = Assignment.find(params[:clone_assignment_id])
×
348

349
    return head :unprocessable_entity if target_assignment.course != source_assignment.course
×
350

351
    if source_assignment.nil?
×
352
      flash_message(:warning, t('groups.clone_warning.could_not_find_source'))
×
353
    elsif target_assignment.nil?
×
354
      flash_message(:warning, t('groups.clone_warning.could_not_find_target'))
×
355
    else
356
      # Clone the groupings
357
      clone_warnings = target_assignment.clone_groupings_from(source_assignment.id)
×
358
      unless clone_warnings.empty?
×
359
        clone_warnings.each { |w| flash_message(:warning, w) }
×
360
      end
361
    end
362

363
    redirect_back(fallback_location: root_path)
×
364
  end
365

366
  def accept_invitation
1✔
367
    # TODO: make this a member route in a new GroupingsController
368
    @assignment = Assignment.find(params[:assignment_id])
6✔
369
    @grouping = @assignment.groupings.find(params[:grouping_id])
6✔
370
    begin
371
      current_role.join(@grouping)
6✔
372
    rescue ActiveRecord::RecordInvalid, RuntimeError => e
373
      flash_message(:error, e.message)
3✔
374
      status = :unprocessable_entity
3✔
375
    else
376
      m_logger = MarkusLogger.instance
3✔
377
      m_logger.log("Student '#{current_role.user_name}' joined group " \
3✔
378
                   "'#{@grouping.group.group_name}'(accepted invitation).")
379
      status = :found
3✔
380
    end
381
    redirect_to course_assignment_path(current_course, @assignment), status: status
6✔
382
  end
383

384
  def decline_invitation
1✔
385
    # TODO: make this a member route in a new GroupingsController
386
    @assignment = Assignment.find(params[:assignment_id])
3✔
387
    @grouping = @assignment.groupings.find(params[:grouping_id])
3✔
388
    begin
389
      @grouping.decline_invitation(current_role)
3✔
390
    rescue RuntimeError => e
391
      flash_message(:error, e.message)
2✔
392
      status = :unprocessable_entity
2✔
393
    else
394
      m_logger = MarkusLogger.instance
1✔
395
      m_logger.log("Student '#{current_role.user_name}' declined invitation for group '#{@grouping.group.group_name}'.")
1✔
396
      status = :found
1✔
397
    end
398
    redirect_to course_assignment_path(current_course, @assignment), status: status
3✔
399
  end
400

401
  def create
1✔
402
    @assignment = Assignment.find(params[:assignment_id])
1✔
403
    @student = current_role
1✔
404
    m_logger = MarkusLogger.instance
1✔
405
    begin
406
      return unless flash_allowance(:error, allowance_to(:create_group?, @assignment)).value
1✔
407
      if params[:workalone]
×
408
        return unless flash_allowance(:error, allowance_to(:work_alone?, @assignment)).value
×
409
        @student.create_group_for_working_alone_student(@assignment.id)
×
410
      else
411
        return unless flash_allowance(:error, allowance_to(:autogenerate_group_name?, @assignment)).value
×
412
        @student.create_autogenerated_name_group(@assignment)
×
413
      end
414
      m_logger.log("Student '#{@student.user_name}' created group.", MarkusLogger::INFO)
×
415
    rescue RuntimeError => e
416
      flash_message(:error, e.message)
×
417
      m_logger.log("Failed to create group. User: '#{@student.user_name}', Error: '#{e.message}'.", MarkusLogger::ERROR)
×
418
    end
419
  ensure
420
    redirect_to course_assignment_path(current_course, @assignment)
1✔
421
  end
422

423
  def destroy
1✔
424
    @assignment = Assignment.find(params[:assignment_id])
1✔
425
    @grouping = current_role.accepted_grouping_for(@assignment.id)
1✔
426
    m_logger = MarkusLogger.instance
1✔
427
    if @grouping.nil?
1✔
428
      m_logger.log('Failed to delete group, since no accepted group for this user existed.' \
1✔
429
                   "User: '#{current_role.user_name}'.", MarkusLogger::ERROR)
430
      flash_message(:error, I18n.t('groups.destroy.errors.do_not_have_a_group'))
1✔
431
      redirect_to course_assignment_path(current_course, @assignment)
1✔
432
      return
1✔
433
    end
434
    if flash_allowance(:error, allowance_to(:destroy?, @grouping)).value
×
435
      begin
436
        Repository.get_class.update_permissions_after(only_on_request: true) do
×
437
          @grouping.student_memberships.each do |member|
×
438
            @grouping.remove_member(member.id)
×
439
          end
440
        end
441
        @grouping.destroy
×
442
        flash_message(:success, I18n.t('flash.actions.destroy.success', resource_name: Group.model_name.human))
×
443
        m_logger.log("Student '#{current_role.user_name}' deleted group '" \
×
444
                     "#{@grouping.group.group_name}'.", MarkusLogger::INFO)
445
      rescue RuntimeError => e
446
        m_logger.log("Failed to delete group '#{@grouping.group.group_name}'. User: '" \
×
447
                     "#{current_role.user_name}', Error: '#{e.message}'.", MarkusLogger::ERROR)
448
      end
449
    end
450
    redirect_to course_assignment_path(current_course, @assignment)
×
451
  end
452

453
  def invite_member
1✔
454
    @assignment = Assignment.find(params[:assignment_id])
4✔
455

456
    @grouping = current_role.accepted_grouping_for(@assignment.id)
4✔
457
    if @grouping.nil?
4✔
458
      flash_message(:error,
×
459
                    I18n.t('groups.invite_member.errors.need_to_create_group'))
460
      redirect_to course_assignment_path(@course, @assignment)
×
461
      return
×
462
    end
463
    if flash_allowance(:error, allowance_to(:invite_member?, @grouping)).value
4✔
464
      to_invite = params[:invite_member].split(',')
4✔
465
      errors = @grouping.invite(to_invite)
4✔
466
      if errors.blank?
4✔
467
        to_invite.each do |i|
4✔
468
          i = i.strip
6✔
469
          invited_user = current_course.students.joins(:user).where(hidden: false).find_by('users.user_name': i)
6✔
470
          if invited_user.receives_invite_emails?
6✔
471
            NotificationMailer.with(inviter: current_role,
4✔
472
                                    invited: invited_user,
473
                                    grouping: @grouping).grouping_invite_email.deliver_later
474
          end
475
        end
476
        flash_message(:success, I18n.t('groups.invite_member.success'))
4✔
477
      else
478
        flash_message(:error, errors.join(' '))
×
479
      end
480
    end
481
    redirect_to course_assignment_path(current_course, @assignment.id)
4✔
482
  end
483

484
  # Deletes pending invitations
485
  def disinvite_member
1✔
486
    assignment = Assignment.find(params[:assignment_id])
4✔
487
    membership = assignment.student_memberships.find(params[:membership])
4✔
488
    authorized = flash_allowance(:error, allowance_to(:disinvite_member?,
4✔
489
                                                      membership.grouping,
490
                                                      context: { membership: membership })).value
491
    if authorized
4✔
492
      disinvited_student = membership.role
1✔
493
      membership.destroy
1✔
494
      m_logger = MarkusLogger.instance
1✔
495
      m_logger.log("Student '#{current_role.user_name}' cancelled invitation for '#{disinvited_student.user_name}'.")
1✔
496
      flash_message(:success, I18n.t('groups.members.member_disinvited'))
1✔
497
    end
498
    status = authorized ? :found : :forbidden
4✔
499
    redirect_to course_assignment_path(current_course, assignment.id), status: status
4✔
500
  end
501

502
  # Deletes memberships which have been declined by students
503
  def delete_rejected
1✔
504
    @assignment = Assignment.find(params[:assignment_id])
5✔
505
    membership = @assignment.student_memberships.find(params[:membership])
5✔
506
    grouping = membership.grouping
5✔
507
    authorized = flash_allowance(:error,
5✔
508
                                 allowance_to(:delete_rejected?, grouping, context: { membership: membership })).value
509
    membership.destroy if authorized
5✔
510
    status = authorized ? :found : :forbidden
5✔
511
    redirect_to course_assignment_path(current_course, @assignment), status: status
5✔
512
  end
513

514
  # These actions act on all currently selected students & groups
515
  def global_actions
1✔
516
    assignment = Assignment.includes([{ groupings: [{ student_memberships: :role, ta_memberships: :role }, :group] }])
9✔
517
                           .find(params[:assignment_id])
518
    grouping_ids = params[:groupings]
9✔
519
    student_ids = params[:students]
9✔
520
    students_to_remove = params[:students_to_remove]
9✔
521

522
    # Start exception catching. If an exception is raised,
523
    # return http response code of 400 (bad request) along
524
    # the error string. The front-end should get it and display
525
    # the message in an error div.
526
    begin
527
      groupings = Grouping.where(id: grouping_ids)
9✔
528
      check_for_groupings(groupings)
9✔
529

530
      # Students are only needed for assign/unassign so don't
531
      # need to check.
532
      students = Student.where(id: student_ids)
9✔
533

534
      case params[:global_actions]
9✔
535
      when 'delete'
536
        delete_groupings(groupings)
2✔
537
      when 'invalid'
538
        invalidate_groupings(groupings)
1✔
539
      when 'valid'
540
        validate_groupings(groupings)
1✔
541
      when 'assign'
542
        add_members(students, groupings, assignment)
2✔
543
      when 'unassign'
544
        remove_members(students_to_remove, groupings)
3✔
545
      end
546
      head :ok
8✔
547
    rescue StandardError => e
548
      flash_now(:error, e.message)
1✔
549
      head :bad_request
1✔
550
    end
551
  end
552

553
  def download_starter_file
1✔
554
    assignment = Assignment.find(params[:assignment_id])
6✔
555
    grouping = current_role.accepted_grouping_for(assignment.id)
6✔
556

557
    authorize! grouping, with: GroupingPolicy
6✔
558

559
    grouping.reset_starter_file_entries if grouping.starter_file_changed
4✔
560

561
    zip_name = "#{assignment.short_identifier}-starter-files-#{current_role.user_name}"
4✔
562
    zip_path = File.join('tmp', zip_name + '.zip')
4✔
563
    FileUtils.rm_rf zip_path
4✔
564
    Zip::File.open(zip_path, create: true) do |zip_file|
4✔
565
      grouping.starter_file_entries.reload.each { |entry| entry.add_files_to_zip_file(zip_file) }
12✔
566
    end
567
    send_file zip_path, filename: File.basename(zip_path)
4✔
568
  end
569

570
  def populate_repo_with_starter_files
1✔
571
    assignment = Assignment.find(params[:assignment_id])
19✔
572
    grouping = current_role.accepted_grouping_for(assignment.id)
19✔
573

574
    authorize! grouping, with: GroupingPolicy
19✔
575

576
    grouping.reset_starter_file_entries if grouping.starter_file_changed
16✔
577

578
    grouping.access_repo do |repo|
16✔
579
      txn = repo.get_transaction(current_role.user_name)
16✔
580
      grouping.starter_file_entries.reload.each { |entry| entry.add_files_to_repo(repo, txn) }
48✔
581
      if repo.commit(txn)
16✔
582
        flash_message(:success, I18n.t('assignments.starter_file.populate_repo_success'))
16✔
583
      else
584
        flash_message(:error, I18n.t('assignments.starter_file.populate_repo_error'))
×
585
      end
586
    end
587
    redirect_to course_assignment_path(current_course, assignment)
16✔
588
  end
589

590
  def auto_match
1✔
NEW
591
    assignment = Assignment.find(params[:assignment_id])
×
NEW
592
    grouping_ids = params[:groupings]
×
NEW
593
    exam_template_id = params[:exam_template_id]
×
NEW
594
    groupings = assignment.groupings.find(grouping_ids)
×
NEW
595
    exam_template = assignment.exam_templates.find(exam_template_id)
×
596

NEW
597
    @current_job = AutoMatchJob.perform_later groupings, exam_template
×
NEW
598
    session[:job_id] = @current_job.job_id
×
599

NEW
600
    respond_to do |format|
×
NEW
601
      format.js { render 'shared/_poll_job' }
×
602
    end
603
  end
604

605
  private
1✔
606

607
  # These methods are called through global actions.
608

609
  # Check that there is at least one grouping selected
610
  def check_for_groupings(groupings)
1✔
611
    if groupings.blank?
9✔
612
      raise I18n.t('groups.select_a_group')
×
613
    end
614
  end
615

616
  # Given a list of grouping, sets their group status to invalid if possible
617
  def invalidate_groupings(groupings)
1✔
618
    groupings.each(&:invalidate_grouping)
1✔
619
  end
620

621
  # Given a list of grouping, sets their group status to valid if possible
622
  def validate_groupings(groupings)
1✔
623
    groupings.each(&:validate_grouping)
1✔
624
  end
625

626
  # Deletes the given list of groupings if possible. Removes each member first.
627
  def delete_groupings(groupings)
1✔
628
    # If any groupings have a submission raise an error.
629
    if groupings.any?(&:has_submission?)
2✔
630
      raise I18n.t('groups.could_not_delete') # should add names of grouping we could not delete
1✔
631
    else
632
      # Remove each student from every group.
633
      Repository.get_class.update_permissions_after(only_on_request: true) do
1✔
634
        groupings.each do |grouping|
1✔
635
          grouping.student_memberships.each do |mem|
1✔
636
            grouping.remove_member(mem.id)
1✔
637
          end
638
          grouping.delete_grouping
1✔
639
        end
640
      end
641
    end
642
  end
643

644
  # Adds students to grouping. `groupings` should be an array with
645
  # only one element, which is the grouping that is supposed to be
646
  # added to.
647
  def add_members(students, groupings, assignment)
1✔
648
    if groupings.size != 1
2✔
649
      raise I18n.t('groups.select_only_one_group')
×
650
    end
651
    if students.blank?
2✔
652
      raise I18n.t('groups.select_a_student')
×
653
    end
654

655
    grouping = groupings.first
2✔
656

657
    students.each do |student|
2✔
658
      add_member(student, grouping, assignment)
4✔
659
    end
660

661
    # Generate warning if the number of people assigned to a group exceeds
662
    # the maximum size of a group
663
    students_in_group = grouping.student_membership_number
2✔
664
    group_name = grouping.group.group_name
2✔
665
    if assignment.student_form_groups && (students_in_group > assignment.group_max)
2✔
666
      raise I18n.t('groups.assign_over_limit', group: group_name)
×
667
    end
668
  end
669

670
  # Adds the student given in student_id to the grouping given in grouping
671
  def add_member(student, grouping, assignment)
1✔
672
    set_membership_status = if grouping.student_memberships.empty?
4✔
673
                              StudentMembership::STATUSES[:inviter]
×
674
                            else
675
                              StudentMembership::STATUSES[:accepted]
4✔
676
                            end
677
    @bad_user_names = []
4✔
678

679
    if student.hidden
4✔
680
      raise I18n.t('groups.invite_member.errors.not_found', user_name: student.user_name)
×
681
    end
682
    if student.has_accepted_grouping_for?(assignment.id)
4✔
683
      raise I18n.t('groups.invite_member.errors.already_grouped', user_name: student.user_name)
×
684
    end
685
    errors = grouping.invite(student.user_name, set_membership_status, invoked_by_instructor: true)
4✔
686
    grouping.reload
4✔
687

688
    if errors.present?
4✔
689
      raise errors.join(' ')
×
690
    end
691

692
    # Generate a warning if a member is added to a group and they
693
    # have fewer grace days credits than already used by that group
694
    if student.remaining_grace_credits < grouping.grace_period_deduction_single
4✔
695
      @warning_grace_day = I18n.t('groups.grace_day_over_limit', group: grouping.group.group_name)
×
696
    end
697

698
    grouping.reload
4✔
699
  end
700

701
  # Removes the students with user names in +member_names+ from the
702
  # groupings in +groupings+. This removes any type of student membership
703
  # even pending memberships.
704
  #
705
  # This is meant to be called with the params from global_actions
706
  def remove_members(member_names, groupings)
1✔
707
    members_to_remove = current_course.students.joins(:user).where('users.user_name': member_names)
3✔
708
    Repository.get_class.update_permissions_after(only_on_request: true) do
3✔
709
      members_to_remove.each do |member|
3✔
710
        groupings.each do |grouping|
2✔
711
          membership = grouping.student_memberships.find_by(role_id: member.id)
2✔
712
          remove_member(membership, grouping)
2✔
713
        end
714
      end
715
    end
716
  end
717

718
  # Removes the given student membership from the given grouping
719
  def remove_member(membership, grouping)
1✔
720
    grouping.remove_member(membership.id)
2✔
721
    grouping.reload
2✔
722
  end
723

724
  # This override is necessary because this controller is acting as a controller
725
  # for both groups and groupings.
726
  #
727
  # TODO: move all grouping methods into their own controller and remove this
728
  def record
1✔
729
    @record ||= Grouping.find_by(id: request.path_parameters[:id]) if request.path_parameters[:id]
148✔
730
  end
731
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