Add setOverrideScore mutation

closes GRADE-84

Test plan:
Note that you can also test this via the /graphiql endpoint on local
Canvas installation once you've logged in (you can use the query
string from the heredoc below).

- Identify a Score object you want to update and its corresponding
  Enrollment object
- In a Rails console:
  > mutation_str = <<~STR
    mutation {
      setOverrideScore(input: {
        enrollmentId: <the enrollment ID>
        gradingPeriodId: <ID of associated grading period if exists>
        overrideScore: 123.4567 <or whatever>
      }) {
        grades {
          gradingPeriod {
            id
            _id
          }
          overrideScore
        }
        errors {
          attribute
          message
        }
      }
    }
  STR
  > user = User.find(<a user ID with appropriate permissions>)
  > CanvasSchema.execute(mutation_str, context: {current_user: user})

- You should get back a response with no errors and a score-like object
  (technically a Grades type in GraphQL)
- The properties of the returned object should include the override score
  you set and, if you updated a score belonging to a grading period, the
  GraphQL and Rails ID fields for the grading period (in the gradingPeriod
  attribute)
- Check the Score object with the matching enrollment/grading period to
  make sure your call did indeed update it

- Also try the following:
  - Excluding the gradingPeriodId field from the input hash (i.e.,
    update the score associated with an enrollment and not belonging
    to a grading period)
  - Passing "null" (without the quotes) for the overrideScore; this
    should clear an existing value
- The following should return errors and no score data:
  - Passing in malformed values for overrideScore or the ID fields
  - Passing in an ID for a non-existent enrollment/grading period
  - Running the CanvasSchema.execute command with current_user set to a
    user who does not have permissions to modify the score in question
    (e.g., a student, or generally someone who's not a teacher in that
    course)

Change-Id: I2b5c6a80842a6febdb1e398a41aa71ea37cdb064
Reviewed-on: https://gerrit.instructure.com/172472
Tested-by: Jenkins
Reviewed-by: Cameron Matheson <cameron@instructure.com>
Reviewed-by: Gary Mei <gmei@instructure.com>
QA-Review: Adrian Packel <apackel@instructure.com>
Product-Review: Keith Garner <kgarner@instructure.com>
This commit is contained in:
Adrian Packel 2018-11-01 14:56:33 -05:00
parent 284d565b37
commit 7fa746b077
7 changed files with 338 additions and 8 deletions

View File

@ -0,0 +1,48 @@
#
# Copyright (C) 2018 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
class Mutations::SetOverrideScore < Mutations::BaseMutation
graphql_name "SetOverrideScore"
argument :enrollment_id, ID, required: true, prepare: GraphQLHelpers.relay_or_legacy_id_prepare_func("Enrollment")
argument :grading_period_id, ID, required: false, prepare: GraphQLHelpers.relay_or_legacy_id_prepare_func("GradingPeriod")
argument :override_score, Float, required: false
field :grades, Types::GradesType, null: true
def resolve(input:)
enrollment_id = input[:enrollment_id]
grading_period_id = input[:grading_period_id]
enrollment = Enrollment.find(enrollment_id)
score_params = grading_period_id.present? ? {grading_period_id: grading_period_id} : nil
score = enrollment.find_score(score_params)
raise ActiveRecord::RecordNotFound if score.blank?
if authorized_action?(score.course, :manage_grades)
score.override_score = input[:override_score]
if score.save
{grades: score}
else
errors_for(score)
end
end
rescue ActiveRecord::RecordNotFound
raise GraphQL::ExecutionError, "not found"
end
end

View File

@ -23,15 +23,28 @@ module Types
description "Contains grade information for a course or grading period"
field :current_score, Float, <<~DESC, null: true
The current score includes all graded assignments
The current score includes all graded assignments, excluding muted submissions.
DESC
field :unposted_current_score, Float, <<~DESC, null: true
The current score includes all graded assignments, including muted submissions.
DESC
field :current_grade, String, null: true
field :unposted_current_grade, String, null: true
field :final_score, Float, <<~DESC, null: true
The final score includes all assignments
(ungraded assignments are counted as 0 points)
The final score includes all assignments, excluding muted submissions
(ungraded assignments are counted as 0 points).
DESC
field :unposted_final_score, Float, <<~DESC, null: true
The final score includes all assignments, including muted submissions
(ungraded assignments are counted as 0 points).
DESC
field :final_grade, String, null: true
field :unposted_final_grade, String, null: true
field :override_score, Float, <<~DESC, null: true
The override score. Supersedes the computed final score if set.
DESC
field :grading_period, GradingPeriodType, null: true
def grading_period

View File

