diff --git a/Gemfile b/Gemfile index 08f5461e..29c2ab7d 100644 --- a/Gemfile +++ b/Gemfile @@ -25,10 +25,10 @@ gem 'rails', '4.2.10' gem 'rails-i18n' gem 'ransack' gem 'rubytree' -gem 'sass-rails' +gem 'sass-rails', '>= 5.0.7' gem 'sdoc', group: :doc gem 'slim-rails' -gem 'bootstrap_pagedown' +gem 'bootstrap_pagedown', '>= 1.1.0' gem 'pagedown-rails' gem 'sorcery' gem 'thread_safe' @@ -66,7 +66,7 @@ end group :test do gem 'autotest-rails' gem 'capybara' - gem 'capybara-selenium' + gem 'capybara-selenium', '>= 0.0.6' gem 'headless' gem 'codeclimate-test-reporter', require: false gem 'database_cleaner' diff --git a/Gemfile.lock b/Gemfile.lock index d167b88c..262c7687 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -78,13 +78,13 @@ GEM capistrano (~> 3.7) capistrano-bundler puma (~> 3.4) - capybara (2.18.0) + capybara (3.3.1) addressable mini_mime (>= 0.1.3) - nokogiri (>= 1.3.3) - rack (>= 1.0.0) - rack-test (>= 0.5.4) - xpath (>= 2.0, < 4.0) + nokogiri (~> 1.8) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + xpath (~> 3.1) capybara-selenium (0.0.6) capybara selenium-webdriver @@ -92,7 +92,7 @@ GEM activemodel (>= 4.0.0) activesupport (>= 4.0.0) mime-types (>= 1.16) - childprocess (0.8.0) + childprocess (0.9.0) ffi (~> 1.0, >= 1.0.11) chronic (0.10.2) codeclimate-test-reporter (1.0.7) @@ -108,7 +108,7 @@ GEM concurrent-ruby (1.0.5) concurrent-ruby-ext (1.0.5) concurrent-ruby (= 1.0.5) - crass (1.0.3) + crass (1.0.4) d3-rails (4.13.0) railties (>= 3.1) database_cleaner (1.6.2) @@ -135,7 +135,7 @@ GEM faye-websocket (0.10.7) eventmachine (>= 0.12.0) websocket-driver (>= 0.5.1) - ffi (1.9.23) + ffi (1.9.25) forgery (0.7.0) globalid (0.4.1) activesupport (>= 4.2.0) @@ -161,7 +161,7 @@ GEM json (2.1.0) jwt (1.5.6) kramdown (1.16.2) - loofah (2.2.0) + loofah (2.2.2) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.0) @@ -181,7 +181,7 @@ GEM net-ssh (4.2.0) netrc (0.11.0) newrelic_rpm (4.8.0.341) - nokogiri (1.8.2) + nokogiri (1.8.3) mini_portile2 (~> 2.3.0) nyan-cat-formatter (0.12.0) rspec (>= 2.99, >= 2.14.2, < 4) @@ -211,7 +211,7 @@ GEM puma (3.11.3) pundit (1.1.0) activesupport (>= 3.0.0) - rack (1.6.9) + rack (1.6.10) rack-mini-profiler (0.10.7) rack (>= 1.2.0) rack-test (0.6.3) @@ -233,8 +233,8 @@ GEM activesupport (>= 4.2.0, < 5.0) nokogiri (~> 1.6) rails-deprecated_sanitizer (>= 1.0.1) - rails-html-sanitizer (1.0.3) - loofah (~> 2.0) + rails-html-sanitizer (1.0.4) + loofah (~> 2.2, >= 2.2.2) rails-i18n (4.0.9) i18n (~> 0.7) railties (~> 4.0) @@ -244,7 +244,7 @@ GEM rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) rainbow (3.0.0) - rake (12.3.0) + rake (12.3.1) ransack (1.8.7) actionpack (>= 3.0) activerecord (>= 3.0) @@ -296,7 +296,7 @@ GEM json (~> 2.1) structured_warnings (~> 0.3) rubyzip (1.2.1) - sass (3.5.5) + sass (3.5.6) sass-listen (~> 4.0.0) sass-listen (4.0.0) rb-fsevent (~> 0.9, >= 0.9.4) @@ -309,7 +309,7 @@ GEM tilt (>= 1.1, < 3) sdoc (1.0.0) rdoc (>= 5.0) - selenium-webdriver (3.10.0) + selenium-webdriver (3.13.0) childprocess (~> 0.5) rubyzip (~> 1.2) simplecov (0.15.1) @@ -330,7 +330,7 @@ GEM oauth2 (~> 1.0, >= 0.8.0) spring (2.0.2) activesupport (>= 4.2) - sprockets (3.7.1) + sprockets (3.7.2) concurrent-ruby (~> 1.0) rack (> 1, < 3) sprockets-rails (3.2.1) @@ -369,7 +369,7 @@ GEM whenever (0.10.0) chronic (>= 0.6.3) will_paginate (3.1.6) - xpath (3.0.0) + xpath (3.1.0) nokogiri (~> 1.8) PLATFORMS @@ -383,7 +383,7 @@ DEPENDENCIES better_errors binding_of_caller bootstrap-will_paginate - bootstrap_pagedown + bootstrap_pagedown (>= 1.1.0) byebug capistrano capistrano-rails @@ -391,7 +391,7 @@ DEPENDENCIES capistrano-upload-config capistrano3-puma capybara - capybara-selenium + capybara-selenium (>= 0.0.6) carrierwave codeclimate-test-reporter concurrent-ruby @@ -430,7 +430,7 @@ DEPENDENCIES rubocop-rspec rubytree rubyzip - sass-rails + sass-rails (>= 5.0.7) sdoc simplecov slim-rails diff --git a/app/assets/javascripts/editor/evaluation.js.erb b/app/assets/javascripts/editor/evaluation.js.erb index 3aa4e25d..19f57ce3 100644 --- a/app/assets/javascripts/editor/evaluation.js.erb +++ b/app/assets/javascripts/editor/evaluation.js.erb @@ -153,18 +153,22 @@ CodeOceanEditorEvaluation = { if (!colorize) { if (output.stdout != undefined && output.stdout != '') { element.append(output.stdout) + //element.text(element.text() + output.stdout) } if (output.stderr != undefined && output.stderr != '') { - element.append('StdErr: ' + output.stderr); + element.append('StdErr: ' + output.stderr); + //element.text('StdErr: ' + element.text() + output.stderr); } } else if (output.stderr) { - element.addClass('text-warning').append(output.stderr); + element.addClass('text-warning').append(output.stderr); + //element.addClass('text-warning').text(element.text() + output.stderr); this.flowrOutputBuffer += output.stderr; this.QaApiOutputBuffer.stderr += output.stderr; } else if (output.stdout) { - element.addClass('text-success').append(output.stdout); + element.addClass('text-success').append(output.stdout); + //element.addClass('text-success').text(element.text() + output.stdout); this.flowrOutputBuffer += output.stdout; this.QaApiOutputBuffer.stdout += output.stdout; } else { diff --git a/app/assets/javascripts/editor/participantsupport.js.erb b/app/assets/javascripts/editor/participantsupport.js.erb index 63787f4a..a826cbf0 100644 --- a/app/assets/javascripts/editor/participantsupport.js.erb +++ b/app/assets/javascripts/editor/participantsupport.js.erb @@ -89,7 +89,8 @@ CodeOceanEditorRequestForComments = { this.createSubmission($('#requestComments'), null, createRequestForComments.bind(this)); $('#comment-modal').modal('hide'); - var button = $('#requestComments'); - button.prop('disabled', true); + // we disabled the button to prevent that the user spams RFCs, but decided against this now. + //var button = $('#requestComments'); + //button.prop('disabled', true); }, }; \ No newline at end of file diff --git a/app/assets/javascripts/exercise_collections.js.erb b/app/assets/javascripts/exercise_collections.js.erb index 3530b636..8ac76c1a 100644 --- a/app/assets/javascripts/exercise_collections.js.erb +++ b/app/assets/javascripts/exercise_collections.js.erb @@ -1,102 +1,207 @@ $(function() { if ($.isController('exercise_collections')) { - var data = $('#data').data('working-times'); - var averageWorkingTimeValue = parseFloat($('#data').data('average-working-time')); + var dataElement = $('#data'); + var exerciseList = $('#exercise-list'); - var margin = { top: 30, right: 40, bottom: 30, left: 50 }, - width = 720 - margin.left - margin.right, - height = 500 - margin.top - margin.bottom; + if (dataElement.isPresent()) { + var data = dataElement.data('working-times'); + var averageWorkingTimeValue = parseFloat(dataElement.data('average-working-time')); - var x = d3.scaleBand().range([0, width]); - var y = d3.scaleLinear().range([height, 0]); + var margin = {top: 30, right: 40, bottom: 30, left: 50}, + width = 720 - margin.left - margin.right, + height = 500 - margin.top - margin.bottom; - var xAxis = d3.axisBottom(x); - var yAxisLeft = d3.axisLeft(y); + var x = d3.scaleBand().range([0, width]); + var y = d3.scaleLinear().range([height, 0]); - var tooltip = d3.select("#graph").append("div").attr("class", "exercise-id-tooltip"); + var xAxis = d3.axisBottom(x); + var yAxisLeft = d3.axisLeft(y); - var averageWorkingTime = d3.line() - .x(function (d) { return x(d.index) + x.bandwidth()/2; }) - .y(function () { return y(averageWorkingTimeValue); }); + var tooltip = d3.select("#graph").append("div").attr("class", "exercise-id-tooltip"); - var minWorkingTime = d3.line() - .x(function (d) { return x(d.index) + x.bandwidth()/2; }) - .y(function () { return y(0.1*averageWorkingTimeValue); }); + var averageWorkingTime = d3.line() + .x(function (d) { + return x(d.index) + x.bandwidth() / 2; + }) + .y(function () { + return y(averageWorkingTimeValue); + }); - var maxWorkingTime = d3.line() - .x(function (d) { return x(d.index) + x.bandwidth()/2; }) - .y(function () { return y(2*averageWorkingTimeValue); }); + var minWorkingTime = d3.line() + .x(function (d) { + return x(d.index) + x.bandwidth() / 2; + }) + .y(function () { + return y(0.1 * averageWorkingTimeValue); + }); - var svg = d3.select('#graph') - .append("svg") - .attr("width", width + margin.left + margin.right) - .attr("height", height + margin.top + margin.bottom) - .append("g") - .attr("transform", - "translate(" + margin.left + "," + margin.top + ")"); + var maxWorkingTime = d3.line() + .x(function (d) { + return x(d.index) + x.bandwidth() / 2; + }) + .y(function () { + return y(2 * averageWorkingTimeValue); + }); - // Get the data - data = Object.keys(data).map(function (key, index) { - return { - index: index, - exercise_id: parseInt(key), - working_time: parseFloat(data[key]) - }; - }); + var svg = d3.select('#graph') + .append("svg") + .attr("width", width + margin.left + margin.right) + .attr("height", height + margin.top + margin.bottom) + .append("g") + .attr("transform", + "translate(" + margin.left + "," + margin.top + ")"); - // Scale the range of the data - x.domain(data.map(function (d) { return d.index; })); - y.domain([0, d3.max(data, function (d) { return d.working_time; })]); + // Get the data + data = Object.keys(data).map(function (key) { + return { + index: parseInt(key), + exercise_id: parseInt(data[key]['exercise_id']), + exercise_title: data[key]['exercise_title'], + working_time: parseFloat(data[key]['working_time']) + }; + }); - // Add the X Axis - svg.append("g") - .attr("class", "x axis") - .attr("transform", "translate(0," + height + ")") - .call(xAxis); + // Scale the range of the data + x.domain(data.map(function (d) { + return d.index; + })); + y.domain([0, d3.max(data, function (d) { + return d.working_time; + })]); - // Add the Y Axis - svg.append("g") - .attr("class", "y axis") - .style("fill", "steelblue") - .call(yAxisLeft); + // Add the X Axis + svg.append("g") + .attr("class", "x axis") + .attr("transform", "translate(0," + height + ")") + .call(xAxis); - // Draw the bars - svg.selectAll("bar") - .data(data) - .enter() - .append("rect") - .attr("class", "value-bar") - .on("mousemove", function (d){ - tooltip - .style("left", d3.event.pageX - 50 + "px") - .style("top", d3.event.pageY + 50 + "px") - .style("display", "inline-block") - .html("<%= I18n.t('activerecord.models.exercise.one') %> ID: " + d.exercise_id + "
" + - "<%= I18n.t('exercises.statistics.average_worktime') %>: " + d.working_time + "s"); - }) - .on("mouseout", function (){ tooltip.style("display", "none");}) - .on("click", function (d) { - window.location.href = "/exercises/" + d.exercise_id + "/statistics"; - }) - .attr("x", function (d) { return x(d.index); }) - .attr("width", x.bandwidth()) - .attr("y", function (d) { return y(d.working_time); }) - .attr("height", function (d) { return height - y(d.working_time); }); + // Add the Y Axis + svg.append("g") + .attr("class", "y axis") + .style("fill", "steelblue") + .call(yAxisLeft); - // Add the average working time path - svg.append("path") - .datum(data) - .attr("class", "line average-working-time") - .attr("d", averageWorkingTime); + // Draw the bars + svg.selectAll("bar") + .data(data) + .enter() + .append("rect") + .attr("class", "value-bar") + .on("mousemove", function (d) { + tooltip + .style("left", d3.event.pageX - 50 + "px") + .style("top", d3.event.pageY + 50 + "px") + .style("display", "inline-block") + .html("<%= I18n.t('activerecord.models.exercise.one') %> ID: " + d.exercise_id + "
" + + "<%= I18n.t('activerecord.attributes.exercise.title') %>: " + d.exercise_title + "
" + + "<%= I18n.t('exercises.statistics.average_worktime') %>: " + d.working_time + "s"); + }) + .on("mouseout", function () { + tooltip.style("display", "none"); + }) + .on("click", function (d) { + window.location.href = "/exercises/" + d.exercise_id + "/statistics"; + }) + .attr("x", function (d) { + return x(d.index); + }) + .attr("width", x.bandwidth()) + .attr("y", function (d) { + return y(d.working_time); + }) + .attr("height", function (d) { + return height - y(d.working_time); + }); - // Add the anomaly paths (min/max average exercise working time) - svg.append("path") - .datum(data) - .attr("class", "line minimum-working-time") - .attr("d", minWorkingTime); - svg.append("path") - .datum(data) - .attr("class", "line maximum-working-time") - .attr("d", maxWorkingTime); + // Add the average working time path + svg.append("path") + .datum(data) + .attr("class", "line average-working-time") + .attr("d", averageWorkingTime); + + // Add the anomaly paths (min/max average exercise working time) + svg.append("path") + .datum(data) + .attr("class", "line minimum-working-time") + .attr("d", minWorkingTime); + svg.append("path") + .datum(data) + .attr("class", "line maximum-working-time") + .attr("d", maxWorkingTime); + } else if (exerciseList.isPresent()) { + var exerciseSelect = $('#exercise-select'); + var list = $("#sortable"); + + var updateExerciseList = function () { + // remove all options from the hidden select and add all selected exercises in the new order + exerciseSelect.find('option').remove(); + var exerciseIdsInSortedOrder = list.sortable('toArray', {attribute: 'data-id'}); + for (var i = 0; i < exerciseIdsInSortedOrder.length; i += 1) { + exerciseSelect.append('') + } + } + + list.sortable({ + items: 'tr', + update: updateExerciseList + }); + list.disableSelection(); + + var addExercisesForm = $('#exercise-selection'); + var addExercisesButton = $('#add-exercises'); + var removeExerciseButtons = $('.remove-exercise'); + var sortButton = $('#sort-button'); + + var collectContainedExercises = function () { + return exerciseList.find('tbody > tr').toArray().map(function (item) {return $(item).data('id')}); + } + + var sortExercises = function() { + var listitems = $('tr', list); + listitems.sort(function (a, b) { + return ($(a).find('td:nth-child(2)').text().toUpperCase() > $(b).find('td:nth-child(2)').text().toUpperCase()) ? 1 : -1; + }); + list.append(listitems); + list.sortable('refresh'); + updateExerciseList(); + } + + var addExercise = function (id, title) { + var exercise = {id: id, title: title} + var collectionExercises = collectContainedExercises(); + if (collectionExercises.indexOf(exercise.id) === -1) { + // only add exercises that are not already contained in the collection + var template = '' + + '' + + '' + exercise.title + '' + + '<%= I18n.t('shared.show') %>' + + '<%= I18n.t('shared.destroy') %>'; + exerciseList.find('tbody').append(template); + $('#exercise-list').find('option[value="' + exercise.id + '"]').prop('selected', true); + } + } + + addExercisesButton.on('click', function (e) { + e.preventDefault(); + var selectedExercises = addExercisesForm.find('select')[0].selectedOptions; + for (var i = 0; i < selectedExercises.length; i++) { + addExercise(selectedExercises[i].value, selectedExercises[i].label); + } + }); + + removeExerciseButtons.on('click', function (e) { + e.preventDefault(); + + var row = $(this).parent().parent(); + var exerciseId = row.data('id'); + $('#exercise-list').find('option[value="' + exerciseId + '"]').prop('selected', false); + row.remove() + }); + + sortButton.on('click', function (e) { + e.preventDefault(); + sortExercises(); + }); + } } }); diff --git a/app/assets/stylesheets/exercise_collections.scss b/app/assets/stylesheets/exercise_collections.scss index 11b6b3a1..79141988 100644 --- a/app/assets/stylesheets/exercise_collections.scss +++ b/app/assets/stylesheets/exercise_collections.scss @@ -57,6 +57,10 @@ rect.value-bar { } } +.table-responsive#exercise-list { + max-height: 512px; +} + .exercise-id-tooltip { position: absolute; display: none; @@ -67,3 +71,19 @@ rect.value-bar { padding: 14px; text-align: center; } + +#exercise-list { + margin-bottom: 20px; +} + +#add-exercise-list { + min-height: 450px; +} + +.exercise-actions { + margin-bottom: 20px; + + button { + margin-right: 10px; + } +} diff --git a/app/controllers/exercise_collections_controller.rb b/app/controllers/exercise_collections_controller.rb index de425dcd..1d7c1782 100644 --- a/app/controllers/exercise_collections_controller.rb +++ b/app/controllers/exercise_collections_controller.rb @@ -9,7 +9,6 @@ class ExerciseCollectionsController < ApplicationController end def show - @exercises = @exercise_collection.exercises.paginate(:page => params[:page]) end def new @@ -50,6 +49,8 @@ class ExerciseCollectionsController < ApplicationController end def exercise_collection_params - params[:exercise_collection].permit(:name, :use_anomaly_detection, :user_id, :user_type, :exercise_ids => []).merge(user_type: InternalUser.name) + sanitized_params = params[:exercise_collection].permit(:name, :use_anomaly_detection, :user_id, :user_type, :exercise_ids => []).merge(user_type: InternalUser.name) + sanitized_params[:exercise_ids] = sanitized_params[:exercise_ids].reject {|v| v.nil? or v == ''} + sanitized_params.tap {|p| p[:exercise_collection_items] = p[:exercise_ids].map.with_index {|_id, index| ExerciseCollectionItem.find_or_create_by(exercise_id: _id, exercise_collection_id: @exercise_collection.id, position: index)}; p.delete(:exercise_ids)} end end diff --git a/app/models/exercise.rb b/app/models/exercise.rb index 3ff49ad8..b2b27af4 100644 --- a/app/models/exercise.rb +++ b/app/models/exercise.rb @@ -14,7 +14,8 @@ class Exercise < ActiveRecord::Base has_and_belongs_to_many :proxy_exercises has_many :user_proxy_exercise_exercises - has_and_belongs_to_many :exercise_collections + has_many :exercise_collection_items + has_many :exercise_collections, through: :exercise_collection_items has_many :user_exercise_interventions has_many :interventions, through: :user_exercise_interventions has_many :exercise_tags diff --git a/app/models/exercise_collection.rb b/app/models/exercise_collection.rb index 5c159dda..a4a19f94 100644 --- a/app/models/exercise_collection.rb +++ b/app/models/exercise_collection.rb @@ -1,23 +1,26 @@ class ExerciseCollection < ActiveRecord::Base include TimeHelper - has_and_belongs_to_many :exercises + has_many :exercise_collection_items + alias_method :items, :exercise_collection_items + has_many :exercises, through: :exercise_collection_items belongs_to :user, polymorphic: true - def exercise_working_times - working_times = {} - exercises.each do |exercise| - working_times[exercise.id] = time_to_f exercise.average_working_time + def collection_statistics + statistics = {} + exercise_collection_items.each do |item| + statistics[item.position] = {exercise_id: item.exercise.id, exercise_title: item.exercise.title, working_time: time_to_f(item.exercise.average_working_time)} end - working_times + statistics end def average_working_time if exercises.empty? 0 else - values = exercise_working_times.values.reject { |v| v.nil?} - values.reduce(:+) / exercises.size + values = collection_statistics.values.reject { |o| o[:working_time].nil?} + sum = values.reduce(0) {|sum, item| sum + item[:working_time]} + sum / values.size end end diff --git a/app/models/exercise_collection_item.rb b/app/models/exercise_collection_item.rb new file mode 100644 index 00000000..c7b01f20 --- /dev/null +++ b/app/models/exercise_collection_item.rb @@ -0,0 +1,4 @@ +class ExerciseCollectionItem < ActiveRecord::Base + belongs_to :exercise_collection + belongs_to :exercise +end diff --git a/app/models/proxy_exercise.rb b/app/models/proxy_exercise.rb index 3d9197fb..c6da3870 100644 --- a/app/models/proxy_exercise.rb +++ b/app/models/proxy_exercise.rb @@ -36,15 +36,31 @@ class ProxyExercise < ActiveRecord::Base Rails.logger.debug("retrieved assigned exercise for user #{user.id}: Exercise #{assigned_user_proxy_exercise.exercise}" ) assigned_user_proxy_exercise.exercise else - Rails.logger.debug("find new matching exercise for user #{user.id}" ) matching_exercise = - begin - find_matching_exercise(user) - rescue => e #fallback - Rails.logger.error("finding matching exercise failed. Fall back to random exercise! Error: #{$!}" ) - @reason[:reason] = "fallback because of error" - @reason[:error] = "#{$!}:\n\t#{e.backtrace.join("\n\t")}" - exercises.where("expected_difficulty > 1").shuffle.first # difficulty should be > 1 to prevent dummy exercise from being chosen. + if (token.eql? "e85689d5") + Rails.logger.debug("Proxy exercise with token e85689d5, split user in groups..") + group = UserGroupSeparator.getGroupExerciseDescriptionTesting(user) + Rails.logger.debug("user assigned to group #{group}") + case group + when :group_a + exercises.where(id: 557).first + when :group_b + exercises.where(id: 558).first + when :group_c + exercises.where(id: 559).first + when :group_d + exercises.where(id: 560).first + end + else + Rails.logger.debug("find new matching exercise for user #{user.id}" ) + begin + find_matching_exercise(user) + rescue => e #fallback + Rails.logger.error("finding matching exercise failed. Fall back to random exercise! Error: #{$!}" ) + @reason[:reason] = "fallback because of error" + @reason[:error] = "#{$!}:\n\t#{e.backtrace.join("\n\t")}" + exercises.where("expected_difficulty > 1").shuffle.first # difficulty should be > 1 to prevent dummy exercise from being chosen. + end end user.user_proxy_exercise_exercises << UserProxyExerciseExercise.create(user: user, exercise: matching_exercise, proxy_exercise: self, reason: @reason.to_json) matching_exercise diff --git a/app/views/exercise_collections/_add_exercise_modal.slim b/app/views/exercise_collections/_add_exercise_modal.slim new file mode 100644 index 00000000..62080f4f --- /dev/null +++ b/app/views/exercise_collections/_add_exercise_modal.slim @@ -0,0 +1,8 @@ +- exercises = Exercise.order(:title) + +form#exercise-selection + .form-group + span.label = t('activerecord.attributes.exercise_collections.exercises') + = collection_select({}, :exercise_ids, exercises, :id, :title, {}, {id: 'add-exercise-list', class: 'form-control', multiple: true}) + +button.btn.btn-primary#add-exercises = t('exercise_collections.form.add_exercises') diff --git a/app/views/exercise_collections/_form.html.slim b/app/views/exercise_collections/_form.html.slim index c204db47..9c765071 100644 --- a/app/views/exercise_collections/_form.html.slim +++ b/app/views/exercise_collections/_form.html.slim @@ -1,7 +1,4 @@ -- exercises = Exercise.order(:title) -- users = InternalUser.order(:name) - -= form_for(@exercise_collection, data: {exercises: exercises, users: users}, multipart: true) do |f| += form_for(@exercise_collection, multipart: true) do |f| = render('shared/form_errors', object: @exercise_collection) .form-group = f.label(t('activerecord.attributes.exercise_collections.name')) @@ -11,8 +8,30 @@ = f.check_box(:use_anomaly_detection, {class: 'form-control'}) .form-group = f.label(t('activerecord.attributes.exercise_collections.user')) - = f.collection_select(:user_id, users, :id, :name, {}, {class: 'form-control'}) - .form-group - = f.label(t('activerecord.attributes.exercise_collections.exercises')) - = f.collection_select(:exercise_ids, exercises, :id, :title, {}, {class: 'form-control', multiple: true}) + = f.collection_select(:user_id, InternalUser.order(:name), :id, :name, {}, {class: 'form-control'}) + + .table-responsive#exercise-list + table.table + thead + tr + th + th = t('activerecord.attributes.exercise_collection_item.exercise') + th colspan=2 = t('shared.actions') + tbody#sortable + - @exercise_collection.items.order(:position).each do |item| + tr data-id=item.exercise.id + td + span.fa.fa-bars + td = item.exercise.title + td = link_to(t('shared.show'), item.exercise) + td + a.remove-exercise href='#' = t('shared.destroy') + .hidden + = f.collection_select(:exercise_ids, Exercise.all, :id, :title, {}, {id: 'exercise-select', class: 'form-control', multiple: true}) + .exercise-actions + button.btn.btn-primary type='button' data-toggle='modal' data-target='#add-exercise-modal' = t('exercise_collections.form.add_exercises') + button.btn.btn-secondary#sort-button type='button' = t('exercise_collections.form.sort_by_title') + .actions = render('shared/submit_button', f: f, object: @exercise_collection) + += render('shared/modal', id: 'add-exercise-modal', title: t('.add_exercises'), template: 'exercise_collections/_add_exercise_modal') diff --git a/app/views/exercise_collections/show.html.slim b/app/views/exercise_collections/show.html.slim index ab85a3ba..c55d1d6b 100644 --- a/app/views/exercise_collections/show.html.slim +++ b/app/views/exercise_collections/show.html.slim @@ -8,20 +8,21 @@ h1 = row(label: 'exercise_collections.updated_at', value: @exercise_collection.updated_at) h4 = t('activerecord.attributes.exercise_collections.exercises') -.table-responsive +.table-responsive#exercise-list table.table thead tr + th = '#' th = t('activerecord.attributes.exercise.title') th = t('activerecord.attributes.exercise.execution_environment') th = t('activerecord.attributes.exercise.user') th = t('shared.actions') tbody - - @exercises.sort_by{|c| c.title}.each do |exercise| + - @exercise_collection.items.sort_by{|item| item.position}.each do |exercise_collection_item| + - exercise = exercise_collection_item.exercise tr + td = exercise_collection_item.position td = link_to(exercise.title, exercise) td = link_to_if(exercise.execution_environment && policy(exercise.execution_environment).show?, exercise.execution_environment, exercise.execution_environment) td = exercise.user.name td = link_to(t('shared.statistics'), statistics_exercise_path(exercise)) - -= render('shared/pagination', collection: @exercises) diff --git a/app/views/exercise_collections/statistics.html.slim b/app/views/exercise_collections/statistics.html.slim index 486a0dbd..60c49a01 100644 --- a/app/views/exercise_collections/statistics.html.slim +++ b/app/views/exercise_collections/statistics.html.slim @@ -6,7 +6,7 @@ h1 = @exercise_collection = row(label: 'exercises.statistics.average_worktime', value: @exercise_collection.average_working_time.round(3).to_s + 's') #graph - #data.hidden(data-working-times=ActiveSupport::JSON.encode(@exercise_collection.exercise_working_times) data-average-working-time=@exercise_collection.average_working_time) + #data.hidden(data-working-times=ActiveSupport::JSON.encode(@exercise_collection.collection_statistics) data-average-working-time=@exercise_collection.average_working_time) #legend - {time: t('exercises.statistics.average_worktime'), min: 'min. anomaly threshold', diff --git a/config/locales/de.yml b/config/locales/de.yml index eed4d500..be078620 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -131,6 +131,8 @@ de: user: "Autor" exercise: "Aufgabe" feedback_text: "Feedback Text" + exercise_collection_item: + exercise: "Aufgabe" models: code_harbor_link: one: CodeHarbor-Link @@ -782,3 +784,7 @@ de: files: "Dateien" users: "Benutzer" integrations: "Integrationen" + exercise_collections: + form: + add_exercises: "Aufgaben hinzufügen" + sort_by_title: "Nach Titel sortieren" diff --git a/config/locales/en.yml b/config/locales/en.yml index 9fed1190..863b0a64 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -131,6 +131,8 @@ en: user: "Author" exercise: "Exercise" feedback_text: "Feedback Text" + exercise_collection_item: + exercise: "Exercise" models: code_harbor_link: one: CodeHarbor Link @@ -782,3 +784,7 @@ en: files: "Files" users: "Users" integrations: "Integrations" + exercise_collections: + form: + add_exercises: "Add exercises" + sort_by_title: "Sort by title" diff --git a/db/migrate/20180703125302_create_exercise_collection_items.rb b/db/migrate/20180703125302_create_exercise_collection_items.rb new file mode 100644 index 00000000..c881c8a5 --- /dev/null +++ b/db/migrate/20180703125302_create_exercise_collection_items.rb @@ -0,0 +1,13 @@ +class CreateExerciseCollectionItems < ActiveRecord::Migration + def up + rename_table :exercise_collections_exercises, :exercise_collection_items + add_column :exercise_collection_items, :position, :integer, default: 0, null: false + add_column :exercise_collection_items, :id, :primary_key + end + + def down + remove_column :exercise_collection_items, :position + remove_column :exercise_collection_items, :id + rename_table :exercise_collection_items, :exercise_collections_exercises + end +end diff --git a/db/schema.rb b/db/schema.rb index 73a2c228..ae1398a6 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: 20180515110030) do +ActiveRecord::Schema.define(version: 20180703125302) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -114,6 +114,15 @@ ActiveRecord::Schema.define(version: 20180515110030) do t.boolean "network_enabled" end + create_table "exercise_collection_items", force: :cascade do |t| + t.integer "exercise_collection_id" + t.integer "exercise_id" + t.integer "position", default: 0, null: false + end + + add_index "exercise_collection_items", ["exercise_collection_id"], name: "index_exercise_collection_items_on_exercise_collection_id", using: :btree + add_index "exercise_collection_items", ["exercise_id"], name: "index_exercise_collection_items_on_exercise_id", using: :btree + create_table "exercise_collections", force: :cascade do |t| t.string "name" t.datetime "created_at" @@ -125,14 +134,6 @@ ActiveRecord::Schema.define(version: 20180515110030) do add_index "exercise_collections", ["user_type", "user_id"], name: "index_exercise_collections_on_user_type_and_user_id", using: :btree - create_table "exercise_collections_exercises", id: false, force: :cascade do |t| - t.integer "exercise_collection_id" - t.integer "exercise_id" - end - - add_index "exercise_collections_exercises", ["exercise_collection_id"], name: "index_exercise_collections_exercises_on_exercise_collection_id", using: :btree - add_index "exercise_collections_exercises", ["exercise_id"], name: "index_exercise_collections_exercises_on_exercise_id", using: :btree - create_table "exercise_tags", force: :cascade do |t| t.integer "exercise_id" t.integer "tag_id" diff --git a/lib/junit_adapter.rb b/lib/junit_adapter.rb index 6230a785..df0eff5d 100644 --- a/lib/junit_adapter.rb +++ b/lib/junit_adapter.rb @@ -2,7 +2,7 @@ class JunitAdapter < TestingFrameworkAdapter COUNT_REGEXP = /Tests run: (\d+)/ FAILURES_REGEXP = /Failures: (\d+)/ SUCCESS_REGEXP = /OK \((\d+) test[s]?\)/ - ASSERTION_ERROR_REGEXP = /java\.lang\.AssertionError:\s(.*)|org\.junit\.ComparisonFailure:\s(.*)/ + ASSERTION_ERROR_REGEXP = /java\.lang\.AssertionError:?\s(.*?)\tat org.junit|org\.junit\.ComparisonFailure:\s(.*?)\tat org.junit/m def self.framework_name 'JUnit' diff --git a/lib/tasks/detect_exercise_anomalies.rake b/lib/tasks/detect_exercise_anomalies.rake index fd8210da..43fe421a 100644 --- a/lib/tasks/detect_exercise_anomalies.rake +++ b/lib/tasks/detect_exercise_anomalies.rake @@ -21,51 +21,57 @@ namespace :detect_exercise_anomalies do WORKING_TIME_CACHE = {} AVERAGE_WORKING_TIME_CACHE = {} - task :with_at_least, [:number_of_exercises, :number_of_solutions] => :environment do |task, args| + task :with_at_least, [:number_of_exercises, :number_of_users] => :environment do |task, args| include TimeHelper number_of_exercises = args[:number_of_exercises] - number_of_solutions = args[:number_of_solutions] + number_of_users = args[:number_of_users] - puts "Searching for exercise collections with at least #{number_of_exercises} exercises and #{number_of_solutions} users." + log "Searching for exercise collections with at least #{number_of_exercises} exercises and #{number_of_users} users." # Get all exercise collections that have at least the specified amount of exercises and at least the specified - # number of submissions AND are flagged for anomaly detection - collections = get_collections(number_of_exercises, number_of_solutions) - puts "Found #{collections.length}." + # number of users AND are flagged for anomaly detection + collections = get_collections(number_of_exercises, number_of_users) + log "Found #{collections.length}." collections.each do |collection| - puts "\t- #{collection}" + log(collection, 1, '- ') anomalies = find_anomalies(collection) - if anomalies.length > 0 and not collection.user.nil? - notify_collection_author(collection, anomalies) + if anomalies.length > 0 + unless collection.user.nil? + notify_collection_author(collection, anomalies) + end notify_users(collection, anomalies) reset_anomaly_detection_flag(collection) end end - puts 'Done.' + log 'Done.' + end + + def log(message='', indent_level=0, prefix='') + puts("\t" * indent_level + "#{prefix}#{message}") end def get_collections(number_of_exercises, number_of_solutions) ExerciseCollection .where(:use_anomaly_detection => true) - .joins("join exercise_collections_exercises ece on exercise_collections.id = ece.exercise_collection_id + .joins("join exercise_collection_items eci on exercise_collections.id = eci.exercise_collection_id join (select e.id from exercises e join submissions s on s.exercise_id = e.id group by e.id having count(s.user_id) > #{ExerciseCollection.sanitize(number_of_solutions)} - ) as exercises_with_submissions on exercises_with_submissions.id = ece.exercise_id") + ) as exercises_with_submissions on exercises_with_submissions.id = eci.exercise_id") .group('exercise_collections.id') .having('count(exercises_with_submissions.id) > ?', number_of_exercises) end def collect_working_times(collection) working_times = {} - collection.exercises.each do |exercise| - puts "\t\t> #{exercise.title}" - working_times[exercise.id] = get_average_working_time(exercise) + collection.exercise_collection_items.order(:position).each do |eci| + log(eci.exercise.title, 2, '> ') + working_times[eci.exercise.id] = get_average_working_time(eci.exercise) end working_times end @@ -98,16 +104,16 @@ namespace :detect_exercise_anomalies do end def notify_collection_author(collection, anomalies) - puts "\t\tSending E-Mail to author (#{collection.user.displayname} <#{collection.user.email}>)..." + log("Sending E-Mail to author (#{collection.user.displayname} <#{collection.user.email}>)...", 2) UserMailer.exercise_anomaly_detected(collection, anomalies).deliver_now end def notify_users(collection, anomalies) by_id_and_type = proc { |u| {user_id: u[:user_id], user_type: u[:user_type]} } - puts "\t\tSending E-Mails to best and worst performing users of each anomaly..." + log("Sending E-Mails to best and worst performing users of each anomaly...", 2) anomalies.each do |exercise_id, average_working_time| - puts "\t\tAnomaly in exercise #{exercise_id} (avg: #{average_working_time} seconds):" + log("Anomaly in exercise #{exercise_id} (avg: #{average_working_time} seconds):", 2) exercise = Exercise.find(exercise_id) users_to_notify = [] @@ -135,7 +141,7 @@ namespace :detect_exercise_anomalies do feedback_link = url_for(action: :new, controller: :user_exercise_feedbacks, exercise_id: exercise.id, host: host) UserMailer.exercise_anomaly_needs_feedback(user, exercise, feedback_link).deliver end - puts "\t\tAsked #{users_to_notify.size} users for feedback." + log("Asked #{users_to_notify.size} users for feedback.", 2) end end @@ -159,7 +165,7 @@ namespace :detect_exercise_anomalies do end def reset_anomaly_detection_flag(collection) - puts "\t\tResetting flag..." + log("Resetting flag...", 2) collection.use_anomaly_detection = false collection.save! end diff --git a/lib/user_group_separator.rb b/lib/user_group_separator.rb index 4dcd00e1..2333b686 100644 --- a/lib/user_group_separator.rb +++ b/lib/user_group_separator.rb @@ -31,4 +31,18 @@ class UserGroupSeparator :recommended_assignment end end + + def self.getGroupExerciseDescriptionTesting(user) + groupById = user.id % 4 + if groupById == 0 + :group_a + elsif groupById == 1 + :group_b + elsif groupById == 2 + :group_c + else # 3 + :group_d + end + end + end \ No newline at end of file