Commit 3d44c83f by Miles Steele

Merge pull request #322 from edx/feature/msteele/instrdash

Instructor Dashboard v2 (disabled)
parents eb9f0347 4b671d11
......@@ -83,4 +83,4 @@ Ian Hoover <ihoover@edx.org>
Mukul Goyal <miki@edx.org>
Robert Marks <rmarks@edx.org>
Yarko Tymciurak <yarkot1@gmail.com>
Miles Steele <miles@milessteele.com>
/*
IMPORTANT:
In order to preserve the uniform grid appearance, all cell styles need to have padding, margin and border sizes.
No built-in (selected, editable, highlight, flashing, invalid, loading, :focus) or user-specified CSS
classes should alter those!
*/
.slick-header.ui-state-default, .slick-headerrow.ui-state-default {
width: 100%;
overflow: hidden;
border-left: 0px;
}
.slick-header-columns, .slick-headerrow-columns {
position: relative;
white-space: nowrap;
cursor: default;
overflow: hidden;
}
.slick-header-column.ui-state-default {
position: relative;
display: inline-block;
overflow: hidden;
-o-text-overflow: ellipsis;
text-overflow: ellipsis;
height: 16px;
line-height: 16px;
margin: 0;
padding: 4px;
border-right: 1px solid silver;
border-left: 0px;
border-top: 0px;
border-bottom: 0px;
float: left;
}
.slick-headerrow-column.ui-state-default {
padding: 4px;
}
.slick-header-column-sorted {
font-style: italic;
}
.slick-sort-indicator {
display: inline-block;
width: 8px;
height: 5px;
margin-left: 4px;
margin-top: 6px;
float: left;
}
.slick-sort-indicator-desc {
background: url(images/sort-desc.gif);
}
.slick-sort-indicator-asc {
background: url(images/sort-asc.gif);
}
.slick-resizable-handle {
position: absolute;
font-size: 0.1px;
display: block;
cursor: col-resize;
width: 4px;
right: 0px;
top: 0;
height: 100%;
}
.slick-sortable-placeholder {
background: silver;
}
.grid-canvas {
position: relative;
outline: 0;
}
.slick-row.ui-widget-content, .slick-row.ui-state-active {
position: absolute;
border: 0px;
width: 100%;
}
.slick-cell, .slick-headerrow-column {
position: absolute;
border: 1px solid transparent;
border-right: 1px dotted silver;
border-bottom-color: silver;
overflow: hidden;
-o-text-overflow: ellipsis;
text-overflow: ellipsis;
vertical-align: middle;
z-index: 1;
padding: 1px 2px 2px 1px;
margin: 0;
white-space: nowrap;
cursor: default;
}
.slick-group {
}
.slick-group-toggle {
display: inline-block;
}
.slick-cell.highlighted {
background: lightskyblue;
background: rgba(0, 0, 255, 0.2);
-webkit-transition: all 0.5s;
-moz-transition: all 0.5s;
-o-transition: all 0.5s;
transition: all 0.5s;
}
.slick-cell.flashing {
border: 1px solid red !important;
}
.slick-cell.editable {
z-index: 11;
overflow: visible;
background: white;
border-color: black;
border-style: solid;
}
.slick-cell:focus {
outline: none;
}
.slick-reorder-proxy {
display: inline-block;
background: blue;
opacity: 0.15;
filter: alpha(opacity = 15);
cursor: move;
}
.slick-reorder-guide {
display: inline-block;
height: 2px;
background: blue;
opacity: 0.7;
filter: alpha(opacity = 70);
}
.slick-selection {
z-index: 10;
position: absolute;
border: 2px dashed black;
}
/*!
* jquery.event.drop - v 2.2
* Copyright (c) 2010 Three Dub Media - http://threedubmedia.com
* Open Source MIT License - http://threedubmedia.com/code/license
*/
// Created: 2008-06-04
// Updated: 2012-05-21
// REQUIRES: jquery 1.7.x, event.drag 2.2
;(function($){ // secure $ jQuery alias
// Events: drop, dropstart, dropend
// add the jquery instance method
$.fn.drop = function( str, arg, opts ){
// figure out the event type
var type = typeof str == "string" ? str : "",
// figure out the event handler...
fn = $.isFunction( str ) ? str : $.isFunction( arg ) ? arg : null;
// fix the event type
if ( type.indexOf("drop") !== 0 )
type = "drop"+ type;
// were options passed
opts = ( str == fn ? arg : opts ) || {};
// trigger or bind event handler
return fn ? this.bind( type, opts, fn ) : this.trigger( type );
};
// DROP MANAGEMENT UTILITY
// returns filtered drop target elements, caches their positions
$.drop = function( opts ){
opts = opts || {};
// safely set new options...
drop.multi = opts.multi === true ? Infinity :
opts.multi === false ? 1 : !isNaN( opts.multi ) ? opts.multi : drop.multi;
drop.delay = opts.delay || drop.delay;
drop.tolerance = $.isFunction( opts.tolerance ) ? opts.tolerance :
opts.tolerance === null ? null : drop.tolerance;
drop.mode = opts.mode || drop.mode || 'intersect';
};
// local refs (increase compression)
var $event = $.event,
$special = $event.special,
// configure the drop special event
drop = $.event.special.drop = {
// these are the default settings
multi: 1, // allow multiple drop winners per dragged element
delay: 20, // async timeout delay
mode: 'overlap', // drop tolerance mode
// internal cache
targets: [],
// the key name for stored drop data
datakey: "dropdata",
// prevent bubbling for better performance
noBubble: true,
// count bound related events
add: function( obj ){
// read the interaction data
var data = $.data( this, drop.datakey );
// count another realted event
data.related += 1;
},
// forget unbound related events
remove: function(){
$.data( this, drop.datakey ).related -= 1;
},
// configure the interactions
setup: function(){
// check for related events
if ( $.data( this, drop.datakey ) )
return;
// initialize the drop element data
var data = {
related: 0,
active: [],
anyactive: 0,
winner: 0,
location: {}
};
// store the drop data on the element
$.data( this, drop.datakey, data );
// store the drop target in internal cache
drop.targets.push( this );
},
// destroy the configure interaction
teardown: function(){
var data = $.data( this, drop.datakey ) || {};
// check for related events
if ( data.related )
return;
// remove the stored data
$.removeData( this, drop.datakey );
// reference the targeted element
var element = this;
// remove from the internal cache
drop.targets = $.grep( drop.targets, function( target ){
return ( target !== element );
});
},
// shared event handler
handler: function( event, dd ){
// local vars
var results, $targets;
// make sure the right data is available
if ( !dd )
return;
// handle various events
switch ( event.type ){
// draginit, from $.event.special.drag
case 'mousedown': // DROPINIT >>
case 'touchstart': // DROPINIT >>
// collect and assign the drop targets
$targets = $( drop.targets );
if ( typeof dd.drop == "string" )
$targets = $targets.filter( dd.drop );
// reset drop data winner properties
$targets.each(function(){
var data = $.data( this, drop.datakey );
data.active = [];
data.anyactive = 0;
data.winner = 0;
});
// set available target elements
dd.droppable = $targets;
// activate drop targets for the initial element being dragged
$special.drag.hijack( event, "dropinit", dd );
break;
// drag, from $.event.special.drag
case 'mousemove': // TOLERATE >>
case 'touchmove': // TOLERATE >>
drop.event = event; // store the mousemove event
if ( !drop.timer )
// monitor drop targets
drop.tolerate( dd );
break;
// dragend, from $.event.special.drag
case 'mouseup': // DROP >> DROPEND >>
case 'touchend': // DROP >> DROPEND >>
drop.timer = clearTimeout( drop.timer ); // delete timer
if ( dd.propagates ){
$special.drag.hijack( event, "drop", dd );
$special.drag.hijack( event, "dropend", dd );
}
break;
}
},
// returns the location positions of an element
locate: function( elem, index ){
var data = $.data( elem, drop.datakey ),
$elem = $( elem ),
posi = $elem.offset() || {},
height = $elem.outerHeight(),
width = $elem.outerWidth(),
location = {
elem: elem,
width: width,
height: height,
top: posi.top,
left: posi.left,
right: posi.left + width,
bottom: posi.top + height
};
// drag elements might not have dropdata
if ( data ){
data.location = location;
data.index = index;
data.elem = elem;
}
return location;
},
// test the location positions of an element against another OR an X,Y coord
contains: function( target, test ){ // target { location } contains test [x,y] or { location }
return ( ( test[0] || test.left ) >= target.left && ( test[0] || test.right ) <= target.right
&& ( test[1] || test.top ) >= target.top && ( test[1] || test.bottom ) <= target.bottom );
},
// stored tolerance modes
modes: { // fn scope: "$.event.special.drop" object
// target with mouse wins, else target with most overlap wins
'intersect': function( event, proxy, target ){
return this.contains( target, [ event.pageX, event.pageY ] ) ? // check cursor
1e9 : this.modes.overlap.apply( this, arguments ); // check overlap
},
// target with most overlap wins
'overlap': function( event, proxy, target ){
// calculate the area of overlap...
return Math.max( 0, Math.min( target.bottom, proxy.bottom ) - Math.max( target.top, proxy.top ) )
* Math.max( 0, Math.min( target.right, proxy.right ) - Math.max( target.left, proxy.left ) );
},
// proxy is completely contained within target bounds
'fit': function( event, proxy, target ){
return this.contains( target, proxy ) ? 1 : 0;
},
// center of the proxy is contained within target bounds
'middle': function( event, proxy, target ){
return this.contains( target, [ proxy.left + proxy.width * .5, proxy.top + proxy.height * .5 ] ) ? 1 : 0;
}
},
// sort drop target cache by by winner (dsc), then index (asc)
sort: function( a, b ){
return ( b.winner - a.winner ) || ( a.index - b.index );
},
// async, recursive tolerance execution
tolerate: function( dd ){
// declare local refs
var i, drp, drg, data, arr, len, elem,
// interaction iteration variables
x = 0, ia, end = dd.interactions.length,
// determine the mouse coords
xy = [ drop.event.pageX, drop.event.pageY ],
// custom or stored tolerance fn
tolerance = drop.tolerance || drop.modes[ drop.mode ];
// go through each passed interaction...
do if ( ia = dd.interactions[x] ){
// check valid interaction
if ( !ia )
return;
// initialize or clear the drop data
ia.drop = [];
// holds the drop elements
arr = [];
len = ia.droppable.length;
// determine the proxy location, if needed
if ( tolerance )
drg = drop.locate( ia.proxy );
// reset the loop
i = 0;
// loop each stored drop target
do if ( elem = ia.droppable[i] ){
data = $.data( elem, drop.datakey );
drp = data.location;
if ( !drp ) continue;
// find a winner: tolerance function is defined, call it
data.winner = tolerance ? tolerance.call( drop, drop.event, drg, drp )
// mouse position is always the fallback
: drop.contains( drp, xy ) ? 1 : 0;
arr.push( data );
} while ( ++i < len ); // loop
// sort the drop targets
arr.sort( drop.sort );
// reset the loop
i = 0;
// loop through all of the targets again
do if ( data = arr[ i ] ){
// winners...
if ( data.winner && ia.drop.length < drop.multi ){
// new winner... dropstart
if ( !data.active[x] && !data.anyactive ){
// check to make sure that this is not prevented
if ( $special.drag.hijack( drop.event, "dropstart", dd, x, data.elem )[0] !== false ){
data.active[x] = 1;
data.anyactive += 1;
}
// if false, it is not a winner
else
data.winner = 0;
}
// if it is still a winner
if ( data.winner )
ia.drop.push( data.elem );
}
// losers...
else if ( data.active[x] && data.anyactive == 1 ){
// former winner... dropend
$special.drag.hijack( drop.event, "dropend", dd, x, data.elem );
data.active[x] = 0;
data.anyactive -= 1;
}
} while ( ++i < len ); // loop
} while ( ++x < end ) // loop
// check if the mouse is still moving or is idle
if ( drop.last && xy[0] == drop.last.pageX && xy[1] == drop.last.pageY )
delete drop.timer; // idle, don't recurse
else // recurse
drop.timer = setTimeout(function(){
drop.tolerate( dd );
}, drop.delay );
// remember event, to compare idleness
drop.last = drop.event;
}
};
// share the same special event configuration with related events...
$special.dropinit = $special.dropstart = $special.dropend = drop;
})(jQuery); // confine scope
\ No newline at end of file
/***
* Contains basic SlickGrid formatters.
*
* NOTE: These are merely examples. You will most likely need to implement something more
* robust/extensible/localizable/etc. for your use!
*
* @module Formatters
* @namespace Slick
*/
(function ($) {
// register namespace
$.extend(true, window, {
"Slick": {
"Formatters": {
"PercentComplete": PercentCompleteFormatter,
"PercentCompleteBar": PercentCompleteBarFormatter,
"YesNo": YesNoFormatter,
"Checkmark": CheckmarkFormatter
}
}
});
function PercentCompleteFormatter(row, cell, value, columnDef, dataContext) {
if (value == null || value === "") {
return "-";
} else if (value < 50) {
return "<span style='color:red;font-weight:bold;'>" + value + "%</span>";
} else {
return "<span style='color:green'>" + value + "%</span>";
}
}
function PercentCompleteBarFormatter(row, cell, value, columnDef, dataContext) {
if (value == null || value === "") {
return "";
}
var color;
if (value < 30) {
color = "red";
} else if (value < 70) {
color = "silver";
} else {
color = "green";
}
return "<span class='percent-complete-bar' style='background:" + color + ";width:" + value + "%'></span>";
}
function YesNoFormatter(row, cell, value, columnDef, dataContext) {
return value ? "Yes" : "No";
}
function CheckmarkFormatter(row, cell, value, columnDef, dataContext) {
return value ? "<img src='../images/tick.png'>" : "";
}
})(jQuery);
This source diff could not be displayed because it is too large. You can view the blob instead.
(function ($) {
$.extend(true, window, {
Slick: {
Data: {
GroupItemMetadataProvider: GroupItemMetadataProvider
}
}
});
/***
* Provides item metadata for group (Slick.Group) and totals (Slick.Totals) rows produced by the DataView.
* This metadata overrides the default behavior and formatting of those rows so that they appear and function
* correctly when processed by the grid.
*
* This class also acts as a grid plugin providing event handlers to expand & collapse groups.
* If "grid.registerPlugin(...)" is not called, expand & collapse will not work.
*
* @class GroupItemMetadataProvider
* @module Data
* @namespace Slick.Data
* @constructor
* @param options
*/
function GroupItemMetadataProvider(options) {
var _grid;
var _defaults = {
groupCssClass: "slick-group",
groupTitleCssClass: "slick-group-title",
totalsCssClass: "slick-group-totals",
groupFocusable: true,
totalsFocusable: false,
toggleCssClass: "slick-group-toggle",
toggleExpandedCssClass: "expanded",
toggleCollapsedCssClass: "collapsed",
enableExpandCollapse: true
};
options = $.extend(true, {}, _defaults, options);
function defaultGroupCellFormatter(row, cell, value, columnDef, item) {
if (!options.enableExpandCollapse) {
return item.title;
}
var indentation = item.level * 15 + "px";
return "<span class='" + options.toggleCssClass + " " +
(item.collapsed ? options.toggleCollapsedCssClass : options.toggleExpandedCssClass) +
"' style='margin-left:" + indentation +"'>" +
"</span>" +
"<span class='" + options.groupTitleCssClass + "' level='" + item.level + "'>" +
item.title +
"</span>";
}
function defaultTotalsCellFormatter(row, cell, value, columnDef, item) {
return (columnDef.groupTotalsFormatter && columnDef.groupTotalsFormatter(item, columnDef)) || "";
}
function init(grid) {
_grid = grid;
_grid.onClick.subscribe(handleGridClick);
_grid.onKeyDown.subscribe(handleGridKeyDown);
}
function destroy() {
if (_grid) {
_grid.onClick.unsubscribe(handleGridClick);
_grid.onKeyDown.unsubscribe(handleGridKeyDown);
}
}
function handleGridClick(e, args) {
var item = this.getDataItem(args.row);
if (item && item instanceof Slick.Group && $(e.target).hasClass(options.toggleCssClass)) {
if (item.collapsed) {
this.getData().expandGroup(item.groupingKey);
} else {
this.getData().collapseGroup(item.groupingKey);
}
e.stopImmediatePropagation();
e.preventDefault();
}
}
// TODO: add -/+ handling
function handleGridKeyDown(e, args) {
if (options.enableExpandCollapse && (e.which == $.ui.keyCode.SPACE)) {
var activeCell = this.getActiveCell();
if (activeCell) {
var item = this.getDataItem(activeCell.row);
if (item && item instanceof Slick.Group) {
if (item.collapsed) {
this.getData().expandGroup(item.groupingKey);
} else {
this.getData().collapseGroup(item.groupingKey);
}
e.stopImmediatePropagation();
e.preventDefault();
}
}
}
}
function getGroupRowMetadata(item) {
return {
selectable: false,
focusable: options.groupFocusable,
cssClasses: options.groupCssClass,
columns: {
0: {
colspan: "*",
formatter: defaultGroupCellFormatter,
editor: null
}
}
};
}
function getTotalsRowMetadata(item) {
return {
selectable: false,
focusable: options.totalsFocusable,
cssClasses: options.totalsCssClass,
formatter: defaultTotalsCellFormatter,
editor: null
};
}
return {
"init": init,
"destroy": destroy,
"getGroupRowMetadata": getGroupRowMetadata,
"getTotalsRowMetadata": getTotalsRowMetadata
};
}
})(jQuery);
(function ($) {
/***
* A sample AJAX data store implementation.
* Right now, it's hooked up to load all Apple-related Digg stories, but can
* easily be extended to support and JSONP-compatible backend that accepts paging parameters.
*/
function RemoteModel() {
// private
var PAGESIZE = 50;
var data = {length: 0};
var searchstr = "apple";
var sortcol = null;
var sortdir = 1;
var h_request = null;
var req = null; // ajax request
// events
var onDataLoading = new Slick.Event();
var onDataLoaded = new Slick.Event();
function init() {
}
function isDataLoaded(from, to) {
for (var i = from; i <= to; i++) {
if (data[i] == undefined || data[i] == null) {
return false;
}
}
return true;
}
function clear() {
for (var key in data) {
delete data[key];
}
data.length = 0;
}
function ensureData(from, to) {
if (req) {
req.abort();
for (var i = req.fromPage; i <= req.toPage; i++)
data[i * PAGESIZE] = undefined;
}
if (from < 0) {
from = 0;
}
var fromPage = Math.floor(from / PAGESIZE);
var toPage = Math.floor(to / PAGESIZE);
while (data[fromPage * PAGESIZE] !== undefined && fromPage < toPage)
fromPage++;
while (data[toPage * PAGESIZE] !== undefined && fromPage < toPage)
toPage--;
if (fromPage > toPage || ((fromPage == toPage) && data[fromPage * PAGESIZE] !== undefined)) {
// TODO: look-ahead
return;
}
var url = "http://services.digg.com/search/stories?query=" + searchstr + "&offset=" + (fromPage * PAGESIZE) + "&count=" + (((toPage - fromPage) * PAGESIZE) + PAGESIZE) + "&appkey=http://slickgrid.googlecode.com&type=javascript";
switch (sortcol) {
case "diggs":
url += ("&sort=" + ((sortdir > 0) ? "digg_count-asc" : "digg_count-desc"));
break;
}
if (h_request != null) {
clearTimeout(h_request);
}
h_request = setTimeout(function () {
for (var i = fromPage; i <= toPage; i++)
data[i * PAGESIZE] = null; // null indicates a 'requested but not available yet'
onDataLoading.notify({from: from, to: to});
req = $.jsonp({
url: url,
callbackParameter: "callback",
cache: true, // Digg doesn't accept the autogenerated cachebuster param
success: onSuccess,
error: function () {
onError(fromPage, toPage)
}
});
req.fromPage = fromPage;
req.toPage = toPage;
}, 50);
}
function onError(fromPage, toPage) {
alert("error loading pages " + fromPage + " to " + toPage);
}
function onSuccess(resp) {
var from = this.fromPage * PAGESIZE, to = from + resp.count;
data.length = parseInt(resp.total);
for (var i = 0; i < resp.stories.length; i++) {
data[from + i] = resp.stories[i];
data[from + i].index = from + i;
}
req = null;
onDataLoaded.notify({from: from, to: to});
}
function reloadData(from, to) {
for (var i = from; i <= to; i++)
delete data[i];
ensureData(from, to);
}
function setSort(column, dir) {
sortcol = column;
sortdir = dir;
clear();
}
function setSearch(str) {
searchstr = str;
clear();
}
init();
return {
// properties
"data": data,
// methods
"clear": clear,
"isDataLoaded": isDataLoaded,
"ensureData": ensureData,
"reloadData": reloadData,
"setSort": setSort,
"setSearch": setSearch,
// events
"onDataLoading": onDataLoading,
"onDataLoaded": onDataLoaded
};
}
// Slick.Data.RemoteModel
$.extend(true, window, { Slick: { Data: { RemoteModel: RemoteModel }}});
})(jQuery);
\ No newline at end of file
"""
Student and course analytics.
Serve miscellaneous course and student data
"""
from django.contrib.auth.models import User
import xmodule.graders as xmgraders
STUDENT_FEATURES = ('username', 'first_name', 'last_name', 'is_staff', 'email')
PROFILE_FEATURES = ('name', 'language', 'location', 'year_of_birth', 'gender',
'level_of_education', 'mailing_address', 'goals')
AVAILABLE_FEATURES = STUDENT_FEATURES + PROFILE_FEATURES
def enrolled_students_features(course_id, features):
"""
Return list of student features as dictionaries.
enrolled_students_features(course_id, ['username, first_name'])
would return [
{'username': 'username1', 'first_name': 'firstname1'}
{'username': 'username2', 'first_name': 'firstname2'}
{'username': 'username3', 'first_name': 'firstname3'}
]
"""
students = User.objects.filter(courseenrollment__course_id=course_id)\
.order_by('username').select_related('profile')
def extract_student(student, features):
""" convert student to dictionary """
student_features = [x for x in STUDENT_FEATURES if x in features]
profile_features = [x for x in PROFILE_FEATURES if x in features]
student_dict = dict((feature, getattr(student, feature))
for feature in student_features)
profile = student.profile
if profile is not None:
profile_dict = dict((feature, getattr(profile, feature))
for feature in profile_features)
student_dict.update(profile_dict)
return student_dict
return [extract_student(student, features) for student in students]
def dump_grading_context(course):
"""
Render information about course grading context
(e.g. which problems are graded in what assignments)
Useful for debugging grading_policy.json and policy.json
Returns HTML string
"""
hbar = "{}\n".format("-" * 77)
msg = hbar
msg += "Course grader:\n"
msg += '%s\n' % course.grader.__class__
graders = {}
if isinstance(course.grader, xmgraders.WeightedSubsectionsGrader):
msg += '\n'
msg += "Graded sections:\n"
for subgrader, category, weight in course.grader.sections:
msg += " subgrader=%s, type=%s, category=%s, weight=%s\n"\
% (subgrader.__class__, subgrader.type, category, weight)
subgrader.index = 1
graders[subgrader.type] = subgrader
msg += hbar
msg += "Listing grading context for course %s\n" % course.id
gcontext = course.grading_context
msg += "graded sections:\n"
msg += '%s\n' % gcontext['graded_sections'].keys()
for (gsomething, gsvals) in gcontext['graded_sections'].items():
msg += "--> Section %s:\n" % (gsomething)
for sec in gsvals:
sdesc = sec['section_descriptor']
frmat = getattr(sdesc.lms, 'format', None)
aname = ''
if frmat in graders:
gform = graders[frmat]
aname = '%s %02d' % (gform.short_label, gform.index)
gform.index += 1
elif sdesc.display_name in graders:
gform = graders[sdesc.display_name]
aname = '%s' % gform.short_label
notes = ''
if getattr(sdesc, 'score_by_attempt', False):
notes = ', score by attempt!'
msg += " %s (format=%s, Assignment=%s%s)\n"\
% (sdesc.display_name, frmat, aname, notes)
msg += "all descriptors:\n"
msg += "length=%d\n" % len(gcontext['all_descriptors'])
msg = '<pre>%s</pre>' % msg.replace('<', '&lt;')
return msg
"""
Student and course analytics.
Format and create csv responses
"""
import csv
from django.http import HttpResponse
def create_csv_response(filename, header, datarows):
"""
Create an HttpResponse with an attached .csv file
header e.g. ['Name', 'Email']
datarows e.g. [['Jim', 'jim@edy.org'], ['Jake', 'jake@edy.org'], ...]
"""
response = HttpResponse(mimetype='text/csv')
response['Content-Disposition'] = 'attachment; filename={0}'\
.format(filename)
csvwriter = csv.writer(
response,
dialect='excel',
quotechar='"',
quoting=csv.QUOTE_ALL)
csvwriter.writerow(header)
for datarow in datarows:
encoded_row = [unicode(s).encode('utf-8') for s in datarow]
csvwriter.writerow(encoded_row)
return response
def format_dictlist(dictlist, features):
"""
Convert a list of dictionaries to be compatible with create_csv_response
`dictlist` is a list of dictionaries
all dictionaries should have keys from features
`features` is a list of features
example code:
dictlist = [
{
'label1': 'value-1,1',
'label2': 'value-1,2',
'label3': 'value-1,3',
'label4': 'value-1,4',
},
{
'label1': 'value-2,1',
'label2': 'value-2,2',
'label3': 'value-2,3',
'label4': 'value-2,4',
}
]
header, datarows = format_dictlist(dictlist, ['label1', 'label4'])
# results in
header = ['label1', 'label4']
datarows = [['value-1,1', 'value-1,4'],
['value-2,1', 'value-2,4']]
}
"""
def dict_to_entry(dct):
""" Convert dictionary to a list for a csv row """
relevant_items = [(k, v) for (k, v) in dct.items() if k in features]
ordered = sorted(relevant_items, key=lambda (k, v): header.index(k))
vals = [v for (_, v) in ordered]
return vals
header = features
datarows = map(dict_to_entry, dictlist)
return header, datarows
def format_instances(instances, features):
"""
Convert a list of instances into a header list and datarows list.
`header` is just `features` e.g. ['username', 'email']
`datarows` is a list of lists, each sublist representing a row in a table
e.g. [['username1', 'email1@email.com'], ['username2', 'email2@email.com']]
for `instances` of length 2.
`instances` is a list of instances, e.g. list of User's
`features` is a list of features
a feature is a string for which getattr(obj, feature) is valid
Returns header and datarows, formatted for input in create_csv_response
"""
header = features
datarows = [[getattr(x, f) for f in features] for x in instances]
return header, datarows
"""
Profile Distributions
Aggregate sums for values of fields in students profiles.
For example:
The distribution in a course for gender might look like:
'gender': {
'type': 'EASY_CHOICE',
'data': {
'no_data': 1234,
'm': 5678,
'o': 2134,
'f': 5678
},
'display_names': {
'no_data': 'No Data',
'm': 'Male',
'o': 'Other',
'f': 'Female'
}
"""
from django.db.models import Count
from student.models import CourseEnrollment, UserProfile
# choices with a restricted domain, e.g. level_of_education
_EASY_CHOICE_FEATURES = ('gender', 'level_of_education')
# choices with a larger domain e.g. year_of_birth
_OPEN_CHOICE_FEATURES = ('year_of_birth',)
AVAILABLE_PROFILE_FEATURES = _EASY_CHOICE_FEATURES + _OPEN_CHOICE_FEATURES
DISPLAY_NAMES = {
'gender': 'Gender',
'level_of_education': 'Level of Education',
'year_of_birth': 'Year Of Birth',
}
class ProfileDistribution(object):
"""
Container for profile distribution data
`feature` is the name of the distribution feature
`feature_display_name` is the display name of feature
`data` is a dictionary of the distribution
`type` is either 'EASY_CHOICE' or 'OPEN_CHOICE'
`choices_display_names` is a dict if the distribution is an 'EASY_CHOICE'
"""
class ValidationError(ValueError):
""" Error thrown if validation fails. """
pass
def __init__(self, feature):
self.feature = feature
self.feature_display_name = DISPLAY_NAMES.get(feature, feature)
# to be set later
self.type = None
self.data = None
self.choices_display_names = None
def validate(self):
"""
Validate this profile distribution.
Throws ProfileDistribution.ValidationError
"""
def validation_assert(predicate):
""" Throw a ValidationError if false. """
if not predicate:
raise ProfileDistribution.ValidationError()
validation_assert(isinstance(self.feature, str))
validation_assert(self.feature in DISPLAY_NAMES)
validation_assert(isinstance(self.feature_display_name, str))
validation_assert(self.type in ['EASY_CHOICE', 'OPEN_CHOICE'])
validation_assert(isinstance(self.data, dict))
if self.type == 'EASY_CHOICE':
validation_assert(isinstance(self.choices_display_names, dict))
def profile_distribution(course_id, feature):
"""
Retrieve distribution of students over a given feature.
feature is one of AVAILABLE_PROFILE_FEATURES.
Returns a ProfileDistribution instance.
NOTE: no_data will appear as a key instead of None/null to adhere to the json spec.
data types are EASY_CHOICE or OPEN_CHOICE
"""
if not feature in AVAILABLE_PROFILE_FEATURES:
raise ValueError(
"unsupported feature requested for distribution '{}'".format(
feature)
)
prd = ProfileDistribution(feature)
if feature in _EASY_CHOICE_FEATURES:
prd.type = 'EASY_CHOICE'
if feature == 'gender':
raw_choices = UserProfile.GENDER_CHOICES
elif feature == 'level_of_education':
raw_choices = UserProfile.LEVEL_OF_EDUCATION_CHOICES
# short name and display name (full) of the choices.
choices = [(short, full)
for (short, full) in raw_choices] + [('no_data', 'No Data')]
def get_filter(feature, value):
""" Get the orm filter parameters for a feature. """
return {
'gender': {'user__profile__gender': value},
'level_of_education': {'user__profile__level_of_education': value},
}[feature]
def get_count(feature, value):
""" Get the count of enrolled students matching the feature value. """
return CourseEnrollment.objects.filter(
course_id=course_id,
**get_filter(feature, value)
).count()
distribution = {}
for (short, full) in choices:
# handle no data case
if short == 'no_data':
distribution['no_data'] = 0
distribution['no_data'] += get_count(feature, None)
distribution['no_data'] += get_count(feature, '')
else:
distribution[short] = get_count(feature, short)
prd.data = distribution
prd.choices_display_names = dict(choices)
elif feature in _OPEN_CHOICE_FEATURES:
prd.type = 'OPEN_CHOICE'
profiles = UserProfile.objects.filter(
user__courseenrollment__course_id=course_id
)
query_distribution = profiles.values(
feature).annotate(Count(feature)).order_by()
# query_distribution is of the form [{'featureval': 'value1', 'featureval__count': 4},
# {'featureval': 'value2', 'featureval__count': 2}, ...]
distribution = dict((vald[feature], vald[feature + '__count'])
for vald in query_distribution)
# distribution is of the form {'value1': 4, 'value2': 2, ...}
# change none to no_data for valid json key
if None in distribution:
# django does not properly count NULL values when using annotate Count
# so
# distribution['no_data'] = distribution.pop(None)
# would always be 0.
# Correctly count null values
distribution['no_data'] = profiles.filter(
**{feature: None}
).count()
prd.data = distribution
prd.validate()
return prd
"""
Tests for instructor.basic
"""
from django.test import TestCase
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from analytics.basic import enrolled_students_features, AVAILABLE_FEATURES, STUDENT_FEATURES, PROFILE_FEATURES
class TestAnalyticsBasic(TestCase):
""" Test basic analytics functions. """
def setUp(self):
self.course_id = 'some/robot/course/id'
self.users = tuple(UserFactory() for _ in xrange(30))
self.ces = tuple(CourseEnrollment.objects.create(course_id=self.course_id, user=user) for user in self.users)
def test_enrolled_students_features_username(self):
self.assertIn('username', AVAILABLE_FEATURES)
userreports = enrolled_students_features(self.course_id, ['username'])
self.assertEqual(len(userreports), len(self.users))
for userreport in userreports:
self.assertEqual(userreport.keys(), ['username'])
self.assertIn(userreport['username'], [user.username for user in self.users])
def test_enrolled_students_features_keys(self):
query_features = ('username', 'name', 'email')
for feature in query_features:
self.assertIn(feature, AVAILABLE_FEATURES)
userreports = enrolled_students_features(self.course_id, query_features)
self.assertEqual(len(userreports), len(self.users))
for userreport in userreports:
self.assertEqual(set(userreport.keys()), set(query_features))
self.assertIn(userreport['username'], [user.username for user in self.users])
self.assertIn(userreport['email'], [user.email for user in self.users])
self.assertIn(userreport['name'], [user.profile.name for user in self.users])
def test_available_features(self):
self.assertEqual(len(AVAILABLE_FEATURES), len(STUDENT_FEATURES + PROFILE_FEATURES))
self.assertEqual(set(AVAILABLE_FEATURES), set(STUDENT_FEATURES + PROFILE_FEATURES))
""" Tests for analytics.csvs """
from django.test import TestCase
from nose.tools import raises
from analytics.csvs import create_csv_response, format_dictlist, format_instances
class TestAnalyticsCSVS(TestCase):
""" Test analytics rendering of csv files."""
def test_create_csv_response_nodata(self):
header = ['Name', 'Email']
datarows = []
res = create_csv_response('robot.csv', header, datarows)
self.assertEqual(res['Content-Type'], 'text/csv')
self.assertEqual(res['Content-Disposition'], 'attachment; filename={0}'.format('robot.csv'))
self.assertEqual(res.content.strip(), '"Name","Email"')
def test_create_csv_response(self):
header = ['Name', 'Email']
datarows = [['Jim', 'jim@edy.org'], ['Jake', 'jake@edy.org'], ['Jeeves', 'jeeves@edy.org']]
res = create_csv_response('robot.csv', header, datarows)
self.assertEqual(res['Content-Type'], 'text/csv')
self.assertEqual(res['Content-Disposition'], 'attachment; filename={0}'.format('robot.csv'))
self.assertEqual(res.content.strip(), '"Name","Email"\r\n"Jim","jim@edy.org"\r\n"Jake","jake@edy.org"\r\n"Jeeves","jeeves@edy.org"')
def test_create_csv_response_empty(self):
header = []
datarows = []
res = create_csv_response('robot.csv', header, datarows)
self.assertEqual(res['Content-Type'], 'text/csv')
self.assertEqual(res['Content-Disposition'], 'attachment; filename={0}'.format('robot.csv'))
self.assertEqual(res.content.strip(), '')
class TestAnalyticsFormatDictlist(TestCase):
""" Test format_dictlist method """
def test_format_dictlist(self):
dictlist = [
{
'label1': 'value-1,1',
'label2': 'value-1,2',
'label3': 'value-1,3',
'label4': 'value-1,4',
},
{
'label1': 'value-2,1',
'label2': 'value-2,2',
'label3': 'value-2,3',
'label4': 'value-2,4',
}
]
features = ['label1', 'label4']
header, datarows = format_dictlist(dictlist, features)
ideal_header = ['label1', 'label4']
ideal_datarows = [['value-1,1', 'value-1,4'],
['value-2,1', 'value-2,4']]
self.assertEqual(header, ideal_header)
self.assertEqual(datarows, ideal_datarows)
def test_format_dictlist_empty(self):
header, datarows = format_dictlist([], [])
self.assertEqual(header, [])
self.assertEqual(datarows, [])
def test_create_csv_response(self):
header = ['Name', 'Email']
datarows = [['Jim', 'jim@edy.org'], ['Jake', 'jake@edy.org'], ['Jeeves', 'jeeves@edy.org']]
res = create_csv_response('robot.csv', header, datarows)
self.assertEqual(res['Content-Type'], 'text/csv')
self.assertEqual(res['Content-Disposition'], 'attachment; filename={0}'.format('robot.csv'))
self.assertEqual(res.content.strip(), '"Name","Email"\r\n"Jim","jim@edy.org"\r\n"Jake","jake@edy.org"\r\n"Jeeves","jeeves@edy.org"')
class TestAnalyticsFormatInstances(TestCase):
""" test format_instances method """
class TestDataClass(object):
""" Test class to generate objects for format_instances """
def __init__(self):
self.a_var = 'aval'
self.b_var = 'bval'
self.c_var = 'cval'
@property
def d_var(self):
""" accessor to see if they work too """
return 'dval'
def setUp(self):
self.instances = [self.TestDataClass() for _ in xrange(5)]
def test_format_instances_response(self):
features = ['a_var', 'c_var', 'd_var']
header, datarows = format_instances(self.instances, features)
self.assertEqual(header, ['a_var', 'c_var', 'd_var'])
self.assertEqual(datarows, [[
'aval',
'cval',
'dval',
] for _ in xrange(len(self.instances))])
def test_format_instances_response_noinstances(self):
features = ['a_var']
header, datarows = format_instances([], features)
self.assertEqual(header, features)
self.assertEqual(datarows, [])
def test_format_instances_response_nofeatures(self):
header, datarows = format_instances(self.instances, [])
self.assertEqual(header, [])
self.assertEqual(datarows, [[] for _ in xrange(len(self.instances))])
@raises(AttributeError)
def test_format_instances_response_nonexistantfeature(self):
format_instances(self.instances, ['robot_not_a_real_feature'])
""" Tests for analytics.distributions """
from django.test import TestCase
from nose.tools import raises
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from analytics.distributions import profile_distribution, AVAILABLE_PROFILE_FEATURES
class TestAnalyticsDistributions(TestCase):
'''Test analytics distribution gathering.'''
def setUp(self):
self.course_id = 'some/robot/course/id'
self.users = [UserFactory(
profile__gender=['m', 'f', 'o'][i % 3],
profile__year_of_birth=i + 1930
) for i in xrange(30)]
self.ces = [CourseEnrollment.objects.create(
course_id=self.course_id,
user=user
) for user in self.users]
@raises(ValueError)
def test_profile_distribution_bad_feature(self):
feature = 'robot-not-a-real-feature'
self.assertNotIn(feature, AVAILABLE_PROFILE_FEATURES)
profile_distribution(self.course_id, feature)
def test_profile_distribution_easy_choice(self):
feature = 'gender'
self.assertIn(feature, AVAILABLE_PROFILE_FEATURES)
distribution = profile_distribution(self.course_id, feature)
self.assertEqual(distribution.type, 'EASY_CHOICE')
self.assertEqual(distribution.data['no_data'], 0)
self.assertEqual(distribution.data['m'], len(self.users) / 3)
self.assertEqual(distribution.choices_display_names['m'], 'Male')
def test_profile_distribution_open_choice(self):
feature = 'year_of_birth'
self.assertIn(feature, AVAILABLE_PROFILE_FEATURES)
distribution = profile_distribution(self.course_id, feature)
print distribution
self.assertEqual(distribution.type, 'OPEN_CHOICE')
self.assertTrue(hasattr(distribution, 'choices_display_names'))
self.assertEqual(distribution.choices_display_names, None)
self.assertNotIn('no_data', distribution.data)
self.assertEqual(distribution.data[1930], 1)
class TestAnalyticsDistributionsNoData(TestCase):
'''Test analytics distribution gathering.'''
def setUp(self):
self.course_id = 'some/robot/course/id'
self.users = [UserFactory(
profile__year_of_birth=i + 1930,
) for i in xrange(5)]
self.nodata_users = [UserFactory(
profile__year_of_birth=None,
profile__gender=[None, ''][i % 2]
) for i in xrange(4)]
self.users += self.nodata_users
self.ces = tuple(CourseEnrollment.objects.create(course_id=self.course_id, user=user) for user in self.users)
def test_profile_distribution_easy_choice_nodata(self):
feature = 'gender'
self.assertIn(feature, AVAILABLE_PROFILE_FEATURES)
distribution = profile_distribution(self.course_id, feature)
print distribution
self.assertEqual(distribution.type, 'EASY_CHOICE')
self.assertTrue(hasattr(distribution, 'choices_display_names'))
self.assertNotEqual(distribution.choices_display_names, None)
self.assertIn('no_data', distribution.data)
self.assertEqual(distribution.data['no_data'], len(self.nodata_users))
def test_profile_distribution_open_choice_nodata(self):
feature = 'year_of_birth'
self.assertIn(feature, AVAILABLE_PROFILE_FEATURES)
distribution = profile_distribution(self.course_id, feature)
print distribution
self.assertEqual(distribution.type, 'OPEN_CHOICE')
self.assertTrue(hasattr(distribution, 'choices_display_names'))
self.assertEqual(distribution.choices_display_names, None)
self.assertIn('no_data', distribution.data)
self.assertEqual(distribution.data['no_data'], len(self.nodata_users))
......@@ -305,6 +305,7 @@ def get_course_tabs(user, course, active_page):
tabs.append(CourseTab('Instructor',
reverse('instructor_dashboard', args=[course.id]),
active_page == 'instructor'))
return tabs
......
"""
Access control operations for use by instructor APIs.
Does not include any access control, be sure to check access before calling.
TO DO sync instructor and staff flags
e.g. should these be possible?
{instructor: true, staff: false}
{instructor: true, staff: true}
"""
import logging
from django.contrib.auth.models import Group
from courseware.access import (get_access_group_name,
course_beta_test_group_name)
from django_comment_common.models import Role
log = logging.getLogger(__name__)
def list_with_level(course, level):
"""
List users who have 'level' access.
`level` is in ['instructor', 'staff', 'beta'] for standard courses.
There could be other levels specific to the course.
If there is no Group for that course-level, returns an empty list
"""
if level == 'beta':
grpname = course_beta_test_group_name(course.location)
else:
grpname = get_access_group_name(course, level)
try:
return Group.objects.get(name=grpname).user_set.all()
except Group.DoesNotExist:
log.info("list_with_level called with non-existant group named {}".format(grpname))
return []
def allow_access(course, user, level):
"""
Allow user access to course modification.
`level` is one of ['instructor', 'staff', 'beta']
"""
_change_access(course, user, level, 'allow')
def revoke_access(course, user, level):
"""
Revoke access from user to course modification.
`level` is one of ['instructor', 'staff', 'beta']
"""
_change_access(course, user, level, 'revoke')
def _change_access(course, user, level, action):
"""
Change access of user.
`level` is one of ['instructor', 'staff', 'beta']
action is one of ['allow', 'revoke']
NOTE: will create a group if it does not yet exist.
"""
if level == 'beta':
grpname = course_beta_test_group_name(course.location)
elif level in ['instructor', 'staff']:
grpname = get_access_group_name(course, level)
else:
raise ValueError("unrecognized level '{}'".format(level))
group, _ = Group.objects.get_or_create(name=grpname)
if action == 'allow':
user.groups.add(group)
elif action == 'revoke':
user.groups.remove(group)
else:
raise ValueError("unrecognized action '{}'".format(action))
def update_forum_role_membership(course_id, user, rolename, action):
"""
Change forum access of user.
`rolename` is one of [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]
`action` is one of ['allow', 'revoke']
if `action` is bad, raises ValueError
if `rolename` does not exist, raises Role.DoesNotExist
"""
role = Role.objects.get(course_id=course_id, name=rolename)
if action == 'allow':
role.users.add(user)
elif action == 'revoke':
role.users.remove(user)
else:
raise ValueError("unrecognized action '{}'".format(action))
"""
Enrollment operations for use by instructor APIs.
Does not include any access control, be sure to check access before calling.
"""
import json
from django.contrib.auth.models import User
from student.models import CourseEnrollment, CourseEnrollmentAllowed
from courseware.models import StudentModule
class EmailEnrollmentState(object):
""" Store the complete enrollment state of an email in a class """
def __init__(self, course_id, email):
exists_user = User.objects.filter(email=email).exists()
exists_ce = CourseEnrollment.objects.filter(course_id=course_id, user__email=email).exists()
ceas = CourseEnrollmentAllowed.objects.filter(course_id=course_id, email=email).all()
exists_allowed = len(ceas) > 0
state_auto_enroll = exists_allowed and ceas[0].auto_enroll
self.user = exists_user
self.enrollment = exists_ce
self.allowed = exists_allowed
self.auto_enroll = bool(state_auto_enroll)
def __repr__(self):
return "{}(user={}, enrollment={}, allowed={}, auto_enroll={})".format(
self.__class__.__name__,
self.user,
self.enrollment,
self.allowed,
self.auto_enroll,
)
def to_dict(self):
"""
example: {
'user': False,
'enrollment': False,
'allowed': True,
'auto_enroll': True,
}
"""
return {
'user': self.user,
'enrollment': self.enrollment,
'allowed': self.allowed,
'auto_enroll': self.auto_enroll,
}
def enroll_email(course_id, student_email, auto_enroll=False):
"""
Enroll a student by email.
`student_email` is student's emails e.g. "foo@bar.com"
`auto_enroll` determines what is put in CourseEnrollmentAllowed.auto_enroll
if auto_enroll is set, then when the email registers, they will be
enrolled in the course automatically.
returns two EmailEnrollmentState's
representing state before and after the action.
"""
previous_state = EmailEnrollmentState(course_id, student_email)
if previous_state.user:
user = User.objects.get(email=student_email)
CourseEnrollment.objects.get_or_create(course_id=course_id, user=user)
else:
cea, _ = CourseEnrollmentAllowed.objects.get_or_create(course_id=course_id, email=student_email)
cea.auto_enroll = auto_enroll
cea.save()
after_state = EmailEnrollmentState(course_id, student_email)
return previous_state, after_state
def unenroll_email(course_id, student_email):
"""
Unenroll a student by email.
`student_email` is student's emails e.g. "foo@bar.com"
returns two EmailEnrollmentState's
representing state before and after the action.
"""
previous_state = EmailEnrollmentState(course_id, student_email)
if previous_state.enrollment:
CourseEnrollment.objects.get(course_id=course_id, user__email=student_email).delete()
if previous_state.allowed:
CourseEnrollmentAllowed.objects.get(course_id=course_id, email=student_email).delete()
after_state = EmailEnrollmentState(course_id, student_email)
return previous_state, after_state
def reset_student_attempts(course_id, student, module_state_key, delete_module=False):
"""
Reset student attempts for a problem. Optionally deletes all student state for the specified problem.
In the previous instructor dashboard it was possible to modify/delete
modules that were not problems. That has been disabled for safety.
`student` is a User
`problem_to_reset` is the name of a problem e.g. 'L2Node1'.
To build the module_state_key 'problem/' and course information will be appended to `problem_to_reset`.
Throws ValueError if `problem_state` is invalid JSON.
"""
module_to_reset = StudentModule.objects.get(student_id=student.id,
course_id=course_id,
module_state_key=module_state_key)
if delete_module:
module_to_reset.delete()
else:
_reset_module_attempts(module_to_reset)
def _reset_module_attempts(studentmodule):
"""
Reset the number of attempts on a studentmodule.
Throws ValueError if `problem_state` is invalid JSON.
"""
# load the state json
problem_state = json.loads(studentmodule.state)
# old_number_of_attempts = problem_state["attempts"]
problem_state["attempts"] = 0
# save
studentmodule.state = json.dumps(problem_state)
studentmodule.save()
......@@ -5,7 +5,7 @@
import csv
from instructor.views import get_student_grade_summary_data
from instructor.views.legacy import get_student_grade_summary_data
from courseware.courses import get_course_by_id
from xmodule.modulestore.django import modulestore
......
"""
Test instructor.access
"""
from nose.tools import raises
from django.contrib.auth.models import Group
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from django.test.utils import override_settings
from courseware.tests.modulestore_config import TEST_DATA_MONGO_MODULESTORE
from courseware.access import get_access_group_name
from django_comment_common.models import (Role,
FORUM_ROLE_MODERATOR)
from instructor.access import (allow_access,
revoke_access,
list_with_level,
update_forum_role_membership)
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestInstructorAccessList(ModuleStoreTestCase):
""" Test access listings. """
def setUp(self):
self.course = CourseFactory.create()
self.instructors = [UserFactory.create() for _ in xrange(4)]
for user in self.instructors:
allow_access(self.course, user, 'instructor')
self.beta_testers = [UserFactory.create() for _ in xrange(4)]
for user in self.beta_testers:
allow_access(self.course, user, 'beta')
def test_list_instructors(self):
instructors = list_with_level(self.course, 'instructor')
self.assertEqual(set(instructors), set(self.instructors))
def test_list_beta(self):
beta_testers = list_with_level(self.course, 'beta')
self.assertEqual(set(beta_testers), set(self.beta_testers))
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestInstructorAccessAllow(ModuleStoreTestCase):
""" Test access allow. """
def setUp(self):
self.course = CourseFactory.create()
def test_allow(self):
user = UserFactory()
allow_access(self.course, user, 'staff')
group = Group.objects.get(
name=get_access_group_name(self.course, 'staff')
)
self.assertIn(user, group.user_set.all())
def test_allow_twice(self):
user = UserFactory()
allow_access(self.course, user, 'staff')
allow_access(self.course, user, 'staff')
group = Group.objects.get(
name=get_access_group_name(self.course, 'staff')
)
self.assertIn(user, group.user_set.all())
def test_allow_beta(self):
""" Test allow beta against list beta. """
user = UserFactory()
allow_access(self.course, user, 'beta')
self.assertIn(user, list_with_level(self.course, 'beta'))
@raises(ValueError)
def test_allow_badlevel(self):
user = UserFactory()
allow_access(self.course, user, 'robot-not-a-level')
group = Group.objects.get(name=get_access_group_name(self.course, 'robot-not-a-level'))
self.assertIn(user, group.user_set.all())
@raises(Exception)
def test_allow_noneuser(self):
user = None
allow_access(self.course, user, 'staff')
group = Group.objects.get(name=get_access_group_name(self.course, 'staff'))
self.assertIn(user, group.user_set.all())
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestInstructorAccessRevoke(ModuleStoreTestCase):
""" Test access revoke. """
def setUp(self):
self.course = CourseFactory.create()
self.staff = [UserFactory.create() for _ in xrange(4)]
for user in self.staff:
allow_access(self.course, user, 'staff')
self.beta_testers = [UserFactory.create() for _ in xrange(4)]
for user in self.beta_testers:
allow_access(self.course, user, 'beta')
def test_revoke(self):
user = self.staff[0]
revoke_access(self.course, user, 'staff')
group = Group.objects.get(
name=get_access_group_name(self.course, 'staff')
)
self.assertNotIn(user, group.user_set.all())
def test_revoke_twice(self):
user = self.staff[0]
revoke_access(self.course, user, 'staff')
group = Group.objects.get(
name=get_access_group_name(self.course, 'staff')
)
self.assertNotIn(user, group.user_set.all())
def test_revoke_beta(self):
user = self.beta_testers[0]
revoke_access(self.course, user, 'beta')
self.assertNotIn(user, list_with_level(self.course, 'beta'))
@raises(ValueError)
def test_revoke_badrolename(self):
user = UserFactory()
revoke_access(self.course, user, 'robot-not-a-level')
group = Group.objects.get(
name=get_access_group_name(self.course, 'robot-not-a-level')
)
self.assertNotIn(user, group.user_set.all())
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestInstructorAccessForum(ModuleStoreTestCase):
"""
Test forum access control.
"""
def setUp(self):
self.course = CourseFactory.create()
self.mod_role = Role.objects.create(
course_id=self.course.id,
name=FORUM_ROLE_MODERATOR
)
self.moderators = [UserFactory.create() for _ in xrange(4)]
for user in self.moderators:
self.mod_role.users.add(user)
def test_allow(self):
user = UserFactory.create()
update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'allow')
self.assertIn(user, self.mod_role.users.all())
def test_allow_twice(self):
user = UserFactory.create()
update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'allow')
self.assertIn(user, self.mod_role.users.all())
update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'allow')
self.assertIn(user, self.mod_role.users.all())
@raises(Role.DoesNotExist)
def test_allow_badrole(self):
user = UserFactory.create()
update_forum_role_membership(self.course.id, user, 'robot-not-a-real-role', 'allow')
def test_revoke(self):
user = self.moderators[0]
update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'revoke')
self.assertNotIn(user, self.mod_role.users.all())
def test_revoke_twice(self):
user = self.moderators[0]
update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'revoke')
self.assertNotIn(user, self.mod_role.users.all())
update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'revoke')
self.assertNotIn(user, self.mod_role.users.all())
def test_revoke_notallowed(self):
user = UserFactory()
update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'revoke')
self.assertNotIn(user, self.mod_role.users.all())
@raises(Role.DoesNotExist)
def test_revoke_badrole(self):
user = self.moderators[0]
update_forum_role_membership(self.course.id, user, 'robot-not-a-real-role', 'allow')
@raises(ValueError)
def test_bad_mode(self):
user = UserFactory()
update_forum_role_membership(self.course.id, user, FORUM_ROLE_MODERATOR, 'robot-not-a-mode')
......@@ -44,9 +44,10 @@ class TestInstructorDashboardGradeDownloadCSV(LoginEnrollmentTestCase):
self.activate_user(self.instructor)
def make_instructor(course):
""" Create an instructor for the course. """
group_name = _course_staff_group_name(course.location)
g = Group.objects.create(name=group_name)
g.user_set.add(User.objects.get(email=self.instructor))
group = Group.objects.create(name=group_name)
group.user_set.add(User.objects.get(email=self.instructor))
make_instructor(self.toy)
......
......@@ -42,7 +42,7 @@ class TestGradebook(ModuleStoreTestCase):
metadata={'graded': True, 'format': 'Homework'}
)
self.users = [UserFactory() for _ in xrange(USER_COUNT)]
self.users = [UserFactory.create() for _ in xrange(USER_COUNT)]
for user in self.users:
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
......
......@@ -12,7 +12,7 @@ from student.tests.factories import UserFactory, CourseEnrollmentFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from instructor import views
from instructor.views import legacy
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestXss(ModuleStoreTestCase):
......@@ -47,7 +47,7 @@ class TestXss(ModuleStoreTestCase):
)
req.user = self._instructor
req.session = {}
resp = views.instructor_dashboard(req, self._course.id)
resp = legacy.instructor_dashboard(req, self._course.id)
respUnicode = resp.content.decode(settings.DEFAULT_CHARSET)
self.assertNotIn(self._evil_student.profile.name, respUnicode)
self.assertIn(escape(self._evil_student.profile.name), respUnicode)
......
"""
Instructor API endpoint urls.
"""
from django.conf.urls import patterns, url
urlpatterns = patterns('', # nopep8
url(r'^students_update_enrollment$',
'instructor.views.api.students_update_enrollment', name="students_update_enrollment"),
url(r'^list_course_role_members$',
'instructor.views.api.list_course_role_members', name="list_course_role_members"),
url(r'^modify_access$',
'instructor.views.api.modify_access', name="modify_access"),
url(r'^get_grading_config$',
'instructor.views.api.get_grading_config', name="get_grading_config"),
url(r'^get_students_features(?P<csv>/csv)?$',
'instructor.views.api.get_students_features', name="get_students_features"),
url(r'^get_distribution$',
'instructor.views.api.get_distribution', name="get_distribution"),
url(r'^get_student_progress_url$',
'instructor.views.api.get_student_progress_url', name="get_student_progress_url"),
url(r'^reset_student_attempts$',
'instructor.views.api.reset_student_attempts', name="reset_student_attempts"),
url(r'^rescore_problem$',
'instructor.views.api.rescore_problem', name="rescore_problem"),
url(r'^list_instructor_tasks$',
'instructor.views.api.list_instructor_tasks', name="list_instructor_tasks"),
url(r'^list_forum_members$',
'instructor.views.api.list_forum_members', name="list_forum_members"),
url(r'^update_forum_role_membership$',
'instructor.views.api.update_forum_role_membership', name="update_forum_role_membership"),
)
"""
Instructor Dashboard Views
"""
from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control
from mitxmako.shortcuts import render_to_response
from django.core.urlresolvers import reverse
from django.utils.html import escape
from django.http import Http404
from courseware.access import has_access
from courseware.courses import get_course_by_id
from django_comment_client.utils import has_forum_access
from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR
from xmodule.modulestore.django import modulestore
from student.models import CourseEnrollment
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def instructor_dashboard_2(request, course_id):
""" Display the instructor dashboard for a course. """
course = get_course_by_id(course_id, depth=None)
access = {
'admin': request.user.is_staff,
'instructor': has_access(request.user, course, 'instructor'),
'staff': has_access(request.user, course, 'staff'),
'forum_admin': has_forum_access(
request.user, course_id, FORUM_ROLE_ADMINISTRATOR
),
}
if not access['staff']:
raise Http404()
sections = [
_section_course_info(course_id),
_section_membership(course_id, access),
_section_student_admin(course_id, access),
_section_data_download(course_id),
_section_analytics(course_id),
]
context = {
'course': course,
'old_dashboard_url': reverse('instructor_dashboard', kwargs={'course_id': course_id}),
'sections': sections,
}
return render_to_response('instructor/instructor_dashboard_2/instructor_dashboard_2.html', context)
"""
Section functions starting with _section return a dictionary of section data.
The dictionary must include at least {
'section_key': 'circus_expo'
'section_display_name': 'Circus Expo'
}
section_key will be used as a css attribute, javascript tie-in, and template import filename.
section_display_name will be used to generate link titles in the nav bar.
""" # pylint: disable=W0105
def _section_course_info(course_id):
""" Provide data for the corresponding dashboard section """
course = get_course_by_id(course_id, depth=None)
section_data = {}
section_data['section_key'] = 'course_info'
section_data['section_display_name'] = 'Course Info'
section_data['course_id'] = course_id
section_data['course_display_name'] = course.display_name
section_data['enrollment_count'] = CourseEnrollment.objects.filter(course_id=course_id).count()
section_data['has_started'] = course.has_started()
section_data['has_ended'] = course.has_ended()
try:
advance = lambda memo, (letter, score): "{}: {}, ".format(letter, score) + memo
section_data['grade_cutoffs'] = reduce(advance, course.grade_cutoffs.items(), "")[:-2]
except Exception:
section_data['grade_cutoffs'] = "Not Available"
# section_data['offline_grades'] = offline_grades_available(course_id)
try:
section_data['course_errors'] = [(escape(a), '') for (a, _) in modulestore().get_item_errors(course.location)]
except Exception:
section_data['course_errors'] = [('Error fetching errors', '')]
return section_data
def _section_membership(course_id, access):
""" Provide data for the corresponding dashboard section """
section_data = {
'section_key': 'membership',
'section_display_name': 'Membership',
'access': access,
'enroll_button_url': reverse('students_update_enrollment', kwargs={'course_id': course_id}),
'unenroll_button_url': reverse('students_update_enrollment', kwargs={'course_id': course_id}),
'list_course_role_members_url': reverse('list_course_role_members', kwargs={'course_id': course_id}),
'modify_access_url': reverse('modify_access', kwargs={'course_id': course_id}),
'list_forum_members_url': reverse('list_forum_members', kwargs={'course_id': course_id}),
'update_forum_role_membership_url': reverse('update_forum_role_membership', kwargs={'course_id': course_id}),
}
return section_data
def _section_student_admin(course_id, access):
""" Provide data for the corresponding dashboard section """
section_data = {
'section_key': 'student_admin',
'section_display_name': 'Student Admin',
'access': access,
'get_student_progress_url_url': reverse('get_student_progress_url', kwargs={'course_id': course_id}),
'enrollment_url': reverse('students_update_enrollment', kwargs={'course_id': course_id}),
'reset_student_attempts_url': reverse('reset_student_attempts', kwargs={'course_id': course_id}),
'rescore_problem_url': reverse('rescore_problem', kwargs={'course_id': course_id}),
'list_instructor_tasks_url': reverse('list_instructor_tasks', kwargs={'course_id': course_id}),
}
return section_data
def _section_data_download(course_id):
""" Provide data for the corresponding dashboard section """
section_data = {
'section_key': 'data_download',
'section_display_name': 'Data Download',
'get_grading_config_url': reverse('get_grading_config', kwargs={'course_id': course_id}),
'get_students_features_url': reverse('get_students_features', kwargs={'course_id': course_id}),
}
return section_data
def _section_analytics(course_id):
""" Provide data for the corresponding dashboard section """
section_data = {
'section_key': 'analytics',
'section_display_name': 'Analytics',
'get_distribution_url': reverse('get_distribution', kwargs={'course_id': course_id}),
}
return section_data
......@@ -769,14 +769,15 @@ def instructor_dashboard(request, course_id):
'plots': plots, # psychometrics
'course_errors': modulestore().get_item_errors(course.location),
'instructor_tasks': instructor_tasks,
'djangopid': os.getpid(),
'mitx_version': getattr(settings, 'MITX_VERSION_STRING', ''),
'offline_grade_log': offline_grades_available(course_id),
'cohorts_ajax_url': reverse('cohorts', kwargs={'course_id': course_id}),
'analytics_results': analytics_results,
}
if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BETA_DASHBOARD'):
context['beta_dashboard_url'] = reverse('instructor_dashboard_2', kwargs={'course_id': course_id})
return render_to_response('courseware/instructor_dashboard.html', context)
......
......@@ -144,6 +144,9 @@ MITX_FEATURES = {
# Enable instructor dash to submit background tasks
'ENABLE_INSTRUCTOR_BACKGROUND_TASKS': True,
# Enable instructor dash beta version link
'ENABLE_INSTRUCTOR_BETA_DASHBOARD': False,
# Allow use of the hint managment instructor view.
'ENABLE_HINTER_INSTRUCTOR_VIEW': False,
......@@ -152,7 +155,7 @@ MITX_FEATURES = {
# Toggle to enable chat availability (configured on a per-course
# basis in Studio)
'ENABLE_CHAT': False
'ENABLE_CHAT': False,
}
# Used for A/B testing
......
......@@ -29,6 +29,7 @@ MITX_FEATURES['ENABLE_PSYCHOMETRICS'] = False # real-time psychometrics (eg i
MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = False
WIKI_ENABLED = True
......
......@@ -29,6 +29,8 @@ MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True
# Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it.
WIKI_ENABLED = True
......
# Analytics Section
# imports from other modules.
# wrap in (-> ... apply) to defer evaluation
# such that the value can be defined later than this assignment (file load order).
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
# Analytics Section
class Analytics
constructor: (@$section) ->
@$section.data 'wrapper', @
# gather elements
@$display = @$section.find '.distribution-display'
@$display_text = @$display.find '.distribution-display-text'
@$display_graph = @$display.find '.distribution-display-graph'
@$display_table = @$display.find '.distribution-display-table'
@$distribution_select = @$section.find 'select#distributions'
@$request_response_error = @$display.find '.request-response-error'
@populate_selector => @$distribution_select.change => @on_selector_change()
reset_display: ->
@$display_text.empty()
@$display_graph.empty()
@$display_table.empty()
@$request_response_error.empty()
# fetch and list available distributions
# `cb` is a callback to be run after
populate_selector: (cb) ->
# ask for no particular distribution to get list of available distribuitions.
@get_profile_distributions undefined,
# on error, print to console and dom.
error: std_ajax_err => @$request_response_error.text "Error getting available distributions."
success: (data) =>
# replace loading text in drop-down with "-- Select Distribution --"
@$distribution_select.find('option').eq(0).text "-- Select Distribution --"
# add all fetched available features to drop-down
for feature in data.available_features
opt = $ '<option/>',
text: data.feature_display_names[feature]
data:
feature: feature
@$distribution_select.append opt
# call callback if one was supplied
cb?()
# display data
on_selector_change: ->
opt = @$distribution_select.children('option:selected')
feature = opt.data 'feature'
@reset_display()
# only proceed if there is a feature attached to the selected option.
return unless feature
@get_profile_distributions feature,
error: std_ajax_err => @$request_response_error.text "Error getting distribution for '#{feature}'."
success: (data) =>
feature_res = data.feature_results
if feature_res.type is 'EASY_CHOICE'
# display on SlickGrid
options =
enableCellNavigation: true
enableColumnReorder: false
forceFitColumns: true
columns = [
id: feature
field: feature
name: feature
,
id: 'count'
field: 'count'
name: 'Count'
]
grid_data = _.map feature_res.data, (value, key) ->
datapoint = {}
datapoint[feature] = feature_res.choices_display_names[key]
datapoint['count'] = value
datapoint
table_placeholder = $ '<div/>', class: 'slickgrid'
@$display_table.append table_placeholder
grid = new Slick.Grid(table_placeholder, grid_data, columns, options)
else if feature_res.feature is 'year_of_birth'
graph_placeholder = $ '<div/>', class: 'year-of-birth'
@$display_graph.append graph_placeholder
graph_data = _.map feature_res.data, (value, key) -> [parseInt(key), value]
$.plot graph_placeholder, [
data: graph_data
]
else
console.warn("unable to show distribution #{feature_res.type}")
@$display_text.text 'Unavailable Metric Display\n' + JSON.stringify(feature_res)
# fetch distribution data from server.
# `handler` can be either a callback for success
# or a mapping e.g. {success: ->, error: ->, complete: ->}
get_profile_distributions: (feature, handler) ->
settings =
dataType: 'json'
url: @$distribution_select.data 'endpoint'
data: feature: feature
if typeof handler is 'function'
_.extend settings, success: handler
else
_.extend settings, handler
$.ajax settings
# slickgrid's layout collapses when rendered
# in an invisible div. use this method to reload
# the AuthList widget
refresh: ->
@on_selector_change()
# handler for when the section title is clicked.
onClickTitle: ->
@refresh()
# export for use
# create parent namespaces if they do not already exist.
# abort if underscore can not be found.
if _?
_.defaults window, InstructorDashboard: {}
_.defaults window.InstructorDashboard, sections: {}
_.defaults window.InstructorDashboard.sections,
Analytics: Analytics
# Course Info Section
# This is the implementation of the simplest section
# of the instructor dashboard.
# imports from other modules.
# wrap in (-> ... apply) to defer evaluation
# such that the value can be defined later than this assignment (file load order).
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
# A typical section object.
# constructed with $section, a jquery object
# which holds the section body container.
class CourseInfo
constructor: (@$section) ->
@$course_errors_wrapper = @$section.find '.course-errors-wrapper'
# if there are errors
if @$course_errors_wrapper.length
@$course_error_toggle = @$course_errors_wrapper.find '.toggle-wrapper'
@$course_error_toggle_text = @$course_error_toggle.find 'h2'
@$course_error_visibility_wrapper = @$course_errors_wrapper.find '.course-errors-visibility-wrapper'
@$course_errors = @$course_errors_wrapper.find '.course-error'
# append "(34)" to the course errors label
@$course_error_toggle_text.text @$course_error_toggle_text.text() + " (#{@$course_errors.length})"
# toggle .open class on errors
# to show and hide them.
@$course_error_toggle.click (e) =>
e.preventDefault()
if @$course_errors_wrapper.hasClass 'open'
@$course_errors_wrapper.removeClass 'open'
else
@$course_errors_wrapper.addClass 'open'
# export for use
# create parent namespaces if they do not already exist.
# abort if underscore can not be found.
if _?
_.defaults window, InstructorDashboard: {}
_.defaults window.InstructorDashboard, sections: {}
_.defaults window.InstructorDashboard.sections,
CourseInfo: CourseInfo
# Data Download Section
# imports from other modules.
# wrap in (-> ... apply) to defer evaluation
# such that the value can be defined later than this assignment (file load order).
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
# Data Download Section
class DataDownload
constructor: (@$section) ->
# gather elements
@$display = @$section.find '.data-display'
@$display_text = @$display.find '.data-display-text'
@$display_table = @$display.find '.data-display-table'
@$request_response_error = @$display.find '.request-response-error'
@$list_studs_btn = @$section.find("input[name='list-profiles']'")
@$grade_config_btn = @$section.find("input[name='dump-gradeconf']'")
# attach click handlers
# this handler binds to both the download
# and the csv button
@$list_studs_btn.click (e) =>
url = @$list_studs_btn.data 'endpoint'
# handle csv special case
if $(e.target).data 'csv'
# redirect the document to the csv file.
url += '/csv'
location.href = url
else
@clear_display()
@$display_table.text 'Loading...'
# fetch user list
$.ajax
dataType: 'json'
url: url
error: std_ajax_err =>
@clear_display()
@$request_response_error.text "Error getting student list."
success: (data) =>
@clear_display()
# display on a SlickGrid
options =
enableCellNavigation: true
enableColumnReorder: false
forceFitColumns: true
columns = ({id: feature, field: feature, name: feature} for feature in data.queried_features)
grid_data = data.students
$table_placeholder = $ '<div/>', class: 'slickgrid'
@$display_table.append $table_placeholder
grid = new Slick.Grid($table_placeholder, grid_data, columns, options)
# grid.autosizeColumns()
@$grade_config_btn.click (e) =>
url = @$grade_config_btn.data 'endpoint'
# display html from grading config endpoint
$.ajax
dataType: 'json'
url: url
error: std_ajax_err =>
@clear_display()
@$request_response_error.text "Error getting grading configuration."
success: (data) =>
@clear_display()
@$display_text.html data['grading_config_summary']
clear_display: ->
@$display_text.empty()
@$display_table.empty()
@$request_response_error.empty()
# export for use
# create parent namespaces if they do not already exist.
# abort if underscore can not be found.
if _?
_.defaults window, InstructorDashboard: {}
_.defaults window.InstructorDashboard, sections: {}
_.defaults window.InstructorDashboard.sections,
DataDownload: DataDownload
# Instructor Dashboard Tab Manager
# The instructor dashboard is broken into sections.
# Only one section is visible at a time,
# and is responsible for its own functionality.
#
# NOTE: plantTimeout (which is just setTimeout from util.coffee)
# is used frequently in the instructor dashboard to isolate
# failures. If one piece of code under a plantTimeout fails
# then it will not crash the rest of the dashboard.
#
# NOTE: The instructor dashboard currently does not
# use backbone. Just lots of jquery. This should be fixed.
#
# NOTE: Server endpoints in the dashboard are stored in
# the 'data-endpoint' attribute of relevant html elements.
# The urls are rendered there by a template.
#
# NOTE: For an example of what a section object should look like
# see course_info.coffee
# imports from other modules
# wrap in (-> ... apply) to defer evaluation
# such that the value can be defined later than this assignment (file load order).
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
# CSS classes
CSS_INSTRUCTOR_CONTENT = 'instructor-dashboard-content-2'
CSS_ACTIVE_SECTION = 'active-section'
CSS_IDASH_SECTION = 'idash-section'
CSS_INSTRUCTOR_NAV = 'instructor-nav'
# prefix for deep-linking
HASH_LINK_PREFIX = '#view-'
# once we're ready, check if this page is the instructor dashboard
$ =>
instructor_dashboard_content = $ ".#{CSS_INSTRUCTOR_CONTENT}"
if instructor_dashboard_content.length > 0
setup_instructor_dashboard instructor_dashboard_content
setup_instructor_dashboard_sections instructor_dashboard_content
# enable navigation bar
# handles hiding and showing sections
setup_instructor_dashboard = (idash_content) =>
# clickable section titles
links = idash_content.find(".#{CSS_INSTRUCTOR_NAV}").find('a')
for link in ($ link for link in links)
link.click (e) ->
e.preventDefault()
# deactivate all link & section styles
idash_content.find(".#{CSS_INSTRUCTOR_NAV}").children().removeClass CSS_ACTIVE_SECTION
idash_content.find(".#{CSS_IDASH_SECTION}").removeClass CSS_ACTIVE_SECTION
# discover section paired to link
section_name = $(this).data 'section'
section = idash_content.find "##{section_name}"
# activate link & section styling
$(this).addClass CSS_ACTIVE_SECTION
section.addClass CSS_ACTIVE_SECTION
# tracking
analytics.pageview "instructor_section:#{section_name}"
# deep linking
# write to url
location.hash = "#{HASH_LINK_PREFIX}#{section_name}"
plantTimeout 0, -> section.data('wrapper')?.onClickTitle?()
# plantTimeout 0, -> section.data('wrapper')?.onExit?()
# activate an initial section by 'clicking' on it.
# check for a deep-link, or click the first link.
click_first_link = ->
link = links.eq(0)
link.click()
link.data('wrapper')?.onClickTitle?()
if (new RegExp "^#{HASH_LINK_PREFIX}").test location.hash
rmatch = (new RegExp "^#{HASH_LINK_PREFIX}(.*)").exec location.hash
section_name = rmatch[1]
link = links.filter "[data-section='#{section_name}']"
if link.length == 1
link.click()
link.data('wrapper')?.onClickTitle?()
else
click_first_link()
else
click_first_link()
# enable sections
setup_instructor_dashboard_sections = (idash_content) ->
# see fault isolation NOTE at top of file.
# an error thrown in one section will not block other sections from exectuing.
plantTimeout 0, -> new window.InstructorDashboard.sections.CourseInfo idash_content.find ".#{CSS_IDASH_SECTION}#course_info"
plantTimeout 0, -> new window.InstructorDashboard.sections.DataDownload idash_content.find ".#{CSS_IDASH_SECTION}#data_download"
plantTimeout 0, -> new window.InstructorDashboard.sections.Membership idash_content.find ".#{CSS_IDASH_SECTION}#membership"
plantTimeout 0, -> new window.InstructorDashboard.sections.StudentAdmin idash_content.find ".#{CSS_IDASH_SECTION}#student_admin"
plantTimeout 0, -> new window.InstructorDashboard.sections.Analytics idash_content.find ".#{CSS_IDASH_SECTION}#analytics"
# Common utilities for instructor dashboard components.
# reverse arguments on common functions to enable
# better coffeescript with callbacks at the end.
plantTimeout = (ms, cb) -> setTimeout cb, ms
plantInterval = (ms, cb) -> setInterval cb, ms
# standard ajax error wrapper
#
# wraps a `handler` function so that first
# it prints basic error information to the console.
std_ajax_err = (handler) -> (jqXHR, textStatus, errorThrown) ->
console.warn """ajax error
textStatus: #{textStatus}
errorThrown: #{errorThrown}"""
handler.apply this, arguments
# export for use
# create parent namespaces if they do not already exist.
# abort if underscore can not be found.
if _?
_.defaults window, InstructorDashboard: {}
window.InstructorDashboard.util =
plantTimeout: plantTimeout
plantInterval: plantInterval
std_ajax_err: std_ajax_err
......@@ -29,6 +29,7 @@ $pink: rgb(182,37,104);
$yellow: rgb(255, 252, 221);
$red: rgb(178, 6, 16);
$error-red: rgb(253, 87, 87);
$danger-red: rgb(212, 64, 64);
$light-gray: rgb(221, 221, 221);
$dark-gray: rgb(51, 51, 51);
$border-color: rgb(200, 200, 200);
......
......@@ -64,6 +64,7 @@
// instructor
@import "course/instructor/instructor";
@import "course/instructor/instructor_2";
// discussion
@import "course/discussion/form-wmd-toolbar";
.instructor-dashboard-wrapper {
display: table;
display: table;
position: relative;
.beta-button-wrapper {
position: absolute;
top: 2em;
right: 2em;
}
section.instructor-dashboard-content {
@extend .content;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment