Skip to content

Commit a48441e

Browse files
committed
submissions bulk grading api
test plan: * see the API documentation for the new action "Grade multiple submissions" closes #CNVS-17674 Change-Id: I3912d78ad64108a5a819585cfdfcc35dd27448b3 Reviewed-on: https://gerrit.instructure.com/46429 Reviewed-by: Jeremy Stanley <jeremy@instructure.com> Product-Review: Jeremy Stanley <jeremy@instructure.com> Tested-by: Jenkins <jenkins@instructure.com> QA-Review: Clare Strong <clare@instructure.com>
1 parent 64db4bf commit a48441e

5 files changed

Lines changed: 321 additions & 2 deletions

File tree

app/controllers/submissions_api_controller.rb

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,9 @@
138138
#
139139
class SubmissionsApiController < ApplicationController
140140
before_filter :get_course_from_section, :require_context
141-
batch_jobs_in_actions :only => :update, :batch => { :priority => Delayed::LOW_PRIORITY }
141+
batch_jobs_in_actions :only => [:update], :batch => { :priority => Delayed::LOW_PRIORITY }
142142

143+
include Api::V1::Progress
143144
include Api::V1::Submission
144145

145146
# @API List assignment submissions
@@ -654,6 +655,47 @@ def update
654655
end
655656
end
656657

658+
# @API Grade multiple submissions for an assignment
659+
#
660+
# Update the grading for multiple student's assignment submissions in
661+
# an asynchronous job.
662+
#
663+
# The user must have permission to manage grades in the appropriate context
664+
# (course or section).
665+
#
666+
# @argument grade_data[<student_id>][posted_grade] [String]
667+
# See documentation for the posted_grade argument in the
668+
# {api:SubmissionsApiController#update Submissions Update} documentation
669+
#
670+
# @argument grade_data[<student_id>][rubric_assessment] [RubricAssessment]
671+
# See documentation for the rubric_assessment argument in the
672+
# {api:SubmissionsApiController#update Submissions Update} documentation
673+
#
674+
# @example_request
675+
#
676+
# curl 'https://<canvas>/api/v1/courses/1/assignments/2/submissions/update_grades' \
677+
# -X POST \
678+
# -F 'grade_data[3][posted_grade]=88' \
679+
# -F 'grade_data[4][posted_grade]=95' \
680+
# -H "Authorization: Bearer <token>"
681+
#
682+
# @returns Progress
683+
def bulk_update
684+
@assignment = @context.assignments.active.find(params[:assignment_id])
685+
686+
unless @assignment.published? && @context.grants_right?(@current_user, session, :manage_grades)
687+
return render_unauthorized_action
688+
end
689+
690+
grade_data = params[:grade_data]
691+
unless grade_data.is_a?(Hash) && grade_data.present?
692+
return render :json => "'grade_data' parameter required", :status => :bad_request
693+
end
694+
695+
progress = Submission.queue_bulk_update(@context, @section, @assignment, @current_user, grade_data)
696+
render :json => progress_json(progress, @current_user, session)
697+
end
698+
657699
# @API Mark submission as read
658700
#
659701
# No request fields are necessary.

app/models/progress.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class Progress < ActiveRecord::Base
2222

2323
belongs_to :context, :polymorphic => true
2424
validates_inclusion_of :context_type, :allow_nil => true, :in => ['ContentMigration', 'Course', 'User',
25-
'Quizzes::QuizStatistics', 'Account', 'GroupCategory', 'ContentExport']
25+
'Quizzes::QuizStatistics', 'Account', 'GroupCategory', 'ContentExport', 'Assignment']
2626
belongs_to :user
2727
attr_accessible :context, :tag, :completion, :message
2828

@@ -97,6 +97,9 @@ def perform
9797
end
9898

9999
def on_permanent_failure(error)
100+
error_report = ErrorReport.log_exception("Progress::Work", error)
101+
@progress.message = "Unexpected error, ID: #{error_report.id rescue "unknown"}"
102+
@progress.save
100103
@progress.fail
101104
end
102105
end

