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

MarkUsProject / Markus / 29268662874

13 Jul 2026 04:59PM UTC coverage: 90.325% (+0.02%) from 90.309%
29268662874

Pull #8055

github

web-flow
Merge 0b1764aa0 into 6750e5727
Pull Request #8055: Implement GET test_runs API route

1218 of 2470 branches covered (49.31%)

Branch coverage included in aggregate %.

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

1 existing line in 1 file now uncovered.

47302 of 51247 relevant lines covered (92.3%)

127.86 hits per line

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

94.46
/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])
10✔
13
      begin
14
        group = assignment.add_group_api(params[:new_group_name], params[:members])
10✔
15
        respond_to do |format|
7✔
16
          format.xml do
7✔
17
            render xml: group.to_xml(root: 'group', skip_types: 'true')
7✔
18
          end
19
          format.json { render json: group.to_json }
7✔
20
        end
21
      rescue StandardError => e
22
        render 'shared/http_status', locals: { code: '422', message:
3✔
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
12✔
32

33
      group_data = include_memberships(groups)
11✔
34

35
      respond_to do |format|
11✔
36
        format.xml do
11✔
37
          render xml: group_data.to_xml(root: 'groups', skip_types: 'true')
4✔
38
        end
39
        format.json { render json: group_data }
18✔
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))
6✔
48

49
      # We found a grouping for that assignment
50
      respond_to do |format|
6✔
51
        format.xml do
6✔
52
          render xml: group_data.to_xml(root: 'groups', skip_types: 'true')
3✔
53
        end
54
        format.json { render json: group_data }
9✔
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] }])
17✔
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) }
23✔
64
            .map { |k, v| k.merge(members: v.map { |h| h.except(*DEFAULT_FIELDS) }) }
46✔
65
    end
66

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

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

