diff --git a/Gemfile b/Gemfile index 6a6486a6..076ed2aa 100644 --- a/Gemfile +++ b/Gemfile @@ -40,7 +40,7 @@ gem 'tubesock' 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 'nokogiri' -gem 'd3-rails' +gem 'd3-rails', '~>4.0' gem 'rest-client' gem 'rubyzip' gem 'whenever', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 7edb56f0..456aeb24 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -388,7 +388,7 @@ DEPENDENCIES coffee-rails concurrent-ruby concurrent-ruby-ext - d3-rails + d3-rails (~> 4.0) database_cleaner docker-api eventmachine (= 1.0.9.1) diff --git a/app/assets/javascripts/exercise_collections.js.erb b/app/assets/javascripts/exercise_collections.js.erb new file mode 100644 index 00000000..3530b636 --- /dev/null +++ b/app/assets/javascripts/exercise_collections.js.erb @@ -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 + "
" + + "<%= 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); + } +}); diff --git a/app/assets/javascripts/exercise_graphs.js b/app/assets/javascripts/exercise_graphs.js index 5f521b39..b095e1a5 100644 --- a/app/assets/javascripts/exercise_graphs.js +++ b/app/assets/javascripts/exercise_graphs.js @@ -1,9 +1,7 @@ $(function() { - // http://localhost:3333/exercises/38/statistics good for testing - // originally at--> localhost:3333/exercises/69/statistics + // /exercises/38/statistics good for testing if ($.isController('exercises') && $('.graph-functions-2').isPresent()) { - // GET THE DATA var submissions = $('#data').data('submissions'); var submissions_length = submissions.length; @@ -14,10 +12,7 @@ $(function() { submissionsAutosaves = []; var maximumValue = 0; - var wtimes = $('#wtimes').data('working_times'); //.hidden#wtimes data-working_times=ActiveSupport::JSON.encode(working_times_until) - - // console.log(submissions); - // console.log(wtimes); + var wtimes = $('#wtimes').data('working_times'); for (var i = 0;i

") - - // 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() { if (self.innerHeight) { return self.innerWidth; @@ -81,22 +69,17 @@ $(function() { //var formatDate = d3.time.format("%M"); - var x = d3.scale.linear() + var x = d3.scaleLinear() .range([0, width]); - var y = d3.scale.linear() + var y = d3.scaleLinear() .range([height, 0]); // - (height/20 - var xAxis = d3.svg.axis() - .scale(x) - .orient("bottom") - .ticks(20); - var yAxis = d3.svg.axis() - .scale(y) - .orient("left") + var xAxis = d3.axisBottom(x).ticks(20); + var yAxis = d3.axisLeft(y) .ticks(20) - .innerTickSize(-width) - .outerTickSize(0); + .tickSizeInner(-width) + .tickSizeOuter(0); - var line = d3.svg.line() + var line = d3.line() .x(function (d, i) { return x(i); }) @@ -225,7 +208,7 @@ $(function() { var x = d3.scale.ordinal() .rangeRoundBands([0, width], .1); - var y = d3.scale.linear() + var y = d3.scaleLinear() .range([0,height-(margin.top + margin.bottom)]); @@ -236,7 +219,7 @@ $(function() { 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") .ticks(10) .innerTickSize(-width); @@ -299,7 +282,7 @@ $(function() { .text("Working Time (Minutes)") .style('font-size', 14); - y = d3.scale.linear() + y = d3.scaleLinear() .domain([(0),max_of_array]) .range([0,height]); diff --git a/app/assets/stylesheets/exercise_collections.scss b/app/assets/stylesheets/exercise_collections.scss new file mode 100644 index 00000000..11b6b3a1 --- /dev/null +++ b/app/assets/stylesheets/exercise_collections.scss @@ -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; +} diff --git a/app/assets/stylesheets/statistics.css.scss b/app/assets/stylesheets/statistics.css.scss index a5ea4430..9ce3d61f 100644 --- a/app/assets/stylesheets/statistics.css.scss +++ b/app/assets/stylesheets/statistics.css.scss @@ -58,3 +58,46 @@ div.negative-result { 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; + } + } + } +} diff --git a/app/controllers/exercise_collections_controller.rb b/app/controllers/exercise_collections_controller.rb index 7980d11a..de425dcd 100644 --- a/app/controllers/exercise_collections_controller.rb +++ b/app/controllers/exercise_collections_controller.rb @@ -1,7 +1,7 @@ class ExerciseCollectionsController < ApplicationController 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 @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) end + def statistics + end + private def set_exercise_collection diff --git a/app/controllers/statistics_controller.rb b/app/controllers/statistics_controller.rb new file mode 100644 index 00000000..a26d5670 --- /dev/null +++ b/app/controllers/statistics_controller.rb @@ -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 diff --git a/app/helpers/statistics_helper.rb b/app/helpers/statistics_helper.rb new file mode 100644 index 00000000..bd597cc1 --- /dev/null +++ b/app/helpers/statistics_helper.rb @@ -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 diff --git a/app/helpers/time_helper.rb b/app/helpers/time_helper.rb new file mode 100644 index 00000000..ed05ede5 --- /dev/null +++ b/app/helpers/time_helper.rb @@ -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 diff --git a/app/models/exercise_collection.rb b/app/models/exercise_collection.rb index f9f09269..661bed81 100644 --- a/app/models/exercise_collection.rb +++ b/app/models/exercise_collection.rb @@ -1,8 +1,21 @@ class ExerciseCollection < ActiveRecord::Base + include TimeHelper has_and_belongs_to_many :exercises 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 "#{I18n.t('activerecord.models.exercise_collection.one')}: #{name} (#{id})" end diff --git a/app/policies/exercise_collection_policy.rb b/app/policies/exercise_collection_policy.rb index ff150290..3d6b725e 100644 --- a/app/policies/exercise_collection_policy.rb +++ b/app/policies/exercise_collection_policy.rb @@ -1,3 +1,7 @@ class ExerciseCollectionPolicy < AdminOnlyPolicy + def statistics? + admin? + end + end diff --git a/app/policies/statistics_policy.rb b/app/policies/statistics_policy.rb new file mode 100644 index 00000000..36ca2b51 --- /dev/null +++ b/app/policies/statistics_policy.rb @@ -0,0 +1,2 @@ +class StatisticsPolicy < AdminOnlyPolicy +end diff --git a/app/views/application/_navigation.html.slim b/app/views/application/_navigation.html.slim index a2604c7a..127e170c 100644 --- a/app/views/application/_navigation.html.slim +++ b/app/views/application/_navigation.html.slim @@ -7,6 +7,7 @@ ul.dropdown-menu role='menu' - if current_user.admin? li = link_to(t('breadcrumbs.dashboard.show'), admin_dashboard_path) + li = link_to(t('breadcrumbs.statistics.show'), statistics_path) li.divider - models = [ExecutionEnvironment, Exercise, ExerciseCollection, ProxyExercise, Tag, Consumer, CodeHarborLink, UserExerciseFeedback, ErrorTemplate, ErrorTemplateAttribute, ExternalUser, FileType, FileTemplate, InternalUser].sort_by {|model| model.model_name.human(count: 2) } diff --git a/app/views/exercise_collections/index.html.slim b/app/views/exercise_collections/index.html.slim index 75a9d011..e0e8ebbc 100644 --- a/app/views/exercise_collections/index.html.slim +++ b/app/views/exercise_collections/index.html.slim @@ -8,7 +8,7 @@ h1 = ExerciseCollection.model_name.human(count: 2) th = t('activerecord.attributes.exercise_collections.name') th = t('activerecord.attributes.exercise_collections.updated_at') th = t('activerecord.attributes.exercise_collections.exercises') - th colspan=3 = t('shared.actions') + th colspan=4 = t('shared.actions') tbody - @exercise_collections.each do |collection| tr @@ -18,6 +18,7 @@ h1 = ExerciseCollection.model_name.human(count: 2) td = collection.exercises.size td = link_to(t('shared.show'), 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) = render('shared/pagination', collection: @exercise_collections) diff --git a/app/views/exercise_collections/statistics.html.slim b/app/views/exercise_collections/statistics.html.slim new file mode 100644 index 00000000..486a0dbd --- /dev/null +++ b/app/views/exercise_collections/statistics.html.slim @@ -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 diff --git a/app/views/statistics/show.html.slim b/app/views/statistics/show.html.slim new file mode 100644 index 00000000..9b7edefe --- /dev/null +++ b/app/views/statistics/show.html.slim @@ -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 diff --git a/config/locales/de.yml b/config/locales/de.yml index ff353b9a..4f5b9dcb 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -219,6 +219,8 @@ de: show: Dashboard sessions: destroy_through_lti: Code-Abgabe + statistics: + show: "Statistiken" consumers: show: link: Konsument @@ -734,3 +736,19 @@ de: subscriptions: 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." + 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)" diff --git a/config/locales/en.yml b/config/locales/en.yml index 7d475c88..f65b90f4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -219,6 +219,8 @@ en: show: Dashboard sessions: destroy_through_lti: Code Submission + statistics: + show: "Statistics" consumers: show: link: Consumer @@ -734,3 +736,19 @@ en: subscriptions: successfully_unsubscribed: "You successfully unsubscribed from this Request for Comment" 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)" diff --git a/config/routes.rb b/config/routes.rb index 264e7471..399bee40 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -42,6 +42,8 @@ Rails.application.routes.draw do get '/help', to: 'application#help' + get 'statistics/', to: 'statistics#show' + concern :statistics do member do get :statistics @@ -82,7 +84,11 @@ Rails.application.routes.draw do end end - resources :exercise_collections + resources :exercise_collections do + member do + get :statistics + end + end resources :proxy_exercises do member do diff --git a/lib/tasks/detect_exercise_anomalies.rake b/lib/tasks/detect_exercise_anomalies.rake index e22550b2..3436b87a 100644 --- a/lib/tasks/detect_exercise_anomalies.rake +++ b/lib/tasks/detect_exercise_anomalies.rake @@ -22,6 +22,8 @@ namespace :detect_exercise_anomalies do AVERAGE_WORKING_TIME_CACHE = {} 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_solutions = args[:number_of_solutions] @@ -71,14 +73,6 @@ namespace :detect_exercise_anomalies do 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) unless AVERAGE_WORKING_TIME_CACHE.key?(exercise.id) seconds = time_to_f exercise.average_working_time