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

MarkUsProject / Markus / 27791313772

18 Jun 2026 09:46PM UTC coverage: 90.24% (+0.02%) from 90.221%
27791313772

Pull #8007

github

web-flow
Merge b8d4b3317 into f55405f2c
Pull Request #8007: Support all annotation types in add_annotations API (#7999)

1071 of 2284 branches covered (46.89%)

Branch coverage included in aggregate %.

150 of 155 new or added lines in 9 files covered. (96.77%)

13 existing lines in 1 file now uncovered.

46498 of 50430 relevant lines covered (92.2%)

126.18 hits per line

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

92.55
/app/controllers/api/groups_controller.rb
1
module Api
2✔
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
2✔
5
    # Define default fields to display for index and show methods
6
    DEFAULT_FIELDS = [:id, :group_name].freeze
2✔
7

8
    # The annotation types accepted by add_annotations (single-table-inheritance class names,
9
    # matching the values reported by the annotations endpoint), mapped to the fields that must
10
    # be present for each. Mirrors each subclass's model validations, enforced in add_annotations
11
    # because insert_all bypasses them.
12
    REQUIRED_ANNOTATION_FIELDS = {
13
      'TextAnnotation' => %i[line_start line_end column_start column_end],
2✔
14
      'ImageAnnotation' => %i[x1 y1 x2 y2],
15
      'PdfAnnotation' => %i[x1 y1 x2 y2 page],
16
      'HtmlAnnotation' => %i[start_node start_offset end_node end_offset]
17
    }.freeze
18

19
    # Create an assignment's group
20
    # Requires: assignment_id
21
    # Optional: filter, fields
22
    def create
2✔
23
      assignment = Assignment.find(params[:assignment_id])
10✔
24
      begin
25
        group = assignment.add_group_api(params[:new_group_name], params[:members])
10✔
26
        respond_to do |format|
7✔
27
          format.xml do
7✔
28
            render xml: group.to_xml(root: 'group', skip_types: 'true')
7✔
29
          end
30
          format.json { render json: group.to_json }
7✔
31
        end
32
      rescue StandardError => e
33
        render 'shared/http_status', locals: { code: '422', message:
3✔
34
          e.message }, status: :unprocessable_content
35
      end
36
    end
37

38
    # Returns an assignment's groups along with their attributes
39
    # Requires: assignment_id
40
    # Optional: filter, fields
41
    def index
2✔
42
      groups = get_collection(assignment.groups) || return
12✔
43

44
      group_data = include_memberships(groups)
11✔
45

46
      respond_to do |format|
11✔
47
        format.xml do
11✔
48
          render xml: group_data.to_xml(root: 'groups', skip_types: 'true')
4✔
49
        end
50
        format.json { render json: group_data }
18✔
51
      end
52
    end
53

54
    # Returns a single group along with its attributes
55
    # Requires: id
56
    # Optional: fields
57
    def show
2✔
58
      group_data = include_memberships(Group.where(id: record.id))
6✔
59

60
      # We found a grouping for that assignment
61
      respond_to do |format|
6✔
62
        format.xml do
6✔
63
          render xml: group_data.to_xml(root: 'groups', skip_types: 'true')
3✔
64
        end
65
        format.json { render json: group_data }
9✔
66
      end
67
    end
68

69
    # Include student_memberships and user info
70
    def include_memberships(groups)
2✔
71
      groups.joins(groupings: [:assignment, { student_memberships: [:role] }])
17✔
72
            .where('assessments.id': params[:assignment_id])
73
            .pluck_to_hash(*DEFAULT_FIELDS, :membership_status, :role_id)
74
            .group_by { |h| h.slice(*DEFAULT_FIELDS) }
23✔
75
            .map { |k, v| k.merge(members: v.map { |h| h.except(*DEFAULT_FIELDS) }) }
46✔
76
    end
77

78
    def add_members
2✔
79
      if self.grouping.nil?
8✔
80
        # The group doesn't have a grouping associated with that assignment
81
        render 'shared/http_status', locals: { code: '422', message:
2✔
82
          'The group is not involved with that assignment' }, status: :unprocessable_content
83
        return
2✔
84
      end
85

86
      students = current_course.students.joins(:user).where('users.user_name': params[:members])
6✔
87
      students.each do |student|
6✔
88
        set_membership_status = if grouping.student_memberships.empty?
