Merge pull request #261 from openHPI/feature/la-dashboard

Add LA dashboard architecture
This commit is contained in:
rteusner
2019-03-12 14:30:25 +01:00
committed by GitHub
60 changed files with 4120 additions and 3042 deletions

View File

@ -1,18 +0,0 @@
{
"presets": [
["env", {
"modules": false,
"targets": {
"browsers": "> 1%",
"uglify": true
},
"useBuiltIns": true
}]
],
"plugins": [
"syntax-dynamic-import",
"transform-object-rest-spread",
["transform-class-properties", { "spec": true }]
]
}

View File

@ -1,3 +0,0 @@
plugins:
postcss-import: {}
postcss-cssnext: {}

View File

@ -5,7 +5,7 @@ services:
language: ruby
rvm:
- 2.5.1
- 2.6.1
cache:
bundler: true
yarn: true
@ -27,7 +27,7 @@ before_install:
- docker pull openhpi/co_execenv_python
- docker pull openhpi/co_execenv_java
- mkdir ~/geckodriver
- wget -O ~/geckodriver/download.tar.gz https://github.com/mozilla/geckodriver/releases/download/v0.23.0/geckodriver-v0.23.0-linux64.tar.gz
- wget -O ~/geckodriver/download.tar.gz https://github.com/mozilla/geckodriver/releases/download/v0.24.0/geckodriver-v0.24.0-linux64.tar.gz
- tar -xvzf ~/geckodriver/download.tar.gz -C ~/geckodriver/
- rm ~/geckodriver/download.tar.gz
- chmod +x ~/geckodriver/geckodriver

View File

@ -17,8 +17,9 @@ gem 'pg'
gem 'pry-byebug'
gem 'puma'
gem 'pundit'
gem 'rails', '5.2.1.1'
gem 'rails', '5.2.2'
gem 'rails-i18n'
gem 'i18n-js'
gem 'ransack'
gem 'rubytree'
gem 'sass-rails'
@ -31,12 +32,12 @@ gem 'tubesock', git: 'https://github.com/gosukiwi/tubesock', branch: 'patch-1' #
gem 'faye-websocket'
gem 'eventmachine', '1.0.9.1' # explicitly added, this is used by faye-websocket, newer versions might crash or
gem 'nokogiri'
gem 'd3-rails'
gem 'webpacker'
gem 'rest-client'
gem 'rubyzip'
gem 'mnemosyne-ruby'
gem 'whenever', require: false
gem 'rails-timeago'
group :development, :staging do
gem 'bootsnap', require: false

View File