86
      render 'shared/http_status', locals: { code: '200', message:
6✔
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
6✔
93
      return page_not_found('No submission exists for that group') if result.nil?
6✔
94

95
      # We shouldn't be able to update marks if marking is already complete.
96
      if result.marking_state == Result::MARKING_STATES[:complete]
6✔
97
        render 'shared/http_status', locals: { code: '404', message:
2✔
98
          'Marking for that submission is already completed' }, status: :not_found
99
        return
2✔
100
      end
101
      matched_criteria = assignment.criteria.where(name: params.keys)
4✔
102
      if matched_criteria.empty?
4✔
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|
4✔
109
        mark_to_change = result.marks.find_or_initialize_by(criterion_id: crit.id)
4✔
110
        mark_to_change.mark = params[crit.name] == 'nil' ? nil : params[crit.name].to_f
4✔
111
        unless mark_to_change.save
4✔
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
4✔
119
      render 'shared/http_status', locals: { code: '200', message:
4✔
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
5✔
125
      return page_not_found('No submission exists for that group') if result.nil?
5✔
126

127
      begin
128
        ExtraMark.create!(result_id: result.id, extra_mark: params[:extra_marks],
4✔
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:
2✔
133
          e.message }, status: :internal_server_error
134
        return
2✔
135
      end
136
      render 'shared/http_status', locals: { code: '200', message:
2✔
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
5✔
142
      return page_not_found('No submission exists for that group') if result.nil?
5✔
143
      extra_mark = ExtraMark.find_by(result_id: result.id,
4✔
144
                                     description: params[:description],
145
                                     extra_mark: params[:extra_marks])
146
      if extra_mark.nil?
4✔
147
        render 'shared/http_status', locals: { code: '404', message:
2✔
148
          'No such Extra Mark exist for that result' }, status: :not_found
149
        return
2✔
150
      end
151
      begin
152
        extra_mark.destroy
2✔
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:
2✔
161
        'Extra mark removed successfully' }, status: :ok
162
    end
163

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

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

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

207
    def test_results
1✔
208
      return render_no_grouping_error unless grouping
10✔
209

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

215
      return render_no_grouping_error if results.blank?
10✔
216

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

220
      respond_to do |format|
9✔
221
        format.xml { render xml: results_by_test_group.to_xml(root: 'test_results', skip_types: 'true') }
11✔
222
        format.json { render json: results_by_test_group }
16✔
223
      end
224
    end
225

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

232
    def test_runs
1✔
233
      return render_no_grouping_error unless grouping
8✔
234

235
      group_runs = TestRun.where(grouping_id: grouping.id)
8✔
236
      test_group_result_ids = TestGroupResult.where(test_run_id: group_runs)
8✔
237
                                             .pluck(:test_run_id, :id)
238
                                             .group_by(&:first)
239
                                             .transform_values { |pairs| pairs.map(&:last) }
8✔
240

241
      grouping_test_runs = group_runs.as_json.map do |test_run|
8✔
242
        test_run.merge('test_group_result_ids' => test_group_result_ids[test_run['id']])
8✔
243
      end
244

245
      respond_to do |format|
8✔
246
        format.xml { render xml: grouping_test_runs.to_xml(root: 'test_runs', skip_types: 'true') }
10✔
247
        format.json { render json: grouping_test_runs }
14✔
248
      end
249
    end
250

251
    def add_annotations
1✔
252
      result = self.grouping&.current_result
14✔
253
      return page_not_found('No submission exists for that group') if result.nil?
14✔
254

255
      force_complete = params.fetch(:force_complete, false)
14✔
256
      # We shouldn't be able to update annotations if marking is already complete, unless forced.
257
      if result.marking_state == Result::MARKING_STATES[:complete] && !force_complete
14✔
258
        return page_not_found('Marking for that submission is already completed')
×
259
      end
260

261
      annots = annotations_params
14✔
262
      submission_files = result.submission.submission_files.index_by(&:filename)
13✔
263

264
      annotation_texts = []
13✔
265
      annotations = []
13✔
266
      count = result.submission.annotations.count + 1
13✔
267
      annotation_category = nil
13✔
268
      error_message = nil
13✔
269

270
      ActiveRecord::Base.transaction do
13✔
271
        annots.each_with_index do |annot_params, i|
13✔
272
          submission_file = submission_files[annot_params[:filename]]
16✔
273
          if submission_file.nil?
16✔
274
            error_message = "Submission file not found: #{annot_params[:filename]}"
1✔
275
            raise ActiveRecord::Rollback
1✔
276
          end
277

278
          expected_class = submission_file.annotation_class
15✔
279
          required = expected_class.required_fields
15✔
280
          requested = annot_params[:type].presence
15✔
281
          if requested && requested != expected_class.name
15✔
282
            error_message = "Annotation type '#{requested}' does not match the type " \
3✔
283
                            "of file '#{annot_params[:filename]}'"
284
            raise ActiveRecord::Rollback
3✔
285
          end
286

287
          missing = required.select { |field| annot_params[field].blank? }
65✔
288
          if missing.any?
12✔
289
            error_message = "Missing required fields for #{expected_class.name}: #{missing.join(', ')}"
1✔
290
            raise ActiveRecord::Rollback
1✔
291
          end
292

293
          extra = (Annotation.location_fields - required).select { |field| annot_params[field].present? }
106✔
294
          if extra.any?
11✔
295
            error_message = "Unexpected fields for #{expected_class.name}: #{extra.join(', ')}"
1✔
296
            raise ActiveRecord::Rollback
1✔
297
          end
298

299
          if annot_params[:annotation_category_name].nil?
10✔
300
            annotation_category_id = nil
9✔
301
          else
302
            name = annot_params[:annotation_category_name]
1✔
303
            if annotation_category.nil? || annotation_category.annotation_category_name != name
1✔
304
              annotation_category = assignment.annotation_categories.find_or_create_by(
1✔
305
                annotation_category_name: name
306
              )
307
            end
308
            annotation_category_id = annotation_category.id
1✔
309
          end
310

311
          annotation_texts << {
10✔
312
            content: annot_params[:content],
313
            annotation_category_id: annotation_category_id,
314
            creator_id: current_role.id,
315
            last_editor_id: current_role.id
316
          }
317
          annotations << {
10✔
318
            type: expected_class.name,
319
            **annot_params.slice(*required).to_h.symbolize_keys,
320
            annotation_text_id: nil,
321
            submission_file_id: submission_file.id,
322
            creator_id: current_role.id,
323
            creator_type: current_role.type,
324
            is_remark: !result.remark_request_submitted_at.nil?,
325
            annotation_number: count + i,
326
            result_id: result.id
327
          }
328
        end
329

330
        imported = AnnotationText.insert_all! annotation_texts
7✔
331
        imported.rows.zip(annotations) do |t, a|
7✔
332
          a[:annotation_text_id] = t[0]
9✔
333
        end
334
        # Each annotation type has a different set of location columns, and insert_all! requires
335
        # uniform keys, so insert one type at a time.
336
        annotations.group_by { |annotation| annotation[:type] }.each_value do |rows|
16✔
337
          Annotation.insert_all!(rows)
8✔
338
        end
339
      end
340

341
      return add_annotations_error(error_message) if error_message
13✔
342

343
      render 'shared/http_status', locals: { code: '200', message:
7✔
344
        HttpStatusHelper::ERROR_CODE['message']['200'] }, status: :ok
345
    end
346

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

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

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

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

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

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

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

481
      render 'shared/http_status', locals: { code: '201', message:
2✔
482
        HttpStatusHelper::ERROR_CODE['message']['201'] }, status: :created
483
    end
484

485
    def add_test_run
1✔
486
      m_logger = MarkusLogger.instance
15✔
487
      # Validate test results against contract schema
488
      validation = TestResultsContract.new.call(params[:test_results].as_json)
15✔
489

490
      if validation.failure?
15✔
491
        return render json: { errors: validation.errors.to_hash }, status: :unprocessable_content
1✔
492
      end
493

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

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

505
      begin
506
        ActiveRecord::Base.transaction do
13✔
507
          test_run = TestRun.create!(
13✔
508
            status: :in_progress,
509
            role: current_role,
510
            grouping: grouping,
511
            submission: submission
512
          )
513

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

525
    def overall_comment
1✔
526
      result = grouping&.current_result
12✔
527
      return page_not_found('No submission exists for that group') if result.nil?
12✔
528

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

553
    private
1✔
554

555
    def assignment
1✔
556
      return @assignment if defined?(@assignment)
125✔
557

558
      @assignment = Assignment.find_by(id: params[:assignment_id])
108✔
559
    end
560

561
    def grouping
1✔
562
      @grouping ||= record.grouping_for_assignment(assignment.id)
165✔
563
    end
564

565
    def annotations_params
1✔
566
      params.expect(annotations: [[
14✔
567
        :annotation_category_name,
568
        :content,
569
        :filename,
570
        :type,
571
        :line_start,
572
        :line_end,
573
        :column_start,
574
        :column_end,
575
        :x1,
576
        :y1,
577
        :x2,
578
        :y2,
579
        :page,
580
        :start_node,
581
        :start_offset,
582
        :end_node,
583
        :end_offset
584
      ]])
585
    end
586

587
    def add_annotations_error(message)
1✔
588
      render 'shared/http_status',
6✔
589
             locals: { code: '422', message: message },
590
             status: :unprocessable_content
591
    end
592

593
    def time_delta_params
1✔
594
      params = extension_params[:time_delta]
5✔
595
      Extension::PARTS.sum { |part| params[part].to_i.public_send(part) }
25✔
596
    end
597

598
    def extension_params
1✔
599
      params.require(:extension).permit({ time_delta: [:weeks, :days, :hours, :minutes] }, :apply_penalty, :note)
12✔
600
    end
601
  end
602
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