10✔
89
                                  StudentMembership::STATUSES[:inviter]
2✔
90
                                else
91
                                  StudentMembership::STATUSES[:accepted]
8✔
92
                                end
93
        grouping.invite(student.user_name, set_membership_status, invoked_by_instructor: true)
10✔
94
        grouping.reload
10✔
95
      end
96

97
      render 'shared/http_status', locals: { code: '200', message:
6✔
98
        HttpStatusHelper::ERROR_CODE['message']['200'] }, status: :ok
99
    end
100

101
    # Update the group's marks for the given assignment.
102
    def update_marks
2✔
103
      result = self.grouping&.current_submission_used&.get_latest_result
6✔
104
      return page_not_found('No submission exists for that group') if result.nil?
6✔
105

106
      # We shouldn't be able to update marks if marking is already complete.
107
      if result.marking_state == Result::MARKING_STATES[:complete]
6✔
108
        render 'shared/http_status', locals: { code: '404', message:
2✔
109
          'Marking for that submission is already completed' }, status: :not_found
110
        return
2✔
111
      end
112
      matched_criteria = assignment.criteria.where(name: params.keys)
4✔
113
      if matched_criteria.empty?
4✔
UNCOV
114
        render 'shared/http_status', locals: { code: '404', message:
×
115
          'No criteria were found that match that request.' }, status: :not_found
UNCOV
116
        return
×
117
      end
118

119
      matched_criteria.each do |crit|
4✔
120
        mark_to_change = result.marks.find_or_initialize_by(criterion_id: crit.id)
4✔
121
        mark_to_change.mark = params[crit.name] == 'nil' ? nil : params[crit.name].to_f
4✔
122
        unless mark_to_change.save
4✔
123
          # Some error occurred (including invalid mark)
UNCOV
124
          render 'shared/http_status', locals: { code: '500', message:
×
125
            mark_to_change.errors.full_messages.first }, status: :internal_server_error
UNCOV
126
          return
×
127
        end
128
      end
129
      result.save
4✔
130
      render 'shared/http_status', locals: { code: '200', message:
4✔
131
        HttpStatusHelper::ERROR_CODE['message']['200'] }, status: :ok
132
    end
133

134
    def create_extra_marks
2✔
135
      result = self.grouping&.current_submission_used&.get_latest_result
5✔
136
      return page_not_found('No submission exists for that group') if result.nil?
5✔
137

138
      begin
139
        ExtraMark.create!(result_id: result.id, extra_mark: params[:extra_marks],
4✔
140
                          description: params[:description], unit: ExtraMark::POINTS)
141
      rescue ActiveRecord::RecordInvalid => e
142
        # Some error occurred
143
        render 'shared/http_status', locals: { code: '500', message:
2✔
144
            e.message }, status: :internal_server_error
145
        return
2✔
146
      end
147
      render 'shared/http_status', locals: { code: '200', message:
2✔
148
          'Extra mark created successfully' }, status: :ok
149
    end
150

151
    def remove_extra_marks
2✔
152
      result = self.grouping&.current_submission_used&.get_latest_result
5✔
153
      return page_not_found('No submission exists for that group') if result.nil?
5✔
154
      extra_mark = ExtraMark.find_by(result_id: result.id,
4✔
155
                                     description: params[:description],
156
                                     extra_mark: params[:extra_marks])
157
      if extra_mark.nil?
4✔
158
        render 'shared/http_status', locals: { code: '404', message:
2✔
159
            'No such Extra Mark exist for that result' }, status: :not_found
160
        return
2✔
161
      end
162
      begin
163
        extra_mark.destroy
2✔
164
      rescue ActiveRecord::RecordNotDestroyed => e
165
        # Some other error occurred
UNCOV
166
        render 'shared/http_status', locals: { code: '500', message:
×
167
            e.message }, status: :internal_server_error
168
        return
×
169
      end
170
      # Successfully deleted the Extra Mark; render success
171
      render 'shared/http_status', locals: { code: '200', message:
2✔
172
          'Extra mark removed successfully' }, status: :ok
173
    end
174

175
    def annotations
2✔
176
      if record # this is a member route
2✔
177
        grouping_relation = assignment.groupings.where(group_id: record.id)
2✔
178
      else # this is a collection route
UNCOV
179
        grouping_relation = assignment.groupings
×
180
      end
181

