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'):