diff --git a/Gemfile b/Gemfile index abdca69c..8cd13375 100644 --- a/Gemfile +++ b/Gemfile @@ -38,6 +38,7 @@ gem 'faye-websocket' gem 'nokogiri' gem 'd3-rails' gem 'rest-client' +gem 'rubyzip' group :development do gem 'better_errors', platform: :ruby diff --git a/Gemfile.lock b/Gemfile.lock index 9f115474..e440fd0b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -397,6 +397,7 @@ DEPENDENCIES rubocop rubocop-rspec rubytree + rubyzip sass-rails (~> 4.0.3) sdoc (~> 0.4.0) selenium-webdriver diff --git a/app/assets/javascripts/editor.js.erb b/app/assets/javascripts/editor.js.erb index 1e64195e..2d73bbf0 100644 --- a/app/assets/javascripts/editor.js.erb +++ b/app/assets/javascripts/editor.js.erb @@ -19,6 +19,10 @@ $(function() { var SERVER_SEND_EVENT = 2; var editors = []; + var editor_for_file = new Map(); + var regex_for_language = new Map(); + var tracepositions_regex; + var active_file = undefined; var active_frame = undefined; var running = false; @@ -37,6 +41,7 @@ $(function() { var ENTER_KEY_CODE = 13; var flowrOutputBuffer = ""; + var QaApiOutputBuffer = {'stdout': '', 'stderr': ''}; var flowrResultHtml = '
' var ajax = function(options) { @@ -175,7 +180,10 @@ $(function() { var downloadCode = function(event) { event.preventDefault(); createSubmission(this, null,function(response) { - var url = response.download_url.replace(FILENAME_URL_PLACEHOLDER, active_file.filename); + 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; }); }; @@ -184,8 +192,8 @@ $(function() { (streamed ? evaluateCodeWithStreamedResponse : evaluateCodeWithoutStreamedResponse)(url, callback); }; - var evaluateCodeWithStreamedResponse = function(url, callback) { - initWebsocketConnection(url); + var evaluateCodeWithStreamedResponse = function(url, onmessageFunction) { + initWebsocketConnection(url, onmessageFunction); // TODO only init turtle when required initTurtle(); @@ -306,9 +314,10 @@ $(function() { } }; - var handleScoringResponse = function(response) { - printScoringResults(response); - var score = _.reduce(response, function(sum, result) { + 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); @@ -316,6 +325,14 @@ $(function() { 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() { @@ -349,13 +366,14 @@ $(function() { flowrOutputBuffer = ''; }; - var handleTestResponse = function(response) { + var handleTestResponse = function(websocket_event) { + result = JSON.parse(websocket_event.data); clearOutput(); - printOutput(response[0], false, 0); + printOutput(result, false, 0); if (qa_api) { - qa_api.executeCommand('syncOutput', [response]); + qa_api.executeCommand('syncOutput', [result]); } - showStatus(response[0]); + showStatus(result); showTab(1); }; @@ -404,12 +422,18 @@ $(function() { 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'); /* @@ -457,6 +481,12 @@ $(function() { $('#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(); }; @@ -527,8 +557,8 @@ $(function() { }; var isBrowserSupported = function() { - // eventsource tests for server send events (used for scoring), websockets is used for run - return Modernizr.eventsource && Modernizr.websockets; + // websockets is used for run, score and test + return Modernizr.websockets; }; var populatePanel = function(panel, result, index) { @@ -574,20 +604,23 @@ $(function() { // output_mode_is_streaming = false; //} if (!colorize) { - if(output.stdout != ''){ + if(output.stdout != undefined && output.stdout != ''){ element.append(output.stdout) } - if(output.stderr != ''){ + 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); @@ -743,7 +776,7 @@ $(function() { showSpinner($('#run')); toggleButtonStates(); var url = response.run_url.replace(FILENAME_URL_PLACEHOLDER, active_file.filename); - evaluateCode(url, true, printChunk); + evaluateCode(url, true, function(evt) { parseCanvasMessage(evt.data, true); }); }); } }; @@ -778,7 +811,7 @@ $(function() { createSubmission(this, null, function(response) { showSpinner($('#assess')); var url = response.score_url; - evaluateCode(url, false, handleScoringResponse); + evaluateCode(url, true, handleScoringResponse); }); }; @@ -876,7 +909,9 @@ $(function() { } var showWorkspaceTab = function(event) { - event.preventDefault(); + if(event){ + event.preventDefault(); + } showTab(0); }; @@ -955,7 +990,7 @@ $(function() { createSubmission(this, null, function(response) { showSpinner($('#test')); var url = response.test_url.replace(FILENAME_URL_PLACEHOLDER, active_file.filename); - evaluateCode(url, false, handleTestResponse); + evaluateCode(url, true, handleTestResponse); }); } }; @@ -974,14 +1009,14 @@ $(function() { $('#test').toggle(isActiveFileTestable()); }; - var initWebsocketConnection = function(url) { + 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 = function(evt) { parseCanvasMessage(evt.data, true); }; + websocket.onmessage = onmessageFunction; websocket.onerror = function(evt) { showWebsocketError(); }; websocket.flush = function() { this.send('\n'); } }; @@ -1035,7 +1070,9 @@ $(function() { 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. @@ -1047,6 +1084,41 @@ $(function() { } }; + + 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); @@ -1160,25 +1232,25 @@ $(function() { var file_id = $('.editor').data('id') var question = $('#question').val(); - $.ajax({ - method: 'POST', - url: '/request_for_comments', - data: { - request_for_comment: { - exercise_id: exercise_id, - file_id: file_id, - question: question, - "requested_at(1i)": 2015, // these are the timestamp values that the request handler demands - "requested_at(2i)":3, // they could be random here, because the timestamp is updated on serverside anyway - "requested_at(3i)":27, - "requested_at(4i)":17, - "requested_at(5i)":06 + 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); + }).done(function() { + hideSpinner(); + $.flash.success({ text: $('#askForCommentsButton').data('message-success') }); + }).error(ajaxError); + } + + createSubmission($('.requestCommentsButton'), null, createRequestForComments); $('#comment-modal').modal('hide'); var button = $('.requestCommentsButton'); @@ -1207,6 +1279,7 @@ $(function() { if ($('#editor').isPresent()) { if (isBrowserSupported()) { + initializeRegexes(); initializeCodePilot(); $('.score, #development-environment').show(); configureEditors(); diff --git a/app/assets/javascripts/exercises.js b/app/assets/javascripts/exercises.js index e6773f40..81da8cf8 100644 --- a/app/assets/javascripts/exercises.js +++ b/app/assets/javascripts/exercises.js @@ -151,6 +151,22 @@ $(function() { }); }; + var updateFileTemplates = function(fileType) { + var jqxhr = $.ajax({ + url: '/file_templates/by_file_type/' + fileType + '.json', + dataType: 'json' + }); + jqxhr.done(function(response) { + var noTemplateLabel = $('#noTemplateLabel').data('text'); + var options = ""; + for (var i = 0; i < response.length; i++) { + options += "" + } + $("#code_ocean_file_file_template_id").find('option').remove().end().append($(options)); + }); + jqxhr.fail(ajaxError); + } + if ($.isController('exercises')) { if ($('table').isPresent()) { enableBatchUpdate(); @@ -165,6 +181,10 @@ $(function() { inferFileAttributes(); observeFileRoleChanges(); overrideTextareaTabBehavior(); + } else if ($('#files.jstree').isPresent()) { + var fileTypeSelect = $('#code_ocean_file_file_type_id'); + fileTypeSelect.on("change", function() {updateFileTemplates(fileTypeSelect.val())}); + updateFileTemplates(fileTypeSelect.val()); } toggleCodeHeight(); if (window.hljs) { diff --git a/app/assets/javascripts/file_templates.js.coffee b/app/assets/javascripts/file_templates.js.coffee new file mode 100644 index 00000000..24f83d18 --- /dev/null +++ b/app/assets/javascripts/file_templates.js.coffee @@ -0,0 +1,3 @@ +# Place all the behaviors and hooks related to the matching controller here. +# All this logic will automatically be available in application.js. +# You can use CoffeeScript in this file: http://coffeescript.org/ diff --git a/app/assets/stylesheets/file_templates.css.scss b/app/assets/stylesheets/file_templates.css.scss new file mode 100644 index 00000000..bf8e27e8 --- /dev/null +++ b/app/assets/stylesheets/file_templates.css.scss @@ -0,0 +1,3 @@ +// Place all the styles related to the FileTemplates controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ diff --git a/app/controllers/code_ocean/files_controller.rb b/app/controllers/code_ocean/files_controller.rb index 74c1932f..55d8b369 100644 --- a/app/controllers/code_ocean/files_controller.rb +++ b/app/controllers/code_ocean/files_controller.rb @@ -10,6 +10,11 @@ module CodeOcean def create @file = CodeOcean::File.new(file_params) + if @file.file_template_id + content = FileTemplate.find(@file.file_template_id).content + content.sub! '{{file_name}}', @file.name + @file.content = content + end authorize! create_and_respond(object: @file, path: proc { implement_exercise_path(@file.context.exercise, tab: 2) }) end diff --git a/app/controllers/concerns/file_parameters.rb b/app/controllers/concerns/file_parameters.rb index e61e719e..295b66c3 100644 --- a/app/controllers/concerns/file_parameters.rb +++ b/app/controllers/concerns/file_parameters.rb @@ -1,6 +1,6 @@ module FileParameters def file_attributes - %w(content context_id feedback_message file_id file_type_id hidden id name native_file path read_only role weight) + %w(content context_id feedback_message file_id file_type_id hidden id name native_file path read_only role weight file_template_id) end private :file_attributes end diff --git a/app/controllers/exercises_controller.rb b/app/controllers/exercises_controller.rb index 0304abb4..45fd04d9 100644 --- a/app/controllers/exercises_controller.rb +++ b/app/controllers/exercises_controller.rb @@ -9,7 +9,6 @@ class ExercisesController < ApplicationController before_action :set_exercise, only: MEMBER_ACTIONS + [:clone, :implement, :run, :statistics, :submit, :reload] before_action :set_external_user, only: [:statistics] before_action :set_file_types, only: [:create, :edit, :new, :update] - before_action :set_teams, only: [:create, :edit, :new, :update] skip_before_filter :verify_authenticity_token, only: [:import_proforma_xml] skip_after_action :verify_authorized, only: [:import_proforma_xml] @@ -119,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, :team_id, :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, :title, files_attributes: file_attributes).merge(user_id: current_user.id, user_type: current_user.class.name) end private :exercise_params @@ -195,11 +194,6 @@ class ExercisesController < ApplicationController end private :set_file_types - def set_teams - @teams = Team.all.order(:name) - end - private :set_teams - def show end diff --git a/app/controllers/file_templates_controller.rb b/app/controllers/file_templates_controller.rb new file mode 100644 index 00000000..a6039500 --- /dev/null +++ b/app/controllers/file_templates_controller.rb @@ -0,0 +1,94 @@ +class FileTemplatesController < ApplicationController + before_action :set_file_template, only: [:show, :edit, :update, :destroy] + + def authorize! + authorize(@file_template || @file_templates) + end + private :authorize! + + def by_file_type + @file_templates = FileTemplate.where(:file_type_id => params[:file_type_id]) + authorize! + respond_to do |format| + format.json { render :show, status: :ok, json: @file_templates.to_json } + end + end + + # GET /file_templates + # GET /file_templates.json + def index + @file_templates = FileTemplate.all.order(:file_type_id).paginate(page: params[:page]) + authorize! + end + + # GET /file_templates/1 + # GET /file_templates/1.json + def show + authorize! + end + + # GET /file_templates/new + def new + @file_template = FileTemplate.new + authorize! + end + + # GET /file_templates/1/edit + def edit + authorize! + end + + # POST /file_templates + # POST /file_templates.json + def create + @file_template = FileTemplate.new(file_template_params) + authorize! + + respond_to do |format| + if @file_template.save + format.html { redirect_to @file_template, notice: 'File template was successfully created.' } + format.json { render :show, status: :created, location: @file_template } + else + format.html { render :new } + format.json { render json: @file_template.errors, status: :unprocessable_entity } + end + end + end + + # PATCH/PUT /file_templates/1 + # PATCH/PUT /file_templates/1.json + def update + authorize! + respond_to do |format| + if @file_template.update(file_template_params) + format.html { redirect_to @file_template, notice: 'File template was successfully updated.' } + format.json { render :show, status: :ok, location: @file_template } + else + format.html { render :edit } + format.json { render json: @file_template.errors, status: :unprocessable_entity } + end + end + end + + # DELETE /file_templates/1 + # DELETE /file_templates/1.json + def destroy + authorize! + @file_template.destroy + respond_to do |format| + format.html { redirect_to file_templates_url, notice: 'File template was successfully destroyed.' } + format.json { head :no_content } + end + end + + private + # Use callbacks to share common setup or constraints between actions. + def set_file_template + @file_template = FileTemplate.find(params[:id]) + end + + # Never trust parameters from the scary internet, only allow the white list through. + def file_template_params + params[:file_template].permit(:name, :file_type_id, :content) + end +end diff --git a/app/controllers/request_for_comments_controller.rb b/app/controllers/request_for_comments_controller.rb index 72653ab2..37d8bef9 100644 --- a/app/controllers/request_for_comments_controller.rb +++ b/app/controllers/request_for_comments_controller.rb @@ -82,6 +82,6 @@ class RequestForCommentsController < ApplicationController # Never trust parameters from the scary internet, only allow the white list through. def request_for_comment_params - params.require(:request_for_comment).permit(:exercise_id, :file_id, :question, :requested_at, :solved).merge(user_id: current_user.id, user_type: current_user.class.name) + 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 4f9d6a5b..1b6f9421 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -6,9 +6,9 @@ class SubmissionsController < ApplicationController include SubmissionScoring include Tubesock::Hijack - before_action :set_submission, only: [:download_file, :render_file, :run, :score, :show, :statistics, :stop, :test] + before_action :set_submission, only: [:download, :download_file, :render_file, :run, :score, :show, :statistics, :stop, :test] before_action :set_docker_client, only: [:run, :test] - before_action :set_files, only: [:download_file, :render_file, :show] + before_action :set_files, only: [:download, :download_file, :render_file, :show] before_action :set_file, only: [:download_file, :render_file] before_action :set_mime_type, only: [:download_file, :render_file] skip_before_action :verify_authenticity_token, only: [:download_file, :render_file] @@ -53,6 +53,20 @@ class SubmissionsController < ApplicationController end end + def download + # files = @submission.files.map{ } + # zipline( files, 'submission.zip') + # send_data(@file.content, filename: @file.name_with_extension) + require 'zip' + stringio = Zip::OutputStream.write_buffer do |zio| + @files.each do |file| + zio.put_next_entry(file.name_with_extension) + zio.write(file.content) + end + end + send_data(stringio.string, filename: @submission.exercise.title.tr(" ", "_") + ".zip") + end + def download_file if @file.native_file? send_file(@file.native_file.path) @@ -214,7 +228,11 @@ class SubmissionsController < ApplicationController end def score - render(json: score_submission(@submission)) + 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)) + end end def set_docker_client @@ -266,8 +284,14 @@ class SubmissionsController < ApplicationController private :store_error def test - output = @docker_client.execute_test_command(@submission, params[:filename]) - render(json: [output]) + hijack do |tubesock| + Thread.new { EventMachine.run } unless EventMachine.reactor_running? && EventMachine.reactor_thread.alive? + + output = @docker_client.execute_test_command(@submission, params[:filename]) + + # tubesock is the socket to the client + tubesock.send_data JSON.dump(output) + end end def with_server_sent_events diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb deleted file mode 100644 index 1ca8f1fe..00000000 --- a/app/controllers/teams_controller.rb +++ /dev/null @@ -1,51 +0,0 @@ -class TeamsController < ApplicationController - include CommonBehavior - - before_action :set_team, only: MEMBER_ACTIONS - - def authorize! - authorize(@team || @teams) - end - private :authorize! - - def create - @team = Team.new(team_params) - authorize! - create_and_respond(object: @team) - end - - def destroy - destroy_and_respond(object: @team) - end - - def edit - end - - def index - @teams = Team.all.includes(:internal_users).order(:name).paginate(page: params[:page]) - authorize! - end - - def new - @team = Team.new - authorize! - end - - def set_team - @team = Team.find(params[:id]) - authorize! - end - private :set_team - - def show - end - - def team_params - params[:team].permit(:name, internal_user_ids: []) - end - private :team_params - - def update - update_and_respond(object: @team, params: team_params) - end -end diff --git a/app/models/exercise.rb b/app/models/exercise.rb index 4a1e0486..5b7ff9b2 100644 --- a/app/models/exercise.rb +++ b/app/models/exercise.rb @@ -11,7 +11,6 @@ class Exercise < ActiveRecord::Base belongs_to :execution_environment has_many :submissions - belongs_to :team has_many :external_users, source: :user, source_type: ExternalUser, through: :submissions has_many :internal_users, source: :user, source_type: InternalUser, through: :submissions diff --git a/app/models/file_template.rb b/app/models/file_template.rb new file mode 100644 index 00000000..ef068e13 --- /dev/null +++ b/app/models/file_template.rb @@ -0,0 +1,10 @@ +class FileTemplate < ActiveRecord::Base + + belongs_to :file_type + + + def to_s + name + end + +end diff --git a/app/models/file_type.rb b/app/models/file_type.rb index 53bf18dc..d3b519d5 100644 --- a/app/models/file_type.rb +++ b/app/models/file_type.rb @@ -12,6 +12,7 @@ class FileType < ActiveRecord::Base has_many :execution_environments has_many :files + has_many :file_templates validates :binary, boolean_presence: true validates :editor_mode, presence: true, unless: :binary? diff --git a/app/models/internal_user.rb b/app/models/internal_user.rb index e5cebde9..8f1bf04b 100644 --- a/app/models/internal_user.rb +++ b/app/models/internal_user.rb @@ -3,8 +3,6 @@ class InternalUser < ActiveRecord::Base authenticates_with_sorcery! - has_and_belongs_to_many :teams - validates :email, presence: true, uniqueness: true validates :password, confirmation: true, if: :password_void?, on: :update, presence: true validates :role, inclusion: {in: ROLES} diff --git a/app/models/request_for_comment.rb b/app/models/request_for_comment.rb index 57b9a079..63d932fc 100644 --- a/app/models/request_for_comment.rb +++ b/app/models/request_for_comment.rb @@ -1,5 +1,6 @@ class RequestForComment < ActiveRecord::Base include Creation + belongs_to :submission belongs_to :exercise belongs_to :file, class_name: 'CodeOcean::File' @@ -13,10 +14,8 @@ class RequestForComment < ActiveRecord::Base self.requested_at = Time.now end - def submission - Submission.find(file.context_id) - end - + # not used right now, finds the last submission for the respective user and exercise. + # might be helpful to check whether the exercise has been solved in the meantime. def last_submission Submission.find_by_sql(" select * from submissions where exercise_id = #{exercise_id} AND @@ -25,6 +24,17 @@ class RequestForComment < ActiveRecord::Base limit 1").first end + # not used any longer, since we directly saved the submission_id now. + # Was used before that to determine the submission belonging to the request_for_comment. + def last_submission_before_creation + Submission.find_by_sql(" select * from submissions + where exercise_id = #{exercise_id} AND + user_id = #{user_id} AND + '#{created_at.localtime}' > created_at + order by created_at desc + limit 1").first + end + def comments_count submission.files.map { |file| file.comments.size}.sum end @@ -35,6 +45,6 @@ class RequestForComment < ActiveRecord::Base private def self.row_number_user_sql - select("id, user_id, exercise_id, file_id, question, requested_at, created_at, updated_at, user_type, solved, row_number() OVER (PARTITION BY user_id ORDER BY created_at DESC) as row_number").to_sql + select("id, user_id, exercise_id, file_id, question, created_at, updated_at, user_type, solved, submission_id, row_number() OVER (PARTITION BY user_id ORDER BY created_at DESC) as row_number").to_sql end end diff --git a/app/models/submission.rb b/app/models/submission.rb index 323f1d58..5a95587f 100644 --- a/app/models/submission.rb +++ b/app/models/submission.rb @@ -2,7 +2,7 @@ class Submission < ActiveRecord::Base include Context include Creation - CAUSES = %w(assess download file render run save submit test autosave) + CAUSES = %w(assess download file render run save submit test autosave requestComments) FILENAME_URL_PLACEHOLDER = '{filename}' belongs_to :exercise @@ -28,13 +28,17 @@ class Submission < ActiveRecord::Base ancestors.merge(descendants).values end - [:download, :render, :run, :test].each do |action| + [:download_file, :render, :run, :test].each do |action| filename = FILENAME_URL_PLACEHOLDER.gsub(/\W/, '') define_method("#{action}_url") do Rails.application.routes.url_helpers.send(:"#{action}_submission_path", self, filename).sub(filename, FILENAME_URL_PLACEHOLDER) end end + def download_url + Rails.application.routes.url_helpers.send(:download_submission_path, self) + end + def main_file collect_files.detect(&:main_file?) end diff --git a/app/models/team.rb b/app/models/team.rb deleted file mode 100644 index a0dcb8d6..00000000 --- a/app/models/team.rb +++ /dev/null @@ -1,10 +0,0 @@ -class Team < ActiveRecord::Base - has_and_belongs_to_many :internal_users - alias_method :members, :internal_users - - validates :name, presence: true - - def to_s - name - end -end diff --git a/app/policies/exercise_policy.rb b/app/policies/exercise_policy.rb index 29a1c570..55f7d16b 100644 --- a/app/policies/exercise_policy.rb +++ b/app/policies/exercise_policy.rb @@ -13,24 +13,19 @@ class ExercisePolicy < AdminOrAuthorPolicy end [:clone?, :destroy?, :edit?, :statistics?, :update?].each do |action| - define_method(action) { admin? || author? || team_member? } + define_method(action) { admin? || author?} end [:implement?, :submit?, :reload?].each do |action| define_method(action) { everyone } end - def team_member? - @record.team.try(:members, []).include?(@user) if @record.team - end - private :team_member? - class Scope < Scope def resolve if @user.admin? @scope.all elsif @user.internal_user? - @scope.where('user_id = ? OR public = TRUE OR (team_id IS NOT NULL AND team_id IN (SELECT t.id FROM teams t JOIN internal_users_teams iut ON t.id = iut.team_id WHERE iut.internal_user_id = ?))', @user.id, @user.id) + @scope.where('user_id = ? OR public = TRUE', @user.id) else @scope.none end diff --git a/app/policies/file_template_policy.rb b/app/policies/file_template_policy.rb new file mode 100644 index 00000000..92ced442 --- /dev/null +++ b/app/policies/file_template_policy.rb @@ -0,0 +1,11 @@ +class FileTemplatePolicy < AdminOnlyPolicy + + def show? + everyone + end + + def by_file_type? + everyone + end + +end diff --git a/app/policies/submission_policy.rb b/app/policies/submission_policy.rb index 18d39f4c..861f5695 100644 --- a/app/policies/submission_policy.rb +++ b/app/policies/submission_policy.rb @@ -8,7 +8,7 @@ class SubmissionPolicy < ApplicationPolicy everyone end - [:download_file?, :render_file?, :run?, :score?, :show?, :statistics?, :stop?, :test?].each do |action| + [:download?, :download_file?, :render_file?, :run?, :score?, :show?, :statistics?, :stop?, :test?].each do |action| define_method(action) { admin? || author? } end diff --git a/app/policies/team_policy.rb b/app/policies/team_policy.rb deleted file mode 100644 index ff05c0c3..00000000 --- a/app/policies/team_policy.rb +++ /dev/null @@ -1,14 +0,0 @@ -class TeamPolicy < ApplicationPolicy - [:create?, :index?, :new?].each do |action| - define_method(action) { admin? } - end - - [:destroy?, :edit?, :show?, :update?].each do |action| - define_method(action) { admin? || member? } - end - - def member? - @record.members.include?(@user) - end - private :member? -end diff --git a/app/views/application/_navigation.html.slim b/app/views/application/_navigation.html.slim index bf9b5a84..b9663d3f 100644 --- a/app/views/application/_navigation.html.slim +++ b/app/views/application/_navigation.html.slim @@ -8,7 +8,7 @@ - if current_user.admin? li = link_to(t('breadcrumbs.dashboard.show'), admin_dashboard_path) li.divider - - models = [ExecutionEnvironment, Exercise, Consumer, CodeHarborLink, ExternalUser, FileType, InternalUser, Team].sort_by { |model| model.model_name.human(count: 2) } + - models = [ExecutionEnvironment, Exercise, Consumer, CodeHarborLink, ExternalUser, FileType, FileTemplate, InternalUser].sort_by { |model| model.model_name.human(count: 2) } - models.each do |model| - if policy(model).index? li = link_to(model.model_name.human(count: 2), send(:"#{model.model_name.collection}_path")) diff --git a/app/views/code_ocean/files/_form.html.slim b/app/views/code_ocean/files/_form.html.slim index 07dd3355..46c5b2c2 100644 --- a/app/views/code_ocean/files/_form.html.slim +++ b/app/views/code_ocean/files/_form.html.slim @@ -8,5 +8,9 @@ .form-group = f.label(:file_type_id, t('activerecord.attributes.file.file_type_id')) = f.collection_select(:file_type_id, FileType.where(binary: false).order(:name), :id, :name, {selected: @exercise.execution_environment.file_type.try(:id)}, class: 'form-control') + .form-group + = f.label(:file_template_id, t('activerecord.attributes.file.file_template_id')) + = f.collection_select(:file_template_id, FileTemplate.all.order(:name), :id, :name, {:include_blank => true}, class: 'form-control') = f.hidden_field(:context_id) + .hidden#noTemplateLabel data-text=t('file_template.no_template_label') .actions = render('shared/submit_button', f: f, object: CodeOcean::File.new) diff --git a/app/views/exercises/_editor_frame.html.slim b/app/views/exercises/_editor_frame.html.slim index eacc62a9..01640fa8 100644 --- a/app/views/exercises/_editor_frame.html.slim +++ b/app/views/exercises/_editor_frame.html.slim @@ -14,6 +14,6 @@ .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' - i.fa.fa-comment-o + button.btn.btn-primary.requestCommentsButton type='button' id="requestComments" + i.fa.fa-comment = t('exercises.editor.requestComments') \ No newline at end of file diff --git a/app/views/exercises/_form.html.slim b/app/views/exercises/_form.html.slim index f0f69f7a..968540af 100644 --- a/app/views/exercises/_form.html.slim +++ b/app/views/exercises/_form.html.slim @@ -17,9 +17,6 @@ = f.label(:instructions) = f.hidden_field(:instructions) .form-control.markdown - /.form-group - = f.label(:team_id) - = f.collection_select(:team_id, @teams, :id, :name, {include_blank: true}, class: 'form-control') .checkbox label = f.check_box(:public) diff --git a/app/views/exercises/external_users/statistics.html.slim b/app/views/exercises/external_users/statistics.html.slim index 56bd614b..3c755be6 100644 --- a/app/views/exercises/external_users/statistics.html.slim +++ b/app/views/exercises/external_users/statistics.html.slim @@ -50,9 +50,9 @@ h1 = "#{@exercise} (external user #{@external_user})" td -submission.testruns.each do |run| - if run.passed - .unit-test-result.positive-result + .unit-test-result.positive-result title=run.output - else - .unit-test-result.negative-result + .unit-test-result.negative-result title=run.output td = Time.at(deltas[1..index].inject(:+)).utc.strftime("%H:%M:%S") if index > 0 -working_times_until.push((Time.at(deltas[1..index].inject(:+)).utc.strftime("%H:%M:%S") if index > 0)) p = t('.addendum') diff --git a/app/views/exercises/show.html.slim b/app/views/exercises/show.html.slim index b4b72932..5c554da8 100644 --- a/app/views/exercises/show.html.slim +++ b/app/views/exercises/show.html.slim @@ -12,7 +12,6 @@ h1 = row(label: 'exercise.description', value: render_markdown(@exercise.description)) = row(label: 'exercise.execution_environment', value: link_to_if(policy(@exercise.execution_environment).show?, @exercise.execution_environment, @exercise.execution_environment)) /= row(label: 'exercise.instructions', value: render_markdown(@exercise.instructions)) -= row(label: 'exercise.team', value: @exercise.team ? link_to(@exercise.team, @exercise.team) : nil) = row(label: 'exercise.maximum_score', value: @exercise.maximum_score) = row(label: 'exercise.public', value: @exercise.public?) = row(label: 'exercise.hide_file_tree', value: @exercise.hide_file_tree?) diff --git a/app/views/file_templates/_form.html.slim b/app/views/file_templates/_form.html.slim new file mode 100644 index 00000000..1a5c34bc --- /dev/null +++ b/app/views/file_templates/_form.html.slim @@ -0,0 +1,12 @@ += form_for(@file_template) do |f| + = render('shared/form_errors', object: @file_template) + .form-group + = f.label(:name) + = f.text_field(:name, class: 'form-control', required: true) + .form-group + = f.label(:file_type_id) + = f.collection_select(:file_type_id, FileType.all.order(:name), :id, :name, {}, class: 'form-control') + .form-group + = f.label(:content) + = f.text_area(:content, class: 'form-control') + .actions = render('shared/submit_button', f: f, object: @file_template) diff --git a/app/views/file_templates/edit.html.slim b/app/views/file_templates/edit.html.slim new file mode 100644 index 00000000..c198271f --- /dev/null +++ b/app/views/file_templates/edit.html.slim @@ -0,0 +1,3 @@ +h1 = @file_template + += render('form') diff --git a/app/views/file_templates/index.html.slim b/app/views/file_templates/index.html.slim new file mode 100644 index 00000000..3022ea53 --- /dev/null +++ b/app/views/file_templates/index.html.slim @@ -0,0 +1,20 @@ +h1 = FileTemplate.model_name.human(count: 2) + +.table-responsive + table.table + thead + tr + th = t('activerecord.attributes.file_template.name') + th = t('activerecord.attributes.file_template.file_type') + th colspan=3 = t('shared.actions') + tbody + - @file_templates.each do |file_template| + tr + td = file_template.name + td = link_to(file_template.file_type, file_type_path(file_template.file_type)) + td = link_to(t('shared.show'), file_template) + td = link_to(t('shared.edit'), edit_file_template_path(file_template)) + td = link_to(t('shared.destroy'), file_template, data: {confirm: t('shared.confirm_destroy')}, method: :delete) + += render('shared/pagination', collection: @file_templates) +p = render('shared/new_button', model: FileTemplate) diff --git a/app/views/file_templates/new.html.slim b/app/views/file_templates/new.html.slim new file mode 100644 index 00000000..bf434860 --- /dev/null +++ b/app/views/file_templates/new.html.slim @@ -0,0 +1,3 @@ +h1 = t('shared.new_model', model: FileTemplate.model_name.human) + += render('form') diff --git a/app/views/file_templates/show.html.slim b/app/views/file_templates/show.html.slim new file mode 100644 index 00000000..19f0d28f --- /dev/null +++ b/app/views/file_templates/show.html.slim @@ -0,0 +1,7 @@ +h1 + = @file_template + = render('shared/edit_button', object: @file_template) + += row(label: 'file_template.name', value: @file_template.name) += row(label: 'file_template.file_type', value: link_to(@file_template.file_type, file_type_path(@file_template.file_type))) += row(label: 'file_template.content', value: @file_template.content) diff --git a/app/views/request_for_comments/index.html.slim b/app/views/request_for_comments/index.html.slim index 3bdbe6d0..b7ada0a2 100644 --- a/app/views/request_for_comments/index.html.slim +++ b/app/views/request_for_comments/index.html.slim @@ -27,6 +27,6 @@ h1 = RequestForComment.model_name.human(count: 2) td = '-' td = request_for_comment.comments_count td = request_for_comment.user.displayname - td = t('shared.time.before', time: distance_of_time_in_words_to_now(request_for_comment.requested_at)) + td = t('shared.time.before', time: distance_of_time_in_words_to_now(request_for_comment.created_at)) = render('shared/pagination', collection: @request_for_comments) \ No newline at end of file diff --git a/app/views/request_for_comments/show.html.erb b/app/views/request_for_comments/show.html.erb index 6ef176bd..4089d878 100644 --- a/app/views/request_for_comments/show.html.erb +++ b/app/views/request_for_comments/show.html.erb @@ -1,21 +1,14 @@<% user = @request_for_comment.user - submission_id = ActiveRecord::Base.connection.execute("select id from submissions - where exercise_id = - #{@request_for_comment.exercise_id} AND - user_id = #{@request_for_comment.user_id} AND - '#{@request_for_comment.created_at}' > created_at - order by created_at desc - limit 1").first['id'].to_i - submission = Submission.find(submission_id) + submission = @request_for_comment.submission %> - <%= user.displayname %> | <%= @request_for_comment.requested_at %> + <%= user.displayname %> | <%= @request_for_comment.created_at.localtime %>