182
      pluck_keys = ['annotations.type as type',
2✔
183
                    'annotation_texts.content as content',
184
                    'submission_files.filename as filename',
185
                    'submission_files.path as path',
186
                    'annotations.page as page',
187
                    'group_id',
188
                    'annotation_categories.annotation_category_name as category',
189
                    'annotations.creator_id as creator_id',
190
                    'annotation_texts.creator_id as content_creator_id',
191
                    'annotations.line_end as line_end',
192
                    'annotations.line_start as line_start',
193
                    'annotations.column_start as column_start',
194
                    'annotations.column_end as column_end',
195
                    'annotations.x1 as x1',
196
                    'annotations.y1 as y1',
197
                    'annotations.x2 as x2',
198
                    'annotations.y2 as y2']
199

200
      annotations = grouping_relation.left_joins(current_submission_used:
2✔
201
                                              [{ submission_files:
202
                                                 [{ annotations:
203
                                                    [{ annotation_text: :annotation_category }] }] }])
204
                                     .where(assessment_id: params[:assignment_id])
205
                                     .where.not('annotations.id': nil)
206
                                     .pluck_to_hash(*pluck_keys)
207
      respond_to do |format|
2✔
208
        format.xml do
2✔
209
          render xml: annotations.to_xml(root: 'annotations', skip_types: 'true')
2✔
210
        end
211
        format.json do
2✔
UNCOV
212
          render json: annotations.to_json
×
213
        end
214
      end
215
    end
216

217
    def test_results
2✔
218
      return render_no_grouping_error unless grouping
10✔
219

220
      # Use the existing Assignment#summary_test_results method filtered for this specific group
221
      # This ensures format consistency with the UI download (summary_test_result_json)
222
      group_name = grouping.group.group_name
10✔
223
      results = assignment.summary_test_results([group_name])
10✔
224

225
      return render_no_grouping_error if results.blank?
10✔
226

227
      # Group by test_group name to match the summary_test_result_json format
228
      results_by_test_group = results.group_by(&:name)
9✔
229

230
      respond_to do |format|
9✔
231
        format.xml { render xml: results_by_test_group.to_xml(root: 'test_results', skip_types: 'true') }
11✔
232
        format.json { render json: results_by_test_group }
16✔
233
      end
234
    end
235

236
    def render_no_grouping_error
2✔
237
      render 'shared/http_status',
1✔
238
             locals: { code: '404', message: 'No test results found for this group' },
239
             status: :not_found
240
    end
241

242
    def add_annotations
2✔
243
      result = self.grouping&.current_result
11✔
244
      return page_not_found('No submission exists for that group') if result.nil?
11✔
245

246
      force_complete = params.fetch(:force_complete, false)
11✔
247
      # We shouldn't be able to update annotations if marking is already complete, unless forced.
248
      if result.marking_state == Result::MARKING_STATES[:complete] && !force_complete
11✔
NEW
249
        return page_not_found('Marking for that submission is already completed')
×
250
      end
251

252
      annots = annotations_params
11✔
253
      submission_files = result.submission.submission_files.index_by(&:filename)
11✔
254

255
      # Validation pass: insert_all bypasses model validations, so guard the input here
256
      # before writing anything to the database. The annotation type is derived from the
257
      # file; a caller-supplied type is optional but must agree with it.
258
      annots.each do |annot_params|
11✔
259
        submission_file = submission_files[annot_params[:filename]]
13✔
260
        if submission_file.nil?
13✔
261
          return add_annotations_error("Submission file not found: #{annot_params[:filename]}")
1✔
262
        end
263

264
        expected_type = submission_file.annotation_type
12✔
265

266
        requested = annot_params[:type].presence
12✔
267
        if requested
12✔
268
          unless REQUIRED_ANNOTATION_FIELDS.key?(requested)
8✔
269
            return add_annotations_error("Invalid annotation type: #{requested}")
1✔
270
          end
271
          if requested != expected_type
7✔
272
            return add_annotations_error(
1✔
273
              "Annotation type '#{requested}' does not match the type of file '#{annot_params[:filename]}'"
274
            )
275
          end
276
        end
277

278
        missing = REQUIRED_ANNOTATION_FIELDS[expected_type].select { |field| annot_params[field].blank? }
54✔
279
        next if missing.empty?
10✔
280

