Allow all updates on assessed outcomes via api

closes OUT-1546

Opens the "Update an outcome" API endpoint to assessed outcomes.
When total points possible are updated, then the LMGB values should
scale according to the original points possible, while the student
graph showing mastery scores should stay the same, since its displayed
as a percentage of total score.

test plan:
  - start up canvas
  - create a course level outcome using the default values for
    criterion ratings, mastery points, calculation method and
    calculation int
  - create an assignment
  - align the outcome to the assignment by creating a rubric
  - as a student, submit to the assignment
  - as a teacher, grade the assignment and provide a 5 out of
    5 for the rubric
  - in the account settings page, enable `Learning Mastery Gradebook` and
    `Student Learning Mastery Gradebook` feature options
  - in the LMGB, confirm that the student has a score of "5 / 3"
  - click on the student's name and view the alignments
  - confirm that the alignment show a score of "5 / 3"
  - hover over the three dots next to "1 Alignment" to confirm
    the graph shows a single data point with 100%
  - confirm that you cannot edit the outcome in the ui
  - using the canvas api, change the outcome's total points possible
    from 5 to 10 by updating the points on the top rating:

    instructions for performing authenticated requests:
    https://canvas.instructure.com/doc/api/index.html

    instructions for updating an outcome via the api:
    https://canvas.instructure.com/doc/api/outcomes.html

    you can determine the id of the latest created outcome
    in the rails console:
    % docker-compose run --rm web bin/rails console
    > LearningOutcome.last.id

    here's an example update in JSON that updates the total
    points possible to 10:

    {
      "display_name": "",
      "title": "Outcome-2018-02-16.101",
      "description": "",
      "calculation_method": "decaying_average",
      "calculation_int": 65,
      "mastery_points": 3,
      "ratings": [
        {
          "description": "Exceeds Expectations",
          "points": 10
        },
        {
          "description": "Meets Expectations",
          "points": 3
        },
        {
          "description": "Does Not Meet Expectations",
          "points": 0
        }
      ]
    }

  - in the LMGB, confirm that the student has a score of "10 / 3"
  - click on the student's name and view the alignments
  - confirm that the alignment show a score of "10 / 3"
  - hover over the three dots next to "1 Alignment" to confirm
    the graph shows a single data point with 100%
  - using the canvas api, change the outcome's total points possible
    from 10 to 5 by updating the points on the top rating
  - create another assignment
  - align the outcome to the assignment by creating a rubric
  - as a student, submit to the assignment
  - as a teacher, grade the assignment and provide a 2.5 out of
    5 for the rubric
  - in the LMGB, confirm that the student has a score of "3.38 / 3"
    (decaying average method: 2.5 * .65 + 5.0 * .35 = 3.375)
  - click on the student's name and view the alignments
  - confirm that the alignment show a score of "3.38 / 3"
  - hover over the three dots next to "1 Alignment" to confirm
    the graph shows a two data points, the most recent at 50% and
    the earlier one at 100%

  * points possible

  - using the canvas api, change the outcome's total points possible
    from 5 to 10 by updating the points on the top rating
  - in the LMGB, confirm that the student has a score of "6.75 / 3"
  - click on the student's name and view the alignments
  - confirm that the alignment show a score of "6.75 / 3"
  - hover over the three dots next to "1 Alignment" to confirm
    the graph shows a two data points, the most recent at 50% and
    the earlier one at 100%

  * calculation method

  - using the canvas api, change the calculation method to 'latest'
  - in the LMGB, confirm that the student has a score of "5 / 3"
  - click on the student's name and view the alignments
  - confirm that the alignment show a score of "5 / 3"
  - hover over the three dots next to "1 Alignment" to confirm
    the graph shows a two data points, the most recent at 50% and
    the earlier one at 100%

  * mastery points

  - using the canvas api, change the mastery points to 10
  - in the LMGB, confirm that the student no longer has mastery
  - click on the student's name and view the alignments
  - confirm the student no longer displays mastery

  * 0 points possible

  - using the canvas api, set all ratings to have 0 points
  - in the LMGB, confirm that the student has a score of "5 / 10"
  - click on the student's name and view the alignments
  - confirm that the alignment show a score of "5 / 10"
  - hover over the three dots next to "1 Alignment" to confirm
    the graph shows a two data points, the most recent at 50% and
    the earlier one at 100%

