add proxied analytics graphs, refactor analytics
This commit is contained in:
@@ -142,5 +142,6 @@ def _section_analytics(course_id):
|
||||
'section_key': 'analytics',
|
||||
'section_display_name': 'Analytics',
|
||||
'get_distribution_url': reverse('get_distribution', kwargs={'course_id': course_id}),
|
||||
'proxy_legacy_analytics_url': reverse('proxy_legacy_analytics', kwargs={'course_id': course_id}),
|
||||
}
|
||||
return section_data
|
||||
|
||||
@@ -6,60 +6,32 @@
|
||||
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
|
||||
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
|
||||
|
||||
# Analytics Section
|
||||
class Analytics
|
||||
constructor: (@$section) ->
|
||||
@$section.data 'wrapper', @
|
||||
|
||||
# gather elements
|
||||
@$display = @$section.find '.distribution-display'
|
||||
@$display_text = @$display.find '.distribution-display-text'
|
||||
@$display_graph = @$display.find '.distribution-display-graph'
|
||||
@$display_table = @$display.find '.distribution-display-table'
|
||||
@$distribution_select = @$section.find 'select#distributions'
|
||||
@$request_response_error = @$display.find '.request-response-error'
|
||||
|
||||
@populate_selector => @$distribution_select.change => @on_selector_change()
|
||||
class ProfileDistributionWidget
|
||||
constructor: ({@$container, @feature, title, @endpoint}) ->
|
||||
# render template
|
||||
template_params =
|
||||
title: title
|
||||
feature: @feature
|
||||
endpoint: @endpoint
|
||||
template_html = $("#profile-distribution-widget-template").html()
|
||||
@$container.html Mustache.render template_html, template_params
|
||||
|
||||
reset_display: ->
|
||||
@$display_text.empty()
|
||||
@$display_graph.empty()
|
||||
@$display_table.empty()
|
||||
@$request_response_error.empty()
|
||||
@$container.find('.display-errors').empty()
|
||||
@$container.find('.display-text').empty()
|
||||
@$container.find('.display-graph').empty()
|
||||
@$container.find('.display-table').empty()
|
||||
|
||||
# fetch and list available distributions
|
||||
# `cb` is a callback to be run after
|
||||
populate_selector: (cb) ->
|
||||
# ask for no particular distribution to get list of available distribuitions.
|
||||
@get_profile_distributions undefined,
|
||||
# on error, print to console and dom.
|
||||
error: std_ajax_err => @$request_response_error.text "Error getting available distributions."
|
||||
success: (data) =>
|
||||
# replace loading text in drop-down with "-- Select Distribution --"
|
||||
@$distribution_select.find('option').eq(0).text "-- Select Distribution --"
|
||||
|
||||
# add all fetched available features to drop-down
|
||||
for feature in data.available_features
|
||||
opt = $ '<option/>',
|
||||
text: data.feature_display_names[feature]
|
||||
data:
|
||||
feature: feature
|
||||
|
||||
@$distribution_select.append opt
|
||||
|
||||
# call callback if one was supplied
|
||||
cb?()
|
||||
show_error: (msg) ->
|
||||
@$container.find('.display-errors').text msg
|
||||
|
||||
# display data
|
||||
on_selector_change: ->
|
||||
opt = @$distribution_select.children('option:selected')
|
||||
feature = opt.data 'feature'
|
||||
|
||||
load: ->
|
||||
@reset_display()
|
||||
# only proceed if there is a feature attached to the selected option.
|
||||
return unless feature
|
||||
@get_profile_distributions feature,
|
||||
error: std_ajax_err => @$request_response_error.text "Error getting distribution for '#{feature}'."
|
||||
|
||||
@get_profile_distributions @feature,
|
||||
error: std_ajax_err => @show_error "Error fetching distribution."
|
||||
success: (data) =>
|
||||
feature_res = data.feature_results
|
||||
if feature_res.type is 'EASY_CHOICE'
|
||||
@@ -70,9 +42,9 @@ class Analytics
|
||||
forceFitColumns: true
|
||||
|
||||
columns = [
|
||||
id: feature
|
||||
field: feature
|
||||
name: feature
|
||||
id: @feature
|
||||
field: @feature
|
||||
name: data.feature_display_names[@feature]
|
||||
,
|
||||
id: 'count'
|
||||
field: 'count'
|
||||
@@ -81,16 +53,16 @@ class Analytics
|
||||
|
||||
grid_data = _.map feature_res.data, (value, key) ->
|
||||
datapoint = {}
|
||||
datapoint[feature] = feature_res.choices_display_names[key]
|
||||
datapoint[@feature] = feature_res.choices_display_names[key]
|
||||
datapoint['count'] = value
|
||||
datapoint
|
||||
|
||||
table_placeholder = $ '<div/>', class: 'slickgrid'
|
||||
@$display_table.append table_placeholder
|
||||
@$container.find('.display-table').append table_placeholder
|
||||
grid = new Slick.Grid(table_placeholder, grid_data, columns, options)
|
||||
else if feature_res.feature is 'year_of_birth'
|
||||
graph_placeholder = $ '<div/>', class: 'year-of-birth'
|
||||
@$display_graph.append graph_placeholder
|
||||
graph_placeholder = $ '<div/>', class: 'graph-placeholder'
|
||||
@$container.find('.display-graph').append graph_placeholder
|
||||
|
||||
graph_data = _.map feature_res.data, (value, key) -> [parseInt(key), value]
|
||||
|
||||
@@ -99,7 +71,7 @@ class Analytics
|
||||
]
|
||||
else
|
||||
console.warn("unable to show distribution #{feature_res.type}")
|
||||
@$display_text.text 'Unavailable Metric Display\n' + JSON.stringify(feature_res)
|
||||
@show_error 'Unavailable metric display.'
|
||||
|
||||
# fetch distribution data from server.
|
||||
# `handler` can be either a callback for success
|
||||
@@ -107,7 +79,7 @@ class Analytics
|
||||
get_profile_distributions: (feature, handler) ->
|
||||
settings =
|
||||
dataType: 'json'
|
||||
url: @$distribution_select.data 'endpoint'
|
||||
url: @endpoint
|
||||
data: feature: feature
|
||||
|
||||
if typeof handler is 'function'
|
||||
@@ -117,13 +89,138 @@ class Analytics
|
||||
|
||||
$.ajax settings
|
||||
|
||||
# slickgrid's layout collapses when rendered
|
||||
# in an invisible div. use this method to reload
|
||||
# the AuthList widget
|
||||
refresh: ->
|
||||
@on_selector_change()
|
||||
|
||||
# handler for when the section title is clicked.
|
||||
class GradeDistributionDisplay
|
||||
constructor: ({@$container, @endpoint}) ->
|
||||
template_params = {}
|
||||
template_html = $('#grade-distributions-widget-template').html()
|
||||
@$container.html Mustache.render template_html, template_params
|
||||
@$problem_selector = @$container.find '.problem-selector'
|
||||
|
||||
reset_display: ->
|
||||
@$container.find('.display-errors').empty()
|
||||
@$container.find('.display-text').empty()
|
||||
@$container.find('.display-graph').empty()
|
||||
|
||||
show_error: (msg) ->
|
||||
@$container.find('.display-errors').text msg
|
||||
|
||||
load: ->
|
||||
@get_grade_distributions
|
||||
error: std_ajax_err => @show_error "Error fetching grade distributions."
|
||||
success: (data) =>
|
||||
@$container.find('.last-updated').text "Last Updated: #{data.time}"
|
||||
|
||||
# populate selector
|
||||
@$problem_selector.empty()
|
||||
for {module_id, grade_info} in data.data
|
||||
I4X_PROBLEM = /i4x:\/\/.*\/.*\/problem\/(.*)/
|
||||
label = (I4X_PROBLEM.exec module_id)?[1]
|
||||
label ?= module_id
|
||||
|
||||
@$problem_selector.append $ '<option/>',
|
||||
text: label
|
||||
data:
|
||||
module_id: module_id
|
||||
grade_info: grade_info
|
||||
|
||||
@$problem_selector.change =>
|
||||
$opt = @$problem_selector.children('option:selected')
|
||||
return unless $opt.length > 0
|
||||
@reset_display()
|
||||
@render_distribution
|
||||
module_id: $opt.data 'module_id'
|
||||
grade_info: $opt.data 'grade_info'
|
||||
|
||||
# one-time first selection of first list item.
|
||||
@$problem_selector.change()
|
||||
|
||||
render_distribution: ({module_id, grade_info}) ->
|
||||
$display_graph = @$container.find('.display-graph')
|
||||
|
||||
graph_data = grade_info.map ({grade, max_grade, num_students}) -> [grade, num_students]
|
||||
total_students = _.reduce ([0].concat grade_info),
|
||||
(accum, {grade, max_grade, num_students}) -> accum + num_students
|
||||
|
||||
# show total students
|
||||
@$container.find('.display-text').text "#{total_students} students scored."
|
||||
|
||||
# render to graph
|
||||
graph_placeholder = $ '<div/>', class: 'graph-placeholder'
|
||||
$display_graph.append graph_placeholder
|
||||
|
||||
graph_data = graph_data
|
||||
|
||||
$.plot graph_placeholder, [
|
||||
data: graph_data
|
||||
bars: show: true
|
||||
color: '#1d9dd9'
|
||||
]
|
||||
|
||||
|
||||
# `handler` can be either a callback for success
|
||||
# or a mapping e.g. {success: ->, error: ->, complete: ->}
|
||||
#
|
||||
# the data passed to the success handler takes this form:
|
||||
# {
|
||||
# "aname": "ProblemGradeDistribution",
|
||||
# "time": "2013-07-31T20:25:56+00:00",
|
||||
# "course_id": "MITx/6.002x/2013_Spring",
|
||||
# "options": {
|
||||
# "course_id": "MITx/6.002x/2013_Spring",
|
||||
# "_id": "6fudge2b49somedbid1e1",
|
||||
# "data": [
|
||||
# {
|
||||
# "module_id": "i4x://MITx/6.002x/problem/Capacitors_and_Energy_Storage",
|
||||
# "grade_info": [
|
||||
# {
|
||||
# "grade": 0.0,
|
||||
# "max_grade": 100.0,
|
||||
# "num_students": 3
|
||||
# }, ... for each grade number between 0 and max_grade
|
||||
# ],
|
||||
# }
|
||||
get_grade_distributions: (handler) ->
|
||||
settings =
|
||||
dataType: 'json'
|
||||
url: @endpoint
|
||||
data: aname: 'ProblemGradeDistribution'
|
||||
|
||||
if typeof handler is 'function'
|
||||
_.extend settings, success: handler
|
||||
else
|
||||
_.extend settings, handler
|
||||
|
||||
$.ajax settings
|
||||
|
||||
|
||||
# Analytics Section
|
||||
class Analytics
|
||||
constructor: (@$section) ->
|
||||
@$section.data 'wrapper', @
|
||||
|
||||
@$pd_containers = @$section.find '.profile-distribution-widget-container'
|
||||
@$gd_containers = @$section.find '.grade-distributions-widget-container'
|
||||
|
||||
@pdws = _.map (@$pd_containers), (container) =>
|
||||
new ProfileDistributionWidget
|
||||
$container: $(container)
|
||||
feature: $(container).data 'feature'
|
||||
title: $(container).data 'title'
|
||||
endpoint: $(container).data 'endpoint'
|
||||
|
||||
@gdws = _.map (@$gd_containers), (container) =>
|
||||
new GradeDistributionDisplay
|
||||
$container: $(container)
|
||||
endpoint: $(container).data 'endpoint'
|
||||
|
||||
refresh: ->
|
||||
for pdw in @pdws
|
||||
pdw.load()
|
||||
|
||||
for gdw in @gdws
|
||||
gdw.load()
|
||||
|
||||
onClickTitle: ->
|
||||
@refresh()
|
||||
|
||||
|
||||
@@ -33,6 +33,46 @@ CSS_INSTRUCTOR_NAV = 'instructor-nav'
|
||||
# prefix for deep-linking
|
||||
HASH_LINK_PREFIX = '#view-'
|
||||
|
||||
|
||||
# helper class for queueing and fault isolation.
|
||||
# Will execute functions marked by waiter.after only after all functions marked by
|
||||
# waiter.waitFor have been called.
|
||||
class SafeWaiter
|
||||
constructor: ->
|
||||
@after_handlers = []
|
||||
@waitFor_handlers = []
|
||||
@fired = false
|
||||
|
||||
after: (f) ->
|
||||
if @fired
|
||||
f()
|
||||
else
|
||||
@after_handlers.push f
|
||||
|
||||
waitFor: (f) ->
|
||||
return if @fired
|
||||
@waitFor_handlers.push f
|
||||
|
||||
# wrap the function so that it notifies the waiter
|
||||
# and can fire the after handlers.
|
||||
=>
|
||||
@waitFor_handlers = @waitFor_handlers.filter (g) -> g isnt f
|
||||
if @waitFor_handlers.length is 0
|
||||
plantTimeout 0, =>
|
||||
@fired = true
|
||||
for cb in @after_handlers
|
||||
cb()
|
||||
|
||||
f.apply this, arguments
|
||||
|
||||
|
||||
# waiter for dashboard sections.
|
||||
# Will only execute after all sections have at least attempted to load.
|
||||
# This is here to facilitate section constructors isolated by setTimeout
|
||||
# while still being able to interact with them under the guarantee
|
||||
# that the sections will be initialized at call time.
|
||||
sections_have_loaded = new SafeWaiter
|
||||
|
||||
# once we're ready, check if this page is the instructor dashboard
|
||||
$ =>
|
||||
instructor_dashboard_content = $ ".#{CSS_INSTRUCTOR_CONTENT}"
|
||||
@@ -45,9 +85,9 @@ $ =>
|
||||
# handles hiding and showing sections
|
||||
setup_instructor_dashboard = (idash_content) =>
|
||||
# clickable section titles
|
||||
links = idash_content.find(".#{CSS_INSTRUCTOR_NAV}").find('a')
|
||||
$links = idash_content.find(".#{CSS_INSTRUCTOR_NAV}").find('a')
|
||||
|
||||
for link in ($ link for link in links)
|
||||
for link in ($ link for link in $links)
|
||||
link.click (e) ->
|
||||
e.preventDefault()
|
||||
|
||||
@@ -70,24 +110,24 @@ setup_instructor_dashboard = (idash_content) =>
|
||||
# write to url
|
||||
location.hash = "#{HASH_LINK_PREFIX}#{section_name}"
|
||||
|
||||
plantTimeout 0, -> section.data('wrapper')?.onClickTitle?()
|
||||
# plantTimeout 0, -> section.data('wrapper')?.onExit?()
|
||||
sections_have_loaded.after ->
|
||||
section.data('wrapper')?.onClickTitle?()
|
||||
|
||||
# TODO enable onExit handler
|
||||
|
||||
|
||||
# activate an initial section by 'clicking' on it.
|
||||
# check for a deep-link, or click the first link.
|
||||
click_first_link = ->
|
||||
link = links.eq(0)
|
||||
link = $links.eq(0)
|
||||
link.click()
|
||||
link.data('wrapper')?.onClickTitle?()
|
||||
|
||||
if (new RegExp "^#{HASH_LINK_PREFIX}").test location.hash
|
||||
rmatch = (new RegExp "^#{HASH_LINK_PREFIX}(.*)").exec location.hash
|
||||
section_name = rmatch[1]
|
||||
link = links.filter "[data-section='#{section_name}']"
|
||||
link = $links.filter "[data-section='#{section_name}']"
|
||||
if link.length == 1
|
||||
link.click()
|
||||
link.data('wrapper')?.onClickTitle?()
|
||||
else
|
||||
click_first_link()
|
||||
else
|
||||
@@ -98,9 +138,14 @@ setup_instructor_dashboard = (idash_content) =>
|
||||
# enable sections
|
||||
setup_instructor_dashboard_sections = (idash_content) ->
|
||||
# see fault isolation NOTE at top of file.
|
||||
# an error thrown in one section will not block other sections from exectuing.
|
||||
plantTimeout 0, -> new window.InstructorDashboard.sections.CourseInfo idash_content.find ".#{CSS_IDASH_SECTION}#course_info"
|
||||
plantTimeout 0, -> new window.InstructorDashboard.sections.DataDownload idash_content.find ".#{CSS_IDASH_SECTION}#data_download"
|
||||
plantTimeout 0, -> new window.InstructorDashboard.sections.Membership idash_content.find ".#{CSS_IDASH_SECTION}#membership"
|
||||
plantTimeout 0, -> new window.InstructorDashboard.sections.StudentAdmin idash_content.find ".#{CSS_IDASH_SECTION}#student_admin"
|
||||
plantTimeout 0, -> new window.InstructorDashboard.sections.Analytics idash_content.find ".#{CSS_IDASH_SECTION}#analytics"
|
||||
# If an error thrown in one section, it will not stop other sections from exectuing.
|
||||
plantTimeout 0, sections_have_loaded.waitFor ->
|
||||
new window.InstructorDashboard.sections.CourseInfo idash_content.find ".#{CSS_IDASH_SECTION}#course_info"
|
||||
plantTimeout 0, sections_have_loaded.waitFor ->
|
||||
new window.InstructorDashboard.sections.DataDownload idash_content.find ".#{CSS_IDASH_SECTION}#data_download"
|
||||
plantTimeout 0, sections_have_loaded.waitFor ->
|
||||
new window.InstructorDashboard.sections.Membership idash_content.find ".#{CSS_IDASH_SECTION}#membership"
|
||||
plantTimeout 0, sections_have_loaded.waitFor ->
|
||||
new window.InstructorDashboard.sections.StudentAdmin idash_content.find ".#{CSS_IDASH_SECTION}#student_admin"
|
||||
plantTimeout 0, sections_have_loaded.waitFor ->
|
||||
new window.InstructorDashboard.sections.Analytics idash_content.find ".#{CSS_IDASH_SECTION}#analytics"
|
||||
|
||||
@@ -36,6 +36,11 @@ section.instructor-dashboard-content-2 {
|
||||
color: $error-red;
|
||||
}
|
||||
|
||||
.display-errors {
|
||||
line-height: 3em;
|
||||
color: $error-red;
|
||||
}
|
||||
|
||||
.slickgrid {
|
||||
margin-left: 1px;
|
||||
color:#333333;
|
||||
@@ -320,25 +325,40 @@ section.instructor-dashboard-content-2 {
|
||||
}
|
||||
|
||||
|
||||
.instructor-dashboard-wrapper-2 section.idash-section#analytics {
|
||||
.distribution-display {
|
||||
margin-top: 1.2em;
|
||||
.profile-distribution-widget {
|
||||
margin-bottom: $baseline * 2;
|
||||
|
||||
.distribution-display-graph {
|
||||
.year-of-birth {
|
||||
width: 750px;
|
||||
height: 250px;
|
||||
}
|
||||
}
|
||||
.display-text {}
|
||||
|
||||
.distribution-display-table {
|
||||
.slickgrid {
|
||||
height: 400px;
|
||||
}
|
||||
.display-graph .graph-placeholder {
|
||||
width: 750px;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.display-table {
|
||||
.slickgrid {
|
||||
height: 250px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.grade-distributions-widget {
|
||||
margin-bottom: $baseline * 2;
|
||||
|
||||
.last-updated {
|
||||
line-height: 2.2em;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.display-graph .graph-placeholder {
|
||||
width: 750px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.display-text {
|
||||
line-height: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.member-list-widget {
|
||||
$width: 20 * $baseline;
|
||||
|
||||
@@ -1,12 +1,54 @@
|
||||
<%page args="section_data"/>
|
||||
|
||||
<h2>Distributions</h2>
|
||||
<select id="distributions" data-endpoint="${ section_data['get_distribution_url'] }">
|
||||
<option> Getting available distributions... </option>
|
||||
</select>
|
||||
<div class="distribution-display">
|
||||
<div class="distribution-display-text"></div>
|
||||
<div class="distribution-display-graph"></div>
|
||||
<div class="distribution-display-table"></div>
|
||||
<div class="request-response-error"></div>
|
||||
</div>
|
||||
<script type="text/template" id="profile-distribution-widget-template">
|
||||
<div class="profile-distribution-widget">
|
||||
<div class="header">
|
||||
<h2 class="title"> {{title}} </h2>
|
||||
</div>
|
||||
<div class="view">
|
||||
<div class="display-errors"></div>
|
||||
<div class="display-text"></div>
|
||||
<div class="display-graph"></div>
|
||||
<div class="display-table"></div>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script type="text/template" id="grade-distributions-widget-template">
|
||||
<div class="grade-distributions-widget">
|
||||
<div class="header">
|
||||
<h2 class="title"> Grade Distribution </h2>
|
||||
Problem: <select class="problem-selector">
|
||||
<option> Loading problem list... </option>
|
||||
</select>
|
||||
<div class="last-updated"></div>
|
||||
</div>
|
||||
<div class="view">
|
||||
<div class="display-errors"></div>
|
||||
<div class="display-text"></div>
|
||||
<div class="display-graph"></div>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<div class="grade-distributions-widget-container"
|
||||
data-endpoint="${ section_data['proxy_legacy_analytics_url'] }"
|
||||
></div>
|
||||
|
||||
<div class="profile-distribution-widget-container"
|
||||
data-title="Year of Birth"
|
||||
data-feature="year_of_birth"
|
||||
data-endpoint="${ section_data['get_distribution_url'] }"
|
||||
></div>
|
||||
|
||||
<div class="profile-distribution-widget-container"
|
||||
data-title="Gender Distribution"
|
||||
data-feature="gender"
|
||||
data-endpoint="${ section_data['get_distribution_url'] }"
|
||||
></div>
|
||||
|
||||
<div class="profile-distribution-widget-container"
|
||||
data-title="Level of Education"
|
||||
data-feature="level_of_education"
|
||||
data-endpoint="${ section_data['get_distribution_url'] }"
|
||||
></div>
|
||||
|
||||
Reference in New Issue
Block a user