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

MarkUsProject / Markus / 20143075828

11 Dec 2025 06:18PM UTC coverage: 91.513%. Remained the same
20143075828

Pull #7763

github

web-flow
Merge 9f55e660a into 3421ef3b2
Pull Request #7763: Release 2.9.0

914 of 1805 branches covered (50.64%)

Branch coverage included in aggregate %.

1584 of 1666 new or added lines in 108 files covered. (95.08%)

573 existing lines in 35 files now uncovered.

43650 of 46892 relevant lines covered (93.09%)

121.63 hits per line

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

82.57
/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! }
144✔
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✔
NEW
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

187
    names = names.map do |h|
9✔
188
      inactive = h['roles.hidden'] ? I18n.t('student.inactive') : ''
9✔
189
      { id: h[:id],
9✔
190
        id_number: h['users.id_number'],
191
        user_name: h['users.user_name'],
192
        value: "#{h['users.first_name']} #{h['users.last_name']}#{inactive}" }
193
    end
194
    render json: names
9✔
195
  end
196

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

209
      # if the user has typed in the whole name without select, or if they typed a name different from the select s_id
210
      if student.nil? || "#{student.first_name} #{student.last_name}" != student_name
10✔
211
        student = current_course.students.joins(:user).where(
6✔
212
          'lower(CONCAT(first_name, \' \', last_name)) like ? OR lower(CONCAT(last_name, \' \', first_name)) like ?',
213
          ApplicationRecord.sanitize_sql_like(student_name.downcase),
214
          ApplicationRecord.sanitize_sql_like(student_name.downcase)
215
        ).first
216
      end
217
      if student.nil?
10✔
218
        flash_message(:error, t('exam_templates.assign_scans.student_not_found', name: params[:names]))
2✔
219
        head :not_found
2✔
220
        return
2✔
221
      end
222
      StudentMembership
8✔
223
        .find_or_create_by(role: student, grouping: @grouping, membership_status: StudentMembership::STATUSES[:inviter])
224
    end
225
    next_grouping
8✔
226
  end
227

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

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

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

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

316
    respond_to do |format|
6✔
317
      format.js { render 'shared/_poll_job' }
12✔
318
    end
319
  end
320

321
  def download
1✔
322
    # TODO: make this a member route in a new SubmissionFileController
UNCOV
323
    file = SubmissionFile.find(params[:select_file_id])
×
324
    # TODO: remove this check once this is moved to a new SubmissionFileController
UNCOV
325
    return page_not_found unless file.course == current_course
×
326

UNCOV
327
    file_contents = file.retrieve_file
×
328

UNCOV
329
    filename = file.filename
×
330
    send_data_download file_contents, filename: filename
×
331
  end
332

333
  def download_grouplist
1✔
334
    assignment = Assignment.find(params[:assignment_id])
5✔
335
    groupings = assignment.groupings.includes(:group, student_memberships: :role)
5✔
336

337
    file_out = MarkusCsv.generate(groupings) do |grouping|
5✔
338
      # csv format is group_name, repo_name, user1_name, user2_name, ... etc
339
      [grouping.group.group_name].concat(
5✔
340
        grouping.student_memberships.map do |member|
341
          member.role.user_name
10✔
342
        end
343
      )
344
    end
345

346
    send_data(file_out,
5✔
347
              type: 'text/csv',
348
              filename: "#{assignment.short_identifier}_group_list.csv",
349
              disposition: 'attachment')
350
  end
351

352
  def use_another_assignment_groups
1✔
UNCOV
353
    target_assignment = Assignment.find(params[:assignment_id])
×
UNCOV
354
    source_assignment = Assignment.find(params[:clone_assignment_id])
×
355

NEW
356
    return head :unprocessable_content if target_assignment.course != source_assignment.course
×
357

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

UNCOV
370
    redirect_back(fallback_location: root_path)
×
371
  end
372

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

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

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

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

460
  def invite_member
