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:
parent
0e41e29cad
commit
75b82ebdd6
|
@ -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"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -395,6 +395,7 @@ a.screenreader-toggle {
|
|||
overflow: hidden;
|
||||
border-style: solid;
|
||||
border-color: $ic-border-dark;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
div.rubric-toggle {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;"> </div>
|
||||
<div class="grade-summary-graph-component" style="width: <%= graph.low_width %>px; height: 0px; margin-top: 10px; border-bottom-width: 2px;"> </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;"> </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;"> </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;"> </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;"> </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))) %>">
|
||||
</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>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"> </div>
|
||||
<div class="min-point" style="left: {{lowLeft}}%;"> </div>
|
||||
<div class="whisker" style="left: {{lowLeft}}%; width: {{lowLqWidth}}%;"> </div>
|
||||
<div class="left-box" style="left: {{lqLeft}}%; width: {{medianLowWidth}}%;"> </div>
|
||||
<div class="right-box" style="left: {{medianLeft}}%; width: {{medianHighWidth}}%;"> </div>
|
||||
<div class="whisker" style="left: {{uqLeft}}%; width: {{highWidth}}%;"> </div>
|
||||
<div class="max-point" style="left: {{highLeft}}%;"> </div>
|
||||
<div class="total-point" style="left: {{maxLeft}}%;"> </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>
|
||||
|
|
Loading…
Reference in New Issue