@ -10,49 +10,49 @@ GIT
GEM
remote: https://rubygems.org/
specs:
ZenTest (4.11.1)
actioncable (5.2.1.1)
actionpack (= 5.2.1.1)
ZenTest (4.11.2)
actioncable (5.2.2)
actionpack (= 5.2.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailer (5.2.1.1)
actionpack (= 5.2.1.1)
actionview (= 5.2.1.1)
activejob (= 5.2.1.1)
actionmailer (5.2.2)
actionpack (= 5.2.2)
actionview (= 5.2.2)
activejob (= 5.2.2)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (5.2.1.1)
actionview (= 5.2.1.1)
activesupport (= 5.2.1.1)
actionpack (5.2.2)
actionview (= 5.2.2)
activesupport (= 5.2.2)
rack (~> 2.0)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.2)
actionview (5.2.1.1)
activesupport (= 5.2.1.1)
actionview (5.2.2)
activesupport (= 5.2.2)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.3)
activejob (5.2.1.1)
activesupport (= 5.2.1.1)
activejob (5.2.2)
activesupport (= 5.2.2)
globalid (>= 0.3.6)
activemodel (5.2.1.1)
activesupport (= 5.2.1.1)
activerecord (5.2.1.1)
activemodel (= 5.2.1.1)
activesupport (= 5.2.1.1)
activemodel (5.2.2)
activesupport (= 5.2.2)
activerecord (5.2.2)
activemodel (= 5.2.2)
activesupport (= 5.2.2)
arel (>= 9.0)
activestorage (5.2.1.1)
actionpack (= 5.2.1.1)
activerecord (= 5.2.1.1)
activestorage (5.2.2)
actionpack (= 5.2.2)
activerecord (= 5.2.2)
marcel (~> 0.3.1)
activesupport (5.2.1.1)
activesupport (5.2.2)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2)
minitest (~> 5.1)
tzinfo (~> 1.1)
addressable (2.5.2)
addressable (2.6.0)
public_suffix (>= 2.0.2, < 4.0)
airbrussh (1.3.1)
sshkit (>= 1.6.1, != 1.7.0)
@ -62,29 +62,28 @@ GEM
autotest-rails (4.2.1)
ZenTest (~> 4.5)
bcrypt (3.1.12)
better_errors (2.5.0)
better_errors (2.5.1)
coderay (>= 1.0.0)
erubi (>= 1.0.0)
rack (>= 0.9.0)
bindex (0.5.0)
binding_of_caller (0.8.0)
debug_inspector (>= 0.0.1)
bootsnap (1.3.2)
bootsnap (1.4.1)
msgpack (~> 1.0)
bootstrap-will_paginate (1.0.0)
will_paginate
builder (3.2.3)
bunny (2.12.0)
bunny (2.14.1)
amq-protocol (~> 2.3, >= 2.3.0)
byebug (10.0.2)
byebug (11.0.0)
capistrano (3.11.0)
airbrussh (>= 1.0.0)
i18n
rake (>= 10.0.0)
sshkit (>= 1.9.0)
capistrano-bundler (1.4.0)
capistrano-bundler (1.5.0)
capistrano (~> 3.1)
sshkit (~> 1.2)
capistrano-rails (1.4.0)
capistrano (~> 3.1)
capistrano-bundler (~> 1.1)
@ -97,7 +96,7 @@ GEM
capistrano (~> 3.7)
capistrano-bundler
puma (~> 3.4)
capybara (3.10.1)
capybara (3.14.0)
addressable
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
@ -105,7 +104,7 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (~> 1.2)
xpath (~> 3.2)
carrierwave (1.2.3)
carrierwave (1.3.1)
activemodel (>= 4.0.0)
activesupport (>= 4.0.0)
mime-types (>= 1.16)
@ -113,10 +112,8 @@ GEM
ffi (~> 1.0, >= 1.0.11)
chronic (0.10.2)
coderay (1.1.2)
concurrent-ruby (1.1.3)
concurrent-ruby (1.1.4)
crass (1.0.4)
d3-rails (5.7.0)
railties (>= 3.1)
database_cleaner (1.7.0)
debug_inspector (0.0.3)
diff-lcs (1.3)
@ -126,34 +123,36 @@ GEM
multi_json
domain_name (0.5.20180417)
unf (>= 0.0.5, < 1.0.0)
erubi (1.7.1)
erubi (1.8.0)
eventmachine (1.0.9.1)
excon (0.62.0)
execjs (2.7.0)
factory_bot (4.11.1)
activesupport (>= 3.0.0)
factory_bot_rails (4.11.1)
factory_bot (~> 4.11.1)
railties (>= 3.0.0)
faraday (0.15.3)
factory_bot (5.0.2)
activesupport (>= 4.2.0)
factory_bot_rails (5.0.1)
factory_bot (~> 5.0.0)
railties (>= 4.2.0)
faraday (0.15.4)
multipart-post (>= 1.2, < 3)
faye-websocket (0.10.7)
eventmachine (>= 0.12.0)
websocket-driver (>= 0.5.1)
ffi (1.9.25)
ffi (1.10.0)
forgery (0.7.0)
globalid (0.4.1)
globalid (0.4.2)
activesupport (>= 4.2.0)
headless (2.3.1)
highline (2.0.0)
highline (2.0.1)
http-cookie (1.0.3)
domain_name (~> 0.5)
i18n (1.1.1)
i18n (1.6.0)
concurrent-ruby (~> 1.0)
i18n-js (3.2.1)
i18n (>= 0.6.6)
ims-lti (1.2.2)
builder
oauth (>= 0.4.5, < 0.6)
jaro_winkler (1.5.1)
jaro_winkler (1.5.2)
jbuilder (2.8.0)
activesupport (>= 4.2.0)
multi_json (>= 1.2)
@ -161,9 +160,9 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.1.0)
json (2.2.0)
jwt (2.1.0)
kramdown (1.17.0)
kramdown (2.1.0)
listen (3.1.5)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
@ -179,25 +178,25 @@ GEM
mime-types (3.2.2)
mime-types-data (~> 3.2015)
mime-types-data (3.2018.0812)
mimemagic (0.3.2)
mimemagic (0.3.3)
mini_mime (1.0.1)
mini_portile2 (2.3.0)
mini_portile2 (2.4.0)
minitest (5.11.3)
mnemosyne-ruby (1.5.1)
activesupport (>= 4)
bunny
msgpack (1.2.4)
msgpack (1.2.7)
multi_json (1.13.1)
multi_xml (0.6.0)
multipart-post (2.0.0)
net-scp (1.2.1)
net-ssh (>= 2.6.5)
net-ssh (5.0.2)
net-ssh (5.1.0)
netrc (0.11.0)
newrelic_rpm (5.4.0.347)
newrelic_rpm (6.1.0.352)
nio4r (2.3.1)
nokogiri (1.8.5)
mini_portile2 (~> 2.3.0)
nokogiri (1.10.1)
mini_portile2 (~> 2.4.0)
nyan-cat-formatter (0.12.0)
rspec (>= 2.99, >= 2.14.2, < 4)
oauth (0.5.4)
@ -209,70 +208,74 @@ GEM
rack (>= 1.2, < 3)
pagedown-bootstrap-rails (2.1.4)
railties (> 3.1)
parallel (1.12.1)
parser (2.5.3.0)
parallel (1.14.0)
parser (2.6.0.0)
ast (~> 2.4.0)
pg (1.1.3)
pg (1.1.4)
powerpack (0.1.2)
pry (0.12.0)
pry (0.12.2)
coderay (~> 1.1.0)
method_source (~> 0.9.0)
pry-byebug (3.6.0)
byebug (~> 10.0)
pry-byebug (3.7.0)
byebug (~> 11.0)
pry (~> 0.10)
psych (3.1.0)
public_suffix (3.0.3)
puma (3.12.0)
pundit (2.0.0)
pundit (2.0.1)
activesupport (>= 3.0.0)
rack (2.0.6)
rack-mini-profiler (1.0.0)
rack-mini-profiler (1.0.2)
rack (>= 1.2.0)
rack-proxy (0.6.5)
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
rails (5.2.1.1)
actioncable (= 5.2.1.1)
actionmailer (= 5.2.1.1)
actionpack (= 5.2.1.1)
actionview (= 5.2.1.1)
activejob (= 5.2.1.1)
activemodel (= 5.2.1.1)
activerecord (= 5.2.1.1)
activestorage (= 5.2.1.1)
activesupport (= 5.2.1.1)
rails (5.2.2)
actioncable (= 5.2.2)
actionmailer (= 5.2.2)
actionpack (= 5.2.2)
actionview (= 5.2.2)
activejob (= 5.2.2)
activemodel (= 5.2.2)
activerecord (= 5.2.2)
activestorage (= 5.2.2)
activesupport (= 5.2.2)
bundler (>= 1.3.0)
railties (= 5.2.1.1)
railties (= 5.2.2)
sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.2)
actionpack (~> 5.x, >= 5.0.1)
actionview (~> 5.x, >= 5.0.1)
activesupport (~> 5.x)
rails-controller-testing (1.0.4)
actionpack (>= 5.0.1.x)
actionview (>= 5.0.1.x)
activesupport (>= 5.0.1.x)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.0.4)
loofah (~> 2.2, >= 2.2.2)
rails-i18n (5.1.2)
rails-i18n (5.1.3)
i18n (>= 0.7, < 2)
railties (>= 5.0, < 6)
railties (5.2.1.1)
actionpack (= 5.2.1.1)
activesupport (= 5.2.1.1)
rails-timeago (2.17.1)
actionpack (>= 3.1)
activesupport (>= 3.1)
railties (5.2.2)
actionpack (= 5.2.2)
activesupport (= 5.2.2)
method_source
rake (>= 0.8.7)
thor (>= 0.19.0, < 2.0)
rainbow (3.0.0)
rake (12.3.1)
ransack (2.1.0)
rake (12.3.2)
ransack (2.1.1)
actionpack (>= 5.0)
activerecord (>= 5.0)
activesupport (>= 5.0)
i18n
rb-fsevent (0.10.3)
rb-inotify (0.9.10)
ffi (>= 0.5.0, < 2)
regexp_parser (1.2.0)
rb-inotify (0.10.0)
ffi (~> 1.0)
regexp_parser (1.3.0)
rest-client (2.0.2)
http-cookie (>= 1.0.2, < 2.0)
mime-types (>= 1.16, < 4.0)
@ -291,7 +294,7 @@ GEM
rspec-mocks (3.8.0)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.8.0)
rspec-rails (3.8.1)
rspec-rails (3.8.2)
actionpack (>= 3.0)
activesupport (>= 3.0)
railties (>= 3.0)
@ -300,15 +303,16 @@ GEM
rspec-mocks (~> 3.8.0)
rspec-support (~> 3.8.0)
rspec-support (3.8.0)
rubocop (0.60.0)
rubocop (0.65.0)
jaro_winkler (~> 1.5.1)
parallel (~> 1.10)
parser (>= 2.5, != 2.5.1.1)
powerpack (~> 0.1)
psych (>= 3.1.0)
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.4.0)
rubocop-rspec (1.30.1)
rubocop-rspec (1.32.0)
rubocop (>= 0.60.0)
ruby-progressbar (1.10.0)
ruby_dep (1.5.0)
@ -316,7 +320,7 @@ GEM
json (~> 2.1)
structured_warnings (~> 0.3)
rubyzip (1.2.2)
sass (3.6.0)
sass (3.7.3)
sass-listen (~> 4.0.0)
sass-listen (4.0.0)
rb-fsevent (~> 0.9, >= 0.9.4)
@ -342,7 +346,7 @@ GEM
actionpack (>= 3.1)
railties (>= 3.1)
slim (>= 3.0, < 5.0)
sorcery (0.12.0)
sorcery (0.13.0)
bcrypt (~> 3.1)
oauth (~> 0.4, >= 0.4.4)
oauth2 (~> 1.0, >= 0.8.0)
@ -355,31 +359,31 @@ GEM
actionpack (>= 4.0)
activesupport (>= 4.0)
sprockets (>= 3.0.0)
sshkit (1.18.0)
sshkit (1.18.2)
net-scp (>= 1.1.2)
net-ssh (>= 2.8.0)
structured_warnings (0.3.0)
temple (0.8.0)
temple (0.8.1)
thor (0.20.3)
thread_safe (0.3.6)
tilt (2.0.8)
tilt (2.0.9)
turbolinks (5.2.0)
turbolinks-source (~> 5.2)
turbolinks-source (5.2.0)
tzinfo (1.2.5)
thread_safe (~> 0.1)
uglifier (4.1.19)
uglifier (4.1.20)
execjs (>= 0.3.0, < 3)
unf (0.1.4)
unf_ext
unf_ext (0.0.7.5)
unicode-display_width (1.4.0)
unicode-display_width (1.4.1)
web-console (3.7.0)
actionview (>= 5.0)
activemodel (>= 5.0)
bindex (>= 0.4.0)
railties (>= 5.0)
webpacker (3.5.5)
webpacker (4.0.2)
activesupport (>= 4.2)
rack-proxy (>= 0.6.1)
railties (>= 4.2)
@ -411,7 +415,6 @@ DEPENDENCIES
capybara
carrierwave
concurrent-ruby
d3-rails
database_cleaner
docker-api
eventmachine (= 1.0.9.1)
@ -420,6 +423,7 @@ DEPENDENCIES
forgery
headless
highline
i18n-js
ims-lti (< 2.0.0)
jbuilder
jquery-rails
@ -435,9 +439,10 @@ DEPENDENCIES
puma
pundit
rack-mini-profiler
rails (= 5.2.1.1)
rails (= 5.2.2)
rails-controller-testing
rails-i18n
rails-timeago
ransack
rest-client
rspec-autotest
@ -460,4 +465,4 @@ DEPENDENCIES
whenever
BUNDLED WITH
1.17.1
1.17.2