281
        return add_annotations_error(
1✔
282
          "Missing required fields for #{expected_type}: #{missing.join(', ')}"
283
        )
284
      end
285

286
      annotation_texts = []
7✔
287
      annotations = []
7✔
288
      count = result.submission.annotations.count + 1
7✔
289
      annotation_category = nil
7✔
290
      annots.each_with_index do |annot_params, i|
7✔
291
        if annot_params[:annotation_category_name].nil?
9✔
292
          annotation_category_id = nil
9✔
293
        else
NEW
294
          name = annot_params[:annotation_category_name]
×
NEW
295
          if annotation_category.nil? || annotation_category.annotation_category_name != name
×
NEW
296
            annotation_category = assignment.annotation_categories.find_or_create_by(
×
297
              annotation_category_name: name
298
            )
299
          end
NEW
300
          annotation_category_id = annotation_category.id
×
301
        end
302

303
        submission_file = submission_files[annot_params[:filename]]
9✔
304
        annotation_texts << {
9✔
305
          content: annot_params[:content],
306
          annotation_category_id: annotation_category_id,
307
          creator_id: current_role.id,
308
          last_editor_id: current_role.id
309
        }
310
        annotations << {
9✔
311
          type: submission_file.annotation_type,
312
          line_start: annot_params[:line_start],
313
          line_end: annot_params[:line_end],
314
          column_start: annot_params[:column_start],
315
          column_end: annot_params[:column_end],
316
          x1: annot_params[:x1],
317
          y1: annot_params[:y1],
318
          x2: annot_params[:x2],
319
          y2: annot_params[:y2],
320
          page: annot_params[:page],
321
          start_node: annot_params[:start_node],
322
          start_offset: annot_params[:start_offset],
323
          end_node: annot_params[:end_node],
324
          end_offset: annot_params[:end_offset],
325
          annotation_text_id: nil,
326
          submission_file_id: submission_file.id,
327
          creator_id: current_role.id,
328
          creator_type: current_role.type,
329
          is_remark: !result.remark_request_submitted_at.nil?,
330
          annotation_number: count + i,
331
          result_id: result.id
332
        }
333
      end
334
      imported = AnnotationText.insert_all! annotation_texts
7✔
335
      imported.rows.zip(annotations) do |t, a|
7✔
336
        a[:annotation_text_id] = t[0]
9✔
337
      end
338
      Annotation.insert_all! annotations
7✔
339
      render 'shared/http_status', locals: { code: '200', message:
7✔
340
        HttpStatusHelper::ERROR_CODE['message']['200'] }, status: :ok
341
    end
342

343
    # Return key:value pairs of group_name:group_id
344
    def group_ids_by_name
2✔
345
      reversed = assignment.groups.pluck(:group_name, :id).to_h
4✔
346
      respond_to do |format|
4✔
347
        format.xml do
4✔
348
          render xml: reversed.to_xml(root: 'groups', skip_types: 'true')
2✔
349
        end
350
        format.json do
4✔
351
          render json: reversed.to_json
2✔
352
        end
353
      end
354
    end
355

356
    # Allow user to set marking state to complete
357
    def update_marking_state
2✔
358
      if has_missing_params?([:marking_state])
4✔
359
        # incomplete/invalid HTTP params
