Merge remote-tracking branch 'origin/master' into feature/improved-tag-stats

This commit is contained in:
Maximilian Grundke
2018-08-02 10:40:52 +02:00
21 changed files with 361 additions and 165 deletions

View File

@ -25,10 +25,10 @@ gem 'rails', '4.2.10'
gem 'rails-i18n' gem 'rails-i18n'
gem 'ransack' gem 'ransack'
gem 'rubytree' gem 'rubytree'
gem 'sass-rails' gem 'sass-rails', '>= 5.0.7'
gem 'sdoc', group: :doc gem 'sdoc', group: :doc
gem 'slim-rails' gem 'slim-rails'
gem 'bootstrap_pagedown' gem 'bootstrap_pagedown', '>= 1.1.0'
gem 'pagedown-rails' gem 'pagedown-rails'
gem 'sorcery' gem 'sorcery'
gem 'thread_safe' gem 'thread_safe'
@ -66,7 +66,7 @@ end
group :test do group :test do
gem 'autotest-rails' gem 'autotest-rails'
gem 'capybara' gem 'capybara'
gem 'capybara-selenium' gem 'capybara-selenium', '>= 0.0.6'
gem 'headless' gem 'headless'
gem 'codeclimate-test-reporter', require: false gem 'codeclimate-test-reporter', require: false
gem 'database_cleaner' gem 'database_cleaner'

View File

