Improve score details and grade statistics

Adds median and quartile calculations, and uses them to generate a
proper box and whiskers plot.

fixes GH-1871
fixes GH-1139

flag = enhanced_grade_statistics

test plan:
- Grade an assignment for at least 5 students
- Verify the student view score details show median and quartile
  numbers
- Verify the box plot matches those numbers and looks reasonable

Change-Id: I6ce4b792a112eed72df095503a6f4e82da2912c1
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/242741
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Spencer Olson <solson@instructure.com>
Reviewed-by: Aaron Shafovaloff <ashafovaloff@instructure.com>
QA-Review: Aaron Shafovaloff <ashafovaloff@instructure.com>
Product-Review: Jody Sailor
Migration-Review: Jeremy Stanley <jeremy@instructure.com>
This commit is contained in:
Jacob Burroughs 2021-05-21 18:27:44 -05:00
parent 0e41e29cad
commit 75b82ebdd6
20 changed files with 366 additions and 123 deletions

View File

@ -262,6 +262,21 @@
# "description": "Mean score",
# "example": 6,
# "type": "integer"
# },
# "upper_q": {
# "description": "Upper quartile score",
# "example": 10,
# "type": "integer"
# },
# "median": {
# "description": "Median score",
# "example": 6,
# "type": "integer"
# },
# "lower_q": {
# "description": "Lower quartile score",
# "example": 1,
# "type": "integer"
# }
# }
# }

View File

@ -20,6 +20,6 @@
class ScoreStatistic < ApplicationRecord
belongs_to :assignment
validates :assignment, :maximum, :minimum, :mean, :count, presence: true
validates :maximum, :minimum, :mean, :count, numericality: true
validates :assignment, :maximum, :minimum, :mean, :count, :lower_q, :median, :upper_q, presence: true
validates :maximum, :minimum, :mean, :count, :lower_q, :median, :upper_q, numericality: true
end

View File

@ -229,18 +229,34 @@ class GradeSummaryAssignmentPresenter
def grade_distribution
@grade_distribution ||= if (stats = @summary.assignment_stats[assignment.id])
[stats.maximum, stats.minimum, stats.mean].map { |stat| stat.to_f.round(2) }
[stats.maximum, stats.minimum, stats.mean, stats.median, stats.lower_q, stats.upper_q].map { |stat| stat&.to_f&.round(2) }
end
end
def graph
@graph ||= begin
high, low, mean = grade_distribution
high, low, mean, median, lower_q, upper_q = grade_distribution
score = submission&.score
GradeSummaryGraph.new(high, low, mean, assignment.points_possible, score)
# Just render the old-style fake box-and whiskers plot (box edges are high and low with middle at mean)
# if flag off or we don't have the new statistics
GradeSummaryGraph.new(
high: high,
low: low,
lower_q: lower_q,
upper_q: upper_q,
median: median,
mean: mean,
points_possible: assignment.points_possible,
score: score,
legacy: !show_advanced_statistics?(median)
)
end
end
def show_advanced_statistics?(median)
Account.site_admin.feature_enabled?(:enhanced_grade_statistics) && !median.nil?
end
def file
@file ||= submission.attachments.detect { |a| plagiarism_attachment?(a) }
end
@ -268,60 +284,55 @@ class GradeSummaryAssignmentPresenter
def viewing_fake_student?
@summary.student_enrollment.fake_student?
end
end
class GradeSummaryGraph
FULLWIDTH = 150.0
GradeSummaryGraph = Struct.new(:high, :low, :lower_q, :upper_q, :median, :mean, :points_possible, :score, :legacy, keyword_init: true) do
def low_pos
pixels_for(legacy ? 0 : low)
end
def initialize(high, low, mean, points_possible, score)
@high = high.to_f
@mean = mean.to_f
@low = low.to_f
@points_possible = points_possible.to_f
@score = score
end
def lq_pos
pixels_for(legacy ? low : lower_q)
end
def low_width
pixels_for(@low)
end
def uq_pos
pixels_for(legacy ? high : upper_q)
end
def high_left
pixels_for(@high)
end
def median_pos
pixels_for(legacy ? mean : median)
end
def high_width
pixels_for(@points_possible - @high)
end
def high_pos
pixels_for(legacy ? points_possible : high)
end
def mean_left
pixels_for(@mean)
end
def max_pos
FULLWIDTH
end
def mean_low_width
pixels_for(@mean - @low)
end
def score_pos
pixels_for(score)
end
def mean_high_width
pixels_for(@high - @mean)
end
def title
if legacy
I18n.t("#grade_summary.graph_title", "Mean %{mean}, High %{high}, Low %{low}",
{
mean: I18n.n(mean), high: I18n.n(high), low: I18n.n(low)
})
else
I18n.t("Median %{median}, High %{high}, Low %{low}",
{
median: I18n.n(median), high: I18n.n(high), low: I18n.n(low)
})
end
end
def max_left
[FULLWIDTH.round, (pixels_for(@high) + 3)].max
end
private
def score_left
pixels_for(@score) - 5
end
def title
I18n.t("#grade_summary.graph_title", "Mean %{mean}, High %{high}, Low %{low}", {
mean: I18n.n(@mean), high: I18n.n(@high), low: I18n.n(@low)
})
end
private
def pixels_for(value)
(value.to_f / @points_possible * FULLWIDTH).round
def pixels_for(value)
(value.to_f / points_possible * FULLWIDTH)
end
end
end

