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

MarkUsProject / Markus / 18330336361

08 Oct 2025 12:50AM UTC coverage: 91.852% (+0.005%) from 91.847%
18330336361

Pull #7690

github

web-flow
Merge ae9a5bfe9 into 738f8f02c
Pull Request #7690: #7674: Ordering Sections on the Section-Specific Settings Table for Timed Assessments

715 of 1504 branches covered (47.54%)

Branch coverage included in aggregate %.

0 of 1 new or added line in 1 file covered. (0.0%)

40 existing lines in 1 file now uncovered.

42346 of 45377 relevant lines covered (93.32%)

119.92 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.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])
12✔
130
    if params.key?(:grouping_id)
12✔
131
      next_grouping = @assignment.groupings.find(params[:grouping_id])
6✔
132
    else
133
      next_grouping = Grouping.get_assign_scans_grouping(@assignment)
6✔
134
    end
135
    if next_grouping&.current_submission_used.nil?
12✔
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(fallback_location: course_assignment_groups_path(current_course, @assignment))
4✔
140
      return
4✔
141
    end
142
    names = next_grouping.non_rejected_student_memberships.map do |u|
8✔
143
      u.user.display_name
4✔
144
    end
145
    num_valid = @assignment.get_num_valid
8✔
146
    num_total = @assignment.groupings.size
8✔
147
    if num_valid == num_total
8✔
148
      flash_message(:success, t('exam_templates.assign_scans.done'))
4✔
149
    end
150
    @data = {
151
      group_name: next_grouping.group.group_name,
8✔
152
      grouping_id: next_grouping.id,
153
      students: names,
154
      num_total: num_total,
155
      num_valid: num_valid
156
    }
157
    next_file = next_grouping.current_submission_used.submission_files.find_by(filename: 'COVER.pdf')
8✔
158
    if next_file.nil?
8✔
159
      flash_message(:warning, I18n.t('exam_templates.assign_scans.no_cover_page'))
6✔
160
    else
161
      @data[:filelink] = download_course_assignment_groups_path(
2✔
162
        current_course, @assignment,
163
        select_file_id: next_grouping.current_submission_used.submission_files.find_by(filename: 'COVER.pdf').id,
164
        show_in_browser: true
165
      )
166
    end
167
  end
168

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

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

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

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

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

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

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

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

322
    file_contents = file.retrieve_file
×
323

UNCOV
324
    filename = file.filename
×
UNCOV
325
    send_data_download file_contents, filename: filename
×
326
  end
327

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

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

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

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

351
    return head :unprocessable_content if target_assignment.course != source_assignment.course
×
352

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

UNCOV
365
    redirect_back(fallback_location: root_path)
×
366
  end
367

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

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

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

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

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

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

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

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

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

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

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

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

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

559
    authorize! grouping, with: GroupingPolicy
6✔
560

561
    grouping.reset_starter_file_entries if grouping.starter_file_changed
4✔
562

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

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

576
    authorize! grouping, with: GroupingPolicy
19✔
577

578
    grouping.reset_starter_file_entries if grouping.starter_file_changed
16✔
579

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

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

UNCOV
599
    @current_job = AutoMatchJob.perform_later groupings, exam_template
×
600
    session[:job_id] = @current_job.job_id
×
601

UNCOV
602
    respond_to do |format|
×
UNCOV
603
      format.js { render 'shared/_poll_job' }
×
604
    end
605
  end
606

607
  private
1✔
608

609
  # These methods are called through global actions.
610

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

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

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

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

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

657
    grouping = groupings.first
2✔
658

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

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

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

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

690
    if errors.present?
4✔
UNCOV
691
      raise errors.join(' ')
×
692
    end
693

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

700
    grouping.reload
4✔
701
  end
702

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

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

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