Merge branch 'master' into fix-ctrl-alt-0
This commit is contained in:
@ -30,7 +30,7 @@ $(function() {
|
||||
numMessages = 0,
|
||||
turtlecanvas = $('#turtlecanvas'),
|
||||
prompt = $('#prompt'),
|
||||
commands = ['input', 'write', 'turtle', 'turtlebatch', 'exit', 'timeout', 'status'],
|
||||
commands = ['input', 'write', 'turtle', 'turtlebatch', 'render', 'exit', 'timeout', 'status'],
|
||||
streams = ['stdin', 'stdout', 'stderr'];
|
||||
|
||||
var ENTER_KEY_CODE = 13;
|
||||
@ -245,6 +245,18 @@ $(function() {
|
||||
}
|
||||
};
|
||||
|
||||
var findOrCreateRenderElement = function(index) {
|
||||
if ($('#render-' + index).isPresent()) {
|
||||
return $('#render-' + index);
|
||||
} else {
|
||||
var element = $('<div>').attr('id', 'render-' + index);
|
||||
$('#render').append(element);
|
||||
return element;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
var getPanelClass = function(result) {
|
||||
if (result.stderr && !result.score) {
|
||||
return 'panel-danger';
|
||||
@ -408,6 +420,8 @@ $(function() {
|
||||
setActiveFile($(element).parent().data('filename'), file_id);
|
||||
|
||||
document.insertLines(0, content.text().split(/\n/));
|
||||
// remove last (empty) that is there by default line
|
||||
document.removeLines(document.getLength()-1,document.getLength()-1);
|
||||
editor.setReadOnly($(element).data('read-only') !== undefined);
|
||||
editor.setShowPrintMargin(false);
|
||||
editor.setTheme(THEME);
|
||||
@ -420,10 +434,7 @@ $(function() {
|
||||
session.setUseWrapMode(true);
|
||||
|
||||
var file_id = $(element).data('id');
|
||||
setAnnotations(editor, file_id);
|
||||
|
||||
session.on('annotationRemoval', handleAnnotationRemoval);
|
||||
session.on('annotationChange', handleAnnotationChange);
|
||||
//setAnnotations(editor, file_id);
|
||||
|
||||
/*
|
||||
* Register event handlers
|
||||
@ -1119,7 +1130,10 @@ $(function() {
|
||||
};
|
||||
|
||||
var initWebsocketConnection = function(url) {
|
||||
websocket = new WebSocket('wss://' + window.location.hostname + ':' + window.location.port + url);
|
||||
//TODO: get the protocol from config file dependent on environment. (dev: ws, prod: wss)
|
||||
//causes: Puma::HttpParserError: Invalid HTTP format, parsing fails.
|
||||
//TODO: make sure that this gets cached.
|
||||
websocket = new WebSocket('<%= DockerClient.config['ws_client_protocol'] %>' + window.location.hostname + ':' + window.location.port + url);
|
||||
websocket.onopen = function(evt) { resetOutputTab(); }; // todo show some kind of indicator for established connection
|
||||
websocket.onclose = function(evt) { /* expected at some point */ };
|
||||
websocket.onmessage = function(evt) { parseCanvasMessage(evt.data, true); };
|
||||
@ -1171,6 +1185,9 @@ $(function() {
|
||||
showCanvas();
|
||||
handleTurtlebatchCommand(msg);
|
||||
break;
|
||||
case 'render':
|
||||
renderWebsocketOutput(msg);
|
||||
break;
|
||||
case 'exit':
|
||||
killWebsocketAndContainer();
|
||||
break;
|
||||
@ -1184,6 +1201,11 @@ $(function() {
|
||||
}
|
||||
};
|
||||
|
||||
var renderWebsocketOutput = function(msg){
|
||||
var element = findOrCreateRenderElement(0);
|
||||
element.append(msg.data);
|
||||
};
|
||||
|
||||
var printWebsocketOutput = function(msg) {
|
||||
if (!msg.data) {
|
||||
return;
|
498
app/assets/javascripts/sortable.js
Normal file
498
app/assets/javascripts/sortable.js
Normal file
@ -0,0 +1,498 @@
|
||||
$(document).ready(function(){
|
||||
(function vendorTableSorter(){
|
||||
/*
|
||||
SortTable
|
||||
version 2
|
||||
7th April 2007
|
||||
Stuart Langridge, http://www.kryogenix.org/code/browser/sorttable/
|
||||
|
||||
Instructions:
|
||||
Download this file
|
||||
Add <script src="sorttable.js"></script> to your HTML
|
||||
Add class="sortable" to any table you'd like to make sortable
|
||||
Click on the headers to sort
|
||||
|
||||
Thanks to many, many people for contributions and suggestions.
|
||||
Licenced as X11: http://www.kryogenix.org/code/browser/licence.html
|
||||
This basically means: do what you want with it.
|
||||
*/
|
||||
|
||||
|
||||
var stIsIE = /*@cc_on!@*/false;
|
||||
|
||||
sorttable = {
|
||||
init: function() {
|
||||
// quit if this function has already been called
|
||||
if (arguments.callee.done) return;
|
||||
// flag this function so we don't do the same thing twice
|
||||
arguments.callee.done = true;
|
||||
// kill the timer
|
||||
if (_timer) clearInterval(_timer);
|
||||
|
||||
if (!document.createElement || !document.getElementsByTagName) return;
|
||||
|
||||
sorttable.DATE_RE = /^(\d\d?)[\/\.-](\d\d?)[\/\.-]((\d\d)?\d\d)$/;
|
||||
|
||||
forEach(document.getElementsByTagName('table'), function(table) {
|
||||
if (table.className.search(/\bsortable\b/) != -1) {
|
||||
sorttable.makeSortable(table);
|
||||
}
|
||||
});
|
||||
|
||||
},
|
||||
|
||||
makeSortable: function(table) {
|
||||
if (table.getElementsByTagName('thead').length == 0) {
|
||||
// table doesn't have a tHead. Since it should have, create one and
|
||||
// put the first table row in it.
|
||||
the = document.createElement('thead');
|
||||
the.appendChild(table.rows[0]);
|
||||
table.insertBefore(the,table.firstChild);
|
||||
}
|
||||
// Safari doesn't support table.tHead, sigh
|
||||
if (table.tHead == null) table.tHead = table.getElementsByTagName('thead')[0];
|
||||
|
||||
if (table.tHead.rows.length != 1) return; // can't cope with two header rows
|
||||
|
||||
// Sorttable v1 put rows with a class of "sortbottom" at the bottom (as
|
||||
// "total" rows, for example). This is B&R, since what you're supposed
|
||||
// to do is put them in a tfoot. So, if there are sortbottom rows,
|
||||
// for backwards compatibility, move them to tfoot (creating it if needed).
|
||||
sortbottomrows = [];
|
||||
for (var i=0; i<table.rows.length; i++) {
|
||||
if (table.rows[i].className.search(/\bsortbottom\b/) != -1) {
|
||||
sortbottomrows[sortbottomrows.length] = table.rows[i];
|
||||
}
|
||||
}
|
||||
if (sortbottomrows) {
|
||||
if (table.tFoot == null) {
|
||||
// table doesn't have a tfoot. Create one.
|
||||
tfo = document.createElement('tfoot');
|
||||
table.appendChild(tfo);
|
||||
}
|
||||
for (var i=0; i<sortbottomrows.length; i++) {
|
||||
tfo.appendChild(sortbottomrows[i]);
|
||||
}
|
||||
delete sortbottomrows;
|
||||
}
|
||||
|
||||
// work through each column and calculate its type
|
||||
headrow = table.tHead.rows[0].cells;
|
||||
for (var i=0; i<headrow.length; i++) {
|
||||
// manually override the type with a sorttable_type attribute
|
||||
if (!headrow[i].className.match(/\bsorttable_nosort\b/)) { // skip this col
|
||||
mtch = headrow[i].className.match(/\bsorttable_([a-z0-9]+)\b/);
|
||||
if (mtch) { override = mtch[1]; }
|
||||
if (mtch && typeof sorttable["sort_"+override] == 'function') {
|
||||
headrow[i].sorttable_sortfunction = sorttable["sort_"+override];
|
||||
} else {
|
||||
headrow[i].sorttable_sortfunction = sorttable.guessType(table,i);
|
||||
}
|
||||
// make it clickable to sort
|
||||
headrow[i].sorttable_columnindex = i;
|
||||
headrow[i].sorttable_tbody = table.tBodies[0];
|
||||
dean_addEvent(headrow[i],"click", sorttable.innerSortFunction = function(e) {
|
||||
|
||||
if (this.className.search(/\bsorttable_sorted\b/) != -1) {
|
||||
// if we're already sorted by this column, just
|
||||
// reverse the table, which is quicker
|
||||
sorttable.reverse(this.sorttable_tbody);
|
||||
this.className = this.className.replace('sorttable_sorted',
|
||||
'sorttable_sorted_reverse');
|
||||
this.removeChild(document.getElementById('sorttable_sortfwdind'));
|
||||
sortrevind = document.createElement('span');
|
||||
sortrevind.id = "sorttable_sortrevind";
|
||||
sortrevind.innerHTML = stIsIE ? ' <font face="webdings">5</font>' : ' ▴';
|
||||
this.appendChild(sortrevind);
|
||||
return;
|
||||
}
|
||||
if (this.className.search(/\bsorttable_sorted_reverse\b/) != -1) {
|
||||
// if we're already sorted by this column in reverse, just
|
||||
// re-reverse the table, which is quicker
|
||||
sorttable.reverse(this.sorttable_tbody);
|
||||
this.className = this.className.replace('sorttable_sorted_reverse',
|
||||
'sorttable_sorted');
|
||||
this.removeChild(document.getElementById('sorttable_sortrevind'));
|
||||
sortfwdind = document.createElement('span');
|
||||
sortfwdind.id = "sorttable_sortfwdind";
|
||||
sortfwdind.innerHTML = stIsIE ? ' <font face="webdings">6</font>' : ' ▾';
|
||||
this.appendChild(sortfwdind);
|
||||
return;
|
||||
}
|
||||
|
||||
// remove sorttable_sorted classes
|
||||
theadrow = this.parentNode;
|
||||
forEach(theadrow.childNodes, function(cell) {
|
||||
if (cell.nodeType == 1) { // an element
|
||||
cell.className = cell.className.replace('sorttable_sorted_reverse','');
|
||||
cell.className = cell.className.replace('sorttable_sorted','');
|
||||
}
|
||||
});
|
||||
sortfwdind = document.getElementById('sorttable_sortfwdind');
|
||||
if (sortfwdind) { sortfwdind.parentNode.removeChild(sortfwdind); }
|
||||
sortrevind = document.getElementById('sorttable_sortrevind');
|
||||
if (sortrevind) { sortrevind.parentNode.removeChild(sortrevind); }
|
||||
|
||||
this.className += ' sorttable_sorted';
|
||||
sortfwdind = document.createElement('span');
|
||||
sortfwdind.id = "sorttable_sortfwdind";
|
||||
sortfwdind.innerHTML = stIsIE ? ' <font face="webdings">6</font>' : ' ▾';
|
||||
this.appendChild(sortfwdind);
|
||||
|
||||
// build an array to sort. This is a Schwartzian transform thing,
|
||||
// i.e., we "decorate" each row with the actual sort key,
|
||||
// sort based on the sort keys, and then put the rows back in order
|
||||
// which is a lot faster because you only do getInnerText once per row
|
||||
row_array = [];
|
||||
col = this.sorttable_columnindex;
|
||||
rows = this.sorttable_tbody.rows;
|
||||
for (var j=0; j<rows.length; j++) {
|
||||
row_array[row_array.length] = [sorttable.getInnerText(rows[j].cells[col]), rows[j]];
|
||||
}
|
||||
/* If you want a stable sort, uncomment the following line */
|
||||
//sorttable.shaker_sort(row_array, this.sorttable_sortfunction);
|
||||
/* and comment out this one */
|
||||
row_array.sort(this.sorttable_sortfunction);
|
||||
|
||||
tb = this.sorttable_tbody;
|
||||
for (var j=0; j<row_array.length; j++) {
|
||||
tb.appendChild(row_array[j][1]);
|
||||
}
|
||||
|
||||
delete row_array;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
guessType: function(table, column) {
|
||||
// guess the type of a column based on its first non-blank row
|
||||
sortfn = sorttable.sort_alpha;
|
||||
for (var i=0; i<table.tBodies[0].rows.length; i++) {
|
||||
text = sorttable.getInnerText(table.tBodies[0].rows[i].cells[column]);
|
||||
if (text != '') {
|
||||
if (text.match(/^-?[£$¤]?[\d,.]+%?$/)) {
|
||||
return sorttable.sort_numeric;
|
||||
}
|
||||
// check for a date: dd/mm/yyyy or dd/mm/yy
|
||||
// can have / or . or - as separator
|
||||
// can be mm/dd as well
|
||||
possdate = text.match(sorttable.DATE_RE)
|
||||
if (possdate) {
|
||||
// looks like a date
|
||||
first = parseInt(possdate[1]);
|
||||
second = parseInt(possdate[2]);
|
||||
if (first > 12) {
|
||||
// definitely dd/mm
|
||||
return sorttable.sort_ddmm;
|
||||
} else if (second > 12) {
|
||||
return sorttable.sort_mmdd;
|
||||
} else {
|
||||
// looks like a date, but we can't tell which, so assume
|
||||
// that it's dd/mm (English imperialism!) and keep looking
|
||||
sortfn = sorttable.sort_ddmm;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return sortfn;
|
||||
},
|
||||
|
||||
getInnerText: function(node) {
|
||||
// gets the text we want to use for sorting for a cell.
|
||||
// strips leading and trailing whitespace.
|
||||
// this is *not* a generic getInnerText function; it's special to sorttable.
|
||||
// for example, you can override the cell text with a customkey attribute.
|
||||
// it also gets .value for <input> fields.
|
||||
|
||||
if (!node) return "";
|
||||
|
||||
hasInputs = (typeof node.getElementsByTagName == 'function') &&
|
||||
node.getElementsByTagName('input').length;
|
||||
|
||||
if (node.getAttribute("sorttable_customkey") != null) {
|
||||
return node.getAttribute("sorttable_customkey");
|
||||
}
|
||||
else if (typeof node.textContent != 'undefined' && !hasInputs) {
|
||||
return node.textContent.replace(/^\s+|\s+$/g, '');
|
||||
}
|
||||
else if (typeof node.innerText != 'undefined' && !hasInputs) {
|
||||
return node.innerText.replace(/^\s+|\s+$/g, '');
|
||||
}
|
||||
else if (typeof node.text != 'undefined' && !hasInputs) {
|
||||
return node.text.replace(/^\s+|\s+$/g, '');
|
||||
}
|
||||
else {
|
||||
switch (node.nodeType) {
|
||||
case 3:
|
||||
if (node.nodeName.toLowerCase() == 'input') {
|
||||
return node.value.replace(/^\s+|\s+$/g, '');
|
||||
}
|
||||
case 4:
|
||||
return node.nodeValue.replace(/^\s+|\s+$/g, '');
|
||||
break;
|
||||
case 1:
|
||||
case 11:
|
||||
var innerText = '';
|
||||
for (var i = 0; i < node.childNodes.length; i++) {
|
||||
innerText += sorttable.getInnerText(node.childNodes[i]);
|
||||
}
|
||||
return innerText.replace(/^\s+|\s+$/g, '');
|
||||
break;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
reverse: function(tbody) {
|
||||
// reverse the rows in a tbody
|
||||
newrows = [];
|
||||
for (var i=0; i<tbody.rows.length; i++) {
|
||||
newrows[newrows.length] = tbody.rows[i];
|
||||
}
|
||||
for (var i=newrows.length-1; i>=0; i--) {
|
||||
tbody.appendChild(newrows[i]);
|
||||
}
|
||||
delete newrows;
|
||||
},
|
||||
|
||||
/* sort functions
|
||||
each sort function takes two parameters, a and b
|
||||
you are comparing a[0] and b[0] */
|
||||
sort_numeric: function(a,b) {
|
||||
aa = parseFloat(a[0].replace(/[^0-9.-]/g,''));
|
||||
if (isNaN(aa)) aa = 0;
|
||||
bb = parseFloat(b[0].replace(/[^0-9.-]/g,''));
|
||||
if (isNaN(bb)) bb = 0;
|
||||
return aa-bb;
|
||||
},
|
||||
sort_alpha: function(a,b) {
|
||||
if (a[0]==b[0]) return 0;
|
||||
if (a[0]<b[0]) return -1;
|
||||
return 1;
|
||||
},
|
||||
sort_ddmm: function(a,b) {
|
||||
mtch = a[0].match(sorttable.DATE_RE);
|
||||
y = mtch[3]; m = mtch[2]; d = mtch[1];
|
||||
if (m.length == 1) m = '0'+m;
|
||||
if (d.length == 1) d = '0'+d;
|
||||
dt1 = y+m+d;
|
||||
mtch = b[0].match(sorttable.DATE_RE);
|
||||
y = mtch[3]; m = mtch[2]; d = mtch[1];
|
||||
if (m.length == 1) m = '0'+m;
|
||||
if (d.length == 1) d = '0'+d;
|
||||
dt2 = y+m+d;
|
||||
if (dt1==dt2) return 0;
|
||||
if (dt1<dt2) return -1;
|
||||
return 1;
|
||||
},
|
||||
sort_mmdd: function(a,b) {
|
||||
mtch = a[0].match(sorttable.DATE_RE);
|
||||
y = mtch[3]; d = mtch[2]; m = mtch[1];
|
||||
if (m.length == 1) m = '0'+m;
|
||||
if (d.length == 1) d = '0'+d;
|
||||
dt1 = y+m+d;
|
||||
mtch = b[0].match(sorttable.DATE_RE);
|
||||
y = mtch[3]; d = mtch[2]; m = mtch[1];
|
||||
if (m.length == 1) m = '0'+m;
|
||||
if (d.length == 1) d = '0'+d;
|
||||
dt2 = y+m+d;
|
||||
if (dt1==dt2) return 0;
|
||||
if (dt1<dt2) return -1;
|
||||
return 1;
|
||||
},
|
||||
|
||||
shaker_sort: function(list, comp_func) {
|
||||
// A stable sort function to allow multi-level sorting of data
|
||||
// see: http://en.wikipedia.org/wiki/Cocktail_sort
|
||||
// thanks to Joseph Nahmias
|
||||
var b = 0;
|
||||
var t = list.length - 1;
|
||||
var swap = true;
|
||||
|
||||
while(swap) {
|
||||
swap = false;
|
||||
for(var i = b; i < t; ++i) {
|
||||
if ( comp_func(list[i], list[i+1]) > 0 ) {
|
||||
var q = list[i]; list[i] = list[i+1]; list[i+1] = q;
|
||||
swap = true;
|
||||
}
|
||||
} // for
|
||||
t--;
|
||||
|
||||
if (!swap) break;
|
||||
|
||||
for(var i = t; i > b; --i) {
|
||||
if ( comp_func(list[i], list[i-1]) < 0 ) {
|
||||
var q = list[i]; list[i] = list[i-1]; list[i-1] = q;
|
||||
swap = true;
|
||||
}
|
||||
} // for
|
||||
b++;
|
||||
|
||||
} // while(swap)
|
||||
}
|
||||
}
|
||||
|
||||
/* ******************************************************************
|
||||
Supporting functions: bundled here to avoid depending on a library
|
||||
****************************************************************** */
|
||||
|
||||
// Dean Edwards/Matthias Miller/John Resig
|
||||
|
||||
/* for Mozilla/Opera9 */
|
||||
if (document.addEventListener) {
|
||||
document.addEventListener("DOMContentLoaded", sorttable.init, false);
|
||||
}
|
||||
|
||||
/* for Internet Explorer */
|
||||
/*@cc_on @*/
|
||||
/*@if (@_win32)
|
||||
document.write("<script id=__ie_onload defer src=javascript:void(0)><\/script>");
|
||||
var script = document.getElementById("__ie_onload");
|
||||
script.onreadystatechange = function() {
|
||||
if (this.readyState == "complete") {
|
||||
sorttable.init(); // call the onload handler
|
||||
}
|
||||
};
|
||||
/*@end @*/
|
||||
|
||||
/* for Safari */
|
||||
if (/WebKit/i.test(navigator.userAgent)) { // sniff
|
||||
var _timer = setInterval(function() {
|
||||
if (/loaded|complete/.test(document.readyState)) {
|
||||
sorttable.init(); // call the onload handler
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
|
||||
/* for other browsers */
|
||||
window.onload = sorttable.init;
|
||||
|
||||
// written by Dean Edwards, 2005
|
||||
// with input from Tino Zijdel, Matthias Miller, Diego Perini
|
||||
|
||||
// http://dean.edwards.name/weblog/2005/10/add-event/
|
||||
|
||||
function dean_addEvent(element, type, handler) {
|
||||
if (element.addEventListener) {
|
||||
element.addEventListener(type, handler, false);
|
||||
} else {
|
||||
// assign each event handler a unique ID
|
||||
if (!handler.$$guid) handler.$$guid = dean_addEvent.guid++;
|
||||
// create a hash table of event types for the element
|
||||
if (!element.events) element.events = {};
|
||||
// create a hash table of event handlers for each element/event pair
|
||||
var handlers = element.events[type];
|
||||
if (!handlers) {
|
||||
handlers = element.events[type] = {};
|
||||
// store the existing event handler (if there is one)
|
||||
if (element["on" + type]) {
|
||||
handlers[0] = element["on" + type];
|
||||
}
|
||||
}
|
||||
// store the event handler in the hash table
|
||||
handlers[handler.$$guid] = handler;
|
||||
// assign a global event handler to do all the work
|
||||
element["on" + type] = handleEvent;
|
||||
}
|
||||
};
|
||||
// a counter used to create unique IDs
|
||||
dean_addEvent.guid = 1;
|
||||
|
||||
function removeEvent(element, type, handler) {
|
||||
if (element.removeEventListener) {
|
||||
element.removeEventListener(type, handler, false);
|
||||
} else {
|
||||
// delete the event handler from the hash table
|
||||
if (element.events && element.events[type]) {
|
||||
delete element.events[type][handler.$$guid];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function handleEvent(event) {
|
||||
var returnValue = true;
|
||||
// grab the event object (IE uses a global event object)
|
||||
event = event || fixEvent(((this.ownerDocument || this.document || this).parentWindow || window).event);
|
||||
// get a reference to the hash table of event handlers
|
||||
var handlers = this.events[event.type];
|
||||
// execute each event handler
|
||||
for (var i in handlers) {
|
||||
this.$$handleEvent = handlers[i];
|
||||
if (this.$$handleEvent(event) === false) {
|
||||
returnValue = false;
|
||||
}
|
||||
}
|
||||
return returnValue;
|
||||
};
|
||||
|
||||
function fixEvent(event) {
|
||||
// add W3C standard event methods
|
||||
event.preventDefault = fixEvent.preventDefault;
|
||||
event.stopPropagation = fixEvent.stopPropagation;
|
||||
return event;
|
||||
};
|
||||
fixEvent.preventDefault = function() {
|
||||
this.returnValue = false;
|
||||
};
|
||||
fixEvent.stopPropagation = function() {
|
||||
this.cancelBubble = true;
|
||||
}
|
||||
|
||||
// Dean's forEach: http://dean.edwards.name/base/forEach.js
|
||||
/*
|
||||
forEach, version 1.0
|
||||
Copyright 2006, Dean Edwards
|
||||
License: http://www.opensource.org/licenses/mit-license.php
|
||||
*/
|
||||
|
||||
// array-like enumeration
|
||||
if (!Array.forEach) { // mozilla already supports this
|
||||
Array.forEach = function(array, block, context) {
|
||||
for (var i = 0; i < array.length; i++) {
|
||||
block.call(context, array[i], i, array);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// generic enumeration
|
||||
Function.prototype.forEach = function(object, block, context) {
|
||||
for (var key in object) {
|
||||
if (typeof this.prototype[key] == "undefined") {
|
||||
block.call(context, object[key], key, object);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// character enumeration
|
||||
String.forEach = function(string, block, context) {
|
||||
Array.forEach(string.split(""), function(chr, index) {
|
||||
block.call(context, chr, index, string);
|
||||
});
|
||||
};
|
||||
|
||||
// globally resolve forEach enumeration
|
||||
var forEach = function(object, block, context) {
|
||||
if (object) {
|
||||
var resolve = Object; // default
|
||||
if (object instanceof Function) {
|
||||
// functions have a "length" property
|
||||
resolve = Function;
|
||||
} else if (object.forEach instanceof Function) {
|
||||
// the object implements a custom forEach method so use that
|
||||
object.forEach(block, context);
|
||||
return;
|
||||
} else if (typeof object == "string") {
|
||||
// the object is a string
|
||||
resolve = String;
|
||||
} else if (typeof object.length == "number") {
|
||||
// the object is array-like
|
||||
resolve = Array;
|
||||
}
|
||||
resolve.forEach(object, block, context);
|
||||
}
|
||||
};
|
||||
}());
|
||||
});
|
120
app/assets/javascripts/submission_statistics.js
Normal file
120
app/assets/javascripts/submission_statistics.js
Normal file
@ -0,0 +1,120 @@
|
||||
$(function() {
|
||||
|
||||
var ACE_FILES_PATH = '/assets/ace/';
|
||||
var THEME = 'ace/theme/textmate';
|
||||
|
||||
var currentSubmission = 0;
|
||||
var active_file = undefined;
|
||||
var fileTrees = []
|
||||
var editor = undefined;
|
||||
var fileTypeById = {}
|
||||
|
||||
var showActiveFile = function() {
|
||||
var session = editor.getSession();
|
||||
var fileType = fileTypeById[active_file.file_type_id]
|
||||
session.setMode(fileType.editor_mode);
|
||||
session.setTabSize(fileType.indent_size);
|
||||
session.setValue(active_file.content);
|
||||
session.setUseSoftTabs(true);
|
||||
session.setUseWrapMode(true);
|
||||
|
||||
showFileTree(currentSubmission);
|
||||
filetree = $(fileTrees[currentSubmission])
|
||||
filetree.jstree("deselect_all");
|
||||
filetree.jstree().select_node(active_file.file_id);
|
||||
};
|
||||
|
||||
var initializeFileTree = function() {
|
||||
$('.files').each(function(index, element) {
|
||||
fileTree = $(element).jstree($(element).data('entries'));
|
||||
fileTree.on('click', 'li.jstree-leaf', function() {
|
||||
var id = parseInt($(this).attr('id'))
|
||||
_.each(files[currentSubmission], function(file) {
|
||||
if (file.file_id === id) {
|
||||
active_file = file;
|
||||
}
|
||||
});
|
||||
showActiveFile();
|
||||
});
|
||||
fileTrees.push(fileTree);
|
||||
});
|
||||
};
|
||||
|
||||
var showFileTree = function(index) {
|
||||
$('.files').hide();
|
||||
$(fileTrees[index].context).show();
|
||||
}
|
||||
|
||||
if ($.isController('exercises') && $('#timeline').isPresent()) {
|
||||
|
||||
_.each(['modePath', 'themePath', 'workerPath'], function(attribute) {
|
||||
ace.config.set(attribute, ACE_FILES_PATH);
|
||||
});
|
||||
|
||||
var slider = $('#submissions-slider>input');
|
||||
var submissions = $('#data').data('submissions');
|
||||
var files = $('#data').data('files');
|
||||
var filetypes = $('#data').data('file-types');
|
||||
var playButton = $('#play-button');
|
||||
var playInterval = undefined;
|
||||
|
||||
editor = ace.edit('current-file');
|
||||
editor.setShowPrintMargin(false);
|
||||
editor.setTheme(THEME);
|
||||
editor.$blockScrolling = Infinity;
|
||||
editor.setReadOnly(true);
|
||||
|
||||
_.each(filetypes, function (filetype) {
|
||||
filetype = JSON.parse(filetype);
|
||||
fileTypeById[filetype.id] = filetype;
|
||||
});
|
||||
|
||||
$('tr[data-id]>.clickable').each(function(index, element) {
|
||||
element = $(element);
|
||||
element.click(function() {
|
||||
slider.val(index);
|
||||
slider.change()
|
||||
});
|
||||
});
|
||||
|
||||
slider.on('change', function(event) {
|
||||
currentSubmission = slider.val();
|
||||
var currentFiles = files[currentSubmission];
|
||||
var fileIndex = 0;
|
||||
_.each(currentFiles, function(file, index) {
|
||||
if (file.name === active_file.name) {
|
||||
fileIndex = index;
|
||||
}
|
||||
})
|
||||
active_file = currentFiles[fileIndex];
|
||||
showActiveFile();
|
||||
});
|
||||
|
||||
stopReplay = function() {
|
||||
clearInterval(playInterval);
|
||||
playInterval = undefined;
|
||||
playButton.find('span.fa').removeClass('fa-pause').addClass('fa-play')
|
||||
}
|
||||
|
||||
playButton.on('click', function(event) {
|
||||
if (playInterval == undefined) {
|
||||
playInterval = setInterval(function() {
|
||||
if ($.isController('exercises') && $('#timeline').isPresent() && slider.val() < submissions.length - 1) {
|
||||
slider.val(parseInt(slider.val()) + 1);
|
||||
slider.change()
|
||||
} else {
|
||||
stopReplay();
|
||||
}
|
||||
}, 5000);
|
||||
playButton.find('span.fa').removeClass('fa-play').addClass('fa-pause')
|
||||
} else {
|
||||
stopReplay();
|
||||
}
|
||||
});
|
||||
|
||||
active_file = files[0][0]
|
||||
initializeFileTree();
|
||||
showActiveFile();
|
||||
}
|
||||
|
||||
});
|
28
app/assets/stylesheets/statistics.css.scss
Normal file
28
app/assets/stylesheets/statistics.css.scss
Normal file
@ -0,0 +1,28 @@
|
||||
#submissions-slider {
|
||||
margin-top: 25px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
#current-file.editor {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flex-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-item {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#play-button {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
margin-right: 15px;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
68
app/controllers/code_harbor_links_controller.rb
Normal file
68
app/controllers/code_harbor_links_controller.rb
Normal file
@ -0,0 +1,68 @@
|
||||
class CodeHarborLinksController < ApplicationController
|
||||
include CommonBehavior
|
||||
before_action :set_code_harbor_link, only: [:show, :edit, :update, :destroy]
|
||||
|
||||
def authorize!
|
||||
authorize(@code_harbor_link || @code_harbor_links)
|
||||
end
|
||||
private :authorize!
|
||||
|
||||
# GET /code_harbor_links
|
||||
# GET /code_harbor_links.json
|
||||
def index
|
||||
@code_harbor_links = CodeHarborLink.where(user_id: current_user.id).paginate(page: params[:page])
|
||||
authorize!
|
||||
end
|
||||
|
||||
# GET /code_harbor_links/1
|
||||
# GET /code_harbor_links/1.json
|
||||
def show
|
||||
authorize!
|
||||
end
|
||||
|
||||
# GET /code_harbor_links/new
|
||||
def new
|
||||
@code_harbor_link = CodeHarborLink.new
|
||||
authorize!
|
||||
end
|
||||
|
||||
# GET /code_harbor_links/1/edit
|
||||
def edit
|
||||
authorize!
|
||||
end
|
||||
|
||||
# POST /code_harbor_links
|
||||
# POST /code_harbor_links.json
|
||||
def create
|
||||
@code_harbor_link = CodeHarborLink.new(code_harbor_link_params)
|
||||
@code_harbor_link.user = current_user
|
||||
authorize!
|
||||
create_and_respond(object: @code_harbor_link)
|
||||
end
|
||||
|
||||
# PATCH/PUT /code_harbor_links/1
|
||||
# PATCH/PUT /code_harbor_links/1.json
|
||||
def update
|
||||
update_and_respond(object: @code_harbor_link, params: code_harbor_link_params)
|
||||
authorize!
|
||||
end
|
||||
|
||||
# DELETE /code_harbor_links/1
|
||||
# DELETE /code_harbor_links/1.json
|
||||
def destroy
|
||||
destroy_and_respond(object: @code_harbor_link)
|
||||
end
|
||||
|
||||
private
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_code_harbor_link
|
||||
@code_harbor_link = CodeHarborLink.find(params[:id])
|
||||
@code_harbor_link.user = current_user
|
||||
authorize!
|
||||
end
|
||||
|
||||
# Never trust parameters from the scary internet, only allow the white list through.
|
||||
def code_harbor_link_params
|
||||
params.require(:code_harbor_link).permit(:oauth2token)
|
||||
end
|
||||
end
|
@ -2,7 +2,7 @@ class ExecutionEnvironmentsController < ApplicationController
|
||||
include CommonBehavior
|
||||
|
||||
before_action :set_docker_images, only: [:create, :edit, :new, :update]
|
||||
before_action :set_execution_environment, only: MEMBER_ACTIONS + [:execute_command, :shell]
|
||||
before_action :set_execution_environment, only: MEMBER_ACTIONS + [:execute_command, :shell, :statistics]
|
||||
before_action :set_testing_framework_adapters, only: [:create, :edit, :new, :update]
|
||||
|
||||
def authorize!
|
||||
@ -28,6 +28,78 @@ class ExecutionEnvironmentsController < ApplicationController
|
||||
render(json: @docker_client.execute_arbitrary_command(params[:command]))
|
||||
end
|
||||
|
||||
|
||||
def working_time_query
|
||||
"""
|
||||
SELECT exercise_id, avg(working_time) as average_time, stddev_samp(extract('epoch' from working_time)) * interval '1 second' as stddev_time
|
||||
FROM
|
||||
(
|
||||
SELECT user_id,
|
||||
exercise_id,
|
||||
sum(working_time_new) AS working_time
|
||||
FROM
|
||||
(SELECT user_id,
|
||||
exercise_id,
|
||||
CASE WHEN working_time >= '0:30:00' THEN '0' ELSE working_time END AS working_time_new
|
||||
FROM
|
||||
(SELECT user_id,
|
||||
exercise_id,
|
||||
id,
|
||||
(created_at - lag(created_at) over (PARTITION BY user_id, exercise_id
|
||||
ORDER BY created_at)) AS working_time
|
||||
FROM submissions
|
||||
WHERE exercise_id IN (SELECT ID FROM exercises WHERE execution_environment_id = #{@execution_environment.id})
|
||||
GROUP BY exercise_id, user_id, id) AS foo) AS bar
|
||||
GROUP BY user_id, exercise_id
|
||||
) AS baz GROUP BY exercise_id;
|
||||
"""
|
||||
end
|
||||
|
||||
def user_query
|
||||
"""
|
||||
SELECT
|
||||
id AS exercise_id,
|
||||
COUNT(DISTINCT user_id) AS users,
|
||||
AVG(score) AS average_score,
|
||||
MAX(score) AS maximum_score,
|
||||
stddev_samp(score) as stddev_score,
|
||||
CASE
|
||||
WHEN MAX(score)=0 THEN 0
|
||||
ELSE 100 / MAX(score) * AVG(score)
|
||||
END AS percent_correct,
|
||||
SUM(submission_count) / COUNT(DISTINCT user_id) AS average_submission_count
|
||||
FROM
|
||||
(SELECT e.id,
|
||||
s.user_id,
|
||||
MAX(s.score) AS score,
|
||||
COUNT(s.id) AS submission_count
|
||||
FROM submissions s
|
||||
JOIN exercises e ON e.id = s.exercise_id
|
||||
WHERE e.execution_environment_id = #{@execution_environment.id}
|
||||
GROUP BY e.id,
|
||||
s.user_id) AS inner_query
|
||||
GROUP BY id;
|
||||
"""
|
||||
end
|
||||
|
||||
def statistics
|
||||
working_time_statistics = {}
|
||||
user_statistics = {}
|
||||
|
||||
ActiveRecord::Base.connection.execute(working_time_query).each do |tuple|
|
||||
working_time_statistics[tuple["exercise_id"].to_i] = tuple
|
||||
end
|
||||
|
||||
ActiveRecord::Base.connection.execute(user_query).each do |tuple|
|
||||
user_statistics[tuple["exercise_id"].to_i] = tuple
|
||||
end
|
||||
|
||||
render locals: {
|
||||
working_time_statistics: working_time_statistics,
|
||||
user_statistics: user_statistics
|
||||
}
|
||||
end
|
||||
|
||||
def execution_environment_params
|
||||
params[:execution_environment].permit(:docker_image, :exposed_ports, :editor_mode, :file_extension, :file_type_id, :help, :indent_size, :memory_limit, :name, :network_enabled, :permitted_execution_time, :pool_size, :run_command, :test_command, :testing_framework).merge(user_id: current_user.id, user_type: current_user.class.name)
|
||||
end
|
||||
|
@ -7,9 +7,14 @@ class ExercisesController < ApplicationController
|
||||
before_action :handle_file_uploads, only: [:create, :update]
|
||||
before_action :set_execution_environments, only: [:create, :edit, :new, :update]
|
||||
before_action :set_exercise, only: MEMBER_ACTIONS + [:clone, :implement, :run, :statistics, :submit, :reload]
|
||||
before_action :set_external_user, only: [:statistics]
|
||||
before_action :set_file_types, only: [:create, :edit, :new, :update]
|
||||
before_action :set_teams, only: [:create, :edit, :new, :update]
|
||||
|
||||
skip_before_filter :verify_authenticity_token, only: [:import_proforma_xml]
|
||||
skip_after_action :verify_authorized, only: [:import_proforma_xml]
|
||||
skip_after_action :verify_policy_scoped, only: [:import_proforma_xml]
|
||||
|
||||
def authorize!
|
||||
authorize(@exercise || @exercises)
|
||||
end
|
||||
@ -61,6 +66,58 @@ class ExercisesController < ApplicationController
|
||||
def edit
|
||||
end
|
||||
|
||||
def import_proforma_xml
|
||||
begin
|
||||
user = user_for_oauth2_request()
|
||||
exercise = Exercise.new
|
||||
request_body = request.body.read
|
||||
exercise.from_proforma_xml(request_body)
|
||||
exercise.user = user
|
||||
saved = exercise.save
|
||||
if saved
|
||||
render :text => 'SUCCESS', :status => 200
|
||||
else
|
||||
logger.info(exercise.errors.full_messages)
|
||||
render :text => 'Invalid exercise', :status => 400
|
||||
end
|
||||
rescue => error
|
||||
if error.class == Hash
|
||||
render :text => error.message, :status => error.status
|
||||
else
|
||||
raise error
|
||||
render :text => '', :status => 500
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def user_for_oauth2_request
|
||||
authorizationHeader = request.headers['Authorization']
|
||||
if authorizationHeader == nil
|
||||
raise ({status: 401, message: 'No Authorization header'})
|
||||
end
|
||||
|
||||
oauth2Token = authorizationHeader.split(' ')[1]
|
||||
if oauth2Token == nil || oauth2Token.size == 0
|
||||
raise ({status: 401, message: 'No token in Authorization header'})
|
||||
end
|
||||
|
||||
user = user_by_code_harbor_token(oauth2Token)
|
||||
if user == nil
|
||||
raise ({status: 401, message: 'Unknown OAuth2 token'})
|
||||
end
|
||||
|
||||
return user
|
||||
end
|
||||
private :user_for_oauth2_request
|
||||
|
||||
def user_by_code_harbor_token(oauth2Token)
|
||||
link = CodeHarborLink.where(:oauth2token => oauth2Token)[0]
|
||||
if link != nil
|
||||
return link.user
|
||||
end
|
||||
end
|
||||
private :user_by_code_harbor_token
|
||||
|
||||
def exercise_params
|
||||
params[:exercise].permit(:description, :execution_environment_id, :file_id, :instructions, :public, :hide_file_tree, :team_id, :title, files_attributes: file_attributes).merge(user_id: current_user.id, user_type: current_user.class.name)
|
||||
end
|
||||
@ -125,6 +182,14 @@ class ExercisesController < ApplicationController
|
||||
end
|
||||
private :set_exercise
|
||||
|
||||
def set_external_user
|
||||
if params[:external_user_id]
|
||||
@external_user = ExternalUser.find(params[:external_user_id])
|
||||
authorize!
|
||||
end
|
||||
end
|
||||
private :set_exercise
|
||||
|
||||
def set_file_types
|
||||
@file_types = FileType.all.order(:name)
|
||||
end
|
||||
@ -143,6 +208,20 @@ class ExercisesController < ApplicationController
|
||||
end
|
||||
|
||||
def statistics
|
||||
if(@external_user)
|
||||
render 'exercises/external_users/statistics'
|
||||
else
|
||||
user_statistics = {}
|
||||
query = "SELECT user_id, MAX(score) AS maximum_score, COUNT(id) AS runs
|
||||
FROM submissions WHERE exercise_id = #{@exercise.id} GROUP BY
|
||||
user_id;"
|
||||
ActiveRecord::Base.connection.execute(query).each do |tuple|
|
||||
user_statistics[tuple["user_id"].to_i] = tuple
|
||||
end
|
||||
render locals: {
|
||||
user_statistics: user_statistics
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def submit
|
||||
|
@ -13,4 +13,54 @@ class ExternalUsersController < ApplicationController
|
||||
@user = ExternalUser.find(params[:id])
|
||||
authorize!
|
||||
end
|
||||
|
||||
def working_time_query
|
||||
"""
|
||||
SELECT user_id,
|
||||
exercise_id,
|
||||
max(score) as maximum_score,
|
||||
count(id) as runs,
|
||||
sum(working_time_new) AS working_time
|
||||
FROM
|
||||
(SELECT user_id,
|
||||
exercise_id,
|
||||
score,
|
||||
id,
|
||||
CASE
|
||||
WHEN working_time >= '0:30:00' THEN '0'
|
||||
ELSE working_time
|
||||
END AS working_time_new
|
||||
FROM
|
||||
(SELECT user_id,
|
||||
exercise_id,
|
||||
max(score) AS score,
|
||||
id,
|
||||
(created_at - lag(created_at) over (PARTITION BY user_id, exercise_id
|
||||
ORDER BY created_at)) AS working_time
|
||||
FROM submissions
|
||||
WHERE user_id = #{@user.id}
|
||||
AND user_type = 'ExternalUser'
|
||||
GROUP BY exercise_id,
|
||||
user_id,
|
||||
id) AS foo) AS bar
|
||||
GROUP BY user_id,
|
||||
exercise_id;
|
||||
"""
|
||||
end
|
||||
|
||||
def statistics
|
||||
@user = ExternalUser.find(params[:id])
|
||||
authorize!
|
||||
|
||||
statistics = {}
|
||||
|
||||
ActiveRecord::Base.connection.execute(working_time_query).each do |tuple|
|
||||
statistics[tuple["exercise_id"].to_i] = tuple
|
||||
end
|
||||
|
||||
render locals: {
|
||||
statistics: statistics
|
||||
}
|
||||
end
|
||||
|
||||
end
|
||||
|
@ -11,7 +11,7 @@ class RequestForCommentsController < ApplicationController
|
||||
# GET /request_for_comments
|
||||
# GET /request_for_comments.json
|
||||
def index
|
||||
@request_for_comments = RequestForComment.last_per_user(2).paginate(page: params[:page])
|
||||
@request_for_comments = RequestForComment.last_per_user(2).order('created_at DESC').paginate(page: params[:page])
|
||||
authorize!
|
||||
end
|
||||
|
||||
|
@ -111,6 +111,7 @@ class SubmissionsController < ApplicationController
|
||||
|
||||
if result[:status] == :container_running
|
||||
socket = result[:socket]
|
||||
command = result[:command]
|
||||
|
||||
socket.on :message do |event|
|
||||
Rails.logger.info( Time.now.getutc.to_s + ": Docker sending: " + event.data)
|
||||
@ -139,6 +140,11 @@ class SubmissionsController < ApplicationController
|
||||
Rails.logger.debug('Rescued parsing error, sent the received client data to docker:' + data)
|
||||
end
|
||||
end
|
||||
|
||||
# Send command after all listeners are attached.
|
||||
# Newline required to flush
|
||||
socket.send command + "\n"
|
||||
Rails.logger.info('Sent command: ' + command.to_s)
|
||||
else
|
||||
kill_socket(tubesock)
|
||||
end
|
||||
@ -176,7 +182,24 @@ class SubmissionsController < ApplicationController
|
||||
for part in message.split("\n")
|
||||
self.parse_message(part,output_stream,socket,false)
|
||||
end
|
||||
elsif(message.include? "<img")
|
||||
#Rails.logger.info('img foung')
|
||||
@buffering = true
|
||||
@buffer = ""
|
||||
@buffer += message
|
||||
#Rails.logger.info('Starting to buffer')
|
||||
elsif(@buffering && (message.include? "/>"))
|
||||
@buffer += message
|
||||
parsed = {'cmd'=>'write','stream'=>output_stream,'data'=>@buffer}
|
||||
socket.send_data JSON.dump(parsed)
|
||||
#socket.send_data @buffer
|
||||
@buffering = false
|
||||
#Rails.logger.info('Sent complete buffer')
|
||||
elsif(@buffering)
|
||||
@buffer += message
|
||||
#Rails.logger.info('Appending to buffer')
|
||||
else
|
||||
#Rails.logger.info('else')
|
||||
parsed = {'cmd'=>'write','stream'=>output_stream,'data'=>message}
|
||||
socket.send_data JSON.dump(parsed)
|
||||
Rails.logger.info('parse_message sent: ' + JSON.dump(parsed))
|
||||
|
2
app/helpers/code_harbor_links_helper.rb
Normal file
2
app/helpers/code_harbor_links_helper.rb
Normal file
@ -0,0 +1,2 @@
|
||||
module CodeHarborLinksHelper
|
||||
end
|
13
app/models/code_harbor_link.rb
Normal file
13
app/models/code_harbor_link.rb
Normal file
@ -0,0 +1,13 @@
|
||||
class CodeHarborLink < ActiveRecord::Base
|
||||
validates :oauth2token, presence: true
|
||||
validates :user_id, presence: true
|
||||
|
||||
belongs_to :internal_user, foreign_key: :user_id
|
||||
alias_method :user, :internal_user
|
||||
alias_method :user=, :internal_user=
|
||||
|
||||
def to_s
|
||||
oauth2token
|
||||
end
|
||||
|
||||
end
|
@ -1,3 +1,4 @@
|
||||
require 'nokogiri'
|
||||
require File.expand_path('../../../lib/active_model/validations/boolean_presence_validator', __FILE__)
|
||||
|
||||
class Exercise < ActiveRecord::Base
|
||||
@ -11,7 +12,10 @@ class Exercise < ActiveRecord::Base
|
||||
belongs_to :execution_environment
|
||||
has_many :submissions
|
||||
belongs_to :team
|
||||
has_many :users, source_type: ExternalUser, through: :submissions
|
||||
|
||||
has_many :external_users, source: :user, source_type: ExternalUser, through: :submissions
|
||||
has_many :internal_users, source: :user, source_type: InternalUser, through: :submissions
|
||||
alias_method :users, :external_users
|
||||
|
||||
scope :with_submissions, -> { where('id IN (SELECT exercise_id FROM submissions)') }
|
||||
|
||||
@ -22,17 +26,83 @@ class Exercise < ActiveRecord::Base
|
||||
validates :title, presence: true
|
||||
validates :token, presence: true, uniqueness: true
|
||||
|
||||
@working_time_statistics = nil
|
||||
|
||||
|
||||
def average_percentage
|
||||
(average_score/ maximum_score * 100).round if average_score
|
||||
if average_score and maximum_score != 0.0
|
||||
(average_score / maximum_score * 100).round
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
def average_score
|
||||
if submissions.exists?(cause: 'submit')
|
||||
maximum_scores_query = submissions.select('MAX(score) AS maximum_score').where(cause: 'submit').group(:user_id).to_sql.sub('$1', id.to_s)
|
||||
maximum_scores_query = submissions.select('MAX(score) AS maximum_score').group(:user_id).to_sql.sub('$1', id.to_s)
|
||||
self.class.connection.execute("SELECT AVG(maximum_score) AS average_score FROM (#{maximum_scores_query}) AS maximum_scores").first['average_score'].to_f
|
||||
else 0 end
|
||||
end
|
||||
|
||||
def average_number_of_submissions
|
||||
user_count = internal_users.distinct.count + external_users.distinct.count
|
||||
return user_count == 0 ? 0 : submissions.count() / user_count.to_f()
|
||||
end
|
||||
|
||||
def user_working_time_query
|
||||
"""
|
||||
SELECT user_id,
|
||||
sum(working_time_new) AS working_time
|
||||
FROM
|
||||
(SELECT user_id,
|
||||
CASE WHEN working_time >= '0:30:00' THEN '0' ELSE working_time END AS working_time_new
|
||||
FROM
|
||||
(SELECT user_id,
|
||||
id,
|
||||
(created_at - lag(created_at) over (PARTITION BY user_id
|
||||
ORDER BY created_at)) AS working_time
|
||||
FROM submissions
|
||||
WHERE exercise_id=#{id}) AS foo) AS bar
|
||||
GROUP BY user_id
|
||||
"""
|
||||
end
|
||||
|
||||
def retrieve_working_time_statistics
|
||||
@working_time_statistics = {}
|
||||
self.class.connection.execute(user_working_time_query).each do |tuple|
|
||||
@working_time_statistics[tuple["user_id"].to_i] = tuple
|
||||
end
|
||||
end
|
||||
|
||||
def average_working_time
|
||||
self.class.connection.execute("""
|
||||
SELECT avg(working_time) as average_time
|
||||
FROM
|
||||
(#{user_working_time_query}) AS baz;
|
||||
""").first['average_time']
|
||||
end
|
||||
|
||||
def average_working_time_for(user_id)
|
||||
if @working_time_statistics == nil
|
||||
retrieve_working_time_statistics()
|
||||
end
|
||||
@working_time_statistics[user_id]["working_time"]
|
||||
end
|
||||
|
||||
def average_working_time_for_only(user_id)
|
||||
self.class.connection.execute("""
|
||||
SELECT sum(working_time_new) AS working_time
|
||||
FROM
|
||||
(SELECT CASE WHEN working_time >= '0:30:00' THEN '0' ELSE working_time END AS working_time_new
|
||||
FROM
|
||||
(SELECT id,
|
||||
(created_at - lag(created_at) over (PARTITION BY user_id
|
||||
ORDER BY created_at)) AS working_time
|
||||
FROM submissions
|
||||
WHERE exercise_id=#{id} and user_id=#{user_id}) AS foo) AS bar
|
||||
""").first["working_time"]
|
||||
end
|
||||
|
||||
def duplicate(attributes = {})
|
||||
exercise = dup
|
||||
exercise.attributes = attributes
|
||||
@ -40,6 +110,54 @@ class Exercise < ActiveRecord::Base
|
||||
exercise
|
||||
end
|
||||
|
||||
def determine_file_role_from_proforma_file(task_node, file_node)
|
||||
file_id = file_node.xpath('@id')
|
||||
file_class = file_node.xpath('@class').first.value
|
||||
comment = file_node.xpath('@comment').first.value
|
||||
is_referenced_by_test = task_node.xpath("p:tests/p:test/p:filerefs/p:fileref[@id=#{file_id}]")
|
||||
is_referenced_by_model_solution = task_node.xpath("p:model-solutions/p:model-solution/p:filerefs/p:fileref[@id=#{file_id}]")
|
||||
if is_referenced_by_test && (file_class == 'internal')
|
||||
return 'teacher_defined_test'
|
||||
elsif is_referenced_by_model_solution && (file_class == 'internal')
|
||||
return 'reference_implementation'
|
||||
elsif (file_class == 'template') && (comment == 'main')
|
||||
return 'main_file'
|
||||
elsif (file_class == 'internal') && (comment == 'main')
|
||||
end
|
||||
return 'regular_file'
|
||||
end
|
||||
|
||||
def from_proforma_xml(xml_string)
|
||||
# how to extract the proforma functionality into a different module in rails?
|
||||
xml = Nokogiri::XML(xml_string)
|
||||
xml.collect_namespaces
|
||||
task_node = xml.xpath('/root/p:task')
|
||||
description = task_node.xpath('p:description/text()')[0].content
|
||||
self.attributes = {
|
||||
title: task_node.xpath('p:meta-data/p:title/text()')[0].content,
|
||||
description: description,
|
||||
instructions: description
|
||||
}
|
||||
task_node.xpath('p:files/p:file').all? { |file|
|
||||
file_name_split = file.xpath('@filename').first.value.split('.')
|
||||
file_class = file.xpath('@class').first.value
|
||||
role = determine_file_role_from_proforma_file(task_node, file)
|
||||
feedback_message_nodes = task_node.xpath("p:tests/p:test/p:test-configuration/c:feedback-message/text()")
|
||||
files.build({
|
||||
name: file_name_split.first,
|
||||
content: file.xpath('text()').first.content,
|
||||
read_only: false,
|
||||
hidden: file_class == 'internal',
|
||||
role: role,
|
||||
feedback_message: (role == 'teacher_defined_test') ? feedback_message_nodes.first.content : nil,
|
||||
file_type: FileType.where(
|
||||
file_extension: ".#{file_name_split.second}"
|
||||
).take
|
||||
})
|
||||
}
|
||||
self.execution_environment_id = 1
|
||||
end
|
||||
|
||||
def generate_token
|
||||
self.token ||= SecureRandom.hex(4)
|
||||
end
|
||||
@ -64,4 +182,5 @@ class Exercise < ActiveRecord::Base
|
||||
end
|
||||
end
|
||||
private :valid_main_file?
|
||||
|
||||
end
|
||||
|
@ -17,4 +17,8 @@ class InternalUser < ActiveRecord::Base
|
||||
activation_token? || reset_password_token?
|
||||
end
|
||||
private :password_void?
|
||||
|
||||
def teacher?
|
||||
role == 'teacher'
|
||||
end
|
||||
end
|
||||
|
@ -13,6 +13,18 @@ class RequestForComment < ActiveRecord::Base
|
||||
self.requested_at = Time.now
|
||||
end
|
||||
|
||||
def submission
|
||||
Submission.find(file.context_id)
|
||||
end
|
||||
|
||||
def last_submission
|
||||
Submission.find_by_sql(" select * from submissions
|
||||
where exercise_id = #{exercise_id} AND
|
||||
user_id = #{user_id}
|
||||
order by created_at desc
|
||||
limit 1").first
|
||||
end
|
||||
|
||||
private
|
||||
def self.row_number_user_sql
|
||||
select("id, user_id, exercise_id, file_id, requested_at, created_at, updated_at, user_type, row_number() OVER (PARTITION BY user_id ORDER BY created_at DESC) as row_number").to_sql
|
||||
|
4
app/models/testrun.rb
Normal file
4
app/models/testrun.rb
Normal file
@ -0,0 +1,4 @@
|
||||
class Testrun < ActiveRecord::Base
|
||||
belongs_to :file
|
||||
belongs_to :submission
|
||||
end
|
3
app/policies/code_harbor_link_policy.rb
Normal file
3
app/policies/code_harbor_link_policy.rb
Normal file
@ -0,0 +1,3 @@
|
||||
class CodeHarborLinkPolicy < AdminOnlyPolicy
|
||||
|
||||
end
|
@ -1,10 +1,10 @@
|
||||
class ExecutionEnvironmentPolicy < AdminOrAuthorPolicy
|
||||
class ExecutionEnvironmentPolicy < AdminOnlyPolicy
|
||||
def author?
|
||||
@user == @record.author
|
||||
end
|
||||
private :author?
|
||||
|
||||
[:execute_command?, :shell?].each do |action|
|
||||
[:execute_command?, :shell?, :statistics?].each do |action|
|
||||
define_method(action) { admin? || author? }
|
||||
end
|
||||
end
|
||||
|
@ -8,7 +8,11 @@ class ExercisePolicy < AdminOrAuthorPolicy
|
||||
admin?
|
||||
end
|
||||
|
||||
[:clone?, :destroy?, :edit?, :show?, :statistics?, :update?].each do |action|
|
||||
def show?
|
||||
@user.internal_user?
|
||||
end
|
||||
|
||||
[:clone?, :destroy?, :edit?, :statistics?, :update?].each do |action|
|
||||
define_method(action) { admin? || author? || team_member? }
|
||||
end
|
||||
|
||||
|
@ -1,2 +1,5 @@
|
||||
class ExternalUserPolicy < AdminOnlyPolicy
|
||||
def statistics?
|
||||
admin?
|
||||
end
|
||||
end
|
||||
|
@ -1,4 +1,4 @@
|
||||
class FileTypePolicy < AdminOrAuthorPolicy
|
||||
class FileTypePolicy < AdminOnlyPolicy
|
||||
def author?
|
||||
@user == @record.author
|
||||
end
|
||||
|
@ -1,6 +1,6 @@
|
||||
class TeamPolicy < ApplicationPolicy
|
||||
[:create?, :index?, :new?].each do |action|
|
||||
define_method(action) { @user.internal_user? }
|
||||
define_method(action) { admin? }
|
||||
end
|
||||
|
||||
[:destroy?, :edit?, :show?, :update?].each do |action|
|
||||
|
@ -1,6 +1,6 @@
|
||||
- content_for :head do
|
||||
= javascript_include_tag('//cdnjs.cloudflare.com/ajax/libs/vis/3.10.0/vis.min.js')
|
||||
= stylesheet_link_tag('//cdnjs.cloudflare.com/ajax/libs/vis/3.10.0/vis.min.css')
|
||||
= javascript_include_tag(asset_path('vis.min.js', type: :javascript))
|
||||
= stylesheet_link_tag(asset_path('vis.min.css', type: :stylesheet))
|
||||
|
||||
h1 = t('breadcrumbs.dashboard.show')
|
||||
|
||||
|
@ -8,7 +8,7 @@
|
||||
- if current_user.admin?
|
||||
li = link_to(t('breadcrumbs.dashboard.show'), admin_dashboard_path)
|
||||
li.divider
|
||||
- models = [ExecutionEnvironment, Exercise, Consumer, ExternalUser, FileType, InternalUser, Submission, Team].sort_by { |model| model.model_name.human(count: 2) }
|
||||
- models = [ExecutionEnvironment, Exercise, Consumer, CodeHarborLink, ExternalUser, FileType, InternalUser, Submission, Team].sort_by { |model| model.model_name.human(count: 2) }
|
||||
- models.each do |model|
|
||||
- if policy(model).index?
|
||||
li = link_to(model.model_name.human(count: 2), send(:"#{model.model_name.collection}_path"))
|
||||
|
6
app/views/code_harbor_links/_form.html.slim
Normal file
6
app/views/code_harbor_links/_form.html.slim
Normal file
@ -0,0 +1,6 @@
|
||||
= form_for(@code_harbor_link) do |f|
|
||||
= render('shared/form_errors', object: @code_harbor_link)
|
||||
.form-group
|
||||
= f.label(:oauth2token)
|
||||
= f.text_field(:oauth2token, class: 'form-control', required: true)
|
||||
.actions = render('shared/submit_button', f: f, object: @code_harbor_link)
|
3
app/views/code_harbor_links/edit.html.slim
Normal file
3
app/views/code_harbor_links/edit.html.slim
Normal file
@ -0,0 +1,3 @@
|
||||
h1 = @code_harbor_link
|
||||
|
||||
= render('form')
|
18
app/views/code_harbor_links/index.html.slim
Normal file
18
app/views/code_harbor_links/index.html.slim
Normal file
@ -0,0 +1,18 @@
|
||||
h1 = CodeHarborLink.model_name.human(count: 2)
|
||||
|
||||
.table-responsive
|
||||
table.table
|
||||
thead
|
||||
tr
|
||||
th = t('activerecord.attributes.code_harbor_link.oauth2token')
|
||||
th colspan=3 = t('shared.actions')
|
||||
tbody
|
||||
- @code_harbor_links.each do |code_harbor_link|
|
||||
tr
|
||||
td = code_harbor_link.oauth2token
|
||||
td = link_to(t('shared.show'), code_harbor_link)
|
||||
td = link_to(t('shared.edit'), edit_code_harbor_link_path(code_harbor_link))
|
||||
td = link_to(t('shared.destroy'), code_harbor_link, data: {confirm: t('shared.confirm_destroy')}, method: :delete)
|
||||
|
||||
= render('shared/pagination', collection: @code_harbor_links)
|
||||
p = render('shared/new_button', model: CodeHarborLink)
|
3
app/views/code_harbor_links/new.html.slim
Normal file
3
app/views/code_harbor_links/new.html.slim
Normal file
@ -0,0 +1,3 @@
|
||||
h1 = t('shared.new_model', model: CodeHarborLink.model_name.human)
|
||||
|
||||
= render('form')
|
7
app/views/code_harbor_links/show.html.slim
Normal file
7
app/views/code_harbor_links/show.html.slim
Normal file
@ -0,0 +1,7 @@
|
||||
h1
|
||||
= @code_harbor_link
|
||||
= render('shared/edit_button', object: @code_harbor_link) if policy(@code_harbor_link).edit?
|
||||
|
||||
- %w[oauth2token].each do |attribute|
|
||||
= row(label: "code_harbor_link.#{attribute}") do
|
||||
= content_tag(:input, nil, class: 'form-control', readonly: true, value: @code_harbor_link.send(attribute))
|
@ -10,7 +10,7 @@ h1 = ExecutionEnvironment.model_name.human(count: 2)
|
||||
th = t('activerecord.attributes.execution_environment.memory_limit')
|
||||
th = t('activerecord.attributes.execution_environment.network_enabled')
|
||||
th = t('activerecord.attributes.execution_environment.permitted_execution_time')
|
||||
th colspan=4 = t('shared.actions')
|
||||
th colspan=5 = t('shared.actions')
|
||||
th colspan=2 = t('shared.resources')
|
||||
tbody
|
||||
- @execution_environments.each do |execution_environment|
|
||||
@ -25,6 +25,7 @@ h1 = ExecutionEnvironment.model_name.human(count: 2)
|
||||
td = link_to(t('shared.edit'), edit_execution_environment_path(execution_environment))
|
||||
td = link_to(t('shared.destroy'), execution_environment, data: {confirm: t('shared.confirm_destroy')}, method: :delete)
|
||||
td = link_to(t('.shell'), shell_execution_environment_path(execution_environment))
|
||||
td = link_to(t('shared.statistics'), statistics_execution_environment_path(execution_environment))
|
||||
td = link_to(t('activerecord.models.error.other'), execution_environment_errors_path(execution_environment.id))
|
||||
td = link_to(t('activerecord.models.hint.other'), execution_environment_hints_path(execution_environment.id))
|
||||
|
||||
|
25
app/views/execution_environments/statistics.html.slim
Normal file
25
app/views/execution_environments/statistics.html.slim
Normal file
@ -0,0 +1,25 @@
|
||||
h1 = @execution_environment
|
||||
|
||||
.table-responsive
|
||||
table.table.table-striped.sortable
|
||||
thead
|
||||
tr
|
||||
- ['.exercise', '.users', '.score', '.maximum_score', '.stddev_score', '.percentage_correct', '.runs', '.worktime', '.stddev_worktime'].each do |title|
|
||||
th.header = t(title)
|
||||
tbody
|
||||
- @execution_environment.exercises.each do |exercise|
|
||||
- us = user_statistics[exercise.id]
|
||||
- if not us then us = {"users" => 0, "average_score" => 0.0, "maximum_score" => 0, "stddev_score" => 0.0, "percent_correct" => nil, "average_submission_count" => 0}
|
||||
- wts = working_time_statistics[exercise.id]
|
||||
- if wts then average_time = wts["average_time"] else 0
|
||||
- if wts then stddev_time = wts["stddev_time"] else 0
|
||||
tr
|
||||
td = link_to exercise.title, controller: "exercises", action: "statistics", id: exercise.id
|
||||
td = us["users"]
|
||||
td = us["average_score"].to_f.round(4)
|
||||
td = us["maximum_score"].to_f.round(2)
|
||||
td = us["stddev_score"].to_f.round(4)
|
||||
td = (us["percent_correct"].to_f or 0).round(4)
|
||||
td = us["average_submission_count"].to_f.round(2)
|
||||
td = average_time
|
||||
td = stddev_time
|
54
app/views/exercises/external_users/statistics.html.slim
Normal file
54
app/views/exercises/external_users/statistics.html.slim
Normal file
@ -0,0 +1,54 @@
|
||||
h1 = "#{@exercise} (external user #{@external_user})"
|
||||
- submissions = Submission.where("user_id = ? AND exercise_id = ?", @external_user.id, @exercise.id).order("created_at")
|
||||
- current_submission = submissions.first
|
||||
- if current_submission
|
||||
- initial_files = current_submission.files.to_a
|
||||
|
||||
- all_files = []
|
||||
- file_types = Set.new()
|
||||
- submissions.each do |submission|
|
||||
- submission.files.each do |file|
|
||||
- file_types.add(ActiveSupport::JSON.encode(file.file_type))
|
||||
- all_files.push(submission.files)
|
||||
|
||||
.hidden#data data-submissions=ActiveSupport::JSON.encode(submissions) data-files=ActiveSupport::JSON.encode(all_files) data-file-types=ActiveSupport::JSON.encode(file_types)
|
||||
|
||||
#stats-editor.row
|
||||
- index = 0
|
||||
- all_files.each do |files|
|
||||
.files class=(@exercise.hide_file_tree ? 'hidden col-sm-3' : 'col-sm-3') data-index=index data-entries=FileTree.new(files).to_js_tree
|
||||
- index += 1
|
||||
div class=(@exercise.hide_file_tree ? 'col-sm-12' : 'col-sm-9')
|
||||
#current-file.editor
|
||||
|
||||
.flex-container
|
||||
button.btn.btn-default id='play-button'
|
||||
span.fa.fa-play
|
||||
#submissions-slider.flex-item
|
||||
input type='range' orient='horizontal' list='datapoints' min=0 max=submissions.length-1 value=0
|
||||
datalist#datapoints
|
||||
- index=0
|
||||
- submissions.each do |submission|
|
||||
option data-submission=submission
|
||||
=index
|
||||
- index += 1
|
||||
|
||||
#timeline
|
||||
.table-responsive
|
||||
table.table
|
||||
thead
|
||||
tr
|
||||
- ['.time', '.cause', '.score', '.time_difference'].each do |title|
|
||||
th.header = t(title)
|
||||
tbody
|
||||
- deltas = submissions.map.with_index {|item, index| delta = item.created_at - submissions[index - 1].created_at if index > 0; if delta == nil or delta > 30*60 then 0 else delta end}
|
||||
- submissions.each_with_index do |submission, index|
|
||||
tr data-id=submission.id
|
||||
td.clickable = submission.created_at.strftime("%F %T")
|
||||
td = submission.cause
|
||||
td = submission.score
|
||||
td = Time.at(deltas[1..index].inject(:+)).utc.strftime("%H:%M:%S") if index > 0
|
||||
p = t('.addendum')
|
||||
|
||||
- else
|
||||
p = t('.no_data_available')
|
@ -27,17 +27,17 @@ h1 = Exercise.model_name.human(count: 2)
|
||||
- @exercises.each do |exercise|
|
||||
tr data-id=exercise.id
|
||||
td = exercise.title
|
||||
td = link_to(exercise.author, exercise.author)
|
||||
td = link_to(exercise.execution_environment, exercise.execution_environment)
|
||||
td = link_to_if(policy(exercise.author).show?, exercise.author, exercise.author)
|
||||
td = link_to_if(policy(exercise.execution_environment).show?, exercise.execution_environment, exercise.execution_environment)
|
||||
td = exercise.files.teacher_defined_tests.count
|
||||
td = exercise.maximum_score
|
||||
td.public data-value=exercise.public? = symbol_for(exercise.public?)
|
||||
td = link_to(t('shared.show'), exercise)
|
||||
td = link_to(t('shared.edit'), edit_exercise_path(exercise))
|
||||
td = link_to(t('shared.destroy'), exercise, data: {confirm: t('shared.confirm_destroy')}, method: :delete)
|
||||
td = link_to(t('.clone'), clone_exercise_path(exercise), data: {confirm: t('shared.confirm_destroy')}, method: :post)
|
||||
td = link_to(t('.implement'), implement_exercise_path(exercise))
|
||||
td = link_to(t('shared.statistics'), statistics_exercise_path(exercise))
|
||||
td = link_to(t('shared.show'), exercise) if policy(exercise).show?
|
||||
td = link_to(t('shared.edit'), edit_exercise_path(exercise)) if policy(exercise).edit?
|
||||
td = link_to(t('shared.destroy'), exercise, data: {confirm: t('shared.confirm_destroy')}, method: :delete) if policy(exercise).destroy?
|
||||
td = link_to(t('.clone'), clone_exercise_path(exercise), data: {confirm: t('shared.confirm_destroy')}, method: :post) if policy(exercise).clone?
|
||||
td = link_to(t('.implement'), implement_exercise_path(exercise)) if policy(exercise).implement?
|
||||
td = link_to(t('shared.statistics'), statistics_exercise_path(exercise)) if policy(exercise).statistics?
|
||||
|
||||
= render('shared/pagination', collection: @exercises)
|
||||
p = render('shared/new_button', model: Exercise)
|
||||
p = render('shared/new_button', model: Exercise)
|
||||
|
@ -4,12 +4,13 @@
|
||||
|
||||
h1
|
||||
= @exercise
|
||||
= render('shared/edit_button', object: @exercise)
|
||||
- if policy(@exercise).edit?
|
||||
= render('shared/edit_button', object: @exercise)
|
||||
|
||||
= row(label: 'exercise.title', value: @exercise.title)
|
||||
= row(label: 'exercise.user', value: link_to(@exercise.author, @exercise.author))
|
||||
= row(label: 'exercise.user', value: link_to_if(policy(@exercise.author).show?, @exercise.author, @exercise.author))
|
||||
= row(label: 'exercise.description', value: @exercise.description)
|
||||
= row(label: 'exercise.execution_environment', value: link_to(@exercise.execution_environment, @exercise.execution_environment))
|
||||
= row(label: 'exercise.execution_environment', value: link_to_if(policy(@exercise.execution_environment).show?, @exercise.execution_environment, @exercise.execution_environment))
|
||||
= row(label: 'exercise.instructions', value: render_markdown(@exercise.instructions))
|
||||
= row(label: 'exercise.team', value: @exercise.team ? link_to(@exercise.team, @exercise.team) : nil)
|
||||
= row(label: 'exercise.maximum_score', value: @exercise.maximum_score)
|
||||
@ -26,5 +27,6 @@ ul.list-unstyled
|
||||
.panel-heading
|
||||
h3.panel-title = file.name_with_extension
|
||||
.panel-body
|
||||
.clearfix = link_to(t('shared.destroy'), file, class:'btn btn-warning btn-sm pull-right', data: {confirm: t('shared.confirm_destroy')}, method: :delete)
|
||||
- if policy(file).destroy?
|
||||
.clearfix = link_to(t('shared.destroy'), file, class:'btn btn-warning btn-sm pull-right', data: {confirm: t('shared.confirm_destroy')}, method: :delete)
|
||||
= render('shared/file', file: file)
|
||||
|
@ -1,9 +1,30 @@
|
||||
h1 = @exercise
|
||||
|
||||
= row(label: '.participants', value: @exercise.users.count)
|
||||
= row(label: '.participants', value: @exercise.users.distinct.count)
|
||||
- [:intermediate, :final].each do |scope|
|
||||
= row(label: ".#{scope}_submissions") do
|
||||
= "#{@exercise.submissions.send(scope).count} (#{t('.users', count: @exercise.submissions.send(scope).distinct.count(:user_id, :user_type))})"
|
||||
= "#{@exercise.submissions.send(scope).count} (#{t('.users', count: @exercise.submissions.send(scope).distinct.count(:user_id))})"
|
||||
= row(label: '.average_score') do
|
||||
p == @exercise.average_score ? t('shared.out_of', maximum_value: @exercise.maximum_score, value: @exercise.average_score.round(2)) : empty
|
||||
p = progress_bar(@exercise.average_percentage)
|
||||
|
||||
= row(label: '.average_worktime') do
|
||||
p = @exercise.average_working_time
|
||||
|
||||
- Hash[:internal_users => t('.internal_users'), :external_users => t('.external_users')].each_pair do |symbol, label|
|
||||
strong = label
|
||||
.table-responsive
|
||||
table.table.table-striped.sortable
|
||||
thead
|
||||
tr
|
||||
- ['.user', '.score', '.runs', '.worktime'].each do |title|
|
||||
th.header = t(title)
|
||||
tbody
|
||||
- @exercise.send(symbol).distinct().each do |user|
|
||||
- if user_statistics[user.id] then us = user_statistics[user.id] else us = {"maximum_score" => nil, "runs" => nil}
|
||||
- label = current_user.teacher? ? "#{user.name}" : "#{user.name} (#{user.email})"
|
||||
tr
|
||||
td = link_to_if symbol==:external_users, label, {controller: "exercises", action: "statistics", external_user_id: user.id, id: @exercise.id}
|
||||
td = us['maximum_score'] or 0
|
||||
td = us['runs']
|
||||
td = @exercise.average_working_time_for(user.id) or 0
|
||||
|
@ -3,3 +3,6 @@ h1 = @user.name
|
||||
= row(label: 'external_user.name', value: @user.name)
|
||||
= row(label: 'external_user.email', value: @user.email)
|
||||
= row(label: 'external_user.consumer', value: link_to(@user.consumer, @user.consumer))
|
||||
|
||||
br
|
||||
= link_to(t('shared.statistics'), statistics_external_user_path(@user))
|
||||
|
18
app/views/external_users/statistics.html.slim
Normal file
18
app/views/external_users/statistics.html.slim
Normal file
@ -0,0 +1,18 @@
|
||||
h1 = t('.title')
|
||||
|
||||
- exercises = Exercise.where(:id => @user.submissions.group(:exercise_id).select(:exercise_id).distinct())
|
||||
|
||||
.table-responsive
|
||||
table.table.table-striped.sortable
|
||||
thead
|
||||
tr
|
||||
- ['.exercise', '.score', '.runs', '.worktime'].each do |title|
|
||||
th.header = t(title)
|
||||
tbody
|
||||
- exercises.each do |exercise|
|
||||
- if statistics[exercise.id] then stats = statistics[exercise.id] else stats = {"working_time" => 0, "runs" => 0, "score" => 0}
|
||||
tr
|
||||
td = link_to exercise, controller: "exercises", action: "statistics", external_user_id: @user.id, id: exercise.id
|
||||
td = stats["maximum_score"] or 0
|
||||
td = stats["runs"] or 0
|
||||
td = stats["working_time"] or 0
|
@ -5,12 +5,12 @@ html lang='en'
|
||||
meta name='viewport' content='width=device-width, initial-scale=1'
|
||||
title = application_name
|
||||
link href=asset_path('favicon.png') rel='icon' type='image/png'
|
||||
= stylesheet_link_tag('//maxcdn.bootstrapcdn.com/bootswatch/3.3.4/yeti/bootstrap.min.css')
|
||||
= stylesheet_link_tag('//maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css')
|
||||
= stylesheet_link_tag(asset_path('bootstrap.min.css', type: :stylesheet))
|
||||
= stylesheet_link_tag(asset_path('font-awesome.min.css', type: :stylesheet))
|
||||
= stylesheet_link_tag('application', media: 'all', 'data-turbolinks-track' => true)
|
||||
= javascript_include_tag('application', 'data-turbolinks-track' => true)
|
||||
= javascript_include_tag('//cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js')
|
||||
= javascript_include_tag('//maxcdn.bootstrapcdn.com/bootstrap/3.3.4/js/bootstrap.min.js')
|
||||
= javascript_include_tag(asset_path('underscore-min.js', type: :javascript))
|
||||
= javascript_include_tag(asset_path('bootstrap.min.js', type: :javascript))
|
||||
= yield(:head)
|
||||
= csrf_meta_tags
|
||||
body
|
||||
|
@ -2,17 +2,15 @@
|
||||
<h4 class="list-group-item-heading"><%= Exercise.find(@request_for_comment.exercise_id) %></h4>
|
||||
<p class="list-group-item-text">
|
||||
<%
|
||||
|
||||
user = @request_for_comment.user
|
||||
submission_id = self.class.connection.execute("select id from submissions
|
||||
where exercise_id =
|
||||
#{@request_for_comment.exercise_id} AND
|
||||
user_id = #{@request_for_comment.user_id} AND
|
||||
#{@request_for_comment.user_id} > created_at
|
||||
order by created_at desc
|
||||
limit 1").first['id'].to_i
|
||||
submission = Submission.find(submission_id)
|
||||
|
||||
user = @request_for_comment.user
|
||||
submission_id = ActiveRecord::Base.connection.execute("select id from submissions
|
||||
where exercise_id =
|
||||
#{@request_for_comment.exercise_id} AND
|
||||
user_id = #{@request_for_comment.user_id} AND
|
||||
'#{@request_for_comment.created_at}' > created_at
|
||||
order by created_at desc
|
||||
limit 1").first['id'].to_i
|
||||
submission = Submission.find(submission_id)
|
||||
%>
|
||||
<%= user %> | <%= @request_for_comment.requested_at %>
|
||||
</p>
|
||||
@ -21,102 +19,96 @@
|
||||
<!--
|
||||
do not put a carriage return in the line below. it will be present in the presentation of the source code, otherwise.
|
||||
-->
|
||||
<div id='commentitor' class='editor' data-read-only='true' data-file-id='<%=@request_for_comment.file_id%>'><%= CodeOcean::File.find(@request_for_comment.file_id).content %>
|
||||
</div>
|
||||
<% submission.files.each do |file| %>
|
||||
<%= (file.path or "") + "/" + file.name + file.file_type.file_extension %>
|
||||
<div id='commentitor' class='editor' data-read-only='true' data-file-id='<%=file.id%>'><%= file.content %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<script type="text/javascript">
|
||||
//(function() {
|
||||
var commentitor = $('#commentitor');
|
||||
var userid = commentitor.data('user-id');
|
||||
var fileid = commentitor.data('file-id');
|
||||
var commentitor = $('.editor');
|
||||
var userid = commentitor.data('user-id');
|
||||
|
||||
commentitor.each(function (index, editor) {
|
||||
currentEditor = ace.edit(editor);
|
||||
currentEditor.setReadOnly(true);
|
||||
|
||||
setAnnotations(currentEditor, $(editor).data('file-id'));
|
||||
});
|
||||
|
||||
function setAnnotations(editor, fileid) {
|
||||
var session = editor.getSession()
|
||||
|
||||
var inputHtml = ''
|
||||
//inputHtml += '<form class="form">'
|
||||
//inputHtml += '<div class="form-group">'
|
||||
//inputHtml += '<p style="display:inline-block"><%= t("exercises.implement.comment.line") %></p>'
|
||||
//inputHtml += '<input type="number" class="form-control" id="lineInput" placeholder="1" required>'
|
||||
//inputHtml += '</div>'
|
||||
inputHtml += '<div class="input-group">'
|
||||
//inputHtml += '<p style="display:inline-block"><%= t("exercises.implement.comment.a_comment") %></p>'
|
||||
inputHtml += '<input type="text" class="form-control" id="commentInput" placeholder="I\'d suggest a variable here" required>'
|
||||
inputHtml += '<span class="input-group-btn"><button id="submitComment" class="btn btn-default"><%= t("exercises.implement.comment.addComment") %>!</button></span>'
|
||||
inputHtml += '</div>'
|
||||
//inputHtml +='</form>'
|
||||
inputHtml += '<div class="input-group">'
|
||||
inputHtml += '<input type="text" class="form-control" id="commentInput"'
|
||||
inputHtml += 'placeholder="I\'d suggest a variable here" required>'
|
||||
inputHtml += '<span class="input-group-btn"><button id="submitComment"'
|
||||
inputHtml += 'class="btn btn-default"><%= t("exercises.implement.comment.addComment") %>!</button></span>'
|
||||
inputHtml += '</div>'
|
||||
|
||||
|
||||
commentitor = ace.edit(commentitor[0]);
|
||||
commentitor.setReadOnly(true);
|
||||
|
||||
setAnnotations();
|
||||
|
||||
function setAnnotations() {
|
||||
var session = commentitor.getSession()
|
||||
|
||||
var jqrequest = $.ajax({
|
||||
dataType: 'json',
|
||||
method: 'GET',
|
||||
url: '/comments',
|
||||
data: {
|
||||
file_id: fileid
|
||||
}
|
||||
});
|
||||
|
||||
jqrequest.done(function(response){
|
||||
//data-container=".ui-front" on gutter cell
|
||||
$.each(response, function(index, comment) {
|
||||
comment.className = "code-ocean_comment"
|
||||
comment.text = comment.username + ": " + comment.text
|
||||
})
|
||||
|
||||
commentitor.getSession().setAnnotations(response)
|
||||
|
||||
//$('.ace_gutter-layer').data('container', '.ui-front')
|
||||
$('.ace_gutter-cell').popover({
|
||||
title: 'Add a comment',
|
||||
html: true,
|
||||
content: inputHtml,
|
||||
position: 'right',
|
||||
trigger: 'focus click'
|
||||
}).on('shown.bs.popover', function() {
|
||||
$('#commentInput').focus()
|
||||
$('#submitComment').click(addComment);
|
||||
console.log($(this).text())
|
||||
$('#commentInput').data('line', $(this).text())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function addComment() {
|
||||
var commentInput = $('#commentInput');
|
||||
var comment = commentInput.val()
|
||||
var line = $('#commentInput').data('line')
|
||||
|
||||
if (line == '' || comment == '') {
|
||||
return
|
||||
} else {
|
||||
line = parseInt(line) - 1
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
data: {
|
||||
comment: {
|
||||
user_id: userid,
|
||||
file_id: fileid,
|
||||
row: line,
|
||||
column: 0,
|
||||
text: comment
|
||||
}
|
||||
},
|
||||
var jqrequest = $.ajax({
|
||||
dataType: 'json',
|
||||
method: 'POST',
|
||||
url: "/comments"
|
||||
}).done(setAnnotations)
|
||||
method: 'GET',
|
||||
url: '/comments',
|
||||
data: {
|
||||
file_id: fileid
|
||||
}
|
||||
});
|
||||
|
||||
$('.ace_gutter-cell').popover('hide')
|
||||
jqrequest.done(function(response){
|
||||
$.each(response, function(index, comment) {
|
||||
comment.className = "code-ocean_comment"
|
||||
comment.text = comment.username + ": " + comment.text
|
||||
})
|
||||
|
||||
editor.getSession().setAnnotations(response)
|
||||
|
||||
$(editor.container).find('.ace_gutter-cell').popover({
|
||||
title: 'Add a comment',
|
||||
html: true,
|
||||
content: inputHtml,
|
||||
position: 'right',
|
||||
trigger: 'focus click'
|
||||
}).on('shown.bs.popover', function() {
|
||||
var container = $(editor.container)
|
||||
container.find('#commentInput').focus()
|
||||
container.find('#submitComment').click(_.partial(addComment, editor, fileid));
|
||||
container.find('#commentInput').data('line', $(this).text())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function addComment(editor, fileid) {
|
||||
var commentInput = $(editor.container).find('#commentInput');
|
||||
var comment = commentInput.val()
|
||||
var line = commentInput.data('line')
|
||||
|
||||
if (line == '' || comment == '') {
|
||||
return
|
||||
} else {
|
||||
line = parseInt(line) - 1
|
||||
}
|
||||
//})()
|
||||
|
||||
$.ajax({
|
||||
data: {
|
||||
comment: {
|
||||
user_id: userid,
|
||||
file_id: fileid,
|
||||
row: line,
|
||||
column: 0,
|
||||
text: comment
|
||||
}
|
||||
},
|
||||
dataType: 'json',
|
||||
method: 'POST',
|
||||
url: "/comments"
|
||||
}).done(setAnnotations(editor, fileid))
|
||||
|
||||
$('.ace_gutter-cell').popover('hide')
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
#commentitor, .ace_gutter, .ace_gutter-layer { overflow: visible }
|
||||
.popover { width: 400px; max-width: none; }
|
||||
</style>
|
||||
</style>
|
||||
|
@ -1,6 +1,6 @@
|
||||
= row(label: 'file.name', value: file.name)
|
||||
= row(label: 'file.path', value: file.path)
|
||||
= row(label: 'file.file_type', value: link_to(file.file_type, file.file_type))
|
||||
= row(label: 'file.file_type', value: link_to_if(policy(file).show?, file.file_type, file.file_type))
|
||||
= row(label: 'file.role', value: file.role? ? t("files.roles.#{file.role}") : '')
|
||||
= row(label: 'file.hidden', value: file.hidden)
|
||||
= row(label: 'file.read_only', value: file.read_only)
|
||||
|
Reference in New Issue
Block a user