Merge pull request #188 from openHPI/feature/ordered_exercise_collection

Ordered exercise collections
This commit is contained in:
rteusner
2018-07-20 13:41:05 +02:00
committed by GitHub
15 changed files with 330 additions and 136 deletions

View File

@ -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 + "<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 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 + "<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)
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('<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

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
class ExerciseCollectionItem < ActiveRecord::Base
belongs_to :exercise_collection
belongs_to :exercise
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)
- 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')

View File

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

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')
#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',

View File

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

View File

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

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.
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"

View File

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