Merge pull request #161 from openHPI/feature/more-statistics

Feature/more statistics
This commit is contained in:
rteusner
2018-03-21 10:23:44 +01:00
committed by GitHub
22 changed files with 492 additions and 83 deletions

View File

@ -40,7 +40,7 @@ gem 'tubesock'
gem 'faye-websocket' gem 'faye-websocket'
gem 'eventmachine', '1.0.9.1' # explicitly added, this is used by faye-websocket, version 1.2.5 still has an error in eventmachine.rb:202: [BUG] Segmentation fault, which is not yet fixed and causes the whole ruby process to crash gem 'eventmachine', '1.0.9.1' # explicitly added, this is used by faye-websocket, version 1.2.5 still has an error in eventmachine.rb:202: [BUG] Segmentation fault, which is not yet fixed and causes the whole ruby process to crash
gem 'nokogiri' gem 'nokogiri'
gem 'd3-rails' gem 'd3-rails', '~>4.0'
gem 'rest-client' gem 'rest-client'
gem 'rubyzip' gem 'rubyzip'
gem 'whenever', require: false gem 'whenever', require: false

View File

@ -388,7 +388,7 @@ DEPENDENCIES
coffee-rails coffee-rails
concurrent-ruby concurrent-ruby
concurrent-ruby-ext concurrent-ruby-ext
d3-rails d3-rails (~> 4.0)
database_cleaner database_cleaner
docker-api docker-api
eventmachine (= 1.0.9.1) eventmachine (= 1.0.9.1)

View File

