Ember Quiz Statistics - Base/Skeleton

A starting point for the implementation of Ember quiz statistics that
includes a route with the required data properly loaded, the necessary
serializers and adapters, and base stylesheet/template to start from.

This patch also adds a new submission statistic called
"submission_scores" that's basically a map between a score percentile
and the count of students who received it.

Closes CNVS-12171

TEST PLAN
---- ----

  - with fabulous quizzes on, go to a quiz show page
  - click the Statistics tab
    - verify that you see the blank page, and the tab is activated
    - verify that no errors are thrown in the console

Testing the new metric:

  - create a quiz with a certain number of points possible
  - take it by a number of students and score diversely
    - also let more than one student have the same score
  - perform an API request to retrieve the statistics and:
    - verify you get the new score distribution statistic and that it is
      correct
    - the metric should contain an entry for each distinct percentile
    - the metric entry should really reflect how many students got that
      score
  - check out the Quiz Statistics API docs and:
    - verify the new "scores" statistic under SubmissionStatistics is
      documented
    - verify that the documentation is clear enough

Change-Id: I1f00bd4c18a0767d6a50767c5d8868f1d6e561ac
Reviewed-on: https://gerrit.instructure.com/32732
Tested-by: Jenkins <jenkins@instructure.com>
QA-Review: Caleb Guanzon <cguanzon@instructure.com>
Reviewed-by: Jason Madsen <jmadsen@instructure.com>
Product-Review: Derek DeVries <ddevries@instructure.com>
This commit is contained in:
Ahmad Amireh 2014-03-04 08:30:12 +02:00 committed by Derek DeVries
parent 87cd6d57b3
commit e471722b26
28 changed files with 476 additions and 7 deletions

View File

@ -0,0 +1,6 @@
define [
'./jsonapi_adapter'
], (JSONAPIAdapter) ->
JSONAPIAdapter.extend
namespace: "api/v1"

View File

@ -0,0 +1,8 @@
define [
'ember'
'./jsonapi_adapter'
], ({get,set}, JSONAPIAdapter) ->
JSONAPIAdapter.extend
buildURL: (type, id) ->
store = @container.lookup('store:main')
store.getById('quizReport', id).get('url')

View File

