diff --git a/.gitignore b/.gitignore index 4a862a70..46060552 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /config/secrets.yml /config/sendmail.yml /config/smtp.yml +/config/docker.yml.erb /config/*.production.yml /config/*.staging.yml /coverage diff --git a/Gemfile.lock b/Gemfile.lock index 198d157a..3493d81f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -419,6 +419,3 @@ DEPENDENCIES uglifier (>= 1.3.0) web-console (~> 2.0) will_paginate (~> 3.0) - -BUNDLED WITH - 1.12.4 diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index ebdcc08d..ccf8ce15 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -24,4 +24,5 @@ //= require bootstrap_pagedown //= require markdown.converter //= require markdown.sanitizer -//= require markdown.editor \ No newline at end of file +//= require markdown.editor +//= require ../../../vendor/assets/javascripts/ace/ext-language_tools \ No newline at end of file diff --git a/app/assets/javascripts/editor.js.erb b/app/assets/javascripts/editor.js.erb index b2f69fcf..f402e982 100644 --- a/app/assets/javascripts/editor.js.erb +++ b/app/assets/javascripts/editor.js.erb @@ -1,1198 +1,27 @@ $(function() { - var ACE_FILES_PATH = '/assets/ace/'; - var ADEQUATE_PERCENTAGE = 50; - var ALT_1_KEY_CODE = 161; - var ALT_2_KEY_CODE = 8220; - var ALT_3_KEY_CODE = 182; - var ALT_4_KEY_CODE = 162; - var ALT_R_KEY_CODE = 174; - var ALT_S_KEY_CODE = 8218; - var ALT_T_KEY_CODE = 8224; - var FILENAME_URL_PLACEHOLDER = '{filename}'; - var SUCCESSFULL_PERCENTAGE = 90; - var THEME = 'ace/theme/textmate'; - var REMEMBER_TAB = false; - var AUTOSAVE_INTERVAL = 15 * 1000; - var REQUEST_FOR_COMMENTS_DELAY = 3 * 60 * 1000; - var editors = []; - var editor_for_file = new Map(); - var regex_for_language = new Map(); - var tracepositions_regex; - var resetTurtle = true; + //Merge all editor components. OOP for the win. O rly. + //TODO Change this. Otherwise it will fuck people up, + //because it's really confusing if the variables and the code are + //split over 6 files. + $.extend( + CodeOceanEditor, + CodeOceanEditorAJAX, + CodeOceanEditorEvaluation, + CodeOceanEditorFlowr, + CodeOceanEditorSubmissions, + CodeOceanEditorTurtle, + CodeOceanEditorWebsocket, + CodeOceanEditorPrompt, + CodeOceanEditorCodePilot, + CodeOceanEditorRequestForComments + ); - var active_file = undefined; - var active_frame = undefined; - var running = false; - var qa_api = undefined; - var output_mode_is_streaming = true; - - var websocket, - turtlescreen, - numMessages = 0, - turtlecanvas = $('#turtlecanvas'), - prompt = $('#prompt'), - commands = ['input', 'write', 'turtle', 'turtlebatch', 'render', 'exit', 'timeout', 'status'], - streams = ['stdin', 'stdout', 'stderr']; - - var ENTER_KEY_CODE = 13; - - var flowrOutputBuffer = ""; - var QaApiOutputBuffer = {'stdout': '', 'stderr': ''}; - var flowrResultHtml = '
' - - var ajax = function(options) { - return $.ajax(_.extend({ - dataType: 'json', - method: 'POST', - }, options)); - }; - - var ajaxError = function(response) { - var message = ((response || {}).responseJSON || {}).message || ''; - - $.flash.danger({ - text: message.length > 0 ? message : $('#flash').data('message-failure') - }); - }; - - var clearOutput = function() { - $('#output pre').remove(); - }; - - var collectFiles = function() { - var editable_editors = _.filter(editors, function(editor) { - return !editor.getReadOnly(); - }); - return _.map(editable_editors, function(editor) { - return { - content: editor.getValue(), - file_id: $(editor.container).data('file-id') - }; - }); - }; - - var configureEditors = function() { - _.each(['modePath', 'themePath', 'workerPath'], function(attribute) { - ace.config.set(attribute, ACE_FILES_PATH); - }); - }; - - var confirmDestroy = function(event) { - event.preventDefault(); - if (confirm($(this).data('message-confirm'))) { - destroyFile(); - } - }; - - var confirmReset = function(event) { - event.preventDefault(); - if (confirm($(this).data('message-confirm'))) { - resetCode(); - } - }; - - //var confirmSubmission = function(event) { - // event.preventDefault(); - // if (confirm($(this).data('message-confirm'))) { - // submitCode(); - // } - //}; - - var createSubmission = function(initiator, filter, callback) { - showSpinner(initiator); - var jqxhr = ajax({ - data: { - submission: { - cause: $(initiator).data('cause') || $(initiator).prop('id'), - exercise_id: $('#editor').data('exercise-id'), - files_attributes: (filter || _.identity)(collectFiles()) - }, - annotations_arr: [] - }, - dataType: 'json', - method: 'POST', - url: $(initiator).data('url') || $('#editor').data('submissions-url') - }); - jqxhr.always(hideSpinner); - jqxhr.done(createSubmissionCallback); - jqxhr.done(callback); - jqxhr.fail(ajaxError); - }; - - var createSubmissionCallback = function(data){ - // set all frames context types to submission - $('.frame').each(function(index, element) { - $(element).data('context-type', 'Submission'); - }); - - // update the ids of the editors and reload the annotations - for (var i = 0; i < editors.length; i++) { - - // set the data attribute to submission - //$(editors[i].container).data('context-type', 'Submission'); - - var file_id_old = $(editors[i].container).data('file-id'); - - // file_id_old is always set. Either it is a reference to a teacher supplied given file, or it is the actual id of a new user created file. - // This is the case, since it is set via a call to ancestor_id on the model, which returns either file_id if set, or id if it is not set. - // therefore the else part is not needed any longer... - - // if we have an file_id set (the file is a copy of a teacher supplied given file) and the new file-ids are present in the response - if (file_id_old != null && data.files){ - // if we find file_id_old (this is the reference to the base file) in the submission, this is the match - for(var j = 0; j< data.files.length; j++){ - if(data.files[j].file_id == file_id_old){ - //$(editors[i].container).data('id') = data.files[j].id; - $(editors[i].container).data('id', data.files[j].id ); - } - } - } - } - // toggle button states (it might be the case that the request for comments button has to be enabled - toggleButtonStates(); - - }; - - var destroyFile = function() { - createSubmission($('#destroy-file'), function(files) { - return _.reject(files, function(file) { - return file.file_id === active_file.id; - }); - }, window.CodeOcean.refresh); - }; - - var downloadCode = function(event) { - event.preventDefault(); - createSubmission(this, null,function(response) { - var url = response.download_url; - - // to download just a single file, use the following url - //var url = response.download_file_url.replace(FILENAME_URL_PLACEHOLDER, active_file.filename); - window.location = url; - }); - }; - - var evaluateCode = function(url, callback) { - initWebsocketConnection(url, callback); - }; - - var fileActionsAvailable = function() { - return isActiveFileRenderable() || isActiveFileRunnable() || isActiveFileStoppable() || isActiveFileTestable(); - }; - - var findOrCreateOutputElement = function(index) { - if ($('#output-' + index).isPresent()) { - return $('#output-' + index); + if ($('#editor').isPresent() && CodeOceanEditor) { + if (CodeOceanEditor.isBrowserSupported()) { + CodeOceanEditor.initializeEverything(); } else { - var element = $('').attr('id', 'output-' + index); - $('#output').append(element); - return element; + $('#alert').show(); } - }; - - var findOrCreateRenderElement = function(index) { - if ($('#render-' + index).isPresent()) { - return $('#render-' + index); - } else { - var element = $('').attr('id', 'render-' + index); - $('#render').append(element); - return element; - } - }; - - var getPanelClass = function(result) { - if (result.stderr && !result.score) { - return 'panel-danger'; - } else if (result.score < 1) { - return 'panel-warning'; - } else { - return 'panel-success'; - } - }; - - var getProgressBarClass = function(percentage) { - if (percentage < ADEQUATE_PERCENTAGE) { - return 'progress-bar progress-bar-striped progress-bar-danger'; - } else if (percentage < SUCCESSFULL_PERCENTAGE) { - return 'progress-bar progress-bar-striped progress-bar-warning'; - } else { - return 'progress-bar progress-bar-striped progress-bar-success'; - } - }; - - var handleKeyPress = function(event) { - if (event.which === ALT_1_KEY_CODE) { - showWorkspaceTab(event); - } else if (event.which === ALT_2_KEY_CODE) { - showTab(1); - } else if (event.which === ALT_3_KEY_CODE) { - showTab(2); - } else if (event.which === ALT_R_KEY_CODE) { - $('#run').trigger('click'); - } else if (event.which === ALT_S_KEY_CODE) { - $('#assess').trigger('click'); - } else if (event.which === ALT_T_KEY_CODE) { - $('#test').trigger('click'); - } else { - return; - } - event.preventDefault(); - }; - - var lastCopyText; - var handleCopyEvent = function(text){ - lastCopyText = text; - }; - - var handlePasteEvent = function (pasteObject) { - //console.log("Handling paste event. this is ", this ); - //console.log("Text: " + pasteObject.text); - - var same = (lastCopyText === pasteObject.text); - //console.log("Text is the same: " + same); - - // if the text is not copied from within the editor (from any file), send an event to lanalytics - if(!same){ - publishCodeOceanEvent("codeocean_editor_paste", { - text: pasteObject.text, - exercise: $('#editor').data('exercise-id'), - file_id: "1" - - }); - } - }; - - var handleScoringResponse = function(websocket_event) { - results = JSON.parse(websocket_event.data); - printScoringResults(results); - var score = _.reduce(results, function(sum, result) { - return sum + result.score * result.weight; - }, 0).toFixed(2); - $('#score').data('score', score); - renderScore(); - showTab(2); - }; - - var handleQaApiOutput = function() { - if (qa_api) { - qa_api.executeCommand('syncOutput', [[QaApiOutputBuffer]]); - // reset the object - } - QaApiOutputBuffer = {'stdout': '', 'stderr': ''}; - } - - // activate flowr only for half of the audience - var isFlowrEnabled = true;//parseInt($('#editor').data('user-id'))%2 == 0; - var handleStderrOutputForFlowr = function() { - if (!isFlowrEnabled) return - - var flowrUrl = $('#flowrHint').data('url'); - var flowrHintBody = $('#flowrHint .panel-body'); - var queryParameters = { - query: flowrOutputBuffer - } - - flowrHintBody.empty(); - - jQuery.getJSON(flowrUrl, queryParameters, function(data) { - jQuery.each(data.queryResults, function(index, question) { - var collapsibleTileHtml = flowrResultHtml.replace(/{{collapseId}}/g, 'collapse-' + question).replace(/{{headingId}}/g, 'heading-' + question); - var resultTile = $(collapsibleTileHtml); - - resultTile.find('h4 > a').text(question.title + ' | Found via ' + question.source); - resultTile.find('.panel-body').html(question.body); - resultTile.find('.panel-body').append('Open this question'); - - flowrHintBody.append(resultTile); - }); - - if (data.queryResults.length !== 0) { - $('#flowrHint').fadeIn(); - } - }) - - flowrOutputBuffer = ''; - }; - - var handleTestResponse = function(websocket_event) { - result = JSON.parse(websocket_event.data); - clearOutput(); - printOutput(result, false, 0); - if (qa_api) { - qa_api.executeCommand('syncOutput', [result]); - } - showStatus(result); - showTab(1); - }; - - var hideSpinner = function() { - $('button i.fa').show(); - $('button i.fa-spin').hide(); - }; - - var autosaveTimer; - var autosaveLabel = $("#autosave-label span"); - - var resetSaveTimer = function(){ - clearTimeout(autosaveTimer); - autosaveTimer = setTimeout(autosave, AUTOSAVE_INTERVAL); - }; - - var autosave = function(){ - var date = new Date(); - autosaveLabel.parent().css("visibility", "visible"); - autosaveLabel.text(date.getHours() + ':' + date.getMinutes() + ':' + date.getSeconds()); - autosaveLabel.text(date.toLocaleTimeString()); - autosaveTimer = null; - createSubmission($('#autosave'), null); - } - - var initializeEditors = function() { - $('.editor').each(function(index, element) { - var editor = ace.edit(element); - - if (qa_api) { - editor.getSession().on("change", function (deltaObject) { - qa_api.executeCommand('syncEditor', [active_file, deltaObject]); - }); - } - var document = editor.getSession().getDocument(); - // insert pre-existing code into editor. we have to use insertLines, otherwise the deltas are not properly added - var file_id = $(element).data('file-id'); - var content = $('.editor-content[data-file-id=' + file_id + ']'); - 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); - editor.commands.bindKey("ctrl+alt+0", null); - editors.push(editor); - editor_for_file.set($(element).parent().data('filename'), editor); - var session = editor.getSession(); - session.setMode($(element).data('mode')); - session.setTabSize($(element).data('indent-size')); - session.setUseSoftTabs(true); - session.setUseWrapMode(true); - - // set regex for parsing error traces based on the mode of the main file. - if( $(element).parent().data('role') == "main_file"){ - tracepositions_regex = regex_for_language.get($(element).data('mode')); - } - - var file_id = $(element).data('id'); - - /* - * Register event handlers - */ - - // editor itself - editor.on("paste", handlePasteEvent); - editor.on("copy", handleCopyEvent); - - // listener for autosave - session.on("change", function (deltaObject) { - resetSaveTimer(); - }); - }); - }; - - var initializeEventHandlers = function() { - $(document).on('click', '#results a', showOutput); - $(document).on('keypress', handleKeyPress); - $('a[data-toggle="tab"]').on('show.bs.tab', storeTab); - initializeFileTreeButtons(); - initializeWorkflowButtons(); - initializeWorkspaceButtons(); - initializeRequestForComments() - }; - - var initializeFileTree = function() { - $('#files').jstree($('#files').data('entries')); - $('#files').on('click', 'li.jstree-leaf', function() { - active_file = { - filename: $(this).text(), - id: parseInt($(this).attr('id')) - }; - var frame = $('[data-file-id="' + active_file.id + '"]').parent(); - showFrame(frame); - toggleButtonStates(); - }); - }; - - var initializeFileTreeButtons = function() { - $('#create-file').on('click', showFileDialog); - $('#destroy-file').on('click', confirmDestroy); - $('#download').on('click', downloadCode); - $('#request-for-comments').on('click', requestComments); - }; - - - var initializeRegexes = function(){ - regex_for_language.set("ace/mode/python", /File "(.+?)", line (\d+)/g); - regex_for_language.set("ace/mode/java", /(.*\.java):(\d+):/g); - } - - var initializeTooltips = function() { - $('[data-tooltip]').tooltip(); - }; - - var initializeWorkflowButtons = function() { - $('#start').on('click', showWorkspaceTab); - //$('#submit').on('click', confirmSubmission); - $('#submit').on('click', submitCode); - }; - - var initializeWorkspaceButtons = function() { - $('#assess').on('click', scoreCode); // todo - $('#dropdown-render, #render').on('click', renderCode); - $('#dropdown-run, #run').on('click', runCode); - $('#dropdown-stop, #stop').on('click', stopCode); // todo - $('#dropdown-test, #test').on('click', testCode); // todo - $('#save').on('click', saveCode); - $('#start-over').on('click', confirmReset); - }; - - var initializeRequestForComments = function () { - var button = $('.requestCommentsButton'); - button.hide(); - button.on('click', function() { - $('#comment-modal').modal('show'); - }); - - $('#askForCommentsButton').on('click', requestComments); - - setTimeout(function() { - button.fadeIn(); - }, REQUEST_FOR_COMMENTS_DELAY); - }; - - var isActiveFileExecutable = function() { - return 'executable' in active_frame.data(); - }; - - var setActiveFile = function (filename, fileId) { - active_file = { - filename: filename, - id: fileId - }; - }; - - var isActiveFileRenderable = function() { - return 'renderable' in active_frame.data(); - }; - - var isActiveFileRunnable = function() { - return isActiveFileExecutable() && ['main_file', 'user_defined_file'].includes(active_frame.data('role')); - }; - - var isActiveFileStoppable = function() { - return isActiveFileRunnable() && running; - }; - - var isActiveFileSubmission = function() { - return ['Submission'].includes(active_frame.data('contextType')); - }; - - var isActiveFileTestable = function() { - return isActiveFileExecutable() && ['teacher_defined_test', 'user_defined_test'].includes(active_frame.data('role')); - }; - - var isBrowserSupported = function() { - // websockets is used for run, score and test - return Modernizr.websockets; - }; - - var populatePanel = function(panel, result, index) { - panel.removeClass('panel-default').addClass(getPanelClass(result)); - panel.find('.panel-title .filename').text(result.filename); - panel.find('.panel-title .number').text(index + 1); - panel.find('.row .col-sm-9').eq(0).find('.number').eq(0).text(result.passed); - panel.find('.row .col-sm-9').eq(0).find('.number').eq(1).text(result.count); - panel.find('.row .col-sm-9').eq(1).find('.number').eq(0).text((result.score * result.weight).toFixed(2)); - panel.find('.row .col-sm-9').eq(1).find('.number').eq(1).text(result.weight); - panel.find('.row .col-sm-9').eq(2).text(result.message); - if (result.error_messages) panel.find('.row .col-sm-9').eq(3).text(result.error_messages.join(', ')); - panel.find('.row .col-sm-9').eq(4).find('a').attr('href', '#output-' + index); - }; - - - var resetOutputTab = function() { - clearOutput(); - $('#hint').fadeOut(); - $('#flowrHint').fadeOut(); - showTab(1); - } - - var printOutput = function(output, colorize, index) { - var element = findOrCreateOutputElement(index); - // disable streaming if desired - //if (output.stdout && output.stdout.length >= 20 && output.stdout.substr(0,20) == "##DISABLESTREAMING##"){ - // output_mode_is_streaming = false; - //} - if (!colorize) { - if(output.stdout != undefined && output.stdout != ''){ - element.append(output.stdout) - } - - if(output.stderr != undefined && output.stderr != ''){ - element.append('There was an error: StdErr: ' + output.stderr); - } - - } else if (output.stderr) { - element.addClass('text-warning').append(output.stderr); - flowrOutputBuffer += output.stderr; - QaApiOutputBuffer.stderr += output.stderr; - } else if (output.stdout) { - //if (output_mode_is_streaming){ - element.addClass('text-success').append(output.stdout); - flowrOutputBuffer += output.stdout; - QaApiOutputBuffer.stdout += output.stdout; - //}else{ - // element.addClass('text-success'); - // element.data('content_buffer' , element.data('content_buffer') + output.stdout); - //} - //} else if (output.code && output.code == '200'){ - // element.append( element.data('content_buffer')); - } else { - element.addClass('text-muted').text($('#output').data('message-no-output')); - } - }; - - var printScoringResult = function(result, index) { - $('#results').show(); - var panel = $('#dummies').children().first().clone(); - populatePanel(panel, result, index); - $('#results ul').first().append(panel); - }; - - var printScoringResults = function(response) { - $('#results ul').first().html(''); - $('.test-count .number').html(response.length); - clearOutput(); - - _.each(response, function(result, index) { - printOutput(result, false, index); - printScoringResult(result, index); - }); - - if (_.some(response, function(result) { - return result.status === 'timeout'; - })) { - showTimeoutMessage(); - } - if (_.some(response, function(result) { - return result.status === 'container_depleted'; - })) { - showContainerDepletedMessage(); - } - if (qa_api) { - // send test response to QA - qa_api.executeCommand('syncOutput', [response]); - } - }; - - // Publishing events for other (JS) components to react to codeocean events - var publishCodeOceanEvent = function (eventName, contextData) { - - var payload = { - user: { - type: 'User', - uuid: $('#editor').data('user-id') - }, - verb: { - type: eventName - }, - resource: { - type: 'page', - uuid: document.location.href - }, - timestamp: new Date().toISOString(), - with_result: {}, - in_context: contextData - }; - - $.ajax("https://open.hpi.de/lanalytics/log", { - type: 'POST', - cache: false, - dataType: 'JSON', - data: payload, - success: {}, - error: {} - }) - - }; - - var renderCode = function(event) { - event.preventDefault(); - if ($('#render').is(':visible')) { - createSubmission(this, null, function(response) { - var url = response.render_url.replace(FILENAME_URL_PLACEHOLDER, active_file.filename); - var pop_up_window = window.open(url); - if (pop_up_window) { - pop_up_window.onerror = function(message) { - clearOutput(); - printOutput({ - stderr: message - }, true, 0); - sendError(message, response.id); - showTab(1); - }; - } - }); - } - }; - - var renderHint = function(object) { - var hint = object.data || object.hint; - if (hint) { - $('#hint .panel-body').text(hint); - $('#hint').fadeIn(); - } - }; - - var renderProgressBar = function(score, maximum_score) { - var percentage = score / maximum_score * 100; - var progress_bar = $('#score .progress-bar'); - progress_bar.removeClass().addClass(getProgressBarClass(percentage)); - progress_bar.attr({ - 'aria-valuemax': maximum_score, - 'aria-valuemin': 0, - 'aria-valuenow': score - }); - progress_bar.css('width', percentage + '%'); - }; - - var renderScore = function() { - var score = parseFloat($('#score').data('score')); - var maxium_score = parseFloat($('#score').data('maximum-score')); - if (score >= 0 && score <= maxium_score && maxium_score >0 ) { - var percentage_score = (score / maxium_score * 100 ).toFixed(0); - $('.score').html(percentage_score + '%'); - } - else { - $('.score').html( 0 + '%'); - } - renderProgressBar(score, maxium_score); - }; - - var resetCode = function() { - showSpinner(this); - ajax({ - method: 'GET', - url: $('#start-over').data('url') - }).success(function(response) { - hideSpinner(); - _.each(editors, function(editor) { - var file_id = $(editor.container).data('file-id'); - var file = _.find(response.files, function(file) { - return file.id === file_id; - }); - editor.setValue(file.content); - }); - }); - }; - - var runCode = function(event) { - event.preventDefault(); - if ($('#run').is(':visible')) { - createSubmission(this, null, function(response) { - $('#stop').data('url', response.stop_url); - running = true; - showSpinner($('#run')); - toggleButtonStates(); - var url = response.run_url.replace(FILENAME_URL_PLACEHOLDER, active_file.filename); - evaluateCode(url, function(evt) { parseCanvasMessage(evt.data, true); }); - }); - } - }; - - var saveCode = function(event) { - event.preventDefault(); - createSubmission(this, null, function() { - $.flash.success({ - text: $('#save').data('message-success') - }); - }); - }; - - var sendError = function(message, submission_id) { - showSpinner($('#render')); - var jqxhr = ajax({ - data: { - error: { - message: message, - submission_id: submission_id - } - }, - url: $('#editor').data('errors-url') - }); - jqxhr.always(hideSpinner); - jqxhr.success(renderHint); - }; - - var scoreCode = function(event) { - event.preventDefault(); - createSubmission(this, null, function(response) { - showSpinner($('#assess')); - var url = response.score_url; - evaluateCode(url, handleScoringResponse); - }); - }; - - var showFileDialog = function(event) { - event.preventDefault(); - createSubmission(this, null, function(response) { - $('#code_ocean_file_context_id').val(response.id); - $('#modal-file').modal('show'); - }); - }; - - var showFirstFile = function() { - var frame = $('.frame[data-role="main_file"]').isPresent() ? $('.frame[data-role="main_file"]') : $('.frame').first(); - var file_id = frame.find('.editor').data('file-id'); - setActiveFile(frame.data('filename'), file_id); - $('#files').jstree().select_node(file_id); - showFrame(frame); - toggleButtonStates(); - }; - - var showFrame = function(frame) { - active_frame = frame; - $('.frame').hide(); - frame.show(); - }; - - var showOutput = function(event) { - event.preventDefault(); - showTab(1); - $('#output').scrollTo($(this).attr('href')); - }; - - var showRequestedTab = function() { - if(REMEMBER_TAB){ - var regexp = /tab=(\d+)/; - if (regexp.test(window.location.search)) { - var index = regexp.exec(window.location.search)[1] - 1; - } else { - var index = localStorage.tab; - } - } else { - // else start with first tab. - var index = 0; - } - showTab(index); - }; - - var showSpinner = function(initiator) { - $(initiator).find('i.fa').hide(); - $(initiator).find('i.fa-spin').show(); - }; - - var showStatus = function(output) { - if (output.status === 'timeout') { - showTimeoutMessage(); - } else if (output.status === 'container_depleted') { - showContainerDepletedMessage(); - } else if (output.stderr) { - $.flash.danger({ - icon: ['fa', 'fa-bug'], - text: $('#run').data('message-failure') - }); - } - /* do not show the success message any longer, puzzles and distracts users. - else { - $.flash.success({ - icon: ['fa', 'fa-check'], - text: $('#run').data('message-success') - }); - } */ - }; - - var showContainerDepletedMessage = function() { - $.flash.danger({ - icon: ['fa', 'fa-clock-o'], - text: $('#editor').data('message-depleted') - }); - }; - - var showTab = function(index) { - $('a[data-toggle="tab"]').eq(index || 0).tab('show'); - }; - - var showTimeoutMessage = function() { - $.flash.info({ - icon: ['fa', 'fa-clock-o'], - text: $('#editor').data('message-timeout') - }); - }; - - var showWebsocketError = function() { - $.flash.danger({ - text: $('#flash').data('message-failure') - }); - } - - var showWorkspaceTab = function(event) { - if(event){ - event.preventDefault(); - } - showTab(0); - }; - - var stopCode = function(event) { - event.preventDefault(); - if (isActiveFileStoppable()) { - killWebsocketAndContainer(); - } - }; - - var killWebsocketAndContainer = function() { - if (websocket.readyState != WebSocket.OPEN) { - return; - } - websocket.send(JSON.stringify({cmd: 'exit'})); - websocket.flush(); - websocket.close(); - - if(turtlescreen != null){ - resetTurtle = true; - } - - hideSpinner(); - running = false; - toggleButtonStates(); - hidePrompt(); - } - - var storeTab = function(event) { - localStorage.tab = $(event.target).parent().index(); - }; - - var submitCode = function() { - createSubmission($('#submit'), null, function(response) { - if (response.redirect) { - localStorage.removeItem('tab'); - window.location = response.redirect; - } - }); - }; - - var testCode = function(event) { - event.preventDefault(); - if ($('#test').is(':visible')) { - createSubmission(this, null, function(response) { - showSpinner($('#test')); - var url = response.test_url.replace(FILENAME_URL_PLACEHOLDER, active_file.filename); - evaluateCode(url, handleTestResponse); - }); - } - }; - - var toggleButtonStates = function() { - $('#destroy-file').prop('disabled', active_frame.data('role') !== 'user_defined_file'); - $('#dropdown-render').toggleClass('disabled', !isActiveFileRenderable()); - $('#dropdown-run').toggleClass('disabled', !isActiveFileRunnable() || running); - $('#dropdown-stop').toggleClass('disabled', !isActiveFileStoppable()); - $('#dropdown-test').toggleClass('disabled', !isActiveFileTestable()); - $('#dummy').toggle(!fileActionsAvailable()); - $('#editor-buttons .dropdown-toggle').toggle(fileActionsAvailable()); - $('#render').toggle(isActiveFileRenderable()); - $('#run').toggle(isActiveFileRunnable() && !running); - $('#stop').toggle(isActiveFileStoppable()); - $('#test').toggle(isActiveFileTestable()); - }; - - var initWebsocketConnection = function(url, onmessageFunction) { - //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 = onmessageFunction; - websocket.onerror = function(evt) { showWebsocketError(); }; - websocket.flush = function() { this.send('\n'); } - }; - - var initTurtle = function() { - // todo guard clause if turtle is not required for the current exercise - - // clear canvas - // turtlecanvas.getContext("2d").clearRect(0, 0, turtlecanvas.width, turtlecanvas.height); - - if(resetTurtle) { - turtlescreen = new Turtle(websocket, turtlecanvas); - showCanvas(); - resetTurtle = false; - } - }; - - var initPrompt = function() { - if ($('#run').isPresent()) { - $('#run').bind('click', hidePrompt); - } - if ($('#prompt').isPresent()) { - $('#prompt').on('keypress', handlePromptKeyPress); - $('#prompt-submit').on('click', submitPromptInput); - } - } - - var executeWebsocketCommand = function(msg) { - if ($.inArray(msg.cmd, commands) == -1) { - console.log("Unknown command: " + msg.cmd); - // skipping unregistered commands is required - // as we may receive mirrored response due to internal behaviour - return; - } - switch(msg.cmd) { - case 'input': - showPrompt(msg); - break; - case 'write': - printWebsocketOutput(msg); - break; - case 'turtle': - initTurtle(); - showCanvas(); - handleTurtleCommand(msg); - break; - case 'turtlebatch': - initTurtle(); - showCanvas(); - handleTurtlebatchCommand(msg); - break; - case 'render': - renderWebsocketOutput(msg); - break; - case 'exit': - killWebsocketAndContainer(); - handleQaApiOutput(); - handleStderrOutputForFlowr(); - augmentStacktraceInOutput(); - break; - case 'timeout': - // just show the timeout message here. Another exit command is sent by the rails backend when the socket to the docker container closes. - showTimeoutMessage(); - break; - case 'status': - showStatus(msg) - break; - } - }; - - - var jumpToSourceLine = function(event){ - var file = $(event.target).data('file'); - var line = $(event.target).data('line'); - - showWorkspaceTab(null); - // set active file ?!?! - - var frame = $('div.frame[data-filename="' + file + '"]'); - showFrame(frame); - - var editor = editor_for_file.get(file); - editor.gotoLine(line, 0); - - }; - - var augmentStacktraceInOutput = function() { - if(tracepositions_regex){ - var element = $('#output>pre'); - var text = element.text(); - element.on( "click", "a", jumpToSourceLine); - - var matches; - - while(matches = tracepositions_regex.exec(text)){ - var frame = $('div.frame[data-filename="' + matches[1] + '"]') - - if(frame.length > 0){ - element.html(text.replace(matches[0], "" + matches[0] + "")); - } - } - } - - }; - - var renderWebsocketOutput = function(msg){ - var element = findOrCreateRenderElement(0); - element.append(msg.data); - }; - - var printWebsocketOutput = function(msg) { - if (!msg.data) { - return; - } - //msg.data = msg.data.replace(/(\r\n|\n|\r)/gm, "
"); - msg.data = msg.data.replace(/(\r)/gm, "\n"); - var stream = {}; - stream[msg.stream] = msg.data; - printOutput(stream, true, 0); - }; - - var handleTurtleCommand = function(msg) { - if (msg.action in turtlescreen) { - result = turtlescreen[msg.action].apply(turtlescreen, msg.args); - websocket.send(JSON.stringify({cmd: 'result', 'result': result})); - } else { - websocket.send(JSON.stringify({cmd: 'exception', exception: 'AttributeError', message: msg.action})); - } - websocket.flush(); - }; - - var handleTurtlebatchCommand = function(msg) { - for (i = 0; i < msg.batch.length; i++) { - cmd = msg.batch[i]; - turtlescreen[cmd[0]].apply(turtlescreen, cmd[1]); - } - }; - - var handlePromptKeyPress = function(evt) { - if (evt.which === ENTER_KEY_CODE) { - submitPromptInput(); - } - } - - var submitPromptInput = function() { - var input = $('#prompt-input'); - var message = input.val(); - websocket.send(JSON.stringify({cmd: 'result', 'data': message})); - websocket.flush(); - input.val(''); - hidePrompt(); - } - - var parseCanvasMessage = function(message, recursive) { - var msg; - message = message.replace(/^\s+|\s+$/g, ""); - try { - // todo validate json instead of catching - msg = JSON.parse(message); - } catch (e) { - if (!recursive) { - return false; - } - // why does docker sometimes send multiple commands at once? - message = message.replace(/^\s+|\s+$/g, ""); - messages = message.split("\n"); - for (var i = 0; i < messages.length; i++) { - if (!messages[i]) { - continue; - } - parseCanvasMessage(messages[i], false); - } - return; - } - executeWebsocketCommand(msg); - }; - - var showPrompt = function(msg) { - var label = $('#prompt .input-group-addon'); - label.text(msg.data || label.data('prompt')); - if (prompt.isPresent() && prompt.hasClass('hidden')) { - prompt.removeClass('hidden'); - } - $('#prompt input').focus(); - } - - var hidePrompt = function() { - if (prompt.isPresent() && !prompt.hasClass('hidden')) { - prompt.addClass('hidden'); - } - } - - var showCanvas = function() { - if ($('#turtlediv').isPresent() - && turtlecanvas.hasClass('hidden')) { - // initialize two-column layout - $('#output-col1').addClass('col-lg-7 col-md-7 two-column'); - turtlecanvas.removeClass('hidden'); - } - }; - - var hideCanvas = function() { - if ($('#turtlediv').isPresent() - && !(turtlecanvas.hasClass('hidden'))) { - output = $('#output-col1'); - if (output.hasClass('two-column')) { - output.removeClass('col-lg-7 col-md-7 two-column'); - } - turtlecanvas.addClass('hidden'); - } - }; - - var requestComments = function() { - var user_id = $('#editor').data('user-id') - var exercise_id = $('#editor').data('exercise-id') - var file_id = $('.editor').data('id') - var question = $('#question').val(); - - var createRequestForComments = function(submission) { - $.ajax({ - method: 'POST', - url: '/request_for_comments', - data: { - request_for_comment: { - exercise_id: exercise_id, - file_id: file_id, - submission_id: submission.id, - question: question - } - } - }).done(function() { - hideSpinner(); - $.flash.success({ text: $('#askForCommentsButton').data('message-success') }); - }).error(ajaxError); - } - - createSubmission($('.requestCommentsButton'), null, createRequestForComments); - - $('#comment-modal').modal('hide'); - var button = $('.requestCommentsButton'); - button.fadeOut(); - } - - var initializeCodePilot = function() { - if ($('#questions-column').isPresent() && (typeof QaApi != 'undefined') && QaApi.isBrowserSupported()) { - $('#editor-column').addClass('col-md-8').removeClass('col-md-10'); - $('#questions-column').addClass('col-md-3'); - - var node = document.getElementById('questions-holder'); - var url = $('#questions-holder').data('url'); - - qa_api = new QaApi(node, url); - } - } - - // save on quit - $(window).on("beforeunload", function() { - if(autosaveTimer){ - autosave(); - } - - }); - - if ($('#editor').isPresent()) { - if (isBrowserSupported()) { - initializeRegexes(); - initializeCodePilot(); - $('.score, #development-environment').show(); - configureEditors(); - initializeEditors(); - initializeEventHandlers(); - initializeFileTree(); - initializeTooltips(); - initPrompt(); - renderScore(); - showFirstFile(); - showRequestedTab(); - } else { - $('#alert').show(); - } } }); diff --git a/app/assets/javascripts/editor/ajax.js.erb b/app/assets/javascripts/editor/ajax.js.erb new file mode 100644 index 00000000..a6caa904 --- /dev/null +++ b/app/assets/javascripts/editor/ajax.js.erb @@ -0,0 +1,16 @@ +CodeOceanEditorAJAX = { + ajax: function(options) { + return $.ajax(_.extend({ + dataType: 'json', + method: 'POST', + }, options)); + }, + + ajaxError: function(response) { + var message = ((response || {}).responseJSON || {}).message || ''; + + $.flash.danger({ + text: message.length > 0 ? message : $('#flash').data('message-failure') + }); + } +}; \ No newline at end of file diff --git a/app/assets/javascripts/editor/codepilot.js.erb b/app/assets/javascripts/editor/codepilot.js.erb new file mode 100644 index 00000000..aa1513be --- /dev/null +++ b/app/assets/javascripts/editor/codepilot.js.erb @@ -0,0 +1,24 @@ +CodeOceanEditorCodePilot = { + qa_api: undefined, + QaApiOutputBuffer: {'stdout': '', 'stderr': ''}, + + initializeCodePilot: function () { + if ($('#questions-column').isPresent() && (typeof QaApi != 'undefined') && QaApi.isBrowserSupported()) { + $('#editor-column').addClass('col-md-10').removeClass('col-md-12'); + $('#questions-column').addClass('col-md-2'); + + var node = document.getElementById('questions-holder'); + var url = $('#questions-holder').data('url'); + + this.qa_api = new QaApi(node, url); + } + }, + + handleQaApiOutput: function () { + if (this.qa_api) { + this.qa_api.executeCommand('syncOutput', [[this.QaApiOutputBuffer]]); + // reset the object + } + this.QaApiOutputBuffer = {'stdout': '', 'stderr': ''}; + } +}; \ No newline at end of file diff --git a/app/assets/javascripts/editor/editor.js.erb b/app/assets/javascripts/editor/editor.js.erb new file mode 100644 index 00000000..c5f8a56b --- /dev/null +++ b/app/assets/javascripts/editor/editor.js.erb @@ -0,0 +1,574 @@ +var CodeOceanEditor = { + //ACE-Editor-Path + // ruby part adds the relative_url_root, if it is set. + ACE_FILES_PATH: '<%= (defined? Rails.application.config.relative_url_root) && Rails.application.config.relative_url_root != nil && Rails.application.config.relative_url_root != "" ? Rails.application.config.relative_url_root : "" %>' + '/assets/ace/', + THEME: 'ace/theme/textmate', + + //Color-Encoding for Percentages in Progress Bars (For submissions) + ADEQUATE_PERCENTAGE: 50, + SUCCESSFULL_PERCENTAGE: 90, + + //Key-Codes (for Hotkeys) + ALT_R_KEY_CODE: 174, + ALT_S_KEY_CODE: 8218, + ALT_T_KEY_CODE: 8224, + ENTER_KEY_CODE: 13, + + //Request-For-Comments-Configuration + REQUEST_FOR_COMMENTS_DELAY: 3 * 60 * 1000, + REQUEST_TOOLTIP_TIME: 5000, + + editors: [], + editor_for_file: new Map(), + regex_for_language: new Map(), + tracepositions_regex: undefined, + + active_file: undefined, + active_frame: undefined, + running: false, + + lastCopyText: null, + + configureEditors: function () { + _.each(['modePath', 'themePath', 'workerPath'], function (attribute) { + ace.config.set(attribute, this.ACE_FILES_PATH); + }.bind(this)); + }, + + confirmDestroy: function (event) { + event.preventDefault(); + if (confirm($(this).data('message-confirm'))) { + this.destroyFile(); + } + }, + + confirmReset: function (event) { + event.preventDefault(); + if (confirm($('#start-over').data('message-confirm'))) { + this.resetCode(); + } + }, + + fileActionsAvailable: function () { + return this.isActiveFileRenderable() || this.isActiveFileRunnable() || this.isActiveFileStoppable() || this.isActiveFileTestable(); + }, + + findOrCreateOutputElement: function (index) { + if ($('#output-' + index).isPresent()) { + return $('#output-' + index); + } else { + var element = $('').attr('id', 'output-' + index); + $('#output').append(element); + return element; + } + }, + + findOrCreateRenderElement: function (index) { + if ($('#render-' + index).isPresent()) { + return $('#render-' + index); + } else { + var element = $('').attr('id', 'render-' + index); + $('#render').append(element); + return element; + } + }, + + getPanelClass: function (result) { + if (result.stderr && !result.score) { + return 'panel-danger'; + } else if (result.score < 1) { + return 'panel-warning'; + } else { + return 'panel-success'; + } + }, + + showOutput: function(event) { + event.preventDefault(); + this.showOutputBar(); + $('#output').scrollTo($(event.target).attr('href')); + }, + + renderProgressBar: function(score, maximum_score) { + var percentage = score / maximum_score * 100; + var progress_bar = $('#score .progress-bar'); + progress_bar.removeClass().addClass(this.getProgressBarClass(percentage)); + progress_bar.attr({ + 'aria-valuemax': maximum_score, + 'aria-valuemin': 0, + 'aria-valuenow': score + }); + progress_bar.css('width', percentage + '%'); + }, + + showFirstFile: function() { + var frame = $('.frame[data-role="main_file"]').isPresent() ? $('.frame[data-role="main_file"]') : $('.frame').first(); + var file_id = frame.find('.editor').data('file-id'); + this.setActiveFile(frame.data('filename'), file_id); + $('#files').jstree().select_node(file_id); + this.showFrame(frame); + this.toggleButtonStates(); + }, + + showFrame: function(frame) { + this.active_frame = frame; + $('.frame').hide(); + frame.show(); + }, + + getProgressBarClass: function (percentage) { + if (percentage < this.ADEQUATE_PERCENTAGE) { + return 'progress-bar progress-bar-striped progress-bar-danger'; + } else if (percentage < this.SUCCESSFULL_PERCENTAGE) { + return 'progress-bar progress-bar-striped progress-bar-warning'; + } else { + return 'progress-bar progress-bar-striped progress-bar-success'; + } + }, + + handleKeyPress: function (event) { + if (event.which === this.ALT_R_KEY_CODE) { + $('#run').trigger('click'); + } else if (event.which === this.ALT_S_KEY_CODE) { + $('#assess').trigger('click'); + } else if (event.which === this.ALT_T_KEY_CODE) { + $('#test').trigger('click'); + } else { + return; + } + event.preventDefault(); + }, + + handleCopyEvent: function (text) { + this.lastCopyText = text; + }, + + handlePasteEvent: function (pasteObject) { + var same = (this.lastCopyText === pasteObject.text); + + // if the text is not copied from within the editor (from any file), send an event to lanalytics + if (!same) { + this.publishCodeOceanEvent("codeocean_editor_paste", { + text: pasteObject.text, + exercise: $('#editor').data('exercise-id'), + file_id: "1" + + }); + } + }, + + hideSpinner: function () { + $('button i.fa').show(); + $('button i.fa-spin').hide(); + }, + + resizeParentOfAceEditor: function (element){ + // calculate needed size: window height - position of top of button-bar - 60 for bar itself and margins + var windowHeight = window.innerHeight - $('#editor-buttons').offset().top - 60; + $(element).parent().height(windowHeight); + }, + + initializeEditors: function () { + $('.editor').each(function (index, element) { + + // Resize frame on load + this.resizeParentOfAceEditor(element); + + // Resize frame on window size change + $(window).resize(function(){ + this.resizeParentOfAceEditor(element); + }.bind(this)); + + var editor = ace.edit(element); + + if (this.qa_api) { + editor.getSession().on("change", function (deltaObject) { + this.qa_api.executeCommand('syncEditor', [this.active_file, deltaObject]); + }.bind(this)); + } + + var document = editor.getSession().getDocument(); + // insert pre-existing code into editor. we have to use insertLines, otherwise the deltas are not properly added + var file_id = $(element).data('file-id'); + var content = $('.editor-content[data-file-id=' + file_id + ']'); + this.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(this.THEME); + + + + // set options for autocompletion + if($(element).data('allow-auto-completion')){ + editor.setOptions({ + enableBasicAutocompletion: true, + enableSnippets: false, + enableLiveAutocompletion: true + }); + } + + + editor.commands.bindKey("ctrl+alt+0", null); + this.editors.push(editor); + this.editor_for_file.set($(element).parent().data('filename'), editor); + var session = editor.getSession(); + session.setMode($(element).data('mode')); + session.setTabSize($(element).data('indent-size')); + session.setUseSoftTabs(true); + session.setUseWrapMode(true); + + // set regex for parsing error traces based on the mode of the main file. + if ($(element).parent().data('role') == "main_file") { + this.tracepositions_regex = this.regex_for_language.get($(element).data('mode')); + } + + var file_id = $(element).data('id'); + + /* + * Register event handlers + */ + + // editor itself + editor.on("paste", this.handlePasteEvent.bind(this)); + editor.on("copy", this.handleCopyEvent.bind(this)); + + // listener for autosave + session.on("change", function (deltaObject) { + this.resetSaveTimer(); + }.bind(this)); + }.bind(this)); + }, + + initializeEventHandlers: function () { + $(document).on('click', '#results a', this.showOutput.bind(this)); + $(document).on('keypress', this.handleKeyPress.bind(this)); + this.initializeFileTreeButtons(); + this.initializeWorkspaceButtons(); + this.initializeRequestForComments() + }, + + initializeFileTree: function () { + $('#files').jstree($('#files').data('entries')); + $('#files').on('click', 'li.jstree-leaf', function (event) { + active_file = { + filename: $(event.target).parent().text(), + id: parseInt($(event.target).parent().attr('id')) + }; + var frame = $('[data-file-id="' + active_file.id + '"]').parent(); + this.showFrame(frame); + this.toggleButtonStates(); + }.bind(this)); + }, + + initializeFileTreeButtons: function () { + $('#create-file').on('click', this.showFileDialog.bind(this)); + $('#create-file-collapsed').on('click', this.showFileDialog.bind(this)); + $('#destroy-file').on('click', this.confirmDestroy.bind(this)); + $('#destroy-file-collapsed').on('click', this.confirmDestroy.bind(this)); + $('#download').on('click', this.downloadCode.bind(this)); + $('#download-collapsed').on('click', this.downloadCode.bind(this)); + $('#request-for-comments').on('click', this.requestComments.bind(this)); + }, + + initializeSideBarCollapse: function() { + $('#sidebar-collapse-collapsed').on('click',this.handleSideBarToggle.bind(this)); + $('#sidebar-collapse').on('click',this.handleSideBarToggle.bind(this)) + }, + + handleSideBarToggle: function() { + $('#sidebar').toggleClass('sidebar-col').toggleClass('sidebar-col-collapsed'); + $('#sidebar-collapsed').toggleClass('hidden'); + $('#sidebar-uncollapsed').toggleClass('hidden'); + }, + + initializeRegexes: function () { + this.regex_for_language.set("ace/mode/python", /File "(.+?)", line (\d+)/g); + this.regex_for_language.set("ace/mode/java", /(.*\.java):(\d+):/g); + }, + + initializeTooltips: function () { + $('[data-tooltip]').tooltip(); + }, + + + initializeWorkspaceButtons: function () { + $('#submit').on('click', this.submitCode.bind(this)); + $('#assess').on('click', this.scoreCode.bind(this)); + $('#dropdown-render, #render').on('click', this.renderCode.bind(this)); + $('#dropdown-run, #run').on('click', this.runCode.bind(this)); + $('#dropdown-stop, #stop').on('click', this.stopCode.bind(this)); + $('#dropdown-test, #test').on('click', this.testCode.bind(this)); + $('#save').on('click', this.saveCode.bind(this)); + $('#start-over').on('click', this.confirmReset.bind(this)); + $('#start-over-collapsed').on('click', this.confirmReset.bind(this)); + }, + + initializeRequestForComments: function () { + var button = $('#requestComments'); + button.prop('disabled', true); + button.on('click', function () { + $('#comment-modal').modal('show'); + }); + + $('#askForCommentsButton').on('click', this.requestComments); + + setTimeout(function () { + button.prop('disabled', false); + button.tooltip('show'); + setTimeout(function() { + button.tooltip('hide'); + }, this.REQUEST_TOOLTIP_TIME); + }.bind(this), this.REQUEST_FOR_COMMENTS_DELAY); + }, + + isActiveFileRenderable: function () { + return 'renderable' in this.active_frame.data(); + }, + + isActiveFileRunnable: function () { + return this.isActiveFileExecutable() && ['main_file', 'user_defined_file'].includes(this.active_frame.data('role')); + }, + + isActiveFileStoppable: function () { + return this.isActiveFileRunnable() && this.running; + }, + + isActiveFileSubmission: function () { + return ['Submission'].includes(this.active_frame.data('contextType')); + }, + + isActiveFileTestable: function () { + return this.isActiveFileExecutable() && ['teacher_defined_test', 'user_defined_test'].includes(this.active_frame.data('role')); + }, + + isBrowserSupported: function () { + // websockets is used for run, score and test + return Modernizr.websockets; + }, + + populatePanel: function (panel, result, index) { + panel.removeClass('panel-default').addClass(this.getPanelClass(result)); + panel.find('.panel-title .filename').text(result.filename); + panel.find('.panel-title .number').text(index + 1); + panel.find('.row .col-sm-9').eq(0).find('.number').eq(0).text(result.passed); + panel.find('.row .col-sm-9').eq(0).find('.number').eq(1).text(result.count); + panel.find('.row .col-sm-9').eq(1).find('.number').eq(0).text((result.score * result.weight).toFixed(2)); + panel.find('.row .col-sm-9').eq(1).find('.number').eq(1).text(result.weight); + panel.find('.row .col-sm-9').eq(2).text(result.message); + if (result.error_messages) panel.find('.row .col-sm-9').eq(3).text(result.error_messages.join(', ')); + panel.find('.row .col-sm-9').eq(4).find('a').attr('href', '#output-' + index); + }, + + publishCodeOceanEvent: function (eventName, contextData) { + + // enhance contextData hash with the user agent + contextData['user_agent'] = navigator.userAgent; + + var payload = { + user: { + type: 'User', + uuid: $('#editor').data('user-id'), + external_id: $('#editor').data('user-external-id') + }, + verb: { + type: eventName + }, + resource: { + type: 'page', + uuid: document.location.href + }, + timestamp: new Date().toISOString(), + with_result: {}, + in_context: contextData + }; + + $.ajax("https://open.hpi.de/lanalytics/log", { + type: 'POST', + cache: false, + dataType: 'JSON', + data: payload, + success: {}, + error: {} + }) + }, + + sendError: function (message, submission_id) { + this.showSpinner($('#render')); + var jqxhr = this.ajax({ + data: { + error: { + message: message, + submission_id: submission_id + } + }, + url: $('#editor').data('errors-url') + }); + jqxhr.always(this.hideSpinner); + jqxhr.success(this.renderHint); + }, + + toggleButtonStates: function () { + $('#destroy-file').prop('disabled', this.active_frame.data('role') !== 'user_defined_file'); + $('#dummy').toggle(!this.fileActionsAvailable()); + $('#render').toggle(this.isActiveFileRenderable()); + $('#run').toggle(this.isActiveFileRunnable() && !this.running); + $('#stop').toggle(this.isActiveFileStoppable()); + $('#test').toggle(this.isActiveFileTestable()); + }, + + jumpToSourceLine: function (event) { + var file = $(event.target).data('file'); + var line = $(event.target).data('line'); + + // set active file, only needed for codepilot, so skipped for now + + var frame = $('div.frame[data-filename="' + file + '"]'); + this.showFrame(frame); + + var editor = this.editor_for_file.get(file); + editor.gotoLine(line, 0); + + }, + + augmentStacktraceInOutput: function () { + if (this.tracepositions_regex) { + var element = $('#output>pre'); + var text = element.text(); + element.on("click", "a", this.jumpToSourceLine.bind(this)); + + var matches; + + while (matches = this.tracepositions_regex.exec(text)) { + var frame = $('div.frame[data-filename="' + matches[1] + '"]') + + if (frame.length > 0) { + element.html(text.replace(matches[0], "" + matches[0] + "")); + } + } + } + }, + + resetOutputTab: function () { + this.clearOutput(); + $('#hint').fadeOut(); + $('#flowrHint').fadeOut(); + this.showOutputBar(); + }, + + isActiveFileBinary: function () { + return 'binary' in this.active_frame.data(); + }, + + isActiveFileExecutable: function () { + return 'executable' in this.active_frame.data(); + }, + + setActiveFile: function (filename, fileId) { + this.active_file = { + filename: filename, + id: fileId + }; + }, + + showSpinner: function(initiator) { + $(initiator).find('i.fa').hide(); + $(initiator).find('i.fa-spin').show(); + }, + + showStatus: function(output) { + if (output.status === 'timeout') { + this.showTimeoutMessage(); + } else if (output.status === 'container_depleted') { + this.showContainerDepletedMessage(); + } else if (output.stderr) { + $.flash.danger({ + icon: ['fa', 'fa-bug'], + text: $('#run').data('message-failure') + }); + } + }, + + showContainerDepletedMessage: function() { + $.flash.danger({ + icon: ['fa', 'fa-clock-o'], + text: $('#editor').data('message-depleted') + }); + }, + + showTimeoutMessage: function() { + $.flash.info({ + icon: ['fa', 'fa-clock-o'], + text: $('#editor').data('message-timeout') + }); + }, + + showWebsocketError: function() { + $.flash.danger({ + text: $('#flash').data('message-failure') + }); + }, + + showFileDialog: function(event) { + event.preventDefault(); + this.createSubmission('#create-file', null, function(response) { + $('#code_ocean_file_context_id').val(response.id); + $('#modal-file').modal('show'); + }.bind(this)); + }, + + initializeOutputBarToggle: function() { + $('#toggle-sidebar-output').on('click',this.hideOutputBar.bind(this)); + $('#toggle-sidebar-output-collapsed').on('click',this.showOutputBar.bind(this)); + }, + + showOutputBar: function() { + $('#output_sidebar_collapsed').addClass('hidden'); + $('#output_sidebar_uncollapsed').removeClass('hidden'); + $('#output_sidebar').removeClass('output-col-collapsed').addClass('output-col'); + }, + + hideOutputBar: function() { + $('#output_sidebar_collapsed').removeClass('hidden'); + $('#output_sidebar_uncollapsed').addClass('hidden'); + $('#output_sidebar').removeClass('output-col').addClass('output-col-collapsed'); + }, + + initializeSideBarTooltips: function() { + $('[data-toggle="tooltip"]').tooltip() + }, + + initializeDescriptionToggle: function() { + $('#exercise-headline').on('click',this.toggleDescriptionPanel.bind(this)) + }, + + toggleDescriptionPanel: function() { + $('#description-panel').toggleClass('description-panel-collapsed'); + $('#description-panel').toggleClass('description-panel'); + $('#description-symbol').toggleClass('fa-chevron-down'); + $('#description-symbol').toggleClass('fa-chevron-right'); + }, + + initializeEverything: function() { + this.initializeRegexes(); + this.initializeCodePilot(); + $('.score, #development-environment').show(); + this.configureEditors(); + this.initializeEditors(); + this.initializeEventHandlers(); + this.initializeFileTree(); + this.initializeSideBarCollapse(); + this.initializeOutputBarToggle(); + this.initializeDescriptionToggle(); + this.initializeSideBarTooltips(); + this.initializeTooltips(); + this.initPrompt(); + this.renderScore(); + this.showFirstFile(); + + $(window).on("beforeunload", this.unloadAutoSave.bind(this)); + } +}; \ No newline at end of file diff --git a/app/assets/javascripts/editor/evaluation.js.erb b/app/assets/javascripts/editor/evaluation.js.erb new file mode 100644 index 00000000..f7b51f30 --- /dev/null +++ b/app/assets/javascripts/editor/evaluation.js.erb @@ -0,0 +1,167 @@ +CodeOceanEditorEvaluation = { + chunkBuffer: [{streamedResponse: true}], + + /** + * Scoring-Functions + */ + scoreCode: function (event) { + event.preventDefault(); + this.createSubmission('#assess', null, function (response) { + this.showSpinner($('#assess')); + $('#score_div').removeClass('hidden'); + var url = response.score_url; + this.initializeSocketForScoring(url); + }.bind(this)); + }, + + handleScoringResponse: function (results) { + this.printScoringResults(results); + var score = _.reduce(results, function (sum, result) { + return sum + result.score * result.weight; + }, 0).toFixed(2); + $('#score').data('score', score); + this.renderScore(); + }, + + printScoringResult: function (result, index) { + $('#results').show(); + var panel = $('#dummies').children().first().clone(); + this.populatePanel(panel, result, index); + $('#results ul').first().append(panel); + }, + + printScoringResults: function (response) { + $('#results ul').first().html(''); + $('.test-count .number').html(response.length); + this.clearOutput(); + + _.each(response, function (result, index) { + this.printOutput(result, false, index); + this.printScoringResult(result, index); + }.bind(this)); + + if (_.some(response, function (result) { + return result.status === 'timeout'; + })) { + this.showTimeoutMessage(); + } + if (_.some(response, function (result) { + return result.status === 'container_depleted'; + })) { + this.showContainerDepletedMessage(); + } + if (this.qa_api) { + // send test response to QA + this.qa_api.executeCommand('syncOutput', [response]); + } + }, + + renderHint: function (object) { + var hint = object.data || object.hint; + if (hint) { + $('#hint .panel-body').text(hint); + $('#hint').fadeIn(); + } + }, + + renderScore: function () { + var score = parseFloat($('#score').data('score')); + var maximum_score = parseFloat($('#score').data('maximum-score')); + if (score >= 0 && score <= maximum_score && maximum_score > 0) { + var percentage_score = (score / maximum_score * 100 ).toFixed(0); + $('.score').html(percentage_score + '%'); + } + else { + $('.score').html(0 + '%'); + } + this.renderProgressBar(score, maximum_score); + }, + + /** + * Testing-Logic + */ + handleTestResponse: function (result) { + this.clearOutput(); + this.printOutput(result, false, 0); + if (this.qa_api) { + this.qa_api.executeCommand('syncOutput', [result]); + } + this.showStatus(result); + this.showOutputBar(); + }, + + /** + * Stop-Logic + */ + stopCode: function (event) { + event.preventDefault(); + if (this.isActiveFileStoppable()) { + this.websocket.send(JSON.stringify({'cmd': 'client_kill'})); + this.killWebsocket(); + this.cleanUpUI(); + } + }, + + killWebsocket: function () { + if (this.websocket != null && this.websocket.getReadyState() != WebSocket.OPEN) { + return; + } + + this.websocket.killWebSocket(); + this.running = false; + }, + + cleanUpUI: function() { + this.hideSpinner(); + this.toggleButtonStates(); + this.hidePrompt(); + }, + + /** + * Output-Logic + */ + renderWebsocketOutput: function(msg){ + var element = this.findOrCreateRenderElement(0); + element.append(msg.data); + }, + + printWebsocketOutput: function(msg) { + if (!msg.data) { + return; + } + msg.data = msg.data.replace(/(\r)/gm, "\n"); + var stream = {}; + stream[msg.stream] = msg.data; + this.printOutput(stream, true, 0); + }, + + clearOutput: function() { + $('#output pre').remove(); + }, + + printOutput: function (output, colorize, index) { + var element = this.findOrCreateOutputElement(index); + if (!colorize) { + if (output.stdout != undefined && output.stdout != '') { + element.append(output.stdout) + } + + if (output.stderr != undefined && output.stderr != '') { + element.append('There was an error: StdErr: ' + output.stderr); + } + + } else if (output.stderr) { + element.addClass('text-warning').append(output.stderr); + this.flowrOutputBuffer += output.stderr; + this.QaApiOutputBuffer.stderr += output.stderr; + } else if (output.stdout) { + element.addClass('text-success').append(output.stdout); + this.flowrOutputBuffer += output.stdout; + this.QaApiOutputBuffer.stdout += output.stdout; + } else { + element.addClass('text-muted').text($('#output').data('message-no-output')); + } + } + + +}; diff --git a/app/assets/javascripts/editor/execution.js.erb b/app/assets/javascripts/editor/execution.js.erb new file mode 100644 index 00000000..3cc9b262 --- /dev/null +++ b/app/assets/javascripts/editor/execution.js.erb @@ -0,0 +1,50 @@ +CodeOceanEditorWebsocket = { + websocket: null, + + createSocketUrl: function(url) { + var rel_url_root = '<%= (defined? config.relative_url_root) && config.relative_url_root != nil && config.relative_url_root != "" ? config.relative_url_root : "" %>'; + return '<%= DockerClient.config['ws_client_protocol'] %>' + window.location.hostname + ':' + rel_url_root + window.location.port + url; + }, + + initializeSocket: function(url) { + this.websocket = new CommandSocket(this.createSocketUrl(url), + function (evt) { + this.resetOutputTab(); + }.bind(this) + ); + this.websocket.onError(this.showWebsocketError.bind(this)); + }, + + initializeSocketForTesting: function(url) { + this.initializeSocket(url); + this.websocket.on('default',this.handleTestResponse.bind(this)); + this.websocket.on('exit', this.handleExitCommand.bind(this)); + }, + + initializeSocketForScoring: function(url) { + this.initializeSocket(url); + this.websocket.on('default',this.handleScoringResponse.bind(this)); + this.websocket.on('exit', this.handleExitCommand.bind(this)); + }, + + initializeSocketForRunning: function(url) { + this.initializeSocket(url); + this.websocket.on('input',this.showPrompt.bind(this)); + this.websocket.on('write', this.printWebsocketOutput.bind(this)); + this.websocket.on('turtle', this.handleTurtleCommand.bind(this)); + this.websocket.on('turtlebatch', this.handleTurtlebatchCommand.bind(this)); + this.websocket.on('render', this.renderWebsocketOutput.bind(this)); + this.websocket.on('exit', this.handleExitCommand.bind(this)); + this.websocket.on('timeout', this.showTimeoutMessage.bind(this)); + this.websocket.on('status', this.showStatus.bind(this)); + }, + + handleExitCommand: function() { + this.killWebsocket(); + this.handleQaApiOutput(); + this.handleStderrOutputForFlowr(); + this.augmentStacktraceInOutput(); + this.cleanUpTurtle(); + this.cleanUpUI(); + } +}; \ No newline at end of file diff --git a/app/assets/javascripts/editor/flowr.js.erb b/app/assets/javascripts/editor/flowr.js.erb new file mode 100644 index 00000000..4dc0de45 --- /dev/null +++ b/app/assets/javascripts/editor/flowr.js.erb @@ -0,0 +1,35 @@ +CodeOceanEditorFlowr = { + isFlowrEnabled: true, + flowrResultHtml: '', + + handleStderrOutputForFlowr: function () { + if (!this.isFlowrEnabled) return; + + var flowrUrl = $('#flowrHint').data('url'); + var flowrHintBody = $('#flowrHint .panel-body'); + var queryParameters = { + query: this.flowrOutputBuffer + }; + + flowrHintBody.empty(); + + jQuery.getJSON(flowrUrl, queryParameters, function (data) { + jQuery.each(data.queryResults, function (index, question) { + var collapsibleTileHtml = this.flowrResultHtml.replace(/{{collapseId}}/g, 'collapse-' + question).replace(/{{headingId}}/g, 'heading-' + question); + var resultTile = $(collapsibleTileHtml); + + resultTile.find('h4 > a').text(question.title + ' | Found via ' + question.source); + resultTile.find('.panel-body').html(question.body); + resultTile.find('.panel-body').append('Open this question'); + + flowrHintBody.append(resultTile); + }); + + if (data.queryResults.length !== 0) { + $('#flowrHint').fadeIn(); + } + }); + + this.flowrOutputBuffer = ''; + } +}; \ No newline at end of file diff --git a/app/assets/javascripts/editor/participantsupport.js.erb b/app/assets/javascripts/editor/participantsupport.js.erb new file mode 100644 index 00000000..1329cef2 --- /dev/null +++ b/app/assets/javascripts/editor/participantsupport.js.erb @@ -0,0 +1,93 @@ +CodeOceanEditorFlowr = { + isFlowrEnabled: true, + flowrResultHtml: '', + + handleStderrOutputForFlowr: function () { + if (!this.isFlowrEnabled) return; + + var flowrUrl = $('#flowrHint').data('url'); + var flowrHintBody = $('#flowrHint .panel-body'); + var queryParameters = { + query: this.flowrOutputBuffer + }; + + flowrHintBody.empty(); + + jQuery.getJSON(flowrUrl, queryParameters, function (data) { + jQuery.each(data.queryResults, function (index, question) { + var collapsibleTileHtml = this.flowrResultHtml.replace(/{{collapseId}}/g, 'collapse-' + question).replace(/{{headingId}}/g, 'heading-' + question); + var resultTile = $(collapsibleTileHtml); + + resultTile.find('h4 > a').text(question.title + ' | Found via ' + question.source); + resultTile.find('.panel-body').html(question.body); + resultTile.find('.panel-body').append('Open this question'); + + flowrHintBody.append(resultTile); + }); + + if (data.queryResults.length !== 0) { + $('#flowrHint').fadeIn(); + } + }); + + this.flowrOutputBuffer = ''; + } +}; + +CodeOceanEditorCodePilot = { + qa_api: undefined, + QaApiOutputBuffer: {'stdout': '', 'stderr': ''}, + + initializeCodePilot: function () { + if ($('#questions-column').isPresent() && (typeof QaApi != 'undefined') && QaApi.isBrowserSupported()) { + $('#editor-column').addClass('col-md-10').removeClass('col-md-12'); + $('#questions-column').addClass('col-md-2'); + + var node = document.getElementById('questions-holder'); + var url = $('#questions-holder').data('url'); + + this.qa_api = new QaApi(node, url); + } + }, + + handleQaApiOutput: function () { + if (this.qa_api) { + this.qa_api.executeCommand('syncOutput', [[this.QaApiOutputBuffer]]); + // reset the object + } + this.QaApiOutputBuffer = {'stdout': '', 'stderr': ''}; + } +}; + +CodeOceanEditorRequestForComments = { + requestComments: function () { + var user_id = $('#editor').data('user-id'); + var exercise_id = $('#editor').data('exercise-id'); + var file_id = $('.editor').data('id'); + var question = $('#question').val(); + + var createRequestForComments = function (submission) { + $.ajax({ + method: 'POST', + url: '/request_for_comments', + data: { + request_for_comment: { + exercise_id: exercise_id, + file_id: file_id, + submission_id: submission.id, + question: question + } + } + }).done(function () { + this.hideSpinner(); + $.flash.success({text: $('#askForCommentsButton').data('message-success')}); + }.bind(this)).error(this.ajaxError.bind(this)); + }; + + this.createSubmission($('.requestCommentsButton'), null, createRequestForComments.bind(this)); + + $('#comment-modal').modal('hide'); + var button = $('#requestComments'); + button.prop('disabled', true); + }, +}; \ No newline at end of file diff --git a/app/assets/javascripts/editor/prompt.js.erb b/app/assets/javascripts/editor/prompt.js.erb new file mode 100644 index 00000000..1cbdfc8f --- /dev/null +++ b/app/assets/javascripts/editor/prompt.js.erb @@ -0,0 +1,45 @@ +CodeOceanEditorPrompt = { + prompt: '#prompt', + + showPrompt: function(msg) { + var label = $('#prompt .input-group-addon'); + var prompt = $(this.prompt); + label.text(msg.data || label.data('prompt')); + if (prompt.isPresent() && prompt.hasClass('hidden')) { + prompt.removeClass('hidden'); + } + $('#prompt input').focus(); + }, + + hidePrompt: function() { + var prompt = $(this.prompt); + if (prompt.isPresent() && !prompt.hasClass('hidden')) { + prompt.addClass('hidden'); + } + }, + + initPrompt: function() { + if ($('#run').isPresent()) { + $('#run').bind('click', this.hidePrompt.bind(this)); + } + if ($('#prompt').isPresent()) { + $('#prompt').on('keypress', this.handlePromptKeyPress.bind(this)); + $('#prompt-submit').on('click', this.submitPromptInput.bind(this)); + } + }, + + submitPromptInput: function() { + var input = $('#prompt-input'); + var message = input.val(); + this.websocket.send(JSON.stringify({cmd: 'result', 'data': message})); + this.websocket.flush(); + input.val(''); + this.hidePrompt(); + }, + + handlePromptKeyPress: function(evt) { + if (evt.which === this.ENTER_KEY_CODE) { + this.submitPromptInput(); + } + } +}; \ No newline at end of file diff --git a/app/assets/javascripts/editor/submissions.js.erb b/app/assets/javascripts/editor/submissions.js.erb new file mode 100644 index 00000000..4b8678a7 --- /dev/null +++ b/app/assets/javascripts/editor/submissions.js.erb @@ -0,0 +1,211 @@ +CodeOceanEditorSubmissions = { + FILENAME_URL_PLACEHOLDER: '{filename}', + + AUTOSAVE_INTERVAL: 15 * 1000, + autosaveTimer: null, + autosaveLabel: "#autosave-label span", + + /** + * Submission-Creation + */ + createSubmission: function (initiator, filter, callback) { + this.showSpinner(initiator); + var jqxhr = this.ajax({ + data: { + submission: { + cause: $(initiator).data('cause') || $(initiator).prop('id'), + exercise_id: $('#editor').data('exercise-id'), + files_attributes: (filter || _.identity)(this.collectFiles()) + }, + annotations_arr: [] + }, + dataType: 'json', + method: 'POST', + url: $(initiator).data('url') || $('#editor').data('submissions-url') + }); + jqxhr.always(this.hideSpinner.bind(this)); + jqxhr.done(this.createSubmissionCallback.bind(this)); + if(callback != null){ + jqxhr.done(callback.bind(this)); + } + + jqxhr.fail(this.ajaxError.bind(this)); + }, + + collectFiles: function() { + var editable_editors = _.filter(this.editors, function(editor) { + return !editor.getReadOnly(); + }); + return _.map(editable_editors, function(editor) { + return { + content: editor.getValue(), + file_id: $(editor.container).data('file-id') + }; + }); + }, + + createSubmissionCallback: function(data){ + // set all frames context types to submission + $('.frame').each(function(index, element) { + $(element).data('context-type', 'Submission'); + }); + + // update the ids of the editors and reload the annotations + for (var i = 0; i < this.editors.length; i++) { + + // set the data attribute to submission + //$(editors[i].container).data('context-type', 'Submission'); + + var file_id_old = $(this.editors[i].container).data('file-id'); + + // file_id_old is always set. Either it is a reference to a teacher supplied given file, or it is the actual id of a new user created file. + // This is the case, since it is set via a call to ancestor_id on the model, which returns either file_id if set, or id if it is not set. + // therefore the else part is not needed any longer... + + // if we have an file_id set (the file is a copy of a teacher supplied given file) and the new file-ids are present in the response + if (file_id_old != null && data.files){ + // if we find file_id_old (this is the reference to the base file) in the submission, this is the match + for(var j = 0; j< data.files.length; j++){ + if(data.files[j].file_id == file_id_old){ + //$(editors[i].container).data('id') = data.files[j].id; + $(this.editors[i].container).data('id', data.files[j].id ); + } + } + } + } + // toggle button states (it might be the case that the request for comments button has to be enabled + this.toggleButtonStates(); + }, + + /** + * File-Management + */ + destroyFile: function() { + this.createSubmission($('#destroy-file'), function(files) { + return _.reject(files, function(file) { + return file.file_id === active_file.id; + }); + }, window.CodeOcean.refresh); + }, + + downloadCode: function(event) { + event.preventDefault(); + this.createSubmission('#download', null,function(response) { + var url = response.download_url; + + // to download just a single file, use the following url + //var url = response.download_file_url.replace(FILENAME_URL_PLACEHOLDER, active_file.filename); + window.location = url; + }); + }, + + resetCode: function() { + this.showSpinner(this); + this.ajax({ + method: 'GET', + url: $('#start-over').data('url') + }).success(function(response) { + this.hideSpinner(); + _.each(this.editors, function(editor) { + var file_id = $(editor.container).data('file-id'); + var file = _.find(response.files, function(file) { + return file.id === file_id; + }); + editor.setValue(file.content); + }.bind(this)); + }.bind(this)); + }, + + renderCode: function(event) { + event.preventDefault(); + if ($('#render').is(':visible')) { + this.createSubmission('#render', null, function (response) { + var url = response.render_url.replace(this.FILENAME_URL_PLACEHOLDER, this.active_file.filename); + var pop_up_window = window.open(url); + if (pop_up_window) { + pop_up_window.onerror = function (message) { + this.clearOutput(); + this.printOutput({ + stderr: message + }, true, 0); + this.sendError(message, response.id); + this.showOutputBar(); + }; + } + }); + } + }, + + /** + * Execution-Logic + */ + runCode: function(event) { + event.preventDefault(); + if ($('#run').is(':visible')) { + this.createSubmission('#run', null, function(response) { + //Run part starts here + $('#stop').data('url', response.stop_url); + this.running = true; + this.showSpinner($('#run')); + $('#score_div').addClass('hidden'); + this.toggleButtonStates(); + var url = response.run_url.replace(this.FILENAME_URL_PLACEHOLDER, this.active_file.filename); + this.initializeSocketForRunning(url); + }.bind(this)); + } + }, + + saveCode: function(event) { + event.preventDefault(); + this.createSubmission('#save', null, function() { + $.flash.success({ + text: $('#save').data('message-success') + }); + }); + }, + + testCode: function(event) { + event.preventDefault(); + if ($('#test').is(':visible')) { + this.createSubmission('#test', null, function(response) { + this.showSpinner($('#test')); + $('#score_div').addClass('hidden'); + var url = response.test_url.replace(this.FILENAME_URL_PLACEHOLDER, this.active_file.filename); + this.initializeSocketForTesting(url); + }.bind(this)); + } + }, + + submitCode: function() { + this.createSubmission($('#submit'), null, function (response) { + if (response.redirect) { + localStorage.removeItem('tab'); + window.location = response.redirect; + } + }) + }, + + /** + * Autosave-Logic + */ + resetSaveTimer: function () { + clearTimeout(this.autosaveTimer); + this.autosaveTimer = setTimeout(this.autosave.bind(this), this.AUTOSAVE_INTERVAL); + }, + + unloadAutoSave: function() { + if(this.autosaveTimer != null){ + this.autosave(); + } + }, + + autosave: function () { + var date = new Date(); + var autosaveLabel = $(this.autosaveLabel); + autosaveLabel.parent().css("visibility", "visible"); + autosaveLabel.text(date.getHours() + ':' + date.getMinutes() + ':' + date.getSeconds()); + autosaveLabel.text(date.toLocaleTimeString()); + this.autosaveTimer = null; + this.createSubmission($('#autosave'), null); + } +}; diff --git a/app/assets/javascripts/editor/turtle.js.erb b/app/assets/javascripts/editor/turtle.js.erb new file mode 100644 index 00000000..bc5552eb --- /dev/null +++ b/app/assets/javascripts/editor/turtle.js.erb @@ -0,0 +1,48 @@ +CodeOceanEditorTurtle = { + turtlecanvas: null, + turtlescreen: null, + resetTurtle: true, + + initTurtle: function () { + if (this.resetTurtle) { + this.resetTurtle = false; + this.turtlecanvas = $('#turtlecanvas'); + this.turtlescreen = new Turtle(this.websocket, this.turtlecanvas); + } + }, + + cleanUpTurtle: function() { + this.resetTurtle = true; + }, + + handleTurtleCommand: function (msg) { + this.initTurtle(); + this.showCanvas(); + if (msg.action in this.turtlescreen) { + var result = this.turtlescreen[msg.action].apply(this.turtlescreen, msg.args); + this.websocket.send(JSON.stringify({cmd: 'result', 'result': result})); + } else { + this.websocket.send(JSON.stringify({cmd: 'exception', exception: 'AttributeError', message: msg.action})); + } + this.websocket.flush(); + }, + + handleTurtlebatchCommand: function (msg) { + this.initTurtle(); + this.showCanvas(); + for (var i = 0; i < msg.batch.length; i++) { + var cmd = msg.batch[i]; + this.turtlescreen[cmd[0]].apply(this.turtlescreen, cmd[1]); + } + }, + + showCanvas: function () { + if ($('#turtlediv').isPresent() + && this.turtlecanvas.hasClass('hidden')) { + // initialize two-column layout + $('#output-col1').addClass('col-lg-7 col-md-7 two-column'); + this.turtlecanvas.removeClass('hidden'); + } + } + +}; \ No newline at end of file diff --git a/app/assets/javascripts/editor/websocket.js.erb b/app/assets/javascripts/editor/websocket.js.erb new file mode 100644 index 00000000..baba623d --- /dev/null +++ b/app/assets/javascripts/editor/websocket.js.erb @@ -0,0 +1,112 @@ +CommandSocket = function(url, onOpen) { + this.handlers = {}; + this.websocket = new WebSocket(url); + this.websocket.onopen = onOpen; + this.websocket.onmessage = this.onMessage.bind(this); + this.websocket.flush = function () { + this.send('\n'); + } +}; + +CommandSocket.prototype.onError = function(callback){ + this.websocket.onerror = callback +}; + +/** + * Allows it to register an event-handler on the given cmd. + * The handler needs to accept one argument, the message. + * There is only handler per command at the moment. + * @param command + * @param handler + */ +CommandSocket.prototype.on = function(command, handler) { + this.handlers[command] = handler; +}; + + +/** + * Used to initialize the recursive message parser. + * @param event + */ +CommandSocket.prototype.onMessage = function(event) { + //Parses the message (serches for linebreaks) and executes every contained cmd. + this.parseMessage(event.data, true) +}; + +/** + * Parses a message, checks wether it contains multiple commands (seperated by linebreaks) + * This needs to be done because of the behavior of the docker-socket connection. + * Because of this, sometimes multiple commands might be executed in one message. + * @param message + * @param recursive + * @returns {boolean} + */ +CommandSocket.prototype.parseMessage = function(message, recursive) { + var msg; + var message_string = message.replace(/^\s+|\s+$/g, ""); + try { + // todo validate json instead of catching + msg = JSON.parse(message_string); + } catch (e) { + if (!recursive) { + return false; + } + // why does docker sometimes send multiple commands at once? + message_string = message_string.replace(/^\s+|\s+$/g, ""); + var messages = message_string.split("\n"); + for (var i = 0; i < messages.length; i++) { + if (!messages[i]) { + continue; + } + this.parseMessage(messages[i], false); + } + return; + } + this.executeCommand(msg); +}; + +/** + * Executes the handler that is registered for a certain command. + * Does nothing if the command was not specified yet. + * If there is a null-handler (defined with on('default',func)) this gets + * executed if the command was not registered or the message has no cmd prop. + * @param cmd + */ +CommandSocket.prototype.executeCommand = function(cmd) { + if ('cmd' in cmd && cmd.cmd in this.handlers) { + this.handlers[cmd.cmd](cmd); + } else if ('default' in this.handlers) { + this.handlers['default'](cmd); + } +}; + +/** + * Used to send a message through the socket. + * If data is not a string we'll try use jsonify to make it a string. + * @param data + */ +CommandSocket.prototype.send = function(data) { + this.websocket.send(data); +}; + +/** + * Returns the ready state of the socket. + */ +CommandSocket.prototype.getReadyState = function() { + return this.websocket.readyState; +}; + +/** + * Flush the websocket. + */ +CommandSocket.prototype.flush = function() { + this.websocket.flush(); +}; + +/** + * Closes the websocket. + */ +CommandSocket.prototype.killWebSocket = function() { + this.websocket.flush(); + this.websocket.close(); +}; \ No newline at end of file diff --git a/app/assets/javascripts/editor_edit.js b/app/assets/javascripts/editor_edit.js deleted file mode 100644 index b1251cf9..00000000 --- a/app/assets/javascripts/editor_edit.js +++ /dev/null @@ -1,55 +0,0 @@ -$(function() { - var ACE_FILES_PATH = '/assets/ace/'; - var THEME = 'ace/theme/textmate'; - - var configureEditors = function() { - _.each(['modePath', 'themePath', 'workerPath'], function(attribute) { - ace.config.set(attribute, ACE_FILES_PATH); - }); - }; - - var initializeEditors = function() { - $('.editor').each(function(index, element) { - var editor = ace.edit(element); - - var document = editor.getSession().getDocument(); - // insert pre-existing code into editor. we have to use insertLines, otherwise the deltas are not properly added - var file_id = $(element).data('file-id'); - var content = $('.editor-content[data-file-id=' + 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); - - var textarea = $('textarea[id="exercise_files_attributes_'+index+'_content"]'); - var content = textarea.val(); - - if (content != undefined) - { - editor.getSession().setValue(content); - editor.getSession().on('change', function(){ - textarea.val(editor.getSession().getValue()); - }); - } - - editor.commands.bindKey("ctrl+alt+0", null); - var session = editor.getSession(); - session.setMode($(element).data('mode')); - session.setTabSize($(element).data('indent-size')); - session.setUseSoftTabs(true); - session.setUseWrapMode(true); - - var file_id = $(element).data('id'); - } - )}; - - if ($('#editor-edit').isPresent()) { - configureEditors(); - initializeEditors(); - $('.frame').show(); - } -}); - diff --git a/app/assets/javascripts/editor_edit.js.erb b/app/assets/javascripts/editor_edit.js.erb new file mode 100644 index 00000000..e69de29b diff --git a/app/assets/javascripts/exercises.js b/app/assets/javascripts/exercises.js.erb similarity index 68% rename from app/assets/javascripts/exercises.js rename to app/assets/javascripts/exercises.js.erb index 9d85b4f1..2a17b405 100644 --- a/app/assets/javascripts/exercises.js +++ b/app/assets/javascripts/exercises.js.erb @@ -1,13 +1,68 @@ $(function() { + // ruby part adds the relative_url_root, if it is set. + var ACE_FILES_PATH = '<%= (defined? Rails.application.config.relative_url_root) && Rails.application.config.relative_url_root != nil && Rails.application.config.relative_url_root != "" ? Rails.application.config.relative_url_root : "" %>' + '/assets/ace/'; + var THEME = 'ace/theme/textmate'; + var TAB_KEY_CODE = 9; var execution_environments; var file_types; + + + var configureEditors = function() { + _.each(['modePath', 'themePath', 'workerPath'], function(attribute) { + ace.config.set(attribute, ACE_FILES_PATH); + }); + }; + + var initializeEditor = function(index, element) { + var editor = ace.edit(element); + + var document = editor.getSession().getDocument(); + // insert pre-existing code into editor. we have to use insertLines, otherwise the deltas are not properly added + var file_id = $(element).data('file-id'); + var content = $('.editor-content[data-file-id=' + 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); + + var textarea = $('textarea[id="exercise_files_attributes_'+index+'_content"]'); + var content = textarea.val(); + + if (content != undefined) + { + editor.getSession().setValue(content); + editor.getSession().on('change', function(){ + textarea.val(editor.getSession().getValue()); + }); + } + + editor.commands.bindKey("ctrl+alt+0", null); + var session = editor.getSession(); + session.setMode($(element).data('mode')); + session.setTabSize($(element).data('indent-size')); + session.setUseSoftTabs(true); + session.setUseWrapMode(true); + } + + var initializeEditors = function() { + // initialize ace editors for all code textareas in the dom except the last one. The last one is the dummy area for new files, which is cloned when needed. + // this one must NOT! be initialized. + $('.editor:not(:last)').each(initializeEditor) + }; + var addFileForm = function(event) { event.preventDefault(); var element = $('#dummies').children().first().clone(); - var html = $('').append(element).html().replace(/index/g, new Date().getTime()); + + // the timestamp is used here, since it is most probably unique. This is strange, but was originally designed that way. + var latestTextAreaIndex = new Date().getTime(); + var html = $('').append(element).html().replace(/index/g, latestTextAreaIndex); $('#files').append(html); $('#files li:last select[name*="file_type_id"]').val(getSelectedExecutionEnvironment().file_type_id); $('#files li:last select').chosen(window.CodeOcean.CHOSEN_OPTIONS); @@ -15,6 +70,10 @@ $(function() { // if we collapse the file forms by default, we need to click on the new element in order to open it. // however, this crashes for more files (if we add several ones by clicking the add button more often), since the elements are probably not correctly added to the files list. //$('#files li:last>div:first>a>div').click(); + + // initialize the ace editor for the new textarea. + // pass the correct index and the last ace editor under the node files. this is the last one, since we just added it. + initializeEditor(latestTextAreaIndex, $('#files .editor').last()[0]); }; var ajaxError = function() { @@ -152,8 +211,9 @@ $(function() { }; var updateFileTemplates = function(fileType) { + var rel_url_root = '<%= (defined? Rails.application.config.relative_url_root) && Rails.application.config.relative_url_root != nil && Rails.application.config.relative_url_root != "" ? Rails.application.config.relative_url_root : "" %>'; var jqxhr = $.ajax({ - url: '/file_templates/by_file_type/' + fileType + '.json', + url: rel_url_root + '/file_templates/by_file_type/' + fileType + '.json', dataType: 'json' }); jqxhr.done(function(response) { @@ -192,4 +252,13 @@ $(function() { highlightCode(); } } + + + if ($('#editor-edit').isPresent()) { + configureEditors(); + initializeEditors(); + $('.frame').show(); + } + + }); diff --git a/app/assets/stylesheets/editor.css.scss b/app/assets/stylesheets/editor.css.scss index e41153e2..755e3409 100644 --- a/app/assets/stylesheets/editor.css.scss +++ b/app/assets/stylesheets/editor.css.scss @@ -7,9 +7,18 @@ button i.fa-spin { width: 100%; } +/* this class is used for the edit view of an exercise. It needs the height set, as it does not automatically resize */ +.edit-frame { + height: 400px; + + audio, img, video { + max-width: 100%; + } +} + + .frame { display: none; - height: 400px; audio, img, video { max-width: 100%; @@ -18,6 +27,7 @@ button i.fa-spin { .score { display: none; + vertical-align: bottom; } #alert, #development-environment { @@ -26,7 +36,6 @@ button i.fa-spin { #dummy { display: none; - width: 100% !important; } #editor-buttons { @@ -64,6 +73,7 @@ button i.fa-spin { #outputInformation { #output { max-height: 500px; + width: 100%; overflow: auto; margin: 2em 0; @@ -89,9 +99,96 @@ button i.fa-spin { font-size: 0.8em; } -.requestCommentsButton { +#turtlecanvas{ + border-style:solid; + border-width:thin; + display: block; + margin: auto; + +} + +/* .requestCommentsButton { position: relative; margin-top: -50px; margin-right: 25px; float: right; +} */ + +.sidebar-col-collapsed { + -webkit-transition: width 2s; + transition: width 2s; + width:67px; + float:left; + min-height: 1px; + padding-left: 15px; + padding-right: 15px; } + +.sidebar-col { + -webkit-transition: width 2s; + transition: width 2s; + width:20%; + float:left; + min-height: 1px; + padding-left: 15px; + padding-right: 15px; +} + +.editor-col { + min-height: 1px; + width:auto; + height:100%; + overflow:hidden; +} + +.output-col { + -webkit-transition: width 2s; + transition: width 2s; + width:40%; + float:right; + min-height: 1px; + padding-left: 15px; + padding-right: 15px; + box-sizing: border-box +} + +.output-col-collapsed { + -webkit-transition: width 2s; + transition: width 2s; + width:67px; + float:right; + min-height: 1px; + padding-left: 15px; + padding-right: 15px; + box-sizing: border-box +} + +.enforce-top-margin { + margin-top: 5px !important; +} + +.enforce-right-margin { + margin-right: 10px !important; +} + +.description-panel-collapsed { + -webkit-transition: width 2s; + transition: width 2s; + height: 0px; + visibility: hidden; +} + +.description-panel { + height: auto; + -webkit-transition: height 2s; + transition: height 2s; + visibility: visible; +} + +.enforce-big-top-margin { + margin-top: 15px !important; +} + +.enforce-bottom-margin { + margin-bottom: 5px !important; +} \ No newline at end of file diff --git a/app/controllers/exercises_controller.rb b/app/controllers/exercises_controller.rb index 75451eb3..31e970f3 100644 --- a/app/controllers/exercises_controller.rb +++ b/app/controllers/exercises_controller.rb @@ -118,7 +118,7 @@ class ExercisesController < ApplicationController private :user_by_code_harbor_token def exercise_params - params[:exercise].permit(:description, :execution_environment_id, :file_id, :instructions, :public, :hide_file_tree, :allow_file_creation, :title, files_attributes: file_attributes).merge(user_id: current_user.id, user_type: current_user.class.name) + params[:exercise].permit(:description, :execution_environment_id, :file_id, :instructions, :public, :hide_file_tree, :allow_file_creation, :allow_auto_completion, :title, files_attributes: file_attributes).merge(user_id: current_user.id, user_type: current_user.class.name) end private :exercise_params @@ -247,23 +247,34 @@ class ExercisesController < ApplicationController end def redirect_after_submit - Rails.logger.debug('Score ' + @submission.normalized_score.to_s) + Rails.logger.debug('Redirecting user with score:s ' + @submission.normalized_score.to_s) if @submission.normalized_score == 1.0 - # if user has an own rfc, redirect to it and message him to clean up and accept the answer. + # if user is external and has an own rfc, redirect to it and message him to clean up and accept the answer. (we need to check that the user is external, + # otherwise an internal user could be shown a false rfc here, since current_user.id is polymorphic, but only makes sense for external users when used with rfcs.) + if current_user.respond_to? :external_id + if rfc = RequestForComment.unsolved.where(exercise_id: @submission.exercise, user_id: current_user.id).first + # set a message that informs the user that his own RFC should be closed. + flash[:notice] = I18n.t('exercises.submit.full_score_redirect_to_own_rfc') + flash.keep(:notice) - # else: show open rfc for same exercise - if rfc = RequestForComment.unsolved.where(exercise_id: @submission.exercise).order("RANDOM()").first + respond_to do |format| + format.html { redirect_to(rfc) } + format.json { render(json: {redirect: url_for(rfc)}) } + end + return - # set a message that informs the user that his score was perfect and help in RFC is greatly appreciated. - flash[:notice] = I18n.t('exercises.submit.full_score_redirect_to_rfc') - flash.keep(:notice) + # else: show open rfc for same exercise if available + elsif rfc = RequestForComment.unsolved.where(exercise_id: @submission.exercise).order("RANDOM()").first + # set a message that informs the user that his score was perfect and help in RFC is greatly appreciated. + flash[:notice] = I18n.t('exercises.submit.full_score_redirect_to_rfc') + flash.keep(:notice) - respond_to do |format| - format.html { redirect_to(rfc) } - format.json { render(json: {redirect: url_for(rfc)}) } + respond_to do |format| + format.html { redirect_to(rfc) } + format.json { render(json: {redirect: url_for(rfc)}) } + end + return end - - return end end redirect_to_lti_return_path diff --git a/app/controllers/request_for_comments_controller.rb b/app/controllers/request_for_comments_controller.rb index 37d8bef9..f9e7137e 100644 --- a/app/controllers/request_for_comments_controller.rb +++ b/app/controllers/request_for_comments_controller.rb @@ -82,6 +82,7 @@ class RequestForCommentsController < ApplicationController # Never trust parameters from the scary internet, only allow the white list through. def request_for_comment_params + # we are using the current_user.id here, since internal users are not able to create comments. The external_user.id is a primary key and does not require the consumer_id to be unique. params.require(:request_for_comment).permit(:exercise_id, :file_id, :question, :requested_at, :solved, :submission_id).merge(user_id: current_user.id, user_type: current_user.class.name) end end diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index baf08cae..16fcd697 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -129,7 +129,7 @@ class SubmissionsController < ApplicationController socket.on :message do |event| Rails.logger.info( Time.now.getutc.to_s + ": Docker sending: " + event.data) - handle_message(event.data, tubesock) + handle_message(event.data, tubesock, result[:container]) end socket.on :close do |event| @@ -139,12 +139,12 @@ class SubmissionsController < ApplicationController tubesock.onmessage do |data| Rails.logger.info(Time.now.getutc.to_s + ": Client sending: " + data) # Check whether the client send a JSON command and kill container - # if the command is 'exit', send it to docker otherwise. + # if the command is 'client_exit', send it to docker otherwise. begin parsed = JSON.parse(data) - if parsed['cmd'] == 'exit' + if parsed['cmd'] == 'client_kill' Rails.logger.debug("Client exited container.") - @docker_client.exit_container(result[:container]) + @docker_client.kill_container(result[:container]) else socket.send data Rails.logger.debug('Sent the received client data to docker:' + data) @@ -171,10 +171,11 @@ class SubmissionsController < ApplicationController tubesock.close end - def handle_message(message, tubesock) + def handle_message(message, tubesock, container) # Handle special commands first if (/^exit/.match(message)) kill_socket(tubesock) + @docker_client.exit_container(container) else # Filter out information about run_command, test_command, user or working directory run_command = @submission.execution_environment.run_command % command_substitutions(params[:filename]) @@ -231,7 +232,13 @@ class SubmissionsController < ApplicationController hijack do |tubesock| Thread.new { EventMachine.run } unless EventMachine.reactor_running? && EventMachine.reactor_thread.alive? # tubesock is the socket to the client - tubesock.send_data JSON.dump(score_submission(@submission)) + + # the score_submission call will end up calling docker exec, which is blocking. + # to ensure responsiveness, we therefore open a thread here. + Thread.new { + tubesock.send_data JSON.dump(score_submission(@submission)) + tubesock.send_data JSON.dump({'cmd' => 'exit'}) + } end end @@ -291,6 +298,7 @@ class SubmissionsController < ApplicationController # tubesock is the socket to the client tubesock.send_data JSON.dump(output) + tubesock.send_data JSON.dump('cmd' => 'exit') end end diff --git a/app/views/exercises/_code_field.html.slim b/app/views/exercises/_code_field.html.slim index 5273a693..bb2a806f 100644 --- a/app/views/exercises/_code_field.html.slim +++ b/app/views/exercises/_code_field.html.slim @@ -2,5 +2,6 @@ = form.label(attribute, label) | a.toggle-input data={text_initial: t('shared.upload_file'), text_toggled: t('shared.back')} href='#' = t('shared.upload_file') - = form.text_area(attribute, class: 'code-field form-control original-input', rows: 16, style: "display:none;") + = form.text_area(attribute, class: 'code-field form-control', rows: 16, style: "display:none;") = form.file_field(attribute, class: 'alternative-input form-control', disabled: true) + = render partial: 'editor_edit', locals: { exercise: @exercise } diff --git a/app/views/exercises/_editor.html.slim b/app/views/exercises/_editor.html.slim index 0ce5b73f..6b14d3c8 100644 --- a/app/views/exercises/_editor.html.slim +++ b/app/views/exercises/_editor.html.slim @@ -1,41 +1,24 @@ -#editor.row data-exercise-id=exercise.id data-message-depleted=t('exercises.editor.depleted') data-message-timeout=t('exercises.editor.timeout', permitted_execution_time: @exercise.execution_environment.permitted_execution_time) data-errors-url=execution_environment_errors_path(exercise.execution_environment) data-submissions-url=submissions_path data-user-id=@current_user.id - div class=(@exercise.hide_file_tree ? 'hidden col-sm-3' : 'col-sm-3') = render('editor_file_tree', files: @files) - div id='frames' class=(@exercise.hide_file_tree ? 'col-sm-12' : 'col-sm-9') +- external_user_id = @current_user.respond_to?(:external_id) ? @current_user.external_id : '' #'tests' #(@current_user.uuid.present? ? @current_user.uuid : '') +#editor.row data-exercise-id=exercise.id data-message-depleted=t('exercises.editor.depleted') data-message-timeout=t('exercises.editor.timeout', permitted_execution_time: @exercise.execution_environment.permitted_execution_time) data-errors-url=execution_environment_errors_path(exercise.execution_environment) data-submissions-url=submissions_path data-user-id=@current_user.id data-user-external-id=external_user_id + div id="sidebar" class=(@exercise.hide_file_tree ? 'sidebar-col-collapsed' : 'sidebar-col') = render('editor_file_tree', exercise: @exercise, files: @files) + div id='output_sidebar' class='output-col-collapsed' = render('exercises/editor_output') + div id='frames' class='editor-col' + #editor-buttons.btn-group.enforce-bottom-margin + // = render('editor_button', data: {:'data-message-success' => t('submissions.create.success'), :'data-placement' => 'top', :'data-tooltip' => true}, icon: 'fa fa-save', id: 'save', label: t('exercises.editor.save'), title: t('.tooltips.save')) + // .btn-group + = render('editor_button', disabled: true, icon: 'fa fa-ban', id: 'dummy', label: t('exercises.editor.dummy')) + = render('editor_button', icon: 'fa fa-desktop', id: 'render', label: t('exercises.editor.render')) + = render('editor_button', data: {:'data-message-failure' => t('exercises.editor.run_failure'), :'data-message-network' => t('exercises.editor.network'), :'data-message-success' => t('exercises.editor.run_success'), :'data-placement' => 'top', :'data-tooltip' => true}, icon: 'fa fa-play', id: 'run', label: t('exercises.editor.run'), title: t('shared.tooltips.shortcut', shortcut: 'ALT + r')) + = render('editor_button', data: {:'data-placement' => 'top', :'data-tooltip' => true}, icon: 'fa fa-stop', id: 'stop', label: t('exercises.editor.stop'), title: t('shared.tooltips.shortcut', shortcut: 'ALT + r')) + = render('editor_button', data: {:'data-placement' => 'top', :'data-tooltip' => true}, icon: 'fa fa-rocket', id: 'test', label: t('exercises.editor.test'), title: t('shared.tooltips.shortcut', shortcut: 'ALT + t')) + = render('editor_button', data: {:'data-placement' => 'top', :'data-tooltip' => true}, icon: 'fa fa-trophy', id: 'assess', label: t('exercises.editor.score'), title: t('shared.tooltips.shortcut', shortcut: 'ALT + s')) + = render('editor_button', icon: 'fa fa-comment', id: 'requestComments', label: t('exercises.editor.requestComments'), title: t('exercises.editor.requestCommentsTooltip')) - @files.each do |file| = render('editor_frame', exercise: exercise, file: file) #autosave-label = t('exercises.editor.lastsaved') span - #editor-buttons.btn-group - = render('editor_button', data: {:'data-message-confirm' => t('exercises.editor.confirm_start_over'), :'data-url' => reload_exercise_path(exercise)}, icon: 'fa fa-history', id: 'start-over', label: t('exercises.editor.start_over')) - // = render('editor_button', data: {:'data-message-success' => t('submissions.create.success'), :'data-placement' => 'top', :'data-tooltip' => true}, icon: 'fa fa-save', id: 'save', label: t('exercises.editor.save'), title: t('.tooltips.save')) button style="display:none" id="autosave" - .btn-group - = render('editor_button', disabled: true, icon: 'fa fa-ban', id: 'dummy', label: t('exercises.editor.dummy')) - = render('editor_button', icon: 'fa fa-desktop', id: 'render', label: t('exercises.editor.render')) - = render('editor_button', data: {:'data-message-failure' => t('exercises.editor.run_failure'), :'data-message-network' => t('exercises.editor.network'), :'data-message-success' => t('exercises.editor.run_success'), :'data-placement' => 'top', :'data-tooltip' => true}, icon: 'fa fa-play', id: 'run', label: t('exercises.editor.run'), title: t('shared.tooltips.shortcut', shortcut: 'ALT + r')) - = render('editor_button', data: {:'data-placement' => 'top', :'data-tooltip' => true}, icon: 'fa fa-stop', id: 'stop', label: t('exercises.editor.stop'), title: t('shared.tooltips.shortcut', shortcut: 'ALT + r')) - = render('editor_button', data: {:'data-placement' => 'top', :'data-tooltip' => true}, icon: 'fa fa-rocket', id: 'test', label: t('exercises.editor.test'), title: t('shared.tooltips.shortcut', shortcut: 'ALT + t')) - button.btn.btn-primary.dropdown-toggle data-toggle='dropdown' type='button' - span.caret - span.sr-only Toggle Dropdown - ul.dropdown-menu role='menu' - li - a#dropdown-render data-cause='render' href='#' - i.fa.fa-desktop - = t('exercises.editor.render') - li - a#dropdown-run data-cause='run' href='#' - i.fa.fa-play - = t('exercises.editor.run') - li - a#dropdown-stop href='#' - i.fa.fa-stop - = t('exercises.editor.stop') - li - a#dropdown-test data-cause='test' href='#' - i.fa.fa-rocket - = t('exercises.editor.test') - = render('editor_button', data: {:'data-placement' => 'top', :'data-tooltip' => true}, icon: 'fa fa-trophy', id: 'assess', label: t('exercises.editor.score'), title: t('shared.tooltips.shortcut', shortcut: 'ALT + s')) + = render('shared/modal', id: 'comment-modal', title: t('exercises.implement.comment.request'), template: 'exercises/_request_comment_dialogcontent') diff --git a/app/views/exercises/_editor_button.html.slim b/app/views/exercises/_editor_button.html.slim index cc03c1a9..ae69529e 100644 --- a/app/views/exercises/_editor_button.html.slim +++ b/app/views/exercises/_editor_button.html.slim @@ -1,4 +1,4 @@ -button.btn class=local_assigns.fetch(:classes, 'btn-primary') *local_assigns.fetch(:data, {}) disabled=local_assigns.fetch(:disabled, false) id=id title=local_assigns[:title] type='button' +button.btn class=local_assigns.fetch(:classes, 'btn-primary btn-sm') *local_assigns.fetch(:data, {}) disabled=local_assigns.fetch(:disabled, false) id=id title=local_assigns[:title] type='button' i.fa.fa-circle-o-notch.fa-spin i class=icon = label diff --git a/app/views/exercises/_editor_edit.html.slim b/app/views/exercises/_editor_edit.html.slim index 810653d2..83f27d68 100644 --- a/app/views/exercises/_editor_edit.html.slim +++ b/app/views/exercises/_editor_edit.html.slim @@ -1,5 +1,5 @@ -#editor-edit.panel-group.row data-exercise-id=@exercise.id +#editor-edit.panel-group.row.original-input data-exercise-id=@exercise.id #frames - .frame + .edit-frame .editor-content.hidden .editor \ No newline at end of file diff --git a/app/views/exercises/_editor_file_tree.html.slim b/app/views/exercises/_editor_file_tree.html.slim index c23158b9..16cc705b 100644 --- a/app/views/exercises/_editor_file_tree.html.slim +++ b/app/views/exercises/_editor_file_tree.html.slim @@ -1,10 +1,28 @@ -#files data-entries=FileTree.new(files).to_js_tree +div id='sidebar-collapsed' class=(@exercise.hide_file_tree ? '' : 'hidden') + = render('editor_button', classes: 'btn-block btn-primary btn-sm', data: {:'data-toggle' => 'tooltip', :'data-placement' => 'right'}, icon: 'fa fa-plus-square', id: 'sidebar-collapse-collapsed', label:'', title:t('exercises.editor.expand_action_sidebar')) -hr + - if @exercise.allow_file_creation and not @exercise.hide_file_tree? + = render('editor_button', classes: 'btn-block btn-primary btn-sm enforce-top-margin', data: {:'data-cause' => 'file', :'data-toggle' => 'tooltip', :'data-placement' => 'right'}, icon: 'fa fa-plus', id: 'create-file-collapsed', label:'', title: t('exercises.editor.create_file')) + + = render('editor_button', classes: 'btn-block btn-primary btn-sm enforce-top-margin', data: {:'data-toggle' => 'tooltip', :'data-placement' => 'right'}, icon: 'fa fa-download', id: 'download-collapsed', label:'', title: t('exercises.editor.download')) + = render('editor_button', classes: 'btn-block btn-primary btn-sm enforce-top-margin', data: {:'data-message-confirm' => t('exercises.editor.confirm_start_over'), :'data-url' => reload_exercise_path(@exercise), :'data-toggle' => 'tooltip', :'data-placement' => 'right'}, icon: 'fa fa-history', id: 'start-over-collapsed', label:'', title: t('exercises.editor.start_over')) + +div id='sidebar-uncollapsed' class=(@exercise.hide_file_tree ? 'hidden' : '') + = render('editor_button', classes: 'btn-block btn-primary btn-sm', icon: 'fa fa-minus-square', id: 'sidebar-collapse', label: t('exercises.editor.collapse_action_sidebar')) + + div class=(@exercise.hide_file_tree ? 'hidden' : '') + hr + + #files data-entries=FileTree.new(files).to_js_tree + + hr + + - if @exercise.allow_file_creation and not @exercise.hide_file_tree? + = render('editor_button', classes: 'btn-block btn-primary btn-sm', data: {:'data-cause' => 'file'}, icon: 'fa fa-plus', id: 'create-file', label: t('exercises.editor.create_file')) + = render('editor_button', classes: 'btn-block btn-warning btn-sm', data: {:'data-cause' => 'file', :'data-message-confirm' => t('shared.confirm_destroy')}, icon: 'fa fa-times', id: 'destroy-file', label: t('exercises.editor.destroy_file')) + + = render('editor_button', classes: 'btn-block btn-primary btn-sm enforce-top-margin', icon: 'fa fa-download', id: 'download', label: t('exercises.editor.download')) + = render('editor_button', classes: 'btn-block btn-primary btn-sm', data: {:'data-message-confirm' => t('exercises.editor.confirm_start_over'), :'data-url' => reload_exercise_path(@exercise)}, icon: 'fa fa-history', id: 'start-over', label: t('exercises.editor.start_over')) - if @exercise.allow_file_creation? - = render('editor_button', classes: 'btn-block btn-primary btn-sm', data: {:'data-cause' => 'file'}, icon: 'fa fa-plus', id: 'create-file', label: t('exercises.editor.create_file')) - = render('editor_button', classes: 'btn-block btn-warning btn-sm', data: {:'data-cause' => 'file', :'data-message-confirm' => t('shared.confirm_destroy')}, icon: 'fa fa-times', id: 'destroy-file', label: t('exercises.editor.destroy_file')) - = render('shared/modal', id: 'modal-file', template: 'code_ocean/files/_form', title: t('exercises.editor.create_file')) - -= render('editor_button', classes: 'btn-block btn-primary btn-sm', icon: 'fa fa-download', id: 'download', label: t('exercises.editor.download')) + = render('shared/modal', id: 'modal-file', template: 'code_ocean/files/_form', title: t('exercises.editor.create_file')) \ No newline at end of file diff --git a/app/views/exercises/_editor_frame.html.slim b/app/views/exercises/_editor_frame.html.slim index 01640fa8..eff1541c 100644 --- a/app/views/exercises/_editor_frame.html.slim +++ b/app/views/exercises/_editor_frame.html.slim @@ -12,8 +12,4 @@ = link_to(file.native_file.file.name_with_extension, file.native_file.url) - else .editor-content.hidden data-file-id=file.ancestor_id = file.content - .editor data-file-id=file.ancestor_id data-indent-size=file.file_type.indent_size data-mode=file.file_type.editor_mode data-read-only=file.read_only data-id=file.id - - button.btn.btn-primary.requestCommentsButton type='button' id="requestComments" - i.fa.fa-comment - = t('exercises.editor.requestComments') \ No newline at end of file + .editor data-file-id=file.ancestor_id data-indent-size=file.file_type.indent_size data-mode=file.file_type.editor_mode data-read-only=file.read_only data-allow-auto-completion=exercise.allow_auto_completion.to_s data-id=file.id \ No newline at end of file diff --git a/app/views/exercises/_editor_output.html.slim b/app/views/exercises/_editor_output.html.slim new file mode 100644 index 00000000..ab09adac --- /dev/null +++ b/app/views/exercises/_editor_output.html.slim @@ -0,0 +1,55 @@ +div id='output_sidebar_collapsed' + = render('editor_button', classes: 'btn-block btn-primary btn-sm', data: {:'data-toggle' => 'tooltip', :'data-placement' => 'left'}, title: t('exercises.editor.expand_output_sidebar'), icon: 'fa fa-plus-square', id: 'toggle-sidebar-output-collapsed', label: '') +div id='output_sidebar_uncollapsed' class='hidden col-sm-12 enforce-bottom-margin' data-message-no-output=t('exercises.implement.no_output') + .row + = render('editor_button', classes: 'btn-block btn-primary btn-sm', icon: 'fa fa-minus-square', id: 'toggle-sidebar-output', label: t('exercises.editor.collapse_output_sidebar')) + + div.enforce-big-top-margin.hidden id='score_div' + #results + h2 = t('exercises.implement.results') + p.test-count == t('exercises.implement.test_count', count: 0) + ul.list-unstyled + ul#dummies.hidden.list-unstyled + li.panel.panel-default + .panel-heading + h3.panel-title == t('exercises.implement.file', filename: '', number: 0) + .panel-body + = row(label: 'exercises.implement.passed_tests', value: t('shared.out_of', maximum_value: 0, value: 0).html_safe) + = row(label: 'activerecord.attributes.submission.score', value: t('shared.out_of', maximum_value: 0, value: 0).html_safe) + = row(label: 'exercises.implement.feedback') + = row(label: 'exercises.implement.error_messages') + = row(label: 'exercises.implement.output', value: link_to(t('shared.show'), '#')) + #score data-maximum-score=@exercise.maximum_score data-score=@submission.try(:score) + h4 + span == "#{t('activerecord.attributes.submission.score')}: " + span.score + .progress + .progress-bar role='progressbar' + + br + - if session[:lti_parameters].try(:has_key?, 'lis_outcome_service_url') + p.text-center = render('editor_button', classes: 'btn-lg btn-success', data: {:'data-url' => submit_exercise_path(@exercise)}, icon: 'fa fa-send', id: 'submit', label: t('exercises.editor.submit')) + - else + p.text-center = render('editor_button', classes: 'btn-lg btn-warning-outline', data: {:'data-placement' => 'bottom', :'data-tooltip' => true}, icon: 'fa fa-clock-o', id: 'submit_outdated', label: t('exercises.editor.exercise_deadline_passed'), title: t('exercises.editor.tooltips.exercise_deadline_passed')) + hr + + div.enforce-big-top-margin + #turtlediv + canvas#turtlecanvas.hidden width=400 height=400 + div.enforce-big-top-margin + #hint + .panel.panel-warning + .panel-heading = t('exercises.implement.hint') + .panel-body + div.enforce-big-top-margin + #prompt.input-group.hidden + span.input-group-addon data-prompt=t('exercises.editor.input') = t('exercises.editor.input') + input#prompt-input.form-control type='text' + span.input-group-btn + button#prompt-submit.btn.btn-primary type="button" = t('exercises.editor.send') + #output + pre = t('exercises.implement.no_output_yet') + - if CodeOcean::Config.new(:code_ocean).read[:flowr][:enabled] + #flowrHint.panel.panel-info data-url=CodeOcean::Config.new(:code_ocean).read[:flowr][:url] role='tab' + .panel-heading = 'Gain more insights here' + .panel-body \ No newline at end of file diff --git a/app/views/exercises/_file_form.html.slim b/app/views/exercises/_file_form.html.slim index 796c6722..065eca66 100644 --- a/app/views/exercises/_file_form.html.slim +++ b/app/views/exercises/_file_form.html.slim @@ -37,5 +37,4 @@ li.panel.panel-default .form-group = f.label(:role, t('activerecord.attributes.file.weight')) = f.number_field(:weight, class: 'form-control', min: 1, step: 'any') - = render('code_field', attribute: :content, form: f, label: t('activerecord.attributes.file.content')) - = render partial: 'editor_edit', locals: { exercise: @exercise } \ No newline at end of file + = render('code_field', attribute: :content, form: f, label: t('activerecord.attributes.file.content')) \ No newline at end of file diff --git a/app/views/exercises/_form.html.slim b/app/views/exercises/_form.html.slim index a640f0c2..c4159254 100644 --- a/app/views/exercises/_form.html.slim +++ b/app/views/exercises/_form.html.slim @@ -28,6 +28,10 @@ label = f.check_box(:allow_file_creation) = t('activerecord.attributes.exercise.allow_file_creation') + .checkbox + label + = f.check_box(:allow_auto_completion) + = t('activerecord.attributes.exercise.allow_auto_completion') h2 = t('activerecord.attributes.exercise.files') ul#files.list-unstyled.panel-group = f.fields_for :files do |files_form| diff --git a/app/views/exercises/implement.html.slim b/app/views/exercises/implement.html.slim index 1feab189..87ff4e1f 100644 --- a/app/views/exercises/implement.html.slim +++ b/app/views/exercises/implement.html.slim @@ -1,85 +1,22 @@ .row - #editor-column.col-md-10.col-md-offset-1 - h1 = @exercise + #editor-column.col-md-12 + div + span.badge.pull-right.score - span.badge.pull-right.score + h1 id="exercise-headline" + i class="fa fa-chevron-down" id="description-symbol" + = @exercise - p.lead = render_markdown(@exercise.description) + #description-panel.lead.description-panel + = render_markdown(@exercise.description) #alert.alert.alert-danger role='alert' h4 = t('.alert.title') p = t('.alert.text', application_name: application_name) #development-environment - ul.nav.nav-justified.nav-tabs role='tablist' - li.active - a data-placement='top' data-toggle='tab' data-tooltip=true href='#workspace' role='tab' title=t('shared.tooltips.shortcut', shortcut: 'ALT + 1') - i.fa.fa-code - = t('.workspace') - li - a data-placement='top' data-toggle='tab' data-tooltip=true href='#outputInformation' role='tab' title=t('shared.tooltips.shortcut', shortcut: 'ALT + 2') - i.fa.fa-terminal - = t('.output') - li - a data-placement='top' data-toggle='tab' data-tooltip=true href='#progress' role='tab' title=t('shared.tooltips.shortcut', shortcut: 'ALT + 3') - i.fa.fa-line-chart - = t('.progress') - - hr - .tab-content #workspace.tab-pane.active = render('editor', exercise: @exercise, files: @files, submission: @submission) - #outputInformation.tab-pane data-message-no-output=t('.no_output') - #hint - .panel.panel-warning - .panel-heading = t('.hint') - .panel-body - .row - / #output-col1.col-sm-12 - #output-col1 - // todo set to full width if turtle isnt used - #prompt.input-group.hidden.col-lg-7.col-md-7.two-column - span.input-group-addon data-prompt=t('exercises.editor.input') = t('exercises.editor.input') - input#prompt-input.form-control type='text' - span.input-group-btn - button#prompt-submit.btn.btn-primary type="button" = t('exercises.editor.send') - #output - pre = t('.no_output_yet') - - if CodeOcean::Config.new(:code_ocean).read[:flowr][:enabled] - #flowrHint.panel.panel-info data-url=CodeOcean::Config.new(:code_ocean).read[:flowr][:url] role='tab' - .panel-heading = 'Gain more insights here' - .panel-body - #output-col2.col-lg-5.col-md-5 - #turtlediv - // todo what should the canvas default size be? - canvas#turtlecanvas.hidden width=400 height=400 style='border-style:solid;border-width:thin' - #progress.tab-pane - #results - h2 = t('.results') - p.test-count == t('.test_count', count: 0) - ul.list-unstyled - ul#dummies.hidden.list-unstyled - li.panel.panel-default - .panel-heading - h3.panel-title == t('.file', filename: '', number: 0) - .panel-body - = row(label: '.passed_tests', value: t('shared.out_of', maximum_value: 0, value: 0).html_safe) - = row(label: 'activerecord.attributes.submission.score', value: t('shared.out_of', maximum_value: 0, value: 0).html_safe) - = row(label: '.feedback') - = row(label: '.error_messages') - = row(label: '.output', value: link_to(t('shared.show'), '#')) - #score data-maximum-score=@exercise.maximum_score data-score=@submission.try(:score) - h4 - span == "#{t('activerecord.attributes.submission.score')}: " - span.score - .progress - .progress-bar role='progressbar' - - br - - if session[:lti_parameters].try(:has_key?, 'lis_outcome_service_url') - p.text-center = render('editor_button', classes: 'btn-lg btn-success', data: {:'data-url' => submit_exercise_path(@exercise)}, icon: 'fa fa-send', id: 'submit', label: t('exercises.editor.submit')) - - else - p.text-center = render('editor_button', classes: 'btn-lg btn-warning-outline', data: {:'data-placement' => 'bottom', :'data-tooltip' => true} , icon: 'fa fa-clock-o', id: 'submit_outdated', label: t('exercises.editor.exercise_deadline_passed'), title: t('exercises.editor.tooltips.exercise_deadline_passed')) - if qa_url #questions-column diff --git a/app/views/exercises/show.html.slim b/app/views/exercises/show.html.slim index 5c554da8..902f8135 100644 --- a/app/views/exercises/show.html.slim +++ b/app/views/exercises/show.html.slim @@ -16,6 +16,7 @@ h1 = row(label: 'exercise.public', value: @exercise.public?) = row(label: 'exercise.hide_file_tree', value: @exercise.hide_file_tree?) = row(label: 'exercise.allow_file_creation', value: @exercise.allow_file_creation?) += row(label: 'exercise.allow_auto_completion', value: @exercise.allow_auto_completion?) = row(label: 'exercise.embedding_parameters') do = content_tag(:input, nil, class: 'form-control', readonly: true, value: embedding_parameters(@exercise)) diff --git a/config/application.rb b/config/application.rb index 68350af1..10a8537d 100644 --- a/config/application.rb +++ b/config/application.rb @@ -43,3 +43,5 @@ module CodeOcean end end end + +Rails.application.config.assets.precompile += %w( markdown-buttons.png ) \ No newline at end of file diff --git a/config/deploy.rb b/config/deploy.rb index a1567cb7..173f2b56 100644 --- a/config/deploy.rb +++ b/config/deploy.rb @@ -3,7 +3,7 @@ set :config_example_suffix, '.example' set :default_env, 'PATH' => '/usr/java/jdk1.8.0_40/bin:$PATH' set :deploy_to, '/var/www/app' set :keep_releases, 3 -set :linked_dirs, %w(bin log public/uploads tmp/cache tmp/files tmp/pids tmp/sockets) +set :linked_dirs, %w(log public/uploads tmp/cache tmp/files tmp/pids tmp/sockets) set :linked_files, %w(config/action_mailer.yml config/code_ocean.yml config/database.yml config/newrelic.yml config/secrets.yml config/sendmail.yml config/smtp.yml) set :log_level, :info set :puma_threads, [0, 16] diff --git a/config/docker.yml.erb b/config/docker.yml.erb index 0a018241..8fac75d0 100644 --- a/config/docker.yml.erb +++ b/config/docker.yml.erb @@ -7,8 +7,8 @@ default: &default development: <<: *default - host: tcp://192.168.59.104:2376 - ws_host: ws://192.168.59.104:2376 #url to connect rails server to docker host + host: tcp://127.0.0.1:2376 + ws_host: ws://127.0.0.1:2376 #url to connect rails server to docker host ws_client_protocol: ws:// #set the websocket protocol to be used by the client to connect to the rails server (ws on development, wss on production) workspace_root: <%= Rails.root.join('tmp', 'files', Rails.env) %> pool: diff --git a/config/docker.yml.erb.example b/config/docker.yml.erb.example new file mode 100644 index 00000000..9f55d126 --- /dev/null +++ b/config/docker.yml.erb.example @@ -0,0 +1,40 @@ +#Why erb? +default: &default + connection_timeout: 3 + pool: + active: false + ports: !ruby/range 4500..4600 + +development: + <<: *default + host: tcp://127.0.0.1:2376 + ws_host: ws://127.0.0.1:2376 #url to connect rails server to docker host + ws_client_protocol: ws:// #set the websocket protocol to be used by the client to connect to the rails server (ws on development, wss on production) + workspace_root: <%= Rails.root.join('tmp', 'files', Rails.env) %> + pool: + active: true + refill: + async: false + batch_size: 8 + interval: 15 + timeout: 60 + #workspace_root: <%= File.join('/', 'shared', Rails.env) %> + +production: + <<: *default + host: unix:///var/run/docker.sock + pool: + active: true + refill: + async: false + batch_size: 8 + interval: 15 + timeout: 60 + workspace_root: <%= Rails.root.join('tmp', 'files', Rails.env) %> + ws_host: ws://localhost:4243 #url to connect rails server to docker host + ws_client_protocol: wss:// #set the websocket protocol to be used by the client to connect to the rails server (ws on development, wss on production) + +test: + <<: *default + host: tcp://192.168.59.104:2376 + workspace_root: <%= File.join('/', 'shared', Rails.env) %> diff --git a/config/environments/staging.rb b/config/environments/staging.rb index 1cb098cb..5dfdae13 100644 --- a/config/environments/staging.rb +++ b/config/environments/staging.rb @@ -23,7 +23,7 @@ Rails.application.configure do config.serve_static_assets = false # Compress JavaScripts and CSS. - config.assets.js_compressor = :uglifier + # config.assets.js_compressor = :uglifier # config.assets.css_compressor = :sass # Do not fallback to assets pipeline if a precompiled asset is missed. diff --git a/config/locales/de.yml b/config/locales/de.yml index a57b2c04..0695eda6 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -36,6 +36,7 @@ de: public: Öffentlich title: Titel user: Autor + allow_auto_completion: "Autovervollständigung aktivieren" allow_file_creation: "Dateierstellung erlauben" external_user: consumer: Konsument @@ -188,6 +189,8 @@ de: worktime: Durchschnittliche Arbeitszeit exercises: editor: + collapse_action_sidebar: Aktions-Leiste Einklappen + collapse_output_sidebar: Ausgabe-Leiste Einklappen confirm_start_over: Wollen Sie wirklich von vorne anfangen? confirm_submit: Wollen Sie Ihren Code wirklich zur Bewertung abgeben? create_file: Neue Datei @@ -195,6 +198,8 @@ de: destroy_file: Datei löschen download: Herunterladen dummy: Keine Aktion + expand_action_sidebar: Aktions-Leiste Ausklappen + expand_output_sidebar: Ausgabe-Leiste Ausklappen input: Ihre Eingabe lastsaved: 'Zuletzt gespeichert: ' network: 'Während Ihr Code läuft, ist Port %{port} unter folgender Adresse erreichbar: %{address}.' @@ -203,6 +208,7 @@ de: run_failure: Ihr Code konnte nicht auf der Plattform ausgeführt werden. run_success: Ihr Code wurde auf der Plattform ausgeführt. requestComments: Kommentare erbitten + requestCommentsTooltip: Falls Sie Hilfe mit Ihrem Code benötigen, können Sie hier Kommentare erbitten save: Speichern score: Bewerten send: Senden @@ -274,6 +280,7 @@ de: submit: failure: Beim Übermitteln Ihrer Punktzahl ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut. full_score_redirect_to_rfc: Herzlichen Glückwunsch! Sie haben die maximale Punktzahl für diese Aufgabe an den Kurs übertragen. Ein anderer Teilnehmer hat eine Frage zu der von Ihnen gelösten Aufgabe. Er würde sich sicherlich sehr über ihre Hilfe und Kommentare freuen. + full_score_redirect_to_own_rfc: Herzlichen Glückwunsch! Sie haben die maximale Punktzahl für diese Aufgabe an den Kurs übertragen. Ihre Frage ist damit wahrscheinlich gelöst? Falls ja, fügen Sie doch den entscheidenden Kniff als Antwort hinzu und markieren die Frage als gelöst, bevor sie das Fenster schließen. external_users: statistics: no_data_available: Keine Daten verfügbar. diff --git a/config/locales/en.yml b/config/locales/en.yml index 691dfe72..6d30f612 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -36,6 +36,7 @@ en: public: Public title: Title user: Author + allow_auto_completion: "Allow auto completion" allow_file_creation: "Allow file creation" external_user: consumer: Consumer @@ -188,6 +189,8 @@ en: worktime: Average Working Time exercises: editor: + collapse_action_sidebar: Collapse Action Sidebar + collapse_output_sidebar: Collapse Output Sidebar confirm_start_over: Do you really want to start over? confirm_submit: Do you really want to submit your code for grading? create_file: New File @@ -195,6 +198,8 @@ en: destroy_file: Delete File download: Download dummy: No Action + expand_action_sidebar: Expand Action Sidebar + expand_output_sidebar: Expand Output Sidebar input: Your input lastsaved: 'Last saved: ' network: 'While your code is running, port %{port} is accessible using the following address: %{address}.' @@ -203,6 +208,7 @@ en: run_failure: Your code could not be run. run_success: Your code was run on our servers. requestComments: Request comments + requestCommentsTooltip: If you need help with your code, you can now request comments here! save: Save score: Score send: Send @@ -274,6 +280,7 @@ en: submit: failure: An error occured while transmitting your score. Please try again later. full_score_redirect_to_rfc: Congratulations! You achieved and submitted the highest possible score for this exercise. Another participant has a question concerning the exercise you just solved. Your help and comments will be greatly appreciated! + full_score_redirect_to_own_rfc: Congratulations! You achieved and submitted the highest possible score for this exercise. Your question concerning the exercise is solved? If so, please share the essential insight with your fellows and mark the question as solved, before you close this window! external_users: statistics: no_data_available: No data available. diff --git a/db/migrate/20160907123009_add_allow_auto_completion_to_exercises.rb b/db/migrate/20160907123009_add_allow_auto_completion_to_exercises.rb new file mode 100644 index 00000000..99941e36 --- /dev/null +++ b/db/migrate/20160907123009_add_allow_auto_completion_to_exercises.rb @@ -0,0 +1,5 @@ +class AddAllowAutoCompletionToExercises < ActiveRecord::Migration + def change + add_column :exercises, :allow_auto_completion, :boolean, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index b2c3b8a4..330f99d6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20160704143402) do +ActiveRecord::Schema.define(version: 20160907123009) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -74,7 +74,6 @@ ActiveRecord::Schema.define(version: 20160704143402) do t.integer "file_type_id" t.integer "memory_limit" t.boolean "network_enabled" - end create_table "exercises", force: true do |t| @@ -90,6 +89,7 @@ ActiveRecord::Schema.define(version: 20160704143402) do t.string "token" t.boolean "hide_file_tree" t.boolean "allow_file_creation" + t.boolean "allow_auto_completion", default: false end create_table "external_users", force: true do |t| diff --git a/lib/docker_client.rb b/lib/docker_client.rb index 9d2f30b6..4672360f 100644 --- a/lib/docker_client.rb +++ b/lib/docker_client.rb @@ -72,9 +72,10 @@ class DockerClient # Headers are required by Docker headers = {'Origin' => 'http://localhost'} + socket_url = DockerClient.config['ws_host'] + '/containers/' + @container.id + '/attach/ws?' + query_params + socket = Faye::WebSocket::Client.new(socket_url, [], :headers => headers) - # rspec error: undefined method `+' for nil:NilClass. problem with ws_host? - socket = Faye::WebSocket::Client.new(DockerClient.config['ws_host'] + '/containers/' + @container.id + '/attach/ws?' + query_params, [], :headers => headers) + Rails.logger.debug "Opening Websocket on URL " + socket_url socket.on :error do |event| Rails.logger.info "Websocket error: " + event.message @@ -263,12 +264,16 @@ class DockerClient end end - def exit_container(container) - Rails.logger.debug('exiting container ' + container.to_s) - # exit the timeout thread if it is still alive + def exit_thread_if_alive if(@thread && @thread.alive?) @thread.exit end + end + + def exit_container(container) + Rails.logger.debug('exiting container ' + container.to_s) + # exit the timeout thread if it is still alive + exit_thread_if_alive # if we use pooling and recylce the containers, put it back. otherwise, destroy it. (DockerContainerPool.config[:active] && RECYCLE_CONTAINERS) ? self.class.return_container(container, @execution_environment) : self.class.destroy_container(container) end @@ -288,6 +293,7 @@ class DockerClient container = self.class.create_container(@execution_environment) DockerContainerPool.add_to_all_containers(container, @execution_environment) end + exit_thread_if_alive end def execute_run_command(submission, filename, &block) @@ -297,13 +303,6 @@ class DockerClient command = submission.execution_environment.run_command % command_substitutions(filename) create_workspace_files = proc { create_workspace_files(container, submission) } open_websocket_connection(command, create_workspace_files, block) - - # to pass the test "it executes the run command" it needs to send a command, not sure if it should be implemented. - if container - container.status = :executing - send_command(command, container, &block) - end - # actual run command is run in the submissions controller, after all listeners are attached. end @@ -329,7 +328,6 @@ class DockerClient Docker::Image.all.map { |image| image.info['RepoTags'] }.flatten.reject { |tag| tag.include?('') } end -# When @image commented test doesn't work -> test set to pending def initialize(options = {}) @execution_environment = options[:execution_environment] # todo: eventually re-enable this if it is cached. But in the end, we do not need this.