@ -0,0 +1,102 @@
$(function() {
if ($.isController('exercise_collections')) {
var data = $('#data').data('working-times');
var averageWorkingTimeValue = parseFloat($('#data').data('average-working-time'));
var margin = { top: 30, right: 40, bottom: 30, left: 50 },
width = 720 - margin.left - margin.right,
height = 500 - margin.top - margin.bottom;
var x = d3.scaleBand().range([0, width]);
var y = d3.scaleLinear().range([height, 0]);
var xAxis = d3.axisBottom(x);
var yAxisLeft = d3.axisLeft(y);
var tooltip = d3.select("#graph").append("div").attr("class", "exercise-id-tooltip");
var averageWorkingTime = d3.line()
.x(function (d) { return x(d.index) + x.bandwidth()/2; })
.y(function () { return y(averageWorkingTimeValue); });
var minWorkingTime = d3.line()
.x(function (d) { return x(d.index) + x.bandwidth()/2; })
.y(function () { return y(0.1*averageWorkingTimeValue); });
var maxWorkingTime = d3.line()
.x(function (d) { return x(d.index) + x.bandwidth()/2; })
.y(function () { return y(2*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 + ")");
// Get the data
data = Object.keys(data).map(function (key, index) {
return {
index: index,
exercise_id: parseInt(key),
working_time: parseFloat(data[key])
};
});
// 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 X Axis
svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + height + ")")
.call(xAxis);
// Add the Y Axis
svg.append("g")
.attr("class", "y axis")
.style("fill", "steelblue")
.call(yAxisLeft);
// 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 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);
}
});

View File

@ -1,9 +1,7 @@
$(function() { $(function() {
// http://localhost:3333/exercises/38/statistics good for testing // /exercises/38/statistics good for testing
// originally at--> localhost:3333/exercises/69/statistics
if ($.isController('exercises') && $('.graph-functions-2').isPresent()) { if ($.isController('exercises') && $('.graph-functions-2').isPresent()) {
// GET THE DATA
var submissions = $('#data').data('submissions'); var submissions = $('#data').data('submissions');
var submissions_length = submissions.length; var submissions_length = submissions.length;
@ -14,10 +12,7 @@ $(function() {
submissionsAutosaves = []; submissionsAutosaves = [];
var maximumValue = 0; var maximumValue = 0;
var wtimes = $('#wtimes').data('working_times'); //.hidden#wtimes data-working_times=ActiveSupport::JSON.encode(working_times_until) var wtimes = $('#wtimes').data('working_times');
// console.log(submissions);
// console.log(wtimes);
for (var i = 0;i<submissions_length;i++){ for (var i = 0;i<submissions_length;i++){
var submission = submissions[i]; var submission = submissions[i];
@ -46,9 +41,6 @@ $(function() {
submissionsSaves.push(submissionArray[1]); submissionsSaves.push(submissionArray[1]);
} }
} }
// console.log(submissionsScoreAndTimeAssess.length);
// console.log(submissionsScoreAndTimeSubmits);
// console.log(submissionsScoreAndTimeRuns);
function get_minutes (time_stamp) { function get_minutes (time_stamp) {
try { try {
@ -94,33 +86,22 @@ $(function() {
height = (width * height_ratio) - margin.top - margin.bottom; height = (width * height_ratio) - margin.top - margin.bottom;
// Set the ranges // Set the ranges
var x = d3.scale.linear().range([0, width]); var x = d3.scaleLinear().range([0, width]);
var y = d3.scale.linear().range([height,0]); var y = d3.scaleLinear().range([height,0]);
//var x = d3.scale.linear() //var x = d3.scaleLinear()
// .range([0, width]); // .range([0, width]);
//var y = d3.scale.linear() //var y = d3.scaleLinear()
// .range([0,height]); // - (height/20 // .range([0,height]); // - (height/20
var xAxis = d3.svg.axis() var xAxis = d3.axisBottom(x).ticks(20);
.scale(x) var yAxis = d3.axisLeft()
.orient("bottom") .scale(d3.scaleLinear().domain([0,maximumValue]).range([height,0]))
.ticks(20);
var yAxis = d3.svg.axis()
.scale(d3.scale.linear().domain([0,maximumValue]).range([height,0]))//y
// .scale(y)
.orient("left")
.ticks(maximumValue) .ticks(maximumValue)
.innerTickSize(-width) .tickSizeInner(-width)
.outerTickSize(0); .tickSizeOuter(0);
//var line = d3.svg.line() var line = d3.line()
// .x(function(d) { return x(d.date); })
// .y(function(d) { return y(d.close); });
var line = d3.svg.line()
.x(function (d) { .x(function (d) {
// console.log(d[1]); // console.log(d[1]);
return x(d[1]); return x(d[1]);
@ -288,23 +269,12 @@ $(function() {
.text(color_hash[String(i)][0]); .text(color_hash[String(i)][0]);
}); });
// function type(d) {
// d.frequency = +d.frequency;
// return d;
// }
//.on("mousemove", mMove)//new again
//.append("title");
} }
try{ try{
graph_assesses(); graph_assesses();
} catch(err){ } catch(err){
alert("could not draw the graph"); console.error("Could not draw the graph", err);
} }
} }

View File

@ -38,18 +38,6 @@ $(function() {
} }
} }
// minutes_count[(maximum_minutes + 1)] = 0;
//$('.graph-functions').html("<p></p>")
// var minutes_count = new Array(10);
// var minutes_array_len = minutes_array.length;
// for (var i=0; i< minutes_count; i++){
//
// for (var j = 0; j < minutes_array_len; j++){
// if ()
// }
// }
function getWidth() { function getWidth() {
if (self.innerHeight) { if (self.innerHeight) {
return self.innerWidth; return self.innerWidth;
@ -81,22 +69,17 @@ $(function() {
//var formatDate = d3.time.format("%M"); //var formatDate = d3.time.format("%M");
var x = d3.scale.linear() var x = d3.scaleLinear()
.range([0, width]); .range([0, width]);
var y = d3.scale.linear() var y = d3.scaleLinear()
.range([height, 0]); // - (height/20 .range([height, 0]); // - (height/20
var xAxis = d3.svg.axis() var xAxis = d3.axisBottom(x).ticks(20);
.scale(x) var yAxis = d3.axisLeft(y)
.orient("bottom")
.ticks(20);
var yAxis = d3.svg.axis()
.scale(y)
.orient("left")
.ticks(20) .ticks(20)
.innerTickSize(-width) .tickSizeInner(-width)
.outerTickSize(0); .tickSizeOuter(0);
var line = d3.svg.line() var line = d3.line()
.x(function (d, i) { .x(function (d, i) {
return x(i); return x(i);
}) })
@ -225,7 +208,7 @@ $(function() {
var x = d3.scale.ordinal() var x = d3.scale.ordinal()
.rangeRoundBands([0, width], .1); .rangeRoundBands([0, width], .1);
var y = d3.scale.linear() var y = d3.scaleLinear()
.range([0,height-(margin.top + margin.bottom)]); .range([0,height-(margin.top + margin.bottom)]);
@ -236,7 +219,7 @@ $(function() {
var yAxis = d3.svg.axis() var yAxis = d3.svg.axis()
.scale(d3.scale.linear().domain([0,max_of_array]).range([height,0]))//y .scale(d3.scaleLinear().domain([0,max_of_array]).range([height,0]))//y
.orient("left") .orient("left")
.ticks(10) .ticks(10)
.innerTickSize(-width); .innerTickSize(-width);
@ -299,7 +282,7 @@ $(function() {
.text("Working Time (Minutes)") .text("Working Time (Minutes)")
.style('font-size', 14); .style('font-size', 14);
y = d3.scale.linear() y = d3.scaleLinear()
.domain([(0),max_of_array]) .domain([(0),max_of_array])
.range([0,height]); .range([0,height]);

View File

@ -0,0 +1,69 @@
$time-color: #008cba;
$min-color: #8efa00;
$avg-color: #ffca00;
$max-color: #ff2600;
path.line.minimum-working-time {
stroke: $min-color;
}
path.line.average-working-time {
stroke: $avg-color;
}
path.line.maximum-working-time {
stroke: $max-color;
}
rect.value-bar {
fill: $time-color;
cursor: pointer;
}
#legend {
display: flex;
margin-top: 20px;
.legend-entry {
flex-grow: 1;
display: flex;
.box {
width: 20px;
height: 20px;
border: solid 1px #000;
}
.box.time {
background-color: $time-color;
}
.box.min {
background-color: $min-color;
}
.box.avg {
background-color: $avg-color;
}
.box.max {
background-color: $max-color;
}
.box-label {
margin-left: 5px;
margin-right: 15px;
}
}
}
.exercise-id-tooltip {
position: absolute;
display: none;
min-width: 80px;
height: auto;
background: none repeat scroll 0 0 #ffffff;
border: 1px solid #008cba;
padding: 14px;
text-align: center;
}

View File

@ -58,3 +58,46 @@ div.negative-result {
box-shadow: 0px 0px 11px 1px rgba(222,0,0,1); box-shadow: 0px 0px 11px 1px rgba(222,0,0,1);
} }
/////////////////////////////////////////////////////////////////////////////////////////////
// StatisticsController:
#statistics-container {
margin-bottom: 40px;
}
.statistics-wrapper {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 150px;
grid-gap: 10px;
> a {
color: #fff;
text-decoration: none;
> div {
border: 2px solid #0055ba;
border-radius: 5px;
background-color: #008cba;
padding: 1em;
display: flex;
flex-flow: column-reverse;
text-align: center;
> .data {
flex-grow: 1;
font-size: 40px;
vertical-align: middle;
line-height: 50px;
> .unit {
font-size: 20px;
}
}
> .title {
height: 42px;
}
}
}
}

View File

@ -1,7 +1,7 @@
class ExerciseCollectionsController < ApplicationController class ExerciseCollectionsController < ApplicationController
include CommonBehavior include CommonBehavior
before_action :set_exercise_collection, only: [:show, :edit, :update, :destroy] before_action :set_exercise_collection, only: [:show, :edit, :update, :destroy, :statistics]
def index def index
@exercise_collections = ExerciseCollection.all.paginate(:page => params[:page]) @exercise_collections = ExerciseCollection.all.paginate(:page => params[:page])
@ -35,6 +35,9 @@ class ExerciseCollectionsController < ApplicationController
update_and_respond(object: @exercise_collection, params: exercise_collection_params) update_and_respond(object: @exercise_collection, params: exercise_collection_params)
end end
def statistics
end
private private
def set_exercise_collection def set_exercise_collection

View File

@ -0,0 +1,16 @@
class StatisticsController < ApplicationController
include StatisticsHelper
def policy_class
StatisticsPolicy
end
def show
authorize self
respond_to do |format|
format.html
format.json { render(json: statistics_data) }
end
end
end

View File

@ -0,0 +1,125 @@
module StatisticsHelper
def statistics_data
[
{
key: 'users',
name: t('statistics.sections.users'),
entries: user_statistics
},
{
key: 'exercises',
name: t('statistics.sections.exercises'),
entries: exercise_statistics
},
{
key: 'request_for_comments',
name: t('statistics.sections.request_for_comments'),
entries: rfc_statistics
}
]
end
def user_statistics
[
{
key: 'internal_users',
name: t('activerecord.models.internal_user.other'),
data: InternalUser.count,
url: internal_users_path
},
{
key: 'external_users',
name: t('activerecord.models.external_user.other'),
data: ExternalUser.count,
url: external_users_path
},
{
key: 'currently_active',
name: t('statistics.entries.users.currently_active'),
data: ExternalUser.joins(:submissions)
.where(['submissions.created_at >= ?', DateTime.now - 5.minutes])
.distinct('external_users.id').count
}
]
end
def exercise_statistics
[
{
key: 'exercises',
name: t('activerecord.models.exercise.other'),
data: Exercise.count,
url: exercises_path
},
{
key: 'average_submissions',
name: t('statistics.entries.exercises.average_number_of_submissions'),
data: (Submission.count.to_f / Exercise.count).round(2)
},
{
key: 'submissions_per_minute',
name: t('statistics.entries.exercises.submissions_per_minute'),
data: (Submission.where('created_at >= ?', DateTime.now - 1.hours).count.to_f / 60).round(2),
unit: '/min'
},
{
key: 'execution_environments',
name: t('activerecord.models.execution_environment.other'),
data: ExecutionEnvironment.count,
url: execution_environments_path
},
{
key: 'exercise_collections',
name: t('activerecord.models.exercise_collection.other'),
data: ExerciseCollection.count,
url: exercise_collections_path
}
]
end
def rfc_statistics
[
{
key: 'rfcs',
name: t('activerecord.models.request_for_comment.other'),
data: RequestForComment.count,
url: request_for_comments_path
},
{
key: 'percent_solved',
name: t('statistics.entries.request_for_comments.percent_solved'),
data: (100.0 / RequestForComment.count * RequestForComment.where(solved: true).count).round(1),
unit: '%',
url: request_for_comments_path + '?q%5Bsolved_not_eq%5D=0'
},
{
key: 'percent_soft_solved',
name: t('statistics.entries.request_for_comments.percent_soft_solved'),
data: (100.0 / RequestForComment.count * RequestForComment.unsolved.where(full_score_reached: true).count).round(1),
unit: '%',
url: request_for_comments_path
},
{
key: 'percent_unsolved',
name: t('statistics.entries.request_for_comments.percent_unsolved'),
data: (100.0 / RequestForComment.count * RequestForComment.unsolved.count).round(1),
unit: '%',
url: request_for_comments_path + '?q%5Bsolved_not_eq%5D=1'
},
{
key: 'comments',
name: t('activerecord.models.comment.other'),
data: Comment.count
},
{
key: 'rfcs_with_comments',
name: t('statistics.entries.request_for_comments.with_comments'),
data: RequestForComment.joins('join "submissions" s on s.id = request_for_comments.submission_id
join "files" f on f.context_id = s.id and f.context_type = \'Submission\'
join "comments" c on c.file_id = f.id').group('request_for_comments.id').count.size
}
]
end
end

View File

@ -0,0 +1,12 @@
module TimeHelper
# convert timestamps ('12:34:56.789') to seconds
def time_to_f(timestamp)
unless timestamp.nil?
timestamp = timestamp.split(':')
return timestamp[0].to_i * 60 * 60 + timestamp[1].to_i * 60 + timestamp[2].to_f
end
nil
end
end

View File

@ -1,8 +1,21 @@
class ExerciseCollection < ActiveRecord::Base class ExerciseCollection < ActiveRecord::Base
include TimeHelper
has_and_belongs_to_many :exercises has_and_belongs_to_many :exercises
belongs_to :user, polymorphic: true 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
end
working_times
end
def average_working_time
exercise_working_times.values.reduce(:+) / exercises.size
end
def to_s def to_s
"#{I18n.t('activerecord.models.exercise_collection.one')}: #{name} (#{id})" "#{I18n.t('activerecord.models.exercise_collection.one')}: #{name} (#{id})"
end end

View File

@ -1,3 +1,7 @@
class ExerciseCollectionPolicy < AdminOnlyPolicy class ExerciseCollectionPolicy < AdminOnlyPolicy
def statistics?
admin?
end
end end

View File

@ -0,0 +1,2 @@
class StatisticsPolicy < AdminOnlyPolicy
end

View File

@ -7,6 +7,7 @@
ul.dropdown-menu role='menu' ul.dropdown-menu role='menu'
- if current_user.admin? - if current_user.admin?
li = link_to(t('breadcrumbs.dashboard.show'), admin_dashboard_path) li = link_to(t('breadcrumbs.dashboard.show'), admin_dashboard_path)
li = link_to(t('breadcrumbs.statistics.show'), statistics_path)
li.divider li.divider
- models = [ExecutionEnvironment, Exercise, ExerciseCollection, ProxyExercise, Tag, Consumer, CodeHarborLink, UserExerciseFeedback, - models = [ExecutionEnvironment, Exercise, ExerciseCollection, ProxyExercise, Tag, Consumer, CodeHarborLink, UserExerciseFeedback,
ErrorTemplate, ErrorTemplateAttribute, ExternalUser, FileType, FileTemplate, InternalUser].sort_by {|model| model.model_name.human(count: 2) } ErrorTemplate, ErrorTemplateAttribute, ExternalUser, FileType, FileTemplate, InternalUser].sort_by {|model| model.model_name.human(count: 2) }

View File

@ -8,7 +8,7 @@ h1 = ExerciseCollection.model_name.human(count: 2)
th = t('activerecord.attributes.exercise_collections.name') th = t('activerecord.attributes.exercise_collections.name')
th = t('activerecord.attributes.exercise_collections.updated_at') th = t('activerecord.attributes.exercise_collections.updated_at')
th = t('activerecord.attributes.exercise_collections.exercises') th = t('activerecord.attributes.exercise_collections.exercises')
th colspan=3 = t('shared.actions') th colspan=4 = t('shared.actions')
tbody tbody
- @exercise_collections.each do |collection| - @exercise_collections.each do |collection|
tr tr
@ -18,6 +18,7 @@ h1 = ExerciseCollection.model_name.human(count: 2)
td = collection.exercises.size td = collection.exercises.size
td = link_to(t('shared.show'), collection) td = link_to(t('shared.show'), collection)
td = link_to(t('shared.edit'), edit_exercise_collection_path(collection)) td = link_to(t('shared.edit'), edit_exercise_collection_path(collection))
td = link_to(t('shared.statistics'), statistics_exercise_collection_path(collection))
td = link_to(t('shared.destroy'), collection, data: {confirm: t('shared.confirm_destroy')}, method: :delete) td = link_to(t('shared.destroy'), collection, data: {confirm: t('shared.confirm_destroy')}, method: :delete)
= render('shared/pagination', collection: @exercise_collections) = render('shared/pagination', collection: @exercise_collections)

View File

@ -0,0 +1,17 @@
h1 = @exercise_collection
= row(label: 'exercise_collections.name', value: @exercise_collection.name)
= row(label: 'exercise_collections.updated_at', value: @exercise_collection.updated_at)
= row(label: 'exercise_collections.exercises', value: @exercise_collection.exercises.count)
= 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)
#legend
- {time: t('exercises.statistics.average_worktime'),
min: 'min. anomaly threshold',
avg: 'average time',
max: 'max. anomaly threshold'}.each_pair do |klass, label|
.legend-entry
div(class="box #{klass}")
.box-label = label

View File

@ -0,0 +1,12 @@
#statistics-container
- statistics_data.each do | section |
h2 = section[:name]
.statistics-wrapper data-key=section[:key]
- section[:entries].each do | entry |
a href=entry[:url]
div data-key=entry[:key]
.title = entry[:name]
.data
span = entry[:data].to_s
span.unit = entry[:unit] if entry.key? :unit

View File

@ -219,6 +219,8 @@ de:
show: Dashboard show: Dashboard
sessions: sessions:
destroy_through_lti: Code-Abgabe destroy_through_lti: Code-Abgabe
statistics:
show: "Statistiken"
consumers: consumers:
show: show:
link: Konsument link: Konsument
@ -734,3 +736,19 @@ de:
subscriptions: subscriptions:
successfully_unsubscribed: "Ihr Abonnement für weitere Kommentare auf dieser Kommentaranfrage wurde erfolgreich beendet." successfully_unsubscribed: "Ihr Abonnement für weitere Kommentare auf dieser Kommentaranfrage wurde erfolgreich beendet."
subscription_not_existent: "Das Abonnement, von dem Sie sich abmelden wollen, existiert nicht." subscription_not_existent: "Das Abonnement, von dem Sie sich abmelden wollen, existiert nicht."
statistics:
sections:
users: "Benutzer"
exercises: "Aufgaben"
request_for_comments: "Kommentaranfragen"
entries:
exercises:
average_number_of_submissions: "Durchschnittliche Zahl von Abgaben"
submissions_per_minute: "Aktuelle Abgabenhäufigkeit (1h)"
request_for_comments:
percent_solved: "Beantwortete Anfragen"
percent_unsolved: "Unbeantwortete Anfragen"
percent_soft_solved: "Ungelöst mit voller Punktzahl"
with_comments: "Anfragen mit Kommentaren"
users:
currently_active: "Aktiv (5 Minuten)"

View File

@ -219,6 +219,8 @@ en:
show: Dashboard show: Dashboard
sessions: sessions:
destroy_through_lti: Code Submission destroy_through_lti: Code Submission
statistics:
show: "Statistics"
consumers: consumers:
show: show:
link: Consumer link: Consumer
@ -734,3 +736,19 @@ en:
subscriptions: subscriptions:
successfully_unsubscribed: "You successfully unsubscribed from this Request for Comment" successfully_unsubscribed: "You successfully unsubscribed from this Request for Comment"
subscription_not_existent: "The subscription you want to unsubscribe from does not exist." subscription_not_existent: "The subscription you want to unsubscribe from does not exist."
statistics:
sections:
users: "Users"
exercises: "Exercises"
request_for_comments: "Requests for Comment"
entries:
exercises:
average_number_of_submissions: "Average Number of Submissions"
submissions_per_minute: "Current Submission Volume (1h)"
request_for_comments:
percent_solved: "Solved Requests"
percent_unsolved: "Unsolved Requests"
percent_soft_solved: "Unsolved with full score"
with_comments: "RfCs with Comments"
users:
currently_active: "Active (5 minutes)"

View File

@ -42,6 +42,8 @@ Rails.application.routes.draw do
get '/help', to: 'application#help' get '/help', to: 'application#help'
get 'statistics/', to: 'statistics#show'
concern :statistics do concern :statistics do
member do member do
get :statistics get :statistics
@ -82,7 +84,11 @@ Rails.application.routes.draw do
end end
end end
resources :exercise_collections resources :exercise_collections do
member do
get :statistics
end
end
resources :proxy_exercises do resources :proxy_exercises do
member do member do

View File

@ -22,6 +22,8 @@ namespace :detect_exercise_anomalies do
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_solutions] => :environment do |task, args|
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_solutions = args[:number_of_solutions]
@ -71,14 +73,6 @@ namespace :detect_exercise_anomalies do
end end
end end
def time_to_f(timestamp)
unless timestamp.nil?
timestamp = timestamp.split(':')
return timestamp[0].to_i * 60 * 60 + timestamp[1].to_i * 60 + timestamp[2].to_f
end
nil
end
def get_average_working_time(exercise) def get_average_working_time(exercise)
unless AVERAGE_WORKING_TIME_CACHE.key?(exercise.id) unless AVERAGE_WORKING_TIME_CACHE.key?(exercise.id)
seconds = time_to_f exercise.average_working_time seconds = time_to_f exercise.average_working_time