From 9bc7a518ee22b5bbf11ff239d771ac6739f245ec Mon Sep 17 00:00:00 2001 From: David Adams Date: Fri, 18 Apr 2014 12:16:10 -0700 Subject: [PATCH] Fixes issue with metrics tab click handlers Click handlers were not getting attached to DOM elements in some cases on slow running machines. Added logic to attach handlers when elements are ready. Added 2 buttons on metrics tab: Download Subsection Data for downloading to csv. Download Problem Data for downloading to csv. --- .../class_dashboard/dashboard_data.py | 137 +++++++--- .../tests/test_dashboard_data.py | 60 +++- lms/djangoapps/class_dashboard/urls.py | 30 ++ .../instructor/views/instructor_dashboard.py | 2 + .../sass/course/instructor/_instructor_2.scss | 22 +- .../class_dashboard/all_section_metrics.js | 21 +- .../class_dashboard/d3_stacked_bar_graph.js | 14 +- .../courseware/instructor_dashboard.html | 4 +- .../instructor_dashboard_2/metrics.html | 257 ++++++++++++------ lms/urls.py | 18 +- 10 files changed, 411 insertions(+), 154 deletions(-) create mode 100644 lms/djangoapps/class_dashboard/urls.py diff --git a/lms/djangoapps/class_dashboard/dashboard_data.py b/lms/djangoapps/class_dashboard/dashboard_data.py index 209d647faf..aa7eb206ba 100644 --- a/lms/djangoapps/class_dashboard/dashboard_data.py +++ b/lms/djangoapps/class_dashboard/dashboard_data.py @@ -2,6 +2,7 @@ Computes the data to display on the Instructor Dashboard """ from util.json_request import JsonResponse +import json from courseware import models from django.db.models import Count @@ -21,9 +22,12 @@ def get_problem_grade_distribution(course_id): `course_id` the course ID for the course interested in - Output is a dict, where the key is the problem 'module_id' and the value is a dict with: + Output is 2 dicts: + 'prob-grade_distrib' where the key is the problem 'module_id' and the value is a dict with: 'max_grade' - max grade for this problem 'grade_distrib' - array of tuples (`grade`,`count`). + 'total_student_count' where the key is problem 'module_id' and the value is number of students + attempting the problem """ # Aggregate query on studentmodule table for grade data for all problems in course @@ -34,6 +38,7 @@ def get_problem_grade_distribution(course_id): ).values('module_state_key', 'grade', 'max_grade').annotate(count_grade=Count('grade')) prob_grade_distrib = {} + total_student_count = {} # Loop through resultset building data for each problem for row in db_query: @@ -53,7 +58,10 @@ def get_problem_grade_distribution(course_id): 'grade_distrib': [(row['grade'], row['count_grade'])] } - return prob_grade_distrib + # Build set of total students attempting each problem + total_student_count[curr_problem] = total_student_count.get(curr_problem, 0) + row['count_grade'] + + return prob_grade_distrib, total_student_count def get_sequential_open_distrib(course_id): @@ -136,7 +144,7 @@ def get_d3_problem_grade_distrib(course_id): 'data' - data for the d3_stacked_bar_graph function of the grade distribution for that problem """ - prob_grade_distrib = get_problem_grade_distribution(course_id) + prob_grade_distrib, total_student_count = get_problem_grade_distribution(course_id) d3_data = [] # Retrieve course object down to problems @@ -178,19 +186,24 @@ def get_d3_problem_grade_distrib(course_id): for (grade, count_grade) in problem_info['grade_distrib']: percent = 0.0 if max_grade > 0: - percent = (grade * 100.0) / max_grade + percent = round((grade * 100.0) / max_grade, 1) - # Construct tooltip for problem in grade distibution view - tooltip = _("{label} {problem_name} - {count_grade} {students} ({percent:.0f}%: {grade:.0f}/{max_grade:.0f} {questions})").format( - label=label, - problem_name=problem_name, - count_grade=count_grade, - students=_("students"), - percent=percent, - grade=grade, - max_grade=max_grade, - questions=_("questions"), - ) + # Compute percent of students with this grade + student_count_percent = 0 + if total_student_count.get(child.location.url(), 0) > 0: + student_count_percent = count_grade * 100 / total_student_count[child.location.url()] + + # Tooltip parameters for problem in grade distribution view + tooltip = { + 'type': 'problem', + 'label': label, + 'problem_name': problem_name, + 'count_grade': count_grade, + 'percent': percent, + 'grade': grade, + 'max_grade': max_grade, + 'student_count_percent': student_count_percent, + } # Construct data to be sent to d3 stack_data.append({ @@ -246,11 +259,14 @@ def get_d3_sequential_open_distrib(course_id): num_students = sequential_open_distrib[subsection.location.url()] stack_data = [] - tooltip = _("{num_students} student(s) opened Subsection {subsection_num}: {subsection_name}").format( - num_students=num_students, - subsection_num=c_subsection, - subsection_name=subsection_name, - ) + + # Tooltip parameters for subsection in open_distribution view + tooltip = { + 'type': 'subsection', + 'num_students': num_students, + 'subsection_num': c_subsection, + 'subsection_name': subsection_name + } stack_data.append({ 'color': 0, @@ -329,19 +345,18 @@ def get_d3_section_grade_distrib(course_id, section): for (grade, count_grade) in grade_distrib[problem]['grade_distrib']: percent = 0.0 if max_grade > 0: - percent = (grade * 100.0) / max_grade + percent = round((grade * 100.0) / max_grade, 1) # Construct tooltip for problem in grade distibution view - tooltip = _("{problem_info_x} {problem_info_n} - {count_grade} {students} ({percent:.0f}%: {grade:.0f}/{max_grade:.0f} {questions})").format( - problem_info_x=problem_info[problem]['x_value'], - count_grade=count_grade, - students=_("students"), - percent=percent, - problem_info_n=problem_info[problem]['display_name'], - grade=grade, - max_grade=max_grade, - questions=_("questions"), - ) + tooltip = { + 'type': 'problem', + 'problem_info_x': problem_info[problem]['x_value'], + 'count_grade': count_grade, + 'percent': percent, + 'problem_info_n': problem_info[problem]['display_name'], + 'grade': grade, + 'max_grade': max_grade, + } stack_data.append({ 'color': percent, @@ -415,6 +430,7 @@ def get_students_opened_subsection(request, csv=False): 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') @@ -447,9 +463,11 @@ def get_students_opened_subsection(request, csv=False): return JsonResponse(response_payload) else: tooltip = request.GET.get('tooltip') - filename = sanitize_filename(tooltip[tooltip.index('S'):]) - header = ['Name', 'Username'] + # Subsection name is everything after 3rd space in tooltip + filename = sanitize_filename(' '.join(tooltip.split(' ')[3:])) + + header = [_("Name").encode('utf-8'), _("Username").encode('utf-8')] for student in students: results.append([student['student__profile__name'], student['student__username']]) @@ -507,7 +525,7 @@ def get_students_problem_grades(request, csv=False): tooltip = request.GET.get('tooltip') filename = sanitize_filename(tooltip[:tooltip.rfind(' - ')]) - header = ['Name', 'Username', 'Grade', 'Percent'] + header = [_("Name").encode('utf-8'), _("Username").encode('utf-8'), _("Grade").encode('utf-8'), _("Percent").encode('utf-8')] for student in students: percent = 0 @@ -519,11 +537,60 @@ def get_students_problem_grades(request, csv=False): return response +def post_metrics_data_csv(request): + """ + Generate a list of opened subsections or problems for the entire course for CSV download. + Returns a header array, and an array of arrays in the format: + section, subsection, count of students for subsections + or section, problem, name, count of students, percent of students, score for problems. + """ + + data = json.loads(request.POST['data']) + sections = json.loads(data['sections']) + tooltips = json.loads(data['tooltips']) + course_id = data['course_id'] + data_type = data['data_type'] + + results = [] + if data_type == 'subsection': + header = [_("Section").encode('utf-8'), _("Subsection").encode('utf-8'), _("Opened by this number of students").encode('utf-8')] + filename = sanitize_filename(_('subsections') + '_' + course_id) + elif data_type == 'problem': + header = [_("Section").encode('utf-8'), _("Problem").encode('utf-8'), _("Name").encode('utf-8'), _("Count of Students").encode('utf-8'), _("% of Students").encode('utf-8'), _("Score").encode('utf-8')] + filename = sanitize_filename(_('problems') + '_' + course_id) + + for index, section in enumerate(sections): + results.append([section]) + + # tooltips array is array of dicts for subsections and + # array of array of dicts for problems. + if data_type == 'subsection': + for tooltip_dict in tooltips[index]: + num_students = tooltip_dict['num_students'] + subsection = tooltip_dict['subsection_name'] + # Append to results offsetting 1 column to the right. + results.append(['', subsection, num_students]) + + elif data_type == 'problem': + for tooltip in tooltips[index]: + for tooltip_dict in tooltip: + label = tooltip_dict['label'] + problem_name = tooltip_dict['problem_name'] + count_grade = tooltip_dict['count_grade'] + student_count_percent = tooltip_dict['student_count_percent'] + percent = tooltip_dict['percent'] + # Append to results offsetting 1 column to the right. + results.append(['', label, problem_name, count_grade, student_count_percent, 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.encode('utf-8') filename = filename[0:25] + '.csv' return filename diff --git a/lms/djangoapps/class_dashboard/tests/test_dashboard_data.py b/lms/djangoapps/class_dashboard/tests/test_dashboard_data.py index a011ee6dce..5d20a8fa3c 100644 --- a/lms/djangoapps/class_dashboard/tests/test_dashboard_data.py +++ b/lms/djangoapps/class_dashboard/tests/test_dashboard_data.py @@ -95,12 +95,15 @@ class TestGetProblemGradeDistribution(ModuleStoreTestCase): def test_get_problem_grade_distribution(self): - prob_grade_distrib = get_problem_grade_distribution(self.course.id) + prob_grade_distrib, total_student_count = get_problem_grade_distribution(self.course.id) for problem in prob_grade_distrib: max_grade = prob_grade_distrib[problem]['max_grade'] self.assertEquals(1, max_grade) + for val in total_student_count.values(): + self.assertEquals(USER_COUNT, val) + def test_get_sequential_open_distibution(self): sequential_open_distrib = get_sequential_open_distrib(self.course.id) @@ -243,6 +246,61 @@ class TestGetProblemGradeDistribution(ModuleStoreTestCase): # Check response contains 1 line for each user +1 for the header self.assertEquals(USER_COUNT + 1, len(response.content.splitlines())) + def test_post_metrics_data_subsections_csv(self): + + url = reverse('post_metrics_data_csv') + + sections = json.dumps(["Introduction"]) + tooltips = json.dumps([[{"subsection_name": "Pre-Course Survey", "subsection_num": 1, "type": "subsection", "num_students": 18963}]]) + course_id = self.course.id + data_type = 'subsection' + + data = json.dumps({'sections': sections, + 'tooltips': tooltips, + 'course_id': course_id, + 'data_type': data_type, + }) + + response = self.client.post(url, {'data': data}) + # Check response contains 1 line for header, 1 line for Section and 1 line for Subsection + self.assertEquals(3, len(response.content.splitlines())) + + def test_post_metrics_data_problems_csv(self): + + url = reverse('post_metrics_data_csv') + + sections = json.dumps(["Introduction"]) + tooltips = json.dumps([[[ + {'student_count_percent': 0, + 'problem_name': 'Q1', + 'grade': 0, + 'percent': 0, + 'label': 'P1.2.1', + 'max_grade': 1, + 'count_grade': 26, + 'type': u'problem'}, + {'student_count_percent': 99, + 'problem_name': 'Q1', + 'grade': 1, + 'percent': 100, + 'label': 'P1.2.1', + 'max_grade': 1, + 'count_grade': 4763, + 'type': 'problem'}, + ]]]) + course_id = self.course.id + data_type = 'problem' + + data = json.dumps({'sections': sections, + 'tooltips': tooltips, + 'course_id': course_id, + 'data_type': data_type, + }) + + response = self.client.post(url, {'data': data}) + # Check response contains 1 line for header, 1 line for Sections and 2 lines for problems + self.assertEquals(4, 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/class_dashboard/urls.py b/lms/djangoapps/class_dashboard/urls.py new file mode 100644 index 0000000000..24198260e3 --- /dev/null +++ b/lms/djangoapps/class_dashboard/urls.py @@ -0,0 +1,30 @@ +""" +Class Dashboard API endpoint urls. +""" + +from django.conf.urls import patterns, url + +urlpatterns = patterns('', # nopep8 + # Json request data for metrics for entire course + url(r'^(?P[^/]+/[^/]+/[^/]+)/all_sequential_open_distrib$', + 'class_dashboard.views.all_sequential_open_distrib', name="all_sequential_open_distrib"), + + url(r'^(?P[^/]+/[^/]+/[^/]+)/all_problem_grade_distribution$', + 'class_dashboard.views.all_problem_grade_distribution', name="all_problem_grade_distribution"), + + # Json request data for metrics for particular section + url(r'^(?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"), + + # For generating metrics data as a csv + url(r'^post_metrics_data_csv_url', + 'class_dashboard.dashboard_data.post_metrics_data_csv', name="post_metrics_data_csv"), +) diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index a7c7bee453..9b0eeab55b 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -250,10 +250,12 @@ def _section_metrics(course_id, access): 'section_key': 'metrics', 'section_display_name': ('Metrics'), 'access': access, + 'course_id': course_id, 'sub_section_display_name': get_section_display_name(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'), + 'post_metrics_data_csv_url': reverse('post_metrics_data_csv'), } return section_data diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index 35a984c6e8..96959814f0 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -591,17 +591,16 @@ section.instructor-dashboard-content-2 { .instructor-dashboard-wrapper-2 section.idash-section#metrics { - .metrics-container { + .metrics-container, .metrics-header-container { position: relative; width: 100%; float: left; clear: both; margin-top: 25px; - - .metrics-left { + + .metrics-left, .metrics-left-header { position: relative; width: 30%; - height: 640px; float: left; margin-right: 2.5%; @@ -609,10 +608,13 @@ section.instructor-dashboard-content-2 { width: 100%; } } - .metrics-right { + .metrics-section.metrics-left { + height: 640px; + } + + .metrics-right, .metrics-right-header { position: relative; width: 65%; - height: 295px; float: left; margin-left: 2.5%; margin-bottom: 25px; @@ -622,6 +624,10 @@ section.instructor-dashboard-content-2 { } } + .metrics-section.metrics-right { + height: 295px; + } + svg { .stacked-bar { cursor: pointer; @@ -718,10 +724,6 @@ section.instructor-dashboard-content-2 { border-radius: 5px; margin-top: 25px; } - - input#graph_reload { - display: none; - } } } diff --git a/lms/templates/class_dashboard/all_section_metrics.js b/lms/templates/class_dashboard/all_section_metrics.js index fc417255c7..86651eaaf4 100644 --- a/lms/templates/class_dashboard/all_section_metrics.js +++ b/lms/templates/class_dashboard/all_section_metrics.js @@ -1,4 +1,4 @@ -<%page args="id_opened_prefix, id_grade_prefix, id_attempt_prefix, id_tooltip_prefix, course_id, **kwargs"/> +<%page args="id_opened_prefix, id_grade_prefix, id_attempt_prefix, id_tooltip_prefix, course_id, allSubsectionTooltipArr, allProblemTooltipArr, **kwargs"/> <%! import json from django.core.urlresolvers import reverse @@ -30,6 +30,13 @@ $(function () { margin: {left:0}, }; + // Construct array of tooltips for all sections for the "Download Subsection Data" button. + var sectionTooltipArr = new Array(); + paramOpened.data.forEach( function(element, index, array) { + sectionTooltipArr[index] = element.stackData[0].tooltip; + }); + allSubsectionTooltipArr[i] = sectionTooltipArr; + barGraphOpened = edx_d3CreateStackedBarGraph(paramOpened, d3.select(curr_id).append("svg"), d3.select("#${id_tooltip_prefix}"+i)); barGraphOpened.scale.stackColor.range(["#555555","#555555"]); @@ -68,6 +75,17 @@ $(function () { bVerticalXAxisLabel : true, }; + // Construct array of tooltips for all sections for the "Download Problem Data" button. + var sectionTooltipArr = new Array(); + paramGrade.data.forEach( function(element, index, array) { + var stackDataArr = new Array(); + for (var j = 0; j < element.stackData.length; j++) { + stackDataArr[j] = element.stackData[j].tooltip + } + sectionTooltipArr[index] = stackDataArr; + }); + allProblemTooltipArr[i] = sectionTooltipArr; + barGraphGrade = edx_d3CreateStackedBarGraph(paramGrade, d3.select(curr_id).append("svg"), d3.select("#${id_tooltip_prefix}"+i)); barGraphGrade.scale.stackColor.domain([0,50,100]).range(["#e13f29","#cccccc","#17a74d"]); @@ -83,6 +101,7 @@ $(function () { i+=1; } + }); }); \ No newline at end of file diff --git a/lms/templates/class_dashboard/d3_stacked_bar_graph.js b/lms/templates/class_dashboard/d3_stacked_bar_graph.js index 8552b3f48e..fd1bdb0f33 100644 --- a/lms/templates/class_dashboard/d3_stacked_bar_graph.js +++ b/lms/templates/class_dashboard/d3_stacked_bar_graph.js @@ -349,8 +349,20 @@ edx_d3CreateStackedBarGraph = function(parameters, svg, divTooltip) { var top = pos[1]-10; var width = $('#'+graph.divTooltip.attr("id")).width(); + // Construct the tooltip + if (d.tooltip['type'] == 'subsection') { + tooltip_str = d.tooltip['num_students'] + ' ' + gettext('student(s) opened Subsection') + ' ' \ + + d.tooltip['subsection_num'] + ': ' + d.tooltip['subsection_name'] + }else if (d.tooltip['type'] == 'problem') { + tooltip_str = d.tooltip['label'] + ' ' + d.tooltip['problem_name'] + ' - ' \ + + d.tooltip['count_grade'] + ' ' + gettext('students') + ' (' \ + + d.tooltip['student_count_percent'] + '%) (' + \ + + d.tooltip['percent'] + '%: ' + \ + + d.tooltip['grade'] +'/' + d.tooltip['max_grade'] + ' ' + + gettext('questions') + ')' + } graph.divTooltip.style("visibility", "visible") - .text(d.tooltip); + .text(tooltip_str); if ((left+width+30) > $("#"+graph.divTooltip.node().parentNode.id).width()) left -= (width+30); diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 00f12011e8..9bf61b34f1 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -725,7 +725,9 @@ function goto( mode) %endfor %endif diff --git a/lms/templates/instructor/instructor_dashboard_2/metrics.html b/lms/templates/instructor/instructor_dashboard_2/metrics.html index fbd07e5a19..535750ba15 100644 --- a/lms/templates/instructor/instructor_dashboard_2/metrics.html +++ b/lms/templates/instructor/instructor_dashboard_2/metrics.html @@ -1,4 +1,4 @@ -<%! from django.utils.translation import ugettext as _ %> + <%! from django.utils.translation import ugettext as _ %> <%page args="section_data"/> @@ -11,19 +11,35 @@ %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.")}