@ -20,4 +20,8 @@ class Types::MutationType < Types::ApplicationObjectType
graphql_name "Mutation"
field :create_group_in_set, mutation: Mutations::CreateGroupInSet
field :set_override_score, <<~DESC, mutation: Mutations::SetOverrideScore
Sets the overridden final score for the associated enrollment, optionally limited to a specific
grading period. This will supersede the computed final score/grade if present.
DESC
end

View File

@ -26,8 +26,9 @@ class Score < ActiveRecord::Base
has_one :score_metadata
validates :enrollment, presence: true
validates :current_score, :unposted_current_score, :final_score,
:unposted_final_score, numericality: true, allow_nil: true
validates :current_score, :unposted_current_score,
:final_score, :unposted_final_score, :override_score,
numericality: true, allow_nil: true
validate :scorable_association_check

View File

@ -495,14 +495,26 @@ enum EnrollmentWorkflowState {
type Grades {
currentGrade: String
# The current score includes all graded assignments
# The current score includes all graded assignments, excluding muted submissions.
currentScore: Float
finalGrade: String
# The final score includes all assignments
# (ungraded assignments are counted as 0 points)
# The final score includes all assignments, excluding muted submissions
# (ungraded assignments are counted as 0 points).
finalScore: Float
gradingPeriod: GradingPeriod
# The override score. Supersedes the computed final score if set.
overrideScore: Float
unpostedCurrentGrade: String
# The current score includes all graded assignments, including muted submissions.
unpostedCurrentScore: Float
unpostedFinalGrade: String
# The final score includes all assignments, including muted submissions
# (ungraded assignments are counted as 0 points).
unpostedFinalScore: Float
}
type GradingPeriod implements Node & Timestamped {
@ -699,6 +711,10 @@ type Module implements Node & Timestamped {
type Mutation {
createGroupInSet(input: CreateGroupInSetInput!): CreateGroupInSetPayload
# Sets the overridden final score for the associated enrollment, optionally limited to a specific
# grading period. This will supersede the computed final score/grade if present.
setOverrideScore(input: SetOverrideScoreInput!): SetOverrideScorePayload
}
# An object with an ID.
@ -835,6 +851,19 @@ enum SelfSignupPolicy {
restricted
}
# Autogenerated input type of SetOverrideScore
input SetOverrideScoreInput {
enrollmentId: ID!
gradingPeriodId: ID
overrideScore: Float
}
# Autogenerated return type of SetOverrideScore
type SetOverrideScorePayload {
errors: [ValidationError!]
grades: Grades
}
# basic information about a students activity in a course
type StudentSummaryAnalytics {
pageViews: PageViewAnalysis

View File

@ -0,0 +1,138 @@
#
# Copyright (C) 2018 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require_relative "../../spec_helper"
describe Mutations::SetOverrideScore do
let!(:account) { Account.create! }
let!(:course) { account.courses.create! }
let!(:student_enrollment) { course.enroll_student(User.create!, enrollment_state: 'active') }
let!(:grading_period) do
group = account.grading_period_groups.create!(title: "a test group")
group.enrollment_terms << course.enrollment_term
group.grading_periods.create!(
title: "a grading period",
start_date: 1.week.ago,
end_date: 1.week.from_now,
close_date: 2.weeks.from_now
)
end
let!(:teacher) { course.enroll_teacher(User.create!, enrollment_state: 'active').user }
let(:score_for_enrollment) { student_enrollment.find_score }
let(:score_for_grading_period) { student_enrollment.find_score(grading_period_id: grading_period.id) }
before(:each) do
course.assignments.create!(title: "hi", grading_type: "points", points_possible: 1000)
end
def mutation_str(enrollment_id: student_enrollment.id, grading_period_id: nil, override_score: 45.0)
override_value = override_score || 'null'
input_string = "enrollmentId: #{enrollment_id} overrideScore: #{override_value}"
input_string += " gradingPeriodId: #{grading_period_id}" if grading_period_id.present?
<<~GQL
mutation {
setOverrideScore(input: {
#{input_string}
}) {
grades {
gradingPeriod {
id
_id
}
overrideScore
}
errors {
attribute
message
}
}
}
GQL
end
context "when executed by a user with permission to set override scores" do
let(:context) { {current_user: teacher} }
describe "returned values" do
it "returns the ID of the grading period in the gradingPeriodId field if the score has a grading period" do
result = CanvasSchema.execute(mutation_str(grading_period_id: grading_period.id), context: context)
expect(result.dig("data", "setOverrideScore", "grades", "gradingPeriod", "_id")).to eq grading_period.id.to_s
end
it "does not return a value for gradingPeriod if the score has no grading period" do
result = CanvasSchema.execute(mutation_str, context: context)
expect(result.dig("data", "setOverrideScore", "grades", "gradingPeriod")).to be nil
end
it "returns the newly-set score in the overrideScore field" do
result = CanvasSchema.execute(mutation_str, context: context)
expect(result.dig("data", "setOverrideScore", "grades", "overrideScore")).to eq 45.0
end
it "returns a null value for the newly-set score in the overrideScore field if the score is cleared" do
result = CanvasSchema.execute(mutation_str(override_score: nil), context: context)
expect(result.dig("data", "setOverrideScore", "grades", "overrideScore")).to be nil
end
end
describe "model changes" do
it "updates the score belonging to a given enrollment ID with no grading period specified" do
CanvasSchema.execute(mutation_str, context: context)
expect(score_for_enrollment.override_score).to eq 45.0
end
it "updates the score belonging to a given enrollment ID with a grading period specified" do
CanvasSchema.execute(mutation_str(grading_period_id: grading_period.id), context: context)
expect(score_for_grading_period.override_score).to eq 45.0
end
it "nullifies the override_score for the associated score object if a null value is passed" do
score_for_enrollment.update!(override_score: 99.0)
CanvasSchema.execute(mutation_str(override_score: nil), context: context)
expect(score_for_enrollment.reload.override_score).to be nil
end
end
describe "error handling" do
it "returns an error if passed an invalid enrollment ID" do
result = CanvasSchema.execute(mutation_str(enrollment_id: 0), context: context)
expect(result.dig("errors", 0, "message")).to eq "not found"
end
it "returns an error if passed a valid enrollment ID but an invalid grading period ID" do
result = CanvasSchema.execute(mutation_str(grading_period_id: 0), context: context)
expect(result.dig("errors", 0, "message")).to eq "not found"
end
end
end
context "when the caller does not have the manage_grades permission" do
it "returns an error" do
result = CanvasSchema.execute(mutation_str, context: {current_user: student_enrollment.user})
expect(result.dig("errors", 0, "message")).to eq "not found"
end
it "does not return data pertaining to the score in question" do
result = CanvasSchema.execute(mutation_str, context: {current_user: student_enrollment.user})
expect(result.dig("data", "setOverrideScore")).to be nil
end
end
end

View File

@ -0,0 +1,97 @@
#
# Copyright (C) 2018 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require_relative '../../spec_helper'
require_relative '../../helpers/graphql_type_tester'
describe Types::GradesType do
let!(:account) { Account.create! }
let!(:course) { account.courses.create!(grading_standard_enabled: true) }
let!(:student_enrollment) { course.enroll_student(User.create!, enrollment_state: 'active') }
let!(:grading_period) do
group = account.grading_period_groups.create!(title: "a test group")
group.enrollment_terms << course.enrollment_term
group.grading_periods.create!(
title: "Pleistocene",
start_date: 1.week.ago,
end_date: 1.week.from_now,
close_date: 2.weeks.from_now
)
end
let!(:teacher) { course.enroll_teacher(User.create!, enrollment_state: 'active').user }
let(:enrollment_type) { GraphQLTypeTester.new(student_enrollment, current_user: teacher) }
before(:each) do
score = student_enrollment.find_score(grading_period_id: grading_period.id)
score.update!(
current_score: 68.0,
final_score: 78.1,
override_score: 88.2,
unposted_current_score: 71.3,
unposted_final_score: 81.4
)
end
def resolve_grades_field(field)
enrollment_type.resolve("grades { #{field} }", current_user: teacher)
end
describe "fields" do
it "resolves the currentScore field to the corresponding Score's current_score" do
expect(resolve_grades_field("currentScore")).to eq 68.0
end
it "resolves the finalScore field to the corresponding Score's final_score" do
expect(resolve_grades_field("finalScore")).to eq 78.1
end
it "resolves the overrideScore field to the corresponding Score's override_score" do
expect(resolve_grades_field("overrideScore")).to eq 88.2
end
it "resolves the unpostedCurrentScore field to the corresponding Score's unposted_current_score" do
expect(resolve_grades_field("unpostedCurrentScore")).to eq 71.3
end
it "resolves the unpostedFinalScore field to the corresponding Score's unposted_final_score" do
expect(resolve_grades_field("unpostedFinalScore")).to eq 81.4
end
it "resolves the currentGrade field to the corresponding Score's current_grade" do
expect(resolve_grades_field("currentGrade")).to eq 'D+'
end
it "resolves the finalGrade field to the corresponding Score's final_grade" do
expect(resolve_grades_field("finalGrade")).to eq 'C+'
end
it "resolves the unpostedCurrentGrade field to the corresponding Score's unposted_current_grade" do
expect(resolve_grades_field("unpostedCurrentGrade")).to eq 'C-'
end
it "resolves the unpostedFinalGrade field to the corresponding Score's unposted_final_grade" do
expect(resolve_grades_field("unpostedFinalGrade")).to eq 'B-'
end
it "resolves the gradingPeriod field to the score's associated grading period" do
expect(resolve_grades_field("gradingPeriod { title }")).to eq 'Pleistocene'
end
end
end