@ -12,6 +12,10 @@ define [
initialize: (container, application) ->
env.setEnv(window.ENV)
Ember.Inflector.inflector.irregular('quizStatistics', 'quizStatistics');
Ember.Inflector.inflector.irregular('questionStatistics', 'questionStatistics');
Ember.Inflector.inflector.irregular('progress', 'progress');
Ember.$.ajaxPrefilter 'json', (options, originalOptions, xhr) ->
options.dataType = 'json'
options.headers = 'Accept': 'application/vnd.api+json'

View File

@ -5,3 +5,4 @@ define ->
@resource 'quiz', path: '/:quiz_id', ->
@route 'show', path: '/'
@route 'moderate', path: '/moderate'
@route 'statistics', path: '/statistics'

View File

@ -0,0 +1,51 @@
define [
'ember'
'ember-data'
], ({Deferred}, {Model, attr}) ->
# The Progress model represents the progress of an async operation happening
# in the back-end that may take some time to complete. The model provides an
# interface to track the completion of this operation.
Model.extend
tag: attr('string')
completion: attr('number')
# workflowState can be any one of:
#
# - undefined: the operation hasn't started
# - "queued": the operation is pending and will start soon
# - "running": the operation is being performed
# - "completed": the operation was completed successfully
# - "failed": the operation failed
workflowState: attr('string')
message: attr('string')
createdAt: attr('date')
updatedAt: attr('date')
# Kick off a poller that will track the completion of the operation.
#
# @param [Integer] [pollingInterval=1000]
# How often to poll the operation for its completion, in milliseconds.
#
# @return [Ember.Deferred]
# A promise that will yield only when the operation is complete.
trackCompletion: (pollingInterval) ->
service = new Deferred()
Ember.run.later this, ->
poll = null
timeout = null
# don't try to do any ajax when we're leaving the page
# workaround for https://code.google.com/p/chromium/issues/detail?id=263981
$(window).on 'beforeunload', -> clearTimeout(timeout)
poll = =>
@reload().then =>
if @get('workflowState') == 'failed'
service.reject()
else if @get('workflowState') == 'completed'
service.resolve()
else
timeout = setTimeout poll, pollingInterval || 1000
poll()
service

View File

@ -0,0 +1,30 @@
define [
'ember'
'ember-data'
'i18n!quizzes'
], (Em, DS, I18n) ->
{alias} = Em.computed
{Model, attr, belongsTo} = DS
Model.extend
quizStatistics: belongsTo 'quizStatistics', async: false
questionType: attr()
questionName: attr()
questionText: attr()
position: attr()
answers: attr()
pointBiserials: attr()
responses: attr()
responseValues: attr()
unexpectedResponseValues: attr()
topStudentCount: attr()
middleStudentCount: attr()
bottomStudentCount: attr()
correctStudentCount: attr()
incorrectStudentCount: attr()
correctStudentRatio: attr()
incorrectStudentRatio: attr()
correctTopStudentCount: attr()
correctMiddleStudentCount: attr()
correctBottomStudentCount: attr()

View File

@ -6,15 +6,15 @@ define [
], (Em, DS, I18n, ajax) ->
{alias, equal, any} = Em.computed
{belongsTo, PromiseObject} = DS
{belongsTo, PromiseObject, hasMany, Model, attr} = DS
Em.onerror = (error) ->
console.log 'ERR', error, error.stack
{Model, attr} = DS
Model.extend
title: attr()
quizType: attr()
links: attr()
htmlURL: attr()
# editURL is temporary until we have a real ember route for it
editURL: (->
@ -86,3 +86,5 @@ define [
{ html: html }
PromiseObject.create promise: promise
).property('quizSubmissionHtmlURL')
quizStatistics: hasMany 'quiz_statistics', async: true
quizReports: hasMany 'quiz_report', async: true

View File

@ -0,0 +1,18 @@
define [
'ember'
'ember-data'
], (Em, DS) ->
{Model, attr, belongsTo} = DS
Model.extend
quiz: belongsTo 'quiz', async: false
anonymous: attr()
includesAllVersions: attr()
reportType: attr()
createdAt: attr('date')
updatedAt: attr('date')
file: attr()
progress: attr()
progressUrl: attr()
url: attr()

View File

@ -0,0 +1,26 @@
define [
'ember'
'ember-data'
], (Em, DS) ->
{alias} = Em.computed
{Model, attr, belongsTo, hasMany} = DS
Model.extend
quiz: belongsTo 'quiz', async: false
questionStatistics: hasMany 'questionStatistics', async: false, embedded: 'load'
generatedAt: attr('date')
multipleAttemptsExist: attr('boolean')
submissionStatistics: attr()
avgCorrect: alias 'submissionStatistics.correct_count_average'
avgIncorrect: alias 'submissionStatistics.incorrect_count_average'
avgDuration: alias 'submissionStatistics.duration_average'
loggedOutUsers: alias 'submissionStatistics.logged_out_users'
avgScore: alias 'submissionStatistics.score_average'
highScore: alias 'submissionStatistics.score_high'
lowScore: alias 'submissionStatistics.score_low'
scoreStdev: alias 'submissionStatistics.score_stdev'
uniqueCount: alias 'submissionStatistics.unique_count'

View File

@ -0,0 +1,21 @@
define [ 'ember', 'underscore' ], (Ember, _) ->
{RSVP} = Ember
Ember.Route.extend
model: (transition, options) ->
quiz = @modelFor('quiz')
quiz.get('quizStatistics').then((items)->
# use the latest statistics report available:
items.sortBy('createdAt').get('lastObject')
).then (latestStatistics)->
# load the reports, we need these to be able to generate if requested
quiz.get('quizReports').then ->
latestStatistics
afterModel: ->
# for some reason, the quiz is not associating with the quiz_questions,
# although the inverse is true (quiz questions *are* associated to the quiz),
# anyway, do it manually:
set = @modelFor('quizStatistics').get('questionStatistics')
set.clear()
set.pushObjects(@store.all('questionStatistics'))

View File

@ -0,0 +1,4 @@
define [ 'ember-data' ], (DS) ->
DS.ActiveModelSerializer.extend
extractSingle: (store, type, payload, id, requestType) ->
@_super store, type, { progress: [ payload ] }, id, requestType

View File

@ -0,0 +1,4 @@
define [ 'ember-data' ], (DS) ->
DS.ActiveModelSerializer.extend
extractSingle: (store, type, payload, id, requestType) ->
@_super store, type, { quiz_reports: [ payload ] }, id, requestType

View File

@ -0,0 +1,12 @@
define [ 'ember-data', 'underscore' ], (DS, _) ->
DS.ActiveModelSerializer.extend
normalizePayload: (type, hash, prop) ->
# how can we add query parameters to model.find('quiz_statistics') ??
if hash.quizzes
_(hash.quizzes).each (quiz) ->
if quiz.links
if quiz.links.quiz_statistics
quiz.links.quiz_statistics += '?include=quiz_questions'
if quiz.links.quiz_reports
quiz.links.quiz_reports += '?includes_all_versions=true'
hash

View File

@ -0,0 +1,6 @@
define [ 'ember-data', 'underscore' ], (DS, _) ->
DS.ActiveModelSerializer.extend
extractArray: (store, type, payload, id, requestType) ->
payload.question_statistics = payload.quiz_statistics[0].question_statistics
@_super(store, type, payload, id, requestType)

View File

@ -42,7 +42,11 @@
{{/link-to}}
{{/link-to}}
<li><a href="#">Statistics</a></li>
{{#link-to 'quiz.statistics' this tagName='li'}}
{{#link-to 'quiz.statistics' this.id}}
{{#t 'statistics'}}Statistics{{/t}}
{{/link-to}}
{{/link-to}}
</ol>
</div>

View File

@ -0,0 +1,2 @@
<div id="quiz-statistics">
</div>

View File

@ -0,0 +1,21 @@
define [
'ember'
'../start_app'
'../environment_setup'
'../shared_ajax_fixtures'
'../../adapters/progress_adapter'
], (Ember, startApp, env, fixtures, Subject) ->
App = null
subject = null
module 'Quiz Report Adapter',
setup: ->
App = startApp()
fixtures.create()
subject = App.__container__.lookup('adapter:progress')
teardown: ->
Ember.run App, 'destroy'
test 'it builds the proper URL', ->
strictEqual subject.buildURL('progress', 1), '/api/v1/progress/1'

View File

@ -0,0 +1,23 @@
define [
'ember'
'../start_app'
'../environment_setup'
'../shared_ajax_fixtures'
'../../adapters/quiz_report_adapter'
], (Ember, startApp, env, fixtures, Subject) ->
App = null
subject = null
module 'Quiz Report Adapter',
setup: ->
App = startApp()
fixtures.create()
subject = App.__container__.lookup('adapter:quizReport')
visit('/1/statistics')
teardown: ->
Ember.run App, 'destroy'
test 'it uses the report URL', ->
url = subject.buildURL 'quizReport', 14
ok url.match('/api/v1/courses/1/quizzes/1/reports/14')

View File

@ -0,0 +1,31 @@
define [
'ember'
'../start_app'
'../environment_setup'
'../shared_ajax_fixtures'
], (Ember, startApp, env, fixtures) ->
App = null
{$} = Ember
module "Quiz Statistics Integration",
setup: ->
App = startApp()
fixtures.create()
teardown: ->
Ember.run App, 'destroy'
testPage = (desc, callback) ->
test desc, ->
visit('/1/statistics').then callback
testPage 'it renders', ->
equal find('#quiz-statistics').length, 1
testPage 'it loads the quiz, statistics, and question statistics', ->
route = App.__container__.lookup('route:quizStatistics')
ok q = route.modelFor('quiz'), 'loads the quiz'
equal q.get('quizReports.length'), 2, 'loads quiz reports'
ok qs = route.modelFor('quizStatistics'), 'loads quiz statistics'
equal qs.get('questionStatistics.length'), 11, 'loads question statistics'

View File

@ -0,0 +1,83 @@
define [
'ic-ajax'
'ember'
'underscore'
'../start_app'
], (ajax, Ember, _, startApp) ->
{run} = Ember
App = null
subject = null
timeout = null
progressFixture = (attrs) ->
_.extend {
"completion": 0,
"context_id": 1,
"context_type": "Quizzes::QuizStatistics",
"created_at": "2014-04-02T09:40:32Z",
"id": 1,
"message": null,
"tag": "Quizzes::QuizStatistics",
"updated_at": "2014-04-02T09:40:36Z",
"user_id": null,
"workflow_state": "running",
"url": "http://localhost:3000/api/v1/progress/1"
}, attrs
module "Progress",
setup: ->
App = startApp()
run ->
container = App.__container__
store = container.lookup 'store:main'
subject = store.createRecord 'progress', progressFixture()
# need to modify the adapter to use ic-ajax, which for some reason it's
# not in the spec...
adapter = container.lookup 'adapter:progress'
adapter.ajax = (url, method) ->
ajax({ url: url, type: method })
teardown: ->
clearTimeout timeout
run App, 'destroy'
testWithTimeout = (desc, callback) ->
asyncTest desc, ->
timeout = setTimeout (->
ok false, "timed out"
start()
), 1000
callback()
testWithTimeout '#trackCompletion: it polls until complete', ->
expect 1
ajax.defineFixture '/api/v1/progress/1',
response: progressFixture(workflow_state: 'completed'),
jqXHR: {}
testStatus: 200
textStatus: 'success'
run ->
subject.trackCompletion(5).then ->
ok true, "notifies me when it's done"
start()
testWithTimeout '#trackCompletion: it reports failures', ->
expect 1
ajax.defineFixture '/api/v1/progress/1',
response: progressFixture(workflow_state: 'failed'),
jqXHR: {}
testStatus: 200
textStatus: 'success'
run ->
subject.trackCompletion(5).then(->
ok false, "progress success callback should never be called"
start()
, ->
ok true, "progress failure callback should be called"
start()
)

View File

@ -0,0 +1,22 @@
define [
'ember'
'../start_app'
'i18n!quizzes'
], (Em, startApp, I18n) ->
{run} = Em
App = null
statistics = null
store = null
module "Quiz",
setup: ->
App = startApp()
container = App.__container__
store = container.lookup 'store:main'
run ->
statistics = store.createRecord 'quiz_statistics', id: '1'
teardown: ->
run App, 'destroy'

File diff suppressed because one or more lines are too long

View File

@ -226,6 +226,11 @@
# "example": 1.24721912892465,
# "type": "number"
# },
# "scores": {
# "description": "A percentile distribution of the student scores, each key is the percentile (ranges between 0 and 100%) while the value is the number of students who received that score.",
# "example": { "50": 1, "34": 5, "100": 1 },
# "type": "object"
# },
# "correct_count_average": {
# "description": "The mean of the number of questions answered correctly by each student.",
# "example": 3.66666666666667,

View File

@ -65,12 +65,14 @@ class Quizzes::QuizStatistics::StudentAnalysis < Quizzes::QuizStatistics::Report
found_ids = {}
score_counter = Stats::Counter.new
questions_hash = {}
quiz_points = [quiz.current_points_possible.to_f, 1.0].max
stats[:questions] = []
stats[:multiple_attempts_exist] = submissions.any? { |s|
s.attempt && s.attempt > 1
}
stats[:submission_user_ids] = Set.new
stats[:submission_logged_out_users] = []
stats[:submission_scores] = Hash.new(0)
stats[:unique_submission_count] = 0
correct_cnt = incorrect_cnt = total_duration = 0
submissions.each_with_index do |sub, index|
@ -82,7 +84,9 @@ class Quizzes::QuizStatistics::StudentAnalysis < Quizzes::QuizStatistics::Report
stats[:submission_logged_out_users] << temp_user
end
if !found_ids[sub.id]
percentile = (sub.score.to_f / quiz_points * 100).round
stats[:unique_submission_count] += 1
stats[:submission_scores][percentile] += 1
found_ids[sub.id] = true
end
answers = sub.submission_data || []

View File

@ -1,5 +1,6 @@
@import "environment.sass";
@import "show";
@import "statistics";
$backgroundColor: #dfe9f0;
$borderColor: #AAA;
$questionHeaderBackground: #F5F5F5;

View File

@ -0,0 +1,40 @@
$highlightColor: #3cb878;
#quiz-statistics {
header {
line-height: 34px;
}
.inline {
display: inline-block;
}
.section-title {
line-height: 1;
font-weight: bold;
}
}
.question-statistics {
border: 1px solid #CAD0D7;
border-radius: 6px;
padding: 10px 20px 0;
position: relative;
margin-bottom: 20px;
section {
display: inline-block;
vertical-align: top;
width: 320px;
}
.question-attempts {
font-size: 0.95em;
}
.question-text {
margin-top: -5px;
margin-bottom: 20px;
font-size: 1.25em;
}
}

View File

@ -17,7 +17,13 @@ namespace :jst do
desc 'precompile ember templates'
task :ember do
require 'handlebars_tasks'
files = Dir.glob("app/coffeescripts/**/*.hbs")
files = if ENV['file'].present?
[ ENV['file'] ]
else
Dir.glob("app/coffeescripts/**/*.hbs")
end
files.each do |file|
HandlebarsTasks::EmberHbs.compile_file(file)
end

View File

@ -25,7 +25,17 @@ describe Quizzes::QuizStatistics::StudentAnalysis do
it 'should calculate mean/stddev as expected with a few submissions' do
q = @course.quizzes.create!
question = q.quiz_questions.create!({
question_data: {
name: 'q1',
points_possible: 30,
question_type: 'essay_question',
question_text: 'ohai mark'
}
})
q.generate_quiz_data
q.save!
@user1 = User.create! :name => "some_user 1"
@user2 = User.create! :name => "some_user 2"
@user3 = User.create! :name => "some_user 2"
@ -34,31 +44,37 @@ describe Quizzes::QuizStatistics::StudentAnalysis do
student_in_course :course => @course, :user => @user3
sub = q.generate_submission(@user1)
sub.workflow_state = 'complete'
sub.submission_data = [{ :points => 15, :text => "", :correct => "undefined", :question_id => -1 }]
sub.submission_data = [{ :points => 15, :text => "", :correct => "undefined", :question_id => question.id }]
sub.score = 15
sub.with_versioning(true, &:save!)
stats = q.statistics
stats[:submission_score_average].should == 15
stats[:submission_score_high].should == 15
stats[:submission_score_low].should == 15
stats[:submission_score_stdev].should == 0
stats[:submission_scores].should == { 50 => 1 }
sub = q.generate_submission(@user2)
sub.workflow_state = 'complete'
sub.submission_data = [{ :points => 17, :text => "", :correct => "undefined", :question_id => -1 }]
sub.submission_data = [{ :points => 17, :text => "", :correct => "undefined", :question_id => question.id }]
sub.score = 17
sub.with_versioning(true, &:save!)
stats = q.statistics
stats[:submission_score_average].should == 16
stats[:submission_score_high].should == 17
stats[:submission_score_low].should == 15
stats[:submission_score_stdev].should == 1
stats[:submission_scores].should == { 50 => 1, 57 => 1 }
sub = q.generate_submission(@user3)
sub.workflow_state = 'complete'
sub.submission_data = [{ :points => 20, :text => "", :correct => "undefined", :question_id => -1 }]
sub.submission_data = [{ :points => 20, :text => "", :correct => "undefined", :question_id => question.id }]
sub.score = 20
sub.with_versioning(true, &:save!)
stats = q.statistics
stats[:submission_score_average].should be_close(17 + 1.0/3, 0.0000000001)
stats[:submission_score_high].should == 20
stats[:submission_score_low].should == 15
stats[:submission_score_stdev].should be_close(Math::sqrt(4 + 2.0/9), 0.0000000001)
stats[:submission_scores].should == { 50 => 1, 57 => 1, 67 => 1 }
end
context "csv" do