Merge pull request #648 from MITx/feature/ichuang/psychometrics
Interrim psychometrics with real-time IRT plots and histograms (see note) Ike is committing to implement the SOA API which discussed in person, in which a separate djangoapp would receive the tracking logs from the LMS, and also be queried by the LMS to perform psychometrics analyses. Initially this psychometrics djangoapp might share some tables like StudentModule with the LMS, but the plan would be to separate them when possible. Ike will implement this for edX by November.
This commit is contained in:
@@ -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)
|
||||
|
||||
8
common/djangoapps/track/admin.py
Normal file
8
common/djangoapps/track/admin.py
Normal file
@@ -0,0 +1,8 @@
|
||||
'''
|
||||
django admin pages for courseware model
|
||||
'''
|
||||
|
||||
from track.models import *
|
||||
from django.contrib import admin
|
||||
|
||||
admin.site.register(TrackingLog)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
412
common/static/js/vendor/flot/jquery.flot.axislabels.js
vendored
Normal file
412
common/static/js/vendor/flot/jquery.flot.axislabels.js
vendored
Normal file
@@ -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 = $('<div class="axisLabels" style="position:absolute;">' +
|
||||
this.opts.axisLabel + '</div>');
|
||||
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 = $('<div id="' + this.axisName +
|
||||
'Label" " class="axisLabels" style="position:absolute;">'
|
||||
+ this.opts.axisLabel + '</div>');
|
||||
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 = $('<div class="axisLabels ' + this.axisName +
|
||||
'Label" style="position:absolute; ' +
|
||||
'color: ' + this.opts.color + '; ' +
|
||||
this.transforms(offsets.degrees, offsets.x, offsets.y) +
|
||||
'">' + this.opts.axisLabel + '</div>');
|
||||
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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 += '<h3>IP address: %s ' % ip
|
||||
html += '<h3>User: %s ' % request.user
|
||||
html += '<h3>IP address: %s <h3>' % ip
|
||||
html += '<h3>User: %s </h3>' % request.user
|
||||
html += '<h3>My pid: %s</h3>' % 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 += '<h2 class="inline-error">Error: "%s" is not a valid course directory</h2>' % reload_dir
|
||||
else:
|
||||
html += '<h2>Reloaded course directory "%s"</h2>' % 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 += "<h2>Already at commit id %s for %s</h2>" % (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 += '<h2>Reloaded course directory "%s"</h2>' % 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 += '<p>commit_id=%s</p>' % 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 += '<hr width="100%"/>'
|
||||
html += '<h2>Course: %s (%s)</h2>' % (course.display_name,cdir)
|
||||
|
||||
html += '<p>commit_id=%s</p>' % get_commit_id(course)
|
||||
|
||||
for field in dumpfields:
|
||||
data = getattr(course,field)
|
||||
html += '<h3>%s</h3>' % field
|
||||
|
||||
0
lms/djangoapps/psychometrics/__init__.py
Normal file
0
lms/djangoapps/psychometrics/__init__.py
Normal file
8
lms/djangoapps/psychometrics/admin.py
Normal file
8
lms/djangoapps/psychometrics/admin.py
Normal file
@@ -0,0 +1,8 @@
|
||||
'''
|
||||
django admin pages for courseware model
|
||||
'''
|
||||
|
||||
from psychometrics.models import *
|
||||
from django.contrib import admin
|
||||
|
||||
admin.site.register(PsychometricData)
|
||||
0
lms/djangoapps/psychometrics/management/__init__.py
Normal file
0
lms/djangoapps/psychometrics/management/__init__.py
Normal file
@@ -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()
|
||||
45
lms/djangoapps/psychometrics/models.py
Normal file
45
lms/djangoapps/psychometrics/models.py
Normal file
@@ -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)
|
||||
|
||||
338
lms/djangoapps/psychometrics/psychoanalyze.py
Normal file
338
lms/djangoapps/psychometrics/psychoanalyze.py
Normal file
@@ -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 += "<br><p><font color='blue'>Grade distribution: %s</font></p>" % gsv
|
||||
|
||||
# generate grade histogram
|
||||
ghist = []
|
||||
|
||||
axisopts = """{
|
||||
xaxes: [{
|
||||
axisLabel: 'Grade'
|
||||
}],
|
||||
yaxes: [{
|
||||
position: 'left',
|
||||
axisLabel: 'Count'
|
||||
}]
|
||||
}"""
|
||||
|
||||
if gsv.max > max_grade:
|
||||
msg += "<br/><p><font color='red'>Something is wrong: max_grade=%s, but max(grades)=%s</font></p>" % (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 += "<br/>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 += "<br/><p><font color='brown'>Time differences between checks: %s</font></p>" % 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
<%block name="headextra">
|
||||
<%static:css group='course'/>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.axislabels.js')}"></script>
|
||||
</%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; }
|
||||
|
||||
</style>
|
||||
|
||||
<script language="JavaScript" type="text/javascript">
|
||||
function goto( mode)
|
||||
{
|
||||
document.idashform.idash_mode.value = mode;
|
||||
document.idashform.submit() ;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="container">
|
||||
<div class="instructor-dashboard-wrapper">
|
||||
|
||||
<section class="instructor-dashboard-content">
|
||||
<h1>Instructor Dashboard</h1>
|
||||
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="${ csrf_token }">
|
||||
<h2>[ <a href="#" onclick="goto('Grades');" class="${modeflag.get('Grades')}">Grades</a> |
|
||||
%if settings.MITX_FEATURES.get('ENABLE_PSYCHOMETRICS'):
|
||||
<a href="#" onclick="goto('Psychometrics');" class="${modeflag.get('Psychometrics')}">Psychometrics</a> |
|
||||
%endif
|
||||
<a href="#" onclick="goto('Admin');" class="${modeflag.get('Admin')}">Admin</a> ]
|
||||
</h2>
|
||||
|
||||
<div style="text-align:right" id="djangopid">${djangopid}</div>
|
||||
|
||||
<form name="idashform" method="POST">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="${ csrf_token }">
|
||||
<input type="hidden" name="idash_mode" value="">
|
||||
|
||||
##-----------------------------------------------------------------------------
|
||||
%if modeflag.get('Grades'):
|
||||
<p>
|
||||
<a href="${reverse('gradebook', kwargs=dict(course_id=course.id))}">Gradebook</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="${reverse('grade_summary', kwargs=dict(course_id=course.id))}">Grade summary</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<input type="submit" name="action" value="Dump list of enrolled students">
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<input type="submit" name="action" value="Dump Grades for all students in this course">
|
||||
<input type="submit" name="action" value="Download CSV of all student grades for this course">
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<input type="submit" name="action" value="Dump all RAW grades for all students in this course">
|
||||
<input type="submit" name="action" value="Download CSV of all RAW grades">
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<input type="submit" name="action" value="Download CSV of answer distributions">
|
||||
</p>
|
||||
%endif
|
||||
|
||||
%if instructor_access:
|
||||
##-----------------------------------------------------------------------------
|
||||
%if modeflag.get('Psychometrics'):
|
||||
|
||||
<p>Select a problem and an action:
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<select name="Problem">
|
||||
%for problem, count in sorted(problems.items(), key=lambda x: x[0]):
|
||||
<option value="${problem}">${problem} [${count}]</option>
|
||||
%endfor
|
||||
</select>
|
||||
</p>
|
||||
<p>
|
||||
<input type="submit" name="action" value="Generate Histogram and IRT Plot">
|
||||
</p>
|
||||
|
||||
<p></p>
|
||||
|
||||
%endif
|
||||
|
||||
##-----------------------------------------------------------------------------
|
||||
%if modeflag.get('Admin'):
|
||||
%if instructor_access:
|
||||
<hr width="40%" style="align:left">
|
||||
<p>
|
||||
<input type="submit" name="action" value="List course staff members">
|
||||
@@ -69,16 +125,20 @@ table.stat_table td {
|
||||
<input type="text" name="staffuser"> <input type="submit" name="action" value="Remove course staff">
|
||||
<input type="submit" name="action" value="Add course staff">
|
||||
<hr width="40%" style="align:left">
|
||||
%endif
|
||||
%endif
|
||||
|
||||
%if settings.MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] and admin_access:
|
||||
%if settings.MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] and admin_access:
|
||||
<p>
|
||||
<input type="submit" name="action" value="Reload course from XML files">
|
||||
<input type="submit" name="action" value="GIT pull and Reload course">
|
||||
%endif
|
||||
%endif
|
||||
|
||||
</form>
|
||||
|
||||
##-----------------------------------------------------------------------------
|
||||
%if modeflag.get('Psychometrics') is None:
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
<p>
|
||||
@@ -99,14 +159,45 @@ table.stat_table td {
|
||||
%endfor
|
||||
</table>
|
||||
</p>
|
||||
%endif
|
||||
|
||||
##-----------------------------------------------------------------------------
|
||||
%if modeflag.get('Psychometrics'):
|
||||
|
||||
%for plot in plots:
|
||||
<br/>
|
||||
<h3>${plot['title']}</h3>
|
||||
<br/>
|
||||
<p>${plot['info']}</p>
|
||||
<br/>
|
||||
<div id="plot_${plot['id']}" style="width:600px;height:300px;"></div>
|
||||
<script type="text/javascript">
|
||||
$(function () {
|
||||
${plot['data']}
|
||||
$.plot($("#plot_${plot['id']}"), ${plot['cmd']} );
|
||||
});
|
||||
</script>
|
||||
<br/>
|
||||
<br/>
|
||||
%endfor
|
||||
|
||||
%endif
|
||||
|
||||
##-----------------------------------------------------------------------------
|
||||
## always show msg
|
||||
|
||||
%if msg:
|
||||
<p>${msg}</p>
|
||||
%endif
|
||||
|
||||
% if course_errors is not UNDEFINED:
|
||||
##-----------------------------------------------------------------------------
|
||||
%if modeflag.get('Admin'):
|
||||
% if course_errors is not UNDEFINED:
|
||||
<h2>Course errors</h2>
|
||||
<div id="course-errors">
|
||||
%if not course_errors:
|
||||
None
|
||||
%else:
|
||||
<ul>
|
||||
% for (summary, err) in course_errors:
|
||||
<li>${summary | h}
|
||||
@@ -118,8 +209,10 @@ table.stat_table td {
|
||||
</li>
|
||||
% endfor
|
||||
</ul>
|
||||
%endif
|
||||
</div>
|
||||
% endif
|
||||
% endif
|
||||
%endif
|
||||
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,11 @@
|
||||
<div id="register_message"></div>
|
||||
%endif
|
||||
%else:
|
||||
<a href="#signup-modal" class="register" rel="leanModal" data-notice='You must Sign Up or <a href="#login-modal" rel="leanModal">Log In</a> to enroll.'>Register for ${course.number}</a>
|
||||
<a href="#signup-modal" class="register" rel="leanModal" data-notice='You must Sign Up
|
||||
% if not settings.MITX_FEATURES['DISABLE_LOGIN_BUTTON']:
|
||||
or <a href="#login-modal" rel="leanModal">Log In</a>
|
||||
% endif
|
||||
to enroll.'>Register for ${course.number}</a>
|
||||
%endif
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<reload_dir>[^/]+)$', 'lms_migration.migrate.manage_modulestores'),
|
||||
url(r'^migrate/reload/(?P<reload_dir>[^/]+)/(?P<commit_id>[^/]+)$', 'lms_migration.migrate.manage_modulestores'),
|
||||
url(r'^gitreload$', 'lms_migration.migrate.gitreload'),
|
||||
url(r'^gitreload/(?P<reload_dir>[^/]+)$', 'lms_migration.migrate.gitreload'),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user