Change-Id: I8aea02a1ece158742cafc969d9c2fcd79a3259c2
Reviewed-on: https://gerrit.instructure.com/141469
Tested-by: Jenkins
Reviewed-by: Matt Berns <mberns@instructure.com>
Reviewed-by: Michael Brewer-Davis <mbd@instructure.com>
QA-Review: Leo Abner <rabner@instructure.com>
Product-Review: Sidharth Oberoi <soberoi@instructure.com>
This commit is contained in:
Augusto Callejas 2018-02-16 14:43:22 -10:00
parent ae690d09f6
commit 6b21b822fc
5 changed files with 43 additions and 46 deletions

View File

@ -49,6 +49,10 @@ define [
handleAdd: (model) =>
alignment_id = model.get('links').alignment
model.set('alignment_name', @alignments.get(alignment_id)?.get('name'))
if model.get('points_possible') > 0
model.set('score', model.get('points_possible') * model.get('percent'))
else
model.set('score', model.get('mastery_points') * model.get('percent'))
parse: (response) ->
@alignments ?= new Backbone.Collection([])

View File

@ -144,10 +144,16 @@ define [
).value()
masteryPercentage: ->
(@model.get('mastery_points') / @model.get('points_possible')) * 100
if @model.get('points_possible') > 0
(@model.get('mastery_points') / @model.get('points_possible')) * 100
else
100
percentageFor: (score) ->
((score / @model.get('points_possible')) * 100)
if @model.get('points_possible') > 0
((score / @model.get('points_possible')) * 100)
else
((score / @model.get('mastery_points')) * 100)
xValue: (point) =>
@x(point.x)

View File

@ -57,7 +57,6 @@ class LearningOutcome < ActiveRecord::Base
validates :short_description, :workflow_state, presence: true
sanitize_field :description, CanvasSanitize::SANITIZE
validate :validate_calculation_int
validate :validate_text_only_changes_when_assessed
set_policy do
# managing a contextual outcome requires manage_outcomes on the outcome's context
@ -107,33 +106,6 @@ class LearningOutcome < ActiveRecord::Base
end
end
def validate_text_only_changes_when_assessed
if persisted? && assessed?
if criterion_non_text_fields_changed? || (self.changes.keys - %w{data description short_description display_name}).any?
self.errors.add(:base, t("This outcome has been used to assess a student. Only text fields can be updated"))
end
end
end
def criterion_non_text_fields_changed?
return false unless self.data_changed?
old_criterion = (self.data_was && self.data_was[:rubric_criterion]) || {}
return true if self.rubric_criterion.symbolize_keys.except(:description, :ratings) != old_criterion.symbolize_keys.except(:description, :ratings)
new_ratings = self.rubric_criterion[:ratings] || []
old_ratings = old_criterion[:ratings] || []
return true if new_ratings.count != old_ratings.count
non_description_changed = false
new_ratings.each_with_index do |new_rating, idx|
if new_rating.symbolize_keys.except(:description) != old_ratings[idx].symbolize_keys.except(:description)
non_description_changed = true
break
end
end
return non_description_changed
end
def self.valid_calculation_method?(method)
CALCULATION_METHODS.keys.include?(method)
end

View File

@ -725,7 +725,7 @@ describe "Outcomes API", type: :request do
}
end
it "should not allow updating calculation method after being used for assessing" do
it "should allow updating calculation method after being used for assessing" do
expect(@outcome).to be_assessed
expect(@outcome.calculation_method).to eq('decaying_average')
@ -737,16 +737,16 @@ describe "Outcomes API", type: :request do
{ :title => "New Title",
:description => "New Description",
:vendor_guid => "vendorguid9000",
:calculation_method => "n_mastery" },
:calculation_method => "highest" },
{},
{ :expected_status => 400 })
{ :expected_status => 200 })
@outcome.reload
expect(json).not_to eq(outcome_json) # it should be filled with an error
expect(@outcome.calculation_method).to eq('decaying_average')
expect(json).to eq(outcome_json)
expect(@outcome.calculation_method).to eq('highest')
end
it "should not allow updating calculation int after being used for assessing" do
it "should allow updating calculation int after being used for assessing" do
expect(@outcome).to be_assessed
expect(@outcome.calculation_method).to eq('decaying_average')
expect(@outcome.calculation_int).to eq(62)
@ -761,12 +761,12 @@ describe "Outcomes API", type: :request do
:vendor_guid => "vendorguid9000",
:calculation_int => "59" },
{},
{ :expected_status => 400 })
{ :expected_status => 200 })
@outcome.reload
expect(json).not_to eq(outcome_json) # it should be filled with an error
expect(json).to eq(outcome_json)
expect(@outcome.calculation_method).to eq('decaying_average')
expect(@outcome.calculation_int).to eq(62)
expect(@outcome.calculation_int).to eq(59)
end
it "should allow updating text-only fields even when assessed" do
@ -808,26 +808,26 @@ describe "Outcomes API", type: :request do
expect(@outcome2.rubric_criterion[:ratings]).to eq new_ratings
end
it "should not allow updating rating points" do
it "should allow updating rating points" do
new_ratings = [{ description: "some new desc1", points: 5 },
{ description: "some new desc2", points: 3 }]
json = api_call(:put, "/api/v1/outcomes/#{@outcome2.id}",
{ :controller => 'outcomes_api', :action => 'update',
:id => @outcome2.id.to_s, :format => 'json' },
{ :ratings => new_ratings },
{}, { :expected_status => 400 })
{}, { :expected_status => 200 })
@outcome2.reload
expect(@outcome2.rubric_criterion[:ratings]).to_not eq new_ratings
expect(@outcome2.rubric_criterion[:ratings]).to eq new_ratings
end
it "should not allow updating mastery points" do
it "should allow updating mastery points" do
json = api_call(:put, "/api/v1/outcomes/#{@outcome2.id}",
{ :controller => 'outcomes_api', :action => 'update',
:id => @outcome2.id.to_s, :format => 'json' },
{ :mastery_points => 7 },
{}, { :expected_status => 400 })
{}, { :expected_status => 200 })
@outcome2.reload
expect(@outcome2.rubric_criterion[:mastery_points]).to_not eq 7
expect(@outcome2.rubric_criterion[:mastery_points]).to eq 7
end
end
end

