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'/> + + <%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

-
- +

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

+
${djangopid}
+ + + + + +##----------------------------------------------------------------------------- +%if modeflag.get('Grades'):

Gradebook +

Grade summary +

+

+

+

+

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

Select a problem and an action: +

+ +

+ +

+

+ +

+ +

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

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


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

+ %endif %endif

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

@@ -99,14 +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'), )