Commit e054f6df by Kristin Stephens Committed by Jason Bau

Queryable student module app

Makes new tables that are queryable to get aggregate data on student per
problem information and student grades at three levels: overall course,
assignment type, and assignment.

Provides UI in instructor dashboard to view student metrics using
class_dashboard app.

Remove primary keys from queryable tables
    De-normalize user info of id, username and name into queryable tables.

Make all tests pass

* use default parameter	in get_or_create for non-unique-together
  values
* properly set module_state_key

Conflicts:
	lms/envs/test.py
parent 1b6e7f37
from mock import Mock, patch
import json
from django.test.utils import override_settings
from django.test import TestCase
from django.core import management
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from courseware.tests.factories import StudentModuleFactory
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from courseware.models import StudentModule
from capa.tests.response_xml_factory import StringResponseXMLFactory
from xmodule.modulestore import Location
from queryable_student_module.management.commands import populate_studentmoduleexpand
from xmodule.course_module import CourseDescriptor
from class_dashboard.dashboard_data import get_problem_grade_distribution, get_problem_attempt_distrib, get_sequential_open_distrib, \
get_last_populate, get_problem_set_grade_distribution, get_d3_problem_grade_distribution, \
get_d3_problem_attempt_distribution, get_d3_sequential_open_distribution, \
get_d3_section_grade_distribution, get_section_display_name, get_array_section_has_problem
USER_COUNT = 11
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestGetProblemGradeDistribution(ModuleStoreTestCase):
"""
Tests needed:
- simple test, make sure output correct
- test when a problem has two max_grade's, should just take the larger value
"""
def setUp(self):
self.command = 'populate_studentmoduleexpand'
self.script_id = "studentmoduleexpand"
self.attempts = 3
self.course = CourseFactory.create()
section = ItemFactory.create(
parent_location=self.course.location,
category="chapter",
display_name="test factory section",
)
sub_section = ItemFactory.create(
parent_location=section.location,
category="sequential",
# metadata={'graded': True, 'format': 'Homework'}
)
unit = ItemFactory.create(
parent_location=sub_section.location,
category="vertical",
metadata={'graded': True, 'format': 'Homework'}
)
self.users = [UserFactory.create() for _ in xrange(USER_COUNT)]
for user in self.users:
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
for i in xrange(USER_COUNT - 1):
category = "problem"
item = ItemFactory.create(
parent_location=unit.location,
category=category,
data=StringResponseXMLFactory().build_xml(answer='foo'),
metadata={'rerandomize': 'always'}
)
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=Location(item.location).url(),
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=Location(item.location).url(),
)
def test_get_problem_grade_distribution(self):
prob_grade_distrib = 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)
def test_get_problem_attempt_distribution(self):
# Call command
management.call_command(self.command, self.course.id)
prob_attempts_distrib = get_problem_attempt_distrib(self.course.id)
for problem in prob_attempts_distrib:
num_attempts = prob_attempts_distrib[problem][self.attempts -1]
self.assertEquals(USER_COUNT, num_attempts)
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.assertEquals(USER_COUNT, num_students)
def test_get_last_populate(self):
timestamp = get_last_populate(self.course.id, self.script_id)
self.assertEquals(timestamp, None)
management.call_command(self.command, self.course.id)
timestamp = get_last_populate(self.course.id, self.script_id)
self.assertNotEquals(timestamp, None)
def test_get_problemset_grade_distrib(self):
prob_grade_distrib = get_problem_grade_distribution(self.course.id)
probset_grade_distrib = get_problem_set_grade_distribution(self.course.id, prob_grade_distrib)
for problem in probset_grade_distrib:
max_grade = probset_grade_distrib[problem]['max_grade']
self.assertEquals(1, max_grade)
grade_distrib = probset_grade_distrib[problem]['grade_distrib']
sum_attempts = 0
for item in grade_distrib:
sum_attempts += item[1]
self.assertEquals(USER_COUNT, sum_attempts)
# @patch('class_dashboard.dashboard_data.get_problem_grade_distribution')
def test_get_d3_problem_grade_distrib(self): #, mock_get_data):
d3_data = get_d3_problem_grade_distribution(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.assertEquals(USER_COUNT, sum_values)
def test_get_d3_problem_attempt_distrib(self):
# Call command
management.call_command(self.command, self.course.id)
d3_data = get_d3_problem_attempt_distribution(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.assertEquals(USER_COUNT, sum_values)
def test_get_d3_sequential_open_distrib(self):
d3_data = get_d3_sequential_open_distribution(self.course.id)
for data in d3_data:
for stack_data in data['data']:
for problem in stack_data['stackData']:
value = problem['value']
self.assertEquals(0, value)
def test_get_d3_section_grade_distrib(self):
d3_data = get_d3_section_grade_distribution(self.course.id, 0)
for stack_data in d3_data:
sum_values = 0
for problem in stack_data['stackData']:
sum_values += problem['value']
self.assertEquals(USER_COUNT, sum_values)
def test_get_section_display_name(self):
section_display_name = get_section_display_name(self.course.id)
self.assertMultiLineEqual(section_display_name[0], 'test factory section')
def test_get_array_section_has_problem(self):
b_section_has_problem = get_array_section_has_problem(self.course.id)
print b_section_has_problem
self.assertEquals(b_section_has_problem[0], True)
from mock import Mock, patch
from django.test import TestCase
from django.test.client import RequestFactory
from django.utils import simplejson
from class_dashboard import views
class TestViews(TestCase):
def setUp(self):
self.request_factory = RequestFactory()
self.request = self.request_factory.get('')
self.request.user = None
self.simple_data = {'test': 'test'}
@patch('class_dashboard.dashboard_data.get_d3_problem_attempt_distribution')
@patch('class_dashboard.views.has_instructor_access_for_class')
def test_all_problem_attempt_distribution_has_access(self, has_access, data_method):
"""
Test returns proper value when have proper access
"""
has_access.return_value = True
data_method.return_value = self.simple_data
response = views.all_problem_attempt_distribution(self.request, 'test/test/test')
self.assertEqual(simplejson.dumps(self.simple_data), response.content)
@patch('class_dashboard.dashboard_data.get_d3_problem_grade_distribution')
@patch('class_dashboard.views.has_instructor_access_for_class')
def test_all_problem_grade_distribution_has_access(self, has_access, data_method):
"""
Test returns proper value when have proper access
"""
has_access.return_value = True
data_method.return_value = self.simple_data
response = views.all_problem_grade_distribution(self.request, 'test/test/test')
self.assertEqual(simplejson.dumps(self.simple_data), response.content)
@patch('class_dashboard.dashboard_data.get_d3_sequential_open_distribution')
@patch('class_dashboard.views.has_instructor_access_for_class')
def test_all_sequential_open_distribution_has_access(self, has_access, data_method):
"""
Test returns proper value when have proper access
"""
has_access.return_value = True
data_method.return_value = self.simple_data
response = views.all_sequential_open_distribution(self.request, 'test/test/test')
self.assertEqual(simplejson.dumps(self.simple_data), response.content)
@patch('class_dashboard.dashboard_data.get_d3_section_grade_distribution')
@patch('class_dashboard.views.has_instructor_access_for_class')
def test_section_problem_grade_distribution_has_access(self, has_access, data_method):
"""
Test returns proper value when have proper access
"""
has_access.return_value = True
data_method.return_value = self.simple_data
response = views.section_problem_grade_distribution(self.request, 'test/test/test', '1')
self.assertEqual(simplejson.dumps(self.simple_data), response.content)
"""
Handles requests for data, returning a json
"""
from django.utils import simplejson
from django.http import HttpResponse
from courseware.courses import get_course_with_access
from courseware.access import has_access
from class_dashboard import dashboard_data
def has_instructor_access_for_class(user, course_id):
"""
Returns true if the `user` is an instructor for the course.
"""
course = get_course_with_access(user, course_id, 'staff', depth=None)
return has_access(user, course, 'instructor')
def all_problem_attempt_distribution(request, course_id):
"""
Creates a json with the attempt 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_attempt_distribution
"""
json = {}
# Only instructor for this particular course can request this information
if has_instructor_access_for_class(request.user, course_id):
json = dashboard_data.get_d3_problem_attempt_distribution(course_id)
else:
json = {'error': "Access Denied: User does not have access to this course's data"}
return HttpResponse(simplejson.dumps(json), mimetype="application/json")
def all_sequential_open_distribution(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_distribution
"""
json = {}
# Only instructor for this particular course can request this information
if has_instructor_access_for_class(request.user, course_id):
json = dashboard_data.get_d3_sequential_open_distribution(course_id)
else:
json = {'error': "Access Denied: User does not have access to this course's data"}
return HttpResponse(simplejson.dumps(json), mimetype="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_distribution
"""
json = {}
# Only instructor for this particular course can request this information
if has_instructor_access_for_class(request.user, course_id):
json = dashboard_data.get_d3_problem_grade_distribution(course_id)
else:
json = {'error': "Access Denied: User does not have access to this course's data"}
return HttpResponse(simplejson.dumps(json), mimetype="application/json")
def section_problem_grade_distribution(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_distribution.
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.
"""
json = {}
# Only instructor for this particular course can request this information
if has_instructor_access_for_class(request.user, course_id):
json = dashboard_data.get_d3_section_grade_distribution(course_id, int(section))
else:
json = {'error': "Access Denied: User does not have access to this course's data"}
return HttpResponse(simplejson.dumps(json), mimetype="application/json")
...@@ -47,6 +47,7 @@ from instructor_task.api import (get_running_instructor_tasks, ...@@ -47,6 +47,7 @@ from instructor_task.api import (get_running_instructor_tasks,
submit_rescore_problem_for_student, submit_rescore_problem_for_student,
submit_reset_problem_attempts_for_all_students) submit_reset_problem_attempts_for_all_students)
from instructor_task.views import get_task_completion_info from instructor_task.views import get_task_completion_info
from class_dashboard import dashboard_data
from mitxmako.shortcuts import render_to_response from mitxmako.shortcuts import render_to_response
from psychometrics import psychoanalyze from psychometrics import psychoanalyze
from student.models import CourseEnrollment, CourseEnrollmentAllowed from student.models import CourseEnrollment, CourseEnrollmentAllowed
...@@ -779,6 +780,15 @@ def instructor_dashboard(request, course_id): ...@@ -779,6 +780,15 @@ def instructor_dashboard(request, course_id):
analytics_results[analytic_name] = get_analytics_result(analytic_name) analytics_results[analytic_name] = get_analytics_result(analytic_name)
#---------------------------------------- #----------------------------------------
# Metrics
metrics_results = {}
if settings.MITX_FEATURES.get('CLASS_DASHBOARD') and idash_mode == 'Metrics':
metrics_results['attempts_timestamp'] = dashboard_data.get_last_populate(course_id, "studentmoduleexpand")
metrics_results['section_display_name'] = dashboard_data.get_section_display_name(course_id)
metrics_results['section_has_problem'] = dashboard_data.get_array_section_has_problem(course_id)
#----------------------------------------
# offline grades? # offline grades?
if use_offline: if use_offline:
...@@ -842,6 +852,7 @@ def instructor_dashboard(request, course_id): ...@@ -842,6 +852,7 @@ def instructor_dashboard(request, course_id):
'cohorts_ajax_url': reverse('cohorts', kwargs={'course_id': course_id}), 'cohorts_ajax_url': reverse('cohorts', kwargs={'course_id': course_id}),
'analytics_results': analytics_results, 'analytics_results': analytics_results,
'metrics_results': metrics_results,
} }
if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BETA_DASHBOARD'): if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BETA_DASHBOARD'):
......
"""
queryable app allows 2 commands to be run from rake:
populate_studentgrade
populate_studentmoduleexpand
These commands populate table in the SQL database and allow for the class_dashboard app
to render course metrics under the instructor dashboard.
"""
"""
Commands for queryable_student_module app:
populate_studentgrades
populate_studentmoduleexpand
"""
"""
Commands for queryable app:
populate_studentgrades
populate_studentmoduleexpand
"""
"""
======== Populate StudentModuleExpand ===============================================================================
Populates the StudentModuleExpand table of the queryable_table model.
For the provided course_id, it will find all rows in the StudentModule table of the courseware model that have
module_type 'problem' and the grade is not null. Then for any rows that have changed since the last populate or do not
have a corresponding row, update the attempts value.
"""
import json
from django.core.management.base import BaseCommand
from courseware.models import StudentModule
from queryable_student_module.models import Log, StudentModuleExpand
from queryable_student_module.util import pre_run_command, more_options
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.inheritance import own_metadata
class Command(BaseCommand):
"""
populate_studentmoduleexpand command
"""
help = "Populates the queryable.StudentModuleExpand table.\n"
help += "Usage: populate_studentmoduleexpand course_id\n"
help += " course_id: course's ID, such as Medicine/HRP258/Statistics_in_Medicine\n"
option_list = BaseCommand.option_list + (more_options(),)
def handle(self, *args, **options):
script_id = "studentmoduleexpand"
print "args = ", args
if len(args) > 0:
course_id = args[0]
else:
print self.help
return
iterative_populate, tstart, last_log_run = pre_run_command(script_id, options, course_id)
# If iterative populate, get all the problems that students have submitted an answer to for this course,
# since the last run
if iterative_populate:
sm_rows = StudentModule.objects.select_related('student', 'student__profile').filter(course_id__exact=course_id, grade__isnull=False,
module_type__exact="problem", modified__gte=last_log_run[0].created)
else:
sm_rows = StudentModule.objects.select_related('student', 'student__profile').filter(course_id__exact=course_id, grade__isnull=False,
module_type__exact="problem")
c_updated_rows = 0
# For each problem, get or create the corresponding StudentModuleExpand row
for sm_row in sm_rows:
# Get the display name for the problem
module_state_key = sm_row.module_state_key
problem = modulestore().get_instance(course_id, module_state_key, 0)
problem_name = own_metadata(problem)['display_name']
sme, created = StudentModuleExpand.objects.get_or_create(student_id=sm_row.student_id, course_id=course_id,
module_state_key=module_state_key,
student_module_id=sm_row.id)
# If the StudentModuleExpand row is new or the StudentModule row was
# more recently updated than the StudentModuleExpand row, fill in/update
# everything and save
if created or (sme.modified < sm_row.modified):
c_updated_rows += 1
sme.grade = sm_row.grade
sme.max_grade = sm_row.max_grade
state = json.loads(sm_row.state)
sme.attempts = state["attempts"]
sme.label = problem_name
sme.username = sm_row.student.username
sme.name = sm_row.student.profile.name
sme.save()
c_all_rows = len(sm_rows)
print "--------------------------------------------------------------------------------"
print "Done! Updated/Created {0} queryable rows out of {1} from courseware_studentmodule".format(
c_updated_rows, c_all_rows)
print "--------------------------------------------------------------------------------"
# Save since everything finished successfully, log latest run.
q_log = Log(script_id=script_id, course_id=course_id, created=tstart)
q_log.save()
"""
model file for queryable app
"""
from django.contrib.auth.models import User
from django.db import models
from courseware.models import StudentModule
class StudentModuleExpand(models.Model):
"""
Expanded version of courseware's model StudentModule. This is only for
instances of module type 'problem'. Adds attribute 'attempts' that is pulled
out of the json in the state attribute.
"""
EXPAND_TYPES = {'problem'}
student_module_id = models.IntegerField(blank=True, null=True, db_index=True)
# The value mapped to 'attempts' in the json in state
attempts = models.IntegerField(null=True, blank=True, db_index=True)
# Values from StudentModule
module_type = models.CharField(max_length=32, default='problem', db_index=True)
module_state_key = models.CharField(max_length=255, db_index=True, db_column='module_id')
course_id = models.CharField(max_length=255, db_index=True)
label = models.CharField(max_length=50, null=True, blank=True)
student_id = models.IntegerField(blank=True, null=True, db_index=True)
username = models.CharField(max_length=30, blank=True, null=True, db_index=True)
name = models.CharField(max_length=255, blank=True, null=True, db_index=True)
class Meta:
"""
Meta definitions
"""
db_table = "queryable_studentmoduleexpand"
unique_together = (('student_id', 'module_state_key', 'course_id'),)
grade = models.FloatField(null=True, blank=True, db_index=True)
max_grade = models.FloatField(null=True, blank=True)
created = models.DateTimeField(auto_now_add=True, db_index=True)
modified = models.DateTimeField(auto_now=True, db_index=True)
class CourseGrade(models.Model):
"""
Holds student's overall course grade as a percentage and letter grade (if letter grade present).
"""
course_id = models.CharField(max_length=255, db_index=True)
percent = models.FloatField(db_index=True, null=True)
grade = models.CharField(max_length=32, db_index=True, null=True)
user_id = models.IntegerField(blank=True, null=True, db_index=True)
username = models.CharField(max_length=30, blank=True, null=True, db_index=True)
name = models.CharField(max_length=255, blank=True, null=True, db_index=True)
class Meta:
"""
Meta definitions
"""
db_table = "queryable_coursegrade"
unique_together = (('user_id', 'course_id'), )
created = models.DateTimeField(auto_now_add=True, db_index=True)
updated = models.DateTimeField(auto_now=True, db_index=True)
class AssignmentTypeGrade(models.Model):
"""
Holds student's average grade for each assignment type per course.
"""
course_id = models.CharField(max_length=255, db_index=True)
category = models.CharField(max_length=255, db_index=True)
percent = models.FloatField(db_index=True, null=True)
user_id = models.IntegerField(blank=True, null=True, db_index=True)
username = models.CharField(max_length=30, blank=True, null=True, db_index=True)
name = models.CharField(max_length=255, blank=True, null=True, db_index=True)
class Meta:
"""
Meta definitions
"""
db_table = "queryable_assignmenttypegrade"
unique_together = (('user_id', 'course_id', 'category'), )
created = models.DateTimeField(auto_now_add=True, db_index=True)
updated = models.DateTimeField(auto_now=True, db_index=True)
class AssignmentGrade(models.Model):
"""
Holds student's assignment grades per course.
"""
course_id = models.CharField(max_length=255, db_index=True)
category = models.CharField(max_length=255, db_index=True)
percent = models.FloatField(db_index=True, null=True)
label = models.CharField(max_length=32, db_index=True)
detail = models.CharField(max_length=255, blank=True, null=True)
user_id = models.IntegerField(blank=True, null=True, db_index=True)
username = models.CharField(max_length=30, blank=True, null=True, db_index=True)
name = models.CharField(max_length=255, blank=True, null=True, db_index=True)
class Meta:
"""
Meta definitions
"""
db_table = "queryable_assignmentgrade"
unique_together = (('user_id', 'course_id', 'label'), )
created = models.DateTimeField(auto_now_add=True, db_index=True)
updated = models.DateTimeField(auto_now=True, db_index=True)
class Log(models.Model):
"""
Log of when a script in this django app was last run. Use to filter out students or rows that don't need to be
processed in the populate scripts and show instructors how fresh the data is.
"""
script_id = models.CharField(max_length=255, db_index=True)
course_id = models.CharField(max_length=255, db_index=True)
created = models.DateTimeField(null=True, db_index=True)
class Meta:
"""
Meta definitions
"""
db_table = "queryable_log"
ordering = ["-created"]
get_latest_by = "created"
import json
from datetime import datetime
from pytz import UTC
from StringIO import StringIO
from django.test import TestCase
from django.test.utils import override_settings
from django.core import management
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from courseware.tests.factories import StudentModuleFactory
from queryable_student_module.models import Log, StudentModuleExpand
from capa.tests.response_xml_factory import StringResponseXMLFactory
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestPopulateStudentModuleExpand(TestCase):
def setUp(self):
self.command = 'populate_studentmoduleexpand'
self.script_id = "studentmoduleexpand"
#self.course_id = 'test/test/test'
self.course = CourseFactory.create()
section = ItemFactory.create(
parent_location=self.course.location,
category="chapter",
display_name="test factory section",
)
sub_section = ItemFactory.create(
parent_location=section.location,
category="sequential",
# metadata={'graded': True, 'format': 'Homework'}
)
unit = ItemFactory.create(
parent_location=sub_section.location,
category="vertical",
metadata={'graded': True, 'format': 'Homework'}
)
category = "problem"
self.item = ItemFactory.create(
parent_location=unit.location,
category=category,
data=StringResponseXMLFactory().build_xml(answer='foo'),
metadata={'rerandomize': 'always'}
)
self.item2 = ItemFactory.create(
parent_location=unit.location,
category=category,
data=StringResponseXMLFactory().build_xml(answer='foo'),
metadata={'rerandomize': 'always'}
)
def test_missing_input(self):
"""
Fails safely when not given enough input
"""
try:
management.call_command(self.command)
self.assertTrue(True)
except:
self.assertTrue(False)
def test_just_logs_if_empty_course(self):
"""
If the course has nothing in it, just logs the run in the log table
"""
management.call_command(self.command, self.course.id)
self.assertEqual(len(Log.objects.filter(script_id__exact=self.script_id, course_id__exact=self.course.id)), 1)
self.assertEqual(len(StudentModuleExpand.objects.filter(course_id__exact=self.course.id)), 0)
def test_force_update(self):
"""
Even if there is a log entry for incremental update, force a full update
This may be done because something happened in the last update.
"""
# Create a StudentModule that is before the log entry
sm = StudentModuleFactory(
course_id=self.course.id,
module_type='problem',
grade=1,
max_grade=1,
state=json.dumps({'attempts': 1}),
module_state_key=self.item.location
)
# Create the log entry
log = Log(script_id=self.script_id, course_id=self.course.id, created=datetime.now(UTC))
log.save()
# Create a StudentModuleExpand that is after the log entry and has a different attempts value
sme = StudentModuleExpand(
course_id=self.course.id,
module_state_key=sm.module_state_key,
student_module_id=sm.id,
attempts=0,
)
# Call command with the -f flag
management.call_command(self.command, self.course.id, force=True)
# Check to see if new rows have been added
self.assertEqual(len(Log.objects.filter(script_id__exact=self.script_id, course_id__exact=self.course.id)), 2)
self.assertEqual(len(StudentModuleExpand.objects.filter(course_id__exact=self.course.id)), 1)
self.assertEqual(StudentModuleExpand.objects.filter(course_id__exact=self.course.id)[0].attempts, 1)
def test_incremental_update_if_log_exists(self):
"""
Make sure it uses the log entry if it exists and we aren't forcing a full update
"""
# Create a StudentModule that is before the log entry
sm = StudentModuleFactory(
course_id=self.course.id,
module_type='problem',
grade=1,
max_grade=1,
state=json.dumps({'attempts': 1}),
module_state_key=self.item.location
)
# Create the log entry
log = Log(script_id=self.script_id, course_id=self.course.id, created=datetime.now(UTC))
log.save()
# Create a StudentModule that is after the log entry
sm = StudentModuleFactory(
course_id=self.course.id,
module_type='problem',
grade=1,
max_grade=1,
state=json.dumps({'attempts': 1}),
module_state_key=self.item.location
)
# Call command
management.call_command(self.command, self.course.id)
# Check to see if new row has been added to log
self.assertEqual(len(Log.objects.filter(script_id__exact=self.script_id, course_id__exact=self.course.id)), 2)
# Even though there are two studentmodules only one row should be created
self.assertEqual(len(StudentModuleExpand.objects.filter(course_id__exact=self.course.id)), 1)
def test_update_only_if_row_modified(self):
"""
Test populate does not update a row if it is not necessary
For example the problem may have a more recent modified date but the attempts value has not changed.
"""
self.assertEqual(len(StudentModuleExpand.objects.filter(course_id__exact=self.course.id)), 0)
# Create a StudentModule
sm1 = StudentModuleFactory(
course_id=self.course.id,
module_type='problem',
grade=1,
max_grade=1,
module_state_key=self.item.location
)
# Create a StudentModuleExpand
sme1 = StudentModuleExpand(
course_id=self.course.id,
student_module_id=sm1.id,
module_state_key=sm1.module_state_key,
student_id=sm1.student.id,
attempts=0,
)
sme1.save()
# Touch the StudentModule row so it has a later modified time
sm1.state = json.dumps({'attempts': 1})
sm1.save()
# Create a StudentModule
sm2 = StudentModuleFactory(
course_id=self.course.id,
module_type='problem',
module_state_key=self.item2.location,
grade=1,
max_grade=1,
state=json.dumps({'attempts': 2}),
)
# Create a StudentModuleExpand that has the same attempts value
sme2 = StudentModuleExpand(
course_id=self.course.id,
student_module_id=sm2.id,
module_state_key=sm2.module_state_key,
student_id=sm2.student.id,
attempts=2,
)
sme2.save()
self.assertEqual(len(StudentModuleExpand.objects.filter(course_id__exact=self.course.id)), 2)
# Call command
management.call_command(self.command, self.course.id)
self.assertEqual(len(StudentModuleExpand.objects.filter(
course_id__exact=self.course.id, module_state_key__exact=sme1.module_state_key)), 1)
self.assertEqual(len(StudentModuleExpand.objects.filter(
course_id__exact=self.course.id, module_state_key__exact=sme2.module_state_key)), 1)
self.assertEqual(StudentModuleExpand.objects.filter(
course_id__exact=self.course.id, module_state_key__exact=sme1.module_state_key)[0].attempts, 1)
self.assertEqual(StudentModuleExpand.objects.filter(
course_id__exact=self.course.id, module_state_key__exact=sme2.module_state_key)[0].attempts, 2)
from django.test import TestCase
from django.test.utils import override_settings
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.inheritance import own_metadata
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from queryable_student_module import util
class TestUtilApproxEqual(TestCase):
"""
Check the approx_equal function
"""
def test_default_tolerance(self):
"""
Check that function with default tolerance
"""
self.assertTrue(util.approx_equal(1.00001, 1.0))
self.assertTrue(util.approx_equal(1.0, 1.00001))
self.assertFalse(util.approx_equal(1.0, 2.0))
self.assertFalse(util.approx_equal(1.0, 1.0002))
def test_smaller_default_tolerance(self):
"""
Set tolerance smaller than default and check if still correct
"""
self.assertTrue(util.approx_equal(1.0, 1.0, 1))
self.assertTrue(util.approx_equal(1.0, 1.000001, 0.000001))
def test_bigger_default_tolerance(self):
"""
Set tolerance bigger than default and check if still correct
"""
self.assertFalse(util.approx_equal(1.0, 2.0, 0.75))
self.assertFalse(util.approx_equal(2.0, 1.0, 0.75))
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestUtilGetAssignmentToProblemMap(TestCase):
"""
Tests the get_assignemnt_to_problem_map
"""
def setUp(self):
self.course = CourseFactory.create()
def test_empty_course(self):
"""
Test for course with nothing in it
"""
problems_map = util.get_assignment_to_problem_map(self.course.id)
self.assertEqual(problems_map, {})
def test_single_assignment(self):
"""
Test returns the problems for a course with a single assignment
"""
section = ItemFactory.create(
parent_location=self.course.location.url(),
category="chapter")
subsection = ItemFactory.create(
parent_location=section.location.url(),
category="sequential",
)
subsection_metadata = own_metadata(subsection)
subsection_metadata['graded'] = True
subsection_metadata['format'] = "Homework"
modulestore().update_metadata(subsection.location, subsection_metadata)
unit = ItemFactory.create(
parent_location=subsection.location.url(),
category="vertical",
)
problem1 = ItemFactory.create(
parent_location=unit.location.url(),
category="problem",
)
problem2 = ItemFactory.create(
parent_location=unit.location.url(),
category="problem",
)
problems_map = util.get_assignment_to_problem_map(self.course.id)
answer = {'Homework': [[problem1.location.url(), problem2.location.url()], ],
}
self.assertEqual(problems_map, answer)
def test_two_assignments_same_type(self):
"""
Test if has two assignments
"""
section = ItemFactory.create(
parent_location=self.course.location.url(),
category="chapter")
subsection1 = ItemFactory.create(
parent_location=section.location.url(),
category="sequential")
subsection_metadata1 = own_metadata(subsection1)
subsection_metadata1['graded'] = True
subsection_metadata1['format'] = "Homework"
modulestore().update_metadata(subsection1.location, subsection_metadata1)
unit1 = ItemFactory.create(
parent_location=subsection1.location.url(),
category="vertical")
problem1 = ItemFactory.create(
parent_location=unit1.location.url(),
category="problem")
subsection2 = ItemFactory.create(
parent_location=section.location.url(),
category="sequential")
subsection_metadata2 = own_metadata(subsection2)
subsection_metadata2['graded'] = True
subsection_metadata2['format'] = "Homework"
modulestore().update_metadata(subsection2.location, subsection_metadata2)
unit2 = ItemFactory.create(
parent_location=subsection2.location.url(),
category="vertical")
problem2 = ItemFactory.create(
parent_location=unit2.location.url(),
category="problem")
problems_map = util.get_assignment_to_problem_map(self.course.id)
answer = {'Homework': [[problem1.location.url()], [problem2.location.url()], ],
}
self.assertEqual(problems_map, answer)
def test_two_assignments_different_types(self):
"""
Creates two assignments of different types
"""
section = ItemFactory.create(
parent_location=self.course.location.url(),
category="chapter")
subsection1 = ItemFactory.create(
parent_location=section.location.url(),
category="sequential")
subsection_metadata1 = own_metadata(subsection1)
subsection_metadata1['graded'] = True
subsection_metadata1['format'] = "Homework"
modulestore().update_metadata(subsection1.location, subsection_metadata1)
unit1 = ItemFactory.create(
parent_location=subsection1.location.url(),
category="vertical")
problem1 = ItemFactory.create(
parent_location=unit1.location.url(),
category="problem")
subsection2 = ItemFactory.create(
parent_location=section.location.url(),
category="sequential")
subsection_metadata2 = own_metadata(subsection2)
subsection_metadata2['graded'] = True
subsection_metadata2['format'] = "Quiz"
modulestore().update_metadata(subsection2.location, subsection_metadata2)
unit2 = ItemFactory.create(
parent_location=subsection2.location.url(),
category="vertical")
problem2 = ItemFactory.create(
parent_location=unit2.location.url(),
category="problem")
problems_map = util.get_assignment_to_problem_map(self.course.id)
answer = {'Homework': [[problem1.location.url()], ],
'Quiz': [[problem2.location.url()], ],
}
self.assertEqual(problems_map, answer)
def test_return_only_graded_subsections(self):
"""
Make sure only returns problems and assignments that are graded
"""
section = ItemFactory.create(
parent_location=self.course.location.url(),
category="chapter")
subsection1 = ItemFactory.create(
parent_location=section.location.url(),
category="sequential")
subsection_metadata1 = own_metadata(subsection1)
subsection_metadata1['graded'] = True
subsection_metadata1['format'] = "Homework"
modulestore().update_metadata(subsection1.location, subsection_metadata1)
unit1 = ItemFactory.create(
parent_location=subsection1.location.url(),
category="vertical")
problem1 = ItemFactory.create(
parent_location=unit1.location.url(),
category="problem")
subsection2 = ItemFactory.create(
parent_location=section.location.url(),
category="sequential")
subsection_metadata2 = own_metadata(subsection2)
subsection_metadata2['format'] = "Quiz"
modulestore().update_metadata(subsection2.location, subsection_metadata2)
unit2 = ItemFactory.create(
parent_location=subsection2.location.url(),
category="vertical")
problem2 = ItemFactory.create(
parent_location=unit2.location.url(),
category="problem")
problems_map = util.get_assignment_to_problem_map(self.course.id)
answer = {'Homework': [[problem1.location.url()], ],
}
self.assertEqual(problems_map, answer)
"""
Utility functions to help with population
"""
from datetime import datetime
from pytz import UTC
import logging
from optparse import make_option
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.inheritance import own_metadata
from queryable_student_module.models import Log
log = logging.getLogger("mitx.queryable")
def get_assignment_to_problem_map(course_id):
"""
Returns a dictionary with assignment types/categories as keys and the value is an array of arrays. Each inner array
holds problem ids for an assignment. The arrays are ordered in the outer array as they are seen in the course, which
is how they are numbered in a student's progress page.
"""
course = modulestore().get_instance(course_id, CourseDescriptor.id_to_location(course_id), depth=4)
assignment_problems_map = {}
for section in course.get_children():
for subsection in section.get_children():
subsection_metadata = own_metadata(subsection)
if ('graded' in subsection_metadata) and subsection_metadata['graded']:
category = subsection_metadata['format']
if category not in assignment_problems_map:
assignment_problems_map[category] = []
problems = []
for unit in subsection.get_children():
for child in unit.get_children():
if child.location.category == 'problem':
problems.append(child.location.url())
assignment_problems_map[category].append(problems)
return assignment_problems_map
def approx_equal(first, second, tolerance=0.0001):
"""
Checks if first and second are at most the specified tolerance away from each other.
"""
return abs(first - second) <= tolerance
def pre_run_command(script_id, options, course_id):
"""
Common pre-run method for both populate_studentgrades and populate_studentmoduleexpand commands.
"""
log.info("--------------------------------------------------------------------------------")
log.info("Populating queryable.{0} table for course {1}".format(script_id, course_id))
log.info("--------------------------------------------------------------------------------")
# Grab when we start, to log later
tstart = datetime.now(UTC)
iterative_populate = True
last_log_run = {}
if options['force']:
log.info("--------------------------------------------------------------------------------")
log.info("Full populate: Forced full populate")
log.info("--------------------------------------------------------------------------------")
iterative_populate = False
if iterative_populate:
# Get when this script was last run for this course
last_log_run = Log.objects.filter(script_id__exact=script_id, course_id__exact=course_id)
length = len(last_log_run)
log.info("--------------------------------------------------------------------------------")
if length > 0:
log.info("Iterative populate: Last log run %s", str(last_log_run[0].created))
else:
log.info("Full populate: Can't find log of last run")
iterative_populate = False
log.info("--------------------------------------------------------------------------------")
return iterative_populate, tstart, last_log_run
def more_options():
"""
Appends common options to options list
"""
option_list = make_option('-f', '--force',
action='store_true',
dest='force',
default=False,
help='Forces a full populate for all students and rows, rather than iterative.')
return option_list
\ No newline at end of file
...@@ -912,6 +912,13 @@ VERIFY_STUDENT = { ...@@ -912,6 +912,13 @@ VERIFY_STUDENT = {
"DAYS_GOOD_FOR" : 365, # How many days is a verficiation good for? "DAYS_GOOD_FOR" : 365, # How many days is a verficiation good for?
} }
########################## QUERYABLE TABLES ########################
INSTALLED_APPS += ('queryable_student_module',)
########################## CLASS DASHBOARD ########################
INSTALLED_APPS += ('class_dashboard',)
MITX_FEATURES['CLASS_DASHBOARD'] = False
######################## CAS authentication ########################### ######################## CAS authentication ###########################
if MITX_FEATURES.get('AUTH_USE_CAS'): if MITX_FEATURES.get('AUTH_USE_CAS'):
......
...@@ -272,10 +272,12 @@ CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = os.environ.get('CYBERSOURCE_P ...@@ -272,10 +272,12 @@ CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = os.environ.get('CYBERSOURCE_P
########################## USER API ######################## ########################## USER API ########################
EDX_API_KEY = None EDX_API_KEY = None
####################### Shoppingcart ########################### ####################### Shoppingcart ###########################
MITX_FEATURES['ENABLE_SHOPPING_CART'] = True MITX_FEATURES['ENABLE_SHOPPING_CART'] = True
########################## CLASS DASHBOARD ########################
MITX_FEATURES['CLASS_DASHBOARD'] = True
##################################################################### #####################################################################
# Lastly, see if the developer has any local overrides. # Lastly, see if the developer has any local overrides.
try: try:
......
...@@ -230,6 +230,9 @@ DATABASES['jabber'] = { ...@@ -230,6 +230,9 @@ DATABASES['jabber'] = {
INSTALLED_APPS += ('jabber',) INSTALLED_APPS += ('jabber',)
########################## CLASS DASHBOARD ########################
MITX_FEATURES['CLASS_DASHBOARD'] = True
################### Make tests quieter ################### Make tests quieter
# OpenID spews messages like this to stderr, we don't need to see them: # OpenID spews messages like this to stderr, we don't need to see them:
...@@ -237,3 +240,6 @@ INSTALLED_APPS += ('jabber',) ...@@ -237,3 +240,6 @@ INSTALLED_APPS += ('jabber',)
import openid.oidutil import openid.oidutil
openid.oidutil.log = lambda message, level=0: None openid.oidutil.log = lambda message, level=0: None
### QUERYABLE APP ###
INSTALLED_APPS += ('queryable_student_module',)
<%page args="id_opened_prefix, id_grade_prefix, id_attempt_prefix, id_tooltip_prefix, course_id, **kwargs"/>
<%!
import json
from django.core.urlresolvers import reverse
%>
$(function () {
d3.json("${reverse('all_sequential_open_distribution', kwargs=dict(course_id=course_id))}", function(error, json) {
var section, paramOpened, barGraphOpened;
var i, curr_id;
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},
};
if (paramOpened.data.length > 0) {
barGraphOpened = edx_d3CreateStackedBarGraph(paramOpened, d3.select(curr_id).append("svg"),
d3.select("#${id_tooltip_prefix}"+i));
barGraphOpened.scale.stackColor.range(["#555555","#555555"]);
barGraphOpened.drawGraph();
}
i+=1;
}
});
d3.json("${reverse('all_problem_grade_distribution', kwargs=dict(course_id=course_id))}", function(error, json) {
var section, paramGrade, barGraphGrade;
var i, curr_id;
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,
};
if ( paramGrade.data.length > 0 ) {
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;
barGraphGrade.drawGraph();
}
i+=1;
}
});
d3.json("${reverse('all_problem_attempt_distribution', kwargs=dict(course_id=course_id))}", function(error, json) {
var section, paramAttempt, barGraphAttempt;
var i, curr_id;
i = 0;
for (section in json) {
curr_id = "#${id_attempt_prefix}"+i;
paramAttempt = {
data: json[section].data,
width: $(curr_id).width(),
height: $(curr_id).height()-25, // Account for header
tag: "attempt"+i,
bVerticalXAxisLabel : true,
};
if ( paramAttempt.data.length > 0 ) {
barGraphAttempt = edx_d3CreateStackedBarGraph(paramAttempt, d3.select(curr_id).append("svg"),
d3.select("#${id_tooltip_prefix}"+i));
barGraphAttempt.scale.stackColor
.range(["#c3c4cd","#b0b4d1","#9ca3d6","#8993da","#7682de","#6372e3",
"#4f61e7","#3c50eb","#2940ef","#1530f4","#021ff8"]);
barGraphAttempt.legend.width += 2;
barGraphAttempt.drawGraph();
}
i+=1;
}
});
});
\ No newline at end of file
...@@ -95,6 +95,38 @@ textarea { ...@@ -95,6 +95,38 @@ textarea {
top: 30px; top: 30px;
} }
.metrics-container {
position: relative;
width: 100%;
float: left;
clear: both;
}
.metrics-left {
position: relative;
width: 30%;
height: 640px;
float: left;
margin-right: 2.5%;
}
.metrics-right {
position: relative;
width: 65%;
height: 295px;
float: left;
margin-left: 2.5%;
margin-bottom: 25px;
}
.metrics-tooltip {
width: 250px;
background-color: lightgray;
padding: 3px;
}
.stacked-bar-graph-legend {
fill: white;
}
</style> </style>
<script language="JavaScript" type="text/javascript"> <script language="JavaScript" type="text/javascript">
...@@ -135,6 +167,9 @@ function goto( mode) ...@@ -135,6 +167,9 @@ function goto( mode)
%if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_ANALYTICS'): %if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_ANALYTICS'):
| <a href="#" onclick="goto('Analytics');" class="${modeflag.get('Analytics')}">${_("Analytics")}</a> | <a href="#" onclick="goto('Analytics');" class="${modeflag.get('Analytics')}">${_("Analytics")}</a>
%endif %endif
%if settings.MITX_FEATURES.get('CLASS_DASHBOARD'):
| <a href="#" onclick="goto('Metrics');" class="${modeflag.get('Metrics')}">${_("Metrics")}</a>
%endif
] ]
</h2> </h2>
...@@ -591,6 +626,47 @@ function goto( mode) ...@@ -591,6 +626,47 @@ function goto( mode)
%endif %endif
%endif %endif
%if modeflag.get('Metrics'):
%if not any (metrics_results.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"/>
<script>
${d3_stacked_bar_graph.body()}
</script>
<div id="metrics"></div>
%for i in range(0,len(metrics_results['section_display_name'])):
<div class="metrics-container" id="metrics_section_${i}">
<h2>Section: ${metrics_results['section_display_name'][i]}</h2>
<div class="metrics-tooltip" id="metric_tooltip_${i}"></div>
<div class="metrics-left" id="metric_opened_${i}">
<h3>Count of Students Opened a Subsection</h3>
</div>
<div class="metrics-right" id="metric_grade_${i}">
<h3>Grade Distribution per Problem</h3>
%if not metrics_results['section_has_problem'][i]:
<p>${_("There are no problems in this section.")}</p>
%endif
</div>
<div class="metrics-right" id="metric_attempts_${i}">
<h3>Attempt Distribution per Problem (Last update: ${metrics_results['attempts_timestamp']})</h3>
%if not metrics_results['section_has_problem'][i]:
<p>${_("There are no problems in this section.")}</p>
%endif
</div>
</div>
%endfor
<script>
${all_section_metrics.body("metric_opened_","metric_grade_","metric_attempts_","metric_tooltip_",course.id)}
</script>
%endif
%endif
%if modeflag.get('Analytics In Progress'): %if modeflag.get('Analytics In Progress'):
##This is not as helpful as it could be -- let's give full point distribution ##This is not as helpful as it could be -- let's give full point distribution
......
...@@ -365,6 +365,25 @@ if settings.COURSEWARE_ENABLED and settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR ...@@ -365,6 +365,25 @@ if settings.COURSEWARE_ENABLED and settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR
include('instructor.views.api_urls')) include('instructor.views.api_urls'))
) )
if settings.MITX_FEATURES.get('CLASS_DASHBOARD'):
urlpatterns += (
# Json request data for metrics for entire course
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/all_problem_attempt_distribution$',
'class_dashboard.views.all_problem_attempt_distribution', name="all_problem_attempt_distribution"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/all_sequential_open_distribution$',
'class_dashboard.views.all_sequential_open_distribution', name="all_sequential_open_distribution"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/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<course_id>[^/]+/[^/]+/[^/]+)/problem_grade_distribution/(?P<section>\d+)$',
'class_dashboard.views.section_problem_grade_distribution', name="section_problem_grade_distribution"),
)
if settings.ENABLE_JASMINE:
urlpatterns += (url(r'^_jasmine/', include('django_jasmine.urls')),)
if settings.DEBUG or settings.MITX_FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'): if settings.DEBUG or settings.MITX_FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'):
## Jasmine and admin ## Jasmine and admin
urlpatterns += (url(r'^admin/', include(admin.site.urls)),) urlpatterns += (url(r'^admin/', include(admin.site.urls)),)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment