Commit 25d45b85 by Miles Steele

Merge pull request #574 from edx/feature/msteele/instrdash

Add cronjob analytics to instructor dashbaord
parents 0f9d7230 fab16f37
......@@ -5,9 +5,10 @@ Unit tests for instructor.api methods.
import unittest
import json
from urllib import quote
from django.conf import settings
from django.test import TestCase
from nose.tools import raises
from mock import Mock
from mock import Mock, patch
from django.test.utils import override_settings
from django.core.urlresolvers import reverse
from django.http import HttpRequest, HttpResponse
......@@ -22,7 +23,11 @@ from student.tests.factories import UserFactory, AdminFactory
from student.models import CourseEnrollment
from courseware.models import StudentModule
# modules which are mocked in test cases.
import instructor_task.api
from instructor.access import allow_access
import instructor.views.api
from instructor.views.api import (
_split_input_list, _msk_from_problem_urlname, common_exceptions_400)
from instructor_task.api_helper import AlreadyRunningError
......@@ -118,6 +123,7 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase):
'list_instructor_tasks',
'list_forum_members',
'update_forum_role_membership',
'proxy_legacy_analytics',
]
for endpoint in staff_level_endpoints:
url = reverse(endpoint, kwargs={'course_id': self.course.id})
......@@ -569,13 +575,10 @@ class TestInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollmentTestCase)
0
)
def test_reset_student_attempts_all(self):
# mock out the function which should be called to execute the action.
@patch.object(instructor_task.api, 'submit_reset_problem_attempts_for_all_students')
def test_reset_student_attempts_all(self, act):
""" Test reset all student attempts. """
# mock out the function which should be called to execute the action.
import instructor_task.api
act = Mock(return_value=None)
instructor_task.api.submit_reset_problem_attempts_for_all_students = act
url = reverse('reset_student_attempts', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'problem_to_reset': self.problem_urlname,
......@@ -626,12 +629,9 @@ class TestInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollmentTestCase)
print response.content
self.assertEqual(response.status_code, 400)
def test_rescore_problem_single(self):
@patch.object(instructor_task.api, 'submit_rescore_problem_for_student')
def test_rescore_problem_single(self, act):
""" Test rescoring of a single student. """
import instructor_task.api
act = Mock(return_value=None)
instructor_task.api.submit_rescore_problem_for_student = act
url = reverse('rescore_problem', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'problem_to_reset': self.problem_urlname,
......@@ -641,12 +641,9 @@ class TestInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollmentTestCase)
self.assertEqual(response.status_code, 200)
self.assertTrue(act.called)
def test_rescore_problem_all(self):
@patch.object(instructor_task.api, 'submit_rescore_problem_for_all_students')
def test_rescore_problem_all(self, act):
""" Test rescoring for all students. """
import instructor_task.api
act = Mock(return_value=None)
instructor_task.api.submit_rescore_problem_for_all_students = act
url = reverse('rescore_problem', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'problem_to_reset': self.problem_urlname,
......@@ -696,12 +693,10 @@ class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase):
self.tasks = [self.FakeTask() for _ in xrange(6)]
def test_list_instructor_tasks_running(self):
@patch.object(instructor_task.api, 'get_running_instructor_tasks')
def test_list_instructor_tasks_running(self, act):
""" Test list of all running tasks. """
import instructor_task.api
act = Mock(return_value=self.tasks)
instructor_task.api.get_running_instructor_tasks = act
act.return_value = self.tasks
url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id})
response = self.client.get(url, {})
print response.content
......@@ -713,12 +708,10 @@ class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase):
expected_res = {'tasks': expected_tasks}
self.assertEqual(json.loads(response.content), expected_res)
def test_list_instructor_tasks_problem(self):
@patch.object(instructor_task.api, 'get_instructor_task_history')
def test_list_instructor_tasks_problem(self, act):
""" Test list task history for problem. """
import instructor_task.api
act = Mock(return_value=self.tasks)
instructor_task.api.get_instructor_task_history = act
act.return_value = self.tasks
url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'problem_urlname': self.problem_urlname,
......@@ -732,12 +725,10 @@ class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase):
expected_res = {'tasks': expected_tasks}
self.assertEqual(json.loads(response.content), expected_res)
def test_list_instructor_tasks_problem_student(self):
@patch.object(instructor_task.api, 'get_instructor_task_history')
def test_list_instructor_tasks_problem_student(self, act):
""" Test list task history for problem AND student. """
import instructor_task.api
act = Mock(return_value=self.tasks)
instructor_task.api.get_instructor_task_history = act
act.return_value = self.tasks
url = reverse('list_instructor_tasks', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'problem_urlname': self.problem_urlname,
......@@ -753,6 +744,95 @@ class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase):
self.assertEqual(json.loads(response.content), expected_res)
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
@override_settings(ANALYTICS_SERVER_URL="http://robotanalyticsserver.netbot:900/")
@override_settings(ANALYTICS_API_KEY="robot_api_key")
class TestInstructorAPIAnalyticsProxy(ModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Test instructor analytics proxy endpoint.
"""
class FakeProxyResponse(object):
""" Fake successful requests response object. """
def __init__(self):
self.status_code = instructor.views.api.codes.OK
self.content = '{"test_content": "robot test content"}'
class FakeBadProxyResponse(object):
""" Fake strange-failed requests response object. """
def __init__(self):
self.status_code = 'notok.'
self.content = '{"test_content": "robot test content"}'
def setUp(self):
self.instructor = AdminFactory.create()
self.course = CourseFactory.create()
self.client.login(username=self.instructor.username, password='test')
@patch.object(instructor.views.api.requests, 'get')
def test_analytics_proxy_url(self, act):
""" Test legacy analytics proxy url generation. """
act.return_value = self.FakeProxyResponse()
url = reverse('proxy_legacy_analytics', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'aname': 'ProblemGradeDistribution'
})
print response.content
self.assertEqual(response.status_code, 200)
# check request url
expected_url = "{url}get?aname={aname}&course_id={course_id}&apikey={api_key}".format(
url="http://robotanalyticsserver.netbot:900/",
aname="ProblemGradeDistribution",
course_id=self.course.id,
api_key="robot_api_key",
)
act.assert_called_once_with(expected_url)
@patch.object(instructor.views.api.requests, 'get')
def test_analytics_proxy(self, act):
"""
Test legacy analytics content proxyin, actg.
"""
act.return_value = self.FakeProxyResponse()
url = reverse('proxy_legacy_analytics', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'aname': 'ProblemGradeDistribution'
})
print response.content
self.assertEqual(response.status_code, 200)
# check response
self.assertTrue(act.called)
expected_res = {'test_content': "robot test content"}
self.assertEqual(json.loads(response.content), expected_res)
@patch.object(instructor.views.api.requests, 'get')
def test_analytics_proxy_reqfailed(self, act):
""" Test proxy when server reponds with failure. """
act.return_value = self.FakeBadProxyResponse()
url = reverse('proxy_legacy_analytics', kwargs={'course_id': self.course.id})
response = self.client.get(url, {
'aname': 'ProblemGradeDistribution'
})
print response.content
self.assertEqual(response.status_code, 500)
@patch.object(instructor.views.api.requests, 'get')
def test_analytics_proxy_missing_param(self, act):
""" Test proxy when missing the aname query parameter. """
act.return_value = self.FakeProxyResponse()
url = reverse('proxy_legacy_analytics', kwargs={'course_id': self.course.id})
response = self.client.get(url, {})
print response.content
self.assertEqual(response.status_code, 400)
self.assertFalse(act.called)
class TestInstructorAPIHelpers(TestCase):
""" Test helpers for instructor.api """
def test_split_input_list(self):
......
......@@ -8,11 +8,15 @@ Many of these GETs may become PUTs in the future.
import re
import logging
import requests
from requests.status_codes import codes
from collections import OrderedDict
from django.conf import settings
from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
from django.http import HttpResponseBadRequest, HttpResponseForbidden
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
from util.json_request import JsonResponse
from courseware.access import has_access
......@@ -725,6 +729,57 @@ def update_forum_role_membership(request, course_id):
return JsonResponse(response_payload)
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_query_params(
aname="name of analytic to query",
)
@common_exceptions_400
def proxy_legacy_analytics(request, course_id):
"""
Proxies to the analytics cron job server.
`aname` is a query parameter specifying which analytic to query.
"""
analytics_name = request.GET.get('aname')
# abort if misconfigured
if not (hasattr(settings, 'ANALYTICS_SERVER_URL') and hasattr(settings, 'ANALYTICS_API_KEY')):
return HttpResponse("Analytics service not configured.", status=501)
url = "{}get?aname={}&course_id={}&apikey={}".format(
settings.ANALYTICS_SERVER_URL,
analytics_name,
course_id,
settings.ANALYTICS_API_KEY,
)
try:
res = requests.get(url)
except Exception:
log.exception("Error requesting from analytics server at %s", url)
return HttpResponse("Error requesting from analytics server.", status=500)
if res.status_code is 200:
# return the successful request content
return HttpResponse(res.content, content_type="application/json")
elif res.status_code is 404:
# forward the 404 and content
return HttpResponse(res.content, content_type="application/json", status=404)
else:
# 500 on all other unexpected status codes.
log.error(
"Error fetching {}, code: {}, msg: {}".format(
url, res.status_code, res.content
)
)
return HttpResponse(
"Error from analytics server ({}).".format(res.status_code),
status=500
)
def _split_input_list(str_list):
"""
Separate out individual student email from the comma, or space separated string.
......
......@@ -30,4 +30,6 @@ urlpatterns = patterns('', # nopep8
'instructor.views.api.list_forum_members', name="list_forum_members"),
url(r'^update_forum_role_membership$',
'instructor.views.api.update_forum_role_membership', name="update_forum_role_membership"),
url(r'^proxy_legacy_analytics$',
'instructor.views.api.proxy_legacy_analytics', name="proxy_legacy_analytics"),
)
......@@ -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").text()
@$container.html Mustache.render template_html, template_params
reset_display: ->
@$display_text.empty()
@$display_graph.empty()
@$display_table.empty()
@$request_response_error.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 --"
@$container.find('.display-errors').empty()
@$container.find('.display-text').empty()
@$container.find('.display-graph').empty()
@$container.find('.display-table').empty()
# 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,27 +42,27 @@ 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'
name: 'Count'
]
grid_data = _.map feature_res.data, (value, key) ->
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
class GradeDistributionDisplay
constructor: ({@$container, @endpoint}) ->
template_params = {}
template_html = $('#grade-distributions-widget-template').text()
@$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: ->
@on_selector_change()
for pdw in @pdws
pdw.load()
for gdw in @gdws
gdw.load()
# handler for when the section title is clicked.
onClickTitle: ->
@refresh()
......
......@@ -33,6 +33,47 @@ CSS_INSTRUCTOR_NAV = 'instructor-nav'
# prefix for deep-linking
HASH_LINK_PREFIX = '#view-'
$active_section = null
# 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.
# To guarantee this functionality, waitFor and after must be called
# before the functions passed to waitFor are 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
@fired = true
@after_handlers.map (cb) -> plantTimeout 0, 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,10 +86,11 @@ $ =>
# 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)
link.click (e) ->
# attach link click handlers
$links.each (i, link) ->
$(link).click (e) ->
e.preventDefault()
# deactivate all link & section styles
......@@ -57,11 +99,11 @@ setup_instructor_dashboard = (idash_content) =>
# discover section paired to link
section_name = $(this).data 'section'
section = idash_content.find "##{section_name}"
$section = idash_content.find "##{section_name}"
# activate link & section styling
$(this).addClass CSS_ACTIVE_SECTION
section.addClass CSS_ACTIVE_SECTION
$section.addClass CSS_ACTIVE_SECTION
# tracking
analytics.pageview "instructor_section:#{section_name}"
......@@ -70,24 +112,29 @@ 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?()
# call onExit handler if exiting a section to a different section.
unless $section.is $active_section
$active_section?.data('wrapper')?.onExit?()
$active_section = $section
# 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
......@@ -97,10 +144,25 @@ 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"
sections_to_initialize = [
constructor: window.InstructorDashboard.sections.CourseInfo
$element: idash_content.find ".#{CSS_IDASH_SECTION}#course_info"
,
constructor: window.InstructorDashboard.sections.DataDownload
$element: idash_content.find ".#{CSS_IDASH_SECTION}#data_download"
,
constructor: window.InstructorDashboard.sections.Membership
$element: idash_content.find ".#{CSS_IDASH_SECTION}#membership"
,
constructor: window.InstructorDashboard.sections.StudentAdmin
$element: idash_content.find ".#{CSS_IDASH_SECTION}#student_admin"
,
constructor: window.InstructorDashboard.sections.Analytics
$element: idash_content.find ".#{CSS_IDASH_SECTION}#analytics"
]
sections_to_initialize.map ({constructor, $element}) ->
# See fault isolation NOTE at top of file.
# If an error is thrown in one section, it will not stop other sections from exectuing.
plantTimeout 0, sections_have_loaded.waitFor ->
new constructor $element
......@@ -6,6 +6,8 @@
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
plantInterval = -> window.InstructorDashboard.util.plantInterval.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
load_IntervalManager = -> window.InstructorDashboard.util.IntervalManager
# wrap window.confirm
# display `msg`
......@@ -102,8 +104,13 @@ class StudentAdmin
@$request_response_error_all = @$section.find ".course-specific-container .request-response-error"
# start polling for task list
# if the list is in the DOM
if @$table_running_tasks.length > 0
@start_refresh_running_task_poll_loop()
# reload every 20 seconds.
TASK_LIST_POLL_INTERVAL = 20000
@reload_running_tasks_list()
@task_poller = new (load_IntervalManager()) TASK_LIST_POLL_INTERVAL, =>
@reload_running_tasks_list()
# attach click handlers
......@@ -255,7 +262,6 @@ class StudentAdmin
create_task_list_table @$table_task_history_all, data.tasks
error: std_ajax_err => @$request_response_error_all.text "Error listing task history for this student and problem."
reload_running_tasks_list: =>
list_endpoint = @$table_running_tasks.data 'endpoint'
$.ajax
......@@ -264,12 +270,6 @@ class StudentAdmin
success: (data) => create_task_list_table @$table_running_tasks, data.tasks
error: std_ajax_err => console.warn "error listing all instructor tasks"
start_refresh_running_task_poll_loop: ->
@reload_running_tasks_list()
if @$section.hasClass 'active-section'
# poll every 20 seconds
plantTimeout 20000, => @start_refresh_running_task_poll_loop()
# wraps a function, but first clear the error displays
clear_errors_then: (cb) ->
@$request_response_error_single.empty()
......@@ -278,14 +278,10 @@ class StudentAdmin
cb?.apply this, arguments
# handler for when the section title is clicked.
onClickTitle: ->
if @$table_running_tasks.length > 0
@start_refresh_running_task_poll_loop()
onClickTitle: -> @task_poller?.start()
# handler for when the section is closed
# not working yet.
# onExit: ->
# clearInterval @reload_running_task_list_slot
onExit: -> @task_poller?.stop()
# export for use
......
......@@ -5,6 +5,7 @@
plantTimeout = (ms, cb) -> setTimeout cb, ms
plantInterval = (ms, cb) -> setInterval cb, ms
# standard ajax error wrapper
#
# wraps a `handler` function so that first
......@@ -16,6 +17,26 @@ std_ajax_err = (handler) -> (jqXHR, textStatus, errorThrown) ->
handler.apply this, arguments
# Helper class for managing the execution of interval tasks.
# Handles pausing and restarting.
class IntervalManager
# Create a manager which will call `fn`
# after a call to .start every `ms` milliseconds.
constructor: (@ms, @fn) ->
@intervalID = null
# Start or restart firing every `ms` milliseconds.
# Soes not fire immediately.
start: ->
if @intervalID is null
@intervalID = setInterval @fn, @ms
# Pause firing.
stop: ->
clearInterval @intervalID
@intervalID = null
# export for use
# create parent namespaces if they do not already exist.
# abort if underscore can not be found.
......@@ -25,3 +46,4 @@ if _?
plantTimeout: plantTimeout
plantInterval: plantInterval
std_ajax_err: std_ajax_err
IntervalManager: IntervalManager
......@@ -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;
......@@ -269,6 +274,10 @@ section.instructor-dashboard-content-2 {
.instructor-dashboard-wrapper-2 section.idash-section#student_admin > {
.action-type-container{
margin-bottom: $baseline * 2;
}
.progress-link-wrapper {
margin-top: 0.7em;
}
......@@ -316,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;
......
<%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>
<%page args="section_data"/>
<div class="student-specific-container">
<div class="student-specific-container action-type-container">
<H2>Student-specific grade adjustment</h2>
<div class="request-response-error"></div>
......@@ -47,12 +47,11 @@
<input type="button" name="task-history-single" value="Show Background Task History for Student" data-endpoint="${ section_data['list_instructor_tasks_url'] }">
<div class="task-history-single-table"></div>
%endif
<hr>
</div>
%if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS') and section_data['access']['instructor']:
<hr>
<div class="course-specific-container">
<div class="course-specific-container action-type-container">
<H2>Course-specific grade adjustment</h2>
<div class="request-response-error"></div>
......@@ -81,9 +80,9 @@
</p>
</div>
<hr>
<div class="running-tasks-container">
<div class="running-tasks-container action-type-container">
<hr>
<h2> Pending Instructor Tasks </h2>
<div class="running-tasks-table" data-endpoint="${ section_data['list_instructor_tasks_url'] }"></div>
</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