From 0f37ee6924bb6899912601c08427c622ca47431f Mon Sep 17 00:00:00 2001 From: David Adams Date: Mon, 17 Mar 2014 14:00:22 -0700 Subject: [PATCH] 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. --- .../class_dashboard/dashboard_data.py | 128 ++++++++++ .../test/test_dashboard_data.py | 108 +++++++- .../instructor/views/instructor_dashboard.py | 4 +- .../sass/course/instructor/_instructor_2.scss | 176 +++++++++---- .../class_dashboard/d3_stacked_bar_graph.js | 1 + .../instructor_dashboard_2/metrics.html | 234 +++++++++++++----- lms/urls.py | 8 + 7 files changed, 538 insertions(+), 121 deletions(-) diff --git a/lms/djangoapps/class_dashboard/dashboard_data.py b/lms/djangoapps/class_dashboard/dashboard_data.py index 62db1c821f..209d647faf 100644 --- a/lms/djangoapps/class_dashboard/dashboard_data.py +++ b/lms/djangoapps/class_dashboard/dashboard_data.py @@ -1,6 +1,7 @@ """ 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 diff --git a/lms/djangoapps/class_dashboard/test/test_dashboard_data.py b/lms/djangoapps/class_dashboard/test/test_dashboard_data.py index 1e511bd607..e53d373b75 100644 --- a/lms/djangoapps/class_dashboard/test/test_dashboard_data.py +++ b/lms/djangoapps/class_dashboard/test/test_dashboard_data.py @@ -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) diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 3a0dbd50cf..fef3e17be4 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -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 diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index b5430de58d..7348b97698 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -555,57 +555,131 @@ 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 { - width: 100%; - } - .metrics-right { - position: relative; - width: 65%; - height: 295px; - float: left; - margin-left: 2.5%; - margin-bottom: 25px; - } - .metrics-right svg { - width: 100%; - } - - .metrics-tooltip { - width: 250px; - background-color: lightgray; - padding: 3px; - } - - .stacked-bar-graph-legend { - fill: white; - } - - p.loading { - padding-top: 100px; - text-align: center; - } - - p.nothing { - padding-top: 25px; - } - - h3.attention { - padding: 10px; - border: 1px solid #999; - border-radius: 5px; - margin-top: 25px; - } - - input#graph_reload { - display: none; + + .metrics-left { + position: relative; + width: 30%; + height: 640px; + float: left; + margin-right: 2.5%; + + svg { + width: 100%; + } + } + .metrics-right { + position: relative; + width: 65%; + height: 295px; + float: left; + margin-left: 2.5%; + margin-bottom: 25px; + + svg { + width: 100%; + } + } + + svg { + .stacked-bar { + cursor: pointer; + } + } + + .metrics-tooltip { + width: 250px; + background-color: lightgray; + 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; + } + + p.loading { + padding-top: 100px; + text-align: center; + } + + p.nothing { + padding-top: 25px; + } + + h3.attention { + padding: 10px; + border: 1px solid #999; + border-radius: 5px; + margin-top: 25px; + } + + input#graph_reload { + display: none; + } } } diff --git a/lms/templates/class_dashboard/d3_stacked_bar_graph.js b/lms/templates/class_dashboard/d3_stacked_bar_graph.js index 97079113fa..8552b3f48e 100644 --- a/lms/templates/class_dashboard/d3_stacked_bar_graph.js +++ b/lms/templates/class_dashboard/d3_stacked_bar_graph.js @@ -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"); diff --git a/lms/templates/instructor/instructor_dashboard_2/metrics.html b/lms/templates/instructor/instructor_dashboard_2/metrics.html index 584869c1da..fbd07e5a19 100644 --- a/lms/templates/instructor/instructor_dashboard_2/metrics.html +++ b/lms/templates/instructor/instructor_dashboard_2/metrics.html @@ -1,80 +1,192 @@ <%! from django.utils.translation import ugettext as _ %> + <%page args="section_data"/> - %if not any (section_data.values()): -

${_("There is no data available to display at this time.")}

- %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"/> +%if not any (section_data.values()): +

${_("There is no data available to display at this time.")}

+%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"/> -

${_("Loading the latest graphs for you; depending on your class size, this may take a few minutes.")}

- +

${_("Loading the latest graphs for you; depending on your class size, this may take a few minutes.")}

+ - %for i in range(0,len(section_data['sub_section_display_name'])): -
-

${_("Section:")} ${section_data['sub_section_display_name'][i]}

-
-
-

${_("Count of Students Opened a Subsection")}

+ + %for i in range(0, len(section_data['sub_section_display_name'])): +
+

${_("Section:")} ${section_data['sub_section_display_name'][i]}

+
+
+

${_("Count of Students Opened a Subsection")}

+
+
+

${_("Grade Distribution per Problem")}

+
+
+
+
+ + + +
-
-

${_("Grade Distribution per Problem")}

-
-
- %endfor - + $('.instructor-nav a').click(function () { + if ($(this).data('section') === "metrics" && firstLoad) { + loadGraphs(); + firstLoad = false; + } + }); - %endif + $('#graph_reload').click(function () { + loadGraphs(); + }); + + if (window.location.hash === "#view-metrics") { + $('.instructor-nav a[data-section="metrics"]').click(); + } + }); + $('.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; + + return location.href = url; + }); + + +%endif diff --git a/lms/urls.py b/lms/urls.py index bf3cc089cd..28c0ca7071 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -381,6 +381,14 @@ if settings.FEATURES.get('CLASS_DASHBOARD'): # Json request data for metrics for particular section url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/problem_grade_distribution/(?P
\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'):