@ -78,13 +78,13 @@ GEM
capistrano (~> 3.7) capistrano (~> 3.7)
capistrano-bundler capistrano-bundler
puma (~> 3.4) puma (~> 3.4)
capybara (2.18.0) capybara (3.3.1)
addressable addressable
mini_mime (>= 0.1.3) mini_mime (>= 0.1.3)
nokogiri (>= 1.3.3) nokogiri (~> 1.8)
rack (>= 1.0.0) rack (>= 1.6.0)
rack-test (>= 0.5.4) rack-test (>= 0.6.3)
xpath (>= 2.0, < 4.0) xpath (~> 3.1)
capybara-selenium (0.0.6) capybara-selenium (0.0.6)
capybara capybara
selenium-webdriver selenium-webdriver
@ -92,7 +92,7 @@ GEM
activemodel (>= 4.0.0) activemodel (>= 4.0.0)
activesupport (>= 4.0.0) activesupport (>= 4.0.0)
mime-types (>= 1.16) mime-types (>= 1.16)
childprocess (0.8.0) childprocess (0.9.0)
ffi (~> 1.0, >= 1.0.11) ffi (~> 1.0, >= 1.0.11)
chronic (0.10.2) chronic (0.10.2)
codeclimate-test-reporter (1.0.7) codeclimate-test-reporter (1.0.7)
@ -108,7 +108,7 @@ GEM
concurrent-ruby (1.0.5) concurrent-ruby (1.0.5)
concurrent-ruby-ext (1.0.5) concurrent-ruby-ext (1.0.5)
concurrent-ruby (= 1.0.5) concurrent-ruby (= 1.0.5)
crass (1.0.3) crass (1.0.4)
d3-rails (4.13.0) d3-rails (4.13.0)
railties (>= 3.1) railties (>= 3.1)
database_cleaner (1.6.2) database_cleaner (1.6.2)
@ -135,7 +135,7 @@ GEM
faye-websocket (0.10.7) faye-websocket (0.10.7)
eventmachine (>= 0.12.0) eventmachine (>= 0.12.0)
websocket-driver (>= 0.5.1) websocket-driver (>= 0.5.1)
ffi (1.9.23) ffi (1.9.25)
forgery (0.7.0) forgery (0.7.0)
globalid (0.4.1) globalid (0.4.1)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
@ -161,7 +161,7 @@ GEM
json (2.1.0) json (2.1.0)
jwt (1.5.6) jwt (1.5.6)
kramdown (1.16.2) kramdown (1.16.2)
loofah (2.2.0) loofah (2.2.2)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.5.9) nokogiri (>= 1.5.9)
mail (2.7.0) mail (2.7.0)
@ -181,7 +181,7 @@ GEM
net-ssh (4.2.0) net-ssh (4.2.0)
netrc (0.11.0) netrc (0.11.0)
newrelic_rpm (4.8.0.341) newrelic_rpm (4.8.0.341)
nokogiri (1.8.2) nokogiri (1.8.3)
mini_portile2 (~> 2.3.0) mini_portile2 (~> 2.3.0)
nyan-cat-formatter (0.12.0) nyan-cat-formatter (0.12.0)
rspec (>= 2.99, >= 2.14.2, < 4) rspec (>= 2.99, >= 2.14.2, < 4)
@ -211,7 +211,7 @@ GEM
puma (3.11.3) puma (3.11.3)
pundit (1.1.0) pundit (1.1.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
rack (1.6.9) rack (1.6.10)
rack-mini-profiler (0.10.7) rack-mini-profiler (0.10.7)
rack (>= 1.2.0) rack (>= 1.2.0)
rack-test (0.6.3) rack-test (0.6.3)
@ -233,8 +233,8 @@ GEM
activesupport (>= 4.2.0, < 5.0) activesupport (>= 4.2.0, < 5.0)
nokogiri (~> 1.6) nokogiri (~> 1.6)
rails-deprecated_sanitizer (>= 1.0.1) rails-deprecated_sanitizer (>= 1.0.1)
rails-html-sanitizer (1.0.3) rails-html-sanitizer (1.0.4)
loofah (~> 2.0) loofah (~> 2.2, >= 2.2.2)
rails-i18n (4.0.9) rails-i18n (4.0.9)
i18n (~> 0.7) i18n (~> 0.7)
railties (~> 4.0) railties (~> 4.0)
@ -244,7 +244,7 @@ GEM
rake (>= 0.8.7) rake (>= 0.8.7)
thor (>= 0.18.1, < 2.0) thor (>= 0.18.1, < 2.0)
rainbow (3.0.0) rainbow (3.0.0)
rake (12.3.0) rake (12.3.1)
ransack (1.8.7) ransack (1.8.7)
actionpack (>= 3.0) actionpack (>= 3.0)
activerecord (>= 3.0) activerecord (>= 3.0)
@ -296,7 +296,7 @@ GEM
json (~> 2.1) json (~> 2.1)
structured_warnings (~> 0.3) structured_warnings (~> 0.3)
rubyzip (1.2.1) rubyzip (1.2.1)
sass (3.5.5) sass (3.5.6)
sass-listen (~> 4.0.0) sass-listen (~> 4.0.0)
sass-listen (4.0.0) sass-listen (4.0.0)
rb-fsevent (~> 0.9, >= 0.9.4) rb-fsevent (~> 0.9, >= 0.9.4)
@ -309,7 +309,7 @@ GEM
tilt (>= 1.1, < 3) tilt (>= 1.1, < 3)
sdoc (1.0.0) sdoc (1.0.0)
rdoc (>= 5.0) rdoc (>= 5.0)
selenium-webdriver (3.10.0) selenium-webdriver (3.13.0)
childprocess (~> 0.5) childprocess (~> 0.5)
rubyzip (~> 1.2) rubyzip (~> 1.2)
simplecov (0.15.1) simplecov (0.15.1)
@ -330,7 +330,7 @@ GEM
oauth2 (~> 1.0, >= 0.8.0) oauth2 (~> 1.0, >= 0.8.0)
spring (2.0.2) spring (2.0.2)
activesupport (>= 4.2) activesupport (>= 4.2)
sprockets (3.7.1) sprockets (3.7.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
rack (> 1, < 3) rack (> 1, < 3)
sprockets-rails (3.2.1) sprockets-rails (3.2.1)
@ -369,7 +369,7 @@ GEM
whenever (0.10.0) whenever (0.10.0)
chronic (>= 0.6.3) chronic (>= 0.6.3)
will_paginate (3.1.6) will_paginate (3.1.6)
xpath (3.0.0) xpath (3.1.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
PLATFORMS PLATFORMS
@ -383,7 +383,7 @@ DEPENDENCIES
better_errors better_errors
binding_of_caller binding_of_caller
bootstrap-will_paginate bootstrap-will_paginate
bootstrap_pagedown bootstrap_pagedown (>= 1.1.0)
byebug byebug
capistrano capistrano
capistrano-rails capistrano-rails
@ -391,7 +391,7 @@ DEPENDENCIES
capistrano-upload-config capistrano-upload-config
capistrano3-puma capistrano3-puma
capybara capybara
capybara-selenium capybara-selenium (>= 0.0.6)
carrierwave carrierwave
codeclimate-test-reporter codeclimate-test-reporter
concurrent-ruby concurrent-ruby
@ -430,7 +430,7 @@ DEPENDENCIES
rubocop-rspec rubocop-rspec
rubytree rubytree
rubyzip rubyzip
sass-rails sass-rails (>= 5.0.7)
sdoc sdoc
simplecov simplecov
slim-rails slim-rails

View File

@ -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. * 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. * Because of this, sometimes multiple commands might be executed in one message.
* @param message * @param message

View File

@ -1,102 +1,207 @@
$(function() { $(function() {
if ($.isController('exercise_collections')) { if ($.isController('exercise_collections')) {
var data = $('#data').data('working-times'); var dataElement = $('#data');
var averageWorkingTimeValue = parseFloat($('#data').data('average-working-time')); var exerciseList = $('#exercise-list');
var margin = { top: 30, right: 40, bottom: 30, left: 50 }, if (dataElement.isPresent()) {
width = 720 - margin.left - margin.right, var data = dataElement.data('working-times');
height = 500 - margin.top - margin.bottom; var averageWorkingTimeValue = parseFloat(dataElement.data('average-working-time'));
var x = d3.scaleBand().range([0, width]); var margin = {top: 30, right: 40, bottom: 30, left: 50},
var y = d3.scaleLinear().range([height, 0]); width = 720 - margin.left - margin.right,
height = 500 - margin.top - margin.bottom;
var xAxis = d3.axisBottom(x); var x = d3.scaleBand().range([0, width]);
var yAxisLeft = d3.axisLeft(y); 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() var tooltip = d3.select("#graph").append("div").attr("class", "exercise-id-tooltip");
.x(function (d) { return x(d.index) + x.bandwidth()/2; })
.y(function () { return y(averageWorkingTimeValue); });
var minWorkingTime = d3.line() var averageWorkingTime = d3.line()
.x(function (d) { return x(d.index) + x.bandwidth()/2; }) .x(function (d) {
.y(function () { return y(0.1*averageWorkingTimeValue); }); return x(d.index) + x.bandwidth() / 2;
})
.y(function () {
return y(averageWorkingTimeValue);
});
var maxWorkingTime = d3.line() var minWorkingTime = d3.line()
.x(function (d) { return x(d.index) + x.bandwidth()/2; }) .x(function (d) {
.y(function () { return y(2*averageWorkingTimeValue); }); return x(d.index) + x.bandwidth() / 2;
})
.y(function () {
return y(0.1 * averageWorkingTimeValue);
});
var svg = d3.select('#graph') var maxWorkingTime = d3.line()
.append("svg") .x(function (d) {
.attr("width", width + margin.left + margin.right) return x(d.index) + x.bandwidth() / 2;
.attr("height", height + margin.top + margin.bottom) })
.append("g") .y(function () {
.attr("transform", return y(2 * averageWorkingTimeValue);
"translate(" + margin.left + "," + margin.top + ")"); });
// Get the data var svg = d3.select('#graph')
data = Object.keys(data).map(function (key, index) { .append("svg")
return { .attr("width", width + margin.left + margin.right)
index: index, .attr("height", height + margin.top + margin.bottom)
exercise_id: parseInt(key), .append("g")
working_time: parseFloat(data[key]) .attr("transform",
}; "translate(" + margin.left + "," + margin.top + ")");
});
// Scale the range of the data // Get the data
x.domain(data.map(function (d) { return d.index; })); data = Object.keys(data).map(function (key) {
y.domain([0, d3.max(data, function (d) { return d.working_time; })]); 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 // Scale the range of the data
svg.append("g") x.domain(data.map(function (d) {
.attr("class", "x axis") return d.index;
.attr("transform", "translate(0," + height + ")") }));
.call(xAxis); y.domain([0, d3.max(data, function (d) {
return d.working_time;
})]);
// Add the Y Axis // Add the X Axis
svg.append("g") svg.append("g")
.attr("class", "y axis") .attr("class", "x axis")
.style("fill", "steelblue") .attr("transform", "translate(0," + height + ")")
.call(yAxisLeft); .call(xAxis);
// Draw the bars // Add the Y Axis
svg.selectAll("bar") svg.append("g")
.data(data) .attr("class", "y axis")
.enter() .style("fill", "steelblue")
.append("rect") .call(yAxisLeft);
.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 + "<br>" +
"<%= 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 average working time path // Draw the bars
svg.append("path") svg.selectAll("bar")
.datum(data) .data(data)
.attr("class", "line average-working-time") .enter()
.attr("d", averageWorkingTime); .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 + "<br>" +
"<%= I18n.t('activerecord.attributes.exercise.title') %>: " + d.exercise_title + "<br>" +
"<%= 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) // Add the average working time path
svg.append("path") svg.append("path")
.datum(data) .datum(data)
.attr("class", "line minimum-working-time") .attr("class", "line average-working-time")
.attr("d", minWorkingTime); .attr("d", averageWorkingTime);
svg.append("path")
.datum(data) // Add the anomaly paths (min/max average exercise working time)
.attr("class", "line maximum-working-time") svg.append("path")
.attr("d", maxWorkingTime); .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('<option value="' + exerciseIdsInSortedOrder[i] + '" selected></option>')
}
}
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 = '<tr data-id="' + exercise.id + '">' +
'<td><span class="fa fa-bars"></span></td>' +
'<td>' + exercise.title + '</td>' +
'<td><a href="/exercises/' + exercise.id + '"><%= I18n.t('shared.show') %></td>' +
'<td><a class="remove-exercise" href="#"><%= I18n.t('shared.destroy') %></td></tr>';
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();
});
}
} }
}); });

View File

@ -62,6 +62,7 @@ function get_escaped_file_content ($file){
$content = $content.replace('\', '\\') $content = $content.replace('\', '\\')
$content = $content -replace "`r`n", '\n' $content = $content -replace "`r`n", '\n'
$content = $content -replace "`n", '\n' $content = $content -replace "`n", '\n'
$content = $content -replace "`t", '\t'
$content = $content.replace('"', '\"') $content = $content.replace('"', '\"')
return $content return $content
} }

View File

@ -19,6 +19,7 @@ button i.fa-spin {
.frame { .frame {
display: none; display: none;
min-height: 300px;
audio, img, video { audio, img, video {
max-width: 100%; max-width: 100%;

View File

@ -57,6 +57,10 @@ rect.value-bar {
} }
} }
.table-responsive#exercise-list {
max-height: 512px;
}
.exercise-id-tooltip { .exercise-id-tooltip {
position: absolute; position: absolute;
display: none; display: none;
@ -67,3 +71,19 @@ rect.value-bar {
padding: 14px; padding: 14px;
text-align: center; text-align: center;
} }
#exercise-list {
margin-bottom: 20px;
}
#add-exercise-list {
min-height: 450px;
}
.exercise-actions {
margin-bottom: 20px;
button {
margin-right: 10px;
}
}

View File

@ -9,7 +9,6 @@ class ExerciseCollectionsController < ApplicationController
end end
def show def show
@exercises = @exercise_collection.exercises.paginate(:page => params[:page])
end end
def new def new
@ -50,6 +49,8 @@ class ExerciseCollectionsController < ApplicationController
end end
def exercise_collection_params 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
end end

View File

@ -14,7 +14,8 @@ class Exercise < ActiveRecord::Base
has_and_belongs_to_many :proxy_exercises has_and_belongs_to_many :proxy_exercises
has_many :user_proxy_exercise_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 :user_exercise_interventions
has_many :interventions, through: :user_exercise_interventions has_many :interventions, through: :user_exercise_interventions
has_many :exercise_tags has_many :exercise_tags

View File

@ -1,23 +1,26 @@
class ExerciseCollection < ActiveRecord::Base class ExerciseCollection < ActiveRecord::Base
include TimeHelper 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 belongs_to :user, polymorphic: true
def exercise_working_times def collection_statistics
working_times = {} statistics = {}
exercises.each do |exercise| exercise_collection_items.each do |item|
working_times[exercise.id] = time_to_f exercise.average_working_time statistics[item.position] = {exercise_id: item.exercise.id, exercise_title: item.exercise.title, working_time: time_to_f(item.exercise.average_working_time)}
end end
working_times statistics
end end
def average_working_time def average_working_time
if exercises.empty? if exercises.empty?
0 0
else else
values = exercise_working_times.values.reject { |v| v.nil?} values = collection_statistics.values.reject { |o| o[:working_time].nil?}
values.reduce(:+) / exercises.size sum = values.reduce(0) {|sum, item| sum + item[:working_time]}
sum / values.size
end end
end end

View File

@ -0,0 +1,4 @@
class ExerciseCollectionItem < ActiveRecord::Base
belongs_to :exercise_collection
belongs_to :exercise
end

View File

@ -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) } # 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: # 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
end end

View File

@ -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')

View File

@ -1,7 +1,4 @@
- exercises = Exercise.order(:title) = form_for(@exercise_collection, multipart: true) do |f|
- users = InternalUser.order(:name)
= form_for(@exercise_collection, data: {exercises: exercises, users: users}, multipart: true) do |f|
= render('shared/form_errors', object: @exercise_collection) = render('shared/form_errors', object: @exercise_collection)
.form-group .form-group
= f.label(t('activerecord.attributes.exercise_collections.name')) = f.label(t('activerecord.attributes.exercise_collections.name'))
@ -11,8 +8,30 @@
= f.check_box(:use_anomaly_detection, {class: 'form-control'}) = f.check_box(:use_anomaly_detection, {class: 'form-control'})
.form-group .form-group
= f.label(t('activerecord.attributes.exercise_collections.user')) = f.label(t('activerecord.attributes.exercise_collections.user'))
= f.collection_select(:user_id, users, :id, :name, {}, {class: 'form-control'}) = f.collection_select(:user_id, InternalUser.order(:name), :id, :name, {}, {class: 'form-control'})
.form-group
= f.label(t('activerecord.attributes.exercise_collections.exercises')) .table-responsive#exercise-list
= f.collection_select(:exercise_ids, exercises, :id, :title, {}, {class: 'form-control', multiple: true}) 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) .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')