View File

@ -243,7 +243,14 @@ class GradeSummaryPresenter
end
def assignment_stats
@stats ||= ScoreStatistic.where(assignment: @context.assignments.active.except(:order)).index_by(&:assignment_id)
@assignment_stats ||= begin
res = ScoreStatistic.where(assignment: @context.assignments.active.except(:order)).index_by(&:assignment_id)
# We must have encountered an *old* assignment; enqueue a refresh
if res.any? { |_, stat| stat.median.nil? }
ScoreStatisticsGenerator.update_score_statistics_in_singleton(@context.id)
end
res
end
end
def assignment_presenters

View File

@ -395,6 +395,7 @@ a.screenreader-toggle {
overflow: hidden;
border-style: solid;
border-color: $ic-border-dark;
box-sizing: border-box;
}
div.rubric-toggle {

View File

@ -21,51 +21,75 @@
#assignment-details-dialog {
.distribution {
margin: 15px 0;
height: 30px;
height: 40px;
position: relative;
div {
position: absolute;
top: 0;
#{direction(left)}: 0;
width: 0;
overflow: hidden;
border-style: solid;
border-color: #aaa;
overflow: hidden;
box-sizing: border-box;
}
.bar-left, .bar-right {
height: 10px;
width: 0px;
margin: 5px 0px;
.zero, .points-possible{
top: 26px;
border-width: 0px;
width: auto;
}
.bar-left {
#{direction(left)}: 0;
border-width: direction-sides(2px 0 2px 2px);
.points-possible {
#{direction(left)}: auto;
#{direction(right)}: 0;
}
.none-left, .none-right {
height: 11px;
border-bottom-width: 2px;
.zero-point {
height: 24px;
margin: 0px;
border-width: 1px;
border-#{direction(right)}-width: 0;
}
.some-left {
height: 20px;
border-width: direction-sides(2px 0pt 2px 2px);
.max-point, .min-point {
height: 14px;
height: 18px;
margin: 3px 0px;
border-width: 2px;
border-#{direction(left)}-width: 0;
}
.whisker {
height: 0px;
margin-top: 10px;
border-width: 2px;
border-#{direction(right)}-width: 0;
}
.left-box {
height: 24px;
border-width: 2px;
border-top-#{direction(left)}-radius: 3px;
border-bottom-#{direction(left)}-radius: 3px;
border-#{direction(right)}-width: 0;
background: #fff;
z-index: 2;
}
.some-right {
height: 20px;
.right-box {
height: 24px;
border-width: 2px;
overflow: hidden;
border-top-#{direction(right)}-radius: 3px;
border-bottom-#{direction(right)}-radius: 3px;
background: #fff;
z-index: 2;
}
.bar-right {
width: 0;
height: 10px;
margin: 5px 0;
#{direction(right)}: 0;
border-width: direction-sides(2px 2px 2px 0);
.total-point {
height: 24px;
margin: 0px 1px;
border-width: 1px;
border-#{direction(left)}-width: 0;
}
}
}

View File

@ -503,18 +503,34 @@
<%= t(:disabled_for_student_view, "Test Student scores are not included in grade statistics.") %>
</td>
<% else %>
<% high, low, mean = assignment_presenter.grade_distribution %>
<% high, low, mean, median, low_q, high_q = assignment_presenter.grade_distribution %>
<% show_advanced_statistics = assignment_presenter.show_advanced_statistics?(median) %>
<td>
<%= before_label(:mean, "Mean") %>
<%= n(round_if_whole(mean)) %>
<% if show_advanced_statistics %>
<br/>
<%= before_label(:median, "Median") %>
<%= n(round_if_whole(median)) %>
<% end %>
</td>
<td>
<%= before_label(:high, "High") %>
<%= n(round_if_whole(high)) %>
<% if show_advanced_statistics %>
<br/>
<%= before_label(:high_q, "Upper Quartile") %>
<%= n(round_if_whole(high_q)) %>
<% end %>
</td>
<td>
<%= before_label(:low, "Low") %>
<%= n(round_if_whole(low)) %>
<% if show_advanced_statistics %>
<br/>
<%= before_label(:low_q, "Lower Quartile") %>
<%= n(round_if_whole(low_q)) %>
<% end %>
</td>
<% if assignment_presenter.deduction_present? %>
<td>
@ -530,19 +546,25 @@
<% end %>
<td colspan="<%= assignment_presenter.deduction_present? ? 1 : 3 %>">
<% graph = assignment_presenter.graph %>
<div style="cursor: pointer; float: <%= direction('right') %>; height: 30px; margin-<%= direction('left') %>: 20px; width: 160px; position: relative; margin-<%= direction('right') %>: 30px;" aria-hidden="true" title="<%= graph.title %>">
<div class="grade-summary-graph-component" style="height: 10px; margin: 5px 0px; border-width: 2px; border-<%= direction('right') %>-width: 0;">&nbsp;</div>
<div class="grade-summary-graph-component" style="width: <%= graph.low_width %>px; height: 0px; margin-top: 10px; border-bottom-width: 2px;">&nbsp;</div>
<div class="grade-summary-graph-component" style="left: <%= graph.high_left %>px; width: <%= graph.high_width %>px; height: 0px; margin-top: 10px; border-bottom-width: 2px;">&nbsp;</div>
<div class="grade-summary-graph-component" style="left: <%= graph.low_width %>px; width: <%= graph.mean_low_width %>px; height: 20px; border-width: 2px; border-top-left-radius: 3px; border-bottom-left-radius: 3px; border-<%= direction('right') %>-width: 0; background: #fff;">&nbsp;</div>
<div class="grade-summary-graph-component" style="left: <%= graph.mean_left%>px; width: <%= graph.mean_high_width%>px; height: 20px; border-width: 2px; border-top-right-radius: 3px; border-bottom-right-radius: 3px; background: #fff;">&nbsp;</div>
<div class="grade-summary-graph-component" style="left: <%= graph.max_left %>px; height: 10px; margin: 5px 0px; border-width: 2px; border-<%= direction('left') %>-width: 0;">&nbsp;</div>
<svg viewBox="-1 0 160 30" xmlns="http://www.w3.org/2000/svg" style="cursor: pointer; float: <%= direction('right') %>; height: 30px; margin-<%= direction('left') %>: 20px; width: 161px; position: relative; margin-<%= direction('right') %>: 30px;" aria-hidden="true">
<title><%= graph.title %></title>
<line class="zero" x1="0" y1="3" x2="0" y2="27" stroke="#556572" />
<line class="possible" x1="<%= graph.max_pos %>" y1="3" x2="<%= graph.max_pos %>" y2="27" stroke="#556572" />
<line class="min" x1="<%= graph.low_pos %>" y1="6" x2="<%= graph.low_pos %>" y2="24" stroke="#556572" stroke-width="2" />
<line class="bottomQ" x1="<%= graph.low_pos %>" y1="15" x2="<%= graph.lq_pos %>" y2="15" stroke="#556572" stroke-width="2" />
<line class="topQ" x1="<%= graph.uq_pos %>" y1="15" x2="<%= graph.high_pos %>" y2="15" stroke="#556572" stroke-width="2" />
<line class="max" x1="<%= graph.high_pos %>" y1="6" x2="<%= graph.high_pos %>" y2="24" stroke="#556572" stroke-width="2" />
<rect class="mid50" x="<%= graph.lq_pos %>" y="3" width="<%= graph.uq_pos - graph.lq_pos %>" height="24" stroke="#556572" stroke-width="2" rx="3" fill="none" />
<line class="median" x1="<%= graph.median_pos %>" y1="3" x2="<%= graph.median_pos %>" y2="27" stroke="#556572" stroke-width="2" />
<% if submission && submission.score %>
<div class="grade-summary-graph-component" style="top: 5px; height: 10px; width: 10px; left: <%= graph.score_left %>px; border: 2px solid #248; background: #abd; border-radius: 3px;" title="<%= before_label(:your_score, "Your Score") %>
<%= t(:submission_score, "*%{score}* out of %{possible}", :wrapper => '\1', :score => n(submission.score), :possible => n(round_if_whole(assignment.points_possible))) %>">&nbsp;
</div>
<rect class="myScore" x="<%= graph.score_pos - 7 %>" y="8" width="14" height="14" stroke="#224488" stroke-width="2" rx="3" fill="#aabbdd">
<title><%= before_label(:your_score, "Your Score") %> <%= t(:submission_score, "*%{score}* out of %{possible}", :wrapper => '\1', :score => n(submission.score), :possible => n(round_if_whole(assignment.points_possible))) %></title>
</rect>
<% end %>
</div>
</svg>
</td>
<% end %>
</tr>

View File

@ -960,9 +960,9 @@
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "lib/score_statistics_generator.rb",
"line": 103,
"line": 113,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "ScoreStatistic.connection.execute(\"INSERT INTO #{ScoreStatistic.quoted_table_name}\\n (assignment_id, maximum, minimum, mean, count, created_at, updated_at, root_account_id)\\nVALUES #{bulk_slice.join(\",\")}\\nON CONFLICT (assignment_id)\\nDO UPDATE SET\\n minimum = excluded.minimum,\\n maximum = excluded.maximum,\\n mean = excluded.mean,\\n count = excluded.count,\\n updated_at = excluded.updated_at,\\n root_account_id = #{root_account_id}\\n\")",
"code": "ScoreStatistic.connection.execute(\"INSERT INTO #{ScoreStatistic.quoted_table_name}\\n (assignment_id, maximum, minimum, mean, lower_q, median, upper_q, count, created_at, updated_at, root_account_id)\\nVALUES #{bulk_slice.join(\",\")}\\nON CONFLICT (assignment_id)\\nDO UPDATE SET\\n minimum = excluded.minimum,\\n maximum = excluded.maximum,\\n mean = excluded.mean,\\n lower_q = excluded.lower_q,\\n median = excluded.median,\\n upper_q = excluded.upper_q,\\n count = excluded.count,\\n updated_at = excluded.updated_at,\\n root_account_id = #{root_account_id}\\n\".squish)",
"render_path": null,
"location": {
"type": "method",

View File

@ -126,6 +126,12 @@ speedgrader_dialog_for_unposted_comments:
display_name: Draft Comment Warning Modal in SpeedGrader
description: Enables modal to warn user when a draft comment is left unposted
in SpeedGrader.
enhanced_grade_statistics:
state: hidden
applies_to: SiteAdmin
display_name: Show quartiles for grades and use them for box and whiskers
description: Updates the student-facing assignment statistics to include quartiles and makes the
box-and-whiskers plot a proper box and whiskers plot.
submission_comment_emojis:
state: hidden
display_name: Emojis in Submission Comments

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
#
# Copyright (C) 2021 - 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 AddQuartilesToScoreStatistics < ActiveRecord::Migration[5.2]
tag :predeploy
def change
add_column :score_statistics, :lower_q, :float
add_column :score_statistics, :median, :float
add_column :score_statistics, :upper_q, :float
end
end

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
#
# Copyright (C) 2021 - 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 BackfillQuartilesInScoreStatistics < ActiveRecord::Migration[6.0]
tag :postdeploy
disable_ddl_transaction!
def up
Course.find_ids_in_ranges(batch_size: 100_000) do |start_at, end_at|
DataFixup::RunUpdateScoreStatistics.delay_if_production(
priority: Delayed::LOW_PRIORITY,
n_strand: ["DataFixup::RunUpdateScoreStatistics", Shard.current.database_server.id]
).run(start_at, end_at)
end
end
end

View File

@ -408,6 +408,14 @@ module Api::V1::Assignment
"max" => stats.maximum.to_f.round(1),
"mean" => stats.mean.to_f.round(1)
}
if stats.median.nil?
# We must be serving an old score statistics, go update in the background to ensure it exists next time
ScoreStatisticsGenerator.update_score_statistics_in_singleton(@context.id)
elsif Account.site_admin.feature_enabled?(:enhanced_grade_statistics)
hash["score_statistics"]["upper_q"] = stats.upper_q.to_f.round(1)
hash["score_statistics"]["median"] = stats.median.to_f.round(1)
hash["score_statistics"]["lower_q"] = stats.lower_q.to_f.round(1)
end
end
end

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
#
# Copyright (C) 2021 - 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/>.
module DataFixup::RunUpdateScoreStatistics
def self.run(start_at, end_at)
# The migration will have us at most a range of 100,000 items,
# we'll break it down to a thousand at a time here.
Course.active.find_ids_in_ranges(start_at: start_at, end_at: end_at) do |batch_start, batch_end|
courses_ids_to_recompute = Course.active.where(id: batch_start..batch_end).pluck(:id)
courses_ids_to_recompute.each { |id| ScoreStatisticsGenerator.update_score_statistics(id) }
end
end
end

View File

@ -41,8 +41,11 @@ class ScoreStatisticsGenerator
def self.update_score_statistics(course_id)
root_account_id = Course.find_by(id: course_id)&.root_account_id
update_assignment_score_statistics(course_id, root_account_id: root_account_id)
update_course_score_statistic(course_id)
# Only necessary in local dev because we are not running in a job
GuardRail.activate(:primary) do
update_assignment_score_statistics(course_id, root_account_id: root_account_id)
update_course_score_statistic(course_id)
end
end
def self.update_assignment_score_statistics(course_id, root_account_id:)
@ -74,6 +77,9 @@ class ScoreStatisticsGenerator
MAX(s.score) AS max,
MIN(s.score) AS min,
AVG(s.score) AS avg,
percentile_cont(0.25) WITHIN GROUP (ORDER BY s.score) AS lower_q,
percentile_cont(0.5) WITHIN GROUP (ORDER BY s.score) AS median,
percentile_cont(0.75) WITHIN GROUP (ORDER BY s.score) AS upper_q,
COUNT(*) AS count
FROM
interesting_submissions s
@ -93,6 +99,9 @@ class ScoreStatisticsGenerator
assignment["max"],
assignment["min"],
assignment["avg"],
assignment["lower_q"],
assignment["median"],
assignment["upper_q"],
assignment["count"],
now,
now,
@ -104,13 +113,16 @@ class ScoreStatisticsGenerator
bulk_values.each_slice(100) do |bulk_slice|
connection.execute(<<~SQL.squish)
INSERT INTO #{ScoreStatistic.quoted_table_name}
(assignment_id, maximum, minimum, mean, count, created_at, updated_at, root_account_id)
(assignment_id, maximum, minimum, mean, lower_q, median, upper_q, count, created_at, updated_at, root_account_id)
VALUES #{bulk_slice.join(",")}
ON CONFLICT (assignment_id)
DO UPDATE SET
minimum = excluded.minimum,
maximum = excluded.maximum,
mean = excluded.mean,
lower_q = excluded.lower_q,
median = excluded.median,
upper_q = excluded.upper_q,
count = excluded.count,
updated_at = excluded.updated_at,
root_account_id = #{root_account_id}

View File

@ -1052,6 +1052,9 @@ describe AssignmentsApiController, type: :request do
end
it "shows min, max, and mean when include flag set" do
allow(Account.site_admin).to receive(:feature_enabled?).and_call_original
allow(Account.site_admin).to receive(:feature_enabled?).with(:enhanced_grade_statistics).and_return(true)
setup_graded_submissions
user_session @students[0]
@user = @students[0]
@ -1068,7 +1071,7 @@ describe AssignmentsApiController, type: :request do
include: ["score_statistics", "submission"]
)
assign = json.first
expect(assign["score_statistics"]).to eq({ "min" => 10, "max" => 18, "mean" => 14 })
expect(assign["score_statistics"]).to eq({ "min" => 10, "max" => 18, "mean" => 14, "lower_q" => 14, "median" => 14, "upper_q" => 14 })
end
it "does not show score statistics when include flag not set" do
@ -1199,6 +1202,9 @@ describe AssignmentsApiController, type: :request do
end
it "shoulds show score statistics when include flag is set" do
allow(Account.site_admin).to receive(:feature_enabled?).and_call_original
allow(Account.site_admin).to receive(:feature_enabled?).with(:enhanced_grade_statistics).and_return(true)
setup_graded_submissions
@observer_enrollment.update_attribute(:associated_user_id, @students[0].id)
@ -1216,7 +1222,7 @@ describe AssignmentsApiController, type: :request do
include: %w[score_statistics submission observed_users]
)
assign = json.first
expect(assign["score_statistics"]).to eq({ "min" => 10, "max" => 18, "mean" => 14 })
expect(assign["score_statistics"]).to eq({ "min" => 10, "max" => 18, "mean" => 14, "lower_q" => 14, "median" => 14, "upper_q" => 14 })
end
it "shoulds not show score statistics when no observed student has a grade" do
@ -1241,6 +1247,9 @@ describe AssignmentsApiController, type: :request do
end
it "shoulds show score statistics when any observed student has a grade" do
allow(Account.site_admin).to receive(:feature_enabled?).and_call_original
allow(Account.site_admin).to receive(:feature_enabled?).with(:enhanced_grade_statistics).and_return(true)
setup_graded_submissions
@observer_enrollment.update_attribute(:associated_user_id, @students[5].id)
@ -1266,7 +1275,7 @@ describe AssignmentsApiController, type: :request do
include: %w[score_statistics submission observed_users]
)
assign = json.first
expect(assign["score_statistics"]).to eq({ "min" => 10, "max" => 18, "mean" => 14 })
expect(assign["score_statistics"]).to eq({ "min" => 10, "max" => 18, "mean" => 14, "lower_q" => 14, "median" => 14, "upper_q" => 14 })
end
it "shoulds not show score statistics when less than 5 students have a graded assignment" do

View File

@ -659,7 +659,7 @@ describe DataFixup::PopulateRootAccountIdOnModels do
it_behaves_like "a datafixup that populates root_account_id" do
let(:record) do
ScoreStatistic.create!(
assignment: reference_record, maximum: 100, minimum: 5, mean: 60, count: 10
assignment: reference_record, maximum: 100, minimum: 5, mean: 60, count: 10, lower_q: 20, median: 50, upper_q: 80
)
end
let(:reference_record) { assignment_model }

View File

@ -141,17 +141,23 @@ describe GradeSummaryAssignmentPresenter do
count: 3,
minimum: 1.3333333,
maximum: 2.6666666,
mean: 2
mean: 2,
lower_q: 1,
median: 2.011111,
upper_q: 2.5
)
presenter = GradeSummaryPresenter.new(@course, @student, @student.id)
assignment_presenter = GradeSummaryAssignmentPresenter.new(presenter, @student, @assignment, @submission)
maximum, minimum, mean = assignment_presenter.grade_distribution
maximum, minimum, mean, median, lower_q, upper_q = assignment_presenter.grade_distribution
aggregate_failures do
expect(minimum).to eq 1.33
expect(maximum).to eq 2.67
expect(mean).to eq 2
expect(median).to eq 2.01
expect(lower_q).to eq 1
expect(upper_q).to eq 2.5
end
end
end

View File

@ -193,6 +193,9 @@ describe GradeSummaryPresenter do
expect(assignment_stats.maximum.to_f).to eq 10
expect(assignment_stats.minimum.to_f).to eq 0
expect(assignment_stats.mean.to_f).to eq 5
expect(assignment_stats.median.to_f).to eq 5
expect(assignment_stats.lower_q.to_f).to eq 2.5
expect(assignment_stats.upper_q.to_f).to eq 7.5
end
it "filters out test students and inactive enrollments" do

View File

@ -38,24 +38,25 @@ export default class AssignmentDetailsDialog {
}
show() {
const {scores, locals} = this.compute()
let tally = 0
let width = 0
const {locals} = this.compute()
const totalWidth = 100
const widthForValue = val => (totalWidth * val) / this.assignment.points_possible
$.extend(locals, {
showDistribution: locals.average && this.assignment.points_possible,
noneLeftWidth: (width = totalWidth * (locals.min / this.assignment.points_possible)),
noneLeftLeft: (tally += width) - width,
someLeftWidth: (width =
totalWidth * ((locals.average - locals.min) / this.assignment.points_possible)),
someLeftLeft: (tally += width) - width,
someRightWidth: (width =
totalWidth * ((locals.max - locals.average) / this.assignment.points_possible)),
someRightLeft: (tally += width) - width,
noneRightWidth: (width =
totalWidth *
((this.assignment.points_possible - locals.max) / this.assignment.points_possible)),
noneRightLeft: (tally += width) - width
lowLeft: widthForValue(locals.min),
lqLeft: widthForValue(locals.lowerQuartile),
medianLeft: widthForValue(locals.median),
uqLeft: widthForValue(locals.upperQuartile),
highLeft: widthForValue(locals.max),
maxLeft: totalWidth,
highWidth: widthForValue(locals.max - locals.upperQuartile),
lowLqWidth: widthForValue(locals.lowerQuartile - locals.min),
medianLowWidth: widthForValue(locals.median - locals.lowerQuartile) + 1,
medianHighWidth: widthForValue(locals.upperQuartile - locals.median)
})
return $(assignmentDetailsDialogTemplate(locals)).dialog({
@ -76,6 +77,7 @@ export default class AssignmentDetailsDialog {
student[`assignment_${assignment.id}`].score != null
)
.map(student => student[`assignment_${assignment.id}`].score)
.sort()
const locals = {
assignment,
@ -83,13 +85,23 @@ export default class AssignmentDetailsDialog {
max: this.nonNumericGuard(Math.max(...scores)),
min: this.nonNumericGuard(Math.min(...scores)),
pointsPossible: this.nonNumericGuard(assignment.points_possible, I18n.t('N/A')),
average: this.nonNumericGuard(round(scores.reduce((a, b) => a + b, 0) / scores.length, 2))
average: this.nonNumericGuard(round(scores.reduce((a, b) => a + b, 0) / scores.length, 2)),
median: this.nonNumericGuard(this.percentile(scores, 0.5)),
lowerQuartile: this.nonNumericGuard(this.percentile(scores, 0.25)),
upperQuartile: this.nonNumericGuard(this.percentile(scores, 0.75))
}
return {scores, locals}
}
nonNumericGuard(number, message = I18n.t('No graded submissions')) {
return isFinite(number) && !isNaN(number) ? I18n.n(number) : message
return Number.isFinite(number) && !Number.isNaN(number) ? I18n.n(number) : message
}
percentile(values, percentile) {
const k = Math.floor(percentile * (values.length - 1) + 1) - 1
const f = (percentile * (values.length - 1) + 1) % 1
return values[k] + f * (values[k + 1] - values[k])
}
}

View File

@ -1,12 +1,16 @@
<div id="assignment-details-dialog" title="{{#t "grading_statistics_for_assignment"}}Grade statistics for: {{assignment.name}}{{/t}}">
{{#if showDistribution}}
<div class="distribution" style="display: block; ">
<div class="bar-left"></div>
<div class="none-left" title="{{#t "no_one_scored_lower"}}No one scored lower than {{min}}{{/t}}" style="width: {{noneLeftWidth}}%; left: {{noneLeftLeft}}%; "></div>
<div class="some-left" title="{{#t "scores_lower_than_the_average"}}Scores lower than the average of {{average}}{{/t}}" style="width: {{someLeftWidth}}%; left: {{someLeftLeft}}%; "></div>
<div class="some-right" title="{{#t "scores_higher_than_the_average"}}Scores higher than the average of {{average}}{{/t}}" style="width: {{someRightWidth}}%; left: {{someRightLeft}}%; "></div>
<div class="none-right" title="{{#t "no_one_scored_higher"}}No one scored higher than {{max}}{{/t}}" style="width: {{noneRightWidth}}%; left: {{noneRightLeft}}%; "></div>
<div class="bar-right"></div>
<div class="distribution" style="display: block; " aria-hidden="true">
<div class="zero">0</div>
<div class="zero-point">&nbsp;</div>
<div class="min-point" style="left: {{lowLeft}}%;">&nbsp;</div>
<div class="whisker" style="left: {{lowLeft}}%; width: {{lowLqWidth}}%;">&nbsp;</div>
<div class="left-box" style="left: {{lqLeft}}%; width: {{medianLowWidth}}%;">&nbsp;</div>
<div class="right-box" style="left: {{medianLeft}}%; width: {{medianHighWidth}}%;">&nbsp;</div>
<div class="whisker" style="left: {{uqLeft}}%; width: {{highWidth}}%;">&nbsp;</div>
<div class="max-point" style="left: {{highLeft}}%;">&nbsp;</div>
<div class="total-point" style="left: {{maxLeft}}%;">&nbsp;</div>
<div class="points-possible">{{assignment.points_possible}}</div>
</div>
{{/if}}
<table id="assignment-details-dialog-stats-table">
@ -18,6 +22,18 @@
<th scope="row">{{#t "high_score"}}High Score:{{/t}}</th>
<td>{{max}}</td>
</tr>
<tr>
<th scope="row">{{#t "upper_quartile"}}Upper Quartile:{{/t}}</th>
<td>{{upperQuartile}}</td>
</tr>
<tr>
<th scope="row">{{#t "median"}}Median Score:{{/t}}</th>
<td>{{median}}</td>
</tr>
<tr>
<th scope="row">{{#t "lower_quartile"}}Lower Quartile:{{/t}}</th>
<td>{{lowerQuartile}}</td>
</tr>
<tr>
<th scope="row">{{#t "low_score"}}Low Score:{{/t}}</th>
<td>{{min}}</td>