1✔
461
    @assignment = Assignment.find(params[:assignment_id])
4✔
462

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

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

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

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

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

537
      # Students are only needed for assign/unassign so don't
538
      # need to check.
539
      students = Student.where(id: student_ids)
9✔
540

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

560
  def download_starter_file
1✔
561
    assignment = Assignment.find(params[:assignment_id])
6✔
562
    grouping = current_role.accepted_grouping_for(assignment.id)
6✔
563

564
    authorize! grouping, with: GroupingPolicy
6✔
565

566
    grouping.reset_starter_file_entries if grouping.starter_file_changed
4✔
567

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

577
  def populate_repo_with_starter_files
1✔
578
    assignment = Assignment.find(params[:assignment_id])
19✔
579
    grouping = current_role.accepted_grouping_for(assignment.id)
19✔
580

581
    authorize! grouping, with: GroupingPolicy
19✔
582

583
    grouping.reset_starter_file_entries if grouping.starter_file_changed
16✔
584

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

597
  def auto_match
1✔
598
    assignment = Assignment.find(params[:assignment_id])
×
UNCOV
599
    grouping_ids = params[:groupings]
×
UNCOV
600
    exam_template_id = params[:exam_template_id]
×
UNCOV
601
    groupings = assignment.groupings.find(grouping_ids)
×
UNCOV
602
    exam_template = assignment.exam_templates.find(exam_template_id)
×
603

UNCOV
604
    @current_job = AutoMatchJob.perform_later groupings, exam_template
×
605
    session[:job_id] = @current_job.job_id
×
606

607
    respond_to do |format|
×
608
      format.js { render 'shared/_poll_job' }
×
609
    end
610
  end
611

612
  private
1✔
613

614
  # These methods are called through global actions.
615

616
  # Check that there is at least one grouping selected
617
  def check_for_groupings(groupings)
1✔
618
    if groupings.blank?
9✔
UNCOV
619
      raise I18n.t('groups.select_a_group')
×
620
    end
621
  end
622

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

628
  # Given a list of grouping, sets their group status to valid if possible
629
  def validate_groupings(groupings)
1✔
630
    groupings.each(&:validate_grouping)
1✔
631
  end
632

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

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

662
    grouping = groupings.first
2✔
663

664
    students.each do |student|
2✔
665
      add_member(student, grouping, assignment)
4✔
666
    end
667

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

677
  # Adds the student given in student_id to the grouping given in grouping
678
  def add_member(student, grouping, assignment)
1✔
679
    set_membership_status = if grouping.student_memberships.empty?
4✔
680
                              StudentMembership::STATUSES[:inviter]
×
681
                            else
682
                              StudentMembership::STATUSES[:accepted]
4✔
683
                            end
684
    @bad_user_names = []
4✔
685

686
    if student.hidden
4✔
687
      raise I18n.t('groups.invite_member.errors.not_found', user_name: student.user_name)
×
688
    end
689
    if student.has_accepted_grouping_for?(assignment.id)
4✔
UNCOV
690
      raise I18n.t('groups.invite_member.errors.already_grouped', user_name: student.user_name)
×
691
    end
692
    errors = grouping.invite(student.user_name, set_membership_status, invoked_by_instructor: true)
4✔
693
    grouping.reload
4✔
694

695
    if errors.present?
4✔
UNCOV
696
      raise errors.join(' ')
×
697
    end
698

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

705
    grouping.reload
4✔
706
  end
707

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

725
  # Removes the given student membership from the given grouping
726
  def remove_member(membership, grouping)
1✔
727
    grouping.remove_member(membership.id)
2✔
728
    grouping.reload
2✔
729
  end
730

731
  # This override is necessary because this controller is acting as a controller
732
  # for both groups and groupings.
733
  #
734
  # TODO: move all grouping methods into their own controller and remove this
735
  def record
1✔
736
    @record ||= Grouping.find_by(id: request.path_parameters[:id]) if request.path_parameters[:id]
153✔
737
  end
738
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc