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

MarkUsProject / Markus / 26985117764

04 Jun 2026 11:10PM UTC coverage: 84.913% (-5.3%) from 90.19%
26985117764

Pull #7972

github

web-flow
Merge c18340c77 into 9a5124c61
Pull Request #7972: Parallelize rspec tests

1023 of 2226 branches covered (45.96%)

Branch coverage included in aggregate %.

36935 of 42476 relevant lines covered (86.95%)

113.68 hits per line

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

12.18
/app/controllers/api/groups_controller.rb
1
module Api
1✔
2
  # Allows for listing Markus groups for a particular assignment.
3
  # Uses Rails' RESTful routes (check 'rake routes' for the configured routes)
4
  class GroupsController < MainApiController
1✔
5
    # Define default fields to display for index and show methods
6
    DEFAULT_FIELDS = [:id, :group_name].freeze
1✔
7

8
    # Create an assignment's group
9
    # Requires: assignment_id
10
    # Optional: filter, fields
11
    def create
1✔
12
      assignment = Assignment.find(params[:assignment_id])
×
13
      begin
14
        group = assignment.add_group_api(params[:new_group_name], params[:members])
×
15
        respond_to do |format|
×
16
          format.xml do
×
17
            render xml: group.to_xml(root: 'group', skip_types: 'true')
×
18
          end
19
          format.json { render json: group.to_json }
×
20
        end
21
      rescue StandardError => e
22
        render 'shared/http_status', locals: { code: '422', message:
×
23
          e.message }, status: :unprocessable_content
24
      end
25
    end
26

27
    # Returns an assignment's groups along with their attributes
28
    # Requires: assignment_id
29
    # Optional: filter, fields
30
    def index
1✔
31
      groups = get_collection(assignment.groups) || return
×
32

33
      group_data = include_memberships(groups)
×
34

35
      respond_to do |format|
×
36
        format.xml do
×
37
          render xml: group_data.to_xml(root: 'groups', skip_types: 'true')
×
38
        end
39
        format.json { render json: group_data }
×
40
      end
41
    end
42

43
    # Returns a single group along with its attributes
44
    # Requires: id
45
    # Optional: fields
46
    def show
1✔
47
      group_data = include_memberships(Group.where(id: record.id))
×
48

49
      # We found a grouping for that assignment
50
      respond_to do |format|
×
51
        format.xml do
×
52
          render xml: group_data.to_xml(root: 'groups', skip_types: 'true')
×
53
        end
54
        format.json { render json: group_data }
×
55
      end
56
    end
57

58
    # Include student_memberships and user info
59
    def include_memberships(groups)
1✔
60
      groups.joins(groupings: [:assignment, { student_memberships: [:role] }])
×
61
            .where('assessments.id': params[:assignment_id])
62
            .pluck_to_hash(*DEFAULT_FIELDS, :membership_status, :role_id)
63
            .group_by { |h| h.slice(*DEFAULT_FIELDS) }
×
64
            .map { |k, v| k.merge(members: v.map { |h| h.except(*DEFAULT_FIELDS) }) }
×
65
    end
66

67
    def add_members
1✔
68
      if self.grouping.nil?
×
69
        # The group doesn't have a grouping associated with that assignment
70
        render 'shared/http_status', locals: { code: '422', message:
×
71
          'The group is not involved with that assignment' }, status: :unprocessable_content
72
        return
×
73
      end
74

75
      students = current_course.students.joins(:user).where('users.user_name': params[:members])
×
76
      students.each do |student|
×
77
        set_membership_status = if grouping.student_memberships.empty?
×
78
                                  StudentMembership::STATUSES[:inviter]
×
79
                                else
80
                                  StudentMembership::STATUSES[:accepted]
×
81
                                end
82
        grouping.invite(student.user_name, set_membership_status, invoked_by_instructor: true)
×
83
        grouping.reload
×
84
      end
85

86
      render 'shared/http_status', locals: { code: '200', message:
×
87
        HttpStatusHelper::ERROR_CODE['message']['200'] }, status: :ok
88
    end
89

90
    # Update the group's marks for the given assignment.
91
    def update_marks
1✔
92
      result = self.grouping&.current_submission_used&.get_latest_result
×
93
      return page_not_found('No submission exists for that group') if result.nil?
×
94

95
      # We shouldn't be able to update marks if marking is already complete.
96
      if result.marking_state == Result::MARKING_STATES[:complete]
×
97
        render 'shared/http_status', locals: { code: '404', message:
×
98
          'Marking for that submission is already completed' }, status: :not_found
99
        return
×
100
      end
101
      matched_criteria = assignment.criteria.where(name: params.keys)
×
102
      if matched_criteria.empty?
×
103
        render 'shared/http_status', locals: { code: '404', message:
×
104
          'No criteria were found that match that request.' }, status: :not_found
105
        return
×
106
      end
107

108
      matched_criteria.each do |crit|
×
109
        mark_to_change = result.marks.find_or_initialize_by(criterion_id: crit.id)
×
110
        mark_to_change.mark = params[crit.name] == 'nil' ? nil : params[crit.name].to_f
×
111
        unless mark_to_change.save
×
112
          # Some error occurred (including invalid mark)
113
          render 'shared/http_status', locals: { code: '500', message:
×
114
            mark_to_change.errors.full_messages.first }, status: :internal_server_error
115
          return
×
116
        end
117
      end
118
      result.save
×
119
      render 'shared/http_status', locals: { code: '200', message:
×
120
        HttpStatusHelper::ERROR_CODE['message']['200'] }, status: :ok
121
    end
122

123
    def create_extra_marks
1✔
124
      result = self.grouping&.current_submission_used&.get_latest_result
×
125
      return page_not_found('No submission exists for that group') if result.nil?
×
126

127
      begin
128
        ExtraMark.create!(result_id: result.id, extra_mark: params[:extra_marks],
×
129
                          description: params[:description], unit: ExtraMark::POINTS)
130
      rescue ActiveRecord::RecordInvalid => e
131
        # Some error occurred
132
        render 'shared/http_status', locals: { code: '500', message:
×
133
            e.message }, status: :internal_server_error
134
        return
×
135
      end
136
      render 'shared/http_status', locals: { code: '200', message:
×
137
          'Extra mark created successfully' }, status: :ok
138
    end
139

140
    def remove_extra_marks
1✔
141
      result = self.grouping&.current_submission_used&.get_latest_result
×
142
      return page_not_found('No submission exists for that group') if result.nil?
×
143
      extra_mark = ExtraMark.find_by(result_id: result.id,
×
144
                                     description: params[:description],
145
                                     extra_mark: params[:extra_marks])
146
      if extra_mark.nil?
×
147
        render 'shared/http_status', locals: { code: '404', message:
×
148
            'No such Extra Mark exist for that result' }, status: :not_found
149
        return
×
150
      end
151
      begin
152
        extra_mark.destroy
×
153
      rescue ActiveRecord::RecordNotDestroyed => e
154
        # Some other error occurred
155
        render 'shared/http_status', locals: { code: '500', message:
×
156
            e.message }, status: :internal_server_error
157
        return
×
158
      end
159
      # Successfully deleted the Extra Mark; render success
160
      render 'shared/http_status', locals: { code: '200', message:
×
161
          'Extra mark removed successfully' }, status: :ok
162
    end
163

164
    def annotations
1✔
165
      if record # this is a member route
×
166
        grouping_relation = assignment.groupings.where(group_id: record.id)
×
167
      else # this is a collection route
168
        grouping_relation = assignment.groupings
×
169
      end
170

171
      pluck_keys = ['annotations.type as type',
×
172
                    'annotation_texts.content as content',
173
                    'submission_files.filename as filename',
174
                    'submission_files.path as path',
175
                    'annotations.page as page',
176
                    'group_id',
177
                    'annotation_categories.annotation_category_name as category',
178
                    'annotations.creator_id as creator_id',
179
                    'annotation_texts.creator_id as content_creator_id',
180
                    'annotations.line_end as line_end',
181
                    'annotations.line_start as line_start',
182
                    'annotations.column_start as column_start',
183
                    'annotations.column_end as column_end',
184
                    'annotations.x1 as x1',
185
                    'annotations.y1 as y1',
186
                    'annotations.x2 as x2',
187
                    'annotations.y2 as y2']
188

189
      annotations = grouping_relation.left_joins(current_submission_used:
×
190
                                              [{ submission_files:
191
                                                 [{ annotations:
192
                                                    [{ annotation_text: :annotation_category }] }] }])
193
                                     .where(assessment_id: params[:assignment_id])
194
                                     .where.not('annotations.id': nil)
195
                                     .pluck_to_hash(*pluck_keys)
196
      respond_to do |format|
×
197
        format.xml do
×
198
          render xml: annotations.to_xml(root: 'annotations', skip_types: 'true')
×
199
        end
200
        format.json do
×
201
          render json: annotations.to_json
×
202
        end
203
      end
204
    end
205

206
    def test_results
1✔
207
      return render_no_grouping_error unless grouping
×
208

209
      # Use the existing Assignment#summary_test_results method filtered for this specific group
210
      # This ensures format consistency with the UI download (summary_test_result_json)
211
      group_name = grouping.group.group_name
×
212
      results = assignment.summary_test_results([group_name])
×
213

214
      return render_no_grouping_error if results.blank?
×
215

216
      # Group by test_group name to match the summary_test_result_json format
217
      results_by_test_group = results.group_by(&:name)
×
218

219
      respond_to do |format|
×
220
        format.xml { render xml: results_by_test_group.to_xml(root: 'test_results', skip_types: 'true') }
×
221
        format.json { render json: results_by_test_group }
×
222
      end
223
    end
224

225
    def render_no_grouping_error
1✔
226
      render 'shared/http_status',
×
227
             locals: { code: '404', message: 'No test results found for this group' },
228
             status: :not_found
229
    end
230

231
    def add_annotations
1✔
232
      result = self.grouping&.current_result
×
233
      return page_not_found('No submission exists for that group') if result.nil?
×
234

235
      force_complete = params.fetch(:force_complete, false)
×
236
      # We shouldn't be able to update annotations if marking is already complete, unless forced.
237
      if result.marking_state == Result::MARKING_STATES[:complete] && !force_complete
×
238
        return page_not_found('Marking for that submission is already completed')
×
239
      end
240

241
      annotation_texts = []
×
242
      annotations = []
×
243
      count = result.submission.annotations.count + 1
×
244
      annotation_category = nil
×
245
      submission_file = nil
×
246
      params[:annotations].each_with_index do |annot_params, i|
×
247
        if annot_params[:annotation_category_name].nil?
×
248
          annotation_category_id = nil
×
249
        else
250
          name = annot_params[:annotation_category_name]
×
251
          if annotation_category.nil? || annotation_category.annotation_category_name != name
×
252
            annotation_category = assignment.annotation_categories.find_or_create_by(
×
253
              annotation_category_name: name
254
            )
255
          end
256
          annotation_category_id = annotation_category.id
×
257
        end
258
        if submission_file.nil? || submission_file.filename != annot_params[:filename]
×
259
          submission_file = result.submission.submission_files.find_by(filename: annot_params[:filename])
×
260
        end
261

262
        annotation_texts << {
×
263
          content: annot_params[:content],
264
          annotation_category_id: annotation_category_id,
265
          creator_id: current_role.id,
266
          last_editor_id: current_role.id
267
        }
268
        annotations << {
×
269
          line_start: annot_params[:line_start],
270
          line_end: annot_params[:line_end],
271
          column_start: annot_params[:column_start],
272
          column_end: annot_params[:column_end],
273
          annotation_text_id: nil,
274
          submission_file_id: submission_file.id,
275
          creator_id: current_role.id,
276
          creator_type: current_role.type,
277
          is_remark: !result.remark_request_submitted_at.nil?,
278
          annotation_number: count + i,
279
          result_id: result.id
280
        }
281
      end
282
      imported = AnnotationText.insert_all! annotation_texts
×
283
      imported.rows.zip(annotations) do |t, a|
×
284
        a[:annotation_text_id] = t[0]
×
285
      end
286
      TextAnnotation.insert_all! annotations
×
287
      render 'shared/http_status', locals: { code: '200', message:
×
288
        HttpStatusHelper::ERROR_CODE['message']['200'] }, status: :ok
289
    end
290

291
    # Return key:value pairs of group_name:group_id
292
    def group_ids_by_name
1✔
293
      reversed = assignment.groups.pluck(:group_name, :id).to_h
×
294
      respond_to do |format|
×
295
        format.xml do
×
296
          render xml: reversed.to_xml(root: 'groups', skip_types: 'true')
×
297
        end
298
        format.json do
×
299
          render json: reversed.to_json
×
300
        end
301
      end
302
    end
303

304
    # Allow user to set marking state to complete
305
    def update_marking_state
1✔
306
      if has_missing_params?([:marking_state])
×
307
        # incomplete/invalid HTTP params
