Updates teacher learning mastery gradebook for fancy math
fixes CNVS-11656 test plan: - enable the teacher and student learning mastery gradebook feature flags - go to gradebook and select the Learning Mastery tab - outcome scores per student and aggregate totals should appear the same as they do on master - hover mouse over outcome title to trigger popover - popover should include a path to the outcome, as well as a pie graph with a key to the right of it that shows colors for 'exceeds expectations', 'meets expectation', and 'does not meet expectations' - popver should then show mastery points needed, outcome description, then a content box that includes the calculation method, along with method description/example. - click a student name to go to student learning mastery gradebook - all things should appear the same was as they did on https://gerrit.instructure.com/#/c/45777/ excluding a minor change to the wording for most recent assessment - masquerade as a student and go to /grades in a course - all popover content should still be the same as when viewing this page as a teacher Change-Id: Ibe4214a4217c4fb42824e66934b49dccff13fe17 Reviewed-on: https://gerrit.instructure.com/48004 Reviewed-by: Simon Williams <simon@instructure.com> QA-Review: Adam Stone <astone@instructure.com> Product-Review: Hilary Scharton <hilary@instructure.com> Tested-by: Jenkins
This commit is contained in:
parent
b68984052d
commit
aa8b5f6dc8
|
@ -57,8 +57,8 @@ define [
|
|||
parent = @rawCollections.groups.get(link.get('outcome_group').id)
|
||||
rollup = rollups[outcome.id]
|
||||
outcome.set('score', rollup?.score)
|
||||
outcome.set('resultTitle', rollup?.title)
|
||||
outcome.set('submissionTime', rollup?.submitted_at)
|
||||
outcome.set('result_title', rollup?.title)
|
||||
outcome.set('submission_time', rollup?.submitted_at)
|
||||
outcome.set('count', rollup?.count || 0)
|
||||
outcome.group = parent
|
||||
parent.get('outcomes').add(outcome)
|
||||
|
|
|
@ -392,7 +392,7 @@ define [
|
|||
|
||||
calculateRatingsTotals: (grid, column) ->
|
||||
results = Grid.View.getColumnResults(grid.getData(), column)
|
||||
ratings = column.outcome.ratings
|
||||
ratings = column.outcome.ratings || []
|
||||
ratings.result_count = results.length
|
||||
points = _.pluck ratings, 'points'
|
||||
counts = _.countBy results, (result) ->
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
define [
|
||||
'i18n!outcomes'
|
||||
], (I18n) ->
|
||||
|
||||
class CalculationMethodContent
|
||||
constructor: (@model) ->
|
||||
@calculation_method = @model.get('calculation_method')
|
||||
@calculation_int = @model.get('calculation_int')
|
||||
|
||||
present: ->
|
||||
@toJSON()[@calculation_method]
|
||||
|
||||
toJSON: ->
|
||||
decaying_average:
|
||||
method: I18n.t("%{recentInt}/%{remainderInt} Decaying Average", {
|
||||
recentInt: @calculation_int
|
||||
remainderInt: 100 - @calculation_int
|
||||
})
|
||||
exampleText: I18n.t("Most recent score counts as 75% of mastery weight, average of all other scores count as 25% of weight."),
|
||||
exScores: "1, 3, 2, 4, 5, 3, 6",
|
||||
exResult: "5.25"
|
||||
n_mastery:
|
||||
method: I18n.t("Achieve mastery %{count} times", {
|
||||
count: @calculation_int
|
||||
})
|
||||
exampleText: I18n.t("Must achieve mastery at least 2 times. Scores above mastery will be averaged to calculate final score."),
|
||||
exScores: "1, 3, 2, 4, 5, 3, 6",
|
||||
exResult: "5.5"
|
||||
latest:
|
||||
method: I18n.t("Latest Score")
|
||||
exampleText: I18n.t("Mastery score reflects the most recent graded assigment or quiz."),
|
||||
exScores: "2, 4, 5, 3",
|
||||
exResult: "3"
|
||||
highest:
|
||||
method: I18n.t("Highest Score")
|
||||
exampleText: I18n.t("Mastery scrore reflects the highest score of a graded assignment or quiz."),
|
||||
exScores: "5, 3, 4, 2",
|
||||
exResult: "5"
|
|
@ -1,8 +1,13 @@
|
|||
define [
|
||||
'underscore'
|
||||
'Backbone'
|
||||
], (_, {Model, Collection}) ->
|
||||
'compiled/models/grade_summary/CalculationMethodContent'
|
||||
], (_, {Model, Collection}, CalculationMethodContent) ->
|
||||
|
||||
class Outcome extends Model
|
||||
defaults:
|
||||
calculation_method: "highest"
|
||||
|
||||
initialize: ->
|
||||
super
|
||||
@set 'friendly_name', @get('display_name') || @get('title')
|
||||
|
@ -40,6 +45,9 @@ define [
|
|||
masteryPercent: ->
|
||||
@get('mastery_points')/@get('points_possible') * 100
|
||||
|
||||
present: ->
|
||||
_.extend({}, @toJSON(), new CalculationMethodContent(@).present())
|
||||
|
||||
toJSON: ->
|
||||
_.extend super,
|
||||
status: @status()
|
||||
|
|
|
@ -105,11 +105,15 @@ define [
|
|||
my: 'center '+(if @options.verticalSide == 'bottom' then 'top' else 'bottom'),
|
||||
at: 'center '+(@options.verticalSide || 'top'),
|
||||
of: @trigger,
|
||||
offset: if @options.verticalSide == 'bottom' then '0px 10px' else '0px -10px',
|
||||
offset: "0px #{@offsetPx()}px",
|
||||
within: 'body',
|
||||
collision: 'flipfit '+(if @options.verticalSide then 'none' else 'flipfit')
|
||||
using: using
|
||||
|
||||
offsetPx: ->
|
||||
offset = if @options.verticalSide == 'bottom' then 10 else -10
|
||||
if @options.invertOffset then (offset * -1) else offset
|
||||
|
||||
restoreFocus: ->
|
||||
# set focus back to the previously focused item.
|
||||
@previousTarget.focus() if @previousTarget and $(@previousTarget).is(':visible')
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
define [
|
||||
'i18n!outcomes'
|
||||
'underscore'
|
||||
'Backbone'
|
||||
'compiled/util/Popover'
|
||||
], (I18n, _, Backbone, Popover) ->
|
||||
|
||||
class OutcomePopoverView extends Backbone.View
|
||||
TIMEOUT_LENGTH: 50
|
||||
|
||||
@optionProperty 'el'
|
||||
@optionProperty 'model'
|
||||
@optionProperty 'template'
|
||||
|
||||
events:
|
||||
'keydown' : 'togglePopover'
|
||||
'mouseenter': 'mouseenter'
|
||||
'mouseleave': 'mouseleave'
|
||||
|
||||
inside: false
|
||||
|
||||
initialize: ->
|
||||
super
|
||||
|
||||
# Overrides
|
||||
render: ->
|
||||
@template(@toJSON())
|
||||
|
||||
# Instance methods
|
||||
closePopover: (e) ->
|
||||
e?.preventDefault()
|
||||
return true unless @popover?
|
||||
@popover.hide()
|
||||
delete @popover
|
||||
|
||||
mouseenter: (e) =>
|
||||
@openPopover(e)
|
||||
@inside = true
|
||||
|
||||
mouseleave: (e) =>
|
||||
@inside = false
|
||||
setTimeout =>
|
||||
return if @inside || !@popover
|
||||
@closePopover()
|
||||
, @TIMEOUT_LENGTH
|
||||
|
||||
openPopover: (e) ->
|
||||
if @closePopover()
|
||||
@popover = new Popover(e, @render(), {
|
||||
verticalSide: 'bottom'
|
||||
manualOffset: 14
|
||||
})
|
||||
|
||||
togglePopover: (e) =>
|
||||
keyPressed = @_getKey(e.keyCode)
|
||||
if keyPressed == "spacebar"
|
||||
@openPopover(e)
|
||||
else if keyPressed == "escape"
|
||||
@closePopover(e)
|
||||
|
||||
# Private
|
||||
_getKey: (keycode) =>
|
||||
keys = {
|
||||
32 : "spacebar"
|
||||
27 : "escape"
|
||||
}
|
||||
keys[keycode]
|
|
@ -2,24 +2,22 @@ define [
|
|||
'i18n!outcomes'
|
||||
'underscore'
|
||||
'Backbone'
|
||||
'timezone'
|
||||
'compiled/views/grade_summary/ProgressBarView'
|
||||
'compiled/util/Popover'
|
||||
'compiled/views/grade_summary/OutcomePopoverView'
|
||||
'jst/grade_summary/outcome'
|
||||
'jst/grade_summary/mastery_hover'
|
||||
], (I18n, _, Backbone, tz, ProgressBarView, Popover, template, mastery_hover) ->
|
||||
'jst/outcomes/outcomePopover'
|
||||
], (I18n, _, Backbone, ProgressBarView, OutcomePopoverView, template, popover_template) ->
|
||||
class OutcomeView extends Backbone.View
|
||||
tagName: 'li'
|
||||
className: 'outcome'
|
||||
template: template
|
||||
mastery_hover: mastery_hover
|
||||
|
||||
TIMEOUT_LENGTH: 50
|
||||
|
||||
events:
|
||||
'keydown .alignment-info i' : 'togglePopover'
|
||||
'mouseenter .alignment-info i': 'mouseenter'
|
||||
'mouseleave .alignment-info i': 'mouseleave'
|
||||
afterRender: ->
|
||||
@popover = new OutcomePopoverView({
|
||||
el: @$('.alignment-info i')
|
||||
model: @model
|
||||
template: popover_template
|
||||
})
|
||||
|
||||
initialize: ->
|
||||
super
|
||||
|
@ -31,103 +29,9 @@ define [
|
|||
statusTooltip: @statusTooltip()
|
||||
progress: @progress
|
||||
|
||||
createPopover: (e) ->
|
||||
methodContent = @getMethodContent()
|
||||
attributes = {
|
||||
scoreDefined: @model.scoreDefined()
|
||||
score: @model.get('score')
|
||||
latestTitle: @model.get('resultTitle') || I18n.t("N/A")
|
||||
masteryPoints: @model.get('mastery_points')
|
||||
masteryLevel: @model.status()
|
||||
method: methodContent.method
|
||||
exampleText: methodContent.exampleText
|
||||
exScores: methodContent.exScores
|
||||
exResult: methodContent.exResult
|
||||
submissionTime: @getSubmissionTime(@model.get('submissionTime'))
|
||||
}
|
||||
popover = new Popover(e, @mastery_hover(attributes), verticalSide: 'bottom', manualOffset: 14)
|
||||
popover.el.on('mouseenter', @mouseenter)
|
||||
popover.el.on('mouseleave', @mouseleave)
|
||||
popover.show(e)
|
||||
popover
|
||||
|
||||
statusTooltip: ->
|
||||
switch @model.status()
|
||||
when 'undefined' then I18n.t 'undefined', 'Unstarted'
|
||||
when 'remedial' then I18n.t 'remedial', 'Remedial'
|
||||
when 'near' then I18n.t 'near', 'Near mastery'
|
||||
when 'mastery' then I18n.t 'mastery', 'Mastery'
|
||||
|
||||
getSubmissionTime: (time) ->
|
||||
if time
|
||||
@model.get('submissionTime')
|
||||
|
||||
getMethodContent: ->
|
||||
#highest for outcomes that pre-date change without a method set
|
||||
#so they keep their original behavior
|
||||
currentMethod = @model.get('calculation_method') || 'highest'
|
||||
methodInt = @model.get('calculation_int')
|
||||
switch currentMethod
|
||||
when "decaying_average" then {
|
||||
method: I18n.t("%{recentInt}/%{remainderInt} Decaying Average", {recentInt: methodInt, remainderInt: 100 - methodInt}),
|
||||
exampleText: I18n.t("Most recent score counts as 75% of mastery weight, average of all other scores count as 25% of weight."),
|
||||
exScores: "1, 3, 2, 4, 5, 3, 6",
|
||||
exResult: "5.25"
|
||||
}
|
||||
when 'n_mastery' then {
|
||||
method: I18n.t("Achieve mastery %{count} times", {count: methodInt}),
|
||||
exampleText: I18n.t("Must achieve mastery at least 2 times. Scores above mastery will be averaged to calculate final score."),
|
||||
exScores: "1, 3, 2, 4, 5, 3, 6",
|
||||
exResult: "5.5"
|
||||
}
|
||||
when 'latest' then {
|
||||
method: I18n.t("Latest Score"),
|
||||
exampleText: I18n.t("Mastery score reflects the most recent graded assigment or quiz."),
|
||||
exScores: "2, 4, 5, 3",
|
||||
exResult: "3"
|
||||
}
|
||||
when 'highest' then {
|
||||
method: I18n.t("Highest Score"),
|
||||
exampleText: I18n.t("Mastery scrore reflects the highest score of a graded assignment or quiz."),
|
||||
exScores: "5, 3, 4, 2",
|
||||
exResult: "5"
|
||||
}
|
||||
|
||||
togglePopover: (e) =>
|
||||
keyPressed = @getKey(e.keyCode)
|
||||
if keyPressed == "spacebar"
|
||||
@openPopover(e)
|
||||
else if keyPressed == "escape"
|
||||
@closePopover(e)
|
||||
|
||||
openPopover: (e) =>
|
||||
e.preventDefault()
|
||||
if @popover
|
||||
@popover.hide()
|
||||
delete @popover
|
||||
@popover = @createPopover(e)
|
||||
|
||||
closePopover: (e) =>
|
||||
return unless @popover
|
||||
e.preventDefault()
|
||||
@popover.hide()
|
||||
delete @popover
|
||||
|
||||
mouseenter: (e) =>
|
||||
@popover = @createPopover(e) unless @popover
|
||||
@inside = true
|
||||
|
||||
mouseleave: (e) =>
|
||||
@inside = false
|
||||
setTimeout =>
|
||||
return if @inside || !@popover
|
||||
@popover.hide()
|
||||
delete @popover
|
||||
, @TIMEOUT_LENGTH
|
||||
|
||||
getKey: (keycode) =>
|
||||
keys = {
|
||||
32 : "spacebar"
|
||||
27 : "escape"
|
||||
}
|
||||
keys[keycode]
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
define [
|
||||
'i18n!gradebook2'
|
||||
'underscore'
|
||||
'Backbone'
|
||||
'compiled/util/Popover'
|
||||
'compiled/models/grade_summary/Outcome'
|
||||
'vendor/d3.v3'
|
||||
'jst/gradebook2/outcome_popover'
|
||||
], (I18n, _, {View}, Popover, d3, popover_template) ->
|
||||
'jst/outcomes/outcomePopover'
|
||||
], (_, {View}, Popover, Outcome, d3, popover_template) ->
|
||||
|
||||
class OutcomeColumnView extends View
|
||||
|
||||
|
@ -24,7 +24,8 @@ define [
|
|||
createPopover: (e) ->
|
||||
@totalsFn()
|
||||
@pickColors()
|
||||
popover = new Popover(e, @popover_template(@attributes), verticalSide: 'bottom')
|
||||
attributes = new Outcome(@attributes)
|
||||
popover = new Popover(e, @popover_template(attributes.present()), verticalSide: 'bottom', invertOffset: true)
|
||||
popover.el.on('mouseenter', @mouseenter)
|
||||
popover.el.on('mouseleave', @mouseleave)
|
||||
@renderChart()
|
||||
|
@ -45,6 +46,7 @@ define [
|
|||
|
||||
pickColors: ->
|
||||
data = @attributes.ratings
|
||||
return unless data
|
||||
last = data.length - 1
|
||||
mastery = @attributes.mastery_points
|
||||
mastery_pos = data.indexOf(
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
#
|
||||
# Copyright (C) 2011 - 2014 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 RollupScore
|
||||
attr_reader :outcome_results, :outcome, :score, :count, :title, :submitted_at
|
||||
def initialize(outcome_results, opts={})
|
||||
@outcome_results = outcome_results
|
||||
@outcome = @outcome_results.first.learning_outcome
|
||||
@count = @outcome_results.size
|
||||
@calculation_method = @outcome.calculation_method || "highest"
|
||||
@calculation_int = @outcome.calculation_int
|
||||
@score = opts[:aggregate_score] ? get_aggregate_score : calculate_results
|
||||
get_latest_result unless opts[:aggregate_score]
|
||||
end
|
||||
|
||||
#todo - do send(@calculation_method) instead of the case to streamline this more
|
||||
def calculate_results
|
||||
# decaying average is default for new outcomes
|
||||
case @calculation_method
|
||||
when 'decaying_average'
|
||||
return nil if @outcome_results.length < 2
|
||||
decaying_average
|
||||
when 'n_mastery'
|
||||
return nil if @outcome_results.length < @calculation_int
|
||||
n_mastery
|
||||
when 'latest'
|
||||
@outcome_results.max_by{|result| result.submitted_at.to_i}.score
|
||||
when 'highest'
|
||||
@outcome_results.max_by{|result| result.score}.score
|
||||
end
|
||||
end
|
||||
|
||||
def n_mastery
|
||||
scores = @outcome_results.map(&:score).sort.last(@calculation_int)
|
||||
(scores.sum.to_f / scores.size).round(2)
|
||||
end
|
||||
|
||||
def decaying_average
|
||||
#default grading method with weight of 65 if none selected.
|
||||
weight = @calculation_int || 65
|
||||
scores = @outcome_results.sort_by{|result| result.submitted_at.to_i}.map(&:score)
|
||||
latestWeighted = scores.pop * (0.01 * weight)
|
||||
olderAvgWeighted = (scores.sum / scores.length) * (0.01 * (100 - weight)).round(2)
|
||||
latestWeighted + olderAvgWeighted
|
||||
end
|
||||
|
||||
def get_latest_result
|
||||
latest_result = @outcome_results.max_by{|result| result.submitted_at.to_i}
|
||||
@submitted_at = latest_result.submitted_at
|
||||
@title = @submitted_at ? latest_result.title.split(", ")[1] : nil
|
||||
end
|
||||
|
||||
def get_aggregate_score
|
||||
scores = @outcome_results.map(&:score)
|
||||
(scores.sum.to_f / scores.size).round(2)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,93 @@
|
|||
@import 'base/environment';
|
||||
|
||||
.outcome-details {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
background-color: #fff;
|
||||
border: 1px solid #b0afaf;
|
||||
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.6);
|
||||
max-width: 500px;
|
||||
min-width: 250px;
|
||||
padding: 18px 12px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
color: #4f5f6e;
|
||||
.title {
|
||||
font-size: 14px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #b0afaf;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.chart {
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
height: 150px
|
||||
}
|
||||
.ratings {
|
||||
font-weight: normal;
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
margin-bottom: 0px;
|
||||
margin-top: 25px;
|
||||
.rating {
|
||||
list-style-type: none;
|
||||
}
|
||||
.legend-color {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
display: inline-block;
|
||||
margin-right: 3px;
|
||||
vertical-align: sub;
|
||||
}
|
||||
}
|
||||
.outcome-info {
|
||||
clear: both;
|
||||
.description {
|
||||
font-weight: 200;
|
||||
}
|
||||
}
|
||||
.mastery {
|
||||
color: #555;
|
||||
}
|
||||
.last-assessment {
|
||||
color: $grayLight;
|
||||
font-size: $fontSizeSmall;
|
||||
}
|
||||
.mastery-details i:before {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.mastery:before {
|
||||
color: #5DC28F;
|
||||
}
|
||||
.near-mastery {
|
||||
color: #DFB056;
|
||||
}
|
||||
.remedial:before {
|
||||
color: #F76A66;
|
||||
}
|
||||
.method-details {
|
||||
background-color: $ic-color-neutral;
|
||||
margin-bottom: 0px;
|
||||
margin-top: 10px;
|
||||
div {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
span {
|
||||
display: inline-block;
|
||||
font-style: italic;
|
||||
}
|
||||
.example {
|
||||
color: $grayLight;
|
||||
font-size: $fontSizeSmall;
|
||||
}
|
||||
.left-content {
|
||||
float: left;
|
||||
}
|
||||
.right-content {
|
||||
padding-left: 10px;
|
||||
}
|
||||
.method-description {
|
||||
width: 320px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -355,53 +355,3 @@ $page-dark: #ebeff2;
|
|||
&.near-mastery { background: $mastery-yellow; }
|
||||
&.remedial { background: $mastery-red; }
|
||||
}
|
||||
|
||||
.outcome-details {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
background-color: #fff;
|
||||
border: 1px solid #b0afaf;
|
||||
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.6);
|
||||
max-width: 500px;
|
||||
min-width: 250px;
|
||||
padding: 18px 12px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
color: #4f5f6e;
|
||||
.title {
|
||||
font-size: 14px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #b0afaf;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.chart {
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
.ratings {
|
||||
font-weight: normal;
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
.rating {
|
||||
list-style-type: none;
|
||||
}
|
||||
.legend-color {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
display: inline-block;
|
||||
margin-right: 3px;
|
||||
vertical-align: sub;
|
||||
}
|
||||
}
|
||||
.mastery {
|
||||
color: #555;
|
||||
}
|
||||
.description {
|
||||
margin: 10px 0;
|
||||
font-size: 11px;
|
||||
font-weight: normal;
|
||||
}
|
||||
.view-more {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
<div class="outcome-details carat-bottom">
|
||||
<div class="title">{{path}}</div>
|
||||
<div class="chart">
|
||||
{{#if ratings.result_count}}
|
||||
<div class="chart-image"></div>
|
||||
<ol class="ratings">
|
||||
{{#each ratings}}
|
||||
<li class="rating"><span class="legend-color" style="background-color: {{color}}" /> {{description}}</li>
|
||||
{{/each}}
|
||||
</ol>
|
||||
{{else}}
|
||||
<p><img src="/images/pie-chart-disabled.png" /></p>
|
||||
<p>{{#t "no_results"}}(no results){{/t}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="mastery">{{#t "mastery_set_at"}}Mastery set at{{/t}}: {{mastery_points}}</div>
|
||||
<div class="description">{{{description}}}</div>
|
||||
<div class="view-more"><a href="{{url}}">{{#t "view_more_details"}}view more details{{/t}}</a></div>
|
||||
</div>
|
|
@ -1,12 +1,32 @@
|
|||
<div class="outcome-details carat-right">
|
||||
<div class="outcome-details">
|
||||
<div class="head-content" tabindex="0">
|
||||
{{#if path}}
|
||||
<div class="title">{{path}}</div>
|
||||
<div class="chart">
|
||||
{{#if ratings.result_count}}
|
||||
<div class="chart-image pull-left"></div>
|
||||
<ol class="ratings pull-left">
|
||||
{{#each ratings}}
|
||||
<li class="rating"><span class="legend-color" style="background-color: {{color}}" /> {{description}}</li>
|
||||
{{/each}}
|
||||
</ol>
|
||||
{{else}}
|
||||
<p><img src="/images/pie-chart-disabled.png" /></p>
|
||||
<p>{{#t "no_results"}}(no results){{/t}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="outcome-info">
|
||||
<div class="mastery">{{#t "mastery_set_at"}}Mastery set at{{/t}}: {{mastery_points}}</div>
|
||||
{{#if description}}<div class="description">{{{description}}}</div>{{/if}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="mastery-details pull-right" aria-hidden="true">
|
||||
{{#if scoreDefined}}
|
||||
<span>{{score}} / {{masteryPoints}}</span>
|
||||
{{#ifEqual masteryLevel 'mastery'}}
|
||||
{{#if score}}
|
||||
<span>{{score}} / {{mastery_points}}</span>
|
||||
{{#ifEqual status 'mastery'}}
|
||||
<i class="icon-check mastery"></i>
|
||||
{{else}}
|
||||
{{#ifEqual masteryLevel 'near'}}
|
||||
{{#ifEqual status 'near'}}
|
||||
<i class="icon-plus near-mastery"></i>
|
||||
{{else}}
|
||||
<i class="icon-x remedial"></i>
|
||||
|
@ -17,16 +37,23 @@
|
|||
{{/if}}
|
||||
</div>
|
||||
<div class="recent-details">
|
||||
<div class="last-assessment">{{#t}}Last Assessment: {{latestTitle}}{{/t}}</div>
|
||||
<div class="submission-time">{{datetimeFormatted submissionTime}}</div>
|
||||
<div class="last-assessment">{{#t}}Latest Assessment{{/t}}:
|
||||
{{#if result_title}}
|
||||
{{result_title}}
|
||||
{{else}}
|
||||
{{#t}}Not available until next submission{{/t}}
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="submission-time">{{datetimeFormatted submission_time}}</div>
|
||||
</div>
|
||||
<div class="screenreader-only">
|
||||
{{#if scoreDefined}}
|
||||
{{#if score}}
|
||||
{{#t}}Current Mastery Score: {{score}} out of {{masteryPoints}}{{/t}}
|
||||
{{else}}
|
||||
{{#t}}No score yet{{/t}}
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="method-details content-box pad-box-mini border border-trbl border-round">
|
||||
<div>
|
|
@ -20,7 +20,7 @@ module Outcomes
|
|||
module ResultAnalytics
|
||||
|
||||
Rollup = Struct.new(:context, :scores)
|
||||
RollupScore = Struct.new(:outcome, :score, :count, :title, :submitted_at)
|
||||
Result = Struct.new(:learning_outcome, :score, :count)
|
||||
|
||||
# Public: Queries learning_outcome_results for rollup.
|
||||
#
|
||||
|
@ -79,13 +79,12 @@ module Outcomes
|
|||
def aggregate_outcome_results_rollup(results, context)
|
||||
rollups = outcome_results_rollups(results)
|
||||
rollup_scores = rollups.map(&:scores).flatten
|
||||
outcome_scores = rollup_scores.group_by(&:outcome)
|
||||
|
||||
aggregate_scores = outcome_scores.map do |outcome, scores|
|
||||
aggregate_score = scores.map(&:score).sum.to_f / scores.size
|
||||
RollupScore.new(outcome, aggregate_score, scores.size)
|
||||
outcome_results = rollup_scores.group_by(&:outcome).values
|
||||
aggregate_results = outcome_results.map do |scores|
|
||||
scores.map{|score| Result.new(score.outcome, score.score, score.count)}
|
||||
end
|
||||
Rollup.new(context, aggregate_scores)
|
||||
aggregate_rollups = aggregate_results.map{|result| RollupScore.new(result,{aggregate_score: true})}
|
||||
Rollup.new(context, aggregate_rollups)
|
||||
end
|
||||
|
||||
# Internal: Generates a rollup of the outcome results, Assuming all the
|
||||
|
@ -97,22 +96,7 @@ module Outcomes
|
|||
# Returns an Array of RollupScore objects
|
||||
def rollup_user_results(user_results)
|
||||
outcome_scores = user_results.chunk(&:learning_outcome_id).map do |_, outcome_results|
|
||||
outcome = outcome_results.first.learning_outcome
|
||||
user_rollup_score = calculate_results(outcome_results, {
|
||||
# || 'highest' is for outcomes created prior to this update with no
|
||||
# method set so they'll keep their initial behavior
|
||||
calculation_method: outcome.calculation_method || "highest",
|
||||
calculation_int: outcome.calculation_int
|
||||
})
|
||||
latest_result = outcome_results.max_by{|result| result.submitted_at.to_i}
|
||||
if !latest_result.submitted_at
|
||||
# don't pass in a title for comparison if there are no submissions with timestamps
|
||||
# otherwise grab the portion of the title that has the assignment/quiz's name
|
||||
latest_result.title = nil
|
||||
else
|
||||
latest_result.title = latest_result.title.split(", ")[1]
|
||||
end
|
||||
RollupScore.new(outcome, user_rollup_score, outcome_results.size, latest_result.title, latest_result.submitted_at)
|
||||
RollupScore.new(outcome_results)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -128,42 +112,6 @@ module Outcomes
|
|||
rollups + missing_users.map { |u| Rollup.new(u, []) }
|
||||
end
|
||||
|
||||
def calculate_results(results, method)
|
||||
# decaying average is default for new outcomes
|
||||
score = case method[:calculation_method]
|
||||
when 'decaying_average'
|
||||
decaying_average(results, method[:calculation_int])
|
||||
when 'n_mastery'
|
||||
n_mastery(results, method[:calculation_int])
|
||||
when 'latest'
|
||||
latest(results)
|
||||
when 'highest'
|
||||
results.max_by{|result| result.score}.score
|
||||
end
|
||||
score
|
||||
end
|
||||
#scoring methods
|
||||
def latest(results)
|
||||
results.max_by{|result| result.submitted_at.to_i}.score
|
||||
end
|
||||
|
||||
def n_mastery(results, n_of_scores)
|
||||
return nil if results.length < n_of_scores
|
||||
|
||||
scores = results.map(&:score).sort.last(n_of_scores)
|
||||
(scores.sum / scores.length).round(2)
|
||||
end
|
||||
|
||||
def decaying_average(results, weight = 75)
|
||||
#default grading method with weight of 75 if none selected
|
||||
return nil if results.length < 2
|
||||
|
||||
scores = results.sort_by{|result| result.submitted_at.to_i}.map(&:score)
|
||||
latestWeighted = scores.pop * (0.01 * weight)
|
||||
olderAvgWeighted = (scores.sum / scores.length) * (0.01 * (100 - weight)).round(2)
|
||||
latestWeighted + olderAvgWeighted
|
||||
end
|
||||
|
||||
class << self
|
||||
include ResultAnalytics
|
||||
end
|
||||
|
|
|
@ -24,7 +24,6 @@ describe Outcomes::ResultAnalytics do
|
|||
let(:ra) { Outcomes::ResultAnalytics }
|
||||
let(:time) { Time.now }
|
||||
Rollup = Outcomes::ResultAnalytics::Rollup
|
||||
RollupScore = Outcomes::ResultAnalytics::RollupScore
|
||||
|
||||
# ResultAnalytics only uses a few fields, so use some mock stuff to avoid all
|
||||
# the surrounding database logic
|
||||
|
@ -46,10 +45,11 @@ describe Outcomes::ResultAnalytics do
|
|||
MockOutcomeResult[MockUser[10, 'a'], MockOutcome[80], 2.0],
|
||||
MockOutcomeResult[MockUser[10, 'a'], MockOutcome[81], 3.0],
|
||||
]
|
||||
expect(ra.rollup_user_results(results)).to eq [
|
||||
RollupScore.new(MockOutcome[80], 2.0, 1),
|
||||
RollupScore.new(MockOutcome[81], 3.0, 1),
|
||||
]
|
||||
rollup = ra.rollup_user_results(results)
|
||||
expect(rollup.size).to eq 2
|
||||
rollup.each.with_index do |ru, i|
|
||||
expect(ru.outcome_results.first).to eq results[i]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -61,9 +61,10 @@ describe Outcomes::ResultAnalytics do
|
|||
MockOutcomeResult[MockUser[10, 'a'], MockOutcome[80], 3.0],
|
||||
MockOutcomeResult[MockUser[10, 'a'], MockOutcome[80], 1.0],
|
||||
]
|
||||
expect(ra.rollup_user_results(results)).to eq [
|
||||
RollupScore.new(MockOutcome[80], 3.0, 2)
|
||||
]
|
||||
rollup = ra.rollup_user_results(results)
|
||||
expect(rollup.size).to eq 1
|
||||
expect(rollup[0].count).to eq 2
|
||||
expect(rollup[0].score).to eq 3.0
|
||||
end
|
||||
|
||||
it 'returns maximum score when highest score method is selected' do
|
||||
|
@ -71,9 +72,9 @@ describe Outcomes::ResultAnalytics do
|
|||
MockOutcomeResult[MockUser[10, 'a'], MockOutcome[80, 'highest'], 3.0],
|
||||
MockOutcomeResult[MockUser[10, 'a'], MockOutcome[80, 'highest'], 1.0],
|
||||
]
|
||||
expect(ra.rollup_user_results(results)).to eq [
|
||||
RollupScore.new(MockOutcome[80, 'highest'], 3.0, 2)
|
||||
]
|
||||
rollup = ra.rollup_user_results(results)
|
||||
expect(rollup[0].score).to eq 3.0
|
||||
expect(rollup[0].outcome.calculation_method).to eq "highest"
|
||||
end
|
||||
|
||||
it 'returns correct score when latest score method is selected' do
|
||||
|
@ -82,9 +83,8 @@ describe Outcomes::ResultAnalytics do
|
|||
MockOutcomeResult[MockUser[10, 'a'], MockOutcome[80, 'latest'], 3.0, "name, o1", time],
|
||||
MockOutcomeResult[MockUser[10, 'a'], MockOutcome[80, 'latest'], 1.0, "name, o1", time - 1.day]
|
||||
]
|
||||
expect(ra.rollup_user_results(results)).to eq [
|
||||
RollupScore.new(MockOutcome[80, 'latest'], 3.0, 3, "o1", time)
|
||||
]
|
||||
rollups = ra.rollup_user_results(results)
|
||||
expect(rollups[0].score).to eq 3.0
|
||||
end
|
||||
|
||||
it 'properly calculates results when method is n# of scores for mastery' do
|
||||
|
@ -102,11 +102,9 @@ describe Outcomes::ResultAnalytics do
|
|||
MockOutcomeResult[MockUser[10, 'a'], MockOutcome[82, 'n_mastery', 4], 1.0],
|
||||
MockOutcomeResult[MockUser[10, 'a'], MockOutcome[82, 'n_mastery', 4], 3.0],
|
||||
]
|
||||
expect(ra.rollup_user_results(results)).to eq [
|
||||
RollupScore.new(MockOutcome[80, 'n_mastery', 3], nil, 2),
|
||||
RollupScore.new(MockOutcome[81, 'n_mastery', 5], nil, 3),
|
||||
RollupScore.new(MockOutcome[82, 'n_mastery', 4], 3.25, 4),
|
||||
]
|
||||
rollups = ra.rollup_user_results(results)
|
||||
expect(rollups.size).to eq 3
|
||||
expect(rollups.map(&:score)).to eq [nil, nil, 3.25]
|
||||
end
|
||||
|
||||
it 'properly calculates results when method is decaying average' do
|
||||
|
@ -119,10 +117,9 @@ describe Outcomes::ResultAnalytics do
|
|||
MockOutcomeResult[MockUser[10, 'a'], MockOutcome[81, 'decaying_average', 75], 1.0, "name, o2", time - 2.days],
|
||||
MockOutcomeResult[MockUser[10, 'a'], MockOutcome[81, 'decaying_average', 75], 3.0, "name, o2", time - 3.days],
|
||||
]
|
||||
expect(ra.rollup_user_results(results)).to eq [
|
||||
RollupScore.new(MockOutcome[80, 'decaying_average', 75], nil, 1, "o1", time),
|
||||
RollupScore.new(MockOutcome[81, 'decaying_average', 75], 3.75, 4, "o2", time),
|
||||
]
|
||||
rollups = ra.rollup_user_results(results)
|
||||
expect(rollups.size).to eq 2
|
||||
expect(rollups.map(&:score)).to eq [nil, 3.75]
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -131,13 +128,14 @@ describe Outcomes::ResultAnalytics do
|
|||
results = [
|
||||
MockOutcomeResult[MockUser[10, 'a'], MockOutcome[80], 4.0],
|
||||
MockOutcomeResult[MockUser[20, 'b'], MockOutcome[80], 5.0],
|
||||
MockOutcomeResult[MockUser[20, 'b'], MockOutcome[80], 3.0],
|
||||
]
|
||||
users = [MockUser[10, 'a'], MockUser[30, 'c']]
|
||||
expect(ra.outcome_results_rollups(results, users)).to eq [
|
||||
Rollup.new(MockUser[10, 'a'], [ RollupScore.new(MockOutcome[80], 4.0, 1) ]),
|
||||
Rollup.new(MockUser[20, 'b'], [ RollupScore.new(MockOutcome[80], 5.0, 1) ]),
|
||||
Rollup.new(MockUser[30, 'c'], []),
|
||||
]
|
||||
rollups = ra.outcome_results_rollups(results, users)
|
||||
rollup_scores = ra.rollup_user_results(results).map(&:outcome_results).flatten
|
||||
rollups.each.with_index do |rollup, i|
|
||||
expect(rollup.scores.map(&:outcome_results).flatten).to eq rollup_scores.find_all{|score| score.user.id == rollup.context.id}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -155,13 +153,10 @@ describe Outcomes::ResultAnalytics do
|
|||
MockOutcomeResult[MockUser[40, 'd'], MockOutcome[81], 7.0],
|
||||
]
|
||||
aggregate_result = ra.aggregate_outcome_results_rollup(results, fake_context)
|
||||
expect(aggregate_result).to eq Rollup.new(
|
||||
fake_context,
|
||||
[
|
||||
RollupScore.new(MockOutcome[80], 2.5, 4),
|
||||
RollupScore.new(MockOutcome[81], 6.0, 3),
|
||||
]
|
||||
)
|
||||
expect(aggregate_result.size).to eq 2
|
||||
expect(aggregate_result.scores.map(&:score)).to eq [2.5, 6.0]
|
||||
expect(aggregate_result.scores[0].outcome_results.size).to eq 4
|
||||
expect(aggregate_result.scores[1].outcome_results.size).to eq 3
|
||||
end
|
||||
end
|
||||
|
||||
|
|
Loading…
Reference in New Issue