Commit 0f37ee69 by David Adams

This makes the metrics tab "bars" clickable.

  Clicking on any of the bars displays a list of students for that
  particular action (either opened the subsection or attempted the
  problem).
  Students are listed for the sub-sections.
  Students, grade and percent are listed for the problems.
  The on-screen list displays only the first 250 students with
  an overflow message if there are more students than that.
  The csv download lists all students.
parent 24b631f7
"""
Computes the data to display on the Instructor Dashboard
"""
from util.json_request import JsonResponse
from courseware import models
from django.db.models import Count
......@@ -9,7 +10,10 @@ from django.utils.translation import ugettext as _
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.inheritance import own_metadata
from analytics.csvs import create_csv_response
# Used to limit the length of list displayed to the screen.
MAX_SCREEN_LIST_LENGTH = 250
def get_problem_grade_distribution(course_id):
"""
......@@ -193,6 +197,7 @@ def get_d3_problem_grade_distrib(course_id):
'color': percent,
'value': count_grade,
'tooltip': tooltip,
'module_url': child.location.url(),
})
problem = {
......@@ -251,6 +256,7 @@ def get_d3_sequential_open_distrib(course_id):
'color': 0,
'value': num_students,
'tooltip': tooltip,
'module_url': subsection.location.url(),
})
subsection = {
'xValue': "SS {0}".format(c_subsection),
......@@ -399,3 +405,125 @@ def get_array_section_has_problem(course_id):
i += 1
return b_section_has_problem
def get_students_opened_subsection(request, csv=False):
"""
Get a list of students that opened a particular subsection.
If 'csv' is False, returns a dict of student's name: username.
If 'csv' is True, returns a header array, and an array of arrays in the format:
student names, usernames for CSV download.
"""
module_id = request.GET.get('module_id')
csv = request.GET.get('csv')
# Query for "opened a subsection" students
students = models.StudentModule.objects.select_related('student').filter(
module_state_key__exact=module_id,
module_type__exact='sequential',
).values('student__username', 'student__profile__name').order_by('student__profile__name')
results = []
if not csv:
# Restrict screen list length
# Adding 1 so can tell if list is larger than MAX_SCREEN_LIST_LENGTH
# without doing another select.
for student in students[0:MAX_SCREEN_LIST_LENGTH + 1]:
results.append({
'name': student['student__profile__name'],
'username': student['student__username'],
})
max_exceeded = False
if len(results) > MAX_SCREEN_LIST_LENGTH:
# Remove the last item so list length is exactly MAX_SCREEN_LIST_LENGTH
del results[-1]
max_exceeded = True
response_payload = {
'results': results,
'max_exceeded': max_exceeded,
}
return JsonResponse(response_payload)
else:
tooltip = request.GET.get('tooltip')
filename = sanitize_filename(tooltip[tooltip.index('S'):])
header = ['Name', 'Username']
for student in students:
results.append([student['student__profile__name'], student['student__username']])
response = create_csv_response(filename, header, results)
return response
def get_students_problem_grades(request, csv=False):
"""
Get a list of students and grades for a particular problem.
If 'csv' is False, returns a dict of student's name: username: grade: percent.
If 'csv' is True, returns a header array, and an array of arrays in the format:
student names, usernames, grades, percents for CSV download.
"""
module_id = request.GET.get('module_id')
csv = request.GET.get('csv')
# Query for "problem grades" students
students = models.StudentModule.objects.select_related('student').filter(
module_state_key__exact=module_id,
module_type__exact='problem',
grade__isnull=False,
).values('student__username', 'student__profile__name', 'grade', 'max_grade').order_by('student__profile__name')
results = []
if not csv:
# Restrict screen list length
# Adding 1 so can tell if list is larger than MAX_SCREEN_LIST_LENGTH
# without doing another select.
for student in students[0:MAX_SCREEN_LIST_LENGTH + 1]:
student_dict = {
'name': student['student__profile__name'],
'username': student['student__username'],
'grade': student['grade'],
}
student_dict['percent'] = 0
if student['max_grade'] > 0:
student_dict['percent'] = round(student['grade'] * 100 / student['max_grade'])
results.append(student_dict)
max_exceeded = False
if len(results) > MAX_SCREEN_LIST_LENGTH:
# Remove the last item so list length is exactly MAX_SCREEN_LIST_LENGTH
del results[-1]
max_exceeded = True
response_payload = {
'results': results,
'max_exceeded': max_exceeded,
}
return JsonResponse(response_payload)
else:
tooltip = request.GET.get('tooltip')
filename = sanitize_filename(tooltip[:tooltip.rfind(' - ')])
header = ['Name', 'Username', 'Grade', 'Percent']
for student in students:
percent = 0
if student['max_grade'] > 0:
percent = round(student['grade'] * 100 / student['max_grade'])
results.append([student['student__profile__name'], student['student__username'], student['grade'], percent])
response = create_csv_response(filename, header, results)
return response
def sanitize_filename(filename):
"""
Utility function
"""
filename = filename.replace(" ", "_")
filename = filename.encode('ascii')
filename = filename[0:25] + '.csv'
return filename
......@@ -3,10 +3,11 @@ Tests for class dashboard (Metrics tab in instructor dashboard)
"""
import json
from mock import patch
from django.test.utils import override_settings
from django.core.urlresolvers import reverse
from django.test.client import RequestFactory
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
......@@ -18,7 +19,8 @@ from xmodule.modulestore import Location
from class_dashboard.dashboard_data import (get_problem_grade_distribution, get_sequential_open_distrib,
get_problem_set_grade_distrib, get_d3_problem_grade_distrib,
get_d3_sequential_open_distrib, get_d3_section_grade_distrib,
get_section_display_name, get_array_section_has_problem
get_section_display_name, get_array_section_has_problem,
get_students_opened_subsection, get_students_problem_grades,
)
from class_dashboard.views import has_instructor_access_for_class
......@@ -33,6 +35,7 @@ class TestGetProblemGradeDistribution(ModuleStoreTestCase):
def setUp(self):
self.request_factory = RequestFactory()
self.instructor = AdminFactory.create()
self.client.login(username=self.instructor.username, password='test')
self.attempts = 3
......@@ -45,27 +48,27 @@ class TestGetProblemGradeDistribution(ModuleStoreTestCase):
category="chapter",
display_name=u"test factory section omega \u03a9",
)
sub_section = ItemFactory.create(
self.sub_section = ItemFactory.create(
parent_location=section.location,
category="sequential",
display_name=u"test subsection omega \u03a9",
)
unit = ItemFactory.create(
parent_location=sub_section.location,
parent_location=self.sub_section.location,
category="vertical",
metadata={'graded': True, 'format': 'Homework'},
display_name=u"test unit omega \u03a9",
)
self.users = [UserFactory.create() for _ in xrange(USER_COUNT)]
self.users = [UserFactory.create(username="metric" + str(__)) for __ in xrange(USER_COUNT)]
for user in self.users:
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
for i in xrange(USER_COUNT - 1):
category = "problem"
item = ItemFactory.create(
self.item = ItemFactory.create(
parent_location=unit.location,
category=category,
data=StringResponseXMLFactory().build_xml(answer='foo'),
......@@ -79,7 +82,7 @@ class TestGetProblemGradeDistribution(ModuleStoreTestCase):
max_grade=1 if i < j else 0.5,
student=user,
course_id=self.course.id,
module_state_key=Location(item.location).url(),
module_state_key=Location(self.item.location).url(),
state=json.dumps({'attempts': self.attempts}),
)
......@@ -87,7 +90,7 @@ class TestGetProblemGradeDistribution(ModuleStoreTestCase):
StudentModuleFactory.create(
course_id=self.course.id,
module_type='sequential',
module_state_key=Location(item.location).url(),
module_state_key=Location(self.item.location).url(),
)
def test_get_problem_grade_distribution(self):
......@@ -151,6 +154,95 @@ class TestGetProblemGradeDistribution(ModuleStoreTestCase):
sum_values += problem['value']
self.assertEquals(USER_COUNT, sum_values)
def test_get_students_problem_grades(self):
attributes = '?module_id=' + self.item.location.url()
request = self.request_factory.get(reverse('get_students_problem_grades') + attributes)
response = get_students_problem_grades(request)
response_content = json.loads(response.content)['results']
response_max_exceeded = json.loads(response.content)['max_exceeded']
self.assertEquals(USER_COUNT, len(response_content))
self.assertEquals(False, response_max_exceeded)
for item in response_content:
if item['grade'] == 0:
self.assertEquals(0, item['percent'])
else:
self.assertEquals(100, item['percent'])
def test_get_students_problem_grades_max(self):
with patch('class_dashboard.dashboard_data.MAX_SCREEN_LIST_LENGTH', 2):
attributes = '?module_id=' + self.item.location.url()
request = self.request_factory.get(reverse('get_students_problem_grades') + attributes)
response = get_students_problem_grades(request)
response_results = json.loads(response.content)['results']
response_max_exceeded = json.loads(response.content)['max_exceeded']
# Only 2 students in the list and response_max_exceeded is True
self.assertEquals(2, len(response_results))
self.assertEquals(True, response_max_exceeded)
def test_get_students_problem_grades_csv(self):
tooltip = 'P1.2.1 Q1 - 3382 Students (100%: 1/1 questions)'
attributes = '?module_id=' + self.item.location.url() + '&tooltip=' + tooltip + '&csv=true'
request = self.request_factory.get(reverse('get_students_problem_grades') + attributes)
response = get_students_problem_grades(request)
# Check header and a row for each student in csv response
self.assertContains(response, '"Name","Username","Grade","Percent"')
self.assertContains(response, '"metric0","0.0","0.0"')
self.assertContains(response, '"metric1","0.0","0.0"')
self.assertContains(response, '"metric2","0.0","0.0"')
self.assertContains(response, '"metric3","0.0","0.0"')
self.assertContains(response, '"metric4","0.0","0.0"')
self.assertContains(response, '"metric5","0.0","0.0"')
self.assertContains(response, '"metric6","0.0","0.0"')
self.assertContains(response, '"metric7","0.0","0.0"')
self.assertContains(response, '"metric8","0.0","0.0"')
self.assertContains(response, '"metric9","0.0","0.0"')
self.assertContains(response, '"metric10","1.0","100.0"')
def test_get_students_opened_subsection(self):
attributes = '?module_id=' + self.item.location.url()
request = self.request_factory.get(reverse('get_students_opened_subsection') + attributes)
response = get_students_opened_subsection(request)
response_results = json.loads(response.content)['results']
response_max_exceeded = json.loads(response.content)['max_exceeded']
self.assertEquals(USER_COUNT, len(response_results))
self.assertEquals(False, response_max_exceeded)
def test_get_students_opened_subsection_max(self):
with patch('class_dashboard.dashboard_data.MAX_SCREEN_LIST_LENGTH', 2):
attributes = '?module_id=' + self.item.location.url()
request = self.request_factory.get(reverse('get_students_opened_subsection') + attributes)
response = get_students_opened_subsection(request)
response_results = json.loads(response.content)['results']
response_max_exceeded = json.loads(response.content)['max_exceeded']
# Only 2 students in the list and response_max_exceeded is True
self.assertEquals(2, len(response_results))
self.assertEquals(True, response_max_exceeded)
def test_get_students_opened_subsection_csv(self):
tooltip = '4162 student(s) opened Subsection 5: Relational Algebra Exercises'
attributes = '?module_id=' + self.item.location.url() + '&tooltip=' + tooltip + '&csv=true'
request = self.request_factory.get(reverse('get_students_opened_subsection') + attributes)
response = get_students_opened_subsection(request)
self.assertContains(response, '"Name","Username"')
# Check response contains 1 line for each user +1 for the header
self.assertEquals(USER_COUNT + 1, len(response.content.splitlines()))
def test_get_section_display_name(self):
section_display_name = get_section_display_name(self.course.id)
......
......@@ -241,6 +241,8 @@ def _section_metrics(course_id, access):
'section_display_name': ('Metrics'),
'access': access,
'sub_section_display_name': get_section_display_name(course_id),
'section_has_problem': get_array_section_has_problem(course_id)
'section_has_problem': get_array_section_has_problem(course_id),
'get_students_opened_subsection_url': reverse('get_students_opened_subsection'),
'get_students_problem_grades_url': reverse('get_students_problem_grades'),
}
return section_data
......@@ -555,17 +555,18 @@ section.instructor-dashboard-content-2 {
float: left;
clear: both;
margin-top: 25px;
}
.metrics-left {
position: relative;
width: 30%;
height: 640px;
float: left;
margin-right: 2.5%;
}
.metrics-left svg {
svg {
width: 100%;
}
}
.metrics-right {
position: relative;
width: 65%;
......@@ -573,10 +574,17 @@ section.instructor-dashboard-content-2 {
float: left;
margin-left: 2.5%;
margin-bottom: 25px;
}
.metrics-right svg {
svg {
width: 100%;
}
}
svg {
.stacked-bar {
cursor: pointer;
}
}
.metrics-tooltip {
width: 250px;
......@@ -584,6 +592,71 @@ section.instructor-dashboard-content-2 {
padding: 3px;
}
.metrics-overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(255,255,255, .75);
display: none;
.metrics-overlay-content-wrapper {
position: relative;
display: block;
height: 475px;
width: 85%;
margin: 5%;
background-color: #fff;
border: 1px solid #000;
border-radius: 25px;
padding: 2.5%;
.metrics-overlay-title {
display: block;
height: 50px;
margin-bottom: 10px;
font-weight: bold;
}
.metrics-overlay-content {
width: 100%;
height: 370px;
overflow: auto;
border: 1px solid #000;
table {
width: 100%;
.header {
background-color: #ddd;
}
th, td {
padding: 10px;
}
}
}
.overflow-message {
padding-top: 20px;
}
.download-csv {
position: absolute;
display: none;
right: 2%;
bottom: 2%;
}
.close-button {
position: absolute;
right: 1.5%;
top: 2%;
font-size: 2em;
}
}
}
.stacked-bar-graph-legend {
fill: white;
}
......@@ -607,6 +680,7 @@ section.instructor-dashboard-content-2 {
input#graph_reload {
display: none;
}
}
}
......
......@@ -329,6 +329,7 @@ edx_d3CreateStackedBarGraph = function(parameters, svg, divTooltip) {
.attr("height", function(d) {
return graph.scale.y(d.y0) - graph.scale.y(d.y1);
})
.attr("id", function(d) { return d.module_url })
.style("fill", function(d) { return graph.scale.stackColor(d.color); })
.style("stroke", "white")
.style("stroke-width", "0.5px");
......
<%! from django.utils.translation import ugettext as _ %>
<%page args="section_data"/>
<script>
${d3_stacked_bar_graph.body()}
</script>
%if not any (section_data.values()):
%if not any (section_data.values()):
<p>${_("There is no data available to display at this time.")}</p>
%else:
%else:
<%namespace name="d3_stacked_bar_graph" file="/class_dashboard/d3_stacked_bar_graph.js"/>
<%namespace name="all_section_metrics" file="/class_dashboard/all_section_metrics.js"/>
<h3 class="attention" id="graph_load">${_("Loading the latest graphs for you; depending on your class size, this may take a few minutes.")}</h3>
<input type="button" id="graph_reload" value="${_("Reload Graphs")}" />
%for i in range(0,len(section_data['sub_section_display_name'])):
<!-- For each section with data, create the divs for displaying the graphs
and the popup window for listing the students
-->
%for i in range(0, len(section_data['sub_section_display_name'])):
<div class="metrics-container" id="metrics_section_${i}">
<h2>${_("Section:")} ${section_data['sub_section_display_name'][i]}</h2>
<div class="metrics-tooltip" id="metric_tooltip_${i}"></div>
<div class="metrics-left" id="metric_opened_${i}">
<div class="metrics-section metrics-left" id="metric_opened_${i}">
<h3>${_("Count of Students Opened a Subsection")}</h3>
</div>
<div class="metrics-right" id="metric_grade_${i}" data-section-has-problem=${section_data['section_has_problem'][i]}>
<div class="metrics-section metrics-right" id="metric_grade_${i}" data-section-has-problem=${section_data['section_has_problem'][i]}>
<h3>${_("Grade Distribution per Problem")}</h3>
</div>
<div class="metrics-overlay">
<div class="metrics-overlay-content-wrapper">
<div class="metrics-overlay-content">
<table>
<thead></thead>
<tbody></tbody>
</table>
</div>
<input class="download-csv metrics-student-opened" type="button" name="dump_student_opened" value="${_("Download Student Opened as a CSV")}" data-endpoint="${section_data['get_students_opened_subsection_url']}" data-csv="true">
<input class="download-csv metrics-student-grades" type="button" name="dump_student_grades" value="${_("Download Student Grades as a CSV")}" data-endpoint="${section_data['get_students_problem_grades_url']}" data-csv="true">
<a class="close-button" href="#"><i class="icon-remove"></i><span class="sr">${_("Close")}</span></a>
</div>
</div>
</div>
%endfor
<script>
......@@ -40,6 +57,7 @@
var nothingP = '<p class="nothing">' + nothingText + '</p>';
var loading = '<p class="loading"><i class="icon-spinner icon-spin icon-large"></i>' + loadingText + '</p>';
// Display spinners or "There are no problems in this section" message
$('.metrics-left').each(function() {
$(this).append(loading);
});
......@@ -53,10 +71,86 @@
});
$('.metrics-left svg, .metrics-right svg').remove();
${all_section_metrics.body("metric_opened_","metric_grade_","metric_attempts_","metric_tooltip_",course.id)}
${all_section_metrics.body("metric_opened_", "metric_grade_", "metric_attempts_", "metric_tooltip_", course.id)}
setTimeout(function() {
$('#graph_load, #graph_reload').toggle();
$('.metrics-left .stacked-bar').on("click", function () {
var module_id = $('rect', this).attr('id');
var metrics_overlay = $(this).closest('.metrics-left').siblings('.metrics-overlay');
// Set module_id attribute on metrics_overlay
metrics_overlay.data("module-id", module_id);
var header = $(this).closest('.metrics-left').siblings('.metrics-tooltip').text();
var overlay_content = '<h3 class="metrics-overlay-title">' + header + '</h3>';
$('.metrics-overlay-content', metrics_overlay).before(overlay_content);
$.ajax({
url: "${section_data['get_students_opened_subsection_url']}",
type: "GET",
data: {module_id: module_id},
dataType: "json",
success: function(response) {
overlay_content = '<tr class="header"><th>${_("Name")}</th><th>${_("Username")}</th></tr>';
$('.metrics-overlay-content thead', metrics_overlay).append(overlay_content);
$.each(response.results, function(index, value ){
overlay_content = '<tr><td>' + value['name'] + "</td><td>" + value['username'] + '</td></tr>';
$('.metrics-overlay-content tbody', metrics_overlay).append(overlay_content);
});
// If student list too long, append message to screen.
if (response.max_exceeded) {
overlay_content = '<p class="overflow-message">${_("This is a partial list, to view all students download as a csv.")}</p>';
$('.metrics-overlay-content', metrics_overlay).after(overlay_content);
}
}
})
metrics_overlay.find('.metrics-student-opened').show();
metrics_overlay.show();
});
$('.metrics-right .stacked-bar').on("click",function () {
var module_id = $('rect', this).attr('id');
var metrics_overlay = $(this).closest('.metrics-right').siblings('.metrics-overlay');
//Set module_id attribute on metrics_overlay
metrics_overlay.data("module-id", module_id);
var header = $(this).closest('.metrics-right').siblings('.metrics-tooltip').text();
var far_index = header.indexOf(' students (');
var near_index = header.substr(0, far_index).lastIndexOf(' ') + 1;
var title = header.substring(0, near_index -3);
var overlay_content = '<h3 class="metrics-overlay-title">' + title + '</h3>';
$('.metrics-overlay-content', metrics_overlay).before(overlay_content);
$.ajax({
url: "${section_data['get_students_problem_grades_url']}",
type: "GET",
data: {module_id: module_id},
dataType: "json",
success: function(response) {
overlay_content = '<tr class="header"><th>${_("Name")}</th><th>${_("Username")}</th><th>${_("Grade")}</th><th>${_("Percent")}</th></tr>';
$('.metrics-overlay-content thead', metrics_overlay).append(overlay_content);
$.each(response.results, function(index, value ){
overlay_content = '<tr><td>' + value['name'] + "</td><td>" + value['username'] + "</td><td>" + value['grade'] + "</td><td>" + value['percent'] + '</td></tr>';
$('.metrics-overlay-content tbody', metrics_overlay).append(overlay_content);
});
// If student list too long, append message to screen.
if (response.max_exceeded) {
overlay_content = '<p class="overflow-message">${_("This is a partial list, to view all students download as a csv.")}</p>';
$('.metrics-overlay-content', metrics_overlay).after(overlay_content);
}
},
})
metrics_overlay.find('.metrics-student-grades').show();
metrics_overlay.show();
});
}, 5000);
}
......@@ -75,6 +169,24 @@
$('.instructor-nav a[data-section="metrics"]').click();
}
});
</script>
$('.metrics-overlay .close-button').click(function(event) {
event.preventDefault();
$('.metrics-overlay-content table thead, .metrics-overlay-content table tbody').empty();
$('.metrics-overlay-content-wrapper h3').remove();
$('.metrics-overlay-content-wrapper p').remove();
$(this).closest(".metrics-overlay").hide();
$('.metrics-overlay .download-csv').hide();
});
$('.metrics-overlay .download-csv').click(function(event) {
var module_id = $(this).closest('.metrics-overlay').data("module-id");
var tooltip = $(this).closest('.metrics-container').children('.metrics-tooltip').text();
var attributes = '?module_id=' + module_id + '&tooltip=' + tooltip + '&csv=true';
var url = $(this).data("endpoint");
url += attributes;
%endif
return location.href = url;
});
</script>
%endif
......@@ -381,6 +381,14 @@ if settings.FEATURES.get('CLASS_DASHBOARD'):
# Json request data for metrics for particular section
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/problem_grade_distribution/(?P<section>\d+)$',
'class_dashboard.views.section_problem_grade_distrib', name="section_problem_grade_distrib"),
# For listing students that opened a sub-section
url(r'^get_students_opened_subsection$',
'class_dashboard.dashboard_data.get_students_opened_subsection', name="get_students_opened_subsection"),
# For listing of students' grade per problem
url(r'^get_students_problem_grades$',
'class_dashboard.dashboard_data.get_students_problem_grades', name="get_students_problem_grades"),
)
if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'):
......
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