View File

@ -31,7 +31,12 @@ QUnit.module('OutcomeResultCollectionSpec', {
mastery_points: 8,
points_possible: 10
})
this.outcome2 = new Outcome({
mastery_points: 8,
points_possible: 0
})
this.outcomeResultCollection = new OutcomeResultCollection([], {outcome: this.outcome})
this.outcomeResultCollection2 = new OutcomeResultCollection([], {outcome: this.outcome2})
this.alignmentName = 'First Alignment Name'
this.alignmentName2 = 'Second Alignment Name'
this.alignmentName3 = 'Third Alignment Name'
@ -39,7 +44,8 @@ QUnit.module('OutcomeResultCollectionSpec', {
outcome_results: [
{
submitted_or_assessed_at: tz.parse('2015-04-24T19:27:54Z'),
links: {alignment: 'alignment_1'}
links: {alignment: 'alignment_1'},
percent: 0.4
}
],
linked: {
@ -108,6 +114,15 @@ test('#handleAdd', function() {
ok(this.outcomeResultCollection.add(this.response.outcome_results[0]))
ok(this.outcomeResultCollection.length, 1)
equal(this.alignmentName, this.outcomeResultCollection.first().get('alignment_name'))
equal(this.outcomeResultCollection.first().get('score'), 4.0)
})
test('#handleAdd 0 points_possible', function() {
equal(this.outcomeResultCollection2.length, 0, 'precondition')
this.outcomeResultCollection2.alignments = new Backbone.Collection(this.response.linked.alignments)
ok(this.outcomeResultCollection2.add(this.response.outcome_results[0]))
ok(this.outcomeResultCollection2.length, 1)
equal(this.outcomeResultCollection2.first().get('score'), 3.2)
})
test('#handleSort', function() {