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/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)
diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py
index d186bcc39c..8bf1a56404 100644
--- a/common/lib/xmodule/xmodule/capa_module.py
+++ b/common/lib/xmodule/xmodule/capa_module.py
@@ -507,8 +507,12 @@ 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
+ self.system.psychometrics_handler(self.get_instance_state())
+
# render problem into HTML
html = self.get_problem_html(encapsulate=False)
diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py
index 65f692957c..b05ea36e50 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.get('display_name','')
+ contents.append(childinfo)
params = {'items': contents,
'element_id': self.location.html_id(),
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);
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..d812791c3d 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,14 +202,31 @@ 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
+ track.views.server_track(request, 'psychometrics %s' % problem, {}, page='idashboard')
+
+ 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),
+ '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..ecde31d6dd 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,36 @@ 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)
+ 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)
+ 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
+ track.views.server_track(request, 'reloaded %s now at %s (pid=%s)' % (reload_dir,
+ new_commit_id,
+ os.getpid()), {}, page='migrate')
#----------------------------------------
@@ -94,6 +127,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/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..5e782df595
--- /dev/null
+++ b/lms/djangoapps/psychometrics/management/commands/init_psychometrics.py
@@ -0,0 +1,70 @@
+#!/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.conf import settings
+from django.core.management.base import BaseCommand
+
+#db = "ocwtutor" # for debugging
+#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)."
+ 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 = 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..dd7d328278
--- /dev/null
+++ b/lms/djangoapps/psychometrics/psychoanalyze.py
@@ -0,0 +1,338 @@
+#
+# 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.conf import settings
+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"
+
+db = getattr(settings, 'DATABASE_FOR_PSYCHOMETRICS', '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 x < self.min:
+ self.min = x
+ if self.max is None:
+ self.max = x
+ else:
+ 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):
+ 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}
+
+ # 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 = []
+
+ axisopts = """{
+ xaxes: [{
+ axisLabel: 'Grade'
+ }],
+ yaxes: [{
+ position: 'left',
+ axisLabel: 'Count'
+ }]
+ }"""
+
+ 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(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, align: "center" }} ], %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, 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):
+ 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) > 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['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
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..e822f05f92 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'/>
+
+
%block>
<%include file="/courseware/course_navigation.html" args="active_page='instructor'" />
@@ -31,37 +33,91 @@ table.stat_table td {
border-color: #666666;
background-color: #ffffff;
}
+
+a.selectedmode { background-color: yellow; }
+
+
+
+
Instructor Dashboard
-
+##-----------------------------------------------------------------------------
+%if modeflag.get('Psychometrics') is None:
+
@@ -99,14 +159,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 +209,10 @@ table.stat_table td {
% endfor
+ %endif
-% endif
+ % endif
+%endif
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
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'),
)