View File

@ -13,7 +13,10 @@
//= require jquery_ujs
//= require turbolinks
//= require pagedown_bootstrap
//= require d3
//= require rails-timeago
//= require locales/jquery.timeago.de.js
//= require i18n
//= require i18n/translations
//
// lib/assets
//= require flash

View File

@ -23,3 +23,14 @@ $.fn.scrollTo = function(selector) {
scrollTop: $(selector).offset().top - $(this).offset().top + $(this).scrollTop()
}, ANIMATION_DURATION);
};
// Same as $.replaceWith, just returns the new element instead of the deleted one
$.fn.replaceWithAndReturnNewElement = function(a) {
const $a = $(a);
this.replaceWith($a);
return $a;
};
// Disable the use of web workers for JStree due to JS error
// See https://github.com/vakata/jstree/issues/1717 for details
$.jstree.defaults.core.worker = false;

View File

@ -0,0 +1,13 @@
// Action Cable provides the framework to deal with WebSockets in Rails.
// You can generate new channels where WebSocket features live using the `rails generate channel` command.
//
//= require action_cable
//= require_self
//= require_tree ./channels
(function() {
this.App || (this.App = {});
App.cable = ActionCable.createConsumer();
}).call(this);

View File

@ -0,0 +1,250 @@
$(document).on('turbolinks:load', function() {
if ($.isController('exercises') && $('.teacher_dashboard').isPresent()) {
const exercise_id = $('.teacher_dashboard').data().exerciseId;
const study_group_id = $('.teacher_dashboard').data().studyGroupId;
const specific_channel = { channel: "LaExercisesChannel", exercise_id: exercise_id, study_group_id: study_group_id };
App.la_exercise = App.cable.subscriptions.create(specific_channel, {
connected: function () {
// Called when the subscription is ready for use on the server
},
disconnected: function () {
// Called when the subscription has been terminated by the server
},
received: function (data) {
// Called when there's incoming data on the websocket for this channel
if (data.type === 'rfc') {
handleNewRfCdata(data);
} else if (data.type === 'working_times') {
handleWorkingTimeUpdate(data.working_time_data)
}
}
});
function handleNewRfCdata(data) {
let $row = $('tr[data-id="' + data.id + '"]');
if ($row.length === 0) {
$row = $($('#posted_rfcs')[0].insertRow(0));
}
$row = $row.replaceWithAndReturnNewElement(data.html);
$row.find('time').timeago();
$row.click(function () {
Turbolinks.visit($(this).data("href"));
});
}
function handleWorkingTimeUpdate(data) {
const user_progress = data['user_progress'];
const additional_user_data = data['additional_user_data'];
const user = additional_user_data[additional_user_data.length - 1][0];
const position = userPosition[user.type + user.id]; // TODO validate: will result in undef. if not existent.
// TODO: Do update
}
const graph_data = $('#initial_graph_data').data('graph_data');
let userPosition = {};
drawGraph(graph_data);
function drawGraph(graph_data) {
const user_progress = graph_data['user_progress'];
const additional_user_data = graph_data['additional_user_data'];
function get_minutes (time_stamp) {
try {
hours = time_stamp.split(":")[0];
minutes = time_stamp.split(":")[1];
seconds = time_stamp.split(":")[2];
seconds /= 60;
minutes = parseFloat(hours * 60) + parseInt(minutes) + seconds;
if (minutes > 0){
return minutes;
} else{
return parseFloat(seconds/60);
}
} catch (err) {
return 0;
}
}
function learners_name(index) {
return additional_user_data[additional_user_data.length - 1][index]["name"] + ", ID: " + additional_user_data[additional_user_data.length - 1][index]["id"];
}
function learners_time(group, index) {
if (user_progress[group] !== null && user_progress[group] !== undefined && user_progress[group][index] !== null) {
return user_progress[group][index]
} else {
return 0;
}
}
if (user_progress.length === 0) {
// No data available
$('#no_chart_data').removeClass("d-none");
return;
}
const margin = ({top: 20, right: 20, bottom: 150, left: 80});
const width = $('#chart_stacked').width();
const height = 500;
const users = user_progress[0].length; // # of users
const n = user_progress.length; // # of different sub bars, called buckets
let working_times_in_minutes = d3.range(n).map((index) => {
if (user_progress[index] !== null) {
return user_progress[index].map((time) => get_minutes(time))
} else return new Array(users).fill(0);
});
let xAxis = svg => svg.append("g")
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).tickSizeOuter(0).tickFormat((index) => learners_name(index)));
let yAxis = svg => svg.append("g")
.attr("transform", `translate(${margin.left}, 0)`)
.call(d3.axisLeft(y).tickSizeOuter(0).tickFormat((index) => index));
let color = d3.scaleSequential(d3.interpolateRdYlGn)
.domain([-0.5 * n, 1.5 * n]);
let userAxis = d3.range(users); // the x-values shared by all series
// Calculate the corresponding start and end value of each value;
const yBarValuesGrouped = d3.stack()
.keys(d3.range(n))
(d3.transpose(working_times_in_minutes)) // stacked working_times_in_minutes
.map((data, i) => data.map(([y0, y1]) => [y0, y1, i]));
const maxYSingleBar = d3.max(working_times_in_minutes, y => d3.max(y));
const maxYBarStacked = d3.max(yBarValuesGrouped, y => d3.max(y, d => d[1]));
let x = d3.scaleBand()
.domain(userAxis)
.rangeRound([margin.left, width - margin.right])
.padding(0.08);
let y = d3.scaleLinear()
.domain([0, maxYBarStacked])
.range([height - margin.bottom, margin.top]);
const svg = d3.select("#chart_stacked")
.append("svg")
.attr("width", '100%')
.attr("height", '100%')
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("preserveAspectRatio","xMinYMin meet");
const rect = svg.selectAll("g")
.data(yBarValuesGrouped)
.enter().append("g")
.attr("fill", (d, i) => color(i))
.selectAll("rect")
.data(d => d)
.join("rect")
.attr("x", (d, i) => x(i))
.attr("y", height - margin.bottom)
.attr("width", x.bandwidth())
.attr("height", 0)
.attr("class", (d) => "bar-stacked-"+d[2]);
svg.append("g")
.attr("class", "x axis")
.call(xAxis)
.selectAll("text")
.style("text-anchor", "end")
.attr("dx", "-.8em")
.attr("dy", ".15em")
.attr("transform", function(d) {
return "rotate(-45)"
});
svg.append("g")
.attr("class", "y axis")
.call(yAxis);
// Y Axis Label
svg.append("text")
.attr("transform", "rotate(-90)")
.attr("x", (-height - margin.top + margin.bottom) / 2)
.attr("dy", "+2em")
.style("text-anchor", "middle")
.text(I18n.t('exercises.study_group_dashboard.time_spent_in_minutes'))
.style('font-size', 14);
// X Axis Label
svg.append("text")
.attr("class", "x axis")
.attr("text-anchor", "middle")
.attr("x", (width + margin.left - margin.right) / 2)
.attr("y", height)
.attr("dy", '-1em')
.text(I18n.t('exercises.study_group_dashboard.learner'))
.style('font-size', 14);
let tip = d3.tip()
.attr('class', 'd3-tip')
.offset([-10, 0])
.html(function(d, i, a) {
return "<strong>Student: </strong><span style='color:orange'>" + learners_name(i) + "</span><br/>" +
"0: " + learners_time(0, i) + "<br/>" +
"1: " + learners_time(1, i) + "<br/>" +
"2: " + learners_time(2, i) + "<br/>" +
"3: " + learners_time(3, i) + "<br/>" +
"4: " + learners_time(4, i);
});
svg.call(tip);
rect.on('mouseenter', tip.show)
.on('mouseout', tip.hide);
function transitionGrouped() {
// Show all sub-bars next to each other
y.domain([0, maxYSingleBar]);
rect.transition()
.duration(500)
.delay((d, i) => i * 20)
.attr("x", (d, i) => x(i) + x.bandwidth() / n * d[2])
.attr("width", x.bandwidth() / n)
.transition()
.attr("y", d => y(d[1] - d[0]))
.attr("height", d => y(0) - y(d[1] - d[0]));
}
function transitionStacked() {
// Show all sub-bars on top of each other
y.domain([0, maxYBarStacked]);
rect.transition()
.duration(500)
.delay((d, i) => i * 20)
.attr("y", d => y(d[1]))
.attr("height", d => y(d[0]) - y(d[1]))
.transition()
.attr("x", (d, i) => x(i))
.attr("width", x.bandwidth());
}
$('#no_chart_data').addClass("d-none");
transitionStacked();
// ToDo: Add button to switch using transitionGrouped();
buildDictionary(additional_user_data);
}
function buildDictionary(users) {
users[users.length - 1].forEach(function(user, index) {
userPosition[user.type + user.id] = index;
});
}
}
});

