Merge pull request #23097 from edx/diana/remove-class-dashboard

Remove class_dashboard.
This commit is contained in:
Diana Huang
2020-02-19 10:57:12 -05:00
committed by GitHub
13 changed files with 0 additions and 2059 deletions

View File

@@ -1,3 +0,0 @@
"""
init.py file for class_dashboard
"""

View File

@@ -1,605 +0,0 @@
"""
Computes the data to display on the Instructor Dashboard
"""
import decimal
import json
from django.db.models import Count
from django.utils.translation import ugettext as _
from opaque_keys.edx.locator import BlockUsageLocator
from six import text_type
from lms.djangoapps.courseware import models
from lms.djangoapps.instructor_analytics.csvs import create_csv_response
from util.json_request import JsonResponse
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.inheritance import own_metadata
from openedx.core.lib.grade_utils import round_away_from_zero
# Used to limit the length of list displayed to the screen.
MAX_SCREEN_LIST_LENGTH = 250
def get_problem_grade_distribution(course_id):
"""
Returns the grade distribution per problem for the course
`course_id` the course ID for the course interested in
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
db_query = models.StudentModule.objects.filter(
course_id__exact=course_id,
grade__isnull=False,
module_type__exact="problem",
).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:
curr_problem = row['module_state_key'].map_into_course(course_id)
# Build set of grade distributions for each problem that has student responses
if curr_problem in prob_grade_distrib:
prob_grade_distrib[curr_problem]['grade_distrib'].append((row['grade'], row['count_grade']))
if (prob_grade_distrib[curr_problem]['max_grade'] != row['max_grade']) and \
(prob_grade_distrib[curr_problem]['max_grade'] < row['max_grade']):
prob_grade_distrib[curr_problem]['max_grade'] = row['max_grade']
else:
prob_grade_distrib[curr_problem] = {
'max_grade': row['max_grade'],
'grade_distrib': [(row['grade'], row['count_grade'])]
}
# 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):
"""
Returns the number of students that opened each subsection/sequential of the course
`course_id` the course ID for the course interested in
Outputs a dict mapping the 'module_id' to the number of students that have opened that subsection/sequential.
"""
# Aggregate query on studentmodule table for "opening a subsection" data
db_query = models.StudentModule.objects.filter(
course_id__exact=course_id,
module_type__exact="sequential",
).values('module_state_key').annotate(count_sequential=Count('module_state_key'))
# Build set of "opened" data for each subsection that has "opened" data
sequential_open_distrib = {}
for row in db_query:
row_loc = row['module_state_key'].map_into_course(course_id)
sequential_open_distrib[row_loc] = row['count_sequential']
return sequential_open_distrib
def get_problem_set_grade_distrib(course_id, problem_set):
"""
Returns the grade distribution for the problems specified in `problem_set`.
`course_id` the course ID for the course interested in
`problem_set` an array of UsageKeys representing problem module_id's.
Requests from the database the a count of each grade for each problem in the `problem_set`.
Returns a dict, where the key is the problem 'module_id' and the value is a dict with two parts:
'max_grade' - the maximum grade possible for the course
'grade_distrib' - array of tuples (`grade`,`count`) ordered by `grade`
"""
# Aggregate query on studentmodule table for grade data for set of problems in course
db_query = models.StudentModule.objects.filter(
course_id__exact=course_id,
grade__isnull=False,
module_type__exact="problem",
module_state_key__in=problem_set,
).values(
'module_state_key',
'grade',
'max_grade',
).annotate(count_grade=Count('grade')).order_by('module_state_key', 'grade')
prob_grade_distrib = {}
# Loop through resultset building data for each problem
for row in db_query:
row_loc = row['module_state_key'].map_into_course(course_id)
if row_loc not in prob_grade_distrib:
prob_grade_distrib[row_loc] = {
'max_grade': 0,
'grade_distrib': [],
}
curr_grade_distrib = prob_grade_distrib[row_loc]
curr_grade_distrib['grade_distrib'].append((row['grade'], row['count_grade']))
if curr_grade_distrib['max_grade'] < row['max_grade']:
curr_grade_distrib['max_grade'] = row['max_grade']
return prob_grade_distrib
def get_d3_problem_grade_distrib(course_id):
"""
Returns problem grade distribution information for each section, data already in format for d3 function.
`course_id` the course ID for the course interested in
Returns an array of dicts in the order of the sections. Each dict has:
'display_name' - display name for the section
'data' - data for the d3_stacked_bar_graph function of the grade distribution for that problem
"""
prob_grade_distrib, total_student_count = get_problem_grade_distribution(course_id)
d3_data = []
# Retrieve course object down to problems
course = modulestore().get_course(course_id, depth=4)
# Iterate through sections, subsections, units, problems
for section in course.get_children():
curr_section = {}
curr_section['display_name'] = own_metadata(section).get('display_name', '')
data = []
c_subsection = 0
for subsection in section.get_children():
c_subsection += 1
c_unit = 0
for unit in subsection.get_children():
c_unit += 1
c_problem = 0
for child in unit.get_children():
# Student data is at the problem level
if child.location.block_type == 'problem':
c_problem += 1
stack_data = []
# Construct label to display for this problem
label = "P{0}.{1}.{2}".format(c_subsection, c_unit, c_problem)
# Only problems in prob_grade_distrib have had a student submission.
if child.location in prob_grade_distrib:
# Get max_grade, grade_distribution for this problem
problem_info = prob_grade_distrib[child.location]
# Get problem_name for tooltip
problem_name = own_metadata(child).get('display_name', '')
# Compute percent of this grade over max_grade
max_grade = float(problem_info['max_grade'])
for (grade, count_grade) in problem_info['grade_distrib']:
percent = 0.0
if max_grade > 0:
percent = round_away_from_zero((grade * 100.0) / max_grade, 1)
# Compute percent of students with this grade
student_count_percent = 0
if total_student_count.get(child.location, 0) > 0:
student_count_percent = count_grade * 100 / total_student_count[child.location]
# 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({
'color': percent,
'value': count_grade,
'tooltip': tooltip,
'module_url': text_type(child.location),
})
problem = {
'xValue': label,
'stackData': stack_data,
}
data.append(problem)
curr_section['data'] = data
d3_data.append(curr_section)
return d3_data
def get_d3_sequential_open_distrib(course_id):
"""
Returns how many students opened a sequential/subsection for each section, data already in format for d3 function.
`course_id` the course ID for the course interested in
Returns an array in the order of the sections and each dict has:
'display_name' - display name for the section
'data' - data for the d3_stacked_bar_graph function of how many students opened each sequential/subsection
"""
sequential_open_distrib = get_sequential_open_distrib(course_id)
d3_data = []
# Retrieve course object down to subsection
course = modulestore().get_course(course_id, depth=2)
# Iterate through sections, subsections
for section in course.get_children():
curr_section = {}
curr_section['display_name'] = own_metadata(section).get('display_name', '')
data = []
c_subsection = 0
# Construct data for each subsection to be sent to d3
for subsection in section.get_children():
c_subsection += 1
subsection_name = own_metadata(subsection).get('display_name', '')
num_students = 0
if subsection.location in sequential_open_distrib:
num_students = sequential_open_distrib[subsection.location]
stack_data = []
# 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,
'value': num_students,
'tooltip': tooltip,
'module_url': text_type(subsection.location),
})
subsection = {
'xValue': u"SS {0}".format(c_subsection),
'stackData': stack_data,
}
data.append(subsection)
curr_section['data'] = data
d3_data.append(curr_section)
return d3_data
def get_d3_section_grade_distrib(course_id, section):
"""
Returns the grade distribution for the problems in the `section` section in a format for the d3 code.
`course_id` a string that is the course's ID.
`section` an int that is a zero-based index into the course's list of sections.
Navigates to the section specified to find all the problems associated with that section and then finds the grade
distribution for those problems. Finally returns an object formated the way the d3_stacked_bar_graph.js expects its
data object to be in.
If this is requested multiple times quickly for the same course, it is better to call
get_d3_problem_grade_distrib and pick out the sections of interest.
Returns an array of dicts with the following keys (taken from d3_stacked_bar_graph.js's documentation)
'xValue' - Corresponding value for the x-axis
'stackData' - Array of objects with key, value pairs that represent a bar:
'color' - Defines what "color" the bar will map to
'value' - Maps to the height of the bar, along the y-axis
'tooltip' - (Optional) Text to display on mouse hover
"""
# Retrieve course object down to problems
course = modulestore().get_course(course_id, depth=4)
problem_set = []
problem_info = {}
c_subsection = 0
for subsection in course.get_children()[section].get_children():
c_subsection += 1
c_unit = 0
for unit in subsection.get_children():
c_unit += 1
c_problem = 0
for child in unit.get_children():
if child.location.block_type == 'problem':
c_problem += 1
problem_set.append(child.location)
problem_info[child.location] = {
'id': text_type(child.location),
'x_value': "P{0}.{1}.{2}".format(c_subsection, c_unit, c_problem),
'display_name': own_metadata(child).get('display_name', ''),
}
# Retrieve grade distribution for these problems
grade_distrib = get_problem_set_grade_distrib(course_id, problem_set)
d3_data = []
# Construct data for each problem to be sent to d3
for problem in problem_set:
stack_data = []
if problem in grade_distrib: # Some problems have no data because students have not tried them yet.
max_grade = float(grade_distrib[problem]['max_grade'])
for (grade, count_grade) in grade_distrib[problem]['grade_distrib']:
percent = 0.0
if max_grade > 0:
percent = round_away_from_zero((grade * 100.0) / max_grade, 1)
# Construct tooltip for problem in grade distibution view
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,
'value': count_grade,
'tooltip': tooltip,
})
d3_data.append({
'xValue': problem_info[problem]['x_value'],
'stackData': stack_data,
})
return d3_data
def get_section_display_name(course_id):
"""
Returns an array of the display names for each section in the course.
`course_id` the course ID for the course interested in
The ith string in the array is the display name of the ith section in the course.
"""
course = modulestore().get_course(course_id, depth=4)
section_display_name = [""] * len(course.get_children())
i = 0
for section in course.get_children():
section_display_name[i] = own_metadata(section).get('display_name', '')
i += 1
return section_display_name
def get_array_section_has_problem(course_id):
"""
Returns an array of true/false whether each section has problems.
`course_id` the course ID for the course interested in
The ith value in the array is true if the ith section in the course contains problems and false otherwise.
"""
course = modulestore().get_course(course_id, depth=4)
b_section_has_problem = [False] * len(course.get_children())
i = 0
for section in course.get_children():
for subsection in section.get_children():
for unit in subsection.get_children():
for child in unit.get_children():
if child.location.block_type == 'problem':
b_section_has_problem[i] = True
break # out of child loop
if b_section_has_problem[i]:
break # out of unit loop
if b_section_has_problem[i]:
break # out of subsection loop
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_state_key = BlockUsageLocator.from_string(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_state_key,
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')
# Subsection name is everything after 3rd space in tooltip
filename = sanitize_filename(' '.join(tooltip.split(' ')[3:]))
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_state_key = BlockUsageLocator.from_string(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=module_state_key,
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_away_from_zero(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_away_from_zero((student['grade'] * 100 / student['max_grade']), 1)
results.append([student['student__profile__name'], student['student__username'], student['grade'], percent])
response = create_csv_response(filename, header, results)
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"), _("Subsection"), _("Opened by this number of students")]
filename = sanitize_filename(_('subsections') + '_' + course_id)
elif data_type == 'problem':
header = [
_("Section"), _("Problem"), _("Name"), _("Count of Students"),
_("Percent of Students"), _("Score"),
]
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('utf-8')
filename = filename[0:25].decode('utf-8') + '.csv'
return filename

View File

@@ -1,331 +0,0 @@
"""
Tests for class dashboard (Metrics tab in instructor dashboard)
"""
import json
from django.test.client import RequestFactory
from django.urls import reverse
from mock import patch
from six import text_type
from six.moves import range
from capa.tests.response_xml_factory import StringResponseXMLFactory
from class_dashboard.dashboard_data import (
get_array_section_has_problem,
get_d3_problem_grade_distrib,
get_d3_section_grade_distrib,
get_d3_sequential_open_distrib,
get_problem_grade_distribution,
get_problem_set_grade_distrib,
get_section_display_name,
get_sequential_open_distrib,
get_students_opened_subsection,
get_students_problem_grades
)
from class_dashboard.views import has_instructor_access_for_class
from lms.djangoapps.courseware.tests.factories import StudentModuleFactory
from student.tests.factories import AdminFactory, CourseEnrollmentFactory, UserFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
USER_COUNT = 11
class TestGetProblemGradeDistribution(SharedModuleStoreTestCase):
"""
Tests related to class_dashboard/dashboard_data.py
"""
@classmethod
def setUpClass(cls):
super(TestGetProblemGradeDistribution, cls).setUpClass()
cls.course = CourseFactory.create(
display_name=u"test course omega \u03a9",
)
with cls.store.bulk_operations(cls.course.id, emit_signals=False):
section = ItemFactory.create(
parent_location=cls.course.location,
category="chapter",
display_name=u"test factory section omega \u03a9",
)
cls.sub_section = ItemFactory.create(
parent_location=section.location,
category="sequential",
display_name=u"test subsection omega \u03a9",
)
cls.unit = ItemFactory.create(
parent_location=cls.sub_section.location,
category="vertical",
metadata={'graded': True, 'format': 'Homework'},
display_name=u"test unit omega \u03a9",
)
cls.items = []
for i in range(USER_COUNT - 1):
item = ItemFactory.create(
parent_location=cls.unit.location,
category="problem",
data=StringResponseXMLFactory().build_xml(answer='foo'),
metadata={'rerandomize': 'always'},
display_name=u"test problem omega \u03a9 " + str(i)
)
cls.items.append(item)
cls.item = item
def setUp(self):
super(TestGetProblemGradeDistribution, self).setUp()
self.request_factory = RequestFactory()
self.instructor = AdminFactory.create()
self.client.login(username=self.instructor.username, password='test')
self.attempts = 3
self.users = [
UserFactory.create(username="metric" + str(__))
for __ in range(USER_COUNT)
]
for user in self.users:
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
for i, item in enumerate(self.items):
for j, user in enumerate(self.users):
StudentModuleFactory.create(
grade=1 if i < j else 0,
max_grade=1 if i < j else 0.5,
student=user,
course_id=self.course.id,
module_state_key=item.location,
state=json.dumps({'attempts': self.attempts}),
)
for j, user in enumerate(self.users):
StudentModuleFactory.create(
course_id=self.course.id,
module_type='sequential',
module_state_key=item.location,
)
def test_get_problem_grade_distribution(self):
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.assertEqual(1, max_grade)
for val in total_student_count.values():
self.assertEqual(USER_COUNT, val)
def test_get_sequential_open_distibution(self):
sequential_open_distrib = get_sequential_open_distrib(self.course.id)
for problem in sequential_open_distrib:
num_students = sequential_open_distrib[problem]
self.assertEqual(USER_COUNT, num_students)
def test_get_problemset_grade_distrib(self):
prob_grade_distrib, __ = get_problem_grade_distribution(self.course.id)
probset_grade_distrib = get_problem_set_grade_distrib(self.course.id, prob_grade_distrib)
for problem in probset_grade_distrib:
max_grade = probset_grade_distrib[problem]['max_grade']
self.assertEqual(1, max_grade)
grade_distrib = probset_grade_distrib[problem]['grade_distrib']
sum_attempts = 0
for item in grade_distrib:
sum_attempts += item[1]
self.assertEqual(USER_COUNT, sum_attempts)
def test_get_d3_problem_grade_distrib(self):
d3_data = get_d3_problem_grade_distrib(self.course.id)
for data in d3_data:
for stack_data in data['data']:
sum_values = 0
for problem in stack_data['stackData']:
sum_values += problem['value']
self.assertEqual(USER_COUNT, sum_values)
def test_get_d3_sequential_open_distrib(self):
d3_data = get_d3_sequential_open_distrib(self.course.id)
for data in d3_data:
for stack_data in data['data']:
for problem in stack_data['stackData']:
value = problem['value']
self.assertEqual(0, value)
def test_get_d3_section_grade_distrib(self):
d3_data = get_d3_section_grade_distrib(self.course.id, 0)
for stack_data in d3_data:
sum_values = 0
for problem in stack_data['stackData']:
sum_values += problem['value']
self.assertEqual(USER_COUNT, sum_values)
def test_get_students_problem_grades(self):
attributes = '?module_id=' + text_type(self.item.location)
request = self.request_factory.get(reverse('get_students_problem_grades') + attributes)
response = get_students_problem_grades(request)
response_content = json.loads(response.content.decode('utf-8'))['results']
response_max_exceeded = json.loads(response.content.decode('utf-8'))['max_exceeded']
self.assertEqual(USER_COUNT, len(response_content))
self.assertEqual(False, response_max_exceeded)
for item in response_content:
if item['grade'] == 0:
self.assertEqual(0, item['percent'])
else:
self.assertEqual(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=' + text_type(self.item.location)
request = self.request_factory.get(reverse('get_students_problem_grades') + attributes)
response = get_students_problem_grades(request)
response_results = json.loads(response.content.decode('utf-8'))['results']
response_max_exceeded = json.loads(response.content.decode('utf-8'))['max_exceeded']
# Only 2 students in the list and response_max_exceeded is True
self.assertEqual(2, len(response_results))
self.assertEqual(True, response_max_exceeded)
def test_get_students_problem_grades_csv(self):
tooltip = u'P1.2.1 Q1 - 3382 Students (100%: 1/1 questions)'
attributes = '?module_id=' + text_type(self.item.location) + '&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=' + text_type(self.item.location)
request = self.request_factory.get(reverse('get_students_opened_subsection') + attributes)
response = get_students_opened_subsection(request)
response_results = json.loads(response.content.decode('utf-8'))['results']
response_max_exceeded = json.loads(response.content.decode('utf-8'))['max_exceeded']
self.assertEqual(USER_COUNT, len(response_results))
self.assertEqual(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=' + text_type(self.item.location)
request = self.request_factory.get(reverse('get_students_opened_subsection') + attributes)
response = get_students_opened_subsection(request)
response_results = json.loads(response.content.decode('utf-8'))['results']
response_max_exceeded = json.loads(response.content.decode('utf-8'))['max_exceeded']
# Only 2 students in the list and response_max_exceeded is True
self.assertEqual(2, len(response_results))
self.assertEqual(True, response_max_exceeded)
def test_get_students_opened_subsection_csv(self):
tooltip = '4162 students opened Subsection 5: Relational Algebra Exercises'
attributes = '?module_id=' + text_type(self.item.location) + '&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.assertEqual(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': text_type(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.assertEqual(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': text_type(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.assertEqual(4, len(response.content.splitlines()))
def test_get_section_display_name(self):
section_display_name = get_section_display_name(self.course.id)
self.assertMultiLineEqual(section_display_name[0], u"test factory section omega \u03a9")
def test_get_array_section_has_problem(self):
b_section_has_problem = get_array_section_has_problem(self.course.id)
self.assertEqual(b_section_has_problem[0], True)
def test_has_instructor_access_for_class(self):
"""
Test for instructor access
"""
ret_val = bool(has_instructor_access_for_class(self.instructor, self.course.id))
self.assertEqual(ret_val, True)

View File

@@ -1,113 +0,0 @@
"""
Tests for class dashboard (Metrics tab in instructor dashboard)
"""
import json
from django.test.client import RequestFactory
from mock import patch
from six import text_type
from class_dashboard import views
from student.tests.factories import AdminFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
class TestViews(ModuleStoreTestCase):
"""
Tests related to class_dashboard/views.py
"""
def setUp(self):
super(TestViews, self).setUp()
self.request_factory = RequestFactory()
self.request = self.request_factory.get('')
self.request.user = None
self.simple_data = {'error': 'error'}
@patch('class_dashboard.views.has_instructor_access_for_class')
def test_all_problem_grade_distribution_has_access(self, has_access):
"""
Test returns proper value when have proper access
"""
has_access.return_value = True
response = views.all_problem_grade_distribution(self.request, 'test/test/test')
self.assertEqual(json.dumps(self.simple_data), response.content.decode('utf-8'))
@patch('class_dashboard.views.has_instructor_access_for_class')
def test_all_problem_grade_distribution_no_access(self, has_access):
"""
Test for no access
"""
has_access.return_value = False
response = views.all_problem_grade_distribution(self.request, 'test/test/test')
self.assertEqual(
"{\"error\": \"Access Denied: User does not have access to this course\'s data\"}",
response.content.decode('utf-8')
)
@patch('class_dashboard.views.has_instructor_access_for_class')
def test_all_sequential_open_distribution_has_access(self, has_access):
"""
Test returns proper value when have proper access
"""
has_access.return_value = True
response = views.all_sequential_open_distrib(self.request, 'test/test/test')
self.assertEqual(json.dumps(self.simple_data), response.content.decode('utf-8'))
@patch('class_dashboard.views.has_instructor_access_for_class')
def test_all_sequential_open_distribution_no_access(self, has_access):
"""
Test for no access
"""
has_access.return_value = False
response = views.all_sequential_open_distrib(self.request, 'test/test/test')
self.assertEqual(
"{\"error\": \"Access Denied: User does not have access to this course\'s data\"}",
response.content.decode('utf-8')
)
@patch('class_dashboard.views.has_instructor_access_for_class')
def test_section_problem_grade_distribution_has_access(self, has_access):
"""
Test returns proper value when have proper access
"""
has_access.return_value = True
response = views.section_problem_grade_distrib(self.request, 'test/test/test', '1')
self.assertEqual(json.dumps(self.simple_data), response.content.decode('utf-8'))
@patch('class_dashboard.views.has_instructor_access_for_class')
def test_section_problem_grade_distribution_no_access(self, has_access):
"""
Test for no access
"""
has_access.return_value = False
response = views.section_problem_grade_distrib(self.request, 'test/test/test', '1')
self.assertEqual(
"{\"error\": \"Access Denied: User does not have access to this course\'s data\"}",
response.content.decode('utf-8')
)
def test_sending_deprecated_id(self):
course = CourseFactory.create()
instructor = AdminFactory.create()
self.request.user = instructor
response = views.all_sequential_open_distrib(self.request, text_type(course.id))
self.assertEqual('[]', response.content.decode('utf-8'))
response = views.all_problem_grade_distribution(self.request, text_type(course.id))
self.assertEqual('[]', response.content.decode('utf-8'))
response = views.section_problem_grade_distrib(self.request, text_type(course.id), 'no section')
self.assertEqual('{"error": "error"}', response.content.decode('utf-8'))

View File

@@ -1,37 +0,0 @@
"""
Class Dashboard API endpoint urls.
"""
from django.conf import settings
from django.conf.urls import url
import class_dashboard.dashboard_data
import class_dashboard.views
COURSE_ID_PATTERN = settings.COURSE_ID_PATTERN
urlpatterns = [
# Json request data for metrics for entire course
url(r'^{}/all_sequential_open_distrib$'.format(settings.COURSE_ID_PATTERN),
class_dashboard.views.all_sequential_open_distrib, name="all_sequential_open_distrib"),
url(r'^{}/all_problem_grade_distribution$'.format(settings.COURSE_ID_PATTERN),
class_dashboard.views.all_problem_grade_distribution, name="all_problem_grade_distribution"),
# Json request data for metrics for particular section
url(r'^{}/problem_grade_distribution/(?P<section>\d+)$'.format(settings.COURSE_ID_PATTERN),
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"),
]

View File

@@ -1,109 +0,0 @@
"""
Handles requests for data, returning a json
"""
import json
import logging
from django.http import HttpResponse
from opaque_keys.edx.keys import CourseKey
from class_dashboard import dashboard_data
from lms.djangoapps.courseware.access import has_access
from lms.djangoapps.courseware.courses import get_course_overview_with_access
log = logging.getLogger(__name__)
def has_instructor_access_for_class(user, course_id):
"""
Returns true if the `user` is an instructor for the course.
"""
course = get_course_overview_with_access(user, 'staff', course_id)
return bool(has_access(user, 'staff', course))
def all_sequential_open_distrib(request, course_id):
"""
Creates a json with the open distribution for all the subsections in the course.
`request` django request
`course_id` the course ID for the course interested in
Returns the format in dashboard_data.get_d3_sequential_open_distrib
"""
data = {}
# Only instructor for this particular course can request this information
course_key = CourseKey.from_string(course_id)
if has_instructor_access_for_class(request.user, course_key):
try:
data = dashboard_data.get_d3_sequential_open_distrib(course_key)
except Exception as ex: # pylint: disable=broad-except
log.error(u'Generating metrics failed with exception: %s', ex)
data = {'error': "error"}
else:
data = {'error': "Access Denied: User does not have access to this course's data"}
return HttpResponse(json.dumps(data), content_type="application/json")
def all_problem_grade_distribution(request, course_id):
"""
Creates a json with the grade distribution for all the problems in the course.
`Request` django request
`course_id` the course ID for the course interested in
Returns the format in dashboard_data.get_d3_problem_grade_distrib
"""
data = {}
# Only instructor for this particular course can request this information
course_key = CourseKey.from_string(course_id)
if has_instructor_access_for_class(request.user, course_key):
try:
data = dashboard_data.get_d3_problem_grade_distrib(course_key)
except Exception as ex: # pylint: disable=broad-except
log.error(u'Generating metrics failed with exception: %s', ex)
data = {'error': "error"}
else:
data = {'error': "Access Denied: User does not have access to this course's data"}
return HttpResponse(json.dumps(data), content_type="application/json")
def section_problem_grade_distrib(request, course_id, section):
"""
Creates a json with the grade distribution for the problems in the specified section.
`request` django request
`course_id` the course ID for the course interested in
`section` The zero-based index of the section for the course
Returns the format in dashboard_data.get_d3_section_grade_distrib
If this is requested multiple times quickly for the same course, it is better to call all_problem_grade_distribution
and pick out the sections of interest.
"""
data = {}
# Only instructor for this particular course can request this information
course_key = CourseKey.from_string(course_id)
if has_instructor_access_for_class(request.user, course_key):
try:
data = dashboard_data.get_d3_section_grade_distrib(course_key, section)
except Exception as ex: # pylint: disable=broad-except
log.error(u'Generating metrics failed with exception: %s', ex)
data = {'error': "error"}
else:
data = {'error': "Access Denied: User does not have access to this course's data"}
return HttpResponse(json.dumps(data), content_type="application/json")

View File

@@ -30,7 +30,6 @@ from xblock.field_data import DictFieldData
from xblock.fields import ScopeIds
from bulk_email.api import is_bulk_email_feature_enabled
from class_dashboard.dashboard_data import get_array_section_has_problem, get_section_display_name
from course_modes.models import CourseMode, CourseModesArchive
from edxmako.shortcuts import render_to_response
from lms.djangoapps.certificates import api as certs_api
@@ -174,10 +173,6 @@ def instructor_dashboard_2(request, course_id):
if is_bulk_email_feature_enabled(course_key):
sections.append(_section_send_email(course, access))
# Gate access to Metrics tab by featue flag and staff authorization
if settings.FEATURES['CLASS_DASHBOARD'] and access['staff']:
sections.append(_section_metrics(course, access))
# Gate access to Ecommerce tab
if course_mode_has_price and (access['finance_admin'] or access['sales_admin']):
sections.append(_section_e_commerce(course, access, paid_modes[0], is_white_label, reports_enabled))
@@ -798,23 +793,6 @@ def _section_analytics(course, access):
return section_data
def _section_metrics(course, access):
"""Provide data for the corresponding dashboard section """
course_key = course.id
section_data = {
'section_key': 'metrics',
'section_display_name': _('Metrics'),
'access': access,
'course_id': six.text_type(course_key),
'sub_section_display_name': get_section_display_name(course_key),
'section_has_problem': get_array_section_has_problem(course_key),
'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
def _section_open_response_assessment(request, course, openassessment_blocks, access):
"""Provide data for the corresponding dashboard section """
course_key = course.id

View File

@@ -2708,11 +2708,6 @@ VERIFICATION_EXPIRY_EMAIL = {
DISABLE_ACCOUNT_ACTIVATION_REQUIREMENT_SWITCH = "verify_student_disable_account_activation_requirement"
### This enables the Metrics tab for the Instructor dashboard ###########
FEATURES['CLASS_DASHBOARD'] = False
if FEATURES.get('CLASS_DASHBOARD'):
INSTALLED_APPS.append('class_dashboard')
################ Enable credit eligibility feature ####################
ENABLE_CREDIT_ELIGIBILITY = True
FEATURES['ENABLE_CREDIT_ELIGIBILITY'] = ENABLE_CREDIT_ELIGIBILITY

View File

@@ -1,108 +0,0 @@
<%page args="id_opened_prefix, id_grade_prefix, id_attempt_prefix, id_tooltip_prefix, course_id, allSubsectionTooltipArr, allProblemTooltipArr, **kwargs"/>
<%!
import json
from django.urls import reverse
from six import text_type
%>
$(function () {
d3.json("${reverse('all_sequential_open_distrib', kwargs=dict(course_id=text_type(course_id)))}", function(error, json) {
var section, paramOpened, barGraphOpened, error;
var i, curr_id;
var errorMessage = gettext('Unable to retrieve data, please try again later.');
error = json.error;
if (error) {
$('.metrics-left .loading').text(errorMessage);
return
}
i = 0;
for (section in json) {
curr_id = "#${id_opened_prefix}"+i;
paramOpened = {
data: json[section].data,
width: $(curr_id).width(),
height: $(curr_id).height()-25, // Account for header
tag: "opened"+i,
bVerticalXAxisLabel : true,
bLegend : false,
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"]);
if (paramOpened.data.length > 0) {
barGraphOpened.drawGraph();
$('svg').siblings('.loading').remove();
} else {
$('svg').siblings('.loading').text(errorMessage);
}
i+=1;
}
});
d3.json("${reverse('all_problem_grade_distribution', kwargs=dict(course_id=text_type(course_id)))}", function(error, json) {
var section, paramGrade, barGraphGrade, error;
var i, curr_id;
var errorMessage = gettext('Unable to retrieve data, please try again later.');
error = json.error;
if (error) {
$('.metrics-right .loading').text(errorMessage);
return
}
i = 0;
for (section in json) {
curr_id = "#${id_grade_prefix}"+i;
paramGrade = {
data: json[section].data,
width: $(curr_id).width(),
height: $(curr_id).height()-25, // Account for header
tag: "grade"+i,
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"]);
barGraphGrade.legend.width += 2;
if ( paramGrade.data.length > 0 ) {
barGraphGrade.drawGraph();
$('svg').siblings('.loading').remove();
} else {
$('svg').siblings('.loading').text(errorMessage);
}
i+=1;
}
});
});

View File

@@ -1,445 +0,0 @@
/*
There are three parameters:
(1) Parameter is of type object. Inside can include (* marks required):
data* - Array of objects with key, value pairs that represent a single stack of bars:
xValue - Corresponding value for the x-axis
stackData - Array of objects with key, value pairs that represent a bar:
color - Defines what "color" the bar will map to
value - Maps to the height of the bar, along the y-axis
tooltip - (Optional) Text to display on mouse hover
height - Height of the SVG the graph will be displayed in (default: 500)
width - Width of the SVG the graph will be displayed in (default: 500)
margin - Object with key, value pairs for the graph's margins within the SVG (default for all: 10)
top - Top margin
bottom - Bottom margin
right - Right margin
left - Left margin
yRange - Array of two values, representing the min and max respectively. (default: [0, <calculated max>])
xRange - Array of either the min and max or ordered ordinals (default: calculated min and max or ordered ordinals given in data)
colorRange - Array of either the min and max or ordered ordinals (default: calculated min and max or ordered ordinals given in data)
bVerticalXAxisLabel - Boolean whether to make the labels in the x-axis veritcal (default: false)
bLegend - Boolean if false does not create the graph with a legend (default: true)
(2) Parameter is a d3 pointer to the SVG the graph will draw itself in.
(3) Parameter is a d3 pointer to a div that will be used for the graph's tooltip.
****Does not actually draw graph.**** Returns an object that includes a function
drawGraph, for when ready to draw graph. Reason for this is, because of all
the defaults, some changes may be needed before drawing the graph
returns an object with the following:
state - All information that can be put in parameters and adding:
margin.axisX - margin to accomodate the x-axis
margin.axisY - margin to acommodate the y-axis
drawGraph - function to call when ready to draw graph
scale - Object containing three d3 scales
x - d3 scale for the x-axis
y - d3 scale for the y-axis
stackColor - d3 scale for the stack color
axis - Object containg the graph's two d3 axis
x - d3 axis for the x-axis
y - d3 axis for the y-axis
svg - d3 pointer to the svg holding the graph
svgGroup - object holding the svg groups
main - svg group holding all other groups
xAxis - svg group holding the x-axis
yAxis - svg group holding the x-axis
bars - svg groups holding the bars
yAxisLabel - d3 pointer to the text component that holds the y axis label
divTooltip - d3 pointer to the div that is used as the tooltip for the graph
rects - d3 collection of the rects used in the bars
legend - object containing information for the legend
height - height of the legend
width - width of the legend (if change, need to update state.margin.axisY also)
range - array of values that appears in the legend
barHeight - height of a bar in the legend, based on height and length of range
*/
edx_d3CreateStackedBarGraph = function(parameters, svg, divTooltip) {
var graph = {
svg : svg,
state : {
data : undefined,
height : 500,
width : 500,
margin: {top: 10, bottom: 10, right: 10, left: 10},
yRange: [0],
xRange : undefined,
colorRange : undefined,
tag : "",
bVerticalXAxisLabel : false,
bLegend : true,
},
divTooltip : divTooltip,
};
var state = graph.state;
// Handle parameters
state.data = parameters.data;
if (parameters.margin != undefined) {
for (var key in state.margin) {
if ((state.margin.hasOwnProperty(key) &&
(parameters.margin[key] != undefined))) {
state.margin[key] = parameters.margin[key];
}
}
}
for (var key in state) {
if ((key != "data") && (key != "margin")) {
if (state.hasOwnProperty(key) && (parameters[key] != undefined)) {
state[key] = parameters[key];
}
}
}
if (state.tag != "")
state.tag = state.tag+"-";
if ((state.xRange == undefined) || (state.yRange.length < 2 ||
state.colorRange == undefined)) {
var aryXRange = [];
var bXIsOrdinal = false;
var maxYRange = 0;
var aryColorRange = [];
var bColorIsOrdinal = false;
for (var stackKey in state.data) {
var stack = state.data[stackKey];
aryXRange.push(stack.xValue);
if (isNaN(stack.xValue))
bXIsOrdinal = true;
var valueTotal = 0;
for (var barKey in stack.stackData) {
var bar = stack.stackData[barKey];
valueTotal += bar.value;
if (isNaN(bar.color))
bColorIsOrdinal = true;
if (aryColorRange.indexOf(bar.color) < 0)
aryColorRange.push(bar.color);
}
if (maxYRange < valueTotal)
maxYRange = valueTotal;
}
if (state.xRange == undefined){
if (bXIsOrdinal)
state.xRange = aryXRange;
else
state.xRange = [
Math.min.apply(null,aryXRange),
Math.max.apply(null,aryXRange)
];
}
if (state.yRange.length < 2)
state.yRange[1] = maxYRange;
if (state.colorRange == undefined){
if (bColorIsOrdinal)
state.colorRange = aryColorRange;
else
state.colorRange = [
Math.min.apply(null,aryColorRange),
Math.max.apply(null,aryColorRange)
];
}
}
// Find needed spacing for axes
var tmpEl = graph.svg.append("text").text(state.yRange[1]+"1234")
.attr("id",state.tag+"stacked-bar-graph-long-str");
state.margin.axisY = document.getElementById(state.tag+"stacked-bar-graph-long-str")
.getComputedTextLength()+state.margin.left;
var longestXAxisStr = "";
if (isNaN(state.xRange[0])) {
for (var i in state.xRange) {
if (longestXAxisStr.length < state.xRange[i].length)
longestXAxisStr = state.xRange[i]+"1234";
}
} else {
longestXAxisStr = state.xRange[1]+"1234";
}
tmpEl.text(longestXAxisStr);
if (state.bVerticalXAxisLabel) {
state.margin.axisX = document.getElementById(state.tag+"stacked-bar-graph-long-str")
.getComputedTextLength()+state.margin.bottom;
} else {
state.margin.axisX = document.getElementById(state.tag+"stacked-bar-graph-long-str")
.clientHeight+state.margin.bottom;
}
tmpEl.remove();
// Add y0 and y1 of the y-axis based on the count and order of the colorRange.
// First, case if color is a number range
if ((state.colorRange.length == 2) && !(isNaN(state.colorRange[0])) &&
!(isNaN(state.colorRange[1]))) {
for (var stackKey in state.data) {
var stack = state.data[stackKey];
stack.stackData.sort(function(a,b) { return a.color - b.color; });
var currTotal = 0;
for (var barKey in stack.stackData) {
var bar = stack.stackData[barKey];
bar.y0 = currTotal;
currTotal += bar.value;
bar.y1 = currTotal;
}
}
} else {
for (var stackKey in state.data) {
var stack = state.data[stackKey];
var tmpStackData = [];
for (var barKey in stack.stackData) {
var bar = stack.stackData[barKey];
tmpStackData[state.colorRange.indexOf(bar.color)] = bar;
}
stack.stackData = tmpStackData;
var currTotal = 0;
for (var barKey in stack.stackData) {
var bar = stack.stackData[barKey];
bar.y0 = currTotal;
currTotal += bar.value;
bar.y1 = currTotal;
}
}
}
// Add information to create legend
if (state.bLegend) {
graph.legend = {
height : (state.height-state.margin.top-state.margin.axisX),
width : 30,
range : state.colorRange,
};
if ((state.colorRange.length == 2) && !(isNaN(state.colorRange[0])) &&
!(isNaN(state.colorRange[1]))) {
graph.legend.range = [];
var i = 0;
var min = state.colorRange[0];
var max = state.colorRange[1];
while (i <= 10) {
graph.legend.range[i] = min+((max-min)/10)*i;
i += 1;
}
}
graph.legend.barHeight = graph.legend.height/graph.legend.range.length;
// Shifting the axis over to make room
graph.state.margin.axisY += graph.legend.width;
}
// Make the scales
graph.scale = {
x: d3.scale.ordinal()
.domain(graph.state.xRange)
.rangeRoundBands([
(graph.state.margin.axisY),
(graph.state.width-graph.state.margin.right)],
.3),
y: d3.scale.linear()
.domain(graph.state.yRange) // yRange is the range of the y-axis values
.range([
(graph.state.height-graph.state.margin.axisX),
graph.state.margin.top
]),
stackColor: d3.scale.ordinal()
.domain(graph.state.colorRange)
.range(["#ffeeee","#ffebeb","#ffd8d8","#ffc4c4","#ffb1b1","#ff9d9d","#ff8989","#ff7676","#ff6262","#ff4e4e","#ff3b3b"])
};
if ((state.colorRange.length == 2) && !(isNaN(state.colorRange[0])) &&
!(isNaN(state.colorRange[1]))) {
graph.scale.stackColor = d3.scale.linear()
.domain(state.colorRange)
.range(["#e13f29","#17a74d"]);
}
// Setup axes
graph.axis = {
x: d3.svg.axis()
.scale(graph.scale.x),
y: d3.svg.axis()
.scale(graph.scale.y),
}
graph.axis.x.orient("bottom");
graph.axis.y.orient("left");
// Draw graph function, to call when ready.
graph.drawGraph = function() {
var graph = this;
// Steup SVG
graph.svg.attr("id", graph.state.tag+"stacked-bar-graph")
.attr("class", "stacked-bar-graph")
.attr("width", graph.state.width)
.attr("height", graph.state.height);
graph.svgGroup = {};
graph.svgGroup.main = graph.svg.append("g");
// Draw Bars
graph.svgGroup.bars = graph.svgGroup.main.selectAll(".stacked-bar")
.data(graph.state.data)
.enter().append("g")
.attr("class", "stacked-bar")
.attr("transform", function(d) {
return "translate("+graph.scale.x(d.xValue)+",0)";
});
graph.rects = graph.svgGroup.bars.selectAll("rect")
.data(function(d) { return d.stackData; })
.enter().append("rect")
.attr("width", function(d) {
return graph.scale.x.rangeBand()
})
.attr("y", function(d) { return graph.scale.y(d.y1); })
.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");
// Setup tooltip
if (graph.divTooltip != undefined) {
graph.divTooltip
.style("position", "absolute")
.style("z-index", "10")
.style("visibility", "hidden");
}
graph.rects
.on("mouseover", function(d) {
var pos = d3.mouse(graph.divTooltip.node().parentNode);
var left = pos[0]+10;
var top = pos[1]-10;
var width = $('#'+graph.divTooltip.attr("id")).width();
// Construct the tooltip
if (d.tooltip['type'] == 'subsection') {
stud_str = ngettext('%(num_students)s student opened Subsection', '%(num_students)s students opened Subsection', d.tooltip['num_students']);
stud_str = interpolate(stud_str, {'num_students': d.tooltip['num_students']}, true);
tooltip_str = stud_str + ' ' + d.tooltip['subsection_num'] + ': ' + d.tooltip['subsection_name'];
}else if (d.tooltip['type'] == 'problem') {
stud_str = ngettext('%(num_students)s student', '%(num_students)s students', d.tooltip['count_grade']);
stud_str = interpolate(stud_str, {'num_students': d.tooltip['count_grade']}, true);
q_str = ngettext('%(num_questions)s question', '%(num_questions)s questions', d.tooltip['max_grade']);
q_str = interpolate(q_str, {'num_questions': d.tooltip['max_grade']}, true);
tooltip_str = d.tooltip['label'] + ' ' + d.tooltip['problem_name'] + ' - ' \
+ stud_str + ' (' + d.tooltip['student_count_percent'] + '%) (' \
+ d.tooltip['percent'] + '%: ' + d.tooltip['grade'] +'/' \
+ q_str + ')';
}
graph.divTooltip.style("visibility", "visible")
.text(tooltip_str);
if ((left+width+30) > $("#"+graph.divTooltip.node().parentNode.id).width())
left -= (width+30);
graph.divTooltip.style("top", top+"px")
.style("left", left+"px");
})
.on("mouseout", function(d){
graph.divTooltip.style("visibility", "hidden")
});
// Add legend
if (graph.state.bLegend) {
graph.svgGroup.legendG = graph.svgGroup.main.append("g")
.attr("class","stacked-bar-graph-legend")
.attr("transform","translate("+graph.state.margin.left+","+
graph.state.margin.top+")");
graph.svgGroup.legendGs = graph.svgGroup.legendG.selectAll(".stacked-bar-graph-legend-g")
.data(graph.legend.range)
.enter().append("g")
.attr("class","stacked-bar-graph-legend-g")
.attr("id",function(d,i) { return graph.state.tag+"legend-"+i; })
.attr("transform", function(d,i) {
return "translate(0,"+
(graph.state.height-graph.state.margin.axisX-((i+1)*(graph.legend.barHeight))) + ")";
});
graph.svgGroup.legendGs.append("rect")
.attr("class","stacked-bar-graph-legend-rect")
.attr("height", graph.legend.barHeight)
.attr("width", graph.legend.width)
.style("fill", graph.scale.stackColor)
.style("stroke", "white");
graph.svgGroup.legendGs.append("text")
.attr("class","axis-label")
.attr("transform", function(d) {
var str = "translate("+(graph.legend.width/2)+","+
(graph.legend.barHeight/2)+")";
return str;
})
.attr("dy", ".35em")
.attr("dx", "-1px")
.style("text-anchor", "middle")
.text(function(d,i) { return d; });
}
// Draw Axes
graph.svgGroup.xAxis = graph.svgGroup.main.append("g")
.attr("class","stacked-bar-graph-axis")
.attr("id",graph.state.tag+"x-axis");
var tmpS = "translate(0,"+(graph.state.height-graph.state.margin.axisX)+")";
if (graph.state.bVerticalXAxisLabel) {
graph.axis.x.orient("left");
tmpS = "rotate(270), translate(-"+(graph.state.height-graph.state.margin.axisX)+",0)";
}
graph.svgGroup.xAxis.attr("transform", tmpS)
.call(graph.axis.x);
graph.svgGroup.yAxis = graph.svgGroup.main.append("g")
.attr("class","stacked-bar-graph-axis")
.attr("id",graph.state.tag+"y-axis")
.attr("transform","translate("+
(graph.state.margin.axisY)+",0)")
.call(graph.axis.y);
graph.yAxisLabel = graph.svgGroup.yAxis.append("text")
.attr("dy","1em")
.attr("transform","rotate(-90)")
.style("text-anchor","end")
.text(gettext("Number of Students"));
};
return graph;
};

View File

@@ -1,276 +0,0 @@
<%!
from django.utils.translation import ugettext as _
from django.template.defaultfilters import escapejs
%>
<%page args="section_data"/>
<script>
${d3_stacked_bar_graph.body()}
</script>
%if not any (section_data.values()):
<p>${_("There is no data available to display at this time.")}</p>
%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"/>
<div id="graph_reload">
<p>${_("Use Reload Graphs to refresh the graphs.")}</p>
<p><input type="button" value="${_("Reload Graphs")}"/></p>
</div>
<div class="metrics-header-container">
<div class="metrics-left-header">
<h3 class="hd hd-3">${_("Subsection Data")}</h3>
<p>${_("Each bar shows the number of students that opened the subsection.")}</p>
<p>${_("You can click on any of the bars to list the students that opened the subsection.")}</p>
<p>${_("You can also download this data as a CSV file.")}</p>
<p><input type="button" id="download_subsection_data" value="${_("Download Subsection Data for all Subsections as a CSV")}" /></p>
</div>
<div class="metrics-right-header">
<h3 class="hd hd-3">${_("Grade Distribution Data")}</h3>
<p>${_("Each bar shows the grade distribution for that problem.")}</p>
<p>${_("You can click on any of the bars to list the students that attempted the problem, along with the grades they received.")}</p>
<p>${_("You can also download this data as a CSV file.")}</p>
<p><input type="button" id="download_problem_data" value="${_("Download Problem Data for all Problems as a CSV")}" /></p>
</div>
</div>
<!-- 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-section metrics-left" id="metric_opened_${i}">
</div>
<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="#"><span class="icon fa fa-remove" aria-hidden="true"></span><span class="sr">${_("Close")}</span></a>
</div>
</div>
</div>
%endfor
<script>
$(function () {
var firstLoad = true;
var allSubsectionTooltipArr = new Array();
var allProblemTooltipArr = new Array();
// Click handler for left bars
$('.metrics-container').on("click", '.metrics-left .stacked-bar', 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>' + _.escape(value['name']) + "</td><td>" + _.escape(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();
});
// Click handler for right bars
$('.metrics-container').on("click", '.metrics-right .stacked-bar', 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(' - ');
var title = header.substring(0, far_index);
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>' + _.escape(value['name']) + "</td><td>" + _.escape(value['username']) + "</td><td>" + _.escape(value['grade']) + "</td><td>" + _.escape(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();
});
loadGraphs = function() {
$('#graph_reload').hide();
$('.metrics-header-container').hide();
$('.loading').remove();
var nothingText = "${_('There are no problems in this section.')}";
var loadingText = "${_('Loading')}";
var nothingP = '<p class="nothing">' + nothingText + '</p>';
var loading = '<p class="loading"><span class="icon fa fa-spinner fa-spin fa-large" aria-hidden="true"></span>' + loadingText + '</p>';
// Display spinners or "There are no problems in this section" message
$('.metrics-left').each(function() {
$(this).append(loading);
});
$('.metrics-right p.nothing').remove();
$('.metrics-right').each(function() {
if ($(this).data('section-has-problem') === "False") {
$(this).append(nothingP);
} else {
$(this).append(loading);
}
});
$('.metrics-left svg, .metrics-right svg').remove();
${all_section_metrics.body("metric_opened_", "metric_grade_", "metric_attempts_", "metric_tooltip_", course.id, allSubsectionTooltipArr, allProblemTooltipArr)}
}
// For downloading subsection and problem data as csv
download_csv_data = function(event) {
var allSectionArr = []
var allTooltipArr = []
if (event.type == 'subsection') {
allTooltipArr = allSubsectionTooltipArr;
} else if (event.type == 'problem') {
allTooltipArr = allProblemTooltipArr;
}
allTooltipArr.forEach( function(element, index, array) {
var metrics_section = 'metrics_section' + '_' + index
// Get Section heading which is everything after first ': '
var heading = $('#' + metrics_section).children('h2').text();
allSectionArr[index] = heading.substr(heading.indexOf(': ') +2)
});
var data = {}
data['sections'] = JSON.stringify(allSectionArr);
data['tooltips'] = JSON.stringify(allTooltipArr);
data['course_id'] = "${section_data['course_id'] | escapejs}";
data['data_type'] = event.type;
var input_data = document.createElement("input");
input_data.name = 'data';
input_data.value = JSON.stringify(data);
var csrf_token_input = document.createElement("input");
csrf_token_input.name = 'csrfmiddlewaretoken';
csrf_token_input.value = "${ csrf_token }"
// Send data as a POST so it doesn't create a huge url
var form = document.createElement("form");
form.action = "${section_data['post_metrics_data_csv_url']}";
form.method = 'post'
form.appendChild(input_data);
form.appendChild(csrf_token_input)
document.body.appendChild(form);
form.submit();
}
$('.instructor-nav a').click(function () {
if ($(this).data('section') === "metrics" && firstLoad) {
loadGraphs();
firstLoad = false;
$('#graph_reload').show();
$('.metrics-header-container').show();
}
});
$('#graph_reload').click(function () {
loadGraphs();
$('#graph_reload').show();
$('.metrics-header-container').show();
});
$('#download_subsection_data').click(function() {
download_csv_data({'type': 'subsection'});
});
$('#download_problem_data').click(function() {
download_csv_data({'type': 'problem'});
});
if (window.location.hash === "#view-metrics") {
$('.instructor-nav [data-section="metrics"]').click();
$('#graph_reload').hide();
$('.metrics-header-container').hide();
}
$(document).ajaxStop(function() {
$('#graph_reload').show();
$('.metrics-header-container').show();
});
});
$('.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 + '&csv=true' + '&tooltip=' + tooltip;
var url = $(this).data("endpoint");
url += attributes;
return location.href = url;
});
</script>
%endif

View File

@@ -771,11 +771,6 @@ if settings.FEATURES.get('ENABLE_STUDENT_HISTORY_VIEW'):
),
]
if settings.FEATURES.get('CLASS_DASHBOARD'):
urlpatterns += [
url(r'^class_dashboard/', include('class_dashboard.urls')),
]
if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'):
# Jasmine and admin
urlpatterns += [