Commit 4afde4dd by Miles Steele

add proxied analytics graphs, refactor analytics

parent fb8c84a5
...@@ -142,5 +142,6 @@ def _section_analytics(course_id): ...@@ -142,5 +142,6 @@ def _section_analytics(course_id):
'section_key': 'analytics', 'section_key': 'analytics',
'section_display_name': 'Analytics', 'section_display_name': 'Analytics',
'get_distribution_url': reverse('get_distribution', kwargs={'course_id': course_id}), '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 return section_data
...@@ -6,60 +6,32 @@ ...@@ -6,60 +6,32 @@
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.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 class ProfileDistributionWidget
@$display = @$section.find '.distribution-display' constructor: ({@$container, @feature, title, @endpoint}) ->
@$display_text = @$display.find '.distribution-display-text' # render template
@$display_graph = @$display.find '.distribution-display-graph' template_params =
@$display_table = @$display.find '.distribution-display-table' title: title
@$distribution_select = @$section.find 'select#distributions' feature: @feature
@$request_response_error = @$display.find '.request-response-error' endpoint: @endpoint
template_html = $("#profile-distribution-widget-template").html()
@populate_selector => @$distribution_select.change => @on_selector_change() @$container.html Mustache.render template_html, template_params
reset_display: -> reset_display: ->
@$display_text.empty() @$container.find('.display-errors').empty()
@$display_graph.empty() @$container.find('.display-text').empty()
@$display_table.empty() @$container.find('.display-graph').empty()
@$request_response_error.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 show_error: (msg) ->
cb?() @$container.find('.display-errors').text msg
# display data # display data
on_selector_change: -> load: ->
opt = @$distribution_select.children('option:selected')
feature = opt.data 'feature'
@reset_display() @reset_display()
# only proceed if there is a feature attached to the selected option.
return unless feature @get_profile_distributions @feature,
@get_profile_distributions feature, error: std_ajax_err => @show_error "Error fetching distribution."
error: std_ajax_err => @$request_response_error.text "Error getting distribution for '#{feature}'."
success: (data) => success: (data) =>
feature_res = data.feature_results feature_res = data.feature_results
if feature_res.type is 'EASY_CHOICE' if feature_res.type is 'EASY_CHOICE'
...@@ -70,9 +42,9 @@ class Analytics ...@@ -70,9 +42,9 @@ class Analytics
forceFitColumns: true forceFitColumns: true
columns = [ columns = [
id: feature id: @feature
field: feature field: @feature
name: feature name: data.feature_display_names[@feature]
, ,
id: 'count' id: 'count'
field: 'count' field: 'count'
...@@ -81,16 +53,16 @@ class Analytics ...@@ -81,16 +53,16 @@ class Analytics
grid_data = _.map feature_res.data, (value, key) -> grid_data = _.map feature_res.data, (value, key) ->
datapoint = {} datapoint = {}
datapoint[feature] = feature_res.choices_display_names[key] datapoint[@feature] = feature_res.choices_display_names[key]
datapoint['count'] = value datapoint['count'] = value
datapoint datapoint
table_placeholder = $ '<div/>', class: 'slickgrid' 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) grid = new Slick.Grid(table_placeholder, grid_data, columns, options)
else if feature_res.feature is 'year_of_birth' else if feature_res.feature is 'year_of_birth'
graph_placeholder = $ '<div/>', class: 'year-of-birth' graph_placeholder = $ '<div/>', class: 'graph-placeholder'
@$display_graph.append graph_placeholder @$container.find('.display-graph').append graph_placeholder
graph_data = _.map feature_res.data, (value, key) -> [parseInt(key), value] graph_data = _.map feature_res.data, (value, key) -> [parseInt(key), value]
...@@ -99,7 +71,7 @@ class Analytics ...@@ -99,7 +71,7 @@ class Analytics
] ]
else else
console.warn("unable to show distribution #{feature_res.type}") 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. # fetch distribution data from server.
# `handler` can be either a callback for success # `handler` can be either a callback for success
...@@ -107,7 +79,7 @@ class Analytics ...@@ -107,7 +79,7 @@ class Analytics
get_profile_distributions: (feature, handler) -> get_profile_distributions: (feature, handler) ->
settings = settings =
dataType: 'json' dataType: 'json'
url: @$distribution_select.data 'endpoint' url: @endpoint
data: feature: feature data: feature: feature
if typeof handler is 'function' if typeof handler is 'function'
...@@ -117,13 +89,138 @@ class Analytics ...@@ -117,13 +89,138 @@ class Analytics
$.ajax settings $.ajax settings
# slickgrid's layout collapses when rendered
# in an invisible div. use this method to reload class GradeDistributionDisplay
# the AuthList widget 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: -> refresh: ->
@on_selector_change() for pdw in @pdws
pdw.load()
for gdw in @gdws
gdw.load()
# handler for when the section title is clicked.
onClickTitle: -> onClickTitle: ->
@refresh() @refresh()
......
...@@ -33,6 +33,46 @@ CSS_INSTRUCTOR_NAV = 'instructor-nav' ...@@ -33,6 +33,46 @@ CSS_INSTRUCTOR_NAV = 'instructor-nav'
# prefix for deep-linking # prefix for deep-linking
HASH_LINK_PREFIX = '#view-' 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 # once we're ready, check if this page is the instructor dashboard
$ => $ =>
instructor_dashboard_content = $ ".#{CSS_INSTRUCTOR_CONTENT}" instructor_dashboard_content = $ ".#{CSS_INSTRUCTOR_CONTENT}"
...@@ -45,9 +85,9 @@ $ => ...@@ -45,9 +85,9 @@ $ =>
# handles hiding and showing sections # handles hiding and showing sections
setup_instructor_dashboard = (idash_content) => setup_instructor_dashboard = (idash_content) =>
# clickable section titles # 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) -> link.click (e) ->
e.preventDefault() e.preventDefault()
...@@ -70,24 +110,24 @@ setup_instructor_dashboard = (idash_content) => ...@@ -70,24 +110,24 @@ setup_instructor_dashboard = (idash_content) =>
# write to url # write to url
location.hash = "#{HASH_LINK_PREFIX}#{section_name}" location.hash = "#{HASH_LINK_PREFIX}#{section_name}"
plantTimeout 0, -> section.data('wrapper')?.onClickTitle?() sections_have_loaded.after ->
# plantTimeout 0, -> section.data('wrapper')?.onExit?() section.data('wrapper')?.onClickTitle?()
# TODO enable onExit handler
# activate an initial section by 'clicking' on it. # activate an initial section by 'clicking' on it.
# check for a deep-link, or click the first link. # check for a deep-link, or click the first link.
click_first_link = -> click_first_link = ->
link = links.eq(0) link = $links.eq(0)
link.click() link.click()
link.data('wrapper')?.onClickTitle?()
if (new RegExp "^#{HASH_LINK_PREFIX}").test location.hash if (new RegExp "^#{HASH_LINK_PREFIX}").test location.hash
rmatch = (new RegExp "^#{HASH_LINK_PREFIX}(.*)").exec location.hash rmatch = (new RegExp "^#{HASH_LINK_PREFIX}(.*)").exec location.hash
section_name = rmatch[1] section_name = rmatch[1]
link = links.filter "[data-section='#{section_name}']" link = $links.filter "[data-section='#{section_name}']"
if link.length == 1 if link.length == 1
link.click() link.click()
link.data('wrapper')?.onClickTitle?()
else else
click_first_link() click_first_link()
else else
...@@ -98,9 +138,14 @@ setup_instructor_dashboard = (idash_content) => ...@@ -98,9 +138,14 @@ setup_instructor_dashboard = (idash_content) =>
# enable sections # enable sections
setup_instructor_dashboard_sections = (idash_content) -> setup_instructor_dashboard_sections = (idash_content) ->
# see fault isolation NOTE at top of file. # see fault isolation NOTE at top of file.
# an error thrown in one section will not block other sections from exectuing. # If an error thrown in one section, it will not stop other sections from exectuing.
plantTimeout 0, -> new window.InstructorDashboard.sections.CourseInfo idash_content.find ".#{CSS_IDASH_SECTION}#course_info" plantTimeout 0, sections_have_loaded.waitFor ->
plantTimeout 0, -> new window.InstructorDashboard.sections.DataDownload idash_content.find ".#{CSS_IDASH_SECTION}#data_download" new window.InstructorDashboard.sections.CourseInfo idash_content.find ".#{CSS_IDASH_SECTION}#course_info"
plantTimeout 0, -> new window.InstructorDashboard.sections.Membership idash_content.find ".#{CSS_IDASH_SECTION}#membership" plantTimeout 0, sections_have_loaded.waitFor ->
plantTimeout 0, -> new window.InstructorDashboard.sections.StudentAdmin idash_content.find ".#{CSS_IDASH_SECTION}#student_admin" new window.InstructorDashboard.sections.DataDownload idash_content.find ".#{CSS_IDASH_SECTION}#data_download"
plantTimeout 0, -> new window.InstructorDashboard.sections.Analytics idash_content.find ".#{CSS_IDASH_SECTION}#analytics" 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 { ...@@ -36,6 +36,11 @@ section.instructor-dashboard-content-2 {
color: $error-red; color: $error-red;
} }
.display-errors {
line-height: 3em;
color: $error-red;
}
.slickgrid { .slickgrid {
margin-left: 1px; margin-left: 1px;
color:#333333; color:#333333;
...@@ -320,25 +325,40 @@ section.instructor-dashboard-content-2 { ...@@ -320,25 +325,40 @@ section.instructor-dashboard-content-2 {
} }
.instructor-dashboard-wrapper-2 section.idash-section#analytics { .profile-distribution-widget {
.distribution-display { margin-bottom: $baseline * 2;
margin-top: 1.2em;
.display-text {}
.distribution-display-graph { .display-graph .graph-placeholder {
.year-of-birth {
width: 750px; width: 750px;
height: 250px; height: 250px;
} }
}
.distribution-display-table { .display-table {
.slickgrid { .slickgrid {
height: 400px; 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 { .member-list-widget {
$width: 20 * $baseline; $width: 20 * $baseline;
......
<%page args="section_data"/> <%page args="section_data"/>
<h2>Distributions</h2> <script type="text/template" id="profile-distribution-widget-template">
<select id="distributions" data-endpoint="${ section_data['get_distribution_url'] }"> <div class="profile-distribution-widget">
<option> Getting available distributions... </option> <div class="header">
</select> <h2 class="title"> {{title}} </h2>
<div class="distribution-display"> </div>
<div class="distribution-display-text"></div> <div class="view">
<div class="distribution-display-graph"></div> <div class="display-errors"></div>
<div class="distribution-display-table"></div> <div class="display-text"></div>
<div class="request-response-error"></div> <div class="display-graph"></div>
</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>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment