From ad5b3a334203394792c90b0d1bfe2dda8efe13b3 Mon Sep 17 00:00:00 2001 From: ichuang Date: Sat, 8 Sep 2012 10:30:18 -0400 Subject: [PATCH 01/15] add admin interface for tracking logs --- common/djangoapps/track/admin.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 common/djangoapps/track/admin.py diff --git a/common/djangoapps/track/admin.py b/common/djangoapps/track/admin.py new file mode 100644 index 0000000000..1f19c59a93 --- /dev/null +++ b/common/djangoapps/track/admin.py @@ -0,0 +1,8 @@ +''' +django admin pages for courseware model +''' + +from track.models import * +from django.contrib import admin + +admin.site.register(TrackingLog) From ab0a58fb7a01d7167906d8fe911128e743cf8977 Mon Sep 17 00:00:00 2001 From: ichuang Date: Sat, 8 Sep 2012 22:31:45 -0400 Subject: [PATCH 02/15] add psychometrics - grade histograms, check time diffs, and IRT plots --- common/lib/xmodule/xmodule/capa_module.py | 3 + lms/djangoapps/courseware/module_render.py | 4 + lms/djangoapps/instructor/views.py | 34 +++++- lms/envs/common.py | 3 + lms/envs/dev.py | 2 + .../courseware/instructor_dashboard.html | 105 ++++++++++++++++-- 6 files changed, 142 insertions(+), 9 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index d186bcc39c..8e2d12d5e9 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -509,6 +509,9 @@ class CapaModule(XModule): event_info['success'] = success self.system.track_function('save_problem_check', event_info) + if hasattr(self.system,'psychometrics_handler'): # update PsychometricsData using callback + self.system.psychometrics_handler(self.get_instance_state()) + # render problem into HTML html = self.get_problem_html(encapsulate=False) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 88368c4a80..b033660c17 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -16,6 +16,7 @@ from capa.xqueue_interface import XQueueInterface from courseware.access import has_access from mitxmako.shortcuts import render_to_string from models import StudentModule, StudentModuleCache +from psychometrics.psychoanalyze import make_psychometrics_data_update_handler from static_replace import replace_urls from xmodule.errortracker import exc_info_to_str from xmodule.exceptions import NotFoundError @@ -230,6 +231,9 @@ def _get_module(user, request, location, student_module_cache, course_id, positi # pass position specified in URL to module through ModuleSystem system.set('position', position) system.set('DEBUG', settings.DEBUG) + if settings.MITX_FEATURES.get('ENABLE_PSYCHOMETRICS') and instance_module is not None: + system.set('psychometrics_handler', # set callback for updating PsychometricsData + make_psychometrics_data_update_handler(instance_module)) try: module = descriptor.xmodule_constructor(system)(instance_state, shared_state) diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index f4e9c27991..74f186b689 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -27,6 +27,7 @@ from django.views.decorators.cache import cache_control from courseware import grades from courseware.access import has_access, get_access_group_name from courseware.courses import (get_course_with_access, get_courses_by_university) +from psychometrics import psychoanalyze from student.models import UserProfile from student.models import UserTestGroup, CourseEnrollment @@ -51,7 +52,18 @@ def instructor_dashboard(request, course_id): instructor_access = has_access(request.user, course, 'instructor') # an instructor can manage staff lists msg = '' - # msg += ('POST=%s' % dict(request.POST)).replace('<','<') + #msg += ('POST=%s' % dict(request.POST)).replace('<','<') + + problems = [] + plots = [] + + # the instructor dashboard page is modal: grades, psychometrics, admin + # keep that state in request.session (defaults to grades mode) + idash_mode = request.POST.get('idash_mode','') + if idash_mode: + request.session['idash_mode'] = idash_mode + else: + idash_mode = request.session.get('idash_mode','Grades') def escape(s): """escape HTML special characters in string""" @@ -149,6 +161,9 @@ def instructor_dashboard(request, course_id): track.views.server_track(request, 'dump-answer-dist-csv', {}, page='idashboard') return return_csv('answer_dist_%s.csv' % course_id, get_answers_distribution(request, course_id)) + #---------------------------------------- + # Admin + elif 'List course staff' in action: group = get_staff_group(course) msg += 'Staff group = %s' % group.name @@ -187,13 +202,28 @@ def instructor_dashboard(request, course_id): user.groups.remove(group) track.views.server_track(request, 'remove-staff %s' % user, {}, page='idashboard') - # For now, mostly a static page + #---------------------------------------- + # psychometrics + + elif action == 'Generate Histogram and IRT Plot': + problem = request.POST['Problem'] + nmsg, plots = psychoanalyze.generate_plots_for_problem(problem) + msg += nmsg + + if idash_mode=='Psychometrics': + problems = psychoanalyze.problems_with_psychometric_data(course_id) + + #---------------------------------------- + # context for rendering context = {'course': course, 'staff_access': True, 'admin_access': request.user.is_staff, 'instructor_access': instructor_access, 'datatable': datatable, 'msg': msg, + 'modeflag': {idash_mode: 'selectedmode'}, + 'problems': problems, # psychometrics + 'plots': plots, # psychometrics 'course_errors': modulestore().get_item_errors(course.location), } diff --git a/lms/envs/common.py b/lms/envs/common.py index 63301d420b..3cfaae940d 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -71,6 +71,8 @@ MITX_FEATURES = { 'ENABLE_DISCUSSION' : False, 'ENABLE_DISCUSSION_SERVICE': True, + 'ENABLE_PSYCHOMETRICS': False, # real-time psychometrics (eg item response theory analysis in instructor dashboard) + 'ENABLE_SQL_TRACKING_LOGS': False, 'ENABLE_LMS_MIGRATION': False, 'ENABLE_MANUAL_GIT_RELOAD': False, @@ -619,6 +621,7 @@ INSTALLED_APPS = ( 'util', 'certificates', 'instructor', + 'psychometrics', #For the wiki 'wiki', # The new django-wiki from benjaoming diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 50befeb875..5a7e019e55 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -20,6 +20,8 @@ MITX_FEATURES['SUBDOMAIN_COURSE_LISTINGS'] = False # Enable to test subdomains- MITX_FEATURES['SUBDOMAIN_BRANDING'] = True MITX_FEATURES['FORCE_UNIVERSITY_DOMAIN'] = None # show all university courses if in dev (ie don't use HTTP_HOST) MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] = True +MITX_FEATURES['ENABLE_PSYCHOMETRICS'] = True # real-time psychometrics (eg item response theory analysis in instructor dashboard) + WIKI_ENABLED = True diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 8568490e5e..d73eda1ed7 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -4,6 +4,8 @@ <%block name="headextra"> <%static:css group='course'/> + + <%include file="/courseware/course_navigation.html" args="active_page='instructor'" /> @@ -31,37 +33,89 @@ table.stat_table td { border-color: #666666; background-color: #ffffff; } + +a.selectedmode { background-color: yellow; } + + +
+

Instructor Dashboard

-
- +

[ Grades | + %if settings.MITX_FEATURES.get('ENABLE_PSYCHOMETRICS'): + Psychometrics | + %endif + Admin ] +

+ + + + +##----------------------------------------------------------------------------- +%if modeflag.get('Grades'):

Gradebook +

Grade summary +

+

+

+

+

+ %endif -%if instructor_access: +##----------------------------------------------------------------------------- +%if modeflag.get('Psychometrics'): + +

Select a problem and an action: +

+ +

+ +

+

+ +

+ +

+ +%endif + +##----------------------------------------------------------------------------- +%if modeflag.get('Admin'): + %if instructor_access:

@@ -69,16 +123,20 @@ table.stat_table td {


- %endif + %endif -%if settings.MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] and admin_access: + %if settings.MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] and admin_access:

+ %endif %endif

+##----------------------------------------------------------------------------- +%if modeflag.get('Psychometrics') is None: +

@@ -99,14 +157,45 @@ table.stat_table td { %endfor

+%endif + +##----------------------------------------------------------------------------- +%if modeflag.get('Psychometrics'): + + %for plot in plots: +
+

${plot['title']}

+
+

${plot['info']}

+
+
+ +
+
+ %endfor + +%endif + +##----------------------------------------------------------------------------- +## always show msg %if msg:

${msg}

%endif -% if course_errors is not UNDEFINED: +##----------------------------------------------------------------------------- +%if modeflag.get('Admin'): + % if course_errors is not UNDEFINED:

Course errors

+ %if not course_errors: + None + %else:
    % for (summary, err) in course_errors:
  • ${summary | h} @@ -118,8 +207,10 @@ table.stat_table td {
  • % endfor
+ %endif
-% endif + % endif +%endif
From 0bf85992da090b624fb915cb87a1323736b6a67c Mon Sep 17 00:00:00 2001 From: ichuang Date: Sat, 8 Sep 2012 22:32:28 -0400 Subject: [PATCH 03/15] psychometrics djangoapp --- lms/djangoapps/psychometrics/__init__.py | 0 lms/djangoapps/psychometrics/admin.py | 8 + .../psychometrics/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/init_psychometrics.py | 66 ++++ lms/djangoapps/psychometrics/models.py | 45 +++ lms/djangoapps/psychometrics/psychoanalyze.py | 312 ++++++++++++++++++ 7 files changed, 431 insertions(+) create mode 100644 lms/djangoapps/psychometrics/__init__.py create mode 100644 lms/djangoapps/psychometrics/admin.py create mode 100644 lms/djangoapps/psychometrics/management/__init__.py create mode 100644 lms/djangoapps/psychometrics/management/commands/__init__.py create mode 100644 lms/djangoapps/psychometrics/management/commands/init_psychometrics.py create mode 100644 lms/djangoapps/psychometrics/models.py create mode 100644 lms/djangoapps/psychometrics/psychoanalyze.py diff --git a/lms/djangoapps/psychometrics/__init__.py b/lms/djangoapps/psychometrics/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/psychometrics/admin.py b/lms/djangoapps/psychometrics/admin.py new file mode 100644 index 0000000000..ff1a14d722 --- /dev/null +++ b/lms/djangoapps/psychometrics/admin.py @@ -0,0 +1,8 @@ +''' +django admin pages for courseware model +''' + +from psychometrics.models import * +from django.contrib import admin + +admin.site.register(PsychometricData) diff --git a/lms/djangoapps/psychometrics/management/__init__.py b/lms/djangoapps/psychometrics/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/psychometrics/management/commands/__init__.py b/lms/djangoapps/psychometrics/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/psychometrics/management/commands/init_psychometrics.py b/lms/djangoapps/psychometrics/management/commands/init_psychometrics.py new file mode 100644 index 0000000000..b7c9779d08 --- /dev/null +++ b/lms/djangoapps/psychometrics/management/commands/init_psychometrics.py @@ -0,0 +1,66 @@ +#!/usr/bin/python +# +# generate pyschometrics data from tracking logs and student module data + +import os, sys, string +import datetime +import json + +from courseware.models import * +from track.models import * +from psychometrics.models import * +from xmodule.modulestore import Location + +from django.core.management.base import BaseCommand + +#db = "ocwtutor" # for debugging +db = "default" + +class Command(BaseCommand): + help = "initialize PsychometricData tables from StudentModule instances (and tracking data, if in SQL)." + help += "Note this is done for all courses for which StudentModule instances exist." + + def handle(self, *args, **options): + + # delete all pmd + + #PsychometricData.objects.all().delete() + #PsychometricData.objects.using(db).all().delete() + + smset = StudentModule.objects.using(db).exclude(max_grade=None) + + for sm in smset: + url = sm.module_state_key + location = Location(url) + if not location.category=="problem": + continue + try: + state = json.loads(sm.state) + done = state['done'] + except: + print "Oops, failed to eval state for %s (state=%s)" % (sm,sm.state) + continue + + if done: # only keep if problem completed + try: + pmd = PsychometricData.objects.using(db).get(studentmodule=sm) + except PsychometricData.DoesNotExist: + pmd = PsychometricData(studentmodule=sm) + + pmd.done = done + pmd.attempts = state['attempts'] + + # get attempt times from tracking log + uname = sm.student.username + tset = TrackingLog.objects.using(db).filter(username=uname, event_type__contains='save_problem_check') + tset = tset.filter(event_source='server') + tset = tset.filter(event__contains="'%s'" % url) + checktimes = [x.dtcreated for x in tset] + pmd.checktimes = json.dumps(checktimes) + if not len(checktimes)==pmd.attempts: + print "Oops, mismatch in number of attempts and check times for %s" % pmd + + #print pmd + pmd.save(using=db) + + print "%d PMD entries" % PsychometricData.objects.using(db).all().count() diff --git a/lms/djangoapps/psychometrics/models.py b/lms/djangoapps/psychometrics/models.py new file mode 100644 index 0000000000..4ffdf59120 --- /dev/null +++ b/lms/djangoapps/psychometrics/models.py @@ -0,0 +1,45 @@ +# +# db model for psychometrics data +# +# this data is collected in real time +# + +from django.db import models +from courseware.models import StudentModule + +class PsychometricData(models.Model): + """ + This data is a table linking student, module, and module performance, + including number of attempts, grade, max grade, and time of checks. + + Links to instances of StudentModule, but only those for capa problems. + + Note that StudentModule.module_state_key is nominally a Location instance (url string). + That means it is of the form {tag}://{org}/{course}/{category}/{name}[@{revision}] + and for capa problems, category = "problem". + + checktimes is extracted from tracking logs, or added by capa module via psychometrics callback. + """ + + studentmodule = models.ForeignKey(StudentModule, db_index=True, unique=True) # contains student, module_state_key, course_id + + done = models.BooleanField(default=False) + attempts = models.IntegerField(default=0) # extracted from studentmodule.state + checktimes = models.TextField(null=True, blank=True) # internally stored as list of datetime objects + + # keep in mind + # grade = studentmodule.grade + # max_grade = studentmodule.max_grade + # student = studentmodule.student + # course_id = studentmodule.course_id + # location = studentmodule.module_state_key + + def __unicode__(self): + sm = self.studentmodule + return "[PsychometricData] %s url=%s, grade=%s, max=%s, attempts=%s, ct=%s" % (sm.student, + sm.module_state_key, + sm.grade, + sm.max_grade, + self.attempts, + self.checktimes) + diff --git a/lms/djangoapps/psychometrics/psychoanalyze.py b/lms/djangoapps/psychometrics/psychoanalyze.py new file mode 100644 index 0000000000..e8dd7b4684 --- /dev/null +++ b/lms/djangoapps/psychometrics/psychoanalyze.py @@ -0,0 +1,312 @@ +# +# File: psychometrics/psychoanalyze.py +# +# generate pyschometrics plots from PsychometricData + +from __future__ import division + +import datetime +import logging +import json +import math +import numpy as np +from scipy.optimize import curve_fit + +from django.db.models import Sum, Max +from psychometrics.models import * +from xmodule.modulestore import Location + +log = logging.getLogger("mitx.psychometrics") + +#db = "ocwtutor" # for debugging +db = "default" + +#----------------------------------------------------------------------------- +# fit functions + +def func_2pl(x,a,b): + """ + 2-parameter logistic function + """ + D = 1.7 + edax = np.exp(D*a*(x-b)) + return edax / (1+edax) + +#----------------------------------------------------------------------------- +# statistics class + +class StatVar(object): + """ + Simple statistics on floating point numbers: avg, sdv, var, min, max + """ + def __init__(self,unit=1): + self.sum = 0 + self.sum2 = 0 + self.cnt = 0 + self.unit = unit + self.min = None + self.max = None + def add(self,x): + if x is None: + return + if self.min is None: + self.min = x + else: + if xself.max: + self.max = x + self.sum += x + self.sum2 += x**2 + self.cnt += 1 + def avg(self): + if self.cnt is None: + return 0 + return self.sum / 1.0 / self.cnt / self.unit + def var(self): + if self.cnt is None: + return 0 + return (self.sum2 / 1.0 / self.cnt / (self.unit**2)) - (self.avg()**2) + def sdv(self): + v = self.var() + if v>0: + return math.sqrt(v) + else: + return 0 + def __str__(self): + return 'cnt=%d, avg=%f, sdv=%f' % (self.cnt,self.avg(),self.sdv()) + def __add__(self,x): + self.add(x) + return self + +#----------------------------------------------------------------------------- +# histogram generator + +def make_histogram(ydata,bins=None): + ''' + Generate histogram of ydata using bins provided, or by default bins + from 0 to 100 by 10. bins should be ordered in increasing order. + + returns dict with keys being bins, and values being counts. + special: hist['bins'] = bins + ''' + if bins is None: + bins = range(0,100,10) + + nbins = len(bins) + hist = dict(zip(bins,[0] * nbins)) + for y in ydata: + for b in bins[::-1]: # in reverse order + if y>b: + hist[b] += 1 + break + # hist['bins'] = bins + return hist + +#----------------------------------------------------------------------------- + +def problems_with_psychometric_data(course_id): + ''' + Return dict of {problems (location urls): count} for which psychometric data is available. + Does this for a given course_id. + ''' + pmdset = PsychometricData.objects.using(db).filter(studentmodule__course_id=course_id) + plist = [p['studentmodule__module_state_key'] for p in pmdset.values('studentmodule__module_state_key').distinct()] + problems = dict( (p,pmdset.filter(studentmodule__module_state_key=p).count()) for p in plist ) + + return problems + +#----------------------------------------------------------------------------- + +def generate_plots_for_problem(problem): + + pmdset = PsychometricData.objects.using(db).filter(studentmodule__module_state_key=problem) + nstudents = pmdset.count() + msg = "" + plots = [] + + if nstudents < 2: + msg += "%s nstudents=%d --> skipping, too few" % (problem,nstudents) + return msg, plots + + max_grade = pmdset[0].studentmodule.max_grade + + agdat = pmdset.aggregate(Sum('attempts'), Max('attempts')) + max_attempts = agdat['attempts__max'] + total_attempts = agdat['attempts__sum'] # not used yet + + msg += "max attempts = %d" % max_attempts + + xdat = range(1,max_attempts+1) + dataset = {'xdat': xdat} + + # generate grade histogram + ghist = [] + + axisopts = """{ + xaxes: [{ + axisLabel: 'Grade' + }], + yaxes: [{ + position: 'left', + axisLabel: 'Count' + }] + }""" + + if max_grade > 1: + ghist = make_histogram([pmd.studentmodule.grade for pmd in pmdset],np.linspace(0,max_grade,max_grade+1)) + ghist_json = json.dumps(ghist.items()) + + plot = {'title': "Grade histogram for %s" % problem, + 'id': 'histogram', + 'info': '', + 'data': "var dhist = %s;\n" % ghist_json, + 'cmd': "[ {data: dhist, bars: { show: true }} ], %s" % axisopts, + } + plots.append(plot) + else: + msg += "
Not generating histogram: max_grade=%s" % max_grade + + # histogram of time differences between checks + # Warning: this is inefficient - doesn't scale to large numbers of students + dtset = [] # time differences in minutes + dtsv = StatVar() + for pmd in pmdset: + try: + checktimes = eval(pmd.checktimes) # update log of attempt timestamps + except: + continue + if len(checktimes)<2: + continue + ct0 = checktimes[0] + for ct in checktimes[1:]: + dt = (ct-ct0).total_seconds()/60.0 + if dt<20: # ignore if dt too long + dtset.append(dt) + dtsv += dt + ct0 = ct + if dtsv.cnt > 2: + msg += "
time differences between checks: %s" % dtsv + bins = np.linspace(0,1.5*dtsv.sdv(),30) + dbar = bins[1]-bins[0] + thist = make_histogram(dtset,bins) + thist_json = json.dumps(sorted(thist.items(), key=lambda(x): x[0])) + + axisopts = """{ xaxes: [{ axisLabel: 'Time (min)'}], yaxes: [{position: 'left',axisLabel: 'Count'}]}""" + + plot = {'title': "Histogram of time differences between checks", + 'id': 'thistogram', + 'info': '', + 'data': "var thist = %s;\n" % thist_json, + 'cmd': "[ {data: thist, bars: { show: true, barWidth:%f }} ], %s" % (dbar, axisopts), + } + plots.append(plot) + + # one IRT plot curve for each grade received (TODO: this assumes integer grades) + for grade in range(1,int(max_grade)+1): + yset = {} + gset = pmdset.filter(studentmodule__grade=grade) + ngset = gset.count() + if ngset==0: + continue + ydat = [] + ylast = 0 + for x in xdat: + y = gset.filter(attempts=x).count()/ngset + ydat.append( y + ylast ) + ylast = y + ylast + yset['ydat'] = ydat + + if len(ydat)>5: # try to fit to logistic function if enough data points + cfp = curve_fit(func_2pl, xdat, ydat, [1.0, max_attempts/2.0]) + yset['fitparam'] = cfp + yset['fitpts'] = func_2pl(np.array(xdat),*cfp[0]) + yset['fiterr'] = [yd-yf for (yd,yf) in zip(ydat,yset['fitpts'])] + fitx = np.linspace(xdat[0],xdat[-1],100) + yset['fitx'] = fitx + yset['fity'] = func_2pl(np.array(fitx),*cfp[0]) + + dataset['grade_%d' % grade] = yset + + axisopts = """{ + xaxes: [{ + axisLabel: 'Number of Attempts' + }], + yaxes: [{ + max:1.0, + position: 'left', + axisLabel: 'Probability of correctness' + }] + }""" + + # generate points for flot plot + for grade in range(1,int(max_grade)+1): + jsdata = "" + jsplots = [] + gkey = 'grade_%d' % grade + if gkey in dataset: + yset = dataset[gkey] + jsdata += "var d%d = %s;\n" % (grade,json.dumps(zip(xdat,yset['ydat']))) + jsplots.append('{ data: d%d, lines: { show: false }, points: { show: true}, color: "red" }' % grade) + if 'fitpts' in yset: + jsdata += 'var fit = %s;\n' % (json.dumps(zip(yset['fitx'],yset['fity']))) + jsplots.append('{ data: fit, lines: { show: true }, color: "blue" }') + (a,b) = yset['fitparam'][0] + irtinfo = "(2PL: D=1.7, a=%6.3f, b=%6.3f)" % (a,b) + else: + irtinfo = "" + + plots.append({'title': 'IRT Plot for grade=%s %s' % (grade,irtinfo), + 'id': "irt%s" % grade, + 'info': '', + 'data': jsdata, + 'cmd' : '[%s], %s' % (','.join(jsplots), axisopts), + }) + + #log.debug('plots = %s' % plots) + return msg, plots + +#----------------------------------------------------------------------------- + +def make_psychometrics_data_update_handler(studentmodule): + """ + Construct and return a procedure which may be called to update + the PsychometricsData instance for the given StudentModule instance. + """ + sm = studentmodule + try: + pmd = PsychometricData.objects.using(db).get(studentmodule=sm) + except PsychometricData.DoesNotExist: + pmd = PsychometricData(studentmodule=sm) + + def psychometrics_data_update_handler(state): + """ + This function may be called each time a problem is successfully checked + (eg on save_problem_check events in capa_module). + + state = instance state (a nice, uniform way to interface - for more future psychometric feature extraction) + """ + try: + state = json.loads(sm.state) + done = state['done'] + except: + log.exception("Oops, failed to eval state for %s (state=%s)" % (sm,sm.state)) + return + + pmd.done = done + pmd.attempts = state['attempts'] + try: + checktimes = eval(pmd.checktimes) # update log of attempt timestamps + except: + checktimes = [] + checktimes.append(datetime.datetime.now()) + pmd.checktimes = checktimes + try: + pmd.save() + except: + log.exception("Error in updating psychometrics data for %s" % sm) + + return psychometrics_data_update_handler From b4a3591310203732ef510488ba7b0da1158b8e50 Mon Sep 17 00:00:00 2001 From: ichuang Date: Sat, 8 Sep 2012 23:21:13 -0400 Subject: [PATCH 04/15] add jquery flot axislabels plugin --- .../js/vendor/flot/jquery.flot.axislabels.js | 412 ++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 common/static/js/vendor/flot/jquery.flot.axislabels.js diff --git a/common/static/js/vendor/flot/jquery.flot.axislabels.js b/common/static/js/vendor/flot/jquery.flot.axislabels.js new file mode 100644 index 0000000000..797f82ec9f --- /dev/null +++ b/common/static/js/vendor/flot/jquery.flot.axislabels.js @@ -0,0 +1,412 @@ +/* +Axis Labels Plugin for flot. +http://github.com/markrcote/flot-axislabels + +Original code is Copyright (c) 2010 Xuan Luo. +Original code was released under the GPLv3 license by Xuan Luo, September 2010. +Original code was rereleased under the MIT license by Xuan Luo, April 2012. + +Improvements by Mark Cote. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + */ +(function ($) { + var options = { }; + + function canvasSupported() { + return !!document.createElement('canvas').getContext; + } + + function canvasTextSupported() { + if (!canvasSupported()) { + return false; + } + var dummy_canvas = document.createElement('canvas'); + var context = dummy_canvas.getContext('2d'); + return typeof context.fillText == 'function'; + } + + function css3TransitionSupported() { + var div = document.createElement('div'); + return typeof div.style.MozTransition != 'undefined' // Gecko + || typeof div.style.OTransition != 'undefined' // Opera + || typeof div.style.webkitTransition != 'undefined' // WebKit + || typeof div.style.transition != 'undefined'; + } + + + function AxisLabel(axisName, position, padding, plot, opts) { + this.axisName = axisName; + this.position = position; + this.padding = padding; + this.plot = plot; + this.opts = opts; + this.width = 0; + this.height = 0; + } + + + CanvasAxisLabel.prototype = new AxisLabel(); + CanvasAxisLabel.prototype.constructor = CanvasAxisLabel; + function CanvasAxisLabel(axisName, position, padding, plot, opts) { + AxisLabel.prototype.constructor.call(this, axisName, position, padding, + plot, opts); + } + + CanvasAxisLabel.prototype.calculateSize = function() { + if (!this.opts.axisLabelFontSizePixels) + this.opts.axisLabelFontSizePixels = 14; + if (!this.opts.axisLabelFontFamily) + this.opts.axisLabelFontFamily = 'sans-serif'; + + var textWidth = this.opts.axisLabelFontSizePixels + this.padding; + var textHeight = this.opts.axisLabelFontSizePixels + this.padding; + if (this.position == 'left' || this.position == 'right') { + this.width = this.opts.axisLabelFontSizePixels + this.padding; + this.height = 0; + } else { + this.width = 0; + this.height = this.opts.axisLabelFontSizePixels + this.padding; + } + }; + + CanvasAxisLabel.prototype.draw = function(box) { + var ctx = this.plot.getCanvas().getContext('2d'); + ctx.save(); + ctx.font = this.opts.axisLabelFontSizePixels + 'px ' + + this.opts.axisLabelFontFamily; + var width = ctx.measureText(this.opts.axisLabel).width; + var height = this.opts.axisLabelFontSizePixels; + var x, y, angle = 0; + if (this.position == 'top') { + x = box.left + box.width/2 - width/2; + y = box.top + height*0.72; + } else if (this.position == 'bottom') { + x = box.left + box.width/2 - width/2; + y = box.top + box.height - height*0.72; + } else if (this.position == 'left') { + x = box.left + height*0.72; + y = box.height/2 + box.top + width/2; + angle = -Math.PI/2; + } else if (this.position == 'right') { + x = box.left + box.width - height*0.72; + y = box.height/2 + box.top - width/2; + angle = Math.PI/2; + } + ctx.translate(x, y); + ctx.rotate(angle); + ctx.fillText(this.opts.axisLabel, 0, 0); + ctx.restore(); + }; + + + HtmlAxisLabel.prototype = new AxisLabel(); + HtmlAxisLabel.prototype.constructor = HtmlAxisLabel; + function HtmlAxisLabel(axisName, position, padding, plot, opts) { + AxisLabel.prototype.constructor.call(this, axisName, position, + padding, plot, opts); + } + + HtmlAxisLabel.prototype.calculateSize = function() { + var elem = $('
' + + this.opts.axisLabel + '
'); + this.plot.getPlaceholder().append(elem); + // store height and width of label itself, for use in draw() + this.labelWidth = elem.outerWidth(true); + this.labelHeight = elem.outerHeight(true); + elem.remove(); + + this.width = this.height = 0; + if (this.position == 'left' || this.position == 'right') { + this.width = this.labelWidth + this.padding; + } else { + this.height = this.labelHeight + this.padding; + } + }; + + HtmlAxisLabel.prototype.draw = function(box) { + this.plot.getPlaceholder().find('#' + this.axisName + 'Label').remove(); + var elem = $('
' + + this.opts.axisLabel + '
'); + this.plot.getPlaceholder().append(elem); + if (this.position == 'top') { + elem.css('left', box.left + box.width/2 - this.labelWidth/2 + 'px'); + elem.css('top', box.top + 'px'); + } else if (this.position == 'bottom') { + elem.css('left', box.left + box.width/2 - this.labelWidth/2 + 'px'); + elem.css('top', box.top + box.height - this.labelHeight + 'px'); + } else if (this.position == 'left') { + elem.css('top', box.top + box.height/2 - this.labelHeight/2 + 'px'); + elem.css('left', box.left + 'px'); + } else if (this.position == 'right') { + elem.css('top', box.top + box.height/2 - this.labelHeight/2 + 'px'); + elem.css('left', box.left + box.width - this.labelWidth + 'px'); + } + }; + + + CssTransformAxisLabel.prototype = new HtmlAxisLabel(); + CssTransformAxisLabel.prototype.constructor = CssTransformAxisLabel; + function CssTransformAxisLabel(axisName, position, padding, plot, opts) { + HtmlAxisLabel.prototype.constructor.call(this, axisName, position, + padding, plot, opts); + } + + CssTransformAxisLabel.prototype.calculateSize = function() { + HtmlAxisLabel.prototype.calculateSize.call(this); + this.width = this.height = 0; + if (this.position == 'left' || this.position == 'right') { + this.width = this.labelHeight + this.padding; + } else { + this.height = this.labelHeight + this.padding; + } + }; + + CssTransformAxisLabel.prototype.transforms = function(degrees, x, y) { + var stransforms = { + '-moz-transform': '', + '-webkit-transform': '', + '-o-transform': '', + '-ms-transform': '' + }; + if (x != 0 || y != 0) { + var stdTranslate = ' translate(' + x + 'px, ' + y + 'px)'; + stransforms['-moz-transform'] += stdTranslate; + stransforms['-webkit-transform'] += stdTranslate; + stransforms['-o-transform'] += stdTranslate; + stransforms['-ms-transform'] += stdTranslate; + } + if (degrees != 0) { + var rotation = degrees / 90; + var stdRotate = ' rotate(' + degrees + 'deg)'; + stransforms['-moz-transform'] += stdRotate; + stransforms['-webkit-transform'] += stdRotate; + stransforms['-o-transform'] += stdRotate; + stransforms['-ms-transform'] += stdRotate; + } + var s = 'top: 0; left: 0; '; + for (var prop in stransforms) { + if (stransforms[prop]) { + s += prop + ':' + stransforms[prop] + ';'; + } + } + s += ';'; + return s; + }; + + CssTransformAxisLabel.prototype.calculateOffsets = function(box) { + var offsets = { x: 0, y: 0, degrees: 0 }; + if (this.position == 'bottom') { + offsets.x = box.left + box.width/2 - this.labelWidth/2; + offsets.y = box.top + box.height - this.labelHeight; + } else if (this.position == 'top') { + offsets.x = box.left + box.width/2 - this.labelWidth/2; + offsets.y = box.top; + } else if (this.position == 'left') { + offsets.degrees = -90; + offsets.x = box.left - this.labelWidth/2 + this.labelHeight/2; + offsets.y = box.height/2 + box.top; + } else if (this.position == 'right') { + offsets.degrees = 90; + offsets.x = box.left + box.width - this.labelWidth/2 + - this.labelHeight/2; + offsets.y = box.height/2 + box.top; + } + return offsets; + }; + + CssTransformAxisLabel.prototype.draw = function(box) { + this.plot.getPlaceholder().find("." + this.axisName + "Label").remove(); + var offsets = this.calculateOffsets(box); + var elem = $('
' + this.opts.axisLabel + '
'); + this.plot.getPlaceholder().append(elem); + }; + + + IeTransformAxisLabel.prototype = new CssTransformAxisLabel(); + IeTransformAxisLabel.prototype.constructor = IeTransformAxisLabel; + function IeTransformAxisLabel(axisName, position, padding, plot, opts) { + CssTransformAxisLabel.prototype.constructor.call(this, axisName, + position, padding, + plot, opts); + this.requiresResize = false; + } + + IeTransformAxisLabel.prototype.transforms = function(degrees, x, y) { + // I didn't feel like learning the crazy Matrix stuff, so this uses + // a combination of the rotation transform and CSS positioning. + var s = ''; + if (degrees != 0) { + var rotation = degrees/90; + while (rotation < 0) { + rotation += 4; + } + s += ' filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=' + rotation + '); '; + // see below + this.requiresResize = (this.position == 'right'); + } + if (x != 0) { + s += 'left: ' + x + 'px; '; + } + if (y != 0) { + s += 'top: ' + y + 'px; '; + } + return s; + }; + + IeTransformAxisLabel.prototype.calculateOffsets = function(box) { + var offsets = CssTransformAxisLabel.prototype.calculateOffsets.call( + this, box); + // adjust some values to take into account differences between + // CSS and IE rotations. + if (this.position == 'top') { + // FIXME: not sure why, but placing this exactly at the top causes + // the top axis label to flip to the bottom... + offsets.y = box.top + 1; + } else if (this.position == 'left') { + offsets.x = box.left; + offsets.y = box.height/2 + box.top - this.labelWidth/2; + } else if (this.position == 'right') { + offsets.x = box.left + box.width - this.labelHeight; + offsets.y = box.height/2 + box.top - this.labelWidth/2; + } + return offsets; + }; + + IeTransformAxisLabel.prototype.draw = function(box) { + CssTransformAxisLabel.prototype.draw.call(this, box); + if (this.requiresResize) { + var elem = this.plot.getPlaceholder().find("." + this.axisName + "Label"); + // Since we used CSS positioning instead of transforms for + // translating the element, and since the positioning is done + // before any rotations, we have to reset the width and height + // in case the browser wrapped the text (specifically for the + // y2axis). + elem.css('width', this.labelWidth); + elem.css('height', this.labelHeight); + } + }; + + + function init(plot) { + // This is kind of a hack. There are no hooks in Flot between + // the creation and measuring of the ticks (setTicks, measureTickLabels + // in setupGrid() ) and the drawing of the ticks and plot box + // (insertAxisLabels in setupGrid() ). + // + // Therefore, we use a trick where we run the draw routine twice: + // the first time to get the tick measurements, so that we can change + // them, and then have it draw it again. + var secondPass = false; + + var axisLabels = {}; + var axisOffsetCounts = { left: 0, right: 0, top: 0, bottom: 0 }; + + var defaultPadding = 2; // padding between axis and tick labels + plot.hooks.draw.push(function (plot, ctx) { + if (!secondPass) { + // MEASURE AND SET OPTIONS + $.each(plot.getAxes(), function(axisName, axis) { + var opts = axis.options // Flot 0.7 + || plot.getOptions()[axisName]; // Flot 0.6 + if (!opts || !opts.axisLabel || !axis.show) + return; + + var renderer = null; + + if (!opts.axisLabelUseHtml && + navigator.appName == 'Microsoft Internet Explorer') { + var ua = navigator.userAgent; + var re = new RegExp("MSIE ([0-9]{1,}[\.0-9]{0,})"); + if (re.exec(ua) != null) { + rv = parseFloat(RegExp.$1); + } + if (rv >= 9 && !opts.axisLabelUseCanvas && !opts.axisLabelUseHtml) { + renderer = CssTransformAxisLabel; + } else if (!opts.axisLabelUseCanvas && !opts.axisLabelUseHtml) { + renderer = IeTransformAxisLabel; + } else if (opts.axisLabelUseCanvas) { + renderer = CanvasAxisLabel; + } else { + renderer = HtmlAxisLabel; + } + } else { + if (opts.axisLabelUseHtml || (!css3TransitionSupported() && !canvasTextSupported()) && !opts.axisLabelUseCanvas) { + renderer = HtmlAxisLabel; + } else if (opts.axisLabelUseCanvas || !css3TransitionSupported()) { + renderer = CanvasAxisLabel; + } else { + renderer = CssTransformAxisLabel; + } + } + + var padding = opts.axisLabelPadding === undefined ? + defaultPadding : opts.axisLabelPadding; + + axisLabels[axisName] = new renderer(axisName, + axis.position, padding, + plot, opts); + + // flot interprets axis.labelHeight and .labelWidth as + // the height and width of the tick labels. We increase + // these values to make room for the axis label and + // padding. + + axisLabels[axisName].calculateSize(); + + // AxisLabel.height and .width are the size of the + // axis label and padding. + axis.labelHeight += axisLabels[axisName].height; + axis.labelWidth += axisLabels[axisName].width; + opts.labelHeight = axis.labelHeight; + opts.labelWidth = axis.labelWidth; + }); + // re-draw with new label widths and heights + secondPass = true; + plot.setupGrid(); + plot.draw(); + } else { + // DRAW + $.each(plot.getAxes(), function(axisName, axis) { + var opts = axis.options // Flot 0.7 + || plot.getOptions()[axisName]; // Flot 0.6 + if (!opts || !opts.axisLabel || !axis.show) + return; + + axisLabels[axisName].draw(axis.box); + }); + } + }); + } + + + $.plot.plugins.push({ + init: init, + options: options, + name: 'axisLabels', + version: '2.0b0' + }); +})(jQuery); From 2045500c6e724300089d5ef98273533cf3951b3b Mon Sep 17 00:00:00 2001 From: ichuang Date: Sat, 8 Sep 2012 23:21:34 -0400 Subject: [PATCH 05/15] make external_auth table searchable in django admin; fix missed instance of login link which should have been behind DISABLE_LOGIN_BUTTON --- common/djangoapps/external_auth/admin.py | 6 +++++- lms/templates/portal/course_about.html | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/external_auth/admin.py b/common/djangoapps/external_auth/admin.py index 343492bca7..e93325bcb2 100644 --- a/common/djangoapps/external_auth/admin.py +++ b/common/djangoapps/external_auth/admin.py @@ -5,4 +5,8 @@ django admin pages for courseware model from external_auth.models import * from django.contrib import admin -admin.site.register(ExternalAuthMap) +class ExternalAuthMapAdmin(admin.ModelAdmin): + search_fields = ['external_id','user__username'] + date_hierarchy = 'dtcreated' + +admin.site.register(ExternalAuthMap, ExternalAuthMapAdmin) diff --git a/lms/templates/portal/course_about.html b/lms/templates/portal/course_about.html index ac7b9090d0..8d2d45117d 100644 --- a/lms/templates/portal/course_about.html +++ b/lms/templates/portal/course_about.html @@ -76,7 +76,11 @@
%endif %else: - Register for ${course.number} + Log In +% endif + to enroll.'>Register for ${course.number} %endif From 31dd119e79fe3de694e3719302449a1272592155 Mon Sep 17 00:00:00 2001 From: ichuang Date: Sun, 9 Sep 2012 17:50:11 -0400 Subject: [PATCH 06/15] settings.DATABASE_FOR_PSYCHOMETRICS overrides db for psychometrics --- .../management/commands/init_psychometrics.py | 8 ++++++-- lms/djangoapps/psychometrics/psychoanalyze.py | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/psychometrics/management/commands/init_psychometrics.py b/lms/djangoapps/psychometrics/management/commands/init_psychometrics.py index b7c9779d08..5e782df595 100644 --- a/lms/djangoapps/psychometrics/management/commands/init_psychometrics.py +++ b/lms/djangoapps/psychometrics/management/commands/init_psychometrics.py @@ -11,10 +11,14 @@ from track.models import * from psychometrics.models import * from xmodule.modulestore import Location +from django.conf import settings from django.core.management.base import BaseCommand #db = "ocwtutor" # for debugging -db = "default" +#db = "default" + +db = getattr(settings,'DATABASE_FOR_PSYCHOMETRICS','default') + class Command(BaseCommand): help = "initialize PsychometricData tables from StudentModule instances (and tracking data, if in SQL)." @@ -56,7 +60,7 @@ class Command(BaseCommand): tset = tset.filter(event_source='server') tset = tset.filter(event__contains="'%s'" % url) checktimes = [x.dtcreated for x in tset] - pmd.checktimes = json.dumps(checktimes) + pmd.checktimes = checktimes if not len(checktimes)==pmd.attempts: print "Oops, mismatch in number of attempts and check times for %s" % pmd diff --git a/lms/djangoapps/psychometrics/psychoanalyze.py b/lms/djangoapps/psychometrics/psychoanalyze.py index e8dd7b4684..c798c73609 100644 --- a/lms/djangoapps/psychometrics/psychoanalyze.py +++ b/lms/djangoapps/psychometrics/psychoanalyze.py @@ -12,6 +12,7 @@ import math import numpy as np from scipy.optimize import curve_fit +from django.conf import settings from django.db.models import Sum, Max from psychometrics.models import * from xmodule.modulestore import Location @@ -19,7 +20,9 @@ from xmodule.modulestore import Location log = logging.getLogger("mitx.psychometrics") #db = "ocwtutor" # for debugging -db = "default" +#db = "default" + +db = getattr(settings,'DATABASE_FOR_PSYCHOMETRICS','default') #----------------------------------------------------------------------------- # fit functions From ccbbb5a99388ace2f1295cb46125bb4a095e4f4f Mon Sep 17 00:00:00 2001 From: ichuang Date: Sun, 9 Sep 2012 19:39:11 -0400 Subject: [PATCH 07/15] commit_id may be specified for modulestore reload - for multi-worker configs also show pid in migration and instructor dashboard views for thread debugging --- lms/djangoapps/instructor/views.py | 1 + lms/djangoapps/lms_migration/migrate.py | 38 ++++++++++++++++--- .../courseware/instructor_dashboard.html | 2 + lms/urls.py | 1 + 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index 74f186b689..f78a1160de 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -225,6 +225,7 @@ def instructor_dashboard(request, course_id): 'problems': problems, # psychometrics 'plots': plots, # psychometrics 'course_errors': modulestore().get_item_errors(course.location), + 'djangopid' : os.getpid(), } return render_to_response('courseware/instructor_dashboard.html', context) diff --git a/lms/djangoapps/lms_migration/migrate.py b/lms/djangoapps/lms_migration/migrate.py index a3a1c595be..403063f983 100644 --- a/lms/djangoapps/lms_migration/migrate.py +++ b/lms/djangoapps/lms_migration/migrate.py @@ -35,7 +35,17 @@ def getip(request): ip = request.META.get('REMOTE_ADDR','None') return ip -def manage_modulestores(request,reload_dir=None): + +def get_commit_id(course): + return course.metadata.get('GIT_COMMIT_ID','No commit id') + # getattr(def_ms.courses[reload_dir], 'GIT_COMMIT_ID','No commit id') + + +def set_commit_id(course,commit_id): + course.metadata['GIT_COMMIT_ID'] = commit_id + # setattr(def_ms.courses[reload_dir], 'GIT_COMMIT_ID', new_commit_id) + +def manage_modulestores(request, reload_dir=None, commit_id=None): ''' Manage the static in-memory modulestores. @@ -52,8 +62,9 @@ def manage_modulestores(request,reload_dir=None): ip = getip(request) if LOCAL_DEBUG: - html += '

IP address: %s ' % ip - html += '

User: %s ' % request.user + html += '

IP address: %s

' % ip + html += '

User: %s

' % request.user + html += '

My pid: %s

' % os.getpid() log.debug('request from ip=%s, user=%s' % (ip,request.user)) if not (ip in ALLOWED_IPS or 'any' in ALLOWED_IPS): @@ -66,14 +77,27 @@ def manage_modulestores(request,reload_dir=None): return HttpResponse(html, status=403) #---------------------------------------- - # reload course if specified + # reload course if specified; handle optional commit_id if reload_dir is not None: if reload_dir not in def_ms.courses: html += '

Error: "%s" is not a valid course directory

' % reload_dir else: - html += '

Reloaded course directory "%s"

' % reload_dir - def_ms.try_load_course(reload_dir) + # reloading based on commit_id is needed when running mutiple worker threads, + # so that a given thread doesn't reload the same commit multiple times + current_commit_id = get_commit_id(def_ms.courses[reload_dir]) + log.debug('commit_id="%s"' % commit_id) + log.debug('current_commit_id="%s"' % current_commit_id) + + if (commit_id is not None) and (commit_id==current_commit_id): + html += "

Already at commit id %s for %s

" % (commit_id, reload_dir) + else: + html += '

Reloaded course directory "%s"

' % reload_dir + def_ms.try_load_course(reload_dir) + gdir = settings.DATA_DIR / reload_dir + new_commit_id = os.popen('cd %s; git log -n 1 | head -1' % gdir).read().strip().split(' ')[1] + set_commit_id(def_ms.courses[reload_dir], new_commit_id) + html += '

commit_id=%s

' % new_commit_id #---------------------------------------- @@ -94,6 +118,8 @@ def manage_modulestores(request,reload_dir=None): html += '
' html += '

Course: %s (%s)

' % (course.display_name,cdir) + html += '

commit_id=%s

' % get_commit_id(course) + for field in dumpfields: data = getattr(course,field) html += '

%s

' % field diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index d73eda1ed7..e822f05f92 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -59,6 +59,8 @@ function goto( mode) Admin ] +
${djangopid}
+
diff --git a/lms/urls.py b/lms/urls.py index 8484ccc40b..49febaf84e 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -237,6 +237,7 @@ if settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'): urlpatterns += ( url(r'^migrate/modules$', 'lms_migration.migrate.manage_modulestores'), url(r'^migrate/reload/(?P[^/]+)$', 'lms_migration.migrate.manage_modulestores'), + url(r'^migrate/reload/(?P[^/]+)/(?P[^/]+)$', 'lms_migration.migrate.manage_modulestores'), url(r'^gitreload$', 'lms_migration.migrate.gitreload'), url(r'^gitreload/(?P[^/]+)$', 'lms_migration.migrate.gitreload'), ) From 2f2e71e0d12c69c1f652ab65ee13ebc702ae2535 Mon Sep 17 00:00:00 2001 From: ichuang Date: Sun, 9 Sep 2012 21:08:31 -0400 Subject: [PATCH 08/15] add tracking log entries for modulestore reload --- lms/djangoapps/lms_migration/migrate.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lms/djangoapps/lms_migration/migrate.py b/lms/djangoapps/lms_migration/migrate.py index 403063f983..ecde31d6dd 100644 --- a/lms/djangoapps/lms_migration/migrate.py +++ b/lms/djangoapps/lms_migration/migrate.py @@ -91,6 +91,12 @@ def manage_modulestores(request, reload_dir=None, commit_id=None): if (commit_id is not None) and (commit_id==current_commit_id): html += "

Already at commit id %s for %s

" % (commit_id, reload_dir) + track.views.server_track(request, + 'reload %s skipped already at %s (pid=%s)' % (reload_dir, + commit_id, + os.getpid(), + ), + {}, page='migrate') else: html += '

Reloaded course directory "%s"

' % reload_dir def_ms.try_load_course(reload_dir) @@ -98,6 +104,9 @@ def manage_modulestores(request, reload_dir=None, commit_id=None): new_commit_id = os.popen('cd %s; git log -n 1 | head -1' % gdir).read().strip().split(' ')[1] set_commit_id(def_ms.courses[reload_dir], new_commit_id) html += '

commit_id=%s

' % new_commit_id + track.views.server_track(request, 'reloaded %s now at %s (pid=%s)' % (reload_dir, + new_commit_id, + os.getpid()), {}, page='migrate') #---------------------------------------- From c1d92f9351de610984fd83ab445a9927ca66caed Mon Sep 17 00:00:00 2001 From: ichuang Date: Mon, 10 Sep 2012 16:57:47 -0400 Subject: [PATCH 09/15] track psychometrics requests; add grade statistics, check for wrong value of max_grade (something not right about StudentModule.max_grade) --- lms/djangoapps/instructor/views.py | 1 + lms/djangoapps/psychometrics/psychoanalyze.py | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index f78a1160de..d812791c3d 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -209,6 +209,7 @@ def instructor_dashboard(request, course_id): problem = request.POST['Problem'] nmsg, plots = psychoanalyze.generate_plots_for_problem(problem) msg += nmsg + track.views.server_track(request, 'psychometrics %s' % problem, {}, page='idashboard') if idash_mode=='Psychometrics': problems = psychoanalyze.problems_with_psychometric_data(course_id) diff --git a/lms/djangoapps/psychometrics/psychoanalyze.py b/lms/djangoapps/psychometrics/psychoanalyze.py index c798c73609..bb2a6ba6a8 100644 --- a/lms/djangoapps/psychometrics/psychoanalyze.py +++ b/lms/djangoapps/psychometrics/psychoanalyze.py @@ -146,6 +146,13 @@ def generate_plots_for_problem(problem): xdat = range(1,max_attempts+1) dataset = {'xdat': xdat} + # compute grade statistics + grades = [pmd.studentmodule.grade for pmd in pmdset] + gsv = StatVar() + for g in grades: + gsv += g + msg += "

Grade distribution: %s

" % gsv + # generate grade histogram ghist = [] @@ -159,8 +166,12 @@ def generate_plots_for_problem(problem): }] }""" + if gsv.max > max_grade: + msg += "

Something is wrong: max_grade=%s, but max(grades)=%s

" % (max_grade, gsv.max) + max_grade = gsv.max + if max_grade > 1: - ghist = make_histogram([pmd.studentmodule.grade for pmd in pmdset],np.linspace(0,max_grade,max_grade+1)) + ghist = make_histogram(grades, np.linspace(0,max_grade,max_grade+1)) ghist_json = json.dumps(ghist.items()) plot = {'title': "Grade histogram for %s" % problem, @@ -192,7 +203,7 @@ def generate_plots_for_problem(problem): dtsv += dt ct0 = ct if dtsv.cnt > 2: - msg += "
time differences between checks: %s" % dtsv + msg += "

Time differences between checks: %s

" % dtsv bins = np.linspace(0,1.5*dtsv.sdv(),30) dbar = bins[1]-bins[0] thist = make_histogram(dtset,bins) @@ -223,7 +234,7 @@ def generate_plots_for_problem(problem): ylast = y + ylast yset['ydat'] = ydat - if len(ydat)>5: # try to fit to logistic function if enough data points + if len(ydat)>3: # try to fit to logistic function if enough data points cfp = curve_fit(func_2pl, xdat, ydat, [1.0, max_attempts/2.0]) yset['fitparam'] = cfp yset['fitpts'] = func_2pl(np.array(xdat),*cfp[0]) From e62e58dc6eae4eb32e4730643c721ba90329cd53 Mon Sep 17 00:00:00 2001 From: ichuang Date: Mon, 10 Sep 2012 20:33:56 -0400 Subject: [PATCH 10/15] log attempts in tracking --- common/lib/xmodule/xmodule/capa_module.py | 1 + 1 file changed, 1 insertion(+) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 8e2d12d5e9..8bf1a56404 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -507,6 +507,7 @@ class CapaModule(XModule): # 'success' will always be incorrect event_info['correct_map'] = correct_map.get_dict() event_info['success'] = success + event_info['attempts'] = self.attempts self.system.track_function('save_problem_check', event_info) if hasattr(self.system,'psychometrics_handler'): # update PsychometricsData using callback From 97a32e64eef6fcfca93876f04580fed8207e877f Mon Sep 17 00:00:00 2001 From: ichuang Date: Mon, 10 Sep 2012 21:33:54 -0400 Subject: [PATCH 11/15] fix tooltip titles in seq_module for non-new-xml-format courses --- common/lib/xmodule/xmodule/seq_module.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 65f692957c..26bdd286c3 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -75,7 +75,7 @@ class SequenceModule(XModule): contents = [] for child in self.get_display_items(): progress = child.get_progress() - contents.append({ + childinfo = { 'content': child.get_html(), 'title': "\n".join( grand_child.display_name.strip() @@ -85,7 +85,10 @@ class SequenceModule(XModule): 'progress_status': Progress.to_js_status_str(progress), 'progress_detail': Progress.to_js_detail_str(progress), 'type': child.get_icon_class(), - }) + } + if childinfo['title']=='': + childinfo['title'] = child.metadata['display_name'] + contents.append(childinfo) params = {'items': contents, 'element_id': self.location.html_id(), From d8c8c85041b30a95ef16272235a242fca787fc71 Mon Sep 17 00:00:00 2001 From: ichuang Date: Mon, 10 Sep 2012 21:42:44 -0400 Subject: [PATCH 12/15] tabs to spaces --- common/lib/xmodule/xmodule/seq_module.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 26bdd286c3..e7e97626db 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -75,7 +75,7 @@ class SequenceModule(XModule): contents = [] for child in self.get_display_items(): progress = child.get_progress() - childinfo = { + childinfo = { 'content': child.get_html(), 'title': "\n".join( grand_child.display_name.strip() @@ -86,7 +86,7 @@ class SequenceModule(XModule): 'progress_detail': Progress.to_js_detail_str(progress), 'type': child.get_icon_class(), } - if childinfo['title']=='': + if childinfo['title']=='': childinfo['title'] = child.metadata['display_name'] contents.append(childinfo) From c6f794547fd77dd99fa9ad8b64ada3fdb9bfbb61 Mon Sep 17 00:00:00 2001 From: ichuang Date: Mon, 10 Sep 2012 21:45:38 -0400 Subject: [PATCH 13/15] more tabs -> spaces --- common/lib/xmodule/xmodule/seq_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index e7e97626db..607c1e500e 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -87,7 +87,7 @@ class SequenceModule(XModule): 'type': child.get_icon_class(), } if childinfo['title']=='': - childinfo['title'] = child.metadata['display_name'] + childinfo['title'] = child.metadata['display_name'] contents.append(childinfo) params = {'items': contents, From 56d44ec7f39fe47cebb1c6e50f555e7cb259b2a4 Mon Sep 17 00:00:00 2001 From: ichuang Date: Mon, 10 Sep 2012 21:55:58 -0400 Subject: [PATCH 14/15] display_name may be empty (eg in tests) --- common/lib/xmodule/xmodule/seq_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 607c1e500e..b05ea36e50 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -87,7 +87,7 @@ class SequenceModule(XModule): 'type': child.get_icon_class(), } if childinfo['title']=='': - childinfo['title'] = child.metadata['display_name'] + childinfo['title'] = child.metadata.get('display_name','') contents.append(childinfo) params = {'items': contents, From bffd9ac38d799e7adb447c018391c2734bbe9840 Mon Sep 17 00:00:00 2001 From: ichuang Date: Mon, 10 Sep 2012 22:28:37 -0400 Subject: [PATCH 15/15] center histogram bars; pep8 --- lms/djangoapps/psychometrics/psychoanalyze.py | 120 ++++++++++-------- 1 file changed, 66 insertions(+), 54 deletions(-) diff --git a/lms/djangoapps/psychometrics/psychoanalyze.py b/lms/djangoapps/psychometrics/psychoanalyze.py index bb2a6ba6a8..dd7d328278 100644 --- a/lms/djangoapps/psychometrics/psychoanalyze.py +++ b/lms/djangoapps/psychometrics/psychoanalyze.py @@ -1,7 +1,7 @@ # # File: psychometrics/psychoanalyze.py # -# generate pyschometrics plots from PsychometricData +# generate pyschometrics plots from PsychometricData from __future__ import division @@ -19,98 +19,108 @@ from xmodule.modulestore import Location log = logging.getLogger("mitx.psychometrics") -#db = "ocwtutor" # for debugging +#db = "ocwtutor" # for debugging #db = "default" -db = getattr(settings,'DATABASE_FOR_PSYCHOMETRICS','default') +db = getattr(settings, 'DATABASE_FOR_PSYCHOMETRICS', 'default') #----------------------------------------------------------------------------- # fit functions -def func_2pl(x,a,b): + +def func_2pl(x, a, b): """ 2-parameter logistic function """ D = 1.7 - edax = np.exp(D*a*(x-b)) - return edax / (1+edax) + edax = np.exp(D * a * (x - b)) + return edax / (1 + edax) #----------------------------------------------------------------------------- # statistics class + class StatVar(object): """ Simple statistics on floating point numbers: avg, sdv, var, min, max """ - def __init__(self,unit=1): + def __init__(self, unit=1): self.sum = 0 self.sum2 = 0 self.cnt = 0 self.unit = unit self.min = None self.max = None - def add(self,x): + + def add(self, x): if x is None: return if self.min is None: self.min = x else: - if xself.max: + if x > self.max: self.max = x self.sum += x self.sum2 += x**2 self.cnt += 1 + def avg(self): if self.cnt is None: return 0 return self.sum / 1.0 / self.cnt / self.unit + def var(self): if self.cnt is None: return 0 return (self.sum2 / 1.0 / self.cnt / (self.unit**2)) - (self.avg()**2) + def sdv(self): v = self.var() if v>0: return math.sqrt(v) else: return 0 + def __str__(self): - return 'cnt=%d, avg=%f, sdv=%f' % (self.cnt,self.avg(),self.sdv()) - def __add__(self,x): + return 'cnt=%d, avg=%f, sdv=%f' % (self.cnt, self.avg(), self.sdv()) + + def __add__(self, x): self.add(x) return self #----------------------------------------------------------------------------- # histogram generator -def make_histogram(ydata,bins=None): + +def make_histogram(ydata, bins=None): ''' Generate histogram of ydata using bins provided, or by default bins from 0 to 100 by 10. bins should be ordered in increasing order. - + returns dict with keys being bins, and values being counts. special: hist['bins'] = bins ''' if bins is None: - bins = range(0,100,10) - + bins = range(0, 100, 10) + nbins = len(bins) - hist = dict(zip(bins,[0] * nbins)) + hist = dict(zip(bins, [0] * nbins)) for y in ydata: - for b in bins[::-1]: # in reverse order + for b in bins[::-1]: # in reverse order if y>b: hist[b] += 1 break # hist['bins'] = bins return hist - + #----------------------------------------------------------------------------- + def problems_with_psychometric_data(course_id): ''' Return dict of {problems (location urls): count} for which psychometric data is available. @@ -118,36 +128,37 @@ def problems_with_psychometric_data(course_id): ''' pmdset = PsychometricData.objects.using(db).filter(studentmodule__course_id=course_id) plist = [p['studentmodule__module_state_key'] for p in pmdset.values('studentmodule__module_state_key').distinct()] - problems = dict( (p,pmdset.filter(studentmodule__module_state_key=p).count()) for p in plist ) + problems = dict( (p, pmdset.filter(studentmodule__module_state_key=p).count()) for p in plist ) return problems #----------------------------------------------------------------------------- + def generate_plots_for_problem(problem): - + pmdset = PsychometricData.objects.using(db).filter(studentmodule__module_state_key=problem) nstudents = pmdset.count() msg = "" plots = [] if nstudents < 2: - msg += "%s nstudents=%d --> skipping, too few" % (problem,nstudents) + msg += "%s nstudents=%d --> skipping, too few" % (problem, nstudents) return msg, plots max_grade = pmdset[0].studentmodule.max_grade agdat = pmdset.aggregate(Sum('attempts'), Max('attempts')) max_attempts = agdat['attempts__max'] - total_attempts = agdat['attempts__sum'] # not used yet + total_attempts = agdat['attempts__sum'] # not used yet msg += "max attempts = %d" % max_attempts - xdat = range(1,max_attempts+1) + xdat = range(1, max_attempts + 1) dataset = {'xdat': xdat} # compute grade statistics - grades = [pmd.studentmodule.grade for pmd in pmdset] + grades = [pmd.studentmodule.grade for pmd in pmdset] gsv = StatVar() for g in grades: gsv += g @@ -171,14 +182,14 @@ def generate_plots_for_problem(problem): max_grade = gsv.max if max_grade > 1: - ghist = make_histogram(grades, np.linspace(0,max_grade,max_grade+1)) + ghist = make_histogram(grades, np.linspace(0, max_grade, max_grade + 1)) ghist_json = json.dumps(ghist.items()) plot = {'title': "Grade histogram for %s" % problem, 'id': 'histogram', 'info': '', 'data': "var dhist = %s;\n" % ghist_json, - 'cmd': "[ {data: dhist, bars: { show: true }} ], %s" % axisopts, + 'cmd': '[ {data: dhist, bars: { show: true, align: "center" }} ], %s' % axisopts, } plots.append(plot) else: @@ -186,27 +197,27 @@ def generate_plots_for_problem(problem): # histogram of time differences between checks # Warning: this is inefficient - doesn't scale to large numbers of students - dtset = [] # time differences in minutes + dtset = [] # time differences in minutes dtsv = StatVar() for pmd in pmdset: try: - checktimes = eval(pmd.checktimes) # update log of attempt timestamps + checktimes = eval(pmd.checktimes) # update log of attempt timestamps except: continue - if len(checktimes)<2: + if len(checktimes) < 2: continue ct0 = checktimes[0] for ct in checktimes[1:]: - dt = (ct-ct0).total_seconds()/60.0 - if dt<20: # ignore if dt too long + dt = (ct - ct0).total_seconds() / 60.0 + if dt < 20: # ignore if dt too long dtset.append(dt) dtsv += dt ct0 = ct if dtsv.cnt > 2: msg += "

Time differences between checks: %s

" % dtsv - bins = np.linspace(0,1.5*dtsv.sdv(),30) - dbar = bins[1]-bins[0] - thist = make_histogram(dtset,bins) + bins = np.linspace(0, 1.5 * dtsv.sdv(), 30) + dbar = bins[1] - bins[0] + thist = make_histogram(dtset, bins) thist_json = json.dumps(sorted(thist.items(), key=lambda(x): x[0])) axisopts = """{ xaxes: [{ axisLabel: 'Time (min)'}], yaxes: [{position: 'left',axisLabel: 'Count'}]}""" @@ -215,33 +226,33 @@ def generate_plots_for_problem(problem): 'id': 'thistogram', 'info': '', 'data': "var thist = %s;\n" % thist_json, - 'cmd': "[ {data: thist, bars: { show: true, barWidth:%f }} ], %s" % (dbar, axisopts), + 'cmd': '[ {data: thist, bars: { show: true, align: "center", barWidth:%f }} ], %s' % (dbar, axisopts), } plots.append(plot) # one IRT plot curve for each grade received (TODO: this assumes integer grades) - for grade in range(1,int(max_grade)+1): + for grade in range(1, int(max_grade) + 1): yset = {} gset = pmdset.filter(studentmodule__grade=grade) ngset = gset.count() - if ngset==0: + if ngset == 0: continue ydat = [] ylast = 0 for x in xdat: - y = gset.filter(attempts=x).count()/ngset + y = gset.filter(attempts=x).count() / ngset ydat.append( y + ylast ) ylast = y + ylast yset['ydat'] = ydat - if len(ydat)>3: # try to fit to logistic function if enough data points - cfp = curve_fit(func_2pl, xdat, ydat, [1.0, max_attempts/2.0]) + if len(ydat) > 3: # try to fit to logistic function if enough data points + cfp = curve_fit(func_2pl, xdat, ydat, [1.0, max_attempts / 2.0]) yset['fitparam'] = cfp - yset['fitpts'] = func_2pl(np.array(xdat),*cfp[0]) - yset['fiterr'] = [yd-yf for (yd,yf) in zip(ydat,yset['fitpts'])] - fitx = np.linspace(xdat[0],xdat[-1],100) + yset['fitpts'] = func_2pl(np.array(xdat), *cfp[0]) + yset['fiterr'] = [yd - yf for (yd, yf) in zip(ydat, yset['fitpts'])] + fitx = np.linspace(xdat[0], xdat[-1], 100) yset['fitx'] = fitx - yset['fity'] = func_2pl(np.array(fitx),*cfp[0]) + yset['fity'] = func_2pl(np.array(fitx), *cfp[0]) dataset['grade_%d' % grade] = yset @@ -257,27 +268,27 @@ def generate_plots_for_problem(problem): }""" # generate points for flot plot - for grade in range(1,int(max_grade)+1): + for grade in range(1, int(max_grade) + 1): jsdata = "" jsplots = [] gkey = 'grade_%d' % grade if gkey in dataset: yset = dataset[gkey] - jsdata += "var d%d = %s;\n" % (grade,json.dumps(zip(xdat,yset['ydat']))) + jsdata += "var d%d = %s;\n" % (grade, json.dumps(zip(xdat, yset['ydat']))) jsplots.append('{ data: d%d, lines: { show: false }, points: { show: true}, color: "red" }' % grade) if 'fitpts' in yset: - jsdata += 'var fit = %s;\n' % (json.dumps(zip(yset['fitx'],yset['fity']))) + jsdata += 'var fit = %s;\n' % (json.dumps(zip(yset['fitx'], yset['fity']))) jsplots.append('{ data: fit, lines: { show: true }, color: "blue" }') - (a,b) = yset['fitparam'][0] - irtinfo = "(2PL: D=1.7, a=%6.3f, b=%6.3f)" % (a,b) + (a, b) = yset['fitparam'][0] + irtinfo = "(2PL: D=1.7, a=%6.3f, b=%6.3f)" % (a, b) else: irtinfo = "" - plots.append({'title': 'IRT Plot for grade=%s %s' % (grade,irtinfo), + plots.append({'title': 'IRT Plot for grade=%s %s' % (grade, irtinfo), 'id': "irt%s" % grade, 'info': '', 'data': jsdata, - 'cmd' : '[%s], %s' % (','.join(jsplots), axisopts), + 'cmd': '[%s], %s' % (','.join(jsplots), axisopts), }) #log.debug('plots = %s' % plots) @@ -285,6 +296,7 @@ def generate_plots_for_problem(problem): #----------------------------------------------------------------------------- + def make_psychometrics_data_update_handler(studentmodule): """ Construct and return a procedure which may be called to update @@ -307,13 +319,13 @@ def make_psychometrics_data_update_handler(studentmodule): state = json.loads(sm.state) done = state['done'] except: - log.exception("Oops, failed to eval state for %s (state=%s)" % (sm,sm.state)) + log.exception("Oops, failed to eval state for %s (state=%s)" % (sm, sm.state)) return pmd.done = done pmd.attempts = state['attempts'] try: - checktimes = eval(pmd.checktimes) # update log of attempt timestamps + checktimes = eval(pmd.checktimes) # update log of attempt timestamps except: checktimes = [] checktimes.append(datetime.datetime.now())