- +
+

${_("Use Reload Graphs to refresh the graphs.")}

+

+
+
+
+

${_("Subsection Data")}

+

${_("Each bar shows the number of students that opened the subsection.")}

+

${_("You can click on any of the bars to list the students that opened the subsection.")}

+

${_("You can also download this data as a CSV file.")}

+

+
+
+

${_("Grade Distribution Data")}

+

${_("Each bar shows the grade distribution for that problem.")}

+

${_("You can click on any of the bars to list the students that attempted the problem, along with the grades they received.")}

+

${_("You can also download this data as a CSV file.")}

+

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

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

+

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

-

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

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

@@ -46,11 +62,91 @@ diff --git a/lms/urls.py b/lms/urls.py index 96fab1b9d9..9a001dc490 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -378,23 +378,7 @@ if settings.COURSEWARE_ENABLED and settings.FEATURES.get('ENABLE_INSTRUCTOR_LEGA if settings.FEATURES.get('CLASS_DASHBOARD'): urlpatterns += ( - # Json request data for metrics for entire course - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/all_sequential_open_distrib$', - 'class_dashboard.views.all_sequential_open_distrib', name="all_sequential_open_distrib"), - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/all_problem_grade_distribution$', - 'class_dashboard.views.all_problem_grade_distribution', name="all_problem_grade_distribution"), - - # 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"), + url(r'^class_dashboard/', include('class_dashboard.urls')), ) if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'):