View File

@ -8,20 +8,21 @@ h1
= row(label: 'exercise_collections.updated_at', value: @exercise_collection.updated_at) = row(label: 'exercise_collections.updated_at', value: @exercise_collection.updated_at)
h4 = t('activerecord.attributes.exercise_collections.exercises') h4 = t('activerecord.attributes.exercise_collections.exercises')
.table-responsive .table-responsive#exercise-list
table.table table.table
thead thead
tr tr
th = '#'
th = t('activerecord.attributes.exercise.title') th = t('activerecord.attributes.exercise.title')
th = t('activerecord.attributes.exercise.execution_environment') th = t('activerecord.attributes.exercise.execution_environment')
th = t('activerecord.attributes.exercise.user') th = t('activerecord.attributes.exercise.user')
th = t('shared.actions') th = t('shared.actions')
tbody 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 tr
td = exercise_collection_item.position
td = link_to(exercise.title, exercise) 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 = link_to_if(exercise.execution_environment && policy(exercise.execution_environment).show?, exercise.execution_environment, exercise.execution_environment)
td = exercise.user.name td = exercise.user.name
td = link_to(t('shared.statistics'), statistics_exercise_path(exercise)) td = link_to(t('shared.statistics'), statistics_exercise_path(exercise))
= render('shared/pagination', collection: @exercises)

View File

@ -6,7 +6,7 @@ h1 = @exercise_collection
= row(label: 'exercises.statistics.average_worktime', value: @exercise_collection.average_working_time.round(3).to_s + 's') = row(label: 'exercises.statistics.average_worktime', value: @exercise_collection.average_working_time.round(3).to_s + 's')
#graph #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 #legend
- {time: t('exercises.statistics.average_worktime'), - {time: t('exercises.statistics.average_worktime'),
min: 'min. anomaly threshold', min: 'min. anomaly threshold',

View File

@ -131,6 +131,8 @@ de:
user: "Autor" user: "Autor"
exercise: "Aufgabe" exercise: "Aufgabe"
feedback_text: "Feedback Text" feedback_text: "Feedback Text"
exercise_collection_item:
exercise: "Aufgabe"
models: models:
code_harbor_link: code_harbor_link:
one: CodeHarbor-Link one: CodeHarbor-Link
@ -782,3 +784,7 @@ de:
files: "Dateien" files: "Dateien"
users: "Benutzer" users: "Benutzer"
integrations: "Integrationen" integrations: "Integrationen"
exercise_collections:
form:
add_exercises: "Aufgaben hinzufügen"
sort_by_title: "Nach Titel sortieren"

View File

@ -131,6 +131,8 @@ en:
user: "Author" user: "Author"
exercise: "Exercise" exercise: "Exercise"
feedback_text: "Feedback Text" feedback_text: "Feedback Text"
exercise_collection_item:
exercise: "Exercise"
models: models:
code_harbor_link: code_harbor_link:
one: CodeHarbor Link one: CodeHarbor Link
@ -356,7 +358,7 @@ en:
external_users: External Users external_users: External Users
finishing_rate: Finishing Rate finishing_rate: Finishing Rate
submit: 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_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! 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: external_users:
@ -782,3 +784,7 @@ en:
files: "Files" files: "Files"
users: "Users" users: "Users"
integrations: "Integrations" integrations: "Integrations"
exercise_collections:
form:
add_exercises: "Add exercises"
sort_by_title: "Sort by title"

View File

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

View File

@ -11,7 +11,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -114,6 +114,15 @@ ActiveRecord::Schema.define(version: 20180515110030) do
t.boolean "network_enabled" t.boolean "network_enabled"
end 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| create_table "exercise_collections", force: :cascade do |t|
t.string "name" t.string "name"
t.datetime "created_at" 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 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| create_table "exercise_tags", force: :cascade do |t|
t.integer "exercise_id" t.integer "exercise_id"
t.integer "tag_id" t.integer "tag_id"

View File

@ -21,59 +21,65 @@ namespace :detect_exercise_anomalies do
WORKING_TIME_CACHE = {} WORKING_TIME_CACHE = {}
AVERAGE_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 include TimeHelper
number_of_exercises = args[:number_of_exercises] 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 # 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 # number of users AND are flagged for anomaly detection
collections = get_collections(number_of_exercises, number_of_solutions) collections = get_collections(number_of_exercises, number_of_users)
puts "Found #{collections.length}." log "Found #{collections.length}."
collections.each do |collection| collections.each do |collection|
puts "\t- #{collection}" log(collection, 1, '- ')
anomalies = find_anomalies(collection) anomalies = find_anomalies(collection)
if anomalies.length > 0 and not collection.user.nil? if anomalies.length > 0
notify_collection_author(collection, anomalies) unless collection.user.nil?
notify_collection_author(collection, anomalies)
end
notify_users(collection, anomalies) notify_users(collection, anomalies)
reset_anomaly_detection_flag(collection) reset_anomaly_detection_flag(collection)
end end
end end
puts 'Done.' log 'Done.'
end
def log(message='', indent_level=0, prefix='')
puts("\t" * indent_level + "#{prefix}#{message}")
end end
def get_collections(number_of_exercises, number_of_solutions) def get_collections(number_of_exercises, number_of_solutions)
ExerciseCollection ExerciseCollection
.where(:use_anomaly_detection => true) .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 join
(select e.id (select e.id
from exercises e from exercises e
join submissions s on s.exercise_id = e.id join submissions s on s.exercise_id = e.id
group by e.id group by e.id
having count(s.user_id) > #{ExerciseCollection.sanitize(number_of_solutions)} 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') .group('exercise_collections.id')
.having('count(exercises_with_submissions.id) > ?', number_of_exercises) .having('count(exercises_with_submissions.id) > ?', number_of_exercises)
end end
def collect_working_times(collection) def collect_working_times(collection)
working_times = {} working_times = {}
collection.exercises.each do |exercise| collection.exercise_collection_items.order(:position).each do |eci|
puts "\t\t> #{exercise.title}" log(eci.exercise.title, 2, '> ')
working_times[exercise.id] = get_average_working_time(exercise) working_times[eci.exercise.id] = get_average_working_time(eci.exercise)
end end
working_times working_times
end end
def find_anomalies(collection) def find_anomalies(collection)
working_times = collect_working_times(collection).reject {|_, value| value.nil?} working_times = collect_working_times(collection).reject {|_, value| value.nil?}
if working_times.size > 0 if working_times.values.size > 0
average = working_times.reduce(:+) / working_times.size average = working_times.values.reduce(:+) / working_times.values.size
return working_times.select do |_, working_time| return working_times.select do |_, working_time|
working_time > average * MAX_TIME_FACTOR or working_time < average * MIN_TIME_FACTOR working_time > average * MAX_TIME_FACTOR or working_time < average * MIN_TIME_FACTOR
end end
@ -98,16 +104,16 @@ namespace :detect_exercise_anomalies do
end end
def notify_collection_author(collection, anomalies) 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 UserMailer.exercise_anomaly_detected(collection, anomalies).deliver_now
end end
def notify_users(collection, anomalies) def notify_users(collection, anomalies)
by_id_and_type = proc { |u| {user_id: u[:user_id], user_type: u[:user_type]} } 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| 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) exercise = Exercise.find(exercise_id)
users_to_notify = [] 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) 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 UserMailer.exercise_anomaly_needs_feedback(user, exercise, feedback_link).deliver
end end
puts "\t\tAsked #{users_to_notify.size} users for feedback." log("Asked #{users_to_notify.size} users for feedback.", 2)
end end
end end
@ -159,7 +165,7 @@ namespace :detect_exercise_anomalies do
end end
def reset_anomaly_detection_flag(collection) def reset_anomaly_detection_flag(collection)
puts "\t\tResetting flag..." log("Resetting flag...", 2)
collection.use_anomaly_detection = false collection.use_anomaly_detection = false
collection.save! collection.save!
end end