308
        render 'shared/http_status', locals: { code: '422', message:
×
309
            HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content
310
        return
×
311
      end
312
      result = self.grouping&.current_submission_used&.get_latest_result
×
313
      return page_not_found('No submission exists for that group') if result.nil?
×
314
      result.marking_state = params[:marking_state]
×
315
      if result.save
×
316
        render 'shared/http_status', locals: { code: '200', message:
×
317
            HttpStatusHelper::ERROR_CODE['message']['200'] }, status: :ok
318
      else
319
        render 'shared/http_status', locals: { code: '500', message:
×
320
            result.errors.full_messages.first }, status: :internal_server_error
321
      end
322
    end
323

324
    def add_tag
1✔
325
      grouping = self.grouping
×
326
      tag = self.assignment.tags.find_by(id: params[:tag_id])
×
327
      if tag.nil? || grouping.nil?
×
328
        raise 'tag or group not found'
×
329
      else
330
        grouping.tags << tag
×
331
        render 'shared/http_status', locals: { code: '200', message:
×
332
          HttpStatusHelper::ERROR_CODE['message']['200'] }, status: :ok
333
      end
334
    rescue StandardError
335
      render 'shared/http_status', locals: { code: '404', message: I18n.t('tags.not_found') }, status: :not_found
×
336
    end
337

338
    def remove_tag
1✔
339
      grouping = self.grouping
×
340
      tag = grouping.tags.find_by(id: params[:tag_id])
×
341
      if tag.nil? || grouping.nil?
×
342
        raise 'tag or grouping not found'
×
343
      else
344
        grouping.tags.destroy(tag)
×
345
        render 'shared/http_status', locals: { code: '200', message:
×
346
          HttpStatusHelper::ERROR_CODE['message']['200'] }, status: :ok
347
      end
348
    rescue StandardError
349
      render 'shared/http_status', locals: { code: '404', message: I18n.t('tags.not_found') }, status: :not_found
×
350
    end
351

352
    def extension
1✔
353
      grouping = Grouping.find_by(group_id: params[:id], assignment: params[:assignment_id])
×
354
      case request.method
×
355
      when 'DELETE'
356
        if grouping.extension.present?
×
357
          grouping.extension.destroy!
×
358
          # Successfully deleted the extension; render success
359
          render 'shared/http_status', locals: { code: '200', message:
×
360
            HttpStatusHelper::ERROR_CODE['message']['200'] }, status: :ok
361
        else
362
          # cannot delete a non existent extension; render failure
363
          render 'shared/http_status', locals: { code: '422', message:
×
364
            HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content
365
        end
366
      when 'POST'
367
        if grouping.extension.nil?
×
368
          extension_values = extension_params
×
369
          extension_values[:time_delta] = time_delta_params if extension_values[:time_delta].present?
×
370
          grouping.create_extension!(extension_values)
×
371
          # Successfully created the extension record; render success
372
          render 'shared/http_status', locals: { code: '201', message:
×
373
            HttpStatusHelper::ERROR_CODE['message']['201'] }, status: :created
374
        else
375
          # cannot create extension as it already exists; render failure
376
          render 'shared/http_status', locals: { code: '422', message:
×
377
            HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content
378
        end
379
      when 'PATCH'
380
        if grouping.extension.present?
×
381
          extension_values = extension_params
×
382
          extension_values[:time_delta] = time_delta_params if extension_values[:time_delta].present?
×
383
          grouping.extension.update!(extension_values)
×
384
          # Successfully updated the extension record; render success
385
          render 'shared/http_status', locals: { code: '200', message:
×
386
            HttpStatusHelper::ERROR_CODE['message']['200'] }, status: :ok
387
        else
388
          # cannot update extension as it does not exists; render failure
389
          render 'shared/http_status', locals: { code: '422', message:
×
390
            HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content
391
        end
392
      end
393
    rescue ActiveRecord::RecordInvalid => e
394
      render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_content
×
395
    end
396

397
    def collect_submission
1✔
398
      @grouping = Grouping.find_by(group_id: params[:id], assignment: params[:assignment_id])
×
399
      unless @grouping.current_submission_used.nil?
×
400
        released = @grouping.current_submission_used.results.exists?(released_to_students: true)
×
401
        if released
×
402
          render 'shared/http_status', locals: { code: '422', message:
×
403
            HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content
404
          return
×
405
        end
406
      end
407

408
      @revision_identifier = params[:revision_identifier]
×
409
      apply_late_penalty = if params[:apply_late_penalty].nil?
×
410
                             false
×
411
                           else
412
                             params[:apply_late_penalty]
×
413
                           end
414
      retain_existing_grading = if params[:retain_existing_grading].nil?
×
415
                                  false
×
416
                                else
417
                                  params[:retain_existing_grading]
×
418
                                end
419
      SubmissionsJob.perform_now([@grouping],
×
420
                                 revision_identifier: @revision_identifier,
421
                                 collect_current: params[:collect_current],
422
                                 apply_late_penalty: apply_late_penalty,
423
                                 retain_existing_grading: retain_existing_grading)
424

425
      render 'shared/http_status', locals: { code: '201', message:
×
426
        HttpStatusHelper::ERROR_CODE['message']['201'] }, status: :created
427
    end
428

429
    def add_test_run
1✔
430
      m_logger = MarkusLogger.instance
×
431
      # Validate test results against contract schema
432
      validation = TestResultsContract.new.call(params[:test_results].as_json)
×
433

434
      if validation.failure?
×
435
        return render json: { errors: validation.errors.to_hash }, status: :unprocessable_content
×
436
      end
437

438
      # Verify grouping exists and is authorized (grouping helper method handles this)
439
      if grouping.nil?
×
440
        return render json: { errors: 'Group not found for this assignment' }, status: :not_found
×
441
      end
442

443
      # Verify submission exists before attempting to create test run
444
      submission = grouping.current_submission_used
×
445
      if submission.nil?
×
446
        return render json: { errors: 'No submission exists for this grouping' }, status: :unprocessable_content
×
447
      end
448

449
      begin
450
        ActiveRecord::Base.transaction do
×
451
          test_run = TestRun.create!(
×
452
            status: :in_progress,
453
            role: current_role,
454
            grouping: grouping,
455
            submission: submission
456
          )
457

458
          test_run.update_results!(JSON.parse(params[:test_results].to_json))
×
459
          render json: { status: 'success', test_run_id: test_run.id }, status: :created
×
460
        end
461
      rescue ActiveRecord::RecordInvalid => e
462
        render json: { errors: e.record.errors.full_messages }, status: :unprocessable_content
×
463
      rescue StandardError => e
464
        m_logger.log("Test results processing failed: #{e.message}\n#{e.backtrace.join("\n")}")
×
465
        render json: { errors: 'Failed to process test results' }, status: :internal_server_error
×
466
      end
467
    end
468

469
    def overall_comment
1✔
470
      result = grouping&.current_result
×
471
      return page_not_found('No submission exists for that group') if result.nil?
×
472

473
      case request.method
×
474
      when 'GET'
475
        respond_to do |format|
×
476
          format.xml do
×
477
            render xml: { overall_comment: result.overall_comment }.to_xml(root: 'result', skip_types: 'true')
×
478
          end
479
          format.json { render json: { overall_comment: result.overall_comment } }
×
480
        end
481
      when 'PATCH'
482
        if has_missing_params?([:overall_comment])
×
483
          render 'shared/http_status', locals: { code: '422', message:
×
484
              HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content
485
          return
×
486
        end
487
        if result.update(overall_comment: params[:overall_comment])
×
488
          head :ok
×
489
        else
490
          render 'shared/http_status',
×
491
                 locals: { code: '422', message: result.errors.full_messages.first },
492
                 status: :unprocessable_content
493
        end
494
      end
495
    end
496

497
    private
1✔
498

499
    def assignment
1✔
500
      return @assignment if defined?(@assignment)
×
501

502
      @assignment = Assignment.find_by(id: params[:assignment_id])
×
503
    end
504

505
    def grouping
1✔
506
      @grouping ||= record.grouping_for_assignment(assignment.id)
×
507
    end
508

509
    def annotations_params
1✔
510
      params.require(annotations: [
×
511
        :annotation_category_name,
512
        :column_end,
513
        :column_start,
514
        :content,
515
        :filename,
516
        :line_end,
517
        :line_start
518
      ])
519
    end
520

521
    def time_delta_params
1✔
522
      params = extension_params[:time_delta]
×
523
      Extension::PARTS.sum { |part| params[part].to_i.public_send(part) }
×
524
    end
525

526
    def extension_params
1✔
527
      params.require(:extension).permit({ time_delta: [:weeks, :days, :hours, :minutes] }, :apply_penalty, :note)
×
528
    end
529
  end
530
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