diff --git a/.gitignore b/.gitignore index 4e183355..89d0de42 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ /rubocop.html /tmp /vagrant/ +/.vagrant *.sublime-* /.idea /.vagrant diff --git a/Gemfile.lock b/Gemfile.lock index c257ca70..5972ea79 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -388,6 +388,3 @@ DEPENDENCIES uglifier (>= 1.3.0) web-console (~> 2.0) will_paginate (~> 3.0) - -BUNDLED WITH - 1.10.6 diff --git a/app/assets/javascripts/submission_statistics.js b/app/assets/javascripts/submission_statistics.js new file mode 100644 index 00000000..75fe42cd --- /dev/null +++ b/app/assets/javascripts/submission_statistics.js @@ -0,0 +1,96 @@ +$(function() { + + var ACE_FILES_PATH = '/assets/ace/'; + var THEME = 'ace/theme/textmate'; + + var currentSubmission = 0; + var active_file = undefined; + var fileTrees = [] + var editor = undefined; + var fileTypeById = {} + + var showActiveFile = function() { + var session = editor.getSession(); + var fileType = fileTypeById[active_file.file_type_id] + session.setMode(fileType.editor_mode); + session.setTabSize(fileType.indent_size); + session.setValue(active_file.content); + session.setUseSoftTabs(true); + session.setUseWrapMode(true); + + showFileTree(currentSubmission); + filetree = $(fileTrees[currentSubmission]) + filetree.jstree("deselect_all"); + filetree.jstree().select_node(active_file.file_id); + }; + + var initializeFileTree = function() { + $('.files').each(function(index, element) { + fileTree = $(element).jstree($(element).data('entries')); + fileTree.on('click', 'li.jstree-leaf', function() { + var id = parseInt($(this).attr('id')) + _.each(files[currentSubmission], function(file) { + if (file.file_id === id) { + active_file = file; + } + }); + showActiveFile(); + }); + fileTrees.push(fileTree); + }); + }; + + var showFileTree = function(index) { + $('.files').hide(); + $(fileTrees[index].context).show(); + } + + if ($.isController('exercises') && $('#timeline').isPresent()) { + + _.each(['modePath', 'themePath', 'workerPath'], function(attribute) { + ace.config.set(attribute, ACE_FILES_PATH); + }); + + var slider = $('#submissions-slider>input'); + var submissions = $('#data').data('submissions'); + var files = $('#data').data('files'); + var filetypes = $('#data').data('file-types'); + + editor = ace.edit('current-file'); + editor.setShowPrintMargin(false); + editor.setTheme(THEME); + editor.$blockScrolling = Infinity; + editor.setReadOnly(true); + + _.each(filetypes, function (filetype) { + filetype = JSON.parse(filetype); + fileTypeById[filetype.id] = filetype; + }); + + $('tr[data-id]>.clickable').each(function(index, element) { + element = $(element); + element.click(function() { + slider.val(index); + slider.change() + }); + }); + + slider.on('change', function(event) { + currentSubmission = slider.val(); + var currentFiles = files[currentSubmission]; + var fileIndex = 0; + _.each(currentFiles, function(file, index) { + if (file.name === active_file.name) { + fileIndex = index; + } + }) + active_file = currentFiles[fileIndex]; + showActiveFile(); + }); + + active_file = files[0][0] + initializeFileTree(); + showActiveFile(); + } + +}); diff --git a/app/assets/stylesheets/statistics.css.scss b/app/assets/stylesheets/statistics.css.scss new file mode 100644 index 00000000..d148f782 --- /dev/null +++ b/app/assets/stylesheets/statistics.css.scss @@ -0,0 +1,12 @@ +#submissions-slider { + margin-top: 25px; + margin-bottom: 25px; +} + +#current-file.editor { + height: 400px; +} + +.clickable { + cursor: pointer; +} diff --git a/app/controllers/execution_environments_controller.rb b/app/controllers/execution_environments_controller.rb index 5e69f8fc..574aaed7 100644 --- a/app/controllers/execution_environments_controller.rb +++ b/app/controllers/execution_environments_controller.rb @@ -2,7 +2,7 @@ class ExecutionEnvironmentsController < ApplicationController include CommonBehavior before_action :set_docker_images, only: [:create, :edit, :new, :update] - before_action :set_execution_environment, only: MEMBER_ACTIONS + [:execute_command, :shell] + before_action :set_execution_environment, only: MEMBER_ACTIONS + [:execute_command, :shell, :statistics] before_action :set_testing_framework_adapters, only: [:create, :edit, :new, :update] def authorize! @@ -28,6 +28,9 @@ class ExecutionEnvironmentsController < ApplicationController render(json: @docker_client.execute_arbitrary_command(params[:command])) end + def statistics + end + def execution_environment_params params[:execution_environment].permit(:docker_image, :exposed_ports, :editor_mode, :file_extension, :file_type_id, :help, :indent_size, :memory_limit, :name, :network_enabled, :permitted_execution_time, :pool_size, :run_command, :test_command, :testing_framework).merge(user_id: current_user.id, user_type: current_user.class.name) end diff --git a/app/controllers/exercises_controller.rb b/app/controllers/exercises_controller.rb index 7a26627a..4f5e5b81 100644 --- a/app/controllers/exercises_controller.rb +++ b/app/controllers/exercises_controller.rb @@ -7,6 +7,7 @@ class ExercisesController < ApplicationController before_action :handle_file_uploads, only: [:create, :update] before_action :set_execution_environments, only: [:create, :edit, :new, :update] before_action :set_exercise, only: MEMBER_ACTIONS + [:clone, :implement, :run, :statistics, :submit, :reload] + before_action :set_external_user, only: [:statistics] before_action :set_file_types, only: [:create, :edit, :new, :update] before_action :set_teams, only: [:create, :edit, :new, :update] @@ -125,6 +126,14 @@ class ExercisesController < ApplicationController end private :set_exercise + def set_external_user + if params[:external_user_id] + @external_user = ExternalUser.find(params[:external_user_id]) + authorize! + end + end + private :set_exercise + def set_file_types @file_types = FileType.all.order(:name) end @@ -143,6 +152,11 @@ class ExercisesController < ApplicationController end def statistics + if(@external_user) + render 'exercises/external_users/statistics' + else + render 'exercises/statistics' + end end def submit diff --git a/app/controllers/external_users_controller.rb b/app/controllers/external_users_controller.rb index 1d22dc58..26af4fbf 100644 --- a/app/controllers/external_users_controller.rb +++ b/app/controllers/external_users_controller.rb @@ -13,4 +13,10 @@ class ExternalUsersController < ApplicationController @user = ExternalUser.find(params[:id]) authorize! end + + def statistics + @user = ExternalUser.find(params[:id]) + authorize! + end + end diff --git a/app/models/exercise.rb b/app/models/exercise.rb index 152d79bf..192c4513 100644 --- a/app/models/exercise.rb +++ b/app/models/exercise.rb @@ -11,7 +11,10 @@ class Exercise < ActiveRecord::Base belongs_to :execution_environment has_many :submissions belongs_to :team - has_many :users, source_type: ExternalUser, through: :submissions + + has_many :external_users, source: :user, source_type: ExternalUser, through: :submissions + has_many :internal_users, source: :user, source_type: InternalUser, through: :submissions + alias_method :users, :external_users scope :with_submissions, -> { where('id IN (SELECT exercise_id FROM submissions)') } @@ -22,15 +25,54 @@ class Exercise < ActiveRecord::Base validates :title, presence: true validates :token, presence: true, uniqueness: true + def average_percentage - (average_score/ maximum_score * 100).round if average_score + (average_score / maximum_score * 100).round if average_score end def average_score if submissions.exists?(cause: 'submit') maximum_scores_query = submissions.select('MAX(score) AS maximum_score').where(cause: 'submit').group(:user_id).to_sql.sub('$1', id.to_s) self.class.connection.execute("SELECT AVG(maximum_score) AS average_score FROM (#{maximum_scores_query}) AS maximum_scores").first['average_score'].to_f - end + else 0 end + end + + def average_number_of_submissions + user_count = internal_users.distinct.count + external_users.distinct.count + return user_count == 0 ? 0 : submissions.count() / user_count.to_f() + end + + def user_working_time_query + """ + SELECT user_id, + sum(working_time_new) AS working_time + FROM + (SELECT user_id, + CASE WHEN working_time >= '0:30:00' THEN '0' ELSE working_time END AS working_time_new + FROM + (SELECT user_id, + id, + (created_at - lag(created_at) over (PARTITION BY user_id + ORDER BY id)) AS working_time + FROM submissions + WHERE exercise_id=#{id}) AS foo) AS bar + GROUP BY user_id + """ + end + + def average_working_time + self.class.connection.execute(""" + SELECT avg(working_time) as average_time + FROM + (#{user_working_time_query}) AS baz; + """).first['average_time'] + end + + def average_working_time_for(user_id) + self.class.connection.execute(""" + #{user_working_time_query} + HAVING user_id = #{user_id} + """).first['working_time'] end def duplicate(attributes = {}) diff --git a/app/policies/execution_environment_policy.rb b/app/policies/execution_environment_policy.rb index 3766f714..bccc9a07 100644 --- a/app/policies/execution_environment_policy.rb +++ b/app/policies/execution_environment_policy.rb @@ -4,7 +4,7 @@ class ExecutionEnvironmentPolicy < AdminOrAuthorPolicy end private :author? - [:execute_command?, :shell?].each do |action| + [:execute_command?, :shell?, :statistics?].each do |action| define_method(action) { admin? || author? } end end diff --git a/app/policies/external_user_policy.rb b/app/policies/external_user_policy.rb index 6f638a8e..2e11060b 100644 --- a/app/policies/external_user_policy.rb +++ b/app/policies/external_user_policy.rb @@ -1,2 +1,5 @@ class ExternalUserPolicy < AdminOnlyPolicy + def statistics? + admin? + end end diff --git a/app/views/execution_environments/index.html.slim b/app/views/execution_environments/index.html.slim index 3043be9d..dc30898f 100644 --- a/app/views/execution_environments/index.html.slim +++ b/app/views/execution_environments/index.html.slim @@ -10,7 +10,7 @@ h1 = ExecutionEnvironment.model_name.human(count: 2) th = t('activerecord.attributes.execution_environment.memory_limit') th = t('activerecord.attributes.execution_environment.network_enabled') th = t('activerecord.attributes.execution_environment.permitted_execution_time') - th colspan=4 = t('shared.actions') + th colspan=5 = t('shared.actions') th colspan=2 = t('shared.resources') tbody - @execution_environments.each do |execution_environment| @@ -25,6 +25,7 @@ h1 = ExecutionEnvironment.model_name.human(count: 2) td = link_to(t('shared.edit'), edit_execution_environment_path(execution_environment)) td = link_to(t('shared.destroy'), execution_environment, data: {confirm: t('shared.confirm_destroy')}, method: :delete) td = link_to(t('.shell'), shell_execution_environment_path(execution_environment)) + td = link_to(t('shared.statistics'), statistics_execution_environment_path(execution_environment)) td = link_to(t('activerecord.models.error.other'), execution_environment_errors_path(execution_environment.id)) td = link_to(t('activerecord.models.hint.other'), execution_environment_hints_path(execution_environment.id)) diff --git a/app/views/execution_environments/statistics.html.slim b/app/views/execution_environments/statistics.html.slim new file mode 100644 index 00000000..57579476 --- /dev/null +++ b/app/views/execution_environments/statistics.html.slim @@ -0,0 +1,15 @@ +h1 = @execution_environment + +.table-responsive + table.table + thead + tr + - ['.exercise', '.score', '.runs', '.worktime'].each do |title| + th.header = t(title) + tbody + - @execution_environment.exercises.each do |exercise| + tr + td = link_to exercise.title, controller: "exercises", action: "statistics", id: exercise.id + td = exercise.average_score + td = exercise.average_number_of_submissions + td = exercise.average_working_time diff --git a/app/views/exercises/external_users/statistics.html.slim b/app/views/exercises/external_users/statistics.html.slim new file mode 100644 index 00000000..ca5142cc --- /dev/null +++ b/app/views/exercises/external_users/statistics.html.slim @@ -0,0 +1,48 @@ +h1 = "#{@exercise} (external user #{@external_user})" +- submissions = Submission.where("user_id = ? AND exercise_id = ?", @external_user.id, @exercise.id) +- current_submission = submissions.first +- if current_submission + - initial_files = current_submission.files.to_a + + - all_files = [] + - file_types = Set.new() + - submissions.each do |submission| + - submission.files.each do |file| + - file_types.add(ActiveSupport::JSON.encode(file.file_type)) + - all_files.push(submission.files) + + .hidden#data data-submissions=ActiveSupport::JSON.encode(submissions) data-files=ActiveSupport::JSON.encode(all_files) data-file-types=ActiveSupport::JSON.encode(file_types) + + #stats-editor.row + - index = 0 + - all_files.each do |files| + .files class=(@exercise.hide_file_tree ? 'hidden col-sm-3' : 'col-sm-3') data-index=index data-entries=FileTree.new(files).to_js_tree + - index += 1 + div class=(@exercise.hide_file_tree ? 'col-sm-12' : 'col-sm-9') + #current-file.editor + + #submissions-slider + input type='range' orient='horizontal' list='datapoints' min=0 max=submissions.length-1 value=0 + datalist#datapoints + - index=0 + - submissions.each do |submission| + option data-submission=submission + =index + - index += 1 + + #timeline + .table-responsive + table.table + thead + tr + - ['.time', '.cause', '.score'].each do |title| + th.header = t(title) + tbody + - submissions.each do |submission| + tr data-id=submission.id + td.clickable = submission.created_at.strftime("%F %T") + td = submission.cause + td = submission.score + +- else + p = t('.no_data_available') diff --git a/app/views/exercises/statistics.html.slim b/app/views/exercises/statistics.html.slim index 77ab091d..0cb26e9a 100644 --- a/app/views/exercises/statistics.html.slim +++ b/app/views/exercises/statistics.html.slim @@ -7,3 +7,23 @@ h1 = @exercise = row(label: '.average_score') do p == @exercise.average_score ? t('shared.out_of', maximum_value: @exercise.maximum_score, value: @exercise.average_score.round(2)) : empty p = progress_bar(@exercise.average_percentage) + += row(label: '.average_worktime') do + p = @exercise.average_working_time + +- Hash[:internal_users => t('.internal_users'), :external_users => t('.external_users')].each_pair do |symbol, label| + strong = label + .table-responsive + table.table + thead + tr + - ['.user', '.score', '.runs', '.worktime'].each do |title| + th.header = t(title) + tbody + - @exercise.send(symbol).distinct().each do |user| + tr + - submissions = @exercise.submissions.where('user_id=?', user.id) + td = link_to_if symbol==:external_users, "#{user.name} (#{user.email})", {controller: "exercises", action: "statistics", external_user_id: user.id, id: @exercise.id} + td = submissions.maximum('score') or 0 + td = submissions.count('id') + td = @exercise.average_working_time_for(user.id) or 0 diff --git a/app/views/external_users/statistics.html.slim b/app/views/external_users/statistics.html.slim new file mode 100644 index 00000000..6c49b967 --- /dev/null +++ b/app/views/external_users/statistics.html.slim @@ -0,0 +1,2 @@ +h1 = @user +H2 = 'Hallo' \ No newline at end of file diff --git a/config/locales/de.yml b/config/locales/de.yml index 647fbaee..d96e6927 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -167,6 +167,11 @@ de: shell: command: Befehl headline: Shell + statistics: + exercise: Übung + score: Durchschnittliche Punktzahl + runs: Durchschnittliche Anzahl von Versuchen + worktime: Durchschnittliche Arbeitszeit exercises: editor: confirm_start_over: Wollen Sie wirklich von vorne anfangen? @@ -239,8 +244,21 @@ de: intermediate_submissions: Intermediäre Abgaben participants: Bearbeitende Nutzer users: '%{count} verschiedene Nutzer' + user: Nutzer + score: Punktzahl + runs: Versuche + worktime: Arbeitszeit + average_worktime: Durchschnittliche Arbeitszeit + internal_users: Interne Nutzer + external_user: Externe Nutzer submit: failure: Beim Übermitteln Ihrer Punktzahl ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut. + external_users: + statistics: + no_data_available: Keine Daten verfügbar. + time: Zeit + cause: Grund + score: Punktzahl files: roles: main_file: Hauptdatei diff --git a/config/locales/en.yml b/config/locales/en.yml index 3af56f90..dc63e3dd 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -167,6 +167,11 @@ en: shell: command: Command headline: Shell + statistics: + exercise: Exercise + score: Average Score + runs: Average Number of Runs + worktime: Average Working Time exercises: editor: confirm_start_over: Do you really want to start over? @@ -239,8 +244,21 @@ en: intermediate_submissions: Intermediate Submissions participants: Participating Users users: '%{count} distinct users' + user: User + score: Score + runs: Runs + worktime: Working Time + average_worktime: Average Working Time + internal_users: Internal Users + external_users: External Users submit: failure: An error occured while transmitting your score. Please try again later. + external_users: + statistics: + no_data_available: No data available. + time: Time + cause: Cause + score: Score files: roles: main_file: Main File diff --git a/config/routes.rb b/config/routes.rb index 1616d9b8..24f6a0d9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,12 +20,20 @@ Rails.application.routes.draw do get '/help', to: 'application#help' + concern :statistics do + member do + get :statistics + end + end + + resources :consumers resources :execution_environments do member do get :shell post 'shell', as: :execute_command, to: :execute_command + get :statistics end resources :errors, only: [:create, :index, :show] @@ -46,7 +54,9 @@ Rails.application.routes.draw do end end - resources :external_users, only: [:index, :show] + resources :external_users, only: [:index, :show], concerns: :statistics do + resources :exercises, concerns: :statistics + end namespace :code_ocean do resources :files, only: [:create, :destroy] diff --git a/spec/controllers/execution_environments_controller_spec.rb b/spec/controllers/execution_environments_controller_spec.rb index 331c3ec7..2abf59ef 100644 --- a/spec/controllers/execution_environments_controller_spec.rb +++ b/spec/controllers/execution_environments_controller_spec.rb @@ -130,6 +130,14 @@ describe ExecutionEnvironmentsController do expect_template(:shell) end + describe 'GET #statistics' do + before(:each) { get :statistics, id: execution_environment.id } + + expect_assigns(execution_environment: :execution_environment) + expect_status(200) + expect_template(:statistics) + end + describe 'GET #show' do before(:each) { get :show, id: execution_environment.id }