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/websocket.js.erb b/app/assets/javascripts/editor/websocket.js.erb
index 1d0e2b25..1bc26648 100644
--- a/app/assets/javascripts/editor/websocket.js.erb
+++ b/app/assets/javascripts/editor/websocket.js.erb
@@ -34,7 +34,7 @@ CommandSocket.prototype.onMessage = function(event) {
};
/**
- * Parses a message, checks wether it contains multiple commands (seperated by linebreaks)
+ * Parses a message, checks whether it contains multiple commands (seperated by linebreaks)
* This needs to be done because of the behavior of the docker-socket connection.
* Because of this, sometimes multiple commands might be executed in one message.
* @param message
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/remote_scripts/windows.ps1 b/app/assets/remote_scripts/windows.ps1
index 9647d2e5..f548caea 100644
--- a/app/assets/remote_scripts/windows.ps1
+++ b/app/assets/remote_scripts/windows.ps1
@@ -62,6 +62,7 @@ function get_escaped_file_content ($file){
$content = $content.replace('\', '\\')
$content = $content -replace "`r`n", '\n'
$content = $content -replace "`n", '\n'
+ $content = $content -replace "`t", '\t'
$content = $content.replace('"', '\"')
return $content
}
diff --git a/app/assets/stylesheets/editor.css.scss b/app/assets/stylesheets/editor.css.scss
index f8e708eb..dad17193 100644
--- a/app/assets/stylesheets/editor.css.scss
+++ b/app/assets/stylesheets/editor.css.scss
@@ -19,6 +19,7 @@ button i.fa-spin {
.frame {
display: none;
+ min-height: 300px;
audio, img, video {
max-width: 100%;
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/submission.rb b/app/models/submission.rb
index ad0767a4..763e1366 100644
--- a/app/models/submission.rb
+++ b/app/models/submission.rb
@@ -70,6 +70,6 @@ class Submission < ActiveRecord::Base
# RequestForComment.unsolved.where(exercise_id: exercise).where.not(question: nil).order("RANDOM()").find { | rfc_element |(rfc_element.comments_count < MAX_COMMENTS_ON_RECOMMENDED_RFC) }
# experimental query:
- RequestForComment.unsolved.joins('JOIN exercise_collections_exercises ece ON ece.exercise_id = request_for_comments.exercise_id').where('ece.exercise_collection_id != 3 OR user_id%10 > 3').where(exercise_id: exercise).where.not(question: nil).order("RANDOM()").find { | rfc_element |(rfc_element.comments_count < MAX_COMMENTS_ON_RECOMMENDED_RFC) }
+ RequestForComment.unsolved.joins('JOIN exercise_collection_items eci ON eci.exercise_id = request_for_comments.exercise_id').where('eci.exercise_collection_id != 3 OR user_id%10 > 3').where(exercise_id: exercise).where.not(question: nil).order("RANDOM()").find { | rfc_element |(rfc_element.comments_count < MAX_COMMENTS_ON_RECOMMENDED_RFC) }
end
end
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..26722361 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
@@ -356,7 +358,7 @@ en:
external_users: External Users
finishing_rate: Finishing Rate
submit:
- failure: An error occured while transmitting your score. Please try again later.
+ failure: An error occurred while transmitting your score. Please try again later.
full_score_redirect_to_rfc: Congratulations! You achieved and submitted the highest possible score for this exercise. Another participant has a question concerning the exercise you just solved. Your help and comments will be greatly appreciated!
full_score_redirect_to_own_rfc: Congratulations! You achieved and submitted the highest possible score for this exercise. Your question concerning the exercise is solved? If so, please share the essential insight with your fellows and mark the question as solved, before you close this window!
external_users:
@@ -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/tasks/detect_exercise_anomalies.rake b/lib/tasks/detect_exercise_anomalies.rake
index be4ff161..43fe421a 100644
--- a/lib/tasks/detect_exercise_anomalies.rake
+++ b/lib/tasks/detect_exercise_anomalies.rake
@@ -21,59 +21,65 @@ 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
def find_anomalies(collection)
working_times = collect_working_times(collection).reject {|_, value| value.nil?}
- if working_times.size > 0
- average = working_times.reduce(:+) / working_times.size
+ if working_times.values.size > 0
+ average = working_times.values.reduce(:+) / working_times.values.size
return working_times.select do |_, working_time|
working_time > average * MAX_TIME_FACTOR or working_time < average * MIN_TIME_FACTOR
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