View File

@ -113,3 +113,7 @@ span.caret {
-webkit-transform: perspective(120px) rotateX(-180deg) rotateY(-179.9deg);
}
}
.table-row-clickable {
cursor: pointer;
}

View File

@ -84,6 +84,10 @@ div#chart_2 {
background-color: #FAFAFA;
}
div#chart_stacked {
max-height: 500px;
background-color: #FAFAFA;
}
a.file-heading {
color: black !important;
@ -115,7 +119,7 @@ a.file-heading {
.d3-tip:after {
box-sizing: border-box;
display: inline;
font-size: 10px;
font-size: 14px;
width: 100%;
line-height: 1;
color: rgba(0, 0, 0, 0.8);
@ -126,7 +130,7 @@ a.file-heading {
/* Style northward tooltips differently */
.d3-tip.n:after {
margin: -1px 0 0 0;
margin: -3px 0 0 0;
top: 100%;
left: 0;
}

View File

@ -0,0 +1,4 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end

View File

@ -0,0 +1,30 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
def disconnect
# Any cleanup work needed when the cable connection is cut.
end
private
def session
# `session` is not available here, so that we need to use `cookies.encrypted` instead
cookies.encrypted[Rails.application.config.session_options[:key]].symbolize_keys
end
def find_verified_user
# Finding the current_user is similar to the code used in application_controller.rb#current_user
current_user = ExternalUser.find_by(id: session[:external_user_id]) || InternalUser.find_by(id: session[:user_id])
if current_user
current_user
else
reject_unauthorized_connection
end
end
end
end

View File

@ -0,0 +1,16 @@
class LaExercisesChannel < ApplicationCable::Channel
def subscribed
stream_from specific_channel
end
def unsubscribed
stop_all_streams
end
private
def specific_channel
reject unless StudyGroupPolicy.new(current_user, StudyGroup.find_by(id: params[:study_group_id])).stream_la?
"la_exercises_#{params[:exercise_id]}_channel_study_group_#{params[:study_group_id]}"
end
end

View File

@ -168,6 +168,7 @@ module Lti
end
group.users |= [@current_user] # add current user if not already member of the group
group.save
session[:study_group_id] = group.id
end
def set_embedding_options

View File

@ -16,7 +16,8 @@ module SubmissionParameters
current_user_id = current_user.id
current_user_class_name = current_user.class.name
end
submission_params = params[:submission].present? ? params[:submission].permit(:cause, :exercise_id, files_attributes: file_attributes).merge(user_id: current_user_id, user_type: current_user_class_name) : {}
# The study_group_id might not be present in the session (e.g. for internal users), resulting in session[:study_group_id] = nil which is intended.
submission_params = params[:submission].present? ? params[:submission].permit(:cause, :exercise_id, files_attributes: file_attributes).merge(user_id: current_user_id, user_type: current_user_class_name, study_group_id: session[:study_group_id]) : {}
reject_illegal_file_attributes!(submission_params)
submission_params
end

View File

@ -40,7 +40,7 @@ class ExecutionEnvironmentsController < ApplicationController
FROM
(SELECT user_id,
exercise_id,
CASE WHEN working_time >= '0:05:00' THEN '0' ELSE working_time END AS working_time_new
CASE WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0' ELSE working_time END AS working_time_new
FROM
(SELECT user_id,
exercise_id,

View File

@ -7,7 +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_and_authorize, only: MEMBER_ACTIONS + [:clone, :implement, :working_times, :intervention, :search, :run, :statistics, :submit, :reload, :feedback]
before_action :set_exercise_and_authorize, only: MEMBER_ACTIONS + [:clone, :implement, :working_times, :intervention, :search, :run, :statistics, :submit, :reload, :feedback, :study_group_dashboard]
before_action :set_external_user_and_authorize, only: [:statistics]
before_action :set_file_types, only: [:create, :edit, :new, :update]
before_action :set_course_token, only: [:implement]
@ -266,7 +266,7 @@ class ExercisesController < ApplicationController
end
def index
@search = policy_scope(Exercise).search(params[:q])
@search = policy_scope(Exercise).ransack(params[:q])
@exercises = @search.result.includes(:execution_environment, :user).order(:title).paginate(page: params[:page])
authorize!
end
@ -319,7 +319,7 @@ class ExercisesController < ApplicationController
private :set_file_types
def collect_set_and_unset_exercise_tags
@search = policy_scope(Tag).search(params[:q])
@search = policy_scope(Tag).ransack(params[:q])
@tags = @search.result.order(:name)
checked_exercise_tags = @exercise.exercise_tags
checked_tags = checked_exercise_tags.collect{|e| e.tag}.to_set
@ -343,7 +343,7 @@ class ExercisesController < ApplicationController
@all_events = (@submissions + interventions).sort_by { |a| a.created_at }
@deltas = @all_events.map.with_index do |item, index|
delta = item.created_at - @all_events[index - 1].created_at if index > 0
if delta == nil or delta > 10 * 60 then 0 else delta end
if delta == nil or delta > StatisticsHelper::WORKING_TIME_DELTA_IN_SECONDS then 0 else delta end
end
@working_times_until = []
@all_events.each_with_index do |_, index|
@ -475,4 +475,15 @@ class ExercisesController < ApplicationController
end
end
def study_group_dashboard
authorize!
@study_group_id = params[:study_group_id]
@request_for_comments = RequestForComment.
where(exercise: @exercise).includes(:submission).
where(submissions: {study_group_id: @study_group_id}).
order(created_at: :desc)
@graph_data = @exercise.get_working_times_for_study_group(@study_group_id)
end
end

View File

@ -27,7 +27,7 @@ class ExternalUsersController < ApplicationController
score,
id,
CASE
WHEN working_time >= '0:05:00' THEN '0'
WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0'
ELSE working_time
END AS working_time_new
FROM

View File

@ -60,7 +60,7 @@ class InternalUsersController < ApplicationController
end
def index
@search = InternalUser.search(params[:q])
@search = InternalUser.ransack(params[:q])
@users = @search.result.includes(:consumer).order(:name).paginate(page: params[:page])
authorize!
end

View File

@ -33,7 +33,7 @@ class ProxyExercisesController < ApplicationController
end
def edit
@search = policy_scope(Exercise).search(params[:q])
@search = policy_scope(Exercise).ransack(params[:q])
@exercises = @search.result.order(:title)
authorize!
end
@ -44,14 +44,14 @@ class ProxyExercisesController < ApplicationController
private :proxy_exercise_params
def index
@search = policy_scope(ProxyExercise).search(params[:q])
@search = policy_scope(ProxyExercise).ransack(params[:q])
@proxy_exercises = @search.result.order(:title).paginate(page: params[:page])
authorize!
end
def new
@proxy_exercise = ProxyExercise.new
@search = policy_scope(Exercise).search(params[:q])
@search = policy_scope(Exercise).ransack(params[:q])
@exercises = @search.result.order(:title)
authorize!
end
@ -63,8 +63,8 @@ class ProxyExercisesController < ApplicationController
private :set_exercise_and_authorize
def show
@search = @proxy_exercise.exercises.search
@exercises = @proxy_exercise.exercises.search.result.order(:title) #@search.result.order(:title)
@search = @proxy_exercise.exercises.ransack
@exercises = @proxy_exercise.exercises.ransack.result.order(:title) #@search.result.order(:title)
end
#we might want to think about auth here

View File

@ -15,7 +15,7 @@ class RequestForCommentsController < ApplicationController
@search = RequestForComment
.last_per_user(2)
.with_last_activity
.search(params[:q])
.ransack(params[:q])
@request_for_comments = @search.result
.order('created_at DESC')
.paginate(page: params[:page], total_entries: @search.result.length)
@ -27,7 +27,7 @@ class RequestForCommentsController < ApplicationController
@search = RequestForComment
.with_last_activity
.where(user_id: current_user.id)
.search(params[:q])
.ransack(params[:q])
@request_for_comments = @search.result
.order('created_at DESC')
.paginate(page: params[:page])
@ -40,7 +40,7 @@ class RequestForCommentsController < ApplicationController
.with_last_activity
.joins(:comments) # we don't need to outer join here, because we know the user has commented on these
.where(comments: {user_id: current_user.id})
.search(params[:q])
.ransack(params[:q])
@request_for_comments = @search.result
.order('last_comment DESC')
.paginate(page: params[:page])
@ -83,17 +83,10 @@ class RequestForCommentsController < ApplicationController
authorize!
end
# GET /request_for_comments/new
def new
@request_for_comment = RequestForComment.new
authorize!
end
# GET /request_for_comments/1/edit
def edit
end
# POST /request_for_comments
# POST /request_for_comments.json
def create
# Consider all requests as JSON
@ -149,7 +142,7 @@ class RequestForCommentsController < ApplicationController
# Never trust parameters from the scary internet, only allow the white list through.
def request_for_comment_params
# we are using the current_user.id here, since internal users are not able to create comments. The external_user.id is a primary key and does not require the consumer_id to be unique.
# The study_group_id might not be present in the session (e.g. for internal users), resulting in session[:study_group_id] = nil which is intended.
params.require(:request_for_comment).permit(:exercise_id, :file_id, :question, :requested_at, :solved, :submission_id).merge(user_id: current_user.id, user_type: current_user.class.name)
end

View File

@ -4,17 +4,17 @@ class StudyGroupsController < ApplicationController
before_action :set_group, only: MEMBER_ACTIONS
def index
@search = StudyGroup.search(params[:q])
@search = StudyGroup.ransack(params[:q])
@study_groups = @search.result.includes(:consumer).order(:name).paginate(page: params[:page])
authorize!
end
def show
@search = @study_group.users.search(params[:q])
@search = @study_group.users.ransack(params[:q])
end
def edit
@search = @study_group.users.search(params[:q])
@search = @study_group.users.ransack(params[:q])
@members = StudyGroupMembership.where(user: @search.result, study_group: @study_group)
end

View File

@ -106,7 +106,7 @@ class SubmissionsController < ApplicationController
end
def index
@search = Submission.search(params[:q])
@search = Submission.ransack(params[:q])
@submissions = @search.result.includes(:exercise, :user).paginate(page: params[:page])
authorize!
end
@ -201,6 +201,8 @@ class SubmissionsController < ApplicationController
save_run_output
if @run_output.blank?
@raw_output ||= ''
@run_output ||= ''
parse_message t('exercises.implement.no_output', timestamp: l(Time.now, format: :short)), 'stdout', tubesock
end

View File

@ -0,0 +1,30 @@
module ActionCableHelper
def trigger_rfc_action_cable
# Context: RfC
if submission.study_group_id.present?
ActionCable.server.broadcast(
"la_exercises_#{exercise_id}_channel_study_group_#{submission.study_group_id}",
type: :rfc,
id: id,
html: (ApplicationController.render(partial: 'request_for_comments/list_entry',
locals: {request_for_comment: self})))
end
end
def trigger_rfc_action_cable_from_comment
# Context: Comment
RequestForComment.find_by(submission: file.context).trigger_rfc_action_cable
end
def trigger_working_times_action_cable
# Context: Submission
if study_group_id.present?
ActionCable.server.broadcast(
"la_exercises_#{exercise_id}_channel_study_group_#{study_group_id}",
type: :working_times,
working_time_data: exercise.get_working_times_for_study_group(study_group_id, user))
end
end
end
# TODO: Check if any user is connected and prevent preparing the data otherwise

View File

@ -1,5 +1,8 @@
module StatisticsHelper
WORKING_TIME_DELTA_IN_SECONDS = 5.minutes
WORKING_TIME_DELTA_IN_SQL_INTERVAL = "'0:05:00'" # yes, a string with quotes
def statistics_data
[
{

View File

@ -13,7 +13,9 @@ import 'bootstrap/dist/js/bootstrap.bundle.min';
import 'chosen-js/chosen.jquery';
import 'jstree';
import 'underscore';
import 'd3'
window._ = _; // Publish underscore's `_` in global namespace
window.d3 = d3; // Publish d3 in global namespace
// CSS
import 'chosen-js/chosen.css';

View File

@ -1,8 +1,12 @@
class Comment < ApplicationRecord
# inherit the creation module: encapsulates that this is a polymorphic user, offers some aliases and makes sure that all necessary attributes are set.
include Creation
include ActionCableHelper
attr_accessor :username, :date, :updated, :editable
belongs_to :file, class_name: 'CodeOcean::File'
belongs_to :user, polymorphic: true
after_save :trigger_rfc_action_cable_from_comment
end

View File

@ -76,7 +76,7 @@ class Exercise < ApplicationRecord
(SELECT user_id,
user_type,
score,
CASE WHEN working_time >= '0:05:00' THEN '0' ELSE working_time END AS working_time_new
CASE WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0' ELSE working_time END AS working_time_new
FROM
(SELECT user_id,
user_type,
@ -90,6 +90,145 @@ class Exercise < ApplicationRecord
"
end
def study_group_working_time_query(exercise_id, study_group_id, additional_filter)
"""
WITH working_time_between_submissions AS (
SELECT submissions.user_id,
submissions.user_type,
score,
created_at,
(created_at - lag(created_at) over (PARTITION BY submissions.user_type, submissions.user_id, exercise_id
ORDER BY created_at)) AS working_time
FROM submissions
WHERE exercise_id = #{exercise_id} AND study_group_id = #{study_group_id} #{additional_filter}),
working_time_with_deltas_ignored AS (
SELECT user_id,
user_type,
score,
sum(CASE WHEN score IS NOT NULL THEN 1 ELSE 0 END)
over (ORDER BY user_type, user_id, created_at ASC) AS change_in_score,
created_at,
CASE WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0' ELSE working_time END AS working_time_filtered
FROM working_time_between_submissions
),
working_times_with_score_expanded AS (
SELECT user_id,
user_type,
created_at,
working_time_filtered,
first_value(score)
over (PARTITION BY user_type, user_id, change_in_score ORDER BY created_at ASC) AS corrected_score
FROM working_time_with_deltas_ignored
),
working_times_with_duplicated_last_row_per_score AS (
SELECT *
FROM working_times_with_score_expanded
UNION ALL
-- Duplicate last row per user and score and make it unique by setting another created_at timestamp.
-- In addition, the working time is set to zero in order to prevent getting a wrong time.
-- This duplication is needed, as we will shift the scores and working times by one and need to ensure not to loose any information.
SELECT DISTINCT ON (user_type, user_id, corrected_score) user_id,
user_type,
created_at + INTERVAL '1us',
'00:00:00' as working_time_filtered,
corrected_score
FROM working_times_with_score_expanded
),
working_times_with_score_not_null_and_shifted AS (
SELECT user_id,
user_type,
coalesce(lag(corrected_score) over (PARTITION BY user_type, user_id ORDER BY created_at ASC),
0) AS shifted_score,
created_at,
working_time_filtered
FROM working_times_with_duplicated_last_row_per_score
),
working_times_to_be_sorted AS (
SELECT user_id,
user_type,
shifted_score AS score,
MIN(created_at) AS start_time,
SUM(working_time_filtered) AS working_time_per_score,
SUM(SUM(working_time_filtered)) over (PARTITION BY user_type, user_id) AS total_working_time
FROM working_times_with_score_not_null_and_shifted
GROUP BY user_id, user_type, score
),
working_times_with_index AS (
SELECT (dense_rank() over (ORDER BY total_working_time, user_type, user_id ASC) - 1) AS index,
user_id,
user_type,
score,
start_time,
working_time_per_score,
total_working_time
FROM working_times_to_be_sorted)
SELECT index,
user_id,
user_type,
name,
score,
start_time,
working_time_per_score,
total_working_time
FROM working_times_with_index
JOIN external_users ON user_type = 'ExternalUser' AND user_id = external_users.id
UNION ALL
SELECT index,
user_id,
user_type,
name,
score,
start_time,
working_time_per_score,
total_working_time
FROM working_times_with_index
JOIN internal_users ON user_type = 'InternalUser' AND user_id = internal_users.id
ORDER BY index, score ASC;
"""
end
def get_working_times_for_study_group(study_group_id, user = nil)
user_progress = []
additional_user_data = []
max_bucket = 4
maximum_score = self.maximum_score
if user.blank?
additional_filter = ''
else
additional_filter = "AND user_id = #{user.id} AND user_type = '#{user.class.name}'"
end
results = self.class.connection.execute(study_group_working_time_query(id, study_group_id, additional_filter)).each do |tuple|
if tuple['score'] <= maximum_score
bucket = tuple['score'] / maximum_score * max_bucket
else
bucket = max_bucket # maximum_score / maximum_score will always be 1
end
user_progress[bucket] ||= []
additional_user_data[bucket] ||= []
additional_user_data[max_bucket + 1] ||= []
user_progress[bucket][tuple['index']] = tuple["working_time_per_score"]
additional_user_data[bucket][tuple['index']] = {start_time: tuple["start_time"], score: tuple["score"]}
additional_user_data[max_bucket + 1][tuple['index']] = {id: tuple['user_id'], type: tuple['user_type'], name: tuple['name']}
end
if results.ntuples > 0
first_index = results[0]['index']
last_index = results[results.ntuples-1]['index']
buckets = last_index - first_index
user_progress.each do |timings_array|
if timings_array.present? && timings_array.length != buckets + 1
timings_array[buckets] = nil
end
end
end
{user_progress: user_progress, additional_user_data: additional_user_data}
end
def get_quantiles(quantiles)
quantiles_str = "[" + quantiles.join(",") + "]"
result = self.class.connection.execute("""
@ -180,7 +319,7 @@ class Exercise < ApplicationRecord
exercise_id,
max_score,
CASE
WHEN working_time >= '0:05:00' THEN '0'
WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0'
ELSE working_time
END AS working_time_new
FROM all_working_times_until_max ), result AS
@ -274,7 +413,7 @@ class Exercise < ApplicationRecord
FILTERED_TIMES_UNTIL_MAX AS
(
SELECT user_id,exercise_id, max_score, CASE WHEN working_time >= '0:05:00' THEN '0' ELSE working_time END AS working_time_new
SELECT user_id,exercise_id, max_score, CASE WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0' ELSE working_time END AS working_time_new
FROM ALL_WORKING_TIMES_UNTIL_MAX
)
SELECT e.external_id AS external_user_id, f.user_id, exercise_id, MAX(max_score) AS max_score, sum(working_time_new) AS working_time

View File

@ -1,5 +1,7 @@
class RequestForComment < ApplicationRecord
include Creation
include ActionCableHelper
belongs_to :submission
belongs_to :exercise
belongs_to :file, class_name: 'CodeOcean::File'
@ -10,6 +12,8 @@ class RequestForComment < ApplicationRecord
scope :unsolved, -> { where(solved: [false, nil]) }
scope :in_range, -> (from, to) { where(created_at: from..to) }
after_save :trigger_rfc_action_cable
def self.last_per_user(n = 5)
from("(#{row_number_user_sql}) as request_for_comments")
.where("row_number <= ?", n)

View File

@ -1,12 +1,12 @@
# frozen_string_literal: true
class StudyGroup < ApplicationRecord
has_many :study_group_memberships
has_many :study_group_memberships, dependent: :destroy
# Use `ExternalUser` as `source_type` for now.
# Using `User` will lead ActiveRecord to access the inexistent table `users`.
# Issue created: https://github.com/rails/rails/issues/34531
has_many :users, through: :study_group_memberships, source_type: 'ExternalUser'
has_many :submissions
has_many :submissions, dependent: :nullify
belongs_to :consumer
def to_s

View File

@ -1,6 +1,7 @@
class Submission < ApplicationRecord
include Context
include Creation
include ActionCableHelper
CAUSES = %w(assess download file render run save submit test autosave requestComments remoteAssess)
FILENAME_URL_PLACEHOLDER = '{filename}'
@ -20,6 +21,8 @@ class Submission < ApplicationRecord
validates :cause, inclusion: {in: CAUSES}
validates :exercise_id, presence: true
after_save :trigger_working_times_action_cable
MAX_COMMENTS_ON_RECOMMENDED_RFC = 5
def build_files_hash(files, attribute)

View File

@ -25,6 +25,22 @@ class ApplicationPolicy
end
private :no_one
def everyone_in_study_group
study_group = @record.study_group
return false if study_group.blank?
users_in_same_study_group = study_group.users
return false if users_in_same_study_group.blank?
users_in_same_study_group.include? @user
end
private :everyone_in_study_group
def teacher_in_study_group
teacher? && everyone_in_study_group
end
private :teacher_in_study_group
def initialize(user, record)
@user = user
@record = record

View File

@ -3,8 +3,8 @@ class ExercisePolicy < AdminOrAuthorPolicy
admin?
end
def show?
admin? || teacher?
[:show?, :study_group_dashboard?].each do |action|
define_method(action) { admin? || teacher? }
end
[:clone?, :destroy?, :edit?, :statistics?, :update?, :feedback?].each do |action|

View File

@ -3,8 +3,8 @@ class StudyGroupPolicy < AdminOnlyPolicy
admin? || teacher?
end
[:show?, :destroy?, :edit?, :update?].each do |action|
define_method(action) { admin? || @user.teacher? && @record.users.include?(@user) }
[:show?, :destroy?, :edit?, :update?, :stream_la?].each do |action|
define_method(action) { admin? || @user.teacher? && @record.present? && @record.users.include?(@user) }
end
class Scope < Scope

View File

@ -12,14 +12,8 @@ class SubmissionPolicy < ApplicationPolicy
admin?
end
def everyone_in_study_group
users_in_same_study_group = @record.study_groups.users
users_in_same_study_group.include? @user
end
private :everyone_in_study_group
def teacher_in_study_group
teacher? && everyone_in_study_group
def show_study_group?
admin? || teacher_in_study_group
end
private :teacher_in_study_group
end

View File

@ -58,7 +58,7 @@ h1 = "#{@exercise} (external user #{@external_user})"
td =
td =
td = @working_times_until[index] if index > 0
p = t('.addendum')
p = t('.addendum', delta: StatisticsHelper::WORKING_TIME_DELTA_IN_SECONDS / 60)
.d-none#wtimes data-working_times=ActiveSupport::JSON.encode(@working_times_until);
div#progress_chart.col-lg-12
.graph-functions-2

View File

@ -0,0 +1,37 @@
- content_for :head do
// Force a full page reload, see https://github.com/turbolinks/turbolinks/issues/326.
Otherwise, code might not be highlighted correctly (race condition)
meta name='turbolinks-visit-control' content='reload'
= javascript_pack_tag('d3-tip', 'data-turbolinks-track': true)
h1
= t('.live_dashboard')
div.teacher_dashboard data-exercise-id="#{@exercise.id}" data-study-group-id="#{@study_group_id}"
h4.mt-4
= t('.time_spent_per_learner')
.d-none#initial_graph_data data-graph_data=ActiveSupport::JSON.encode(@graph_data);
div.w-100#chart_stacked
.d-none.badge-info.container.py-2#no_chart_data
i class="fa fa-info" aria-hidden="true"
= t('.no_data_yet')
h4.mt-4
= t('.related_requests_for_comments')
.table-responsive
table.table.table-hover.mt-4
thead
tr
th.text-center
i.mr-0 class="fa fa-lightbulb-o" aria-hidden="true" title = t('request_for_comments.solved')
th.text-center
i.mr-0 class="fa fa-comment" aria-hidden="true" title = t('request_for_comments.comments') align="center"
th.col-12 = t('activerecord.attributes.request_for_comments.question')
th = t('activerecord.attributes.request_for_comments.username')
th.text-nowrap = t('activerecord.attributes.request_for_comments.requested_at')
tbody#posted_rfcs
= render(partial: 'request_for_comments/list_entry', collection: @request_for_comments, as: :request_for_comment)

View File

@ -5,6 +5,7 @@ html lang='en'
meta name='viewport' content='width=device-width, initial-scale=1'
title = application_name
link href=asset_path('favicon.png') rel='icon' type='image/png'
= action_cable_meta_tag
= stylesheet_pack_tag('application', media: 'all', 'data-turbolinks-track': true)
= stylesheet_pack_tag('stylesheets', media: 'all', 'data-turbolinks-track': true)
= stylesheet_link_tag('application', media: 'all', 'data-turbolinks-track': true)
@ -12,6 +13,10 @@ html lang='en'
= javascript_include_tag('application', 'data-turbolinks-track': true)
= yield(:head)
= csrf_meta_tags
= timeago_script_tag
script type="text/javascript"
| I18n.defaultLocale = "#{I18n.default_locale}";
| I18n.locale = "#{I18n.locale}";
body
- unless @embed_options[:hide_navbar]
nav.navbar.navbar-dark.bg-dark.navbar-expand-md.mb-4.py-1 role='navigation'

View File

@ -0,0 +1,15 @@
tr.table-row-clickable data-id=request_for_comment.id data-href=request_for_comment_path(request_for_comment)
td.p-2
- if request_for_comment.solved?
span.fa.fa-check.fa-2x.text-success aria-hidden="true"
- elsif request_for_comment.full_score_reached
span.fa.fa-check.fa-2x style="color:darkgrey" aria-hidden="true"
- else
= ''
td.text-center = request_for_comment.comments_count
- if request_for_comment.has_attribute?(:question) && request_for_comment.question.present?
td.text-primary = truncate(request_for_comment.question, length: 200)
- else
td.text-black-50.font-italic = t('request_for_comments.no_question')
td = request_for_comment.user
td = timeago_tag request_for_comment.created_at

View File

@ -9,6 +9,9 @@
- testruns = Testrun.where(:submission_id => @request_for_comment.submission)
= link_to_if(policy(user).show?, user.displayname, user)
| | #{@request_for_comment.created_at.localtime}
- if @request_for_comment.submission.study_group.present? && policy(@request_for_comment.submission).show_study_group?
= ' | '
= link_to_if(policy(@request_for_comment.submission.study_group).show?, @request_for_comment.submission.study_group, @request_for_comment.submission.study_group)
.rfc
.description
h5

View File

@ -9,6 +9,7 @@ h1 = @submission
= row(label: 'submission.exercise', value: link_to_if(policy(@submission.exercise).show?, @submission.exercise, @submission.exercise))
= row(label: 'submission.user', value: link_to_if(policy(@submission.user).show?, @submission.user, @submission.user))
= row(label: 'submission.study_group', value: link_to_if(policy(@submission.study_group).show?, @submission.study_group, @submission.study_group))
= row(label: 'submission.cause', value: t("submissions.causes.#{@submission.cause}"))
= row(label: 'submission.score', value: @submission.score)

View File

@ -1,5 +1,5 @@
== t('mailers.user_mailer.send_thank_you_note.body',
receiver_displayname: @receiver_displayname,
link_to_comment: link_to(@rfc_link, @rfc_link),
author: @author.displayname,
author: @author,
thank_you_note: @thank_you_note )

70
babel.config.js Normal file
View File

@ -0,0 +1,70 @@
module.exports = function(api) {
var validEnv = ['development', 'test', 'production']
var currentEnv = api.env()
var isDevelopmentEnv = api.env('development')
var isProductionEnv = api.env('production')
var isTestEnv = api.env('test')
if (!validEnv.includes(currentEnv)) {
throw new Error(
'Please specify a valid `NODE_ENV` or ' +
'`BABEL_ENV` environment variables. Valid values are "development", ' +
'"test", and "production". Instead, received: ' +
JSON.stringify(currentEnv) +
'.'
)
}
return {
presets: [
isTestEnv && [
require('@babel/preset-env').default,
{
targets: {
node: 'current'
}
}
],
(isProductionEnv || isDevelopmentEnv) && [
require('@babel/preset-env').default,
{
forceAllTransforms: true,
useBuiltIns: 'entry',
modules: false,
exclude: ['transform-typeof-symbol']
}
]
].filter(Boolean),
plugins: [
require('babel-plugin-macros'),
require('@babel/plugin-syntax-dynamic-import').default,
isTestEnv && require('babel-plugin-dynamic-import-node'),
require('@babel/plugin-transform-destructuring').default,
[
require('@babel/plugin-proposal-class-properties').default,
{
loose: true
}
],
[
require('@babel/plugin-proposal-object-rest-spread').default,
{
useBuiltIns: true
}
],
[
require('@babel/plugin-transform-runtime').default,
{
helpers: false,
regenerator: true
}
],
[
require('@babel/plugin-transform-regenerator').default,
{
async: false
}
]
].filter(Boolean)
}
};

View File

@ -12,4 +12,8 @@ require "bundler/setup"
require "webpacker"
require "webpacker/webpack_runner"
Webpacker::WebpackRunner.run(ARGV)
APP_ROOT = File.expand_path("..", __dir__)
Dir.chdir(APP_ROOT) do
Webpacker::WebpackRunner.run(ARGV)
end

View File

@ -12,4 +12,8 @@ require "bundler/setup"
require "webpacker"
require "webpacker/dev_server_runner"
Webpacker::DevServerRunner.run(ARGV)
APP_ROOT = File.expand_path("..", __dir__)
Dir.chdir(APP_ROOT) do
Webpacker::DevServerRunner.run(ARGV)
end

View File

@ -28,5 +28,7 @@ module CodeOcean
config.autoload_paths << Rails.root.join('lib')
config.eager_load_paths << Rails.root.join('lib')
config.assets.precompile += %w( markdown-buttons.png )
config.action_cable.mount_path = '/cable'
end
end

View File

@ -1,10 +1,14 @@
development:
adapter: async
adapter: postgresql
test:
adapter: async
adapter: postgresql
staging:
adapter: postgresql
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: code_ocean_production
adapter: postgresql # redis
# all other options below are only used for redis
# url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
# channel_prefix: code_ocean_production

View File

@ -101,6 +101,7 @@ de:
files: Dateien
score: Punktzahl
user: Autor
study_group: Lerngruppe
study_group:
name: Name
external_id: Externe ID
@ -347,6 +348,7 @@ de:
implement: Implementieren
test_files: Test-Dateien
feedback: Feedback
study_group_dashboard: Live Dashboard
statistics:
average_score: Durchschnittliche Punktzahl
final_submissions: Finale Abgaben
@ -365,6 +367,13 @@ de:
failure: Beim Übermitteln Ihrer Punktzahl ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.
full_score_redirect_to_rfc: Herzlichen Glückwunsch! Sie haben die maximale Punktzahl für diese Aufgabe an den Kurs übertragen. Ein anderer Teilnehmer hat eine Frage zu der von Ihnen gelösten Aufgabe. Er würde sich sicherlich sehr über ihre Hilfe und Kommentare freuen.
full_score_redirect_to_own_rfc: Herzlichen Glückwunsch! Sie haben die maximale Punktzahl für diese Aufgabe an den Kurs übertragen. Ihre Frage ist damit wahrscheinlich gelöst? Falls ja, fügen Sie doch den entscheidenden Kniff als Antwort hinzu und markieren die Frage als gelöst, bevor sie das Fenster schließen.
study_group_dashboard:
live_dashboard: Live Dashboard
time_spent_per_learner: Verwendete Zeit pro Lerner
related_requests_for_comments: Zugehörige Kommentaranfragen
learner: Lerner
time_spent_in_minutes: benötigte Zeit in Minuten
no_data_yet: Bisher sind keine Daten verfügbar
external_users:
statistics:
no_data_available: Keine Daten verfügbar.
@ -373,7 +382,7 @@ de:
score: Punktzahl
tests: Unit Tests
time_difference: 'Arbeitszeit bis hier*'
addendum: '* Differenzen von mehr als 10 Minuten werden ignoriert.'
addendum: '* Differenzen von mehr als %{delta} Minuten werden ignoriert.'
proxy_exercises:
index:
clone: Duplizieren

View File

@ -101,6 +101,7 @@ en:
files: Files
score: Score
user: Author
study_group: Study Group
study_group:
name: Name
external_id: External ID
@ -347,6 +348,7 @@ en:
implement: Implement
test_files: Test Files
feedback: Feedback
study_group_dashboard: Live Dashboard
statistics:
average_score: Average Score
final_submissions: Final Submissions
@ -365,6 +367,13 @@ en:
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_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!
study_group_dashboard:
live_dashboard: Live Dashboard
time_spent_per_learner: Time spent per Learner
related_requests_for_comments: Related Requests for Comments
learner: Learner
time_spent_in_minutes: Time spent in Minutes
no_data_yet: No data available yet
external_users:
statistics:
no_data_available: No data available.
@ -373,7 +382,7 @@ en:
score: Score
tests: Unit Test Results
time_difference: 'Working Time until here*'
addendum: '* Deltas longer than 10 minutes are ignored.'
addendum: "* Deltas longer than %{delta} minutes are ignored."
proxy_exercises:
index:
clone: Duplicate

View File

@ -83,6 +83,7 @@ Rails.application.routes.draw do
get :feedback
get :reload
post :submit
get 'study_group_dashboard/:study_group_id', to: 'exercises#study_group_dashboard'
end
end
@ -151,4 +152,5 @@ Rails.application.routes.draw do
post "/evaluate", to: 'remote_evaluation#evaluate', via: [:post]
mount ActionCable.server => '/cable'
end

View File

@ -18,6 +18,7 @@ environment.plugins.prepend('Provide', new webpack.ProvidePlugin({
_: 'underscore',
vis: 'vis',
hljs: 'highlight.js',
d3: 'd3',
})
);

View File

@ -3,8 +3,11 @@
default: &default
source_path: app/javascript
source_entry_path: packs
public_root_path: public
public_output_path: packs
cache_path: tmp/cache/webpacker
check_yarn_integrity: false
webpack_compile_output: false
# Additional paths webpack should lookup modules
# ['app/assets', 'engine/foo/app/assets']
@ -13,7 +16,25 @@ default: &default
# Reload manifest.json on all requests so we reload latest compiled packs
cache_manifest: false
# Extract and emit a css file
extract_css: true
static_assets_extensions:
- .jpg
- .jpeg
- .png
- .gif
- .tiff
- .ico
- .svg
- .eot
- .otf
- .ttf
- .woff
- .woff2
extensions:
- .mjs
- .js
- .sass
- .scss
@ -31,6 +52,9 @@ development:
<<: *default
compile: true
# Verifies that versions and hashed value of the package contents in the project's package.json
check_yarn_integrity: true
# Reference: https://webpack.js.org/configuration/dev-server/
dev_server:
https: false
@ -66,5 +90,8 @@ production:
# Production depends on precompilation of packs prior to booting for performance.
compile: false
# Extract and emit a css file
extract_css: true
# Cache manifest.json for performance
cache_manifest: true

View File

@ -0,0 +1,6 @@
class AddIndicesForRequestForComments < ActiveRecord::Migration[5.2]
def change
add_index :request_for_comments, :submission_id
add_index :request_for_comments, :exercise_id
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2018_11_29_093207) do
ActiveRecord::Schema.define(version: 2019_02_13_131802) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -292,6 +292,8 @@ ActiveRecord::Schema.define(version: 2018_11_29_093207) do
t.text "thank_you_note"
t.boolean "full_score_reached", default: false
t.integer "times_featured", default: 0
t.index ["exercise_id"], name: "index_request_for_comments_on_exercise_id"
t.index ["submission_id"], name: "index_request_for_comments_on_submission_id"
end
create_table "searches", force: :cascade do |t|
@ -415,4 +417,5 @@ ActiveRecord::Schema.define(version: 2018_11_29_093207) do
t.index ["user_type", "user_id"], name: "index_user_proxy_exercise_exercises_on_user_type_and_user_id"
end
add_foreign_key "submissions", "study_groups"
end

View File

@ -1,9 +1,10 @@
{
"dependencies": {
"@rails/webpacker": "3.5",
"@rails/webpacker": "4.0",
"bootstrap": "^4.1.3",
"bootswatch": "^4.1.3",
"chosen-js": "^1.8.7",
"d3": "^5.9.1",
"d3-tip": "^0.9.1",
"font-awesome": "^4.7.0",
"highlight.js": "^9.12.0",
@ -17,7 +18,7 @@
"webpack-merge": "^4.1.4"
},
"devDependencies": {
"webpack-dev-server": "2.11.2"
"webpack-dev-server": "3.2.1"
},
"scripts": {
"webpack": "./bin/webpack",

12
postcss.config.js Normal file
View File

@ -0,0 +1,12 @@
module.exports = {
plugins: [
require('postcss-import'),
require('postcss-flexbugs-fixes'),
require('postcss-preset-env')({
autoprefixer: {
flexbox: 'no-2009'
},
stage: 3
})
]
};

View File

@ -5,9 +5,9 @@
######## VERSION INFORMATION ########
postgres_version=10
ruby_version=2.5.1
rails_version=5.2.1
geckodriver_version=0.23.0
ruby_version=2.6.1
rails_version=5.2.2
geckodriver_version=0.24.0
########## INSTALL SCRIPT ###########

View File

@ -0,0 +1,4 @@
require 'factory_bot'
# Use "old" FactoryBot default to allow auto-creating associations for #build
FactoryBot.use_parent_strategy = false

6036
yarn.lock

File diff suppressed because it is too large Load Diff