diff --git a/Gemfile b/Gemfile index bf089d6c..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 @@ -47,6 +48,7 @@ group :development do gem 'capistrano-rails' gem 'capistrano-rvm' gem 'capistrano-upload-config' + gem 'rack-mini-profiler' gem 'rubocop', require: false gem 'rubocop-rspec' end diff --git a/Gemfile.lock b/Gemfile.lock index a2739805..e440fd0b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -195,6 +195,8 @@ GEM pundit (1.1.0) activesupport (>= 3.0.0) rack (1.5.5) + rack-mini-profiler (0.10.1) + rack (>= 1.2.0) rack-test (0.6.3) rack (>= 1.0) rails (4.1.14.1) @@ -324,6 +326,7 @@ GEM json (>= 1.8.0) unf (0.1.4) unf_ext + unf (0.1.4-java) unf_ext (0.0.7.1) unicode-display_width (0.3.1) web-console (2.3.0) @@ -383,6 +386,7 @@ DEPENDENCIES pry puma (~> 2.15.3) pundit + rack-mini-profiler rails (~> 4.1.13) rails-i18n (~> 4.0.0) rake @@ -393,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 c69551bb..a8297cfe 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; @@ -175,7 +179,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; }); }; @@ -404,12 +411,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 +470,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(); }; @@ -587,7 +606,7 @@ $(function() { } else if (output.stdout) { //if (output_mode_is_streaming){ element.addClass('text-success').append(output.stdout); - flowrOutputBuffer += output.stdout; + // flowrOutputBuffer += output.stdout; //}else{ // element.addClass('text-success'); // element.data('content_buffer' , element.data('content_buffer') + output.stdout); @@ -704,9 +723,15 @@ $(function() { }; var renderScore = function() { - var score = $('#score').data('score'); - var maxium_score = $('#score').data('maximum-score'); - $('.score').html((score || '?') + ' / ' + maxium_score); + 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); }; @@ -870,7 +895,9 @@ $(function() { } var showWorkspaceTab = function(event) { - event.preventDefault(); + if(event){ + event.preventDefault(); + } showTab(0); }; @@ -1030,6 +1057,7 @@ $(function() { case 'exit': killWebsocketAndContainer(); 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. @@ -1041,6 +1069,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); @@ -1154,25 +1217,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'); @@ -1201,6 +1264,7 @@ $(function() { if ($('#editor').isPresent()) { if (isBrowserSupported()) { + initializeRegexes(); initializeCodePilot(); $('.score, #development-environment').show(); configureEditors(); diff --git a/app/assets/javascripts/exercise_graphs.js b/app/assets/javascripts/exercise_graphs.js index 2d3588c6..5f521b39 100644 --- a/app/assets/javascripts/exercise_graphs.js +++ b/app/assets/javascripts/exercise_graphs.js @@ -9,6 +9,9 @@ $(function() { submissionsScoreAndTimeAssess = [[0,0]]; submissionsScoreAndTimeSubmits = [[0,0]]; + submissionsScoreAndTimeRuns = []; + submissionsSaves = []; + submissionsAutosaves = []; var maximumValue = 0; var wtimes = $('#wtimes').data('working_times'); //.hidden#wtimes data-working_times=ActiveSupport::JSON.encode(working_times_until) @@ -18,43 +21,42 @@ $(function() { for (var i = 0;i 0) { + submissionArray[1] = workingTime; + } + if(submission.score>maximumValue){ + maximumValue = submission.score; + } if(submission.cause == "assess"){ - var workingTime = get_minutes(wtimes[i]); - var submissionArray = [submission.score, 0]; - - if (workingTime > 0) { - submissionArray[1] = workingTime; - } - - if(submission.score>maximumValue){ - maximumValue = submission.score; - } submissionsScoreAndTimeAssess.push(submissionArray); } else if(submission.cause == "submit"){ - var workingTime = get_minutes(wtimes[i]); - var submissionArray = [submission.score, 0]; - - if (workingTime > 0) { - submissionArray[1] = workingTime; - } - - if(submission.score>maximumValue){ - maximumValue = submission.score; - } submissionsScoreAndTimeSubmits.push(submissionArray); + } else if(submission.cause == "run"){ + submissionsScoreAndTimeRuns.push(submissionArray[1]); + } else if(submission.cause == "autosave"){ + submissionsAutosaves.push(submissionArray[1]); + } else if(submission.cause == "save"){ + submissionsSaves.push(submissionArray[1]); } } // console.log(submissionsScoreAndTimeAssess.length); // console.log(submissionsScoreAndTimeSubmits); + // console.log(submissionsScoreAndTimeRuns); function get_minutes (time_stamp) { try { hours = time_stamp.split(":")[0]; minutes = time_stamp.split(":")[1]; seconds = time_stamp.split(":")[2]; - - minutes = parseFloat(hours * 60) + parseInt(minutes); + seconds /= 60; + minutes = parseFloat(hours * 60) + parseInt(minutes) + seconds; if (minutes > 0){ return minutes; } else{ @@ -82,6 +84,9 @@ $(function() { function graph_assesses() { // MAKE THE GRAPH var width_ratio = .8; + if (getWidth()*width_ratio > 1000){ + width_ratio = 1000/getWidth(); + } var height_ratio = .7; // percent of height var margin = {top: 100, right: 20, bottom: 70, left: 70},//30,50 @@ -131,15 +136,32 @@ $(function() { .append("g") .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); + var largestSubmittedTimeStamp = submissions[submissions_length-1]; + var largestArrayForRange; - x.domain(d3.extent(submissionsScoreAndTimeAssess, function (d) { - // console.log(d[1]); - return (d[1]); - })); - y.domain(d3.extent(submissionsScoreAndTimeAssess, function (d) { + if(largestSubmittedTimeStamp.cause == "assess"){ + largestArrayForRange = submissionsScoreAndTimeAssess; + x.domain([0,largestArrayForRange[largestArrayForRange.length - 1][1]]).clamp(true); + } else if(largestSubmittedTimeStamp.cause == "submit"){ + largestArrayForRange = submissionsScoreAndTimeSubmits; + x.domain([0,largestArrayForRange[largestArrayForRange.length - 1][1]]).clamp(true); + } else if(largestSubmittedTimeStamp.cause == "run"){ + largestArrayForRange = submissionsScoreAndTimeRuns; + x.domain([0,largestArrayForRange[largestArrayForRange.length - 1]]).clamp(true); + } else if(largestSubmittedTimeStamp.cause == "autosave"){ + largestArrayForRange = submissionsAutosaves; + x.domain([0,largestArrayForRange[largestArrayForRange.length - 1]]).clamp(true); + } else if(largestSubmittedTimeStamp.cause == "save"){ + largestArrayForRange = submissionsSaves; + x.domain([0,largestArrayForRange[largestArrayForRange.length - 1]]).clamp(true); + } + // take maximum value between assesses and submits + var yDomain = submissionsScoreAndTimeAssess.concat(submissionsScoreAndTimeSubmits); + y.domain(d3.extent(yDomain, function (d) { // console.log(d[0]); return (d[0]); })); + // y.domain([0,2]).clamp(true); svg.append("g") //x axis .attr("class", "x axis") @@ -177,15 +199,15 @@ $(function() { .style('font-size', 20) .style('text-decoration', 'underline'); - // - // svg.append("path") - // //.datum() - // .attr("class", "line") - // .attr('id', 'myPath')// new - // .attr("stroke", "black") - // .attr("stroke-width", 5) - // .attr("fill", "none")// end new - // .attr("d", line(submissionsScoreAndTimeAssess));//--- + + svg.append("path") + //.datum() + .attr("class", "line") + .attr('id', 'myPath')// new + .attr("stroke", "black") + .attr("stroke-width", 5) + .attr("fill", "none")// end new + .attr("d", line(submissionsScoreAndTimeAssess));//--- svg.append("path") .datum(submissionsScoreAndTimeAssess) @@ -204,27 +226,35 @@ $(function() { .attr("cx", function(d) { return x(d[1]); }) .attr("cy", function(d) { return y(d[0]); }); - - svg.append("path") - .datum(submissionsScoreAndTimeSubmits) - .attr("class", "line2") - .attr('id', 'myPath')// new - .attr("stroke", "blue") - .attr("stroke-width", 5) - .attr("fill", "none")// end new - .attr("d", line);//--- + if (submissionsScoreAndTimeSubmits.length > 0){ + // get rid of the 0 element at the beginning + submissionsScoreAndTimeSubmits.shift(); + } svg.selectAll("dot") // Add dots to submits .data(submissionsScoreAndTimeSubmits) .enter().append("circle") - .attr("r", 3.5) + .attr("r", 6) + .attr("stroke", "black") + .attr("fill", "blue") .attr("cx", function(d) { return x(d[1]); }) .attr("cy", function(d) { return y(d[0]); }); + for (var i = 0; i < submissionsScoreAndTimeRuns.length; i++) { + svg.append("line") + .attr("stroke", "red") + .attr("stroke-width", 1) + .attr("fill", "none")// end new + .attr("y1", y(0)) + .attr("y2", 0) + .attr("x1", x(submissionsScoreAndTimeRuns[i])) + .attr("x2", x(submissionsScoreAndTimeRuns[i])); + } var color_hash = { 0 : ["Submissions", "blue"], - 1 : ["Assesses", "orange"] - } + 1 : ["Assesses", "orange"], + 2 : ["Runs", "red"] + }; // add legend var legend = svg.append("g") @@ -234,7 +264,8 @@ $(function() { .attr("height", 100) .attr("width", 100); - var dataset = [submissionsScoreAndTimeSubmits,submissionsScoreAndTimeAssess]; + var dataset = [submissionsScoreAndTimeSubmits,submissionsScoreAndTimeAssess, submissionsScoreAndTimeRuns]; + var yOffset = -70; legend.selectAll('g').data(dataset) .enter() @@ -243,14 +274,14 @@ $(function() { var g = d3.select(this); g.append("rect") .attr("x", 20) - .attr("y", i*25 + 8) + .attr("y", i*25 + yOffset)// + 8 .attr("width", 10) .attr("height", 10) .style("fill", color_hash[String(i)][1]); g.append("text") .attr("x", 40) - .attr("y", i * 25 + 18) + .attr("y", i * 25 + yOffset + 10)// + 18 .attr("height",30) .attr("width",100) .style("fill", color_hash[String(i)][1]) @@ -273,7 +304,7 @@ $(function() { try{ graph_assesses(); } catch(err){ - // not enough data + alert("could not draw the graph"); } } diff --git a/app/assets/javascripts/exercises.js b/app/assets/javascripts/exercises.js index 0e3805db..81da8cf8 100644 --- a/app/assets/javascripts/exercises.js +++ b/app/assets/javascripts/exercises.js @@ -12,6 +12,9 @@ $(function() { $('#files li:last select[name*="file_type_id"]').val(getSelectedExecutionEnvironment().file_type_id); $('#files li:last select').chosen(window.CodeOcean.CHOSEN_OPTIONS); $('body, html').scrollTo('#add-file'); + // 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(); }; var ajaxError = function() { diff --git a/app/assets/javascripts/turtle.js b/app/assets/javascripts/turtle.js index f2fa184b..ddbb27eb 100644 --- a/app/assets/javascripts/turtle.js +++ b/app/assets/javascripts/turtle.js @@ -41,8 +41,7 @@ Turtle.prototype.update = function () { var i, k, canvas, ctx, dx, dy, item, c, length; canvas = this.canvas[0]; ctx = canvas.getContext('2d'); - ctx.fillStyle = '#fff'; - ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.clearRect(0, 0, canvas.width, canvas.height); length = this.items.length; dx = canvas.width / 2; dy = canvas.height / 2; @@ -56,8 +55,8 @@ Turtle.prototype.update = function () { for (k = 2; k < c.length; k += 2) { ctx.lineTo(c[k] + dx, c[k + 1] + dy); } - if (this.fill) { - ctx.strokeStyle = this.fill; + if (item.fill) { + ctx.strokeStyle = item.fill; } ctx.stroke(); diff --git a/app/assets/javascripts/working_time_graphs.js b/app/assets/javascripts/working_time_graphs.js index 315d2c09..181b3799 100644 --- a/app/assets/javascripts/working_time_graphs.js +++ b/app/assets/javascripts/working_time_graphs.js @@ -4,7 +4,7 @@ $(function() { if ($.isController('exercises') && $('.graph-functions').isPresent()) { var working_times = $('#data').data('working-time'); - + function get_minutes (time_stamp){ try{ hours = time_stamp.split(":")[0]; @@ -67,6 +67,9 @@ $(function() { // DRAW THE LINE GRAPH ------------------------------------------------------------------------------ function draw_line_graph() { var width_ratio = .8; + if (getWidth()*width_ratio > 1000){ + width_ratio = 1000/getWidth(); + } var height_ratio = .7; // percent of height // currently sets as percentage of window width, however, unfortunately diff --git a/app/assets/stylesheets/exercises.css.scss b/app/assets/stylesheets/exercises.css.scss index f348c025..eb1b5300 100644 --- a/app/assets/stylesheets/exercises.css.scss +++ b/app/assets/stylesheets/exercises.css.scss @@ -49,7 +49,10 @@ div#chart_2 { } - +a.file-heading { + color: black !important; + text-decoration: none; +} .bar { fill: orange; diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb index fd6840ff..fe7f454c 100644 --- a/app/controllers/comments_controller.rb +++ b/app/controllers/comments_controller.rb @@ -46,7 +46,11 @@ class CommentsController < ApplicationController # POST /comments # POST /comments.json def create - @comment = Comment.new(comment_params) + @comment = Comment.new(comment_params_without_request_id) + + if comment_params[:request_id] + UserMailer.got_new_comment(@comment, RequestForComment.find(comment_params[:request_id]), current_user) + end respond_to do |format| if @comment.save @@ -64,7 +68,7 @@ class CommentsController < ApplicationController # PATCH/PUT /comments/1.json def update respond_to do |format| - if @comment.update(comment_params) + if @comment.update(comment_params_without_request_id) format.html { head :no_content, notice: 'Comment was successfully updated.' } format.json { render :show, status: :ok, location: @comment } else @@ -101,10 +105,14 @@ class CommentsController < ApplicationController @comment = Comment.find(params[:id]) end + def comment_params_without_request_id + comment_params.except :request_id + end + # Never trust parameters from the scary internet, only allow the white list through. def comment_params #params.require(:comment).permit(:user_id, :file_id, :row, :column, :text) # fuer production mode, damit böse menschen keine falsche user_id uebergeben: - params.require(:comment).permit(:file_id, :row, :column, :text).merge(user_id: current_user.id, user_type: current_user.class.name) + params.require(:comment).permit(:file_id, :row, :column, :text, :request_id).merge(user_id: current_user.id, user_type: current_user.class.name) end 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/request_for_comments_controller.rb b/app/controllers/request_for_comments_controller.rb index 555dad09..37d8bef9 100644 --- a/app/controllers/request_for_comments_controller.rb +++ b/app/controllers/request_for_comments_controller.rb @@ -1,5 +1,5 @@ class RequestForCommentsController < ApplicationController - before_action :set_request_for_comment, only: [:show, :edit, :update, :destroy] + before_action :set_request_for_comment, only: [:show, :edit, :update, :destroy, :mark_as_solved] skip_after_action :verify_authorized @@ -20,6 +20,18 @@ class RequestForCommentsController < ApplicationController render 'index' end + def mark_as_solved + authorize! + @request_for_comment.solved = true + respond_to do |format| + if @request_for_comment.save + format.json { render :show, status: :ok, location: @request_for_comment } + else + format.json { render json: @request_for_comment.errors, status: :unprocessable_entity } + end + end + end + # GET /request_for_comments/1 # GET /request_for_comments/1.json def show @@ -70,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).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..a59c17cc 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) 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/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 4b3c71f4..e1773d48 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -11,4 +11,13 @@ class UserMailer < ActionMailer::Base @reset_password_url = reset_password_internal_user_url(user, token: user.reset_password_token) mail(subject: t('mailers.user_mailer.reset_password.subject'), to: user.email) end + + def got_new_comment(comment, request_for_comment, commenting_user) + # todo: check whether we can take the last known locale of the receiver? + @receiver_displayname = request_for_comment.user.displayname + @commenting_user_displayname = commenting_user.displayname + @comment_text = comment.text + @rfc_link = request_for_comment_url(request_for_comment) + mail(subject: t('mailers.user_mailer.got_new_comment.subject', commenting_user_displayname: @commenting_user_displayname), to: request_for_comment.user.email).deliver + end end diff --git a/app/models/code_ocean/file.rb b/app/models/code_ocean/file.rb index 58440f1b..22c6e877 100644 --- a/app/models/code_ocean/file.rb +++ b/app/models/code_ocean/file.rb @@ -35,6 +35,7 @@ module CodeOcean has_many :files has_many :testruns + has_many :comments alias_method :descendants, :files mount_uploader :native_file, FileUploader 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/external_user.rb b/app/models/external_user.rb index 8038d2dc..538f4997 100644 --- a/app/models/external_user.rb +++ b/app/models/external_user.rb @@ -7,7 +7,9 @@ class ExternalUser < ActiveRecord::Base def displayname result = name if(consumer.name == 'openHPI') - result = Xikolo::UserClient.get(external_id.to_s)[:display_name] + result = Rails.cache.fetch("#{cache_key}/displayname", expires_in: 12.hours) do + Xikolo::UserClient.get(external_id.to_s)[:display_name] + end end result end 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 aca99938..63d932fc 100644 --- a/app/models/request_for_comment.rb +++ b/app/models/request_for_comment.rb @@ -1,7 +1,8 @@ class RequestForComment < ActiveRecord::Base + include Creation + belongs_to :submission belongs_to :exercise belongs_to :file, class_name: 'CodeOcean::File' - belongs_to :user, polymorphic: true before_create :set_requested_timestamp @@ -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,12 +24,27 @@ 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 + def to_s "RFC-" + self.id.to_s end private def self.row_number_user_sql - select("id, user_id, exercise_id, file_id, question, requested_at, created_at, updated_at, user_type, row_number() OVER (PARTITION BY user_id ORDER BY created_at DESC) as row_number").to_sql + 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/request_for_comment_policy.rb b/app/policies/request_for_comment_policy.rb index cf252338..f592e3bd 100644 --- a/app/policies/request_for_comment_policy.rb +++ b/app/policies/request_for_comment_policy.rb @@ -1,5 +1,8 @@ class RequestForCommentPolicy < ApplicationPolicy - + def author? + @user == @record.author + end + private :author? def create? everyone @@ -13,6 +16,10 @@ class RequestForCommentPolicy < ApplicationPolicy define_method(action) { admin? } end + def mark_as_solved? + admin? || author? + end + def edit? admin? 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 0f859844..8aa289d3 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, FileTemplate, InternalUser, Submission, Team].sort_by { |model| model.model_name.human(count: 2) } + - models = [ExecutionEnvironment, Exercise, Consumer, CodeHarborLink, ExternalUser, FileType, FileTemplate, InternalUser, Submission].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/exercises/_editor.html.slim b/app/views/exercises/_editor.html.slim index 42b12e42..0ce5b73f 100644 --- a/app/views/exercises/_editor.html.slim +++ b/app/views/exercises/_editor.html.slim @@ -38,4 +38,4 @@ = 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') \ No newline at end of file += render('shared/modal', id: 'comment-modal', title: t('exercises.implement.comment.request'), template: 'exercises/_request_comment_dialogcontent') 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/_file_form.html.slim b/app/views/exercises/_file_form.html.slim index c737f068..7bb4cd27 100644 --- a/app/views/exercises/_file_form.html.slim +++ b/app/views/exercises/_file_form.html.slim @@ -1,10 +1,10 @@ - id = f.object.id li.panel.panel-default .panel-heading role="tab" id="heading" - div.clearfix role="button" - span = f.object.name - a.pull-right data-toggle="collapse" data-parent="#files" href="#collapse#{id}" collapse - .panel-collapse.collapse.in id="collapse#{id}" role="tabpanel" + a.file-heading data-toggle="collapse" data-parent="#files" href="#collapse#{id}" + div.clearfix role="button" + span = f.object.name + .panel-collapse.collapse-in id="collapse#{id}" role="tabpanel" .panel-body .clearfix = link_to(t('shared.destroy'), '#', class:'btn btn-warning btn-sm discard-file pull-right') .form-group 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/implement.html.slim b/app/views/exercises/implement.html.slim index 728492b6..0c5e109b 100644 --- a/app/views/exercises/implement.html.slim +++ b/app/views/exercises/implement.html.slim @@ -78,6 +78,9 @@ 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 #questions-holder data-url="#{qa_url}/qa/index/#{@exercise.id}/#{@user_id}" diff --git a/app/views/exercises/index.html.slim b/app/views/exercises/index.html.slim index 97847f1f..ae018e82 100644 --- a/app/views/exercises/index.html.slim +++ b/app/views/exercises/index.html.slim @@ -27,7 +27,7 @@ h1 = Exercise.model_name.human(count: 2) - @exercises.each do |exercise| tr data-id=exercise.id td = exercise.title - td = link_to_if(policy(exercise.author).show?, exercise.author, exercise.author) + td = link_to_if(exercise.author && policy(exercise.author).show?, exercise.author, exercise.author) td = link_to_if(exercise.execution_environment && policy(exercise.execution_environment).show?, exercise.execution_environment, exercise.execution_environment) td = exercise.files.teacher_defined_tests.count td = exercise.maximum_score diff --git a/app/views/exercises/show.html.slim b/app/views/exercises/show.html.slim index fc28272a..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?) @@ -23,13 +22,15 @@ h1 h2 = t('activerecord.attributes.exercise.files') ul.list-unstyled.panel-group#files - - @exercise.files.each do |file| + - @exercise.files.order('name').each do |file| li.panel.panel-default .panel-heading role="tab" id="heading" - div.clearfix role="button" - span.panel-title = file.name_with_extension - a.pull-right data-toggle="collapse" data-parent="#files" href="#collapse#{file.id}" collapse - .panel-collapse.collapse.in id="collapse#{file.id}" role="tabpanel" + a.file-heading data-toggle="collapse" data-parent="#files" href=".collapse#{file.id}" + div.clearfix role="button" + span = file.name_with_extension + // probably set an icon here that shows that the rows can be collapsed + //span.pull-right.collapse.in class="collapse#{file.id}" ☼ + .panel-collapse.collapse class="collapse#{file.id}" role="tabpanel" .panel-body - if policy(file).destroy? .clearfix = link_to(t('shared.destroy'), file, class:'btn btn-warning btn-sm pull-right', data: {confirm: t('shared.confirm_destroy')}, method: :delete) diff --git a/app/views/request_for_comments/index.html.slim b/app/views/request_for_comments/index.html.slim index 6d4e059c..b7ada0a2 100644 --- a/app/views/request_for_comments/index.html.slim +++ b/app/views/request_for_comments/index.html.slim @@ -4,19 +4,29 @@ h1 = RequestForComment.model_name.human(count: 2) table.table.sortable thead tr + th + i class="fa fa-lightbulb-o" aria-hidden="true" title = t('request_for_comments.solved') align="right" th = t('activerecord.attributes.request_for_comments.exercise') th = t('activerecord.attributes.request_for_comments.question') + th + i class="fa fa-comment" aria-hidden="true" title = t('request_for_comments.comments') align="center" th = t('activerecord.attributes.request_for_comments.username') th = t('activerecord.attributes.request_for_comments.requested_at') tbody - @request_for_comments.each do |request_for_comment| tr data-id=request_for_comment.id + - if request_for_comment.solved? + td + span class="fa fa-check" aria-hidden="true" + - else + td = '' td = link_to(request_for_comment.exercise.title, request_for_comment) - if request_for_comment.has_attribute?(:question) && request_for_comment.question td = truncate(request_for_comment.question, length: 200) - else 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 e4dc7f06..4089d878 100644 --- a/app/views/request_for_comments/show.html.erb +++ b/app/views/request_for_comments/show.html.erb @@ -1,46 +1,76 @@
-

<%= Exercise.find(@request_for_comment.exercise_id) %>

+

<%= link_to(@request_for_comment.exercise.title, [:implement, @request_for_comment.exercise]) %>

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

+
+ <%= t('activerecord.attributes.exercise.description') %>: "<%= render_markdown(@request_for_comment.exercise.description) %>" +
+
<% if @request_for_comment.question and not @request_for_comment.question == '' %> - <%= t('activerecord.attributes.request_for_comments.question')%>: "<%= @request_for_comment.question %>" + <%= t('activerecord.attributes.request_for_comments.question')%>: "<%= @request_for_comment.question %>" <% else %> - <%= t('request_for_comments.no_question') %> + <%= t('activerecord.attributes.request_for_comments.question')%>: <%= t('request_for_comments.no_question') %> <% end %>
+ <% if (policy(@request_for_comment).mark_as_solved? and not @request_for_comment.solved?) %> + + <% elsif (@request_for_comment.solved?) %> + + <% else %> + + <% end %>
<% submission.files.each do |file| %> <%= (file.path or "") + "/" + file.name + file.file_type.file_extension %> -
<%= file.content %> +
<%= file.content %>
<% end %> <%= render('shared/modal', id: 'comment-modal', title: t('exercises.implement.comment.dialogtitle'), template: 'exercises/_comment_dialogcontent') %>