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

MarkUsProject / Markus / 12485314243

24 Dec 2024 07:02PM UTC coverage: 91.756% (-0.002%) from 91.758%
12485314243

Pull #7303

github

web-flow
Merge 776a049eb into 919aac97e
Pull Request #7303: build(deps): bump sinatra from 3.1.0 to 4.1.0

623 of 1357 branches covered (45.91%)

Branch coverage included in aggregate %.

9 of 9 new or added lines in 3 files covered. (100.0%)

121 existing lines in 4 files now uncovered.

41170 of 44191 relevant lines covered (93.16%)

120.53 hits per line

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

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

8
  skip_forgery_protection only: [:download]  # Allow Javascript files to be downloaded
1✔
9

10
  content_security_policy only: [:assign_scans] do |p|
1✔
11
    p.img_src :self, :blob
12✔
12
  end
13

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

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

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

57
  def rename_group
1✔
58
    @grouping = record
3✔
59
    @assignment = record.assignment
3✔
60
    @group = @grouping.group
3✔
61

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

68
    if existing
3✔
69

70
      # We link the grouping to the group already existing
71

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

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

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

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

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

119
    respond_to do |format|
2✔
120
      format.html
2✔
121
      format.json do
2✔
UNCOV
122
        render json: @assignment.all_grouping_data
×
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
8✔
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 = false 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
                                 Membership.select(:role_id)
181
                                           .joins(:grouping)
182
                                           .where(groupings: { assessment_id: params[:assignment_id] }))
183
                          .pluck_to_hash(:id, 'users.id_number', 'users.user_name',
184
                                         'users.first_name', 'users.last_name')
185
    names = names.map do |h|
8✔
186
      { id: h[:id],
8✔
187
        id_number: h['users.id_number'],
188
        user_name: h['users.user_name'],
189
        value: "#{h['users.first_name']} #{h['users.last_name']}" }
190
    end
191
    render json: names
8✔
192
  end
193

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

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

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

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

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

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

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

321
    file_contents = file.retrieve_file
×
322

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

558
    authorize! grouping, with: GroupingPolicy
6✔
559

560
    grouping.reset_starter_file_entries if grouping.starter_file_changed
4✔
561

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

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

575
    authorize! grouping, with: GroupingPolicy
19✔
576

577
    grouping.reset_starter_file_entries if grouping.starter_file_changed
16✔
578

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

591
  private
1✔
592

593
  # These methods are called through global actions.
594

595
  # Check that there is at least one grouping selected
596
  def check_for_groupings(groupings)
1✔
597
    if groupings.blank?
9✔
UNCOV
598
      raise I18n.t('groups.select_a_group')
×
599
    end
600
  end
601

602
  # Given a list of grouping, sets their group status to invalid if possible
603
  def invalidate_groupings(groupings)
1✔
604
    groupings.each(&:invalidate_grouping)
1✔
605
  end
606

607
  # Given a list of grouping, sets their group status to valid if possible
608
  def validate_groupings(groupings)
1✔
609
    groupings.each(&:validate_grouping)
1✔
610
  end
611

612
  # Deletes the given list of groupings if possible. Removes each member first.
613
  def delete_groupings(groupings)
1✔
614
    # If any groupings have a submission raise an error.
615
    if groupings.any?(&:has_submission?)
2✔
616
      raise I18n.t('groups.could_not_delete') # should add names of grouping we could not delete
1✔
617
    else
618
      # Remove each student from every group.
619
      Repository.get_class.update_permissions_after(only_on_request: true) do
1✔
620
        groupings.each do |grouping|
1✔
621
          grouping.student_memberships.each do |mem|
1✔
622
            grouping.remove_member(mem.id)
1✔
623
          end
624
          grouping.delete_grouping
1✔
625
        end
626
      end
627
    end
628
  end
629

630
  # Adds students to grouping. `groupings` should be an array with
631
  # only one element, which is the grouping that is supposed to be
632
  # added to.
633
  def add_members(students, groupings, assignment)
1✔
634
    if groupings.size != 1
2✔
UNCOV
635
      raise I18n.t('groups.select_only_one_group')
×
636
    end
637
    if students.blank?
2✔
UNCOV
638
      raise I18n.t('groups.select_a_student')
×
639
    end
640

641
    grouping = groupings.first
2✔
642

643
    students.each do |student|
2✔
644
      add_member(student, grouping, assignment)
4✔
645
    end
646

647
    # Generate warning if the number of people assigned to a group exceeds
648
    # the maximum size of a group
649
    students_in_group = grouping.student_membership_number
2✔
650
    group_name = grouping.group.group_name
2✔
651
    if assignment.student_form_groups && (students_in_group > assignment.group_max)
2✔
UNCOV
652
      raise I18n.t('groups.assign_over_limit', group: group_name)
×
653
    end
654
  end
655

656
  # Adds the student given in student_id to the grouping given in grouping
657
  def add_member(student, grouping, assignment)
1✔
658
    set_membership_status = if grouping.student_memberships.empty?
4✔
UNCOV
659
                              StudentMembership::STATUSES[:inviter]
×
660
                            else
661
                              StudentMembership::STATUSES[:accepted]
4✔
662
                            end
663
    @bad_user_names = []
4✔
664

665
    if student.hidden
4✔
UNCOV
666
      raise I18n.t('groups.invite_member.errors.not_found', user_name: student.user_name)
×
667
    end
668
    if student.has_accepted_grouping_for?(assignment.id)
4✔
UNCOV
669
      raise I18n.t('groups.invite_member.errors.already_grouped', user_name: student.user_name)
×
670
    end
671
    errors = grouping.invite(student.user_name, set_membership_status, invoked_by_instructor: true)
4✔
672
    grouping.reload
4✔
673

674
    if errors.present?
4✔
UNCOV
675
      raise errors.join(' ')
×
676
    end
677

678
    # Generate a warning if a member is added to a group and they
679
    # have fewer grace days credits than already used by that group
680
    if student.remaining_grace_credits < grouping.grace_period_deduction_single
4✔
UNCOV
681
      @warning_grace_day = I18n.t('groups.grace_day_over_limit', group: grouping.group.group_name)
×
682
    end
683

684
    grouping.reload
4✔
685
  end
686

687
  # Removes the students with user names in +member_names+ from the
688
  # groupings in +groupings+. This removes any type of student membership
689
  # even pending memberships.
690
  #
691
  # This is meant to be called with the params from global_actions
692
  def remove_members(member_names, groupings)
1✔
693
    members_to_remove = current_course.students.joins(:user).where('users.user_name': member_names)
3✔
694
    Repository.get_class.update_permissions_after(only_on_request: true) do
3✔
695
      members_to_remove.each do |member|
3✔
696
        groupings.each do |grouping|
2✔
697
          membership = grouping.student_memberships.find_by(role_id: member.id)
2✔
698
          remove_member(membership, grouping)
2✔
699
        end
700
      end
701
    end
702
  end
703

704
  # Removes the given student membership from the given grouping
705
  def remove_member(membership, grouping)
1✔
706
    grouping.remove_member(membership.id)
2✔
707
    grouping.reload
2✔
708
  end
709

710
  # This override is necessary because this controller is acting as a controller
711
  # for both groups and groupings.
712
  #
713
  # TODO: move all grouping methods into their own controller and remove this
714
  def record
1✔
715
    @record ||= Grouping.find_by(id: request.path_parameters[:id]) if request.path_parameters[:id]
147✔
716
  end
717
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