Merge branch 'master' into fix-ctrl-alt-0

This commit is contained in:
Ralf Teusner
2016-03-02 17:26:55 +01:00
110 changed files with 2795 additions and 309 deletions

View File

@ -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;

View 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 ? '&nbsp<font face="webdings">5</font>' : '&nbsp;&#x25B4;';
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 ? '&nbsp<font face="webdings">6</font>' : '&nbsp;&#x25BE;';
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 ? '&nbsp<font face="webdings">6</font>' : '&nbsp;&#x25BE;';
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);
}
};
}());
});

View 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();
}
});

View 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;
}

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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))

View File

@ -0,0 +1,2 @@
module CodeHarborLinksHelper
end

View 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

View File

@ -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

View File

@ -17,4 +17,8 @@ class InternalUser < ActiveRecord::Base
activation_token? || reset_password_token?
end
private :password_void?
def teacher?
role == 'teacher'
end
end

View File

@ -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
View File

@ -0,0 +1,4 @@
class Testrun < ActiveRecord::Base
belongs_to :file
belongs_to :submission
end

View File

@ -0,0 +1,3 @@
class CodeHarborLinkPolicy < AdminOnlyPolicy
end

View File

@ -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

View File

@ -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

View File

@ -1,2 +1,5 @@
class ExternalUserPolicy < AdminOnlyPolicy
def statistics?
admin?
end
end

View File

@ -1,4 +1,4 @@
class FileTypePolicy < AdminOrAuthorPolicy
class FileTypePolicy < AdminOnlyPolicy
def author?
@user == @record.author
end

View File

@ -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|

View File

@ -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')

View File

@ -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"))

View 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)

View File

@ -0,0 +1,3 @@
h1 = @code_harbor_link
= render('form')

View 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)

View File

@ -0,0 +1,3 @@
h1 = t('shared.new_model', model: CodeHarborLink.model_name.human)
= render('form')

View 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))

View File

@ -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))

View 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

View 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')

View File

@ -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)

View File

@ -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)

View 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

View File

@ -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))

View 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

View File

@ -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

View File

@ -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>

View File

@ -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)