UNCOV
360
        render 'shared/http_status', locals: { code: '422', message:
×
361
            HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content
UNCOV
362
        return
×
363
      end
364
      result = self.grouping&.current_submission_used&.get_latest_result
4✔
365
      return page_not_found('No submission exists for that group') if result.nil?
4✔
366
      result.marking_state = params[:marking_state]
4✔
367
      if result.save
4✔
368
        render 'shared/http_status', locals: { code: '200', message:
4✔
369
            HttpStatusHelper::ERROR_CODE['message']['200'] }, status: :ok
370
      else
UNCOV
371
        render 'shared/http_status', locals: { code: '500', message:
×
372
            result.errors.full_messages.first }, status: :internal_server_error
373
      end
374
    end
375

376
    def add_tag
2✔
377
      grouping = self.grouping
2✔
378
      tag = self.assignment.tags.find_by(id: params[:tag_id])
2✔
379
      if tag.nil? || grouping.nil?
2✔
380
        raise 'tag or group not found'
1✔
381
      else
382
        grouping.tags << tag
1✔
383
        render 'shared/http_status', locals: { code: '200', message:
1✔
384
          HttpStatusHelper::ERROR_CODE['message']['200'] }, status: :ok
385
      end
386
    rescue StandardError
387
      render 'shared/http_status', locals: { code: '404', message: I18n.t('tags.not_found') }, status: :not_found
1✔
388
    end
389

390
    def remove_tag
2✔
391
      grouping = self.grouping
2✔
392
      tag = grouping.tags.find_by(id: params[:tag_id])
2✔
393
      if tag.nil? || grouping.nil?
2✔
394
        raise 'tag or grouping not found'
1✔
395
      else
396
        grouping.tags.destroy(tag)
1✔
397
        render 'shared/http_status', locals: { code: '200', message:
1✔
398
          HttpStatusHelper::ERROR_CODE['message']['200'] }, status: :ok
399
      end
400
    rescue StandardError
401
      render 'shared/http_status', locals: { code: '404', message: I18n.t('tags.not_found') }, status: :not_found
1✔
402
    end
403

404
    def extension
2✔
405
      grouping = Grouping.find_by(group_id: params[:id], assignment: params[:assignment_id])
12✔
406
      case request.method
12✔
407
      when 'DELETE'
408
        if grouping.extension.present?
2✔
409
          grouping.extension.destroy!
1✔
410
          # Successfully deleted the extension; render success
411
          render 'shared/http_status', locals: { code: '200', message:
1✔
412
            HttpStatusHelper::ERROR_CODE['message']['200'] }, status: :ok
413
        else
414
          # cannot delete a non existent extension; render failure
415
          render 'shared/http_status', locals: { code: '422', message:
1✔
416
            HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content
417
        end
418
      when 'POST'
419
        if grouping.extension.nil?
5✔
420
          extension_values = extension_params
4✔
421
          extension_values[:time_delta] = time_delta_params if extension_values[:time_delta].present?
4✔
422
          grouping.create_extension!(extension_values)
4✔
423
          # Successfully created the extension record; render success
424
          render 'shared/http_status', locals: { code: '201', message:
2✔
425
            HttpStatusHelper::ERROR_CODE['message']['201'] }, status: :created
426
        else
427
          # cannot create extension as it already exists; render failure
428
          render 'shared/http_status', locals: { code: '422', message:
1✔
429
            HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content
430
        end
431
      when 'PATCH'
432
        if grouping.extension.present?
4✔
433
          extension_values = extension_params
3✔
434
          extension_values[:time_delta] = time_delta_params if extension_values[:time_delta].present?
3✔
435
          grouping.extension.update!(extension_values)
3✔
436
          # Successfully updated the extension record; render success
437
          render 'shared/http_status', locals: { code: '200', message:
3✔
438
            HttpStatusHelper::ERROR_CODE['message']['200'] }, status: :ok
439
        else
440
          # cannot update extension as it does not exists; render failure
441
          render 'shared/http_status', locals: { code: '422', message:
1✔
442
            HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content
443
        end
444
      end
445
    rescue ActiveRecord::RecordInvalid => e
446
      render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_content
2✔
447
    end
448

449
    def collect_submission
2✔
450
      @grouping = Grouping.find_by(group_id: params[:id], assignment: params[:assignment_id])
3✔
451
      unless @grouping.current_submission_used.nil?
3✔
452
        released = @grouping.current_submission_used.results.exists?(released_to_students: true)
1✔
453
        if released
1✔
454
          render 'shared/http_status', locals: { code: '422', message:
1✔
455
            HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content
456
          return
1✔
457
        end
458
      end
459

460
      @revision_identifier = params[:revision_identifier]
2✔
461
      apply_late_penalty = if params[:apply_late_penalty].nil?
2✔
UNCOV
462
                             false
×
463
                           else
464
                             params[:apply_late_penalty]
2✔
465
                           end
466
      retain_existing_grading = if params[:retain_existing_grading].nil?
2✔
UNCOV
467
                                  false
×
468
                                else
469
                                  params[:retain_existing_grading]
2✔
470
                                end
471
      SubmissionsJob.perform_now([@grouping],
2✔
472
                                 revision_identifier: @revision_identifier,
473
                                 collect_current: params[:collect_current],
474
                                 apply_late_penalty: apply_late_penalty,
475
                                 retain_existing_grading: retain_existing_grading)
476

477
      render 'shared/http_status', locals: { code: '201', message:
2✔
478
        HttpStatusHelper::ERROR_CODE['message']['201'] }, status: :created
479
    end
480

481
    def add_test_run
2✔
482
      m_logger = MarkusLogger.instance
15✔
483
      # Validate test results against contract schema
484
      validation = TestResultsContract.new.call(params[:test_results].as_json)
15✔
485

486
      if validation.failure?
15✔
487
        return render json: { errors: validation.errors.to_hash }, status: :unprocessable_content
1✔
488
      end
489

490
      # Verify grouping exists and is authorized (grouping helper method handles this)
491
      if grouping.nil?
14✔
UNCOV
492
        return render json: { errors: 'Group not found for this assignment' }, status: :not_found
×
493
      end
494

495
      # Verify submission exists before attempting to create test run
496
      submission = grouping.current_submission_used
14✔
497
      if submission.nil?
14✔
498
        return render json: { errors: 'No submission exists for this grouping' }, status: :unprocessable_content
1✔
499
      end
500

501
      begin
502
        ActiveRecord::Base.transaction do
13✔
503
          test_run = TestRun.create!(
13✔
504
            status: :in_progress,
505
            role: current_role,
506
            grouping: grouping,
507
            submission: submission
508
          )
509

510
          test_run.update_results!(JSON.parse(params[:test_results].to_json))
10✔
511
          render json: { status: 'success', test_run_id: test_run.id }, status: :created
7✔
512
        end
513
      rescue ActiveRecord::RecordInvalid => e
514
        render json: { errors: e.record.errors.full_messages }, status: :unprocessable_content
3✔
515
      rescue StandardError => e
516
        m_logger.log("Test results processing failed: #{e.message}\n#{e.backtrace.join("\n")}")
3✔
517
        render json: { errors: 'Failed to process test results' }, status: :internal_server_error
3✔
518
      end
519
    end
520

521
    def overall_comment
2✔
522
      result = grouping&.current_result
12✔
523
      return page_not_found('No submission exists for that group') if result.nil?
12✔
524

525
      case request.method
10✔
526
      when 'GET'
527
        respond_to do |format|
5✔
528
          format.xml do
5✔
529
            render xml: { overall_comment: result.overall_comment }.to_xml(root: 'result', skip_types: 'true')
3✔
530
          end
531
          format.json { render json: { overall_comment: result.overall_comment } }
7✔
532
        end
533
      when 'PATCH'
534
        if has_missing_params?([:overall_comment])
5✔
535
          render 'shared/http_status', locals: { code: '422', message:
3✔
536
              HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content
537
          return
3✔
538
        end
539
        if result.update(overall_comment: params[:overall_comment])
2✔
540
          head :ok
1✔
541
        else
542
          render 'shared/http_status',
1✔
543
                 locals: { code: '422', message: result.errors.full_messages.first },
544
                 status: :unprocessable_content
545
        end
546
      end
547
    end
548

549
    private
2✔
550

551
    def assignment
2✔
552
      return @assignment if defined?(@assignment)
113✔
553

554
      @assignment = Assignment.find_by(id: params[:assignment_id])
97✔
555
    end
556

557
    def grouping
2✔
558
      @grouping ||= record.grouping_for_assignment(assignment.id)
146✔
559
    end
560

561
    def annotations_params
2✔
562
      params.permit(annotations: [
11✔
563
        :annotation_category_name,
564
        :content,
565
        :filename,
566
        :type,
567
        :line_start,
568
        :line_end,
569
        :column_start,
570
        :column_end,
571
        :x1,
572
        :y1,
573
        :x2,
574
        :y2,
575
        :page,
576
        :start_node,
577
        :start_offset,
578
        :end_node,
579
        :end_offset
580
      ])[:annotations] || []
581
    end
582

583
    def add_annotations_error(message)
2✔
584
      render 'shared/http_status',
4✔
585
             locals: { code: '422', message: message },
586
             status: :unprocessable_content
587
    end
588

589
    def time_delta_params
2✔
590
      params = extension_params[:time_delta]
5✔
591
      Extension::PARTS.sum { |part| params[part].to_i.public_send(part) }
25✔
592
    end
593

594
    def extension_params
2✔
595
      params.require(:extension).permit({ time_delta: [:weeks, :days, :hours, :minutes] }, :apply_penalty, :note)
12✔
596
    end
597
  end
598
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