app/models/submission.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,4 +1200,60 @@ def muted_assignment?
12001200
def without_graded_submission?
12011201
!self.has_submission? && !self.graded?
12021202
end
1203+
1204+
def self.queue_bulk_update(context, section, assignment, grader, grade_data)
1205+
progress = Progress.create!(:context => assignment, :tag => "submissions_update")
1206+
progress.process_job(self, :process_bulk_update, {}, context, section, assignment, grader, grade_data)
1207+
progress
1208+
end
1209+
1210+
def self.process_bulk_update(progress, context, section, assignment, grader, grade_data)
1211+
missing_ids = []
1212+
1213+
scope = assignment.students_with_visibility(context.students_visible_to(grader))
1214+
if section
1215+
scope = scope.where(:enrollments => { :course_section_id => section })
1216+
end
1217+
1218+
preloaded_users = scope.where(:id => grade_data.map{|id, data| id})
1219+
1220+
Delayed::Batch.serial_batch(:priority => Delayed::LOW_PRIORITY) do
1221+
grade_data.each do |user_id, user_data|
1222+
1223+
user = preloaded_users.detect{|u| u.global_id == Shard.global_id_for(user_id)}
1224+
if !user && (params = Api.sis_find_params_for_collection(scope, [user_id], context.root_account)) && params != :not_found
1225+
params[:limit] = 1
1226+
user = scope.all(params).first
1227+
end
1228+
unless user
1229+
missing_ids << user_id
1230+
next
1231+
end
1232+
1233+
if grade = user_data[:posted_grade]
1234+
submissions = assignment.grade_student(user, { :grader => grader, :grade => grade})
1235+
submission = submissions.first
1236+
else
1237+
submission = assignment.find_or_create_submission(user)
1238+
end
1239+
1240+
assessment = user_data[:rubric_assessment]
1241+
if assessment.is_a?(Hash) && assignment.rubric_association
1242+
# prepend each key with "criterion_", which is required by the current
1243+
# RubricAssociation#assess code.
1244+
assessment.keys.each do |crit_name|
1245+
assessment["criterion_#{crit_name}"] = assessment.delete(crit_name)
1246+
end
1247+
assignment.rubric_association.assess(
1248+
:assessor => grader, :user => user, :artifact => submission,
1249+
:assessment => assessment.merge(:assessment_type => 'grading'))
1250+
end
1251+
end
1252+
end
1253+
if missing_ids.any?
1254+
progress.message = "Couldn't find User(s) with API ids #{missing_ids.map{|id| "'#{id}'"}.join(", ")}"
1255+
progress.save
1256+
progress.fail
1257+
end
1258+
end
12031259
end

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,7 @@ def submissions_api(context, path_prefix = context)
886886
post "#{context.pluralize}/:#{context}_id/assignments/:assignment_id/submissions", action: :create, controller: :submissions
887887
post "#{context.pluralize}/:#{context}_id/assignments/:assignment_id/submissions/:user_id/files", action: :create_file
888888
put "#{context.pluralize}/:#{context}_id/assignments/:assignment_id/submissions/:user_id", action: :update
889+
post "#{context.pluralize}/:#{context}_id/assignments/:assignment_id/submissions/update_grades", action: :bulk_update
889890
end
890891
submissions_api("course")
891892
submissions_api("section", "course_section")

spec/apis/v1/submissions_api_spec.rb

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2621,4 +2621,221 @@ def course_with_student_and_submitted_homework
26212621
action: 'mark_submission_unread', controller: 'submissions_api', format: 'json'})
26222622
expect(@submission.reload.read?(@teacher)).to be_falsey
26232623
end
2624+
2625+
context 'bulk update' do
2626+
before :each do
2627+
@student1 = user(:active_all => true)
2628+
@student2 = user(:active_all => true)
2629+
course_with_teacher(:active_all => true)
2630+
@default_section = @course.default_section
2631+
@section = @course.course_sections.create!(:name => "section2")
2632+
@course.enroll_user(@student1, 'StudentEnrollment', :section => @section).accept!
2633+
@course.enroll_user(@student2, 'StudentEnrollment').accept!
2634+
@a1 = @course.assignments.create!({:title => 'assignment1', :grading_type => 'percent', :points_possible => 10})
2635+
end
2636+
2637+
it "should queue bulk update through courses" do
2638+
grade_data = {
2639+
:grade_data => {
2640+
@student1.id => { :posted_grade => '75%'},
2641+
@student2.id => { :posted_grade => '95%'}
2642+
}
2643+
}
2644+
2645+
json = api_call(:post,
2646+
"/api/v1/courses/#{@course.id}/assignments/#{@a1.id}/submissions/update_grades",
2647+
{ :controller => 'submissions_api', :action => 'bulk_update',
2648+
:format => 'json', :course_id => @course.id.to_s,
2649+
:assignment_id => @a1.id.to_s }, grade_data)
2650+
2651+
run_jobs
2652+
progress = Progress.find(json["id"])
2653+
expect(progress.completed?).to be_truthy
2654+
2655+
expect(Submission.count).to eq 2
2656+
s1 = @student1.submissions.first
2657+
expect(s1.grade).to eq "75%"
2658+
s2 = @student2.submissions.first
2659+
expect(s2.grade).to eq "95%"
2660+
end
2661+
2662+
it "should find users through sis api ids" do
2663+
student3 = user_with_pseudonym(:active_all => true)
2664+
student3.pseudonym.update_attribute(:sis_user_id, 'my-student-id')
2665+
@course.enroll_user(student3, 'StudentEnrollment').accept!
2666+
2667+
grade_data = {
2668+
:grade_data => {
2669+
'sis_user_id:my-student-id' => { :posted_grade => '75%'}
2670+
}
2671+
}
2672+
2673+
@user = @teacher
2674+
json = api_call(:post,
2675+
"/api/v1/courses/#{@course.id}/assignments/#{@a1.id}/submissions/update_grades",
2676+
{ :controller => 'submissions_api', :action => 'bulk_update',
2677+
:format => 'json', :course_id => @course.id.to_s,
2678+
:assignment_id => @a1.id.to_s }, grade_data)
2679+
2680+
run_jobs
2681+
progress = Progress.find(json["id"])
2682+
expect(progress.completed?).to be_truthy
2683+
2684+
expect(Submission.count).to eq 1
2685+
s1 = student3.submissions.first
2686+
expect(s1.grade).to eq "75%"
2687+
end
2688+
2689+
it "should restrict with differentiated assignments" do
2690+
@a1.only_visible_to_overrides = true
2691+
@a1.save!
2692+
create_section_override_for_assignment(@a1, course_section: @section)
2693+
@course.enable_feature!(:differentiated_assignments)
2694+
2695+
student3 = user_with_pseudonym(:active_all => true)
2696+
student3.pseudonym.update_attribute(:sis_user_id, 'my-student-id')
2697+
@course.enroll_user(student3, 'StudentEnrollment').accept!
2698+
2699+
grade_data = {
2700+
:grade_data => {
2701+
@student1.id => { :posted_grade => '75%'},
2702+
@student2.id => { :posted_grade => '95%'},
2703+
'sis_user_id:my-student-id' => { :posted_grade => '85%'},
2704+
}
2705+
}
2706+
2707+
@user = @teacher
2708+
json = api_call(:post,
2709+
"/api/v1/courses/#{@course.id}/assignments/#{@a1.id}/submissions/update_grades",
2710+
{ :controller => 'submissions_api', :action => 'bulk_update',
2711+
:format => 'json', :course_id => @course.id.to_s,
2712+
:assignment_id => @a1.id.to_s }, grade_data)
2713+
2714+
run_jobs
2715+
progress = Progress.find(json["id"])
2716+
expect(progress.failed?).to be_truthy
2717+
expect(progress.message).to eq "Couldn't find User(s) with API ids '#{@student2.id}', 'sis_user_id:my-student-id'"
2718+
2719+
@course.disable_feature!(:differentiated_assignments)
2720+
json = api_call(:post,
2721+
"/api/v1/courses/#{@course.id}/assignments/#{@a1.id}/submissions/update_grades",
2722+
{ :controller => 'submissions_api', :action => 'bulk_update',
2723+
:format => 'json', :course_id => @course.id.to_s,
2724+
:assignment_id => @a1.id.to_s }, grade_data)
2725+
2726+
run_jobs
2727+
progress = Progress.find(json["id"])
2728+
expect(progress.completed?).to be_truthy
2729+
2730+
expect(Submission.count).to eq 3
2731+
s1 = @student1.submissions.first
2732+
expect(s1.grade).to eq "75%"
2733+
s2 = @student2.submissions.first
2734+
expect(s2.grade).to eq "95%"
2735+
s3 = student3.submissions.first
2736+
expect(s3.grade).to eq "85%"
2737+
end
2738+
2739+
it "should queue bulk update through sections" do
2740+
grade_data = {
2741+
:grade_data => {
2742+
@student1.id => { :posted_grade => '75%'}
2743+
}
2744+
}
2745+
2746+
json = api_call(:post,
2747+
"/api/v1/sections/#{@section.id}/assignments/#{@a1.id}/submissions/update_grades",
2748+
{ :controller => 'submissions_api', :action => 'bulk_update',
2749+
:format => 'json', :section_id => @section.id.to_s,
2750+
:assignment_id => @a1.id.to_s }, grade_data)
2751+
2752+
run_jobs
2753+
progress = Progress.find(json["id"])
2754+
expect(progress.completed?).to be_truthy
2755+
2756+
expect(Submission.count).to eq 1
2757+
s1 = @student1.submissions.first
2758+
expect(s1.grade).to eq "75%"
2759+
end
2760+
2761+
it "should allow bulk grading with rubric assessments" do
2762+
rubric = rubric_model(:user => @user, :context => @course, :data => larger_rubric_data)
2763+
@a1.create_rubric_association(:rubric => rubric, :purpose => 'grading', :use_for_grading => true, :context => @course)
2764+
2765+
grade_data = {
2766+
:grade_data => {
2767+
@student1.id => { :posted_grade => '75%'},
2768+
@student2.id => {
2769+
:rubric_assessment => {
2770+
:crit1 => { :points => 7 },
2771+
:crit2 => { :points => 2, :comments => 'Rock on' }
2772+
}
2773+
}
2774+
}
2775+
}
2776+
2777+
json = api_call(:post,
2778+
"/api/v1/courses/#{@course.id}/assignments/#{@a1.id}/submissions/update_grades",
2779+
{ :controller => 'submissions_api', :action => 'bulk_update',
2780+
:format => 'json', :course_id => @course.id.to_s,
2781+
:assignment_id => @a1.id.to_s }, grade_data)
2782+
2783+
run_jobs
2784+
progress = Progress.find(json["id"])
2785+
expect(progress.completed?).to be_truthy
2786+
2787+
expect(Submission.count).to eq 2
2788+
s1 = @student1.submissions.first
2789+
expect(s1.grade).to eq "75%"
2790+
s2 = @student2.submissions.first
2791+
expect(s2.rubric_assessment).not_to be_nil
2792+
expect(s2.rubric_assessment.data).to eq(
2793+
[{:description=>"B",
2794+
:criterion_id=>"crit1",
2795+
:comments_enabled=>true,
2796+
:points=>7,
2797+
:learning_outcome_id=>nil,
2798+
:id=>"rat2",
2799+
:comments=>nil},
2800+
{:description=>"Pass",
2801+
:criterion_id=>"crit2",
2802+
:comments_enabled=>true,
2803+
:points=>2,
2804+
:learning_outcome_id=>nil,
2805+
:id=>"rat1",
2806+
:comments=>"Rock on",
2807+
:comments_html=>"Rock on"}]
2808+
)
2809+
end
2810+
2811+
it "should require authorization" do
2812+
@user = @student1
2813+
raw_api_call(:post,
2814+
"/api/v1/courses/#{@course.id}/assignments/#{@a1.id}/submissions/update_grades",
2815+
{ :controller => 'submissions_api', :action => 'bulk_update',
2816+
:format => 'json', :course_id => @course.id.to_s,
2817+
:assignment_id => @a1.id.to_s }, {})
2818+
assert_status(401)
2819+
end
2820+
2821+
it "should check user ids for sections" do
2822+
grade_data = {
2823+
:grade_data => {
2824+
@student1.id => { :posted_grade => '75%'},
2825+
@student2.id => { :posted_grade => '95%'}
2826+
}
2827+
}
2828+
2829+
json = api_call(:post,
2830+
"/api/v1/sections/#{@section.id}/assignments/#{@a1.id}/submissions/update_grades",
2831+
{ :controller => 'submissions_api', :action => 'bulk_update',
2832+
:format => 'json', :section_id => @section.id.to_s,
2833+
:assignment_id => @a1.id.to_s }, grade_data)
2834+
2835+
run_jobs
2836+
progress = Progress.find(json["id"])
2837+
expect(progress.failed?).to be_truthy
2838+
expect(progress.message).to eq "Couldn't find User(s) with API ids '#{@student2.id}'"
2839+
end
2840+
end
26242841
end

0 commit comments

Comments
 (0)